Metadata-Version: 2.4
Name: toktik
Version: 0.1.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.

## 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` | `list_sessions`, `get_session`, `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.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    # 51 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
```

## Publishing

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

```bash
git tag sdk-python-v0.1.0
git push origin sdk-python-v0.1.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`.
