Skip to content

Commit fff1fba

Browse files
committed
feat(decoders): add can_decode predicate to ResponseDecoder protocol
1 parent 59d8d03 commit fff1fba

8 files changed

Lines changed: 204 additions & 6 deletions

‎src/httpware/decoders/__init__.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,18 @@
1010
class ResponseDecoder(Protocol):
1111
"""Structural protocol every response-body decoder satisfies."""
1212

13+
def can_decode(self, model: type) -> bool:
14+
"""Return True iff this decoder claims responsibility for `model`.
15+
16+
The client walks its `_decoders` tuple in order and picks the first
17+
decoder whose `can_decode` returns True. Implementations should claim
18+
every model type they can actually handle — broad is correct, because
19+
list ordering encodes the caller's preference for shared shapes.
20+
Native types of another library (e.g. `PydanticDecoder` vs
21+
`msgspec.Struct`) MUST be rejected.
22+
"""
23+
...
24+
1325
def decode(self, content: bytes, model: type[T]) -> T:
1426
"""Decode `content` (raw response bytes) into an instance of `model`.
1527

‎src/httpware/decoders/msgspec.py‎

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
"""MsgspecDecoder — opt-in ResponseDecoder backed by msgspec.json.decode."""
1+
"""MsgspecDecoder — opt-in ResponseDecoder backed by a cached msgspec.json.Decoder."""
22

3+
import functools
34
from typing import TypeVar
45

56
from httpware._internal import import_checker
@@ -14,19 +15,49 @@
1415
T = TypeVar("T")
1516

1617

18+
@functools.lru_cache(maxsize=1024)
19+
def _get_msgspec_decoder(model: type[T]) -> "msgspec.json.Decoder[T]":
20+
return msgspec.json.Decoder(model)
21+
22+
1723
class MsgspecDecoder:
18-
"""Decode raw response bytes via `msgspec.json.decode(content, type=model)`.
24+
"""Decode raw response bytes via a cached `msgspec.json.Decoder(model)`.
1925
2026
Requires the `msgspec` extra: `pip install httpware[msgspec]`. Importing
2127
this module without the extra works (the `msgspec` import is guarded by a
22-
`find_spec` check), but instantiating the decoder raises `ImportError` with
23-
the install hint.
28+
`find_spec` check), but instantiating the decoder raises `ImportError`.
2429
"""
2530

2631
def __init__(self) -> None:
2732
if not import_checker.is_msgspec_installed:
2833
raise ImportError(MISSING_DEPENDENCY_MESSAGE)
2934

35+
def can_decode(self, model: type) -> bool:
36+
"""Return True iff msgspec natively understands `model`.
37+
38+
Cached via `_get_msgspec_decoder`; subsequent calls reuse the same
39+
Decoder instance. Rejects `pydantic.BaseModel` subclasses — msgspec
40+
will *build* a Decoder for them (falling back to a generic
41+
`CustomType`) but cannot actually decode them without a `dec_hook`,
42+
so we use `msgspec.inspect.type_info` to detect the fallback and
43+
refuse to claim the model.
44+
"""
45+
try:
46+
info = msgspec.inspect.type_info(model)
47+
except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
48+
return False
49+
if isinstance(info, msgspec.inspect.CustomType):
50+
return False
51+
try:
52+
_get_msgspec_decoder(model)
53+
except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
54+
return False
55+
return True
56+
3057
def decode(self, content: bytes, model: type[T]) -> T:
3158
"""Validate `content` as JSON against `model` in a single parse pass."""
32-
return msgspec.json.decode(content, type=model)
59+
try:
60+
decoder = _get_msgspec_decoder(model)
61+
except TypeError:
62+
decoder = msgspec.json.Decoder(model)
63+
return decoder.decode(content)

‎src/httpware/decoders/pydantic.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,20 @@ def __init__(self) -> None:
3636
if not import_checker.is_pydantic_installed:
3737
raise ImportError(MISSING_DEPENDENCY_MESSAGE)
3838

39+
def can_decode(self, model: type) -> bool:
40+
"""Return True iff pydantic can build a schema for `model`.
41+
42+
Cached via `_get_adapter`; subsequent calls (including `decode`) reuse
43+
the same `TypeAdapter` instance. Rejects `msgspec.Struct` subclasses —
44+
pydantic raises `PydanticSchemaGenerationError` (a `TypeError`) when
45+
building a schema for them.
46+
"""
47+
try:
48+
_get_adapter(model)
49+
except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
50+
return False
51+
return True
52+
3953
def decode(self, content: bytes, model: type[T]) -> T:
4054
"""Validate `content` as JSON against `model` in a single parse pass."""
4155
try:

‎tests/test_client_construction.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ def test_default_decoder_is_pydantic_decoder() -> None:
5555

5656
def test_explicit_decoder_is_honored() -> None:
5757
class _Stub:
58+
def can_decode(self, model: type) -> bool: # noqa: ARG002 # pragma: no cover
59+
return True
60+
5861
def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 # pragma: no cover
5962
return None
6063

‎tests/test_client_sync.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ def test_default_decoder_is_pydantic_decoder() -> None:
6767

6868
def test_explicit_decoder_is_honored() -> None:
6969
class _Stub:
70+
def can_decode(self, model: type) -> bool: # noqa: ARG002 # pragma: no cover
71+
return True
72+
7073
def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 # pragma: no cover
7174
return None
7275

‎tests/test_decoders_msgspec.py‎

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
"""Unit tests for httpware.decoders.msgspec.MsgspecDecoder."""
22

3+
import dataclasses
34
from http import HTTPStatus
5+
from unittest.mock import patch
46

57
import httpx2
68
import msgspec
9+
import pydantic
710
import pytest
811

912
from httpware import AsyncClient, DecodeError
1013
from httpware._internal import import_checker
1114
from httpware.decoders import ResponseDecoder
12-
from httpware.decoders.msgspec import MsgspecDecoder
15+
from httpware.decoders.msgspec import MsgspecDecoder, _get_msgspec_decoder
1316

1417

1518
class _Item(msgspec.Struct):
@@ -70,3 +73,88 @@ def handler(request: httpx2.Request) -> httpx2.Response:
7073
exc = exc_info.value
7174
assert exc.model is _Item
7275
assert isinstance(exc.original, (msgspec.DecodeError, msgspec.ValidationError))
76+
77+
78+
class _PydanticUser(pydantic.BaseModel):
79+
id: int
80+
name: str
81+
82+
83+
@dataclasses.dataclass
84+
class _DC:
85+
id: int
86+
name: str
87+
88+
89+
@pytest.fixture(autouse=True)
90+
def _clear_msgspec_cache() -> None:
91+
_get_msgspec_decoder.cache_clear()
92+
93+
94+
def test_msgspec_can_decode_struct() -> None:
95+
assert MsgspecDecoder().can_decode(_Item) is True
96+
97+
98+
def test_msgspec_can_decode_dataclass() -> None:
99+
assert MsgspecDecoder().can_decode(_DC) is True
100+
101+
102+
def test_msgspec_can_decode_dict() -> None:
103+
assert MsgspecDecoder().can_decode(dict) is True
104+
105+
106+
def test_msgspec_can_decode_list_of_structs() -> None:
107+
assert MsgspecDecoder().can_decode(list[_Item]) is True
108+
109+
110+
def test_msgspec_can_decode_primitive_int() -> None:
111+
assert MsgspecDecoder().can_decode(int) is True
112+
113+
114+
def test_msgspec_rejects_pydantic_basemodel() -> None:
115+
assert MsgspecDecoder().can_decode(_PydanticUser) is False
116+
117+
118+
def test_msgspec_can_decode_uses_cache() -> None:
119+
_get_msgspec_decoder.cache_clear()
120+
decoder = MsgspecDecoder()
121+
decoder.can_decode(_Item)
122+
decoder.can_decode(_Item)
123+
info = _get_msgspec_decoder.cache_info()
124+
assert info.hits >= 1
125+
assert info.misses == 1
126+
127+
128+
def test_can_decode_returns_false_when_type_info_raises() -> None:
129+
"""`type_info` failures (unrecognized type) are treated as a soft 'no'."""
130+
with patch(
131+
"httpware.decoders.msgspec.msgspec.inspect.type_info",
132+
side_effect=TypeError("unknown"),
133+
):
134+
assert MsgspecDecoder().can_decode(_Item) is False
135+
136+
137+
def test_can_decode_returns_false_when_decoder_build_raises() -> None:
138+
"""A `_get_msgspec_decoder` failure after type_info-classification is a soft 'no'."""
139+
_get_msgspec_decoder.cache_clear()
140+
with patch(
141+
"httpware.decoders.msgspec._get_msgspec_decoder",
142+
side_effect=TypeError("cannot build decoder"),
143+
):
144+
assert MsgspecDecoder().can_decode(_Item) is False
145+
146+
147+
def test_unhashable_model_falls_back_to_uncached_decoder() -> None:
148+
"""Unhashable `model` falls back to a direct uncached `msgspec.json.Decoder`.
149+
150+
Mirrors `PydanticDecoder`'s unhashable-fallback test: when `_get_msgspec_decoder`
151+
raises `TypeError` (e.g., an unhashable parameterized type), `decode` bypasses
152+
the cache so the user-visible error is `msgspec`'s own decode error, not a
153+
`functools`-internal `TypeError`.
154+
"""
155+
with patch(
156+
"httpware.decoders.msgspec._get_msgspec_decoder",
157+
side_effect=TypeError("unhashable type"),
158+
):
159+
result = MsgspecDecoder().decode(b"42", int)
160+
assert result == 42 # noqa: PLR2004

‎tests/test_decoders_pydantic.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import dataclasses
66
from unittest.mock import patch
77

8+
import msgspec
89
import pydantic
910
import pytest
1011

@@ -176,3 +177,46 @@ def test_malformed_payload_raises_validation_error(payload: bytes, model: type)
176177
"""
177178
with pytest.raises(pydantic.ValidationError):
178179
PydanticDecoder().decode(payload, model)
180+
181+
182+
class _Struct(msgspec.Struct):
183+
id: int
184+
name: str
185+
186+
187+
def test_pydantic_can_decode_basemodel() -> None:
188+
assert PydanticDecoder().can_decode(User) is True
189+
190+
191+
def test_pydantic_can_decode_dataclass() -> None:
192+
assert PydanticDecoder().can_decode(UserDC) is True
193+
194+
195+
def test_pydantic_can_decode_dict() -> None:
196+
assert PydanticDecoder().can_decode(dict) is True
197+
198+
199+
def test_pydantic_can_decode_list_of_models() -> None:
200+
assert PydanticDecoder().can_decode(list[User]) is True
201+
202+
203+
def test_pydantic_can_decode_primitive_int() -> None:
204+
assert PydanticDecoder().can_decode(int) is True
205+
206+
207+
def test_pydantic_can_decode_optional_int() -> None:
208+
assert PydanticDecoder().can_decode(int | None) is True # ty: ignore[invalid-argument-type]
209+
210+
211+
def test_pydantic_rejects_msgspec_struct() -> None:
212+
assert PydanticDecoder().can_decode(_Struct) is False
213+
214+
215+
def test_pydantic_can_decode_uses_cache() -> None:
216+
_get_adapter.cache_clear()
217+
decoder = PydanticDecoder()
218+
decoder.can_decode(User)
219+
decoder.can_decode(User)
220+
info = _get_adapter.cache_info()
221+
assert info.hits >= 1
222+
assert info.misses == 1

‎tests/test_optional_extras_pydantic_missing.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
class _FakeDecoder:
1818
"""Test stand-in for ResponseDecoder; never called at runtime."""
1919

20+
def can_decode(self, model: type) -> bool: # noqa: ARG002 — name pinned by ResponseDecoder protocol
21+
return True # pragma: no cover
22+
2023
def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 — name pinned by ResponseDecoder protocol
2124
return model() # pragma: no cover
2225

0 commit comments

Comments
 (0)