stackshift 1.0.0__tar.gz → 1.0.1__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 (29) hide show
  1. stackshift-1.0.1/PKG-INFO +215 -0
  2. {stackshift-1.0.0 → stackshift-1.0.1}/pyproject.toml +2 -1
  3. {stackshift-1.0.0 → stackshift-1.0.1}/stackshift/assets.py +3 -1
  4. stackshift-1.0.1/stackshift/assets_workflows.py +176 -0
  5. stackshift-1.0.1/stackshift.egg-info/PKG-INFO +215 -0
  6. {stackshift-1.0.0 → stackshift-1.0.1}/stackshift.egg-info/SOURCES.txt +2 -0
  7. stackshift-1.0.1/tests/test_assets_workflows.py +34 -0
  8. stackshift-1.0.0/PKG-INFO +0 -6
  9. stackshift-1.0.0/stackshift.egg-info/PKG-INFO +0 -6
  10. {stackshift-1.0.0 → stackshift-1.0.1}/README.md +0 -0
  11. {stackshift-1.0.0 → stackshift-1.0.1}/setup.cfg +0 -0
  12. {stackshift-1.0.0 → stackshift-1.0.1}/stackshift/__init__.py +0 -0
  13. {stackshift-1.0.0 → stackshift-1.0.1}/stackshift/client.py +0 -0
  14. {stackshift-1.0.0 → stackshift-1.0.1}/stackshift/mail.py +0 -0
  15. {stackshift-1.0.0 → stackshift-1.0.1}/stackshift/mail_advanced.py +0 -0
  16. {stackshift-1.0.0 → stackshift-1.0.1}/stackshift/mail_campaigns.py +0 -0
  17. {stackshift-1.0.0 → stackshift-1.0.1}/stackshift/mail_events_webhooks.py +0 -0
  18. {stackshift-1.0.0 → stackshift-1.0.1}/stackshift/mail_exports.py +0 -0
  19. {stackshift-1.0.0 → stackshift-1.0.1}/stackshift/mail_reputation.py +0 -0
  20. {stackshift-1.0.0 → stackshift-1.0.1}/stackshift/mail_streams.py +0 -0
  21. {stackshift-1.0.0 → stackshift-1.0.1}/stackshift/projects.py +0 -0
  22. {stackshift-1.0.0 → stackshift-1.0.1}/stackshift.egg-info/dependency_links.txt +0 -0
  23. {stackshift-1.0.0 → stackshift-1.0.1}/stackshift.egg-info/requires.txt +0 -0
  24. {stackshift-1.0.0 → stackshift-1.0.1}/stackshift.egg-info/top_level.txt +0 -0
  25. {stackshift-1.0.0 → stackshift-1.0.1}/tests/test_mail.py +0 -0
  26. {stackshift-1.0.0 → stackshift-1.0.1}/tests/test_mail_advanced.py +0 -0
  27. {stackshift-1.0.0 → stackshift-1.0.1}/tests/test_mail_diagnostics.py +0 -0
  28. {stackshift-1.0.0 → stackshift-1.0.1}/tests/test_mail_events_webhooks.py +0 -0
  29. {stackshift-1.0.0 → stackshift-1.0.1}/tests/test_mail_exports.py +0 -0
@@ -0,0 +1,215 @@
1
+ Metadata-Version: 2.4
2
+ Name: stackshift
3
+ Version: 1.0.1
4
+ Summary: Official Python SDK for StackShift.
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: requests>=2.31
8
+
9
+ # StackShift Python SDK
10
+
11
+ Official Python SDK for StackShift.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install stackshift
17
+ ```
18
+
19
+ ## Send email
20
+
21
+ ```python
22
+ from stackshift import StackShift
23
+
24
+ stackshift = StackShift()
25
+
26
+ message = stackshift.mail.send(
27
+ from_="StackShift <noreply@mail.stackshift.cloud>",
28
+ to="ada@example.com",
29
+ subject="Welcome",
30
+ text="Welcome to StackShift.",
31
+ idempotency_key="welcome:user_123",
32
+ )
33
+
34
+ print(message["id"], message["status"], message["idempotencyStatus"])
35
+ ```
36
+
37
+ Inspect message status:
38
+
39
+ ```python
40
+ messages = stackshift.mail.messages.list(status="mta_accepted", limit=20)
41
+ detail = stackshift.mail.messages.get(messages["data"][0]["id"])
42
+ attempts = stackshift.mail.messages.attempts(detail["id"])
43
+ logs = stackshift.mail.messages.logs(detail["id"])
44
+ ```
45
+
46
+ `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`.
47
+
48
+ ## Events and webhooks
49
+
50
+ ```python
51
+ events = stackshift.mail.events.list(
52
+ type="mail.message.bounced",
53
+ message_id="msg_123",
54
+ limit=20,
55
+ )
56
+
57
+ event = stackshift.mail.events.get("evt_123")
58
+ timeline = stackshift.mail.messages.timeline("msg_123")
59
+
60
+ webhook = stackshift.mail.webhooks.create(
61
+ url="https://example.com/stackshift-mail",
62
+ event_types=["mail.message.bounced", "mail.otp.verified"],
63
+ )
64
+ print(webhook["id"], webhook["secret"]) # Secret is only returned on create/rotate.
65
+
66
+ deliveries = stackshift.mail.webhooks.deliveries(webhook["id"], status="failed")
67
+ retried = stackshift.mail.webhooks.retry_delivery(deliveries["data"][0]["id"])
68
+ ```
69
+
70
+ Verify a webhook signature before processing the payload:
71
+
72
+ ```python
73
+ valid = stackshift.mail.webhooks.verify_signature(
74
+ raw_body=request.get_data(),
75
+ signature_header=request.headers.get("StackShift-Signature"),
76
+ timestamp_header=request.headers.get("StackShift-Timestamp"),
77
+ secret=os.environ["STACKSHIFT_WEBHOOK_SECRET"],
78
+ )
79
+ ```
80
+
81
+ Webhook handlers should be idempotent. StackShift retries non-2xx responses. Delivery events do not include full email bodies or OTP codes by default.
82
+
83
+ ## Send a template
84
+
85
+ Templates are rendered by StackShift servers. The Python SDK only calls the REST API.
86
+
87
+ ```python
88
+ stackshift.mail.templates.create(
89
+ name="Welcome Email",
90
+ slug="welcome-email",
91
+ subject="Welcome, {{name}}",
92
+ text="Welcome, {{name}}",
93
+ )
94
+
95
+ preview = stackshift.mail.templates.preview(
96
+ "welcome-email",
97
+ data={"name": "Ada"},
98
+ )
99
+
100
+ message = stackshift.mail.send_template(
101
+ template="welcome-email",
102
+ to="ada@example.com",
103
+ from_="Acme <noreply@acme.com>",
104
+ data={"name": "Ada"},
105
+ idempotency_key="welcome:user_123",
106
+ )
107
+ ```
108
+
109
+ 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`.
110
+
111
+ ## Bounces and suppressions
112
+
113
+ ```python
114
+ suppressions = stackshift.mail.suppressions.list()
115
+
116
+ manual = stackshift.mail.suppressions.create(
117
+ email="bad@example.com",
118
+ reason="manual",
119
+ )
120
+
121
+ stackshift.mail.suppressions.delete(manual["id"])
122
+
123
+ bounces = stackshift.mail.bounces.list(type="hard")
124
+ message_bounces = stackshift.mail.messages.bounces("msg_123")
125
+ ```
126
+
127
+ Hard bounces are automatically suppressed by the backend. Suppressions are workspace scoped and checked before a message is queued.
128
+
129
+ ## Verify a sending domain
130
+
131
+ ```python
132
+ domain = stackshift.mail.domains.create("acme.com")
133
+ print(domain["records"])
134
+
135
+ stackshift.mail.domains.verify(domain["id"])
136
+
137
+ message = stackshift.mail.send(
138
+ from_="Acme <noreply@acme.com>",
139
+ to="user@example.com",
140
+ subject="Welcome",
141
+ html="<h1>Welcome</h1>",
142
+ text="Welcome",
143
+ )
144
+ ```
145
+
146
+ 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.
147
+
148
+ ## Upload an asset
149
+
150
+ ```python
151
+ from stackshift import StackShift
152
+
153
+ stackshift = StackShift()
154
+
155
+ asset = stackshift.assets.upload(
156
+ "avatar.png",
157
+ folder="avatars",
158
+ visibility="public",
159
+ metadata={"user_id": "user_123"},
160
+ )
161
+
162
+ print(asset["url"])
163
+ ```
164
+
165
+ You do not pass a deployed StackShift project ID. The API key identifies the StackShift account, and StackShift resolves the default asset space internally.
166
+
167
+ ## Private asset URL
168
+
169
+ ```python
170
+ signed = stackshift.assets.signed_url(
171
+ asset["id"],
172
+ expires_in="10m",
173
+ max_downloads=1,
174
+ )
175
+
176
+ print(signed["url"])
177
+ ```
178
+
179
+ ## Image transformations
180
+
181
+ Use built-in presets or create your own named transformations.
182
+
183
+ ```python
184
+ from stackshift import StackShift, asset_transform_options, get_asset_transform_preset
185
+
186
+ stackshift = StackShift()
187
+ hero = get_asset_transform_preset("hero")
188
+ hero_options = asset_transform_options(hero)
189
+
190
+ stackshift.assets.create_transformation(hero["name"], **hero_options)
191
+
192
+ named = stackshift.assets.named_url("asset_123", hero["name"])
193
+ signed = stackshift.assets.signed_transform_url("asset_123", **hero_options, expiresIn="10m")
194
+
195
+ stackshift.assets.delete_transformation("old-preset")
196
+ print(named, signed["url"])
197
+ ```
198
+
199
+ ## Direct browser uploads
200
+
201
+ Create the upload session on your Python backend:
202
+
203
+ ```python
204
+ upload = stackshift.assets.signed_upload_url(
205
+ bucket="avatars",
206
+ key="users/user_123.png",
207
+ visibility="public",
208
+ expiresIn="10m",
209
+ maxBytes=5_000_000,
210
+ )
211
+ ```
212
+
213
+ Then upload the browser `File` to `upload["url"]` from your frontend. Do not expose your StackShift API key to browser code.
214
+
215
+ The SDK only talks to the StackShift REST API. Storage placement, replication, disks, and repair are StackShift internals.
@@ -4,8 +4,9 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "stackshift"
7
- version = "1.0.0"
7
+ version = "1.0.1"
8
8
  description = "Official Python SDK for StackShift."
9
+ readme = "README.md"
9
10
  requires-python = ">=3.9"
10
11
  dependencies = ["requests>=2.31"]
11
12
 
@@ -6,6 +6,8 @@ from pathlib import Path
6
6
  from typing import Any, BinaryIO
7
7
  from urllib.parse import quote
8
8
 
9
+ from .assets_workflows import AssetWorkflowsMixin
10
+
9
11
 
10
12
  ASSET_TRANSFORM_PRESETS: list[dict[str, Any]] = [
11
13
  {"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},
@@ -47,7 +49,7 @@ def asset_transform_spec(**options: Any) -> str:
47
49
  return _transform_spec(options)
48
50
 
49
51
 
50
- class AssetsClient:
52
+ class AssetsClient(AssetWorkflowsMixin):
51
53
  def __init__(self, client: Any) -> None:
52
54
  self._client = client
53
55
 
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from urllib.parse import quote, urlencode
5
+
6
+
7
+ class AssetWorkflowsMixin:
8
+ """Typed route helpers for deterministic media and governed asset workflows."""
9
+
10
+ _client: Any
11
+
12
+ def create_transformation_v2(
13
+ self, name: str, definition: dict[str, Any], *, eager: bool = False
14
+ ) -> dict[str, Any]:
15
+ return self._client.request(
16
+ "POST", "/assets/transformations",
17
+ json={"name": name, "definition": definition, "eager": eager},
18
+ )
19
+
20
+ def materialize_transform(
21
+ self,
22
+ asset_id: str,
23
+ *,
24
+ definition: dict[str, Any] | None = None,
25
+ preset: str | None = None,
26
+ idempotency_key: str | None = None,
27
+ ) -> dict[str, Any]:
28
+ payload = {key: value for key, value in {
29
+ "definition": definition, "preset": preset,
30
+ }.items() if value not in (None, "")}
31
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
32
+ return self._client.request(
33
+ "POST", f"/assets/{quote(asset_id, safe='')}/transforms", json=payload, headers=headers
34
+ )
35
+
36
+ def derivatives(self, asset_id: str) -> dict[str, Any]:
37
+ return self._client.request("GET", f"/assets/{quote(asset_id, safe='')}/derivatives")
38
+
39
+ def generate(
40
+ self, payload: dict[str, Any], *, idempotency_key: str
41
+ ) -> dict[str, Any]:
42
+ return self._client.request(
43
+ "POST", "/assets/generations", json=payload,
44
+ headers={"Idempotency-Key": idempotency_key},
45
+ )
46
+
47
+ def automation(self) -> dict[str, Any]:
48
+ return self._client.request("GET", "/assets/automation")
49
+
50
+ def workflow_catalog(self) -> dict[str, Any]:
51
+ return self._client.request("GET", "/assets/workflows/catalog")
52
+
53
+ def create_workflow(self, payload: dict[str, Any]) -> dict[str, Any]:
54
+ return self._client.request("POST", "/assets/workflows", json=payload)
55
+
56
+ def workflows(self, *, page: int = 1, per_page: int = 20) -> list[dict[str, Any]]:
57
+ query = urlencode({"page": page, "per_page": per_page})
58
+ return self._client.request("GET", f"/assets/workflows?{query}")
59
+
60
+ def workflow(self, workflow_id: str) -> dict[str, Any]:
61
+ return self._client.request("GET", self._workflow_path(workflow_id))
62
+
63
+ def update_workflow_draft(
64
+ self, workflow_id: str, payload: dict[str, Any]
65
+ ) -> dict[str, Any]:
66
+ return self._client.request(
67
+ "PUT", f"{self._workflow_path(workflow_id)}/draft", json=payload
68
+ )
69
+
70
+ def validate_workflow(self, workflow_id: str) -> dict[str, Any]:
71
+ return self._client.request("POST", f"{self._workflow_path(workflow_id)}/validate")
72
+
73
+ def publish_workflow(
74
+ self, workflow_id: str, expected_revision: int
75
+ ) -> dict[str, Any]:
76
+ return self._client.request(
77
+ "POST", f"{self._workflow_path(workflow_id)}/publish",
78
+ json={"expected_revision": expected_revision},
79
+ )
80
+
81
+ def activate_workflow(
82
+ self, workflow_id: str, version_id: str, expected_revision: int
83
+ ) -> None:
84
+ self._client.request(
85
+ "POST", f"{self._workflow_path(workflow_id)}/activate",
86
+ json={"version_id": version_id, "expected_revision": expected_revision},
87
+ )
88
+
89
+ def workflow_versions(self, workflow_id: str) -> list[dict[str, Any]]:
90
+ return self._client.request("GET", f"{self._workflow_path(workflow_id)}/versions")
91
+
92
+ def run_workflow(
93
+ self, workflow_id: str, payload: dict[str, Any] | None = None,
94
+ *, idempotency_key: str | None = None,
95
+ ) -> dict[str, Any]:
96
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
97
+ return self._client.request(
98
+ "POST", f"{self._workflow_path(workflow_id)}/run",
99
+ json=payload or {}, headers=headers,
100
+ )
101
+
102
+ def workflow_runs(
103
+ self, workflow_id: str, *, page: int = 1, per_page: int = 20
104
+ ) -> list[dict[str, Any]]:
105
+ query = urlencode({"page": page, "per_page": per_page})
106
+ return self._client.request(
107
+ "GET", f"{self._workflow_path(workflow_id)}/runs?{query}"
108
+ )
109
+
110
+ def workflow_run(self, run_id: str) -> dict[str, Any]:
111
+ return self._client.request("GET", self._workflow_run_path(run_id))
112
+
113
+ def cancel_workflow_run(self, run_id: str) -> dict[str, Any]:
114
+ return self._client.request("POST", f"{self._workflow_run_path(run_id)}/cancel")
115
+
116
+ def retry_workflow_run(self, run_id: str) -> dict[str, Any]:
117
+ return self._client.request("POST", f"{self._workflow_run_path(run_id)}/retry")
118
+
119
+ def workflow_approvals(self, status: str | None = None) -> list[dict[str, Any]]:
120
+ if status:
121
+ return self._client.request(
122
+ "GET", f"/assets/workflows/approvals?{urlencode({'status': status})}"
123
+ )
124
+ return self._client.request("GET", "/assets/workflows/approvals")
125
+
126
+ def decide_workflow_approval(
127
+ self, approval_id: str, approved: bool, payload: dict[str, Any] | None = None
128
+ ) -> dict[str, Any]:
129
+ return (
130
+ self.approve_workflow(approval_id, payload)
131
+ if approved else self.reject_workflow(approval_id, payload)
132
+ )
133
+
134
+ def approve_workflow(
135
+ self, approval_id: str, payload: dict[str, Any] | None = None
136
+ ) -> dict[str, Any]:
137
+ return self._client.request(
138
+ "POST", f"/assets/workflows/approvals/{quote(approval_id, safe='')}/approve",
139
+ json=payload or {},
140
+ )
141
+
142
+ def reject_workflow(
143
+ self, approval_id: str, payload: dict[str, Any] | None = None
144
+ ) -> dict[str, Any]:
145
+ return self._client.request(
146
+ "POST", f"/assets/workflows/approvals/{quote(approval_id, safe='')}/reject",
147
+ json=payload or {},
148
+ )
149
+
150
+ def workflow_connections(self) -> list[dict[str, Any]]:
151
+ return self._client.request("GET", "/assets/workflows/connections")
152
+
153
+ def create_workflow_connection(self, payload: dict[str, Any]) -> dict[str, Any]:
154
+ return self._client.request("POST", "/assets/workflows/connections", json=payload)
155
+
156
+ def rotate_workflow_connection_credential(
157
+ self, connection_id: str, payload: dict[str, Any]
158
+ ) -> dict[str, Any]:
159
+ return self._client.request(
160
+ "POST",
161
+ f"/assets/workflows/connections/{quote(connection_id, safe='')}/credential/rotate",
162
+ json=payload,
163
+ )
164
+
165
+ def rotate_workflow_inbound_hook(self, workflow_id: str) -> dict[str, Any]:
166
+ return self._client.request(
167
+ "POST", f"{self._workflow_path(workflow_id)}/inbound-hook/rotate"
168
+ )
169
+
170
+ @staticmethod
171
+ def _workflow_path(workflow_id: str) -> str:
172
+ return f"/assets/workflows/{quote(workflow_id, safe='')}"
173
+
174
+ @staticmethod
175
+ def _workflow_run_path(run_id: str) -> str:
176
+ return f"/assets/workflows/runs/{quote(run_id, safe='')}"
@@ -0,0 +1,215 @@
1
+ Metadata-Version: 2.4
2
+ Name: stackshift
3
+ Version: 1.0.1
4
+ Summary: Official Python SDK for StackShift.
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: requests>=2.31
8
+
9
+ # StackShift Python SDK
10
+
11
+ Official Python SDK for StackShift.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install stackshift
17
+ ```
18
+
19
+ ## Send email
20
+
21
+ ```python
22
+ from stackshift import StackShift
23
+
24
+ stackshift = StackShift()
25
+
26
+ message = stackshift.mail.send(
27
+ from_="StackShift <noreply@mail.stackshift.cloud>",
28
+ to="ada@example.com",
29
+ subject="Welcome",
30
+ text="Welcome to StackShift.",
31
+ idempotency_key="welcome:user_123",
32
+ )
33
+
34
+ print(message["id"], message["status"], message["idempotencyStatus"])
35
+ ```
36
+
37
+ Inspect message status:
38
+
39
+ ```python
40
+ messages = stackshift.mail.messages.list(status="mta_accepted", limit=20)
41
+ detail = stackshift.mail.messages.get(messages["data"][0]["id"])
42
+ attempts = stackshift.mail.messages.attempts(detail["id"])
43
+ logs = stackshift.mail.messages.logs(detail["id"])
44
+ ```
45
+
46
+ `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`.
47
+
48
+ ## Events and webhooks
49
+
50
+ ```python
51
+ events = stackshift.mail.events.list(
52
+ type="mail.message.bounced",
53
+ message_id="msg_123",
54
+ limit=20,
55
+ )
56
+
57
+ event = stackshift.mail.events.get("evt_123")
58
+ timeline = stackshift.mail.messages.timeline("msg_123")
59
+
60
+ webhook = stackshift.mail.webhooks.create(
61
+ url="https://example.com/stackshift-mail",
62
+ event_types=["mail.message.bounced", "mail.otp.verified"],
63
+ )
64
+ print(webhook["id"], webhook["secret"]) # Secret is only returned on create/rotate.
65
+
66
+ deliveries = stackshift.mail.webhooks.deliveries(webhook["id"], status="failed")
67
+ retried = stackshift.mail.webhooks.retry_delivery(deliveries["data"][0]["id"])
68
+ ```
69
+
70
+ Verify a webhook signature before processing the payload:
71
+
72
+ ```python
73
+ valid = stackshift.mail.webhooks.verify_signature(
74
+ raw_body=request.get_data(),
75
+ signature_header=request.headers.get("StackShift-Signature"),
76
+ timestamp_header=request.headers.get("StackShift-Timestamp"),
77
+ secret=os.environ["STACKSHIFT_WEBHOOK_SECRET"],
78
+ )
79
+ ```
80
+
81
+ Webhook handlers should be idempotent. StackShift retries non-2xx responses. Delivery events do not include full email bodies or OTP codes by default.
82
+
83
+ ## Send a template
84
+
85
+ Templates are rendered by StackShift servers. The Python SDK only calls the REST API.
86
+
87
+ ```python
88
+ stackshift.mail.templates.create(
89
+ name="Welcome Email",
90
+ slug="welcome-email",
91
+ subject="Welcome, {{name}}",
92
+ text="Welcome, {{name}}",
93
+ )
94
+
95
+ preview = stackshift.mail.templates.preview(
96
+ "welcome-email",
97
+ data={"name": "Ada"},
98
+ )
99
+
100
+ message = stackshift.mail.send_template(
101
+ template="welcome-email",
102
+ to="ada@example.com",
103
+ from_="Acme <noreply@acme.com>",
104
+ data={"name": "Ada"},
105
+ idempotency_key="welcome:user_123",
106
+ )
107
+ ```
108
+
109
+ 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`.
110
+
111
+ ## Bounces and suppressions
112
+
113
+ ```python
114
+ suppressions = stackshift.mail.suppressions.list()
115
+
116
+ manual = stackshift.mail.suppressions.create(
117
+ email="bad@example.com",
118
+ reason="manual",
119
+ )
120
+
121
+ stackshift.mail.suppressions.delete(manual["id"])
122
+
123
+ bounces = stackshift.mail.bounces.list(type="hard")
124
+ message_bounces = stackshift.mail.messages.bounces("msg_123")
125
+ ```
126
+
127
+ Hard bounces are automatically suppressed by the backend. Suppressions are workspace scoped and checked before a message is queued.
128
+
129
+ ## Verify a sending domain
130
+
131
+ ```python
132
+ domain = stackshift.mail.domains.create("acme.com")
133
+ print(domain["records"])
134
+
135
+ stackshift.mail.domains.verify(domain["id"])
136
+
137
+ message = stackshift.mail.send(
138
+ from_="Acme <noreply@acme.com>",
139
+ to="user@example.com",
140
+ subject="Welcome",
141
+ html="<h1>Welcome</h1>",
142
+ text="Welcome",
143
+ )
144
+ ```
145
+
146
+ 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.
147
+
148
+ ## Upload an asset
149
+
150
+ ```python
151
+ from stackshift import StackShift
152
+
153
+ stackshift = StackShift()
154
+
155
+ asset = stackshift.assets.upload(
156
+ "avatar.png",
157
+ folder="avatars",
158
+ visibility="public",
159
+ metadata={"user_id": "user_123"},
160
+ )
161
+
162
+ print(asset["url"])
163
+ ```
164
+
165
+ You do not pass a deployed StackShift project ID. The API key identifies the StackShift account, and StackShift resolves the default asset space internally.
166
+
167
+ ## Private asset URL
168
+
169
+ ```python
170
+ signed = stackshift.assets.signed_url(
171
+ asset["id"],
172
+ expires_in="10m",
173
+ max_downloads=1,
174
+ )
175
+
176
+ print(signed["url"])
177
+ ```
178
+
179
+ ## Image transformations
180
+
181
+ Use built-in presets or create your own named transformations.
182
+
183
+ ```python
184
+ from stackshift import StackShift, asset_transform_options, get_asset_transform_preset
185
+
186
+ stackshift = StackShift()
187
+ hero = get_asset_transform_preset("hero")
188
+ hero_options = asset_transform_options(hero)
189
+
190
+ stackshift.assets.create_transformation(hero["name"], **hero_options)
191
+
192
+ named = stackshift.assets.named_url("asset_123", hero["name"])
193
+ signed = stackshift.assets.signed_transform_url("asset_123", **hero_options, expiresIn="10m")
194
+
195
+ stackshift.assets.delete_transformation("old-preset")
196
+ print(named, signed["url"])
197
+ ```
198
+
199
+ ## Direct browser uploads
200
+
201
+ Create the upload session on your Python backend:
202
+
203
+ ```python
204
+ upload = stackshift.assets.signed_upload_url(
205
+ bucket="avatars",
206
+ key="users/user_123.png",
207
+ visibility="public",
208
+ expiresIn="10m",
209
+ maxBytes=5_000_000,
210
+ )
211
+ ```
212
+
213
+ Then upload the browser `File` to `upload["url"]` from your frontend. Do not expose your StackShift API key to browser code.
214
+
215
+ The SDK only talks to the StackShift REST API. Storage placement, replication, disks, and repair are StackShift internals.
@@ -2,6 +2,7 @@ README.md
2
2
  pyproject.toml
3
3
  stackshift/__init__.py
4
4
  stackshift/assets.py
5
+ stackshift/assets_workflows.py
5
6
  stackshift/client.py
6
7
  stackshift/mail.py
7
8
  stackshift/mail_advanced.py
@@ -16,6 +17,7 @@ stackshift.egg-info/SOURCES.txt
16
17
  stackshift.egg-info/dependency_links.txt
17
18
  stackshift.egg-info/requires.txt
18
19
  stackshift.egg-info/top_level.txt
20
+ tests/test_assets_workflows.py
19
21
  tests/test_mail.py
20
22
  tests/test_mail_advanced.py
21
23
  tests/test_mail_diagnostics.py
@@ -0,0 +1,34 @@
1
+ from stackshift.assets import AssetsClient
2
+
3
+
4
+ class FakeClient:
5
+ def __init__(self):
6
+ self.calls = []
7
+
8
+ def request(self, method, path, **kwargs):
9
+ self.calls.append((method, path, kwargs))
10
+ return {"method": method, "path": path}
11
+
12
+
13
+ def test_transform_generation_and_workflow_routes():
14
+ transport = FakeClient()
15
+ assets = AssetsClient(transport)
16
+
17
+ assets.materialize_transform(
18
+ "asset/1", preset="web-ready", idempotency_key="render-1"
19
+ )
20
+ method, path, options = transport.calls[-1]
21
+ assert (method, path) == ("POST", "/assets/asset%2F1/transforms")
22
+ assert options["json"] == {"preset": "web-ready"}
23
+ assert options["headers"]["Idempotency-Key"] == "render-1"
24
+
25
+ assets.generate(
26
+ {"prompt": "A violet nebula", "size": "1024x1024", "format": "png", "bucket": "generated"},
27
+ idempotency_key="generation-1",
28
+ )
29
+ assert transport.calls[-1][2]["headers"]["Idempotency-Key"] == "generation-1"
30
+
31
+ assets.publish_workflow("workflow/1", 4)
32
+ method, path, options = transport.calls[-1]
33
+ assert (method, path) == ("POST", "/assets/workflows/workflow%2F1/publish")
34
+ assert options["json"] == {"expected_revision": 4}
stackshift-1.0.0/PKG-INFO DELETED
@@ -1,6 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: stackshift
3
- Version: 1.0.0
4
- Summary: Official Python SDK for StackShift.
5
- Requires-Python: >=3.9
6
- Requires-Dist: requests>=2.31
@@ -1,6 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: stackshift
3
- Version: 1.0.0
4
- Summary: Official Python SDK for StackShift.
5
- Requires-Python: >=3.9
6
- Requires-Dist: requests>=2.31
File without changes
File without changes