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.
- brellium_api_client/__init__.py +98 -0
- brellium_api_client/_base_client.py +2904 -0
- brellium_api_client/_client.py +721 -0
- brellium_api_client/_compat.py +195 -0
- brellium_api_client/_constants.py +14 -0
- brellium_api_client/_decoding.py +187 -0
- brellium_api_client/_event_handler.py +85 -0
- brellium_api_client/_exceptions.py +126 -0
- brellium_api_client/_files.py +173 -0
- brellium_api_client/_models.py +965 -0
- brellium_api_client/_qs.py +147 -0
- brellium_api_client/_resource.py +43 -0
- brellium_api_client/_response.py +842 -0
- brellium_api_client/_send_queue.py +90 -0
- brellium_api_client/_streaming.py +412 -0
- brellium_api_client/_types.py +279 -0
- brellium_api_client/_utils/__init__.py +64 -0
- brellium_api_client/_utils/_compat.py +45 -0
- brellium_api_client/_utils/_datetime_parse.py +156 -0
- brellium_api_client/_utils/_json.py +35 -0
- brellium_api_client/_utils/_logs.py +356 -0
- brellium_api_client/_utils/_path.py +127 -0
- brellium_api_client/_utils/_proxy.py +65 -0
- brellium_api_client/_utils/_reflection.py +42 -0
- brellium_api_client/_utils/_resources_proxy.py +24 -0
- brellium_api_client/_utils/_streams.py +12 -0
- brellium_api_client/_utils/_sync.py +58 -0
- brellium_api_client/_utils/_transform.py +472 -0
- brellium_api_client/_utils/_typing.py +156 -0
- brellium_api_client/_utils/_utils.py +428 -0
- brellium_api_client/_version.py +4 -0
- brellium_api_client/py.typed +0 -0
- brellium_api_client/resources/__init__.py +89 -0
- brellium_api_client/resources/audits.py +597 -0
- brellium_api_client/resources/coding_evaluations.py +354 -0
- brellium_api_client/resources/documents.py +654 -0
- brellium_api_client/resources/email_recipients.py +718 -0
- brellium_api_client/resources/question_sets.py +313 -0
- brellium_api_client/resources/users.py +852 -0
- brellium_api_client/types/__init__.py +49 -0
- brellium_api_client/types/audit.py +56 -0
- brellium_api_client/types/audit_batch_result.py +59 -0
- brellium_api_client/types/audit_batch_update_flags_params.py +22 -0
- brellium_api_client/types/audit_display_info_item.py +21 -0
- brellium_api_client/types/audit_list.py +26 -0
- brellium_api_client/types/audit_list_params.py +56 -0
- brellium_api_client/types/audit_person.py +23 -0
- brellium_api_client/types/audit_response.py +32 -0
- brellium_api_client/types/audit_update_params.py +13 -0
- brellium_api_client/types/coding_evaluation.py +54 -0
- brellium_api_client/types/coding_evaluation_list.py +26 -0
- brellium_api_client/types/coding_evaluation_list_params.py +50 -0
- brellium_api_client/types/created_user.py +13 -0
- brellium_api_client/types/document.py +64 -0
- brellium_api_client/types/document_create_params.py +32 -0
- brellium_api_client/types/document_list.py +26 -0
- brellium_api_client/types/document_list_params.py +50 -0
- brellium_api_client/types/document_update_params.py +16 -0
- brellium_api_client/types/document_upload_list.py +26 -0
- brellium_api_client/types/document_with_upload.py +15 -0
- brellium_api_client/types/em_evaluation.py +85 -0
- brellium_api_client/types/email_recipient.py +37 -0
- brellium_api_client/types/email_recipient_create_params.py +22 -0
- brellium_api_client/types/email_recipient_list.py +26 -0
- brellium_api_client/types/email_recipient_list_params.py +38 -0
- brellium_api_client/types/email_recipient_update_params.py +22 -0
- brellium_api_client/types/icd_code_finding.py +22 -0
- brellium_api_client/types/icd_criterion.py +21 -0
- brellium_api_client/types/icd_evaluation.py +32 -0
- brellium_api_client/types/mdm_data.py +18 -0
- brellium_api_client/types/mdm_problem.py +18 -0
- brellium_api_client/types/mdm_risk.py +18 -0
- brellium_api_client/types/presigned_upload.py +25 -0
- brellium_api_client/types/question.py +33 -0
- brellium_api_client/types/question_set.py +42 -0
- brellium_api_client/types/question_set_list.py +26 -0
- brellium_api_client/types/question_set_list_params.py +29 -0
- brellium_api_client/types/user.py +37 -0
- brellium_api_client/types/user_batch_result.py +59 -0
- brellium_api_client/types/user_batch_update_params.py +27 -0
- brellium_api_client/types/user_create_params.py +19 -0
- brellium_api_client/types/user_invitation.py +17 -0
- brellium_api_client/types/user_list.py +26 -0
- brellium_api_client/types/user_list_params.py +38 -0
- brellium_api_client/types/user_update_params.py +19 -0
- brellium_api_client-0.2.0.dist-info/METADATA +183 -0
- brellium_api_client-0.2.0.dist-info/RECORD +89 -0
- brellium_api_client-0.2.0.dist-info/WHEEL +4 -0
- 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)}
|