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 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,4 @@
1
+ """Single source of the package version. Kept in step with ``@toktikhq/sdk-js`` on feature parity,
2
+ not on an identical number — the two ship independently."""
3
+
4
+ __version__ = "0.1.0"
@@ -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