poststack 0.4.0__tar.gz → 0.5.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.
- {poststack-0.4.0 → poststack-0.5.0}/PKG-INFO +2 -2
- {poststack-0.4.0 → poststack-0.5.0}/pyproject.toml +6 -2
- {poststack-0.4.0 → poststack-0.5.0}/src/poststack/__init__.py +1 -5
- {poststack-0.4.0 → poststack-0.5.0}/src/poststack/_client.py +351 -333
- {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/contacts.py +14 -0
- {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/webhooks.py +54 -0
- {poststack-0.4.0 → poststack-0.5.0}/src/poststack/types.py +49 -1
- poststack-0.5.0/tests/__init__.py +0 -0
- {poststack-0.4.0 → poststack-0.5.0}/.gitignore +0 -0
- {poststack-0.4.0 → poststack-0.5.0}/LICENSE +0 -0
- {poststack-0.4.0 → poststack-0.5.0}/README.md +0 -0
- {poststack-0.4.0 → poststack-0.5.0}/src/poststack/_errors.py +0 -0
- /poststack-0.4.0/tests/__init__.py → /poststack-0.5.0/src/poststack/py.typed +0 -0
- {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/__init__.py +0 -0
- {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/api_keys.py +0 -0
- {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/broadcasts.py +0 -0
- {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/contact_properties.py +0 -0
- {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/domains.py +0 -0
- {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/email_validations.py +0 -0
- {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/emails.py +0 -0
- {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/mailboxes.py +0 -0
- {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/segments.py +0 -0
- {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/signup_forms.py +0 -0
- {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/subscription_topics.py +0 -0
- {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/suppressions.py +0 -0
- {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/templates.py +0 -0
- {poststack-0.4.0 → poststack-0.5.0}/src/poststack/resources/workflows.py +0 -0
- {poststack-0.4.0 → poststack-0.5.0}/tests/test_client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: poststack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Official Python SDK for the PostStack Email API
|
|
5
5
|
Project-URL: Homepage, https://poststack.dev
|
|
6
6
|
Project-URL: Documentation, https://poststack.dev/docs
|
|
@@ -22,7 +22,7 @@ Classifier: Topic :: Communications :: Email
|
|
|
22
22
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
23
|
Requires-Python: >=3.10
|
|
24
24
|
Requires-Dist: httpx>=0.25.0
|
|
25
|
-
Requires-Dist: pydantic>=2.
|
|
25
|
+
Requires-Dist: pydantic>=2.4.0
|
|
26
26
|
Provides-Extra: dev
|
|
27
27
|
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
|
|
28
28
|
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "poststack"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "Official Python SDK for the PostStack Email API"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -25,7 +25,8 @@ classifiers = [
|
|
|
25
25
|
]
|
|
26
26
|
dependencies = [
|
|
27
27
|
"httpx>=0.25.0",
|
|
28
|
-
|
|
28
|
+
# 2.4.0 floor closes GHSA-mr82-8j83-vxmv (regex DoS in 2.0–2.3.x).
|
|
29
|
+
"pydantic>=2.4.0",
|
|
29
30
|
]
|
|
30
31
|
|
|
31
32
|
[project.optional-dependencies]
|
|
@@ -43,6 +44,9 @@ Issues = "https://github.com/getpoststack/python-sdk/issues"
|
|
|
43
44
|
|
|
44
45
|
[tool.hatch.build.targets.wheel]
|
|
45
46
|
packages = ["src/poststack"]
|
|
47
|
+
# PEP 561: ship the `py.typed` marker so downstream type checkers (mypy,
|
|
48
|
+
# pyright) honor our annotations.
|
|
49
|
+
include = ["src/poststack/py.typed"]
|
|
46
50
|
|
|
47
51
|
[tool.hatch.build.targets.sdist]
|
|
48
52
|
include = [
|
|
@@ -10,8 +10,6 @@ Public API::
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
-
from typing import Optional
|
|
14
|
-
|
|
15
13
|
from ._client import (
|
|
16
14
|
DEFAULT_BASE_URL,
|
|
17
15
|
AsyncPostStackClient,
|
|
@@ -51,7 +49,7 @@ from .resources import (
|
|
|
51
49
|
WorkflowsResource,
|
|
52
50
|
)
|
|
53
51
|
|
|
54
|
-
__version__ = "0.
|
|
52
|
+
__version__ = "0.5.0"
|
|
55
53
|
|
|
56
54
|
__all__ = [
|
|
57
55
|
"PostStack",
|
|
@@ -155,5 +153,3 @@ class AsyncPostStack:
|
|
|
155
153
|
await self.aclose()
|
|
156
154
|
|
|
157
155
|
|
|
158
|
-
# Re-export for advanced callers.
|
|
159
|
-
_: Optional[type] = None # noqa: E305 - keeps ruff/mypy happy with Optional import
|
|
@@ -1,333 +1,351 @@
|
|
|
1
|
-
"""HTTP client used by every resource module.
|
|
2
|
-
|
|
3
|
-
Implements two parallel clients (sync + async) with identical interfaces so
|
|
4
|
-
resources can pick the transport they need. Auth is a simple Bearer token.
|
|
5
|
-
|
|
6
|
-
Both clients apply:
|
|
7
|
-
- a per-attempt timeout (default 30s, override per call)
|
|
8
|
-
- exponential-backoff retries on transient failures (network errors, 408,
|
|
9
|
-
429, 5xx) up to ``max_retries`` (default 3)
|
|
10
|
-
- automatic ``Idempotency-Key`` injection on POSTs so that a retry after a
|
|
11
|
-
network blip does not create a duplicate resource
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
from __future__ import annotations
|
|
15
|
-
|
|
16
|
-
import asyncio
|
|
17
|
-
import random
|
|
18
|
-
import time
|
|
19
|
-
import uuid
|
|
20
|
-
from typing import Any, Mapping, Optional
|
|
21
|
-
|
|
22
|
-
import httpx
|
|
23
|
-
|
|
24
|
-
from ._errors import PostStackError
|
|
25
|
-
|
|
26
|
-
DEFAULT_BASE_URL = "https://api.poststack.dev"
|
|
27
|
-
DEFAULT_TIMEOUT = 30.0
|
|
28
|
-
DEFAULT_MAX_RETRIES = 3
|
|
29
|
-
BASE_RETRY_DELAY = 0.25
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
self.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
self,
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
self
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
self
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
) ->
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
self,
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
self
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
1
|
+
"""HTTP client used by every resource module.
|
|
2
|
+
|
|
3
|
+
Implements two parallel clients (sync + async) with identical interfaces so
|
|
4
|
+
resources can pick the transport they need. Auth is a simple Bearer token.
|
|
5
|
+
|
|
6
|
+
Both clients apply:
|
|
7
|
+
- a per-attempt timeout (default 30s, override per call)
|
|
8
|
+
- exponential-backoff retries on transient failures (network errors, 408,
|
|
9
|
+
429, 5xx) up to ``max_retries`` (default 3)
|
|
10
|
+
- automatic ``Idempotency-Key`` injection on POSTs so that a retry after a
|
|
11
|
+
network blip does not create a duplicate resource
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import random
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
from typing import Any, Mapping, Optional
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from ._errors import PostStackError
|
|
25
|
+
|
|
26
|
+
DEFAULT_BASE_URL = "https://api.poststack.dev"
|
|
27
|
+
DEFAULT_TIMEOUT = 30.0
|
|
28
|
+
DEFAULT_MAX_RETRIES = 3
|
|
29
|
+
BASE_RETRY_DELAY = 0.25
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _sdk_version() -> str:
|
|
33
|
+
"""Read the package version lazily to avoid a circular import.
|
|
34
|
+
|
|
35
|
+
``__init__.py`` imports from this module before assigning
|
|
36
|
+
``__version__``, so resolving at import time would observe the wrong
|
|
37
|
+
value (or fail). Calling at request-build time is cheap and keeps
|
|
38
|
+
``__version__`` as the single source of truth (mirrored in
|
|
39
|
+
pyproject.toml at release).
|
|
40
|
+
"""
|
|
41
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
return version("poststack")
|
|
45
|
+
except PackageNotFoundError:
|
|
46
|
+
# Editable install / source tree without an installed dist.
|
|
47
|
+
from importlib import import_module
|
|
48
|
+
|
|
49
|
+
return getattr(import_module("poststack"), "__version__", "0.0.0")
|
|
50
|
+
|
|
51
|
+
RETRYABLE_STATUSES = {408, 429, 500, 502, 503, 504}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _build_headers(api_key: str) -> dict[str, str]:
|
|
55
|
+
return {
|
|
56
|
+
"Authorization": f"Bearer {api_key}",
|
|
57
|
+
"Content-Type": "application/json",
|
|
58
|
+
"User-Agent": f"PostStack-Python-SDK/{_sdk_version()}",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _clean_params(
|
|
63
|
+
params: Optional[Mapping[str, Any]],
|
|
64
|
+
) -> Optional[dict[str, str]]:
|
|
65
|
+
if not params:
|
|
66
|
+
return None
|
|
67
|
+
out: dict[str, str] = {}
|
|
68
|
+
for key, value in params.items():
|
|
69
|
+
if value is None:
|
|
70
|
+
continue
|
|
71
|
+
out[key] = str(value)
|
|
72
|
+
return out or None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
76
|
+
if response.is_success:
|
|
77
|
+
return
|
|
78
|
+
request_id = response.headers.get("x-request-id")
|
|
79
|
+
try:
|
|
80
|
+
body = response.json()
|
|
81
|
+
except ValueError:
|
|
82
|
+
body = {}
|
|
83
|
+
error_message = (
|
|
84
|
+
body.get("error") if isinstance(body, dict) else None
|
|
85
|
+
) or response.reason_phrase or "Unknown error"
|
|
86
|
+
code = body.get("code") if isinstance(body, dict) else None
|
|
87
|
+
raise PostStackError(
|
|
88
|
+
status_code=response.status_code,
|
|
89
|
+
error=error_message,
|
|
90
|
+
code=code,
|
|
91
|
+
request_id=request_id,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _parse_body(response: httpx.Response) -> Any:
|
|
96
|
+
# Some endpoints (e.g. CSV export) return non-JSON. Fall back to text.
|
|
97
|
+
content_type = response.headers.get("content-type", "")
|
|
98
|
+
if "application/json" in content_type:
|
|
99
|
+
return response.json()
|
|
100
|
+
text = response.text
|
|
101
|
+
if not text:
|
|
102
|
+
return None
|
|
103
|
+
try:
|
|
104
|
+
return response.json()
|
|
105
|
+
except ValueError:
|
|
106
|
+
return text
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _retry_delay(attempt: int) -> float:
|
|
110
|
+
"""Full-jitter exponential backoff — pick uniformly in [0, base*2**attempt)."""
|
|
111
|
+
return random.random() * (BASE_RETRY_DELAY * (2**attempt))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _idempotency_headers() -> dict[str, str]:
|
|
115
|
+
return {"Idempotency-Key": str(uuid.uuid4())}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class PostStackClient:
|
|
119
|
+
"""Synchronous HTTP client."""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
api_key: str,
|
|
124
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
125
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
126
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
127
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
128
|
+
) -> None:
|
|
129
|
+
self._base_url = base_url.rstrip("/")
|
|
130
|
+
self._timeout = timeout
|
|
131
|
+
self._max_retries = max_retries
|
|
132
|
+
self._httpx = httpx.Client(
|
|
133
|
+
base_url=self._base_url,
|
|
134
|
+
headers=_build_headers(api_key),
|
|
135
|
+
timeout=timeout,
|
|
136
|
+
transport=transport,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Lifecycle ------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def close(self) -> None:
|
|
142
|
+
self._httpx.close()
|
|
143
|
+
|
|
144
|
+
def __enter__(self) -> "PostStackClient":
|
|
145
|
+
return self
|
|
146
|
+
|
|
147
|
+
def __exit__(self, *args: Any) -> None:
|
|
148
|
+
self.close()
|
|
149
|
+
|
|
150
|
+
# HTTP methods ---------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def _request(
|
|
153
|
+
self,
|
|
154
|
+
method: str,
|
|
155
|
+
path: str,
|
|
156
|
+
*,
|
|
157
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
158
|
+
body: Any = None,
|
|
159
|
+
extra_headers: Optional[Mapping[str, str]] = None,
|
|
160
|
+
timeout: Optional[float] = None,
|
|
161
|
+
) -> Any:
|
|
162
|
+
request_timeout = timeout if timeout is not None else self._timeout
|
|
163
|
+
|
|
164
|
+
last_exc: Optional[BaseException] = None
|
|
165
|
+
for attempt in range(self._max_retries + 1):
|
|
166
|
+
try:
|
|
167
|
+
response = self._httpx.request(
|
|
168
|
+
method,
|
|
169
|
+
path,
|
|
170
|
+
params=_clean_params(params),
|
|
171
|
+
json=body if body is not None else None,
|
|
172
|
+
headers=dict(extra_headers) if extra_headers else None,
|
|
173
|
+
timeout=request_timeout,
|
|
174
|
+
)
|
|
175
|
+
except httpx.TransportError as exc:
|
|
176
|
+
last_exc = exc
|
|
177
|
+
if attempt < self._max_retries:
|
|
178
|
+
time.sleep(_retry_delay(attempt))
|
|
179
|
+
continue
|
|
180
|
+
raise
|
|
181
|
+
|
|
182
|
+
if (
|
|
183
|
+
response.status_code in RETRYABLE_STATUSES
|
|
184
|
+
and attempt < self._max_retries
|
|
185
|
+
):
|
|
186
|
+
time.sleep(_retry_delay(attempt))
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
_raise_for_status(response)
|
|
190
|
+
return _parse_body(response)
|
|
191
|
+
|
|
192
|
+
# If we exit the loop via `continue` on the final attempt, last_exc
|
|
193
|
+
# holds the most recent error.
|
|
194
|
+
assert last_exc is not None # noqa: S101
|
|
195
|
+
raise last_exc
|
|
196
|
+
|
|
197
|
+
def get(
|
|
198
|
+
self,
|
|
199
|
+
path: str,
|
|
200
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
201
|
+
*,
|
|
202
|
+
timeout: Optional[float] = None,
|
|
203
|
+
) -> Any:
|
|
204
|
+
return self._request("GET", path, params=params, timeout=timeout)
|
|
205
|
+
|
|
206
|
+
def post(
|
|
207
|
+
self,
|
|
208
|
+
path: str,
|
|
209
|
+
body: Any = None,
|
|
210
|
+
*,
|
|
211
|
+
timeout: Optional[float] = None,
|
|
212
|
+
) -> Any:
|
|
213
|
+
return self._request(
|
|
214
|
+
"POST",
|
|
215
|
+
path,
|
|
216
|
+
body=body,
|
|
217
|
+
extra_headers=_idempotency_headers(),
|
|
218
|
+
timeout=timeout,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def patch(
|
|
222
|
+
self,
|
|
223
|
+
path: str,
|
|
224
|
+
body: Any,
|
|
225
|
+
*,
|
|
226
|
+
timeout: Optional[float] = None,
|
|
227
|
+
) -> Any:
|
|
228
|
+
return self._request("PATCH", path, body=body, timeout=timeout)
|
|
229
|
+
|
|
230
|
+
def delete(
|
|
231
|
+
self,
|
|
232
|
+
path: str,
|
|
233
|
+
*,
|
|
234
|
+
timeout: Optional[float] = None,
|
|
235
|
+
) -> Any:
|
|
236
|
+
return self._request("DELETE", path, timeout=timeout)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class AsyncPostStackClient:
|
|
240
|
+
"""Asynchronous HTTP client."""
|
|
241
|
+
|
|
242
|
+
def __init__(
|
|
243
|
+
self,
|
|
244
|
+
api_key: str,
|
|
245
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
246
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
247
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
248
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
249
|
+
) -> None:
|
|
250
|
+
self._base_url = base_url.rstrip("/")
|
|
251
|
+
self._timeout = timeout
|
|
252
|
+
self._max_retries = max_retries
|
|
253
|
+
self._httpx = httpx.AsyncClient(
|
|
254
|
+
base_url=self._base_url,
|
|
255
|
+
headers=_build_headers(api_key),
|
|
256
|
+
timeout=timeout,
|
|
257
|
+
transport=transport,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
async def aclose(self) -> None:
|
|
261
|
+
await self._httpx.aclose()
|
|
262
|
+
|
|
263
|
+
async def __aenter__(self) -> "AsyncPostStackClient":
|
|
264
|
+
return self
|
|
265
|
+
|
|
266
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
267
|
+
await self.aclose()
|
|
268
|
+
|
|
269
|
+
async def _request(
|
|
270
|
+
self,
|
|
271
|
+
method: str,
|
|
272
|
+
path: str,
|
|
273
|
+
*,
|
|
274
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
275
|
+
body: Any = None,
|
|
276
|
+
extra_headers: Optional[Mapping[str, str]] = None,
|
|
277
|
+
timeout: Optional[float] = None,
|
|
278
|
+
) -> Any:
|
|
279
|
+
request_timeout = timeout if timeout is not None else self._timeout
|
|
280
|
+
|
|
281
|
+
last_exc: Optional[BaseException] = None
|
|
282
|
+
for attempt in range(self._max_retries + 1):
|
|
283
|
+
try:
|
|
284
|
+
response = await self._httpx.request(
|
|
285
|
+
method,
|
|
286
|
+
path,
|
|
287
|
+
params=_clean_params(params),
|
|
288
|
+
json=body if body is not None else None,
|
|
289
|
+
headers=dict(extra_headers) if extra_headers else None,
|
|
290
|
+
timeout=request_timeout,
|
|
291
|
+
)
|
|
292
|
+
except httpx.TransportError as exc:
|
|
293
|
+
last_exc = exc
|
|
294
|
+
if attempt < self._max_retries:
|
|
295
|
+
await asyncio.sleep(_retry_delay(attempt))
|
|
296
|
+
continue
|
|
297
|
+
raise
|
|
298
|
+
|
|
299
|
+
if (
|
|
300
|
+
response.status_code in RETRYABLE_STATUSES
|
|
301
|
+
and attempt < self._max_retries
|
|
302
|
+
):
|
|
303
|
+
await asyncio.sleep(_retry_delay(attempt))
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
_raise_for_status(response)
|
|
307
|
+
return _parse_body(response)
|
|
308
|
+
|
|
309
|
+
assert last_exc is not None # noqa: S101
|
|
310
|
+
raise last_exc
|
|
311
|
+
|
|
312
|
+
async def get(
|
|
313
|
+
self,
|
|
314
|
+
path: str,
|
|
315
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
316
|
+
*,
|
|
317
|
+
timeout: Optional[float] = None,
|
|
318
|
+
) -> Any:
|
|
319
|
+
return await self._request("GET", path, params=params, timeout=timeout)
|
|
320
|
+
|
|
321
|
+
async def post(
|
|
322
|
+
self,
|
|
323
|
+
path: str,
|
|
324
|
+
body: Any = None,
|
|
325
|
+
*,
|
|
326
|
+
timeout: Optional[float] = None,
|
|
327
|
+
) -> Any:
|
|
328
|
+
return await self._request(
|
|
329
|
+
"POST",
|
|
330
|
+
path,
|
|
331
|
+
body=body,
|
|
332
|
+
extra_headers=_idempotency_headers(),
|
|
333
|
+
timeout=timeout,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
async def patch(
|
|
337
|
+
self,
|
|
338
|
+
path: str,
|
|
339
|
+
body: Any,
|
|
340
|
+
*,
|
|
341
|
+
timeout: Optional[float] = None,
|
|
342
|
+
) -> Any:
|
|
343
|
+
return await self._request("PATCH", path, body=body, timeout=timeout)
|
|
344
|
+
|
|
345
|
+
async def delete(
|
|
346
|
+
self,
|
|
347
|
+
path: str,
|
|
348
|
+
*,
|
|
349
|
+
timeout: Optional[float] = None,
|
|
350
|
+
) -> Any:
|
|
351
|
+
return await self._request("DELETE", path, timeout=timeout)
|
|
@@ -32,6 +32,13 @@ class ContactsResource:
|
|
|
32
32
|
f"/contacts/{quote(id, safe='')}", timeout=timeout
|
|
33
33
|
)
|
|
34
34
|
|
|
35
|
+
def get_by_email(
|
|
36
|
+
self, email: str, *, timeout: Optional[float] = None
|
|
37
|
+
) -> Dict[str, Any]:
|
|
38
|
+
return self._client.get(
|
|
39
|
+
f"/contacts/by-email/{quote(email, safe='')}", timeout=timeout
|
|
40
|
+
)
|
|
41
|
+
|
|
35
42
|
def update(
|
|
36
43
|
self,
|
|
37
44
|
id: str,
|
|
@@ -92,6 +99,13 @@ class AsyncContactsResource:
|
|
|
92
99
|
f"/contacts/{quote(id, safe='')}", timeout=timeout
|
|
93
100
|
)
|
|
94
101
|
|
|
102
|
+
async def get_by_email(
|
|
103
|
+
self, email: str, *, timeout: Optional[float] = None
|
|
104
|
+
) -> Dict[str, Any]:
|
|
105
|
+
return await self._client.get(
|
|
106
|
+
f"/contacts/by-email/{quote(email, safe='')}", timeout=timeout
|
|
107
|
+
)
|
|
108
|
+
|
|
95
109
|
async def update(
|
|
96
110
|
self,
|
|
97
111
|
id: str,
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
5
7
|
from typing import Any, Dict, Mapping, Optional
|
|
6
8
|
|
|
7
9
|
from .._client import AsyncPostStackClient, PostStackClient
|
|
@@ -14,6 +16,14 @@ class WebhooksResource:
|
|
|
14
16
|
def create(
|
|
15
17
|
self, input: Dict[str, Any], *, timeout: Optional[float] = None
|
|
16
18
|
) -> Dict[str, Any]:
|
|
19
|
+
"""Create a webhook.
|
|
20
|
+
|
|
21
|
+
The server response is ``{"webhook": {...}, "signingSecret": "..."}``.
|
|
22
|
+
Store ``signingSecret`` immediately — it's only surfaced here (the
|
|
23
|
+
server keeps an encrypted copy and afterwards only returns a
|
|
24
|
+
masked prefix). Pass it to :meth:`verify` to validate inbound
|
|
25
|
+
deliveries.
|
|
26
|
+
"""
|
|
17
27
|
return self._client.post("/webhooks", input, timeout=timeout)
|
|
18
28
|
|
|
19
29
|
def get(
|
|
@@ -71,6 +81,45 @@ class WebhooksResource:
|
|
|
71
81
|
timeout=timeout,
|
|
72
82
|
)
|
|
73
83
|
|
|
84
|
+
@staticmethod
|
|
85
|
+
def verify(
|
|
86
|
+
payload: str | bytes, signature_header: str, secret: str
|
|
87
|
+
) -> bool:
|
|
88
|
+
"""Verify an incoming webhook against the ``X-PostStack-Signature``
|
|
89
|
+
header.
|
|
90
|
+
|
|
91
|
+
Header format is ``sha256=<hex-hmac>``. The HMAC is SHA-256 of the
|
|
92
|
+
raw JSON request body keyed by the webhook's plaintext signing
|
|
93
|
+
secret. Pass ``payload`` exactly as received — re-serializing
|
|
94
|
+
through ``json.loads/dumps`` will change byte-for-byte content and
|
|
95
|
+
the signature will not match.
|
|
96
|
+
|
|
97
|
+
Returns ``False`` on any shape mismatch (missing/malformed header,
|
|
98
|
+
wrong length, mismatched HMAC) so callers can ``return 401`` on
|
|
99
|
+
the bare boolean. Uses :func:`hmac.compare_digest` for the byte
|
|
100
|
+
comparison.
|
|
101
|
+
"""
|
|
102
|
+
if (
|
|
103
|
+
not isinstance(signature_header, str)
|
|
104
|
+
or not signature_header.startswith("sha256=")
|
|
105
|
+
):
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
provided_hex = signature_header[len("sha256=") :].strip().lower()
|
|
109
|
+
if not provided_hex or any(
|
|
110
|
+
c not in "0123456789abcdef" for c in provided_hex
|
|
111
|
+
):
|
|
112
|
+
return False
|
|
113
|
+
if len(provided_hex) % 2 != 0:
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
body = payload.encode("utf-8") if isinstance(payload, str) else payload
|
|
117
|
+
expected_hex = hmac.new(
|
|
118
|
+
secret.encode("utf-8"), body, hashlib.sha256
|
|
119
|
+
).hexdigest()
|
|
120
|
+
|
|
121
|
+
return hmac.compare_digest(expected_hex, provided_hex)
|
|
122
|
+
|
|
74
123
|
|
|
75
124
|
class AsyncWebhooksResource:
|
|
76
125
|
def __init__(self, client: AsyncPostStackClient) -> None:
|
|
@@ -79,6 +128,8 @@ class AsyncWebhooksResource:
|
|
|
79
128
|
async def create(
|
|
80
129
|
self, input: Dict[str, Any], *, timeout: Optional[float] = None
|
|
81
130
|
) -> Dict[str, Any]:
|
|
131
|
+
"""Create a webhook. See :meth:`WebhooksResource.create` for the
|
|
132
|
+
response shape and signing-secret semantics."""
|
|
82
133
|
return await self._client.post("/webhooks", input, timeout=timeout)
|
|
83
134
|
|
|
84
135
|
async def get(
|
|
@@ -137,3 +188,6 @@ class AsyncWebhooksResource:
|
|
|
137
188
|
f"/webhooks/{id}/deliveries/{delivery_id}/replay",
|
|
138
189
|
timeout=timeout,
|
|
139
190
|
)
|
|
191
|
+
|
|
192
|
+
# Verify is sync-only — pure crypto, no IO. Alias for ergonomics.
|
|
193
|
+
verify = staticmethod(WebhooksResource.verify)
|
|
@@ -226,6 +226,29 @@ class ImportContactsResult(_Model):
|
|
|
226
226
|
# ---------------------------------------------------------------------------
|
|
227
227
|
|
|
228
228
|
|
|
229
|
+
SegmentComparator = Literal[
|
|
230
|
+
"equals", "not_equals", "contains", "starts_with", "ends_with"
|
|
231
|
+
]
|
|
232
|
+
SegmentOperator = Literal["and", "or"]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class SegmentCondition(_Model):
|
|
236
|
+
"""One leaf condition in a segment rules tree.
|
|
237
|
+
|
|
238
|
+
The field is **comparator**, not operator — `operator` is reserved for
|
|
239
|
+
the rules-tree boolean connective on `SegmentRules`.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
field: str
|
|
243
|
+
comparator: SegmentComparator
|
|
244
|
+
value: str
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class SegmentRules(_Model):
|
|
248
|
+
operator: SegmentOperator
|
|
249
|
+
conditions: List[SegmentCondition]
|
|
250
|
+
|
|
251
|
+
|
|
229
252
|
class Segment(_Model):
|
|
230
253
|
id: str
|
|
231
254
|
name: str
|
|
@@ -234,8 +257,9 @@ class Segment(_Model):
|
|
|
234
257
|
|
|
235
258
|
|
|
236
259
|
class SegmentPreviewResult(_Model):
|
|
260
|
+
"""Server returns count only — no `sample` field."""
|
|
261
|
+
|
|
237
262
|
count: int
|
|
238
|
-
sample: List[Contact]
|
|
239
263
|
|
|
240
264
|
|
|
241
265
|
# ---------------------------------------------------------------------------
|
|
@@ -277,6 +301,21 @@ class Webhook(_Model):
|
|
|
277
301
|
events: List[str]
|
|
278
302
|
active: bool
|
|
279
303
|
created_at: str = Field(alias="createdAt")
|
|
304
|
+
# Plaintext signing secret — populated only on the response from
|
|
305
|
+
# `Webhooks.create()` (the server keeps an encrypted copy and only
|
|
306
|
+
# surfaces a masked prefix on subsequent reads).
|
|
307
|
+
signing_secret: Optional[str] = Field(
|
|
308
|
+
default=None, alias="signingSecret"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class CreateWebhookResult(_Model):
|
|
313
|
+
"""Response shape for `Webhooks.create()` — wraps the webhook plus its
|
|
314
|
+
plaintext `signing_secret` so callers can store the secret immediately.
|
|
315
|
+
"""
|
|
316
|
+
|
|
317
|
+
webhook: Webhook
|
|
318
|
+
signing_secret: str = Field(alias="signingSecret")
|
|
280
319
|
|
|
281
320
|
|
|
282
321
|
class WebhookDelivery(_Model):
|
|
@@ -352,6 +391,10 @@ class ApiKey(_Model):
|
|
|
352
391
|
last_used_at: Optional[str] = Field(default=None, alias="lastUsedAt")
|
|
353
392
|
expires_at: Optional[str] = Field(default=None, alias="expiresAt")
|
|
354
393
|
created_at: str = Field(alias="createdAt")
|
|
394
|
+
# Plaintext secret — only present on `ApiKeys.create()` /
|
|
395
|
+
# `ApiKeys.rotate()`. List/get responses omit it (server stores only
|
|
396
|
+
# a peppered hash and cannot re-issue).
|
|
397
|
+
key: Optional[str] = None
|
|
355
398
|
|
|
356
399
|
|
|
357
400
|
# ---------------------------------------------------------------------------
|
|
@@ -489,9 +532,14 @@ __all__ = [
|
|
|
489
532
|
"ContactProperty",
|
|
490
533
|
"ImportContactsResult",
|
|
491
534
|
"Segment",
|
|
535
|
+
"SegmentComparator",
|
|
536
|
+
"SegmentCondition",
|
|
537
|
+
"SegmentOperator",
|
|
492
538
|
"SegmentPreviewResult",
|
|
539
|
+
"SegmentRules",
|
|
493
540
|
"Template",
|
|
494
541
|
"TemplatePreset",
|
|
542
|
+
"CreateWebhookResult",
|
|
495
543
|
"Webhook",
|
|
496
544
|
"WebhookDelivery",
|
|
497
545
|
"Broadcast",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|