stackshift 0.1.0__tar.gz → 0.1.2__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.
- {stackshift-0.1.0 → stackshift-0.1.2}/PKG-INFO +1 -1
- stackshift-0.1.2/README.md +207 -0
- {stackshift-0.1.0 → stackshift-0.1.2}/pyproject.toml +1 -1
- stackshift-0.1.2/stackshift/__init__.py +33 -0
- stackshift-0.1.2/stackshift/assets.py +390 -0
- stackshift-0.1.2/stackshift/client.py +79 -0
- stackshift-0.1.2/stackshift/mail.py +379 -0
- stackshift-0.1.2/stackshift/mail_advanced.py +101 -0
- stackshift-0.1.2/stackshift/mail_phase7.py +153 -0
- stackshift-0.1.2/stackshift/mail_reputation.py +38 -0
- {stackshift-0.1.0 → stackshift-0.1.2}/stackshift.egg-info/PKG-INFO +1 -1
- {stackshift-0.1.0 → stackshift-0.1.2}/stackshift.egg-info/SOURCES.txt +8 -1
- stackshift-0.1.2/tests/test_mail.py +405 -0
- stackshift-0.1.2/tests/test_mail_advanced.py +96 -0
- stackshift-0.1.2/tests/test_mail_phase7.py +174 -0
- stackshift-0.1.0/README.md +0 -42
- stackshift-0.1.0/stackshift/__init__.py +0 -5
- stackshift-0.1.0/stackshift/assets.py +0 -89
- stackshift-0.1.0/stackshift/client.py +0 -44
- {stackshift-0.1.0 → stackshift-0.1.2}/setup.cfg +0 -0
- {stackshift-0.1.0 → stackshift-0.1.2}/stackshift/projects.py +0 -0
- {stackshift-0.1.0 → stackshift-0.1.2}/stackshift.egg-info/dependency_links.txt +0 -0
- {stackshift-0.1.0 → stackshift-0.1.2}/stackshift.egg-info/requires.txt +0 -0
- {stackshift-0.1.0 → stackshift-0.1.2}/stackshift.egg-info/top_level.txt +0 -0
|
@@ -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="sent", 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
|
+
In this MVP, `sent` means the message was accepted by StackShift's outbound MTA. It does not mean inbox delivery, opens, clicks, or spam placement. A `sent` message can later become `bounced`.
|
|
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.
|
|
@@ -0,0 +1,33 @@
|
|
|
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_phase7 import MailEventsClient, MailWebhooksClient, verify_webhook_signature
|
|
12
|
+
from .projects import ProjectsClient
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"StackShift",
|
|
16
|
+
"ASSET_TRANSFORM_PRESETS",
|
|
17
|
+
"AssetsClient",
|
|
18
|
+
"MailClient",
|
|
19
|
+
"MailDomainsClient",
|
|
20
|
+
"MailEventsClient",
|
|
21
|
+
"MailLimitsClient",
|
|
22
|
+
"MailOTPClient",
|
|
23
|
+
"MailOTPChallengesClient",
|
|
24
|
+
"MailReputationClient",
|
|
25
|
+
"MailReputationEventsClient",
|
|
26
|
+
"MailTemplatesClient",
|
|
27
|
+
"MailWebhooksClient",
|
|
28
|
+
"ProjectsClient",
|
|
29
|
+
"asset_transform_options",
|
|
30
|
+
"asset_transform_spec",
|
|
31
|
+
"get_asset_transform_preset",
|
|
32
|
+
"verify_webhook_signature",
|
|
33
|
+
]
|
|
@@ -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)
|