lothc 0.2.0__tar.gz → 0.2.2__tar.gz
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.
- {lothc-0.2.0 → lothc-0.2.2}/PKG-INFO +3 -2
- {lothc-0.2.0 → lothc-0.2.2}/README.md +1 -1
- {lothc-0.2.0 → lothc-0.2.2}/lothc/_client.py +344 -81
- {lothc-0.2.0 → lothc-0.2.2}/pyproject.toml +21 -9
- {lothc-0.2.0 → lothc-0.2.2}/.gitignore +0 -0
- {lothc-0.2.0 → lothc-0.2.2}/LICENSE +0 -0
- {lothc-0.2.0 → lothc-0.2.2}/lothc/__init__.py +0 -0
- {lothc-0.2.0 → lothc-0.2.2}/lothc/_compat.py +0 -0
- {lothc-0.2.0 → lothc-0.2.2}/lothc/_oauth.py +0 -0
- {lothc-0.2.0 → lothc-0.2.2}/lothc/py.typed +0 -0
- {lothc-0.2.0 → lothc-0.2.2}/lothc/testing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lothc
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Lord Of The Http Clients — a typed HTTP client built on pyreqwest, with first-class pydantic and msgspec support
|
|
5
5
|
Project-URL: Homepage, https://github.com/RogerThomas/lothc
|
|
6
6
|
Project-URL: Repository, https://github.com/RogerThomas/lothc
|
|
@@ -12,6 +12,7 @@ Classifier: Development Status :: 3 - Alpha
|
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.13
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
15
16
|
Classifier: Typing :: Typed
|
|
16
17
|
Requires-Python: >=3.13
|
|
17
18
|
Requires-Dist: pyreqwest>=0.13.0
|
|
@@ -110,7 +111,7 @@ validate yourself. Any type expression works, so `into=list[Pokemon]` decodes a
|
|
|
110
111
|
| | |
|
|
111
112
|
|---|---|
|
|
112
113
|
| **Two clients, one API** | `HTTPClient` (async) and `SyncHTTPClient` (sync) — identical surface, both backed by pyreqwest. |
|
|
113
|
-
| **Every verb** | `get`, `post`, `put`, `patch`, `delete`, `head`, `download` (each returning a `Response`: `.data`, `.status`, `.headers`) — see [Verbs](https://rogerthomas.github.io/lothc/verbs/). |
|
|
114
|
+
| **Every verb** | `get`, `post`, `put`, `patch`, `delete`, `head`, `options`, `download` (each returning a `Response`: `.data`, `.status`, `.headers`) — see [Verbs](https://rogerthomas.github.io/lothc/verbs/). |
|
|
114
115
|
| **Typed params, headers & forms** | A `BaseModel`/`Struct` for query params or headers (with `None`-field omission), or a body sent urlencoded (`data=`) or as a real multipart form (`form=`). |
|
|
115
116
|
| **Precise static types** | Every verb is paired `@overload`s, not a cast-laden generic — your editor knows the exact return type. |
|
|
116
117
|
| **SSE & streaming** | Spec-compliant SSE — automatic reconnect with `Last-Event-ID`, server `retry:` honored, typed decode (discriminated unions included) — plus raw or NDJSON-typed `stream_get`/`stream_post` — see [SSE](https://rogerthomas.github.io/lothc/sse/) / [Streaming](https://rogerthomas.github.io/lothc/streaming/). |
|
|
@@ -84,7 +84,7 @@ validate yourself. Any type expression works, so `into=list[Pokemon]` decodes a
|
|
|
84
84
|
| | |
|
|
85
85
|
|---|---|
|
|
86
86
|
| **Two clients, one API** | `HTTPClient` (async) and `SyncHTTPClient` (sync) — identical surface, both backed by pyreqwest. |
|
|
87
|
-
| **Every verb** | `get`, `post`, `put`, `patch`, `delete`, `head`, `download` (each returning a `Response`: `.data`, `.status`, `.headers`) — see [Verbs](https://rogerthomas.github.io/lothc/verbs/). |
|
|
87
|
+
| **Every verb** | `get`, `post`, `put`, `patch`, `delete`, `head`, `options`, `download` (each returning a `Response`: `.data`, `.status`, `.headers`) — see [Verbs](https://rogerthomas.github.io/lothc/verbs/). |
|
|
88
88
|
| **Typed params, headers & forms** | A `BaseModel`/`Struct` for query params or headers (with `None`-field omission), or a body sent urlencoded (`data=`) or as a real multipart form (`form=`). |
|
|
89
89
|
| **Precise static types** | Every verb is paired `@overload`s, not a cast-laden generic — your editor knows the exact return type. |
|
|
90
90
|
| **SSE & streaming** | Spec-compliant SSE — automatic reconnect with `Last-Event-ID`, server `retry:` honored, typed decode (discriminated unions included) — plus raw or NDJSON-typed `stream_get`/`stream_post` — see [SSE](https://rogerthomas.github.io/lothc/sse/) / [Streaming](https://rogerthomas.github.io/lothc/streaming/). |
|
|
@@ -167,9 +167,9 @@ type SyncAuthProvider = Callable[[], str]
|
|
|
167
167
|
|
|
168
168
|
@dataclass(slots=True)
|
|
169
169
|
class RequestInfo:
|
|
170
|
-
"""The request actually sent, attached to `Response.request
|
|
171
|
-
`HTTPResponseError.request` — the target you asked for, not necessarily the
|
|
172
|
-
response came from if redirects were followed.
|
|
170
|
+
"""The request actually sent, attached to `Response.request` (every non-streaming verb's
|
|
171
|
+
result) and to `HTTPResponseError.request` — the target you asked for, not necessarily the
|
|
172
|
+
one a final response came from if redirects were followed.
|
|
173
173
|
"""
|
|
174
174
|
|
|
175
175
|
method: str
|
|
@@ -190,7 +190,9 @@ class HTTPResponseError[E = object](Exception):
|
|
|
190
190
|
`.error` is the body decoded: as `E` when a subclass pins it
|
|
191
191
|
(`class APIError(HTTPResponseError[NotFound | Conflict]): pass`, passed as `raises=`), else
|
|
192
192
|
as a JSON object (`dict`), else `None` (HTML, text, an array). When a pinned `E` didn't match,
|
|
193
|
-
`.parse_error` holds why (the decode library's own `ValueError
|
|
193
|
+
`.parse_error` holds why (the decode library's own `ValueError`, or a `ValueError` chained to
|
|
194
|
+
whatever else decoding raised, e.g. a `RecursionError` for a body nested too deep to parse).
|
|
195
|
+
`.parse_error` isn't
|
|
194
196
|
carried across pickling, since pydantic's `ValidationError` can't be pickled.
|
|
195
197
|
"""
|
|
196
198
|
|
|
@@ -310,16 +312,14 @@ def _atomic_download_file(dest: Path) -> Generator[BufferedWriter]:
|
|
|
310
312
|
raise
|
|
311
313
|
|
|
312
314
|
|
|
313
|
-
def
|
|
314
|
-
"""Append `chunk` to `pending` and return
|
|
315
|
+
def _take_completed_lines(pending: bytearray, chunk: Buffer) -> list[bytearray]:
|
|
316
|
+
"""Append `chunk` to `pending` and return every line it completes, leaving only the
|
|
315
317
|
still-unterminated tail in `pending`.
|
|
316
318
|
|
|
317
319
|
Linear in the bytes received. The old `line, buffer = buffer.split(b"\\n", 1)` loop copied the
|
|
318
320
|
whole remainder once per line (a 4MB chunk took ~4.7s; this takes ~15ms), and only the newly
|
|
319
321
|
appended bytes are searched for a newline, since everything before the last one has already
|
|
320
|
-
been consumed; rescanning all of `pending` made one long line quadratic instead.
|
|
321
|
-
is only whitespace is skipped rather than decoded, which covers the `\\r` a CRLF-terminated
|
|
322
|
-
stream leaves on its blank lines.
|
|
322
|
+
been consumed; rescanning all of `pending` made one long line quadratic instead.
|
|
323
323
|
"""
|
|
324
324
|
start = len(pending)
|
|
325
325
|
pending += chunk
|
|
@@ -328,7 +328,32 @@ def _complete_ndjson_lines(pending: bytearray, chunk: Buffer) -> list[bytearray]
|
|
|
328
328
|
return []
|
|
329
329
|
lines = pending[:end].split(b"\n")
|
|
330
330
|
del pending[: end + 1]
|
|
331
|
-
return
|
|
331
|
+
return lines
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _complete_ndjson_lines(pending: bytearray, chunk: Buffer) -> list[bytearray]:
|
|
335
|
+
"""The non-blank NDJSON lines `chunk` completes (see `_take_completed_lines`). A line that is
|
|
336
|
+
only whitespace is skipped rather than decoded, which covers the `\\r` a CRLF-terminated
|
|
337
|
+
stream leaves on its blank lines; a JSON decoder tolerates the `\\r` ending any other line."""
|
|
338
|
+
return [line for line in _take_completed_lines(pending, chunk) if line.strip()]
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _strip_carriage_return(line: bytearray) -> bytearray:
|
|
342
|
+
return line[:-1] if line.endswith(b"\r") else line
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _complete_text_lines(pending: bytearray, chunk: Buffer) -> list[bytearray]:
|
|
346
|
+
"""The raw text lines `chunk` completes, for `into=str` (see `_take_completed_lines`): every
|
|
347
|
+
one, blank lines included, with one trailing `\\r` (from a CRLF terminator) stripped."""
|
|
348
|
+
return [_strip_carriage_return(line) for line in _take_completed_lines(pending, chunk)]
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _final_line(pending: bytearray, *, raw_text: bool) -> bytearray | None:
|
|
352
|
+
"""The unterminated tail left in `pending` when a line stream ends, if it holds a line: any
|
|
353
|
+
bytes at all for `into=str` (CR-stripped like the others), else only a non-blank one."""
|
|
354
|
+
if raw_text:
|
|
355
|
+
return _strip_carriage_return(pending) if pending else None
|
|
356
|
+
return pending if pending.strip() else None
|
|
332
357
|
|
|
333
358
|
|
|
334
359
|
def _idle_timeout_error(idle: float) -> HTTPTimeoutError:
|
|
@@ -1100,8 +1125,22 @@ class _TextDecoder:
|
|
|
1100
1125
|
return data if isinstance(data, str) else str(data, "utf-8")
|
|
1101
1126
|
|
|
1102
1127
|
|
|
1128
|
+
@dataclass(slots=True)
|
|
1129
|
+
class _SSEBytesDecoder:
|
|
1130
|
+
"""`sse(into=bytes)`: an event's `data`, which arrives as text, back as its UTF-8 bytes.
|
|
1131
|
+
Only `sse()` uses it; everywhere else `bytes` is no decoder at all (`None`)."""
|
|
1132
|
+
|
|
1133
|
+
def decode(self, data: _JSONInput) -> bytes:
|
|
1134
|
+
return data.encode() if isinstance(data, str) else bytes(data)
|
|
1135
|
+
|
|
1136
|
+
|
|
1103
1137
|
type _Decoder = (
|
|
1104
|
-
Decoder[Any]
|
|
1138
|
+
Decoder[Any]
|
|
1139
|
+
| TypeAdapter[Any]
|
|
1140
|
+
| _JSONObjectDecoder
|
|
1141
|
+
| _DictSubclassDecoder
|
|
1142
|
+
| _TextDecoder
|
|
1143
|
+
| _SSEBytesDecoder
|
|
1105
1144
|
)
|
|
1106
1145
|
type _Into = TypeForm[Any] | TypeAdapterTyping[Any] | DecoderTyping[Any] | None
|
|
1107
1146
|
|
|
@@ -1237,6 +1276,53 @@ def _is_bare_dict_subclass(form: object) -> TypeIs[type[dict[Any, Any]]]:
|
|
|
1237
1276
|
return _is_class(form) and not is_typeddict(form) and issubclass(form, dict)
|
|
1238
1277
|
|
|
1239
1278
|
|
|
1279
|
+
def _is_json_object_form(form: object) -> bool:
|
|
1280
|
+
"""Whether `form` means "any JSON object", which stdlib `json` decodes with no extra: `dict`,
|
|
1281
|
+
`dict[str, Any]` however it's spelled (`typing.Dict[str, Any]`, bare `typing.Dict`), and
|
|
1282
|
+
`Mapping[str, Any]`, since the `dict` it gets is one.
|
|
1283
|
+
|
|
1284
|
+
>>> from typing import Dict
|
|
1285
|
+
>>> [_is_json_object_form(f) for f in (dict, Dict[str, Any], Mapping[str, Any], dict[str, int])]
|
|
1286
|
+
[True, True, True, False]
|
|
1287
|
+
"""
|
|
1288
|
+
if form is dict:
|
|
1289
|
+
return True
|
|
1290
|
+
# `in` a tuple, not a set: an argument can be unhashable (`Annotated` with a `dict` in it).
|
|
1291
|
+
return get_origin(form) in {dict, Mapping} and get_args(form) in ((), (str, Any))
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
def _is_tuple(value: object) -> TypeIs[tuple[object, ...]]:
|
|
1295
|
+
# A `TypeIs`, not an inline `isinstance`: that narrows to a partially unknown tuple.
|
|
1296
|
+
return isinstance(value, tuple)
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
def _has_msgspec_custom_type(form: object) -> bool:
|
|
1300
|
+
"""Whether msgspec reads some part of `form` as a "custom type" (`Path`, `deque[int]`,
|
|
1301
|
+
`IPv4Address`, a parametrized `dict` subclass such as `OrderedDict[str, int]`): one it only
|
|
1302
|
+
decodes through a `dec_hook`, which lothc doesn't pass, so its `Decoder` builds fine and then
|
|
1303
|
+
rejects every body. Walks `msgspec.inspect.type_info`'s tree, a recursive one ending at the
|
|
1304
|
+
first node seen twice. `object` is a custom type to `type_info` but decodes as `Any`."""
|
|
1305
|
+
try:
|
|
1306
|
+
root = msgspec.inspect.type_info(form)
|
|
1307
|
+
except TypeError:
|
|
1308
|
+
# Not a type msgspec knows at all: its `Decoder` reports that itself.
|
|
1309
|
+
return False
|
|
1310
|
+
seen: set[int] = set()
|
|
1311
|
+
pending: list[object] = [root]
|
|
1312
|
+
while pending:
|
|
1313
|
+
node = pending.pop()
|
|
1314
|
+
if _is_tuple(node):
|
|
1315
|
+
pending.extend(node)
|
|
1316
|
+
continue
|
|
1317
|
+
if not isinstance(node, (msgspec.inspect.Type, msgspec.inspect.Field)) or id(node) in seen:
|
|
1318
|
+
continue
|
|
1319
|
+
if isinstance(node, msgspec.inspect.CustomType) and node.cls is not object:
|
|
1320
|
+
return True
|
|
1321
|
+
seen.add(id(node))
|
|
1322
|
+
pending.extend(getattr(node, name) for name in node.__struct_fields__)
|
|
1323
|
+
return False
|
|
1324
|
+
|
|
1325
|
+
|
|
1240
1326
|
def _msgspec_decoder(form: object) -> Decoder[Any]:
|
|
1241
1327
|
try:
|
|
1242
1328
|
return Decoder(form)
|
|
@@ -1253,12 +1339,13 @@ def _pydantic_adapter(form: object) -> TypeAdapter[Any]:
|
|
|
1253
1339
|
|
|
1254
1340
|
|
|
1255
1341
|
def _build_decoder(form: object, model_bases: set[type]) -> _Decoder:
|
|
1256
|
-
"""The decoder for a type expression: raw text for `str`, stdlib `json` for
|
|
1257
|
-
|
|
1258
|
-
to, else msgspec (faster)
|
|
1342
|
+
"""The decoder for a type expression: raw text for `str`, stdlib `json` for a JSON object
|
|
1343
|
+
(`dict`, `dict[str, Any]`, `Mapping[str, Any]`) and bare `dict` subclasses, else whichever
|
|
1344
|
+
library the form's models (or pydantic-only metadata) belong to, else msgspec (faster) unless
|
|
1345
|
+
it can't decode part of the form, else pydantic."""
|
|
1259
1346
|
if form is str:
|
|
1260
1347
|
return _TextDecoder()
|
|
1261
|
-
if form
|
|
1348
|
+
if _is_json_object_form(form):
|
|
1262
1349
|
return _JSONObjectDecoder()
|
|
1263
1350
|
if _is_bare_dict_subclass(form):
|
|
1264
1351
|
return _DictSubclassDecoder(form)
|
|
@@ -1276,14 +1363,22 @@ def _build_decoder(form: object, model_bases: set[type]) -> _Decoder:
|
|
|
1276
1363
|
" msgspec would silently skip: install lothc[pydantic]."
|
|
1277
1364
|
)
|
|
1278
1365
|
return _pydantic_adapter(form)
|
|
1279
|
-
|
|
1366
|
+
# A Struct form stays with msgspec whatever its fields are: pydantic can't build a Struct.
|
|
1367
|
+
if msgspec_installed and (Struct in model_bases or not _has_msgspec_custom_type(form)):
|
|
1280
1368
|
return _msgspec_decoder(form)
|
|
1369
|
+
if msgspec_installed and not pydantic_installed:
|
|
1370
|
+
raise TypeError(
|
|
1371
|
+
f"into={form!r} contains a type msgspec can't decode from JSON (e.g. Path, deque,"
|
|
1372
|
+
" IPv4Address, or a parametrized dict subclass such as OrderedDict[str, int]), so"
|
|
1373
|
+
" every response would fail to decode: install lothc[pydantic], which can. A bare"
|
|
1374
|
+
" dict subclass (into=OrderedDict) needs neither extra."
|
|
1375
|
+
)
|
|
1281
1376
|
if pydantic_installed:
|
|
1282
1377
|
return _pydantic_adapter(form)
|
|
1283
1378
|
raise TypeError(
|
|
1284
1379
|
f"into={form!r} needs msgspec or pydantic to decode it: install lothc[msgspec] or"
|
|
1285
|
-
" lothc[pydantic]. Only bytes, str, dict (and its subclasses)
|
|
1286
|
-
" without either."
|
|
1380
|
+
" lothc[pydantic]. Only bytes, str, dict (and its bare subclasses), dict[str, Any] and"
|
|
1381
|
+
" Mapping[str, Any] work without either."
|
|
1287
1382
|
)
|
|
1288
1383
|
|
|
1289
1384
|
|
|
@@ -1344,11 +1439,18 @@ def _decode_json_line(data: _JSONInput, decoder: _Decoder) -> Any: # noqa: ANN4
|
|
|
1344
1439
|
return decoder.decode(data)
|
|
1345
1440
|
|
|
1346
1441
|
|
|
1442
|
+
def _sse_data_decoder(into: _Into) -> _Decoder:
|
|
1443
|
+
"""The decoder for each SSE event's `data`, resolved once per stream. Never `None`, unlike
|
|
1444
|
+
`_decoder_for`'s: `data` arrives as text, so the default (`into=None`) keeps it as text and
|
|
1445
|
+
`into=bytes` encodes it back."""
|
|
1446
|
+
if into is bytes:
|
|
1447
|
+
return _SSEBytesDecoder()
|
|
1448
|
+
decoder = _decoder_for(into)
|
|
1449
|
+
return _TextDecoder() if decoder is None else decoder
|
|
1450
|
+
|
|
1451
|
+
|
|
1347
1452
|
def _dispatch_sse_record(
|
|
1348
|
-
record: bytes,
|
|
1349
|
-
state: _SSEStreamState,
|
|
1350
|
-
into: _Into,
|
|
1351
|
-
decoder: _Decoder | None,
|
|
1453
|
+
record: bytes, state: _SSEStreamState, decoder: _Decoder
|
|
1352
1454
|
) -> SSEEvent[Any] | None:
|
|
1353
1455
|
"""Apply one record's `retry:`/`id:` fields to `state`, then build its event (if it has data).
|
|
1354
1456
|
|
|
@@ -1364,12 +1466,7 @@ def _dispatch_sse_record(
|
|
|
1364
1466
|
state.last_event_id = parsed_record.id
|
|
1365
1467
|
if parsed_record.data is None:
|
|
1366
1468
|
return None
|
|
1367
|
-
|
|
1368
|
-
data = _decode_json_line(parsed_record.data, decoder)
|
|
1369
|
-
elif into is bytes:
|
|
1370
|
-
data = parsed_record.data.encode()
|
|
1371
|
-
else:
|
|
1372
|
-
data = parsed_record.data
|
|
1469
|
+
data = _decode_json_line(parsed_record.data, decoder)
|
|
1373
1470
|
return SSEEvent(id=state.last_event_id, event=parsed_record.event, data=data)
|
|
1374
1471
|
|
|
1375
1472
|
|
|
@@ -2269,7 +2366,7 @@ def _parse_typed_headers(
|
|
|
2269
2366
|
|
|
2270
2367
|
@dataclass
|
|
2271
2368
|
class Response[TData, THeaders: TypedHeaders | None = None]:
|
|
2272
|
-
"""What `get`/`post`/`put`/`patch`/`delete`/`head`/`download` return: the decoded body
|
|
2369
|
+
"""What `get`/`post`/`put`/`patch`/`delete`/`head`/`options`/`download` return: the decoded body
|
|
2273
2370
|
(`.data`) alongside the status and headers.
|
|
2274
2371
|
|
|
2275
2372
|
`.typed_headers` is `None` unless `response_headers_type` was passed to the call that
|
|
@@ -2297,12 +2394,6 @@ class _ErrorBodyDecoding:
|
|
|
2297
2394
|
decoder: _Decoder | None
|
|
2298
2395
|
|
|
2299
2396
|
|
|
2300
|
-
# One per `raises=` class, shared by both clients, like `_decoders` (which caches the decoder
|
|
2301
|
-
# itself): what this saves is the walk through the class's bases on every error. Unbounded,
|
|
2302
|
-
# since the classes come from source code, not data.
|
|
2303
|
-
_error_body_decodings: dict[type, _ErrorBodyDecoding] = {}
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
2397
|
def _is_response_error_class(value: object) -> TypeIs[type[HTTPResponseError[Any]]]:
|
|
2307
2398
|
return isinstance(value, type) and issubclass(value, HTTPResponseError)
|
|
2308
2399
|
|
|
@@ -2366,7 +2457,15 @@ def _pinned_error_form(cls: type, bindings: Mapping[TypeVar, object]) -> tuple[(
|
|
|
2366
2457
|
return ()
|
|
2367
2458
|
|
|
2368
2459
|
|
|
2369
|
-
|
|
2460
|
+
# One per `raises=` class, shared by both clients, like `_decoders` (which caches the decoder
|
|
2461
|
+
# itself): what this saves is the walk through the class's bases on every error. Unbounded, since
|
|
2462
|
+
# the classes come from source code, not data. `cache` stores nothing when the call raises, so an
|
|
2463
|
+
# unusable `raises` (not an `HTTPResponseError` subclass, an unbound `E`, an `E` no library can
|
|
2464
|
+
# decode) is a `TypeError` on every call: a caller bug, never cached and never swallowed.
|
|
2465
|
+
@cache
|
|
2466
|
+
def _error_body_decoding(raises: object) -> _ErrorBodyDecoding:
|
|
2467
|
+
"""`raises`'s resolved `E` and its decoder. Takes `object`, since nothing has checked yet
|
|
2468
|
+
that `raises` really is an `HTTPResponseError` subclass."""
|
|
2370
2469
|
if not _is_response_error_class(raises):
|
|
2371
2470
|
raise TypeError(
|
|
2372
2471
|
f"raises= must be HTTPResponseError or a subclass of it, got {raises!r}. To decode"
|
|
@@ -2385,16 +2484,6 @@ def _resolve_error_body_decoding(raises: object) -> _ErrorBodyDecoding:
|
|
|
2385
2484
|
return _ErrorBodyDecoding(pinned=True, decoder=_decoder_for(form))
|
|
2386
2485
|
|
|
2387
2486
|
|
|
2388
|
-
def _error_body_decoding(raises: type[HTTPResponseError[Any]]) -> _ErrorBodyDecoding:
|
|
2389
|
-
"""`raises`'s resolved `E` and its decoder, cached per class. An unusable `raises` (not an
|
|
2390
|
-
`HTTPResponseError` subclass, an unbound `E`, an `E` no library can decode) raises
|
|
2391
|
-
`TypeError`: a caller bug, so it's never cached and never swallowed."""
|
|
2392
|
-
decoding = _error_body_decodings.get(raises)
|
|
2393
|
-
if decoding is None:
|
|
2394
|
-
decoding = _error_body_decodings[raises] = _resolve_error_body_decoding(raises)
|
|
2395
|
-
return decoding
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
2487
|
def _validate_raises(raises: type[HTTPResponseError[Any]] | None) -> None:
|
|
2399
2488
|
"""Resolve a per-call `raises=` before anything is sent, so a bad class is a `TypeError` on
|
|
2400
2489
|
every call rather than one that only surfaces on a real 4xx/5xx, in place of that error.
|
|
@@ -2404,10 +2493,11 @@ def _validate_raises(raises: type[HTTPResponseError[Any]] | None) -> None:
|
|
|
2404
2493
|
|
|
2405
2494
|
|
|
2406
2495
|
def _json_object_or_none(body: bytes) -> dict[str, Any] | None:
|
|
2407
|
-
"""`body` as a JSON object, or `None` if it isn't one (HTML, text, an array, `null
|
|
2496
|
+
"""`body` as a JSON object, or `None` if it isn't one (HTML, text, an array, `null`, or
|
|
2497
|
+
nested too deep to parse)."""
|
|
2408
2498
|
try:
|
|
2409
2499
|
return _JSONObjectDecoder().decode(body)
|
|
2410
|
-
except ValueError:
|
|
2500
|
+
except (ValueError, RecursionError): # `RecursionError`: nested too deep for stdlib `json`
|
|
2411
2501
|
return None
|
|
2412
2502
|
|
|
2413
2503
|
|
|
@@ -2416,6 +2506,18 @@ def _decode_pinned_error(body: bytes, decoder: _Decoder | None) -> Any: # noqa:
|
|
|
2416
2506
|
return body if decoder is None else _decode_json_line(body, decoder)
|
|
2417
2507
|
|
|
2418
2508
|
|
|
2509
|
+
def _as_parse_error(error: Exception) -> ValueError:
|
|
2510
|
+
"""`error` as the `ValueError` `.parse_error` promises: itself if it already is one (every
|
|
2511
|
+
decode library's own failure), else a `ValueError` chained to it (`__cause__`), for the
|
|
2512
|
+
`RecursionError` a deeply nested body raises and the `TypeError` a pinned `dict` subclass's
|
|
2513
|
+
own constructor can raise."""
|
|
2514
|
+
if isinstance(error, ValueError):
|
|
2515
|
+
return error
|
|
2516
|
+
parse_error = ValueError(f"error body could not be decoded: {error!r}")
|
|
2517
|
+
parse_error.__cause__ = error
|
|
2518
|
+
return parse_error
|
|
2519
|
+
|
|
2520
|
+
|
|
2419
2521
|
def _status_error(
|
|
2420
2522
|
raises: type[HTTPResponseError[Any]],
|
|
2421
2523
|
status: int,
|
|
@@ -2429,21 +2531,26 @@ def _status_error(
|
|
|
2429
2531
|
own contract: a body that doesn't match the pinned `E` (a proxy's HTML 502, an error shape
|
|
2430
2532
|
the union doesn't cover) keeps the decode library's `ValueError` on `.parse_error` and falls
|
|
2431
2533
|
back to a JSON object, else `None`, rather than replacing the status error with a
|
|
2432
|
-
`ValidationError`.
|
|
2534
|
+
`ValidationError`. A body too deeply nested to parse, or one a pinned `dict` subclass's
|
|
2535
|
+
constructor rejects, is treated the same, its error wrapped in a `ValueError`. Nothing pinned:
|
|
2536
|
+
a JSON object, else `None`.
|
|
2433
2537
|
"""
|
|
2434
2538
|
decoding = _error_body_decoding(raises)
|
|
2435
2539
|
if not decoding.pinned:
|
|
2436
2540
|
return raises(status, body, request_info, _json_object_or_none(body), headers=headers)
|
|
2437
2541
|
try:
|
|
2438
2542
|
error = _decode_pinned_error(body, decoding.decoder)
|
|
2439
|
-
|
|
2543
|
+
# An unusable `E` was already rejected when `raises` was resolved, so anything decoding
|
|
2544
|
+
# raises here is the body's fault: too deeply nested (`RecursionError`), or refused by a
|
|
2545
|
+
# pinned `dict` subclass's own constructor (`TypeError`).
|
|
2546
|
+
except (ValueError, RecursionError, TypeError) as decode_error:
|
|
2440
2547
|
return raises(
|
|
2441
2548
|
status,
|
|
2442
2549
|
body,
|
|
2443
2550
|
request_info,
|
|
2444
2551
|
_json_object_or_none(body),
|
|
2445
2552
|
headers=headers,
|
|
2446
|
-
parse_error=
|
|
2553
|
+
parse_error=_as_parse_error(decode_error),
|
|
2447
2554
|
)
|
|
2448
2555
|
return raises(status, body, request_info, error, headers=headers)
|
|
2449
2556
|
|
|
@@ -2454,7 +2561,13 @@ class HTTPClient:
|
|
|
2454
2561
|
See `SyncHTTPClient` for the sync mirror — same methods, same overload shapes.
|
|
2455
2562
|
"""
|
|
2456
2563
|
|
|
2457
|
-
_default_retry_methods: ClassVar[frozenset[str]] = frozenset({
|
|
2564
|
+
_default_retry_methods: ClassVar[frozenset[str]] = frozenset({
|
|
2565
|
+
"GET",
|
|
2566
|
+
"PUT",
|
|
2567
|
+
"DELETE",
|
|
2568
|
+
"HEAD",
|
|
2569
|
+
"OPTIONS",
|
|
2570
|
+
})
|
|
2458
2571
|
# The streaming verbs (`sse`, `stream_get`, `stream_post`, `download`) swap the client's total
|
|
2459
2572
|
# `timeout` for this per-request one whenever the caller doesn't pass their own: pyreqwest has
|
|
2460
2573
|
# no "no timeout" override, and a total timeout on a long-lived body kills every healthy
|
|
@@ -2522,12 +2635,13 @@ class HTTPClient:
|
|
|
2522
2635
|
`bearer_token` is a static token; `bearer_auth` is an async callable resolved fresh on
|
|
2523
2636
|
every request; `basic_auth` is a `(username, password)` pair — provide at most one of the
|
|
2524
2637
|
three. `max_retries` enables a real retry middleware (backoff, `Retry-After`-aware); with
|
|
2525
|
-
no `retry_methods`, only the idempotent verbs (`GET`/`PUT`/`DELETE`/`HEAD`)
|
|
2526
|
-
`backoff_base` scales the wait between attempts (`backoff_base * 2 **
|
|
2527
|
-
0.1 default waits 0.1s then 0.2s) — a `Retry-After` header on the
|
|
2528
|
-
computed delay, up to `max_retry_after` (60s by default): a server
|
|
2529
|
-
ends retrying, and its 429/503 is raised as `HTTPResponseError`
|
|
2530
|
-
from `e.headers["Retry-After"]`. Pass `max_retry_after=None`
|
|
2638
|
+
no `retry_methods`, only the idempotent verbs (`GET`/`PUT`/`DELETE`/`HEAD`/`OPTIONS`)
|
|
2639
|
+
retry, and `backoff_base` scales the wait between attempts (`backoff_base * 2 **
|
|
2640
|
+
attempt`, so the 0.1 default waits 0.1s then 0.2s) — a `Retry-After` header on the
|
|
2641
|
+
response wins over the computed delay, up to `max_retry_after` (60s by default): a server
|
|
2642
|
+
asking for a longer wait ends retrying, and its 429/503 is raised as `HTTPResponseError`
|
|
2643
|
+
so you can schedule a retry from `e.headers["Retry-After"]`. Pass `max_retry_after=None`
|
|
2644
|
+
to honour any wait.
|
|
2531
2645
|
|
|
2532
2646
|
`connect_timeout` bounds only the TCP connect phase (separate from `timeout`, which
|
|
2533
2647
|
covers the whole request); `read_timeout` bounds the idle gap between two consecutive
|
|
@@ -3662,8 +3776,7 @@ class HTTPClient:
|
|
|
3662
3776
|
self,
|
|
3663
3777
|
request_builder: RequestBuilder,
|
|
3664
3778
|
state: _SSEStreamState,
|
|
3665
|
-
|
|
3666
|
-
decoder: _Decoder | None,
|
|
3779
|
+
decoder: _Decoder,
|
|
3667
3780
|
*,
|
|
3668
3781
|
error_for_status: bool,
|
|
3669
3782
|
raises: type[HTTPResponseError[Any]] | None,
|
|
@@ -3700,7 +3813,7 @@ class HTTPClient:
|
|
|
3700
3813
|
records, tail = _split_sse_records(bytes(buffer))
|
|
3701
3814
|
buffer = bytearray(tail)
|
|
3702
3815
|
for record in records:
|
|
3703
|
-
event = _dispatch_sse_record(record, state,
|
|
3816
|
+
event = _dispatch_sse_record(record, state, decoder)
|
|
3704
3817
|
if event is not None:
|
|
3705
3818
|
yield event
|
|
3706
3819
|
except (PyreqwestRequestError, PyreqwestBuilderError) as error:
|
|
@@ -3723,7 +3836,7 @@ class HTTPClient:
|
|
|
3723
3836
|
) -> AsyncGenerator[SSEEvent[Any]]:
|
|
3724
3837
|
_validate_raises(raises)
|
|
3725
3838
|
# Resolved once per stream, not per event or per reconnect.
|
|
3726
|
-
decoder =
|
|
3839
|
+
decoder = _sse_data_decoder(into)
|
|
3727
3840
|
state = _SSEStreamState(retry_delay=reconnect_delay)
|
|
3728
3841
|
consecutive_reconnects = 0
|
|
3729
3842
|
effective_timeout = timeout if timeout is not None else self._streaming_default_timeout
|
|
@@ -3737,7 +3850,6 @@ class HTTPClient:
|
|
|
3737
3850
|
connection = self._sse_connection(
|
|
3738
3851
|
request_builder,
|
|
3739
3852
|
state,
|
|
3740
|
-
into,
|
|
3741
3853
|
decoder,
|
|
3742
3854
|
error_for_status=error_for_status,
|
|
3743
3855
|
raises=raises,
|
|
@@ -3932,15 +4044,18 @@ class HTTPClient:
|
|
|
3932
4044
|
if chunk is None:
|
|
3933
4045
|
return
|
|
3934
4046
|
yield bytes(chunk)
|
|
4047
|
+
raw_text = isinstance(decoder, _TextDecoder)
|
|
4048
|
+
complete_lines = _complete_text_lines if raw_text else _complete_ndjson_lines
|
|
3935
4049
|
pending = bytearray()
|
|
3936
4050
|
while True:
|
|
3937
4051
|
chunk = await _read_chunk_within(raw_response, idle)
|
|
3938
4052
|
if chunk is None:
|
|
3939
4053
|
break
|
|
3940
|
-
for line in
|
|
4054
|
+
for line in complete_lines(pending, chunk):
|
|
3941
4055
|
yield _decode_json_line(line, decoder)
|
|
3942
|
-
|
|
3943
|
-
|
|
4056
|
+
final_line = _final_line(pending, raw_text=raw_text)
|
|
4057
|
+
if final_line is not None:
|
|
4058
|
+
yield _decode_json_line(final_line, decoder)
|
|
3944
4059
|
except (PyreqwestRequestError, PyreqwestBuilderError) as error:
|
|
3945
4060
|
raise _translate_transport_error(error) from error
|
|
3946
4061
|
|
|
@@ -4342,6 +4457,76 @@ class HTTPClient:
|
|
|
4342
4457
|
elapsed,
|
|
4343
4458
|
)
|
|
4344
4459
|
|
|
4460
|
+
@overload
|
|
4461
|
+
async def options(
|
|
4462
|
+
self,
|
|
4463
|
+
path: str,
|
|
4464
|
+
*,
|
|
4465
|
+
params: Params | None = None,
|
|
4466
|
+
headers: Headers | None = None,
|
|
4467
|
+
timeout: float | None = None,
|
|
4468
|
+
skip_auth: bool = False,
|
|
4469
|
+
error_for_status: bool = True,
|
|
4470
|
+
raises: type[HTTPResponseError[Any]] | None = None,
|
|
4471
|
+
) -> Response[None]: ...
|
|
4472
|
+
@overload
|
|
4473
|
+
async def options[THeaders: TypedHeaders](
|
|
4474
|
+
self,
|
|
4475
|
+
path: str,
|
|
4476
|
+
*,
|
|
4477
|
+
params: Params | None = None,
|
|
4478
|
+
headers: Headers | None = None,
|
|
4479
|
+
timeout: float | None = None,
|
|
4480
|
+
skip_auth: bool = False,
|
|
4481
|
+
response_headers_type: type[THeaders],
|
|
4482
|
+
error_for_status: bool = True,
|
|
4483
|
+
raises: type[HTTPResponseError[Any]] | None = None,
|
|
4484
|
+
) -> Response[None, THeaders]: ...
|
|
4485
|
+
async def options(
|
|
4486
|
+
self,
|
|
4487
|
+
path: str,
|
|
4488
|
+
*,
|
|
4489
|
+
params: Params | None = None,
|
|
4490
|
+
headers: Headers | None = None,
|
|
4491
|
+
timeout: float | None = None,
|
|
4492
|
+
skip_auth: bool = False,
|
|
4493
|
+
response_headers_type: type[TypedHeaders] | None = None,
|
|
4494
|
+
error_for_status: bool = True,
|
|
4495
|
+
raises: type[HTTPResponseError[Any]] | None = None,
|
|
4496
|
+
) -> Response[None, Any]:
|
|
4497
|
+
"""OPTIONS `path` (e.g. a CORS preflight) — status and headers only, any body is discarded.
|
|
4498
|
+
Pass `response_headers_type` to get the response headers parsed into `.typed_headers`.
|
|
4499
|
+
|
|
4500
|
+
`timeout` overrides the client's own for this call only; `skip_auth` omits the
|
|
4501
|
+
`Authorization` header (and skips invoking `bearer_auth`) for this call; `raises` overrides
|
|
4502
|
+
the client's own `raises` (the `HTTPResponseError` subclass a 4xx/5xx is raised as) for
|
|
4503
|
+
this call.
|
|
4504
|
+
"""
|
|
4505
|
+
_validate_raises(raises)
|
|
4506
|
+
request_builder = await self._prepare_request(
|
|
4507
|
+
self._client.request("OPTIONS", path), params, headers, timeout, skip_auth=skip_auth
|
|
4508
|
+
)
|
|
4509
|
+
started = time.monotonic()
|
|
4510
|
+
raw_response, request_info = await _send(request_builder)
|
|
4511
|
+
elapsed = time.monotonic() - started
|
|
4512
|
+
if error_for_status:
|
|
4513
|
+
await self._check_status(raw_response, raises, request_info)
|
|
4514
|
+
response_headers = _LazyHeaders(raw_response.headers)
|
|
4515
|
+
typed_headers = (
|
|
4516
|
+
None
|
|
4517
|
+
if response_headers_type is None
|
|
4518
|
+
else _parse_typed_headers(response_headers, response_headers_type)
|
|
4519
|
+
)
|
|
4520
|
+
return Response(
|
|
4521
|
+
None,
|
|
4522
|
+
raw_response.status,
|
|
4523
|
+
response_headers,
|
|
4524
|
+
typed_headers,
|
|
4525
|
+
request_info,
|
|
4526
|
+
raw_response.version,
|
|
4527
|
+
elapsed,
|
|
4528
|
+
)
|
|
4529
|
+
|
|
4345
4530
|
|
|
4346
4531
|
class SyncHTTPClient:
|
|
4347
4532
|
"""Sync typed HTTP client: `with SyncHTTPClient(base_url=...) as client:`.
|
|
@@ -4349,7 +4534,13 @@ class SyncHTTPClient:
|
|
|
4349
4534
|
See `HTTPClient` for the async mirror — same methods, same overload shapes.
|
|
4350
4535
|
"""
|
|
4351
4536
|
|
|
4352
|
-
_default_retry_methods: ClassVar[frozenset[str]] = frozenset({
|
|
4537
|
+
_default_retry_methods: ClassVar[frozenset[str]] = frozenset({
|
|
4538
|
+
"GET",
|
|
4539
|
+
"PUT",
|
|
4540
|
+
"DELETE",
|
|
4541
|
+
"HEAD",
|
|
4542
|
+
"OPTIONS",
|
|
4543
|
+
})
|
|
4353
4544
|
# The streaming verbs (`sse`, `stream_get`, `stream_post`, `download`) swap the client's total
|
|
4354
4545
|
# `timeout` for this per-request one whenever the caller doesn't pass their own: pyreqwest has
|
|
4355
4546
|
# no "no timeout" override, and a total timeout on a long-lived body kills every healthy
|
|
@@ -4417,12 +4608,13 @@ class SyncHTTPClient:
|
|
|
4417
4608
|
`bearer_token` is a static token; `bearer_auth` is a callable resolved fresh on every
|
|
4418
4609
|
request; `basic_auth` is a `(username, password)` pair — provide at most one of the
|
|
4419
4610
|
three. `max_retries` enables a real retry middleware (backoff, `Retry-After`-aware); with
|
|
4420
|
-
no `retry_methods`, only the idempotent verbs (`GET`/`PUT`/`DELETE`/`HEAD`)
|
|
4421
|
-
`backoff_base` scales the wait between attempts (`backoff_base * 2 **
|
|
4422
|
-
0.1 default waits 0.1s then 0.2s) — a `Retry-After` header on the
|
|
4423
|
-
computed delay, up to `max_retry_after` (60s by default): a server
|
|
4424
|
-
ends retrying, and its 429/503 is raised as `HTTPResponseError`
|
|
4425
|
-
from `e.headers["Retry-After"]`. Pass `max_retry_after=None`
|
|
4611
|
+
no `retry_methods`, only the idempotent verbs (`GET`/`PUT`/`DELETE`/`HEAD`/`OPTIONS`)
|
|
4612
|
+
retry, and `backoff_base` scales the wait between attempts (`backoff_base * 2 **
|
|
4613
|
+
attempt`, so the 0.1 default waits 0.1s then 0.2s) — a `Retry-After` header on the
|
|
4614
|
+
response wins over the computed delay, up to `max_retry_after` (60s by default): a server
|
|
4615
|
+
asking for a longer wait ends retrying, and its 429/503 is raised as `HTTPResponseError`
|
|
4616
|
+
so you can schedule a retry from `e.headers["Retry-After"]`. Pass `max_retry_after=None`
|
|
4617
|
+
to honour any wait.
|
|
4426
4618
|
|
|
4427
4619
|
`connect_timeout` bounds only the TCP connect phase (separate from `timeout`, which
|
|
4428
4620
|
covers the whole request); `read_timeout` bounds the idle gap between two consecutive
|
|
@@ -5555,8 +5747,7 @@ class SyncHTTPClient:
|
|
|
5555
5747
|
self,
|
|
5556
5748
|
request_builder: SyncRequestBuilder,
|
|
5557
5749
|
state: _SSEStreamState,
|
|
5558
|
-
|
|
5559
|
-
decoder: _Decoder | None,
|
|
5750
|
+
decoder: _Decoder,
|
|
5560
5751
|
*,
|
|
5561
5752
|
error_for_status: bool,
|
|
5562
5753
|
raises: type[HTTPResponseError[Any]] | None,
|
|
@@ -5589,7 +5780,7 @@ class SyncHTTPClient:
|
|
|
5589
5780
|
records, tail = _split_sse_records(bytes(buffer))
|
|
5590
5781
|
buffer = bytearray(tail)
|
|
5591
5782
|
for record in records:
|
|
5592
|
-
event = _dispatch_sse_record(record, state,
|
|
5783
|
+
event = _dispatch_sse_record(record, state, decoder)
|
|
5593
5784
|
if event is not None:
|
|
5594
5785
|
yield event
|
|
5595
5786
|
except (PyreqwestRequestError, PyreqwestBuilderError) as error:
|
|
@@ -5613,7 +5804,7 @@ class SyncHTTPClient:
|
|
|
5613
5804
|
) -> Generator[SSEEvent[Any]]:
|
|
5614
5805
|
_validate_raises(raises)
|
|
5615
5806
|
# Resolved once per stream, not per event or per reconnect.
|
|
5616
|
-
decoder =
|
|
5807
|
+
decoder = _sse_data_decoder(into)
|
|
5617
5808
|
state = _SSEStreamState(retry_delay=reconnect_delay)
|
|
5618
5809
|
consecutive_reconnects = 0
|
|
5619
5810
|
effective_timeout = timeout if timeout is not None else self._streaming_default_timeout
|
|
@@ -5627,7 +5818,6 @@ class SyncHTTPClient:
|
|
|
5627
5818
|
connection = self._sse_connection(
|
|
5628
5819
|
request_builder,
|
|
5629
5820
|
state,
|
|
5630
|
-
into,
|
|
5631
5821
|
decoder,
|
|
5632
5822
|
error_for_status=error_for_status,
|
|
5633
5823
|
raises=raises,
|
|
@@ -5841,12 +6031,15 @@ class SyncHTTPClient:
|
|
|
5841
6031
|
if decoder is None:
|
|
5842
6032
|
yield from chunks
|
|
5843
6033
|
return
|
|
6034
|
+
raw_text = isinstance(decoder, _TextDecoder)
|
|
6035
|
+
complete_lines = _complete_text_lines if raw_text else _complete_ndjson_lines
|
|
5844
6036
|
pending = bytearray()
|
|
5845
6037
|
for chunk in chunks:
|
|
5846
|
-
for line in
|
|
6038
|
+
for line in complete_lines(pending, chunk):
|
|
5847
6039
|
yield _decode_json_line(line, decoder)
|
|
5848
|
-
|
|
5849
|
-
|
|
6040
|
+
final_line = _final_line(pending, raw_text=raw_text)
|
|
6041
|
+
if final_line is not None:
|
|
6042
|
+
yield _decode_json_line(final_line, decoder)
|
|
5850
6043
|
except (PyreqwestRequestError, PyreqwestBuilderError) as error:
|
|
5851
6044
|
raise _translate_transport_error(error) from error
|
|
5852
6045
|
|
|
@@ -6281,3 +6474,73 @@ class SyncHTTPClient:
|
|
|
6281
6474
|
raw_response.version,
|
|
6282
6475
|
elapsed,
|
|
6283
6476
|
)
|
|
6477
|
+
|
|
6478
|
+
@overload
|
|
6479
|
+
def options(
|
|
6480
|
+
self,
|
|
6481
|
+
path: str,
|
|
6482
|
+
*,
|
|
6483
|
+
params: Params | None = None,
|
|
6484
|
+
headers: Headers | None = None,
|
|
6485
|
+
timeout: float | None = None,
|
|
6486
|
+
skip_auth: bool = False,
|
|
6487
|
+
error_for_status: bool = True,
|
|
6488
|
+
raises: type[HTTPResponseError[Any]] | None = None,
|
|
6489
|
+
) -> Response[None]: ...
|
|
6490
|
+
@overload
|
|
6491
|
+
def options[THeaders: TypedHeaders](
|
|
6492
|
+
self,
|
|
6493
|
+
path: str,
|
|
6494
|
+
*,
|
|
6495
|
+
params: Params | None = None,
|
|
6496
|
+
headers: Headers | None = None,
|
|
6497
|
+
timeout: float | None = None,
|
|
6498
|
+
skip_auth: bool = False,
|
|
6499
|
+
response_headers_type: type[THeaders],
|
|
6500
|
+
error_for_status: bool = True,
|
|
6501
|
+
raises: type[HTTPResponseError[Any]] | None = None,
|
|
6502
|
+
) -> Response[None, THeaders]: ...
|
|
6503
|
+
def options(
|
|
6504
|
+
self,
|
|
6505
|
+
path: str,
|
|
6506
|
+
*,
|
|
6507
|
+
params: Params | None = None,
|
|
6508
|
+
headers: Headers | None = None,
|
|
6509
|
+
timeout: float | None = None,
|
|
6510
|
+
skip_auth: bool = False,
|
|
6511
|
+
response_headers_type: type[TypedHeaders] | None = None,
|
|
6512
|
+
error_for_status: bool = True,
|
|
6513
|
+
raises: type[HTTPResponseError[Any]] | None = None,
|
|
6514
|
+
) -> Response[None, Any]:
|
|
6515
|
+
"""OPTIONS `path` (e.g. a CORS preflight) — status and headers only, any body is discarded.
|
|
6516
|
+
Pass `response_headers_type` to get the response headers parsed into `.typed_headers`.
|
|
6517
|
+
|
|
6518
|
+
`timeout` overrides the client's own for this call only; `skip_auth` omits the
|
|
6519
|
+
`Authorization` header (and skips invoking `bearer_auth`) for this call; `raises` overrides
|
|
6520
|
+
the client's own `raises` (the `HTTPResponseError` subclass a 4xx/5xx is raised as) for
|
|
6521
|
+
this call.
|
|
6522
|
+
"""
|
|
6523
|
+
_validate_raises(raises)
|
|
6524
|
+
request_builder = self._prepare_request(
|
|
6525
|
+
self._client.request("OPTIONS", path), params, headers, timeout, skip_auth=skip_auth
|
|
6526
|
+
)
|
|
6527
|
+
started = time.monotonic()
|
|
6528
|
+
raw_response, request_info = _send_sync(request_builder)
|
|
6529
|
+
elapsed = time.monotonic() - started
|
|
6530
|
+
if error_for_status:
|
|
6531
|
+
self._check_status(raw_response, raises, request_info)
|
|
6532
|
+
response_headers = _LazyHeaders(raw_response.headers)
|
|
6533
|
+
typed_headers = (
|
|
6534
|
+
None
|
|
6535
|
+
if response_headers_type is None
|
|
6536
|
+
else _parse_typed_headers(response_headers, response_headers_type)
|
|
6537
|
+
)
|
|
6538
|
+
return Response(
|
|
6539
|
+
None,
|
|
6540
|
+
raw_response.status,
|
|
6541
|
+
response_headers,
|
|
6542
|
+
typed_headers,
|
|
6543
|
+
request_info,
|
|
6544
|
+
raw_response.version,
|
|
6545
|
+
elapsed,
|
|
6546
|
+
)
|
|
@@ -17,6 +17,7 @@ classifiers = [
|
|
|
17
17
|
"Intended Audience :: Developers",
|
|
18
18
|
"Programming Language :: Python :: 3.13",
|
|
19
19
|
"Programming Language :: Python :: 3.14",
|
|
20
|
+
"Programming Language :: Python :: 3.15",
|
|
20
21
|
"Typing :: Typed",
|
|
21
22
|
]
|
|
22
23
|
|
|
@@ -48,35 +49,45 @@ lothc = "lothc.testing"
|
|
|
48
49
|
|
|
49
50
|
[dependency-groups]
|
|
50
51
|
dev = [
|
|
51
|
-
"aiohttp>=3.14.3",
|
|
52
|
-
"aiosonic>=1.0.5",
|
|
53
52
|
"asv>=0.6.4",
|
|
54
53
|
"basedpyright>=1.38.0",
|
|
55
54
|
"coverage>=7.16.0",
|
|
56
55
|
"deptry>=0.25.1",
|
|
57
|
-
"httpx>=0.28.1",
|
|
58
|
-
"httpx2>=2.12.0",
|
|
59
56
|
"msgspec>=0.19.0",
|
|
60
57
|
"mypy>=2.3.1",
|
|
61
|
-
"niquests>=3.21.0",
|
|
62
58
|
"prek>=0.4.14",
|
|
63
59
|
"pydantic>=2.12.5",
|
|
64
60
|
"pylint>=4.0.7",
|
|
65
61
|
"pytest>=9.0.2",
|
|
66
62
|
"pytest-asyncio>=1.3.0",
|
|
67
63
|
"pytest-codspeed>=5.0.3",
|
|
68
|
-
"requests>=2.34.2",
|
|
69
64
|
"rich>=15.0.0",
|
|
70
65
|
"ruff>=0.15.14",
|
|
71
66
|
"trustme>=1.2.1",
|
|
72
67
|
"ty>=0.0.74",
|
|
73
|
-
"types-requests>=2.33.0.20260712",
|
|
74
68
|
"vulture>=2.16",
|
|
75
69
|
"yeetr[uvloop]>=2026.8.14.post1",
|
|
76
70
|
"zuban>=0.9.1",
|
|
77
71
|
]
|
|
72
|
+
# The other HTTP clients perf.py races lothc against (plus requests' stubs, since `basedpyright .`
|
|
73
|
+
# type-checks perf.py). Nothing in lothc/ or tests/ imports them, so the test-only free-threaded
|
|
74
|
+
# CI jobs leave this group out: niquests pulls in qh3, which has no free-threaded 3.15 wheel.
|
|
75
|
+
perf = [
|
|
76
|
+
"aiohttp>=3.14.3",
|
|
77
|
+
"aiosonic>=1.0.5",
|
|
78
|
+
"httpx>=0.28.1",
|
|
79
|
+
"httpx2>=2.12.0",
|
|
80
|
+
"niquests>=3.21.0",
|
|
81
|
+
"requests>=2.34.2",
|
|
82
|
+
"types-requests>=2.33.0.20260712",
|
|
83
|
+
]
|
|
78
84
|
docs = ["zensical>=0.0.56"]
|
|
79
85
|
|
|
86
|
+
[tool.uv]
|
|
87
|
+
# perf too, so a plain `uv run` (which syncs the default groups) keeps perf.py runnable and
|
|
88
|
+
# type-checkable locally; jobs that must not install it pass --no-default-groups.
|
|
89
|
+
default-groups = ["dev", "perf"]
|
|
90
|
+
|
|
80
91
|
[build-system]
|
|
81
92
|
requires = ["hatchling", "hatch-vcs"]
|
|
82
93
|
build-backend = "hatchling.build"
|
|
@@ -193,6 +204,7 @@ known-first-party = ["lothc"]
|
|
|
193
204
|
"S105", # hardcoded-password false positives on deliberate literal test values, e.g.
|
|
194
205
|
# test_oauth.py asserting a cache file holds `"access_token": "token-1"`
|
|
195
206
|
"S106", # hardcoded-password false positives on deliberate literal test values
|
|
207
|
+
"S107", # the same, as a default: `_oauth_server.py`'s `client_secret="client-secret"`
|
|
196
208
|
"S603", # subprocess call with a fixed, non-attacker-controlled argument list
|
|
197
209
|
"S607", # partial executable path — deliberately PATH-resolved, matching Taskfile invocations
|
|
198
210
|
]
|
|
@@ -251,8 +263,8 @@ max-attributes = 15 # `OAuthProvider`/`SyncOAuthProvider` (lothc/_oauth.py) stor
|
|
|
251
263
|
max-locals = 29 # one real case (the streaming line-buffering generator) legitimately needs a
|
|
252
264
|
# couple more than pylint's default 15 — matches this project's own mccabe complexity ceiling
|
|
253
265
|
# ([tool.ruff.lint.mccabe] max-complexity = 15) in spirit, just a different metric. Bumped again,
|
|
254
|
-
# 20 -> 21, after `_sse_stream`/its sync mirror gained a 21st local (the new `error_type`
|
|
255
|
-
# threaded through for typed error bodies), then 21 -> 27 after `build`/its sync mirror gained
|
|
266
|
+
# 20 -> 21, after `_sse_stream`/its sync mirror gained a 21st local (the then-new `error_type`
|
|
267
|
+
# param, since replaced by `raises`, threaded through for typed error bodies), then 21 -> 27 after `build`/its sync mirror gained
|
|
256
268
|
# the TLS/connection-pool config params (`connect_timeout`, `root_certificates`, `identity_pem`,
|
|
257
269
|
# `min_tls_version`, `max_tls_version`, `danger_accept_invalid_certs`, `https_only`,
|
|
258
270
|
# `max_connections`, `pool_idle_timeout`, `pool_max_idle_per_host`, `pool_timeout`), then
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|