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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lothc
3
- Version: 0.2.0
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`/`head`'s result and to
171
- `HTTPResponseError.request` — the target you asked for, not necessarily the one a final
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`). `.parse_error` isn't
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 _complete_ndjson_lines(pending: bytearray, chunk: Buffer) -> list[bytearray]:
314
- """Append `chunk` to `pending` and return the non-blank lines it completes, leaving only the
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. A line that
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 [line for line in lines if line.strip()]
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] | TypeAdapter[Any] | _JSONObjectDecoder | _DictSubclassDecoder | _TextDecoder
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 `dict` and its
1257
- bare subclasses, else whichever library the form's models (or pydantic-only metadata) belong
1258
- to, else msgspec (faster), else pydantic."""
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 is dict or form == dict[str, Any]:
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
- if msgspec_installed:
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) and dict[str, Any] work"
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
- if decoder is not None:
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
- def _resolve_error_body_decoding(raises: object) -> _ErrorBodyDecoding:
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`. Nothing pinned: a JSON object, else `None`.
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
- except ValueError as parse_error:
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=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({"GET", "PUT", "DELETE", "HEAD"})
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`) retry, and
2526
- `backoff_base` scales the wait between attempts (`backoff_base * 2 ** attempt`, so the
2527
- 0.1 default waits 0.1s then 0.2s) — a `Retry-After` header on the response wins over the
2528
- computed delay, up to `max_retry_after` (60s by default): a server asking for a longer wait
2529
- ends retrying, and its 429/503 is raised as `HTTPResponseError` so you can schedule a retry
2530
- from `e.headers["Retry-After"]`. Pass `max_retry_after=None` to honour any wait.
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
- into: _Into,
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, into, decoder)
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 = _decoder_for(into)
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 _complete_ndjson_lines(pending, chunk):
4054
+ for line in complete_lines(pending, chunk):
3941
4055
  yield _decode_json_line(line, decoder)
3942
- if pending.strip():
3943
- yield _decode_json_line(pending, decoder)
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({"GET", "PUT", "DELETE", "HEAD"})
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`) retry, and
4421
- `backoff_base` scales the wait between attempts (`backoff_base * 2 ** attempt`, so the
4422
- 0.1 default waits 0.1s then 0.2s) — a `Retry-After` header on the response wins over the
4423
- computed delay, up to `max_retry_after` (60s by default): a server asking for a longer wait
4424
- ends retrying, and its 429/503 is raised as `HTTPResponseError` so you can schedule a retry
4425
- from `e.headers["Retry-After"]`. Pass `max_retry_after=None` to honour any wait.
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
- into: _Into,
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, into, decoder)
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 = _decoder_for(into)
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 _complete_ndjson_lines(pending, chunk):
6038
+ for line in complete_lines(pending, chunk):
5847
6039
  yield _decode_json_line(line, decoder)
5848
- if pending.strip():
5849
- yield _decode_json_line(pending, decoder)
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` param,
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