stackshift 1.3.0__tar.gz → 1.3.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.
- {stackshift-1.3.0 → stackshift-1.3.1}/PKG-INFO +20 -1
- {stackshift-1.3.0 → stackshift-1.3.1}/README.md +19 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/pyproject.toml +1 -1
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/__init__.py +2 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/assets.py +17 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/assets_workflows.py +8 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/client.py +2 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/mail.py +2 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/mail_billing.py +3 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/mail_brands.py +1 -1
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/mail_campaigns.py +6 -4
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/mail_events_webhooks.py +9 -0
- stackshift-1.3.1/stackshift/partner.py +93 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift.egg-info/PKG-INFO +20 -1
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift.egg-info/SOURCES.txt +2 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/tests/test_assets_workflows.py +17 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/tests/test_mail_events_webhooks.py +19 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/tests/test_mail_platform.py +24 -0
- stackshift-1.3.1/tests/test_partner.py +82 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/setup.cfg +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/ai_credits.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/assets_dam.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/assets_media.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/assets_platform.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/assets_video.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/mail_advanced.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/mail_exports.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/mail_mailboxes.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/mail_marketing.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/mail_migration.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/mail_reputation.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/mail_streams.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/mail_tracking.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/projects.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/support_action_manifest.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/support_action_receiver.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/support_action_sqlite.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/support_action_types.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/support_actions.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift/workload_security.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift.egg-info/dependency_links.txt +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift.egg-info/requires.txt +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/stackshift.egg-info/top_level.txt +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/tests/test_ai_credits.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/tests/test_assets_media.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/tests/test_assets_platform.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/tests/test_mail.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/tests/test_mail_advanced.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/tests/test_mail_diagnostics.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/tests/test_mail_exports.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/tests/test_mail_mailboxes.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/tests/test_mail_simulation.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/tests/test_mail_subscribers.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/tests/test_mail_tracking.py +0 -0
- {stackshift-1.3.0 → stackshift-1.3.1}/tests/test_support_actions.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackshift
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.1
|
|
4
4
|
Summary: Official Python SDK for StackShift.
|
|
5
5
|
Requires-Python: >=3.9
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -16,6 +16,25 @@ Official Python SDK for StackShift.
|
|
|
16
16
|
pip install stackshift
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
+
## Partner platforms
|
|
20
|
+
|
|
21
|
+
Staff-enabled partner accounts buy domains for merchants from their StackShift wallet. Use an API token with the `partner:manage` scope (and `mail:manage` to run campaigns) on your server:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
domain = stackshift.partner.purchase_domain(
|
|
25
|
+
domain="merchantshop.com",
|
|
26
|
+
years=1,
|
|
27
|
+
merchant_ref="m_123",
|
|
28
|
+
registrant={"first_name": "Ada", "last_name": "Obi", "email": "ada@merchantshop.com", "phone": "+2348012345678", "address": "1 Marina", "city": "Lagos", "state": "Lagos", "postal_code": "101001", "country": "NG"},
|
|
29
|
+
brand={"name": "Merchant Shop", "postalAddress": "1 Marina, Lagos, Nigeria"},
|
|
30
|
+
replies={"mode": "forward", "forwardTo": "ada@gmail.com"},
|
|
31
|
+
idempotency_key="order-m_123-1",
|
|
32
|
+
)
|
|
33
|
+
status = stackshift.partner.get_domain(domain["id"])
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`partner` also exposes `overview`, `update_settings`, `list_domains` (with `q` and `attention` to find the merchants that need you), `retry_domain`, `get_diagnosis`, `restore_dns`, `transfer_out`, `resume_brand`, `get_complaints`, `list_complaint_sources`, `suppress_never_opened`, `undo_suppression`, `usage`, `list_requests` and `create_request`. `transfer_out` needs the `partner:transfer` scope (or `*`); `partner:manage` alone is refused. Purchases are capped at 30 per minute and, per partner, at 25 domains in a rolling 24 hours (429 `daily_purchase_limit`; staff can raise it). Validation errors are 400, state conflicts are 409. The `brand` and `replies` dictionaries use API camelCase keys.
|
|
37
|
+
|
|
19
38
|
## AI credits
|
|
20
39
|
|
|
21
40
|
AI credits are account-scoped. Use the shared client to inspect usage, start a top-up, or select managed billing versus your own provider key:
|
|
@@ -8,6 +8,25 @@ Official Python SDK for StackShift.
|
|
|
8
8
|
pip install stackshift
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
+
## Partner platforms
|
|
12
|
+
|
|
13
|
+
Staff-enabled partner accounts buy domains for merchants from their StackShift wallet. Use an API token with the `partner:manage` scope (and `mail:manage` to run campaigns) on your server:
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
domain = stackshift.partner.purchase_domain(
|
|
17
|
+
domain="merchantshop.com",
|
|
18
|
+
years=1,
|
|
19
|
+
merchant_ref="m_123",
|
|
20
|
+
registrant={"first_name": "Ada", "last_name": "Obi", "email": "ada@merchantshop.com", "phone": "+2348012345678", "address": "1 Marina", "city": "Lagos", "state": "Lagos", "postal_code": "101001", "country": "NG"},
|
|
21
|
+
brand={"name": "Merchant Shop", "postalAddress": "1 Marina, Lagos, Nigeria"},
|
|
22
|
+
replies={"mode": "forward", "forwardTo": "ada@gmail.com"},
|
|
23
|
+
idempotency_key="order-m_123-1",
|
|
24
|
+
)
|
|
25
|
+
status = stackshift.partner.get_domain(domain["id"])
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`partner` also exposes `overview`, `update_settings`, `list_domains` (with `q` and `attention` to find the merchants that need you), `retry_domain`, `get_diagnosis`, `restore_dns`, `transfer_out`, `resume_brand`, `get_complaints`, `list_complaint_sources`, `suppress_never_opened`, `undo_suppression`, `usage`, `list_requests` and `create_request`. `transfer_out` needs the `partner:transfer` scope (or `*`); `partner:manage` alone is refused. Purchases are capped at 30 per minute and, per partner, at 25 domains in a rolling 24 hours (429 `daily_purchase_limit`; staff can raise it). Validation errors are 400, state conflicts are 409. The `brand` and `replies` dictionaries use API camelCase keys.
|
|
29
|
+
|
|
11
30
|
## AI credits
|
|
12
31
|
|
|
13
32
|
AI credits are account-scoped. Use the shared client to inspect usage, start a top-up, or select managed billing versus your own provider key:
|
|
@@ -21,6 +21,7 @@ from .mail_mailboxes import (
|
|
|
21
21
|
MailboxesClient,
|
|
22
22
|
MailboxRoutesClient,
|
|
23
23
|
)
|
|
24
|
+
from .partner import PartnerClient
|
|
24
25
|
from .projects import ProjectsClient
|
|
25
26
|
from .workload_security import WorkloadSecurityClient
|
|
26
27
|
|
|
@@ -49,6 +50,7 @@ __all__ = [
|
|
|
49
50
|
"MailboxImportsClient",
|
|
50
51
|
"MailboxesClient",
|
|
51
52
|
"MailboxRoutesClient",
|
|
53
|
+
"PartnerClient",
|
|
52
54
|
"ProjectsClient",
|
|
53
55
|
"WorkloadSecurityClient",
|
|
54
56
|
"asset_transform_options",
|
|
@@ -230,6 +230,23 @@ class AssetsClient(AssetWorkflowsMixin):
|
|
|
230
230
|
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
231
231
|
return self._client.request("POST", path, headers=headers)
|
|
232
232
|
|
|
233
|
+
def usage(self, asset_id: str) -> dict[str, Any]:
|
|
234
|
+
return self._client.request("GET", f"/assets/{quote(asset_id, safe='')}/usage")
|
|
235
|
+
|
|
236
|
+
def hold(self, asset_id: str) -> dict[str, Any]:
|
|
237
|
+
return self._client.request("GET", f"/assets/{quote(asset_id, safe='')}/hold")
|
|
238
|
+
|
|
239
|
+
def create_reupload_request(
|
|
240
|
+
self, asset_id: str, *, email: str | None = None, message: str | None = None
|
|
241
|
+
) -> dict[str, Any]:
|
|
242
|
+
payload = {key: value for key, value in {"email": email, "message": message}.items() if value}
|
|
243
|
+
return self._client.request(
|
|
244
|
+
"POST", f"/assets/{quote(asset_id, safe='')}/reupload-requests", json=payload
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def reupload_requests(self, asset_id: str) -> dict[str, Any]:
|
|
248
|
+
return self._client.request("GET", f"/assets/{quote(asset_id, safe='')}/reupload-requests")
|
|
249
|
+
|
|
233
250
|
def versions(self, asset_id: str) -> dict[str, Any]:
|
|
234
251
|
return self._client.request("GET", f"/assets/{asset_id}/versions")
|
|
235
252
|
|
|
@@ -17,6 +17,14 @@ class AssetWorkflowsMixin:
|
|
|
17
17
|
json={"name": name, "definition": definition, "eager": eager},
|
|
18
18
|
)
|
|
19
19
|
|
|
20
|
+
def estimate_transform(
|
|
21
|
+
self, asset_id: str, version_id: str, definition: dict[str, Any]
|
|
22
|
+
) -> dict[str, Any]:
|
|
23
|
+
return self._client.request(
|
|
24
|
+
"POST", f"/assets/{quote(asset_id, safe='')}/transforms/estimate",
|
|
25
|
+
json={"version_id": version_id, "definition": definition},
|
|
26
|
+
)
|
|
27
|
+
|
|
20
28
|
def materialize_transform(
|
|
21
29
|
self,
|
|
22
30
|
asset_id: str,
|
|
@@ -29,6 +29,7 @@ class StackShift:
|
|
|
29
29
|
from .ai_credits import AICreditsClient
|
|
30
30
|
from .assets import AssetsClient
|
|
31
31
|
from .mail import MailClient
|
|
32
|
+
from .partner import PartnerClient
|
|
32
33
|
from .projects import ProjectsClient
|
|
33
34
|
from .workload_security import WorkloadSecurityClient
|
|
34
35
|
|
|
@@ -36,6 +37,7 @@ class StackShift:
|
|
|
36
37
|
self.projects = ProjectsClient(self)
|
|
37
38
|
self.assets = AssetsClient(self)
|
|
38
39
|
self.mail = MailClient(self)
|
|
40
|
+
self.partner = PartnerClient(self)
|
|
39
41
|
self.workload_security = WorkloadSecurityClient(self)
|
|
40
42
|
|
|
41
43
|
def request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
@@ -311,12 +311,14 @@ class MailSuppressionsClient:
|
|
|
311
311
|
*,
|
|
312
312
|
email: str | None = None,
|
|
313
313
|
reason: str | None = None,
|
|
314
|
+
brand_id: str | None = None,
|
|
314
315
|
limit: int | None = None,
|
|
315
316
|
cursor: str | None = None,
|
|
316
317
|
) -> dict[str, Any]:
|
|
317
318
|
params = {
|
|
318
319
|
"email": email,
|
|
319
320
|
"reason": reason,
|
|
321
|
+
"brandId": brand_id,
|
|
320
322
|
"limit": limit,
|
|
321
323
|
"cursor": cursor,
|
|
322
324
|
}
|
|
@@ -7,3 +7,6 @@ class MailBillingClient:
|
|
|
7
7
|
|
|
8
8
|
def summary(self) -> dict[str, Any]:
|
|
9
9
|
return self._client.mail_request("GET", "/mail/billing")
|
|
10
|
+
|
|
11
|
+
def period_usage(self, period: str) -> dict[str, Any]:
|
|
12
|
+
return self._client.mail_request("GET", "/mail/billing", params={"period": period})
|
|
@@ -28,7 +28,7 @@ class MailBrandsClient:
|
|
|
28
28
|
|
|
29
29
|
|
|
30
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"}
|
|
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", "merchant_ref": "merchantRef"}
|
|
32
32
|
unknown = fields.keys() - names.keys() - set(names.values())
|
|
33
33
|
if unknown:
|
|
34
34
|
raise ValueError("Unknown brand fields: " + ", ".join(sorted(unknown)))
|
|
@@ -12,8 +12,9 @@ class MailAudiencesClient:
|
|
|
12
12
|
payload = {"brandId": brand_id, "name": name, "description": description}
|
|
13
13
|
return self._client.mail_request("POST", "/mail/audiences", json={key: value for key, value in payload.items() if value is not None})
|
|
14
14
|
|
|
15
|
-
def list(self) -> dict[str, Any]:
|
|
16
|
-
|
|
15
|
+
def list(self, *, brand_id: str | None = None) -> dict[str, Any]:
|
|
16
|
+
params = {"brandId": brand_id} if brand_id else None
|
|
17
|
+
return self._client.mail_request("GET", "/mail/audiences", params=params)
|
|
17
18
|
|
|
18
19
|
def get(self, audience_id: str) -> dict[str, Any]:
|
|
19
20
|
return self._client.mail_request("GET", f"/mail/audiences/{quote(audience_id, safe='')}")
|
|
@@ -83,8 +84,9 @@ class MailCampaignsClient:
|
|
|
83
84
|
}
|
|
84
85
|
return self._client.mail_request("POST", "/mail/campaigns", json={key: value for key, value in payload.items() if value is not None})
|
|
85
86
|
|
|
86
|
-
def list(self) -> dict[str, Any]:
|
|
87
|
-
|
|
87
|
+
def list(self, *, brand_id: str | None = None) -> dict[str, Any]:
|
|
88
|
+
params = {"brandId": brand_id} if brand_id else None
|
|
89
|
+
return self._client.mail_request("GET", "/mail/campaigns", params=params)
|
|
88
90
|
|
|
89
91
|
def get(self, campaign_id: str) -> dict[str, Any]:
|
|
90
92
|
return self._client.mail_request("GET", f"/mail/campaigns/{quote(campaign_id)}")
|
|
@@ -79,6 +79,15 @@ class MailWebhooksClient:
|
|
|
79
79
|
def rotate_secret(self, webhook_id: str) -> dict[str, Any]:
|
|
80
80
|
return self._client.mail_request("POST", f"/mail/webhooks/{quote(webhook_id)}/rotate-secret")
|
|
81
81
|
|
|
82
|
+
def reveal_secret(self, webhook_id: str) -> dict[str, Any]:
|
|
83
|
+
return self._client.mail_request("POST", f"/mail/webhooks/{quote(webhook_id)}/reveal-secret")
|
|
84
|
+
|
|
85
|
+
def diagnosis(self, webhook_id: str) -> dict[str, Any]:
|
|
86
|
+
return self._client.mail_request("GET", f"/mail/webhooks/{quote(webhook_id)}/diagnosis")
|
|
87
|
+
|
|
88
|
+
def diagnose(self, webhook_id: str) -> dict[str, Any]:
|
|
89
|
+
return self._client.mail_request("POST", f"/mail/webhooks/{quote(webhook_id)}/diagnosis")
|
|
90
|
+
|
|
82
91
|
def delete(self, webhook_id: str) -> None:
|
|
83
92
|
self._client.mail_request("DELETE", f"/mail/webhooks/{quote(webhook_id)}")
|
|
84
93
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PartnerClient:
|
|
8
|
+
def __init__(self, client: Any) -> None:
|
|
9
|
+
self._client = client
|
|
10
|
+
|
|
11
|
+
def overview(self) -> dict[str, Any]:
|
|
12
|
+
return self._client.request("GET", "/partner")
|
|
13
|
+
|
|
14
|
+
def update_settings(self, *, complaint_pause_rate: float, bounce_pause_rate: float) -> dict[str, Any]:
|
|
15
|
+
return self._client.request(
|
|
16
|
+
"PUT", "/partner/settings",
|
|
17
|
+
json={"complaintPauseRate": complaint_pause_rate, "bouncePauseRate": bounce_pause_rate},
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
def purchase_domain(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
domain: str,
|
|
24
|
+
years: int,
|
|
25
|
+
merchant_ref: str,
|
|
26
|
+
registrant: dict[str, Any],
|
|
27
|
+
brand: dict[str, Any],
|
|
28
|
+
replies: dict[str, Any],
|
|
29
|
+
idempotency_key: str,
|
|
30
|
+
currency: str | None = None,
|
|
31
|
+
) -> dict[str, Any]:
|
|
32
|
+
body = {"domain": domain, "years": years, "merchantRef": merchant_ref, "registrant": registrant, "brand": brand, "replies": replies}
|
|
33
|
+
if currency is not None:
|
|
34
|
+
body["currency"] = currency
|
|
35
|
+
return self._client.request("POST", "/partner/domains", headers={"Idempotency-Key": idempotency_key}, json=body)
|
|
36
|
+
|
|
37
|
+
def list_domains(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
merchant_ref: str | None = None,
|
|
41
|
+
q: str | None = None,
|
|
42
|
+
attention: bool | None = None,
|
|
43
|
+
limit: int | None = None,
|
|
44
|
+
offset: int | None = None,
|
|
45
|
+
) -> dict[str, Any]:
|
|
46
|
+
params = {"merchantRef": merchant_ref, "q": q, "attention": "true" if attention else None, "limit": limit, "offset": offset}
|
|
47
|
+
return self._client.request("GET", "/partner/domains", params={key: value for key, value in params.items() if value is not None})
|
|
48
|
+
|
|
49
|
+
def get_domain(self, domain_id: str) -> dict[str, Any]:
|
|
50
|
+
return self._client.request("GET", f"/partner/domains/{quote(domain_id, safe='')}")
|
|
51
|
+
|
|
52
|
+
def retry_domain(self, domain_id: str) -> dict[str, Any]:
|
|
53
|
+
return self._client.request("POST", f"/partner/domains/{quote(domain_id, safe='')}/retry")
|
|
54
|
+
|
|
55
|
+
def transfer_out(self, domain_id: str) -> dict[str, Any]:
|
|
56
|
+
return self._client.request("POST", f"/partner/domains/{quote(domain_id, safe='')}/transfer-out")
|
|
57
|
+
|
|
58
|
+
def get_diagnosis(self, domain_id: str) -> dict[str, Any]:
|
|
59
|
+
return self._client.request("GET", f"/partner/domains/{quote(domain_id, safe='')}/diagnosis")
|
|
60
|
+
|
|
61
|
+
def restore_dns(self, domain_id: str) -> dict[str, Any]:
|
|
62
|
+
return self._client.request("POST", f"/partner/domains/{quote(domain_id, safe='')}/restore-dns")
|
|
63
|
+
|
|
64
|
+
def resume_brand(self, brand_id: str) -> dict[str, Any]:
|
|
65
|
+
return self._client.request("POST", f"/partner/brands/{quote(brand_id, safe='')}/resume")
|
|
66
|
+
|
|
67
|
+
def get_complaints(self, brand_id: str) -> dict[str, Any]:
|
|
68
|
+
return self._client.request("GET", f"/partner/brands/{quote(brand_id, safe='')}/complaints")
|
|
69
|
+
|
|
70
|
+
def list_complaint_sources(self, brand_id: str) -> list[dict[str, Any]]:
|
|
71
|
+
return self._client.request("GET", f"/partner/brands/{quote(brand_id, safe='')}/complaints/sources")
|
|
72
|
+
|
|
73
|
+
def suppress_never_opened(self, brand_id: str, *, audience_id: str) -> dict[str, Any]:
|
|
74
|
+
return self._client.request(
|
|
75
|
+
"POST", f"/partner/brands/{quote(brand_id, safe='')}/suppressions/bulk",
|
|
76
|
+
json={"audienceId": audience_id, "reason": "never_opened"},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def undo_suppression(self, brand_id: str, bulk_id: str) -> dict[str, Any]:
|
|
80
|
+
return self._client.request("POST", f"/partner/brands/{quote(brand_id, safe='')}/suppressions/bulk/{quote(bulk_id, safe='')}/undo")
|
|
81
|
+
|
|
82
|
+
def usage(self, *, start: str | None = None, end: str | None = None) -> dict[str, Any]:
|
|
83
|
+
params = {"from": start, "to": end}
|
|
84
|
+
return self._client.request("GET", "/partner/usage", params={key: value for key, value in params.items() if value is not None})
|
|
85
|
+
|
|
86
|
+
def list_requests(self) -> dict[str, Any]:
|
|
87
|
+
return self._client.request("GET", "/partner/requests")
|
|
88
|
+
|
|
89
|
+
def create_request(self, *, kind: str, message: str | None = None) -> dict[str, Any]:
|
|
90
|
+
body = {"kind": kind}
|
|
91
|
+
if message is not None:
|
|
92
|
+
body["message"] = message
|
|
93
|
+
return self._client.request("POST", "/partner/requests", json=body)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackshift
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.1
|
|
4
4
|
Summary: Official Python SDK for StackShift.
|
|
5
5
|
Requires-Python: >=3.9
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -16,6 +16,25 @@ Official Python SDK for StackShift.
|
|
|
16
16
|
pip install stackshift
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
+
## Partner platforms
|
|
20
|
+
|
|
21
|
+
Staff-enabled partner accounts buy domains for merchants from their StackShift wallet. Use an API token with the `partner:manage` scope (and `mail:manage` to run campaigns) on your server:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
domain = stackshift.partner.purchase_domain(
|
|
25
|
+
domain="merchantshop.com",
|
|
26
|
+
years=1,
|
|
27
|
+
merchant_ref="m_123",
|
|
28
|
+
registrant={"first_name": "Ada", "last_name": "Obi", "email": "ada@merchantshop.com", "phone": "+2348012345678", "address": "1 Marina", "city": "Lagos", "state": "Lagos", "postal_code": "101001", "country": "NG"},
|
|
29
|
+
brand={"name": "Merchant Shop", "postalAddress": "1 Marina, Lagos, Nigeria"},
|
|
30
|
+
replies={"mode": "forward", "forwardTo": "ada@gmail.com"},
|
|
31
|
+
idempotency_key="order-m_123-1",
|
|
32
|
+
)
|
|
33
|
+
status = stackshift.partner.get_domain(domain["id"])
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`partner` also exposes `overview`, `update_settings`, `list_domains` (with `q` and `attention` to find the merchants that need you), `retry_domain`, `get_diagnosis`, `restore_dns`, `transfer_out`, `resume_brand`, `get_complaints`, `list_complaint_sources`, `suppress_never_opened`, `undo_suppression`, `usage`, `list_requests` and `create_request`. `transfer_out` needs the `partner:transfer` scope (or `*`); `partner:manage` alone is refused. Purchases are capped at 30 per minute and, per partner, at 25 domains in a rolling 24 hours (429 `daily_purchase_limit`; staff can raise it). Validation errors are 400, state conflicts are 409. The `brand` and `replies` dictionaries use API camelCase keys.
|
|
37
|
+
|
|
19
38
|
## AI credits
|
|
20
39
|
|
|
21
40
|
AI credits are account-scoped. Use the shared client to inspect usage, start a top-up, or select managed billing versus your own provider key:
|
|
@@ -22,6 +22,7 @@ stackshift/mail_migration.py
|
|
|
22
22
|
stackshift/mail_reputation.py
|
|
23
23
|
stackshift/mail_streams.py
|
|
24
24
|
stackshift/mail_tracking.py
|
|
25
|
+
stackshift/partner.py
|
|
25
26
|
stackshift/projects.py
|
|
26
27
|
stackshift/support_action_manifest.py
|
|
27
28
|
stackshift/support_action_receiver.py
|
|
@@ -48,4 +49,5 @@ tests/test_mail_platform.py
|
|
|
48
49
|
tests/test_mail_simulation.py
|
|
49
50
|
tests/test_mail_subscribers.py
|
|
50
51
|
tests/test_mail_tracking.py
|
|
52
|
+
tests/test_partner.py
|
|
51
53
|
tests/test_support_actions.py
|
|
@@ -22,6 +22,23 @@ def test_transform_generation_and_workflow_routes():
|
|
|
22
22
|
assert options["json"] == {"preset": "web-ready"}
|
|
23
23
|
assert options["headers"]["Idempotency-Key"] == "render-1"
|
|
24
24
|
|
|
25
|
+
definition = {"version": 2, "media_kind": "image", "operations": [{"type": "remove_background"}], "output": {"kind": "image", "format": "webp"}}
|
|
26
|
+
assets.estimate_transform("asset/1", "version-1", definition)
|
|
27
|
+
method, path, options = transport.calls[-1]
|
|
28
|
+
assert (method, path) == ("POST", "/assets/asset%2F1/transforms/estimate")
|
|
29
|
+
assert options["json"] == {"version_id": "version-1", "definition": definition}
|
|
30
|
+
|
|
31
|
+
assets.usage("asset/1")
|
|
32
|
+
assert transport.calls[-1][:2] == ("GET", "/assets/asset%2F1/usage")
|
|
33
|
+
assets.hold("asset/1")
|
|
34
|
+
assert transport.calls[-1][:2] == ("GET", "/assets/asset%2F1/hold")
|
|
35
|
+
assets.create_reupload_request("asset/1", email="owner@example.com")
|
|
36
|
+
method, path, options = transport.calls[-1]
|
|
37
|
+
assert (method, path) == ("POST", "/assets/asset%2F1/reupload-requests")
|
|
38
|
+
assert options["json"] == {"email": "owner@example.com"}
|
|
39
|
+
assets.reupload_requests("asset/1")
|
|
40
|
+
assert transport.calls[-1][:2] == ("GET", "/assets/asset%2F1/reupload-requests")
|
|
41
|
+
|
|
25
42
|
assets.generate(
|
|
26
43
|
{"prompt": "A violet nebula", "size": "1024x1024", "format": "png", "bucket": "generated"},
|
|
27
44
|
idempotency_key="generation-1",
|
|
@@ -43,6 +43,12 @@ class FakeSession:
|
|
|
43
43
|
return FakeResponse(200, {"data": [webhook_response()], "nextCursor": None})
|
|
44
44
|
if url.endswith("/mail/webhooks/wh_123/rotate-secret"):
|
|
45
45
|
return FakeResponse(200, {"secret": "whsec_new"})
|
|
46
|
+
if url.endswith("/mail/webhooks/wh_123/reveal-secret"):
|
|
47
|
+
return FakeResponse(200, {"secret": "whsec_revealed"})
|
|
48
|
+
if url.endswith("/mail/webhooks/wh_123/diagnosis") and method == "POST":
|
|
49
|
+
return FakeResponse(200, {"diagnosis": {"checkedAt": "2026-05-09T12:00:00Z", "repliesRead": 3, "summary": "The endpoint answers 500 to every delivery."}})
|
|
50
|
+
if url.endswith("/mail/webhooks/wh_123/diagnosis"):
|
|
51
|
+
return FakeResponse(200, {"diagnosis": None})
|
|
46
52
|
if url.endswith("/mail/webhooks/wh_123/deliveries"):
|
|
47
53
|
return FakeResponse(200, {"data": [delivery_response("failed")], "nextCursor": None})
|
|
48
54
|
if url.endswith("/mail/webhooks/wh_123") and method == "PATCH":
|
|
@@ -90,6 +96,19 @@ class MailEventsWebhooksSDKTest(unittest.TestCase):
|
|
|
90
96
|
rotated = stackshift.mail.webhooks.rotate_secret("wh_123")
|
|
91
97
|
self.assertEqual(rotated["secret"], "whsec_new")
|
|
92
98
|
|
|
99
|
+
revealed = stackshift.mail.webhooks.reveal_secret("wh_123")
|
|
100
|
+
self.assertEqual(revealed["secret"], "whsec_revealed")
|
|
101
|
+
self.assertEqual(session.calls[-1][0], "POST")
|
|
102
|
+
|
|
103
|
+
stored = stackshift.mail.webhooks.diagnosis("wh_123")
|
|
104
|
+
self.assertIsNone(stored["diagnosis"])
|
|
105
|
+
self.assertEqual(session.calls[-1][0], "GET")
|
|
106
|
+
self.assertTrue(session.calls[-1][1].endswith("/mail/webhooks/wh_123/diagnosis"))
|
|
107
|
+
|
|
108
|
+
diagnosed = stackshift.mail.webhooks.diagnose("wh_123")
|
|
109
|
+
self.assertEqual(diagnosed["diagnosis"]["repliesRead"], 3)
|
|
110
|
+
self.assertEqual(session.calls[-1][0], "POST")
|
|
111
|
+
|
|
93
112
|
deliveries = stackshift.mail.webhooks.deliveries("wh_123", status="failed")
|
|
94
113
|
self.assertEqual(deliveries["data"][0]["status"], "failed")
|
|
95
114
|
|
|
@@ -27,3 +27,27 @@ class MailPlatformTests(unittest.TestCase):
|
|
|
27
27
|
self.assertEqual(transport.path, "/mail/billing")
|
|
28
28
|
mail.migration.save_checklist("example.com", "resend", ["audit"], revision=1)
|
|
29
29
|
self.assertEqual(transport.payload, {"domain": "example.com", "provider": "resend", "completed": ["audit"], "revision": 1})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ParamsTransport:
|
|
33
|
+
def mail_request(self, method, path, **kwargs):
|
|
34
|
+
self.method, self.path, self.params = method, path, kwargs.get("params")
|
|
35
|
+
return {"data": []}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MailBrandScopedReadTests(unittest.TestCase):
|
|
39
|
+
def test_billing_period_and_brand_filters(self):
|
|
40
|
+
transport = ParamsTransport()
|
|
41
|
+
mail = MailClient(transport)
|
|
42
|
+
mail.billing.period_usage("2026-09")
|
|
43
|
+
self.assertEqual((transport.method, transport.path, transport.params), ("GET", "/mail/billing", {"period": "2026-09"}))
|
|
44
|
+
mail.audiences.list(brand_id="brand_1")
|
|
45
|
+
self.assertEqual((transport.path, transport.params), ("/mail/audiences", {"brandId": "brand_1"}))
|
|
46
|
+
mail.audiences.list()
|
|
47
|
+
self.assertIsNone(transport.params)
|
|
48
|
+
mail.campaigns.list(brand_id="brand_1")
|
|
49
|
+
self.assertEqual((transport.path, transport.params), ("/mail/campaigns", {"brandId": "brand_1"}))
|
|
50
|
+
mail.campaigns.list()
|
|
51
|
+
self.assertIsNone(transport.params)
|
|
52
|
+
mail.suppressions.list(brand_id="brand_1", reason="complaint")
|
|
53
|
+
self.assertEqual((transport.path, transport.params), ("/mail/suppressions", {"reason": "complaint", "brandId": "brand_1"}))
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from stackshift import StackShift
|
|
4
|
+
from stackshift.partner import PartnerClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RecordingClient:
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self.calls = []
|
|
10
|
+
|
|
11
|
+
def request(self, *args, **kwargs):
|
|
12
|
+
self.calls.append((args, kwargs))
|
|
13
|
+
return {"id": "result"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PartnerClientTests(unittest.TestCase):
|
|
17
|
+
def test_requests_use_the_partner_routes_and_purchase_idempotency(self):
|
|
18
|
+
client = RecordingClient()
|
|
19
|
+
partner = PartnerClient(client)
|
|
20
|
+
registrant = {"first_name": "Ada", "last_name": "Obi", "email": "ada@merchantshop.com", "phone": "+2348012345678", "address": "1 Marina", "city": "Lagos", "state": "Lagos", "postal_code": "101001", "country": "NG"}
|
|
21
|
+
brand = {"name": "Merchant Shop", "postalAddress": "1 Marina, Lagos"}
|
|
22
|
+
replies = {"mode": "forward", "forwardTo": "ada@gmail.com"}
|
|
23
|
+
|
|
24
|
+
self.assertEqual(partner.overview(), {"id": "result"})
|
|
25
|
+
partner.update_settings(complaint_pause_rate=0.002, bounce_pause_rate=0.04)
|
|
26
|
+
partner.purchase_domain(domain="merchantshop.com", years=1, merchant_ref="m_123", registrant=registrant, brand=brand, replies=replies, idempotency_key="order-1", currency="USD")
|
|
27
|
+
partner.list_domains(merchant_ref="m_123", limit=20)
|
|
28
|
+
partner.list_domains()
|
|
29
|
+
partner.get_domain("dom/1")
|
|
30
|
+
partner.retry_domain("dom_1")
|
|
31
|
+
partner.transfer_out("dom_1")
|
|
32
|
+
partner.resume_brand("brand_1")
|
|
33
|
+
partner.usage(start="2026-10-01T00:00:00Z", end="2026-11-01T00:00:00Z")
|
|
34
|
+
partner.list_domains(q="shop", attention=True)
|
|
35
|
+
partner.get_diagnosis("dom/1")
|
|
36
|
+
partner.restore_dns("dom_1")
|
|
37
|
+
partner.get_complaints("brand_1")
|
|
38
|
+
partner.list_complaint_sources("brand_1")
|
|
39
|
+
partner.suppress_never_opened("brand_1", audience_id="aud_1")
|
|
40
|
+
partner.undo_suppression("brand_1", "bulk/1")
|
|
41
|
+
partner.create_request(kind="purchase_limit", message="We onboard 100 merchants a day.")
|
|
42
|
+
partner.create_request(kind="dedicated_addresses")
|
|
43
|
+
partner.list_requests()
|
|
44
|
+
calls = client.calls
|
|
45
|
+
|
|
46
|
+
self.assertEqual(calls[0][0], ("GET", "/partner"))
|
|
47
|
+
self.assertEqual(calls[1][0], ("PUT", "/partner/settings"))
|
|
48
|
+
self.assertEqual(calls[1][1]["json"], {"complaintPauseRate": 0.002, "bouncePauseRate": 0.04})
|
|
49
|
+
self.assertEqual(calls[2][0], ("POST", "/partner/domains"))
|
|
50
|
+
self.assertEqual(calls[2][1]["headers"], {"Idempotency-Key": "order-1"})
|
|
51
|
+
self.assertEqual(calls[2][1]["json"], {"domain": "merchantshop.com", "years": 1, "merchantRef": "m_123", "registrant": registrant, "brand": brand, "replies": replies, "currency": "USD"})
|
|
52
|
+
self.assertEqual(calls[3][1]["params"], {"merchantRef": "m_123", "limit": 20})
|
|
53
|
+
self.assertEqual(calls[4][1]["params"], {})
|
|
54
|
+
self.assertEqual(calls[5][0], ("GET", "/partner/domains/dom%2F1"))
|
|
55
|
+
self.assertEqual(calls[6][0], ("POST", "/partner/domains/dom_1/retry"))
|
|
56
|
+
self.assertEqual(calls[7][0], ("POST", "/partner/domains/dom_1/transfer-out"))
|
|
57
|
+
self.assertEqual(calls[8][0], ("POST", "/partner/brands/brand_1/resume"))
|
|
58
|
+
self.assertEqual(calls[9][1]["params"], {"from": "2026-10-01T00:00:00Z", "to": "2026-11-01T00:00:00Z"})
|
|
59
|
+
self.assertEqual(calls[10][1]["params"], {"q": "shop", "attention": "true"})
|
|
60
|
+
self.assertEqual(calls[11][0], ("GET", "/partner/domains/dom%2F1/diagnosis"))
|
|
61
|
+
self.assertEqual(calls[12][0], ("POST", "/partner/domains/dom_1/restore-dns"))
|
|
62
|
+
self.assertEqual(calls[13][0], ("GET", "/partner/brands/brand_1/complaints"))
|
|
63
|
+
self.assertEqual(calls[14][0], ("GET", "/partner/brands/brand_1/complaints/sources"))
|
|
64
|
+
self.assertEqual(calls[15][0], ("POST", "/partner/brands/brand_1/suppressions/bulk"))
|
|
65
|
+
self.assertEqual(calls[15][1]["json"], {"audienceId": "aud_1", "reason": "never_opened"})
|
|
66
|
+
self.assertEqual(calls[16][0], ("POST", "/partner/brands/brand_1/suppressions/bulk/bulk%2F1/undo"))
|
|
67
|
+
self.assertEqual(calls[17][0], ("POST", "/partner/requests"))
|
|
68
|
+
self.assertEqual(calls[17][1]["json"], {"kind": "purchase_limit", "message": "We onboard 100 merchants a day."})
|
|
69
|
+
self.assertEqual(calls[18][1]["json"], {"kind": "dedicated_addresses"})
|
|
70
|
+
self.assertEqual(calls[19][0], ("GET", "/partner/requests"))
|
|
71
|
+
|
|
72
|
+
def test_shared_client_exposes_partner_and_brand_merchant_reference(self):
|
|
73
|
+
shared = StackShift(api_key="sspat_fixture")
|
|
74
|
+
self.assertIsInstance(shared.partner, PartnerClient)
|
|
75
|
+
calls = []
|
|
76
|
+
shared.mail_request = lambda *args, **kwargs: calls.append((args, kwargs)) or {}
|
|
77
|
+
shared.mail.brands.create(name="Shop", merchant_ref="m_123", postal_address="1 Marina")
|
|
78
|
+
self.assertEqual(calls[0][1]["json"], {"name": "Shop", "merchantRef": "m_123", "postalAddress": "1 Marina"})
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|