transcodely 0.2.0__py3-none-any.whl
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.
- buf/__init__.py +0 -0
- buf/validate/__init__.py +0 -0
- buf/validate/validate_pb2.py +469 -0
- buf/validate/validate_pb2.pyi +651 -0
- transcodely/__init__.py +175 -0
- transcodely/_codec/__init__.py +0 -0
- transcodely/_codec/json_codec.py +193 -0
- transcodely/_transport/__init__.py +0 -0
- transcodely/_transport/error_mapping.py +102 -0
- transcodely/_transport/headers.py +31 -0
- transcodely/_transport/transport.py +312 -0
- transcodely/client.py +160 -0
- transcodely/errors.py +115 -0
- transcodely/pagination.py +53 -0
- transcodely/py.typed +0 -0
- transcodely/resources/__init__.py +0 -0
- transcodely/resources/_helpers.py +29 -0
- transcodely/resources/api_keys.py +48 -0
- transcodely/resources/apps.py +59 -0
- transcodely/resources/events.py +118 -0
- transcodely/resources/health.py +19 -0
- transcodely/resources/jobs.py +120 -0
- transcodely/resources/memberships.py +54 -0
- transcodely/resources/organizations.py +73 -0
- transcodely/resources/origins.py +55 -0
- transcodely/resources/presets.py +63 -0
- transcodely/resources/users.py +46 -0
- transcodely/resources/videos.py +114 -0
- transcodely/resources/webhook_endpoints.py +242 -0
- transcodely/streaming.py +47 -0
- transcodely/types.py +570 -0
- transcodely/v1/api_key_pb2.py +69 -0
- transcodely/v1/api_key_pb2.pyi +97 -0
- transcodely/v1/app_pb2.py +107 -0
- transcodely/v1/app_pb2.pyi +219 -0
- transcodely/v1/codec_av1_pb2.py +55 -0
- transcodely/v1/codec_av1_pb2.pyi +34 -0
- transcodely/v1/codec_h264_pb2.py +71 -0
- transcodely/v1/codec_h264_pb2.pyi +44 -0
- transcodely/v1/codec_h265_pb2.py +69 -0
- transcodely/v1/codec_h265_pb2.pyi +42 -0
- transcodely/v1/codec_vp9_pb2.py +57 -0
- transcodely/v1/codec_vp9_pb2.pyi +30 -0
- transcodely/v1/common_pb2.py +67 -0
- transcodely/v1/common_pb2.pyi +171 -0
- transcodely/v1/content_aware_pb2.py +56 -0
- transcodely/v1/content_aware_pb2.pyi +51 -0
- transcodely/v1/drm_pb2.py +65 -0
- transcodely/v1/drm_pb2.pyi +68 -0
- transcodely/v1/hdr_pb2.py +57 -0
- transcodely/v1/hdr_pb2.pyi +64 -0
- transcodely/v1/health_pb2.py +44 -0
- transcodely/v1/health_pb2.pyi +44 -0
- transcodely/v1/job_pb2.py +201 -0
- transcodely/v1/job_pb2.pyi +546 -0
- transcodely/v1/media_pb2.py +43 -0
- transcodely/v1/media_pb2.pyi +100 -0
- transcodely/v1/membership_pb2.py +71 -0
- transcodely/v1/membership_pb2.pyi +122 -0
- transcodely/v1/organization_pb2.py +81 -0
- transcodely/v1/organization_pb2.pyi +121 -0
- transcodely/v1/origin_pb2.py +157 -0
- transcodely/v1/origin_pb2.pyi +317 -0
- transcodely/v1/preset_pb2.py +192 -0
- transcodely/v1/preset_pb2.pyi +290 -0
- transcodely/v1/streaming_pb2.py +59 -0
- transcodely/v1/streaming_pb2.pyi +56 -0
- transcodely/v1/subtitles_pb2.py +69 -0
- transcodely/v1/subtitles_pb2.pyi +75 -0
- transcodely/v1/thumbnails_pb2.py +63 -0
- transcodely/v1/thumbnails_pb2.pyi +74 -0
- transcodely/v1/user_pb2.py +72 -0
- transcodely/v1/user_pb2.pyi +137 -0
- transcodely/v1/video_pb2.py +166 -0
- transcodely/v1/video_pb2.pyi +367 -0
- transcodely/v1/webhook_pb2.py +151 -0
- transcodely/v1/webhook_pb2.pyi +336 -0
- transcodely/version.py +11 -0
- transcodely/webhooks/__init__.py +44 -0
- transcodely/webhooks/construct_event.py +111 -0
- transcodely/webhooks/signature.py +110 -0
- transcodely/webhooks/types.py +115 -0
- transcodely-0.2.0.dist-info/METADATA +349 -0
- transcodely-0.2.0.dist-info/RECORD +86 -0
- transcodely-0.2.0.dist-info/WHEEL +4 -0
- transcodely-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Hand-rolled Connect-RPC HTTP transport over httpx.
|
|
2
|
+
|
|
3
|
+
Why not a Connect-Python client library? The ecosystem is sparse and none
|
|
4
|
+
expose hooks for swapping the JSON codec, but Transcodely's wire format
|
|
5
|
+
requires snake_case + simplified-enum JSON. Owning the transport gives us
|
|
6
|
+
end-to-end control with ~250 LoC.
|
|
7
|
+
|
|
8
|
+
Wire format reference:
|
|
9
|
+
- Unary: POST {base_url}/{service.full_name}/{method.name}
|
|
10
|
+
Content-Type: application/json
|
|
11
|
+
body: JSON-encoded request
|
|
12
|
+
- Server-streaming: same URL, Content-Type: application/connect+json
|
|
13
|
+
body: 5-byte envelope header + JSON payload, repeated
|
|
14
|
+
stream ends with an end-stream frame (flag bit 0x02)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import random
|
|
20
|
+
import struct
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any, Callable, Generator, Iterable, Optional, TypeVar
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
from google.protobuf.message import Message
|
|
27
|
+
|
|
28
|
+
from .._codec import json_codec
|
|
29
|
+
from ..errors import RateLimitError, TranscodelyError
|
|
30
|
+
from ..version import API_VERSION, DEFAULT_BASE_URL
|
|
31
|
+
from .error_mapping import connection_error, map_http_error
|
|
32
|
+
from .headers import client_user_agent_json, new_idempotency_key, user_agent
|
|
33
|
+
|
|
34
|
+
TReq = TypeVar("TReq", bound=Message)
|
|
35
|
+
TRes = TypeVar("TRes", bound=Message)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class CallOptions:
|
|
40
|
+
"""Per-call overrides for timeout, retries, idempotency, headers."""
|
|
41
|
+
|
|
42
|
+
timeout: Optional[float] = None
|
|
43
|
+
max_retries: Optional[int] = None
|
|
44
|
+
idempotency_key: Optional[str] = None
|
|
45
|
+
api_version: Optional[str] = None
|
|
46
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class LogEvent:
|
|
51
|
+
service: str
|
|
52
|
+
method: str
|
|
53
|
+
duration_ms: int
|
|
54
|
+
attempt: int
|
|
55
|
+
status: Optional[int] = None
|
|
56
|
+
request_id: Optional[str] = None
|
|
57
|
+
error: Optional[TranscodelyError] = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_WRITE_PREFIXES = ("Create", "Update", "Delete", "Cancel", "Confirm", "Revoke", "Archive", "Enable")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _is_write(method_name: str) -> bool:
|
|
64
|
+
return any(method_name.startswith(p) for p in _WRITE_PREFIXES)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Transport:
|
|
68
|
+
"""Synchronous transport. ``client.jobs.*`` calls go through this."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
api_key: str,
|
|
73
|
+
*,
|
|
74
|
+
base_url: Optional[str] = None,
|
|
75
|
+
timeout: float = 30.0,
|
|
76
|
+
max_retries: int = 3,
|
|
77
|
+
api_version: Optional[str] = None,
|
|
78
|
+
default_headers: Optional[dict[str, str]] = None,
|
|
79
|
+
http_client: Optional[httpx.Client] = None,
|
|
80
|
+
logger: Optional[Callable[[LogEvent], None]] = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
if not api_key:
|
|
83
|
+
raise ValueError("Transcodely: api_key is required")
|
|
84
|
+
self.api_key = api_key
|
|
85
|
+
self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
86
|
+
self.timeout = timeout
|
|
87
|
+
self.max_retries = max_retries
|
|
88
|
+
self.api_version = api_version or API_VERSION
|
|
89
|
+
self.default_headers = default_headers or {}
|
|
90
|
+
self._client = http_client or httpx.Client(timeout=timeout)
|
|
91
|
+
self._owns_client = http_client is None
|
|
92
|
+
self._logger = logger
|
|
93
|
+
#: ID of the most recent successful or failed request, Stripe-style.
|
|
94
|
+
self.last_request_id: Optional[str] = None
|
|
95
|
+
|
|
96
|
+
def close(self) -> None:
|
|
97
|
+
if self._owns_client:
|
|
98
|
+
self._client.close()
|
|
99
|
+
|
|
100
|
+
def __enter__(self) -> "Transport":
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def __exit__(self, *args: Any) -> None:
|
|
104
|
+
self.close()
|
|
105
|
+
|
|
106
|
+
# ---------- unary ----------
|
|
107
|
+
|
|
108
|
+
def unary(
|
|
109
|
+
self,
|
|
110
|
+
service_name: str,
|
|
111
|
+
method_name: str,
|
|
112
|
+
request: Message,
|
|
113
|
+
response: TRes,
|
|
114
|
+
opts: Optional[CallOptions] = None,
|
|
115
|
+
) -> TRes:
|
|
116
|
+
opts = opts or CallOptions()
|
|
117
|
+
url = f"{self.base_url}/{service_name}/{method_name}"
|
|
118
|
+
body = json_codec.serialize(request)
|
|
119
|
+
idempotency_key = (
|
|
120
|
+
opts.idempotency_key or new_idempotency_key() if _is_write(method_name) else None
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def attempt_call(attempt: int) -> TRes:
|
|
124
|
+
headers = self._build_headers(opts, "application/json", idempotency_key)
|
|
125
|
+
started = time.monotonic()
|
|
126
|
+
try:
|
|
127
|
+
resp = self._client.post(
|
|
128
|
+
url,
|
|
129
|
+
content=body,
|
|
130
|
+
headers=headers,
|
|
131
|
+
timeout=opts.timeout if opts.timeout is not None else self.timeout,
|
|
132
|
+
)
|
|
133
|
+
except httpx.HTTPError as exc:
|
|
134
|
+
raise connection_error(exc) from exc
|
|
135
|
+
self.last_request_id = resp.headers.get("x-request-id") or self.last_request_id
|
|
136
|
+
self._emit(
|
|
137
|
+
LogEvent(
|
|
138
|
+
service=service_name,
|
|
139
|
+
method=method_name,
|
|
140
|
+
attempt=attempt,
|
|
141
|
+
duration_ms=int((time.monotonic() - started) * 1000),
|
|
142
|
+
status=resp.status_code,
|
|
143
|
+
request_id=self.last_request_id,
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
if not (200 <= resp.status_code < 300):
|
|
147
|
+
raise map_http_error(
|
|
148
|
+
status=resp.status_code,
|
|
149
|
+
body=_try_json(resp.content),
|
|
150
|
+
headers=resp.headers,
|
|
151
|
+
)
|
|
152
|
+
return json_codec.deserialize(resp.content, response)
|
|
153
|
+
|
|
154
|
+
return self._with_retry(service_name, method_name, opts, attempt_call)
|
|
155
|
+
|
|
156
|
+
# ---------- server-streaming ----------
|
|
157
|
+
|
|
158
|
+
def stream(
|
|
159
|
+
self,
|
|
160
|
+
service_name: str,
|
|
161
|
+
method_name: str,
|
|
162
|
+
request: Message,
|
|
163
|
+
response_factory: Callable[[], TRes],
|
|
164
|
+
opts: Optional[CallOptions] = None,
|
|
165
|
+
) -> Generator[TRes, None, None]:
|
|
166
|
+
opts = opts or CallOptions()
|
|
167
|
+
url = f"{self.base_url}/{service_name}/{method_name}"
|
|
168
|
+
headers = self._build_headers(opts, "application/connect+json")
|
|
169
|
+
body = _encode_envelope(0, json_codec.serialize(request))
|
|
170
|
+
try:
|
|
171
|
+
with self._client.stream(
|
|
172
|
+
"POST",
|
|
173
|
+
url,
|
|
174
|
+
content=body,
|
|
175
|
+
headers=headers,
|
|
176
|
+
timeout=opts.timeout if opts.timeout is not None else None,
|
|
177
|
+
) as resp:
|
|
178
|
+
self.last_request_id = resp.headers.get("x-request-id") or self.last_request_id
|
|
179
|
+
if not (200 <= resp.status_code < 300):
|
|
180
|
+
text = resp.read()
|
|
181
|
+
raise map_http_error(
|
|
182
|
+
status=resp.status_code,
|
|
183
|
+
body=_try_json(text),
|
|
184
|
+
headers=resp.headers,
|
|
185
|
+
)
|
|
186
|
+
for frame in _read_envelopes(resp.iter_bytes()):
|
|
187
|
+
flags, payload = frame
|
|
188
|
+
if flags & 0x02:
|
|
189
|
+
# End-stream frame: payload may carry a JSON `error` block.
|
|
190
|
+
if not payload:
|
|
191
|
+
return
|
|
192
|
+
end = _try_json(payload)
|
|
193
|
+
if isinstance(end, dict) and end.get("error"):
|
|
194
|
+
raise map_http_error(
|
|
195
|
+
status=400,
|
|
196
|
+
body=end["error"],
|
|
197
|
+
headers=resp.headers,
|
|
198
|
+
)
|
|
199
|
+
return
|
|
200
|
+
yield json_codec.deserialize(payload, response_factory())
|
|
201
|
+
except httpx.HTTPError as exc:
|
|
202
|
+
raise connection_error(exc) from exc
|
|
203
|
+
|
|
204
|
+
# ---------- helpers ----------
|
|
205
|
+
|
|
206
|
+
def _build_headers(
|
|
207
|
+
self,
|
|
208
|
+
opts: CallOptions,
|
|
209
|
+
content_type: str,
|
|
210
|
+
idempotency_key: Optional[str] = None,
|
|
211
|
+
) -> dict[str, str]:
|
|
212
|
+
headers = {
|
|
213
|
+
"content-type": content_type,
|
|
214
|
+
"connect-protocol-version": "1",
|
|
215
|
+
"authorization": f"Bearer {self.api_key}",
|
|
216
|
+
"user-agent": user_agent(),
|
|
217
|
+
"x-transcodely-client-user-agent": client_user_agent_json(),
|
|
218
|
+
"transcodely-version": opts.api_version or self.api_version,
|
|
219
|
+
"accept": content_type,
|
|
220
|
+
}
|
|
221
|
+
if idempotency_key:
|
|
222
|
+
headers["idempotency-key"] = idempotency_key
|
|
223
|
+
headers.update(self.default_headers)
|
|
224
|
+
headers.update(opts.headers)
|
|
225
|
+
return headers
|
|
226
|
+
|
|
227
|
+
def _emit(self, event: LogEvent) -> None:
|
|
228
|
+
if self._logger is not None:
|
|
229
|
+
try:
|
|
230
|
+
self._logger(event)
|
|
231
|
+
except Exception:
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
def _with_retry(
|
|
235
|
+
self,
|
|
236
|
+
service: str,
|
|
237
|
+
method: str,
|
|
238
|
+
opts: CallOptions,
|
|
239
|
+
fn: Callable[[int], TRes],
|
|
240
|
+
) -> TRes:
|
|
241
|
+
max_retries = opts.max_retries if opts.max_retries is not None else self.max_retries
|
|
242
|
+
last_error: Optional[BaseException] = None
|
|
243
|
+
for attempt in range(1, max_retries + 2):
|
|
244
|
+
try:
|
|
245
|
+
return fn(attempt)
|
|
246
|
+
except Exception as exc:
|
|
247
|
+
last_error = exc
|
|
248
|
+
if attempt > max_retries or not _is_retryable(exc):
|
|
249
|
+
raise
|
|
250
|
+
_sleep_backoff(attempt, exc)
|
|
251
|
+
if last_error is not None:
|
|
252
|
+
raise last_error
|
|
253
|
+
raise RuntimeError("unreachable")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _is_retryable(exc: BaseException) -> bool:
|
|
257
|
+
if not isinstance(exc, TranscodelyError):
|
|
258
|
+
return False
|
|
259
|
+
if isinstance(exc, RateLimitError):
|
|
260
|
+
return True
|
|
261
|
+
if exc.__class__.__name__ == "APIConnectionError":
|
|
262
|
+
return True
|
|
263
|
+
if exc.http_status is not None and exc.http_status >= 500:
|
|
264
|
+
return True
|
|
265
|
+
return False
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _sleep_backoff(attempt: int, exc: BaseException) -> None:
|
|
269
|
+
if isinstance(exc, RateLimitError) and exc.retry_after_ms is not None:
|
|
270
|
+
time.sleep(max(0.0, exc.retry_after_ms / 1000.0))
|
|
271
|
+
return
|
|
272
|
+
base = 0.25
|
|
273
|
+
cap = 2.0
|
|
274
|
+
expo = min(cap, base * 2 ** (attempt - 1))
|
|
275
|
+
jitter = expo * (0.5 + random.random() * 0.5)
|
|
276
|
+
time.sleep(jitter)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ---------- Connect envelope helpers ----------
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _encode_envelope(flags: int, payload: bytes) -> bytes:
|
|
283
|
+
return struct.pack(">BI", flags & 0xFF, len(payload)) + payload
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _read_envelopes(stream: Iterable[bytes]) -> Generator[tuple[int, bytes], None, None]:
|
|
287
|
+
buf = bytearray()
|
|
288
|
+
for chunk in stream:
|
|
289
|
+
if chunk:
|
|
290
|
+
buf.extend(chunk)
|
|
291
|
+
while len(buf) >= 5:
|
|
292
|
+
flags = buf[0]
|
|
293
|
+
length = struct.unpack(">I", bytes(buf[1:5]))[0]
|
|
294
|
+
if len(buf) < 5 + length:
|
|
295
|
+
break
|
|
296
|
+
payload = bytes(buf[5 : 5 + length])
|
|
297
|
+
del buf[: 5 + length]
|
|
298
|
+
yield flags, payload
|
|
299
|
+
if buf:
|
|
300
|
+
# Trailing bytes that didn't make a complete frame
|
|
301
|
+
raise connection_error(RuntimeError("stream ended with partial frame"))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _try_json(data: bytes) -> Any:
|
|
305
|
+
if not data:
|
|
306
|
+
return None
|
|
307
|
+
try:
|
|
308
|
+
import json
|
|
309
|
+
|
|
310
|
+
return json.loads(data.decode("utf-8"))
|
|
311
|
+
except Exception:
|
|
312
|
+
return data.decode("utf-8", errors="replace")
|
transcodely/client.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Transcodely root client (synchronous)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ._transport.transport import LogEvent, Transport
|
|
10
|
+
from .resources.api_keys import ApiKeys
|
|
11
|
+
from .resources.apps import Apps
|
|
12
|
+
from .resources.events import Events
|
|
13
|
+
from .resources.health import Health
|
|
14
|
+
from .resources.jobs import Jobs
|
|
15
|
+
from .resources.memberships import Memberships
|
|
16
|
+
from .resources.organizations import Organizations
|
|
17
|
+
from .resources.origins import Origins
|
|
18
|
+
from .resources.presets import Presets
|
|
19
|
+
from .resources.users import Users
|
|
20
|
+
from .resources.videos import Videos
|
|
21
|
+
from .resources.webhook_endpoints import WebhookEndpoints
|
|
22
|
+
from .version import API_VERSION, SDK_VERSION
|
|
23
|
+
from .webhooks import Webhooks
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Transcodely:
|
|
27
|
+
"""Synchronous Transcodely API client.
|
|
28
|
+
|
|
29
|
+
Use as a context manager (``with Transcodely(api_key=...) as client:``) to
|
|
30
|
+
make sure the underlying HTTP client is closed cleanly.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
API_VERSION: str = API_VERSION
|
|
34
|
+
SDK_VERSION: str = SDK_VERSION
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
api_key: str,
|
|
39
|
+
*,
|
|
40
|
+
base_url: Optional[str] = None,
|
|
41
|
+
timeout: float = 30.0,
|
|
42
|
+
max_retries: int = 3,
|
|
43
|
+
api_version: Optional[str] = None,
|
|
44
|
+
default_headers: Optional[dict[str, str]] = None,
|
|
45
|
+
http_client: Optional[httpx.Client] = None,
|
|
46
|
+
logger: Optional[Callable[[LogEvent], None]] = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
self._transport = Transport(
|
|
49
|
+
api_key,
|
|
50
|
+
base_url=base_url,
|
|
51
|
+
timeout=timeout,
|
|
52
|
+
max_retries=max_retries,
|
|
53
|
+
api_version=api_version,
|
|
54
|
+
default_headers=default_headers,
|
|
55
|
+
http_client=http_client,
|
|
56
|
+
logger=logger,
|
|
57
|
+
)
|
|
58
|
+
self._jobs: Optional[Jobs] = None
|
|
59
|
+
self._videos: Optional[Videos] = None
|
|
60
|
+
self._presets: Optional[Presets] = None
|
|
61
|
+
self._origins: Optional[Origins] = None
|
|
62
|
+
self._apps: Optional[Apps] = None
|
|
63
|
+
self._api_keys: Optional[ApiKeys] = None
|
|
64
|
+
self._organizations: Optional[Organizations] = None
|
|
65
|
+
self._memberships: Optional[Memberships] = None
|
|
66
|
+
self._users: Optional[Users] = None
|
|
67
|
+
self._health: Optional[Health] = None
|
|
68
|
+
self._webhook_endpoints: Optional[WebhookEndpoints] = None
|
|
69
|
+
self._events: Optional[Events] = None
|
|
70
|
+
|
|
71
|
+
def close(self) -> None:
|
|
72
|
+
self._transport.close()
|
|
73
|
+
|
|
74
|
+
def __enter__(self) -> "Transcodely":
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
def __exit__(self, *args: Any) -> None:
|
|
78
|
+
self.close()
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def last_request_id(self) -> Optional[str]:
|
|
82
|
+
"""Stripe-style: ID of the most recent successful or failed request."""
|
|
83
|
+
return self._transport.last_request_id
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def jobs(self) -> Jobs:
|
|
87
|
+
if self._jobs is None:
|
|
88
|
+
self._jobs = Jobs(self._transport)
|
|
89
|
+
return self._jobs
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def videos(self) -> Videos:
|
|
93
|
+
if self._videos is None:
|
|
94
|
+
self._videos = Videos(self._transport)
|
|
95
|
+
return self._videos
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def presets(self) -> Presets:
|
|
99
|
+
if self._presets is None:
|
|
100
|
+
self._presets = Presets(self._transport)
|
|
101
|
+
return self._presets
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def origins(self) -> Origins:
|
|
105
|
+
if self._origins is None:
|
|
106
|
+
self._origins = Origins(self._transport)
|
|
107
|
+
return self._origins
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def apps(self) -> Apps:
|
|
111
|
+
if self._apps is None:
|
|
112
|
+
self._apps = Apps(self._transport)
|
|
113
|
+
return self._apps
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def api_keys(self) -> ApiKeys:
|
|
117
|
+
if self._api_keys is None:
|
|
118
|
+
self._api_keys = ApiKeys(self._transport)
|
|
119
|
+
return self._api_keys
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def organizations(self) -> Organizations:
|
|
123
|
+
if self._organizations is None:
|
|
124
|
+
self._organizations = Organizations(self._transport)
|
|
125
|
+
return self._organizations
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def memberships(self) -> Memberships:
|
|
129
|
+
if self._memberships is None:
|
|
130
|
+
self._memberships = Memberships(self._transport)
|
|
131
|
+
return self._memberships
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def users(self) -> Users:
|
|
135
|
+
if self._users is None:
|
|
136
|
+
self._users = Users(self._transport)
|
|
137
|
+
return self._users
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def health(self) -> Health:
|
|
141
|
+
if self._health is None:
|
|
142
|
+
self._health = Health(self._transport)
|
|
143
|
+
return self._health
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def webhook_endpoints(self) -> WebhookEndpoints:
|
|
147
|
+
if self._webhook_endpoints is None:
|
|
148
|
+
self._webhook_endpoints = WebhookEndpoints(self._transport)
|
|
149
|
+
return self._webhook_endpoints
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def events(self) -> Events:
|
|
153
|
+
if self._events is None:
|
|
154
|
+
self._events = Events(self._transport)
|
|
155
|
+
return self._events
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def webhooks(self) -> type[Webhooks]:
|
|
159
|
+
"""Stripe-style facade: ``client.webhooks.construct_event(body, sig, secret)``."""
|
|
160
|
+
return Webhooks
|
transcodely/errors.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Typed exception hierarchy for the Transcodely SDK.
|
|
2
|
+
|
|
3
|
+
Every exception raised by SDK methods inherits from :class:`TranscodelyError`,
|
|
4
|
+
so ``except TranscodelyError`` plus ``isinstance`` checks is enough for typed
|
|
5
|
+
error handling. Mirrors the TypeScript and Go SDK error classes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class FieldViolation:
|
|
16
|
+
"""A field-level validation violation from an :class:`InvalidRequestError`."""
|
|
17
|
+
|
|
18
|
+
field: str
|
|
19
|
+
description: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TranscodelyError(Exception):
|
|
23
|
+
"""Base class for every Transcodely SDK exception."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
message: str,
|
|
28
|
+
*,
|
|
29
|
+
code: Optional[str] = None,
|
|
30
|
+
type: Optional[str] = None,
|
|
31
|
+
errors: Optional[list[FieldViolation]] = None,
|
|
32
|
+
http_status: Optional[int] = None,
|
|
33
|
+
request_id: Optional[str] = None,
|
|
34
|
+
raw: Any = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.code: Optional[str] = code
|
|
38
|
+
self.type: Optional[str] = type
|
|
39
|
+
self.errors: list[FieldViolation] = errors or []
|
|
40
|
+
self.http_status: Optional[int] = http_status
|
|
41
|
+
self.request_id: Optional[str] = request_id
|
|
42
|
+
self.raw: Any = raw
|
|
43
|
+
|
|
44
|
+
def __repr__(self) -> str:
|
|
45
|
+
return (
|
|
46
|
+
f"{type(self).__name__}(message={super().__str__()!r}, code={self.code!r}, "
|
|
47
|
+
f"http_status={self.http_status!r}, request_id={self.request_id!r})"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class APIConnectionError(TranscodelyError):
|
|
52
|
+
"""Network failure: DNS, TLS, connection refused, no HTTP response."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class APIError(TranscodelyError):
|
|
56
|
+
"""5xx server-side error."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AuthenticationError(TranscodelyError):
|
|
60
|
+
"""401 — invalid, missing, revoked, or expired API key."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class PermissionError(TranscodelyError):
|
|
64
|
+
"""403 — authenticated but lacking permission."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class NotFoundError(TranscodelyError):
|
|
68
|
+
"""404 — entity not found."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ConflictError(TranscodelyError):
|
|
72
|
+
"""409 — already exists, idempotency conflict, slug taken."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class RateLimitError(TranscodelyError):
|
|
76
|
+
"""429 — rate-limited. ``retry_after_ms`` reflects the ``Retry-After`` header."""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
message: str,
|
|
81
|
+
*,
|
|
82
|
+
retry_after_ms: Optional[int] = None,
|
|
83
|
+
**kwargs: Any,
|
|
84
|
+
) -> None:
|
|
85
|
+
super().__init__(message, **kwargs)
|
|
86
|
+
self.retry_after_ms: Optional[int] = retry_after_ms
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class InvalidRequestError(TranscodelyError):
|
|
90
|
+
"""400 / 422 — request body or parameters were invalid."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class PreconditionError(TranscodelyError):
|
|
94
|
+
"""412 — preconditions not met (e.g. job not cancelable in current state)."""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class WebhookError(TranscodelyError):
|
|
98
|
+
"""Base class for webhook signature-verification failures.
|
|
99
|
+
|
|
100
|
+
Raised by :func:`transcodely.construct_event` /
|
|
101
|
+
:func:`transcodely.verify_signature`. Catch this to handle any verification
|
|
102
|
+
failure, or one of its subclasses to distinguish the cause.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class WebhookSignatureError(WebhookError):
|
|
107
|
+
"""The signature header was malformed or no signature matched the body."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class WebhookTimestampError(WebhookError):
|
|
111
|
+
"""The signature timestamp fell outside the allowed tolerance window."""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class WebhookPayloadError(WebhookError):
|
|
115
|
+
"""The body was not valid JSON or did not match the event-envelope shape."""
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Cursor pagination helper.
|
|
2
|
+
|
|
3
|
+
Every ``list`` method returns a :class:`Page` you can iterate manually or via
|
|
4
|
+
``.auto_paging_iter()`` to walk every result lazily.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Callable, Generator, Generic, Iterator, Optional, TypeVar
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class PageContents(Generic[T]):
|
|
17
|
+
items: list[T]
|
|
18
|
+
next_cursor: Optional[str]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Page(Generic[T]):
|
|
22
|
+
"""A single page of results plus an opt-in iterator across all pages."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, fetcher: Callable[[Optional[str]], PageContents[T]]) -> None:
|
|
25
|
+
self._fetcher = fetcher
|
|
26
|
+
self._first: Optional[PageContents[T]] = None
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def items(self) -> list[T]:
|
|
30
|
+
return self._first_page().items
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def next_cursor(self) -> Optional[str]:
|
|
34
|
+
return self._first_page().next_cursor
|
|
35
|
+
|
|
36
|
+
def __iter__(self) -> Iterator[T]:
|
|
37
|
+
return iter(self._first_page().items)
|
|
38
|
+
|
|
39
|
+
def auto_paging_iter(self) -> Generator[T, None, None]:
|
|
40
|
+
"""Yield every item, transparently fetching subsequent pages."""
|
|
41
|
+
cursor: Optional[str] = None
|
|
42
|
+
while True:
|
|
43
|
+
page = self._fetcher(cursor)
|
|
44
|
+
for item in page.items:
|
|
45
|
+
yield item
|
|
46
|
+
if not page.next_cursor:
|
|
47
|
+
return
|
|
48
|
+
cursor = page.next_cursor
|
|
49
|
+
|
|
50
|
+
def _first_page(self) -> PageContents[T]:
|
|
51
|
+
if self._first is None:
|
|
52
|
+
self._first = self._fetcher(None)
|
|
53
|
+
return self._first
|
transcodely/py.typed
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Internal helpers shared by resource modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Mapping, Optional
|
|
6
|
+
|
|
7
|
+
from google.protobuf import json_format
|
|
8
|
+
from google.protobuf.message import Message
|
|
9
|
+
|
|
10
|
+
from .._codec.json_codec import transform_enums_in_dict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def fill_from_dict(msg: Message, data: Mapping[str, Any]) -> Message:
|
|
14
|
+
"""Populate ``msg`` from a plain dict, accepting simplified-enum strings.
|
|
15
|
+
|
|
16
|
+
The codec's enum-expand pass lets callers pass ``{"codec": "h264"}`` while
|
|
17
|
+
the proto stores ``VIDEO_CODEC_H264`` (an int after parse).
|
|
18
|
+
"""
|
|
19
|
+
payload = dict(data)
|
|
20
|
+
transform_enums_in_dict(payload, msg.DESCRIPTOR, mode="expand")
|
|
21
|
+
json_format.ParseDict(payload, msg, ignore_unknown_fields=True)
|
|
22
|
+
return msg
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def assign_pagination(req_pagination: Any, *, limit: Optional[int], cursor: Optional[str]) -> None:
|
|
26
|
+
if limit is not None:
|
|
27
|
+
req_pagination.limit = limit
|
|
28
|
+
if cursor is not None:
|
|
29
|
+
req_pagination.cursor = cursor
|