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.
Files changed (86) hide show
  1. buf/__init__.py +0 -0
  2. buf/validate/__init__.py +0 -0
  3. buf/validate/validate_pb2.py +469 -0
  4. buf/validate/validate_pb2.pyi +651 -0
  5. transcodely/__init__.py +175 -0
  6. transcodely/_codec/__init__.py +0 -0
  7. transcodely/_codec/json_codec.py +193 -0
  8. transcodely/_transport/__init__.py +0 -0
  9. transcodely/_transport/error_mapping.py +102 -0
  10. transcodely/_transport/headers.py +31 -0
  11. transcodely/_transport/transport.py +312 -0
  12. transcodely/client.py +160 -0
  13. transcodely/errors.py +115 -0
  14. transcodely/pagination.py +53 -0
  15. transcodely/py.typed +0 -0
  16. transcodely/resources/__init__.py +0 -0
  17. transcodely/resources/_helpers.py +29 -0
  18. transcodely/resources/api_keys.py +48 -0
  19. transcodely/resources/apps.py +59 -0
  20. transcodely/resources/events.py +118 -0
  21. transcodely/resources/health.py +19 -0
  22. transcodely/resources/jobs.py +120 -0
  23. transcodely/resources/memberships.py +54 -0
  24. transcodely/resources/organizations.py +73 -0
  25. transcodely/resources/origins.py +55 -0
  26. transcodely/resources/presets.py +63 -0
  27. transcodely/resources/users.py +46 -0
  28. transcodely/resources/videos.py +114 -0
  29. transcodely/resources/webhook_endpoints.py +242 -0
  30. transcodely/streaming.py +47 -0
  31. transcodely/types.py +570 -0
  32. transcodely/v1/api_key_pb2.py +69 -0
  33. transcodely/v1/api_key_pb2.pyi +97 -0
  34. transcodely/v1/app_pb2.py +107 -0
  35. transcodely/v1/app_pb2.pyi +219 -0
  36. transcodely/v1/codec_av1_pb2.py +55 -0
  37. transcodely/v1/codec_av1_pb2.pyi +34 -0
  38. transcodely/v1/codec_h264_pb2.py +71 -0
  39. transcodely/v1/codec_h264_pb2.pyi +44 -0
  40. transcodely/v1/codec_h265_pb2.py +69 -0
  41. transcodely/v1/codec_h265_pb2.pyi +42 -0
  42. transcodely/v1/codec_vp9_pb2.py +57 -0
  43. transcodely/v1/codec_vp9_pb2.pyi +30 -0
  44. transcodely/v1/common_pb2.py +67 -0
  45. transcodely/v1/common_pb2.pyi +171 -0
  46. transcodely/v1/content_aware_pb2.py +56 -0
  47. transcodely/v1/content_aware_pb2.pyi +51 -0
  48. transcodely/v1/drm_pb2.py +65 -0
  49. transcodely/v1/drm_pb2.pyi +68 -0
  50. transcodely/v1/hdr_pb2.py +57 -0
  51. transcodely/v1/hdr_pb2.pyi +64 -0
  52. transcodely/v1/health_pb2.py +44 -0
  53. transcodely/v1/health_pb2.pyi +44 -0
  54. transcodely/v1/job_pb2.py +201 -0
  55. transcodely/v1/job_pb2.pyi +546 -0
  56. transcodely/v1/media_pb2.py +43 -0
  57. transcodely/v1/media_pb2.pyi +100 -0
  58. transcodely/v1/membership_pb2.py +71 -0
  59. transcodely/v1/membership_pb2.pyi +122 -0
  60. transcodely/v1/organization_pb2.py +81 -0
  61. transcodely/v1/organization_pb2.pyi +121 -0
  62. transcodely/v1/origin_pb2.py +157 -0
  63. transcodely/v1/origin_pb2.pyi +317 -0
  64. transcodely/v1/preset_pb2.py +192 -0
  65. transcodely/v1/preset_pb2.pyi +290 -0
  66. transcodely/v1/streaming_pb2.py +59 -0
  67. transcodely/v1/streaming_pb2.pyi +56 -0
  68. transcodely/v1/subtitles_pb2.py +69 -0
  69. transcodely/v1/subtitles_pb2.pyi +75 -0
  70. transcodely/v1/thumbnails_pb2.py +63 -0
  71. transcodely/v1/thumbnails_pb2.pyi +74 -0
  72. transcodely/v1/user_pb2.py +72 -0
  73. transcodely/v1/user_pb2.pyi +137 -0
  74. transcodely/v1/video_pb2.py +166 -0
  75. transcodely/v1/video_pb2.pyi +367 -0
  76. transcodely/v1/webhook_pb2.py +151 -0
  77. transcodely/v1/webhook_pb2.pyi +336 -0
  78. transcodely/version.py +11 -0
  79. transcodely/webhooks/__init__.py +44 -0
  80. transcodely/webhooks/construct_event.py +111 -0
  81. transcodely/webhooks/signature.py +110 -0
  82. transcodely/webhooks/types.py +115 -0
  83. transcodely-0.2.0.dist-info/METADATA +349 -0
  84. transcodely-0.2.0.dist-info/RECORD +86 -0
  85. transcodely-0.2.0.dist-info/WHEEL +4 -0
  86. 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