sendclipper 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.
- sendclipper/__init__.py +65 -0
- sendclipper/_client.py +81 -0
- sendclipper/_core.py +454 -0
- sendclipper/_errors.py +118 -0
- sendclipper/_version.py +3 -0
- sendclipper/_webhooks.py +147 -0
- sendclipper/py.typed +0 -0
- sendclipper/resources/__init__.py +1 -0
- sendclipper/resources/_base.py +15 -0
- sendclipper/resources/audience.py +119 -0
- sendclipper/resources/domains.py +47 -0
- sendclipper/resources/emails.py +68 -0
- sendclipper/resources/mailboxes.py +89 -0
- sendclipper/resources/sandboxes.py +137 -0
- sendclipper/resources/templates.py +115 -0
- sendclipper/resources/webhooks.py +105 -0
- sendclipper/resources/workspace.py +30 -0
- sendclipper/types.py +875 -0
- sendclipper-0.1.0.dist-info/METADATA +233 -0
- sendclipper-0.1.0.dist-info/RECORD +22 -0
- sendclipper-0.1.0.dist-info/WHEEL +4 -0
- sendclipper-0.1.0.dist-info/licenses/LICENSE +21 -0
sendclipper/__init__.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The official Python SDK for the SendClipper email API.
|
|
3
|
+
|
|
4
|
+
::
|
|
5
|
+
|
|
6
|
+
from sendclipper import SendClipper
|
|
7
|
+
|
|
8
|
+
sc = SendClipper("sc_live_...")
|
|
9
|
+
data, error = sc.emails.send({
|
|
10
|
+
"from": "hello@send.acme.io",
|
|
11
|
+
"to": "ada@lumen.io",
|
|
12
|
+
"subject": "Your receipt",
|
|
13
|
+
"html": "<p>Thanks for your order.</p>",
|
|
14
|
+
})
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from ._client import SendClipper
|
|
18
|
+
from ._core import (
|
|
19
|
+
DEFAULT_BASE_URL,
|
|
20
|
+
DownloadedFile,
|
|
21
|
+
ErrorResponse,
|
|
22
|
+
RequestOptions,
|
|
23
|
+
Result,
|
|
24
|
+
Transport,
|
|
25
|
+
TransportRequest,
|
|
26
|
+
TransportResponse,
|
|
27
|
+
urllib_transport,
|
|
28
|
+
)
|
|
29
|
+
from ._errors import (
|
|
30
|
+
SendClipperApiError,
|
|
31
|
+
SendClipperConnectionError,
|
|
32
|
+
SendClipperError,
|
|
33
|
+
WebhookVerificationError,
|
|
34
|
+
)
|
|
35
|
+
from ._version import __version__
|
|
36
|
+
from ._webhooks import (
|
|
37
|
+
WEBHOOK_TOLERANCE_SECONDS,
|
|
38
|
+
parse_signature_header,
|
|
39
|
+
sign_payload,
|
|
40
|
+
signature_header,
|
|
41
|
+
verify_webhook,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"SendClipper",
|
|
46
|
+
"DEFAULT_BASE_URL",
|
|
47
|
+
"DownloadedFile",
|
|
48
|
+
"ErrorResponse",
|
|
49
|
+
"RequestOptions",
|
|
50
|
+
"Result",
|
|
51
|
+
"Transport",
|
|
52
|
+
"TransportRequest",
|
|
53
|
+
"TransportResponse",
|
|
54
|
+
"urllib_transport",
|
|
55
|
+
"SendClipperError",
|
|
56
|
+
"SendClipperApiError",
|
|
57
|
+
"SendClipperConnectionError",
|
|
58
|
+
"WebhookVerificationError",
|
|
59
|
+
"verify_webhook",
|
|
60
|
+
"sign_payload",
|
|
61
|
+
"signature_header",
|
|
62
|
+
"parse_signature_header",
|
|
63
|
+
"WEBHOOK_TOLERANCE_SECONDS",
|
|
64
|
+
"__version__",
|
|
65
|
+
]
|
sendclipper/_client.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Mapping, Optional
|
|
4
|
+
|
|
5
|
+
from ._core import HttpClient, Transport
|
|
6
|
+
from .resources.audience import Audiences, Contacts, Suppressions
|
|
7
|
+
from .resources.domains import Domains
|
|
8
|
+
from .resources.emails import Batch, Emails
|
|
9
|
+
from .resources.mailboxes import Mailboxes
|
|
10
|
+
from .resources.sandboxes import Sandboxes
|
|
11
|
+
from .resources.templates import Templates
|
|
12
|
+
from .resources.webhooks import Webhooks
|
|
13
|
+
from .resources.workspace import ApiKeys, Automations, WorkspaceResource
|
|
14
|
+
|
|
15
|
+
__all__ = ["SendClipper"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SendClipper:
|
|
19
|
+
"""
|
|
20
|
+
The SendClipper API client.
|
|
21
|
+
|
|
22
|
+
::
|
|
23
|
+
|
|
24
|
+
from sendclipper import SendClipper
|
|
25
|
+
|
|
26
|
+
sc = SendClipper("sc_live_...") # or SendClipper(): reads SENDCLIPPER_API_KEY
|
|
27
|
+
data, error = sc.emails.send({
|
|
28
|
+
"from": "hello@acme.io",
|
|
29
|
+
"to": "ada@lumen.io",
|
|
30
|
+
"subject": "Hi",
|
|
31
|
+
"html": "<p>Hi</p>",
|
|
32
|
+
})
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
api_key: Optional[str] = None,
|
|
38
|
+
*,
|
|
39
|
+
base_url: Optional[str] = None,
|
|
40
|
+
timeout: float = 30.0,
|
|
41
|
+
max_retries: int = 2,
|
|
42
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
43
|
+
transport: Optional[Transport] = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""
|
|
46
|
+
:param api_key: A ``sc_live_...`` key. Defaults to ``SENDCLIPPER_API_KEY``.
|
|
47
|
+
:param base_url: Defaults to ``SENDCLIPPER_BASE_URL``, then ``https://sendclipper.com``. No ``/v1``.
|
|
48
|
+
:param timeout: Per attempt, in seconds.
|
|
49
|
+
:param max_retries: Retries after a network error, a 429 or a 5xx. A write is
|
|
50
|
+
only retried with an ``Idempotency-Key``, which the SDK adds to every JSON
|
|
51
|
+
write, so a retried send can never go out twice.
|
|
52
|
+
:param default_headers: Sent on every request.
|
|
53
|
+
:param transport: Your own HTTP sender (a proxy, a test double). Defaults to the standard library.
|
|
54
|
+
"""
|
|
55
|
+
http = HttpClient(
|
|
56
|
+
api_key,
|
|
57
|
+
base_url=base_url,
|
|
58
|
+
timeout=timeout,
|
|
59
|
+
max_retries=max_retries,
|
|
60
|
+
default_headers=default_headers,
|
|
61
|
+
transport=transport,
|
|
62
|
+
)
|
|
63
|
+
self._http = http
|
|
64
|
+
#: The API origin requests go to, without ``/v1``.
|
|
65
|
+
self.base_url = http.base_url
|
|
66
|
+
self.emails = Emails(http)
|
|
67
|
+
self.batch = Batch(http)
|
|
68
|
+
self.domains = Domains(http)
|
|
69
|
+
self.templates = Templates(http)
|
|
70
|
+
self.sandboxes = Sandboxes(http)
|
|
71
|
+
self.webhooks = Webhooks(http)
|
|
72
|
+
self.contacts = Contacts(http)
|
|
73
|
+
self.audiences = Audiences(http)
|
|
74
|
+
self.suppressions = Suppressions(http)
|
|
75
|
+
self.mailboxes = Mailboxes(http)
|
|
76
|
+
self.automations = Automations(http)
|
|
77
|
+
self.workspace = WorkspaceResource(http)
|
|
78
|
+
self.api_keys = ApiKeys(http)
|
|
79
|
+
|
|
80
|
+
def __repr__(self) -> str:
|
|
81
|
+
return f"SendClipper(base_url={self.base_url!r})"
|
sendclipper/_core.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import random
|
|
6
|
+
import re
|
|
7
|
+
import socket
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
import uuid
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from email.utils import parsedate_to_datetime
|
|
15
|
+
from typing import (
|
|
16
|
+
Any,
|
|
17
|
+
Callable,
|
|
18
|
+
Dict,
|
|
19
|
+
Generic,
|
|
20
|
+
Iterator,
|
|
21
|
+
List,
|
|
22
|
+
Mapping,
|
|
23
|
+
Optional,
|
|
24
|
+
Tuple,
|
|
25
|
+
TypedDict,
|
|
26
|
+
TypeVar,
|
|
27
|
+
Union,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from ._errors import SendClipperApiError, SendClipperConnectionError, to_api_error
|
|
31
|
+
from ._version import __version__
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"DEFAULT_BASE_URL",
|
|
35
|
+
"DownloadedFile",
|
|
36
|
+
"ErrorResponse",
|
|
37
|
+
"Query",
|
|
38
|
+
"RequestOptions",
|
|
39
|
+
"Result",
|
|
40
|
+
"Transport",
|
|
41
|
+
"TransportRequest",
|
|
42
|
+
"TransportResponse",
|
|
43
|
+
"urllib_transport",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
DEFAULT_BASE_URL = "https://sendclipper.com"
|
|
47
|
+
|
|
48
|
+
T = TypeVar("T")
|
|
49
|
+
|
|
50
|
+
Query = Mapping[str, Union[str, int, float, bool, None]]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RequestOptions(TypedDict, total=False):
|
|
54
|
+
"""Keyword arguments every API method takes after its own parameters."""
|
|
55
|
+
|
|
56
|
+
#: Your own key, reused when YOU retry the same logical call later.
|
|
57
|
+
idempotency_key: str
|
|
58
|
+
#: Override the client's per-attempt timeout (seconds) for this call.
|
|
59
|
+
timeout: float
|
|
60
|
+
#: Override the client's retry count for this call.
|
|
61
|
+
max_retries: int
|
|
62
|
+
#: Extra headers for this call.
|
|
63
|
+
headers: Mapping[str, str]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class ErrorResponse:
|
|
68
|
+
"""What an API refusal or a network failure looks like."""
|
|
69
|
+
|
|
70
|
+
#: SendClipper's machine-readable code: ``domain_not_verified``, ``validation_failed``,
|
|
71
|
+
#: ``rate_limited``, ``network_error``, ...
|
|
72
|
+
name: str
|
|
73
|
+
#: A sentence meant for a person.
|
|
74
|
+
message: str
|
|
75
|
+
#: The HTTP status, or ``None`` when no answer arrived (network, timeout).
|
|
76
|
+
status_code: Optional[int]
|
|
77
|
+
#: Extra, code-specific facts (e.g. the missing template tags).
|
|
78
|
+
detail: Any = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Result(Generic[T]):
|
|
82
|
+
"""
|
|
83
|
+
Every API method returns one. Exactly one of ``data`` and ``error`` is set.
|
|
84
|
+
|
|
85
|
+
It unpacks to ``(data, error)``::
|
|
86
|
+
|
|
87
|
+
data, error = sc.emails.send({...})
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
__slots__ = ("data", "error", "headers")
|
|
91
|
+
|
|
92
|
+
def __init__(self, data: Optional[T], error: Optional[ErrorResponse], headers: Optional[Dict[str, str]]) -> None:
|
|
93
|
+
self.data = data
|
|
94
|
+
self.error = error
|
|
95
|
+
#: The response headers (lower-case names), or ``None`` when no answer arrived.
|
|
96
|
+
self.headers = headers
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def ok(self) -> bool:
|
|
100
|
+
return self.error is None
|
|
101
|
+
|
|
102
|
+
def unwrap(self) -> T:
|
|
103
|
+
"""Return ``data``, or raise the error as ``SendClipperApiError``."""
|
|
104
|
+
if self.error is not None:
|
|
105
|
+
e = self.error
|
|
106
|
+
raise SendClipperApiError(e.status_code or 0, e.name, e.message, e.detail, self.headers)
|
|
107
|
+
return self.data # type: ignore[return-value]
|
|
108
|
+
|
|
109
|
+
def __iter__(self) -> Iterator[Any]:
|
|
110
|
+
yield self.data
|
|
111
|
+
yield self.error
|
|
112
|
+
|
|
113
|
+
def __repr__(self) -> str:
|
|
114
|
+
if self.error is not None:
|
|
115
|
+
return f"Result(error={self.error!r})"
|
|
116
|
+
return f"Result(data={self.data!r})"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class DownloadedFile:
|
|
121
|
+
"""What a download returns."""
|
|
122
|
+
|
|
123
|
+
content: bytes
|
|
124
|
+
content_type: Optional[str]
|
|
125
|
+
filename: Optional[str]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class TransportRequest:
|
|
130
|
+
method: str
|
|
131
|
+
url: str
|
|
132
|
+
headers: Dict[str, str]
|
|
133
|
+
body: Optional[bytes]
|
|
134
|
+
#: Seconds.
|
|
135
|
+
timeout: float
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class TransportResponse:
|
|
140
|
+
status: int
|
|
141
|
+
#: Lower-case header names.
|
|
142
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
143
|
+
body: bytes = b""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
#: Sends one request. Raise ``TimeoutError`` for a timeout and any other
|
|
147
|
+
#: exception when no answer arrived; return the response for every status.
|
|
148
|
+
Transport = Callable[[TransportRequest], TransportResponse]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class _SameHostRedirects(urllib.request.HTTPRedirectHandler):
|
|
152
|
+
"""Follow redirects, but never carry the API key to another host."""
|
|
153
|
+
|
|
154
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
|
155
|
+
new = super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
156
|
+
if new is not None and urllib.parse.urlsplit(newurl).netloc != urllib.parse.urlsplit(req.full_url).netloc:
|
|
157
|
+
new.remove_header("Authorization")
|
|
158
|
+
return new
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
_opener = urllib.request.build_opener(_SameHostRedirects())
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def urllib_transport(req: TransportRequest) -> TransportResponse:
|
|
165
|
+
"""The default transport: the standard library, no dependencies."""
|
|
166
|
+
r = urllib.request.Request(req.url, data=req.body, method=req.method, headers=req.headers)
|
|
167
|
+
try:
|
|
168
|
+
with _opener.open(r, timeout=req.timeout) as resp:
|
|
169
|
+
return TransportResponse(resp.status, _lower_headers(resp.headers.items()), resp.read())
|
|
170
|
+
except urllib.error.HTTPError as e:
|
|
171
|
+
try:
|
|
172
|
+
body = e.read()
|
|
173
|
+
finally:
|
|
174
|
+
e.close()
|
|
175
|
+
return TransportResponse(e.code, _lower_headers(e.headers.items() if e.headers else []), body)
|
|
176
|
+
except urllib.error.URLError as e:
|
|
177
|
+
if isinstance(e.reason, (socket.timeout, TimeoutError)):
|
|
178
|
+
raise TimeoutError(str(e.reason)) from e
|
|
179
|
+
raise ConnectionError(str(e.reason)) from e
|
|
180
|
+
except socket.timeout as e: # a read that stalled after the headers
|
|
181
|
+
raise TimeoutError(str(e)) from e
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
_RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504}
|
|
185
|
+
_MAX_RETRY_AFTER_SECONDS = 60.0
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class HttpClient:
|
|
189
|
+
def __init__(
|
|
190
|
+
self,
|
|
191
|
+
api_key: Optional[str] = None,
|
|
192
|
+
*,
|
|
193
|
+
base_url: Optional[str] = None,
|
|
194
|
+
timeout: float = 30.0,
|
|
195
|
+
max_retries: int = 2,
|
|
196
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
197
|
+
transport: Optional[Transport] = None,
|
|
198
|
+
) -> None:
|
|
199
|
+
self.api_key = api_key if api_key is not None else os.environ.get("SENDCLIPPER_API_KEY")
|
|
200
|
+
raw = base_url or os.environ.get("SENDCLIPPER_BASE_URL") or DEFAULT_BASE_URL
|
|
201
|
+
self.base_url = re.sub(r"/v1$", "", raw.rstrip("/"))
|
|
202
|
+
self.timeout = timeout
|
|
203
|
+
self.max_retries = max(0, max_retries)
|
|
204
|
+
self.default_headers = dict(default_headers or {})
|
|
205
|
+
self.transport: Transport = transport or urllib_transport
|
|
206
|
+
|
|
207
|
+
def call(
|
|
208
|
+
self,
|
|
209
|
+
method: str,
|
|
210
|
+
path: str,
|
|
211
|
+
*,
|
|
212
|
+
query: Optional[Query] = None,
|
|
213
|
+
body: Any = None,
|
|
214
|
+
form: Optional[Tuple[bytes, str]] = None,
|
|
215
|
+
as_: str = "json",
|
|
216
|
+
bearer: Optional[str] = None,
|
|
217
|
+
idempotency_key: Optional[str] = None,
|
|
218
|
+
timeout: Optional[float] = None,
|
|
219
|
+
max_retries: Optional[int] = None,
|
|
220
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
221
|
+
) -> Result[Any]:
|
|
222
|
+
"""The public face of every API method: never raises for an API refusal or a network failure."""
|
|
223
|
+
try:
|
|
224
|
+
data, response_headers = self.request(
|
|
225
|
+
method,
|
|
226
|
+
path,
|
|
227
|
+
query=query,
|
|
228
|
+
body=body,
|
|
229
|
+
form=form,
|
|
230
|
+
as_=as_,
|
|
231
|
+
bearer=bearer,
|
|
232
|
+
idempotency_key=idempotency_key,
|
|
233
|
+
timeout=timeout,
|
|
234
|
+
max_retries=max_retries,
|
|
235
|
+
headers=headers,
|
|
236
|
+
)
|
|
237
|
+
return Result(data, None, response_headers)
|
|
238
|
+
except SendClipperApiError as err:
|
|
239
|
+
return Result(
|
|
240
|
+
None,
|
|
241
|
+
ErrorResponse(err.code, err.message, err.status, err.detail),
|
|
242
|
+
dict(err.headers) if err.headers else None,
|
|
243
|
+
)
|
|
244
|
+
except SendClipperConnectionError as err:
|
|
245
|
+
return Result(None, ErrorResponse("network_error", err.message, None), None)
|
|
246
|
+
|
|
247
|
+
def request(
|
|
248
|
+
self,
|
|
249
|
+
method: str,
|
|
250
|
+
path: str,
|
|
251
|
+
*,
|
|
252
|
+
query: Optional[Query] = None,
|
|
253
|
+
body: Any = None,
|
|
254
|
+
form: Optional[Tuple[bytes, str]] = None,
|
|
255
|
+
as_: str = "json",
|
|
256
|
+
bearer: Optional[str] = None,
|
|
257
|
+
idempotency_key: Optional[str] = None,
|
|
258
|
+
timeout: Optional[float] = None,
|
|
259
|
+
max_retries: Optional[int] = None,
|
|
260
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
261
|
+
) -> Tuple[Any, Dict[str, str]]:
|
|
262
|
+
"""Like ``call()``, but raises ``SendClipperApiError`` / ``SendClipperConnectionError``."""
|
|
263
|
+
token = bearer or self.api_key
|
|
264
|
+
if not token:
|
|
265
|
+
raise SendClipperApiError(
|
|
266
|
+
401,
|
|
267
|
+
"api_key_missing",
|
|
268
|
+
'No SendClipper API key. Pass one to SendClipper("sc_live_...") or set SENDCLIPPER_API_KEY.',
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
url = f"{self.base_url}/v1{path if path.startswith('/') else '/' + path}"
|
|
272
|
+
pairs = [(k, _query_value(v)) for k, v in (query or {}).items() if v is not None and v != ""]
|
|
273
|
+
if pairs:
|
|
274
|
+
url += "?" + urllib.parse.urlencode(pairs)
|
|
275
|
+
|
|
276
|
+
out_headers: Dict[str, str] = {
|
|
277
|
+
"Authorization": f"Bearer {token}",
|
|
278
|
+
"Accept": "application/json" if as_ == "json" else "*/*",
|
|
279
|
+
"User-Agent": f"sendclipper-python/{__version__}",
|
|
280
|
+
**self.default_headers,
|
|
281
|
+
**dict(headers or {}),
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
payload: Optional[bytes] = None
|
|
285
|
+
if form is not None:
|
|
286
|
+
payload, out_headers["Content-Type"] = form
|
|
287
|
+
elif body is not None:
|
|
288
|
+
out_headers["Content-Type"] = "application/json"
|
|
289
|
+
payload = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
290
|
+
|
|
291
|
+
is_read = method in ("GET", "HEAD")
|
|
292
|
+
# The API de-duplicates every JSON mutation on this header for 24 hours.
|
|
293
|
+
# A multipart body is not fingerprinted server-side, so it gets no key
|
|
294
|
+
# and is never retried.
|
|
295
|
+
if not is_read and form is None:
|
|
296
|
+
out_headers["Idempotency-Key"] = idempotency_key or f"sc-python-{uuid.uuid4()}"
|
|
297
|
+
elif idempotency_key:
|
|
298
|
+
out_headers["Idempotency-Key"] = idempotency_key
|
|
299
|
+
can_retry = is_read or "Idempotency-Key" in out_headers
|
|
300
|
+
retries = max(0, self.max_retries if max_retries is None else max_retries) if can_retry else 0
|
|
301
|
+
per_attempt = self.timeout if timeout is None else timeout
|
|
302
|
+
|
|
303
|
+
attempt = 0
|
|
304
|
+
while True:
|
|
305
|
+
try:
|
|
306
|
+
res = self.transport(TransportRequest(method, url, out_headers, payload, per_attempt))
|
|
307
|
+
except TimeoutError as err:
|
|
308
|
+
if attempt < retries:
|
|
309
|
+
time.sleep(_backoff(attempt))
|
|
310
|
+
attempt += 1
|
|
311
|
+
continue
|
|
312
|
+
raise SendClipperConnectionError(
|
|
313
|
+
f"SendClipper did not answer within {per_attempt:g} s ({method} {urllib.parse.urlsplit(url).path}).",
|
|
314
|
+
err,
|
|
315
|
+
) from err
|
|
316
|
+
except Exception as err: # noqa: BLE001 - any transport failure is "no answer"
|
|
317
|
+
if attempt < retries:
|
|
318
|
+
time.sleep(_backoff(attempt))
|
|
319
|
+
attempt += 1
|
|
320
|
+
continue
|
|
321
|
+
raise SendClipperConnectionError(f"Could not reach SendClipper at {self.base_url}: {err}", err) from err
|
|
322
|
+
|
|
323
|
+
res_headers = {k.lower(): v for k, v in res.headers.items()}
|
|
324
|
+
ok = 200 <= res.status < 300
|
|
325
|
+
|
|
326
|
+
if not ok and res.status in _RETRYABLE_STATUS and attempt < retries:
|
|
327
|
+
wait = _retry_after(res_headers)
|
|
328
|
+
time.sleep(wait if wait is not None else _backoff(attempt))
|
|
329
|
+
attempt += 1
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
if not ok:
|
|
333
|
+
api_err = to_api_error(res.status, _decode(res.body, res_headers), res_headers)
|
|
334
|
+
# Our own earlier attempt is still running server-side (a timeout
|
|
335
|
+
# that was not a failure). Its result is replayed once it finishes.
|
|
336
|
+
if api_err.code == "idempotency_key_in_flight" and attempt < retries:
|
|
337
|
+
wait = _retry_after(res_headers)
|
|
338
|
+
time.sleep(wait if wait is not None else _backoff(attempt))
|
|
339
|
+
attempt += 1
|
|
340
|
+
continue
|
|
341
|
+
raise api_err
|
|
342
|
+
|
|
343
|
+
if as_ == "bytes":
|
|
344
|
+
return (
|
|
345
|
+
DownloadedFile(
|
|
346
|
+
res.body,
|
|
347
|
+
res_headers.get("content-type"),
|
|
348
|
+
_filename_of(res_headers.get("content-disposition")),
|
|
349
|
+
),
|
|
350
|
+
res_headers,
|
|
351
|
+
)
|
|
352
|
+
text = _decode(res.body, res_headers)
|
|
353
|
+
if as_ == "text":
|
|
354
|
+
return text, res_headers
|
|
355
|
+
if not text:
|
|
356
|
+
return None, res_headers
|
|
357
|
+
try:
|
|
358
|
+
return json.loads(text), res_headers
|
|
359
|
+
except ValueError:
|
|
360
|
+
return text, res_headers
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _query_value(v: Union[str, int, float, bool]) -> str:
|
|
364
|
+
# The API reads booleans the way JavaScript writes them.
|
|
365
|
+
if isinstance(v, bool):
|
|
366
|
+
return "true" if v else "false"
|
|
367
|
+
return str(v)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _backoff(attempt: int) -> float:
|
|
371
|
+
base = min(8.0, 0.5 * 2.0**attempt)
|
|
372
|
+
return base / 2 + random.random() * (base / 2)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _retry_after(headers: Mapping[str, str]) -> Optional[float]:
|
|
376
|
+
raw = headers.get("retry-after")
|
|
377
|
+
if not raw:
|
|
378
|
+
return None
|
|
379
|
+
try:
|
|
380
|
+
seconds = float(raw)
|
|
381
|
+
if seconds >= 0:
|
|
382
|
+
return min(_MAX_RETRY_AFTER_SECONDS, seconds)
|
|
383
|
+
except ValueError:
|
|
384
|
+
pass
|
|
385
|
+
try:
|
|
386
|
+
at = parsedate_to_datetime(raw).timestamp()
|
|
387
|
+
except (TypeError, ValueError, IndexError):
|
|
388
|
+
return None
|
|
389
|
+
return min(_MAX_RETRY_AFTER_SECONDS, max(0.0, at - time.time()))
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _decode(body: bytes, headers: Mapping[str, str]) -> str:
|
|
393
|
+
m = re.search(r"charset=([\w-]+)", headers.get("content-type", ""), re.I)
|
|
394
|
+
try:
|
|
395
|
+
return body.decode(m.group(1) if m else "utf-8", errors="replace")
|
|
396
|
+
except LookupError:
|
|
397
|
+
return body.decode("utf-8", errors="replace")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _filename_of(disposition: Optional[str]) -> Optional[str]:
|
|
401
|
+
if not disposition:
|
|
402
|
+
return None
|
|
403
|
+
star = re.search(r"filename\*\s*=\s*(?:UTF-8'')?([^;]+)", disposition, re.I)
|
|
404
|
+
if star:
|
|
405
|
+
try:
|
|
406
|
+
return urllib.parse.unquote(star.group(1).strip().strip('"'), errors="strict")
|
|
407
|
+
except UnicodeDecodeError:
|
|
408
|
+
pass
|
|
409
|
+
plain = re.search(r'filename\s*=\s*"?([^";]+)"?', disposition, re.I)
|
|
410
|
+
return plain.group(1).strip() if plain else None
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _lower_headers(items: Any) -> Dict[str, str]:
|
|
414
|
+
out: Dict[str, str] = {}
|
|
415
|
+
for k, v in items:
|
|
416
|
+
key = k.lower()
|
|
417
|
+
out[key] = f"{out[key]}, {v}" if key in out else v
|
|
418
|
+
return out
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def seg(value: str) -> str:
|
|
422
|
+
"""Percent-encode one path segment."""
|
|
423
|
+
return urllib.parse.quote(str(value), safe="")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def strip_none(params: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
|
|
427
|
+
return {k: v for k, v in (params or {}).items() if v is not None}
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def multipart(fields: List[Tuple[str, str]], files: List[Tuple[str, str, str, bytes]]) -> Tuple[bytes, str]:
|
|
431
|
+
"""``multipart/form-data``: ``fields`` are (name, value), ``files`` are (name, filename, type, bytes)."""
|
|
432
|
+
boundary = f"----sendclipper{uuid.uuid4().hex}"
|
|
433
|
+
parts: List[bytes] = []
|
|
434
|
+
for name, value in fields:
|
|
435
|
+
parts.append(
|
|
436
|
+
f'--{boundary}\r\nContent-Disposition: form-data; name="{_quote(name)}"\r\n\r\n'.encode("utf-8")
|
|
437
|
+
+ value.encode("utf-8")
|
|
438
|
+
+ b"\r\n"
|
|
439
|
+
)
|
|
440
|
+
for name, filename, ctype, content in files:
|
|
441
|
+
parts.append(
|
|
442
|
+
(
|
|
443
|
+
f'--{boundary}\r\nContent-Disposition: form-data; name="{_quote(name)}"; filename="{_quote(filename)}"\r\n'
|
|
444
|
+
f"Content-Type: {ctype}\r\n\r\n"
|
|
445
|
+
).encode("utf-8")
|
|
446
|
+
+ content
|
|
447
|
+
+ b"\r\n"
|
|
448
|
+
)
|
|
449
|
+
parts.append(f"--{boundary}--\r\n".encode("utf-8"))
|
|
450
|
+
return b"".join(parts), f"multipart/form-data; boundary={boundary}"
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _quote(value: str) -> str:
|
|
454
|
+
return value.replace("\\", "\\\\").replace('"', '\\"').replace("\r", " ").replace("\n", " ")
|