Skip to content

Commit 5377bdd

Browse files
authored
Merge pull request #48 from modern-python/test/dispatch-decoder-identity
test(dispatch): prove which decoder runs on shared shapes + sync twins
2 parents b1e6533 + 8beffb2 commit 5377bdd

1 file changed

Lines changed: 75 additions & 26 deletions

File tree

tests/test_client_dispatch.py

Lines changed: 75 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,18 @@
55
shared shapes route to the first decoder in the list.
66
"""
77

8+
import contextlib
89
import dataclasses
10+
from collections.abc import Iterator
911
from http import HTTPStatus
12+
from unittest.mock import MagicMock, patch
1013

1114
import httpx2
1215
import msgspec
1316
import pydantic
1417
import pytest
1518

16-
from httpware import AsyncClient, Client, MissingDecoderError
19+
from httpware import AsyncClient, Client, MissingDecoderError, ResponseDecoder
1720
from httpware.decoders.msgspec import MsgspecDecoder
1821
from httpware.decoders.pydantic import PydanticDecoder
1922

@@ -56,6 +59,19 @@ def handler(request: httpx2.Request) -> httpx2.Response:
5659
)
5760

5861

62+
@contextlib.contextmanager
63+
def _decode_spies(*decoders: ResponseDecoder) -> Iterator[list[MagicMock]]:
64+
"""Wrap each decoder's `decode` so a test can assert WHICH one ran.
65+
66+
Two real decoders that both claim a shared shape (e.g. `dict[str, int]` or a
67+
stdlib dataclass) produce identical output, so output equality can't prove
68+
the ordering invariant. Spying on `decode` shows which decoder the dispatcher
69+
actually selected. Spies are yielded in the same order as `decoders`.
70+
"""
71+
with contextlib.ExitStack() as stack:
72+
yield [stack.enter_context(patch.object(d, "decode", wraps=d.decode)) for d in decoders]
73+
74+
5975
async def test_async_basemodel_routes_to_pydantic() -> None:
6076
client = _async_client_with_body(
6177
b'{"id": 1, "name": "Ada"}',
@@ -77,33 +93,37 @@ async def test_async_struct_routes_to_msgspec() -> None:
7793

7894

7995
async def test_async_dict_routes_to_first_decoder() -> None:
80-
"""Shared shape: first decoder in the list wins."""
81-
pyd = PydanticDecoder()
82-
msg = MsgspecDecoder()
96+
"""Shared shape (dict): the FIRST decoder in the list actually decodes it."""
97+
pyd, msg = PydanticDecoder(), MsgspecDecoder()
8398
client = _async_client_with_body(b'{"a": 1}', decoders=[pyd, msg])
84-
result = await client.get("https://example.test/x", response_model=dict[str, int])
85-
assert type(result) is dict
99+
with _decode_spies(pyd, msg) as (pyd_spy, msg_spy):
100+
result = await client.get("https://example.test/x", response_model=dict[str, int])
86101
assert result == {"a": 1}
102+
pyd_spy.assert_called_once()
103+
msg_spy.assert_not_called()
87104

88105

89106
async def test_async_dict_routes_to_msgspec_when_first() -> None:
90-
"""Reversed list flips routing for shared shapes."""
91-
client = _async_client_with_body(
92-
b'{"a": 1}',
93-
decoders=[MsgspecDecoder(), PydanticDecoder()],
94-
)
95-
result = await client.get("https://example.test/x", response_model=dict[str, int])
107+
"""Reversed list: the shared shape now routes to msgspec, proven by the spy."""
108+
msg, pyd = MsgspecDecoder(), PydanticDecoder()
109+
client = _async_client_with_body(b'{"a": 1}', decoders=[msg, pyd])
110+
with _decode_spies(msg, pyd) as (msg_spy, pyd_spy):
111+
result = await client.get("https://example.test/x", response_model=dict[str, int])
96112
assert result == {"a": 1}
113+
msg_spy.assert_called_once()
114+
pyd_spy.assert_not_called()
97115

98116

99117
async def test_async_dataclass_routes_to_first_decoder() -> None:
100-
client = _async_client_with_body(
101-
b'{"id": 1, "name": "Ada"}',
102-
decoders=[PydanticDecoder(), MsgspecDecoder()],
103-
)
104-
result = await client.get("https://example.test/x", response_model=_DC)
118+
"""Stdlib dataclass is a shared shape; the first decoder (pydantic) decodes it."""
119+
pyd, msg = PydanticDecoder(), MsgspecDecoder()
120+
client = _async_client_with_body(b'{"id": 1, "name": "Ada"}', decoders=[pyd, msg])
121+
with _decode_spies(pyd, msg) as (pyd_spy, msg_spy):
122+
result = await client.get("https://example.test/x", response_model=_DC)
105123
assert type(result) is _DC
106124
assert result.id == 1
125+
pyd_spy.assert_called_once()
126+
msg_spy.assert_not_called()
107127

108128

109129
async def test_async_list_of_basemodel_routes_to_pydantic() -> None:
@@ -176,22 +196,51 @@ def test_sync_struct_routes_to_msgspec() -> None:
176196

177197

178198
def test_sync_dict_routes_to_first_decoder() -> None:
179-
client = _sync_client_with_body(
180-
b'{"a": 1}',
181-
decoders=[PydanticDecoder(), MsgspecDecoder()],
182-
)
183-
result = client.get("https://example.test/x", response_model=dict[str, int])
199+
"""Sync twin: shared shape (dict) routes to the first decoder (pydantic)."""
200+
pyd, msg = PydanticDecoder(), MsgspecDecoder()
201+
client = _sync_client_with_body(b'{"a": 1}', decoders=[pyd, msg])
202+
with _decode_spies(pyd, msg) as (pyd_spy, msg_spy):
203+
result = client.get("https://example.test/x", response_model=dict[str, int])
184204
assert result == {"a": 1}
205+
pyd_spy.assert_called_once()
206+
msg_spy.assert_not_called()
185207
client.close()
186208

187209

188210
def test_sync_dict_routes_to_msgspec_when_first() -> None:
211+
"""Sync twin: reversed list routes the shared shape to msgspec."""
212+
msg, pyd = MsgspecDecoder(), PydanticDecoder()
213+
client = _sync_client_with_body(b'{"a": 1}', decoders=[msg, pyd])
214+
with _decode_spies(msg, pyd) as (msg_spy, pyd_spy):
215+
result = client.get("https://example.test/x", response_model=dict[str, int])
216+
assert result == {"a": 1}
217+
msg_spy.assert_called_once()
218+
pyd_spy.assert_not_called()
219+
client.close()
220+
221+
222+
def test_sync_dataclass_routes_to_first_decoder() -> None:
223+
"""Sync twin: stdlib dataclass routes to the first decoder (pydantic)."""
224+
pyd, msg = PydanticDecoder(), MsgspecDecoder()
225+
client = _sync_client_with_body(b'{"id": 1, "name": "Ada"}', decoders=[pyd, msg])
226+
with _decode_spies(pyd, msg) as (pyd_spy, msg_spy):
227+
result = client.get("https://example.test/x", response_model=_DC)
228+
assert type(result) is _DC
229+
assert result.id == 1
230+
pyd_spy.assert_called_once()
231+
msg_spy.assert_not_called()
232+
client.close()
233+
234+
235+
def test_sync_list_of_basemodel_routes_to_pydantic() -> None:
236+
"""Sync twin: list[BaseModel] is claimed only by pydantic (msgspec rejects it)."""
189237
client = _sync_client_with_body(
190-
b'{"a": 1}',
191-
decoders=[MsgspecDecoder(), PydanticDecoder()],
238+
b'[{"id": 1, "name": "Ada"}, {"id": 2, "name": "Bo"}]',
239+
decoders=[PydanticDecoder(), MsgspecDecoder()],
192240
)
193-
result = client.get("https://example.test/x", response_model=dict[str, int])
194-
assert result == {"a": 1}
241+
result = client.get("https://example.test/x", response_model=list[_PydanticUser])
242+
assert len(result) == 2 # noqa: PLR2004
243+
assert all(type(item) is _PydanticUser for item in result)
195244
client.close()
196245

197246

0 commit comments

Comments
 (0)