stackshift 1.0.1__tar.gz → 1.1.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 (42) hide show
  1. {stackshift-1.0.1 → stackshift-1.1.0}/PKG-INFO +38 -1
  2. {stackshift-1.0.1 → stackshift-1.1.0}/README.md +37 -0
  3. {stackshift-1.0.1 → stackshift-1.1.0}/pyproject.toml +1 -1
  4. {stackshift-1.0.1 → stackshift-1.1.0}/stackshift/__init__.py +2 -0
  5. {stackshift-1.0.1 → stackshift-1.1.0}/stackshift/assets.py +4 -0
  6. stackshift-1.1.0/stackshift/assets_dam.py +193 -0
  7. stackshift-1.1.0/stackshift/assets_media.py +48 -0
  8. stackshift-1.1.0/stackshift/assets_video.py +65 -0
  9. {stackshift-1.0.1 → stackshift-1.1.0}/stackshift/client.py +2 -0
  10. {stackshift-1.0.1 → stackshift-1.1.0}/stackshift/mail.py +24 -1
  11. {stackshift-1.0.1 → stackshift-1.1.0}/stackshift/mail_advanced.py +4 -0
  12. stackshift-1.1.0/stackshift/mail_billing.py +9 -0
  13. stackshift-1.1.0/stackshift/mail_brands.py +35 -0
  14. stackshift-1.1.0/stackshift/mail_campaigns.py +108 -0
  15. stackshift-1.1.0/stackshift/mail_marketing.py +35 -0
  16. stackshift-1.1.0/stackshift/mail_migration.py +20 -0
  17. stackshift-1.1.0/stackshift/mail_tracking.py +33 -0
  18. stackshift-1.1.0/stackshift/workload_security.py +67 -0
  19. {stackshift-1.0.1 → stackshift-1.1.0}/stackshift.egg-info/PKG-INFO +38 -1
  20. {stackshift-1.0.1 → stackshift-1.1.0}/stackshift.egg-info/SOURCES.txt +15 -1
  21. stackshift-1.1.0/tests/test_assets_media.py +110 -0
  22. stackshift-1.1.0/tests/test_mail_platform.py +29 -0
  23. stackshift-1.1.0/tests/test_mail_simulation.py +28 -0
  24. stackshift-1.1.0/tests/test_mail_subscribers.py +37 -0
  25. stackshift-1.1.0/tests/test_mail_tracking.py +37 -0
  26. stackshift-1.0.1/stackshift/mail_campaigns.py +0 -61
  27. {stackshift-1.0.1 → stackshift-1.1.0}/setup.cfg +0 -0
  28. {stackshift-1.0.1 → stackshift-1.1.0}/stackshift/assets_workflows.py +0 -0
  29. {stackshift-1.0.1 → stackshift-1.1.0}/stackshift/mail_events_webhooks.py +0 -0
  30. {stackshift-1.0.1 → stackshift-1.1.0}/stackshift/mail_exports.py +0 -0
  31. {stackshift-1.0.1 → stackshift-1.1.0}/stackshift/mail_reputation.py +0 -0
  32. {stackshift-1.0.1 → stackshift-1.1.0}/stackshift/mail_streams.py +0 -0
  33. {stackshift-1.0.1 → stackshift-1.1.0}/stackshift/projects.py +0 -0
  34. {stackshift-1.0.1 → stackshift-1.1.0}/stackshift.egg-info/dependency_links.txt +0 -0
  35. {stackshift-1.0.1 → stackshift-1.1.0}/stackshift.egg-info/requires.txt +0 -0
  36. {stackshift-1.0.1 → stackshift-1.1.0}/stackshift.egg-info/top_level.txt +0 -0
  37. {stackshift-1.0.1 → stackshift-1.1.0}/tests/test_assets_workflows.py +0 -0
  38. {stackshift-1.0.1 → stackshift-1.1.0}/tests/test_mail.py +0 -0
  39. {stackshift-1.0.1 → stackshift-1.1.0}/tests/test_mail_advanced.py +0 -0
  40. {stackshift-1.0.1 → stackshift-1.1.0}/tests/test_mail_diagnostics.py +0 -0
  41. {stackshift-1.0.1 → stackshift-1.1.0}/tests/test_mail_events_webhooks.py +0 -0
  42. {stackshift-1.0.1 → stackshift-1.1.0}/tests/test_mail_exports.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackshift
3
- Version: 1.0.1
3
+ Version: 1.1.0
4
4
  Summary: Official Python SDK for StackShift.
5
5
  Requires-Python: >=3.9
6
6
  Description-Content-Type: text/markdown
@@ -213,3 +213,40 @@ upload = stackshift.assets.signed_upload_url(
213
213
  Then upload the browser `File` to `upload["url"]` from your frontend. Do not expose your StackShift API key to browser code.
214
214
 
215
215
  The SDK only talks to the StackShift REST API. Storage placement, replication, disks, and repair are StackShift internals.
216
+
217
+ ### Campaign drafts and engagement
218
+
219
+ `mail.campaigns.create(content={...})` accepts a private campaign content snapshot; template/version inputs still work. Save the complete draft with `update(id, revision=revision, **draft)` (draft keys use API camelCase). `preview`, `test`, `send(id, revision=revision)`, and `schedule(id, send_at, revision=revision)` protect the reviewed content. Stale revisions return 409.
220
+
221
+ Send methods accept `tracking={"opens": False, "clicks": True}`. Omitted fields inherit workspace defaults. `mail.tracking` exposes `settings`, `update_settings`, `message`, `campaign`, and branded-domain methods. Deployment capability flags control availability. Opens are approximate; detailed activity expires after 90 days. OTPs and tests never track.
222
+
223
+ ### Isolated testing, brands, migration, and spending
224
+
225
+ Use a `sspat_test_` credential and `simulation={"scenario": "delayed"}` for a 60-second simulated delay. Delivered, bounced, and complained outcomes are also supported. Test resources are isolated; no real email is delivered.
226
+
227
+ ```python
228
+ brand = client.mail.brands.create(name="Product", editable_fields=["company"])
229
+ audience = client.mail.audiences.create(brand_id=brand["id"], name="Product news")
230
+ ```
231
+
232
+ Audience creation requires an explicit `brand_id`. Shared profile fields belong to the brand; consent stays per audience. `mail.automations`, `mail.forms`, and `mail.segments` expose create/list methods; their input dictionaries use API camelCase keys.
233
+
234
+ `mail.migration` supports public DNS audits/plans and authenticated checklists. `mail.billing.summary()` and `mail.campaigns.estimate(id)` expose allowance and credit. Pass `approved_max_kobo=0` (or another approved maximum) alongside `revision` when sending/scheduling campaigns. Billing remains inactive until a rate card is explicitly published and activated; funding and spending opt-in are separate owner-only dashboard actions.
235
+
236
+ ## Native video, governed DAM and product rendering
237
+
238
+ Use the space-scoped DAM and video clients for collaborators, metadata schemas, bucket governance, reviewed publications, picker capabilities, model preparation, product rendering, galleries, version-pinned encoding, captions and playback sessions.
239
+
240
+ ```python
241
+ dam = sdk.assets.dam.for_space(space_id)
242
+ video = sdk.assets.video.for_space(space_id)
243
+ schema = dam.create_metadata_schema({"name": "Products", "fields": [{
244
+ "key": "product_name", "label": "Product name", "type": "text", "required": True,
245
+ }]})
246
+ published = dam.publish_metadata_schema(schema["id"], schema["revision"])
247
+ workspace = video.workspace(asset_id)
248
+ ```
249
+
250
+ Mutations carry the supplied revision in `If-Match`. Model preparation and rendering carry an idempotency key; render submission also carries the accepted maximum units. Playback renewal and events authenticate with the session credential, not the account key. Keep these clients on your backend and return grants with `Cache-Control: private, no-store`.
251
+
252
+ See the [Assets SDK guide](https://docs.stackshift.cloud/assets/sdk-media-workflows) and its linked feature guides for complete language examples, inputs, permissions, review transitions and browser callbacks.
@@ -205,3 +205,40 @@ upload = stackshift.assets.signed_upload_url(
205
205
  Then upload the browser `File` to `upload["url"]` from your frontend. Do not expose your StackShift API key to browser code.
206
206
 
207
207
  The SDK only talks to the StackShift REST API. Storage placement, replication, disks, and repair are StackShift internals.
208
+
209
+ ### Campaign drafts and engagement
210
+
211
+ `mail.campaigns.create(content={...})` accepts a private campaign content snapshot; template/version inputs still work. Save the complete draft with `update(id, revision=revision, **draft)` (draft keys use API camelCase). `preview`, `test`, `send(id, revision=revision)`, and `schedule(id, send_at, revision=revision)` protect the reviewed content. Stale revisions return 409.
212
+
213
+ Send methods accept `tracking={"opens": False, "clicks": True}`. Omitted fields inherit workspace defaults. `mail.tracking` exposes `settings`, `update_settings`, `message`, `campaign`, and branded-domain methods. Deployment capability flags control availability. Opens are approximate; detailed activity expires after 90 days. OTPs and tests never track.
214
+
215
+ ### Isolated testing, brands, migration, and spending
216
+
217
+ Use a `sspat_test_` credential and `simulation={"scenario": "delayed"}` for a 60-second simulated delay. Delivered, bounced, and complained outcomes are also supported. Test resources are isolated; no real email is delivered.
218
+
219
+ ```python
220
+ brand = client.mail.brands.create(name="Product", editable_fields=["company"])
221
+ audience = client.mail.audiences.create(brand_id=brand["id"], name="Product news")
222
+ ```
223
+
224
+ Audience creation requires an explicit `brand_id`. Shared profile fields belong to the brand; consent stays per audience. `mail.automations`, `mail.forms`, and `mail.segments` expose create/list methods; their input dictionaries use API camelCase keys.
225
+
226
+ `mail.migration` supports public DNS audits/plans and authenticated checklists. `mail.billing.summary()` and `mail.campaigns.estimate(id)` expose allowance and credit. Pass `approved_max_kobo=0` (or another approved maximum) alongside `revision` when sending/scheduling campaigns. Billing remains inactive until a rate card is explicitly published and activated; funding and spending opt-in are separate owner-only dashboard actions.
227
+
228
+ ## Native video, governed DAM and product rendering
229
+
230
+ Use the space-scoped DAM and video clients for collaborators, metadata schemas, bucket governance, reviewed publications, picker capabilities, model preparation, product rendering, galleries, version-pinned encoding, captions and playback sessions.
231
+
232
+ ```python
233
+ dam = sdk.assets.dam.for_space(space_id)
234
+ video = sdk.assets.video.for_space(space_id)
235
+ schema = dam.create_metadata_schema({"name": "Products", "fields": [{
236
+ "key": "product_name", "label": "Product name", "type": "text", "required": True,
237
+ }]})
238
+ published = dam.publish_metadata_schema(schema["id"], schema["revision"])
239
+ workspace = video.workspace(asset_id)
240
+ ```
241
+
242
+ Mutations carry the supplied revision in `If-Match`. Model preparation and rendering carry an idempotency key; render submission also carries the accepted maximum units. Playback renewal and events authenticate with the session credential, not the account key. Keep these clients on your backend and return grants with `Cache-Control: private, no-store`.
243
+
244
+ See the [Assets SDK guide](https://docs.stackshift.cloud/assets/sdk-media-workflows) and its linked feature guides for complete language examples, inputs, permissions, review transitions and browser callbacks.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "stackshift"
7
- version = "1.0.1"
7
+ version = "1.1.0"
8
8
  description = "Official Python SDK for StackShift."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -13,6 +13,7 @@ from .mail_streams import MailStreamsClient
13
13
  from .mail_campaigns import MailAudiencesClient, MailCampaignsClient
14
14
  from .mail_exports import MailExportsClient
15
15
  from .projects import ProjectsClient
16
+ from .workload_security import WorkloadSecurityClient
16
17
 
17
18
  __all__ = [
18
19
  "StackShift",
@@ -33,6 +34,7 @@ __all__ = [
33
34
  "MailCampaignsClient",
34
35
  "MailExportsClient",
35
36
  "ProjectsClient",
37
+ "WorkloadSecurityClient",
36
38
  "asset_transform_options",
37
39
  "asset_transform_spec",
38
40
  "get_asset_transform_preset",
@@ -7,6 +7,8 @@ from typing import Any, BinaryIO
7
7
  from urllib.parse import quote
8
8
 
9
9
  from .assets_workflows import AssetWorkflowsMixin
10
+ from .assets_dam import AssetDAMClient
11
+ from .assets_video import AssetVideoClient
10
12
 
11
13
 
12
14
  ASSET_TRANSFORM_PRESETS: list[dict[str, Any]] = [
@@ -52,6 +54,8 @@ def asset_transform_spec(**options: Any) -> str:
52
54
  class AssetsClient(AssetWorkflowsMixin):
53
55
  def __init__(self, client: Any) -> None:
54
56
  self._client = client
57
+ self.dam = AssetDAMClient(client)
58
+ self.video = AssetVideoClient(client)
55
59
 
56
60
  def upload(
57
61
  self,
@@ -0,0 +1,193 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from urllib.parse import quote
5
+
6
+ from .assets_media import AssetMediaClient
7
+
8
+
9
+ class AssetDAMClient(AssetMediaClient):
10
+ def spaces(self):
11
+ return self._request("GET", f"/assets/spaces")
12
+
13
+ def collaborators(self):
14
+ return self._request("GET", f"/assets/collaborators")
15
+
16
+ def invite(self, input: dict[str, Any]):
17
+ return self._request("POST", f"/assets/collaborators", body=input)
18
+
19
+ def accept_invitation(self, id: str):
20
+ return self._request("POST", f"/assets/invitations/{quote(id, safe='')}/accept")
21
+
22
+ def update_collaborator(self, id: str, revision: int, actions: list[str]):
23
+ return self._request(
24
+ "PUT",
25
+ f"/assets/collaborators/{quote(id, safe='')}",
26
+ body={"actions": actions},
27
+ revision=revision,
28
+ )
29
+
30
+ def revoke_collaborator(self, id: str, revision: int):
31
+ return self._request(
32
+ "DELETE", f"/assets/collaborators/{quote(id, safe='')}", revision=revision
33
+ )
34
+
35
+ def metadata_schemas(self):
36
+ return self._request("GET", f"/assets/metadata-schemas")
37
+
38
+ def metadata_editor(self, asset_id: str):
39
+ return self._request(
40
+ "GET", f"/assets/{quote(asset_id, safe='')}/metadata-editor"
41
+ )
42
+
43
+ def create_metadata_schema(self, input: dict[str, Any]):
44
+ return self._request("POST", f"/assets/metadata-schemas", body=input)
45
+
46
+ def update_metadata_schema(self, id: str, revision: int, input: dict[str, Any]):
47
+ return self._request(
48
+ "PUT",
49
+ f"/assets/metadata-schemas/{quote(id, safe='')}",
50
+ body=input,
51
+ revision=revision,
52
+ )
53
+
54
+ def publish_metadata_schema(self, id: str, revision: int):
55
+ return self._request(
56
+ "POST",
57
+ f"/assets/metadata-schemas/{quote(id, safe='')}/publish",
58
+ revision=revision,
59
+ )
60
+
61
+ def bucket_governance(self, id: str):
62
+ return self._request("GET", f"/assets/buckets/{quote(id, safe='')}/governance")
63
+
64
+ def save_bucket_governance(self, id: str, input: dict[str, Any]):
65
+ return self._request(
66
+ "PUT", f"/assets/buckets/{quote(id, safe='')}/governance", body=input
67
+ )
68
+
69
+ def migrate_bucket_governance(self, id: str, revision: int):
70
+ return self._request(
71
+ "POST",
72
+ f"/assets/buckets/{quote(id, safe='')}/governance/migrate",
73
+ revision=revision,
74
+ )
75
+
76
+ def publications(self, query: dict[str, Any] | None = None):
77
+ return self._request("GET", f"/assets/publications", query=query)
78
+
79
+ def publication_renditions(self, asset_id: str):
80
+ return self._request(
81
+ "GET", f"/assets/{quote(asset_id, safe='')}/publication-renditions"
82
+ )
83
+
84
+ def create_publication(self, input: dict[str, Any]):
85
+ return self._request("POST", f"/assets/publications", body=input)
86
+
87
+ def update_publication(self, id: str, revision: int, input: dict[str, Any]):
88
+ return self._request(
89
+ "PUT",
90
+ f"/assets/publications/{quote(id, safe='')}",
91
+ body=input,
92
+ revision=revision,
93
+ )
94
+
95
+ def transition_publication(self, id: str, revision: int, action: str, note: str):
96
+ return self._request(
97
+ "POST",
98
+ f"/assets/publications/{quote(id, safe='')}/{quote(action, safe='')}",
99
+ body={"note": note},
100
+ revision=revision,
101
+ )
102
+
103
+ def resolve_publication(self, id: str):
104
+ return self._request(
105
+ "GET", f"/assets/publications/{quote(id, safe='')}/resolve"
106
+ )
107
+
108
+ def preview_publication(self, id: str):
109
+ return self._request(
110
+ "GET", f"/assets/publications/{quote(id, safe='')}/preview"
111
+ )
112
+
113
+ def resolve_publication_ar(self, id: str):
114
+ return self._request("POST", f"/assets/publications/{quote(id, safe='')}/ar")
115
+
116
+ def create_picker_capability(self, input: dict[str, Any]):
117
+ return self._request("POST", f"/assets/picker-capabilities", body=input)
118
+
119
+ def revoke_picker_capability(self, id: str):
120
+ return self._request(
121
+ "DELETE", f"/assets/picker-capabilities/{quote(id, safe='')}"
122
+ )
123
+
124
+ def models(self, asset_id: str):
125
+ return self._request("GET", f"/assets/{quote(asset_id, safe='')}/models")
126
+
127
+ def estimate_render(self, asset_id: str, revision: int, input: dict[str, Any]):
128
+ return self._request(
129
+ "POST",
130
+ f"/assets/{quote(asset_id, safe='')}/models",
131
+ body={**input, "operation": "estimate"},
132
+ revision=revision,
133
+ )
134
+
135
+ def render_product(
136
+ self,
137
+ asset_id: str,
138
+ revision: int,
139
+ idempotency_key: str,
140
+ input: dict[str, Any],
141
+ maximum_units: int,
142
+ ):
143
+ return self._request(
144
+ "POST",
145
+ f"/assets/{quote(asset_id, safe='')}/models",
146
+ body={**input, "operation": "render", "maximum_units": maximum_units},
147
+ revision=revision,
148
+ idempotency_key=idempotency_key,
149
+ )
150
+
151
+ def process_model(self, asset_id: str, revision: int, idempotency_key: str):
152
+ return self._request(
153
+ "POST",
154
+ f"/assets/{quote(asset_id, safe='')}/models",
155
+ revision=revision,
156
+ idempotency_key=idempotency_key,
157
+ )
158
+
159
+ def galleries(self, query: dict[str, Any] | None = None):
160
+ return self._request("GET", f"/assets/galleries", query=query)
161
+
162
+ def create_gallery(self, input: dict[str, Any]):
163
+ return self._request("POST", f"/assets/galleries", body=input)
164
+
165
+ def update_gallery(self, id: str, revision: int, input: dict[str, Any]):
166
+ return self._request(
167
+ "PUT",
168
+ f"/assets/galleries/{quote(id, safe='')}",
169
+ body=input,
170
+ revision=revision,
171
+ )
172
+
173
+ def transition_gallery(self, id: str, revision: int, action: str):
174
+ return self._request(
175
+ "POST",
176
+ f"/assets/galleries/{quote(id, safe='')}/{quote(action, safe='')}",
177
+ revision=revision,
178
+ )
179
+
180
+ def resolve_gallery(self, id: str):
181
+ return self._request("GET", f"/assets/galleries/{quote(id, safe='')}/resolve")
182
+
183
+ def preview_gallery(self, id: str):
184
+ return self._request(
185
+ "GET", f"/assets/galleries/{quote(id, safe='')}/resolve?preview=true"
186
+ )
187
+
188
+ def resolve_gallery_ar(self, id: str, publication_id: str):
189
+ return self._request(
190
+ "POST",
191
+ f"/assets/galleries/{quote(id, safe='')}/ar",
192
+ body={"publication_id": publication_id},
193
+ )
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from urllib.parse import urlsplit
5
+
6
+
7
+ class AssetMediaClient:
8
+ def __init__(self, client: Any, space_id: str = "") -> None:
9
+ self._client = client
10
+ self._space_id = space_id
11
+
12
+ def for_space(self, space_id: str):
13
+ return type(self)(self._client, space_id)
14
+
15
+ def _request(
16
+ self,
17
+ method: str,
18
+ path: str,
19
+ *,
20
+ body=None,
21
+ revision=None,
22
+ idempotency_key=None,
23
+ query=None,
24
+ ):
25
+ headers = {}
26
+ if self._space_id:
27
+ headers["X-Asset-Space-ID"] = self._space_id
28
+ if revision is not None:
29
+ headers["If-Match"] = f'"{revision}"'
30
+ if idempotency_key is not None:
31
+ headers["Idempotency-Key"] = idempotency_key
32
+ return self._client.request(
33
+ method, path, json=body, headers=headers, params=query
34
+ )
35
+
36
+ def _playback(self, path: str, credential: str, body: dict[str, Any]):
37
+ from .client import StackShift
38
+
39
+ if not credential.strip():
40
+ raise ValueError("Playback credential is required")
41
+ base = urlsplit(self._client.base_url)
42
+ # A separate client prevents account authentication from replacing the session credential.
43
+ playback = StackShift(
44
+ api_key=credential,
45
+ base_url=f"{base.scheme}://{base.netloc}",
46
+ session=self._client.session,
47
+ )
48
+ return playback.request("POST", "/playback" + path, json=body)
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from urllib.parse import quote
5
+
6
+ from .assets_media import AssetMediaClient
7
+
8
+
9
+ class AssetVideoClient(AssetMediaClient):
10
+ def workspace(self, asset_id: str):
11
+ return self._request("GET", f"/assets/{quote(asset_id, safe='')}/video")
12
+
13
+ def process(self, asset_id: str, version_id: str):
14
+ return self._request(
15
+ "POST",
16
+ f"/assets/{quote(asset_id, safe='')}/versions/{quote(version_id, safe='')}/video",
17
+ )
18
+
19
+ def create_session(self, asset_id: str):
20
+ return self._request(
21
+ "POST", f"/assets/{quote(asset_id, safe='')}/playback-sessions"
22
+ )
23
+
24
+ def revoke_session(self, asset_id: str, session_id: str):
25
+ return self._request(
26
+ "DELETE",
27
+ f"/assets/{quote(asset_id, safe='')}/playback-sessions/{quote(session_id, safe='')}",
28
+ )
29
+
30
+ def captions(self, asset_id: str, version_id: str):
31
+ return self._request(
32
+ "GET",
33
+ f"/assets/{quote(asset_id, safe='')}/versions/{quote(version_id, safe='')}/video/captions",
34
+ )
35
+
36
+ def add_caption(self, asset_id: str, version_id: str, input: dict[str, Any]):
37
+ return self._request(
38
+ "POST",
39
+ f"/assets/{quote(asset_id, safe='')}/versions/{quote(version_id, safe='')}/video/captions",
40
+ body=input,
41
+ )
42
+
43
+ def remove_caption(self, asset_id: str, version_id: str, caption_id: str):
44
+ return self._request(
45
+ "DELETE",
46
+ f"/assets/{quote(asset_id, safe='')}/versions/{quote(version_id, safe='')}/video/captions/{quote(caption_id, safe='')}",
47
+ )
48
+
49
+ def review(self, asset_id: str, version_id: str, decision: str, reason: str):
50
+ return self._request(
51
+ "POST",
52
+ f"/assets/{quote(asset_id, safe='')}/versions/{quote(version_id, safe='')}/video/review",
53
+ body={"decision": decision, "reason": reason},
54
+ )
55
+
56
+ def analytics(self, asset_id: str):
57
+ return self._request(
58
+ "GET", f"/assets/{quote(asset_id, safe='')}/video/analytics"
59
+ )
60
+
61
+ def renew_session(self, credential: str):
62
+ return self._playback("/sessions/renew", credential, {})
63
+
64
+ def events(self, credential: str, events: list[dict[str, Any]]):
65
+ return self._playback("/events", credential, {"events": events})
@@ -29,10 +29,12 @@ class StackShift:
29
29
  from .assets import AssetsClient
30
30
  from .mail import MailClient
31
31
  from .projects import ProjectsClient
32
+ from .workload_security import WorkloadSecurityClient
32
33
 
33
34
  self.projects = ProjectsClient(self)
34
35
  self.assets = AssetsClient(self)
35
36
  self.mail = MailClient(self)
37
+ self.workload_security = WorkloadSecurityClient(self)
36
38
 
37
39
  def request(self, method: str, path: str, **kwargs: Any) -> Any:
38
40
  return self._request(self.base_url, method, path, **kwargs)
@@ -7,8 +7,13 @@ from .mail_advanced import MailAnalyticsClient, MailBatchClient, MailInboundClie
7
7
  from .mail_events_webhooks import MailEventsClient, MailWebhooksClient
8
8
  from .mail_reputation import MailLimitsClient, MailReputationClient
9
9
  from .mail_streams import MailStreamsClient
10
+ from .mail_brands import MailBrandsClient
11
+ from .mail_billing import MailBillingClient
12
+ from .mail_marketing import MailAutomationsClient, MailFormsClient, MailSegmentsClient
13
+ from .mail_migration import MailMigrationClient
10
14
  from .mail_campaigns import MailAudiencesClient, MailCampaignsClient
11
15
  from .mail_exports import MailExportsClient
16
+ from .mail_tracking import MailTrackingClient
12
17
 
13
18
 
14
19
  class MailClient:
@@ -23,8 +28,15 @@ class MailClient:
23
28
  self.events = MailEventsClient(client)
24
29
  self.webhooks = MailWebhooksClient(client)
25
30
  self.streams = MailStreamsClient(client)
31
+ self.brands = MailBrandsClient(client)
32
+ self.billing = MailBillingClient(client)
33
+ self.automations = MailAutomationsClient(client)
34
+ self.forms = MailFormsClient(client)
35
+ self.segments = MailSegmentsClient(client)
36
+ self.migration = MailMigrationClient(client)
26
37
  self.audiences = MailAudiencesClient(client)
27
38
  self.campaigns = MailCampaignsClient(client)
39
+ self.tracking = MailTrackingClient(client)
28
40
  self.exports = MailExportsClient(client)
29
41
  self.limits = MailLimitsClient(client)
30
42
  self.reputation = MailReputationClient(client)
@@ -49,6 +61,8 @@ class MailClient:
49
61
  headers: dict[str, str] | None = None,
50
62
  tags: dict[str, str] | None = None,
51
63
  stream: str | None = None,
64
+ tracking: dict[str, bool] | None = None,
65
+ simulation: dict[str, str] | None = None,
52
66
  ) -> dict[str, Any]:
53
67
  payload = {
54
68
  "from": from_,
@@ -64,6 +78,8 @@ class MailClient:
64
78
  "headers": headers,
65
79
  "tags": tags,
66
80
  "stream": stream,
81
+ "tracking": tracking,
82
+ "simulation": simulation,
67
83
  }
68
84
  clean = {key: value for key, value in payload.items() if value is not None}
69
85
  return self._client.mail_request("POST", "/mail/send", json=clean)
@@ -84,6 +100,8 @@ class MailClient:
84
100
  headers: dict[str, str] | None = None,
85
101
  tags: dict[str, str] | None = None,
86
102
  stream: str | None = None,
103
+ tracking: dict[str, bool] | None = None,
104
+ simulation: dict[str, str] | None = None,
87
105
  ) -> dict[str, Any]:
88
106
  payload = {
89
107
  "template": template,
@@ -99,6 +117,8 @@ class MailClient:
99
117
  "headers": headers,
100
118
  "tags": tags,
101
119
  "stream": stream,
120
+ "tracking": tracking,
121
+ "simulation": simulation,
102
122
  }
103
123
  clean = {key: value for key, value in payload.items() if value is not None}
104
124
  return self._client.mail_request("POST", "/mail/send-template", json=clean)
@@ -201,8 +221,9 @@ class MailTemplatesClient:
201
221
  data: dict[str, Any],
202
222
  from_: str | dict[str, str] | None = None,
203
223
  version_id: str | None = None,
224
+ simulation: dict[str, str] | None = None,
204
225
  ) -> dict[str, Any]:
205
- payload = {"to": to, "from": from_, "data": data, "versionId": version_id}
226
+ payload = {"simulation": simulation, "to": to, "from": from_, "data": data, "versionId": version_id}
206
227
  clean = {key: value for key, value in payload.items() if value is not None}
207
228
  return self._client.mail_request("POST", f"/mail/templates/{quote(template_id)}/test-send", json=clean)
208
229
 
@@ -348,6 +369,7 @@ class MailOTPClient:
348
369
  idempotency_key: str | None = None,
349
370
  subject: str | None = None,
350
371
  brand_name: str | None = None,
372
+ simulation: dict[str, str] | None = None,
351
373
  ) -> dict[str, Any]:
352
374
  payload = {
353
375
  "to": to,
@@ -359,6 +381,7 @@ class MailOTPClient:
359
381
  "idempotencyKey": idempotency_key,
360
382
  "subject": subject,
361
383
  "brandName": brand_name,
384
+ "simulation": simulation,
362
385
  }
363
386
  clean = {key: value for key, value in payload.items() if value is not None}
364
387
  return self._client.mail_request("POST", "/mail/otp/send", json=clean)
@@ -39,6 +39,8 @@ class MailBatchClient:
39
39
  headers: dict[str, str] | None = None,
40
40
  tags: dict[str, str] | None = None,
41
41
  stream: str | None = None,
42
+ tracking: dict[str, bool] | None = None,
43
+ simulation: dict[str, str] | None = None,
42
44
  ) -> dict[str, Any]:
43
45
  payload = {
44
46
  "template": template,
@@ -48,6 +50,8 @@ class MailBatchClient:
48
50
  "headers": headers,
49
51
  "tags": tags,
50
52
  "stream": stream,
53
+ "tracking": tracking,
54
+ "simulation": simulation,
51
55
  }
52
56
  clean = {key: value for key, value in payload.items() if value is not None}
53
57
  return self._client.mail_request("POST", "/mail/batch-template", json=clean)
@@ -0,0 +1,9 @@
1
+ from typing import Any
2
+
3
+
4
+ class MailBillingClient:
5
+ def __init__(self, client: Any) -> None:
6
+ self._client = client
7
+
8
+ def summary(self) -> dict[str, Any]:
9
+ return self._client.mail_request("GET", "/mail/billing")
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from urllib.parse import quote
5
+
6
+
7
+ class MailBrandsClient:
8
+ def __init__(self, client: Any) -> None:
9
+ self._client = client
10
+
11
+ def create(self, *, name: str, **fields: Any) -> dict[str, Any]:
12
+ return self._client.mail_request("POST", "/mail/brands", json={"name": name, **_brand_fields(fields)})
13
+
14
+ def list(self) -> dict[str, Any]:
15
+ return self._client.mail_request("GET", "/mail/brands")
16
+
17
+ def get(self, brand_id: str) -> dict[str, Any]:
18
+ return self._client.mail_request("GET", f"/mail/brands/{quote(brand_id, safe='')}")
19
+
20
+ def update(self, brand_id: str, *, revision: int, name: str, **fields: Any) -> dict[str, Any]:
21
+ return self._client.mail_request(
22
+ "PUT", f"/mail/brands/{quote(brand_id, safe='')}",
23
+ json={"revision": revision, "name": name, **_brand_fields(fields)},
24
+ )
25
+
26
+ def archive(self, brand_id: str) -> dict[str, Any]:
27
+ return self._client.mail_request("POST", f"/mail/brands/{quote(brand_id, safe='')}/archive")
28
+
29
+
30
+ def _brand_fields(fields: dict[str, Any]) -> dict[str, Any]:
31
+ names = {"logo_url": "logoUrl", "sending_domain_id": "sendingDomainId", "default_from": "defaultFrom", "reply_to": "replyTo", "postal_address": "postalAddress", "website": "website", "support_email": "supportEmail", "editable_fields": "editableFields"}
32
+ unknown = fields.keys() - names.keys() - set(names.values())
33
+ if unknown:
34
+ raise ValueError("Unknown brand fields: " + ", ".join(sorted(unknown)))
35
+ return {names.get(key, key): value for key, value in fields.items()}