From c0b6327a950a8c45e47714837a01013d16d4bc5f Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 17 Oct 2026 11:44:44 +0300 Subject: [PATCH 1/3] fix: order-aware decoder cache, msgspec field claims, unread responses - Key non-class models by themselves plus their __args__ in order, so `A | B` and `B | A` (and `list[A | B]` vs `list[B | A]`) no longer share a TypeAdapter; plain classes keep the identity key. - Replace the verdict cache and adapter cache with one dict per decoder (key -> adapter or None) read by both can_decode and decode. - Key unhashable models by repr with an equality check, so their adapter is built once instead of on every request. - MsgspecDecoder.can_decode walks field types of Structs, dataclasses, TypedDicts and NamedTuples (cycle-safe) and rejects models that would need a dec_hook, so MissingDecoderError fires before the request. - _BoundDecoder reads an unread response body before decoding, inside the DecodeError wrap. --- docs/decoders.md | 5 +- src/httpware/decoders/_caching.py | 58 +++++++---- src/httpware/decoders/_resolver.py | 4 +- src/httpware/decoders/msgspec.py | 115 +++++++++++----------- src/httpware/decoders/pydantic.py | 43 ++++----- tests/test_decoder_resolver.py | 53 ++++++++++- tests/test_decoders_msgspec.py | 148 +++++++++++++++++++++++++---- tests/test_decoders_pydantic.py | 66 +++++++++++-- 8 files changed, 348 insertions(+), 144 deletions(-) diff --git a/docs/decoders.md b/docs/decoders.md index 5b094fc..83585a0 100644 --- a/docs/decoders.md +++ b/docs/decoders.md @@ -33,10 +33,11 @@ Both clients take `decoders: Sequence[ResponseDecoder] | None = None`. The list - Order is preference. With `decoders=[CsvDecoder(), PydanticDecoder()]`, pydantic only sees the types the CSV decoder declined. When two decoders could both handle a type, the earlier one wins. - `decoders=None` uses the installed extras: pydantic then msgspec when both are installed, whichever one is installed, or no decoders at all. Passing a list replaces the defaults, so include the built-ins you still want: `decoders=[CsvDecoder(), PydanticDecoder()]`. - If `response_model=` is set and no decoder accepts it, the client raises `MissingDecoderError` before sending the request. That means nothing handles the type, and the fix is to install an extra or pass `decoders=[...]`. A `DecodeError` instead means a decoder ran and the body didn't fit the type, which points at the server or the model. See [Errors](errors.md). +- `MsgspecDecoder` accepts a `Struct`, dataclass, `TypedDict` or `NamedTuple` only if msgspec can decode every field type itself, nested fields included. A field typed as a pydantic model or some other class would need a `dec_hook`, which `MsgspecDecoder` doesn't take, so it declines the model and you get `MissingDecoderError` before the request goes out. ## One sync protocol for both clients -Middleware comes in sync and async versions, but there is only one `ResponseDecoder` protocol, used by both clients. `decode` is synchronous because the body has already been read when it runs, so there is nothing to await. The same decoder works with either client. +Middleware comes in sync and async versions, but there is only one `ResponseDecoder` protocol, used by both clients. `decode` is synchronous because the client has the whole body by the time it runs, so there is nothing to await. If a middleware returns a streamed response it never read, the client reads it before decoding. In `AsyncClient` that stream also has to support sync iteration, as `httpx2.ByteStream` does; otherwise the middleware should `await response.aread()` before returning it. The same decoder works with either client. ## Writing your own @@ -131,5 +132,5 @@ This decoder makes two passes: `json.loads`, then `structure`. The built-ins dec ## See also -- [`src/httpware/decoders/`](https://github.com/modern-python/httpware/tree/main/src/httpware/decoders): the built-in decoders, including how they cache `can_decode` results and parsers per type. +- [`src/httpware/decoders/`](https://github.com/modern-python/httpware/tree/main/src/httpware/decoders): the built-in decoders, including how each caches one parser, or one rejection, per type. - [Quickstart: typed responses](index.md#typed-responses): `response_model=` with the default decoders. diff --git a/src/httpware/decoders/_caching.py b/src/httpware/decoders/_caching.py index c3989f3..d0524ba 100644 --- a/src/httpware/decoders/_caching.py +++ b/src/httpware/decoders/_caching.py @@ -1,30 +1,52 @@ -"""Shared memoizing get-or-build cache for the decoder adapters (Seam C). +"""One cache per decoder, keyed so that union member order counts (Seam C). -Imports neither pydantic nor msgspec — safe for both decoder modules to -import without crossing Seam C's per-extra import isolation. +Imports neither pydantic nor msgspec, so both decoder modules can use it +without crossing Seam C's per-extra import isolation. """ import typing +from collections.abc import Callable, Hashable V = typing.TypeVar("V") +_Cache: typing.TypeAlias = dict[Hashable, tuple[object, V | None]] -def _get_or_build(cache: dict[type, V], model: type, build: typing.Callable[[], V]) -> V: - """Return cache[model], building and memoizing it via build() on miss. - Unhashable models bypass the cache entirely: dict.get(model) raises - TypeError, in which case this returns a fresh, uncached build() every - call rather than raising. A real cached value (bool verdict or a - built adapter/decoder) is never None, so a None result unambiguously - means "not cached yet" — callers must not store None as a value. +def _key(model: object) -> Hashable: + """Return a key that tells `int | str` from `str | int`, which compare equal. + + A class keys by identity; anything else by itself plus the keys of its + `__args__`, in order. + """ + if isinstance(model, type): + return model + return (model, *map(_key, getattr(model, "__args__", ()))) + + +def _lookup(cache: "_Cache[V]", model: object, build: Callable[[typing.Any], V | None]) -> V | None: + """Return the cached `build(model)`, building it on a miss; None means `model` cannot be decoded. + + `build` must not raise. Concurrent first calls may build twice; the last write wins. """ try: - cached = cache.get(model) - except TypeError: - return build() - if cached is not None: - return cached - result = build() - cache[model] = result - return result + key = _key(model) + entry = cache.get(key) + except Exception: # noqa: BLE001 — unhashable models fall back to a repr key + return _lookup_by_repr(cache, model, build) + if entry is None: + entry = cache[key] = (model, build(model)) + return entry[1] + + +def _lookup_by_repr(cache: "_Cache[V]", model: object, build: Callable[[typing.Any], V | None]) -> V | None: + """Key an unhashable `model` by its repr; the equality check keeps two models with one repr apart.""" + try: + key = repr(model) + entry = cache.get(key) + if entry is not None and entry[0] == model: + return entry[1] + except Exception: # noqa: BLE001 — a model that cannot be keyed is built uncached + return build(model) + entry = cache[key] = (model, build(model)) + return entry[1] diff --git a/src/httpware/decoders/_resolver.py b/src/httpware/decoders/_resolver.py index 3e3cec3..dd5acd9 100644 --- a/src/httpware/decoders/_resolver.py +++ b/src/httpware/decoders/_resolver.py @@ -36,9 +36,9 @@ def __init__(self, decoder: ResponseDecoder, model: type[T]) -> None: self._model = model def decode(self, response: httpx2.Response) -> T: - """Decode `response.content` into `model`, wrapping any failure as `DecodeError`.""" + """Read the body if needed and decode it into `model`, wrapping any failure as `DecodeError`.""" try: - return self._decoder.decode(response.content, self._model) + return self._decoder.decode(response.read(), self._model) except Exception as exc: raise DecodeError(response=response, model=self._model, original=exc) from exc diff --git a/src/httpware/decoders/msgspec.py b/src/httpware/decoders/msgspec.py index 5b51ce3..5211994 100644 --- a/src/httpware/decoders/msgspec.py +++ b/src/httpware/decoders/msgspec.py @@ -1,10 +1,11 @@ """MsgspecDecoder — opt-in ResponseDecoder backed by a per-instance msgspec.json.Decoder cache.""" import typing +from collections.abc import Iterator from typing import TypeVar from httpware._internal import import_checker -from httpware.decoders._caching import _get_or_build +from httpware.decoders._caching import _Cache, _lookup if import_checker.is_msgspec_installed: @@ -16,36 +17,50 @@ T = TypeVar("T") -def _contains_custom_type(info: "msgspec.inspect.Type") -> bool: - """Return True if `info` is a CustomType or nests one in its parameters. +def _child_types(info: "msgspec.inspect.Type") -> Iterator["msgspec.inspect.Type"]: + """Yield the types nested in `info`: container parameters and the field types of a Struct-like model.""" + for name in info.__struct_fields__: + value = getattr(info, name) + if isinstance(value, msgspec.inspect.Type): + yield value + elif isinstance(value, tuple): + for item in value: + if isinstance(item, msgspec.inspect.Type): + yield item + elif isinstance(item, msgspec.inspect.Field): + yield item.type + - Walks generic-container parameterization (list/dict/set/tuple/union element - types) by visiting any attribute that is itself a `msgspec.inspect.Type` or a - tuple of them. It deliberately does NOT descend into `StructType`/dataclass - fields: those expose `fields` as `Field` objects (not `Type`), so the walk - stops at the boundary of a type msgspec natively owns. That boundary is what - makes the walk both correct (a Struct is a valid target) and safe against - infinite recursion on self-referential struct definitions. +def _is_native(info: "msgspec.inspect.Type") -> bool: + """Return True iff no `CustomType` appears anywhere in the type graph rooted at `info`. + + The graph is cyclic for self-referential models, so each node is visited once. """ - if not import_checker.is_msgspec_installed: - raise ImportError(MISSING_DEPENDENCY_MESSAGE) - if isinstance(info, msgspec.inspect.CustomType): - return True - for name in dir(info): - if name.startswith("_"): + seen: set[int] = set() + stack = [info] + while stack: + node = stack.pop() + if isinstance(node, msgspec.inspect.CustomType): + return False + if id(node) in seen: continue - value = getattr(info, name, None) - if isinstance(value, msgspec.inspect.Type): - if _contains_custom_type(value): - return True - elif ( - isinstance(value, tuple) - and value - and all(isinstance(item, msgspec.inspect.Type) for item in value) - and any(_contains_custom_type(item) for item in value) - ): - return True - return False + seen.add(id(node)) + stack.extend(_child_types(node)) + return True + + +def _build_adapter(model: object) -> "msgspec.json.Decoder[typing.Any] | None": + """Return a `msgspec.json.Decoder` for `model`, or None when msgspec cannot decode it natively. + + msgspec builds a Decoder for almost any class through its `CustomType` + fallback, so the type graph is checked for one first, field types included. + """ + try: + if not _is_native(msgspec.inspect.type_info(model)): + return None + return msgspec.json.Decoder(model) + except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no + return None class MsgspecDecoder: @@ -56,49 +71,25 @@ class MsgspecDecoder: `find_spec` check), but instantiating the decoder raises `ImportError`. """ - _msgspec_decoders: dict[type, "msgspec.json.Decoder[typing.Any]"] - _can_decode_results: dict[type, bool] + _adapters: "_Cache[msgspec.json.Decoder[typing.Any]]" def __init__(self) -> None: if not import_checker.is_msgspec_installed: raise ImportError(MISSING_DEPENDENCY_MESSAGE) - self._msgspec_decoders = {} - self._can_decode_results = {} - - def _get_msgspec_decoder(self, model: type[T]) -> "msgspec.json.Decoder[T]": - return _get_or_build(self._msgspec_decoders, model, lambda: msgspec.json.Decoder(model)) + self._adapters = {} def can_decode(self, model: type) -> bool: """Return True iff msgspec natively understands `model` end-to-end. - The verdict is memoized per `model`: the probe below (an uncached - `type_info` call plus a recursive tree walk) runs once per type, not on - every dispatch. Unhashable models skip the cache and probe fresh. - """ - return _get_or_build(self._can_decode_results, model, lambda: self._probe_can_decode(model)) - - def _probe_can_decode(self, model: type) -> bool: - """Decide whether msgspec natively decodes `model` (the uncached path). - - msgspec builds a Decoder for almost any class via a generic CustomType - fallback; the Decoder constructor does NOT raise on unsupported types - (e.g. pydantic.BaseModel, or a container parameterized by one). We walk - msgspec.inspect.type_info and reject if a CustomType appears anywhere in - the type tree, so MissingDecoderError fires before a request is sent. + Rejects a model if any type in it, including the field types of a + Struct, dataclass, TypedDict or NamedTuple, would need a `dec_hook`. + The adapter, or the rejection, is cached per `model`. """ - try: - info = msgspec.inspect.type_info(model) - except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no - return False - if _contains_custom_type(info): - return False - try: - self._get_msgspec_decoder(model) - except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no - return False - return True + return _lookup(self._adapters, model, _build_adapter) is not None def decode(self, content: bytes, model: type[T]) -> T: """Validate `content` as JSON against `model` in a single parse pass.""" - decoder = self._get_msgspec_decoder(model) - return decoder.decode(content) + adapter = _lookup(self._adapters, model, _build_adapter) + if adapter is None: + adapter = msgspec.json.Decoder(model) # decoding then raises msgspec's own error + return adapter.decode(content) diff --git a/src/httpware/decoders/pydantic.py b/src/httpware/decoders/pydantic.py index 3eb02e7..ebcefe3 100644 --- a/src/httpware/decoders/pydantic.py +++ b/src/httpware/decoders/pydantic.py @@ -11,7 +11,7 @@ class entirely when `is_pydantic_installed` is False, so `AsyncClient()` does from typing import TypeVar from httpware._internal import import_checker -from httpware.decoders._caching import _get_or_build +from httpware.decoders._caching import _Cache, _lookup if import_checker.is_pydantic_installed: @@ -25,6 +25,14 @@ class entirely when `is_pydantic_installed` is False, so `AsyncClient()` does T = TypeVar("T") +def _build_adapter(model: object) -> "TypeAdapter[typing.Any] | None": + """Return a `TypeAdapter` for `model`, or None when pydantic cannot build a schema for it.""" + try: + return TypeAdapter(model) + except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no + return None + + class PydanticDecoder: """Decode raw response bytes into `model` via a per-instance cached `pydantic.TypeAdapter`. @@ -34,42 +42,25 @@ class PydanticDecoder: `ImportError`. """ - _adapters: dict[type, "TypeAdapter[typing.Any]"] - _can_decode_results: dict[type, bool] + _adapters: "_Cache[TypeAdapter[typing.Any]]" def __init__(self) -> None: if not import_checker.is_pydantic_installed: raise ImportError(MISSING_DEPENDENCY_MESSAGE) self._adapters = {} - self._can_decode_results = {} - - def _get_adapter(self, model: type[T]) -> "TypeAdapter[T]": - return _get_or_build(self._adapters, model, lambda: TypeAdapter(model)) def can_decode(self, model: type) -> bool: """Return True iff pydantic can build a schema for `model`. - The verdict is memoized per `model` so a rejection (which costs a - `PydanticSchemaGenerationError` round-trip) is not re-probed on every - dispatch. Unhashable models skip the cache and probe fresh. - """ - return _get_or_build(self._can_decode_results, model, lambda: self._probe_can_decode(model)) - - def _probe_can_decode(self, model: type) -> bool: - """Decide whether pydantic can build a schema for `model` (uncached). - - Probes via `_get_adapter`; subsequent calls (including `decode`) reuse - the cached `TypeAdapter`. Rejects `msgspec.Struct` subclasses — - pydantic raises `PydanticSchemaGenerationError` (a `TypeError`) when - building a schema for them. + The adapter, or the rejection, is cached per `model`, so `decode` reuses + what this built. `msgspec.Struct` subclasses are rejected: pydantic + cannot build a schema for them. """ - try: - self._get_adapter(model) - except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no - return False - return True + return _lookup(self._adapters, model, _build_adapter) is not None def decode(self, content: bytes, model: type[T]) -> T: """Validate `content` as JSON against `model` in a single parse pass.""" - adapter = self._get_adapter(model) + adapter = _lookup(self._adapters, model, _build_adapter) + if adapter is None: + adapter = TypeAdapter(model) # raises pydantic's own schema error return adapter.validate_json(content) diff --git a/tests/test_decoder_resolver.py b/tests/test_decoder_resolver.py index 410266d..bf45007 100644 --- a/tests/test_decoder_resolver.py +++ b/tests/test_decoder_resolver.py @@ -1,13 +1,15 @@ """Seam-level tests for _DecoderResolver / _BoundDecoder. -Drives resolve() and _BoundDecoder.decode() directly with a fake decoder list — -no client, no MockTransport. Covers resolution, the pre-flight MissingDecoderError -(with registered_names + ordering), first-match-wins, and DecodeError wrapping. +Drives resolve() and _BoundDecoder.decode() directly with a fake decoder list. +Covers resolution, the pre-flight MissingDecoderError (with registered_names + +ordering), first-match-wins, and DecodeError wrapping. The last tests run both +clients over MockTransport to decode a response a middleware built but never read. """ import httpx2 import pytest +from httpware import AsyncClient, Client from httpware.decoders._resolver import _DecoderResolver from httpware.errors import DecodeError, MissingDecoderError @@ -83,3 +85,48 @@ def test_decode_failure_wraps_as_decode_error() -> None: assert ei.value.model is _Wanted assert ei.value.original is boom assert ei.value.__cause__ is boom + + +class _EchoDecoder: + def can_decode(self, model: type) -> bool: + return model is bytes + + def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 — echoes the body + return content + + +def _unread_response() -> httpx2.Response: + return httpx2.Response(200, stream=httpx2.ByteStream(b"streamed")) + + +def test_bound_decoder_reads_an_unread_response() -> None: + bound = _DecoderResolver((_EchoDecoder(),)).resolve(bytes) + assert bound.decode(_unread_response()) == b"streamed" + + +def _never_called(request: httpx2.Request) -> httpx2.Response: # pragma: no cover — the request must not go out + raise AssertionError(request.url) + + +def test_client_decodes_unread_response_from_middleware() -> None: + def synthesize(request: httpx2.Request, next: object) -> httpx2.Response: # noqa: A002, ARG001 + return _unread_response() + + client = Client( + httpx2_client=httpx2.Client(transport=httpx2.MockTransport(_never_called)), + decoders=[_EchoDecoder()], + middleware=[synthesize], + ) + assert client.get("https://example.test/x", response_model=bytes) == b"streamed" + + +async def test_async_client_decodes_unread_response_from_middleware() -> None: + async def synthesize(request: httpx2.Request, next: object) -> httpx2.Response: # noqa: A002, ARG001 + return _unread_response() + + client = AsyncClient( + httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(_never_called)), + decoders=[_EchoDecoder()], + middleware=[synthesize], + ) + assert await client.get("https://example.test/x", response_model=bytes) == b"streamed" diff --git a/tests/test_decoders_msgspec.py b/tests/test_decoders_msgspec.py index 4e54f5f..90f7e3e 100644 --- a/tests/test_decoders_msgspec.py +++ b/tests/test_decoders_msgspec.py @@ -10,10 +10,10 @@ import pydantic import pytest -from httpware import AsyncClient, DecodeError +from httpware import AsyncClient, Client, DecodeError, MissingDecoderError from httpware._internal import import_checker from httpware.decoders import ResponseDecoder -from httpware.decoders.msgspec import MsgspecDecoder, _contains_custom_type +from httpware.decoders.msgspec import MsgspecDecoder class _Item(msgspec.Struct): @@ -115,8 +115,8 @@ def test_msgspec_can_decode_uses_cache() -> None: decoder = MsgspecDecoder() decoder.can_decode(_Item) decoder.can_decode(_Item) - assert len(decoder._msgspec_decoders) == 1 # noqa: SLF001 - assert _Item in decoder._msgspec_decoders # noqa: SLF001 + assert len(decoder._adapters) == 1 # noqa: SLF001 + assert _Item in decoder._adapters # noqa: SLF001 def test_can_decode_returns_false_when_type_info_raises() -> None: @@ -129,10 +129,9 @@ def test_can_decode_returns_false_when_type_info_raises() -> None: def test_can_decode_returns_false_when_decoder_build_raises() -> None: - """A `_get_msgspec_decoder` failure after type_info-classification is a soft 'no'.""" - with patch.object( - MsgspecDecoder, - "_get_msgspec_decoder", + """A `msgspec.json.Decoder` failure after type_info-classification is a soft 'no'.""" + with patch( + "httpware.decoders.msgspec.msgspec.json.Decoder", side_effect=TypeError("cannot build decoder"), ): assert MsgspecDecoder().can_decode(_Item) is False @@ -147,8 +146,8 @@ def test_unhashable_model_falls_back_to_uncached_decoder() -> None: `msgspec`'s own decode error, not a `TypeError` from the cache lookup. """ decoder = MsgspecDecoder() - decoder._msgspec_decoders = MagicMock() # noqa: SLF001 - decoder._msgspec_decoders.get.side_effect = TypeError("unhashable type") # noqa: SLF001 + decoder._adapters = MagicMock() # noqa: SLF001 + decoder._adapters.get.side_effect = TypeError("unhashable type") # noqa: SLF001 result = decoder.decode(b"42", int) assert result == 42 # noqa: PLR2004 @@ -208,7 +207,7 @@ def test_msgspec_can_decode_result_is_cached() -> None: assert decoder.can_decode(_Item) is True assert decoder.can_decode(_Item) is True assert spy.call_count == 1 - assert decoder._can_decode_results[_Item] is True # noqa: SLF001 + assert decoder._adapters[_Item][1] is not None # noqa: SLF001 def test_msgspec_can_decode_caches_negative_verdict() -> None: @@ -221,14 +220,14 @@ def test_msgspec_can_decode_caches_negative_verdict() -> None: assert decoder.can_decode(_PydanticUser) is False assert decoder.can_decode(_PydanticUser) is False assert spy.call_count == 1 - assert decoder._can_decode_results[_PydanticUser] is False # noqa: SLF001 + assert decoder._adapters[_PydanticUser] == (_PydanticUser, None) # noqa: SLF001 def test_msgspec_can_decode_unhashable_model_does_not_raise() -> None: """An unhashable model falls back to a fresh probe instead of raising from the cache.""" decoder = MsgspecDecoder() - decoder._can_decode_results = MagicMock() # noqa: SLF001 - decoder._can_decode_results.get.side_effect = TypeError("unhashable type") # noqa: SLF001 + decoder._adapters = MagicMock() # noqa: SLF001 + decoder._adapters.get.side_effect = TypeError("unhashable type") # noqa: SLF001 assert decoder.can_decode(_Item) is True @@ -248,10 +247,117 @@ def test_can_decode_agrees_with_decode_for_unhashable_buildable_model() -> None: assert decoder.decode(b"42", model) == 42 # noqa: PLR2004 # ty: ignore[invalid-argument-type] -def test_contains_custom_type_raises_import_error_when_msgspec_absent( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """When msgspec is absent, _contains_custom_type must raise ImportError, not NameError.""" - monkeypatch.setattr(import_checker, "is_msgspec_installed", False) - with pytest.raises(ImportError, match="MsgspecDecoder requires"): - _contains_custom_type(None) # ty: ignore[invalid-argument-type] -- guard fires before isinstance checks +class _Opaque: + pass + + +class _StructWithPydanticField(msgspec.Struct): + user: _PydanticUser + + +class _StructWithOptionalPydanticList(msgspec.Struct): + users: list[_PydanticUser] | None + + +class _Outer(msgspec.Struct): + inner: _StructWithPydanticField + + +@dataclasses.dataclass +class _DCWithOpaqueField: + thing: _Opaque + + +class _TDWithPydanticField(typing.TypedDict): + user: _PydanticUser + + +class _NTWithPydanticField(typing.NamedTuple): + user: _PydanticUser + + +class _Node(msgspec.Struct): + value: int + children: "list[_Node]" + + +class _BadNode(msgspec.Struct): + children: "list[_BadNode]" + user: _PydanticUser + + +@pytest.mark.parametrize( + "model", + [ + _StructWithPydanticField, + _StructWithOptionalPydanticList, + _Outer, + list[_Outer], + _DCWithOpaqueField, + _TDWithPydanticField, + _NTWithPydanticField, + _BadNode, + typing.Annotated[_StructWithPydanticField, msgspec.Meta(title="x")], + ], +) +def test_msgspec_rejects_models_with_fields_it_cannot_decode(model: type) -> None: + assert MsgspecDecoder().can_decode(model) is False + + +def test_msgspec_accepts_self_referential_struct() -> None: + decoder = MsgspecDecoder() + assert decoder.can_decode(_Node) is True + assert decoder.decode(b'{"value":1,"children":[{"value":2,"children":[]}]}', _Node) == _Node( + value=1, children=[_Node(value=2, children=[])] + ) + + +def test_unhashable_model_builds_decoder_once() -> None: + with patch("httpware.decoders.msgspec.msgspec.json.Decoder", wraps=msgspec.json.Decoder) as spy: + decoder = MsgspecDecoder() + for _ in range(3): + model = typing.Annotated[list[int], {"a": 1}] + assert decoder.can_decode(model) is True # ty: ignore[invalid-argument-type] + assert decoder.decode(b"[1]", model) == [1] # ty: ignore[invalid-argument-type] + assert spy.call_count == 1 + + +def _never_called(request: httpx2.Request) -> httpx2.Response: # pragma: no cover — the request must not go out + raise AssertionError(request.url) + + +def test_client_rejects_struct_with_unsupported_field_before_sending() -> None: + client = Client( + httpx2_client=httpx2.Client(transport=httpx2.MockTransport(_never_called)), + decoders=[MsgspecDecoder()], + ) + with pytest.raises(MissingDecoderError): + client.get("https://example.test/x", response_model=_StructWithPydanticField) + + +async def test_async_client_rejects_struct_with_unsupported_field_before_sending() -> None: + client = AsyncClient( + httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(_never_called)), + decoders=[MsgspecDecoder()], + ) + with pytest.raises(MissingDecoderError): + await client.get("https://example.test/x", response_model=_StructWithPydanticField) + + +class _BrokenRepr: + __hash__ = None + + def __repr__(self) -> str: + raise RuntimeError + + +def test_model_that_cannot_be_keyed_is_still_decoded() -> None: + model = typing.Annotated[int, _BrokenRepr()] + decoder = MsgspecDecoder() + assert decoder.can_decode(model) is True # ty: ignore[invalid-argument-type] + assert decoder.decode(b"42", model) == 42 # noqa: PLR2004 # ty: ignore[invalid-argument-type] + + +def test_decode_into_rejected_model_raises_msgspec_error() -> None: + with pytest.raises(msgspec.ValidationError): + MsgspecDecoder().decode(b'{"id": 1, "name": "Ada"}', _PydanticUser) diff --git a/tests/test_decoders_pydantic.py b/tests/test_decoders_pydantic.py index 1f87c6a..270ceb1 100644 --- a/tests/test_decoders_pydantic.py +++ b/tests/test_decoders_pydantic.py @@ -125,10 +125,7 @@ def one_decode(_: int) -> User: results = list(pool.map(one_decode, range(50))) assert all(type(r) is User and r.id == 1 for r in results) - # `dict` reads/writes are atomic in CPython but the get→set sequence in - # `_get_or_build` is not — concurrent first-callers may both build a - # TypeAdapter before one wins (idempotent; loser is GC'd). Bounded by - # worker count. + # Concurrent first callers may each build an adapter; the last write wins. assert 1 <= spy.call_count <= n_workers @@ -216,28 +213,28 @@ def test_pydantic_can_decode_uses_cache() -> None: def test_pydantic_can_decode_result_is_cached() -> None: """Repeat can_decode calls reuse a cached verdict, not the per-dispatch probe.""" decoder = PydanticDecoder() - with patch.object(decoder, "_get_adapter", wraps=decoder._get_adapter) as spy: # noqa: SLF001 + with patch("httpware.decoders.pydantic.TypeAdapter", wraps=pydantic.TypeAdapter) as spy: assert decoder.can_decode(User) is True assert decoder.can_decode(User) is True assert spy.call_count == 1 - assert decoder._can_decode_results[User] is True # noqa: SLF001 + assert decoder._adapters[User][1] is not None # noqa: SLF001 def test_pydantic_can_decode_caches_negative_verdict() -> None: """A rejection is cached too, so repeat probes skip the schema-build round-trip.""" decoder = PydanticDecoder() - with patch.object(decoder, "_get_adapter", wraps=decoder._get_adapter) as spy: # noqa: SLF001 + with patch("httpware.decoders.pydantic.TypeAdapter", wraps=pydantic.TypeAdapter) as spy: assert decoder.can_decode(_Struct) is False assert decoder.can_decode(_Struct) is False assert spy.call_count == 1 - assert decoder._can_decode_results[_Struct] is False # noqa: SLF001 + assert decoder._adapters[_Struct] == (_Struct, None) # noqa: SLF001 def test_pydantic_can_decode_unhashable_model_does_not_raise() -> None: """An unhashable model falls back to a fresh probe instead of raising from the cache.""" decoder = PydanticDecoder() - decoder._can_decode_results = MagicMock() # noqa: SLF001 - decoder._can_decode_results.get.side_effect = TypeError("unhashable type") # noqa: SLF001 + decoder._adapters = MagicMock() # noqa: SLF001 + decoder._adapters.get.side_effect = TypeError("unhashable type") # noqa: SLF001 assert decoder.can_decode(User) is True @@ -255,3 +252,52 @@ def test_can_decode_agrees_with_decode_for_unhashable_buildable_model() -> None: decoder = PydanticDecoder() assert decoder.can_decode(model) is True # ty: ignore[invalid-argument-type] assert decoder.decode(b"42", model) == 42 # noqa: PLR2004 # ty: ignore[invalid-argument-type] + + +class _Left(pydantic.BaseModel): + x: int + + +class _Right(pydantic.BaseModel): + x: int + + +def test_union_member_order_is_part_of_the_cache_key() -> None: + """Smart mode breaks a tie by member order, so `A | B` and `B | A` decode differently.""" + decoder = PydanticDecoder() + assert type(decoder.decode(b'{"x": 1}', _Left | _Right)) is _Left # ty: ignore[invalid-argument-type] + assert type(decoder.decode(b'{"x": 1}', _Right | _Left)) is _Right + + +def test_nested_union_member_order_is_part_of_the_cache_key() -> None: + decoder = PydanticDecoder() + (left,) = decoder.decode(b'[{"x": 1}]', list[_Left | _Right]) + (right,) = decoder.decode(b'[{"x": 1}]', list[_Right | _Left]) + assert type(left) is _Left + assert type(right) is _Right + + +def test_unhashable_model_builds_adapter_once() -> None: + with patch("httpware.decoders.pydantic.TypeAdapter", wraps=pydantic.TypeAdapter) as spy: + decoder = PydanticDecoder() + for _ in range(3): + model = typing.Annotated[list[int], {"a": 1}] + assert decoder.can_decode(model) is True # ty: ignore[invalid-argument-type] + assert decoder.decode(b"[1]", model) == [1] # ty: ignore[invalid-argument-type] + assert spy.call_count == 1 + + +def test_unhashable_models_with_the_same_repr_do_not_share_an_adapter() -> None: + first_row = pydantic.create_model("Twin", a=int) + second_row = pydantic.create_model("Twin", b=int) + first = typing.Annotated[list[first_row], {"k": 1}] # ty: ignore[invalid-type-form] + second = typing.Annotated[list[second_row], {"k": 1}] # ty: ignore[invalid-type-form] + assert repr(first) == repr(second) + decoder = PydanticDecoder() + assert decoder.decode(b'[{"a": 1}]', first)[0].a == 1 # ty: ignore[invalid-argument-type] + assert decoder.decode(b'[{"b": 2}]', second)[0].b == 2 # noqa: PLR2004 # ty: ignore[invalid-argument-type] + + +def test_decode_into_rejected_model_raises_the_schema_error() -> None: + with pytest.raises(pydantic.PydanticSchemaGenerationError): + PydanticDecoder().decode(b'{"id": 1, "name": "Ada"}', _Struct) From 43f442660415835e13a01722fda5451d1392db48 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 17 Oct 2026 11:50:39 +0300 Subject: [PATCH 2/3] fix: type the TypeAdapter builder argument as Any for pydantic 2.14 stubs --- src/httpware/decoders/pydantic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/httpware/decoders/pydantic.py b/src/httpware/decoders/pydantic.py index ebcefe3..6e0e2a1 100644 --- a/src/httpware/decoders/pydantic.py +++ b/src/httpware/decoders/pydantic.py @@ -25,7 +25,7 @@ class entirely when `is_pydantic_installed` is False, so `AsyncClient()` does T = TypeVar("T") -def _build_adapter(model: object) -> "TypeAdapter[typing.Any] | None": +def _build_adapter(model: typing.Any) -> "TypeAdapter[typing.Any] | None": # noqa: ANN401 """Return a `TypeAdapter` for `model`, or None when pydantic cannot build a schema for it.""" try: return TypeAdapter(model) From 119febf64d635a1d07b575f05a58e2d140f33664 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 17 Oct 2026 11:50:39 +0300 Subject: [PATCH 3/3] test: build repr-collision models in a form pydantic 2.0 accepts --- tests/test_decoders_pydantic.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_decoders_pydantic.py b/tests/test_decoders_pydantic.py index 270ceb1..b5f3c20 100644 --- a/tests/test_decoders_pydantic.py +++ b/tests/test_decoders_pydantic.py @@ -288,8 +288,8 @@ def test_unhashable_model_builds_adapter_once() -> None: def test_unhashable_models_with_the_same_repr_do_not_share_an_adapter() -> None: - first_row = pydantic.create_model("Twin", a=int) - second_row = pydantic.create_model("Twin", b=int) + first_row = pydantic.create_model("Twin", a=(int, ...)) + second_row = pydantic.create_model("Twin", b=(int, ...)) first = typing.Annotated[list[first_row], {"k": 1}] # ty: ignore[invalid-type-form] second = typing.Annotated[list[second_row], {"k": 1}] # ty: ignore[invalid-type-form] assert repr(first) == repr(second)