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 +52 -0
- sendora/_answers.py +304 -0
- sendora/_client.py +234 -0
- sendora/_errors.py +209 -0
- sendora/_json.py +40 -0
- sendora/_model.py +67 -0
- sendora/_options.py +141 -0
- sendora/_pages.py +60 -0
- sendora/_retry.py +49 -0
- sendora/_transport.py +405 -0
- sendora/_version.py +3 -0
- sendora/_wire.py +266 -0
- sendora/py.typed +0 -0
- sendora/resources/__init__.py +6 -0
- sendora/resources/email.py +211 -0
- sendora/resources/messages.py +177 -0
- sendora/types.py +519 -0
- sendora-0.1.0.dist-info/METADATA +36 -0
- sendora-0.1.0.dist-info/RECORD +21 -0
- sendora-0.1.0.dist-info/WHEEL +4 -0
- sendora-0.1.0.dist-info/licenses/LICENSE +21 -0
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})"
|