stackshift 1.2.0__tar.gz → 1.3.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.
- {stackshift-1.2.0 → stackshift-1.3.0}/PKG-INFO +47 -1
- {stackshift-1.2.0 → stackshift-1.3.0}/README.md +46 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/pyproject.toml +1 -1
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift/__init__.py +24 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift/client.py +1 -1
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift/mail.py +14 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift/mail_campaigns.py +1 -1
- stackshift-1.3.0/stackshift/mail_mailboxes.py +276 -0
- stackshift-1.3.0/stackshift/support_action_manifest.py +40 -0
- stackshift-1.3.0/stackshift/support_action_receiver.py +58 -0
- stackshift-1.3.0/stackshift/support_action_sqlite.py +46 -0
- stackshift-1.3.0/stackshift/support_action_types.py +83 -0
- stackshift-1.3.0/stackshift/support_actions.py +141 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift.egg-info/PKG-INFO +47 -1
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift.egg-info/SOURCES.txt +9 -1
- {stackshift-1.2.0 → stackshift-1.3.0}/tests/test_mail.py +3 -3
- stackshift-1.3.0/tests/test_mail_mailboxes.py +91 -0
- stackshift-1.3.0/tests/test_support_actions.py +122 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/setup.cfg +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift/ai_credits.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift/assets.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift/assets_dam.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift/assets_media.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift/assets_platform.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift/assets_video.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift/assets_workflows.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift/mail_advanced.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift/mail_billing.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift/mail_brands.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift/mail_events_webhooks.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift/mail_exports.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift/mail_marketing.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift/mail_migration.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift/mail_reputation.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift/mail_streams.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift/mail_tracking.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift/projects.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift/workload_security.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift.egg-info/dependency_links.txt +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift.egg-info/requires.txt +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/stackshift.egg-info/top_level.txt +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/tests/test_ai_credits.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/tests/test_assets_media.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/tests/test_assets_platform.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/tests/test_assets_workflows.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/tests/test_mail_advanced.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/tests/test_mail_diagnostics.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/tests/test_mail_events_webhooks.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/tests/test_mail_exports.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/tests/test_mail_platform.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/tests/test_mail_simulation.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/tests/test_mail_subscribers.py +0 -0
- {stackshift-1.2.0 → stackshift-1.3.0}/tests/test_mail_tracking.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackshift
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Official Python SDK for StackShift.
|
|
5
5
|
Requires-Python: >=3.9
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -101,6 +101,35 @@ valid = stackshift.mail.webhooks.verify_signature(
|
|
|
101
101
|
|
|
102
102
|
Webhook handlers should be idempotent. StackShift retries non-2xx responses. Delivery events do not include full email bodies or OTP codes by default.
|
|
103
103
|
|
|
104
|
+
## Mailboxes
|
|
105
|
+
|
|
106
|
+
Host real mailboxes on your own domain. The mailbox owner gets an invitation by email and sets their own password.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
domain = stackshift.mail.mailbox_domains.create("acme.com")
|
|
110
|
+
print(domain["records"])
|
|
111
|
+
stackshift.mail.mailbox_domains.verify(domain["id"])
|
|
112
|
+
|
|
113
|
+
mailbox = stackshift.mail.mailboxes.create(
|
|
114
|
+
domain_id=domain["id"],
|
|
115
|
+
local_part="ada",
|
|
116
|
+
owner_email="ada@personal.example",
|
|
117
|
+
plan="basic",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
stackshift.mail.aliases.create(mailbox_id=mailbox["id"], local_part="sales", send_as=True)
|
|
121
|
+
stackshift.mail.routes.create(
|
|
122
|
+
domain_id=domain["id"],
|
|
123
|
+
pattern="orders-*",
|
|
124
|
+
targets=[{"kind": "app", "webhookUrl": "https://app.example.com/inbound"}],
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
devices = stackshift.mail.mailboxes.list_devices(mailbox["id"])
|
|
128
|
+
stackshift.mail.mailboxes.suspend(mailbox["id"])
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`stackshift.mail.groups` manages team addresses, and `stackshift.mail.mailbox_imports` copies mail from Google, Microsoft or any IMAP server.
|
|
132
|
+
|
|
104
133
|
## Send a template
|
|
105
134
|
|
|
106
135
|
Templates are rendered by StackShift servers. The Python SDK only calls the REST API.
|
|
@@ -282,3 +311,20 @@ preview = platform.migrations.start({"source_connection_id": connection_id,
|
|
|
282
311
|
"destination_bucket_id": bucket_id, "source_prefix": "campaigns/2026", "dry_run": True})
|
|
283
312
|
report = platform.migrations.report(preview["id"])
|
|
284
313
|
```
|
|
314
|
+
|
|
315
|
+
## Support action receivers (unreleased)
|
|
316
|
+
|
|
317
|
+
`ActionManifest`, `ActionDescriptor`, installation consent types and `validate_action_catalog` describe `stackshift.support.app/v2`. V1 remains actionless. Registered writes require idempotency, a reconciliation endpoint and a registered read verifier; installations explicitly select `action_operations`. These SDK helpers perform no registration or network calls.
|
|
318
|
+
|
|
319
|
+
Use `receive_action(raw_bytes, headers, store=..., execute=..., reconcile=..., verification=...)`. Verification requires `key_id`, `secret` (the installation action secret, not webhook secret), `installation_id`, `actions`, and a trusted `expected_customer_external_id`. Do not derive that expectation from unverified request JSON. Each synchronous handler returns `(state, result_dict, optional_safe_error_code)`. Success output is checked against the registered flat schema. Receipt helpers `encode_receipt`, `sign_action`, and `verify_receipt` are also exported.
|
|
320
|
+
|
|
321
|
+
`SQLiteActionStore('/absolute/path/claims.sqlite')` uses standard-library sqlite3, WAL and FULL synchronization. Never use an in-memory database for restart safety. The runnable stdin receiver in `examples/support_action_receiver.py` intentionally returns `demo_not_implemented`; replace its handlers with authorized customer-bound business transactions. Set `ACTION_KEY_ID`, `ACTION_SECRET`, `ACTION_INSTALLATION_ID`, and trusted `EXPECTED_CUSTOMER_EXTERNAL_ID`:
|
|
322
|
+
|
|
323
|
+
```sh
|
|
324
|
+
PYTHONPATH=. python examples/support_action_receiver.py /absolute/path/claims.sqlite headers.json actions.json < body.json
|
|
325
|
+
.venv/bin/python -m unittest discover -s tests -p test_support_actions.py -v
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Send the returned `body` unchanged with `headers`. Execution retries retain exact original bytes and request ID with fresh delivery IDs. A handler error is stored as unknown; running/unknown work is never dispatched again. Reconciliation is a separate handler with empty arguments and the original target request ID, and must query actual downstream state. Accepted results remain replayable; there is no asynchronous callback endpoint yet. Disk claims do not make remote side effects atomic: use downstream idempotency or one transactional business/receipt store, and preserve uncertain claims for reconciliation/human review. This is a single-host SQLite example; distributed receivers need a shared durable store.
|
|
329
|
+
|
|
330
|
+
See `../support-actions-conformance/README.md` for exact signatures and adapter requirements. Seven action tests pass, including shared vectors, two-connection concurrency, restart and fenced completion. The existing virtualenv is required here because system Python lacks the SDK's declared `requests` dependency. No live installation/provider qualification was performed; unreleased.
|
|
@@ -93,6 +93,35 @@ valid = stackshift.mail.webhooks.verify_signature(
|
|
|
93
93
|
|
|
94
94
|
Webhook handlers should be idempotent. StackShift retries non-2xx responses. Delivery events do not include full email bodies or OTP codes by default.
|
|
95
95
|
|
|
96
|
+
## Mailboxes
|
|
97
|
+
|
|
98
|
+
Host real mailboxes on your own domain. The mailbox owner gets an invitation by email and sets their own password.
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
domain = stackshift.mail.mailbox_domains.create("acme.com")
|
|
102
|
+
print(domain["records"])
|
|
103
|
+
stackshift.mail.mailbox_domains.verify(domain["id"])
|
|
104
|
+
|
|
105
|
+
mailbox = stackshift.mail.mailboxes.create(
|
|
106
|
+
domain_id=domain["id"],
|
|
107
|
+
local_part="ada",
|
|
108
|
+
owner_email="ada@personal.example",
|
|
109
|
+
plan="basic",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
stackshift.mail.aliases.create(mailbox_id=mailbox["id"], local_part="sales", send_as=True)
|
|
113
|
+
stackshift.mail.routes.create(
|
|
114
|
+
domain_id=domain["id"],
|
|
115
|
+
pattern="orders-*",
|
|
116
|
+
targets=[{"kind": "app", "webhookUrl": "https://app.example.com/inbound"}],
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
devices = stackshift.mail.mailboxes.list_devices(mailbox["id"])
|
|
120
|
+
stackshift.mail.mailboxes.suspend(mailbox["id"])
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`stackshift.mail.groups` manages team addresses, and `stackshift.mail.mailbox_imports` copies mail from Google, Microsoft or any IMAP server.
|
|
124
|
+
|
|
96
125
|
## Send a template
|
|
97
126
|
|
|
98
127
|
Templates are rendered by StackShift servers. The Python SDK only calls the REST API.
|
|
@@ -274,3 +303,20 @@ preview = platform.migrations.start({"source_connection_id": connection_id,
|
|
|
274
303
|
"destination_bucket_id": bucket_id, "source_prefix": "campaigns/2026", "dry_run": True})
|
|
275
304
|
report = platform.migrations.report(preview["id"])
|
|
276
305
|
```
|
|
306
|
+
|
|
307
|
+
## Support action receivers (unreleased)
|
|
308
|
+
|
|
309
|
+
`ActionManifest`, `ActionDescriptor`, installation consent types and `validate_action_catalog` describe `stackshift.support.app/v2`. V1 remains actionless. Registered writes require idempotency, a reconciliation endpoint and a registered read verifier; installations explicitly select `action_operations`. These SDK helpers perform no registration or network calls.
|
|
310
|
+
|
|
311
|
+
Use `receive_action(raw_bytes, headers, store=..., execute=..., reconcile=..., verification=...)`. Verification requires `key_id`, `secret` (the installation action secret, not webhook secret), `installation_id`, `actions`, and a trusted `expected_customer_external_id`. Do not derive that expectation from unverified request JSON. Each synchronous handler returns `(state, result_dict, optional_safe_error_code)`. Success output is checked against the registered flat schema. Receipt helpers `encode_receipt`, `sign_action`, and `verify_receipt` are also exported.
|
|
312
|
+
|
|
313
|
+
`SQLiteActionStore('/absolute/path/claims.sqlite')` uses standard-library sqlite3, WAL and FULL synchronization. Never use an in-memory database for restart safety. The runnable stdin receiver in `examples/support_action_receiver.py` intentionally returns `demo_not_implemented`; replace its handlers with authorized customer-bound business transactions. Set `ACTION_KEY_ID`, `ACTION_SECRET`, `ACTION_INSTALLATION_ID`, and trusted `EXPECTED_CUSTOMER_EXTERNAL_ID`:
|
|
314
|
+
|
|
315
|
+
```sh
|
|
316
|
+
PYTHONPATH=. python examples/support_action_receiver.py /absolute/path/claims.sqlite headers.json actions.json < body.json
|
|
317
|
+
.venv/bin/python -m unittest discover -s tests -p test_support_actions.py -v
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Send the returned `body` unchanged with `headers`. Execution retries retain exact original bytes and request ID with fresh delivery IDs. A handler error is stored as unknown; running/unknown work is never dispatched again. Reconciliation is a separate handler with empty arguments and the original target request ID, and must query actual downstream state. Accepted results remain replayable; there is no asynchronous callback endpoint yet. Disk claims do not make remote side effects atomic: use downstream idempotency or one transactional business/receipt store, and preserve uncertain claims for reconciliation/human review. This is a single-host SQLite example; distributed receivers need a shared durable store.
|
|
321
|
+
|
|
322
|
+
See `../support-actions-conformance/README.md` for exact signatures and adapter requirements. Seven action tests pass, including shared vectors, two-connection concurrency, restart and fenced completion. The existing virtualenv is required here because system Python lacks the SDK's declared `requests` dependency. No live installation/provider qualification was performed; unreleased.
|
|
@@ -13,6 +13,14 @@ from .mail_events_webhooks import MailEventsClient, MailWebhooksClient, verify_w
|
|
|
13
13
|
from .mail_streams import MailStreamsClient
|
|
14
14
|
from .mail_campaigns import MailAudiencesClient, MailCampaignsClient
|
|
15
15
|
from .mail_exports import MailExportsClient
|
|
16
|
+
from .mail_mailboxes import (
|
|
17
|
+
MailboxAliasesClient,
|
|
18
|
+
MailboxDomainsClient,
|
|
19
|
+
MailboxGroupsClient,
|
|
20
|
+
MailboxImportsClient,
|
|
21
|
+
MailboxesClient,
|
|
22
|
+
MailboxRoutesClient,
|
|
23
|
+
)
|
|
16
24
|
from .projects import ProjectsClient
|
|
17
25
|
from .workload_security import WorkloadSecurityClient
|
|
18
26
|
|
|
@@ -35,6 +43,12 @@ __all__ = [
|
|
|
35
43
|
"MailAudiencesClient",
|
|
36
44
|
"MailCampaignsClient",
|
|
37
45
|
"MailExportsClient",
|
|
46
|
+
"MailboxAliasesClient",
|
|
47
|
+
"MailboxDomainsClient",
|
|
48
|
+
"MailboxGroupsClient",
|
|
49
|
+
"MailboxImportsClient",
|
|
50
|
+
"MailboxesClient",
|
|
51
|
+
"MailboxRoutesClient",
|
|
38
52
|
"ProjectsClient",
|
|
39
53
|
"WorkloadSecurityClient",
|
|
40
54
|
"asset_transform_options",
|
|
@@ -42,3 +56,13 @@ __all__ = [
|
|
|
42
56
|
"get_asset_transform_preset",
|
|
43
57
|
"verify_webhook_signature",
|
|
44
58
|
]
|
|
59
|
+
|
|
60
|
+
from .support_actions import (ActionProtocolError, sign_action, verify_action, verify_receipt, encode_receipt)
|
|
61
|
+
from .support_action_receiver import ActionClaim, ActionDurableStore, SignedActionResponse, receive_action
|
|
62
|
+
from .support_action_sqlite import SQLiteActionStore
|
|
63
|
+
from .support_action_types import ActionDescriptor, ActionField, ActionManifest, ActionRequest, ActionReceipt
|
|
64
|
+
__all__ += ["ActionProtocolError", "sign_action", "verify_action", "verify_receipt", "encode_receipt", "ActionClaim", "ActionDurableStore", "SignedActionResponse", "receive_action", "SQLiteActionStore", "ActionDescriptor", "ActionField", "ActionManifest", "ActionRequest", "ActionReceipt"]
|
|
65
|
+
from .support_action_manifest import validate_action_catalog
|
|
66
|
+
__all__ += ['validate_action_catalog']
|
|
67
|
+
from .support_action_types import ActionInstallationInput, ActionInstallationCredentials
|
|
68
|
+
__all__ += ['ActionInstallationInput', 'ActionInstallationCredentials']
|
|
@@ -12,7 +12,7 @@ class StackShift:
|
|
|
12
12
|
*,
|
|
13
13
|
api_key: str | None = None,
|
|
14
14
|
base_url: str = "https://api.stackshift.cloud/api/v1",
|
|
15
|
-
mail_base_url: str = "https://api.stackshift.cloud/v1",
|
|
15
|
+
mail_base_url: str = "https://api.stackshift.cloud/api/v1",
|
|
16
16
|
cdn_base_url: str = "https://cdn.stackshift.cloud",
|
|
17
17
|
session: requests.Session | None = None,
|
|
18
18
|
) -> None:
|
|
@@ -13,6 +13,14 @@ from .mail_marketing import MailAutomationsClient, MailFormsClient, MailSegments
|
|
|
13
13
|
from .mail_migration import MailMigrationClient
|
|
14
14
|
from .mail_campaigns import MailAudiencesClient, MailCampaignsClient
|
|
15
15
|
from .mail_exports import MailExportsClient
|
|
16
|
+
from .mail_mailboxes import (
|
|
17
|
+
MailboxAliasesClient,
|
|
18
|
+
MailboxDomainsClient,
|
|
19
|
+
MailboxGroupsClient,
|
|
20
|
+
MailboxImportsClient,
|
|
21
|
+
MailboxesClient,
|
|
22
|
+
MailboxRoutesClient,
|
|
23
|
+
)
|
|
16
24
|
from .mail_tracking import MailTrackingClient
|
|
17
25
|
|
|
18
26
|
|
|
@@ -38,6 +46,12 @@ class MailClient:
|
|
|
38
46
|
self.campaigns = MailCampaignsClient(client)
|
|
39
47
|
self.tracking = MailTrackingClient(client)
|
|
40
48
|
self.exports = MailExportsClient(client)
|
|
49
|
+
self.mailbox_domains = MailboxDomainsClient(client)
|
|
50
|
+
self.mailboxes = MailboxesClient(client)
|
|
51
|
+
self.aliases = MailboxAliasesClient(client)
|
|
52
|
+
self.groups = MailboxGroupsClient(client)
|
|
53
|
+
self.routes = MailboxRoutesClient(client)
|
|
54
|
+
self.mailbox_imports = MailboxImportsClient(client)
|
|
41
55
|
self.limits = MailLimitsClient(client)
|
|
42
56
|
self.reputation = MailReputationClient(client)
|
|
43
57
|
self.scheduled = MailScheduledClient(client)
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _seg(value: str) -> str:
|
|
8
|
+
return quote(value, safe="")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _camel(fields: dict[str, Any], names: dict[str, str], label: str) -> dict[str, Any]:
|
|
12
|
+
unknown = fields.keys() - names.keys() - set(names.values())
|
|
13
|
+
if unknown:
|
|
14
|
+
raise ValueError(f"Unknown {label} fields: " + ", ".join(sorted(unknown)))
|
|
15
|
+
return {names.get(key, key): value for key, value in fields.items()}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _present(payload: dict[str, Any]) -> dict[str, Any]:
|
|
19
|
+
return {key: value for key, value in payload.items() if value is not None}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MailboxDomainsClient:
|
|
23
|
+
def __init__(self, client: Any) -> None:
|
|
24
|
+
self._client = client
|
|
25
|
+
|
|
26
|
+
def create(self, domain: str) -> dict[str, Any]:
|
|
27
|
+
return self._client.mail_request("POST", "/mail/mailbox-domains", json={"domain": domain})
|
|
28
|
+
|
|
29
|
+
def list(self) -> dict[str, Any]:
|
|
30
|
+
return self._client.mail_request("GET", "/mail/mailbox-domains")
|
|
31
|
+
|
|
32
|
+
def get(self, domain_id: str) -> dict[str, Any]:
|
|
33
|
+
return self._client.mail_request("GET", f"/mail/mailbox-domains/{_seg(domain_id)}")
|
|
34
|
+
|
|
35
|
+
def update(self, domain_id: str, **fields: Any) -> dict[str, Any]:
|
|
36
|
+
names = {"catch_all": "catchAll", "retention_days": "retentionDays", "addon_storage_units": "addonStorageUnits"}
|
|
37
|
+
return self._client.mail_request(
|
|
38
|
+
"PATCH", f"/mail/mailbox-domains/{_seg(domain_id)}", json=_camel(fields, names, "mailbox domain")
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def verify(self, domain_id: str) -> dict[str, Any]:
|
|
42
|
+
return self._client.mail_request("POST", f"/mail/mailbox-domains/{_seg(domain_id)}/verify")
|
|
43
|
+
|
|
44
|
+
def apply_dns(self, domain_id: str) -> dict[str, Any]:
|
|
45
|
+
return self._client.mail_request("POST", f"/mail/mailbox-domains/{_seg(domain_id)}/dns/apply")
|
|
46
|
+
|
|
47
|
+
def dns_applications(self, domain_id: str) -> dict[str, Any]:
|
|
48
|
+
return self._client.mail_request("GET", f"/mail/mailbox-domains/{_seg(domain_id)}/dns/applications")
|
|
49
|
+
|
|
50
|
+
def delete(self, domain_id: str) -> None:
|
|
51
|
+
self._client.mail_request("DELETE", f"/mail/mailbox-domains/{_seg(domain_id)}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class MailboxesClient:
|
|
55
|
+
def __init__(self, client: Any) -> None:
|
|
56
|
+
self._client = client
|
|
57
|
+
|
|
58
|
+
def plans(self) -> dict[str, Any]:
|
|
59
|
+
return self._client.mail_request("GET", "/mail/mailbox-plans")
|
|
60
|
+
|
|
61
|
+
def client_settings(self) -> dict[str, Any]:
|
|
62
|
+
return self._client.mail_request("GET", "/mail/mailbox-client-settings")
|
|
63
|
+
|
|
64
|
+
def usage(self) -> dict[str, Any]:
|
|
65
|
+
return self._client.mail_request("GET", "/mail/mailbox-usage")
|
|
66
|
+
|
|
67
|
+
def create(
|
|
68
|
+
self,
|
|
69
|
+
*,
|
|
70
|
+
domain_id: str,
|
|
71
|
+
local_part: str,
|
|
72
|
+
display_name: str | None = None,
|
|
73
|
+
kind: str | None = None,
|
|
74
|
+
owner_user_id: str | None = None,
|
|
75
|
+
owner_email: str | None = None,
|
|
76
|
+
plan: str | None = None,
|
|
77
|
+
quota_bytes: int | None = None,
|
|
78
|
+
ai_enabled: bool | None = None,
|
|
79
|
+
) -> dict[str, Any]:
|
|
80
|
+
payload = {
|
|
81
|
+
"domainId": domain_id,
|
|
82
|
+
"localPart": local_part,
|
|
83
|
+
"displayName": display_name,
|
|
84
|
+
"kind": kind,
|
|
85
|
+
"ownerUserId": owner_user_id,
|
|
86
|
+
"ownerEmail": owner_email,
|
|
87
|
+
"plan": plan,
|
|
88
|
+
"quotaBytes": quota_bytes,
|
|
89
|
+
"aiEnabled": ai_enabled,
|
|
90
|
+
}
|
|
91
|
+
return self._client.mail_request("POST", "/mail/mailboxes", json=_present(payload))
|
|
92
|
+
|
|
93
|
+
def list(self, *, domain_id: str | None = None) -> dict[str, Any]:
|
|
94
|
+
return self._client.mail_request("GET", "/mail/mailboxes", params=_present({"domainId": domain_id}))
|
|
95
|
+
|
|
96
|
+
def get(self, mailbox_id: str) -> dict[str, Any]:
|
|
97
|
+
return self._client.mail_request("GET", f"/mail/mailboxes/{_seg(mailbox_id)}")
|
|
98
|
+
|
|
99
|
+
def update(
|
|
100
|
+
self,
|
|
101
|
+
mailbox_id: str,
|
|
102
|
+
*,
|
|
103
|
+
display_name: str | None = None,
|
|
104
|
+
quota_bytes: int | None = None,
|
|
105
|
+
ai_enabled: bool | None = None,
|
|
106
|
+
plan: str | None = None,
|
|
107
|
+
) -> dict[str, Any]:
|
|
108
|
+
payload = {"displayName": display_name, "quotaBytes": quota_bytes, "aiEnabled": ai_enabled, "plan": plan}
|
|
109
|
+
return self._client.mail_request("PATCH", f"/mail/mailboxes/{_seg(mailbox_id)}", json=_present(payload))
|
|
110
|
+
|
|
111
|
+
def delete(self, mailbox_id: str) -> None:
|
|
112
|
+
self._client.mail_request("DELETE", f"/mail/mailboxes/{_seg(mailbox_id)}")
|
|
113
|
+
|
|
114
|
+
def suspend(self, mailbox_id: str) -> dict[str, Any]:
|
|
115
|
+
return self._client.mail_request("POST", f"/mail/mailboxes/{_seg(mailbox_id)}/suspend")
|
|
116
|
+
|
|
117
|
+
def resume(self, mailbox_id: str) -> dict[str, Any]:
|
|
118
|
+
return self._client.mail_request("POST", f"/mail/mailboxes/{_seg(mailbox_id)}/resume")
|
|
119
|
+
|
|
120
|
+
def resend_invitation(self, mailbox_id: str) -> dict[str, Any]:
|
|
121
|
+
return self._client.mail_request("POST", f"/mail/mailboxes/{_seg(mailbox_id)}/invitation/resend")
|
|
122
|
+
|
|
123
|
+
def reset_password(self, mailbox_id: str) -> dict[str, Any]:
|
|
124
|
+
return self._client.mail_request("POST", f"/mail/mailboxes/{_seg(mailbox_id)}/password-reset")
|
|
125
|
+
|
|
126
|
+
def create_profile_link(self, mailbox_id: str) -> dict[str, Any]:
|
|
127
|
+
return self._client.mail_request("POST", f"/mail/mailboxes/{_seg(mailbox_id)}/mobileconfig")
|
|
128
|
+
|
|
129
|
+
def list_devices(self, mailbox_id: str) -> dict[str, Any]:
|
|
130
|
+
return self._client.mail_request("GET", f"/mail/mailboxes/{_seg(mailbox_id)}/devices")
|
|
131
|
+
|
|
132
|
+
def revoke_device(self, mailbox_id: str, kind: str, device_id: str) -> None:
|
|
133
|
+
self._client.mail_request("DELETE", f"/mail/mailboxes/{_seg(mailbox_id)}/devices/{_seg(kind)}/{_seg(device_id)}")
|
|
134
|
+
|
|
135
|
+
def list_members(self, mailbox_id: str) -> dict[str, Any]:
|
|
136
|
+
return self._client.mail_request("GET", f"/mail/mailboxes/{_seg(mailbox_id)}/members")
|
|
137
|
+
|
|
138
|
+
def add_member(self, mailbox_id: str, user_id: str) -> dict[str, Any]:
|
|
139
|
+
return self._client.mail_request("POST", f"/mail/mailboxes/{_seg(mailbox_id)}/members", json={"userId": user_id})
|
|
140
|
+
|
|
141
|
+
def remove_member(self, mailbox_id: str, user_id: str) -> None:
|
|
142
|
+
self._client.mail_request("DELETE", f"/mail/mailboxes/{_seg(mailbox_id)}/members/{_seg(user_id)}")
|
|
143
|
+
|
|
144
|
+
def list_app_passwords(self, mailbox_id: str) -> dict[str, Any]:
|
|
145
|
+
return self._client.mail_request("GET", f"/mail/mailboxes/{_seg(mailbox_id)}/app-passwords")
|
|
146
|
+
|
|
147
|
+
def create_app_password(self, mailbox_id: str, label: str) -> dict[str, Any]:
|
|
148
|
+
return self._client.mail_request(
|
|
149
|
+
"POST", f"/mail/mailboxes/{_seg(mailbox_id)}/app-passwords", json={"label": label}
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def revoke_app_password(self, mailbox_id: str, password_id: str) -> None:
|
|
153
|
+
self._client.mail_request("DELETE", f"/mail/mailboxes/{_seg(mailbox_id)}/app-passwords/{_seg(password_id)}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class MailboxAliasesClient:
|
|
157
|
+
def __init__(self, client: Any) -> None:
|
|
158
|
+
self._client = client
|
|
159
|
+
|
|
160
|
+
def create(
|
|
161
|
+
self, *, mailbox_id: str, local_part: str, domain_id: str | None = None, send_as: bool | None = None
|
|
162
|
+
) -> dict[str, Any]:
|
|
163
|
+
payload = {"mailboxId": mailbox_id, "localPart": local_part, "domainId": domain_id, "sendAs": send_as}
|
|
164
|
+
return self._client.mail_request("POST", "/mail/aliases", json=_present(payload))
|
|
165
|
+
|
|
166
|
+
def list(self, *, mailbox_id: str | None = None) -> dict[str, Any]:
|
|
167
|
+
return self._client.mail_request("GET", "/mail/aliases", params=_present({"mailboxId": mailbox_id}))
|
|
168
|
+
|
|
169
|
+
def update(self, alias_id: str, *, send_as: bool) -> dict[str, Any]:
|
|
170
|
+
return self._client.mail_request("PATCH", f"/mail/aliases/{_seg(alias_id)}", json={"sendAs": send_as})
|
|
171
|
+
|
|
172
|
+
def delete(self, alias_id: str) -> None:
|
|
173
|
+
self._client.mail_request("DELETE", f"/mail/aliases/{_seg(alias_id)}")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class MailboxGroupsClient:
|
|
177
|
+
def __init__(self, client: Any) -> None:
|
|
178
|
+
self._client = client
|
|
179
|
+
|
|
180
|
+
def create(
|
|
181
|
+
self,
|
|
182
|
+
*,
|
|
183
|
+
domain_id: str,
|
|
184
|
+
local_part: str,
|
|
185
|
+
display_name: str | None = None,
|
|
186
|
+
member_ids: list[str] | None = None,
|
|
187
|
+
moderator_id: str | None = None,
|
|
188
|
+
) -> dict[str, Any]:
|
|
189
|
+
payload = {
|
|
190
|
+
"domainId": domain_id,
|
|
191
|
+
"localPart": local_part,
|
|
192
|
+
"displayName": display_name,
|
|
193
|
+
"memberIds": member_ids,
|
|
194
|
+
"moderatorId": moderator_id,
|
|
195
|
+
}
|
|
196
|
+
return self._client.mail_request("POST", "/mail/groups", json=_present(payload))
|
|
197
|
+
|
|
198
|
+
def list(self, *, domain_id: str | None = None) -> dict[str, Any]:
|
|
199
|
+
return self._client.mail_request("GET", "/mail/groups", params=_present({"domainId": domain_id}))
|
|
200
|
+
|
|
201
|
+
def get(self, group_id: str) -> dict[str, Any]:
|
|
202
|
+
return self._client.mail_request("GET", f"/mail/groups/{_seg(group_id)}")
|
|
203
|
+
|
|
204
|
+
def update(self, group_id: str, **fields: Any) -> dict[str, Any]:
|
|
205
|
+
names = {"display_name": "displayName", "moderator_id": "moderatorId", "member_ids": "memberIds"}
|
|
206
|
+
return self._client.mail_request("PATCH", f"/mail/groups/{_seg(group_id)}", json=_camel(fields, names, "group"))
|
|
207
|
+
|
|
208
|
+
def delete(self, group_id: str) -> None:
|
|
209
|
+
self._client.mail_request("DELETE", f"/mail/groups/{_seg(group_id)}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class MailboxRoutesClient:
|
|
213
|
+
def __init__(self, client: Any) -> None:
|
|
214
|
+
self._client = client
|
|
215
|
+
|
|
216
|
+
def create(
|
|
217
|
+
self, *, domain_id: str, pattern: str, targets: list[dict[str, Any]], priority: int | None = None
|
|
218
|
+
) -> dict[str, Any]:
|
|
219
|
+
payload = {"domainId": domain_id, "pattern": pattern, "targets": targets, "priority": priority}
|
|
220
|
+
return self._client.mail_request("POST", "/mail/routes", json=_present(payload))
|
|
221
|
+
|
|
222
|
+
def list(self, *, domain_id: str | None = None) -> dict[str, Any]:
|
|
223
|
+
return self._client.mail_request("GET", "/mail/routes", params=_present({"domainId": domain_id}))
|
|
224
|
+
|
|
225
|
+
def get(self, route_id: str) -> dict[str, Any]:
|
|
226
|
+
return self._client.mail_request("GET", f"/mail/routes/{_seg(route_id)}")
|
|
227
|
+
|
|
228
|
+
def update(
|
|
229
|
+
self, route_id: str, *, priority: int | None = None, targets: list[dict[str, Any]] | None = None
|
|
230
|
+
) -> dict[str, Any]:
|
|
231
|
+
return self._client.mail_request(
|
|
232
|
+
"PATCH", f"/mail/routes/{_seg(route_id)}", json=_present({"priority": priority, "targets": targets})
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def delete(self, route_id: str) -> None:
|
|
236
|
+
self._client.mail_request("DELETE", f"/mail/routes/{_seg(route_id)}")
|
|
237
|
+
|
|
238
|
+
def app_health(self, domain_id: str) -> dict[str, Any]:
|
|
239
|
+
return self._client.mail_request("GET", "/mail/routes/app-health", params={"domainId": domain_id})
|
|
240
|
+
|
|
241
|
+
def retry_deliveries(self, route_id: str) -> dict[str, Any]:
|
|
242
|
+
return self._client.mail_request("POST", f"/mail/routes/{_seg(route_id)}/retry-deliveries")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class MailboxImportsClient:
|
|
246
|
+
def __init__(self, client: Any) -> None:
|
|
247
|
+
self._client = client
|
|
248
|
+
|
|
249
|
+
def start(
|
|
250
|
+
self,
|
|
251
|
+
*,
|
|
252
|
+
domain_id: str,
|
|
253
|
+
source: str,
|
|
254
|
+
accounts: list[dict[str, Any]],
|
|
255
|
+
host: str | None = None,
|
|
256
|
+
port: int | None = None,
|
|
257
|
+
) -> dict[str, Any]:
|
|
258
|
+
payload = {"domainId": domain_id, "source": source, "accounts": accounts, "host": host, "port": port}
|
|
259
|
+
return self._client.mail_request("POST", "/mail/mailbox-imports", json=_present(payload))
|
|
260
|
+
|
|
261
|
+
def list(self) -> dict[str, Any]:
|
|
262
|
+
return self._client.mail_request("GET", "/mail/mailbox-imports")
|
|
263
|
+
|
|
264
|
+
def get(self, import_id: str) -> dict[str, Any]:
|
|
265
|
+
return self._client.mail_request("GET", f"/mail/mailbox-imports/{_seg(import_id)}")
|
|
266
|
+
|
|
267
|
+
def cancel(self, import_id: str) -> dict[str, Any]:
|
|
268
|
+
return self._client.mail_request("POST", f"/mail/mailbox-imports/{_seg(import_id)}/cancel")
|
|
269
|
+
|
|
270
|
+
def skip(self, import_id: str, mailbox_id: str) -> dict[str, Any]:
|
|
271
|
+
return self._client.mail_request(
|
|
272
|
+
"POST", f"/mail/mailbox-imports/{_seg(import_id)}/mailboxes/{_seg(mailbox_id)}/skip"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def switch(self, import_id: str, when: str) -> dict[str, Any]:
|
|
276
|
+
return self._client.mail_request("POST", f"/mail/mailbox-imports/{_seg(import_id)}/switch", json={"when": when})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Local catalog validation; server registration and destination policy remain authoritative."""
|
|
2
|
+
import math
|
|
3
|
+
import re
|
|
4
|
+
from urllib.parse import urlsplit
|
|
5
|
+
from .support_actions import _check
|
|
6
|
+
|
|
7
|
+
_RESERVED = {'customer', 'customer_id', 'external_customer_id', 'external_id', 'contact_id', 'workspace_id', 'environment_id', 'inbox_id', 'conversation_id', 'installation_id', 'tenant_id', 'user_id', 'organization_id', 'authorization', 'scope', 'identity', 'credentials', 'secret', 'token'}
|
|
8
|
+
def _endpoint(value):
|
|
9
|
+
if not isinstance(value, str) or len(value) > 2048: return False
|
|
10
|
+
try:
|
|
11
|
+
url = urlsplit(value)
|
|
12
|
+
return url.scheme == 'https' and bool(url.hostname) and not url.username and not url.password and '?' not in value and '#' not in value
|
|
13
|
+
except ValueError: return False
|
|
14
|
+
|
|
15
|
+
def _fields(schema, input_schema):
|
|
16
|
+
_check(type(schema) is dict and len(schema) <= 32, 'invalid_catalog')
|
|
17
|
+
for name, field in schema.items():
|
|
18
|
+
_check(bool(re.fullmatch(r'[a-z][a-z0-9_]{0,63}', name)) and (not input_schema or name not in _RESERVED and not name.startswith(('customer_', 'tenant_', 'auth_'))), 'invalid_catalog')
|
|
19
|
+
_check(type(field) is dict and {'type', 'required'} <= field.keys() and field.keys() <= {'type', 'required', 'max_length', 'minimum', 'maximum', 'enum'} and type(field['required']) is bool, 'invalid_catalog')
|
|
20
|
+
kind = field['type']; values = field.get('enum', [])
|
|
21
|
+
if kind == 'string':
|
|
22
|
+
limit = field.get('max_length')
|
|
23
|
+
_check(type(limit) is int and 1 <= limit <= 16000 and 'minimum' not in field and 'maximum' not in field and type(values) is list and len(values) <= 64 and all(type(v) is str and len(v) <= limit for v in values) and len(set(values)) == len(values), 'invalid_catalog')
|
|
24
|
+
elif kind in ('number', 'integer'):
|
|
25
|
+
bounds = [field[key] for key in ('minimum', 'maximum') if key in field]
|
|
26
|
+
_check(not field.get('max_length') and not values and all(type(n) in (int, float) and abs(n) < math.inf and (kind != 'integer' or int(n) == n and abs(n) <= 9007199254740991) for n in bounds) and field.get('minimum', -math.inf) <= field.get('maximum', math.inf), 'invalid_catalog')
|
|
27
|
+
else:
|
|
28
|
+
_check(kind == 'boolean' and not field.get('max_length') and not values and 'minimum' not in field and 'maximum' not in field, 'invalid_catalog')
|
|
29
|
+
|
|
30
|
+
def validate_action_catalog(version: str, actions: list) -> None:
|
|
31
|
+
_check(isinstance(version, str) and len(version) <= 32 and bool(re.fullmatch(r'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)', version)) and type(actions) is list and 1 <= len(actions) <= 32, 'invalid_catalog')
|
|
32
|
+
names = set()
|
|
33
|
+
for action in actions:
|
|
34
|
+
_check(type(action) is dict and all(key in action for key in ('name', 'version', 'identity_required', 'risk', 'idempotency_supported', 'safe_retry', 'timeout_seconds', 'endpoint', 'input', 'output')), 'invalid_catalog')
|
|
35
|
+
name = action['name']
|
|
36
|
+
_check(type(name) is str and bool(re.fullmatch(r'[a-z][a-z0-9_.]{0,79}', name)) and name not in names and action['version'] == version and action['identity_required'] is True and action['risk'] in ('read', 'bounded_write', 'customer_change', 'financial', 'security') and type(action['idempotency_supported']) is bool and type(action['safe_retry']) is bool and type(action['timeout_seconds']) is int and 1 <= action['timeout_seconds'] <= 30 and _endpoint(action['endpoint']), 'invalid_catalog')
|
|
37
|
+
_check((action['risk'] == 'read' or action['idempotency_supported'] and _endpoint(action.get('reconciliation_endpoint')) and action.get('verification_operation')) and (not action['safe_retry'] or action['idempotency_supported']) and (not action.get('reconciliation_endpoint') or _endpoint(action['reconciliation_endpoint'])), 'invalid_catalog')
|
|
38
|
+
_fields(action['input'], True); _fields(action['output'], False); names.add(name)
|
|
39
|
+
for action in actions:
|
|
40
|
+
_check(not action.get('verification_operation') or any(a['name'] == action['verification_operation'] and a['risk'] == 'read' for a in actions), 'invalid_catalog')
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Durable claims are required; no in-memory fallback or automatic lease takeover."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Callable, Optional, Protocol
|
|
6
|
+
from .support_actions import (ActionProtocolError, REQUEST_CONTEXT, RECEIPT_CONTEXT, encode_receipt,
|
|
7
|
+
sign_action, validate_fields, verify_action)
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ActionClaim:
|
|
11
|
+
state: str # acquired, completed, running, unknown, conflict
|
|
12
|
+
fence: str = ""
|
|
13
|
+
response: Optional[bytes] = None
|
|
14
|
+
|
|
15
|
+
class ActionDurableStore(Protocol):
|
|
16
|
+
def claim(self, namespace: str, key: str, digest: str) -> ActionClaim: ...
|
|
17
|
+
def complete(self, namespace: str, key: str, fence: str, response: bytes, unknown: bool = False) -> None: ...
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class SignedActionResponse:
|
|
21
|
+
body: bytes
|
|
22
|
+
headers: dict
|
|
23
|
+
|
|
24
|
+
def receive_action(raw: bytes, headers: dict, *, store: ActionDurableStore, execute: Callable,
|
|
25
|
+
reconcile: Callable, verification: dict) -> SignedActionResponse:
|
|
26
|
+
options = dict(verification)
|
|
27
|
+
now = options.setdefault("now", int(time.time()))
|
|
28
|
+
request = verify_action(raw, headers, **options)
|
|
29
|
+
normalized = {name.lower(): value for name, value in headers.items()}
|
|
30
|
+
delivery = normalized["x-stackshift-delivery-id"]
|
|
31
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
32
|
+
delivery_key = request["installation_id"] + ":" + delivery
|
|
33
|
+
delivery_claim = store.claim("delivery", delivery_key, digest)
|
|
34
|
+
if delivery_claim.state != "acquired":
|
|
35
|
+
raise ActionProtocolError("delivery_replay")
|
|
36
|
+
key = ":".join((request["installation_id"], request["action"]["name"], request["action"]["version"], request["request_id"], request["mode"]))
|
|
37
|
+
claim = store.claim("execution", key, digest)
|
|
38
|
+
if claim.state == "conflict":
|
|
39
|
+
raise ActionProtocolError("idempotency_conflict")
|
|
40
|
+
if claim.state in ("completed", "unknown") and claim.response is not None:
|
|
41
|
+
response = claim.response
|
|
42
|
+
elif claim.state in ("running", "unknown"):
|
|
43
|
+
response = encode_receipt(request["request_id"], "unknown" if claim.state == "unknown" else "accepted", {}, "execution_unknown" if claim.state == "unknown" else "execution_in_progress")
|
|
44
|
+
elif claim.state == "acquired":
|
|
45
|
+
try:
|
|
46
|
+
handler = reconcile if request["mode"] == "reconcile" else execute
|
|
47
|
+
state, result, code = handler(request)
|
|
48
|
+
action = next(a for a in options["actions"] if a["name"] == request["action"]["name"] and a["version"] == request["action"]["version"])
|
|
49
|
+
if state == "succeeded": validate_fields(result, action["output"])
|
|
50
|
+
response = encode_receipt(request["request_id"], state, result, code)
|
|
51
|
+
except Exception:
|
|
52
|
+
response = encode_receipt(request["request_id"], "unknown", {}, "handler_outcome_unknown")
|
|
53
|
+
state = "unknown"
|
|
54
|
+
store.complete("execution", key, claim.fence, response, state == "unknown")
|
|
55
|
+
else:
|
|
56
|
+
raise ActionProtocolError("invalid_store_claim")
|
|
57
|
+
store.complete("delivery", delivery_key, delivery_claim.fence, response)
|
|
58
|
+
return SignedActionResponse(response, sign_action(response, RECEIPT_CONTEXT, options["key_id"], options["secret"], now, delivery))
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""SQLite durable claims. Keep this database for the complete request/retry horizon."""
|
|
2
|
+
import sqlite3
|
|
3
|
+
import threading
|
|
4
|
+
import uuid
|
|
5
|
+
from .support_actions import ActionProtocolError
|
|
6
|
+
from .support_action_receiver import ActionClaim
|
|
7
|
+
|
|
8
|
+
class SQLiteActionStore:
|
|
9
|
+
def __init__(self, path: str):
|
|
10
|
+
if not path or path == ":memory:" or path.startswith("file:"):
|
|
11
|
+
raise ActionProtocolError("durable_database_required")
|
|
12
|
+
self._lock = threading.Lock()
|
|
13
|
+
self._db = sqlite3.connect(path, timeout=10, check_same_thread=False, isolation_level=None)
|
|
14
|
+
self._db.execute("PRAGMA journal_mode=WAL")
|
|
15
|
+
self._db.execute("PRAGMA synchronous=FULL")
|
|
16
|
+
self._db.execute("""CREATE TABLE IF NOT EXISTS support_action_claims (
|
|
17
|
+
namespace TEXT NOT NULL, claim_key TEXT NOT NULL, digest TEXT NOT NULL,
|
|
18
|
+
state TEXT NOT NULL, fence TEXT NOT NULL, response BLOB,
|
|
19
|
+
PRIMARY KEY(namespace,claim_key))""")
|
|
20
|
+
|
|
21
|
+
def claim(self, namespace: str, key: str, digest: str) -> ActionClaim:
|
|
22
|
+
with self._lock:
|
|
23
|
+
self._db.execute("BEGIN IMMEDIATE")
|
|
24
|
+
try:
|
|
25
|
+
fence = str(uuid.uuid4())
|
|
26
|
+
cursor = self._db.execute("INSERT OR IGNORE INTO support_action_claims VALUES(?,?,?,'running',?,NULL)", (namespace, key, digest, fence))
|
|
27
|
+
if cursor.rowcount == 1:
|
|
28
|
+
result = ActionClaim("acquired", fence)
|
|
29
|
+
else:
|
|
30
|
+
row = self._db.execute("SELECT digest,state,fence,response FROM support_action_claims WHERE namespace=? AND claim_key=?", (namespace, key)).fetchone()
|
|
31
|
+
result = ActionClaim("conflict" if row[0] != digest else row[1], row[2], row[3])
|
|
32
|
+
self._db.execute("COMMIT")
|
|
33
|
+
return result
|
|
34
|
+
except BaseException:
|
|
35
|
+
self._db.execute("ROLLBACK")
|
|
36
|
+
raise
|
|
37
|
+
|
|
38
|
+
def complete(self, namespace: str, key: str, fence: str, response: bytes, unknown: bool = False) -> None:
|
|
39
|
+
with self._lock:
|
|
40
|
+
cursor = self._db.execute("UPDATE support_action_claims SET state=?,response=? WHERE namespace=? AND claim_key=? AND fence=? AND state='running'", ("unknown" if unknown else "completed", response, namespace, key, fence))
|
|
41
|
+
if cursor.rowcount != 1:
|
|
42
|
+
raise ActionProtocolError("fence_lost")
|
|
43
|
+
|
|
44
|
+
def close(self) -> None:
|
|
45
|
+
with self._lock:
|
|
46
|
+
self._db.close()
|