sendora 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.
sendora/__init__.py ADDED
@@ -0,0 +1,52 @@
1
+ """The Python client for Sendora's email API.
2
+
3
+ ``Sendora`` and ``AsyncSendora`` take a server key; every failed call
4
+ raises a ``SendoraError``. The request shapes and answers are in
5
+ ``sendora.types``.
6
+ """
7
+
8
+ from ._client import AsyncSendora, Sendora
9
+ from ._errors import (
10
+ APIConnectionError,
11
+ APIStatusError,
12
+ APITimeoutError,
13
+ AuthenticationError,
14
+ BadRequestError,
15
+ ConflictError,
16
+ InternalServerError,
17
+ NotFoundError,
18
+ PermissionDeniedError,
19
+ RateLimitError,
20
+ ResponseValidationError,
21
+ SendoraError,
22
+ UnprocessableEntityError,
23
+ WebhookVerificationError,
24
+ )
25
+ from ._options import DEFAULT_BASE_URL
26
+ from ._transport import AsyncHttpClient, HttpClient
27
+ from ._version import __version__
28
+ from .types import SendoraErrorCode
29
+
30
+ __all__ = [
31
+ "DEFAULT_BASE_URL",
32
+ "APIConnectionError",
33
+ "APIStatusError",
34
+ "APITimeoutError",
35
+ "AsyncHttpClient",
36
+ "AsyncSendora",
37
+ "AuthenticationError",
38
+ "BadRequestError",
39
+ "ConflictError",
40
+ "HttpClient",
41
+ "InternalServerError",
42
+ "NotFoundError",
43
+ "PermissionDeniedError",
44
+ "RateLimitError",
45
+ "ResponseValidationError",
46
+ "Sendora",
47
+ "SendoraError",
48
+ "SendoraErrorCode",
49
+ "UnprocessableEntityError",
50
+ "WebhookVerificationError",
51
+ "__version__",
52
+ ]
sendora/_answers.py ADDED
@@ -0,0 +1,304 @@
1
+ """An answer's status, headers and bytes as a value or an error.
2
+
3
+ Pure: the transports hand over what came back, and the same function reads
4
+ it for the regular and the async clients.
5
+ """
6
+
7
+ import json
8
+ import re
9
+ from collections.abc import Mapping
10
+ from dataclasses import dataclass
11
+ from datetime import datetime
12
+ from typing import Literal, TypeVar, cast
13
+
14
+ from ._errors import (
15
+ APIStatusError,
16
+ AuthenticationError,
17
+ BadRequestError,
18
+ ConflictError,
19
+ InternalServerError,
20
+ NotFoundError,
21
+ PermissionDeniedError,
22
+ RateLimitError,
23
+ ResponseValidationError,
24
+ SendoraError,
25
+ UnprocessableEntityError,
26
+ )
27
+ from ._json import is_list, is_object
28
+ from ._model import Answer
29
+ from ._wire import decode
30
+ from .types import (
31
+ LimitScope,
32
+ SendoraErrorCode,
33
+ SuppressedRecipient,
34
+ ValidationIssue,
35
+ )
36
+
37
+ _A = TypeVar("_A", bound=Answer)
38
+
39
+ Expect = Literal["json", "bytes", "none"]
40
+ """What a call answers when it succeeds: parsed JSON, the bytes, or nothing."""
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class Reply:
45
+ """What came back for one attempt.
46
+
47
+ Attributes:
48
+ status: The HTTP status.
49
+ headers: The headers, names lower-cased.
50
+ content: The body's bytes.
51
+ """
52
+
53
+ status: int
54
+ headers: Mapping[str, str]
55
+ content: bytes
56
+
57
+
58
+ def interpret(reply: Reply, expect: Expect, base_url: str) -> object:
59
+ """The value a reply amounts to, or the error it is.
60
+
61
+ Args:
62
+ reply: What came back.
63
+ expect: What the call answers when it succeeds.
64
+ base_url: The API's address, named when a redirect answers.
65
+
66
+ Returns:
67
+ Parsed JSON, the bytes, or ``None``.
68
+
69
+ Raises:
70
+ SendoraError: The reply is a refusal, a redirect, or unreadable.
71
+ """
72
+ status = reply.status
73
+ if status == 204: # noqa: PLR2004
74
+ return None
75
+ if 200 <= status < 300: # noqa: PLR2004
76
+ if expect == "bytes":
77
+ return reply.content
78
+ if expect == "none":
79
+ return None
80
+ parsed = _parsed(reply.content)
81
+ if parsed is _NOTHING:
82
+ msg = (
83
+ f"The API answered {status} without a JSON body; the call took effect."
84
+ )
85
+ raise ResponseValidationError(
86
+ msg, code="unexpected_response", status=status
87
+ )
88
+ return parsed
89
+ if 300 <= status < 400: # noqa: PLR2004
90
+ msg = f"The API answered a redirect; check base_url ({base_url})."
91
+ raise APIStatusError(msg, code="unexpected_response", status=status)
92
+ raise error_from_answer(
93
+ status, _parsed(reply.content), reply.headers.get("retry-after")
94
+ )
95
+
96
+
97
+ def decode_sent(cls: type[_A], value: object, idempotency_key: str | None) -> _A:
98
+ """A send's answer decoded, its idempotency key on the error if it cannot be.
99
+
100
+ The API accepted the send, so a caller repeating it under the same key
101
+ gets the first answer back rather than a second message.
102
+
103
+ Args:
104
+ cls: The answer's class.
105
+ value: The parsed JSON.
106
+ idempotency_key: The key the send went under, which an error carries.
107
+
108
+ Returns:
109
+ The answer.
110
+
111
+ Raises:
112
+ ResponseValidationError: The answer is not one this release reads.
113
+ """
114
+ try:
115
+ return decode(cls, value)
116
+ except ResponseValidationError as error:
117
+ error.idempotency_key = idempotency_key
118
+ raise
119
+
120
+
121
+ _NOTHING = object()
122
+
123
+ # The API's codes are snake_case; anything else, such as the reason phrase a
124
+ # framework's own error body carries, is not one.
125
+ _API_CODE = re.compile(r"[a-z][a-z0-9_]*")
126
+
127
+ # The API, in JavaScript, sends no whole number above 2**53 - 1 exactly; a
128
+ # longer one is refused rather than read into a wait no sleep can take.
129
+ _DIGITS = re.compile(r"[0-9]{1,16}")
130
+ _LARGEST = 2**53 - 1
131
+
132
+
133
+ def _parsed(content: bytes) -> object:
134
+ try:
135
+ parsed: object = json.loads(content)
136
+ except ValueError:
137
+ return _NOTHING
138
+ return parsed
139
+
140
+
141
+ def error_from_answer(
142
+ status: int, body: object, retry_after: str | None
143
+ ) -> SendoraError:
144
+ """The error a refusal amounts to, of the class its status names.
145
+
146
+ An answer that is not the API's JSON is ``unexpected_response``, but for
147
+ the edge's own plain-text 429 and 413, which Traefik answers before the
148
+ API sees the request.
149
+
150
+ Args:
151
+ status: The HTTP status.
152
+ body: The parsed JSON, or anything else that came.
153
+ retry_after: The ``Retry-After`` header.
154
+
155
+ Returns:
156
+ The error, every field the answer carries filled.
157
+ """
158
+ kind = _class_for(status)
159
+ if not is_object(body):
160
+ if status == 429: # noqa: PLR2004
161
+ msg = (
162
+ "Too many requests reached Sendora at once; its edge refused this one."
163
+ )
164
+ return kind(
165
+ msg, code="rate_limited", status=status, retry_after=_whole(retry_after)
166
+ )
167
+ if status == 413: # noqa: PLR2004
168
+ msg = (
169
+ "The request body is larger than Sendora accepts; its edge refused it."
170
+ )
171
+ return kind(
172
+ msg,
173
+ code="request_too_large",
174
+ status=status,
175
+ retry_after=_whole(retry_after),
176
+ )
177
+ msg = f"The API answered {status} without an error body."
178
+ return kind(
179
+ msg,
180
+ code="unexpected_response",
181
+ status=status,
182
+ retry_after=_whole(retry_after),
183
+ )
184
+ answer = body
185
+ raw = answer.get("error")
186
+ code = (
187
+ _code(raw)
188
+ if isinstance(raw, str) and _API_CODE.fullmatch(raw)
189
+ else "unexpected_response"
190
+ )
191
+ issues = _readable(ValidationIssue, answer.get("issues"))
192
+ message = answer.get("message")
193
+ if not isinstance(message, str):
194
+ message = (
195
+ "The request is invalid: "
196
+ + "; ".join(f"{issue.path}: {issue.message}" for issue in issues)
197
+ if code == "invalid_request"
198
+ else f"The API answered {status} without an error message."
199
+ )
200
+ return kind(
201
+ message,
202
+ code=code,
203
+ status=status,
204
+ retry_after=_either(_whole(answer.get("retryAfter")), _whole(retry_after)),
205
+ scope=_scope(answer.get("scope")),
206
+ limit=_whole(answer.get("limit")),
207
+ cap=_whole(answer.get("cap")),
208
+ used=_whole(answer.get("used")),
209
+ resets_at=_time(answer.get("resetsAt")),
210
+ max=_either(_whole(answer.get("max")), _whole(answer.get("maxServers"))),
211
+ issues=issues,
212
+ suppressed=_readable(SuppressedRecipient, answer.get("suppressed")),
213
+ stream_id=_text(answer.get("streamId"))
214
+ if code == "recipient_suppressed"
215
+ else None,
216
+ addresses=_texts(answer.get("addresses")),
217
+ existing_id=_existing_id(code, answer),
218
+ index=_whole(answer.get("index")) if code == "substitution_missing" else None,
219
+ keys=_texts(answer.get("keys")) if code == "substitution_missing" else [],
220
+ )
221
+
222
+
223
+ def _class_for(status: int) -> type[APIStatusError]:
224
+ if status >= 500: # noqa: PLR2004
225
+ return InternalServerError
226
+ by_status: dict[int, type[APIStatusError]] = {
227
+ 400: BadRequestError,
228
+ 401: AuthenticationError,
229
+ 403: PermissionDeniedError,
230
+ 404: NotFoundError,
231
+ 409: ConflictError,
232
+ 422: UnprocessableEntityError,
233
+ 429: RateLimitError,
234
+ }
235
+ return by_status.get(status, APIStatusError)
236
+
237
+
238
+ def _code(raw: str) -> SendoraErrorCode:
239
+ # A cast does nothing at run time, so no mutant of it can fail a test.
240
+ return cast("SendoraErrorCode", raw) # pragma: no mutate
241
+
242
+
243
+ _EXISTING_IDS = {
244
+ "domain_exists": "domainId",
245
+ "webhook_exists": "webhookId",
246
+ "stream_exists": "streamId",
247
+ "inbound_stream_exists": "streamId",
248
+ "inbound_domain_exists": "inboundDomainId",
249
+ }
250
+
251
+
252
+ def _existing_id(code: str, answer: Mapping[str, object]) -> str | None:
253
+ if code not in _EXISTING_IDS:
254
+ return None
255
+ return _text(answer.get(_EXISTING_IDS[code]))
256
+
257
+
258
+ def _either(first: int | None, second: int | None) -> int | None:
259
+ return second if first is None else first
260
+
261
+
262
+ def _whole(value: object) -> int | None:
263
+ if isinstance(value, str) and _DIGITS.fullmatch(value):
264
+ value = int(value)
265
+ if type(value) is int and 0 <= value <= _LARGEST:
266
+ return value
267
+ return None
268
+
269
+
270
+ def _text(value: object) -> str | None:
271
+ return value if isinstance(value, str) else None
272
+
273
+
274
+ def _texts(value: object) -> list[str]:
275
+ if not is_list(value):
276
+ return []
277
+ return [member for member in value if isinstance(member, str)]
278
+
279
+
280
+ def _time(value: object) -> datetime | None:
281
+ if not isinstance(value, str):
282
+ return None
283
+ try:
284
+ return datetime.fromisoformat(value)
285
+ except ValueError:
286
+ return None
287
+
288
+
289
+ def _scope(value: object) -> LimitScope | None:
290
+ scopes: tuple[LimitScope, ...] = ("tenant", "server", "test")
291
+ return next((scope for scope in scopes if scope == value), None)
292
+
293
+
294
+ def _readable(cls: type[_A], value: object) -> list[_A]:
295
+ """The entries of a list the SDK can read as ``cls``; the rest are left out."""
296
+ if not is_list(value):
297
+ return []
298
+ found: list[_A] = []
299
+ for entry in value:
300
+ try:
301
+ found.append(decode(cls, entry))
302
+ except ResponseValidationError:
303
+ continue
304
+ return found
sendora/_client.py ADDED
@@ -0,0 +1,234 @@
1
+ """The clients for a server key: ``Sendora`` and ``AsyncSendora``."""
2
+
3
+ from types import TracebackType
4
+ from typing import Self
5
+
6
+ from ._options import DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, options_for
7
+ from ._transport import AsyncHttpClient, AsyncTransport, HttpClient, SyncTransport
8
+ from .resources.email import AsyncEmail, Email
9
+ from .resources.messages import AsyncMessages, Messages
10
+
11
+ _SERVER_KEY = "SENDORA_API_TOKEN"
12
+
13
+
14
+ class Sendora:
15
+ """The client for a server key (``sk_…``): everything inside one server.
16
+
17
+ One client per process is the intended use; it is safe across threads
18
+ and reuses its connections. Close it, or use it as a context manager.
19
+
20
+ Attributes:
21
+ email: Sends mail, one message or a batch.
22
+ messages: Reads the message log.
23
+
24
+ Example:
25
+ >>> with Sendora() as sendora: # the key from SENDORA_API_TOKEN
26
+ ... sent = sendora.email.send(
27
+ ... from_="notices@example.se",
28
+ ... to=["anna@example.com"],
29
+ ... subject="Your order",
30
+ ... text="Thank you for your order.",
31
+ ... )
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ token: str | None = None,
37
+ *,
38
+ base_url: str | None = None,
39
+ timeout: float = DEFAULT_TIMEOUT,
40
+ max_retries: int = DEFAULT_MAX_RETRIES,
41
+ http_client: HttpClient | None = None,
42
+ ) -> None:
43
+ """Makes the client; nothing is sent until a call.
44
+
45
+ Args:
46
+ token: The server key; ``SENDORA_API_TOKEN`` when not given.
47
+ base_url: The API's address; ``SENDORA_BASE_URL``, then
48
+ https://api.sendora.se, when not given.
49
+ timeout: Seconds each step of a request may take: connecting,
50
+ sending, waiting for the answer.
51
+ max_retries: How many times a failed call is repeated when
52
+ repeating is safe; 0 turns retries off.
53
+ http_client: Your own ``httpx2.Client``, for a proxy or a
54
+ private CA. Its connections, proxy and certificates are
55
+ used, not its default headers, query or cookies; the SDK
56
+ never closes it.
57
+
58
+ Raises:
59
+ ValueError: A setting is missing or out of range.
60
+ TypeError: ``http_client`` is not an ``httpx2.Client``.
61
+ """
62
+ try:
63
+ transport = SyncTransport(
64
+ options_for(
65
+ token,
66
+ base_url=base_url,
67
+ timeout=timeout,
68
+ max_retries=max_retries,
69
+ variable=_SERVER_KEY,
70
+ ),
71
+ http_client,
72
+ )
73
+ except (ValueError, TypeError) as refused:
74
+ # An error tracker keeps the locals of each frame it is handed,
75
+ # so the frames that held the key are dropped, and this one's.
76
+ del token
77
+ raise refused.with_traceback(None) from None
78
+ self._attach(transport)
79
+
80
+ def _attach(self, transport: SyncTransport) -> None:
81
+ self._transport = transport
82
+ self.email: Email = Email(transport)
83
+ self.messages: Messages = Messages(transport)
84
+
85
+ def with_options(
86
+ self, *, timeout: float | None = None, max_retries: int | None = None
87
+ ) -> Self:
88
+ """A client with another timeout or retry count, sharing these connections.
89
+
90
+ Args:
91
+ timeout: Seconds each step of a request may take.
92
+ max_retries: How many times a failed call is repeated.
93
+
94
+ Returns:
95
+ The new client. It sends over this client's connections, which
96
+ close with this client alone: close this one when both are done.
97
+ """
98
+ transport = self._transport.with_options(
99
+ timeout=timeout, max_retries=max_retries
100
+ )
101
+ changed = object.__new__(type(self))
102
+ changed._attach(transport) # noqa: SLF001 - a client of this class, new here
103
+ return changed
104
+
105
+ def close(self) -> None:
106
+ """Closes the client's connections, unless they are a caller's own."""
107
+ self._transport.close()
108
+
109
+ def __enter__(self) -> Self:
110
+ """Answers the client itself."""
111
+ return self
112
+
113
+ def __exit__(
114
+ self,
115
+ kind: type[BaseException] | None,
116
+ error: BaseException | None,
117
+ traceback: TracebackType | None,
118
+ ) -> None:
119
+ """Closes the client."""
120
+ self.close()
121
+
122
+ def __repr__(self) -> str:
123
+ """Names the address, never the key."""
124
+ return f"{type(self).__name__}(base_url={self._transport.options.base_url!r})"
125
+
126
+
127
+ class AsyncSendora:
128
+ """The async client for a server key (``sk_…``), under asyncio.
129
+
130
+ Attributes:
131
+ email: Sends mail, one message or a batch.
132
+ messages: Reads the message log.
133
+
134
+ Example:
135
+ >>> async with AsyncSendora() as sendora:
136
+ ... sent = await sendora.email.send(
137
+ ... from_="notices@example.se",
138
+ ... to=["anna@example.com"],
139
+ ... subject="Your order",
140
+ ... text="Thank you for your order.",
141
+ ... )
142
+ """
143
+
144
+ def __init__(
145
+ self,
146
+ token: str | None = None,
147
+ *,
148
+ base_url: str | None = None,
149
+ timeout: float = DEFAULT_TIMEOUT,
150
+ max_retries: int = DEFAULT_MAX_RETRIES,
151
+ http_client: AsyncHttpClient | None = None,
152
+ ) -> None:
153
+ """Makes the client; nothing is sent until a call.
154
+
155
+ Args:
156
+ token: The server key; ``SENDORA_API_TOKEN`` when not given.
157
+ base_url: The API's address; ``SENDORA_BASE_URL``, then
158
+ https://api.sendora.se, when not given.
159
+ timeout: Seconds each step of a request may take: connecting,
160
+ sending, waiting for the answer.
161
+ max_retries: How many times a failed call is repeated when
162
+ repeating is safe; 0 turns retries off.
163
+ http_client: Your own ``httpx2.AsyncClient``, for a proxy or a
164
+ private CA. Its connections, proxy and certificates are
165
+ used, not its default headers, query or cookies; the SDK
166
+ never closes it.
167
+
168
+ Raises:
169
+ ValueError: A setting is missing or out of range.
170
+ TypeError: ``http_client`` is not an ``httpx2.AsyncClient``.
171
+ """
172
+ try:
173
+ transport = AsyncTransport(
174
+ options_for(
175
+ token,
176
+ base_url=base_url,
177
+ timeout=timeout,
178
+ max_retries=max_retries,
179
+ variable=_SERVER_KEY,
180
+ ),
181
+ http_client,
182
+ )
183
+ except (ValueError, TypeError) as refused:
184
+ # An error tracker keeps the locals of each frame it is handed,
185
+ # so the frames that held the key are dropped, and this one's.
186
+ del token
187
+ raise refused.with_traceback(None) from None
188
+ self._attach(transport)
189
+
190
+ def _attach(self, transport: AsyncTransport) -> None:
191
+ self._transport = transport
192
+ self.email: AsyncEmail = AsyncEmail(transport)
193
+ self.messages: AsyncMessages = AsyncMessages(transport)
194
+
195
+ def with_options(
196
+ self, *, timeout: float | None = None, max_retries: int | None = None
197
+ ) -> Self:
198
+ """A client with another timeout or retry count, sharing these connections.
199
+
200
+ Args:
201
+ timeout: Seconds each step of a request may take.
202
+ max_retries: How many times a failed call is repeated.
203
+
204
+ Returns:
205
+ The new client. It sends over this client's connections, which
206
+ close with this client alone: close this one when both are done.
207
+ """
208
+ transport = self._transport.with_options(
209
+ timeout=timeout, max_retries=max_retries
210
+ )
211
+ changed = object.__new__(type(self))
212
+ changed._attach(transport) # noqa: SLF001 - a client of this class, new here
213
+ return changed
214
+
215
+ async def close(self) -> None:
216
+ """Closes the client's connections, unless they are a caller's own."""
217
+ await self._transport.close()
218
+
219
+ async def __aenter__(self) -> Self:
220
+ """Answers the client itself."""
221
+ return self
222
+
223
+ async def __aexit__(
224
+ self,
225
+ kind: type[BaseException] | None,
226
+ error: BaseException | None,
227
+ traceback: TracebackType | None,
228
+ ) -> None:
229
+ """Closes the client."""
230
+ await self.close()
231
+
232
+ def __repr__(self) -> str:
233
+ """Names the address, never the key."""
234
+ return f"{type(self).__name__}(base_url={self._transport.options.base_url!r})"