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,111 @@
|
|
|
1
|
+
"""Verify a signed webhook delivery and decode it into a typed :class:`Event`.
|
|
2
|
+
|
|
3
|
+
Python port of the TypeScript SDK's ``webhooks/construct-event.ts``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
from typing import Any, Callable, Mapping, Optional, Union
|
|
11
|
+
|
|
12
|
+
from ..errors import WebhookPayloadError
|
|
13
|
+
from .signature import DEFAULT_TOLERANCE_SECONDS, verify_signature
|
|
14
|
+
from .types import Event, EventRequest, decoder_for_type
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def construct_event(
|
|
18
|
+
raw_body: Union[str, bytes],
|
|
19
|
+
sig_header: str,
|
|
20
|
+
secret: Union[str, list[str]],
|
|
21
|
+
*,
|
|
22
|
+
tolerance: int = DEFAULT_TOLERANCE_SECONDS,
|
|
23
|
+
now: Optional[Callable[[], int]] = None,
|
|
24
|
+
) -> Event:
|
|
25
|
+
"""Verify a signed webhook delivery and return the typed :class:`Event`.
|
|
26
|
+
|
|
27
|
+
``secret`` may be a single signing secret or a list of secrets — pass
|
|
28
|
+
``[previous, current]`` during a secret rotation's overlap window.
|
|
29
|
+
|
|
30
|
+
:raises WebhookSignatureError: the signature is missing/malformed or doesn't match.
|
|
31
|
+
:raises WebhookTimestampError: the timestamp is outside the tolerance window.
|
|
32
|
+
:raises WebhookPayloadError: the body isn't valid JSON or the envelope shape is wrong.
|
|
33
|
+
"""
|
|
34
|
+
body_bytes = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
|
|
35
|
+
|
|
36
|
+
verify_signature(body_bytes, sig_header, secret, tolerance=tolerance, now=now)
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
parsed = json.loads(body_bytes.decode("utf-8"))
|
|
40
|
+
except (ValueError, UnicodeDecodeError) as cause:
|
|
41
|
+
raise WebhookPayloadError("Webhook body is not valid JSON") from cause
|
|
42
|
+
|
|
43
|
+
return _build_event(parsed)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _require_string(obj: Mapping[str, Any], key: str) -> str:
|
|
47
|
+
v = obj.get(key)
|
|
48
|
+
if not isinstance(v, str) or len(v) == 0:
|
|
49
|
+
raise WebhookPayloadError(f"Webhook envelope is missing required string field `{key}`")
|
|
50
|
+
return v
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _require_number(obj: Mapping[str, Any], key: str) -> int:
|
|
54
|
+
v = obj.get(key)
|
|
55
|
+
# bool is a subclass of int in Python — reject it explicitly so a JSON
|
|
56
|
+
# `true` doesn't sail through as the number 1 (matches the TS typeof check).
|
|
57
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v):
|
|
58
|
+
raise WebhookPayloadError(f"Webhook envelope field `{key}` must be a number")
|
|
59
|
+
return int(v)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _build_event(parsed: Any) -> Event:
|
|
63
|
+
if not isinstance(parsed, dict):
|
|
64
|
+
raise WebhookPayloadError("Webhook body must be a JSON object")
|
|
65
|
+
|
|
66
|
+
id_ = _require_string(parsed, "id")
|
|
67
|
+
if parsed.get("object") != "event":
|
|
68
|
+
raise WebhookPayloadError('Webhook envelope `object` must be "event"')
|
|
69
|
+
api_version = _require_string(parsed, "api_version")
|
|
70
|
+
created = _require_string(parsed, "created")
|
|
71
|
+
type_ = _require_string(parsed, "type")
|
|
72
|
+
pending_webhooks = _require_number(parsed, "pending_webhooks")
|
|
73
|
+
|
|
74
|
+
data_raw = parsed.get("data")
|
|
75
|
+
if not isinstance(data_raw, dict):
|
|
76
|
+
raise WebhookPayloadError("Webhook envelope field `data` must be a JSON object")
|
|
77
|
+
|
|
78
|
+
request_raw = parsed.get("request")
|
|
79
|
+
if not isinstance(request_raw, dict):
|
|
80
|
+
raise WebhookPayloadError("Webhook envelope field `request` must be a JSON object")
|
|
81
|
+
# `request.id` is null for events emitted outside a request scope — every
|
|
82
|
+
# worker-callback event (job.succeeded/failed/canceled/progress, output.*,
|
|
83
|
+
# video.uploaded) and every SendTestWebhook delivery. Accept null or a
|
|
84
|
+
# non-empty string; reject "" and non-strings (mirrors the JS SDK).
|
|
85
|
+
request_id = request_raw.get("id")
|
|
86
|
+
if request_id is not None and (not isinstance(request_id, str) or request_id == ""):
|
|
87
|
+
raise WebhookPayloadError(
|
|
88
|
+
"Webhook envelope field `request.id` must be a non-empty string or null"
|
|
89
|
+
)
|
|
90
|
+
idempotency_key = request_raw.get("idempotency_key")
|
|
91
|
+
if idempotency_key is not None and not isinstance(idempotency_key, str):
|
|
92
|
+
raise WebhookPayloadError(
|
|
93
|
+
"Webhook envelope field `request.idempotency_key` must be a string or null"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
decode = decoder_for_type(type_)
|
|
97
|
+
data: Any = decode(data_raw) if decode else data_raw
|
|
98
|
+
|
|
99
|
+
return Event(
|
|
100
|
+
id=id_,
|
|
101
|
+
object="event",
|
|
102
|
+
api_version=api_version,
|
|
103
|
+
created=created,
|
|
104
|
+
type=type_,
|
|
105
|
+
data=data,
|
|
106
|
+
pending_webhooks=pending_webhooks,
|
|
107
|
+
request=EventRequest(id=request_id, idempotency_key=idempotency_key),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
__all__ = ["construct_event"]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Webhook signature verification.
|
|
2
|
+
|
|
3
|
+
Python port of the TypeScript SDK's ``webhooks/signature.ts``. The signing
|
|
4
|
+
math is identical across all Transcodely SDKs: HMAC-SHA-256 over
|
|
5
|
+
``"<unix-timestamp>.<raw-body-bytes>"``, hex-encoded, carried in the
|
|
6
|
+
``Transcodely-Signature`` header as ``t=<ts>,v1=<hex>``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
import time
|
|
14
|
+
from typing import Callable, Optional, Union
|
|
15
|
+
|
|
16
|
+
from ..errors import WebhookSignatureError, WebhookTimestampError
|
|
17
|
+
|
|
18
|
+
#: Default signature tolerance window in seconds (Stripe parity).
|
|
19
|
+
DEFAULT_TOLERANCE_SECONDS = 300
|
|
20
|
+
|
|
21
|
+
#: HTTP header name carrying the signature (lower-cased for case-insensitive lookup).
|
|
22
|
+
SIGNATURE_HEADER = "transcodely-signature"
|
|
23
|
+
|
|
24
|
+
#: HTTP header name carrying the event ID (``evt_*``). Useful as an idempotency
|
|
25
|
+
#: key for handlers that dedupe redeliveries — the same event ID rides every
|
|
26
|
+
#: retry of a given event.
|
|
27
|
+
EVENT_ID_HEADER = "webhook-id"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _parse_header(header: str) -> tuple[int, list[str]]:
|
|
31
|
+
"""Parse the ``Transcodely-Signature`` header.
|
|
32
|
+
|
|
33
|
+
The header is a comma-separated list of ``key=value`` pairs. ``t`` is the
|
|
34
|
+
unix timestamp (seconds); each ``v1`` entry is a hex-encoded
|
|
35
|
+
HMAC-SHA-256. Unknown keys are ignored so future scheme versions don't
|
|
36
|
+
break older receivers.
|
|
37
|
+
"""
|
|
38
|
+
timestamp: Optional[int] = None
|
|
39
|
+
signatures: list[str] = []
|
|
40
|
+
for part in header.split(","):
|
|
41
|
+
eq = part.find("=")
|
|
42
|
+
if eq < 0:
|
|
43
|
+
continue
|
|
44
|
+
key = part[:eq].strip()
|
|
45
|
+
value = part[eq + 1 :].strip()
|
|
46
|
+
if key == "t":
|
|
47
|
+
try:
|
|
48
|
+
timestamp = int(value)
|
|
49
|
+
except ValueError:
|
|
50
|
+
# Divergence from the JS SDK (which parses with Number() and an
|
|
51
|
+
# isInteger check, so it would accept "123.0"/"1e3"): int() is
|
|
52
|
+
# stricter and rejects those. Harmless — the API always emits a
|
|
53
|
+
# plain integer timestamp.
|
|
54
|
+
continue
|
|
55
|
+
elif key == "v1":
|
|
56
|
+
signatures.append(value)
|
|
57
|
+
if timestamp is None:
|
|
58
|
+
raise WebhookSignatureError("Signature header is missing the timestamp (t=) component")
|
|
59
|
+
if not signatures:
|
|
60
|
+
raise WebhookSignatureError("Signature header has no v1 entries")
|
|
61
|
+
return timestamp, signatures
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def verify_signature(
|
|
65
|
+
raw_body: Union[str, bytes],
|
|
66
|
+
sig_header: str,
|
|
67
|
+
secret: Union[str, list[str]],
|
|
68
|
+
*,
|
|
69
|
+
tolerance: int = DEFAULT_TOLERANCE_SECONDS,
|
|
70
|
+
now: Optional[Callable[[], int]] = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Verify a webhook signature. Raises on failure; returns ``None`` on success.
|
|
73
|
+
|
|
74
|
+
Multiple secrets are accepted so a customer rotating their signing secret
|
|
75
|
+
can pass ``[previous, current]`` during the overlap window without dropping
|
|
76
|
+
legitimate deliveries signed under either key.
|
|
77
|
+
|
|
78
|
+
:raises WebhookSignatureError: the header is malformed or no signature matched.
|
|
79
|
+
:raises WebhookTimestampError: the timestamp is outside ``tolerance`` seconds.
|
|
80
|
+
"""
|
|
81
|
+
timestamp, signatures = _parse_header(sig_header)
|
|
82
|
+
|
|
83
|
+
now_fn: Callable[[], int] = now or (lambda: int(time.time()))
|
|
84
|
+
if abs(now_fn() - timestamp) > tolerance:
|
|
85
|
+
raise WebhookTimestampError(
|
|
86
|
+
f"Signature timestamp is outside the tolerance window ({tolerance}s)"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
secrets = [secret] if isinstance(secret, str) else secret
|
|
90
|
+
body_bytes = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
|
|
91
|
+
payload = f"{timestamp}.".encode("utf-8") + body_bytes
|
|
92
|
+
|
|
93
|
+
for s in secrets:
|
|
94
|
+
expected = hmac.new(s.encode("utf-8"), payload, hashlib.sha256).hexdigest()
|
|
95
|
+
for candidate in signatures:
|
|
96
|
+
# `expected` is lowercase hex (hexdigest); normalize the candidate so
|
|
97
|
+
# an upper/mixed-case hex digest still matches — the JS SDK compares
|
|
98
|
+
# decoded bytes, which is inherently case-insensitive.
|
|
99
|
+
if hmac.compare_digest(expected, candidate.lower()):
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
raise WebhookSignatureError("No signatures matched the expected value")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
__all__ = [
|
|
106
|
+
"DEFAULT_TOLERANCE_SECONDS",
|
|
107
|
+
"EVENT_ID_HEADER",
|
|
108
|
+
"SIGNATURE_HEADER",
|
|
109
|
+
"verify_signature",
|
|
110
|
+
]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Public types for the webhook surface.
|
|
2
|
+
|
|
3
|
+
Python port of the TypeScript SDK's ``webhooks/types.ts`` — the customer-facing
|
|
4
|
+
:class:`Event` shape plus the resource-decoder table shared between the verify
|
|
5
|
+
helper (:func:`transcodely.construct_event`) and the ``client.events`` resource
|
|
6
|
+
bridge. Both paths converge on the same :class:`Event`, so a ``job.succeeded``
|
|
7
|
+
handler behaves identically whether the event arrived over an HTTP delivery or
|
|
8
|
+
was pulled from :meth:`Events.retrieve`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Callable, Literal, Mapping, Optional
|
|
15
|
+
|
|
16
|
+
from google.protobuf.message import Message
|
|
17
|
+
|
|
18
|
+
from ..resources._helpers import fill_from_dict
|
|
19
|
+
from ..v1 import app_pb2, job_pb2, video_pb2
|
|
20
|
+
|
|
21
|
+
#: The 13 canonical event types the webhook system can emit, mirroring the API's
|
|
22
|
+
#: ``domain.WebhookEventTypes()``. The ``"*"`` wildcard is intentionally absent —
|
|
23
|
+
#: it is a subscription value only, never the ``type`` of an emitted event. Used
|
|
24
|
+
#: for annotations and documentation; the ``Event.type`` field is a plain ``str``
|
|
25
|
+
#: so an older SDK still parses a ``type`` the API has since added (forward
|
|
26
|
+
#: compatibility).
|
|
27
|
+
EventType = Literal[
|
|
28
|
+
"job.created",
|
|
29
|
+
"job.succeeded",
|
|
30
|
+
"job.failed",
|
|
31
|
+
"job.canceled",
|
|
32
|
+
"job.progress",
|
|
33
|
+
"output.created",
|
|
34
|
+
"output.ready",
|
|
35
|
+
"output.failed",
|
|
36
|
+
"output.progress",
|
|
37
|
+
"video.uploaded",
|
|
38
|
+
"video.deleted",
|
|
39
|
+
"app.created",
|
|
40
|
+
"app.updated",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class EventRequest:
|
|
46
|
+
"""The originating API request that triggered an event, if known."""
|
|
47
|
+
|
|
48
|
+
#: Request ID (``req_*``), or ``None`` for events emitted outside a request
|
|
49
|
+
#: scope (worker-callback events like ``job.succeeded`` and every test send).
|
|
50
|
+
id: Optional[str]
|
|
51
|
+
#: Idempotency key the original request carried, or ``None``.
|
|
52
|
+
idempotency_key: Optional[str]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, kw_only=True)
|
|
56
|
+
class Event:
|
|
57
|
+
"""A webhook event, unified across the HTTP delivery envelope and the API.
|
|
58
|
+
|
|
59
|
+
``data`` carries the decoded resource snapshot — a :class:`~transcodely.Job`,
|
|
60
|
+
:class:`~transcodely.JobOutput`, :class:`~transcodely.Video`, or
|
|
61
|
+
:class:`~transcodely.App` for a known ``type``, or the raw ``dict`` for an
|
|
62
|
+
unrecognized one (forward compatibility).
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
#: Event ID (``evt_*``).
|
|
66
|
+
id: str
|
|
67
|
+
#: Resource discriminator. Always ``"event"``.
|
|
68
|
+
object: str = "event"
|
|
69
|
+
#: API version at emit time (e.g. ``"2026-05-23"``). Frozen for the event's life.
|
|
70
|
+
api_version: str
|
|
71
|
+
#: RFC 3339 UTC timestamp of when the event was created.
|
|
72
|
+
created: str
|
|
73
|
+
#: Event type, e.g. ``"job.succeeded"``. See :data:`EventType`.
|
|
74
|
+
type: str
|
|
75
|
+
#: The decoded resource snapshot (a proto message, or ``dict`` for unknown types).
|
|
76
|
+
data: Any
|
|
77
|
+
#: Delivery attempts still pending across all subscribed endpoints.
|
|
78
|
+
pending_webhooks: int
|
|
79
|
+
#: The originating API request.
|
|
80
|
+
request: EventRequest
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
#: Alias matching the TypeScript SDK's ``WebhookEvent`` name.
|
|
84
|
+
WebhookEvent = Event
|
|
85
|
+
|
|
86
|
+
Decoder = Callable[[Mapping[str, Any]], Message]
|
|
87
|
+
|
|
88
|
+
#: Event-type prefix → resource decoder. Shared by ``construct_event`` (verify
|
|
89
|
+
#: path; ``data`` arrives pre-parsed) and the ``events`` resource bridge (API
|
|
90
|
+
#: path; ``data`` arrives as a JSON string on the proto ``Event``). ``output.``
|
|
91
|
+
#: is listed first to make the intent explicit; the prefixes are disjoint.
|
|
92
|
+
RESOURCE_DECODERS: list[tuple[str, Decoder]] = [
|
|
93
|
+
("output.", lambda d: fill_from_dict(job_pb2.JobOutput(), d)),
|
|
94
|
+
("job.", lambda d: fill_from_dict(job_pb2.Job(), d)),
|
|
95
|
+
("video.", lambda d: fill_from_dict(video_pb2.Video(), d)),
|
|
96
|
+
("app.", lambda d: fill_from_dict(app_pb2.App(), d)),
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def decoder_for_type(type: str) -> Optional[Decoder]:
|
|
101
|
+
"""Return a resource decoder for ``type``, or ``None`` if the type is unknown."""
|
|
102
|
+
for prefix, decode in RESOURCE_DECODERS:
|
|
103
|
+
if type.startswith(prefix):
|
|
104
|
+
return decode
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
__all__ = [
|
|
109
|
+
"Event",
|
|
110
|
+
"EventRequest",
|
|
111
|
+
"EventType",
|
|
112
|
+
"RESOURCE_DECODERS",
|
|
113
|
+
"WebhookEvent",
|
|
114
|
+
"decoder_for_type",
|
|
115
|
+
]
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: transcodely
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Official Python SDK for the Transcodely video transcoding API
|
|
5
|
+
Project-URL: Homepage, https://transcodely.com
|
|
6
|
+
Project-URL: Documentation, https://transcodely.com/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/transcodely/transcodely-python
|
|
8
|
+
Project-URL: Issues, https://github.com/transcodely/transcodely-python/issues
|
|
9
|
+
Author: Transcodely
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: connect-rpc,dash,encoding,hls,streaming,transcodely,transcoding,video
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Multimedia :: Video :: Conversion
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: httpx>=0.27
|
|
27
|
+
Requires-Dist: protobuf>=5.0
|
|
28
|
+
Requires-Dist: typing-extensions>=4.10
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
34
|
+
Provides-Extra: fast
|
|
35
|
+
Requires-Dist: orjson>=3.10; extra == 'fast'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# transcodely
|
|
39
|
+
|
|
40
|
+
Official Python SDK for the [Transcodely](https://transcodely.com) video transcoding API.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install transcodely
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import os
|
|
50
|
+
from transcodely import Transcodely
|
|
51
|
+
|
|
52
|
+
with Transcodely(api_key=os.environ["TRANSCODELY_API_KEY"]) as client:
|
|
53
|
+
job = client.jobs.create(
|
|
54
|
+
input_url="https://example.com/source.mp4",
|
|
55
|
+
outputs=[{
|
|
56
|
+
"type": "hls",
|
|
57
|
+
"video": [
|
|
58
|
+
{"codec": "h264", "resolution": "1080p"},
|
|
59
|
+
{"codec": "h264", "resolution": "720p"},
|
|
60
|
+
],
|
|
61
|
+
}],
|
|
62
|
+
)
|
|
63
|
+
print(job.id) # "job_a1b2c3d4e5f6"
|
|
64
|
+
|
|
65
|
+
for event in client.jobs.watch(job.id):
|
|
66
|
+
print(event.job.status, event.job.progress)
|
|
67
|
+
if event.job.status == 4: # JOB_STATUS_COMPLETED
|
|
68
|
+
break
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The simplified-string form (`"hls"`, `"h264"`, `"1080p"`) is what the API actually emits over the wire — the SDK round-trips it transparently to and from the proto enum integers.
|
|
72
|
+
|
|
73
|
+
## Authentication
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
client = Transcodely(api_key=os.environ["TRANSCODELY_API_KEY"])
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
API keys are opaque `ak_`-prefixed secrets — pass the full value shown once at creation time.
|
|
80
|
+
|
|
81
|
+
## Resources
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
client.jobs # create / get / list / cancel / confirm / watch
|
|
85
|
+
client.videos # upload helpers, multipart, get / list / update / delete / watch
|
|
86
|
+
client.presets # create / get / get_by_slug / list / update / duplicate / archive
|
|
87
|
+
client.origins # create / get / list / update / validate / archive
|
|
88
|
+
client.apps # create / get / list / update / archive / enable_hosting
|
|
89
|
+
client.api_keys # create / get / list / revoke
|
|
90
|
+
client.organizations # create / get / list / update / check_slug
|
|
91
|
+
client.memberships # list / get / update_role / remove
|
|
92
|
+
client.users # get_me / get / list / update_me
|
|
93
|
+
client.health # check
|
|
94
|
+
client.webhook_endpoints # create / retrieve / update / delete / list / rotate_secret / send_test / list_deliveries / get_health
|
|
95
|
+
client.events # retrieve / list / resend
|
|
96
|
+
client.webhooks # construct_event / verify_signature (signature-verification helpers)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Origins
|
|
100
|
+
|
|
101
|
+
An origin tells Transcodely where to read source media from and where to write outputs. Every origin belongs to a single provider; pass exactly one provider-config field (`s3`, `gcs`, `http`, or `r2`) on create.
|
|
102
|
+
|
|
103
|
+
### Create an S3 origin
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
origin = client.origins.create(
|
|
107
|
+
name="Production S3",
|
|
108
|
+
permissions=["read", "write"],
|
|
109
|
+
s3={
|
|
110
|
+
"bucket": "my-bucket",
|
|
111
|
+
"region": "us-east-1",
|
|
112
|
+
"credentials": {
|
|
113
|
+
"access_key_id": os.environ["S3_ACCESS_KEY"],
|
|
114
|
+
"secret_access_key": os.environ["S3_SECRET_KEY"],
|
|
115
|
+
},
|
|
116
|
+
# "endpoint": "https://s3.custom.example.com", # for MinIO, Wasabi, etc.
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Create a GCS origin
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
origin = client.origins.create(
|
|
125
|
+
name="Production GCS",
|
|
126
|
+
permissions=["read", "write"],
|
|
127
|
+
gcs={
|
|
128
|
+
"bucket": "my-gcs-bucket",
|
|
129
|
+
"credentials": {
|
|
130
|
+
"service_account_json": os.environ["GCS_SERVICE_ACCOUNT_JSON"],
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Create an HTTP origin
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
origin = client.origins.create(
|
|
140
|
+
name="Public CDN",
|
|
141
|
+
permissions=["read"], # HTTP origins are read-only
|
|
142
|
+
http={
|
|
143
|
+
"base_url": "https://media.example.com",
|
|
144
|
+
"credentials": {
|
|
145
|
+
"headers": {"Authorization": f"Bearer {os.environ['MEDIA_TOKEN']}"},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Create an R2 origin
|
|
152
|
+
|
|
153
|
+
R2 supports two forms. With `account_id` (32-char hex) the endpoint is derived for you, optionally with a data-residency jurisdiction:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
origin = client.origins.create(
|
|
157
|
+
name="Production R2",
|
|
158
|
+
permissions=["read", "write"],
|
|
159
|
+
r2={
|
|
160
|
+
"bucket": "media",
|
|
161
|
+
"account_id": os.environ["R2_ACCOUNT_ID"],
|
|
162
|
+
"jurisdiction": "default", # or "eu", "fedramp"
|
|
163
|
+
"credentials": {
|
|
164
|
+
"access_key_id": os.environ["R2_ACCESS_KEY"],
|
|
165
|
+
"secret_access_key": os.environ["R2_SECRET_KEY"],
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Or, with an explicit `endpoint` (custom domain bound to a bucket, or a jurisdiction not yet enumerated):
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
r2 = {
|
|
175
|
+
"bucket": "media",
|
|
176
|
+
"endpoint": "https://media.example.com",
|
|
177
|
+
"credentials": {
|
|
178
|
+
"access_key_id": os.environ["R2_ACCESS_KEY"],
|
|
179
|
+
"secret_access_key": os.environ["R2_SECRET_KEY"],
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Provide either `account_id` or `endpoint`, never both. `jurisdiction` only applies when `account_id` is set.
|
|
185
|
+
|
|
186
|
+
## Errors
|
|
187
|
+
|
|
188
|
+
All exceptions inherit from `TranscodelyError`:
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
from transcodely import (
|
|
192
|
+
Transcodely,
|
|
193
|
+
TranscodelyError,
|
|
194
|
+
InvalidRequestError,
|
|
195
|
+
NotFoundError,
|
|
196
|
+
RateLimitError,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
client.jobs.create(input_url=..., outputs=[...])
|
|
201
|
+
except InvalidRequestError as err:
|
|
202
|
+
for v in err.errors:
|
|
203
|
+
print(f"{v.field}: {v.description}")
|
|
204
|
+
except RateLimitError as err:
|
|
205
|
+
time.sleep((err.retry_after_ms or 1000) / 1000)
|
|
206
|
+
except TranscodelyError as err:
|
|
207
|
+
print(f"[{err.request_id}] {err.code}: {err}")
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
| Class | Status | When |
|
|
211
|
+
|---|---|---|
|
|
212
|
+
| `APIConnectionError` | — | Network / DNS / TLS failure |
|
|
213
|
+
| `APIError` | 5xx | Server-side error |
|
|
214
|
+
| `AuthenticationError` | 401 | Bad / missing / revoked key |
|
|
215
|
+
| `PermissionError` | 403 | Authenticated but forbidden |
|
|
216
|
+
| `NotFoundError` | 404 | Resource doesn't exist |
|
|
217
|
+
| `ConflictError` | 409 | Idempotency conflict, slug taken |
|
|
218
|
+
| `RateLimitError` | 429 | Carries `retry_after_ms` |
|
|
219
|
+
| `InvalidRequestError` | 400 | Carries `errors: list[FieldViolation]` |
|
|
220
|
+
| `PreconditionError` | 412 | Wrong state (e.g. job not cancelable) |
|
|
221
|
+
|
|
222
|
+
Every error carries `request_id`, `code`, `http_status`, and `raw` for debugging.
|
|
223
|
+
|
|
224
|
+
Webhook verification raises `WebhookError` (or a subclass) — see [Webhooks](#webhooks).
|
|
225
|
+
|
|
226
|
+
## Pagination
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
# One page
|
|
230
|
+
page = client.jobs.list(limit=50)
|
|
231
|
+
print(page.items, page.next_cursor)
|
|
232
|
+
|
|
233
|
+
# All items, automatically across pages
|
|
234
|
+
for job in client.jobs.list(limit=50).auto_paging_iter():
|
|
235
|
+
print(job.id)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Idempotency
|
|
239
|
+
|
|
240
|
+
`jobs.create` accepts `idempotency_key`. If you don't pass one, the SDK generates a UUID v4 so retries are safe by default. For cross-process safety, pass your own:
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
client.jobs.create(
|
|
244
|
+
input_url="...",
|
|
245
|
+
outputs=[...],
|
|
246
|
+
idempotency_key="create-job-for-asset-12345",
|
|
247
|
+
)
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
For all other write methods, the SDK ships an `Idempotency-Key` HTTP header automatically.
|
|
251
|
+
|
|
252
|
+
## Streaming watch
|
|
253
|
+
|
|
254
|
+
```python
|
|
255
|
+
for event in client.jobs.watch(job.id):
|
|
256
|
+
print(event.event, event.job.status, event.job.progress)
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
The SDK auto-reconnects on transient network failures — Watch is read-only and re-emits a SNAPSHOT on every reconnect. HEARTBEAT events are filtered by default.
|
|
260
|
+
|
|
261
|
+
## Webhooks
|
|
262
|
+
|
|
263
|
+
Verify a signed delivery and get back a typed `Event`. Pass the **raw** request body (bytes or str — do not re-serialize) and the `Transcodely-Signature` header:
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
from transcodely import construct_event, WebhookSignatureError
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
event = construct_event(
|
|
270
|
+
request.body, # raw bytes/str, exactly as received
|
|
271
|
+
request.headers["transcodely-signature"],
|
|
272
|
+
"whsec_...", # your endpoint's signing secret
|
|
273
|
+
)
|
|
274
|
+
except WebhookSignatureError:
|
|
275
|
+
return Response(status_code=400)
|
|
276
|
+
|
|
277
|
+
# `event.data` is the decoded resource (a Job, JobOutput, Video, or App).
|
|
278
|
+
if event.type == "job.succeeded":
|
|
279
|
+
print("job done:", event.data.id)
|
|
280
|
+
elif event.type == "video.uploaded":
|
|
281
|
+
print("new video:", event.data.id)
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`construct_event` also accepts a **list** of secrets so deliveries keep verifying during a secret rotation's overlap window:
|
|
285
|
+
|
|
286
|
+
```python
|
|
287
|
+
event = construct_event(body, sig_header, ["whsec_previous", "whsec_current"])
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Tuning and errors:
|
|
291
|
+
|
|
292
|
+
- `tolerance` (default `300` seconds) bounds clock skew / replay; widen or narrow it per call.
|
|
293
|
+
- `WebhookSignatureError` — header malformed or no signature matched.
|
|
294
|
+
- `WebhookTimestampError` — timestamp outside the tolerance window.
|
|
295
|
+
- `WebhookPayloadError` — body isn't valid JSON or doesn't match the event envelope.
|
|
296
|
+
|
|
297
|
+
All three inherit from `WebhookError`. `client.webhooks.construct_event(...)` is an alias for the module-level function.
|
|
298
|
+
|
|
299
|
+
Manage endpoints and replay events via the API:
|
|
300
|
+
|
|
301
|
+
```python
|
|
302
|
+
endpoint = client.webhook_endpoints.create(
|
|
303
|
+
app_id="app_123",
|
|
304
|
+
url="https://example.com/hooks/transcodely",
|
|
305
|
+
enabled_events=["job.succeeded", "job.failed"], # or ["*"] for all
|
|
306
|
+
)
|
|
307
|
+
print(endpoint.secret) # shown only on create + rotate_secret — store it now
|
|
308
|
+
|
|
309
|
+
# The same typed Event, fetched from the API instead of an HTTP delivery:
|
|
310
|
+
event = client.events.retrieve("evt_123")
|
|
311
|
+
for event in client.events.list(app_id="app_123").auto_paging_iter():
|
|
312
|
+
print(event.type, event.id)
|
|
313
|
+
|
|
314
|
+
client.events.resend("evt_123") # re-queue delivery to all subscribed endpoints
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
The 13 event types are `job.created`, `job.succeeded`, `job.failed`, `job.canceled`, `job.progress`, `output.created`, `output.ready`, `output.failed`, `output.progress`, `video.uploaded`, `video.deleted`, `app.created`, and `app.updated`. Subscribe to `"*"` to receive all of them (including ones added later). An unrecognized future type still verifies; its `event.data` is left as a plain `dict`.
|
|
318
|
+
|
|
319
|
+
## Configuration
|
|
320
|
+
|
|
321
|
+
```python
|
|
322
|
+
Transcodely(
|
|
323
|
+
api_key, # required
|
|
324
|
+
base_url=None, # default: https://api.transcodely.com
|
|
325
|
+
timeout=30.0, # seconds
|
|
326
|
+
max_retries=3,
|
|
327
|
+
api_version=None, # override the pinned API version
|
|
328
|
+
default_headers=None, # dict, sent on every request
|
|
329
|
+
http_client=None, # custom httpx.Client
|
|
330
|
+
logger=None, # callable(LogEvent)
|
|
331
|
+
)
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
## Request IDs
|
|
335
|
+
|
|
336
|
+
```python
|
|
337
|
+
client.jobs.get("job_x")
|
|
338
|
+
print(client.last_request_id) # "req_*"
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Errors also carry the request ID via `err.request_id` for log correlation.
|
|
342
|
+
|
|
343
|
+
## Versioning
|
|
344
|
+
|
|
345
|
+
The SDK follows semver, starting at `0.1.0`. Breaking changes are allowed on minor bumps until `1.0.0`. Each release pins a specific calendar-versioned API (`Transcodely.API_VERSION`) and sends `Transcodely-Version` on every request.
|
|
346
|
+
|
|
347
|
+
## License
|
|
348
|
+
|
|
349
|
+
[MIT](LICENSE).
|