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.
- sendora-0.1.0/.gitignore +11 -0
- sendora-0.1.0/CHANGELOG.md +8 -0
- sendora-0.1.0/LICENSE +21 -0
- sendora-0.1.0/PKG-INFO +36 -0
- sendora-0.1.0/README.md +14 -0
- sendora-0.1.0/pyproject.toml +127 -0
- sendora-0.1.0/src/sendora/__init__.py +52 -0
- sendora-0.1.0/src/sendora/_answers.py +304 -0
- sendora-0.1.0/src/sendora/_client.py +234 -0
- sendora-0.1.0/src/sendora/_errors.py +209 -0
- sendora-0.1.0/src/sendora/_json.py +40 -0
- sendora-0.1.0/src/sendora/_model.py +67 -0
- sendora-0.1.0/src/sendora/_options.py +141 -0
- sendora-0.1.0/src/sendora/_pages.py +60 -0
- sendora-0.1.0/src/sendora/_retry.py +49 -0
- sendora-0.1.0/src/sendora/_transport.py +405 -0
- sendora-0.1.0/src/sendora/_version.py +3 -0
- sendora-0.1.0/src/sendora/_wire.py +266 -0
- sendora-0.1.0/src/sendora/py.typed +0 -0
- sendora-0.1.0/src/sendora/resources/__init__.py +6 -0
- sendora-0.1.0/src/sendora/resources/email.py +211 -0
- sendora-0.1.0/src/sendora/resources/messages.py +177 -0
- sendora-0.1.0/src/sendora/types.py +519 -0
sendora-0.1.0/.gitignore
ADDED
|
@@ -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.
|
sendora-0.1.0/README.md
ADDED
|
@@ -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
|