Skip to content

Commit b0cdded

Browse files
committed
feat(client)!: sync Client takes decoders=[...] with type-dispatched routing
1 parent 46e6d7e commit b0cdded

5 files changed

Lines changed: 97 additions & 45 deletions

File tree

src/httpware/client.py

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -30,20 +30,6 @@
3030
f"{_FORWARDED_KWARG_NAMES}; configure the httpx2 client you pass instead."
3131
)
3232

33-
_DEFAULT_DECODER_MISSING_MESSAGE = (
34-
"decoder=None defaults to PydanticDecoder, which requires the "
35-
"'pydantic' extra. Either install it (`pip install httpware[pydantic]`) or "
36-
"pass an explicit decoder=..."
37-
)
38-
39-
40-
def _default_pydantic_decoder() -> ResponseDecoder:
41-
if not import_checker.is_pydantic_installed:
42-
raise ImportError(_DEFAULT_DECODER_MISSING_MESSAGE)
43-
from httpware.decoders.pydantic import PydanticDecoder # noqa: PLC0415 — lazy by design
44-
45-
return PydanticDecoder()
46-
4733

4834
def _build_default_decoders() -> tuple[ResponseDecoder, ...]:
4935
"""Construct the default decoder tuple based on installed extras.
@@ -833,7 +819,7 @@ class Client:
833819

834820
_httpx2_client: httpx2.Client
835821
_owns_client: bool
836-
_decoder: ResponseDecoder
822+
_decoders: tuple[ResponseDecoder, ...]
837823
_user_middleware: tuple[Middleware, ...]
838824
_dispatch: Next
839825

@@ -848,7 +834,7 @@ def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call
848834
limits: httpx2.Limits | None = None,
849835
auth: httpx2.Auth | None = None,
850836
httpx2_client: httpx2.Client | None = None,
851-
decoder: ResponseDecoder | None = None,
837+
decoders: Sequence[ResponseDecoder] | None = None,
852838
middleware: Sequence[Middleware] = (),
853839
) -> None:
854840
if httpx2_client is not None:
@@ -884,10 +870,17 @@ def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call
884870
self._httpx2_client = httpx2.Client(**kwargs)
885871
self._owns_client = True
886872

887-
self._decoder = decoder if decoder is not None else _default_pydantic_decoder()
873+
self._decoders = tuple(decoders) if decoders is not None else _build_default_decoders()
888874
self._user_middleware = tuple(middleware)
889875
self._dispatch = compose(self._user_middleware, self._terminal)
890876

877+
def _dispatch_decoder(self, model: type) -> ResponseDecoder | None:
878+
"""Walk `_decoders` and return the first decoder claiming `model`, or None."""
879+
for decoder in self._decoders:
880+
if decoder.can_decode(model):
881+
return decoder
882+
return None
883+
891884
def _terminal(self, request: httpx2.Request) -> httpx2.Response:
892885
try:
893886
with _httpx2_exception_mapper_sync():
@@ -936,11 +929,19 @@ def send(
936929
response_model: type[T] | None = None,
937930
) -> httpx2.Response | T:
938931
"""Send `request` through the middleware chain. Decode if `response_model` is set."""
939-
response = self._dispatch(request)
940932
if response_model is None:
941-
return response
933+
return self._dispatch(request)
934+
935+
decoder = self._dispatch_decoder(response_model)
936+
if decoder is None:
937+
raise MissingDecoderError(
938+
model=response_model,
939+
registered_names=tuple(type(d).__name__ for d in self._decoders),
940+
)
941+
942+
response = self._dispatch(request)
942943
try:
943-
return self._decoder.decode(response.content, response_model)
944+
return decoder.decode(response.content, response_model)
944945
except Exception as exc:
945946
raise DecodeError(response=response, model=response_model, original=exc) from exc
946947

@@ -959,9 +960,16 @@ def send_with_response(
959960
Not for streaming responses — decodes ``response.content``, which
960961
requires the body to be fully read. Use ``stream()`` for streaming.
961962
"""
963+
decoder = self._dispatch_decoder(response_model)
964+
if decoder is None:
965+
raise MissingDecoderError(
966+
model=response_model,
967+
registered_names=tuple(type(d).__name__ for d in self._decoders),
968+
)
969+
962970
response = self._dispatch(request)
963971
try:
964-
decoded = self._decoder.decode(response.content, response_model)
972+
decoded = decoder.decode(response.content, response_model)
965973
except Exception as exc:
966974
raise DecodeError(response=response, model=response_model, original=exc) from exc
967975
return response, decoded

src/httpware/decoders/pydantic.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
"""PydanticDecoder — module-level cached TypeAdapter adapter for ResponseDecoder.
22
3-
Requires the `pydantic` extra: `pip install httpware[pydantic]`. The optional-extras
4-
gate is enforced upstream — `client.py:_default_pydantic_decoder()` raises
5-
ImportError when pydantic is absent, so this module is never imported in that
6-
path. Tests simulating "pydantic not installed" patch
7-
`import_checker.is_pydantic_installed=False` at runtime, after this module is
8-
already loaded; `PydanticDecoder.__init__` then raises ImportError with the
9-
install hint.
3+
Requires the `pydantic` extra: `pip install httpware[pydantic]`. Constructing
4+
`PydanticDecoder()` directly when pydantic is not installed raises ImportError.
5+
The default-decoder path in `client.py:_build_default_decoders()` skips this
6+
class entirely when `is_pydantic_installed` is False, so `AsyncClient()` does
7+
not trip the ImportError when the user is not using `response_model=`.
108
"""
119

1210
import functools

tests/test_client_send_with_response_sync.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import pydantic
77
import pytest
88

9-
from httpware import Client, ClientError, DecodeError, NotFoundError
9+
from httpware import Client, ClientError, DecodeError, MissingDecoderError, NotFoundError
1010
from httpware.middleware import before_request
1111

1212

@@ -128,3 +128,22 @@ def handler(request: httpx2.Request) -> httpx2.Response:
128128
response, _ = client.send_with_response(request, response_model=_User)
129129
assert recorded[0].headers.get("x-test") == "ok"
130130
assert response.request.headers.get("x-test") == "ok"
131+
132+
133+
def test_sync_send_with_response_raises_missing_decoder_before_http_call() -> None:
134+
def handler(_: httpx2.Request) -> httpx2.Response: # pragma: no cover
135+
pytest.fail("transport should not be invoked when MissingDecoderError fires")
136+
137+
transport = httpx2.MockTransport(handler)
138+
client = Client(
139+
httpx2_client=httpx2.Client(transport=transport),
140+
decoders=[],
141+
)
142+
143+
class _Foo:
144+
pass
145+
146+
request = client.build_request("GET", "https://example.test/x")
147+
with pytest.raises(MissingDecoderError):
148+
client.send_with_response(request, response_model=_Foo)
149+
client.close()

tests/test_client_sync.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from httpware import Client, NotFoundError
1010
from httpware.decoders.pydantic import PydanticDecoder
11-
from httpware.errors import TransportError
11+
from httpware.errors import MissingDecoderError, TransportError
1212

1313

1414
# ---------- Construction ----------
@@ -59,22 +59,49 @@ def test_caller_owned_client_with_forwarded_kwargs_is_typeerror(kwargs: dict) ->
5959
caller.close()
6060

6161

62-
def test_default_decoder_is_pydantic_decoder() -> None:
62+
def test_default_decoders_includes_pydantic_when_installed() -> None:
6363
client = Client()
64-
assert isinstance(client._decoder, PydanticDecoder) # noqa: SLF001
64+
assert any(isinstance(d, PydanticDecoder) for d in client._decoders) # noqa: SLF001
6565
client.close()
6666

6767

68-
def test_explicit_decoder_is_honored() -> None:
68+
def test_explicit_decoders_is_honored() -> None:
6969
class _Stub:
7070
def can_decode(self, model: type) -> bool: # noqa: ARG002 # pragma: no cover
7171
return True
7272

7373
def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 # pragma: no cover
7474
return None
7575

76-
client = Client(decoder=_Stub())
77-
assert isinstance(client._decoder, _Stub) # noqa: SLF001
76+
stub = _Stub()
77+
client = Client(decoders=[stub])
78+
assert client._decoders == (stub,) # noqa: SLF001
79+
client.close()
80+
81+
82+
def test_empty_decoders_is_honored() -> None:
83+
client = Client(decoders=[])
84+
assert client._decoders == () # noqa: SLF001
85+
client.close()
86+
87+
88+
def test_sync_missing_decoder_raised_before_http_call() -> None:
89+
def handler(_: httpx2.Request) -> httpx2.Response: # pragma: no cover
90+
pytest.fail("transport should not be invoked when MissingDecoderError fires")
91+
92+
transport = httpx2.MockTransport(handler)
93+
client = Client(
94+
httpx2_client=httpx2.Client(transport=transport),
95+
decoders=[],
96+
)
97+
98+
class _Foo:
99+
pass
100+
101+
with pytest.raises(MissingDecoderError) as exc_info:
102+
client.get("https://example.test/x", response_model=_Foo)
103+
assert exc_info.value.model is _Foo
104+
assert exc_info.value.registered_names == ()
78105
client.close()
79106

80107

tests/test_optional_extras_pydantic_missing.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,12 @@ def test_async_client_no_pydantic_constructs_without_raising() -> None:
3939
assert all(not isinstance(d, PydanticDecoder) for d in client._decoders) # noqa: SLF001
4040

4141

42-
def test_sync_client_default_decoder_raises_when_pydantic_missing() -> None:
43-
with (
44-
patch("httpware._internal.import_checker.is_pydantic_installed", False),
45-
pytest.raises(ImportError, match=r"httpware\[pydantic\]"),
46-
):
47-
Client()
42+
def test_sync_client_no_pydantic_constructs_without_raising() -> None:
43+
"""Client() with pydantic missing must not raise — lazy default policy."""
44+
with patch("httpware._internal.import_checker.is_pydantic_installed", False):
45+
client = Client()
46+
assert all(not isinstance(d, PydanticDecoder) for d in client._decoders) # noqa: SLF001
47+
client.close()
4848

4949

5050
def test_async_client_accepts_explicit_decoders_without_pydantic() -> None:
@@ -55,9 +55,9 @@ def test_async_client_accepts_explicit_decoders_without_pydantic() -> None:
5555
assert client._decoders == (fake,) # noqa: SLF001
5656

5757

58-
def test_sync_client_accepts_explicit_decoder_without_pydantic() -> None:
59-
"""Sync mirror: explicit decoder= escapes the fail-fast AND is wired for sync Client too."""
58+
def test_sync_client_accepts_explicit_decoders_without_pydantic() -> None:
6059
fake = _FakeDecoder()
6160
with patch("httpware._internal.import_checker.is_pydantic_installed", False):
62-
client = Client(decoder=fake)
63-
assert client._decoder is fake # noqa: SLF001 — wired the explicit decoder, not a default
61+
client = Client(decoders=[fake])
62+
assert client._decoders == (fake,) # noqa: SLF001
63+
client.close()

0 commit comments

Comments
 (0)