poststack 0.4.0__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.
poststack/__init__.py ADDED
@@ -0,0 +1,159 @@
1
+ """PostStack Python SDK.
2
+
3
+ Public API::
4
+
5
+ from poststack import PostStack, AsyncPostStack, PostStackError
6
+
7
+ client = PostStack(api_key="sk_live_...")
8
+ client.emails.send({"from": "a@b.com", "to": ["c@d.com"], "subject": "hi"})
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Optional
14
+
15
+ from ._client import (
16
+ DEFAULT_BASE_URL,
17
+ AsyncPostStackClient,
18
+ PostStackClient,
19
+ )
20
+ from ._errors import APIError, PostStackError
21
+ from .resources import (
22
+ ApiKeysResource,
23
+ AsyncApiKeysResource,
24
+ AsyncBroadcastsResource,
25
+ AsyncContactPropertiesResource,
26
+ AsyncContactsResource,
27
+ AsyncDomainsResource,
28
+ AsyncEmailsResource,
29
+ AsyncEmailValidationsResource,
30
+ AsyncMailboxesResource,
31
+ AsyncSegmentsResource,
32
+ AsyncSignupFormsResource,
33
+ AsyncSubscriptionTopicsResource,
34
+ AsyncSuppressionsResource,
35
+ AsyncTemplatesResource,
36
+ AsyncWebhooksResource,
37
+ AsyncWorkflowsResource,
38
+ BroadcastsResource,
39
+ ContactPropertiesResource,
40
+ ContactsResource,
41
+ DomainsResource,
42
+ EmailsResource,
43
+ EmailValidationsResource,
44
+ MailboxesResource,
45
+ SegmentsResource,
46
+ SignupFormsResource,
47
+ SubscriptionTopicsResource,
48
+ SuppressionsResource,
49
+ TemplatesResource,
50
+ WebhooksResource,
51
+ WorkflowsResource,
52
+ )
53
+
54
+ __version__ = "0.4.0"
55
+
56
+ __all__ = [
57
+ "PostStack",
58
+ "AsyncPostStack",
59
+ "PostStackError",
60
+ "APIError",
61
+ "__version__",
62
+ ]
63
+
64
+
65
+ class PostStack:
66
+ """Synchronous PostStack client.
67
+
68
+ Example::
69
+
70
+ client = PostStack(api_key="sk_live_...")
71
+ client.emails.send({
72
+ "from": "hello@example.com",
73
+ "to": ["user@example.com"],
74
+ "subject": "Hello",
75
+ "html": "<p>Hi</p>",
76
+ })
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ api_key: str,
82
+ base_url: str = DEFAULT_BASE_URL,
83
+ timeout: float = 30.0,
84
+ ) -> None:
85
+ self._client = PostStackClient(
86
+ api_key=api_key, base_url=base_url, timeout=timeout
87
+ )
88
+ self.emails = EmailsResource(self._client)
89
+ self.domains = DomainsResource(self._client)
90
+ self.contacts = ContactsResource(self._client)
91
+ self.contact_properties = ContactPropertiesResource(self._client)
92
+ self.segments = SegmentsResource(self._client)
93
+ self.templates = TemplatesResource(self._client)
94
+ self.webhooks = WebhooksResource(self._client)
95
+ self.broadcasts = BroadcastsResource(self._client)
96
+ self.suppressions = SuppressionsResource(self._client)
97
+ self.api_keys = ApiKeysResource(self._client)
98
+ self.mailboxes = MailboxesResource(self._client)
99
+ self.subscription_topics = SubscriptionTopicsResource(self._client)
100
+ self.workflows = WorkflowsResource(self._client)
101
+ self.signup_forms = SignupFormsResource(self._client)
102
+ self.email_validations = EmailValidationsResource(self._client)
103
+
104
+ def close(self) -> None:
105
+ self._client.close()
106
+
107
+ def __enter__(self) -> "PostStack":
108
+ return self
109
+
110
+ def __exit__(self, *args: object) -> None:
111
+ self.close()
112
+
113
+
114
+ class AsyncPostStack:
115
+ """Asynchronous PostStack client.
116
+
117
+ Example::
118
+
119
+ async with AsyncPostStack(api_key="sk_live_...") as client:
120
+ await client.emails.send({...})
121
+ """
122
+
123
+ def __init__(
124
+ self,
125
+ api_key: str,
126
+ base_url: str = DEFAULT_BASE_URL,
127
+ timeout: float = 30.0,
128
+ ) -> None:
129
+ self._client = AsyncPostStackClient(
130
+ api_key=api_key, base_url=base_url, timeout=timeout
131
+ )
132
+ self.emails = AsyncEmailsResource(self._client)
133
+ self.domains = AsyncDomainsResource(self._client)
134
+ self.contacts = AsyncContactsResource(self._client)
135
+ self.contact_properties = AsyncContactPropertiesResource(self._client)
136
+ self.segments = AsyncSegmentsResource(self._client)
137
+ self.templates = AsyncTemplatesResource(self._client)
138
+ self.webhooks = AsyncWebhooksResource(self._client)
139
+ self.broadcasts = AsyncBroadcastsResource(self._client)
140
+ self.suppressions = AsyncSuppressionsResource(self._client)
141
+ self.api_keys = AsyncApiKeysResource(self._client)
142
+ self.mailboxes = AsyncMailboxesResource(self._client)
143
+ self.subscription_topics = AsyncSubscriptionTopicsResource(self._client)
144
+ self.workflows = AsyncWorkflowsResource(self._client)
145
+ self.signup_forms = AsyncSignupFormsResource(self._client)
146
+ self.email_validations = AsyncEmailValidationsResource(self._client)
147
+
148
+ async def aclose(self) -> None:
149
+ await self._client.aclose()
150
+
151
+ async def __aenter__(self) -> "AsyncPostStack":
152
+ return self
153
+
154
+ async def __aexit__(self, *args: object) -> None:
155
+ await self.aclose()
156
+
157
+
158
+ # Re-export for advanced callers.
159
+ _: Optional[type] = None # noqa: E305 - keeps ruff/mypy happy with Optional import
poststack/_client.py ADDED
@@ -0,0 +1,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
+ SDK_VERSION = "0.5.0"
31
+ USER_AGENT = f"PostStack-Python-SDK/{SDK_VERSION}"
32
+
33
+ RETRYABLE_STATUSES = {408, 429, 500, 502, 503, 504}
34
+
35
+
36
+ def _build_headers(api_key: str) -> dict[str, str]:
37
+ return {
38
+ "Authorization": f"Bearer {api_key}",
39
+ "Content-Type": "application/json",
40
+ "User-Agent": USER_AGENT,
41
+ }
42
+
43
+
44
+ def _clean_params(
45
+ params: Optional[Mapping[str, Any]],
46
+ ) -> Optional[dict[str, str]]:
47
+ if not params:
48
+ return None
49
+ out: dict[str, str] = {}
50
+ for key, value in params.items():
51
+ if value is None:
52
+ continue
53
+ out[key] = str(value)
54
+ return out or None
55
+
56
+
57
+ def _raise_for_status(response: httpx.Response) -> None:
58
+ if response.is_success:
59
+ return
60
+ request_id = response.headers.get("x-request-id")
61
+ try:
62
+ body = response.json()
63
+ except ValueError:
64
+ body = {}
65
+ error_message = (
66
+ body.get("error") if isinstance(body, dict) else None
67
+ ) or response.reason_phrase or "Unknown error"
68
+ code = body.get("code") if isinstance(body, dict) else None
69
+ raise PostStackError(
70
+ status_code=response.status_code,
71
+ error=error_message,
72
+ code=code,
73
+ request_id=request_id,
74
+ )
75
+
76
+
77
+ def _parse_body(response: httpx.Response) -> Any:
78
+ # Some endpoints (e.g. CSV export) return non-JSON. Fall back to text.
79
+ content_type = response.headers.get("content-type", "")
80
+ if "application/json" in content_type:
81
+ return response.json()
82
+ text = response.text
83
+ if not text:
84
+ return None
85
+ try:
86
+ return response.json()
87
+ except ValueError:
88
+ return text
89
+
90
+
91
+ def _retry_delay(attempt: int) -> float:
92
+ """Full-jitter exponential backoff — pick uniformly in [0, base*2**attempt)."""
93
+ return random.random() * (BASE_RETRY_DELAY * (2**attempt))
94
+
95
+
96
+ def _idempotency_headers() -> dict[str, str]:
97
+ return {"Idempotency-Key": str(uuid.uuid4())}
98
+
99
+
100
+ class PostStackClient:
101
+ """Synchronous HTTP client."""
102
+
103
+ def __init__(
104
+ self,
105
+ api_key: str,
106
+ base_url: str = DEFAULT_BASE_URL,
107
+ timeout: float = DEFAULT_TIMEOUT,
108
+ max_retries: int = DEFAULT_MAX_RETRIES,
109
+ transport: Optional[httpx.BaseTransport] = None,
110
+ ) -> None:
111
+ self._base_url = base_url.rstrip("/")
112
+ self._timeout = timeout
113
+ self._max_retries = max_retries
114
+ self._httpx = httpx.Client(
115
+ base_url=self._base_url,
116
+ headers=_build_headers(api_key),
117
+ timeout=timeout,
118
+ transport=transport,
119
+ )
120
+
121
+ # Lifecycle ------------------------------------------------------------
122
+
123
+ def close(self) -> None:
124
+ self._httpx.close()
125
+
126
+ def __enter__(self) -> "PostStackClient":
127
+ return self
128
+
129
+ def __exit__(self, *args: Any) -> None:
130
+ self.close()
131
+
132
+ # HTTP methods ---------------------------------------------------------
133
+
134
+ def _request(
135
+ self,
136
+ method: str,
137
+ path: str,
138
+ *,
139
+ params: Optional[Mapping[str, Any]] = None,
140
+ body: Any = None,
141
+ extra_headers: Optional[Mapping[str, str]] = None,
142
+ timeout: Optional[float] = None,
143
+ ) -> Any:
144
+ request_timeout = timeout if timeout is not None else self._timeout
145
+
146
+ last_exc: Optional[BaseException] = None
147
+ for attempt in range(self._max_retries + 1):
148
+ try:
149
+ response = self._httpx.request(
150
+ method,
151
+ path,
152
+ params=_clean_params(params),
153
+ json=body if body is not None else None,
154
+ headers=dict(extra_headers) if extra_headers else None,
155
+ timeout=request_timeout,
156
+ )
157
+ except httpx.TransportError as exc:
158
+ last_exc = exc
159
+ if attempt < self._max_retries:
160
+ time.sleep(_retry_delay(attempt))
161
+ continue
162
+ raise
163
+
164
+ if (
165
+ response.status_code in RETRYABLE_STATUSES
166
+ and attempt < self._max_retries
167
+ ):
168
+ time.sleep(_retry_delay(attempt))
169
+ continue
170
+
171
+ _raise_for_status(response)
172
+ return _parse_body(response)
173
+
174
+ # If we exit the loop via `continue` on the final attempt, last_exc
175
+ # holds the most recent error.
176
+ assert last_exc is not None # noqa: S101
177
+ raise last_exc
178
+
179
+ def get(
180
+ self,
181
+ path: str,
182
+ params: Optional[Mapping[str, Any]] = None,
183
+ *,
184
+ timeout: Optional[float] = None,
185
+ ) -> Any:
186
+ return self._request("GET", path, params=params, timeout=timeout)
187
+
188
+ def post(
189
+ self,
190
+ path: str,
191
+ body: Any = None,
192
+ *,
193
+ timeout: Optional[float] = None,
194
+ ) -> Any:
195
+ return self._request(
196
+ "POST",
197
+ path,
198
+ body=body,
199
+ extra_headers=_idempotency_headers(),
200
+ timeout=timeout,
201
+ )
202
+
203
+ def patch(
204
+ self,
205
+ path: str,
206
+ body: Any,
207
+ *,
208
+ timeout: Optional[float] = None,
209
+ ) -> Any:
210
+ return self._request("PATCH", path, body=body, timeout=timeout)
211
+
212
+ def delete(
213
+ self,
214
+ path: str,
215
+ *,
216
+ timeout: Optional[float] = None,
217
+ ) -> Any:
218
+ return self._request("DELETE", path, timeout=timeout)
219
+
220
+
221
+ class AsyncPostStackClient:
222
+ """Asynchronous HTTP client."""
223
+
224
+ def __init__(
225
+ self,
226
+ api_key: str,
227
+ base_url: str = DEFAULT_BASE_URL,
228
+ timeout: float = DEFAULT_TIMEOUT,
229
+ max_retries: int = DEFAULT_MAX_RETRIES,
230
+ transport: Optional[httpx.AsyncBaseTransport] = None,
231
+ ) -> None:
232
+ self._base_url = base_url.rstrip("/")
233
+ self._timeout = timeout
234
+ self._max_retries = max_retries
235
+ self._httpx = httpx.AsyncClient(
236
+ base_url=self._base_url,
237
+ headers=_build_headers(api_key),
238
+ timeout=timeout,
239
+ transport=transport,
240
+ )
241
+
242
+ async def aclose(self) -> None:
243
+ await self._httpx.aclose()
244
+
245
+ async def __aenter__(self) -> "AsyncPostStackClient":
246
+ return self
247
+
248
+ async def __aexit__(self, *args: Any) -> None:
249
+ await self.aclose()
250
+
251
+ async def _request(
252
+ self,
253
+ method: str,
254
+ path: str,
255
+ *,
256
+ params: Optional[Mapping[str, Any]] = None,
257
+ body: Any = None,
258
+ extra_headers: Optional[Mapping[str, str]] = None,
259
+ timeout: Optional[float] = None,
260
+ ) -> Any:
261
+ request_timeout = timeout if timeout is not None else self._timeout
262
+
263
+ last_exc: Optional[BaseException] = None
264
+ for attempt in range(self._max_retries + 1):
265
+ try:
266
+ response = await self._httpx.request(
267
+ method,
268
+ path,
269
+ params=_clean_params(params),
270
+ json=body if body is not None else None,
271
+ headers=dict(extra_headers) if extra_headers else None,
272
+ timeout=request_timeout,
273
+ )
274
+ except httpx.TransportError as exc:
275
+ last_exc = exc
276
+ if attempt < self._max_retries:
277
+ await asyncio.sleep(_retry_delay(attempt))
278
+ continue
279
+ raise
280
+
281
+ if (
282
+ response.status_code in RETRYABLE_STATUSES
283
+ and attempt < self._max_retries
284
+ ):
285
+ await asyncio.sleep(_retry_delay(attempt))
286
+ continue
287
+
288
+ _raise_for_status(response)
289
+ return _parse_body(response)
290
+
291
+ assert last_exc is not None # noqa: S101
292
+ raise last_exc
293
+
294
+ async def get(
295
+ self,
296
+ path: str,
297
+ params: Optional[Mapping[str, Any]] = None,
298
+ *,
299
+ timeout: Optional[float] = None,
300
+ ) -> Any:
301
+ return await self._request("GET", path, params=params, timeout=timeout)
302
+
303
+ async def post(
304
+ self,
305
+ path: str,
306
+ body: Any = None,
307
+ *,
308
+ timeout: Optional[float] = None,
309
+ ) -> Any:
310
+ return await self._request(
311
+ "POST",
312
+ path,
313
+ body=body,
314
+ extra_headers=_idempotency_headers(),
315
+ timeout=timeout,
316
+ )
317
+
318
+ async def patch(
319
+ self,
320
+ path: str,
321
+ body: Any,
322
+ *,
323
+ timeout: Optional[float] = None,
324
+ ) -> Any:
325
+ return await self._request("PATCH", path, body=body, timeout=timeout)
326
+
327
+ async def delete(
328
+ self,
329
+ path: str,
330
+ *,
331
+ timeout: Optional[float] = None,
332
+ ) -> Any:
333
+ return await self._request("DELETE", path, timeout=timeout)
poststack/_errors.py ADDED
@@ -0,0 +1,33 @@
1
+ """Errors raised by the PostStack SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+
8
+ class PostStackError(Exception):
9
+ """Raised when the PostStack API returns a non-2xx response."""
10
+
11
+ def __init__(
12
+ self,
13
+ status_code: int,
14
+ error: str,
15
+ code: Optional[str] = None,
16
+ request_id: Optional[str] = None,
17
+ ) -> None:
18
+ super().__init__(error)
19
+ self.status_code = status_code
20
+ self.error = error
21
+ self.code = code
22
+ self.request_id = request_id
23
+
24
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
25
+ return (
26
+ f"PostStackError(status_code={self.status_code!r}, "
27
+ f"error={self.error!r}, code={self.code!r}, "
28
+ f"request_id={self.request_id!r})"
29
+ )
30
+
31
+
32
+ # Alias retained for callers expecting a generic APIError type.
33
+ APIError = PostStackError
@@ -0,0 +1,59 @@
1
+ """Resource classes mirroring the PostStack TypeScript SDK."""
2
+
3
+ from .api_keys import ApiKeysResource, AsyncApiKeysResource
4
+ from .broadcasts import AsyncBroadcastsResource, BroadcastsResource
5
+ from .contact_properties import (
6
+ AsyncContactPropertiesResource,
7
+ ContactPropertiesResource,
8
+ )
9
+ from .contacts import AsyncContactsResource, ContactsResource
10
+ from .domains import AsyncDomainsResource, DomainsResource
11
+ from .email_validations import (
12
+ AsyncEmailValidationsResource,
13
+ EmailValidationsResource,
14
+ )
15
+ from .emails import AsyncEmailsResource, EmailsResource
16
+ from .mailboxes import AsyncMailboxesResource, MailboxesResource
17
+ from .segments import AsyncSegmentsResource, SegmentsResource
18
+ from .signup_forms import AsyncSignupFormsResource, SignupFormsResource
19
+ from .subscription_topics import (
20
+ AsyncSubscriptionTopicsResource,
21
+ SubscriptionTopicsResource,
22
+ )
23
+ from .suppressions import AsyncSuppressionsResource, SuppressionsResource
24
+ from .templates import AsyncTemplatesResource, TemplatesResource
25
+ from .webhooks import AsyncWebhooksResource, WebhooksResource
26
+ from .workflows import AsyncWorkflowsResource, WorkflowsResource
27
+
28
+ __all__ = [
29
+ "ApiKeysResource",
30
+ "AsyncApiKeysResource",
31
+ "BroadcastsResource",
32
+ "AsyncBroadcastsResource",
33
+ "ContactPropertiesResource",
34
+ "AsyncContactPropertiesResource",
35
+ "ContactsResource",
36
+ "AsyncContactsResource",
37
+ "DomainsResource",
38
+ "AsyncDomainsResource",
39
+ "EmailValidationsResource",
40
+ "AsyncEmailValidationsResource",
41
+ "EmailsResource",
42
+ "AsyncEmailsResource",
43
+ "MailboxesResource",
44
+ "AsyncMailboxesResource",
45
+ "SegmentsResource",
46
+ "AsyncSegmentsResource",
47
+ "SignupFormsResource",
48
+ "AsyncSignupFormsResource",
49
+ "SubscriptionTopicsResource",
50
+ "AsyncSubscriptionTopicsResource",
51
+ "SuppressionsResource",
52
+ "AsyncSuppressionsResource",
53
+ "TemplatesResource",
54
+ "AsyncTemplatesResource",
55
+ "WebhooksResource",
56
+ "AsyncWebhooksResource",
57
+ "WorkflowsResource",
58
+ "AsyncWorkflowsResource",
59
+ ]
@@ -0,0 +1,63 @@
1
+ """API keys resource — mirrors packages/sdk/src/resources/api-keys.ts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Mapping, Optional
6
+
7
+ from .._client import AsyncPostStackClient, PostStackClient
8
+
9
+
10
+ class ApiKeysResource:
11
+ def __init__(self, client: PostStackClient) -> None:
12
+ self._client = client
13
+
14
+ def create(
15
+ self, input: Dict[str, Any], *, timeout: Optional[float] = None
16
+ ) -> Dict[str, Any]:
17
+ return self._client.post("/api-keys", input, timeout=timeout)
18
+
19
+ def list(
20
+ self,
21
+ params: Optional[Mapping[str, Any]] = None,
22
+ *,
23
+ timeout: Optional[float] = None,
24
+ ) -> Dict[str, Any]:
25
+ return self._client.get("/api-keys", params, timeout=timeout)
26
+
27
+ def get(
28
+ self, id: int, *, timeout: Optional[float] = None
29
+ ) -> Dict[str, Any]:
30
+ return self._client.get(f"/api-keys/{id}", timeout=timeout)
31
+
32
+ def revoke(
33
+ self, id: int, *, timeout: Optional[float] = None
34
+ ) -> Dict[str, Any]:
35
+ return self._client.delete(f"/api-keys/{id}", timeout=timeout)
36
+
37
+
38
+ class AsyncApiKeysResource:
39
+ def __init__(self, client: AsyncPostStackClient) -> None:
40
+ self._client = client
41
+
42
+ async def create(
43
+ self, input: Dict[str, Any], *, timeout: Optional[float] = None
44
+ ) -> Dict[str, Any]:
45
+ return await self._client.post("/api-keys", input, timeout=timeout)
46
+
47
+ async def list(
48
+ self,
49
+ params: Optional[Mapping[str, Any]] = None,
50
+ *,
51
+ timeout: Optional[float] = None,
52
+ ) -> Dict[str, Any]:
53
+ return await self._client.get("/api-keys", params, timeout=timeout)
54
+
55
+ async def get(
56
+ self, id: int, *, timeout: Optional[float] = None
57
+ ) -> Dict[str, Any]:
58
+ return await self._client.get(f"/api-keys/{id}", timeout=timeout)
59
+
60
+ async def revoke(
61
+ self, id: int, *, timeout: Optional[float] = None
62
+ ) -> Dict[str, Any]:
63
+ return await self._client.delete(f"/api-keys/{id}", timeout=timeout)