timbro-protocol 0.10.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.
- timbro_protocol/__init__.py +45 -0
- timbro_protocol/_client.py +343 -0
- timbro_protocol/_core.py +565 -0
- timbro_protocol/_errors.py +302 -0
- timbro_protocol/_headers.py +75 -0
- timbro_protocol/_operation.py +41 -0
- timbro_protocol/_pagination.py +63 -0
- timbro_protocol/_version.py +1 -0
- timbro_protocol/_webhooks.py +219 -0
- timbro_protocol/py.typed +0 -0
- timbro_protocol/runtime.py +56 -0
- timbro_protocol-0.10.0.dist-info/METADATA +37 -0
- timbro_protocol-0.10.0.dist-info/RECORD +16 -0
- timbro_protocol-0.10.0.dist-info/WHEEL +5 -0
- timbro_protocol-0.10.0.dist-info/licenses/LICENSE +202 -0
- timbro_protocol-0.10.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Shared errors and Standard Webhooks verification of the Timbro Python SDKs.
|
|
2
|
+
|
|
3
|
+
SDK packages build on `timbro_protocol.runtime`.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from ._errors import (
|
|
7
|
+
APIConnectionError,
|
|
8
|
+
APIError,
|
|
9
|
+
APITimeoutError,
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
ConflictError,
|
|
12
|
+
InvalidRequestError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
PermissionDeniedError,
|
|
15
|
+
ProblemDetail,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
ServerError,
|
|
18
|
+
TimbroError,
|
|
19
|
+
UnexpectedResponseError,
|
|
20
|
+
WebhookRejection,
|
|
21
|
+
WebhookVerificationError,
|
|
22
|
+
)
|
|
23
|
+
from ._version import __version__
|
|
24
|
+
from ._webhooks import sign_webhook, verify_webhook_signature
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"APIConnectionError",
|
|
28
|
+
"APIError",
|
|
29
|
+
"APITimeoutError",
|
|
30
|
+
"AuthenticationError",
|
|
31
|
+
"ConflictError",
|
|
32
|
+
"InvalidRequestError",
|
|
33
|
+
"NotFoundError",
|
|
34
|
+
"PermissionDeniedError",
|
|
35
|
+
"ProblemDetail",
|
|
36
|
+
"RateLimitError",
|
|
37
|
+
"ServerError",
|
|
38
|
+
"TimbroError",
|
|
39
|
+
"UnexpectedResponseError",
|
|
40
|
+
"WebhookRejection",
|
|
41
|
+
"WebhookVerificationError",
|
|
42
|
+
"__version__",
|
|
43
|
+
"sign_webhook",
|
|
44
|
+
"verify_webhook_signature",
|
|
45
|
+
]
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""Sync and async executors over the sans-I/O core; only sending, reading, and sleeping differ."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Any, TypeVar
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from . import _core
|
|
14
|
+
from ._core import ClientSettings, FileResponse, TimeoutTypes
|
|
15
|
+
from ._errors import TimbroError
|
|
16
|
+
from ._operation import Operation
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
Decode = Callable[[httpx.Response, bytes], T]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SyncAPIClient:
|
|
23
|
+
"""Sends Timbro operations over an `httpx.Client` with the contract's retry rules."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
api_key: str,
|
|
29
|
+
user_agent: str,
|
|
30
|
+
base_url: str = _core.DEFAULT_BASE_URL,
|
|
31
|
+
timeout: TimeoutTypes = _core.DEFAULT_TIMEOUT,
|
|
32
|
+
max_retries: int = _core.DEFAULT_MAX_RETRIES,
|
|
33
|
+
http_client: httpx.Client | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._settings = ClientSettings.create(
|
|
36
|
+
api_key=api_key,
|
|
37
|
+
base_url=base_url,
|
|
38
|
+
timeout=timeout,
|
|
39
|
+
max_retries=max_retries,
|
|
40
|
+
user_agent=user_agent,
|
|
41
|
+
)
|
|
42
|
+
self._http_client = http_client
|
|
43
|
+
self._owns_http_client = http_client is None
|
|
44
|
+
self._lock = threading.Lock()
|
|
45
|
+
self._closed = False
|
|
46
|
+
|
|
47
|
+
def request(
|
|
48
|
+
self,
|
|
49
|
+
operation: Operation,
|
|
50
|
+
*,
|
|
51
|
+
path_params: Mapping[str, str] | None = None,
|
|
52
|
+
query: Mapping[str, object] | None = None,
|
|
53
|
+
body: Mapping[str, Any] | None = None,
|
|
54
|
+
idempotency_key: str | None = None,
|
|
55
|
+
access_token: str | None = None,
|
|
56
|
+
expect: Mapping[str, str] | None = None,
|
|
57
|
+
timeout: TimeoutTypes | None = None,
|
|
58
|
+
) -> Any:
|
|
59
|
+
"""Sends a JSON operation and returns its decoded object, or None for a 204."""
|
|
60
|
+
request = _core.build_request(
|
|
61
|
+
self._settings,
|
|
62
|
+
operation,
|
|
63
|
+
path_params=path_params or {},
|
|
64
|
+
query=query,
|
|
65
|
+
body=body,
|
|
66
|
+
idempotency_key=idempotency_key,
|
|
67
|
+
access_token=access_token,
|
|
68
|
+
timeout=self._timeout(timeout),
|
|
69
|
+
)
|
|
70
|
+
return self._send(
|
|
71
|
+
operation,
|
|
72
|
+
request,
|
|
73
|
+
lambda response, content: _core.decode_response(operation, response, content, expect),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def download(
|
|
77
|
+
self,
|
|
78
|
+
operation: Operation,
|
|
79
|
+
*,
|
|
80
|
+
media_type: str,
|
|
81
|
+
path_params: Mapping[str, str] | None = None,
|
|
82
|
+
access_token: str | None = None,
|
|
83
|
+
timeout: TimeoutTypes | None = None,
|
|
84
|
+
) -> FileResponse:
|
|
85
|
+
"""Sends a file operation and returns its bytes and headers, unverified."""
|
|
86
|
+
request = _core.build_request(
|
|
87
|
+
self._settings,
|
|
88
|
+
operation,
|
|
89
|
+
path_params=path_params or {},
|
|
90
|
+
query=None,
|
|
91
|
+
body=None,
|
|
92
|
+
idempotency_key=None,
|
|
93
|
+
access_token=access_token,
|
|
94
|
+
accept=media_type,
|
|
95
|
+
timeout=self._timeout(timeout),
|
|
96
|
+
)
|
|
97
|
+
return self._send(
|
|
98
|
+
operation,
|
|
99
|
+
request,
|
|
100
|
+
lambda response, content: _core.decode_file(operation, response, content),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def _send(self, operation: Operation, request: httpx.Request, decode: Decode[T]) -> T:
|
|
104
|
+
http_client = self._client()
|
|
105
|
+
retry = 0
|
|
106
|
+
reached = False
|
|
107
|
+
while True:
|
|
108
|
+
try:
|
|
109
|
+
response = http_client.send(request, follow_redirects=False, stream=True)
|
|
110
|
+
try:
|
|
111
|
+
content = _read(response)
|
|
112
|
+
finally:
|
|
113
|
+
response.close()
|
|
114
|
+
except httpx.TransportError as error:
|
|
115
|
+
reached = reached or _core.may_have_arrived(error)
|
|
116
|
+
delay = _core.retry_delay_after_transport_error(
|
|
117
|
+
operation, request, retry, self._settings.max_retries
|
|
118
|
+
)
|
|
119
|
+
if delay is None:
|
|
120
|
+
failure = _core.transport_error(operation, error)
|
|
121
|
+
raise _core.settle(operation, request, failure, reached) from error
|
|
122
|
+
else:
|
|
123
|
+
reached = True
|
|
124
|
+
_core.log_response(operation, response, retry + 1)
|
|
125
|
+
delay = _core.retry_delay_after_response(
|
|
126
|
+
operation, request, response, retry, self._settings.max_retries
|
|
127
|
+
)
|
|
128
|
+
if delay is None:
|
|
129
|
+
try:
|
|
130
|
+
return decode(response, content)
|
|
131
|
+
except TimbroError as failure:
|
|
132
|
+
_core.settle(operation, request, failure, reached)
|
|
133
|
+
raise
|
|
134
|
+
time.sleep(delay)
|
|
135
|
+
retry += 1
|
|
136
|
+
|
|
137
|
+
def _timeout(self, timeout: TimeoutTypes | None) -> httpx.Timeout | None:
|
|
138
|
+
if timeout is not None:
|
|
139
|
+
return _core.as_timeout(timeout)
|
|
140
|
+
return self._settings.timeout if self._owns_http_client else None
|
|
141
|
+
|
|
142
|
+
def close(self) -> None:
|
|
143
|
+
"""Closes the HTTP client this object created; a caller-supplied client stays open."""
|
|
144
|
+
with self._lock:
|
|
145
|
+
self._closed = True
|
|
146
|
+
if self._owns_http_client and self._http_client is not None:
|
|
147
|
+
self._http_client.close()
|
|
148
|
+
|
|
149
|
+
def __enter__(self) -> SyncAPIClient:
|
|
150
|
+
return self
|
|
151
|
+
|
|
152
|
+
def __exit__(
|
|
153
|
+
self,
|
|
154
|
+
exc_type: type[BaseException] | None,
|
|
155
|
+
exc: BaseException | None,
|
|
156
|
+
traceback: TracebackType | None,
|
|
157
|
+
) -> None:
|
|
158
|
+
self.close()
|
|
159
|
+
|
|
160
|
+
def _client(self) -> httpx.Client:
|
|
161
|
+
with self._lock:
|
|
162
|
+
if self._closed:
|
|
163
|
+
raise RuntimeError("The Timbro client is closed.")
|
|
164
|
+
if self._http_client is None:
|
|
165
|
+
self._http_client = httpx.Client(timeout=self._settings.timeout)
|
|
166
|
+
return self._http_client
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class AsyncAPIClient:
|
|
170
|
+
"""Sends Timbro operations over an `httpx.AsyncClient` with the contract's retry rules."""
|
|
171
|
+
|
|
172
|
+
def __init__(
|
|
173
|
+
self,
|
|
174
|
+
*,
|
|
175
|
+
api_key: str,
|
|
176
|
+
user_agent: str,
|
|
177
|
+
base_url: str = _core.DEFAULT_BASE_URL,
|
|
178
|
+
timeout: TimeoutTypes = _core.DEFAULT_TIMEOUT,
|
|
179
|
+
max_retries: int = _core.DEFAULT_MAX_RETRIES,
|
|
180
|
+
http_client: httpx.AsyncClient | None = None,
|
|
181
|
+
) -> None:
|
|
182
|
+
self._settings = ClientSettings.create(
|
|
183
|
+
api_key=api_key,
|
|
184
|
+
base_url=base_url,
|
|
185
|
+
timeout=timeout,
|
|
186
|
+
max_retries=max_retries,
|
|
187
|
+
user_agent=user_agent,
|
|
188
|
+
)
|
|
189
|
+
self._http_client = http_client
|
|
190
|
+
self._owns_http_client = http_client is None
|
|
191
|
+
self._closed = False
|
|
192
|
+
|
|
193
|
+
async def request(
|
|
194
|
+
self,
|
|
195
|
+
operation: Operation,
|
|
196
|
+
*,
|
|
197
|
+
path_params: Mapping[str, str] | None = None,
|
|
198
|
+
query: Mapping[str, object] | None = None,
|
|
199
|
+
body: Mapping[str, Any] | None = None,
|
|
200
|
+
idempotency_key: str | None = None,
|
|
201
|
+
access_token: str | None = None,
|
|
202
|
+
expect: Mapping[str, str] | None = None,
|
|
203
|
+
timeout: TimeoutTypes | None = None,
|
|
204
|
+
) -> Any:
|
|
205
|
+
"""Sends a JSON operation and returns its decoded object, or None for a 204."""
|
|
206
|
+
request = _core.build_request(
|
|
207
|
+
self._settings,
|
|
208
|
+
operation,
|
|
209
|
+
path_params=path_params or {},
|
|
210
|
+
query=query,
|
|
211
|
+
body=body,
|
|
212
|
+
idempotency_key=idempotency_key,
|
|
213
|
+
access_token=access_token,
|
|
214
|
+
timeout=self._timeout(timeout),
|
|
215
|
+
)
|
|
216
|
+
return await self._send(
|
|
217
|
+
operation,
|
|
218
|
+
request,
|
|
219
|
+
lambda response, content: _core.decode_response(operation, response, content, expect),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
async def download(
|
|
223
|
+
self,
|
|
224
|
+
operation: Operation,
|
|
225
|
+
*,
|
|
226
|
+
media_type: str,
|
|
227
|
+
path_params: Mapping[str, str] | None = None,
|
|
228
|
+
access_token: str | None = None,
|
|
229
|
+
timeout: TimeoutTypes | None = None,
|
|
230
|
+
) -> FileResponse:
|
|
231
|
+
"""Sends a file operation and returns its bytes and headers, unverified."""
|
|
232
|
+
request = _core.build_request(
|
|
233
|
+
self._settings,
|
|
234
|
+
operation,
|
|
235
|
+
path_params=path_params or {},
|
|
236
|
+
query=None,
|
|
237
|
+
body=None,
|
|
238
|
+
idempotency_key=None,
|
|
239
|
+
access_token=access_token,
|
|
240
|
+
accept=media_type,
|
|
241
|
+
timeout=self._timeout(timeout),
|
|
242
|
+
)
|
|
243
|
+
return await self._send(
|
|
244
|
+
operation,
|
|
245
|
+
request,
|
|
246
|
+
lambda response, content: _core.decode_file(operation, response, content),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
async def _send(self, operation: Operation, request: httpx.Request, decode: Decode[T]) -> T:
|
|
250
|
+
# Imported here so that synchronous callers never pay for importing asyncio.
|
|
251
|
+
import asyncio
|
|
252
|
+
|
|
253
|
+
http_client = self._client()
|
|
254
|
+
retry = 0
|
|
255
|
+
reached = False
|
|
256
|
+
while True:
|
|
257
|
+
try:
|
|
258
|
+
response = await http_client.send(request, follow_redirects=False, stream=True)
|
|
259
|
+
try:
|
|
260
|
+
content = await _aread(response)
|
|
261
|
+
finally:
|
|
262
|
+
await response.aclose()
|
|
263
|
+
except httpx.TransportError as error:
|
|
264
|
+
reached = reached or _core.may_have_arrived(error)
|
|
265
|
+
delay = _core.retry_delay_after_transport_error(
|
|
266
|
+
operation, request, retry, self._settings.max_retries
|
|
267
|
+
)
|
|
268
|
+
if delay is None:
|
|
269
|
+
failure = _core.transport_error(operation, error)
|
|
270
|
+
raise _core.settle(operation, request, failure, reached) from error
|
|
271
|
+
else:
|
|
272
|
+
reached = True
|
|
273
|
+
_core.log_response(operation, response, retry + 1)
|
|
274
|
+
delay = _core.retry_delay_after_response(
|
|
275
|
+
operation, request, response, retry, self._settings.max_retries
|
|
276
|
+
)
|
|
277
|
+
if delay is None:
|
|
278
|
+
try:
|
|
279
|
+
return decode(response, content)
|
|
280
|
+
except TimbroError as failure:
|
|
281
|
+
_core.settle(operation, request, failure, reached)
|
|
282
|
+
raise
|
|
283
|
+
await asyncio.sleep(delay)
|
|
284
|
+
retry += 1
|
|
285
|
+
|
|
286
|
+
def _timeout(self, timeout: TimeoutTypes | None) -> httpx.Timeout | None:
|
|
287
|
+
if timeout is not None:
|
|
288
|
+
return _core.as_timeout(timeout)
|
|
289
|
+
return self._settings.timeout if self._owns_http_client else None
|
|
290
|
+
|
|
291
|
+
async def aclose(self) -> None:
|
|
292
|
+
"""Closes the HTTP client this object created; a caller-supplied client stays open."""
|
|
293
|
+
self._closed = True
|
|
294
|
+
if self._owns_http_client and self._http_client is not None:
|
|
295
|
+
await self._http_client.aclose()
|
|
296
|
+
|
|
297
|
+
async def __aenter__(self) -> AsyncAPIClient:
|
|
298
|
+
return self
|
|
299
|
+
|
|
300
|
+
async def __aexit__(
|
|
301
|
+
self,
|
|
302
|
+
exc_type: type[BaseException] | None,
|
|
303
|
+
exc: BaseException | None,
|
|
304
|
+
traceback: TracebackType | None,
|
|
305
|
+
) -> None:
|
|
306
|
+
await self.aclose()
|
|
307
|
+
|
|
308
|
+
def _client(self) -> httpx.AsyncClient:
|
|
309
|
+
if self._closed:
|
|
310
|
+
raise RuntimeError("The Timbro client is closed.")
|
|
311
|
+
if self._http_client is None:
|
|
312
|
+
self._http_client = httpx.AsyncClient(timeout=self._settings.timeout)
|
|
313
|
+
return self._http_client
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _read(response: httpx.Response) -> bytes:
|
|
317
|
+
"""Reads the body, stopping one chunk past the limit so the decoder can refuse it.
|
|
318
|
+
|
|
319
|
+
A body whose declared length is already past the limit is not read at all.
|
|
320
|
+
"""
|
|
321
|
+
if _core.declares_too_much(response):
|
|
322
|
+
return b""
|
|
323
|
+
chunks: list[bytes] = []
|
|
324
|
+
size = 0
|
|
325
|
+
for chunk in response.iter_bytes():
|
|
326
|
+
chunks.append(chunk)
|
|
327
|
+
size += len(chunk)
|
|
328
|
+
if size > _core.MAX_RESPONSE_BYTES:
|
|
329
|
+
break
|
|
330
|
+
return b"".join(chunks)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
async def _aread(response: httpx.Response) -> bytes:
|
|
334
|
+
if _core.declares_too_much(response):
|
|
335
|
+
return b""
|
|
336
|
+
chunks: list[bytes] = []
|
|
337
|
+
size = 0
|
|
338
|
+
async for chunk in response.aiter_bytes():
|
|
339
|
+
chunks.append(chunk)
|
|
340
|
+
size += len(chunk)
|
|
341
|
+
if size > _core.MAX_RESPONSE_BYTES:
|
|
342
|
+
break
|
|
343
|
+
return b"".join(chunks)
|