brellium-api-client 0.2.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.
Files changed (89) hide show
  1. brellium_api_client/__init__.py +98 -0
  2. brellium_api_client/_base_client.py +2904 -0
  3. brellium_api_client/_client.py +721 -0
  4. brellium_api_client/_compat.py +195 -0
  5. brellium_api_client/_constants.py +14 -0
  6. brellium_api_client/_decoding.py +187 -0
  7. brellium_api_client/_event_handler.py +85 -0
  8. brellium_api_client/_exceptions.py +126 -0
  9. brellium_api_client/_files.py +173 -0
  10. brellium_api_client/_models.py +965 -0
  11. brellium_api_client/_qs.py +147 -0
  12. brellium_api_client/_resource.py +43 -0
  13. brellium_api_client/_response.py +842 -0
  14. brellium_api_client/_send_queue.py +90 -0
  15. brellium_api_client/_streaming.py +412 -0
  16. brellium_api_client/_types.py +279 -0
  17. brellium_api_client/_utils/__init__.py +64 -0
  18. brellium_api_client/_utils/_compat.py +45 -0
  19. brellium_api_client/_utils/_datetime_parse.py +156 -0
  20. brellium_api_client/_utils/_json.py +35 -0
  21. brellium_api_client/_utils/_logs.py +356 -0
  22. brellium_api_client/_utils/_path.py +127 -0
  23. brellium_api_client/_utils/_proxy.py +65 -0
  24. brellium_api_client/_utils/_reflection.py +42 -0
  25. brellium_api_client/_utils/_resources_proxy.py +24 -0
  26. brellium_api_client/_utils/_streams.py +12 -0
  27. brellium_api_client/_utils/_sync.py +58 -0
  28. brellium_api_client/_utils/_transform.py +472 -0
  29. brellium_api_client/_utils/_typing.py +156 -0
  30. brellium_api_client/_utils/_utils.py +428 -0
  31. brellium_api_client/_version.py +4 -0
  32. brellium_api_client/py.typed +0 -0
  33. brellium_api_client/resources/__init__.py +89 -0
  34. brellium_api_client/resources/audits.py +597 -0
  35. brellium_api_client/resources/coding_evaluations.py +354 -0
  36. brellium_api_client/resources/documents.py +654 -0
  37. brellium_api_client/resources/email_recipients.py +718 -0
  38. brellium_api_client/resources/question_sets.py +313 -0
  39. brellium_api_client/resources/users.py +852 -0
  40. brellium_api_client/types/__init__.py +49 -0
  41. brellium_api_client/types/audit.py +56 -0
  42. brellium_api_client/types/audit_batch_result.py +59 -0
  43. brellium_api_client/types/audit_batch_update_flags_params.py +22 -0
  44. brellium_api_client/types/audit_display_info_item.py +21 -0
  45. brellium_api_client/types/audit_list.py +26 -0
  46. brellium_api_client/types/audit_list_params.py +56 -0
  47. brellium_api_client/types/audit_person.py +23 -0
  48. brellium_api_client/types/audit_response.py +32 -0
  49. brellium_api_client/types/audit_update_params.py +13 -0
  50. brellium_api_client/types/coding_evaluation.py +54 -0
  51. brellium_api_client/types/coding_evaluation_list.py +26 -0
  52. brellium_api_client/types/coding_evaluation_list_params.py +50 -0
  53. brellium_api_client/types/created_user.py +13 -0
  54. brellium_api_client/types/document.py +64 -0
  55. brellium_api_client/types/document_create_params.py +32 -0
  56. brellium_api_client/types/document_list.py +26 -0
  57. brellium_api_client/types/document_list_params.py +50 -0
  58. brellium_api_client/types/document_update_params.py +16 -0
  59. brellium_api_client/types/document_upload_list.py +26 -0
  60. brellium_api_client/types/document_with_upload.py +15 -0
  61. brellium_api_client/types/em_evaluation.py +85 -0
  62. brellium_api_client/types/email_recipient.py +37 -0
  63. brellium_api_client/types/email_recipient_create_params.py +22 -0
  64. brellium_api_client/types/email_recipient_list.py +26 -0
  65. brellium_api_client/types/email_recipient_list_params.py +38 -0
  66. brellium_api_client/types/email_recipient_update_params.py +22 -0
  67. brellium_api_client/types/icd_code_finding.py +22 -0
  68. brellium_api_client/types/icd_criterion.py +21 -0
  69. brellium_api_client/types/icd_evaluation.py +32 -0
  70. brellium_api_client/types/mdm_data.py +18 -0
  71. brellium_api_client/types/mdm_problem.py +18 -0
  72. brellium_api_client/types/mdm_risk.py +18 -0
  73. brellium_api_client/types/presigned_upload.py +25 -0
  74. brellium_api_client/types/question.py +33 -0
  75. brellium_api_client/types/question_set.py +42 -0
  76. brellium_api_client/types/question_set_list.py +26 -0
  77. brellium_api_client/types/question_set_list_params.py +29 -0
  78. brellium_api_client/types/user.py +37 -0
  79. brellium_api_client/types/user_batch_result.py +59 -0
  80. brellium_api_client/types/user_batch_update_params.py +27 -0
  81. brellium_api_client/types/user_create_params.py +19 -0
  82. brellium_api_client/types/user_invitation.py +17 -0
  83. brellium_api_client/types/user_list.py +26 -0
  84. brellium_api_client/types/user_list_params.py +38 -0
  85. brellium_api_client/types/user_update_params.py +19 -0
  86. brellium_api_client-0.2.0.dist-info/METADATA +183 -0
  87. brellium_api_client-0.2.0.dist-info/RECORD +89 -0
  88. brellium_api_client-0.2.0.dist-info/WHEEL +4 -0
  89. brellium_api_client-0.2.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,2904 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import json
5
+ import time
6
+ import uuid
7
+ import zlib
8
+ import email
9
+ import asyncio
10
+ import inspect
11
+ import logging
12
+ import platform
13
+ import warnings
14
+ import email.utils
15
+ from types import TracebackType
16
+ from random import random
17
+ from dataclasses import field, dataclass
18
+ from typing import (
19
+ TYPE_CHECKING,
20
+ Any,
21
+ Dict,
22
+ List,
23
+ Type,
24
+ Union,
25
+ Generic,
26
+ Mapping,
27
+ TypeVar,
28
+ Iterable,
29
+ Iterator,
30
+ Optional,
31
+ Generator,
32
+ AsyncIterator,
33
+ cast,
34
+ overload,
35
+ )
36
+ from typing_extensions import Literal, override, get_origin
37
+
38
+ import anyio
39
+ import httpx
40
+ import pydantic
41
+ from httpx import URL
42
+ from pydantic import PrivateAttr
43
+
44
+ from . import _exceptions
45
+ from ._qs import Querystring
46
+ from ._decoding import CodingRefused, BoundedDecoder, decoding_error
47
+ from ._files import to_httpx_files, async_to_httpx_files
48
+ from ._types import (
49
+ Body,
50
+ Omit,
51
+ Query,
52
+ Headers,
53
+ Timeout,
54
+ NotGiven,
55
+ ResponseT,
56
+ AnyMapping,
57
+ PostParser,
58
+ BinaryTypes,
59
+ RequestFiles,
60
+ HttpxSendArgs,
61
+ RequestOptions,
62
+ AsyncBinaryTypes,
63
+ HttpxRequestFiles,
64
+ ModelBuilderProtocol,
65
+ not_given,
66
+ )
67
+ from ._utils import is_dict, is_list, asyncify, is_given, lru_cache, is_mapping
68
+ from ._compat import PYDANTIC_V1, model_copy, model_dump
69
+ from ._models import GenericModel, SecurityOptions, FinalRequestOptions, validate_type, construct_type
70
+ from ._response import (
71
+ APIResponse,
72
+ BaseAPIResponse,
73
+ AsyncAPIResponse,
74
+ BinaryAPIResponse,
75
+ AsyncBinaryAPIResponse,
76
+ StreamedBinaryAPIResponse,
77
+ AsyncStreamedBinaryAPIResponse,
78
+ extract_response_type,
79
+ )
80
+ from ._constants import (
81
+ DEFAULT_TIMEOUT,
82
+ MAX_RETRY_DELAY,
83
+ DEFAULT_MAX_RETRIES,
84
+ INITIAL_RETRY_DELAY,
85
+ RAW_RESPONSE_HEADER,
86
+ OVERRIDE_CAST_TO_HEADER,
87
+ DEFAULT_CONNECTION_LIMITS,
88
+ )
89
+ from ._streaming import Stream, SSEDecoder, AsyncStream, SSEBytesDecoder
90
+ from ._exceptions import (
91
+ APIError,
92
+ APIStatusError,
93
+ APITimeoutError,
94
+ APIConnectionError,
95
+ APIResponseValidationError,
96
+ )
97
+ from ._utils._json import openapi_dumps
98
+
99
+ log: logging.Logger = logging.getLogger(__name__)
100
+
101
+ # TODO: make base page type vars covariant
102
+ SyncPageT = TypeVar("SyncPageT", bound="BaseSyncPage[Any]")
103
+ AsyncPageT = TypeVar("AsyncPageT", bound="BaseAsyncPage[Any]")
104
+
105
+
106
+ _T = TypeVar("_T")
107
+ _T_co = TypeVar("_T_co", covariant=True)
108
+
109
+ _StreamT = TypeVar("_StreamT", bound=Stream[Any])
110
+ _AsyncStreamT = TypeVar("_AsyncStreamT", bound=AsyncStream[Any])
111
+
112
+ if TYPE_CHECKING:
113
+ from httpx._config import (
114
+ DEFAULT_TIMEOUT_CONFIG, # pyright: ignore[reportPrivateImportUsage]
115
+ )
116
+
117
+ HTTPX_DEFAULT_TIMEOUT = DEFAULT_TIMEOUT_CONFIG
118
+ else:
119
+ try:
120
+ from httpx._config import DEFAULT_TIMEOUT_CONFIG as HTTPX_DEFAULT_TIMEOUT
121
+ except ImportError:
122
+ # httpx's own default of five seconds, for a version that no longer exposes the constant.
123
+ HTTPX_DEFAULT_TIMEOUT = Timeout(5.0)
124
+
125
+
126
+ # The redirect statuses the SDK follows, the ones the fetch spec follows. A 300 only offers choices
127
+ # and a 304 tells a conditional request its copy is current, so neither is a hop: like a redirect
128
+ # without a `Location`, each reaches the caller as a status error.
129
+ _REDIRECT_STATUSES: frozenset[int] = frozenset({301, 302, 303, 307, 308})
130
+
131
+ # How many redirects one request follows before giving up with a connection error.
132
+ MAX_REDIRECTS = 20
133
+
134
+ # Every header a credential travels in, lower-cased: `Authorization`, `Proxy-Authorization`, `Cookie`,
135
+ # the conventional key headers, and each header this SDK's auth schemes place (an `apiKey` scheme
136
+ # names its own). A redirect that leaves the origin drops all of them; the log masks the same set.
137
+ _CREDENTIAL_HEADERS: frozenset[str] = frozenset(
138
+ ("authorization", "proxy-authorization", "api-key", "x-api-key", "cookie", "set-cookie")
139
+ )
140
+
141
+ # The query parameters this SDK's `apiKey` schemes place a credential in.
142
+ _CREDENTIAL_QUERY: tuple[str, ...] = ()
143
+
144
+ # The headers that describe a request body, dropped with the body when a redirect turns the request
145
+ # into a bodiless GET (the fetch spec's list, plus the framing headers httpx sets).
146
+ _BODY_HEADERS: tuple[str, ...] = (
147
+ "content-type",
148
+ "content-length",
149
+ "content-encoding",
150
+ "content-language",
151
+ "content-location",
152
+ "transfer-encoding",
153
+ )
154
+
155
+ _DEFAULT_PORTS: Dict[str, int] = {"http": 80, "https": 443}
156
+
157
+ # The most bytes a buffered response body may decode to (after transparent decompression), so a
158
+ # hostile or broken server cannot make the client hold an unbounded body in memory. A binary
159
+ # download or a streamed response is handed to the caller unread and is not capped.
160
+ MAX_RESPONSE_BODY_BYTES = 64 * 1024 * 1024
161
+
162
+ # The most bytes of an error response's body read for its message and `body`; the rest is dropped.
163
+ MAX_ERROR_BODY_BYTES = 64 * 1024
164
+
165
+
166
+ class _ResponseRefused(Exception):
167
+ """A response the SDK will not act on, reported to the caller as an SDK error with `message`."""
168
+
169
+ def __init__(self, message: str, *, request: httpx.Request) -> None:
170
+ super().__init__(message)
171
+ self.message = message
172
+ self.request = request
173
+
174
+
175
+ class _RedirectRefused(_ResponseRefused):
176
+ """A redirect the SDK will not follow; it reaches the caller as an `APIConnectionError`."""
177
+
178
+
179
+ class _BodyRefused(_ResponseRefused):
180
+ """A buffered body the SDK will not read; it reaches the caller as an `APIError` and is not retried."""
181
+
182
+
183
+ class _BodyTooLarge(_BodyRefused):
184
+ """A buffered body larger than `MAX_RESPONSE_BODY_BYTES`."""
185
+
186
+
187
+ def _origin(url: URL) -> tuple[str, str, int | None]:
188
+ """Scheme, host and port of `url`, the default port spelled out, so `https://h:443` is `https://h`."""
189
+ scheme = url.scheme.lower()
190
+ return (scheme, url.host.lower(), url.port if url.port is not None else _DEFAULT_PORTS.get(scheme))
191
+
192
+
193
+ def _is_followed_redirect(response: httpx.Response) -> bool:
194
+ """Whether `response` is a redirect the SDK follows: a 301, 302, 303, 307 or 308 naming a `Location`."""
195
+ return response.status_code in _REDIRECT_STATUSES and bool(response.headers.get("location", "").strip())
196
+
197
+
198
+ def _is_replayable(stream: object) -> bool:
199
+ """Whether a request body can be sent again on the next hop.
200
+
201
+ Bytes can, and so can a multipart body whose files are bytes or seekable (httpx rewinds each one
202
+ as it renders the part). An iterator or an unseekable file was consumed by the first request, so
203
+ resending it would deliver an empty or truncated body the server would act on.
204
+ """
205
+ if isinstance(stream, httpx.ByteStream):
206
+ return True
207
+ fields = getattr(stream, "fields", None)
208
+ if fields is None:
209
+ return False
210
+ for field in cast("list[Any]", fields):
211
+ file = getattr(field, "file", None)
212
+ if file is None or isinstance(file, (bytes, str)):
213
+ continue
214
+ seekable = getattr(file, "seekable", None)
215
+ try:
216
+ if not (callable(seekable) and seekable()):
217
+ return False
218
+ except Exception:
219
+ return False
220
+ return True
221
+
222
+
223
+ def _redirect_request(
224
+ request: httpx.Request, response: httpx.Response, *, sent_headers: httpx.Headers, keep_credentials: bool
225
+ ) -> tuple[httpx.Request, bool]:
226
+ """The request a followed redirect sends next, and whether it still carries the credentials.
227
+
228
+ `Location` is resolved against the URL that was redirected (RFC 3986 section 5.2). Only http and
229
+ https are followed, and never from https down to http, which would send the request and its
230
+ credentials in clear.
231
+
232
+ A 303 answering anything but HEAD, and a 301 or 302 answering a POST, is fetched again as a
233
+ bodiless GET, as the fetch spec does. Every other redirect keeps its method and resends its body
234
+ (a PUT answered with a 301 must not become a GET that succeeds without doing what was asked), and
235
+ a body that cannot be replayed is refused rather than resent empty.
236
+
237
+ Leaving the origin (scheme, host and port) drops every credential header, whether the client or a
238
+ per-call `extra_headers` set it, and the drop holds for the rest of the chain, so a chain that
239
+ comes back to the first origin arrives without them. A query-placed key cannot leave at all: the
240
+ target's query is the `Location`'s own. On the same origin a relative `Location` cannot carry the
241
+ query key, so it is carried over exactly as a header credential is.
242
+
243
+ The next hop is built from `sent_headers`, the headers as they stood before httpx ran the auth flow:
244
+ an `httpx.Auth` signs the request object in place, so its headers (whatever their names) must not
245
+ be copied onto a hop it did not sign. A hop that stays on the origin is signed again on send.
246
+ """
247
+ location = response.headers.get("location", "").strip()
248
+ try:
249
+ target = request.url.join(location)
250
+ except Exception as exc:
251
+ raise _RedirectRefused("Redirect with an unusable Location header", request=request) from exc
252
+
253
+ scheme = target.scheme.lower()
254
+ if scheme not in ("http", "https") or not target.host:
255
+ raise _RedirectRefused("Redirect to an unsupported URL scheme", request=request)
256
+ if request.url.scheme.lower() == "https" and scheme == "http":
257
+ raise _RedirectRefused("Redirect from https to insecure http", request=request)
258
+ target = target.copy_with(fragment=None)
259
+
260
+ headers = httpx.Headers(sent_headers)
261
+ method = request.method.upper()
262
+ status = response.status_code
263
+ stream: Any = request.stream
264
+ if (status == 303 and method != "HEAD") or (status in (301, 302) and method == "POST"):
265
+ method = "GET"
266
+ stream = None
267
+ for name in _BODY_HEADERS:
268
+ headers.pop(name, None)
269
+ elif not _is_replayable(stream):
270
+ raise _RedirectRefused("Redirect would resend a request body that cannot be replayed", request=request)
271
+
272
+ if keep_credentials and _origin(request.url) == _origin(target):
273
+ present = set(target.params.keys())
274
+ carried = [
275
+ (key, value)
276
+ for key, value in request.url.params.multi_items()
277
+ if key in _CREDENTIAL_QUERY and key not in present
278
+ ]
279
+ if carried:
280
+ target = target.copy_merge_params(tuple(carried))
281
+ _merge_set_cookies(headers, response, target)
282
+ else:
283
+ keep_credentials = False
284
+ for name in [name for name in headers if name.lower() in _CREDENTIAL_HEADERS]:
285
+ del headers[name]
286
+
287
+ # httpx only fills `Host` in for a request built without a stream, so it is set here for both.
288
+ headers["Host"] = target.netloc.decode("ascii")
289
+ extensions = dict(request.extensions)
290
+ if "_" in target.host:
291
+ # work around https://github.com/encode/httpx/discussions/2880, as `_build_request` does
292
+ extensions["sni_hostname"] = target.host.replace("_", "-")
293
+ else:
294
+ extensions.pop("sni_hostname", None)
295
+
296
+ return httpx.Request(method, target, headers=headers, stream=stream, extensions=extensions), keep_credentials
297
+
298
+
299
+ def _merge_set_cookies(headers: httpx.Headers, response: httpx.Response, target: httpx.URL) -> None:
300
+ """Folds the cookies a same-origin redirect sets into the next hop's `Cookie` header.
301
+
302
+ httpx writes a request's cookies into a header when the request is built, so without this a cookie
303
+ the 3xx itself sets (a session, say) would be missing from the next hop, as it is not when httpx
304
+ follows. A cookie the redirect sets replaces the one of the same name the header carried.
305
+ """
306
+ if not response.headers.get_list("set-cookie"):
307
+ return
308
+ fresh = httpx.Cookies()
309
+ fresh.extract_cookies(response)
310
+ probe = httpx.Request("GET", target)
311
+ fresh.set_cookie_header(probe)
312
+ added = probe.headers.get("cookie")
313
+ if not added:
314
+ return
315
+ pairs: Dict[str, str] = {}
316
+ for part in [*headers.get("cookie", "").split(";"), *added.split(";")]:
317
+ part = part.strip()
318
+ if part:
319
+ pairs[part.partition("=")[0]] = part
320
+ headers["Cookie"] = "; ".join(pairs.values())
321
+
322
+
323
+ def _describe_body(value: object) -> str:
324
+ """A request body as the debug log shows it: its shape and size, never its values.
325
+
326
+ A mapping lists its keys, which are the API's field names; every value, nested ones included,
327
+ stays out of the log.
328
+ """
329
+ if isinstance(value, (bytes, bytearray)):
330
+ return f"<{len(value)} bytes>"
331
+ if isinstance(value, str):
332
+ return f"<{len(value)} characters>"
333
+ if is_mapping(value):
334
+ keys = ", ".join(sorted(str(key) for key in value))
335
+ return f"<mapping with keys: {keys}>"
336
+ if is_list(value) or isinstance(value, tuple):
337
+ return f"<{len(cast('list[object]', value))} items>"
338
+ return f"<{type(value).__name__}>"
339
+
340
+
341
+ def _read_deadline(request: httpx.Request, started: float) -> float | None:
342
+ """When reading a buffered body must have finished: the request's read timeout after `started`, when its headers arrived.
343
+
344
+ httpx applies its read timeout to each socket read, so a server trickling one byte per read keeps
345
+ a body arriving forever; this turns the same number into a deadline for reading the whole body.
346
+ """
347
+ timeout = cast("Mapping[str, Any]", request.extensions.get("timeout") or {})
348
+ read = timeout.get("read")
349
+ return None if read is None else started + float(read)
350
+
351
+
352
+ def _is_unbuffered_cast(cast_to: object) -> bool:
353
+ """Whether `cast_to` hands the body to the caller as it arrived: a binary download or a raw `httpx.Response`.
354
+
355
+ Those bodies are the caller's to bound, so neither the size cap nor the read deadline applies.
356
+ The legacy binary wrapper is matched by name, since its module ships only with the legacy surface.
357
+ """
358
+ origin = get_origin(cast_to) or cast_to
359
+ if not inspect.isclass(origin):
360
+ return False
361
+ if issubclass(
362
+ origin,
363
+ (
364
+ httpx.Response,
365
+ BinaryAPIResponse,
366
+ AsyncBinaryAPIResponse,
367
+ StreamedBinaryAPIResponse,
368
+ AsyncStreamedBinaryAPIResponse,
369
+ ),
370
+ ):
371
+ return True
372
+ return any(base.__name__ == "HttpxBinaryResponseContent" for base in origin.__mro__)
373
+
374
+
375
+ def _body_limit(response: httpx.Response, *, capped: bool) -> tuple[int | None, bool]:
376
+ """The byte limit for reading `response`, and whether reaching it truncates rather than refuses.
377
+
378
+ An error body is read for its message only, so it is cut at `MAX_ERROR_BODY_BYTES`; a success body
379
+ is refused past `MAX_RESPONSE_BODY_BYTES`, unless `capped` is off for a body the caller takes raw.
380
+ """
381
+ if not response.is_success:
382
+ return MAX_ERROR_BODY_BYTES, True
383
+ return (MAX_RESPONSE_BODY_BYTES if capped else None), False
384
+
385
+
386
+ def _too_large(response: httpx.Response, limit: int) -> _BodyTooLarge:
387
+ """The error for a success body that decoded past `limit` bytes."""
388
+ return _BodyTooLarge(
389
+ f"Response body exceeds the {limit} byte limit ({limit // (1024 * 1024)} MiB) for a buffered response",
390
+ request=response.request,
391
+ )
392
+
393
+
394
+ class _BodyBuffer:
395
+ """The decoded bytes of a buffered body read so far, held to the limit `_body_limit` sets."""
396
+
397
+ def __init__(self, response: httpx.Response, *, capped: bool) -> None:
398
+ self._response = response
399
+ self._limit, self._truncate = _body_limit(response, capped=capped)
400
+ self._chunks: List[bytes] = []
401
+ self._size = 0
402
+
403
+ def add(self, pieces: Iterable[bytes]) -> bool:
404
+ """Appends `pieces`; True once a truncated body is full and reading should stop.
405
+
406
+ A body refused past the limit raises `_BodyTooLarge` as soon as a piece would cross it, so a
407
+ decompression bomb stops being inflated there.
408
+ """
409
+ for piece in pieces:
410
+ if self._limit is not None and self._size + len(piece) > self._limit:
411
+ if not self._truncate:
412
+ raise _too_large(self._response, self._limit)
413
+ self._chunks.append(piece[: self._limit - self._size])
414
+ return True
415
+ self._size += len(piece)
416
+ self._chunks.append(piece)
417
+ return False
418
+
419
+ @property
420
+ def truncates(self) -> bool:
421
+ """Whether this is an error body, read for its message: past the limit or the deadline it is cut, not refused."""
422
+ return self._truncate
423
+
424
+ def content(self) -> bytes:
425
+ """The body read, decoded and joined."""
426
+ return b"".join(self._chunks)
427
+
428
+
429
+ def _read_body(response: httpx.Response, *, capped: bool, deadline: float | None) -> None:
430
+ """Reads `response`'s body into memory, bounded in size and, when `deadline` is set, in time.
431
+
432
+ The bytes are counted after transparent decompression, and gzip and deflate (stacked or not) are
433
+ inflated by `BoundedDecoder` in bounded steps, so a small compressed body cannot expand past the
434
+ limit. A read still running at `deadline` raises `httpx.ReadTimeout`, which the request loop
435
+ retries or reports like any other timeout, except on an error body, which keeps what arrived by
436
+ then so its status error still stands; httpx cannot interrupt one blocked socket read, so the
437
+ deadline can be overrun by at most one read timeout.
438
+ """
439
+ body = _BodyBuffer(response, capped=capped)
440
+ try:
441
+ decoder = BoundedDecoder.for_response(response, capped=capped)
442
+ if decoder is None:
443
+ for chunk in response.iter_bytes():
444
+ if body.add((chunk,)):
445
+ break
446
+ if deadline is not None and time.monotonic() > deadline:
447
+ if body.truncates:
448
+ break
449
+ raise httpx.ReadTimeout("Timed out reading the response body", request=response.request)
450
+ else:
451
+ for raw in response.iter_raw():
452
+ if body.add(decoder.decode(raw)):
453
+ break
454
+ if deadline is not None and time.monotonic() > deadline:
455
+ if body.truncates:
456
+ break
457
+ raise httpx.ReadTimeout("Timed out reading the response body", request=response.request)
458
+ else:
459
+ body.add(decoder.flush())
460
+ except CodingRefused as exc:
461
+ raise _BodyRefused(exc.message, request=response.request) from None
462
+ except zlib.error as exc:
463
+ raise decoding_error(response, exc) from exc
464
+ finally:
465
+ response.close()
466
+ response._content = body.content() # pyright: ignore[reportPrivateUsage]
467
+
468
+
469
+ async def _aread_body(response: httpx.Response, *, capped: bool, deadline: float | None) -> None:
470
+ """The async twin of `_read_body`, where the deadline cancels a read that is still blocked."""
471
+ body = _BodyBuffer(response, capped=capped)
472
+
473
+ async def read() -> None:
474
+ """Reads the body into `body` until it ends or `body` is full."""
475
+ decoder = BoundedDecoder.for_response(response, capped=capped)
476
+ if decoder is None:
477
+ async for chunk in response.aiter_bytes():
478
+ if body.add((chunk,)):
479
+ return
480
+ return
481
+ async for raw in response.aiter_raw():
482
+ if body.add(decoder.decode(raw)):
483
+ return
484
+ body.add(decoder.flush())
485
+
486
+ try:
487
+ if deadline is None:
488
+ await read()
489
+ else:
490
+ try:
491
+ with anyio.fail_after(max(0.0, deadline - time.monotonic())):
492
+ await read()
493
+ except TimeoutError as exc:
494
+ if not body.truncates:
495
+ raise httpx.ReadTimeout("Timed out reading the response body", request=response.request) from exc
496
+ except CodingRefused as exc:
497
+ raise _BodyRefused(exc.message, request=response.request) from None
498
+ except zlib.error as exc:
499
+ raise decoding_error(response, exc) from exc
500
+ finally:
501
+ await response.aclose()
502
+ response._content = body.content() # pyright: ignore[reportPrivateUsage]
503
+
504
+
505
+ @dataclass
506
+ class SchemeCredentials:
507
+ """The credential one security scheme puts on a request, split by where it is sent."""
508
+
509
+ headers: Dict[str, str] = field(default_factory=dict)
510
+ query: Dict[str, str] = field(default_factory=dict)
511
+ cookies: Dict[str, str] = field(default_factory=dict)
512
+
513
+ @property
514
+ def configured(self) -> bool:
515
+ """Whether the scheme's client option(s) are set, so it has a credential to send."""
516
+ return bool(self.headers or self.query or self.cookies)
517
+
518
+
519
+ def _merge_scheme_credentials(parts: List[SchemeCredentials]) -> SchemeCredentials:
520
+ """The credentials of several schemes sent together, one request's worth."""
521
+ merged = SchemeCredentials()
522
+ for part in parts:
523
+ merged.headers.update(part.headers)
524
+ merged.query.update(part.query)
525
+ merged.cookies.update(part.cookies)
526
+ return merged
527
+
528
+
529
+ @dataclass(frozen=True)
530
+ class AuthLocations:
531
+ """The lower-cased header names, and the query and cookie names, the client's credentials use."""
532
+
533
+ headers: frozenset[str] = frozenset()
534
+ query: frozenset[str] = frozenset()
535
+ cookies: frozenset[str] = frozenset()
536
+
537
+
538
+ @dataclass
539
+ class CursorPageConfig:
540
+ """Per-operation cursor pagination descriptor baked into a page by the generated list method.
541
+
542
+ The Python view of the shared, language-neutral descriptor: where the items array lives, how to
543
+ read the next cursor, which request param carries it, and on which carrier (query vs body).
544
+ Driving extraction from this config — rather than typed `items`/`next_cursor` model fields — lets
545
+ one page class serve every cursor scheme, including `cursor_id` (cursor read from the last item)
546
+ and body-carried cursors.
547
+ """
548
+
549
+ # Body path to the items array (e.g. ["items"], ["data"]).
550
+ items_path: List[str] = field(default_factory=list)
551
+ # "field" (a top-level response field) or "item" (a field on the last item — `cursor_id`).
552
+ cursor_kind: str = "field"
553
+ # Path to the cursor value: from the body root when cursor_kind is "field", or from the last item.
554
+ cursor_path: List[str] = field(default_factory=list)
555
+ # Request param wire name that carries the cursor.
556
+ cursor_param: str = "cursor"
557
+ # "query" or "body": the carrier the cursor param rides on the next request.
558
+ cursor_location: str = "query"
559
+ # Keep paginating across an empty page when set.
560
+ continue_on_empty_items: bool = False
561
+ # Body path to the scheme's `has_more` boolean. Only an explicit False stops pagination;
562
+ # a missing or non-boolean value falls back to cursor-presence semantics.
563
+ has_more_path: List[str] = field(default_factory=list)
564
+
565
+
566
+ class PageInfo:
567
+ """Stores the necessary information to build the request to retrieve the next page.
568
+
569
+ Exactly one of `url`, `params` (query carrier), or `json` (body carrier) must be set.
570
+ """
571
+
572
+ url: URL | NotGiven
573
+ params: Query | NotGiven
574
+ json: Body | NotGiven
575
+
576
+ @overload
577
+ def __init__(
578
+ self,
579
+ *,
580
+ url: URL,
581
+ ) -> None: ...
582
+
583
+ @overload
584
+ def __init__(
585
+ self,
586
+ *,
587
+ params: Query,
588
+ ) -> None: ...
589
+
590
+ @overload
591
+ def __init__(
592
+ self,
593
+ *,
594
+ json: Body,
595
+ ) -> None: ...
596
+
597
+ def __init__(
598
+ self,
599
+ *,
600
+ url: URL | NotGiven = not_given,
601
+ json: Body | NotGiven = not_given,
602
+ params: Query | NotGiven = not_given,
603
+ ) -> None:
604
+ self.url = url
605
+ self.json = json
606
+ self.params = params
607
+
608
+ @override
609
+ def __repr__(self) -> str:
610
+ if self.url:
611
+ return f"{self.__class__.__name__}(url={self.url})"
612
+ if self.json:
613
+ return f"{self.__class__.__name__}(json={self.json})"
614
+ return f"{self.__class__.__name__}(params={self.params})"
615
+
616
+
617
+ class BasePage(GenericModel, Generic[_T]):
618
+ """
619
+ Defines the core interface for pagination.
620
+
621
+ Type Args:
622
+ ModelT: The pydantic model that represents an item in the response.
623
+
624
+ Methods:
625
+ has_next_page(): Check if there is another page available
626
+ next_page_info(): Get the necessary information to make a request for the next page
627
+ """
628
+
629
+ _options: FinalRequestOptions = PrivateAttr()
630
+ _model: Type[_T] = PrivateAttr()
631
+ # Per-operation cursor descriptor; set by the paginated method via _set_private_attributes and
632
+ # carried forward across pages so every page extracts items/cursor identically. None for pages
633
+ # that predate metadata-driven pagination.
634
+ _cursor_config: Optional[CursorPageConfig] = PrivateAttr(None)
635
+
636
+ def has_next_page(self) -> bool:
637
+ items = self._get_page_items()
638
+ if not items:
639
+ return False
640
+ return self.next_page_info() is not None
641
+
642
+ def next_page_info(self) -> Optional[PageInfo]: ...
643
+
644
+ def _get_page_items(self) -> Iterable[_T]: # type: ignore[empty-body]
645
+ ...
646
+
647
+ def _params_from_url(self, url: URL) -> httpx.QueryParams:
648
+ # TODO: do we have to preprocess params here?
649
+ return httpx.QueryParams(cast(Any, self._options.params)).merge(url.params)
650
+
651
+ def _info_to_options(self, info: PageInfo) -> FinalRequestOptions:
652
+ options = model_copy(self._options)
653
+ options._strip_raw_response_header()
654
+
655
+ if not isinstance(info.params, NotGiven):
656
+ options.params = {**options.params, **info.params}
657
+ return options
658
+
659
+ if not isinstance(info.url, NotGiven):
660
+ # The next request carries this client's credentials, so a page URL a response names on
661
+ # another scheme, host or port is refused rather than dialed: a server must not be able
662
+ # to send the caller's key elsewhere by naming it as the next page. The message names
663
+ # neither URL, since either may carry a token. The origin compared is the one that served
664
+ # this page: an operation with its own `servers` entry is sent to an absolute URL, else
665
+ # to the client's base URL. `_client` is declared by the sync and async page classes,
666
+ # each with its own client type.
667
+ current = URL(self._options.url)
668
+ served_by: URL = current if current.is_absolute_url else cast(Any, self)._client.base_url
669
+ if info.url.is_absolute_url and _origin(info.url) != _origin(served_by):
670
+ raise RuntimeError("Refusing to follow a next page URL that points away from the API's origin")
671
+ params = self._params_from_url(info.url)
672
+ url = info.url.copy_with(params=params)
673
+ options.params = dict(url.params)
674
+ options.url = str(url)
675
+ return options
676
+
677
+ if not isinstance(info.json, NotGiven):
678
+ if not is_mapping(info.json):
679
+ raise TypeError("Pagination is only supported with mappings")
680
+
681
+ if not options.json_data:
682
+ options.json_data = {**info.json}
683
+ else:
684
+ if not is_mapping(options.json_data):
685
+ raise TypeError("Pagination is only supported with mappings")
686
+
687
+ options.json_data = {**options.json_data, **info.json}
688
+ return options
689
+
690
+ raise ValueError("Unexpected PageInfo state")
691
+
692
+ def _next_page_repeats(self) -> bool:
693
+ """Whether the next page's request would be exactly the request that fetched this page.
694
+
695
+ A server that answers with the cursor (or link, or body token) it was just given would make
696
+ auto-pagination fetch the same page forever, so the walk stops there instead.
697
+ """
698
+ info = self.next_page_info()
699
+ if info is None:
700
+ return False
701
+ try:
702
+ options = self._info_to_options(info)
703
+ except (TypeError, ValueError):
704
+ return False
705
+ return (
706
+ options.url == self._options.url
707
+ and options.params == self._options.params
708
+ and options.json_data == self._options.json_data
709
+ )
710
+
711
+
712
+ class BaseSyncPage(BasePage[_T], Generic[_T]):
713
+ _client: SyncAPIClient = pydantic.PrivateAttr()
714
+
715
+ def _set_private_attributes(
716
+ self,
717
+ client: SyncAPIClient,
718
+ model: Type[_T],
719
+ options: FinalRequestOptions,
720
+ cursor_config: Optional[CursorPageConfig] = None,
721
+ ) -> None:
722
+ if (not PYDANTIC_V1) and getattr(self, "__pydantic_private__", None) is None:
723
+ self.__pydantic_private__ = {}
724
+
725
+ self._model = model
726
+ self._client = client
727
+ self._options = options
728
+ self._cursor_config = cursor_config
729
+
730
+ # Pydantic uses a custom `__iter__` method to support casting BaseModels
731
+ # to dictionaries. e.g. dict(model).
732
+ # As we want to support `for item in page`, this is inherently incompatible
733
+ # with the default pydantic behaviour. It is not possible to support both
734
+ # use cases at once. Fortunately, this is not a big deal as all other pydantic
735
+ # methods should continue to work as expected as there is an alternative method
736
+ # to cast a model to a dictionary, model.dict(), which is used internally
737
+ # by pydantic.
738
+ def __iter__(self) -> Iterator[_T]: # type: ignore
739
+ for page in self.iter_pages():
740
+ for item in page._get_page_items():
741
+ yield item
742
+
743
+ def iter_pages(self: SyncPageT) -> Iterator[SyncPageT]:
744
+ page = self
745
+ while True:
746
+ yield page
747
+ if page.has_next_page() and not page._next_page_repeats():
748
+ page = page.get_next_page()
749
+ else:
750
+ return
751
+
752
+ def get_next_page(self: SyncPageT) -> SyncPageT:
753
+ info = self.next_page_info()
754
+ if not info:
755
+ raise RuntimeError(
756
+ "No next page expected; please check `.has_next_page()` before calling `.get_next_page()`."
757
+ )
758
+
759
+ options = self._info_to_options(info)
760
+ # Carry the cursor descriptor forward so the next page extracts items/cursor identically.
761
+ return self._client._request_api_list(
762
+ self._model, page=self.__class__, options=options, cursor_config=self._cursor_config
763
+ )
764
+
765
+
766
+ class AsyncPaginator(Generic[_T, AsyncPageT]):
767
+ def __init__(
768
+ self,
769
+ client: AsyncAPIClient,
770
+ options: FinalRequestOptions,
771
+ page_cls: Type[AsyncPageT],
772
+ model: Type[_T],
773
+ cursor_config: Optional[CursorPageConfig] = None,
774
+ ) -> None:
775
+ self._model = model
776
+ self._client = client
777
+ self._options = options
778
+ self._page_cls = page_cls
779
+ self._cursor_config = cursor_config
780
+
781
+ def __await__(self) -> Generator[Any, None, AsyncPageT]:
782
+ return self._get_page().__await__()
783
+
784
+ async def _get_page(self) -> AsyncPageT:
785
+ def _parser(resp: AsyncPageT) -> AsyncPageT:
786
+ resp._set_private_attributes(
787
+ model=self._model,
788
+ options=self._options,
789
+ client=self._client,
790
+ cursor_config=self._cursor_config,
791
+ )
792
+ return resp
793
+
794
+ self._options.post_parser = _parser
795
+
796
+ return await self._client.request(self._page_cls, self._options)
797
+
798
+ async def __aiter__(self) -> AsyncIterator[_T]:
799
+ # https://github.com/microsoft/pyright/issues/3464
800
+ page = cast(
801
+ AsyncPageT,
802
+ await self, # type: ignore
803
+ )
804
+ async for item in page:
805
+ yield item
806
+
807
+
808
+ class BaseAsyncPage(BasePage[_T], Generic[_T]):
809
+ _client: AsyncAPIClient = pydantic.PrivateAttr()
810
+
811
+ def _set_private_attributes(
812
+ self,
813
+ model: Type[_T],
814
+ client: AsyncAPIClient,
815
+ options: FinalRequestOptions,
816
+ cursor_config: Optional[CursorPageConfig] = None,
817
+ ) -> None:
818
+ if (not PYDANTIC_V1) and getattr(self, "__pydantic_private__", None) is None:
819
+ self.__pydantic_private__ = {}
820
+
821
+ self._model = model
822
+ self._client = client
823
+ self._options = options
824
+ self._cursor_config = cursor_config
825
+
826
+ async def __aiter__(self) -> AsyncIterator[_T]:
827
+ async for page in self.iter_pages():
828
+ for item in page._get_page_items():
829
+ yield item
830
+
831
+ async def iter_pages(self: AsyncPageT) -> AsyncIterator[AsyncPageT]:
832
+ page = self
833
+ while True:
834
+ yield page
835
+ if page.has_next_page() and not page._next_page_repeats():
836
+ page = await page.get_next_page()
837
+ else:
838
+ return
839
+
840
+ async def get_next_page(self: AsyncPageT) -> AsyncPageT:
841
+ info = self.next_page_info()
842
+ if not info:
843
+ raise RuntimeError(
844
+ "No next page expected; please check `.has_next_page()` before calling `.get_next_page()`."
845
+ )
846
+
847
+ options = self._info_to_options(info)
848
+ # Carry the cursor descriptor forward so the next page extracts items/cursor identically.
849
+ return await self._client._request_api_list(
850
+ self._model, page=self.__class__, options=options, cursor_config=self._cursor_config
851
+ )
852
+
853
+
854
+ _HttpxClientT = TypeVar("_HttpxClientT", bound=Union[httpx.Client, httpx.AsyncClient])
855
+ _DefaultStreamT = TypeVar("_DefaultStreamT", bound=Union[Stream[Any], AsyncStream[Any]])
856
+
857
+
858
+ class BaseClient(Generic[_HttpxClientT, _DefaultStreamT]):
859
+ _client: _HttpxClientT
860
+ _version: str
861
+ _base_url: URL
862
+ max_retries: int
863
+ timeout: Union[float, Timeout, None]
864
+ _strict_response_validation: bool
865
+ _idempotency_header: str | None
866
+ _default_stream_cls: type[_DefaultStreamT] | None = None
867
+
868
+ def __init__(
869
+ self,
870
+ *,
871
+ version: str,
872
+ base_url: str | URL,
873
+ _strict_response_validation: bool,
874
+ max_retries: int = DEFAULT_MAX_RETRIES,
875
+ timeout: float | Timeout | None = DEFAULT_TIMEOUT,
876
+ custom_headers: Mapping[str, str] | None = None,
877
+ custom_query: Mapping[str, object] | None = None,
878
+ ) -> None:
879
+ self._version = version
880
+ self._base_url = self._enforce_trailing_slash(URL(base_url))
881
+ self.max_retries = max_retries
882
+ self.timeout = timeout
883
+ self._custom_headers = custom_headers or {}
884
+ self._custom_query = custom_query or {}
885
+ self._strict_response_validation = _strict_response_validation
886
+ self._idempotency_header = None
887
+ self._platform: Platform | None = None
888
+
889
+ if max_retries is None: # pyright: ignore[reportUnnecessaryComparison]
890
+ raise TypeError(
891
+ "max_retries cannot be None. If you want to disable retries, pass `0`; if you want unlimited retries, pass `math.inf` or a very high number; if you want the default behavior, pass `brellium_api_client.DEFAULT_MAX_RETRIES`"
892
+ )
893
+
894
+ def _enforce_trailing_slash(self, url: URL) -> URL:
895
+ if url.raw_path.endswith(b"/"):
896
+ return url
897
+ return url.copy_with(raw_path=url.raw_path + b"/")
898
+
899
+ def _make_status_error_from_response(
900
+ self,
901
+ response: httpx.Response,
902
+ ) -> APIStatusError:
903
+ if response.is_closed and not response.is_stream_consumed:
904
+ # We can't read the response body as it has been closed
905
+ # before it was read. This can happen if an event hook
906
+ # raises a status error.
907
+ body = None
908
+ err_msg = f"Error code: {response.status_code}"
909
+ else:
910
+ err_text = response.text.strip()
911
+ body = err_text
912
+
913
+ try:
914
+ body = json.loads(err_text)
915
+ err_msg = f"Error code: {response.status_code} - {body}"
916
+ except Exception:
917
+ err_msg = err_text or f"Error code: {response.status_code}"
918
+
919
+ return self._make_status_error(err_msg, body=body, response=response)
920
+
921
+ def _make_status_error(
922
+ self,
923
+ err_msg: str,
924
+ *,
925
+ body: object,
926
+ response: httpx.Response,
927
+ ) -> _exceptions.APIStatusError:
928
+ raise NotImplementedError()
929
+
930
+ @property
931
+ def _auth_by_scheme(self) -> dict[str, SchemeCredentials]:
932
+ """The credentials each security scheme is sent with, by the scheme's name in the document."""
933
+ return {}
934
+
935
+ def _select_auth(self, security: SecurityOptions) -> SchemeCredentials:
936
+ """
937
+ The credentials an operation with its own `security` requirements is sent with.
938
+
939
+ The first alternative whose schemes all have a configured credential wins, and only its
940
+ credentials are sent: two schemes can share a wire slot (`Authorization` for Basic and
941
+ Bearer), so merging every configured credential would let one overwrite the one the
942
+ operation asked for. An empty alternative is the anonymous one and selects nothing.
943
+
944
+ With no alternative met, the configured part of the first alternative that has one is sent:
945
+ the request only goes out at all when the caller supplies the rest some other way (see
946
+ `_credential_supplied`), and withholding the half the client holds would leave it incomplete.
947
+ An empty (anonymous) alternative counts as met, so an operation that also accepts `{}` is sent
948
+ nothing rather than a partial credential. Only schemes the operation names are ever sent.
949
+ """
950
+ available = self._auth_by_scheme
951
+ partial: SchemeCredentials | None = None
952
+ for alternative in security:
953
+ parts = [available.get(scheme) for scheme in alternative]
954
+ configured = [part for part in parts if part is not None and part.configured]
955
+ if len(configured) == len(parts):
956
+ return _merge_scheme_credentials(configured)
957
+ if partial is None and configured:
958
+ partial = _merge_scheme_credentials(configured)
959
+ return partial if partial is not None else SchemeCredentials()
960
+
961
+ @property
962
+ def _auth_locations(self) -> AuthLocations:
963
+ """Where the client's credentials travel, configured or not."""
964
+ return AuthLocations()
965
+
966
+ def _credential_supplied(
967
+ self,
968
+ security: SecurityOptions,
969
+ headers: Mapping[str, str],
970
+ custom_headers: Headers,
971
+ params: Query,
972
+ cookies: Mapping[str, str],
973
+ ) -> bool:
974
+ """
975
+ Whether the request already carries a credential the caller set some other way than a client
976
+ option — the client's `default_headers` or `default_query`, or the call's `extra_headers` or
977
+ `extra_query` — or omits a credential header on purpose, as the client-wide check accepts.
978
+
979
+ A value `_select_auth` put on the request is the client's own partial credential, not the
980
+ caller's, so it never counts: a half-configured alternative is refused unless the caller
981
+ supplies the rest.
982
+ """
983
+ locations = self._auth_locations
984
+ selected = self._select_auth(security)
985
+ selected_headers = {name.lower(): value for name, value in selected.headers.items()}
986
+ return (
987
+ any(
988
+ name.lower() in locations.headers and value and selected_headers.get(name.lower()) != value
989
+ for name, value in headers.items()
990
+ )
991
+ or any(
992
+ name.lower() in locations.headers and isinstance(value, Omit) for name, value in custom_headers.items()
993
+ )
994
+ or any(
995
+ params.get(name) is not None and params.get(name) != selected.query.get(name)
996
+ for name in locations.query
997
+ )
998
+ or any(
999
+ cookies.get(name) is not None and cookies.get(name) != selected.cookies.get(name)
1000
+ for name in locations.cookies
1001
+ )
1002
+ )
1003
+
1004
+ def _security_met(self, security: SecurityOptions) -> bool:
1005
+ """
1006
+ Whether an operation's own requirements can be met: it is public (`[]`), lists the anonymous
1007
+ alternative (an empty one), or the credentials of some alternative are all configured.
1008
+ """
1009
+ available = self._auth_by_scheme
1010
+ return not security or any(
1011
+ all((part := available.get(scheme)) is not None and part.configured for scheme in alternative)
1012
+ for alternative in security
1013
+ )
1014
+
1015
+ def _auth_query(
1016
+ self,
1017
+ security: SecurityOptions | None, # noqa: ARG002
1018
+ ) -> dict[str, str]:
1019
+ return {}
1020
+
1021
+ def _auth_cookies(
1022
+ self,
1023
+ security: SecurityOptions | None, # noqa: ARG002
1024
+ ) -> dict[str, str]:
1025
+ return {}
1026
+
1027
+ def _custom_auth(
1028
+ self,
1029
+ security: SecurityOptions | None, # noqa: ARG002
1030
+ ) -> httpx.Auth | None:
1031
+ # Single hook for custom httpx auth on both the sync and async request paths. It defaults to
1032
+ # the legacy public `custom_auth` property so subclasses that already override that property
1033
+ # keep applying identically on both paths; override `_custom_auth` itself to layer
1034
+ # per-operation, security-aware auth on top.
1035
+ return self.custom_auth
1036
+
1037
+ def _build_headers(
1038
+ self,
1039
+ options: FinalRequestOptions,
1040
+ *,
1041
+ params: Query,
1042
+ cookies: Mapping[str, str],
1043
+ retries_taken: int = 0,
1044
+ ) -> httpx.Headers:
1045
+ custom_headers = options.headers or {}
1046
+ # Auth headers are folded into `default_headers` (matching the reference SDKs), so
1047
+ # merging `default_headers` here already applies the configured credentials.
1048
+ default_headers = self.default_headers
1049
+ if options.security is not None:
1050
+ # The operation names its own requirements: swap the client-wide credential headers for
1051
+ # the selected alternative's, keeping any header the caller configured on the client.
1052
+ auth_names = {name.lower() for name in self.auth_headers}
1053
+ custom_names = {name.lower() for name in self._custom_headers}
1054
+ default_headers = {
1055
+ **{
1056
+ name: value
1057
+ for name, value in default_headers.items()
1058
+ if name.lower() not in auth_names or name.lower() in custom_names
1059
+ },
1060
+ **self._select_auth(options.security).headers,
1061
+ **self._custom_headers,
1062
+ }
1063
+ headers_dict = _merge_mappings(default_headers, custom_headers)
1064
+ if options.security is None:
1065
+ # The client-wide check that some credential is configured.
1066
+ self._validate_headers(headers_dict, custom_headers, params, cookies)
1067
+ elif not self._security_met(options.security) and not self._credential_supplied(
1068
+ options.security, headers_dict, custom_headers, params, cookies
1069
+ ):
1070
+ # An operation naming its own requirements is checked against those alone: a public one
1071
+ # or one met by a query key is never refused for want of a header, and the client-wide
1072
+ # check would both pass a document whose root allows anonymous calls and name credentials
1073
+ # that cannot meet this operation. A credential the caller supplies outside the client's
1074
+ # options takes the place of a configured one; the selected credentials are already
1075
+ # swapped in above, so an unselected configured one does not count.
1076
+ alternatives = " or ".join(" + ".join(alternative) for alternative in options.security)
1077
+ raise TypeError(
1078
+ f"Could not resolve authentication method for this operation. Expected the credentials of {alternatives} to be set."
1079
+ )
1080
+
1081
+ # headers are case-insensitive while dictionaries are not.
1082
+ headers = httpx.Headers(headers_dict)
1083
+
1084
+ idempotency_header = self._idempotency_header
1085
+ if idempotency_header and options.idempotency_key and idempotency_header not in headers:
1086
+ headers[idempotency_header] = options.idempotency_key
1087
+
1088
+ # Don't set these headers if they were already set or removed by the caller. We check
1089
+ # `custom_headers`, which can contain `Omit()`, instead of `headers` to account for the removal case.
1090
+ lower_custom_headers = [header.lower() for header in custom_headers]
1091
+ if "x-scalar-retry-count" not in lower_custom_headers:
1092
+ headers["x-scalar-retry-count"] = str(retries_taken)
1093
+ if "x-scalar-read-timeout" not in lower_custom_headers:
1094
+ timeout = self.timeout if isinstance(options.timeout, NotGiven) else options.timeout
1095
+ if isinstance(timeout, Timeout):
1096
+ timeout = timeout.read
1097
+ if timeout is not None:
1098
+ headers["x-scalar-read-timeout"] = str(timeout)
1099
+
1100
+ return headers
1101
+
1102
+ def _prepare_url(self, url: str) -> URL:
1103
+ """
1104
+ Merge a URL argument together with any 'base_url' on the client,
1105
+ to create the URL used for the outgoing request.
1106
+ """
1107
+ target = URL(url)
1108
+ if target.is_absolute_url:
1109
+ return target
1110
+
1111
+ # A relative URL always extends the base path, even when it starts with "/": operation paths
1112
+ # are written from the API root, which is wherever `base_url` points. Exactly one "/" joins
1113
+ # the two halves. `raw_path` carries the query string, so a query on `url` is kept.
1114
+ base_path = self.base_url.raw_path.rstrip(b"/")
1115
+ return self.base_url.copy_with(raw_path=b"/".join((base_path, target.raw_path.lstrip(b"/"))))
1116
+
1117
+ def _make_sse_decoder(self) -> SSEDecoder | SSEBytesDecoder:
1118
+ return SSEDecoder()
1119
+
1120
+ def _build_request(
1121
+ self,
1122
+ options: FinalRequestOptions,
1123
+ *,
1124
+ retries_taken: int = 0,
1125
+ ) -> httpx.Request:
1126
+ if log.isEnabledFor(logging.DEBUG):
1127
+ # The request bodies are described, never dumped: a body carries whatever the caller
1128
+ # sends (passwords, tokens, personal data), and the log redaction only knows credential
1129
+ # header and query names. The headers and params are dumped, masked by that redaction.
1130
+ dumped = model_dump(
1131
+ options,
1132
+ exclude_unset=True,
1133
+ exclude={"content", "json_data", "extra_json", "files"},
1134
+ )
1135
+ for name in ("content", "json_data", "extra_json", "files"):
1136
+ value: object = getattr(options, name, None)
1137
+ if value is not None and is_given(value):
1138
+ dumped[name] = _describe_body(value)
1139
+ log.debug("Request options: %s", dumped)
1140
+ kwargs: dict[str, Any] = {}
1141
+
1142
+ json_data = options.json_data
1143
+ if options.extra_json is not None:
1144
+ if json_data is None:
1145
+ json_data = cast(Body, options.extra_json)
1146
+ elif is_mapping(json_data):
1147
+ json_data = _merge_mappings(json_data, options.extra_json)
1148
+ else:
1149
+ raise RuntimeError(f"Unexpected JSON data type, {type(json_data)}, cannot merge with `extra_body`")
1150
+
1151
+ params = _merge_mappings({**self._auth_query(options.security), **self.default_query}, options.params)
1152
+ cookies = self._auth_cookies(options.security)
1153
+ headers = self._build_headers(options, retries_taken=retries_taken, params=params, cookies=cookies)
1154
+ content_type = headers.get("Content-Type")
1155
+ files = options.files
1156
+
1157
+ # If the given Content-Type header is multipart/form-data then it
1158
+ # has to be removed so that httpx can generate the header with
1159
+ # additional information for us as it has to be in this form
1160
+ # for the server to be able to correctly parse the request:
1161
+ # multipart/form-data; boundary=---abc--
1162
+ if content_type is not None and content_type.startswith("multipart/form-data"):
1163
+ if "boundary" not in content_type:
1164
+ # only remove the header if the boundary hasn't been explicitly set
1165
+ # as the caller doesn't want httpx to come up with their own boundary
1166
+ headers.pop("Content-Type")
1167
+
1168
+ # As we are now sending multipart/form-data instead of application/json
1169
+ # we need to tell httpx to use it, https://www.python-httpx.org/advanced/clients/#multipart-file-encoding
1170
+ if json_data:
1171
+ if not is_dict(json_data):
1172
+ raise TypeError(
1173
+ f"Expected query input to be a dictionary for multipart requests but got {type(json_data)} instead."
1174
+ )
1175
+ kwargs["data"] = self._serialize_multipartform(json_data)
1176
+
1177
+ # httpx determines whether or not to send a "multipart/form-data"
1178
+ # request based on the truthiness of the "files" argument.
1179
+ # This gets around that issue by generating a dict value that
1180
+ # evaluates to true.
1181
+ #
1182
+ # https://github.com/encode/httpx/discussions/2399#discussioncomment-3814186
1183
+ if not files:
1184
+ files = cast(HttpxRequestFiles, ForceMultipartDict())
1185
+
1186
+ prepared_url = self._prepare_url(options.url)
1187
+ # preserve hard-coded query params from the url
1188
+ if params and prepared_url.query:
1189
+ params = {**dict(prepared_url.params.items()), **params}
1190
+ prepared_url = prepared_url.copy_with(raw_path=prepared_url.raw_path.split(b"?", 1)[0])
1191
+ if "_" in prepared_url.host:
1192
+ # work around https://github.com/encode/httpx/discussions/2880
1193
+ kwargs["extensions"] = {"sni_hostname": prepared_url.host.replace("_", "-")}
1194
+
1195
+ is_body_allowed = options.method.lower() != "get"
1196
+
1197
+ if is_body_allowed:
1198
+ if options.content is not None and json_data is not None:
1199
+ raise TypeError("Passing both `content` and `json_data` is not supported")
1200
+ if options.content is not None and files is not None:
1201
+ raise TypeError("Passing both `content` and `files` is not supported")
1202
+ if options.content is not None:
1203
+ kwargs["content"] = options.content
1204
+ elif isinstance(json_data, bytes):
1205
+ kwargs["content"] = json_data
1206
+ elif not files:
1207
+ # Don't set content when JSON is sent as multipart/form-data,
1208
+ # since httpx's content param overrides other body arguments
1209
+ kwargs["content"] = openapi_dumps(json_data) if is_given(json_data) and json_data is not None else None
1210
+ kwargs["files"] = files
1211
+ else:
1212
+ headers.pop("Content-Type", None)
1213
+ kwargs.pop("data", None)
1214
+
1215
+ # TODO: report this error to httpx
1216
+ return self._client.build_request( # pyright: ignore[reportUnknownMemberType]
1217
+ headers=headers,
1218
+ timeout=self.timeout if isinstance(options.timeout, NotGiven) else options.timeout,
1219
+ method=options.method,
1220
+ url=prepared_url,
1221
+ # the `Query` type that we use is incompatible with qs'
1222
+ # `Params` type as it needs to be typed as `Mapping[str, object]`
1223
+ # so that passing a `TypedDict` doesn't cause an error.
1224
+ # https://github.com/microsoft/pyright/issues/3526#event-6715453066
1225
+ params=self.qs.stringify(cast(Mapping[str, Any], params)) if params else None,
1226
+ cookies=cookies or None,
1227
+ **kwargs,
1228
+ )
1229
+
1230
+ def _serialize_multipartform(self, data: Mapping[object, object]) -> dict[str, object]:
1231
+ items = self.qs.stringify_items(
1232
+ # TODO: type ignore is required as stringify_items is well typed but we can't be
1233
+ # well typed without heavy validation.
1234
+ data, # type: ignore
1235
+ array_format="brackets",
1236
+ )
1237
+ serialized: dict[str, object] = {}
1238
+ for key, value in items:
1239
+ existing = serialized.get(key)
1240
+
1241
+ if not existing:
1242
+ serialized[key] = value
1243
+ continue
1244
+
1245
+ # If a value has already been set for this key then that
1246
+ # means we're sending data like `array[]=[1, 2, 3]` and we
1247
+ # need to tell httpx that we want to send multiple values with
1248
+ # the same key which is done by using a list or a tuple.
1249
+ #
1250
+ # Note: 2d arrays should never result in the same key at both
1251
+ # levels so it's safe to assume that if the value is a list,
1252
+ # it was because we changed it to be a list.
1253
+ if is_list(existing):
1254
+ existing.append(value)
1255
+ else:
1256
+ serialized[key] = [existing, value]
1257
+
1258
+ return serialized
1259
+
1260
+ def _maybe_override_cast_to(self, cast_to: type[ResponseT], options: FinalRequestOptions) -> type[ResponseT]:
1261
+ if not is_given(options.headers):
1262
+ return cast_to
1263
+
1264
+ # make a copy of the headers so we don't mutate user-input
1265
+ headers = dict(options.headers)
1266
+
1267
+ # we internally support defining a temporary header to override the
1268
+ # default `cast_to` type for use with `.with_raw_response` and `.with_streaming_response`
1269
+ # see _response.py for implementation details
1270
+ override_cast_to = headers.pop(OVERRIDE_CAST_TO_HEADER, not_given)
1271
+ if is_given(override_cast_to):
1272
+ options.headers = headers
1273
+ return cast(Type[ResponseT], override_cast_to)
1274
+
1275
+ return cast_to
1276
+
1277
+ def _should_stream_response_body(self, request: httpx.Request) -> bool:
1278
+ return request.headers.get(RAW_RESPONSE_HEADER) == "stream" # type: ignore[no-any-return]
1279
+
1280
+ def _process_response_data(
1281
+ self,
1282
+ *,
1283
+ data: object,
1284
+ cast_to: type[ResponseT],
1285
+ response: httpx.Response,
1286
+ ) -> ResponseT:
1287
+ if data is None:
1288
+ return cast(ResponseT, None)
1289
+
1290
+ if cast_to is object:
1291
+ return cast(ResponseT, data)
1292
+
1293
+ try:
1294
+ if inspect.isclass(cast_to) and issubclass(cast_to, ModelBuilderProtocol):
1295
+ return cast(ResponseT, cast_to.build(response=response, data=data))
1296
+
1297
+ if self._strict_response_validation:
1298
+ return cast(ResponseT, validate_type(type_=cast_to, value=data))
1299
+
1300
+ return cast(ResponseT, construct_type(type_=cast_to, value=data))
1301
+ except pydantic.ValidationError as err:
1302
+ raise APIResponseValidationError(response=response, body=data) from err
1303
+
1304
+ @property
1305
+ def qs(self) -> Querystring:
1306
+ return Querystring()
1307
+
1308
+ @property
1309
+ def custom_auth(self) -> httpx.Auth | None:
1310
+ return None
1311
+
1312
+ @property
1313
+ def auth_headers(self) -> dict[str, str]:
1314
+ return {}
1315
+
1316
+ @property
1317
+ def default_headers(self) -> dict[str, str | Omit]:
1318
+ return {
1319
+ "Accept": "application/json",
1320
+ # Only the codings `BoundedDecoder` inflates in bounded steps; httpx would otherwise add br
1321
+ # and zstd whenever their optional packages are installed, and inflates those unbounded.
1322
+ "Accept-Encoding": "gzip, deflate",
1323
+ "Content-Type": "application/json",
1324
+ "User-Agent": self.user_agent,
1325
+ **self.platform_headers(),
1326
+ **self.auth_headers,
1327
+ **self._custom_headers,
1328
+ }
1329
+
1330
+ @property
1331
+ def default_query(self) -> dict[str, object]:
1332
+ return {
1333
+ **self._custom_query,
1334
+ }
1335
+
1336
+ def _validate_headers(
1337
+ self,
1338
+ headers: Headers, # noqa: ARG002
1339
+ custom_headers: Headers, # noqa: ARG002
1340
+ params: Query, # noqa: ARG002
1341
+ cookies: Mapping[str, str], # noqa: ARG002
1342
+ ) -> None:
1343
+ """Validate merged auth-bearing headers, query params, and cookies.
1344
+
1345
+ Does nothing by default.
1346
+ """
1347
+ return
1348
+
1349
+ @property
1350
+ def user_agent(self) -> str:
1351
+ return f"{self.__class__.__name__}/Python {self._version}"
1352
+
1353
+ @property
1354
+ def base_url(self) -> URL:
1355
+ return self._base_url
1356
+
1357
+ @base_url.setter
1358
+ def base_url(self, url: URL | str) -> None:
1359
+ self._base_url = self._enforce_trailing_slash(url if isinstance(url, URL) else URL(url))
1360
+
1361
+ def platform_headers(self) -> Dict[str, str]:
1362
+ # the actual implementation is in a separate `lru_cache` decorated
1363
+ # function because adding `lru_cache` to methods will leak memory
1364
+ # https://github.com/python/cpython/issues/88476
1365
+ return platform_headers(self._version, platform=self._platform)
1366
+
1367
+ def _parse_retry_after_header(self, response_headers: Optional[httpx.Headers] = None) -> float | None:
1368
+ """Returns a float of the number of seconds (not milliseconds) to wait after retrying, or None if unspecified.
1369
+
1370
+ About the Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
1371
+ See also https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After#syntax
1372
+ """
1373
+ if response_headers is None:
1374
+ return None
1375
+
1376
+ # First, try the non-standard `retry-after-ms` header for milliseconds,
1377
+ # which is more precise than integer-seconds `retry-after`
1378
+ try:
1379
+ retry_ms_header = response_headers.get("retry-after-ms", None)
1380
+ return float(retry_ms_header) / 1000
1381
+ except (TypeError, ValueError):
1382
+ pass
1383
+
1384
+ # Next, try parsing `retry-after` header as seconds (allowing nonstandard floats).
1385
+ retry_header = response_headers.get("retry-after")
1386
+ try:
1387
+ # note: the spec indicates that this should only ever be an integer
1388
+ # but if someone sends a float there's no reason for us to not respect it
1389
+ return float(retry_header)
1390
+ except (TypeError, ValueError):
1391
+ pass
1392
+
1393
+ # Last, try parsing `retry-after` as a date.
1394
+ retry_date_tuple = email.utils.parsedate_tz(retry_header)
1395
+ if retry_date_tuple is None:
1396
+ return None
1397
+
1398
+ retry_date = email.utils.mktime_tz(retry_date_tuple)
1399
+ return float(retry_date - time.time())
1400
+
1401
+ def _calculate_retry_timeout(
1402
+ self,
1403
+ remaining_retries: int,
1404
+ options: FinalRequestOptions,
1405
+ response_headers: Optional[httpx.Headers] = None,
1406
+ ) -> float:
1407
+ max_retries = options.get_max_retries(self.max_retries)
1408
+
1409
+ # If the API asks us to wait a certain amount of time (and it's a reasonable amount), just do what it says.
1410
+ retry_after = self._parse_retry_after_header(response_headers)
1411
+ if retry_after is not None and 0 < retry_after <= 60:
1412
+ return retry_after
1413
+
1414
+ # Also cap retry count to 1000 to avoid any potential overflows with `pow`
1415
+ nb_retries = min(max_retries - remaining_retries, 1000)
1416
+
1417
+ # Apply exponential backoff, but not more than the max.
1418
+ sleep_seconds = min(INITIAL_RETRY_DELAY * pow(2.0, nb_retries), MAX_RETRY_DELAY)
1419
+
1420
+ # Apply some jitter, plus-or-minus half a second.
1421
+ jitter = 1 - 0.25 * random()
1422
+ timeout = sleep_seconds * jitter
1423
+ return timeout if timeout >= 0 else 0
1424
+
1425
+ def _should_retry(self, response: httpx.Response) -> bool:
1426
+ # Note: this is not a standard header
1427
+ should_retry_header = response.headers.get("x-should-retry")
1428
+
1429
+ # If the server explicitly says whether or not to retry, obey.
1430
+ if should_retry_header == "true":
1431
+ log.debug("Retrying as header `x-should-retry` is set to `true`")
1432
+ return True
1433
+ if should_retry_header == "false":
1434
+ log.debug("Not retrying as header `x-should-retry` is set to `false`")
1435
+ return False
1436
+
1437
+ # Retry on request timeouts.
1438
+ if response.status_code == 408:
1439
+ log.debug("Retrying due to status code %i", response.status_code)
1440
+ return True
1441
+
1442
+ # Retry on lock timeouts.
1443
+ if response.status_code == 409:
1444
+ log.debug("Retrying due to status code %i", response.status_code)
1445
+ return True
1446
+
1447
+ # Retry on rate limits.
1448
+ if response.status_code == 429:
1449
+ log.debug("Retrying due to status code %i", response.status_code)
1450
+ return True
1451
+
1452
+ # Retry internal errors.
1453
+ if response.status_code >= 500:
1454
+ log.debug("Retrying due to status code %i", response.status_code)
1455
+ return True
1456
+
1457
+ log.debug("Not retrying")
1458
+ return False
1459
+
1460
+ def _idempotency_key(self) -> str:
1461
+ return f"scalar-python-retry-{uuid.uuid4()}"
1462
+
1463
+
1464
+ class _DefaultHttpxClient(httpx.Client):
1465
+ def __init__(self, **kwargs: Any) -> None:
1466
+ kwargs.setdefault("timeout", DEFAULT_TIMEOUT)
1467
+ kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS)
1468
+ # The SDK follows redirects itself (see `_send_following_redirects`), so httpx never does.
1469
+ kwargs.setdefault("follow_redirects", False)
1470
+ super().__init__(**kwargs)
1471
+
1472
+
1473
+ if TYPE_CHECKING:
1474
+ DefaultHttpxClient = httpx.Client
1475
+ """An alias to `httpx.Client` that provides the same defaults that this SDK
1476
+ uses internally.
1477
+
1478
+ This is useful because overriding the `http_client` with your own instance of
1479
+ `httpx.Client` will result in httpx's defaults being used, not ours.
1480
+ """
1481
+ else:
1482
+ DefaultHttpxClient = _DefaultHttpxClient
1483
+
1484
+
1485
+ class SyncHttpxClientWrapper(DefaultHttpxClient):
1486
+ def __del__(self) -> None:
1487
+ if self.is_closed:
1488
+ return
1489
+
1490
+ try:
1491
+ self.close()
1492
+ except Exception:
1493
+ pass
1494
+
1495
+
1496
+ class SyncAPIClient(BaseClient[httpx.Client, Stream[Any]]):
1497
+ _client: httpx.Client
1498
+ _default_stream_cls: type[Stream[Any]] | None = None
1499
+
1500
+ def __init__(
1501
+ self,
1502
+ *,
1503
+ version: str,
1504
+ base_url: str | URL,
1505
+ max_retries: int = DEFAULT_MAX_RETRIES,
1506
+ timeout: float | Timeout | None | NotGiven = not_given,
1507
+ http_client: httpx.Client | None = None,
1508
+ custom_headers: Mapping[str, str] | None = None,
1509
+ custom_query: Mapping[str, object] | None = None,
1510
+ _strict_response_validation: bool,
1511
+ **kwargs: Any,
1512
+ ) -> None:
1513
+ if not is_given(timeout):
1514
+ # if the user passed in a custom http client with a non-default
1515
+ # timeout set then we use that timeout.
1516
+ #
1517
+ # note: there is an edge case here where the user passes in a client
1518
+ # where they've explicitly set the timeout to match the default timeout
1519
+ # as this check is structural, meaning that we'll think they didn't
1520
+ # pass in a timeout and will ignore it
1521
+ if http_client and http_client.timeout != HTTPX_DEFAULT_TIMEOUT:
1522
+ timeout = http_client.timeout
1523
+ else:
1524
+ timeout = DEFAULT_TIMEOUT
1525
+
1526
+ if http_client is not None and not isinstance(http_client, httpx.Client): # pyright: ignore[reportUnnecessaryIsInstance]
1527
+ raise TypeError(
1528
+ f"Invalid `http_client` argument; Expected an instance of `httpx.Client` but got {type(http_client)}"
1529
+ )
1530
+
1531
+ super().__init__(
1532
+ version=version,
1533
+ # cast to a valid type because mypy doesn't understand our type narrowing
1534
+ timeout=cast(Timeout, timeout),
1535
+ base_url=base_url,
1536
+ max_retries=max_retries,
1537
+ custom_query=custom_query,
1538
+ custom_headers=custom_headers,
1539
+ _strict_response_validation=_strict_response_validation,
1540
+ )
1541
+ self._client = http_client or SyncHttpxClientWrapper(
1542
+ base_url=base_url,
1543
+ # cast to a valid type because mypy doesn't understand our type narrowing
1544
+ timeout=cast(Timeout, timeout),
1545
+ # Used by generated `with_options(_extra_kwargs=...)` for HTTPX client configuration.
1546
+ **kwargs,
1547
+ )
1548
+
1549
+ def is_closed(self) -> bool:
1550
+ return self._client.is_closed
1551
+
1552
+ def close(self) -> None:
1553
+ """Close the underlying HTTPX client.
1554
+
1555
+ The client will *not* be usable after this.
1556
+ """
1557
+ # If an error is thrown while constructing a client, self._client
1558
+ # may not be present
1559
+ if hasattr(self, "_client"):
1560
+ self._client.close()
1561
+
1562
+ def __enter__(self: _T) -> _T:
1563
+ return self
1564
+
1565
+ def __exit__(
1566
+ self,
1567
+ exc_type: type[BaseException] | None,
1568
+ exc: BaseException | None,
1569
+ exc_tb: TracebackType | None,
1570
+ ) -> None:
1571
+ self.close()
1572
+
1573
+ def _prepare_options(
1574
+ self,
1575
+ options: FinalRequestOptions, # noqa: ARG002
1576
+ ) -> FinalRequestOptions:
1577
+ """Hook for mutating the given options"""
1578
+ return options
1579
+
1580
+ def _prepare_request(
1581
+ self,
1582
+ request: httpx.Request, # noqa: ARG002
1583
+ ) -> None:
1584
+ """This method is used as a callback for mutating the `Request` object
1585
+ after it has been constructed.
1586
+ This is useful for cases where you want to add certain headers based off of
1587
+ the request properties, e.g. `url`, `method` etc.
1588
+ """
1589
+ return None
1590
+
1591
+ def _send_following_redirects(
1592
+ self,
1593
+ request: httpx.Request,
1594
+ *,
1595
+ options: FinalRequestOptions,
1596
+ auth: httpx.Auth | None,
1597
+ ) -> httpx.Response:
1598
+ """Sends `request`, following redirects in the SDK rather than in httpx, and returns the final response unread.
1599
+
1600
+ httpx is told not to follow on every send, so a caller-supplied `http_client` built with
1601
+ `follow_redirects=True` cannot follow either: httpx strips only `Authorization` on a
1602
+ cross-origin hop and would resend an API key header, cookies and a 307/308 body to whatever
1603
+ host the server names. See `_redirect_request` for what each hop keeps. A hop that has left
1604
+ the origin is sent with a no-op `auth`, so neither `custom_auth`, the caller's client-level
1605
+ `auth=` nor a `.netrc` entry (httpx < 0.24 reads one by default) signs it.
1606
+
1607
+ `options.follow_redirects=False` turns following off altogether: the 3xx itself reaches the
1608
+ caller as a status error. Unset or `True`, the SDK follows at most `MAX_REDIRECTS` hops.
1609
+ """
1610
+ keep_credentials = True
1611
+ hops = 0
1612
+ while True:
1613
+ kwargs: HttpxSendArgs = {"follow_redirects": False}
1614
+ if not keep_credentials:
1615
+ kwargs["auth"] = httpx.Auth()
1616
+ elif auth is not None:
1617
+ kwargs["auth"] = auth
1618
+ sent_headers = httpx.Headers(request.headers)
1619
+ response = self._client.send(request, stream=True, **kwargs)
1620
+ if options.follow_redirects is False or not _is_followed_redirect(response):
1621
+ return response
1622
+ try:
1623
+ if hops >= MAX_REDIRECTS:
1624
+ raise _RedirectRefused(f"Maximum redirects ({MAX_REDIRECTS}) exceeded", request=request)
1625
+ request, keep_credentials = _redirect_request(
1626
+ request, response, sent_headers=sent_headers, keep_credentials=keep_credentials
1627
+ )
1628
+ finally:
1629
+ response.close()
1630
+ hops += 1
1631
+ log.debug("Following redirect %i to %s", response.status_code, request.url)
1632
+
1633
+ @overload
1634
+ def request(
1635
+ self,
1636
+ cast_to: Type[ResponseT],
1637
+ options: FinalRequestOptions,
1638
+ *,
1639
+ stream: Literal[True],
1640
+ stream_cls: Type[_StreamT],
1641
+ ) -> _StreamT: ...
1642
+
1643
+ @overload
1644
+ def request(
1645
+ self,
1646
+ cast_to: Type[ResponseT],
1647
+ options: FinalRequestOptions,
1648
+ *,
1649
+ stream: Literal[False] = False,
1650
+ ) -> ResponseT: ...
1651
+
1652
+ @overload
1653
+ def request(
1654
+ self,
1655
+ cast_to: Type[ResponseT],
1656
+ options: FinalRequestOptions,
1657
+ *,
1658
+ stream: bool = False,
1659
+ stream_cls: Type[_StreamT] | None = None,
1660
+ ) -> ResponseT | _StreamT: ...
1661
+
1662
+ def request(
1663
+ self,
1664
+ cast_to: Type[ResponseT],
1665
+ options: FinalRequestOptions,
1666
+ *,
1667
+ stream: bool = False,
1668
+ stream_cls: type[_StreamT] | None = None,
1669
+ ) -> ResponseT | _StreamT:
1670
+ cast_to = self._maybe_override_cast_to(cast_to, options)
1671
+
1672
+ # create a copy of the options we were given so that if the
1673
+ # options are mutated later & we then retry, the retries are
1674
+ # given the original options
1675
+ input_options = model_copy(options)
1676
+ if input_options.idempotency_key is None and input_options.method.lower() != "get":
1677
+ # ensure the idempotency key is reused between requests
1678
+ input_options.idempotency_key = self._idempotency_key()
1679
+
1680
+ response: httpx.Response | None = None
1681
+ max_retries = input_options.get_max_retries(self.max_retries)
1682
+
1683
+ retries_taken = 0
1684
+ for retries_taken in range(max_retries + 1):
1685
+ options = model_copy(input_options)
1686
+ options = self._prepare_options(options)
1687
+
1688
+ remaining_retries = max_retries - retries_taken
1689
+ request = self._build_request(options, retries_taken=retries_taken)
1690
+ self._prepare_request(request)
1691
+
1692
+ custom_auth = self._custom_auth(options.security)
1693
+ streamed = stream or self._should_stream_response_body(request=request)
1694
+
1695
+ log.debug("Sending HTTP Request: %s %s", request.method, request.url)
1696
+
1697
+ response = None
1698
+ try:
1699
+ response = self._send_following_redirects(request, options=options, auth=custom_auth)
1700
+ # The body's deadline starts once the headers are in: sending the request and waiting
1701
+ # for the answer are bounded by httpx's own write and read timeouts.
1702
+ started = time.monotonic()
1703
+ if not streamed:
1704
+ # Only a successful download is the caller's to bound; an error body is read for
1705
+ # its message, capped and on the deadline like any other.
1706
+ unbounded = _is_unbuffered_cast(cast_to) and response.is_success
1707
+ _read_body(
1708
+ response,
1709
+ capped=not unbounded,
1710
+ deadline=None if unbounded else _read_deadline(request, started),
1711
+ )
1712
+ except _RedirectRefused as err:
1713
+ log.debug("Refusing redirect: %s", err.message)
1714
+ raise APIConnectionError(message=err.message, request=err.request) from None
1715
+ except _BodyRefused as err:
1716
+ log.debug("Refusing response body: %s", err.message)
1717
+ raise APIError(err.message, err.request, body=None) from None
1718
+ except httpx.TimeoutException as err:
1719
+ log.debug("Encountered httpx.TimeoutException", exc_info=True)
1720
+
1721
+ if remaining_retries > 0:
1722
+ self._sleep_for_retry(
1723
+ retries_taken=retries_taken,
1724
+ max_retries=max_retries,
1725
+ options=input_options,
1726
+ response=None,
1727
+ )
1728
+ continue
1729
+
1730
+ log.debug("Raising timeout error")
1731
+ raise APITimeoutError(request=request) from err
1732
+ except Exception as err:
1733
+ log.debug("Encountered Exception", exc_info=True)
1734
+
1735
+ if remaining_retries > 0:
1736
+ self._sleep_for_retry(
1737
+ retries_taken=retries_taken,
1738
+ max_retries=max_retries,
1739
+ options=input_options,
1740
+ response=None,
1741
+ )
1742
+ continue
1743
+
1744
+ log.debug("Raising connection error")
1745
+ raise APIConnectionError(request=request) from err
1746
+
1747
+ log.debug(
1748
+ 'HTTP Response: %s %s "%i %s" %s',
1749
+ response.request.method,
1750
+ response.request.url,
1751
+ response.status_code,
1752
+ response.reason_phrase,
1753
+ response.headers,
1754
+ )
1755
+
1756
+ try:
1757
+ response.raise_for_status()
1758
+ except httpx.HTTPStatusError as err: # thrown on 3xx (one not followed), 4xx and 5xx
1759
+ log.debug("Encountered httpx.HTTPStatusError", exc_info=True)
1760
+
1761
+ if remaining_retries > 0 and self._should_retry(err.response):
1762
+ err.response.close()
1763
+ self._sleep_for_retry(
1764
+ retries_taken=retries_taken,
1765
+ max_retries=max_retries,
1766
+ options=input_options,
1767
+ response=response,
1768
+ )
1769
+ continue
1770
+
1771
+ # A streamed response is still unread: read the first `MAX_ERROR_BODY_BYTES` of it
1772
+ # for the message, within the read deadline, never the whole body a hostile server
1773
+ # could make endless or trickle forever.
1774
+ if not err.response.is_closed:
1775
+ _read_body(
1776
+ err.response, capped=True, deadline=_read_deadline(err.response.request, time.monotonic())
1777
+ )
1778
+
1779
+ log.debug("Re-raising status error")
1780
+ raise self._make_status_error_from_response(err.response) from None
1781
+
1782
+ break
1783
+
1784
+ assert response is not None, "could not resolve response (should never happen)"
1785
+ return self._process_response(
1786
+ cast_to=cast_to,
1787
+ options=options,
1788
+ response=response,
1789
+ stream=stream,
1790
+ stream_cls=stream_cls,
1791
+ retries_taken=retries_taken,
1792
+ )
1793
+
1794
+ def _sleep_for_retry(
1795
+ self, *, retries_taken: int, max_retries: int, options: FinalRequestOptions, response: httpx.Response | None
1796
+ ) -> None:
1797
+ remaining_retries = max_retries - retries_taken
1798
+ if remaining_retries == 1:
1799
+ log.debug("1 retry left")
1800
+ else:
1801
+ log.debug("%i retries left", remaining_retries)
1802
+
1803
+ timeout = self._calculate_retry_timeout(remaining_retries, options, response.headers if response else None)
1804
+ log.info("Retrying request to %s in %f seconds", options.url, timeout)
1805
+
1806
+ time.sleep(timeout)
1807
+
1808
+ def _process_response(
1809
+ self,
1810
+ *,
1811
+ cast_to: Type[ResponseT],
1812
+ options: FinalRequestOptions,
1813
+ response: httpx.Response,
1814
+ stream: bool,
1815
+ stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None,
1816
+ retries_taken: int = 0,
1817
+ ) -> ResponseT:
1818
+ origin = get_origin(cast_to) or cast_to
1819
+
1820
+ if (
1821
+ inspect.isclass(origin)
1822
+ and issubclass(origin, BaseAPIResponse)
1823
+ # we only want to actually return the custom BaseAPIResponse class if we're
1824
+ # returning the raw response, or if we're not streaming SSE, as if we're streaming
1825
+ # SSE then `cast_to` doesn't actively reflect the type we need to parse into
1826
+ and (not stream or bool(response.request.headers.get(RAW_RESPONSE_HEADER)))
1827
+ ):
1828
+ if not issubclass(origin, APIResponse):
1829
+ raise TypeError(f"API Response types must subclass {APIResponse}; Received {origin}")
1830
+
1831
+ response_cls = cast("type[BaseAPIResponse[Any]]", cast_to)
1832
+ return cast(
1833
+ ResponseT,
1834
+ response_cls(
1835
+ raw=response,
1836
+ client=self,
1837
+ cast_to=extract_response_type(response_cls),
1838
+ stream=stream,
1839
+ stream_cls=stream_cls,
1840
+ options=options,
1841
+ retries_taken=retries_taken,
1842
+ ),
1843
+ )
1844
+
1845
+ if cast_to == httpx.Response:
1846
+ return cast(ResponseT, response)
1847
+
1848
+ api_response = APIResponse(
1849
+ raw=response,
1850
+ client=self,
1851
+ cast_to=cast("type[ResponseT]", cast_to), # pyright: ignore[reportUnnecessaryCast]
1852
+ stream=stream,
1853
+ stream_cls=stream_cls,
1854
+ options=options,
1855
+ retries_taken=retries_taken,
1856
+ )
1857
+ if bool(response.request.headers.get(RAW_RESPONSE_HEADER)):
1858
+ return cast(ResponseT, api_response)
1859
+
1860
+ return api_response.parse()
1861
+
1862
+ def _request_api_list(
1863
+ self,
1864
+ model: Type[object],
1865
+ page: Type[SyncPageT],
1866
+ options: FinalRequestOptions,
1867
+ cursor_config: Optional[CursorPageConfig] = None,
1868
+ ) -> SyncPageT:
1869
+ def _parser(resp: SyncPageT) -> SyncPageT:
1870
+ resp._set_private_attributes(
1871
+ client=self,
1872
+ model=model,
1873
+ options=options,
1874
+ cursor_config=cursor_config,
1875
+ )
1876
+ return resp
1877
+
1878
+ options.post_parser = _parser
1879
+
1880
+ return self.request(page, options, stream=False)
1881
+
1882
+ @overload
1883
+ def get(
1884
+ self,
1885
+ path: str,
1886
+ *,
1887
+ cast_to: Type[ResponseT],
1888
+ options: RequestOptions = {},
1889
+ stream: Literal[False] = False,
1890
+ ) -> ResponseT: ...
1891
+
1892
+ @overload
1893
+ def get(
1894
+ self,
1895
+ path: str,
1896
+ *,
1897
+ cast_to: Type[ResponseT],
1898
+ options: RequestOptions = {},
1899
+ stream: Literal[True],
1900
+ stream_cls: type[_StreamT],
1901
+ ) -> _StreamT: ...
1902
+
1903
+ @overload
1904
+ def get(
1905
+ self,
1906
+ path: str,
1907
+ *,
1908
+ cast_to: Type[ResponseT],
1909
+ options: RequestOptions = {},
1910
+ stream: bool,
1911
+ stream_cls: type[_StreamT] | None = None,
1912
+ ) -> ResponseT | _StreamT: ...
1913
+
1914
+ def get(
1915
+ self,
1916
+ path: str,
1917
+ *,
1918
+ cast_to: Type[ResponseT],
1919
+ options: RequestOptions = {},
1920
+ stream: bool = False,
1921
+ stream_cls: type[_StreamT] | None = None,
1922
+ ) -> ResponseT | _StreamT:
1923
+ opts = FinalRequestOptions.construct(method="get", url=path, **options)
1924
+ # cast is required because mypy complains about returning Any even though
1925
+ # it understands the type variables
1926
+ return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls))
1927
+
1928
+ @overload
1929
+ def post(
1930
+ self,
1931
+ path: str,
1932
+ *,
1933
+ cast_to: Type[ResponseT],
1934
+ body: Body | None = None,
1935
+ content: BinaryTypes | None = None,
1936
+ options: RequestOptions = {},
1937
+ files: RequestFiles | None = None,
1938
+ stream: Literal[False] = False,
1939
+ ) -> ResponseT: ...
1940
+
1941
+ @overload
1942
+ def post(
1943
+ self,
1944
+ path: str,
1945
+ *,
1946
+ cast_to: Type[ResponseT],
1947
+ body: Body | None = None,
1948
+ content: BinaryTypes | None = None,
1949
+ options: RequestOptions = {},
1950
+ files: RequestFiles | None = None,
1951
+ stream: Literal[True],
1952
+ stream_cls: type[_StreamT],
1953
+ ) -> _StreamT: ...
1954
+
1955
+ @overload
1956
+ def post(
1957
+ self,
1958
+ path: str,
1959
+ *,
1960
+ cast_to: Type[ResponseT],
1961
+ body: Body | None = None,
1962
+ content: BinaryTypes | None = None,
1963
+ options: RequestOptions = {},
1964
+ files: RequestFiles | None = None,
1965
+ stream: bool,
1966
+ stream_cls: type[_StreamT] | None = None,
1967
+ ) -> ResponseT | _StreamT: ...
1968
+
1969
+ def post(
1970
+ self,
1971
+ path: str,
1972
+ *,
1973
+ cast_to: Type[ResponseT],
1974
+ body: Body | None = None,
1975
+ content: BinaryTypes | None = None,
1976
+ options: RequestOptions = {},
1977
+ files: RequestFiles | None = None,
1978
+ stream: bool = False,
1979
+ stream_cls: type[_StreamT] | None = None,
1980
+ ) -> ResponseT | _StreamT:
1981
+ if body is not None and content is not None:
1982
+ raise TypeError("Passing both `body` and `content` is not supported")
1983
+ if files is not None and content is not None:
1984
+ raise TypeError("Passing both `files` and `content` is not supported")
1985
+ if isinstance(body, bytes):
1986
+ warnings.warn(
1987
+ "Passing raw bytes as `body` is deprecated and will be removed in a future version. "
1988
+ "Please pass raw bytes via the `content` parameter instead.",
1989
+ DeprecationWarning,
1990
+ stacklevel=2,
1991
+ )
1992
+ opts = FinalRequestOptions.construct(
1993
+ method="post", url=path, json_data=body, content=content, files=to_httpx_files(files), **options
1994
+ )
1995
+ return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls))
1996
+
1997
+ def patch(
1998
+ self,
1999
+ path: str,
2000
+ *,
2001
+ cast_to: Type[ResponseT],
2002
+ body: Body | None = None,
2003
+ content: BinaryTypes | None = None,
2004
+ files: RequestFiles | None = None,
2005
+ options: RequestOptions = {},
2006
+ ) -> ResponseT:
2007
+ if body is not None and content is not None:
2008
+ raise TypeError("Passing both `body` and `content` is not supported")
2009
+ if files is not None and content is not None:
2010
+ raise TypeError("Passing both `files` and `content` is not supported")
2011
+ if isinstance(body, bytes):
2012
+ warnings.warn(
2013
+ "Passing raw bytes as `body` is deprecated and will be removed in a future version. "
2014
+ "Please pass raw bytes via the `content` parameter instead.",
2015
+ DeprecationWarning,
2016
+ stacklevel=2,
2017
+ )
2018
+ opts = FinalRequestOptions.construct(
2019
+ method="patch", url=path, json_data=body, content=content, files=to_httpx_files(files), **options
2020
+ )
2021
+ return self.request(cast_to, opts)
2022
+
2023
+ def put(
2024
+ self,
2025
+ path: str,
2026
+ *,
2027
+ cast_to: Type[ResponseT],
2028
+ body: Body | None = None,
2029
+ content: BinaryTypes | None = None,
2030
+ files: RequestFiles | None = None,
2031
+ options: RequestOptions = {},
2032
+ ) -> ResponseT:
2033
+ if body is not None and content is not None:
2034
+ raise TypeError("Passing both `body` and `content` is not supported")
2035
+ if files is not None and content is not None:
2036
+ raise TypeError("Passing both `files` and `content` is not supported")
2037
+ if isinstance(body, bytes):
2038
+ warnings.warn(
2039
+ "Passing raw bytes as `body` is deprecated and will be removed in a future version. "
2040
+ "Please pass raw bytes via the `content` parameter instead.",
2041
+ DeprecationWarning,
2042
+ stacklevel=2,
2043
+ )
2044
+ opts = FinalRequestOptions.construct(
2045
+ method="put", url=path, json_data=body, content=content, files=to_httpx_files(files), **options
2046
+ )
2047
+ return self.request(cast_to, opts)
2048
+
2049
+ def delete(
2050
+ self,
2051
+ path: str,
2052
+ *,
2053
+ cast_to: Type[ResponseT],
2054
+ body: Body | None = None,
2055
+ content: BinaryTypes | None = None,
2056
+ options: RequestOptions = {},
2057
+ ) -> ResponseT:
2058
+ if body is not None and content is not None:
2059
+ raise TypeError("Passing both `body` and `content` is not supported")
2060
+ if isinstance(body, bytes):
2061
+ warnings.warn(
2062
+ "Passing raw bytes as `body` is deprecated and will be removed in a future version. "
2063
+ "Please pass raw bytes via the `content` parameter instead.",
2064
+ DeprecationWarning,
2065
+ stacklevel=2,
2066
+ )
2067
+ opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options)
2068
+ return self.request(cast_to, opts)
2069
+
2070
+ def get_api_list(
2071
+ self,
2072
+ path: str,
2073
+ *,
2074
+ model: Type[object],
2075
+ page: Type[SyncPageT],
2076
+ body: Body | None = None,
2077
+ files: RequestFiles | None = None,
2078
+ options: RequestOptions = {},
2079
+ method: str = "get",
2080
+ cursor_config: Optional[CursorPageConfig] = None,
2081
+ ) -> SyncPageT:
2082
+ opts = FinalRequestOptions.construct(
2083
+ method=method, url=path, json_data=body, files=to_httpx_files(files), **options
2084
+ )
2085
+ return self._request_api_list(model, page, opts, cursor_config=cursor_config)
2086
+
2087
+
2088
+ class _DefaultAsyncHttpxClient(httpx.AsyncClient):
2089
+ def __init__(self, **kwargs: Any) -> None:
2090
+ kwargs.setdefault("timeout", DEFAULT_TIMEOUT)
2091
+ kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS)
2092
+ # The SDK follows redirects itself (see `_send_following_redirects`), so httpx never does.
2093
+ kwargs.setdefault("follow_redirects", False)
2094
+ super().__init__(**kwargs)
2095
+
2096
+
2097
+ try:
2098
+ import httpx_aiohttp
2099
+ except ImportError:
2100
+
2101
+ class _DefaultAioHttpClient(httpx.AsyncClient):
2102
+ def __init__(self, **_kwargs: Any) -> None:
2103
+ raise RuntimeError("To use the aiohttp client you must have installed the package with the `aiohttp` extra")
2104
+ else:
2105
+
2106
+ class _DefaultAioHttpClient(httpx_aiohttp.HttpxAiohttpClient): # type: ignore
2107
+ def __init__(self, **kwargs: Any) -> None:
2108
+ kwargs.setdefault("timeout", DEFAULT_TIMEOUT)
2109
+ kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS)
2110
+ # The SDK follows redirects itself (see `_send_following_redirects`), so httpx never does.
2111
+ kwargs.setdefault("follow_redirects", False)
2112
+
2113
+ super().__init__(**kwargs)
2114
+
2115
+
2116
+ if TYPE_CHECKING:
2117
+ DefaultAsyncHttpxClient = httpx.AsyncClient
2118
+ """An alias to `httpx.AsyncClient` that provides the same defaults that this SDK
2119
+ uses internally.
2120
+
2121
+ This is useful because overriding the `http_client` with your own instance of
2122
+ `httpx.AsyncClient` will result in httpx's defaults being used, not ours.
2123
+ """
2124
+
2125
+ DefaultAioHttpClient = httpx.AsyncClient
2126
+ """An alias to `httpx.AsyncClient` that changes the default HTTP transport to `aiohttp`."""
2127
+ else:
2128
+ DefaultAsyncHttpxClient = _DefaultAsyncHttpxClient
2129
+ DefaultAioHttpClient = _DefaultAioHttpClient
2130
+
2131
+
2132
+ class AsyncHttpxClientWrapper(DefaultAsyncHttpxClient):
2133
+ def __del__(self) -> None:
2134
+ if self.is_closed:
2135
+ return
2136
+
2137
+ try:
2138
+ # TODO(someday): support non asyncio runtimes here
2139
+ asyncio.get_running_loop().create_task(self.aclose())
2140
+ except Exception:
2141
+ pass
2142
+
2143
+
2144
+ class AsyncAPIClient(BaseClient[httpx.AsyncClient, AsyncStream[Any]]):
2145
+ _client: httpx.AsyncClient
2146
+ _default_stream_cls: type[AsyncStream[Any]] | None = None
2147
+
2148
+ def __init__(
2149
+ self,
2150
+ *,
2151
+ version: str,
2152
+ base_url: str | URL,
2153
+ _strict_response_validation: bool,
2154
+ max_retries: int = DEFAULT_MAX_RETRIES,
2155
+ timeout: float | Timeout | None | NotGiven = not_given,
2156
+ http_client: httpx.AsyncClient | None = None,
2157
+ custom_headers: Mapping[str, str] | None = None,
2158
+ custom_query: Mapping[str, object] | None = None,
2159
+ **kwargs: Any,
2160
+ ) -> None:
2161
+ if not is_given(timeout):
2162
+ # if the user passed in a custom http client with a non-default
2163
+ # timeout set then we use that timeout.
2164
+ #
2165
+ # note: there is an edge case here where the user passes in a client
2166
+ # where they've explicitly set the timeout to match the default timeout
2167
+ # as this check is structural, meaning that we'll think they didn't
2168
+ # pass in a timeout and will ignore it
2169
+ if http_client and http_client.timeout != HTTPX_DEFAULT_TIMEOUT:
2170
+ timeout = http_client.timeout
2171
+ else:
2172
+ timeout = DEFAULT_TIMEOUT
2173
+
2174
+ if http_client is not None and not isinstance(http_client, httpx.AsyncClient): # pyright: ignore[reportUnnecessaryIsInstance]
2175
+ raise TypeError(
2176
+ f"Invalid `http_client` argument; Expected an instance of `httpx.AsyncClient` but got {type(http_client)}"
2177
+ )
2178
+
2179
+ super().__init__(
2180
+ version=version,
2181
+ base_url=base_url,
2182
+ # cast to a valid type because mypy doesn't understand our type narrowing
2183
+ timeout=cast(Timeout, timeout),
2184
+ max_retries=max_retries,
2185
+ custom_query=custom_query,
2186
+ custom_headers=custom_headers,
2187
+ _strict_response_validation=_strict_response_validation,
2188
+ )
2189
+ self._client = http_client or AsyncHttpxClientWrapper(
2190
+ base_url=base_url,
2191
+ # cast to a valid type because mypy doesn't understand our type narrowing
2192
+ timeout=cast(Timeout, timeout),
2193
+ # Used by generated `with_options(_extra_kwargs=...)` for HTTPX client configuration.
2194
+ **kwargs,
2195
+ )
2196
+
2197
+ def is_closed(self) -> bool:
2198
+ return self._client.is_closed
2199
+
2200
+ async def close(self) -> None:
2201
+ """Close the underlying HTTPX client.
2202
+
2203
+ The client will *not* be usable after this.
2204
+ """
2205
+ await self._client.aclose()
2206
+
2207
+ async def __aenter__(self: _T) -> _T:
2208
+ return self
2209
+
2210
+ async def __aexit__(
2211
+ self,
2212
+ exc_type: type[BaseException] | None,
2213
+ exc: BaseException | None,
2214
+ exc_tb: TracebackType | None,
2215
+ ) -> None:
2216
+ await self.close()
2217
+
2218
+ async def _prepare_options(
2219
+ self,
2220
+ options: FinalRequestOptions, # noqa: ARG002
2221
+ ) -> FinalRequestOptions:
2222
+ """Hook for mutating the given options"""
2223
+ return options
2224
+
2225
+ async def _prepare_request(
2226
+ self,
2227
+ request: httpx.Request, # noqa: ARG002
2228
+ ) -> None:
2229
+ """This method is used as a callback for mutating the `Request` object
2230
+ after it has been constructed.
2231
+ This is useful for cases where you want to add certain headers based off of
2232
+ the request properties, e.g. `url`, `method` etc.
2233
+ """
2234
+ return None
2235
+
2236
+ async def _send_following_redirects(
2237
+ self,
2238
+ request: httpx.Request,
2239
+ *,
2240
+ options: FinalRequestOptions,
2241
+ auth: httpx.Auth | None,
2242
+ ) -> httpx.Response:
2243
+ """The async twin of `SyncAPIClient._send_following_redirects`, which documents the rules."""
2244
+ keep_credentials = True
2245
+ hops = 0
2246
+ while True:
2247
+ kwargs: HttpxSendArgs = {"follow_redirects": False}
2248
+ if not keep_credentials:
2249
+ kwargs["auth"] = httpx.Auth()
2250
+ elif auth is not None:
2251
+ kwargs["auth"] = auth
2252
+ sent_headers = httpx.Headers(request.headers)
2253
+ response = await self._client.send(request, stream=True, **kwargs)
2254
+ if options.follow_redirects is False or not _is_followed_redirect(response):
2255
+ return response
2256
+ try:
2257
+ if hops >= MAX_REDIRECTS:
2258
+ raise _RedirectRefused(f"Maximum redirects ({MAX_REDIRECTS}) exceeded", request=request)
2259
+ request, keep_credentials = _redirect_request(
2260
+ request, response, sent_headers=sent_headers, keep_credentials=keep_credentials
2261
+ )
2262
+ finally:
2263
+ await response.aclose()
2264
+ hops += 1
2265
+ log.debug("Following redirect %i to %s", response.status_code, request.url)
2266
+
2267
+ @overload
2268
+ async def request(
2269
+ self,
2270
+ cast_to: Type[ResponseT],
2271
+ options: FinalRequestOptions,
2272
+ *,
2273
+ stream: Literal[False] = False,
2274
+ ) -> ResponseT: ...
2275
+
2276
+ @overload
2277
+ async def request(
2278
+ self,
2279
+ cast_to: Type[ResponseT],
2280
+ options: FinalRequestOptions,
2281
+ *,
2282
+ stream: Literal[True],
2283
+ stream_cls: type[_AsyncStreamT],
2284
+ ) -> _AsyncStreamT: ...
2285
+
2286
+ @overload
2287
+ async def request(
2288
+ self,
2289
+ cast_to: Type[ResponseT],
2290
+ options: FinalRequestOptions,
2291
+ *,
2292
+ stream: bool,
2293
+ stream_cls: type[_AsyncStreamT] | None = None,
2294
+ ) -> ResponseT | _AsyncStreamT: ...
2295
+
2296
+ async def request(
2297
+ self,
2298
+ cast_to: Type[ResponseT],
2299
+ options: FinalRequestOptions,
2300
+ *,
2301
+ stream: bool = False,
2302
+ stream_cls: type[_AsyncStreamT] | None = None,
2303
+ ) -> ResponseT | _AsyncStreamT:
2304
+ if self._platform is None:
2305
+ # `get_platform` can make blocking IO calls so we
2306
+ # execute it earlier while we are in an async context
2307
+ self._platform = await asyncify(get_platform)()
2308
+
2309
+ cast_to = self._maybe_override_cast_to(cast_to, options)
2310
+
2311
+ # create a copy of the options we were given so that if the
2312
+ # options are mutated later & we then retry, the retries are
2313
+ # given the original options
2314
+ input_options = model_copy(options)
2315
+ if input_options.idempotency_key is None and input_options.method.lower() != "get":
2316
+ # ensure the idempotency key is reused between requests
2317
+ input_options.idempotency_key = self._idempotency_key()
2318
+
2319
+ response: httpx.Response | None = None
2320
+ max_retries = input_options.get_max_retries(self.max_retries)
2321
+
2322
+ retries_taken = 0
2323
+ for retries_taken in range(max_retries + 1):
2324
+ options = model_copy(input_options)
2325
+ options = await self._prepare_options(options)
2326
+
2327
+ remaining_retries = max_retries - retries_taken
2328
+ request = self._build_request(options, retries_taken=retries_taken)
2329
+ await self._prepare_request(request)
2330
+
2331
+ custom_auth = self._custom_auth(options.security)
2332
+ streamed = stream or self._should_stream_response_body(request=request)
2333
+
2334
+ log.debug("Sending HTTP Request: %s %s", request.method, request.url)
2335
+
2336
+ response = None
2337
+ try:
2338
+ response = await self._send_following_redirects(request, options=options, auth=custom_auth)
2339
+ # The body's deadline starts once the headers are in: sending the request and waiting
2340
+ # for the answer are bounded by httpx's own write and read timeouts.
2341
+ started = time.monotonic()
2342
+ if not streamed:
2343
+ # Only a successful download is the caller's to bound; an error body is read for
2344
+ # its message, capped and on the deadline like any other.
2345
+ unbounded = _is_unbuffered_cast(cast_to) and response.is_success
2346
+ await _aread_body(
2347
+ response,
2348
+ capped=not unbounded,
2349
+ deadline=None if unbounded else _read_deadline(request, started),
2350
+ )
2351
+ except _RedirectRefused as err:
2352
+ log.debug("Refusing redirect: %s", err.message)
2353
+ raise APIConnectionError(message=err.message, request=err.request) from None
2354
+ except _BodyRefused as err:
2355
+ log.debug("Refusing response body: %s", err.message)
2356
+ raise APIError(err.message, err.request, body=None) from None
2357
+ except httpx.TimeoutException as err:
2358
+ log.debug("Encountered httpx.TimeoutException", exc_info=True)
2359
+
2360
+ if remaining_retries > 0:
2361
+ await self._sleep_for_retry(
2362
+ retries_taken=retries_taken,
2363
+ max_retries=max_retries,
2364
+ options=input_options,
2365
+ response=None,
2366
+ )
2367
+ continue
2368
+
2369
+ log.debug("Raising timeout error")
2370
+ raise APITimeoutError(request=request) from err
2371
+ except Exception as err:
2372
+ log.debug("Encountered Exception", exc_info=True)
2373
+
2374
+ if remaining_retries > 0:
2375
+ await self._sleep_for_retry(
2376
+ retries_taken=retries_taken,
2377
+ max_retries=max_retries,
2378
+ options=input_options,
2379
+ response=None,
2380
+ )
2381
+ continue
2382
+
2383
+ log.debug("Raising connection error")
2384
+ raise APIConnectionError(request=request) from err
2385
+
2386
+ log.debug(
2387
+ 'HTTP Response: %s %s "%i %s" %s',
2388
+ response.request.method,
2389
+ response.request.url,
2390
+ response.status_code,
2391
+ response.reason_phrase,
2392
+ response.headers,
2393
+ )
2394
+
2395
+ try:
2396
+ response.raise_for_status()
2397
+ except httpx.HTTPStatusError as err: # thrown on 3xx (one not followed), 4xx and 5xx
2398
+ log.debug("Encountered httpx.HTTPStatusError", exc_info=True)
2399
+
2400
+ if remaining_retries > 0 and self._should_retry(err.response):
2401
+ await err.response.aclose()
2402
+ await self._sleep_for_retry(
2403
+ retries_taken=retries_taken,
2404
+ max_retries=max_retries,
2405
+ options=input_options,
2406
+ response=response,
2407
+ )
2408
+ continue
2409
+
2410
+ # A streamed response is still unread: read the first `MAX_ERROR_BODY_BYTES` of it
2411
+ # for the message, within the read deadline, never the whole body a hostile server
2412
+ # could make endless or trickle forever.
2413
+ if not err.response.is_closed:
2414
+ await _aread_body(
2415
+ err.response, capped=True, deadline=_read_deadline(err.response.request, time.monotonic())
2416
+ )
2417
+
2418
+ log.debug("Re-raising status error")
2419
+ raise self._make_status_error_from_response(err.response) from None
2420
+
2421
+ break
2422
+
2423
+ assert response is not None, "could not resolve response (should never happen)"
2424
+ return await self._process_response(
2425
+ cast_to=cast_to,
2426
+ options=options,
2427
+ response=response,
2428
+ stream=stream,
2429
+ stream_cls=stream_cls,
2430
+ retries_taken=retries_taken,
2431
+ )
2432
+
2433
+ async def _sleep_for_retry(
2434
+ self, *, retries_taken: int, max_retries: int, options: FinalRequestOptions, response: httpx.Response | None
2435
+ ) -> None:
2436
+ remaining_retries = max_retries - retries_taken
2437
+ if remaining_retries == 1:
2438
+ log.debug("1 retry left")
2439
+ else:
2440
+ log.debug("%i retries left", remaining_retries)
2441
+
2442
+ timeout = self._calculate_retry_timeout(remaining_retries, options, response.headers if response else None)
2443
+ log.info("Retrying request to %s in %f seconds", options.url, timeout)
2444
+
2445
+ await anyio.sleep(timeout)
2446
+
2447
+ async def _process_response(
2448
+ self,
2449
+ *,
2450
+ cast_to: Type[ResponseT],
2451
+ options: FinalRequestOptions,
2452
+ response: httpx.Response,
2453
+ stream: bool,
2454
+ stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None,
2455
+ retries_taken: int = 0,
2456
+ ) -> ResponseT:
2457
+ origin = get_origin(cast_to) or cast_to
2458
+
2459
+ if (
2460
+ inspect.isclass(origin)
2461
+ and issubclass(origin, BaseAPIResponse)
2462
+ # we only want to actually return the custom BaseAPIResponse class if we're
2463
+ # returning the raw response, or if we're not streaming SSE, as if we're streaming
2464
+ # SSE then `cast_to` doesn't actively reflect the type we need to parse into
2465
+ and (not stream or bool(response.request.headers.get(RAW_RESPONSE_HEADER)))
2466
+ ):
2467
+ if not issubclass(origin, AsyncAPIResponse):
2468
+ raise TypeError(f"API Response types must subclass {AsyncAPIResponse}; Received {origin}")
2469
+
2470
+ response_cls = cast("type[BaseAPIResponse[Any]]", cast_to)
2471
+ return cast(
2472
+ "ResponseT",
2473
+ response_cls(
2474
+ raw=response,
2475
+ client=self,
2476
+ cast_to=extract_response_type(response_cls),
2477
+ stream=stream,
2478
+ stream_cls=stream_cls,
2479
+ options=options,
2480
+ retries_taken=retries_taken,
2481
+ ),
2482
+ )
2483
+
2484
+ if cast_to == httpx.Response:
2485
+ return cast(ResponseT, response)
2486
+
2487
+ api_response = AsyncAPIResponse(
2488
+ raw=response,
2489
+ client=self,
2490
+ cast_to=cast("type[ResponseT]", cast_to), # pyright: ignore[reportUnnecessaryCast]
2491
+ stream=stream,
2492
+ stream_cls=stream_cls,
2493
+ options=options,
2494
+ retries_taken=retries_taken,
2495
+ )
2496
+ if bool(response.request.headers.get(RAW_RESPONSE_HEADER)):
2497
+ return cast(ResponseT, api_response)
2498
+
2499
+ return await api_response.parse()
2500
+
2501
+ def _request_api_list(
2502
+ self,
2503
+ model: Type[_T],
2504
+ page: Type[AsyncPageT],
2505
+ options: FinalRequestOptions,
2506
+ cursor_config: Optional[CursorPageConfig] = None,
2507
+ ) -> AsyncPaginator[_T, AsyncPageT]:
2508
+ return AsyncPaginator(client=self, options=options, page_cls=page, model=model, cursor_config=cursor_config)
2509
+
2510
+ @overload
2511
+ async def get(
2512
+ self,
2513
+ path: str,
2514
+ *,
2515
+ cast_to: Type[ResponseT],
2516
+ options: RequestOptions = {},
2517
+ stream: Literal[False] = False,
2518
+ ) -> ResponseT: ...
2519
+
2520
+ @overload
2521
+ async def get(
2522
+ self,
2523
+ path: str,
2524
+ *,
2525
+ cast_to: Type[ResponseT],
2526
+ options: RequestOptions = {},
2527
+ stream: Literal[True],
2528
+ stream_cls: type[_AsyncStreamT],
2529
+ ) -> _AsyncStreamT: ...
2530
+
2531
+ @overload
2532
+ async def get(
2533
+ self,
2534
+ path: str,
2535
+ *,
2536
+ cast_to: Type[ResponseT],
2537
+ options: RequestOptions = {},
2538
+ stream: bool,
2539
+ stream_cls: type[_AsyncStreamT] | None = None,
2540
+ ) -> ResponseT | _AsyncStreamT: ...
2541
+
2542
+ async def get(
2543
+ self,
2544
+ path: str,
2545
+ *,
2546
+ cast_to: Type[ResponseT],
2547
+ options: RequestOptions = {},
2548
+ stream: bool = False,
2549
+ stream_cls: type[_AsyncStreamT] | None = None,
2550
+ ) -> ResponseT | _AsyncStreamT:
2551
+ opts = FinalRequestOptions.construct(method="get", url=path, **options)
2552
+ return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)
2553
+
2554
+ @overload
2555
+ async def post(
2556
+ self,
2557
+ path: str,
2558
+ *,
2559
+ cast_to: Type[ResponseT],
2560
+ body: Body | None = None,
2561
+ content: AsyncBinaryTypes | None = None,
2562
+ files: RequestFiles | None = None,
2563
+ options: RequestOptions = {},
2564
+ stream: Literal[False] = False,
2565
+ ) -> ResponseT: ...
2566
+
2567
+ @overload
2568
+ async def post(
2569
+ self,
2570
+ path: str,
2571
+ *,
2572
+ cast_to: Type[ResponseT],
2573
+ body: Body | None = None,
2574
+ content: AsyncBinaryTypes | None = None,
2575
+ files: RequestFiles | None = None,
2576
+ options: RequestOptions = {},
2577
+ stream: Literal[True],
2578
+ stream_cls: type[_AsyncStreamT],
2579
+ ) -> _AsyncStreamT: ...
2580
+
2581
+ @overload
2582
+ async def post(
2583
+ self,
2584
+ path: str,
2585
+ *,
2586
+ cast_to: Type[ResponseT],
2587
+ body: Body | None = None,
2588
+ content: AsyncBinaryTypes | None = None,
2589
+ files: RequestFiles | None = None,
2590
+ options: RequestOptions = {},
2591
+ stream: bool,
2592
+ stream_cls: type[_AsyncStreamT] | None = None,
2593
+ ) -> ResponseT | _AsyncStreamT: ...
2594
+
2595
+ async def post(
2596
+ self,
2597
+ path: str,
2598
+ *,
2599
+ cast_to: Type[ResponseT],
2600
+ body: Body | None = None,
2601
+ content: AsyncBinaryTypes | None = None,
2602
+ files: RequestFiles | None = None,
2603
+ options: RequestOptions = {},
2604
+ stream: bool = False,
2605
+ stream_cls: type[_AsyncStreamT] | None = None,
2606
+ ) -> ResponseT | _AsyncStreamT:
2607
+ if body is not None and content is not None:
2608
+ raise TypeError("Passing both `body` and `content` is not supported")
2609
+ if files is not None and content is not None:
2610
+ raise TypeError("Passing both `files` and `content` is not supported")
2611
+ if isinstance(body, bytes):
2612
+ warnings.warn(
2613
+ "Passing raw bytes as `body` is deprecated and will be removed in a future version. "
2614
+ "Please pass raw bytes via the `content` parameter instead.",
2615
+ DeprecationWarning,
2616
+ stacklevel=2,
2617
+ )
2618
+ opts = FinalRequestOptions.construct(
2619
+ method="post", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options
2620
+ )
2621
+ return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)
2622
+
2623
+ async def patch(
2624
+ self,
2625
+ path: str,
2626
+ *,
2627
+ cast_to: Type[ResponseT],
2628
+ body: Body | None = None,
2629
+ content: AsyncBinaryTypes | None = None,
2630
+ files: RequestFiles | None = None,
2631
+ options: RequestOptions = {},
2632
+ ) -> ResponseT:
2633
+ if body is not None and content is not None:
2634
+ raise TypeError("Passing both `body` and `content` is not supported")
2635
+ if files is not None and content is not None:
2636
+ raise TypeError("Passing both `files` and `content` is not supported")
2637
+ if isinstance(body, bytes):
2638
+ warnings.warn(
2639
+ "Passing raw bytes as `body` is deprecated and will be removed in a future version. "
2640
+ "Please pass raw bytes via the `content` parameter instead.",
2641
+ DeprecationWarning,
2642
+ stacklevel=2,
2643
+ )
2644
+ opts = FinalRequestOptions.construct(
2645
+ method="patch",
2646
+ url=path,
2647
+ json_data=body,
2648
+ content=content,
2649
+ files=await async_to_httpx_files(files),
2650
+ **options,
2651
+ )
2652
+ return await self.request(cast_to, opts)
2653
+
2654
+ async def put(
2655
+ self,
2656
+ path: str,
2657
+ *,
2658
+ cast_to: Type[ResponseT],
2659
+ body: Body | None = None,
2660
+ content: AsyncBinaryTypes | None = None,
2661
+ files: RequestFiles | None = None,
2662
+ options: RequestOptions = {},
2663
+ ) -> ResponseT:
2664
+ if body is not None and content is not None:
2665
+ raise TypeError("Passing both `body` and `content` is not supported")
2666
+ if files is not None and content is not None:
2667
+ raise TypeError("Passing both `files` and `content` is not supported")
2668
+ if isinstance(body, bytes):
2669
+ warnings.warn(
2670
+ "Passing raw bytes as `body` is deprecated and will be removed in a future version. "
2671
+ "Please pass raw bytes via the `content` parameter instead.",
2672
+ DeprecationWarning,
2673
+ stacklevel=2,
2674
+ )
2675
+ opts = FinalRequestOptions.construct(
2676
+ method="put", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options
2677
+ )
2678
+ return await self.request(cast_to, opts)
2679
+
2680
+ async def delete(
2681
+ self,
2682
+ path: str,
2683
+ *,
2684
+ cast_to: Type[ResponseT],
2685
+ body: Body | None = None,
2686
+ content: AsyncBinaryTypes | None = None,
2687
+ options: RequestOptions = {},
2688
+ ) -> ResponseT:
2689
+ if body is not None and content is not None:
2690
+ raise TypeError("Passing both `body` and `content` is not supported")
2691
+ if isinstance(body, bytes):
2692
+ warnings.warn(
2693
+ "Passing raw bytes as `body` is deprecated and will be removed in a future version. "
2694
+ "Please pass raw bytes via the `content` parameter instead.",
2695
+ DeprecationWarning,
2696
+ stacklevel=2,
2697
+ )
2698
+ opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options)
2699
+ return await self.request(cast_to, opts)
2700
+
2701
+ def get_api_list(
2702
+ self,
2703
+ path: str,
2704
+ *,
2705
+ model: Type[_T],
2706
+ page: Type[AsyncPageT],
2707
+ body: Body | None = None,
2708
+ files: RequestFiles | None = None,
2709
+ options: RequestOptions = {},
2710
+ method: str = "get",
2711
+ cursor_config: Optional[CursorPageConfig] = None,
2712
+ ) -> AsyncPaginator[_T, AsyncPageT]:
2713
+ opts = FinalRequestOptions.construct(
2714
+ method=method, url=path, json_data=body, files=to_httpx_files(files), **options
2715
+ )
2716
+ return self._request_api_list(model, page, opts, cursor_config=cursor_config)
2717
+
2718
+
2719
+ def make_request_options(
2720
+ *,
2721
+ query: Query | None = None,
2722
+ extra_headers: Headers | None = None,
2723
+ extra_query: Query | None = None,
2724
+ extra_body: Body | None = None,
2725
+ idempotency_key: str | None = None,
2726
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
2727
+ post_parser: PostParser | NotGiven = not_given,
2728
+ security: SecurityOptions | None = None,
2729
+ ) -> RequestOptions:
2730
+ """Create a dict of type RequestOptions without keys of NotGiven values."""
2731
+ options: RequestOptions = {}
2732
+ if extra_headers is not None:
2733
+ options["headers"] = extra_headers
2734
+
2735
+ if extra_body is not None:
2736
+ options["extra_json"] = cast(AnyMapping, extra_body)
2737
+
2738
+ if query is not None:
2739
+ options["params"] = query
2740
+
2741
+ if extra_query is not None:
2742
+ options["params"] = {**options.get("params", {}), **extra_query}
2743
+
2744
+ if not isinstance(timeout, NotGiven):
2745
+ options["timeout"] = timeout
2746
+
2747
+ if idempotency_key is not None:
2748
+ options["idempotency_key"] = idempotency_key
2749
+
2750
+ if is_given(post_parser):
2751
+ # internal
2752
+ options["post_parser"] = post_parser # type: ignore
2753
+
2754
+ if security is not None:
2755
+ options["security"] = security
2756
+
2757
+ return options
2758
+
2759
+
2760
+ class ForceMultipartDict(Dict[str, None]):
2761
+ def __bool__(self) -> bool:
2762
+ return True
2763
+
2764
+
2765
+ class OtherPlatform:
2766
+ def __init__(self, name: str) -> None:
2767
+ self.name = name
2768
+
2769
+ @override
2770
+ def __str__(self) -> str:
2771
+ return f"Other:{self.name}"
2772
+
2773
+
2774
+ Platform = Union[
2775
+ OtherPlatform,
2776
+ Literal[
2777
+ "MacOS",
2778
+ "Linux",
2779
+ "Windows",
2780
+ "FreeBSD",
2781
+ "OpenBSD",
2782
+ "iOS",
2783
+ "Android",
2784
+ "Unknown",
2785
+ ],
2786
+ ]
2787
+
2788
+
2789
+ def get_platform() -> Platform:
2790
+ try:
2791
+ system = platform.system().lower()
2792
+ platform_name = platform.platform().lower()
2793
+ except Exception:
2794
+ return "Unknown"
2795
+
2796
+ if "iphone" in platform_name or "ipad" in platform_name:
2797
+ # Tested using Python3IDE on an iPhone 11 and Pythonista on an iPad 7
2798
+ # system is Darwin and platform_name is a string like:
2799
+ # - Darwin-21.6.0-iPhone12,1-64bit
2800
+ # - Darwin-21.6.0-iPad7,11-64bit
2801
+ return "iOS"
2802
+
2803
+ if system == "darwin":
2804
+ return "MacOS"
2805
+
2806
+ if system == "windows":
2807
+ return "Windows"
2808
+
2809
+ if "android" in platform_name:
2810
+ # Tested using Pydroid 3
2811
+ # system is Linux and platform_name is a string like 'Linux-5.10.81-android12-9-00001-geba40aecb3b7-ab8534902-aarch64-with-libc'
2812
+ return "Android"
2813
+
2814
+ # The BSDs are not Linux distributions: `platform.system()` reports them by name (`FreeBSD`,
2815
+ # `OpenBSD`), so the kernel name alone tells them apart and no distribution lookup is needed.
2816
+ if system == "freebsd":
2817
+ return "FreeBSD"
2818
+
2819
+ if system == "openbsd":
2820
+ return "OpenBSD"
2821
+
2822
+ if system == "linux":
2823
+ return "Linux"
2824
+
2825
+ if platform_name:
2826
+ return OtherPlatform(platform_name)
2827
+
2828
+ return "Unknown"
2829
+
2830
+
2831
+ @lru_cache(maxsize=None)
2832
+ def platform_headers(version: str, *, platform: Platform | None) -> Dict[str, str]:
2833
+ return {
2834
+ "X-Scalar-Lang": "python",
2835
+ "X-Scalar-Package-Version": version,
2836
+ "X-Scalar-OS": str(platform or get_platform()),
2837
+ "X-Scalar-Arch": str(get_architecture()),
2838
+ "X-Scalar-Runtime": get_python_runtime(),
2839
+ "X-Scalar-Runtime-Version": get_python_version(),
2840
+ }
2841
+
2842
+
2843
+ class OtherArch:
2844
+ def __init__(self, name: str) -> None:
2845
+ self.name = name
2846
+
2847
+ @override
2848
+ def __str__(self) -> str:
2849
+ return f"other:{self.name}"
2850
+
2851
+
2852
+ Arch = Union[OtherArch, Literal["x32", "x64", "arm", "arm64", "unknown"]]
2853
+
2854
+
2855
+ def get_python_runtime() -> str:
2856
+ try:
2857
+ return platform.python_implementation()
2858
+ except Exception:
2859
+ return "unknown"
2860
+
2861
+
2862
+ def get_python_version() -> str:
2863
+ try:
2864
+ return platform.python_version()
2865
+ except Exception:
2866
+ return "unknown"
2867
+
2868
+
2869
+ def get_architecture() -> Arch:
2870
+ try:
2871
+ machine = platform.machine().lower()
2872
+ except Exception:
2873
+ return "unknown"
2874
+
2875
+ if machine in ("arm64", "aarch64"):
2876
+ return "arm64"
2877
+
2878
+ # TODO: untested
2879
+ if machine == "arm":
2880
+ return "arm"
2881
+
2882
+ if machine == "x86_64":
2883
+ return "x64"
2884
+
2885
+ # TODO: untested
2886
+ if sys.maxsize <= 2**32:
2887
+ return "x32"
2888
+
2889
+ if machine:
2890
+ return OtherArch(machine)
2891
+
2892
+ return "unknown"
2893
+
2894
+
2895
+ def _merge_mappings(
2896
+ obj1: Mapping[_T_co, Union[_T, Omit]],
2897
+ obj2: Mapping[_T_co, Union[_T, Omit]],
2898
+ ) -> Dict[_T_co, _T]:
2899
+ """Merge two mappings of the same type, removing any values that are instances of `Omit`.
2900
+
2901
+ In cases with duplicate keys the second mapping takes precedence.
2902
+ """
2903
+ merged = {**obj1, **obj2}
2904
+ return {key: value for key, value in merged.items() if not isinstance(value, Omit)}