tezzinc 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.
tezzinc-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: tezzinc
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Tezzinc video transcoding & subtitling platform.
5
+ Author: Tezzinc
6
+ License: MIT
7
+ Project-URL: Homepage, https://tezzinc.com
8
+ Project-URL: Documentation, https://tezzinc.com/#/docs/python
9
+ Project-URL: Source, https://tezzinc.com
10
+ Keywords: tezzinc,video,transcoding,ffmpeg,subtitles,api,sdk
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Multimedia :: Video :: Conversion
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: httpx>=0.24
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7; extra == "dev"
26
+ Requires-Dist: pytest-httpx>=0.30; extra == "dev"
27
+ Requires-Dist: ruff>=0.1; extra == "dev"
28
+
29
+ # tezzinc — Python SDK
30
+
31
+ The official Python client for the [Tezzinc](https://tezzinc.com) video
32
+ transcoding & subtitling platform. A thin, typed wrapper over the same REST
33
+ API the console and the MCP server use.
34
+
35
+ ```bash
36
+ pip install tezzinc
37
+ ```
38
+
39
+ ## Quickstart
40
+
41
+ ```python
42
+ import os
43
+ from tezzinc import Tezzinc, TezzincError
44
+
45
+ client = Tezzinc(api_key=os.environ["TEZZINC_API_KEY"]) # or just Tezzinc()
46
+
47
+ # 1. Register a source
48
+ asset = client.assets.create(
49
+ source_uri="https://example.com/clip.mp4",
50
+ content_type="video/mp4",
51
+ )
52
+
53
+ # 2. Submit a transcode
54
+ job = client.jobs.create(
55
+ asset_id=asset.id,
56
+ spec={"preset_id": "h264_1080p"},
57
+ correlation_id="my-first-job",
58
+ )
59
+ print("submitted", job.id, "status=", job.status)
60
+
61
+ # 3. Wait for it (polling; use a webhook on a server)
62
+ final = client.jobs.wait_for(job.id, timeout=600, poll_interval=2.0)
63
+
64
+ # 4. Read the outputs (signed, short-lived URLs)
65
+ for out in final.outputs:
66
+ signed = client.outputs.signed_url(out.id, ttl_seconds=900)
67
+ print(out.preset_id, out.byte_size, signed.url)
68
+ ```
69
+
70
+ ## Configuration
71
+
72
+ The client reads the API key from, in order: the `api_key=` argument, the
73
+ `TEZZINC_API_KEY` environment variable, or `Tezzinc.from_config("config.json")`.
74
+ The base URL defaults to `https://tezzinc.com` and can be overridden with
75
+ `base_url=` or `TEZZINC_API_BASE`.
76
+
77
+ ## Errors
78
+
79
+ Every non-2xx response raises `TezzincError` with `.status` (HTTP code),
80
+ `.code` (machine-readable, e.g. `"quota_exceeded"`), `.message`, and
81
+ `.headers` (read `Retry-After` on a 429). Transport failures raise
82
+ `TezzincConnectionError` (a subclass, `status == 0`).
83
+
84
+ ```python
85
+ from tezzinc import TezzincError
86
+
87
+ try:
88
+ client.jobs.create(asset_id=asset.id, spec={"preset_id": "h264_1080p"})
89
+ except TezzincError as e:
90
+ if e.status == 429:
91
+ retry_after = int(e.headers.get("Retry-After", "60"))
92
+ elif e.status >= 500:
93
+ ... # retry with jitter
94
+ ```
95
+
96
+ ## Resources
97
+
98
+ | Namespace | Methods |
99
+ |-----------|---------|
100
+ | `client.assets` | `create`, `get`, `list`, `delete` |
101
+ | `client.jobs` | `create`, `get`, `list`, `cancel`, `wait_for` |
102
+ | `client.presets` | `list` |
103
+ | `client.outputs` | `signed_url` |
104
+
105
+ Responses are attribute-accessible (`job.status`, `job.outputs[0].preset_id`)
106
+ and also expose `.raw` for the underlying dict.
107
+
108
+ MIT licensed.
@@ -0,0 +1,80 @@
1
+ # tezzinc — Python SDK
2
+
3
+ The official Python client for the [Tezzinc](https://tezzinc.com) video
4
+ transcoding & subtitling platform. A thin, typed wrapper over the same REST
5
+ API the console and the MCP server use.
6
+
7
+ ```bash
8
+ pip install tezzinc
9
+ ```
10
+
11
+ ## Quickstart
12
+
13
+ ```python
14
+ import os
15
+ from tezzinc import Tezzinc, TezzincError
16
+
17
+ client = Tezzinc(api_key=os.environ["TEZZINC_API_KEY"]) # or just Tezzinc()
18
+
19
+ # 1. Register a source
20
+ asset = client.assets.create(
21
+ source_uri="https://example.com/clip.mp4",
22
+ content_type="video/mp4",
23
+ )
24
+
25
+ # 2. Submit a transcode
26
+ job = client.jobs.create(
27
+ asset_id=asset.id,
28
+ spec={"preset_id": "h264_1080p"},
29
+ correlation_id="my-first-job",
30
+ )
31
+ print("submitted", job.id, "status=", job.status)
32
+
33
+ # 3. Wait for it (polling; use a webhook on a server)
34
+ final = client.jobs.wait_for(job.id, timeout=600, poll_interval=2.0)
35
+
36
+ # 4. Read the outputs (signed, short-lived URLs)
37
+ for out in final.outputs:
38
+ signed = client.outputs.signed_url(out.id, ttl_seconds=900)
39
+ print(out.preset_id, out.byte_size, signed.url)
40
+ ```
41
+
42
+ ## Configuration
43
+
44
+ The client reads the API key from, in order: the `api_key=` argument, the
45
+ `TEZZINC_API_KEY` environment variable, or `Tezzinc.from_config("config.json")`.
46
+ The base URL defaults to `https://tezzinc.com` and can be overridden with
47
+ `base_url=` or `TEZZINC_API_BASE`.
48
+
49
+ ## Errors
50
+
51
+ Every non-2xx response raises `TezzincError` with `.status` (HTTP code),
52
+ `.code` (machine-readable, e.g. `"quota_exceeded"`), `.message`, and
53
+ `.headers` (read `Retry-After` on a 429). Transport failures raise
54
+ `TezzincConnectionError` (a subclass, `status == 0`).
55
+
56
+ ```python
57
+ from tezzinc import TezzincError
58
+
59
+ try:
60
+ client.jobs.create(asset_id=asset.id, spec={"preset_id": "h264_1080p"})
61
+ except TezzincError as e:
62
+ if e.status == 429:
63
+ retry_after = int(e.headers.get("Retry-After", "60"))
64
+ elif e.status >= 500:
65
+ ... # retry with jitter
66
+ ```
67
+
68
+ ## Resources
69
+
70
+ | Namespace | Methods |
71
+ |-----------|---------|
72
+ | `client.assets` | `create`, `get`, `list`, `delete` |
73
+ | `client.jobs` | `create`, `get`, `list`, `cancel`, `wait_for` |
74
+ | `client.presets` | `list` |
75
+ | `client.outputs` | `signed_url` |
76
+
77
+ Responses are attribute-accessible (`job.status`, `job.outputs[0].preset_id`)
78
+ and also expose `.raw` for the underlying dict.
79
+
80
+ MIT licensed.
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tezzinc"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for the Tezzinc video transcoding & subtitling platform."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Tezzinc" }]
13
+ keywords = ["tezzinc", "video", "transcoding", "ffmpeg", "subtitles", "api", "sdk"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Multimedia :: Video :: Conversion",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = ["httpx>=0.24"]
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["pytest>=7", "pytest-httpx>=0.30", "ruff>=0.1"]
30
+
31
+ [project.urls]
32
+ Homepage = "https://tezzinc.com"
33
+ Documentation = "https://tezzinc.com/#/docs/python"
34
+ Source = "https://tezzinc.com"
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+
39
+ [tool.setuptools.package-data]
40
+ tezzinc = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,35 @@
1
+ """Tezzinc — the official Python SDK for the Tezzinc video platform.
2
+
3
+ from tezzinc import Tezzinc, TezzincError
4
+
5
+ client = Tezzinc(api_key="tz_live_...") # or TEZZINC_API_KEY
6
+ asset = client.assets.create(source_uri="https://example.com/clip.mp4")
7
+ job = client.jobs.create(asset_id=asset.id, spec={"preset_id": "h264_1080p"})
8
+ final = client.jobs.wait_for(job.id, timeout=600)
9
+ for out in final.outputs:
10
+ print(client.outputs.signed_url(out.id).url)
11
+
12
+ ``import tezzinc`` and ``from tezzinc import Tezzinc`` both work — the import
13
+ path is a stable alias for the company name.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ from .client import Tezzinc
21
+ from .errors import TezzincConnectionError, TezzincError
22
+ from .models import Asset, Job, Output, Preset, Resource, SignedUrl
23
+
24
+ __all__ = [
25
+ "Tezzinc",
26
+ "TezzincError",
27
+ "TezzincConnectionError",
28
+ "Asset",
29
+ "Job",
30
+ "Output",
31
+ "Preset",
32
+ "SignedUrl",
33
+ "Resource",
34
+ "__version__",
35
+ ]
@@ -0,0 +1,297 @@
1
+ """The Tezzinc client and its resource namespaces.
2
+
3
+ from tezzinc import Tezzinc
4
+ client = Tezzinc(api_key="tz_live_...")
5
+ asset = client.assets.create(source_uri="https://…/clip.mp4")
6
+ job = client.jobs.create(asset_id=asset.id, spec={"preset_id": "h264_1080p"})
7
+ final = client.jobs.wait_for(job.id, timeout=600)
8
+ for out in final.outputs:
9
+ print(client.outputs.signed_url(out.id).url)
10
+
11
+ The client is a thin, dependency-light wrapper over the same REST surface the
12
+ console and the MCP server call. It holds one :class:`httpx.Client` (connection
13
+ pooling, HTTP/2) and translates every non-2xx response into a
14
+ :class:`TezzincError`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json as _json
20
+ import os
21
+ import time
22
+ from pathlib import Path
23
+ from typing import Any, Mapping
24
+
25
+ import httpx
26
+
27
+ from .errors import TezzincConnectionError, TezzincError
28
+ from .models import Asset, Job, Preset, SignedUrl
29
+
30
+ __all__ = ["Tezzinc"]
31
+
32
+ DEFAULT_BASE_URL = "https://tezzinc.com"
33
+ DEFAULT_TIMEOUT = 30.0
34
+ _TERMINAL = ("completed", "failed", "cancelled")
35
+
36
+
37
+ class Tezzinc:
38
+ """Entry point to the Tezzinc API.
39
+
40
+ Parameters
41
+ ----------
42
+ api_key:
43
+ Your API key. Falls back to the ``TEZZINC_API_KEY`` environment
44
+ variable. Required — the SDK refuses to make unauthenticated calls
45
+ rather than sending them and getting a 401.
46
+ base_url:
47
+ API root. Falls back to ``TEZZINC_API_BASE`` / ``TEZZINC_BASE_URL``,
48
+ else ``https://tezzinc.com``.
49
+ timeout:
50
+ Per-request timeout in seconds.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ api_key: str | None = None,
56
+ *,
57
+ base_url: str | None = None,
58
+ timeout: float = DEFAULT_TIMEOUT,
59
+ _transport: httpx.BaseTransport | None = None,
60
+ ) -> None:
61
+ key = api_key or os.environ.get("TEZZINC_API_KEY")
62
+ if not key:
63
+ raise TezzincError(
64
+ 0,
65
+ code="missing_api_key",
66
+ message=(
67
+ "No API key. Pass api_key=... or set TEZZINC_API_KEY. "
68
+ "Create a key in the console under Developer -> API keys."
69
+ ),
70
+ )
71
+ base = (
72
+ base_url
73
+ or os.environ.get("TEZZINC_API_BASE")
74
+ or os.environ.get("TEZZINC_BASE_URL")
75
+ or DEFAULT_BASE_URL
76
+ ).rstrip("/")
77
+ self._api_key = key
78
+ self.base_url = base
79
+ self._http = httpx.Client(
80
+ base_url=base,
81
+ timeout=timeout,
82
+ headers={
83
+ "Authorization": f"Bearer {key}",
84
+ "User-Agent": f"tezzinc-python/{_version()}",
85
+ "Accept": "application/json",
86
+ },
87
+ transport=_transport,
88
+ )
89
+ # Resource namespaces.
90
+ self.assets = _Assets(self)
91
+ self.jobs = _Jobs(self)
92
+ self.presets = _Presets(self)
93
+ self.outputs = _Outputs(self)
94
+
95
+ # -- alternate constructors -------------------------------------------
96
+ @classmethod
97
+ def from_config(cls, path: str | Path, **kwargs: Any) -> "Tezzinc":
98
+ """Build a client from a JSON config file: ``{"api_key": "...",
99
+ "base_url": "..."}``. Explicit kwargs win over the file."""
100
+ data = _json.loads(Path(path).read_text())
101
+ return cls(
102
+ api_key=kwargs.pop("api_key", None) or data.get("api_key"),
103
+ base_url=kwargs.pop("base_url", None) or data.get("base_url"),
104
+ **kwargs,
105
+ )
106
+
107
+ # -- context manager ---------------------------------------------------
108
+ def __enter__(self) -> "Tezzinc":
109
+ return self
110
+
111
+ def __exit__(self, *exc: object) -> None:
112
+ self.close()
113
+
114
+ def close(self) -> None:
115
+ self._http.close()
116
+
117
+ # -- the one request path everything funnels through ------------------
118
+ def _request(self, method: str, path: str, **kw: Any) -> Any:
119
+ try:
120
+ resp = self._http.request(method, path, **kw)
121
+ except httpx.TimeoutException as e:
122
+ raise TezzincConnectionError(f"request timed out: {e}") from e
123
+ except httpx.HTTPError as e:
124
+ raise TezzincConnectionError(f"network error: {e}") from e
125
+ if resp.status_code >= 400:
126
+ raise _error_from_response(resp)
127
+ if resp.status_code == 204 or not resp.content:
128
+ return None
129
+ try:
130
+ return resp.json()
131
+ except ValueError:
132
+ return resp.text
133
+
134
+
135
+ class _Assets:
136
+ def __init__(self, client: Tezzinc) -> None:
137
+ self._c = client
138
+
139
+ def create(
140
+ self,
141
+ *,
142
+ source_uri: str,
143
+ content_type: str | None = None,
144
+ **extra: Any,
145
+ ) -> Asset:
146
+ """Register a source URL as an asset. The platform fetches and probes
147
+ it; the returned asset carries ``id`` and ``status``."""
148
+ body: dict[str, Any] = {"source_uri": source_uri}
149
+ if content_type:
150
+ body["content_type"] = content_type
151
+ body.update(extra)
152
+ return Asset(self._c._request("POST", "/v1/videos", json=body))
153
+
154
+ def get(self, asset_id: str) -> Asset:
155
+ return Asset(self._c._request("GET", f"/v1/videos/{asset_id}"))
156
+
157
+ def list(self, *, limit: int = 50, offset: int = 0, status: str | None = None) -> list[Asset]:
158
+ params: dict[str, Any] = {"limit": limit, "offset": offset}
159
+ if status:
160
+ params["status"] = status
161
+ rows = self._c._request("GET", "/v1/videos", params=params) or []
162
+ return [Asset(r) for r in rows]
163
+
164
+ def delete(self, asset_id: str) -> None:
165
+ self._c._request("DELETE", f"/v1/videos/{asset_id}")
166
+
167
+
168
+ class _Jobs:
169
+ def __init__(self, client: Tezzinc) -> None:
170
+ self._c = client
171
+
172
+ def create(
173
+ self,
174
+ *,
175
+ asset_id: str,
176
+ spec: Mapping[str, Any] | None = None,
177
+ preset_id: str | None = None,
178
+ correlation_id: str | None = None,
179
+ idempotency_key: str | None = None,
180
+ **extra: Any,
181
+ ) -> Job:
182
+ """Submit a transcode against an asset.
183
+
184
+ ``spec`` mirrors the documented shape ``{"preset_id": "...",
185
+ "options": {...}}``; ``preset_id`` may also be passed directly. Extra
186
+ keyword args (priority, clip_start_seconds, …) pass through to the API.
187
+ """
188
+ spec = dict(spec or {})
189
+ pid = preset_id or spec.pop("preset_id", None)
190
+ if not pid:
191
+ raise TezzincError(
192
+ 0, code="missing_preset",
193
+ message="jobs.create needs a preset_id (directly or in spec['preset_id']).",
194
+ )
195
+ body: dict[str, Any] = {"asset_id": asset_id, "preset_id": pid}
196
+ # Flatten spec['options'] and any remaining spec keys as passthrough.
197
+ options = spec.pop("options", None)
198
+ if isinstance(options, Mapping):
199
+ body.update(options)
200
+ body.update(spec)
201
+ body.update(extra)
202
+ if correlation_id:
203
+ body["correlation_id"] = correlation_id
204
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
205
+ return Job(self._c._request("POST", "/v1/transcodes", json=body, headers=headers))
206
+
207
+ def get(self, job_id: str) -> Job:
208
+ return Job(self._c._request("GET", f"/v1/jobs/{job_id}"))
209
+
210
+ def list(self, *, limit: int = 50, offset: int = 0, status: str | None = None) -> list[Job]:
211
+ params: dict[str, Any] = {"limit": limit, "offset": offset}
212
+ if status:
213
+ params["status"] = status
214
+ rows = self._c._request("GET", "/v1/jobs", params=params) or []
215
+ return [Job(r) for r in rows]
216
+
217
+ def cancel(self, job_id: str) -> Job:
218
+ return Job(self._c._request("POST", f"/v1/jobs/{job_id}/cancel"))
219
+
220
+ def wait_for(
221
+ self,
222
+ job_id: str,
223
+ *,
224
+ timeout: float = 600.0,
225
+ poll_interval: float = 2.0,
226
+ ) -> Job:
227
+ """Poll until the job reaches a terminal state or ``timeout`` seconds
228
+ elapse. Raises :class:`TimeoutError` on timeout — the job itself is
229
+ untouched and can be polled again."""
230
+ deadline = time.monotonic() + timeout
231
+ while True:
232
+ job = self.get(job_id)
233
+ if job.get("status") in _TERMINAL:
234
+ return job
235
+ if time.monotonic() >= deadline:
236
+ raise TimeoutError(
237
+ f"job {job_id} did not finish within {timeout:.0f}s "
238
+ f"(last status: {job.get('status')})"
239
+ )
240
+ time.sleep(poll_interval)
241
+
242
+
243
+ class _Presets:
244
+ def __init__(self, client: Tezzinc) -> None:
245
+ self._c = client
246
+
247
+ def list(self) -> list[Preset]:
248
+ rows = self._c._request("GET", "/v1/presets") or []
249
+ return [Preset(r) for r in rows]
250
+
251
+
252
+ class _Outputs:
253
+ def __init__(self, client: Tezzinc) -> None:
254
+ self._c = client
255
+
256
+ def signed_url(self, output_id: str, *, ttl_seconds: int = 900) -> SignedUrl:
257
+ """Refresh a short-lived signed download URL for a finished output."""
258
+ return SignedUrl(
259
+ self._c._request(
260
+ "GET", f"/v1/outputs/{output_id}/signed-url", params={"ttl": ttl_seconds}
261
+ )
262
+ )
263
+
264
+
265
+ # ---------------------------------------------------------------------------
266
+ # helpers
267
+ # ---------------------------------------------------------------------------
268
+
269
+
270
+ def _error_from_response(resp: httpx.Response) -> TezzincError:
271
+ code = message = None
272
+ payload: Any = None
273
+ try:
274
+ payload = resp.json()
275
+ if isinstance(payload, dict):
276
+ detail = payload.get("detail", payload)
277
+ if isinstance(detail, dict):
278
+ code = detail.get("code")
279
+ message = detail.get("message") or detail.get("detail")
280
+ elif isinstance(detail, str):
281
+ message = detail
282
+ except ValueError:
283
+ message = resp.text or None
284
+ return TezzincError(
285
+ resp.status_code,
286
+ code=code,
287
+ message=message,
288
+ headers=resp.headers,
289
+ request_id=resp.headers.get("x-correlation-id") or resp.headers.get("x-request-id"),
290
+ payload=payload,
291
+ )
292
+
293
+
294
+ def _version() -> str:
295
+ from . import __version__
296
+
297
+ return __version__
@@ -0,0 +1,85 @@
1
+ """Exceptions raised by the Tezzinc SDK.
2
+
3
+ Every non-2xx response from the API surfaces as a :class:`TezzincError`. The
4
+ ``status`` field is the HTTP status, ``code`` is the machine-readable error
5
+ code (e.g. ``"quota_exceeded"``), ``message`` is the human explanation, and
6
+ ``headers`` exposes response headers (so callers can read ``Retry-After`` on a
7
+ 429). Network-level failures raise :class:`TezzincConnectionError`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Iterator, Mapping
13
+
14
+
15
+ class _CIHeaders(Mapping):
16
+ """A read-only, case-insensitive header map.
17
+
18
+ HTTP header names are case-insensitive, so ``e.headers.get("Retry-After")``
19
+ must work whether the transport stored it as ``Retry-After`` or
20
+ ``retry-after``. httpx lower-cases them; this restores lookup parity so the
21
+ documented recipe works regardless of the transport's casing.
22
+ """
23
+
24
+ def __init__(self, data: Mapping[str, str] | None = None) -> None:
25
+ self._d = {str(k).lower(): v for k, v in dict(data or {}).items()}
26
+
27
+ def __getitem__(self, key: str) -> str:
28
+ return self._d[str(key).lower()]
29
+
30
+ def __iter__(self) -> Iterator[str]:
31
+ return iter(self._d)
32
+
33
+ def __len__(self) -> int:
34
+ return len(self._d)
35
+
36
+ def __repr__(self) -> str:
37
+ return f"_CIHeaders({self._d!r})"
38
+
39
+
40
+ class TezzincError(Exception):
41
+ """An error returned by the Tezzinc API.
42
+
43
+ Attributes
44
+ ----------
45
+ status:
46
+ The HTTP status code (0 for transport-level failures).
47
+ code:
48
+ The machine-readable error code, when the API supplied one.
49
+ message:
50
+ The human-readable message.
51
+ headers:
52
+ The response headers, so a caller can read ``Retry-After`` etc.
53
+ request_id:
54
+ The correlation id the server echoed, useful in support tickets.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ status: int,
60
+ code: str | None = None,
61
+ message: str | None = None,
62
+ *,
63
+ headers: Mapping[str, str] | None = None,
64
+ request_id: str | None = None,
65
+ payload: Any = None,
66
+ ) -> None:
67
+ self.status = status
68
+ self.code = code
69
+ self.message = message or code or f"HTTP {status}"
70
+ self.headers = _CIHeaders(headers)
71
+ self.request_id = request_id
72
+ self.payload = payload
73
+ super().__init__(f"[{status}] {self.code or 'error'}: {self.message}")
74
+
75
+
76
+ class TezzincConnectionError(TezzincError):
77
+ """A transport-level failure (DNS, TLS, connection reset, timeout).
78
+
79
+ Carries ``status=0`` because no HTTP response was received. Kept a subclass
80
+ of :class:`TezzincError` so ``except TezzincError`` catches every failure a
81
+ caller can get from the SDK.
82
+ """
83
+
84
+ def __init__(self, message: str) -> None:
85
+ super().__init__(0, code="connection_error", message=message)
@@ -0,0 +1,93 @@
1
+ """Lightweight response models.
2
+
3
+ The SDK returns objects with attribute access (``asset.id``, ``job.status``)
4
+ rather than raw dicts, because the documentation and every example use dotted
5
+ access. They are deliberately permissive: any field the API adds is still
6
+ reachable as an attribute AND via ``[]`` / ``.raw``, so a server that grows a
7
+ field does not require an SDK upgrade to read it. This mirrors how the REST
8
+ surface evolves — additive, never renamed.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Iterator
14
+
15
+
16
+ class Resource:
17
+ """A dict wrapper exposing keys as attributes.
18
+
19
+ ``resource.id`` and ``resource["id"]`` are equivalent; ``resource.raw`` is
20
+ the untouched dict. Nested dicts/lists are wrapped lazily so
21
+ ``job.outputs[0].preset_id`` works without the caller thinking about it.
22
+ """
23
+
24
+ __slots__ = ("_data",)
25
+
26
+ def __init__(self, data: dict[str, Any] | None = None) -> None:
27
+ object.__setattr__(self, "_data", dict(data or {}))
28
+
29
+ # -- attribute / item access ------------------------------------------
30
+ def __getattr__(self, name: str) -> Any:
31
+ try:
32
+ return _wrap(self._data[name])
33
+ except KeyError:
34
+ raise AttributeError(
35
+ f"{type(self).__name__!s} has no field {name!r} "
36
+ f"(available: {', '.join(sorted(self._data)) or 'none'})"
37
+ ) from None
38
+
39
+ def __getitem__(self, key: str) -> Any:
40
+ return _wrap(self._data[key])
41
+
42
+ def get(self, key: str, default: Any = None) -> Any:
43
+ return _wrap(self._data.get(key, default))
44
+
45
+ @property
46
+ def raw(self) -> dict[str, Any]:
47
+ return self._data
48
+
49
+ def __contains__(self, key: object) -> bool:
50
+ return key in self._data
51
+
52
+ def __iter__(self) -> Iterator[str]:
53
+ return iter(self._data)
54
+
55
+ def __eq__(self, other: object) -> bool:
56
+ if isinstance(other, Resource):
57
+ return self._data == other._data
58
+ return NotImplemented
59
+
60
+ def __repr__(self) -> str:
61
+ ident = self._data.get("id")
62
+ head = f"id={ident!r} " if ident else ""
63
+ return f"<{type(self).__name__} {head}{list(self._data)}>"
64
+
65
+
66
+ def _wrap(value: Any) -> Any:
67
+ if isinstance(value, dict):
68
+ return Resource(value)
69
+ if isinstance(value, list):
70
+ return [_wrap(v) for v in value]
71
+ return value
72
+
73
+
74
+ # Named subclasses so ``repr`` and ``isinstance`` read nicely; behaviour is
75
+ # identical to Resource.
76
+ class Asset(Resource):
77
+ pass
78
+
79
+
80
+ class Job(Resource):
81
+ pass
82
+
83
+
84
+ class Output(Resource):
85
+ pass
86
+
87
+
88
+ class Preset(Resource):
89
+ pass
90
+
91
+
92
+ class SignedUrl(Resource):
93
+ pass
File without changes
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: tezzinc
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Tezzinc video transcoding & subtitling platform.
5
+ Author: Tezzinc
6
+ License: MIT
7
+ Project-URL: Homepage, https://tezzinc.com
8
+ Project-URL: Documentation, https://tezzinc.com/#/docs/python
9
+ Project-URL: Source, https://tezzinc.com
10
+ Keywords: tezzinc,video,transcoding,ffmpeg,subtitles,api,sdk
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Multimedia :: Video :: Conversion
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: httpx>=0.24
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7; extra == "dev"
26
+ Requires-Dist: pytest-httpx>=0.30; extra == "dev"
27
+ Requires-Dist: ruff>=0.1; extra == "dev"
28
+
29
+ # tezzinc — Python SDK
30
+
31
+ The official Python client for the [Tezzinc](https://tezzinc.com) video
32
+ transcoding & subtitling platform. A thin, typed wrapper over the same REST
33
+ API the console and the MCP server use.
34
+
35
+ ```bash
36
+ pip install tezzinc
37
+ ```
38
+
39
+ ## Quickstart
40
+
41
+ ```python
42
+ import os
43
+ from tezzinc import Tezzinc, TezzincError
44
+
45
+ client = Tezzinc(api_key=os.environ["TEZZINC_API_KEY"]) # or just Tezzinc()
46
+
47
+ # 1. Register a source
48
+ asset = client.assets.create(
49
+ source_uri="https://example.com/clip.mp4",
50
+ content_type="video/mp4",
51
+ )
52
+
53
+ # 2. Submit a transcode
54
+ job = client.jobs.create(
55
+ asset_id=asset.id,
56
+ spec={"preset_id": "h264_1080p"},
57
+ correlation_id="my-first-job",
58
+ )
59
+ print("submitted", job.id, "status=", job.status)
60
+
61
+ # 3. Wait for it (polling; use a webhook on a server)
62
+ final = client.jobs.wait_for(job.id, timeout=600, poll_interval=2.0)
63
+
64
+ # 4. Read the outputs (signed, short-lived URLs)
65
+ for out in final.outputs:
66
+ signed = client.outputs.signed_url(out.id, ttl_seconds=900)
67
+ print(out.preset_id, out.byte_size, signed.url)
68
+ ```
69
+
70
+ ## Configuration
71
+
72
+ The client reads the API key from, in order: the `api_key=` argument, the
73
+ `TEZZINC_API_KEY` environment variable, or `Tezzinc.from_config("config.json")`.
74
+ The base URL defaults to `https://tezzinc.com` and can be overridden with
75
+ `base_url=` or `TEZZINC_API_BASE`.
76
+
77
+ ## Errors
78
+
79
+ Every non-2xx response raises `TezzincError` with `.status` (HTTP code),
80
+ `.code` (machine-readable, e.g. `"quota_exceeded"`), `.message`, and
81
+ `.headers` (read `Retry-After` on a 429). Transport failures raise
82
+ `TezzincConnectionError` (a subclass, `status == 0`).
83
+
84
+ ```python
85
+ from tezzinc import TezzincError
86
+
87
+ try:
88
+ client.jobs.create(asset_id=asset.id, spec={"preset_id": "h264_1080p"})
89
+ except TezzincError as e:
90
+ if e.status == 429:
91
+ retry_after = int(e.headers.get("Retry-After", "60"))
92
+ elif e.status >= 500:
93
+ ... # retry with jitter
94
+ ```
95
+
96
+ ## Resources
97
+
98
+ | Namespace | Methods |
99
+ |-----------|---------|
100
+ | `client.assets` | `create`, `get`, `list`, `delete` |
101
+ | `client.jobs` | `create`, `get`, `list`, `cancel`, `wait_for` |
102
+ | `client.presets` | `list` |
103
+ | `client.outputs` | `signed_url` |
104
+
105
+ Responses are attribute-accessible (`job.status`, `job.outputs[0].preset_id`)
106
+ and also expose `.raw` for the underlying dict.
107
+
108
+ MIT licensed.
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/tezzinc/__init__.py
4
+ src/tezzinc/client.py
5
+ src/tezzinc/errors.py
6
+ src/tezzinc/models.py
7
+ src/tezzinc/py.typed
8
+ src/tezzinc.egg-info/PKG-INFO
9
+ src/tezzinc.egg-info/SOURCES.txt
10
+ src/tezzinc.egg-info/dependency_links.txt
11
+ src/tezzinc.egg-info/requires.txt
12
+ src/tezzinc.egg-info/top_level.txt
13
+ tests/test_sdk.py
@@ -0,0 +1,6 @@
1
+ httpx>=0.24
2
+
3
+ [dev]
4
+ pytest>=7
5
+ pytest-httpx>=0.30
6
+ ruff>=0.1
@@ -0,0 +1 @@
1
+ tezzinc
@@ -0,0 +1,198 @@
1
+ """SDK unit tests — no network. A mock httpx transport records requests and
2
+ returns canned responses, so we assert the SDK builds the right calls and maps
3
+ responses/errors correctly."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+
9
+ import httpx
10
+ import pytest
11
+
12
+ from tezzinc import Job, Tezzinc, TezzincError
13
+ from tezzinc.errors import TezzincConnectionError
14
+
15
+
16
+ def make_client(handler, **kw):
17
+ return Tezzinc(api_key="tz_test_key", base_url="https://api.test", _transport=httpx.MockTransport(handler), **kw)
18
+
19
+
20
+ # -- auth / construction -----------------------------------------------------
21
+
22
+
23
+ def test_requires_api_key(monkeypatch):
24
+ monkeypatch.delenv("TEZZINC_API_KEY", raising=False)
25
+ with pytest.raises(TezzincError) as ei:
26
+ Tezzinc()
27
+ assert ei.value.code == "missing_api_key"
28
+
29
+
30
+ def test_api_key_from_env(monkeypatch):
31
+ monkeypatch.setenv("TEZZINC_API_KEY", "tz_env")
32
+ c = Tezzinc(_transport=httpx.MockTransport(lambda r: httpx.Response(200, json=[])))
33
+ assert c._api_key == "tz_env"
34
+
35
+
36
+ def test_sends_bearer_auth():
37
+ seen = {}
38
+
39
+ def handler(request):
40
+ seen["auth"] = request.headers.get("authorization")
41
+ seen["ua"] = request.headers.get("user-agent")
42
+ return httpx.Response(200, json=[])
43
+
44
+ make_client(handler).presets.list()
45
+ assert seen["auth"] == "Bearer tz_test_key"
46
+ assert seen["ua"].startswith("tezzinc-python/")
47
+
48
+
49
+ # -- assets ------------------------------------------------------------------
50
+
51
+
52
+ def test_assets_create_posts_source_uri():
53
+ seen = {}
54
+
55
+ def handler(request):
56
+ seen["path"] = request.url.path
57
+ seen["body"] = json.loads(request.content)
58
+ return httpx.Response(201, json={"id": "a1", "status": "pending"})
59
+
60
+ asset = make_client(handler).assets.create(source_uri="https://x/y.mp4", content_type="video/mp4")
61
+ assert seen["path"] == "/v1/videos"
62
+ assert seen["body"] == {"source_uri": "https://x/y.mp4", "content_type": "video/mp4"}
63
+ assert asset.id == "a1"
64
+ assert asset.status == "pending"
65
+
66
+
67
+ # -- jobs --------------------------------------------------------------------
68
+
69
+
70
+ def test_jobs_create_flattens_spec():
71
+ seen = {}
72
+
73
+ def handler(request):
74
+ seen["path"] = request.url.path
75
+ seen["body"] = json.loads(request.content)
76
+ return httpx.Response(201, json={"id": "j1", "status": "queued", "outputs": []})
77
+
78
+ job = make_client(handler).jobs.create(
79
+ asset_id="a1",
80
+ spec={"preset_id": "h264_1080p", "options": {"priority": 5}},
81
+ correlation_id="c1",
82
+ )
83
+ assert seen["path"] == "/v1/transcodes"
84
+ assert seen["body"] == {
85
+ "asset_id": "a1",
86
+ "preset_id": "h264_1080p",
87
+ "priority": 5,
88
+ "correlation_id": "c1",
89
+ }
90
+ assert isinstance(job, Job) and job.id == "j1"
91
+
92
+
93
+ def test_jobs_create_requires_preset():
94
+ c = make_client(lambda r: httpx.Response(201, json={}))
95
+ with pytest.raises(TezzincError) as ei:
96
+ c.jobs.create(asset_id="a1", spec={})
97
+ assert ei.value.code == "missing_preset"
98
+
99
+
100
+ def test_jobs_create_idempotency_header():
101
+ seen = {}
102
+
103
+ def handler(request):
104
+ seen["idem"] = request.headers.get("idempotency-key")
105
+ return httpx.Response(201, json={"id": "j1"})
106
+
107
+ make_client(handler).jobs.create(asset_id="a1", preset_id="h264_720p", idempotency_key="k9")
108
+ assert seen["idem"] == "k9"
109
+
110
+
111
+ def test_wait_for_polls_until_terminal():
112
+ states = iter(["queued", "processing", "completed"])
113
+
114
+ def handler(request):
115
+ return httpx.Response(200, json={"id": "j1", "status": next(states), "outputs": [{"id": "o1"}]})
116
+
117
+ job = make_client(handler).jobs.wait_for("j1", timeout=5, poll_interval=0)
118
+ assert job.status == "completed"
119
+ assert job.outputs[0].id == "o1"
120
+
121
+
122
+ def test_wait_for_times_out():
123
+ def handler(request):
124
+ return httpx.Response(200, json={"id": "j1", "status": "processing"})
125
+
126
+ with pytest.raises(TimeoutError):
127
+ make_client(handler).jobs.wait_for("j1", timeout=0.05, poll_interval=0.01)
128
+
129
+
130
+ # -- outputs -----------------------------------------------------------------
131
+
132
+
133
+ def test_outputs_signed_url_passes_ttl():
134
+ seen = {}
135
+
136
+ def handler(request):
137
+ seen["path"] = request.url.path
138
+ seen["ttl"] = request.url.params.get("ttl")
139
+ return httpx.Response(200, json={"url": "https://signed/x"})
140
+
141
+ signed = make_client(handler).outputs.signed_url("o1", ttl_seconds=120)
142
+ assert seen["path"] == "/v1/outputs/o1/signed-url"
143
+ assert seen["ttl"] == "120"
144
+ assert signed.url == "https://signed/x"
145
+
146
+
147
+ # -- error mapping -----------------------------------------------------------
148
+
149
+
150
+ def test_error_maps_status_code_message_headers():
151
+ def handler(request):
152
+ return httpx.Response(
153
+ 429,
154
+ json={"detail": {"code": "quota_exceeded", "message": "slow down"}},
155
+ headers={"Retry-After": "42"},
156
+ )
157
+
158
+ with pytest.raises(TezzincError) as ei:
159
+ make_client(handler).presets.list()
160
+ e = ei.value
161
+ assert e.status == 429
162
+ assert e.code == "quota_exceeded"
163
+ assert e.message == "slow down"
164
+ assert e.headers.get("Retry-After") == "42"
165
+
166
+
167
+ def test_connection_error_is_tezzinc_error():
168
+ def handler(request):
169
+ raise httpx.ConnectError("boom")
170
+
171
+ with pytest.raises(TezzincConnectionError) as ei:
172
+ make_client(handler).presets.list()
173
+ assert ei.value.status == 0
174
+ assert isinstance(ei.value, TezzincError)
175
+
176
+
177
+ # -- model access ------------------------------------------------------------
178
+
179
+
180
+ def test_resource_attribute_and_item_access():
181
+ def handler(request):
182
+ return httpx.Response(200, json=[{"id": "p1", "codec": "h264", "extra": {"nested": 1}}])
183
+
184
+ presets = make_client(handler).presets.list()
185
+ assert presets[0].id == "p1"
186
+ assert presets[0]["codec"] == "h264"
187
+ assert presets[0].extra.nested == 1
188
+ assert presets[0].raw == {"id": "p1", "codec": "h264", "extra": {"nested": 1}}
189
+
190
+
191
+ def test_missing_field_raises_helpful_attribute_error():
192
+ def handler(request):
193
+ return httpx.Response(200, json=[{"id": "p1"}])
194
+
195
+ p = make_client(handler).presets.list()[0]
196
+ with pytest.raises(AttributeError) as ei:
197
+ _ = p.nonexistent
198
+ assert "available" in str(ei.value)