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 +108 -0
- tezzinc-0.1.0/README.md +80 -0
- tezzinc-0.1.0/pyproject.toml +40 -0
- tezzinc-0.1.0/setup.cfg +4 -0
- tezzinc-0.1.0/src/tezzinc/__init__.py +35 -0
- tezzinc-0.1.0/src/tezzinc/client.py +297 -0
- tezzinc-0.1.0/src/tezzinc/errors.py +85 -0
- tezzinc-0.1.0/src/tezzinc/models.py +93 -0
- tezzinc-0.1.0/src/tezzinc/py.typed +0 -0
- tezzinc-0.1.0/src/tezzinc.egg-info/PKG-INFO +108 -0
- tezzinc-0.1.0/src/tezzinc.egg-info/SOURCES.txt +13 -0
- tezzinc-0.1.0/src/tezzinc.egg-info/dependency_links.txt +1 -0
- tezzinc-0.1.0/src/tezzinc.egg-info/requires.txt +6 -0
- tezzinc-0.1.0/src/tezzinc.egg-info/top_level.txt +1 -0
- tezzinc-0.1.0/tests/test_sdk.py +198 -0
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.
|
tezzinc-0.1.0/README.md
ADDED
|
@@ -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"]
|
tezzinc-0.1.0/setup.cfg
ADDED
|
@@ -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 @@
|
|
|
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)
|