diff --git a/architecture/decoders.md b/architecture/decoders.md index 84b3aa6..be4f97d 100644 --- a/architecture/decoders.md +++ b/architecture/decoders.md @@ -7,9 +7,9 @@ A protocol seam is a documented internal boundary. AI agents and contributors mu Both clients take `decoders: Sequence[ResponseDecoder] | None = None` (a *list*, not a single instance) and dispatch via each decoder's `can_decode(model)` predicate. `AsyncClient()` / `Client()` do not raise on missing extras. - **Where:** `src/httpware/client.py` ↔ `src/httpware/decoders/`. -- **Contract:** the client holds `_decoders: tuple[ResponseDecoder, ...]` composed at `__init__` and frozen for the client's lifetime. The Protocol exposes two methods: - - `can_decode(model: type) -> bool` — predicate used at send-time to walk `_decoders` and pick the first claiming decoder (`_dispatch_decoder` on both classes). Built-in decoders claim broadly (pydantic via `TypeAdapter(model)` probe, msgspec via `msgspec.inspect.type_info(model)` + `CustomType` filter); list ordering decides ambiguous shared shapes (dataclass, primitive, generic). Native types of another library MUST be rejected. `can_decode` MUST NOT raise — it runs in `_dispatch_decoder`, outside the `DecodeError` try/except, so a raising probe escapes the `ClientError` contract; a decoder that cannot decide must return False, not raise (the built-ins treat any probe failure as False). This is a documented obligation on implementers, not an enforced guard. - - `decode(content: bytes, model: type[T]) -> T` — the decode itself. Any exception is wrapped by `Client.send` / `AsyncClient.send` (when `response_model=` is set) and `Client.send_with_response` / `AsyncClient.send_with_response` into `httpware.DecodeError` (a `ClientError` subclass carrying `response`, `model`, `original`). Decoder implementers do not need to raise `DecodeError` directly. -- **Pre-flight check:** when `response_model=` is set and no decoder claims it, `send` / `send_with_response` raise `MissingDecoderError(model=..., registered_names=...)` BEFORE the HTTP call. `MissingDecoderError` is a sibling of `DecodeError` under `ClientError`, and is distinct from it: `DecodeError` means the decoder ran and the payload was malformed; the two have distinct corrective actions (install an extra or pass `decoders=[...]`). +- **Contract:** the client holds `_decoders: tuple[ResponseDecoder, ...]` composed at `__init__` and frozen for the client's lifetime, plus a `_decoder_resolver: _DecoderResolver` (`decoders/_resolver.py`) wrapping that tuple — the synchronous Seam B orchestrator both clients share. The Protocol exposes two methods: + - `can_decode(model: type) -> bool` — predicate used at send-time to walk `_decoders` and pick the first claiming decoder (`_DecoderResolver.resolve`). Built-in decoders claim broadly (pydantic via `TypeAdapter(model)` probe, msgspec via `msgspec.inspect.type_info(model)` + `CustomType` filter); list ordering decides ambiguous shared shapes (dataclass, primitive, generic). Native types of another library MUST be rejected. `can_decode` MUST NOT raise — it runs in `_DecoderResolver.resolve`, outside the `_BoundDecoder.decode` wrap, so a raising probe escapes the `ClientError` contract; a decoder that cannot decide must return False, not raise (the built-ins treat any probe failure as False). This is a documented obligation on implementers, not an enforced guard. + - `decode(content: bytes, model: type[T]) -> T` — the decode itself. Any exception is wrapped by `_BoundDecoder.decode` — the bound decoder `resolve` returns, sealing decoder + model together — into `httpware.DecodeError` (a `ClientError` subclass carrying `response`, `model`, `original`); this runs for `send` / `send_with_response` on both clients when `response_model=` is set. Decoder implementers do not need to raise `DecodeError` directly. +- **Pre-flight check:** when `response_model=` is set and no decoder claims it, `_DecoderResolver.resolve` (called by `send` / `send_with_response` before `_dispatch`) raises `MissingDecoderError(model=..., registered_names=...)` BEFORE the HTTP call. `MissingDecoderError` is a sibling of `DecodeError` under `ClientError`, and is distinct from it: `DecodeError` means the decoder ran and the payload was malformed; the two have distinct corrective actions (install an extra or pass `decoders=[...]`). - **Default list:** `decoders=None` resolves via `client.py:_build_default_decoders()` against installed extras — pydantic-first when both are present, either-only when only one is installed, empty tuple when neither. `AsyncClient()` / `Client()` never raise on missing extras; failure surfaces only at the first `response_model=` use site. - **Rule:** the decoder must operate on raw bytes in a single parse pass. Two-pass decoding (`json.loads` then `validate_python`) is rejected: a single bytes-in / typed-object-out pass avoids the redundant intermediate `dict` allocation and parses faster. The Pydantic adapter implements this as `TypeAdapter(model).validate_json(content)`, with the `TypeAdapter` cached per-instance on `PydanticDecoder._adapters: dict[type, TypeAdapter]` (populated lazily on first `_get_adapter()` call); the msgspec adapter mirrors the pattern with `MsgspecDecoder._msgspec_decoders: dict[type, msgspec.json.Decoder]`. Cache lifetime matches the decoder/client, not the process — no module-level state, no autouse cache-clear fixtures in tests. diff --git a/planning/changes/2026-06-23.02-decoder-resolver-extraction/design.md b/planning/changes/2026-06-23.02-decoder-resolver-extraction/design.md new file mode 100644 index 0000000..d477cce --- /dev/null +++ b/planning/changes/2026-06-23.02-decoder-resolver-extraction/design.md @@ -0,0 +1,144 @@ +--- +status: shipped +date: 2026-06-23 +slug: decoder-resolver-extraction +summary: Extract a _DecoderResolver (+ generic _BoundDecoder) for Seam B, collapsing the 4-site resolve/raise/decode/wrap smear in client.py. +supersedes: null +superseded_by: null +pr: 77 +outcome: Shipped via #77 — resolution + pre-flight MissingDecoderError + DecodeError wrapping moved into _DecoderResolver/_BoundDecoder (decoders/_resolver.py); the 4 send/send_with_response sites collapse to resolve→dispatch→bound.decode, both _dispatch_decoder methods removed, decoder+model sealed together. Behaviour byte-identical (723 tests, 100% coverage). New seam suite tests/test_decoder_resolver.py; promoted into architecture/decoders.md. Internal refactor — no release. +--- + +# Design: Extract a `_DecoderResolver` for Seam B + +## Summary + +The decoder seam (Seam B) is real — two adapters, pydantic and msgspec — but the +logic for *using* it is copy-pasted across four `send` / `send_with_response` +methods on the two clients. This change pulls that logic behind one +`_DecoderResolver` (plus a small generic `_BoundDecoder`) in a new +`decoders/_resolver.py`, so the resolution + pre-flight error + decode-wrapping +live once and the four call sites shrink to `resolve → dispatch → decode`. +Behaviour is unchanged. + +## Motivation + +- The same ~8-line block appears at `client.py:185`, `:213`, `:1200`, `:1228`: + walk `_decoders`, raise `MissingDecoderError(registered_names=…)` if none + claims the model (pre-flight, before the HTTP call), then `decoder.decode(...)` + wrapping any failure as `DecodeError`. Plus `_dispatch_decoder` defined twice. +- **Depth:** the `ResponseDecoder` protocol is a clean interface, but its *use* + is shallow — smeared across four call sites instead of behind one module. + Extracting concentrates it: one place to change the error wording or + resolution, and a seam testable directly with a fake decoder list (no HTTP). +- **Deletion test:** delete the resolver and the 8-line block reappears in all + four methods — it concentrates real complexity, so it earns its keep. + (Contrast `_dispatch_decoder` *today*: a 3-line loop, shallow on its own; the + win is bundling resolution **with** the pre-flight error and decode-wrapping.) +- Fully synchronous (`can_decode`/`decode` are sync), so **one** resolver shape + serves both `Client` and `AsyncClient` — no sync/async twins. + +## Non-goals + +- No behaviour change. Resolution order, the pre-flight `MissingDecoderError` + (with its `registered_names` snapshot), and `DecodeError` wrapping stay + byte-identical. +- Not changing the `ResponseDecoder` protocol, the pydantic/msgspec adapters, or + `_build_default_decoders()` (stays in `client.py`, still tested there). +- Not touching the `decoders=None → defaults` resolution — the resolver takes + the already-built tuple. + +## Design + +### 1. `decoders/_resolver.py` — `_DecoderResolver` + `_BoundDecoder` + +```python +class _BoundDecoder(Generic[T]): + """A decoder bound to the model it will decode into.""" + + def __init__(self, decoder: ResponseDecoder, model: type[T]) -> None: + self._decoder = decoder + self._model = model + + def decode(self, response: httpx2.Response) -> T: + try: + return self._decoder.decode(response.content, self._model) + except Exception as exc: + raise DecodeError(response=response, model=self._model, original=exc) from exc + + +class _DecoderResolver: + """Resolves a response_model to the first claiming decoder; the Seam B orchestrator.""" + + def __init__(self, decoders: tuple[ResponseDecoder, ...]) -> None: + self._decoders = decoders + + def resolve(self, model: type[T]) -> _BoundDecoder[T]: + for decoder in self._decoders: + if decoder.can_decode(model): + return _BoundDecoder(decoder, model) + raise MissingDecoderError( + model=model, + registered_names=tuple(type(d).__name__ for d in self._decoders), + ) +``` + +`_BoundDecoder` seals the decoder and the model together at resolve time, so a +decoder/model mismatch is unrepresentable downstream; the caller's remaining +knowledge shrinks to "decode this response". Lives in a new private module +inside the decoders subpackage — co-located with the protocol + adapters it +orchestrates, no import cycle (`decoders/` never imports `client`). + +### 2. The four call sites collapse + +```python +bound = self._decoder_resolver.resolve(response_model) # pre-flight; may raise MissingDecoderError +response = self._dispatch(request) +return bound.decode(response) # post-HTTP; wraps DecodeError +``` + +`send_with_response` is the same but returns `(response, bound.decode(response))`. +The pre-flight invariant — `MissingDecoderError` before the HTTP call — stays +enforced by call ordering (`resolve` before `_dispatch`), exactly as +`_dispatch_decoder` is called before `_dispatch` today. + +### 3. What the clients hold + +- Keep `self._decoders` (four test files assert `client._decoders == …` via + `# noqa: SLF001` — it stays a public-by-convention attribute). +- Add `self._decoder_resolver = _DecoderResolver(self._decoders)` in both + `__init__`s. +- Remove both `_dispatch_decoder` methods (used nowhere but the four sites). + +## Out of scope + +- Folding `_build_default_decoders()` into the resolver. +- Any change to streaming (`stream()` never decodes — no `response_model`). + +## Testing + +- **Parity net:** existing decoder/client suites stay green unchanged — + `test_decoders_pydantic.py`, `test_decoders_msgspec.py`, + `test_client_decoders_default.py`, `test_client_construction.py`, + `test_client_sync.py`, `test_optional_extras_pydantic_missing.py`. +- **New seam tests:** `tests/test_decoder_resolver.py` drives `resolve` / + `_BoundDecoder.decode` directly with a fake decoder list (no client, no + `MockTransport`): claiming model → bound that decodes; no claimer → + `MissingDecoderError` with the right `registered_names` and order; decode + success; decode raises → `DecodeError` carrying `response` / `model` / + `original`; first-match-wins ordering across two fakes. +- `just lint` and `just test` both clean (100% coverage gate). + +## Risk + +- **Behavioural drift** (low × medium): a reordering changes resolution + precedence or the `registered_names` content. *Mitigation:* extract under the + green decoder/client suites, which assert defaults, ordering, and error + identity; do not edit them in this change. +- **Pre-flight ordering regression** (low × medium): `MissingDecoderError` must + still fire before the HTTP call. *Mitigation:* the call sites keep `resolve` + strictly before `_dispatch`; existing tests assert no request is sent on a + missing decoder. +- **Typing** (low × low): `_BoundDecoder` must be `Generic[T]` so `T` flows to + `send`'s overloads. *Mitigation:* `ty check` in the gate; `send`'s overload + tests in `test_client_typing.py` cover the surface. diff --git a/planning/changes/2026-06-23.02-decoder-resolver-extraction/plan.md b/planning/changes/2026-06-23.02-decoder-resolver-extraction/plan.md new file mode 100644 index 0000000..f7ab438 --- /dev/null +++ b/planning/changes/2026-06-23.02-decoder-resolver-extraction/plan.md @@ -0,0 +1,152 @@ +--- +status: shipped +date: 2026-06-23 +slug: decoder-resolver-extraction +spec: decoder-resolver-extraction +pr: 77 +--- + +# decoder-resolver-extraction — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps +> use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move the 4-site decoder resolve/raise/decode/wrap smear behind one +`_DecoderResolver` (+ generic `_BoundDecoder`), with no behaviour change. + +**Spec:** [`design.md`](./design.md) + +**Branch:** `refactor/decoder-resolver-extraction` + +**Commit strategy:** Per-task commits. + +--- + +### Task 1: Add `_DecoderResolver` and rewire both clients + +**Files:** +- Create: `src/httpware/decoders/_resolver.py` +- Modify: `src/httpware/client.py` + +Existing decoder/client suites are the parity net — do not edit them in this task. + +- [ ] **Step 1: Create `decoders/_resolver.py`** + + Add `_BoundDecoder(Generic[T])` (holds decoder + model; `decode(response) -> T` + wrapping `except Exception` as `DecodeError`) and `_DecoderResolver` + (`__init__(decoders: tuple[ResponseDecoder, ...])`, `resolve(model: type[T]) -> + _BoundDecoder[T]` walking `_decoders`, raising `MissingDecoderError(model=…, + registered_names=tuple(type(d).__name__ for d in self._decoders))` when none + claims). Import `T` from `httpware.decoders` (the protocol's TypeVar) or define + locally; import `DecodeError`/`MissingDecoderError` from `httpware.errors`, + `ResponseDecoder` from `httpware.decoders`. + +- [ ] **Step 2: Wire the resolver into both clients** + + In `AsyncClient.__init__` and `Client.__init__`, after `self._decoders = …`, + add `self._decoder_resolver = _DecoderResolver(self._decoders)`. Keep + `self._decoders`. Import `_DecoderResolver` from + `httpware.decoders._resolver`. + +- [ ] **Step 3: Replace the four call sites** + + In `AsyncClient.send`, `AsyncClient.send_with_response`, `Client.send`, + `Client.send_with_response`, replace each resolve/raise/decode/wrap block with + `bound = self._decoder_resolver.resolve(response_model)` (before + `self._dispatch(request)`), then `bound.decode(response)` after. Keep the + `send_with_response` `(response, decoded)` shape. Remove both + `_dispatch_decoder` methods. `MissingDecoderError`/`DecodeError` imports in + `client.py` may become unused — drop them if so. + +- [ ] **Step 4: Verify parity** + + ```bash + uv run pytest tests/test_decoders_pydantic.py tests/test_decoders_msgspec.py \ + tests/test_client_decoders_default.py tests/test_client_construction.py \ + tests/test_client_sync.py tests/test_optional_extras_pydantic_missing.py \ + tests/test_client_typing.py --no-cov -q + ``` + All green, unchanged. If any fail, the extraction drifted — fix the resolver, + not the tests. + +- [ ] **Step 5: Commit** + + ```bash + git add src/httpware/decoders/_resolver.py src/httpware/client.py + git commit -m "refactor(decoders): extract _DecoderResolver for Seam B + + Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +### Task 2: Add seam-level resolver tests + +**Files:** +- Create: `tests/test_decoder_resolver.py` + +Drive `resolve` / `_BoundDecoder.decode` directly with a fake decoder list — no +client, no `MockTransport`. + +- [ ] **Step 1: Write the matrix** + + A `FakeDecoder` claiming a set of model types (annotate all args). Cover: + claiming model → bound whose `.decode(response)` returns the decoded value; + no claimer → `MissingDecoderError` with `registered_names` matching the fake + list and order; first-match-wins across two fakes; decode success on good + bytes; decoder raises → `DecodeError` carrying `response` / `model` / + `original`; empty decoder tuple → `MissingDecoderError` with `()`. + +- [ ] **Step 2: Run** + + ```bash + uv run pytest tests/test_decoder_resolver.py --no-cov -q + ``` + All green. + +- [ ] **Step 3: Commit** + + ```bash + git add tests/test_decoder_resolver.py + git commit -m "test(decoders): cover _DecoderResolver at the seam + + Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +### Task 3: Promote to architecture, lint, full suite + +**Files:** +- Modify: `architecture/decoders.md` +- Modify: `planning/changes/2026-06-23.02-decoder-resolver-extraction/design.md` (frontmatter at ship) + +- [ ] **Step 1: Promote the living truth** + + In `architecture/decoders.md`, replace the `_dispatch_decoder` references and + the "`send`/`send_with_response` raise `MissingDecoderError`" framing with the + `_DecoderResolver` / `_BoundDecoder` description: resolution + pre-flight error + + decode-wrapping live in the resolver; the clients call `resolve` before + dispatch and `bound.decode` after. Keep it prose, no frontmatter. + +- [ ] **Step 2: Full gate** + + ```bash + just lint && just test + ``` + Both clean (100% coverage). Confirm the `httpx2._` and other review-only + invariants still hold in the diff. + +- [ ] **Step 3: Ship frontmatter + commit** + + Set `status: shipped`, `pr`, and `outcome` in `design.md` once the PR number + exists. Run `just index` to confirm the listing regenerates. + + ```bash + git add architecture/decoders.md planning/changes/2026-06-23.02-decoder-resolver-extraction/ + git commit -m "docs(decoders): promote _DecoderResolver into architecture truth + + Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` diff --git a/src/httpware/client.py b/src/httpware/client.py index fd5c066..a3e086b 100644 --- a/src/httpware/client.py +++ b/src/httpware/client.py @@ -16,7 +16,8 @@ _raise_on_status_error, ) from httpware.decoders import ResponseDecoder -from httpware.errors import DecodeError, MissingDecoderError, ResponseTooLargeError, TransportError +from httpware.decoders._resolver import _DecoderResolver +from httpware.errors import ResponseTooLargeError, TransportError from httpware.middleware import AsyncMiddleware, AsyncNext, Middleware, Next from httpware.middleware.chain import compose, compose_async @@ -144,17 +145,11 @@ def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call self._owns_client = True self._decoders = tuple(decoders) if decoders is not None else _build_default_decoders() + self._decoder_resolver = _DecoderResolver(self._decoders) self._user_middleware = tuple(middleware) self._dispatch = compose_async(self._user_middleware, self._terminal) self._max_error_body_bytes = max_error_body_bytes - def _dispatch_decoder(self, model: type) -> ResponseDecoder | None: - """Walk `_decoders` and return the first decoder claiming `model`, or None.""" - for decoder in self._decoders: - if decoder.can_decode(model): - return decoder - return None - async def _terminal(self, request: httpx2.Request) -> httpx2.Response: try: async with _httpx2_exception_mapper(): @@ -182,18 +177,9 @@ async def send( if response_model is None: return await self._dispatch(request) - decoder = self._dispatch_decoder(response_model) - if decoder is None: - raise MissingDecoderError( - model=response_model, - registered_names=tuple(type(d).__name__ for d in self._decoders), - ) - + bound = self._decoder_resolver.resolve(response_model) response = await self._dispatch(request) - try: - return decoder.decode(response.content, response_model) - except Exception as exc: - raise DecodeError(response=response, model=response_model, original=exc) from exc + return bound.decode(response) async def send_with_response( self, @@ -210,19 +196,9 @@ async def send_with_response( Not for streaming responses — decodes ``response.content``, which requires the body to be fully read. Use ``stream()`` for streaming. """ - decoder = self._dispatch_decoder(response_model) - if decoder is None: - raise MissingDecoderError( - model=response_model, - registered_names=tuple(type(d).__name__ for d in self._decoders), - ) - + bound = self._decoder_resolver.resolve(response_model) response = await self._dispatch(request) - try: - decoded = decoder.decode(response.content, response_model) - except Exception as exc: - raise DecodeError(response=response, model=response_model, original=exc) from exc - return response, decoded + return response, bound.decode(response) def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request: """Delegate request construction to the wrapped httpx2.AsyncClient.""" @@ -1135,17 +1111,11 @@ def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call self._owns_client = True self._decoders = tuple(decoders) if decoders is not None else _build_default_decoders() + self._decoder_resolver = _DecoderResolver(self._decoders) self._user_middleware = tuple(middleware) self._dispatch = compose(self._user_middleware, self._terminal) self._max_error_body_bytes = max_error_body_bytes - def _dispatch_decoder(self, model: type) -> ResponseDecoder | None: - """Walk `_decoders` and return the first decoder claiming `model`, or None.""" - for decoder in self._decoders: - if decoder.can_decode(model): - return decoder - return None - def _terminal(self, request: httpx2.Request) -> httpx2.Response: try: with _httpx2_exception_mapper_sync(): @@ -1197,18 +1167,9 @@ def send( if response_model is None: return self._dispatch(request) - decoder = self._dispatch_decoder(response_model) - if decoder is None: - raise MissingDecoderError( - model=response_model, - registered_names=tuple(type(d).__name__ for d in self._decoders), - ) - + bound = self._decoder_resolver.resolve(response_model) response = self._dispatch(request) - try: - return decoder.decode(response.content, response_model) - except Exception as exc: - raise DecodeError(response=response, model=response_model, original=exc) from exc + return bound.decode(response) def send_with_response( self, @@ -1225,19 +1186,9 @@ def send_with_response( Not for streaming responses — decodes ``response.content``, which requires the body to be fully read. Use ``stream()`` for streaming. """ - decoder = self._dispatch_decoder(response_model) - if decoder is None: - raise MissingDecoderError( - model=response_model, - registered_names=tuple(type(d).__name__ for d in self._decoders), - ) - + bound = self._decoder_resolver.resolve(response_model) response = self._dispatch(request) - try: - decoded = decoder.decode(response.content, response_model) - except Exception as exc: - raise DecodeError(response=response, model=response_model, original=exc) from exc - return response, decoded + return response, bound.decode(response) def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request: """Delegate request construction to the wrapped httpx2.Client.""" diff --git a/src/httpware/decoders/_resolver.py b/src/httpware/decoders/_resolver.py new file mode 100644 index 0000000..c862f32 --- /dev/null +++ b/src/httpware/decoders/_resolver.py @@ -0,0 +1,64 @@ +"""The Seam B orchestrator: resolve a response_model to a claiming decoder, then decode. + +`_DecoderResolver` walks the client's frozen `_decoders` tuple and returns the +first decoder whose `can_decode` claims the model, bound to that model as a +`_BoundDecoder`. Resolution (and the pre-flight `MissingDecoderError`) is a +separate step from decoding because the HTTP call happens between them: the +client calls `resolve` before `_dispatch` — so a missing decoder fails before +the request goes out — and `_BoundDecoder.decode` after the response arrives. + +Both clients hold one `_DecoderResolver`; it is fully synchronous, so there is +no sync/async split. See architecture/decoders.md for the full Seam B contract. +""" + +from typing import Generic, TypeVar + +import httpx2 + +from httpware.decoders import ResponseDecoder +from httpware.errors import DecodeError, MissingDecoderError + + +T = TypeVar("T") + + +class _BoundDecoder(Generic[T]): + """A `ResponseDecoder` sealed to the `model` it will decode into. + + Binding the decoder and model together at resolve time makes a + decoder/model mismatch unrepresentable: the caller supplies only the + response. Decode failures are wrapped as `DecodeError` (the Seam B + contract — implementers never raise it directly). + """ + + def __init__(self, decoder: ResponseDecoder, model: type[T]) -> None: + self._decoder = decoder + self._model = model + + def decode(self, response: httpx2.Response) -> T: + """Decode `response.content` into `model`, wrapping any failure as `DecodeError`.""" + try: + return self._decoder.decode(response.content, self._model) + except Exception as exc: + raise DecodeError(response=response, model=self._model, original=exc) from exc + + +class _DecoderResolver: + """Resolves a `response_model` to the first claiming decoder in a frozen list.""" + + def __init__(self, decoders: tuple[ResponseDecoder, ...]) -> None: + self._decoders = decoders + + def resolve(self, model: type[T]) -> _BoundDecoder[T]: + """Return the first decoder claiming `model`, bound to it. + + Raises `MissingDecoderError` when no registered decoder claims `model`. + Called before the HTTP call, so the failure is pre-flight. + """ + for decoder in self._decoders: + if decoder.can_decode(model): + return _BoundDecoder(decoder, model) + raise MissingDecoderError( + model=model, + registered_names=tuple(type(d).__name__ for d in self._decoders), + ) diff --git a/tests/test_decoder_resolver.py b/tests/test_decoder_resolver.py new file mode 100644 index 0000000..410266d --- /dev/null +++ b/tests/test_decoder_resolver.py @@ -0,0 +1,85 @@ +"""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. +""" + +import httpx2 +import pytest + +from httpware.decoders._resolver import _DecoderResolver +from httpware.errors import DecodeError, MissingDecoderError + + +class _Wanted: + pass + + +class _Other: + pass + + +class _FakeDecoder: + def __init__( + self, + claims: set[type], + *, + decoded: object = "OK", + raises: Exception | None = None, + ) -> None: + self._claims = claims + self._decoded = decoded + self._raises = raises + + def can_decode(self, model: type) -> bool: + return model in self._claims + + def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 — fake returns a preset + if self._raises is not None: + raise self._raises + return self._decoded + + +def _response(content: bytes = b"payload") -> httpx2.Response: + return httpx2.Response(200, content=content) + + +def test_resolve_returns_bound_that_decodes() -> None: + resolver = _DecoderResolver((_FakeDecoder({_Wanted}, decoded="decoded!"),)) + bound = resolver.resolve(_Wanted) + assert bound.decode(_response()) == "decoded!" + + +def test_resolve_no_claimer_raises_missing_decoder() -> None: + resolver = _DecoderResolver((_FakeDecoder({_Other}),)) + with pytest.raises(MissingDecoderError) as ei: + resolver.resolve(_Wanted) + assert ei.value.model is _Wanted + assert ei.value.registered_names == ("_FakeDecoder",) + + +def test_empty_decoder_tuple_raises_with_empty_names() -> None: + resolver = _DecoderResolver(()) + with pytest.raises(MissingDecoderError) as ei: + resolver.resolve(_Wanted) + assert ei.value.registered_names == () + + +def test_first_claiming_decoder_wins() -> None: + first = _FakeDecoder({_Wanted}, decoded="first") + second = _FakeDecoder({_Wanted}, decoded="second") + resolver = _DecoderResolver((first, second)) + assert resolver.resolve(_Wanted).decode(_response()) == "first" + + +def test_decode_failure_wraps_as_decode_error() -> None: + boom = ValueError("bad payload") + resolver = _DecoderResolver((_FakeDecoder({_Wanted}, raises=boom),)) + response = _response(b"garbage") + with pytest.raises(DecodeError) as ei: + resolver.resolve(_Wanted).decode(response) + assert ei.value.response is response + assert ei.value.model is _Wanted + assert ei.value.original is boom + assert ei.value.__cause__ is boom