stackshift 0.1.0__tar.gz → 1.0.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.
Files changed (30) hide show
  1. {stackshift-0.1.0 → stackshift-1.0.0}/PKG-INFO +1 -1
  2. stackshift-1.0.0/README.md +207 -0
  3. {stackshift-0.1.0 → stackshift-1.0.0}/pyproject.toml +1 -1
  4. stackshift-1.0.0/stackshift/__init__.py +40 -0
  5. stackshift-1.0.0/stackshift/assets.py +390 -0
  6. stackshift-1.0.0/stackshift/client.py +79 -0
  7. stackshift-1.0.0/stackshift/mail.py +411 -0
  8. stackshift-1.0.0/stackshift/mail_advanced.py +121 -0
  9. stackshift-1.0.0/stackshift/mail_campaigns.py +61 -0
  10. stackshift-1.0.0/stackshift/mail_events_webhooks.py +161 -0
  11. stackshift-1.0.0/stackshift/mail_exports.py +30 -0
  12. stackshift-1.0.0/stackshift/mail_reputation.py +38 -0
  13. stackshift-1.0.0/stackshift/mail_streams.py +18 -0
  14. {stackshift-0.1.0 → stackshift-1.0.0}/stackshift.egg-info/PKG-INFO +1 -1
  15. stackshift-1.0.0/stackshift.egg-info/SOURCES.txt +23 -0
  16. stackshift-1.0.0/tests/test_mail.py +405 -0
  17. stackshift-1.0.0/tests/test_mail_advanced.py +105 -0
  18. stackshift-1.0.0/tests/test_mail_diagnostics.py +22 -0
  19. stackshift-1.0.0/tests/test_mail_events_webhooks.py +174 -0
  20. stackshift-1.0.0/tests/test_mail_exports.py +30 -0
  21. stackshift-0.1.0/README.md +0 -42
  22. stackshift-0.1.0/stackshift/__init__.py +0 -5
  23. stackshift-0.1.0/stackshift/assets.py +0 -89
  24. stackshift-0.1.0/stackshift/client.py +0 -44
  25. stackshift-0.1.0/stackshift.egg-info/SOURCES.txt +0 -11
  26. {stackshift-0.1.0 → stackshift-1.0.0}/setup.cfg +0 -0
  27. {stackshift-0.1.0 → stackshift-1.0.0}/stackshift/projects.py +0 -0
  28. {stackshift-0.1.0 → stackshift-1.0.0}/stackshift.egg-info/dependency_links.txt +0 -0
  29. {stackshift-0.1.0 → stackshift-1.0.0}/stackshift.egg-info/requires.txt +0 -0
  30. {stackshift-0.1.0 → stackshift-1.0.0}/stackshift.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackshift
3
- Version: 0.1.0
3
+ Version: 1.0.0
4
4
  Summary: Official Python SDK for StackShift.
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: requests>=2.31
@@ -0,0 +1,207 @@
1
+ # StackShift Python SDK
2
+
3
+ Official Python SDK for StackShift.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install stackshift
9
+ ```
10
+
11
+ ## Send email
12
+
13
+ ```python
14
+ from stackshift import StackShift
15
+
16
+ stackshift = StackShift()
17
+
18
+ message = stackshift.mail.send(
19
+ from_="StackShift <noreply@mail.stackshift.cloud>",
20
+ to="ada@example.com",
21
+ subject="Welcome",
22
+ text="Welcome to StackShift.",
23
+ idempotency_key="welcome:user_123",
24
+ )
25
+
26
+ print(message["id"], message["status"], message["idempotencyStatus"])
27
+ ```
28
+
29
+ Inspect message status:
30
+
31
+ ```python
32
+ messages = stackshift.mail.messages.list(status="mta_accepted", limit=20)
33
+ detail = stackshift.mail.messages.get(messages["data"][0]["id"])
34
+ attempts = stackshift.mail.messages.attempts(detail["id"])
35
+ logs = stackshift.mail.messages.logs(detail["id"])
36
+ ```
37
+
38
+ `mta_accepted` means the message was accepted by StackShift's outbound MTA. It does not mean recipient-MX acceptance, inbox placement, opens, clicks, or spam placement. Recipient records later transition independently to `delayed`, `delivered`, `bounced`, `failed`, `suppressed`, or `complained`.
39
+
40
+ ## Events and webhooks
41
+
42
+ ```python
43
+ events = stackshift.mail.events.list(
44
+ type="mail.message.bounced",
45
+ message_id="msg_123",
46
+ limit=20,
47
+ )
48
+
49
+ event = stackshift.mail.events.get("evt_123")
50
+ timeline = stackshift.mail.messages.timeline("msg_123")
51
+
52
+ webhook = stackshift.mail.webhooks.create(
53
+ url="https://example.com/stackshift-mail",
54
+ event_types=["mail.message.bounced", "mail.otp.verified"],
55
+ )
56
+ print(webhook["id"], webhook["secret"]) # Secret is only returned on create/rotate.
57
+
58
+ deliveries = stackshift.mail.webhooks.deliveries(webhook["id"], status="failed")
59
+ retried = stackshift.mail.webhooks.retry_delivery(deliveries["data"][0]["id"])
60
+ ```
61
+
62
+ Verify a webhook signature before processing the payload:
63
+
64
+ ```python
65
+ valid = stackshift.mail.webhooks.verify_signature(
66
+ raw_body=request.get_data(),
67
+ signature_header=request.headers.get("StackShift-Signature"),
68
+ timestamp_header=request.headers.get("StackShift-Timestamp"),
69
+ secret=os.environ["STACKSHIFT_WEBHOOK_SECRET"],
70
+ )
71
+ ```
72
+
73
+ Webhook handlers should be idempotent. StackShift retries non-2xx responses. Delivery events do not include full email bodies or OTP codes by default.
74
+
75
+ ## Send a template
76
+
77
+ Templates are rendered by StackShift servers. The Python SDK only calls the REST API.
78
+
79
+ ```python
80
+ stackshift.mail.templates.create(
81
+ name="Welcome Email",
82
+ slug="welcome-email",
83
+ subject="Welcome, {{name}}",
84
+ text="Welcome, {{name}}",
85
+ )
86
+
87
+ preview = stackshift.mail.templates.preview(
88
+ "welcome-email",
89
+ data={"name": "Ada"},
90
+ )
91
+
92
+ message = stackshift.mail.send_template(
93
+ template="welcome-email",
94
+ to="ada@example.com",
95
+ from_="Acme <noreply@acme.com>",
96
+ data={"name": "Ada"},
97
+ idempotency_key="welcome:user_123",
98
+ )
99
+ ```
100
+
101
+ Missing variables fail before a message is queued. Template sends use the same sender-domain, suppression, idempotency, Durable Jobs, and Postfix handoff pipeline as `mail.send`.
102
+
103
+ ## Bounces and suppressions
104
+
105
+ ```python
106
+ suppressions = stackshift.mail.suppressions.list()
107
+
108
+ manual = stackshift.mail.suppressions.create(
109
+ email="bad@example.com",
110
+ reason="manual",
111
+ )
112
+
113
+ stackshift.mail.suppressions.delete(manual["id"])
114
+
115
+ bounces = stackshift.mail.bounces.list(type="hard")
116
+ message_bounces = stackshift.mail.messages.bounces("msg_123")
117
+ ```
118
+
119
+ Hard bounces are automatically suppressed by the backend. Suppressions are workspace scoped and checked before a message is queued.
120
+
121
+ ## Verify a sending domain
122
+
123
+ ```python
124
+ domain = stackshift.mail.domains.create("acme.com")
125
+ print(domain["records"])
126
+
127
+ stackshift.mail.domains.verify(domain["id"])
128
+
129
+ message = stackshift.mail.send(
130
+ from_="Acme <noreply@acme.com>",
131
+ to="user@example.com",
132
+ subject="Welcome",
133
+ html="<h1>Welcome</h1>",
134
+ text="Welcome",
135
+ )
136
+ ```
137
+
138
+ Add the returned SPF, DKIM, and return-path DNS records before verification. DMARC is recommended unless your environment sets `MAIL_DMARC_REQUIRED=true`. DNS propagation can take time.
139
+
140
+ ## Upload an asset
141
+
142
+ ```python
143
+ from stackshift import StackShift
144
+
145
+ stackshift = StackShift()
146
+
147
+ asset = stackshift.assets.upload(
148
+ "avatar.png",
149
+ folder="avatars",
150
+ visibility="public",
151
+ metadata={"user_id": "user_123"},
152
+ )
153
+
154
+ print(asset["url"])
155
+ ```
156
+
157
+ You do not pass a deployed StackShift project ID. The API key identifies the StackShift account, and StackShift resolves the default asset space internally.
158
+
159
+ ## Private asset URL
160
+
161
+ ```python
162
+ signed = stackshift.assets.signed_url(
163
+ asset["id"],
164
+ expires_in="10m",
165
+ max_downloads=1,
166
+ )
167
+
168
+ print(signed["url"])
169
+ ```
170
+
171
+ ## Image transformations
172
+
173
+ Use built-in presets or create your own named transformations.
174
+
175
+ ```python
176
+ from stackshift import StackShift, asset_transform_options, get_asset_transform_preset
177
+
178
+ stackshift = StackShift()
179
+ hero = get_asset_transform_preset("hero")
180
+ hero_options = asset_transform_options(hero)
181
+
182
+ stackshift.assets.create_transformation(hero["name"], **hero_options)
183
+
184
+ named = stackshift.assets.named_url("asset_123", hero["name"])
185
+ signed = stackshift.assets.signed_transform_url("asset_123", **hero_options, expiresIn="10m")
186
+
187
+ stackshift.assets.delete_transformation("old-preset")
188
+ print(named, signed["url"])
189
+ ```
190
+
191
+ ## Direct browser uploads
192
+
193
+ Create the upload session on your Python backend:
194
+
195
+ ```python
196
+ upload = stackshift.assets.signed_upload_url(
197
+ bucket="avatars",
198
+ key="users/user_123.png",
199
+ visibility="public",
200
+ expiresIn="10m",
201
+ maxBytes=5_000_000,
202
+ )
203
+ ```
204
+
205
+ Then upload the browser `File` to `upload["url"]` from your frontend. Do not expose your StackShift API key to browser code.
206
+
207
+ The SDK only talks to the StackShift REST API. Storage placement, replication, disks, and repair are StackShift internals.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "stackshift"
7
- version = "0.1.0"
7
+ version = "1.0.0"
8
8
  description = "Official Python SDK for StackShift."
9
9
  requires-python = ">=3.9"
10
10
  dependencies = ["requests>=2.31"]
@@ -0,0 +1,40 @@
1
+ from .client import StackShift
2
+ from .assets import ASSET_TRANSFORM_PRESETS, AssetsClient, asset_transform_options, asset_transform_spec, get_asset_transform_preset
3
+ from .mail import (
4
+ MailClient,
5
+ MailDomainsClient,
6
+ MailOTPChallengesClient,
7
+ MailOTPClient,
8
+ MailTemplatesClient,
9
+ )
10
+ from .mail_reputation import MailLimitsClient, MailReputationClient, MailReputationEventsClient
11
+ from .mail_events_webhooks import MailEventsClient, MailWebhooksClient, verify_webhook_signature
12
+ from .mail_streams import MailStreamsClient
13
+ from .mail_campaigns import MailAudiencesClient, MailCampaignsClient
14
+ from .mail_exports import MailExportsClient
15
+ from .projects import ProjectsClient
16
+
17
+ __all__ = [
18
+ "StackShift",
19
+ "ASSET_TRANSFORM_PRESETS",
20
+ "AssetsClient",
21
+ "MailClient",
22
+ "MailDomainsClient",
23
+ "MailEventsClient",
24
+ "MailLimitsClient",
25
+ "MailOTPClient",
26
+ "MailOTPChallengesClient",
27
+ "MailReputationClient",
28
+ "MailReputationEventsClient",
29
+ "MailTemplatesClient",
30
+ "MailWebhooksClient",
31
+ "MailStreamsClient",
32
+ "MailAudiencesClient",
33
+ "MailCampaignsClient",
34
+ "MailExportsClient",
35
+ "ProjectsClient",
36
+ "asset_transform_options",
37
+ "asset_transform_spec",
38
+ "get_asset_transform_preset",
39
+ "verify_webhook_signature",
40
+ ]
@@ -0,0 +1,390 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import hashlib
5
+ from pathlib import Path
6
+ from typing import Any, BinaryIO
7
+ from urllib.parse import quote
8
+
9
+
10
+ ASSET_TRANSFORM_PRESETS: list[dict[str, Any]] = [
11
+ {"id": "web-thumbnail", "name": "web-thumbnail", "category": "responsive-web", "description": "Compact grid thumbnail for libraries and dashboards.", "width": 320, "height": 180, "crop": "fill", "format": "webp", "quality": 78},
12
+ {"id": "web-card", "name": "web-card", "category": "responsive-web", "description": "Balanced card image for product and content listings.", "width": 640, "height": 360, "crop": "fill", "format": "webp", "quality": 82},
13
+ {"id": "feature-image", "name": "feature-image", "category": "responsive-web", "description": "Wide content feature image with enough detail for retina screens.", "width": 1200, "height": 675, "crop": "fill", "format": "webp", "quality": 84},
14
+ {"id": "hero", "name": "hero", "category": "responsive-web", "description": "Large hero image for landing pages and page headers.", "width": 1600, "height": 900, "crop": "fill", "format": "webp", "quality": 84},
15
+ {"id": "full-width-banner", "name": "full-width-banner", "category": "responsive-web", "description": "Extra-wide banner for app shells and marketing sections.", "width": 1920, "height": 720, "crop": "fill", "format": "webp", "quality": 82},
16
+ {"id": "open-graph", "name": "open-graph", "category": "social", "description": "Share preview image for Open Graph cards.", "width": 1200, "height": 630, "crop": "fill", "format": "jpeg", "quality": 85},
17
+ {"id": "square-post", "name": "square-post", "category": "social", "description": "Square image for social feeds.", "width": 1080, "height": 1080, "crop": "fill", "format": "webp", "quality": 84},
18
+ {"id": "story", "name": "story", "category": "social", "description": "Vertical story format for mobile social placements.", "width": 1080, "height": 1920, "crop": "fill", "format": "webp", "quality": 82},
19
+ {"id": "x-twitter-card", "name": "x-twitter-card", "category": "social", "description": "Landscape card optimized for X and Twitter previews.", "width": 1200, "height": 675, "crop": "fill", "format": "jpeg", "quality": 84},
20
+ {"id": "linkedin-share", "name": "linkedin-share", "category": "social", "description": "LinkedIn share preview dimensions.", "width": 1200, "height": 627, "crop": "fill", "format": "jpeg", "quality": 84},
21
+ {"id": "product-square", "name": "product-square", "category": "product", "description": "Square product image with clean containment.", "width": 1024, "height": 1024, "crop": "fit", "format": "webp", "quality": 86},
22
+ {"id": "product-card", "name": "product-card", "category": "product", "description": "Product listing image for commerce grids.", "width": 640, "height": 640, "crop": "fit", "format": "webp", "quality": 84},
23
+ {"id": "product-zoom", "name": "product-zoom", "category": "product", "description": "High-detail product image for zoom interactions.", "width": 2000, "height": 2000, "crop": "fit", "format": "jpeg", "quality": 90},
24
+ {"id": "gallery-thumb", "name": "gallery-thumb", "category": "product", "description": "Compact gallery thumbnail for product detail pages.", "width": 400, "height": 400, "crop": "fill", "format": "webp", "quality": 82},
25
+ {"id": "avatar-small", "name": "avatar-small", "category": "avatar-profile", "description": "Small circular avatar source for dense UI.", "width": 128, "height": 128, "crop": "fill", "format": "webp", "quality": 82},
26
+ {"id": "avatar-retina", "name": "avatar-retina", "category": "avatar-profile", "description": "Retina avatar source for account and profile UI.", "width": 256, "height": 256, "crop": "fill", "format": "webp", "quality": 84},
27
+ {"id": "profile-cover", "name": "profile-cover", "category": "avatar-profile", "description": "Wide profile cover image.", "width": 1500, "height": 500, "crop": "fill", "format": "webp", "quality": 82},
28
+ {"id": "webp-auto", "name": "webp-auto", "category": "performance", "description": "Auto-sized WebP delivery with browser-friendly compression.", "width": 1280, "crop": "fit", "format": "webp", "quality": 78},
29
+ {"id": "avif-compressed", "name": "avif-compressed", "category": "performance", "description": "Highly compressed AVIF for modern browsers.", "width": 1280, "crop": "fit", "format": "avif", "quality": 62},
30
+ {"id": "lqip", "name": "lqip", "category": "performance", "description": "Tiny low-quality placeholder for progressive image loading.", "width": 48, "crop": "fit", "format": "jpeg", "quality": 35},
31
+ {"id": "blur-placeholder", "name": "blur-placeholder", "category": "performance", "description": "Small placeholder candidate for blurred loading states.", "width": 32, "height": 32, "crop": "fill", "format": "jpeg", "quality": 30},
32
+ {"id": "email-safe-600", "name": "email-safe-600", "category": "email-docs", "description": "Email-safe content image with conservative width.", "width": 600, "crop": "fit", "format": "jpeg", "quality": 82},
33
+ {"id": "markdown-image", "name": "markdown-image", "category": "email-docs", "description": "Documentation image that fits most reading columns.", "width": 800, "crop": "fit", "format": "webp", "quality": 82},
34
+ {"id": "docs-screenshot", "name": "docs-screenshot", "category": "email-docs", "description": "Crisp screenshot size for guides and changelogs.", "width": 1440, "height": 900, "crop": "fit", "format": "webp", "quality": 84},
35
+ ]
36
+
37
+
38
+ def get_asset_transform_preset(name: str) -> dict[str, Any] | None:
39
+ return next((preset for preset in ASSET_TRANSFORM_PRESETS if preset["name"] == name), None)
40
+
41
+
42
+ def asset_transform_options(preset: dict[str, Any]) -> dict[str, Any]:
43
+ return {key: preset[key] for key in ("width", "height", "crop", "format", "quality") if key in preset}
44
+
45
+
46
+ def asset_transform_spec(**options: Any) -> str:
47
+ return _transform_spec(options)
48
+
49
+
50
+ class AssetsClient:
51
+ def __init__(self, client: Any) -> None:
52
+ self._client = client
53
+
54
+ def upload(
55
+ self,
56
+ file: str | Path | BinaryIO,
57
+ *,
58
+ bucket: str | None = None,
59
+ key: str | None = None,
60
+ folder: str | None = None,
61
+ visibility: str | None = None,
62
+ cache_control: str | None = None,
63
+ metadata: dict[str, Any] | None = None,
64
+ ) -> dict[str, Any]:
65
+ return self._multipart("POST", "/assets/upload", file, {
66
+ "bucket": bucket,
67
+ "key": key,
68
+ "folder": folder,
69
+ "visibility": visibility,
70
+ "cache_control": cache_control,
71
+ "metadata": json.dumps(metadata) if metadata else None,
72
+ })
73
+
74
+ def list(self, **params: Any) -> dict[str, Any]:
75
+ clean = {key: value for key, value in params.items() if value not in (None, "")}
76
+ return self._client.request("GET", "/assets", params=clean)
77
+
78
+ def get(self, asset_id: str) -> dict[str, Any]:
79
+ return self._client.request("GET", f"/assets/{asset_id}")
80
+
81
+ def delete(self, asset_id: str, revision: int, *, idempotency_key: str | None = None) -> dict[str, Any]:
82
+ headers = {"If-Match": f'"{revision}"'}
83
+ if idempotency_key:
84
+ headers["Idempotency-Key"] = idempotency_key
85
+ return self._client.request("DELETE", f"/assets/{asset_id}", headers=headers)
86
+
87
+ def replace(
88
+ self,
89
+ asset_id: str,
90
+ revision: int,
91
+ file: str | Path | BinaryIO,
92
+ *,
93
+ cache_control: str | None = None,
94
+ metadata: dict[str, Any] | None = None,
95
+ ) -> dict[str, Any]:
96
+ return self._multipart("PUT", f"/assets/{asset_id}/replace", file, {
97
+ "cache_control": cache_control,
98
+ "metadata": json.dumps(metadata) if metadata else None,
99
+ }, headers={"If-Match": f'"{revision}"'})
100
+
101
+ def signed_url(
102
+ self,
103
+ asset_id: str,
104
+ *,
105
+ expires_in: str = "10m",
106
+ max_downloads: int | None = None,
107
+ ) -> dict[str, Any]:
108
+ return self._client.request("POST", f"/assets/{asset_id}/signed-url", json={
109
+ "expires_in": expires_in,
110
+ "max_downloads": max_downloads,
111
+ })
112
+
113
+ def signed_upload_url(self, **payload: Any) -> dict[str, Any]:
114
+ return self.create_upload_session(**payload)
115
+
116
+ def create_upload_session(self, **payload: Any) -> dict[str, Any]:
117
+ idempotency_key = payload.pop("idempotency_key", None)
118
+ clean = {key: value for key, value in payload.items() if value not in (None, "")}
119
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
120
+ return self._client.request("POST", "/assets/upload-sessions", json=clean, headers=headers)
121
+
122
+ def create_chunked_upload_session(self, **payload: Any) -> dict[str, Any]:
123
+ return self.create_upload_session(mode="chunked", **payload)
124
+
125
+ def upload_chunk(
126
+ self,
127
+ upload_url_or_token: str,
128
+ part_number: int,
129
+ chunk: bytes | BinaryIO,
130
+ ) -> dict[str, Any]:
131
+ target = upload_url_or_token.rstrip("/")
132
+ if not target.startswith("http"):
133
+ origin = self._client.base_url.removesuffix("/api/v1")
134
+ target = f"{origin}/uploads/{quote(upload_url_or_token)}"
135
+
136
+ raw = chunk if isinstance(chunk, bytes) else chunk.read()
137
+ response = self._client.session.put(
138
+ f"{target}/parts/{part_number}",
139
+ data=raw,
140
+ headers={"User-Agent": "StackShift-Python/0.1", "X-Content-SHA256": hashlib.sha256(raw).hexdigest()},
141
+ )
142
+ payload = response.json() if response.content else None
143
+ if response.status_code < 200 or response.status_code >= 300:
144
+ message = payload.get("message") if isinstance(payload, dict) else None
145
+ raise RuntimeError(message or f"StackShift upload failed with {response.status_code}")
146
+ return payload["data"] if isinstance(payload, dict) and "data" in payload else payload
147
+
148
+ def resume_upload_session(self, session_id: str) -> dict[str, Any]:
149
+ return self._client.request("GET", f"/assets/upload-sessions/{session_id}")
150
+
151
+ def complete_upload_session(self, session_id: str) -> dict[str, Any]:
152
+ return self._client.request("POST", f"/assets/upload-sessions/{session_id}/complete")
153
+
154
+ def cancel_upload_session(self, session_id: str) -> dict[str, Any]:
155
+ return self._client.request("DELETE", f"/assets/upload-sessions/{session_id}")
156
+
157
+ def list_buckets(self) -> dict[str, Any]:
158
+ return self._client.request("GET", "/assets/buckets")
159
+
160
+ def create_bucket(self, **payload: Any) -> dict[str, Any]:
161
+ return self._client.request("POST", "/assets/buckets", json=payload)
162
+
163
+ def update_bucket(self, bucket_id: str, revision: int, **payload: Any) -> dict[str, Any]:
164
+ return self._client.request("PUT", f"/assets/buckets/{bucket_id}", json=payload, headers={"If-Match": f'"{revision}"'})
165
+
166
+ def delete_bucket(self, bucket_id: str, revision: int) -> dict[str, Any]:
167
+ return self._client.request("DELETE", f"/assets/buckets/{bucket_id}", headers={"If-Match": f'"{revision}"'})
168
+
169
+ def set_tags(self, asset_id: str, revision: int, tags: list[str]) -> dict[str, Any]:
170
+ return self._client.request("PUT", f"/assets/{asset_id}/tags", json={"tags": tags}, headers={"If-Match": f'"{revision}"'})
171
+
172
+ def patch_metadata(self, asset_id: str, revision: int, metadata: dict[str, Any]) -> dict[str, Any]:
173
+ return self._client.request("PATCH", f"/assets/{asset_id}/metadata", json={"metadata": metadata}, headers={"If-Match": f'"{revision}"'})
174
+
175
+ def patch(self, asset_id: str, revision: int, **payload: Any) -> dict[str, Any]:
176
+ return self._client.request("PATCH", f"/assets/{asset_id}", json=payload, headers={"If-Match": f'"{revision}"'})
177
+
178
+ def url(self, asset_id: str, **options: Any) -> str:
179
+ spec = _transform_spec(options)
180
+ return f"{self._client.cdn_base_url}/assets/{quote(asset_id)}/tr/{quote(spec)}"
181
+
182
+ def named_url(self, asset_id: str, name: str) -> str:
183
+ return f"{self._client.cdn_base_url}/t/{quote(name)}/assets/{quote(asset_id)}"
184
+
185
+ def version_url(self, asset_id: str, version_id: str, token: str | None = None) -> str:
186
+ prefix = "/private/assets" if token else "/assets"
187
+ result = f"{self._client.cdn_base_url}{prefix}/{quote(asset_id)}/versions/{quote(version_id)}"
188
+ return f"{result}?token={quote(token)}" if token else result
189
+
190
+ def branch_url(self, asset_id: str, branch: str, token: str | None = None) -> str:
191
+ prefix = "/private/assets" if token else "/assets"
192
+ result = f"{self._client.cdn_base_url}{prefix}/{quote(asset_id)}/branches/{quote(branch)}"
193
+ return f"{result}?token={quote(token)}" if token else result
194
+
195
+ def video_url(self, asset_id: str, kind: str, profile: str = "default", token: str | None = None) -> str:
196
+ prefix = "/private/assets" if token else "/assets"
197
+ result = f"{self._client.cdn_base_url}{prefix}/{quote(asset_id)}/video/{quote(kind)}/{quote(profile)}"
198
+ return f"{result}?token={quote(token)}" if token else result
199
+
200
+ def ai(self, asset_id: str) -> dict[str, Any]:
201
+ return self._client.request("GET", f"/assets/{asset_id}/ai")
202
+
203
+ def request_ai_analyze(self, asset_id: str, *, idempotency_key: str | None = None) -> dict[str, Any]:
204
+ return self._request_ai_job(f"/assets/{asset_id}/ai/analyze", idempotency_key)
205
+
206
+ def request_moderation(self, asset_id: str, *, idempotency_key: str | None = None) -> dict[str, Any]:
207
+ return self._request_ai_job(f"/assets/{asset_id}/moderation", idempotency_key)
208
+
209
+ def transcript(self, asset_id: str) -> dict[str, Any]:
210
+ return self._client.request("GET", f"/assets/{asset_id}/transcript")
211
+
212
+ def request_transcript(self, asset_id: str, *, idempotency_key: str | None = None) -> dict[str, Any]:
213
+ return self._request_ai_job(f"/assets/{asset_id}/transcript", idempotency_key)
214
+
215
+ def request_smart_crop(self, asset_id: str, *, idempotency_key: str | None = None) -> dict[str, Any]:
216
+ return self._request_ai_job(f"/assets/{asset_id}/smart-crop", idempotency_key)
217
+
218
+ def request_background_removal(self, asset_id: str, *, idempotency_key: str | None = None) -> dict[str, Any]:
219
+ return self._request_ai_job(f"/assets/{asset_id}/background-remove", idempotency_key)
220
+
221
+ def _request_ai_job(self, path: str, idempotency_key: str | None) -> dict[str, Any]:
222
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
223
+ return self._client.request("POST", path, headers=headers)
224
+
225
+ def versions(self, asset_id: str) -> dict[str, Any]:
226
+ return self._client.request("GET", f"/assets/{asset_id}/versions")
227
+
228
+ def create_branch(
229
+ self,
230
+ asset_id: str,
231
+ name: str,
232
+ from_version_id: str | None = None,
233
+ ) -> dict[str, Any]:
234
+ payload = {"name": name, "from_version_id": from_version_id}
235
+ clean = {key: value for key, value in payload.items() if value not in (None, "")}
236
+ return self._client.request("POST", f"/assets/{asset_id}/branches", json=clean)
237
+
238
+ def restore_version(self, asset_id: str, version_id: str) -> dict[str, Any]:
239
+ return self._client.request("POST", f"/assets/{asset_id}/versions/{version_id}/restore")
240
+
241
+ def promote_branch(self, asset_id: str, branch: str) -> dict[str, Any]:
242
+ return self._client.request("POST", f"/assets/{asset_id}/branches/{quote(branch)}/promote")
243
+
244
+ def collections(self) -> dict[str, Any]:
245
+ return self._client.request("GET", "/assets/collections")
246
+
247
+ def create_collection(self, **payload: Any) -> dict[str, Any]:
248
+ clean = {key: value for key, value in payload.items() if value not in (None, "")}
249
+ return self._client.request("POST", "/assets/collections", json=clean)
250
+
251
+ def update_collection(self, collection_id: str, **payload: Any) -> dict[str, Any]:
252
+ clean = {key: value for key, value in payload.items() if value not in (None, "")}
253
+ return self._client.request("PUT", f"/assets/collections/{collection_id}", json=clean)
254
+
255
+ def delete_collection(self, collection_id: str) -> dict[str, Any]:
256
+ return self._client.request("DELETE", f"/assets/collections/{collection_id}")
257
+
258
+ def saved_searches(self) -> dict[str, Any]:
259
+ return self._client.request("GET", "/assets/saved-searches")
260
+
261
+ def save_search(self, **payload: Any) -> dict[str, Any]:
262
+ clean = {key: value for key, value in payload.items() if value not in (None, "")}
263
+ return self._client.request("POST", "/assets/saved-searches", json=clean)
264
+
265
+ def update_saved_search(self, search_id: str, **payload: Any) -> dict[str, Any]:
266
+ clean = {key: value for key, value in payload.items() if value not in (None, "")}
267
+ return self._client.request("PUT", f"/assets/saved-searches/{search_id}", json=clean)
268
+
269
+ def delete_saved_search(self, search_id: str) -> dict[str, Any]:
270
+ return self._client.request("DELETE", f"/assets/saved-searches/{search_id}")
271
+
272
+ def get_policy(self) -> dict[str, Any]:
273
+ return self._client.request("GET", "/assets/policy")
274
+
275
+ def update_policy(self, **payload: Any) -> dict[str, Any]:
276
+ clean = {key: value for key, value in payload.items() if value not in (None, "")}
277
+ return self._client.request("PUT", "/assets/policy", json=clean)
278
+
279
+ def create_transformation(self, name: str, **options: Any) -> dict[str, Any]:
280
+ payload = {"name": name, **options}
281
+ clean = {key: value for key, value in payload.items() if value not in (None, "")}
282
+ return self._client.request("POST", "/assets/transformations", json=clean)
283
+
284
+ def list_transformations(self) -> dict[str, Any]:
285
+ return self._client.request("GET", "/assets/transformations")
286
+
287
+ def delete_transformation(self, name: str) -> dict[str, Any]:
288
+ return self._client.request("DELETE", f"/assets/transformations/{quote(name)}")
289
+
290
+ def signed_transform_url(self, asset_id: str, **options: Any) -> dict[str, Any]:
291
+ clean = {key: value for key, value in options.items() if value not in (None, "")}
292
+ return self._client.request("POST", f"/assets/{asset_id}/transform-url", json=clean)
293
+
294
+ def get_ai_config(self) -> dict[str, Any]:
295
+ return self._client.request("GET", "/assets/ai/config")
296
+
297
+ def update_ai_config(self, revision: int, *, enabled_actions: list[str], monthly_spend_cap_micros: int) -> dict[str, Any]:
298
+ return self._client.request("PUT", "/assets/ai/config", headers={"If-Match": f'"{revision}"'}, json={
299
+ "enabled_actions": enabled_actions,
300
+ "monthly_spend_cap_micros": monthly_spend_cap_micros,
301
+ })
302
+
303
+ def job(self, job_id: str) -> dict[str, Any]:
304
+ return self._client.request("GET", f"/assets/jobs/{quote(job_id)}")
305
+
306
+ def cancel_job(self, job_id: str) -> dict[str, Any]:
307
+ return self._client.request("POST", f"/assets/jobs/{quote(job_id)}/cancel")
308
+
309
+ def create_lifecycle_rule(self, **payload: Any) -> dict[str, Any]:
310
+ return self._client.request("POST", "/assets/lifecycle-rules", json=payload)
311
+
312
+ def list_lifecycle_rules(self) -> dict[str, Any]:
313
+ return self._client.request("GET", "/assets/lifecycle-rules")
314
+
315
+ def delete_lifecycle_rule(self, rule_id: str) -> dict[str, Any]:
316
+ return self._client.request("DELETE", f"/assets/lifecycle-rules/{quote(rule_id)}")
317
+
318
+ def create_domain(self, domain: str) -> dict[str, Any]:
319
+ return self._client.request("POST", "/assets/domains", json={"domain": domain})
320
+
321
+ def list_domains(self) -> dict[str, Any]:
322
+ return self._client.request("GET", "/assets/domains")
323
+
324
+ def verify_domain(self, domain_id: str) -> dict[str, Any]:
325
+ return self._client.request("POST", f"/assets/domains/{quote(domain_id)}/verify")
326
+
327
+ def delete_domain(self, domain_id: str) -> dict[str, Any]:
328
+ return self._client.request("DELETE", f"/assets/domains/{quote(domain_id)}")
329
+
330
+ def analytics(self) -> dict[str, Any]:
331
+ return self._client.request("GET", "/assets/analytics")
332
+
333
+ def usage_summary(self) -> dict[str, Any]:
334
+ return self._client.request("GET", "/assets/summary")
335
+
336
+ def events(self, limit: int = 50) -> dict[str, Any]:
337
+ return self._client.request("GET", "/assets/events", params={"limit": limit})
338
+
339
+ def create_webhook(self, url: str, event_types: list[str] | None = None) -> dict[str, Any]:
340
+ return self._client.request("POST", "/assets/webhooks", json={"url": url, "event_types": event_types or ["asset.*"]})
341
+
342
+ def list_webhooks(self) -> dict[str, Any]:
343
+ return self._client.request("GET", "/assets/webhooks")
344
+
345
+ def delete_webhook(self, webhook_id: str) -> dict[str, Any]:
346
+ return self._client.request("DELETE", f"/assets/webhooks/{quote(webhook_id)}")
347
+
348
+ def list_webhook_deliveries(self, webhook_id: str, limit: int = 50) -> dict[str, Any]:
349
+ return self._client.request("GET", f"/assets/webhooks/{quote(webhook_id)}/deliveries", params={"limit": limit})
350
+
351
+ def retry_webhook_delivery(self, delivery_id: str) -> dict[str, Any]:
352
+ return self._client.request("POST", f"/assets/webhook-deliveries/{quote(delivery_id)}/retry")
353
+
354
+ def purge(self, asset_id: str) -> dict[str, Any]:
355
+ return self._client.request("POST", f"/assets/{quote(asset_id)}/purge")
356
+
357
+ def bulk(self, *, action: str, items: list[dict[str, Any]], idempotency_key: str | None = None, **payload: Any) -> dict[str, Any]:
358
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
359
+ return self._client.request("POST", "/assets/bulk", json={"action": action, "items": items, **payload}, headers=headers)
360
+
361
+ def _multipart(self, method: str, suffix: str, file: str | Path | BinaryIO, fields: dict[str, Any], headers: dict[str, str] | None = None) -> dict[str, Any]:
362
+ should_close = False
363
+ if isinstance(file, (str, Path)):
364
+ handle = Path(file).open("rb")
365
+ filename = Path(file).name
366
+ should_close = True
367
+ else:
368
+ handle = file
369
+ filename = getattr(file, "name", "asset")
370
+
371
+ try:
372
+ data = {key: value for key, value in fields.items() if value not in (None, "")}
373
+ return self._client.request(method, suffix, data=data, files={"file": (filename, handle)}, headers=headers)
374
+ finally:
375
+ if should_close:
376
+ handle.close()
377
+
378
+
379
+ def _transform_spec(options: dict[str, Any]) -> str:
380
+ parts: list[str] = []
381
+ if options.get("width"):
382
+ parts.append(f"w_{options['width']}")
383
+ if options.get("height"):
384
+ parts.append(f"h_{options['height']}")
385
+ parts.append(f"c_{options.get('crop') or 'fit'}")
386
+ parts.append(f"f_{options.get('format') or 'auto'}")
387
+ parts.append(f"q_{options.get('quality') or 'auto'}")
388
+ parts.append(f"g_{options.get('gravity') or 'centre'}")
389
+ parts.append(f"a_{options.get('animation') or 'first'}")
390
+ return ",".join(parts)