Metadata-Version: 2.4
Name: toktik
Version: 0.4.0
Summary: Typed Python client for the TokTik Developer API (REST + realtime LIVE events)
Author: TokTik
License-Expression: MIT
Project-URL: Homepage, https://toktikhq.com
Project-URL: Documentation, https://docs.toktikhq.com/docs/sdks
Keywords: tiktok,live,realtime,developer-api
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: realtime
Requires-Dist: websockets<17,>=12; extra == "realtime"
Provides-Extra: dev
Requires-Dist: websockets<17,>=12; extra == "dev"
Requires-Dist: mypy>=1.8; extra == "dev"
Requires-Dist: ruff>=0.5; extra == "dev"
Dynamic: license-file

# toktik — Python SDK for the TokTik Developer API

Typed Python client for the TokTik Developer API: the full REST data-plane plus a realtime
(`asyncio`) LIVE-event client. Feature parity with [`@toktikhq/sdk-js`](../sdk-js); the contract source
of truth is [`@v2/contracts`](../contracts) / the served `GET /openapi.json`.

```python
from toktik import TokTikClient

client = TokTikClient(api_key="ttk_live_...")
board = client.rankings.official(board="hourly", region="VN")
print(board["provenance"])          # the envelope is never stripped
```

```python
import asyncio
from toktik import TokTikClient, EventFrame

async def main():
    client = TokTikClient(api_key="ttk_live_...")
    async for frame in await client.live.stream(["@creator"]):
        if isinstance(frame, EventFrame):
            print(frame.event, frame.data)          # chat / gift / like / ...

asyncio.run(main())
```

## Install

```bash
pip install toktik                # REST only — zero dependencies
pip install "toktik[realtime]"    # adds `websockets` for the realtime client
```

The REST client speaks HTTP through the standard library, so the data-plane surface pulls in nothing.
The realtime client needs a WebSocket implementation; installing the `realtime` extra provides it, or
you can pass your own transport (`RealtimeStream(connect=...)`).

## Design

- **Provenance is never stripped.** Data methods return the parsed JSON envelope unchanged —
  `{"data": ..., "provenance": {...}}`. For observed (rather than officially published) data, that
  context *is* part of the answer. The two documented exceptions match the JS SDK: `exports.download`
  returns CSV text, and `account.usage` is returned directly (no envelope).
- **Errors are surfaced, not swallowed.** A non-2xx raises `TokTikApiError` carrying `status`, `code`,
  `request_id` and helpers (`is_payment_required` for 402, `is_forbidden` for 403, `is_rate_limited`
  for 429, `retryable`). Distinguishing them is the whole point: 402 means buy credits, 403 means the
  key lacks the scope, 429 means back off.
- **Argument names are snake_case, mapped to the server's wire names.** Every route's Fastify schema
  sets `additionalProperties: false`, so a wrong query key is a hard 400. The mapping is transcribed
  from the routes, and a CI parity test asserts every documented data-plane path is covered.
- **The realtime client owns token lifetime, reconnection and resume.** A fresh handshake token is
  minted per connection (expiry becomes a reconnect, not a dead socket); reconnects use exponential
  backoff with full jitter; and because the gateway keeps no per-connection memory, subscriptions are
  re-sent on every open. `queued` / `active` / `offline` / `unavailable` are reported honestly.

## Pending (`202`) answers

A creator we hold nothing fresh for is resolved from TikTok on demand, so `creators.get`,
`content.creator_stories`, `creators.following` / `followers`, `content.video_comments` and
`live.playback` can answer `202` "not ready yet": a plain dict
`{"status": "resolving" | "collecting", "retryAfterSeconds": N, ...}` with no `data` key. It is a
success, not an error (no exception), and it is never billed. The SAME call returns the real
envelope once the read lands. While TikTok's read is failing the server holds it for up to 120 s
per call.

```python
from toktik import TokTikPendingTimeout, is_pending, wait_until_ready

result = client.creators.get("someone")
if is_pending(result):
    print("check again in", result["retryAfterSeconds"], "s")

# Or let the SDK poll: honours retryAfterSeconds (clamped to 1–120 s), gives up after 3 minutes.
try:
    profile = wait_until_ready(lambda: client.creators.get("someone"))
    print(profile["data"]["creator"]["followerCount"])
except TokTikPendingTimeout as err:
    print("still not ready; the server suggested", err.retry_after_seconds, "s")
```

A non-2xx answer inside `wait_until_ready` still raises `TokTikApiError` unchanged. After a
`TokTikPendingTimeout`, calling the method again later is safe — but a manual retry that lands the
read may be billed like any delivered read.

## REST surface

| Namespace | Methods | Scope |
|---|---|---|
| `client.rankings` | `official(board, region, limit)`, `movers(board, region, limit)`, `history(board, region, limit, cursor, league_tier)`, `regions()`, `games(region)` | `rank:read` |
| `client.live_pro` | `directory(region, live_only, limit)`, `regions()` — the LIVE Pro creator directory | `live-pro:read` |
| `client.recruit` | `list(region, exclude_live_pro, new_only, min_followers, max_coins_30d, min_score_today, sort, limit, cursor)`, `mark(creator_uid, status, note)`, `unmark(creator_uid)`, `marks(status, limit, cursor)` | `rank:read` (pool), `watchlist:manage` (marks) |
| `client.live` | `creator_performance`, `stream_token`, `stream` | `live:read` / `live:stream` |
| `client.creators` | `list`, `get`, `changes`, `analysis`, `following`, `followers` | `creator:read` |
| `client.content` | `creator_videos`, `video`, `video_comments` | `content:read` |
| `client.gifters` | `list`, `get`, `for_creator` | `gifter:read` |
| `client.agencies` | `list(region, q, service_type, public_profile_only, sort, limit, offset)`, `get(agency_id)`, `regions()` — contact details only on the Agency plan | `agencies:read` |
| `client.trends` | `list(region, type)` | `trend:read` |
| `client.exports` | `list`, `create`, `get`, `download` | `export` |
| `client.account` | `entitlements`, `usage` | `keys:manage` |

## Realtime

`await client.live.stream(creator_ids)` returns a connected `RealtimeStream` — an async iterator of
frames:

- `StatusFrame(creator_id, status, room_id, reason)` — subscription lifecycle
  (`queued`→`active`→…, or `offline`).
- `EventFrame(event, event_id, creator_id, room_id, sequence, room_state, data, provenance)` — a LIVE
  event (`chat`, `gift`, `like`, `member`, `roomUser`, `social`, `control`, `envelope`, `goodyBag`, `unknown`).
- `ErrorFrame(code, message, retryable)` — a server-side subscription error.

`subscribe(id)` / `unsubscribe(id)` change the set mid-stream; `aclose()` (or `async with`) stops for
good and cancels reconnection.

The token is minted from platform-api; its `wsUrl` points at the API host, which nginx routes to the
gateway in production. Driving a **local** gateway directly, mint the token yourself and pass the
gateway URL — see [`examples/realtime_demo.py`](examples/realtime_demo.py).

## Development

```bash
python -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
python -m unittest discover -s tests    # 63 tests, no network
mypy && ruff check src tests
```

The OpenAPI parity snapshot is generated from `@v2/contracts` (regenerated + diffed in CI):

```bash
npm run build --workspace @v2/contracts
node packages/sdk-python/scripts/gen_openapi_snapshot.mjs
```

## 0.4.0

Adds `is_pending()`, `wait_until_ready()` and `TokTikPendingTimeout` for the `202` "not ready yet"
answer (CR.9; see "Pending (`202`) answers"). Additive, no breaking change: the REST methods still
return the raw dict.

## 0.3.1

`recruit.list(country=…)` and the `recruit` export accept `country` (the creator's RESOLVED country — what a Recruit country card groups by; `region` stays the board bucket). Additive, no breaking change.

## 0.3.0

Adds `client.live_pro` (`directory`, `regions` — LP.4/LP.7) and `client.recruit` (`list`, `mark`, `unmark`, `marks` — the
recruiting pool + your workspace's marks, EC.1/EC.2); `exports.create` takes the new `recruit` dataset with its filters
(`region`, `exclude_live_pro`, `new_only`, `min_followers`, `max_coins_30d`). Additive, no breaking change.

## 0.2.0 (breaking)

`live.list_sessions` and `live.get_session` were removed — `GET /v1/live/sessions` answers `410 endpoint_removed`
since 2026-09-24. `exports.create` now requires `dataset` and takes every real export: `rankings` (`board`, `region`,
optional `game` / `period`), `gifters` (`range_days`, optional `region` / `segment`), `creator_roster`, and
`creator_gifters` (`creator_unique_id`, optional `window`). `live_sessions` is no longer a dataset.
The agency directory is not exportable (`dataset="agencies"` was removed in EX.7 and answers 400). A job's `filename`
names its main scope and the UTC minute: `toktik-<dataset>[-<scope>]-<YYYYMMDD>-<HHmm>.<format>`.

## Publishing

Releases are tag-driven through `.github/workflows/sdk-release.yml`:

```bash
git tag sdk-python-v0.4.0
git push origin sdk-python-v0.4.0
```

The release job builds and checks both the sdist and wheel, installs the wheel into a clean virtual
environment, and publishes with PyPI Trusted Publishing. The PyPI project must trust this repository,
that workflow, and the `pypi` GitHub environment before the first release.

## Known scope limits

- **Response bodies are typed `dict` (`JsonDict`), not generated models.** The shapes live in
  `@v2/contracts` (TypeScript); generating Pydantic models from them was deliberately deferred to
  avoid silent drift. Method arguments and realtime frames *are* typed.
- **Sync REST only.** An `asyncio` REST client is not yet provided; the realtime client is async, and
  it mints its token off the event loop via `asyncio.to_thread`.
