zapsmtp 0.1.0b1__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.
- zapsmtp/__init__.py +61 -0
- zapsmtp/_attachments.py +65 -0
- zapsmtp/_base_client.py +501 -0
- zapsmtp/_exceptions.py +148 -0
- zapsmtp/_generated/__init__.py +2 -0
- zapsmtp/_generated/async_resources.py +4661 -0
- zapsmtp/_generated/client.py +206 -0
- zapsmtp/_generated/models.py +2243 -0
- zapsmtp/_generated/params.py +155 -0
- zapsmtp/_generated/resources.py +4661 -0
- zapsmtp/_models.py +114 -0
- zapsmtp/_pagination.py +195 -0
- zapsmtp/_resource.py +24 -0
- zapsmtp/_response.py +97 -0
- zapsmtp/_retry.py +65 -0
- zapsmtp/_types.py +40 -0
- zapsmtp/_utils.py +129 -0
- zapsmtp/_version.py +2 -0
- zapsmtp/_webhooks.py +153 -0
- zapsmtp/py.typed +0 -0
- zapsmtp/types.py +11 -0
- zapsmtp-0.1.0b1.dist-info/METADATA +332 -0
- zapsmtp-0.1.0b1.dist-info/RECORD +25 -0
- zapsmtp-0.1.0b1.dist-info/WHEEL +4 -0
- zapsmtp-0.1.0b1.dist-info/licenses/LICENSE +21 -0
zapsmtp/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""The official Python library for the ZapSMTP email API.
|
|
2
|
+
|
|
3
|
+
from zapsmtp import ZapSMTP
|
|
4
|
+
|
|
5
|
+
client = ZapSMTP() # reads ZAP_API_KEY
|
|
6
|
+
client.emails.send(from_="Acme <hello@acme.com>", to="jane@example.org", subject="Hi", text="Hello")
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from . import types
|
|
10
|
+
from ._attachments import attachment, attachment_from_file
|
|
11
|
+
from ._base_client import AsyncAPIClient, SyncAPIClient
|
|
12
|
+
from ._exceptions import (
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
ConflictError,
|
|
15
|
+
ConnectionError,
|
|
16
|
+
NotFoundError,
|
|
17
|
+
PermissionError,
|
|
18
|
+
RateLimitError,
|
|
19
|
+
ServerError,
|
|
20
|
+
ValidationError,
|
|
21
|
+
WebhookVerificationError,
|
|
22
|
+
ZapSMTPError,
|
|
23
|
+
)
|
|
24
|
+
from ._generated.client import AsyncZapSMTP, ZapSMTP
|
|
25
|
+
from ._models import BaseModel
|
|
26
|
+
from ._pagination import AsyncPage, AsyncPageRequest, SyncPage
|
|
27
|
+
from ._response import APIResponse
|
|
28
|
+
from ._types import NOT_GIVEN, NotGiven
|
|
29
|
+
from ._version import __version__
|
|
30
|
+
from ._webhooks import WebhookEvent, verify_webhook, verify_webhook_events
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"APIResponse",
|
|
34
|
+
"AsyncAPIClient",
|
|
35
|
+
"AsyncPage",
|
|
36
|
+
"AsyncPageRequest",
|
|
37
|
+
"AsyncZapSMTP",
|
|
38
|
+
"AuthenticationError",
|
|
39
|
+
"BaseModel",
|
|
40
|
+
"ConflictError",
|
|
41
|
+
"ConnectionError",
|
|
42
|
+
"NOT_GIVEN",
|
|
43
|
+
"NotFoundError",
|
|
44
|
+
"NotGiven",
|
|
45
|
+
"PermissionError",
|
|
46
|
+
"RateLimitError",
|
|
47
|
+
"ServerError",
|
|
48
|
+
"SyncAPIClient",
|
|
49
|
+
"SyncPage",
|
|
50
|
+
"ValidationError",
|
|
51
|
+
"WebhookEvent",
|
|
52
|
+
"WebhookVerificationError",
|
|
53
|
+
"ZapSMTP",
|
|
54
|
+
"ZapSMTPError",
|
|
55
|
+
"__version__",
|
|
56
|
+
"attachment",
|
|
57
|
+
"attachment_from_file",
|
|
58
|
+
"types",
|
|
59
|
+
"verify_webhook",
|
|
60
|
+
"verify_webhook_events",
|
|
61
|
+
]
|
zapsmtp/_attachments.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Attachment helpers: the content is sent base64-encoded."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import os
|
|
7
|
+
from typing import Optional, Union
|
|
8
|
+
|
|
9
|
+
from ._generated.params import OutgoingAttachment
|
|
10
|
+
|
|
11
|
+
__all__ = ["attachment", "attachment_from_file"]
|
|
12
|
+
|
|
13
|
+
_TYPES = {
|
|
14
|
+
".pdf": "application/pdf",
|
|
15
|
+
".png": "image/png",
|
|
16
|
+
".jpg": "image/jpeg",
|
|
17
|
+
".jpeg": "image/jpeg",
|
|
18
|
+
".gif": "image/gif",
|
|
19
|
+
".webp": "image/webp",
|
|
20
|
+
".svg": "image/svg+xml",
|
|
21
|
+
".txt": "text/plain",
|
|
22
|
+
".csv": "text/csv",
|
|
23
|
+
".html": "text/html",
|
|
24
|
+
".ics": "text/calendar",
|
|
25
|
+
".json": "application/json",
|
|
26
|
+
".xml": "application/xml",
|
|
27
|
+
".zip": "application/zip",
|
|
28
|
+
".doc": "application/msword",
|
|
29
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
30
|
+
".xls": "application/vnd.ms-excel",
|
|
31
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def attachment(
|
|
36
|
+
filename: str,
|
|
37
|
+
content: Union[bytes, bytearray, memoryview, str],
|
|
38
|
+
content_type: Optional[str] = None,
|
|
39
|
+
content_id: Optional[str] = None,
|
|
40
|
+
) -> OutgoingAttachment:
|
|
41
|
+
"""An attachment from bytes or text (text is sent as UTF-8).
|
|
42
|
+
|
|
43
|
+
The content type follows the file name's extension unless given.
|
|
44
|
+
``content_id`` makes it inline: reference it as ``cid:<content_id>``.
|
|
45
|
+
"""
|
|
46
|
+
data = content.encode("utf-8") if isinstance(content, str) else bytes(content)
|
|
47
|
+
result: OutgoingAttachment = {"filename": filename, "content": base64.b64encode(data).decode("ascii")}
|
|
48
|
+
kind = content_type if content_type is not None else _TYPES.get(os.path.splitext(filename)[1].lower())
|
|
49
|
+
if kind:
|
|
50
|
+
result["content_type"] = kind
|
|
51
|
+
if content_id:
|
|
52
|
+
result["content_id"] = content_id
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def attachment_from_file(
|
|
57
|
+
path: Union[str, "os.PathLike[str]"],
|
|
58
|
+
filename: Optional[str] = None,
|
|
59
|
+
content_type: Optional[str] = None,
|
|
60
|
+
content_id: Optional[str] = None,
|
|
61
|
+
) -> OutgoingAttachment:
|
|
62
|
+
"""An attachment read from a file; ``filename`` defaults to the file's name."""
|
|
63
|
+
with open(path, "rb") as f:
|
|
64
|
+
data = f.read()
|
|
65
|
+
return attachment(filename if filename is not None else os.path.basename(os.fspath(path)), data, content_type, content_id)
|
zapsmtp/_base_client.py
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
"""The hand-written core of the SDK: HTTP, retries, errors, paging.
|
|
2
|
+
|
|
3
|
+
The API resources and models are generated (``_generated``) from the API
|
|
4
|
+
reference; they call :meth:`SyncAPIClient._request` and friends.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import platform
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any, Dict, Mapping, Optional, Tuple, Type, TypeVar, Union
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from ._exceptions import ConnectionError, ValidationError, ZapSMTPError, error_for
|
|
21
|
+
from ._models import BaseModel, construct
|
|
22
|
+
from ._pagination import AsyncPage, AsyncPageRequest, Query, SyncPage
|
|
23
|
+
from ._response import ResponseInfo, record
|
|
24
|
+
from ._retry import backoff, retry_after_seconds, retryable
|
|
25
|
+
from ._types import NOT_GIVEN, NotGiven, Timeout, TimeoutArg
|
|
26
|
+
from ._utils import MultipartForm, dump_json, encode_query, expand_path, loads, strip
|
|
27
|
+
from ._version import __version__
|
|
28
|
+
|
|
29
|
+
__all__ = ["AsyncAPIClient", "RequestOptions", "SyncAPIClient"]
|
|
30
|
+
|
|
31
|
+
DEFAULT_BASE_URL = "https://api.zapsmtp.com/v1"
|
|
32
|
+
DEFAULT_TIMEOUT = 60.0
|
|
33
|
+
DEFAULT_MAX_RETRIES = 2
|
|
34
|
+
|
|
35
|
+
M = TypeVar("M", bound=BaseModel)
|
|
36
|
+
SyncClientT = TypeVar("SyncClientT", bound="SyncAPIClient")
|
|
37
|
+
AsyncClientT = TypeVar("AsyncClientT", bound="AsyncAPIClient")
|
|
38
|
+
|
|
39
|
+
_SAFE_METHODS = frozenset({"GET", "PUT", "DELETE"})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _sleep(seconds: float) -> None:
|
|
43
|
+
time.sleep(seconds)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def _async_sleep(seconds: float) -> None:
|
|
47
|
+
await asyncio.sleep(seconds)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _monotonic() -> float:
|
|
51
|
+
return time.monotonic()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class RequestOptions:
|
|
56
|
+
"""Per-request settings every generated method takes as keyword arguments."""
|
|
57
|
+
|
|
58
|
+
idempotency_key: Optional[str] = None
|
|
59
|
+
extra_headers: Optional[Mapping[str, str]] = None
|
|
60
|
+
timeout: TimeoutArg = NOT_GIVEN
|
|
61
|
+
max_retries: Optional[int] = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class _Request:
|
|
66
|
+
method: str
|
|
67
|
+
path: str
|
|
68
|
+
query: Query = field(default_factory=dict)
|
|
69
|
+
body: Any = NOT_GIVEN
|
|
70
|
+
form: Optional[MultipartForm] = None
|
|
71
|
+
headers: Mapping[str, Optional[str]] = field(default_factory=dict)
|
|
72
|
+
idempotent: bool = False
|
|
73
|
+
path_params: Mapping[str, Any] = field(default_factory=dict)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class _Prepared:
|
|
78
|
+
method: str
|
|
79
|
+
url: str
|
|
80
|
+
headers: httpx.Headers
|
|
81
|
+
content: Optional[bytes]
|
|
82
|
+
form: Optional[MultipartForm]
|
|
83
|
+
timeout: Timeout
|
|
84
|
+
max_retries: int
|
|
85
|
+
safe: bool
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _AttemptTimeout(Exception):
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class _Answer:
|
|
94
|
+
status: int
|
|
95
|
+
headers: httpx.Headers
|
|
96
|
+
text: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _request(
|
|
100
|
+
method: str,
|
|
101
|
+
path: str,
|
|
102
|
+
query: Optional[Query],
|
|
103
|
+
body: Any,
|
|
104
|
+
form: Optional[MultipartForm],
|
|
105
|
+
headers: Optional[Mapping[str, Optional[str]]],
|
|
106
|
+
idempotent: bool,
|
|
107
|
+
path_params: Optional[Mapping[str, Any]],
|
|
108
|
+
) -> _Request:
|
|
109
|
+
return _Request(
|
|
110
|
+
method,
|
|
111
|
+
path,
|
|
112
|
+
strip(query or {}),
|
|
113
|
+
strip(body) if isinstance(body, dict) else body,
|
|
114
|
+
form,
|
|
115
|
+
headers or {},
|
|
116
|
+
idempotent,
|
|
117
|
+
path_params or {},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _user_agent() -> str:
|
|
122
|
+
return f"zapsmtp-python/{__version__} (python/{platform.python_version()})"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _decode(answer: _Answer) -> Any:
|
|
126
|
+
if not answer.text:
|
|
127
|
+
return None
|
|
128
|
+
try:
|
|
129
|
+
return loads(answer.text)
|
|
130
|
+
except ValueError:
|
|
131
|
+
return answer.text
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _connection_error(err: Exception, timeout: Timeout) -> ConnectionError:
|
|
135
|
+
if isinstance(err, (_AttemptTimeout, httpx.TimeoutException)):
|
|
136
|
+
return ConnectionError(f"timed out after {timeout:g} s" if timeout is not None else "timed out")
|
|
137
|
+
return ConnectionError(str(err) or type(err).__name__)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class _BaseClient:
|
|
141
|
+
api_key: str
|
|
142
|
+
base_url: str
|
|
143
|
+
timeout: Timeout
|
|
144
|
+
max_retries: int
|
|
145
|
+
|
|
146
|
+
def __init__(
|
|
147
|
+
self,
|
|
148
|
+
api_key: Optional[str],
|
|
149
|
+
*,
|
|
150
|
+
base_url: Optional[str],
|
|
151
|
+
timeout: Timeout,
|
|
152
|
+
max_retries: int,
|
|
153
|
+
default_headers: Optional[Mapping[str, str]],
|
|
154
|
+
) -> None:
|
|
155
|
+
key = api_key if api_key is not None else os.environ.get("ZAP_API_KEY")
|
|
156
|
+
if not key:
|
|
157
|
+
raise ZapSMTPError("Missing API key: pass api_key=... or set ZAP_API_KEY")
|
|
158
|
+
self.api_key = key
|
|
159
|
+
self.base_url = (base_url or os.environ.get("ZAP_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
160
|
+
self.timeout = timeout
|
|
161
|
+
self.max_retries = max_retries
|
|
162
|
+
self._default_headers: Dict[str, str] = dict(default_headers or {})
|
|
163
|
+
|
|
164
|
+
def __repr__(self) -> str:
|
|
165
|
+
return f"{type(self).__name__}(base_url={self.base_url!r})"
|
|
166
|
+
|
|
167
|
+
def _prepare(self, req: _Request, options: RequestOptions) -> _Prepared:
|
|
168
|
+
headers = httpx.Headers(
|
|
169
|
+
{"Authorization": f"Bearer {self.api_key}", "Accept": "application/json", "User-Agent": _user_agent()}
|
|
170
|
+
)
|
|
171
|
+
headers.update(self._default_headers)
|
|
172
|
+
headers.update({k: v for k, v in req.headers.items() if v is not None})
|
|
173
|
+
if req.idempotent:
|
|
174
|
+
key = options.idempotency_key
|
|
175
|
+
headers["Idempotency-Key"] = str(uuid.uuid4()) if key is None else key
|
|
176
|
+
headers.update(options.extra_headers or {}) # the caller's headers replace ours, whatever their case
|
|
177
|
+
# an empty key turns deduplication off on the server, so a retried send
|
|
178
|
+
# could go out twice: refused before anything is sent
|
|
179
|
+
key = headers.get("idempotency-key")
|
|
180
|
+
if key is not None and not key.strip():
|
|
181
|
+
raise ValidationError("idempotency_key must not be empty", code="invalid_parameter")
|
|
182
|
+
content: Optional[bytes] = None
|
|
183
|
+
if req.form is None and not isinstance(req.body, NotGiven):
|
|
184
|
+
headers["Content-Type"] = "application/json"
|
|
185
|
+
content = dump_json(req.body)
|
|
186
|
+
query = encode_query(req.query)
|
|
187
|
+
timeout = self.timeout if isinstance(options.timeout, NotGiven) else options.timeout
|
|
188
|
+
return _Prepared(
|
|
189
|
+
method=req.method,
|
|
190
|
+
url=self.base_url + expand_path(req.path, req.path_params) + (f"?{query}" if query else ""),
|
|
191
|
+
headers=headers,
|
|
192
|
+
content=content,
|
|
193
|
+
form=req.form,
|
|
194
|
+
timeout=timeout,
|
|
195
|
+
max_retries=self.max_retries if options.max_retries is None else options.max_retries,
|
|
196
|
+
safe=req.method in _SAFE_METHODS or req.idempotent,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def _build(self, client: Union[httpx.Client, httpx.AsyncClient], p: _Prepared) -> httpx.Request:
|
|
200
|
+
if p.form is not None:
|
|
201
|
+
return client.build_request(
|
|
202
|
+
p.method,
|
|
203
|
+
p.url,
|
|
204
|
+
headers=p.headers,
|
|
205
|
+
files={p.form.field: (p.form.filename, p.form.content)},
|
|
206
|
+
data=p.form.fields,
|
|
207
|
+
timeout=p.timeout,
|
|
208
|
+
)
|
|
209
|
+
return client.build_request(p.method, p.url, headers=p.headers, content=p.content, timeout=p.timeout)
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def _deadline(p: _Prepared) -> Optional[float]:
|
|
213
|
+
return None if p.timeout is None else _monotonic() + p.timeout
|
|
214
|
+
|
|
215
|
+
@staticmethod
|
|
216
|
+
def _check(deadline: Optional[float]) -> None:
|
|
217
|
+
# the timeout bounds the whole attempt, the body read included
|
|
218
|
+
if deadline is not None and _monotonic() > deadline:
|
|
219
|
+
raise _AttemptTimeout()
|
|
220
|
+
|
|
221
|
+
@classmethod
|
|
222
|
+
def _checked(cls, chunk: bytes, deadline: Optional[float]) -> bytes:
|
|
223
|
+
cls._check(deadline)
|
|
224
|
+
return chunk
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def _answer(response: httpx.Response, body: bytes) -> _Answer:
|
|
228
|
+
return _Answer(response.status_code, response.headers, body.decode(response.charset_encoding or "utf-8", "replace"))
|
|
229
|
+
|
|
230
|
+
@staticmethod
|
|
231
|
+
def _outcome(answer: _Answer) -> Tuple[Any, ResponseInfo, Optional[ZapSMTPError]]:
|
|
232
|
+
info = ResponseInfo(answer.status, answer.headers, answer.headers.get("x-request-id"))
|
|
233
|
+
parsed = _decode(answer)
|
|
234
|
+
if 200 <= answer.status < 300:
|
|
235
|
+
record(info)
|
|
236
|
+
return parsed, info, None
|
|
237
|
+
return parsed, info, error_for(answer.status, parsed, answer.headers)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class SyncAPIClient(_BaseClient):
|
|
241
|
+
"""The synchronous client core. Use :class:`zapsmtp.ZapSMTP`."""
|
|
242
|
+
|
|
243
|
+
def __init__(
|
|
244
|
+
self,
|
|
245
|
+
api_key: Optional[str] = None,
|
|
246
|
+
*,
|
|
247
|
+
base_url: Optional[str] = None,
|
|
248
|
+
timeout: Timeout = DEFAULT_TIMEOUT,
|
|
249
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
250
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
251
|
+
http_client: Optional[httpx.Client] = None,
|
|
252
|
+
) -> None:
|
|
253
|
+
super().__init__(
|
|
254
|
+
api_key, base_url=base_url, timeout=timeout, max_retries=max_retries, default_headers=default_headers
|
|
255
|
+
)
|
|
256
|
+
self._owns_client = http_client is None
|
|
257
|
+
self._client = http_client if http_client is not None else httpx.Client()
|
|
258
|
+
|
|
259
|
+
def with_options(
|
|
260
|
+
self: SyncClientT,
|
|
261
|
+
*,
|
|
262
|
+
api_key: Optional[str] = None,
|
|
263
|
+
base_url: Optional[str] = None,
|
|
264
|
+
timeout: TimeoutArg = NOT_GIVEN,
|
|
265
|
+
max_retries: Optional[int] = None,
|
|
266
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
267
|
+
) -> SyncClientT:
|
|
268
|
+
"""A copy of this client with some settings changed; it shares the connection pool."""
|
|
269
|
+
return type(self)(
|
|
270
|
+
api_key or self.api_key,
|
|
271
|
+
base_url=base_url or self.base_url,
|
|
272
|
+
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
|
|
273
|
+
max_retries=self.max_retries if max_retries is None else max_retries,
|
|
274
|
+
default_headers={**self._default_headers, **(default_headers or {})},
|
|
275
|
+
http_client=self._client,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def close(self) -> None:
|
|
279
|
+
"""Closes the connection pool, unless it was passed in as ``http_client``."""
|
|
280
|
+
if self._owns_client:
|
|
281
|
+
self._client.close()
|
|
282
|
+
|
|
283
|
+
def __enter__(self: SyncClientT) -> SyncClientT:
|
|
284
|
+
return self
|
|
285
|
+
|
|
286
|
+
def __exit__(self, *exc: object) -> None:
|
|
287
|
+
self.close()
|
|
288
|
+
|
|
289
|
+
def _attempt(self, p: _Prepared) -> _Answer:
|
|
290
|
+
deadline = self._deadline(p)
|
|
291
|
+
response = self._client.send(self._build(self._client, p), stream=True)
|
|
292
|
+
try:
|
|
293
|
+
self._check(deadline)
|
|
294
|
+
chunks = [self._checked(chunk, deadline) for chunk in response.iter_bytes()]
|
|
295
|
+
self._check(deadline) # the end of the body too, not only its chunks
|
|
296
|
+
return self._answer(response, b"".join(chunks))
|
|
297
|
+
finally:
|
|
298
|
+
response.close()
|
|
299
|
+
|
|
300
|
+
def _send(self, req: _Request, options: RequestOptions) -> Any:
|
|
301
|
+
p = self._prepare(req, options)
|
|
302
|
+
attempt = -1
|
|
303
|
+
while True:
|
|
304
|
+
attempt += 1
|
|
305
|
+
try:
|
|
306
|
+
answer = self._attempt(p)
|
|
307
|
+
except Exception as err:
|
|
308
|
+
# a timeout or a broken connection (sending, or reading the
|
|
309
|
+
# answer): the request may have reached the server, so only a
|
|
310
|
+
# request that is safe to repeat is sent again
|
|
311
|
+
if attempt < p.max_retries and p.safe:
|
|
312
|
+
_sleep(backoff(attempt, None))
|
|
313
|
+
continue
|
|
314
|
+
raise _connection_error(err, p.timeout) from err
|
|
315
|
+
parsed, info, error = self._outcome(answer)
|
|
316
|
+
if error is None:
|
|
317
|
+
return parsed
|
|
318
|
+
if attempt < p.max_retries and retryable(info.status, error.code, p.safe):
|
|
319
|
+
_sleep(backoff(attempt, retry_after_seconds(info.headers)))
|
|
320
|
+
continue
|
|
321
|
+
raise error
|
|
322
|
+
|
|
323
|
+
def _request(
|
|
324
|
+
self,
|
|
325
|
+
method: str,
|
|
326
|
+
path: str,
|
|
327
|
+
*,
|
|
328
|
+
cast: Type[M],
|
|
329
|
+
options: RequestOptions,
|
|
330
|
+
path_params: Optional[Mapping[str, Any]] = None,
|
|
331
|
+
query: Optional[Query] = None,
|
|
332
|
+
body: Any = NOT_GIVEN,
|
|
333
|
+
form: Optional[MultipartForm] = None,
|
|
334
|
+
headers: Optional[Mapping[str, Optional[str]]] = None,
|
|
335
|
+
idempotent: bool = False,
|
|
336
|
+
) -> M:
|
|
337
|
+
result: M = construct(cast, self._send(_request(method, path, query, body, form, headers, idempotent, path_params), options))
|
|
338
|
+
return result
|
|
339
|
+
|
|
340
|
+
def _request_empty(
|
|
341
|
+
self,
|
|
342
|
+
method: str,
|
|
343
|
+
path: str,
|
|
344
|
+
*,
|
|
345
|
+
options: RequestOptions,
|
|
346
|
+
path_params: Optional[Mapping[str, Any]] = None,
|
|
347
|
+
query: Optional[Query] = None,
|
|
348
|
+
body: Any = NOT_GIVEN,
|
|
349
|
+
headers: Optional[Mapping[str, Optional[str]]] = None,
|
|
350
|
+
idempotent: bool = False,
|
|
351
|
+
) -> None:
|
|
352
|
+
self._send(_request(method, path, query, body, None, headers, idempotent, path_params), options)
|
|
353
|
+
|
|
354
|
+
def _request_page(
|
|
355
|
+
self,
|
|
356
|
+
method: str,
|
|
357
|
+
path: str,
|
|
358
|
+
*,
|
|
359
|
+
cast: Type[M],
|
|
360
|
+
paging: Optional[str],
|
|
361
|
+
options: RequestOptions,
|
|
362
|
+
path_params: Optional[Mapping[str, Any]] = None,
|
|
363
|
+
query: Optional[Query] = None,
|
|
364
|
+
) -> SyncPage[M]:
|
|
365
|
+
def load(q: Query) -> SyncPage[M]:
|
|
366
|
+
req = _request(method, path, q, NOT_GIVEN, None, None, False, path_params)
|
|
367
|
+
return SyncPage(self._send(req, options), cast, req.query, paging, load)
|
|
368
|
+
|
|
369
|
+
return load(strip(query or {}))
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class AsyncAPIClient(_BaseClient):
|
|
373
|
+
"""The asyncio client core. Use :class:`zapsmtp.AsyncZapSMTP`."""
|
|
374
|
+
|
|
375
|
+
def __init__(
|
|
376
|
+
self,
|
|
377
|
+
api_key: Optional[str] = None,
|
|
378
|
+
*,
|
|
379
|
+
base_url: Optional[str] = None,
|
|
380
|
+
timeout: Timeout = DEFAULT_TIMEOUT,
|
|
381
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
382
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
383
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
384
|
+
) -> None:
|
|
385
|
+
super().__init__(
|
|
386
|
+
api_key, base_url=base_url, timeout=timeout, max_retries=max_retries, default_headers=default_headers
|
|
387
|
+
)
|
|
388
|
+
self._owns_client = http_client is None
|
|
389
|
+
self._client = http_client if http_client is not None else httpx.AsyncClient()
|
|
390
|
+
|
|
391
|
+
def with_options(
|
|
392
|
+
self: AsyncClientT,
|
|
393
|
+
*,
|
|
394
|
+
api_key: Optional[str] = None,
|
|
395
|
+
base_url: Optional[str] = None,
|
|
396
|
+
timeout: TimeoutArg = NOT_GIVEN,
|
|
397
|
+
max_retries: Optional[int] = None,
|
|
398
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
399
|
+
) -> AsyncClientT:
|
|
400
|
+
"""A copy of this client with some settings changed; it shares the connection pool."""
|
|
401
|
+
return type(self)(
|
|
402
|
+
api_key or self.api_key,
|
|
403
|
+
base_url=base_url or self.base_url,
|
|
404
|
+
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
|
|
405
|
+
max_retries=self.max_retries if max_retries is None else max_retries,
|
|
406
|
+
default_headers={**self._default_headers, **(default_headers or {})},
|
|
407
|
+
http_client=self._client,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
async def close(self) -> None:
|
|
411
|
+
"""Closes the connection pool, unless it was passed in as ``http_client``."""
|
|
412
|
+
if self._owns_client:
|
|
413
|
+
await self._client.aclose()
|
|
414
|
+
|
|
415
|
+
async def __aenter__(self: AsyncClientT) -> AsyncClientT:
|
|
416
|
+
return self
|
|
417
|
+
|
|
418
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
419
|
+
await self.close()
|
|
420
|
+
|
|
421
|
+
async def _attempt(self, p: _Prepared) -> _Answer:
|
|
422
|
+
deadline = self._deadline(p)
|
|
423
|
+
response = await self._client.send(self._build(self._client, p), stream=True)
|
|
424
|
+
try:
|
|
425
|
+
self._check(deadline)
|
|
426
|
+
chunks = [self._checked(chunk, deadline) async for chunk in response.aiter_bytes()]
|
|
427
|
+
self._check(deadline) # the end of the body too, not only its chunks
|
|
428
|
+
return self._answer(response, b"".join(chunks))
|
|
429
|
+
finally:
|
|
430
|
+
await response.aclose()
|
|
431
|
+
|
|
432
|
+
async def _send(self, req: _Request, options: RequestOptions) -> Any:
|
|
433
|
+
p = self._prepare(req, options)
|
|
434
|
+
attempt = -1
|
|
435
|
+
while True:
|
|
436
|
+
attempt += 1
|
|
437
|
+
try:
|
|
438
|
+
answer = await self._attempt(p)
|
|
439
|
+
except Exception as err:
|
|
440
|
+
# see SyncAPIClient._send; cancellation (asyncio.CancelledError)
|
|
441
|
+
# is not an Exception and goes straight through
|
|
442
|
+
if attempt < p.max_retries and p.safe:
|
|
443
|
+
await _async_sleep(backoff(attempt, None))
|
|
444
|
+
continue
|
|
445
|
+
raise _connection_error(err, p.timeout) from err
|
|
446
|
+
parsed, info, error = self._outcome(answer)
|
|
447
|
+
if error is None:
|
|
448
|
+
return parsed
|
|
449
|
+
if attempt < p.max_retries and retryable(info.status, error.code, p.safe):
|
|
450
|
+
await _async_sleep(backoff(attempt, retry_after_seconds(info.headers)))
|
|
451
|
+
continue
|
|
452
|
+
raise error
|
|
453
|
+
|
|
454
|
+
async def _request(
|
|
455
|
+
self,
|
|
456
|
+
method: str,
|
|
457
|
+
path: str,
|
|
458
|
+
*,
|
|
459
|
+
cast: Type[M],
|
|
460
|
+
options: RequestOptions,
|
|
461
|
+
path_params: Optional[Mapping[str, Any]] = None,
|
|
462
|
+
query: Optional[Query] = None,
|
|
463
|
+
body: Any = NOT_GIVEN,
|
|
464
|
+
form: Optional[MultipartForm] = None,
|
|
465
|
+
headers: Optional[Mapping[str, Optional[str]]] = None,
|
|
466
|
+
idempotent: bool = False,
|
|
467
|
+
) -> M:
|
|
468
|
+
result: M = construct(cast, await self._send(_request(method, path, query, body, form, headers, idempotent, path_params), options))
|
|
469
|
+
return result
|
|
470
|
+
|
|
471
|
+
async def _request_empty(
|
|
472
|
+
self,
|
|
473
|
+
method: str,
|
|
474
|
+
path: str,
|
|
475
|
+
*,
|
|
476
|
+
options: RequestOptions,
|
|
477
|
+
path_params: Optional[Mapping[str, Any]] = None,
|
|
478
|
+
query: Optional[Query] = None,
|
|
479
|
+
body: Any = NOT_GIVEN,
|
|
480
|
+
headers: Optional[Mapping[str, Optional[str]]] = None,
|
|
481
|
+
idempotent: bool = False,
|
|
482
|
+
) -> None:
|
|
483
|
+
await self._send(_request(method, path, query, body, None, headers, idempotent, path_params), options)
|
|
484
|
+
|
|
485
|
+
def _request_page(
|
|
486
|
+
self,
|
|
487
|
+
method: str,
|
|
488
|
+
path: str,
|
|
489
|
+
*,
|
|
490
|
+
cast: Type[M],
|
|
491
|
+
paging: Optional[str],
|
|
492
|
+
options: RequestOptions,
|
|
493
|
+
path_params: Optional[Mapping[str, Any]] = None,
|
|
494
|
+
query: Optional[Query] = None,
|
|
495
|
+
) -> AsyncPageRequest[M]:
|
|
496
|
+
async def load(q: Query) -> AsyncPage[M]:
|
|
497
|
+
req = _request(method, path, q, NOT_GIVEN, None, None, False, path_params)
|
|
498
|
+
return AsyncPage(await self._send(req, options), cast, req.query, paging, load)
|
|
499
|
+
|
|
500
|
+
first = strip(query or {})
|
|
501
|
+
return AsyncPageRequest(lambda: load(first))
|