sendora 0.1.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.
@@ -0,0 +1,11 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ .hypothesis/
8
+ .coverage
9
+ htmlcov/
10
+ mutants/
11
+ dist/
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - `Sendora` and `AsyncSendora` for a server key, with `email.send`,
6
+ `email.send_batch`, `messages.get`, `messages.search` and
7
+ `messages.search_all`; their settings from the environment, the errors,
8
+ and the retries the TypeScript SDK makes.
sendora-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sendora
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
sendora-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.5
2
+ Name: sendora
3
+ Version: 0.1.0
4
+ Summary: The Python client for Sendora's email API.
5
+ Project-URL: Homepage, https://sendora.se/docs
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: api,email,sdk,sendora,transactional email
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Communications :: Email
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: httpx2<3,>=2.3
21
+ Description-Content-Type: text/markdown
22
+
23
+ # sendora
24
+
25
+ The Python client for [Sendora](https://sendora.se)'s email API.
26
+
27
+ This package is being built: it covers the API in the releases before
28
+ 1.0.0, and its full guide arrives with 1.0.0. Until then, the TypeScript
29
+ SDK and the API reference at https://sendora.se/docs describe what it
30
+ wraps.
31
+
32
+ ```
33
+ pip install sendora
34
+ ```
35
+
36
+ It needs Python 3.11 or newer and has one dependency, httpx2.
@@ -0,0 +1,14 @@
1
+ # sendora
2
+
3
+ The Python client for [Sendora](https://sendora.se)'s email API.
4
+
5
+ This package is being built: it covers the API in the releases before
6
+ 1.0.0, and its full guide arrives with 1.0.0. Until then, the TypeScript
7
+ SDK and the API reference at https://sendora.se/docs describe what it
8
+ wraps.
9
+
10
+ ```
11
+ pip install sendora
12
+ ```
13
+
14
+ It needs Python 3.11 or newer and has one dependency, httpx2.
@@ -0,0 +1,127 @@
1
+ [project]
2
+ name = "sendora"
3
+ dynamic = ["version"]
4
+ description = "The Python client for Sendora's email API."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.11"
9
+ dependencies = ["httpx2>=2.3,<3"]
10
+ keywords = ["email", "transactional email", "api", "sdk", "sendora"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Intended Audience :: Developers",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3 :: Only",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Programming Language :: Python :: 3.14",
20
+ "Topic :: Communications :: Email",
21
+ "Typing :: Typed",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://sendora.se/docs"
26
+
27
+ [build-system]
28
+ requires = ["hatchling==1.32.4"]
29
+ build-backend = "hatchling.build"
30
+
31
+ [tool.hatch.version]
32
+ path = "src/sendora/_version.py"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/sendora"]
36
+
37
+ [tool.hatch.build.targets.sdist]
38
+ only-include = ["src", "README.md", "CHANGELOG.md", "LICENSE", "pyproject.toml"]
39
+
40
+ [dependency-groups]
41
+ test = [
42
+ "anyio==4.15.1",
43
+ "coverage==7.16.1",
44
+ "hypothesis==6.168.1",
45
+ "pytest==9.1.1",
46
+ ]
47
+ dev = [
48
+ "mutmut==3.8.0",
49
+ "mypy==2.3.1",
50
+ "pyright[nodejs]==1.1.414",
51
+ "ruff==0.16.9",
52
+ ]
53
+
54
+ [tool.uv]
55
+ default-groups = ["dev", "test"]
56
+
57
+ [tool.ruff]
58
+ target-version = "py311"
59
+ line-length = 88
60
+ src = ["src", "tests"]
61
+
62
+ [tool.ruff.lint]
63
+ select = ["ALL"]
64
+ ignore = [
65
+ # The formatter owns these.
66
+ "COM812",
67
+ "ISC001",
68
+ # No copyright header per file; the licence is the package's.
69
+ "CPY001",
70
+ ]
71
+
72
+ [tool.ruff.lint.per-file-ignores]
73
+ "tests/**" = ["S101", "S105", "S106", "PLR2004", "D", "SLF001", "FBT001", "ANN401", "E501"]
74
+ "scripts/**" = ["T201", "D", "INP001"]
75
+
76
+ [tool.ruff.lint.pydocstyle]
77
+ convention = "google"
78
+
79
+ [tool.ruff.lint.flake8-tidy-imports.banned-api]
80
+ "smtplib".msg = "The SDK sends mail only through the API."
81
+ "__future__.annotations".msg = "The decoder reads annotations at run time."
82
+
83
+ [tool.pyright]
84
+ include = ["src", "tests", "scripts"]
85
+ typeCheckingMode = "strict"
86
+ pythonVersion = "3.11"
87
+ # Each checker honours its own ignore comments and refuses one it no longer
88
+ # needs, so a line that must not type-check fails the check when it does.
89
+ enableTypeIgnoreComments = false
90
+ reportUnnecessaryTypeIgnoreComment = "error"
91
+
92
+ [tool.mypy]
93
+ strict = true
94
+ python_version = "3.11"
95
+ files = ["src", "tests", "scripts"]
96
+
97
+ [tool.pytest]
98
+ testpaths = ["tests/unit"]
99
+ strict = true
100
+ addopts = ["-p", "no:cacheprovider"]
101
+
102
+ [tool.coverage.run]
103
+ branch = true
104
+ source = ["sendora"]
105
+
106
+ [tool.coverage.report]
107
+ fail_under = 100
108
+ show_missing = true
109
+ exclude_also = ["if TYPE_CHECKING:", "@overload"]
110
+
111
+ [tool.mutmut]
112
+ source_paths = ["src/sendora"]
113
+ only_mutate = [
114
+ "src/sendora/_answers.py",
115
+ "src/sendora/_errors.py",
116
+ "src/sendora/_json.py",
117
+ "src/sendora/_pages.py",
118
+ "src/sendora/_retry.py",
119
+ "src/sendora/_wire.py",
120
+ ]
121
+ # The package-shape and release tests read files mutmut does not copy, and
122
+ # exercise nothing it mutates.
123
+ pytest_add_cli_args_test_selection = [
124
+ "tests/unit",
125
+ "--ignore=tests/unit/test_package.py",
126
+ "--ignore=tests/unit/test_check_release.py",
127
+ ]
@@ -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
+ ]
@@ -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