Metadata-Version: 2.4
Name: reddigraph
Version: 1.0.0
Summary: Python client for ReddiGraph — the Reddit community intelligence API
License-Expression: MIT
Project-URL: Homepage, https://reddigraph.com
Project-URL: Documentation, https://github.com/arthur-mf/ReddiGraph/tree/main/sdk#readme
Project-URL: Source, https://github.com/arthur-mf/ReddiGraph
Project-URL: Issues, https://github.com/arthur-mf/ReddiGraph/issues
Project-URL: Changelog, https://github.com/arthur-mf/ReddiGraph/blob/main/CHANGELOG.md
Keywords: reddit,api,client,graphql,social-media,analytics
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx<1,>=0.27.0
Requires-Dist: pydantic<3,>=2.0.0
Dynamic: license-file

# reddigraph

Python client for **ReddiGraph**, the Reddit community intelligence API. Sync
and async, fully typed, with retries and cursor pagination handled for you.

```bash
pip install reddigraph
```

## Usage

```python
from reddigraph import ReddiGraph

with ReddiGraph(api_key="rg_live_…") as api:
    page = api.subreddit_feed("technology", sort="top", time="week")
    for post in page.data:
        print(post.score, post.title)
```

`api_key` falls back to `REDDIGRAPH_API_KEY`, and `base_url` to
`REDDIGRAPH_BASE_URL` and then to `https://api.reddigraph.com` — so only a
self-hosted or staging deployment needs to pass it. The key is sent as
`Authorization: Bearer …`.

Keep one client for the lifetime of your process — it reuses connections.

### Async

`AsyncReddiGraph` mirrors `ReddiGraph` method for method:

```python
from reddigraph import AsyncReddiGraph

async with AsyncReddiGraph(api_key="rg_live_…") as api:
    user = await api.user("spez")
```

### Pagination

`paginate` follows the `after` cursor and yields items, not pages:

```python
for post in api.paginate(api.subreddit_feed, "technology", sort="new", max_pages=5):
    print(post.title)

# async
async for post in api.paginate(api.subreddit_feed, "technology", max_pages=5):
    ...
```

Without `max_pages` it stops when the API reports no next page. Every other
argument is forwarded to each call unchanged.

### Errors

```python
from reddigraph import NotFound, RateLimited, ReddiGraphError

try:
    api.post("t3_missing")
except NotFound:
    ...
except RateLimited as exc:
    time.sleep(exc.retry_after)
except ReddiGraphError as exc:
    print(exc.code, exc.status_code, exc.details)
```

`InvalidInput`, `AuthenticationError`, `NotFound`, `RateLimited`,
`UpstreamError` and `TransportError` all derive from `ReddiGraphError`. When the
server sends a request id it is kept in `exc.details["request_id"]` — quote it
when reporting a problem.

Arguments that could not produce a valid request — an empty batch, a name with a
slash in it — raise `ValueError` before anything is sent.

### Retries

429, 500, 502, 503, 504 and connection failures are retried `max_retries` times
(default 2). A `Retry-After` is obeyed as sent; everything else backs off
exponentially with jitter, up to `max_backoff` seconds (default 20). Other 4xx
responses are never retried.

A `POST` is the exception to the 5xx rule: a server error can mean the write
landed and only the answer was lost, so replaying it would queue a second export
or create a second monitor. Those are retried on a 429 — which is refused before
any work is done — but never on a 5xx.

## Methods

| Method | Returns |
|---|---|
| `search_posts(query, subreddit, sort, time, after)` | `PostList` |
| `search_subreddits(query, after)` | `SubredditSearchList` |
| `search_users(query, after)` | `UserSearchList` |
| `search_comments(query, after)` | `CommentSearchList` |
| `trending_searches()` | `TrendingList` |
| `subreddit_feed(subreddit, sort, time, after)` | `PostList` |
| `popular_feed(sort, after)` | `PostList` |
| `discover_communities(sort, after)` | `Discovery` |
| `subreddit_about(subreddit)` | `Subreddit` |
| `subreddit_rules(subreddit)` | `RuleList` |
| `subreddit_styles(subreddit)` | `SubredditStyles` |
| `subreddit_taxonomy(subreddit)` | `TaxonomyList` |
| `subreddit_highlights(subreddit)` | `HighlightList` |
| `subreddit_wiki_page(subreddit, page)` | `WikiPage` |
| `post(post_id)` | `Post` |
| `posts(post_ids)` | `PostList` |
| `post_comments(post_id, sort, limit)` | `CommentList` |
| `user(username)` | `User` |
| `user_posts(username, sort, after)` | `PostList` |
| `user_comments(username, sort, after)` | `CommentList` |
| `user_trophies(username)` | `TrophyList` |
| `analyze_subreddit(subreddit, window, deep)` | `SubredditIntelligence` |
| `analyze_conversation(post_id, sort, limit)` | `ConversationIntelligence` |
| `related_subreddits(subreddit, window, limit)` | `RelatedSubreddits` |
| `compare_subreddits(subreddits, window)` | `SubredditComparison` |
| `trends(subreddits, window, baseline, ...)` | `Trends` |
| `subreddit_history(subreddit, days)` | `HistorySeries` |
| `post_history(post_id, days)` | `HistorySeries` |
| `topic_history(topic, days)` | `HistorySeries` |
| `create_monitor(kind, name, ...)` | `Monitor` |
| `monitors()` / `monitor(id)` | `MonitorList` / `Monitor` |
| `update_monitor(id, **changes)` | `Monitor` |
| `delete_monitor(id)` | — |
| `monitor_events(id, since, limit)` | `MonitorEventList` |
| `add_webhook(id, url)` | `Webhook` |
| `webhooks(id)` / `delete_webhook(id, hook_id)` | `WebhookList` / — |
| `monitor_deliveries(id, limit)` | `DeliveryList` |
| `create_export(kind, fmt, params)` | `Export` |
| `exports()` / `export(id)` | `ExportList` / `Export` |
| `download_export(id)` | `bytes` |
| `resolve_url(url)` | `ResolvedUrl` |
| `health()` | `Health` |
| `ready()` | `Health` |

Names accept the `r/` and `u/` prefixes; post ids accept `t3_abc123` or `abc123`.

### Intelligence

Four methods answer a question rather than return a page:

```python
week = api.analyze_subreddit("kubernetes", window="7d")

# Read the sample before the numbers.
if week.sample.coverage < 1.0:
    print(f"only {week.sample.coverage:.0%} of the window was reached")

print(week.activity.posts_per_hour, week.engagement.median_post_score)
for topic in week.content.emerging_topics:
    print(topic.topic, topic.growth_ratio, topic.unique_authors)
```

Every one of them carries `window`, `generated_at` and `sample`. A metric that
could not be measured is `None` next to a stated reason — never `0`. Percentiles
are `None` below five observations; `growth_ratio` is `None` for a topic with no
baseline, which is what `is_new` is for.

`analyze_subreddit(deep=False)` skips `op_reply_rate`, the one metric costing an
extra call per sampled post.

`related_subreddits` scores candidates on several signals and tells you which
ones it could measure:

```python
for row in api.related_subreddits("kubernetes").data:
    print(row.subreddit, row.similarity, row.measured_signals)
```

A `similarity` resting on one signal is a weaker claim than the same number
resting on four. Aggregate audience overlap is withheld below 25 distinct
authors on either side, with the reason in
`row.signals.audience_overlap_withheld_because`.

Full definitions: [`docs/METRICS.md`](https://github.com/arthur-mf/ReddiGraph/blob/main/docs/METRICS.md).

### Monitors and webhooks

```python
monitor = api.create_monitor("keyword", name="Brand watch", query="reddigraph")
hook = api.add_webhook(monitor.id, "https://example.com/hooks/reddigraph")
print(hook.secret)   # shown once, here, and never again — store it now
```

Verify each delivery with the shipped helper rather than reimplementing it. The
two easy mistakes — comparing signatures with `==`, and ignoring the timestamp —
are both silent, and both are handled below:

```python
from reddigraph import verify_webhook

@app.post("/hooks/reddigraph")
async def hook(request):
    raw = await request.body()      # raw bytes; a re-encoded dict will not match
    if not verify_webhook(
        secret=SECRET,
        body=raw,
        signature=request.headers["X-ReddiGraph-Signature"],
        timestamp=request.headers["X-ReddiGraph-Timestamp"],
    ):
        return Response(status_code=401)
```

Events carry a deterministic `id`. Deduplicate on it — the same id is never sent
twice by the same monitor, and it is stable across our redeploys.

### Exports

```python
job = api.create_export("subreddit_posts", "parquet", {"subreddit": "python"})
while api.export(job.id).status in {"queued", "running"}:
    time.sleep(5)

done = api.export(job.id)
if done.truncated:
    print(f"{done.rows} of {done.rows_available} rows — your plan's ceiling applied")
```

The file is available for 24 hours; the job record outlives it, so arriving late
gets an explanation rather than a 404. `download_export` fetches it in one piece:

```python
open("posts.parquet", "wb").write(api.download_export(job.id))
```

For a dataset too large to hold in memory, stream `done.download_url` yourself
instead.

## Notes

- Comment lists are **flat**, ordered depth-first: rebuild the tree from `depth`
  and `parent_id`, and use `has_more` to spot a branch Reddit truncated.
- `discover_communities` returns no posts — recommended communities and topic
  lists only. It replaces the old `explore_feed`, which the name misdescribed.
- Search results are lighter than the dedicated endpoints, and they say so in
  their own types: `UserSearchResult` and `SubredditSearchResult` document every
  field the pane does not return. A `false` on one of those means *unknown*, not
  *no* — fetch `user()` or `subreddit_about()` for the real value.
- Unknown fields are preserved, so a newer server cannot break an older client.
- `posts()` is capped by your plan, not by the SDK: 10 ids on basic, 25 on pro,
  50 on ultra, 100 on mega. Over the ceiling the server answers `InvalidInput`
  with `plan`, `limit` and `received` in `exc.details`.
