senderkit 0.1.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.
senderkit/__init__.py ADDED
@@ -0,0 +1,95 @@
1
+ """SenderKit Python SDK.
2
+
3
+ from senderkit import SenderKit
4
+
5
+ with SenderKit(api_key="sk_test_...") as sk:
6
+ sk.send("welcome", "user@example.com", vars={"name": "Ada"})
7
+
8
+ See :class:`SenderKit` / :class:`AsyncSenderKit` for the client, ``senderkit.errors``
9
+ for the exception hierarchy, and :class:`WebhookVerifier` for inbound webhooks.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from . import errors
15
+ from ._version import VERSION
16
+ from .client import AsyncSenderKit, SenderKit
17
+ from .errors import (
18
+ APIError,
19
+ AuthenticationError,
20
+ ConflictError,
21
+ NetworkError,
22
+ PaymentRequiredError,
23
+ RateLimitError,
24
+ SenderKitError,
25
+ SignatureVerificationError,
26
+ TimeoutError,
27
+ ValidationError,
28
+ )
29
+ from .models import (
30
+ Attachment,
31
+ BatchResult,
32
+ Channel,
33
+ Context,
34
+ EmailContent,
35
+ Message,
36
+ MessageList,
37
+ PushContent,
38
+ RawSend,
39
+ RenderResult,
40
+ SendResult,
41
+ SmsContent,
42
+ TemplateDetail,
43
+ TemplateSend,
44
+ TemplateSummary,
45
+ TemplateVariable,
46
+ TemplateVersion,
47
+ WebPushContent,
48
+ Workspace,
49
+ )
50
+ from .webhooks import WebhookEvent, WebhookVerifier
51
+
52
+ __version__ = VERSION
53
+
54
+ __all__ = [
55
+ "VERSION",
56
+ "__version__",
57
+ "SenderKit",
58
+ "AsyncSenderKit",
59
+ "errors",
60
+ # Errors
61
+ "SenderKitError",
62
+ "APIError",
63
+ "AuthenticationError",
64
+ "ValidationError",
65
+ "PaymentRequiredError",
66
+ "ConflictError",
67
+ "RateLimitError",
68
+ "TimeoutError",
69
+ "NetworkError",
70
+ "SignatureVerificationError",
71
+ # Requests
72
+ "TemplateSend",
73
+ "RawSend",
74
+ "EmailContent",
75
+ "SmsContent",
76
+ "PushContent",
77
+ "WebPushContent",
78
+ "Attachment",
79
+ "Channel",
80
+ # Responses
81
+ "SendResult",
82
+ "BatchResult",
83
+ "Message",
84
+ "MessageList",
85
+ "TemplateSummary",
86
+ "TemplateDetail",
87
+ "TemplateVersion",
88
+ "TemplateVariable",
89
+ "RenderResult",
90
+ "Context",
91
+ "Workspace",
92
+ # Webhooks
93
+ "WebhookVerifier",
94
+ "WebhookEvent",
95
+ ]
senderkit/_http.py ADDED
@@ -0,0 +1,280 @@
1
+ """Internal HTTP transport: header construction, retries with backoff,
2
+ idempotency, timeout/network mapping, and error-to-exception translation.
3
+
4
+ Two thin transports — ``Transport`` (sync, ``httpx.Client``) and
5
+ ``AsyncTransport`` (``httpx.AsyncClient``) — share all policy via the module
6
+ helpers below. Not part of the public API; the client and resources use it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import random
14
+ import time
15
+ from typing import Any, Dict, Optional
16
+
17
+ import httpx
18
+
19
+ from . import errors
20
+ from ._version import VERSION
21
+
22
+ RETRY_BASE_MS = 250
23
+ RETRY_CAP_MS = 5_000
24
+
25
+
26
+ def _is_retryable(status: int) -> bool:
27
+ """429 and 5xx (except 501 Not Implemented) are worth retrying."""
28
+ return status == 429 or (500 <= status < 600 and status != 501)
29
+
30
+
31
+ def _build_headers(
32
+ api_key: str,
33
+ idempotency_key: Optional[str],
34
+ extra: Optional[Dict[str, str]],
35
+ ) -> Dict[str, str]:
36
+ headers = {
37
+ "Authorization": f"Bearer {api_key}",
38
+ "Accept": "application/json",
39
+ "User-Agent": f"senderkit-python/{VERSION}",
40
+ }
41
+ if idempotency_key:
42
+ headers["Idempotency-Key"] = idempotency_key
43
+ if extra:
44
+ headers.update(extra)
45
+ return headers
46
+
47
+
48
+ def _parse_retry_after(response: httpx.Response) -> Optional[float]:
49
+ value = response.headers.get("retry-after")
50
+ if not value:
51
+ return None
52
+ try:
53
+ return float(value)
54
+ except ValueError:
55
+ return None
56
+
57
+
58
+ def _backoff_seconds(attempt: int, retry_after: Optional[float]) -> float:
59
+ """Full-jitter exponential backoff, capped at ``RETRY_CAP_MS``.
60
+
61
+ When the server sent ``Retry-After`` we wait at least that long (also capped),
62
+ so we never hammer ahead of an explicit instruction.
63
+ """
64
+ cap = RETRY_CAP_MS / 1000
65
+ ceiling = min(RETRY_CAP_MS, RETRY_BASE_MS * (2**attempt)) / 1000
66
+ jitter = random.uniform(0, ceiling)
67
+ if retry_after is not None:
68
+ return min(max(retry_after, jitter), cap)
69
+ return jitter
70
+
71
+
72
+ def _clean_query(query: Optional[Dict[str, Any]]) -> Optional[Dict[str, str]]:
73
+ if not query:
74
+ return None
75
+ return {k: str(v) for k, v in query.items() if v is not None}
76
+
77
+
78
+ def _raise_for_status(response: httpx.Response) -> None:
79
+ status = response.status_code
80
+ if 200 <= status < 300:
81
+ return
82
+
83
+ code: Optional[str] = None
84
+ message: Optional[str] = None
85
+ issues: Any = None
86
+ try:
87
+ body = response.json()
88
+ err = body.get("error") if isinstance(body, dict) else None
89
+ if isinstance(err, dict):
90
+ code = err.get("code")
91
+ message = err.get("message")
92
+ issues = err.get("issues")
93
+ except (ValueError, json.JSONDecodeError):
94
+ pass
95
+
96
+ message = message or f"HTTP {status}"
97
+ request_id = response.headers.get("x-request-id")
98
+
99
+ if status in (401, 403):
100
+ raise errors.AuthenticationError(
101
+ message, status=status, code=code, issues=issues, request_id=request_id
102
+ )
103
+ if status in (400, 422):
104
+ raise errors.ValidationError(
105
+ message, status=status, code=code, issues=issues, request_id=request_id
106
+ )
107
+ if status == 402:
108
+ raise errors.PaymentRequiredError(
109
+ message, status=status, code=code, issues=issues, request_id=request_id
110
+ )
111
+ if status == 409:
112
+ raise errors.ConflictError(
113
+ message, status=status, code=code, issues=issues, request_id=request_id
114
+ )
115
+ if status == 429:
116
+ raise errors.RateLimitError(
117
+ message,
118
+ status=status,
119
+ code=code,
120
+ issues=issues,
121
+ request_id=request_id,
122
+ retry_after=_parse_retry_after(response),
123
+ )
124
+ raise errors.APIError(message, status=status, code=code, issues=issues, request_id=request_id)
125
+
126
+
127
+ class _BaseTransport:
128
+ def __init__(self, api_key: str, base_url: str, max_retries: int) -> None:
129
+ self._api_key = api_key
130
+ self._base_url = base_url.rstrip("/")
131
+ self._max_retries = max_retries
132
+
133
+ def _url(self, path: str) -> str:
134
+ return self._base_url + (path if path.startswith("/") else f"/{path}")
135
+
136
+ def _prepare(
137
+ self,
138
+ body: Optional[Dict[str, Any]],
139
+ idempotency_key: Optional[str],
140
+ headers: Optional[Dict[str, str]],
141
+ accept: Optional[str],
142
+ ) -> tuple[Dict[str, str], Optional[bytes]]:
143
+ h = _build_headers(self._api_key, idempotency_key, headers)
144
+ if accept:
145
+ h["Accept"] = accept
146
+ content: Optional[bytes] = None
147
+ if body is not None:
148
+ content = json.dumps(body).encode()
149
+ h["Content-Type"] = "application/json"
150
+ return h, content
151
+
152
+
153
+ class Transport(_BaseTransport):
154
+ """Synchronous transport over ``httpx.Client``."""
155
+
156
+ def __init__(
157
+ self,
158
+ api_key: str,
159
+ base_url: str,
160
+ timeout: float,
161
+ max_retries: int,
162
+ http_client: Optional[httpx.Client] = None,
163
+ ) -> None:
164
+ super().__init__(api_key, base_url, max_retries)
165
+ self._owns_client = http_client is None
166
+ self._client = http_client or httpx.Client(timeout=timeout)
167
+
168
+ def request(
169
+ self,
170
+ method: str,
171
+ path: str,
172
+ *,
173
+ query: Optional[Dict[str, Any]] = None,
174
+ body: Optional[Dict[str, Any]] = None,
175
+ idempotency_key: Optional[str] = None,
176
+ headers: Optional[Dict[str, str]] = None,
177
+ accept: Optional[str] = None,
178
+ ) -> httpx.Response:
179
+ url = self._url(path)
180
+ h, content = self._prepare(body, idempotency_key, headers, accept)
181
+ params = _clean_query(query)
182
+ attempts = 1 + self._max_retries
183
+
184
+ for attempt in range(attempts):
185
+ try:
186
+ response = self._client.request(
187
+ method, url, params=params, content=content, headers=h
188
+ )
189
+ except httpx.TimeoutException as exc:
190
+ raise errors.TimeoutError(f"Request timed out: {exc}") from exc
191
+ except httpx.RequestError as exc:
192
+ if attempt < attempts - 1:
193
+ time.sleep(_backoff_seconds(attempt, None))
194
+ continue
195
+ raise errors.NetworkError(f"Network error: {exc}") from exc
196
+
197
+ if _is_retryable(response.status_code) and attempt < attempts - 1:
198
+ time.sleep(_backoff_seconds(attempt, _parse_retry_after(response)))
199
+ continue
200
+
201
+ _raise_for_status(response)
202
+ return response
203
+
204
+ raise errors.NetworkError("Request failed after retries") # pragma: no cover
205
+
206
+ def request_json(self, method: str, path: str, **kwargs: Any) -> Dict[str, Any]:
207
+ response = self.request(method, path, **kwargs)
208
+ if not response.content:
209
+ return {}
210
+ data = response.json()
211
+ return data if isinstance(data, dict) else {"data": data}
212
+
213
+ def close(self) -> None:
214
+ if self._owns_client:
215
+ self._client.close()
216
+
217
+
218
+ class AsyncTransport(_BaseTransport):
219
+ """Asynchronous transport over ``httpx.AsyncClient``."""
220
+
221
+ def __init__(
222
+ self,
223
+ api_key: str,
224
+ base_url: str,
225
+ timeout: float,
226
+ max_retries: int,
227
+ http_client: Optional[httpx.AsyncClient] = None,
228
+ ) -> None:
229
+ super().__init__(api_key, base_url, max_retries)
230
+ self._owns_client = http_client is None
231
+ self._client = http_client or httpx.AsyncClient(timeout=timeout)
232
+
233
+ async def request(
234
+ self,
235
+ method: str,
236
+ path: str,
237
+ *,
238
+ query: Optional[Dict[str, Any]] = None,
239
+ body: Optional[Dict[str, Any]] = None,
240
+ idempotency_key: Optional[str] = None,
241
+ headers: Optional[Dict[str, str]] = None,
242
+ accept: Optional[str] = None,
243
+ ) -> httpx.Response:
244
+ url = self._url(path)
245
+ h, content = self._prepare(body, idempotency_key, headers, accept)
246
+ params = _clean_query(query)
247
+ attempts = 1 + self._max_retries
248
+
249
+ for attempt in range(attempts):
250
+ try:
251
+ response = await self._client.request(
252
+ method, url, params=params, content=content, headers=h
253
+ )
254
+ except httpx.TimeoutException as exc:
255
+ raise errors.TimeoutError(f"Request timed out: {exc}") from exc
256
+ except httpx.RequestError as exc:
257
+ if attempt < attempts - 1:
258
+ await asyncio.sleep(_backoff_seconds(attempt, None))
259
+ continue
260
+ raise errors.NetworkError(f"Network error: {exc}") from exc
261
+
262
+ if _is_retryable(response.status_code) and attempt < attempts - 1:
263
+ await asyncio.sleep(_backoff_seconds(attempt, _parse_retry_after(response)))
264
+ continue
265
+
266
+ _raise_for_status(response)
267
+ return response
268
+
269
+ raise errors.NetworkError("Request failed after retries") # pragma: no cover
270
+
271
+ async def request_json(self, method: str, path: str, **kwargs: Any) -> Dict[str, Any]:
272
+ response = await self.request(method, path, **kwargs)
273
+ if not response.content:
274
+ return {}
275
+ data = response.json()
276
+ return data if isinstance(data, dict) else {"data": data}
277
+
278
+ async def aclose(self) -> None:
279
+ if self._owns_client:
280
+ await self._client.aclose()
@@ -0,0 +1,184 @@
1
+ """Turn request DTOs into the JSON shapes the API expects.
2
+
3
+ Centralizes the snake_case → camelCase mapping, ``None`` pruning, datetime →
4
+ ISO-8601 conversion, and enum unwrapping so the client and resources never
5
+ hand-build wire dicts. Kept explicit (rather than reflection-based) so the
6
+ mapping is auditable against ``openapi.yaml``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import datetime
12
+ from typing import Any, Dict, Optional, Tuple
13
+
14
+ from .models import (
15
+ Attachment,
16
+ Channel,
17
+ ChannelLike,
18
+ Content,
19
+ EmailContent,
20
+ PushContent,
21
+ RawSend,
22
+ SmsContent,
23
+ TemplateSend,
24
+ WebPushContent,
25
+ )
26
+
27
+
28
+ def _iso(value: Any) -> Any:
29
+ """ISO-8601 string for a datetime; pass strings (and None) through."""
30
+ if isinstance(value, datetime):
31
+ return value.isoformat()
32
+ return value
33
+
34
+
35
+ def _channel_value(channel: Optional[ChannelLike]) -> Optional[str]:
36
+ if channel is None:
37
+ return None
38
+ return channel.value if isinstance(channel, Channel) else str(channel)
39
+
40
+
41
+ def _prune(d: Dict[str, Any]) -> Dict[str, Any]:
42
+ """Drop keys whose value is ``None`` (omit-if-absent semantics)."""
43
+ return {k: v for k, v in d.items() if v is not None}
44
+
45
+
46
+ def _attachment_to_wire(a: Attachment) -> Dict[str, Any]:
47
+ return _prune(
48
+ {
49
+ "filename": a.filename,
50
+ "contentType": a.content_type,
51
+ "content": a.content,
52
+ "inline": a.inline,
53
+ "contentId": a.content_id,
54
+ }
55
+ )
56
+
57
+
58
+ def channel_for_content(content: Content) -> str:
59
+ """Infer the channel string from a content instance."""
60
+ if isinstance(content, EmailContent):
61
+ return "email"
62
+ if isinstance(content, SmsContent):
63
+ return "sms"
64
+ if isinstance(content, PushContent):
65
+ return "push"
66
+ if isinstance(content, WebPushContent):
67
+ return "web-push"
68
+ raise TypeError(f"Unsupported content type: {type(content)!r}")
69
+
70
+
71
+ def content_to_wire(content: Content) -> Dict[str, Any]:
72
+ if isinstance(content, EmailContent):
73
+ return _prune(
74
+ {
75
+ "subject": content.subject,
76
+ "preheader": content.preheader,
77
+ "html": content.html,
78
+ "text": content.text,
79
+ "cc": content.cc,
80
+ "bcc": content.bcc,
81
+ "replyTo": content.reply_to,
82
+ "attachments": (
83
+ [_attachment_to_wire(a) for a in content.attachments]
84
+ if content.attachments
85
+ else None
86
+ ),
87
+ }
88
+ )
89
+ if isinstance(content, SmsContent):
90
+ return {"body": content.body}
91
+ if isinstance(content, PushContent):
92
+ return _prune(
93
+ {
94
+ "title": content.title,
95
+ "body": content.body,
96
+ "data": content.data,
97
+ "badge": content.badge,
98
+ "sound": content.sound,
99
+ }
100
+ )
101
+ if isinstance(content, WebPushContent):
102
+ return _prune(
103
+ {
104
+ "title": content.title,
105
+ "body": content.body,
106
+ "icon": content.icon,
107
+ "clickUrl": content.click_url,
108
+ "data": content.data,
109
+ "badge": content.badge,
110
+ }
111
+ )
112
+ raise TypeError(f"Unsupported content type: {type(content)!r}")
113
+
114
+
115
+ def template_send_to_body(req: TemplateSend) -> Dict[str, Any]:
116
+ return _prune(
117
+ {
118
+ "template": req.template,
119
+ "to": req.to,
120
+ "vars": req.vars,
121
+ "version": req.version,
122
+ "channel": _channel_value(req.channel),
123
+ "metadata": req.metadata,
124
+ "scheduledAt": _iso(req.scheduled_at),
125
+ "cc": req.cc,
126
+ "bcc": req.bcc,
127
+ "replyTo": req.reply_to,
128
+ "attachments": (
129
+ [_attachment_to_wire(a) for a in req.attachments] if req.attachments else None
130
+ ),
131
+ }
132
+ )
133
+
134
+
135
+ def raw_send_to_body(req: RawSend) -> Dict[str, Any]:
136
+ channel = _channel_value(req.channel) or channel_for_content(req.content)
137
+ return _prune(
138
+ {
139
+ "channel": channel,
140
+ "to": req.to,
141
+ "from": req.from_,
142
+ "interpolate": req.interpolate,
143
+ "content": content_to_wire(req.content),
144
+ "vars": req.vars,
145
+ "metadata": req.metadata,
146
+ "scheduledAt": _iso(req.scheduled_at),
147
+ }
148
+ )
149
+
150
+
151
+ def build_send(req: Any) -> Tuple[Dict[str, Any], Optional[str]]:
152
+ """Serialize a send request, returning ``(body, idempotency_key)``."""
153
+ if isinstance(req, TemplateSend):
154
+ return template_send_to_body(req), req.idempotency_key
155
+ if isinstance(req, RawSend):
156
+ return raw_send_to_body(req), req.idempotency_key
157
+ raise TypeError(f"Expected TemplateSend or RawSend, got {type(req)!r}")
158
+
159
+
160
+ def list_messages_query(
161
+ *,
162
+ limit: Optional[int],
163
+ cursor: Optional[str],
164
+ status: Optional[str],
165
+ channel: Optional[ChannelLike],
166
+ template: Optional[str],
167
+ metadata: Optional[Dict[str, Any]],
168
+ tail: Optional[str],
169
+ ) -> Dict[str, Any]:
170
+ """Build the query dict for ``GET /v1/messages``, including ``metadata[key]``."""
171
+ query: Dict[str, Any] = _prune(
172
+ {
173
+ "limit": limit,
174
+ "cursor": cursor,
175
+ "status": status.value if isinstance(status, Channel) else status,
176
+ "channel": _channel_value(channel),
177
+ "template": template,
178
+ "tail": tail,
179
+ }
180
+ )
181
+ if metadata:
182
+ for key, value in metadata.items():
183
+ query[f"metadata[{key}]"] = value
184
+ return query
senderkit/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the SDK version, surfaced in the User-Agent header."""
2
+
3
+ VERSION = "0.1.0" # x-release-please-version