stackshift 0.1.0__py3-none-any.whl → 0.1.2__py3-none-any.whl
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/__init__.py +30 -2
- stackshift/assets.py +311 -10
- stackshift/client.py +37 -2
- stackshift/mail.py +379 -0
- stackshift/mail_advanced.py +101 -0
- stackshift/mail_phase7.py +153 -0
- stackshift/mail_reputation.py +38 -0
- {stackshift-0.1.0.dist-info → stackshift-0.1.2.dist-info}/METADATA +1 -1
- stackshift-0.1.2.dist-info/RECORD +12 -0
- {stackshift-0.1.0.dist-info → stackshift-0.1.2.dist-info}/WHEEL +1 -1
- stackshift-0.1.0.dist-info/RECORD +0 -8
- {stackshift-0.1.0.dist-info → stackshift-0.1.2.dist-info}/top_level.txt +0 -0
stackshift/__init__.py
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
from .client import StackShift
|
|
2
|
-
from .assets import AssetsClient
|
|
2
|
+
from .assets import ASSET_TRANSFORM_PRESETS, AssetsClient, asset_transform_options, asset_transform_spec, get_asset_transform_preset
|
|
3
|
+
from .mail import (
|
|
4
|
+
MailClient,
|
|
5
|
+
MailDomainsClient,
|
|
6
|
+
MailOTPChallengesClient,
|
|
7
|
+
MailOTPClient,
|
|
8
|
+
MailTemplatesClient,
|
|
9
|
+
)
|
|
10
|
+
from .mail_reputation import MailLimitsClient, MailReputationClient, MailReputationEventsClient
|
|
11
|
+
from .mail_phase7 import MailEventsClient, MailWebhooksClient, verify_webhook_signature
|
|
3
12
|
from .projects import ProjectsClient
|
|
4
13
|
|
|
5
|
-
__all__ = [
|
|
14
|
+
__all__ = [
|
|
15
|
+
"StackShift",
|
|
16
|
+
"ASSET_TRANSFORM_PRESETS",
|
|
17
|
+
"AssetsClient",
|
|
18
|
+
"MailClient",
|
|
19
|
+
"MailDomainsClient",
|
|
20
|
+
"MailEventsClient",
|
|
21
|
+
"MailLimitsClient",
|
|
22
|
+
"MailOTPClient",
|
|
23
|
+
"MailOTPChallengesClient",
|
|
24
|
+
"MailReputationClient",
|
|
25
|
+
"MailReputationEventsClient",
|
|
26
|
+
"MailTemplatesClient",
|
|
27
|
+
"MailWebhooksClient",
|
|
28
|
+
"ProjectsClient",
|
|
29
|
+
"asset_transform_options",
|
|
30
|
+
"asset_transform_spec",
|
|
31
|
+
"get_asset_transform_preset",
|
|
32
|
+
"verify_webhook_signature",
|
|
33
|
+
]
|
stackshift/assets.py
CHANGED
|
@@ -1,8 +1,50 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import hashlib
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import Any, BinaryIO
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
ASSET_TRANSFORM_PRESETS: list[dict[str, Any]] = [
|
|
11
|
+
{"id": "web-thumbnail", "name": "web-thumbnail", "category": "responsive-web", "description": "Compact grid thumbnail for libraries and dashboards.", "width": 320, "height": 180, "crop": "fill", "format": "webp", "quality": 78},
|
|
12
|
+
{"id": "web-card", "name": "web-card", "category": "responsive-web", "description": "Balanced card image for product and content listings.", "width": 640, "height": 360, "crop": "fill", "format": "webp", "quality": 82},
|
|
13
|
+
{"id": "feature-image", "name": "feature-image", "category": "responsive-web", "description": "Wide content feature image with enough detail for retina screens.", "width": 1200, "height": 675, "crop": "fill", "format": "webp", "quality": 84},
|
|
14
|
+
{"id": "hero", "name": "hero", "category": "responsive-web", "description": "Large hero image for landing pages and page headers.", "width": 1600, "height": 900, "crop": "fill", "format": "webp", "quality": 84},
|
|
15
|
+
{"id": "full-width-banner", "name": "full-width-banner", "category": "responsive-web", "description": "Extra-wide banner for app shells and marketing sections.", "width": 1920, "height": 720, "crop": "fill", "format": "webp", "quality": 82},
|
|
16
|
+
{"id": "open-graph", "name": "open-graph", "category": "social", "description": "Share preview image for Open Graph cards.", "width": 1200, "height": 630, "crop": "fill", "format": "jpeg", "quality": 85},
|
|
17
|
+
{"id": "square-post", "name": "square-post", "category": "social", "description": "Square image for social feeds.", "width": 1080, "height": 1080, "crop": "fill", "format": "webp", "quality": 84},
|
|
18
|
+
{"id": "story", "name": "story", "category": "social", "description": "Vertical story format for mobile social placements.", "width": 1080, "height": 1920, "crop": "fill", "format": "webp", "quality": 82},
|
|
19
|
+
{"id": "x-twitter-card", "name": "x-twitter-card", "category": "social", "description": "Landscape card optimized for X and Twitter previews.", "width": 1200, "height": 675, "crop": "fill", "format": "jpeg", "quality": 84},
|
|
20
|
+
{"id": "linkedin-share", "name": "linkedin-share", "category": "social", "description": "LinkedIn share preview dimensions.", "width": 1200, "height": 627, "crop": "fill", "format": "jpeg", "quality": 84},
|
|
21
|
+
{"id": "product-square", "name": "product-square", "category": "product", "description": "Square product image with clean containment.", "width": 1024, "height": 1024, "crop": "fit", "format": "webp", "quality": 86},
|
|
22
|
+
{"id": "product-card", "name": "product-card", "category": "product", "description": "Product listing image for commerce grids.", "width": 640, "height": 640, "crop": "fit", "format": "webp", "quality": 84},
|
|
23
|
+
{"id": "product-zoom", "name": "product-zoom", "category": "product", "description": "High-detail product image for zoom interactions.", "width": 2000, "height": 2000, "crop": "fit", "format": "jpeg", "quality": 90},
|
|
24
|
+
{"id": "gallery-thumb", "name": "gallery-thumb", "category": "product", "description": "Compact gallery thumbnail for product detail pages.", "width": 400, "height": 400, "crop": "fill", "format": "webp", "quality": 82},
|
|
25
|
+
{"id": "avatar-small", "name": "avatar-small", "category": "avatar-profile", "description": "Small circular avatar source for dense UI.", "width": 128, "height": 128, "crop": "fill", "format": "webp", "quality": 82},
|
|
26
|
+
{"id": "avatar-retina", "name": "avatar-retina", "category": "avatar-profile", "description": "Retina avatar source for account and profile UI.", "width": 256, "height": 256, "crop": "fill", "format": "webp", "quality": 84},
|
|
27
|
+
{"id": "profile-cover", "name": "profile-cover", "category": "avatar-profile", "description": "Wide profile cover image.", "width": 1500, "height": 500, "crop": "fill", "format": "webp", "quality": 82},
|
|
28
|
+
{"id": "webp-auto", "name": "webp-auto", "category": "performance", "description": "Auto-sized WebP delivery with browser-friendly compression.", "width": 1280, "crop": "fit", "format": "webp", "quality": 78},
|
|
29
|
+
{"id": "avif-compressed", "name": "avif-compressed", "category": "performance", "description": "Highly compressed AVIF for modern browsers.", "width": 1280, "crop": "fit", "format": "avif", "quality": 62},
|
|
30
|
+
{"id": "lqip", "name": "lqip", "category": "performance", "description": "Tiny low-quality placeholder for progressive image loading.", "width": 48, "crop": "fit", "format": "jpeg", "quality": 35},
|
|
31
|
+
{"id": "blur-placeholder", "name": "blur-placeholder", "category": "performance", "description": "Small placeholder candidate for blurred loading states.", "width": 32, "height": 32, "crop": "fill", "format": "jpeg", "quality": 30},
|
|
32
|
+
{"id": "email-safe-600", "name": "email-safe-600", "category": "email-docs", "description": "Email-safe content image with conservative width.", "width": 600, "crop": "fit", "format": "jpeg", "quality": 82},
|
|
33
|
+
{"id": "markdown-image", "name": "markdown-image", "category": "email-docs", "description": "Documentation image that fits most reading columns.", "width": 800, "crop": "fit", "format": "webp", "quality": 82},
|
|
34
|
+
{"id": "docs-screenshot", "name": "docs-screenshot", "category": "email-docs", "description": "Crisp screenshot size for guides and changelogs.", "width": 1440, "height": 900, "crop": "fit", "format": "webp", "quality": 84},
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_asset_transform_preset(name: str) -> dict[str, Any] | None:
|
|
39
|
+
return next((preset for preset in ASSET_TRANSFORM_PRESETS if preset["name"] == name), None)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def asset_transform_options(preset: dict[str, Any]) -> dict[str, Any]:
|
|
43
|
+
return {key: preset[key] for key in ("width", "height", "crop", "format", "quality") if key in preset}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def asset_transform_spec(**options: Any) -> str:
|
|
47
|
+
return _transform_spec(options)
|
|
6
48
|
|
|
7
49
|
|
|
8
50
|
class AssetsClient:
|
|
@@ -25,7 +67,7 @@ class AssetsClient:
|
|
|
25
67
|
"key": key,
|
|
26
68
|
"folder": folder,
|
|
27
69
|
"visibility": visibility,
|
|
28
|
-
"
|
|
70
|
+
"cache_control": cache_control,
|
|
29
71
|
"metadata": json.dumps(metadata) if metadata else None,
|
|
30
72
|
})
|
|
31
73
|
|
|
@@ -36,21 +78,25 @@ class AssetsClient:
|
|
|
36
78
|
def get(self, asset_id: str) -> dict[str, Any]:
|
|
37
79
|
return self._client.request("GET", f"/assets/{asset_id}")
|
|
38
80
|
|
|
39
|
-
def delete(self, asset_id: str) -> dict[str, Any]:
|
|
40
|
-
|
|
81
|
+
def delete(self, asset_id: str, revision: int, *, idempotency_key: str | None = None) -> dict[str, Any]:
|
|
82
|
+
headers = {"If-Match": f'"{revision}"'}
|
|
83
|
+
if idempotency_key:
|
|
84
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
85
|
+
return self._client.request("DELETE", f"/assets/{asset_id}", headers=headers)
|
|
41
86
|
|
|
42
87
|
def replace(
|
|
43
88
|
self,
|
|
44
89
|
asset_id: str,
|
|
90
|
+
revision: int,
|
|
45
91
|
file: str | Path | BinaryIO,
|
|
46
92
|
*,
|
|
47
93
|
cache_control: str | None = None,
|
|
48
94
|
metadata: dict[str, Any] | None = None,
|
|
49
95
|
) -> dict[str, Any]:
|
|
50
96
|
return self._multipart("PUT", f"/assets/{asset_id}/replace", file, {
|
|
51
|
-
"
|
|
97
|
+
"cache_control": cache_control,
|
|
52
98
|
"metadata": json.dumps(metadata) if metadata else None,
|
|
53
|
-
})
|
|
99
|
+
}, headers={"If-Match": f'"{revision}"'})
|
|
54
100
|
|
|
55
101
|
def signed_url(
|
|
56
102
|
self,
|
|
@@ -60,18 +106,259 @@ class AssetsClient:
|
|
|
60
106
|
max_downloads: int | None = None,
|
|
61
107
|
) -> dict[str, Any]:
|
|
62
108
|
return self._client.request("POST", f"/assets/{asset_id}/signed-url", json={
|
|
63
|
-
"
|
|
64
|
-
"
|
|
109
|
+
"expires_in": expires_in,
|
|
110
|
+
"max_downloads": max_downloads,
|
|
65
111
|
})
|
|
66
112
|
|
|
67
113
|
def signed_upload_url(self, **payload: Any) -> dict[str, Any]:
|
|
68
114
|
return self.create_upload_session(**payload)
|
|
69
115
|
|
|
70
116
|
def create_upload_session(self, **payload: Any) -> dict[str, Any]:
|
|
117
|
+
idempotency_key = payload.pop("idempotency_key", None)
|
|
71
118
|
clean = {key: value for key, value in payload.items() if value not in (None, "")}
|
|
72
|
-
|
|
119
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
120
|
+
return self._client.request("POST", "/assets/upload-sessions", json=clean, headers=headers)
|
|
121
|
+
|
|
122
|
+
def create_chunked_upload_session(self, **payload: Any) -> dict[str, Any]:
|
|
123
|
+
return self.create_upload_session(mode="chunked", **payload)
|
|
124
|
+
|
|
125
|
+
def upload_chunk(
|
|
126
|
+
self,
|
|
127
|
+
upload_url_or_token: str,
|
|
128
|
+
part_number: int,
|
|
129
|
+
chunk: bytes | BinaryIO,
|
|
130
|
+
) -> dict[str, Any]:
|
|
131
|
+
target = upload_url_or_token.rstrip("/")
|
|
132
|
+
if not target.startswith("http"):
|
|
133
|
+
origin = self._client.base_url.removesuffix("/api/v1")
|
|
134
|
+
target = f"{origin}/uploads/{quote(upload_url_or_token)}"
|
|
135
|
+
|
|
136
|
+
raw = chunk if isinstance(chunk, bytes) else chunk.read()
|
|
137
|
+
response = self._client.session.put(
|
|
138
|
+
f"{target}/parts/{part_number}",
|
|
139
|
+
data=raw,
|
|
140
|
+
headers={"User-Agent": "StackShift-Python/0.1", "X-Content-SHA256": hashlib.sha256(raw).hexdigest()},
|
|
141
|
+
)
|
|
142
|
+
payload = response.json() if response.content else None
|
|
143
|
+
if response.status_code < 200 or response.status_code >= 300:
|
|
144
|
+
message = payload.get("message") if isinstance(payload, dict) else None
|
|
145
|
+
raise RuntimeError(message or f"StackShift upload failed with {response.status_code}")
|
|
146
|
+
return payload["data"] if isinstance(payload, dict) and "data" in payload else payload
|
|
147
|
+
|
|
148
|
+
def resume_upload_session(self, session_id: str) -> dict[str, Any]:
|
|
149
|
+
return self._client.request("GET", f"/assets/upload-sessions/{session_id}")
|
|
150
|
+
|
|
151
|
+
def complete_upload_session(self, session_id: str) -> dict[str, Any]:
|
|
152
|
+
return self._client.request("POST", f"/assets/upload-sessions/{session_id}/complete")
|
|
153
|
+
|
|
154
|
+
def cancel_upload_session(self, session_id: str) -> dict[str, Any]:
|
|
155
|
+
return self._client.request("DELETE", f"/assets/upload-sessions/{session_id}")
|
|
156
|
+
|
|
157
|
+
def list_buckets(self) -> dict[str, Any]:
|
|
158
|
+
return self._client.request("GET", "/assets/buckets")
|
|
159
|
+
|
|
160
|
+
def create_bucket(self, **payload: Any) -> dict[str, Any]:
|
|
161
|
+
return self._client.request("POST", "/assets/buckets", json=payload)
|
|
162
|
+
|
|
163
|
+
def update_bucket(self, bucket_id: str, revision: int, **payload: Any) -> dict[str, Any]:
|
|
164
|
+
return self._client.request("PUT", f"/assets/buckets/{bucket_id}", json=payload, headers={"If-Match": f'"{revision}"'})
|
|
165
|
+
|
|
166
|
+
def delete_bucket(self, bucket_id: str, revision: int) -> dict[str, Any]:
|
|
167
|
+
return self._client.request("DELETE", f"/assets/buckets/{bucket_id}", headers={"If-Match": f'"{revision}"'})
|
|
168
|
+
|
|
169
|
+
def set_tags(self, asset_id: str, revision: int, tags: list[str]) -> dict[str, Any]:
|
|
170
|
+
return self._client.request("PUT", f"/assets/{asset_id}/tags", json={"tags": tags}, headers={"If-Match": f'"{revision}"'})
|
|
171
|
+
|
|
172
|
+
def patch_metadata(self, asset_id: str, revision: int, metadata: dict[str, Any]) -> dict[str, Any]:
|
|
173
|
+
return self._client.request("PATCH", f"/assets/{asset_id}/metadata", json={"metadata": metadata}, headers={"If-Match": f'"{revision}"'})
|
|
174
|
+
|
|
175
|
+
def patch(self, asset_id: str, revision: int, **payload: Any) -> dict[str, Any]:
|
|
176
|
+
return self._client.request("PATCH", f"/assets/{asset_id}", json=payload, headers={"If-Match": f'"{revision}"'})
|
|
177
|
+
|
|
178
|
+
def url(self, asset_id: str, **options: Any) -> str:
|
|
179
|
+
spec = _transform_spec(options)
|
|
180
|
+
return f"{self._client.cdn_base_url}/assets/{quote(asset_id)}/tr/{quote(spec)}"
|
|
181
|
+
|
|
182
|
+
def named_url(self, asset_id: str, name: str) -> str:
|
|
183
|
+
return f"{self._client.cdn_base_url}/t/{quote(name)}/assets/{quote(asset_id)}"
|
|
184
|
+
|
|
185
|
+
def version_url(self, asset_id: str, version_id: str, token: str | None = None) -> str:
|
|
186
|
+
prefix = "/private/assets" if token else "/assets"
|
|
187
|
+
result = f"{self._client.cdn_base_url}{prefix}/{quote(asset_id)}/versions/{quote(version_id)}"
|
|
188
|
+
return f"{result}?token={quote(token)}" if token else result
|
|
189
|
+
|
|
190
|
+
def branch_url(self, asset_id: str, branch: str, token: str | None = None) -> str:
|
|
191
|
+
prefix = "/private/assets" if token else "/assets"
|
|
192
|
+
result = f"{self._client.cdn_base_url}{prefix}/{quote(asset_id)}/branches/{quote(branch)}"
|
|
193
|
+
return f"{result}?token={quote(token)}" if token else result
|
|
194
|
+
|
|
195
|
+
def video_url(self, asset_id: str, kind: str, profile: str = "default", token: str | None = None) -> str:
|
|
196
|
+
prefix = "/private/assets" if token else "/assets"
|
|
197
|
+
result = f"{self._client.cdn_base_url}{prefix}/{quote(asset_id)}/video/{quote(kind)}/{quote(profile)}"
|
|
198
|
+
return f"{result}?token={quote(token)}" if token else result
|
|
199
|
+
|
|
200
|
+
def ai(self, asset_id: str) -> dict[str, Any]:
|
|
201
|
+
return self._client.request("GET", f"/assets/{asset_id}/ai")
|
|
202
|
+
|
|
203
|
+
def request_ai_analyze(self, asset_id: str, *, idempotency_key: str | None = None) -> dict[str, Any]:
|
|
204
|
+
return self._request_ai_job(f"/assets/{asset_id}/ai/analyze", idempotency_key)
|
|
205
|
+
|
|
206
|
+
def request_moderation(self, asset_id: str, *, idempotency_key: str | None = None) -> dict[str, Any]:
|
|
207
|
+
return self._request_ai_job(f"/assets/{asset_id}/moderation", idempotency_key)
|
|
208
|
+
|
|
209
|
+
def transcript(self, asset_id: str) -> dict[str, Any]:
|
|
210
|
+
return self._client.request("GET", f"/assets/{asset_id}/transcript")
|
|
211
|
+
|
|
212
|
+
def request_transcript(self, asset_id: str, *, idempotency_key: str | None = None) -> dict[str, Any]:
|
|
213
|
+
return self._request_ai_job(f"/assets/{asset_id}/transcript", idempotency_key)
|
|
214
|
+
|
|
215
|
+
def request_smart_crop(self, asset_id: str, *, idempotency_key: str | None = None) -> dict[str, Any]:
|
|
216
|
+
return self._request_ai_job(f"/assets/{asset_id}/smart-crop", idempotency_key)
|
|
217
|
+
|
|
218
|
+
def request_background_removal(self, asset_id: str, *, idempotency_key: str | None = None) -> dict[str, Any]:
|
|
219
|
+
return self._request_ai_job(f"/assets/{asset_id}/background-remove", idempotency_key)
|
|
220
|
+
|
|
221
|
+
def _request_ai_job(self, path: str, idempotency_key: str | None) -> dict[str, Any]:
|
|
222
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
223
|
+
return self._client.request("POST", path, headers=headers)
|
|
224
|
+
|
|
225
|
+
def versions(self, asset_id: str) -> dict[str, Any]:
|
|
226
|
+
return self._client.request("GET", f"/assets/{asset_id}/versions")
|
|
227
|
+
|
|
228
|
+
def create_branch(
|
|
229
|
+
self,
|
|
230
|
+
asset_id: str,
|
|
231
|
+
name: str,
|
|
232
|
+
from_version_id: str | None = None,
|
|
233
|
+
) -> dict[str, Any]:
|
|
234
|
+
payload = {"name": name, "from_version_id": from_version_id}
|
|
235
|
+
clean = {key: value for key, value in payload.items() if value not in (None, "")}
|
|
236
|
+
return self._client.request("POST", f"/assets/{asset_id}/branches", json=clean)
|
|
237
|
+
|
|
238
|
+
def restore_version(self, asset_id: str, version_id: str) -> dict[str, Any]:
|
|
239
|
+
return self._client.request("POST", f"/assets/{asset_id}/versions/{version_id}/restore")
|
|
240
|
+
|
|
241
|
+
def promote_branch(self, asset_id: str, branch: str) -> dict[str, Any]:
|
|
242
|
+
return self._client.request("POST", f"/assets/{asset_id}/branches/{quote(branch)}/promote")
|
|
73
243
|
|
|
74
|
-
def
|
|
244
|
+
def collections(self) -> dict[str, Any]:
|
|
245
|
+
return self._client.request("GET", "/assets/collections")
|
|
246
|
+
|
|
247
|
+
def create_collection(self, **payload: Any) -> dict[str, Any]:
|
|
248
|
+
clean = {key: value for key, value in payload.items() if value not in (None, "")}
|
|
249
|
+
return self._client.request("POST", "/assets/collections", json=clean)
|
|
250
|
+
|
|
251
|
+
def update_collection(self, collection_id: str, **payload: Any) -> dict[str, Any]:
|
|
252
|
+
clean = {key: value for key, value in payload.items() if value not in (None, "")}
|
|
253
|
+
return self._client.request("PUT", f"/assets/collections/{collection_id}", json=clean)
|
|
254
|
+
|
|
255
|
+
def delete_collection(self, collection_id: str) -> dict[str, Any]:
|
|
256
|
+
return self._client.request("DELETE", f"/assets/collections/{collection_id}")
|
|
257
|
+
|
|
258
|
+
def saved_searches(self) -> dict[str, Any]:
|
|
259
|
+
return self._client.request("GET", "/assets/saved-searches")
|
|
260
|
+
|
|
261
|
+
def save_search(self, **payload: Any) -> dict[str, Any]:
|
|
262
|
+
clean = {key: value for key, value in payload.items() if value not in (None, "")}
|
|
263
|
+
return self._client.request("POST", "/assets/saved-searches", json=clean)
|
|
264
|
+
|
|
265
|
+
def update_saved_search(self, search_id: str, **payload: Any) -> dict[str, Any]:
|
|
266
|
+
clean = {key: value for key, value in payload.items() if value not in (None, "")}
|
|
267
|
+
return self._client.request("PUT", f"/assets/saved-searches/{search_id}", json=clean)
|
|
268
|
+
|
|
269
|
+
def delete_saved_search(self, search_id: str) -> dict[str, Any]:
|
|
270
|
+
return self._client.request("DELETE", f"/assets/saved-searches/{search_id}")
|
|
271
|
+
|
|
272
|
+
def get_policy(self) -> dict[str, Any]:
|
|
273
|
+
return self._client.request("GET", "/assets/policy")
|
|
274
|
+
|
|
275
|
+
def update_policy(self, **payload: Any) -> dict[str, Any]:
|
|
276
|
+
clean = {key: value for key, value in payload.items() if value not in (None, "")}
|
|
277
|
+
return self._client.request("PUT", "/assets/policy", json=clean)
|
|
278
|
+
|
|
279
|
+
def create_transformation(self, name: str, **options: Any) -> dict[str, Any]:
|
|
280
|
+
payload = {"name": name, **options}
|
|
281
|
+
clean = {key: value for key, value in payload.items() if value not in (None, "")}
|
|
282
|
+
return self._client.request("POST", "/assets/transformations", json=clean)
|
|
283
|
+
|
|
284
|
+
def list_transformations(self) -> dict[str, Any]:
|
|
285
|
+
return self._client.request("GET", "/assets/transformations")
|
|
286
|
+
|
|
287
|
+
def delete_transformation(self, name: str) -> dict[str, Any]:
|
|
288
|
+
return self._client.request("DELETE", f"/assets/transformations/{quote(name)}")
|
|
289
|
+
|
|
290
|
+
def signed_transform_url(self, asset_id: str, **options: Any) -> dict[str, Any]:
|
|
291
|
+
clean = {key: value for key, value in options.items() if value not in (None, "")}
|
|
292
|
+
return self._client.request("POST", f"/assets/{asset_id}/transform-url", json=clean)
|
|
293
|
+
|
|
294
|
+
def get_ai_config(self) -> dict[str, Any]:
|
|
295
|
+
return self._client.request("GET", "/assets/ai/config")
|
|
296
|
+
|
|
297
|
+
def update_ai_config(self, revision: int, *, enabled_actions: list[str], monthly_spend_cap_micros: int) -> dict[str, Any]:
|
|
298
|
+
return self._client.request("PUT", "/assets/ai/config", headers={"If-Match": f'"{revision}"'}, json={
|
|
299
|
+
"enabled_actions": enabled_actions,
|
|
300
|
+
"monthly_spend_cap_micros": monthly_spend_cap_micros,
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
def job(self, job_id: str) -> dict[str, Any]:
|
|
304
|
+
return self._client.request("GET", f"/assets/jobs/{quote(job_id)}")
|
|
305
|
+
|
|
306
|
+
def cancel_job(self, job_id: str) -> dict[str, Any]:
|
|
307
|
+
return self._client.request("POST", f"/assets/jobs/{quote(job_id)}/cancel")
|
|
308
|
+
|
|
309
|
+
def create_lifecycle_rule(self, **payload: Any) -> dict[str, Any]:
|
|
310
|
+
return self._client.request("POST", "/assets/lifecycle-rules", json=payload)
|
|
311
|
+
|
|
312
|
+
def list_lifecycle_rules(self) -> dict[str, Any]:
|
|
313
|
+
return self._client.request("GET", "/assets/lifecycle-rules")
|
|
314
|
+
|
|
315
|
+
def delete_lifecycle_rule(self, rule_id: str) -> dict[str, Any]:
|
|
316
|
+
return self._client.request("DELETE", f"/assets/lifecycle-rules/{quote(rule_id)}")
|
|
317
|
+
|
|
318
|
+
def create_domain(self, domain: str) -> dict[str, Any]:
|
|
319
|
+
return self._client.request("POST", "/assets/domains", json={"domain": domain})
|
|
320
|
+
|
|
321
|
+
def list_domains(self) -> dict[str, Any]:
|
|
322
|
+
return self._client.request("GET", "/assets/domains")
|
|
323
|
+
|
|
324
|
+
def verify_domain(self, domain_id: str) -> dict[str, Any]:
|
|
325
|
+
return self._client.request("POST", f"/assets/domains/{quote(domain_id)}/verify")
|
|
326
|
+
|
|
327
|
+
def delete_domain(self, domain_id: str) -> dict[str, Any]:
|
|
328
|
+
return self._client.request("DELETE", f"/assets/domains/{quote(domain_id)}")
|
|
329
|
+
|
|
330
|
+
def analytics(self) -> dict[str, Any]:
|
|
331
|
+
return self._client.request("GET", "/assets/analytics")
|
|
332
|
+
|
|
333
|
+
def usage_summary(self) -> dict[str, Any]:
|
|
334
|
+
return self._client.request("GET", "/assets/summary")
|
|
335
|
+
|
|
336
|
+
def events(self, limit: int = 50) -> dict[str, Any]:
|
|
337
|
+
return self._client.request("GET", "/assets/events", params={"limit": limit})
|
|
338
|
+
|
|
339
|
+
def create_webhook(self, url: str, event_types: list[str] | None = None) -> dict[str, Any]:
|
|
340
|
+
return self._client.request("POST", "/assets/webhooks", json={"url": url, "event_types": event_types or ["asset.*"]})
|
|
341
|
+
|
|
342
|
+
def list_webhooks(self) -> dict[str, Any]:
|
|
343
|
+
return self._client.request("GET", "/assets/webhooks")
|
|
344
|
+
|
|
345
|
+
def delete_webhook(self, webhook_id: str) -> dict[str, Any]:
|
|
346
|
+
return self._client.request("DELETE", f"/assets/webhooks/{quote(webhook_id)}")
|
|
347
|
+
|
|
348
|
+
def list_webhook_deliveries(self, webhook_id: str, limit: int = 50) -> dict[str, Any]:
|
|
349
|
+
return self._client.request("GET", f"/assets/webhooks/{quote(webhook_id)}/deliveries", params={"limit": limit})
|
|
350
|
+
|
|
351
|
+
def retry_webhook_delivery(self, delivery_id: str) -> dict[str, Any]:
|
|
352
|
+
return self._client.request("POST", f"/assets/webhook-deliveries/{quote(delivery_id)}/retry")
|
|
353
|
+
|
|
354
|
+
def purge(self, asset_id: str) -> dict[str, Any]:
|
|
355
|
+
return self._client.request("POST", f"/assets/{quote(asset_id)}/purge")
|
|
356
|
+
|
|
357
|
+
def bulk(self, *, action: str, items: list[dict[str, Any]], idempotency_key: str | None = None, **payload: Any) -> dict[str, Any]:
|
|
358
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
359
|
+
return self._client.request("POST", "/assets/bulk", json={"action": action, "items": items, **payload}, headers=headers)
|
|
360
|
+
|
|
361
|
+
def _multipart(self, method: str, suffix: str, file: str | Path | BinaryIO, fields: dict[str, Any], headers: dict[str, str] | None = None) -> dict[str, Any]:
|
|
75
362
|
should_close = False
|
|
76
363
|
if isinstance(file, (str, Path)):
|
|
77
364
|
handle = Path(file).open("rb")
|
|
@@ -83,7 +370,21 @@ class AssetsClient:
|
|
|
83
370
|
|
|
84
371
|
try:
|
|
85
372
|
data = {key: value for key, value in fields.items() if value not in (None, "")}
|
|
86
|
-
return self._client.request(method, suffix, data=data, files={"file": (filename, handle)})
|
|
373
|
+
return self._client.request(method, suffix, data=data, files={"file": (filename, handle)}, headers=headers)
|
|
87
374
|
finally:
|
|
88
375
|
if should_close:
|
|
89
376
|
handle.close()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _transform_spec(options: dict[str, Any]) -> str:
|
|
380
|
+
parts: list[str] = []
|
|
381
|
+
if options.get("width"):
|
|
382
|
+
parts.append(f"w_{options['width']}")
|
|
383
|
+
if options.get("height"):
|
|
384
|
+
parts.append(f"h_{options['height']}")
|
|
385
|
+
parts.append(f"c_{options.get('crop') or 'fit'}")
|
|
386
|
+
parts.append(f"f_{options.get('format') or 'auto'}")
|
|
387
|
+
parts.append(f"q_{options.get('quality') or 'auto'}")
|
|
388
|
+
parts.append(f"g_{options.get('gravity') or 'centre'}")
|
|
389
|
+
parts.append(f"a_{options.get('animation') or 'first'}")
|
|
390
|
+
return ",".join(parts)
|
stackshift/client.py
CHANGED
|
@@ -12,6 +12,8 @@ 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",
|
|
16
|
+
cdn_base_url: str = "https://cdn.stackshift.cloud",
|
|
15
17
|
session: requests.Session | None = None,
|
|
16
18
|
) -> None:
|
|
17
19
|
api_key = api_key or os.getenv("STACKSHIFT_API_KEY")
|
|
@@ -20,25 +22,58 @@ class StackShift:
|
|
|
20
22
|
|
|
21
23
|
self.api_key = api_key
|
|
22
24
|
self.base_url = base_url.rstrip("/")
|
|
25
|
+
self.mail_base_url = mail_base_url.rstrip("/")
|
|
26
|
+
self.cdn_base_url = cdn_base_url.rstrip("/")
|
|
23
27
|
self.session = session or requests.Session()
|
|
24
28
|
|
|
25
29
|
from .assets import AssetsClient
|
|
30
|
+
from .mail import MailClient
|
|
26
31
|
from .projects import ProjectsClient
|
|
27
32
|
|
|
28
33
|
self.projects = ProjectsClient(self)
|
|
29
34
|
self.assets = AssetsClient(self)
|
|
35
|
+
self.mail = MailClient(self)
|
|
30
36
|
|
|
31
37
|
def request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
38
|
+
return self._request(self.base_url, method, path, **kwargs)
|
|
39
|
+
|
|
40
|
+
def mail_request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
41
|
+
return self._request(self.mail_base_url, method, path, **kwargs)
|
|
42
|
+
|
|
43
|
+
def _request(self, base_url: str, method: str, path: str, **kwargs: Any) -> Any:
|
|
32
44
|
headers = kwargs.pop("headers", {})
|
|
33
45
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
34
46
|
headers["User-Agent"] = "StackShift-Python/0.1"
|
|
35
47
|
|
|
36
|
-
response = self.session.request(method, f"{
|
|
48
|
+
response = self.session.request(method, f"{base_url}{path}", headers=headers, **kwargs)
|
|
37
49
|
payload = response.json() if response.content else None
|
|
38
50
|
if response.status_code < 200 or response.status_code >= 300:
|
|
51
|
+
error = payload.get("error") if isinstance(payload, dict) else None
|
|
52
|
+
if isinstance(error, dict):
|
|
53
|
+
raise StackShiftAPIError(
|
|
54
|
+
error.get("message") or f"StackShift API request failed with {response.status_code}",
|
|
55
|
+
status_code=response.status_code,
|
|
56
|
+
code=error.get("code"),
|
|
57
|
+
details=error.get("details"),
|
|
58
|
+
)
|
|
39
59
|
message = payload.get("message") if isinstance(payload, dict) else None
|
|
40
|
-
raise
|
|
60
|
+
raise StackShiftAPIError(message or f"StackShift API request failed with {response.status_code}", status_code=response.status_code)
|
|
41
61
|
|
|
42
62
|
if isinstance(payload, dict) and "success" in payload and "data" in payload:
|
|
43
63
|
return payload["data"]
|
|
44
64
|
return payload
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class StackShiftAPIError(RuntimeError):
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
message: str,
|
|
71
|
+
*,
|
|
72
|
+
status_code: int,
|
|
73
|
+
code: str | None = None,
|
|
74
|
+
details: dict[str, Any] | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
super().__init__(message)
|
|
77
|
+
self.status_code = status_code
|
|
78
|
+
self.code = code
|
|
79
|
+
self.details = details
|
stackshift/mail.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
from .mail_advanced import MailAnalyticsClient, MailBatchClient, MailInboundClient, MailScheduledClient
|
|
7
|
+
from .mail_phase7 import MailEventsClient, MailWebhooksClient
|
|
8
|
+
from .mail_reputation import MailLimitsClient, MailReputationClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MailClient:
|
|
12
|
+
def __init__(self, client: Any) -> None:
|
|
13
|
+
self._client = client
|
|
14
|
+
self.domains = MailDomainsClient(client)
|
|
15
|
+
self.messages = MailMessagesClient(client)
|
|
16
|
+
self.suppressions = MailSuppressionsClient(client)
|
|
17
|
+
self.bounces = MailBouncesClient(client)
|
|
18
|
+
self.otp = MailOTPClient(client)
|
|
19
|
+
self.templates = MailTemplatesClient(client)
|
|
20
|
+
self.events = MailEventsClient(client)
|
|
21
|
+
self.webhooks = MailWebhooksClient(client)
|
|
22
|
+
self.limits = MailLimitsClient(client)
|
|
23
|
+
self.reputation = MailReputationClient(client)
|
|
24
|
+
self.scheduled = MailScheduledClient(client)
|
|
25
|
+
self.batch = MailBatchClient(client)
|
|
26
|
+
self.inbound = MailInboundClient(client)
|
|
27
|
+
self.analytics = MailAnalyticsClient(client)
|
|
28
|
+
|
|
29
|
+
def send(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
from_: str | dict[str, str],
|
|
33
|
+
to: str | list[str] | list[dict[str, str]],
|
|
34
|
+
subject: str,
|
|
35
|
+
html: str | None = None,
|
|
36
|
+
text: str | None = None,
|
|
37
|
+
cc: str | list[str] | list[dict[str, str]] | None = None,
|
|
38
|
+
bcc: str | list[str] | list[dict[str, str]] | None = None,
|
|
39
|
+
reply_to: str | dict[str, str] | None = None,
|
|
40
|
+
idempotency_key: str | None = None,
|
|
41
|
+
attachments: list[dict[str, Any]] | None = None,
|
|
42
|
+
) -> dict[str, Any]:
|
|
43
|
+
payload = {
|
|
44
|
+
"from": from_,
|
|
45
|
+
"to": to,
|
|
46
|
+
"cc": cc,
|
|
47
|
+
"bcc": bcc,
|
|
48
|
+
"replyTo": reply_to,
|
|
49
|
+
"subject": subject,
|
|
50
|
+
"html": html,
|
|
51
|
+
"text": text,
|
|
52
|
+
"idempotencyKey": idempotency_key,
|
|
53
|
+
"attachments": attachments,
|
|
54
|
+
}
|
|
55
|
+
clean = {key: value for key, value in payload.items() if value is not None}
|
|
56
|
+
return self._client.mail_request("POST", "/mail/send", json=clean)
|
|
57
|
+
|
|
58
|
+
def send_template(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
template: str,
|
|
62
|
+
to: str | list[str] | list[dict[str, str]],
|
|
63
|
+
data: dict[str, Any],
|
|
64
|
+
from_: str | dict[str, str] | None = None,
|
|
65
|
+
version_id: str | None = None,
|
|
66
|
+
cc: str | list[str] | list[dict[str, str]] | None = None,
|
|
67
|
+
bcc: str | list[str] | list[dict[str, str]] | None = None,
|
|
68
|
+
reply_to: str | dict[str, str] | None = None,
|
|
69
|
+
idempotency_key: str | None = None,
|
|
70
|
+
attachments: list[dict[str, Any]] | None = None,
|
|
71
|
+
) -> dict[str, Any]:
|
|
72
|
+
payload = {
|
|
73
|
+
"template": template,
|
|
74
|
+
"versionId": version_id,
|
|
75
|
+
"to": to,
|
|
76
|
+
"from": from_,
|
|
77
|
+
"cc": cc,
|
|
78
|
+
"bcc": bcc,
|
|
79
|
+
"replyTo": reply_to,
|
|
80
|
+
"data": data,
|
|
81
|
+
"idempotencyKey": idempotency_key,
|
|
82
|
+
"attachments": attachments,
|
|
83
|
+
}
|
|
84
|
+
clean = {key: value for key, value in payload.items() if value is not None}
|
|
85
|
+
return self._client.mail_request("POST", "/mail/send-template", json=clean)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class MailTemplatesClient:
|
|
89
|
+
def __init__(self, client: Any) -> None:
|
|
90
|
+
self._client = client
|
|
91
|
+
|
|
92
|
+
def create(
|
|
93
|
+
self,
|
|
94
|
+
*,
|
|
95
|
+
name: str,
|
|
96
|
+
slug: str,
|
|
97
|
+
subject: str,
|
|
98
|
+
description: str | None = None,
|
|
99
|
+
html: str | None = None,
|
|
100
|
+
text: str | None = None,
|
|
101
|
+
) -> dict[str, Any]:
|
|
102
|
+
payload = {
|
|
103
|
+
"name": name,
|
|
104
|
+
"slug": slug,
|
|
105
|
+
"description": description,
|
|
106
|
+
"subject": subject,
|
|
107
|
+
"html": html,
|
|
108
|
+
"text": text,
|
|
109
|
+
}
|
|
110
|
+
clean = {key: value for key, value in payload.items() if value is not None}
|
|
111
|
+
return self._client.mail_request("POST", "/mail/templates", json=clean)
|
|
112
|
+
|
|
113
|
+
def list(
|
|
114
|
+
self,
|
|
115
|
+
*,
|
|
116
|
+
status: str | None = None,
|
|
117
|
+
limit: int | None = None,
|
|
118
|
+
cursor: str | None = None,
|
|
119
|
+
search: str | None = None,
|
|
120
|
+
) -> dict[str, Any]:
|
|
121
|
+
params = {"status": status, "limit": limit, "cursor": cursor, "search": search}
|
|
122
|
+
clean = {key: value for key, value in params.items() if value not in (None, "")}
|
|
123
|
+
return self._client.mail_request("GET", "/mail/templates", params=clean)
|
|
124
|
+
|
|
125
|
+
def get(self, template_id_or_slug: str) -> dict[str, Any]:
|
|
126
|
+
return self._client.mail_request("GET", f"/mail/templates/{quote(template_id_or_slug)}")
|
|
127
|
+
|
|
128
|
+
def update(
|
|
129
|
+
self,
|
|
130
|
+
template_id: str,
|
|
131
|
+
*,
|
|
132
|
+
name: str | None = None,
|
|
133
|
+
description: str | None = None,
|
|
134
|
+
subject: str | None = None,
|
|
135
|
+
html: str | None = None,
|
|
136
|
+
text: str | None = None,
|
|
137
|
+
) -> dict[str, Any]:
|
|
138
|
+
payload = {
|
|
139
|
+
"name": name,
|
|
140
|
+
"description": description,
|
|
141
|
+
"subject": subject,
|
|
142
|
+
"html": html,
|
|
143
|
+
"text": text,
|
|
144
|
+
}
|
|
145
|
+
clean = {key: value for key, value in payload.items() if value is not None}
|
|
146
|
+
return self._client.mail_request("PATCH", f"/mail/templates/{quote(template_id)}", json=clean)
|
|
147
|
+
|
|
148
|
+
def delete(self, template_id: str) -> None:
|
|
149
|
+
self._client.mail_request("DELETE", f"/mail/templates/{quote(template_id)}")
|
|
150
|
+
|
|
151
|
+
def versions(self, template_id: str) -> dict[str, Any]:
|
|
152
|
+
return self._client.mail_request("GET", f"/mail/templates/{quote(template_id)}/versions")
|
|
153
|
+
|
|
154
|
+
def get_version(self, template_id: str, version_id: str) -> dict[str, Any]:
|
|
155
|
+
return self._client.mail_request("GET", f"/mail/templates/{quote(template_id)}/versions/{quote(version_id)}")
|
|
156
|
+
|
|
157
|
+
def activate_version(self, template_id: str, version_id: str) -> dict[str, Any]:
|
|
158
|
+
return self._client.mail_request("POST", f"/mail/templates/{quote(template_id)}/versions/{quote(version_id)}/activate")
|
|
159
|
+
|
|
160
|
+
def preview(self, template_id: str, *, data: dict[str, Any], version_id: str | None = None) -> dict[str, Any]:
|
|
161
|
+
payload = {"data": data, "versionId": version_id}
|
|
162
|
+
clean = {key: value for key, value in payload.items() if value is not None}
|
|
163
|
+
return self._client.mail_request("POST", f"/mail/templates/{quote(template_id)}/preview", json=clean)
|
|
164
|
+
|
|
165
|
+
def test_send(
|
|
166
|
+
self,
|
|
167
|
+
template_id: str,
|
|
168
|
+
*,
|
|
169
|
+
to: str | list[str] | list[dict[str, str]],
|
|
170
|
+
data: dict[str, Any],
|
|
171
|
+
from_: str | dict[str, str] | None = None,
|
|
172
|
+
version_id: str | None = None,
|
|
173
|
+
) -> dict[str, Any]:
|
|
174
|
+
payload = {"to": to, "from": from_, "data": data, "versionId": version_id}
|
|
175
|
+
clean = {key: value for key, value in payload.items() if value is not None}
|
|
176
|
+
return self._client.mail_request("POST", f"/mail/templates/{quote(template_id)}/test-send", json=clean)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class MailDomainsClient:
|
|
180
|
+
def __init__(self, client: Any) -> None:
|
|
181
|
+
self._client = client
|
|
182
|
+
|
|
183
|
+
def create(self, domain: str) -> dict[str, Any]:
|
|
184
|
+
return self._client.mail_request("POST", "/mail/domains", json={"domain": domain})
|
|
185
|
+
|
|
186
|
+
def list(self) -> dict[str, Any]:
|
|
187
|
+
return self._client.mail_request("GET", "/mail/domains")
|
|
188
|
+
|
|
189
|
+
def get(self, domain_id: str) -> dict[str, Any]:
|
|
190
|
+
return self._client.mail_request("GET", f"/mail/domains/{quote(domain_id)}")
|
|
191
|
+
|
|
192
|
+
def verify(self, domain_id: str) -> dict[str, Any]:
|
|
193
|
+
return self._client.mail_request("POST", f"/mail/domains/{quote(domain_id)}/verify")
|
|
194
|
+
|
|
195
|
+
def delete(self, domain_id: str) -> None:
|
|
196
|
+
self._client.mail_request("DELETE", f"/mail/domains/{quote(domain_id)}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class MailMessagesClient:
|
|
200
|
+
def __init__(self, client: Any) -> None:
|
|
201
|
+
self._client = client
|
|
202
|
+
|
|
203
|
+
def list(
|
|
204
|
+
self,
|
|
205
|
+
*,
|
|
206
|
+
status: str | None = None,
|
|
207
|
+
limit: int | None = None,
|
|
208
|
+
cursor: str | None = None,
|
|
209
|
+
to: str | None = None,
|
|
210
|
+
from_: str | None = None,
|
|
211
|
+
) -> dict[str, Any]:
|
|
212
|
+
params = {
|
|
213
|
+
"status": status,
|
|
214
|
+
"limit": limit,
|
|
215
|
+
"cursor": cursor,
|
|
216
|
+
"to": to,
|
|
217
|
+
"from": from_,
|
|
218
|
+
}
|
|
219
|
+
clean = {key: value for key, value in params.items() if value not in (None, "")}
|
|
220
|
+
return self._client.mail_request("GET", "/mail/messages", params=clean)
|
|
221
|
+
|
|
222
|
+
def get(self, message_id: str) -> dict[str, Any]:
|
|
223
|
+
return self._client.mail_request("GET", f"/mail/messages/{quote(message_id)}")
|
|
224
|
+
|
|
225
|
+
def attempts(self, message_id: str) -> dict[str, Any]:
|
|
226
|
+
return self._client.mail_request("GET", f"/mail/messages/{quote(message_id)}/attempts")
|
|
227
|
+
|
|
228
|
+
def logs(self, message_id: str) -> dict[str, Any]:
|
|
229
|
+
return self._client.mail_request("GET", f"/mail/messages/{quote(message_id)}/logs")
|
|
230
|
+
|
|
231
|
+
def bounces(self, message_id: str) -> dict[str, Any]:
|
|
232
|
+
return self._client.mail_request("GET", f"/mail/messages/{quote(message_id)}/bounces")
|
|
233
|
+
|
|
234
|
+
def timeline(self, message_id: str) -> dict[str, Any]:
|
|
235
|
+
return self._client.mail_request("GET", f"/mail/messages/{quote(message_id)}/timeline")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class MailSuppressionsClient:
|
|
239
|
+
def __init__(self, client: Any) -> None:
|
|
240
|
+
self._client = client
|
|
241
|
+
|
|
242
|
+
def list(
|
|
243
|
+
self,
|
|
244
|
+
*,
|
|
245
|
+
email: str | None = None,
|
|
246
|
+
reason: str | None = None,
|
|
247
|
+
limit: int | None = None,
|
|
248
|
+
cursor: str | None = None,
|
|
249
|
+
) -> dict[str, Any]:
|
|
250
|
+
params = {
|
|
251
|
+
"email": email,
|
|
252
|
+
"reason": reason,
|
|
253
|
+
"limit": limit,
|
|
254
|
+
"cursor": cursor,
|
|
255
|
+
}
|
|
256
|
+
clean = {key: value for key, value in params.items() if value not in (None, "")}
|
|
257
|
+
return self._client.mail_request("GET", "/mail/suppressions", params=clean)
|
|
258
|
+
|
|
259
|
+
def create(
|
|
260
|
+
self,
|
|
261
|
+
*,
|
|
262
|
+
email: str,
|
|
263
|
+
reason: str | None = None,
|
|
264
|
+
metadata: dict[str, Any] | None = None,
|
|
265
|
+
) -> dict[str, Any]:
|
|
266
|
+
payload = {
|
|
267
|
+
"email": email,
|
|
268
|
+
"reason": reason,
|
|
269
|
+
"metadata": metadata,
|
|
270
|
+
}
|
|
271
|
+
clean = {key: value for key, value in payload.items() if value is not None}
|
|
272
|
+
return self._client.mail_request("POST", "/mail/suppressions", json=clean)
|
|
273
|
+
|
|
274
|
+
def delete(self, suppression_id: str) -> None:
|
|
275
|
+
self._client.mail_request("DELETE", f"/mail/suppressions/{quote(suppression_id)}")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class MailBouncesClient:
|
|
279
|
+
def __init__(self, client: Any) -> None:
|
|
280
|
+
self._client = client
|
|
281
|
+
|
|
282
|
+
def list(
|
|
283
|
+
self,
|
|
284
|
+
*,
|
|
285
|
+
email: str | None = None,
|
|
286
|
+
type: str | None = None,
|
|
287
|
+
message_id: str | None = None,
|
|
288
|
+
limit: int | None = None,
|
|
289
|
+
cursor: str | None = None,
|
|
290
|
+
) -> dict[str, Any]:
|
|
291
|
+
params = {
|
|
292
|
+
"email": email,
|
|
293
|
+
"type": type,
|
|
294
|
+
"messageId": message_id,
|
|
295
|
+
"limit": limit,
|
|
296
|
+
"cursor": cursor,
|
|
297
|
+
}
|
|
298
|
+
clean = {key: value for key, value in params.items() if value not in (None, "")}
|
|
299
|
+
return self._client.mail_request("GET", "/mail/bounces", params=clean)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class MailOTPClient:
|
|
303
|
+
def __init__(self, client: Any) -> None:
|
|
304
|
+
self._client = client
|
|
305
|
+
self.challenges = MailOTPChallengesClient(client)
|
|
306
|
+
|
|
307
|
+
def send(
|
|
308
|
+
self,
|
|
309
|
+
*,
|
|
310
|
+
to: str,
|
|
311
|
+
purpose: str,
|
|
312
|
+
from_: str | None = None,
|
|
313
|
+
expires_in: str | None = None,
|
|
314
|
+
code_length: int | None = None,
|
|
315
|
+
metadata: dict[str, Any] | None = None,
|
|
316
|
+
idempotency_key: str | None = None,
|
|
317
|
+
subject: str | None = None,
|
|
318
|
+
brand_name: str | None = None,
|
|
319
|
+
) -> dict[str, Any]:
|
|
320
|
+
payload = {
|
|
321
|
+
"to": to,
|
|
322
|
+
"purpose": purpose,
|
|
323
|
+
"from": from_,
|
|
324
|
+
"expiresIn": expires_in,
|
|
325
|
+
"codeLength": code_length,
|
|
326
|
+
"metadata": metadata,
|
|
327
|
+
"idempotencyKey": idempotency_key,
|
|
328
|
+
"subject": subject,
|
|
329
|
+
"brandName": brand_name,
|
|
330
|
+
}
|
|
331
|
+
clean = {key: value for key, value in payload.items() if value is not None}
|
|
332
|
+
return self._client.mail_request("POST", "/mail/otp/send", json=clean)
|
|
333
|
+
|
|
334
|
+
def verify(
|
|
335
|
+
self,
|
|
336
|
+
*,
|
|
337
|
+
code: str,
|
|
338
|
+
to: str | None = None,
|
|
339
|
+
purpose: str | None = None,
|
|
340
|
+
challenge_id: str | None = None,
|
|
341
|
+
) -> dict[str, Any]:
|
|
342
|
+
payload = {
|
|
343
|
+
"to": to,
|
|
344
|
+
"purpose": purpose,
|
|
345
|
+
"challengeId": challenge_id,
|
|
346
|
+
"code": code,
|
|
347
|
+
}
|
|
348
|
+
clean = {key: value for key, value in payload.items() if value is not None}
|
|
349
|
+
return self._client.mail_request("POST", "/mail/otp/verify", json=clean)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class MailOTPChallengesClient:
|
|
353
|
+
def __init__(self, client: Any) -> None:
|
|
354
|
+
self._client = client
|
|
355
|
+
|
|
356
|
+
def list(
|
|
357
|
+
self,
|
|
358
|
+
*,
|
|
359
|
+
to: str | None = None,
|
|
360
|
+
purpose: str | None = None,
|
|
361
|
+
status: str | None = None,
|
|
362
|
+
limit: int | None = None,
|
|
363
|
+
cursor: str | None = None,
|
|
364
|
+
) -> dict[str, Any]:
|
|
365
|
+
params = {
|
|
366
|
+
"to": to,
|
|
367
|
+
"purpose": purpose,
|
|
368
|
+
"status": status,
|
|
369
|
+
"limit": limit,
|
|
370
|
+
"cursor": cursor,
|
|
371
|
+
}
|
|
372
|
+
clean = {key: value for key, value in params.items() if value not in (None, "")}
|
|
373
|
+
return self._client.mail_request("GET", "/mail/otp/challenges", params=clean)
|
|
374
|
+
|
|
375
|
+
def get(self, challenge_id: str) -> dict[str, Any]:
|
|
376
|
+
return self._client.mail_request("GET", f"/mail/otp/challenges/{quote(challenge_id)}")
|
|
377
|
+
|
|
378
|
+
def cancel(self, challenge_id: str) -> dict[str, Any]:
|
|
379
|
+
return self._client.mail_request("POST", f"/mail/otp/challenges/{quote(challenge_id)}/cancel")
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MailScheduledClient:
|
|
8
|
+
def __init__(self, client: Any) -> None:
|
|
9
|
+
self._client = client
|
|
10
|
+
|
|
11
|
+
def create(self, **payload: Any) -> dict[str, Any]:
|
|
12
|
+
clean = {key: value for key, value in payload.items() if value is not None}
|
|
13
|
+
return self._client.mail_request("POST", "/mail/scheduled", json=clean)
|
|
14
|
+
|
|
15
|
+
def list(self) -> dict[str, Any]:
|
|
16
|
+
return self._client.mail_request("GET", "/mail/scheduled")
|
|
17
|
+
|
|
18
|
+
def get(self, scheduled_id: str) -> dict[str, Any]:
|
|
19
|
+
return self._client.mail_request("GET", f"/mail/scheduled/{quote(scheduled_id)}")
|
|
20
|
+
|
|
21
|
+
def cancel(self, scheduled_id: str) -> dict[str, Any]:
|
|
22
|
+
return self._client.mail_request("POST", f"/mail/scheduled/{quote(scheduled_id)}/cancel")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MailBatchClient:
|
|
26
|
+
def __init__(self, client: Any) -> None:
|
|
27
|
+
self._client = client
|
|
28
|
+
|
|
29
|
+
def send(self, *, from_: str | dict[str, str], messages: list[dict[str, Any]]) -> dict[str, Any]:
|
|
30
|
+
return self._client.mail_request("POST", "/mail/batch", json={"from": from_, "messages": messages})
|
|
31
|
+
|
|
32
|
+
def send_template(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
template: str,
|
|
36
|
+
from_: str | dict[str, str],
|
|
37
|
+
recipients: list[dict[str, Any]],
|
|
38
|
+
version_id: str | None = None,
|
|
39
|
+
) -> dict[str, Any]:
|
|
40
|
+
payload = {"template": template, "from": from_, "recipients": recipients, "versionId": version_id}
|
|
41
|
+
clean = {key: value for key, value in payload.items() if value is not None}
|
|
42
|
+
return self._client.mail_request("POST", "/mail/batch-template", json=clean)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MailInboundClient:
|
|
46
|
+
def __init__(self, client: Any) -> None:
|
|
47
|
+
self.domains = MailInboundDomainsClient(client)
|
|
48
|
+
self.messages = MailInboundMessagesClient(client)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class MailInboundDomainsClient:
|
|
52
|
+
def __init__(self, client: Any) -> None:
|
|
53
|
+
self._client = client
|
|
54
|
+
|
|
55
|
+
def create(self, domain: str) -> dict[str, Any]:
|
|
56
|
+
return self._client.mail_request("POST", "/mail/inbound/domains", json={"domain": domain})
|
|
57
|
+
|
|
58
|
+
def list(self) -> dict[str, Any]:
|
|
59
|
+
return self._client.mail_request("GET", "/mail/inbound/domains")
|
|
60
|
+
|
|
61
|
+
def verify(self, domain_id: str) -> dict[str, Any]:
|
|
62
|
+
return self._client.mail_request("POST", f"/mail/inbound/domains/{quote(domain_id)}/verify")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class MailInboundMessagesClient:
|
|
66
|
+
def __init__(self, client: Any) -> None:
|
|
67
|
+
self._client = client
|
|
68
|
+
|
|
69
|
+
def list(self) -> dict[str, Any]:
|
|
70
|
+
return self._client.mail_request("GET", "/mail/inbound/messages")
|
|
71
|
+
|
|
72
|
+
def get(self, message_id: str) -> dict[str, Any]:
|
|
73
|
+
return self._client.mail_request("GET", f"/mail/inbound/messages/{quote(message_id)}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class MailAnalyticsClient:
|
|
77
|
+
def __init__(self, client: Any) -> None:
|
|
78
|
+
self._client = client
|
|
79
|
+
|
|
80
|
+
def get(
|
|
81
|
+
self,
|
|
82
|
+
*,
|
|
83
|
+
range_: str | None = None,
|
|
84
|
+
from_: str | None = None,
|
|
85
|
+
to: str | None = None,
|
|
86
|
+
interval: str | None = None,
|
|
87
|
+
domain: str | None = None,
|
|
88
|
+
template: str | None = None,
|
|
89
|
+
event: str | None = None,
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
params = {
|
|
92
|
+
"range": range_,
|
|
93
|
+
"from": from_,
|
|
94
|
+
"to": to,
|
|
95
|
+
"interval": interval,
|
|
96
|
+
"domain": domain,
|
|
97
|
+
"template": template,
|
|
98
|
+
"event": event,
|
|
99
|
+
}
|
|
100
|
+
clean = {key: value for key, value in params.items() if value not in (None, "")}
|
|
101
|
+
return self._client.mail_request("GET", "/mail/analytics", params=clean)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MailEventsClient:
|
|
11
|
+
def __init__(self, client: Any) -> None:
|
|
12
|
+
self._client = client
|
|
13
|
+
|
|
14
|
+
def list(
|
|
15
|
+
self,
|
|
16
|
+
*,
|
|
17
|
+
type: str | None = None,
|
|
18
|
+
resource_type: str | None = None,
|
|
19
|
+
resource_id: str | None = None,
|
|
20
|
+
message_id: str | None = None,
|
|
21
|
+
limit: int | None = None,
|
|
22
|
+
cursor: str | None = None,
|
|
23
|
+
from_: str | None = None,
|
|
24
|
+
to: str | None = None,
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
params = {
|
|
27
|
+
"type": type,
|
|
28
|
+
"resourceType": resource_type,
|
|
29
|
+
"resourceId": resource_id,
|
|
30
|
+
"messageId": message_id,
|
|
31
|
+
"limit": limit,
|
|
32
|
+
"cursor": cursor,
|
|
33
|
+
"from": from_,
|
|
34
|
+
"to": to,
|
|
35
|
+
}
|
|
36
|
+
clean = {key: value for key, value in params.items() if value not in (None, "")}
|
|
37
|
+
return self._client.mail_request("GET", "/mail/events", params=clean)
|
|
38
|
+
|
|
39
|
+
def get(self, event_id: str) -> dict[str, Any]:
|
|
40
|
+
return self._client.mail_request("GET", f"/mail/events/{quote(event_id)}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MailWebhooksClient:
|
|
44
|
+
def __init__(self, client: Any) -> None:
|
|
45
|
+
self._client = client
|
|
46
|
+
|
|
47
|
+
def create(
|
|
48
|
+
self,
|
|
49
|
+
*,
|
|
50
|
+
url: str,
|
|
51
|
+
event_types: list[str],
|
|
52
|
+
description: str | None = None,
|
|
53
|
+
) -> dict[str, Any]:
|
|
54
|
+
payload = {"url": url, "description": description, "eventTypes": event_types}
|
|
55
|
+
clean = {key: value for key, value in payload.items() if value is not None}
|
|
56
|
+
return self._client.mail_request("POST", "/mail/webhooks", json=clean)
|
|
57
|
+
|
|
58
|
+
def list(self, *, limit: int | None = None, cursor: str | None = None) -> dict[str, Any]:
|
|
59
|
+
params = {"limit": limit, "cursor": cursor}
|
|
60
|
+
clean = {key: value for key, value in params.items() if value not in (None, "")}
|
|
61
|
+
return self._client.mail_request("GET", "/mail/webhooks", params=clean)
|
|
62
|
+
|
|
63
|
+
def get(self, webhook_id: str) -> dict[str, Any]:
|
|
64
|
+
return self._client.mail_request("GET", f"/mail/webhooks/{quote(webhook_id)}")
|
|
65
|
+
|
|
66
|
+
def update(
|
|
67
|
+
self,
|
|
68
|
+
webhook_id: str,
|
|
69
|
+
*,
|
|
70
|
+
url: str | None = None,
|
|
71
|
+
description: str | None = None,
|
|
72
|
+
event_types: list[str] | None = None,
|
|
73
|
+
status: str | None = None,
|
|
74
|
+
) -> dict[str, Any]:
|
|
75
|
+
payload = {"url": url, "description": description, "eventTypes": event_types, "status": status}
|
|
76
|
+
clean = {key: value for key, value in payload.items() if value is not None}
|
|
77
|
+
return self._client.mail_request("PATCH", f"/mail/webhooks/{quote(webhook_id)}", json=clean)
|
|
78
|
+
|
|
79
|
+
def rotate_secret(self, webhook_id: str) -> dict[str, Any]:
|
|
80
|
+
return self._client.mail_request("POST", f"/mail/webhooks/{quote(webhook_id)}/rotate-secret")
|
|
81
|
+
|
|
82
|
+
def delete(self, webhook_id: str) -> None:
|
|
83
|
+
self._client.mail_request("DELETE", f"/mail/webhooks/{quote(webhook_id)}")
|
|
84
|
+
|
|
85
|
+
def deliveries(
|
|
86
|
+
self,
|
|
87
|
+
webhook_id: str,
|
|
88
|
+
*,
|
|
89
|
+
status: str | None = None,
|
|
90
|
+
limit: int | None = None,
|
|
91
|
+
cursor: str | None = None,
|
|
92
|
+
) -> dict[str, Any]:
|
|
93
|
+
params = {"status": status, "limit": limit, "cursor": cursor}
|
|
94
|
+
clean = {key: value for key, value in params.items() if value not in (None, "")}
|
|
95
|
+
return self._client.mail_request("GET", f"/mail/webhooks/{quote(webhook_id)}/deliveries", params=clean)
|
|
96
|
+
|
|
97
|
+
def get_delivery(self, delivery_id: str) -> dict[str, Any]:
|
|
98
|
+
return self._client.mail_request("GET", f"/mail/webhook-deliveries/{quote(delivery_id)}")
|
|
99
|
+
|
|
100
|
+
def retry_delivery(self, delivery_id: str) -> dict[str, Any]:
|
|
101
|
+
return self._client.mail_request("POST", f"/mail/webhook-deliveries/{quote(delivery_id)}/retry")
|
|
102
|
+
|
|
103
|
+
def verify_signature(
|
|
104
|
+
self,
|
|
105
|
+
*,
|
|
106
|
+
raw_body: bytes | str,
|
|
107
|
+
signature_header: str | None,
|
|
108
|
+
timestamp_header: str | None,
|
|
109
|
+
secret: str,
|
|
110
|
+
tolerance_seconds: int = 300,
|
|
111
|
+
now: int | None = None,
|
|
112
|
+
) -> bool:
|
|
113
|
+
return verify_webhook_signature(
|
|
114
|
+
raw_body=raw_body,
|
|
115
|
+
signature_header=signature_header,
|
|
116
|
+
timestamp_header=timestamp_header,
|
|
117
|
+
secret=secret,
|
|
118
|
+
tolerance_seconds=tolerance_seconds,
|
|
119
|
+
now=now,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def verify_webhook_signature(
|
|
124
|
+
*,
|
|
125
|
+
raw_body: bytes | str,
|
|
126
|
+
signature_header: str | None,
|
|
127
|
+
timestamp_header: str | None,
|
|
128
|
+
secret: str,
|
|
129
|
+
tolerance_seconds: int = 300,
|
|
130
|
+
now: int | None = None,
|
|
131
|
+
) -> bool:
|
|
132
|
+
if not raw_body or not signature_header or not timestamp_header or not secret:
|
|
133
|
+
return False
|
|
134
|
+
try:
|
|
135
|
+
timestamp = int(timestamp_header)
|
|
136
|
+
except ValueError:
|
|
137
|
+
return False
|
|
138
|
+
current = int(time.time()) if now is None else int(now)
|
|
139
|
+
if abs(current - timestamp) > tolerance_seconds:
|
|
140
|
+
return False
|
|
141
|
+
body = raw_body.encode() if isinstance(raw_body, str) else raw_body
|
|
142
|
+
signed = timestamp_header.encode() + b"." + body
|
|
143
|
+
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
|
|
144
|
+
provided = _signature_v1(signature_header)
|
|
145
|
+
return bool(provided) and hmac.compare_digest(expected, provided)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _signature_v1(header: str) -> str:
|
|
149
|
+
for part in header.split(","):
|
|
150
|
+
key, _, value = part.strip().partition("=")
|
|
151
|
+
if key == "v1":
|
|
152
|
+
return value
|
|
153
|
+
return ""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MailLimitsClient:
|
|
7
|
+
def __init__(self, client: Any) -> None:
|
|
8
|
+
self._client = client
|
|
9
|
+
|
|
10
|
+
def get(self) -> dict[str, Any]:
|
|
11
|
+
return self._client.mail_request("GET", "/mail/limits")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MailReputationClient:
|
|
15
|
+
def __init__(self, client: Any) -> None:
|
|
16
|
+
self._client = client
|
|
17
|
+
self.events = MailReputationEventsClient(client)
|
|
18
|
+
|
|
19
|
+
def get(self) -> dict[str, Any]:
|
|
20
|
+
return self._client.mail_request("GET", "/mail/reputation")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MailReputationEventsClient:
|
|
24
|
+
def __init__(self, client: Any) -> None:
|
|
25
|
+
self._client = client
|
|
26
|
+
|
|
27
|
+
def list(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
severity: str | None = None,
|
|
31
|
+
type: str | None = None,
|
|
32
|
+
limit: int | None = None,
|
|
33
|
+
cursor: str | None = None,
|
|
34
|
+
) -> dict[str, Any]:
|
|
35
|
+
params = {"severity": severity, "type": type, "limit": limit, "cursor": cursor}
|
|
36
|
+
clean = {key: value for key, value in params.items() if value not in (None, "")}
|
|
37
|
+
return self._client.mail_request("GET", "/mail/reputation/events", params=clean)
|
|
38
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
stackshift/__init__.py,sha256=e5QZUzE6m_195Cvvawyv8p_M18eNkQzW6sBgBn3SlRo,1008
|
|
2
|
+
stackshift/assets.py,sha256=qRzL9BNZSneNBc3es_vLsY4_hh_r3zOfdO_rAKQq5LU,22949
|
|
3
|
+
stackshift/client.py,sha256=AcJivv4VRCQ756MRrxCrLi0Q4in-4np0Re8ceQSTF1Y,3000
|
|
4
|
+
stackshift/mail.py,sha256=vegCniMcWBvHtz_UGAfNfVxFYTety8H3KKFQsWfKZSs,13462
|
|
5
|
+
stackshift/mail_advanced.py,sha256=-lzajXqlrkR8qA4YpRX5Sv3VwcEQITtKfIXbWwjRRQE,3533
|
|
6
|
+
stackshift/mail_phase7.py,sha256=Ud2fyKwt-vpaw7vgEayLw5q9tiDIF-fkfTBaoGJMJBI,5377
|
|
7
|
+
stackshift/mail_reputation.py,sha256=mPvPkZeqcQc5808PhBK7gYsgf4Fk_U19nRxSsJ60MGU,1112
|
|
8
|
+
stackshift/projects.py,sha256=oYWaE7xNRR6OXuPEwJH-oFFeOmDA9ozq6jJY09ANIHA,498
|
|
9
|
+
stackshift-0.1.2.dist-info/METADATA,sha256=GKbdBmBmZWOUs8DTe-amYxZNbplrh7pAlkaRtkBWQ14,152
|
|
10
|
+
stackshift-0.1.2.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
11
|
+
stackshift-0.1.2.dist-info/top_level.txt,sha256=qe3uWsu_O9mNtq7bIck9Y8rjTIMrH0kzgAlGFDoRGqs,11
|
|
12
|
+
stackshift-0.1.2.dist-info/RECORD,,
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
stackshift/__init__.py,sha256=Z0Q6SrKZyIx-YBt5pdIVXgnJGF4TeBYKtUA7vdxKlU4,161
|
|
2
|
-
stackshift/assets.py,sha256=qvu1qXd5wJqeWc4hpdGYcorXXMtZUNEwjlaT61lu7-A,3119
|
|
3
|
-
stackshift/client.py,sha256=YM9xgy0145f37l8x6A-Ecrg9zw5Z1E6DTRKEdiQ0P4g,1566
|
|
4
|
-
stackshift/projects.py,sha256=oYWaE7xNRR6OXuPEwJH-oFFeOmDA9ozq6jJY09ANIHA,498
|
|
5
|
-
stackshift-0.1.0.dist-info/METADATA,sha256=M9M2rhNT9c7bNRMCQkRTQ-Db8mYZ7T_Ccxva3KXCnq8,152
|
|
6
|
-
stackshift-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
-
stackshift-0.1.0.dist-info/top_level.txt,sha256=qe3uWsu_O9mNtq7bIck9Y8rjTIMrH0kzgAlGFDoRGqs,11
|
|
8
|
-
stackshift-0.1.0.dist-info/RECORD,,
|
|
File without changes
|