toktik 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- toktik-0.1.0/LICENSE +21 -0
- toktik-0.1.0/PKG-INFO +146 -0
- toktik-0.1.0/README.md +123 -0
- toktik-0.1.0/pyproject.toml +61 -0
- toktik-0.1.0/setup.cfg +4 -0
- toktik-0.1.0/src/toktik/__init__.py +30 -0
- toktik-0.1.0/src/toktik/_http.py +151 -0
- toktik-0.1.0/src/toktik/_version.py +4 -0
- toktik-0.1.0/src/toktik/client.py +103 -0
- toktik-0.1.0/src/toktik/errors.py +61 -0
- toktik-0.1.0/src/toktik/py.typed +0 -0
- toktik-0.1.0/src/toktik/realtime.py +426 -0
- toktik-0.1.0/src/toktik/resources.py +373 -0
- toktik-0.1.0/src/toktik.egg-info/PKG-INFO +146 -0
- toktik-0.1.0/src/toktik.egg-info/SOURCES.txt +20 -0
- toktik-0.1.0/src/toktik.egg-info/dependency_links.txt +1 -0
- toktik-0.1.0/src/toktik.egg-info/requires.txt +8 -0
- toktik-0.1.0/src/toktik.egg-info/top_level.txt +1 -0
- toktik-0.1.0/tests/test_http.py +119 -0
- toktik-0.1.0/tests/test_openapi_parity.py +106 -0
- toktik-0.1.0/tests/test_realtime.py +300 -0
- toktik-0.1.0/tests/test_resources.py +149 -0
toktik-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TokTik
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
toktik-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: toktik
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed Python client for the TokTik Developer API (REST + realtime LIVE events)
|
|
5
|
+
Author: TokTik
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://toktikhq.com
|
|
8
|
+
Project-URL: Documentation, https://docs.toktikhq.com/docs/sdks
|
|
9
|
+
Keywords: tiktok,live,realtime,developer-api
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Provides-Extra: realtime
|
|
17
|
+
Requires-Dist: websockets<17,>=12; extra == "realtime"
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: websockets<17,>=12; extra == "dev"
|
|
20
|
+
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
21
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# toktik — Python SDK for the TokTik Developer API
|
|
25
|
+
|
|
26
|
+
Typed Python client for the TokTik Developer API: the full REST data-plane plus a realtime
|
|
27
|
+
(`asyncio`) LIVE-event client. Feature parity with [`@toktikhq/sdk-js`](../sdk-js); the contract source
|
|
28
|
+
of truth is [`@v2/contracts`](../contracts) / the served `GET /openapi.json`.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from toktik import TokTikClient
|
|
32
|
+
|
|
33
|
+
client = TokTikClient(api_key="ttk_live_...")
|
|
34
|
+
board = client.rankings.official(board="hourly", region="VN")
|
|
35
|
+
print(board["provenance"]) # the envelope is never stripped
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import asyncio
|
|
40
|
+
from toktik import TokTikClient, EventFrame
|
|
41
|
+
|
|
42
|
+
async def main():
|
|
43
|
+
client = TokTikClient(api_key="ttk_live_...")
|
|
44
|
+
async for frame in await client.live.stream(["@creator"]):
|
|
45
|
+
if isinstance(frame, EventFrame):
|
|
46
|
+
print(frame.event, frame.data) # chat / gift / like / ...
|
|
47
|
+
|
|
48
|
+
asyncio.run(main())
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install toktik # REST only — zero dependencies
|
|
55
|
+
pip install "toktik[realtime]" # adds `websockets` for the realtime client
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The REST client speaks HTTP through the standard library, so the data-plane surface pulls in nothing.
|
|
59
|
+
The realtime client needs a WebSocket implementation; installing the `realtime` extra provides it, or
|
|
60
|
+
you can pass your own transport (`RealtimeStream(connect=...)`).
|
|
61
|
+
|
|
62
|
+
## Design
|
|
63
|
+
|
|
64
|
+
- **Provenance is never stripped.** Data methods return the parsed JSON envelope unchanged —
|
|
65
|
+
`{"data": ..., "provenance": {...}}`. For observed (rather than officially published) data, that
|
|
66
|
+
context *is* part of the answer. The two documented exceptions match the JS SDK: `exports.download`
|
|
67
|
+
returns CSV text, and `account.usage` is returned directly (no envelope).
|
|
68
|
+
- **Errors are surfaced, not swallowed.** A non-2xx raises `TokTikApiError` carrying `status`, `code`,
|
|
69
|
+
`request_id` and helpers (`is_payment_required` for 402, `is_forbidden` for 403, `is_rate_limited`
|
|
70
|
+
for 429, `retryable`). Distinguishing them is the whole point: 402 means buy credits, 403 means the
|
|
71
|
+
key lacks the scope, 429 means back off.
|
|
72
|
+
- **Argument names are snake_case, mapped to the server's wire names.** Every route's Fastify schema
|
|
73
|
+
sets `additionalProperties: false`, so a wrong query key is a hard 400. The mapping is transcribed
|
|
74
|
+
from the routes, and a CI parity test asserts every documented data-plane path is covered.
|
|
75
|
+
- **The realtime client owns token lifetime, reconnection and resume.** A fresh handshake token is
|
|
76
|
+
minted per connection (expiry becomes a reconnect, not a dead socket); reconnects use exponential
|
|
77
|
+
backoff with full jitter; and because the gateway keeps no per-connection memory, subscriptions are
|
|
78
|
+
re-sent on every open. `queued` / `active` / `offline` / `unavailable` are reported honestly.
|
|
79
|
+
|
|
80
|
+
## REST surface
|
|
81
|
+
|
|
82
|
+
| Namespace | Methods | Scope |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `client.rankings` | `official(board, region, limit)`, `movers(board, region, limit)`, `history(board, region, limit, cursor, league_tier)`, `regions()`, `games(region)` | `rank:read` |
|
|
85
|
+
| `client.live` | `list_sessions`, `get_session`, `creator_performance`, `stream_token`, `stream` | `live:read` / `live:stream` |
|
|
86
|
+
| `client.creators` | `list`, `get`, `changes`, `analysis`, `following`, `followers` | `creator:read` |
|
|
87
|
+
| `client.content` | `creator_videos`, `video`, `video_comments` | `content:read` |
|
|
88
|
+
| `client.gifters` | `list`, `get`, `for_creator` | `gifter:read` |
|
|
89
|
+
| `client.trends` | `list(region, type)` | `trend:read` |
|
|
90
|
+
| `client.exports` | `list`, `create`, `get`, `download` | `export` |
|
|
91
|
+
| `client.account` | `entitlements`, `usage` | `keys:manage` |
|
|
92
|
+
|
|
93
|
+
## Realtime
|
|
94
|
+
|
|
95
|
+
`await client.live.stream(creator_ids)` returns a connected `RealtimeStream` — an async iterator of
|
|
96
|
+
frames:
|
|
97
|
+
|
|
98
|
+
- `StatusFrame(creator_id, status, room_id, reason)` — subscription lifecycle
|
|
99
|
+
(`queued`→`active`→…, or `offline`).
|
|
100
|
+
- `EventFrame(event, event_id, creator_id, room_id, sequence, room_state, data, provenance)` — a LIVE
|
|
101
|
+
event (`chat`, `gift`, `like`, `member`, `roomUser`, `social`, `control`, `envelope`, `goodyBag`, `unknown`).
|
|
102
|
+
- `ErrorFrame(code, message, retryable)` — a server-side subscription error.
|
|
103
|
+
|
|
104
|
+
`subscribe(id)` / `unsubscribe(id)` change the set mid-stream; `aclose()` (or `async with`) stops for
|
|
105
|
+
good and cancels reconnection.
|
|
106
|
+
|
|
107
|
+
The token is minted from platform-api; its `wsUrl` points at the API host, which nginx routes to the
|
|
108
|
+
gateway in production. Driving a **local** gateway directly, mint the token yourself and pass the
|
|
109
|
+
gateway URL — see [`examples/realtime_demo.py`](examples/realtime_demo.py).
|
|
110
|
+
|
|
111
|
+
## Development
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
python -m venv .venv && . .venv/bin/activate
|
|
115
|
+
pip install -e ".[dev]"
|
|
116
|
+
python -m unittest discover -s tests # 51 tests, no network
|
|
117
|
+
mypy && ruff check src tests
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The OpenAPI parity snapshot is generated from `@v2/contracts` (regenerated + diffed in CI):
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
npm run build --workspace @v2/contracts
|
|
124
|
+
node packages/sdk-python/scripts/gen_openapi_snapshot.mjs
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Publishing
|
|
128
|
+
|
|
129
|
+
Releases are tag-driven through `.github/workflows/sdk-release.yml`:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
git tag sdk-python-v0.1.0
|
|
133
|
+
git push origin sdk-python-v0.1.0
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The release job builds and checks both the sdist and wheel, installs the wheel into a clean virtual
|
|
137
|
+
environment, and publishes with PyPI Trusted Publishing. The PyPI project must trust this repository,
|
|
138
|
+
that workflow, and the `pypi` GitHub environment before the first release.
|
|
139
|
+
|
|
140
|
+
## Known scope limits
|
|
141
|
+
|
|
142
|
+
- **Response bodies are typed `dict` (`JsonDict`), not generated models.** The shapes live in
|
|
143
|
+
`@v2/contracts` (TypeScript); generating Pydantic models from them was deliberately deferred to
|
|
144
|
+
avoid silent drift. Method arguments and realtime frames *are* typed.
|
|
145
|
+
- **Sync REST only.** An `asyncio` REST client is not yet provided; the realtime client is async, and
|
|
146
|
+
it mints its token off the event loop via `asyncio.to_thread`.
|
toktik-0.1.0/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# toktik — Python SDK for the TokTik Developer API
|
|
2
|
+
|
|
3
|
+
Typed Python client for the TokTik Developer API: the full REST data-plane plus a realtime
|
|
4
|
+
(`asyncio`) LIVE-event client. Feature parity with [`@toktikhq/sdk-js`](../sdk-js); the contract source
|
|
5
|
+
of truth is [`@v2/contracts`](../contracts) / the served `GET /openapi.json`.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from toktik import TokTikClient
|
|
9
|
+
|
|
10
|
+
client = TokTikClient(api_key="ttk_live_...")
|
|
11
|
+
board = client.rankings.official(board="hourly", region="VN")
|
|
12
|
+
print(board["provenance"]) # the envelope is never stripped
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import asyncio
|
|
17
|
+
from toktik import TokTikClient, EventFrame
|
|
18
|
+
|
|
19
|
+
async def main():
|
|
20
|
+
client = TokTikClient(api_key="ttk_live_...")
|
|
21
|
+
async for frame in await client.live.stream(["@creator"]):
|
|
22
|
+
if isinstance(frame, EventFrame):
|
|
23
|
+
print(frame.event, frame.data) # chat / gift / like / ...
|
|
24
|
+
|
|
25
|
+
asyncio.run(main())
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install toktik # REST only — zero dependencies
|
|
32
|
+
pip install "toktik[realtime]" # adds `websockets` for the realtime client
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The REST client speaks HTTP through the standard library, so the data-plane surface pulls in nothing.
|
|
36
|
+
The realtime client needs a WebSocket implementation; installing the `realtime` extra provides it, or
|
|
37
|
+
you can pass your own transport (`RealtimeStream(connect=...)`).
|
|
38
|
+
|
|
39
|
+
## Design
|
|
40
|
+
|
|
41
|
+
- **Provenance is never stripped.** Data methods return the parsed JSON envelope unchanged —
|
|
42
|
+
`{"data": ..., "provenance": {...}}`. For observed (rather than officially published) data, that
|
|
43
|
+
context *is* part of the answer. The two documented exceptions match the JS SDK: `exports.download`
|
|
44
|
+
returns CSV text, and `account.usage` is returned directly (no envelope).
|
|
45
|
+
- **Errors are surfaced, not swallowed.** A non-2xx raises `TokTikApiError` carrying `status`, `code`,
|
|
46
|
+
`request_id` and helpers (`is_payment_required` for 402, `is_forbidden` for 403, `is_rate_limited`
|
|
47
|
+
for 429, `retryable`). Distinguishing them is the whole point: 402 means buy credits, 403 means the
|
|
48
|
+
key lacks the scope, 429 means back off.
|
|
49
|
+
- **Argument names are snake_case, mapped to the server's wire names.** Every route's Fastify schema
|
|
50
|
+
sets `additionalProperties: false`, so a wrong query key is a hard 400. The mapping is transcribed
|
|
51
|
+
from the routes, and a CI parity test asserts every documented data-plane path is covered.
|
|
52
|
+
- **The realtime client owns token lifetime, reconnection and resume.** A fresh handshake token is
|
|
53
|
+
minted per connection (expiry becomes a reconnect, not a dead socket); reconnects use exponential
|
|
54
|
+
backoff with full jitter; and because the gateway keeps no per-connection memory, subscriptions are
|
|
55
|
+
re-sent on every open. `queued` / `active` / `offline` / `unavailable` are reported honestly.
|
|
56
|
+
|
|
57
|
+
## REST surface
|
|
58
|
+
|
|
59
|
+
| Namespace | Methods | Scope |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `client.rankings` | `official(board, region, limit)`, `movers(board, region, limit)`, `history(board, region, limit, cursor, league_tier)`, `regions()`, `games(region)` | `rank:read` |
|
|
62
|
+
| `client.live` | `list_sessions`, `get_session`, `creator_performance`, `stream_token`, `stream` | `live:read` / `live:stream` |
|
|
63
|
+
| `client.creators` | `list`, `get`, `changes`, `analysis`, `following`, `followers` | `creator:read` |
|
|
64
|
+
| `client.content` | `creator_videos`, `video`, `video_comments` | `content:read` |
|
|
65
|
+
| `client.gifters` | `list`, `get`, `for_creator` | `gifter:read` |
|
|
66
|
+
| `client.trends` | `list(region, type)` | `trend:read` |
|
|
67
|
+
| `client.exports` | `list`, `create`, `get`, `download` | `export` |
|
|
68
|
+
| `client.account` | `entitlements`, `usage` | `keys:manage` |
|
|
69
|
+
|
|
70
|
+
## Realtime
|
|
71
|
+
|
|
72
|
+
`await client.live.stream(creator_ids)` returns a connected `RealtimeStream` — an async iterator of
|
|
73
|
+
frames:
|
|
74
|
+
|
|
75
|
+
- `StatusFrame(creator_id, status, room_id, reason)` — subscription lifecycle
|
|
76
|
+
(`queued`→`active`→…, or `offline`).
|
|
77
|
+
- `EventFrame(event, event_id, creator_id, room_id, sequence, room_state, data, provenance)` — a LIVE
|
|
78
|
+
event (`chat`, `gift`, `like`, `member`, `roomUser`, `social`, `control`, `envelope`, `goodyBag`, `unknown`).
|
|
79
|
+
- `ErrorFrame(code, message, retryable)` — a server-side subscription error.
|
|
80
|
+
|
|
81
|
+
`subscribe(id)` / `unsubscribe(id)` change the set mid-stream; `aclose()` (or `async with`) stops for
|
|
82
|
+
good and cancels reconnection.
|
|
83
|
+
|
|
84
|
+
The token is minted from platform-api; its `wsUrl` points at the API host, which nginx routes to the
|
|
85
|
+
gateway in production. Driving a **local** gateway directly, mint the token yourself and pass the
|
|
86
|
+
gateway URL — see [`examples/realtime_demo.py`](examples/realtime_demo.py).
|
|
87
|
+
|
|
88
|
+
## Development
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
python -m venv .venv && . .venv/bin/activate
|
|
92
|
+
pip install -e ".[dev]"
|
|
93
|
+
python -m unittest discover -s tests # 51 tests, no network
|
|
94
|
+
mypy && ruff check src tests
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The OpenAPI parity snapshot is generated from `@v2/contracts` (regenerated + diffed in CI):
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
npm run build --workspace @v2/contracts
|
|
101
|
+
node packages/sdk-python/scripts/gen_openapi_snapshot.mjs
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Publishing
|
|
105
|
+
|
|
106
|
+
Releases are tag-driven through `.github/workflows/sdk-release.yml`:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
git tag sdk-python-v0.1.0
|
|
110
|
+
git push origin sdk-python-v0.1.0
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The release job builds and checks both the sdist and wheel, installs the wheel into a clean virtual
|
|
114
|
+
environment, and publishes with PyPI Trusted Publishing. The PyPI project must trust this repository,
|
|
115
|
+
that workflow, and the `pypi` GitHub environment before the first release.
|
|
116
|
+
|
|
117
|
+
## Known scope limits
|
|
118
|
+
|
|
119
|
+
- **Response bodies are typed `dict` (`JsonDict`), not generated models.** The shapes live in
|
|
120
|
+
`@v2/contracts` (TypeScript); generating Pydantic models from them was deliberately deferred to
|
|
121
|
+
avoid silent drift. Method arguments and realtime frames *are* typed.
|
|
122
|
+
- **Sync REST only.** An `asyncio` REST client is not yet provided; the realtime client is async, and
|
|
123
|
+
it mints its token off the event loop via `asyncio.to_thread`.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "toktik"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Typed Python client for the TokTik Developer API (REST + realtime LIVE events)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "TokTik" }]
|
|
14
|
+
keywords = ["tiktok", "live", "realtime", "developer-api"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
# The REST client is dependency-free (stdlib urllib). The realtime client needs a WebSocket
|
|
21
|
+
# implementation; it is an OPTIONAL dependency so `import toktik` and every REST call work without it.
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
realtime = ["websockets>=12,<17"]
|
|
26
|
+
dev = ["websockets>=12,<17", "mypy>=1.8", "ruff>=0.5"]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://toktikhq.com"
|
|
30
|
+
Documentation = "https://docs.toktikhq.com/docs/sdks"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools]
|
|
33
|
+
package-dir = { "" = "src" }
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.package-data]
|
|
39
|
+
toktik = ["py.typed"]
|
|
40
|
+
|
|
41
|
+
[tool.mypy]
|
|
42
|
+
# Runtime supports 3.9 (see requires-python); the type-checker baseline is 3.10 because current mypy
|
|
43
|
+
# refuses to target 3.9. Checking against 3.10 still validates the 3.9 code (all annotations are
|
|
44
|
+
# stringised via `from __future__ import annotations`).
|
|
45
|
+
python_version = "3.10"
|
|
46
|
+
strict = true
|
|
47
|
+
# The data methods return parsed JSON, which is genuinely dynamic (`Any`). The `JsonDict` return
|
|
48
|
+
# annotations document intent; warn_return_any would only force noisy casts around every call.
|
|
49
|
+
warn_return_any = false
|
|
50
|
+
files = ["src/toktik"]
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
line-length = 110
|
|
54
|
+
target-version = "py39"
|
|
55
|
+
src = ["src", "tests"]
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint]
|
|
58
|
+
# E/F/I/B catch real defects. The UP (pyupgrade) family is intentionally omitted: the package
|
|
59
|
+
# supports Python 3.9, and auto-rewriting to PEP 604/585 forms risks runtime `X | None` aliases that
|
|
60
|
+
# only work on 3.10+. `Optional`/`Dict`/`List` are correct and clear here.
|
|
61
|
+
select = ["E", "F", "I", "B"]
|
toktik-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Typed Python client for the TokTik Developer API (REST + realtime LIVE events)."""
|
|
2
|
+
|
|
3
|
+
from ._http import HttpTransport, build_query
|
|
4
|
+
from ._version import __version__
|
|
5
|
+
from .client import TokTikClient
|
|
6
|
+
from .errors import TokTikApiError
|
|
7
|
+
from .realtime import (
|
|
8
|
+
ErrorFrame,
|
|
9
|
+
EventFrame,
|
|
10
|
+
RealtimeError,
|
|
11
|
+
RealtimeStream,
|
|
12
|
+
ServerFrame,
|
|
13
|
+
StatusFrame,
|
|
14
|
+
normalize_creator_id,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"TokTikClient",
|
|
19
|
+
"TokTikApiError",
|
|
20
|
+
"HttpTransport",
|
|
21
|
+
"build_query",
|
|
22
|
+
"RealtimeStream",
|
|
23
|
+
"StatusFrame",
|
|
24
|
+
"EventFrame",
|
|
25
|
+
"ErrorFrame",
|
|
26
|
+
"ServerFrame",
|
|
27
|
+
"RealtimeError",
|
|
28
|
+
"normalize_creator_id",
|
|
29
|
+
"__version__",
|
|
30
|
+
]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Shared request plumbing. Deliberately has no retries, caching, or credential storage — the same
|
|
2
|
+
stance as ``@toktikhq/sdk-js``'s ``HttpTransport``.
|
|
3
|
+
|
|
4
|
+
The REST client is intentionally dependency-free: it speaks HTTP through the standard library
|
|
5
|
+
(``urllib.request``) so ``pip install toktik`` pulls in nothing for the data-plane surface. A custom
|
|
6
|
+
``transport`` seam is exposed for tests and for callers who would rather route through ``httpx`` or a
|
|
7
|
+
signed proxy.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Callable, Mapping, Optional, Tuple
|
|
15
|
+
from urllib.error import HTTPError
|
|
16
|
+
from urllib.parse import urlencode
|
|
17
|
+
from urllib.request import Request, urlopen
|
|
18
|
+
|
|
19
|
+
from .errors import TokTikApiError
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE_URL = "https://api.toktikhq.com"
|
|
22
|
+
|
|
23
|
+
# A transport turns a request into ``(status, body_text)``. Any non-2xx status is reported here, not
|
|
24
|
+
# raised — this layer, not the transport, owns turning a status into ``TokTikApiError``.
|
|
25
|
+
Transport = Callable[[str, str, Mapping[str, str], Optional[bytes]], Tuple[int, str]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_query(parameters: Optional[Mapping[str, Any]] = None) -> str:
|
|
29
|
+
"""Serialise query parameters, dropping ``None``/empty and expanding lists into repeats.
|
|
30
|
+
|
|
31
|
+
Repeatable filters (e.g. ``?type=a&type=b``) must not collapse into ``"a,b"`` — mirrors the JS
|
|
32
|
+
SDK's ``buildQuery``.
|
|
33
|
+
"""
|
|
34
|
+
if not parameters:
|
|
35
|
+
return ""
|
|
36
|
+
pairs: list[tuple[str, str]] = []
|
|
37
|
+
for key, value in parameters.items():
|
|
38
|
+
if value is None or value == "":
|
|
39
|
+
continue
|
|
40
|
+
if isinstance(value, (list, tuple)):
|
|
41
|
+
for item in value:
|
|
42
|
+
if item is None or item == "":
|
|
43
|
+
continue
|
|
44
|
+
pairs.append((key, _stringify(item)))
|
|
45
|
+
else:
|
|
46
|
+
pairs.append((key, _stringify(value)))
|
|
47
|
+
if not pairs:
|
|
48
|
+
return ""
|
|
49
|
+
return "?" + urlencode(pairs)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _stringify(value: Any) -> str:
|
|
53
|
+
if isinstance(value, bool):
|
|
54
|
+
# Match JS `String(true)` -> "true", not Python's "True".
|
|
55
|
+
return "true" if value else "false"
|
|
56
|
+
return str(value)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _safe_json(raw: str) -> Any:
|
|
60
|
+
try:
|
|
61
|
+
return json.loads(raw)
|
|
62
|
+
except (ValueError, TypeError):
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class _StdlibTransport:
|
|
68
|
+
"""Default transport over ``urllib.request``. Kept tiny and swappable."""
|
|
69
|
+
|
|
70
|
+
timeout: float = 30.0
|
|
71
|
+
|
|
72
|
+
def __call__(
|
|
73
|
+
self,
|
|
74
|
+
method: str,
|
|
75
|
+
url: str,
|
|
76
|
+
headers: Mapping[str, str],
|
|
77
|
+
body: Optional[bytes],
|
|
78
|
+
) -> Tuple[int, str]:
|
|
79
|
+
request = Request(url, data=body, method=method)
|
|
80
|
+
for name, value in headers.items():
|
|
81
|
+
request.add_header(name, value)
|
|
82
|
+
try:
|
|
83
|
+
with urlopen(request, timeout=self.timeout) as response: # noqa: S310 - fixed https host
|
|
84
|
+
return response.status, response.read().decode("utf-8")
|
|
85
|
+
except HTTPError as error: # noqa: PERF203 - the error path is not hot
|
|
86
|
+
# A 4xx/5xx is an ordinary answer to us, not an exception: read the JSON error envelope.
|
|
87
|
+
raw = error.read().decode("utf-8", errors="replace") if error.fp is not None else ""
|
|
88
|
+
return error.code, raw
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class HttpTransport:
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
api_key: str,
|
|
95
|
+
base_url: Optional[str] = None,
|
|
96
|
+
transport: Optional[Transport] = None,
|
|
97
|
+
timeout: float = 30.0,
|
|
98
|
+
) -> None:
|
|
99
|
+
api_key = (api_key or "").strip()
|
|
100
|
+
if not api_key:
|
|
101
|
+
raise ValueError("api_key is required")
|
|
102
|
+
self._api_key = api_key
|
|
103
|
+
self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
104
|
+
self._transport: Transport = transport or _StdlibTransport(timeout=timeout)
|
|
105
|
+
|
|
106
|
+
def get(self, path: str, parameters: Optional[Mapping[str, Any]] = None) -> Any:
|
|
107
|
+
return self._request("GET", f"{path}{build_query(parameters)}")
|
|
108
|
+
|
|
109
|
+
def get_text(self, path: str, parameters: Optional[Mapping[str, Any]] = None) -> str:
|
|
110
|
+
"""For endpoints that answer with something other than JSON (today: the CSV export download).
|
|
111
|
+
|
|
112
|
+
Errors are still parsed as JSON, because a failure is always the JSON error envelope.
|
|
113
|
+
"""
|
|
114
|
+
raw = self._request("GET", f"{path}{build_query(parameters)}", response_kind="text")
|
|
115
|
+
return raw
|
|
116
|
+
|
|
117
|
+
def post(self, path: str, body: Any = None) -> Any:
|
|
118
|
+
return self._request("POST", path, body=body)
|
|
119
|
+
|
|
120
|
+
def _request(
|
|
121
|
+
self,
|
|
122
|
+
method: str,
|
|
123
|
+
path: str,
|
|
124
|
+
body: Any = None,
|
|
125
|
+
response_kind: str = "json",
|
|
126
|
+
) -> Any:
|
|
127
|
+
headers = {
|
|
128
|
+
"authorization": f"Bearer {self._api_key}",
|
|
129
|
+
"accept": "application/json",
|
|
130
|
+
}
|
|
131
|
+
encoded: Optional[bytes] = None
|
|
132
|
+
if body is not None:
|
|
133
|
+
headers["content-type"] = "application/json"
|
|
134
|
+
encoded = json.dumps(body).encode("utf-8")
|
|
135
|
+
status, raw = self._transport(method, f"{self.base_url}{path}", headers, encoded)
|
|
136
|
+
if not 200 <= status < 300:
|
|
137
|
+
envelope = _safe_json(raw) if raw else None
|
|
138
|
+
code = message = request_id = None
|
|
139
|
+
if isinstance(envelope, dict):
|
|
140
|
+
code = envelope.get("code")
|
|
141
|
+
message = envelope.get("message")
|
|
142
|
+
request_id = envelope.get("requestId")
|
|
143
|
+
raise TokTikApiError(
|
|
144
|
+
status,
|
|
145
|
+
code,
|
|
146
|
+
request_id,
|
|
147
|
+
message or f"TokTik API request failed ({status}).",
|
|
148
|
+
)
|
|
149
|
+
if response_kind == "text":
|
|
150
|
+
return raw
|
|
151
|
+
return _safe_json(raw) if raw else None
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Typed client for the TokTik Developer API.
|
|
2
|
+
|
|
3
|
+
Example::
|
|
4
|
+
|
|
5
|
+
from toktik import TokTikClient
|
|
6
|
+
|
|
7
|
+
client = TokTikClient(api_key="ttk_live_...")
|
|
8
|
+
board = client.rankings.official(board="hourly", region="VN")
|
|
9
|
+
print(board["provenance"])
|
|
10
|
+
|
|
11
|
+
# realtime (needs the `realtime` extra):
|
|
12
|
+
async for frame in await client.live.stream(["@creator"]):
|
|
13
|
+
if frame.type == "event":
|
|
14
|
+
print(frame.event, frame.data)
|
|
15
|
+
|
|
16
|
+
Most data methods return the provenance envelope **unchanged** — ``data`` alongside the
|
|
17
|
+
``provenance`` block describing how fresh the answer is and how it was obtained. The SDK never
|
|
18
|
+
strips it. ``exports.download`` (CSV text) and ``account.usage`` are the documented exceptions.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
from typing import Any, List, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
from ._http import HttpTransport, Transport
|
|
27
|
+
from .realtime import RealtimeStream, WsConnect
|
|
28
|
+
from .resources import (
|
|
29
|
+
AccountResource,
|
|
30
|
+
ContentResource,
|
|
31
|
+
CreatorsResource,
|
|
32
|
+
ExportsResource,
|
|
33
|
+
GiftersResource,
|
|
34
|
+
LiveResource,
|
|
35
|
+
RankingsResource,
|
|
36
|
+
TrendsResource,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _LiveNamespace(LiveResource):
|
|
41
|
+
"""``LiveResource`` plus ``stream()`` — the realtime entry point, which needs the client to mint
|
|
42
|
+
and refresh handshake tokens off the event loop."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, http: HttpTransport) -> None:
|
|
45
|
+
super().__init__(http)
|
|
46
|
+
|
|
47
|
+
async def _mint(self) -> Tuple[str, str]:
|
|
48
|
+
# ``stream_token`` is a blocking stdlib HTTP call; keep it off the event loop.
|
|
49
|
+
minted: Any = await asyncio.to_thread(self.stream_token)
|
|
50
|
+
return minted["token"], minted["wsUrl"]
|
|
51
|
+
|
|
52
|
+
async def stream(
|
|
53
|
+
self,
|
|
54
|
+
creator_ids: List[str],
|
|
55
|
+
*,
|
|
56
|
+
connect: Optional[WsConnect] = None,
|
|
57
|
+
max_reconnect_attempts: float = float("inf"),
|
|
58
|
+
base_reconnect_delay: float = 0.5,
|
|
59
|
+
max_reconnect_delay: float = 30.0,
|
|
60
|
+
on_reconnect: Optional[Any] = None,
|
|
61
|
+
on_error: Optional[Any] = None,
|
|
62
|
+
) -> RealtimeStream:
|
|
63
|
+
"""Open a realtime stream. Mints its own handshake token and keeps it fresh across reconnects.
|
|
64
|
+
|
|
65
|
+
Returns a connected :class:`~toktik.realtime.RealtimeStream` that is an async iterator of
|
|
66
|
+
status/event/error frames.
|
|
67
|
+
"""
|
|
68
|
+
stream = RealtimeStream(
|
|
69
|
+
self._mint,
|
|
70
|
+
connect=connect,
|
|
71
|
+
max_reconnect_attempts=max_reconnect_attempts,
|
|
72
|
+
base_reconnect_delay=base_reconnect_delay,
|
|
73
|
+
max_reconnect_delay=max_reconnect_delay,
|
|
74
|
+
on_reconnect=on_reconnect,
|
|
75
|
+
on_error=on_error,
|
|
76
|
+
)
|
|
77
|
+
await stream.connect(creator_ids)
|
|
78
|
+
return stream
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TokTikClient:
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
api_key: str,
|
|
85
|
+
base_url: Optional[str] = None,
|
|
86
|
+
transport: Optional[Transport] = None,
|
|
87
|
+
timeout: float = 30.0,
|
|
88
|
+
) -> None:
|
|
89
|
+
self._http = HttpTransport(
|
|
90
|
+
api_key=api_key, base_url=base_url, transport=transport, timeout=timeout
|
|
91
|
+
)
|
|
92
|
+
self.live = _LiveNamespace(self._http)
|
|
93
|
+
self.rankings = RankingsResource(self._http)
|
|
94
|
+
self.creators = CreatorsResource(self._http)
|
|
95
|
+
self.content = ContentResource(self._http)
|
|
96
|
+
self.gifters = GiftersResource(self._http)
|
|
97
|
+
self.trends = TrendsResource(self._http)
|
|
98
|
+
self.exports = ExportsResource(self._http)
|
|
99
|
+
self.account = AccountResource(self._http)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def base_url(self) -> str:
|
|
103
|
+
return self._http.base_url
|