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.
@@ -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)