Skip to content

Commit 802b222

Browse files
lesnik512claude
andcommitted
feat(client): add async per-verb *_with_response siblings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 09e16ca commit 802b222

2 files changed

Lines changed: 295 additions & 0 deletions

File tree

src/httpware/client.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,37 @@ async def _request_with_body( # noqa: PLR0913 — mirrors httpx2 per-method sig
298298
)
299299
return await self.send(request, response_model=response_model)
300300

301+
async def _request_with_body_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
302+
self,
303+
method: str,
304+
url: str,
305+
*,
306+
params: typing.Any | None = None,
307+
headers: typing.Any | None = None,
308+
cookies: typing.Any | None = None,
309+
timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
310+
extensions: typing.Any | None = None,
311+
json: typing.Any | None = None,
312+
content: typing.Any | None = None,
313+
data: typing.Any | None = None,
314+
files: typing.Any | None = None,
315+
response_model: type[T],
316+
) -> tuple[httpx2.Response, T]:
317+
request = self._prepare_request(
318+
method,
319+
url,
320+
params=params,
321+
headers=headers,
322+
cookies=cookies,
323+
timeout=timeout,
324+
extensions=extensions,
325+
json=json,
326+
content=content,
327+
data=data,
328+
files=files,
329+
)
330+
return await self.send_with_response(request, response_model=response_model)
331+
301332
@typing.overload
302333
async def get(
303334
self,
@@ -347,6 +378,29 @@ async def get( # noqa: PLR0913 — mirrors httpx2 per-method signatures
347378
response_model=response_model,
348379
)
349380

381+
async def get_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
382+
self,
383+
url: str,
384+
*,
385+
params: typing.Any | None = None,
386+
headers: typing.Any | None = None,
387+
cookies: typing.Any | None = None,
388+
timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
389+
extensions: typing.Any | None = None,
390+
response_model: type[T],
391+
) -> tuple[httpx2.Response, T]:
392+
"""Send a GET request; return (response, decoded body)."""
393+
return await self._request_with_body_with_response(
394+
"GET",
395+
url,
396+
params=params,
397+
headers=headers,
398+
cookies=cookies,
399+
timeout=timeout,
400+
extensions=extensions,
401+
response_model=response_model,
402+
)
403+
350404
@typing.overload
351405
async def post(
352406
self,
@@ -412,6 +466,37 @@ async def post( # noqa: PLR0913 — mirrors httpx2 per-method signatures
412466
response_model=response_model,
413467
)
414468

469+
async def post_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
470+
self,
471+
url: str,
472+
*,
473+
params: typing.Any | None = None,
474+
headers: typing.Any | None = None,
475+
cookies: typing.Any | None = None,
476+
timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
477+
extensions: typing.Any | None = None,
478+
json: typing.Any | None = None,
479+
content: typing.Any | None = None,
480+
data: typing.Any | None = None,
481+
files: typing.Any | None = None,
482+
response_model: type[T],
483+
) -> tuple[httpx2.Response, T]:
484+
"""Send a POST request; return (response, decoded body)."""
485+
return await self._request_with_body_with_response(
486+
"POST",
487+
url,
488+
params=params,
489+
headers=headers,
490+
cookies=cookies,
491+
timeout=timeout,
492+
extensions=extensions,
493+
json=json,
494+
content=content,
495+
data=data,
496+
files=files,
497+
response_model=response_model,
498+
)
499+
415500
@typing.overload
416501
async def put(
417502
self,
@@ -477,6 +562,37 @@ async def put( # noqa: PLR0913 — mirrors httpx2 per-method signatures
477562
response_model=response_model,
478563
)
479564

565+
async def put_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
566+
self,
567+
url: str,
568+
*,
569+
params: typing.Any | None = None,
570+
headers: typing.Any | None = None,
571+
cookies: typing.Any | None = None,
572+
timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
573+
extensions: typing.Any | None = None,
574+
json: typing.Any | None = None,
575+
content: typing.Any | None = None,
576+
data: typing.Any | None = None,
577+
files: typing.Any | None = None,
578+
response_model: type[T],
579+
) -> tuple[httpx2.Response, T]:
580+
"""Send a PUT request; return (response, decoded body)."""
581+
return await self._request_with_body_with_response(
582+
"PUT",
583+
url,
584+
params=params,
585+
headers=headers,
586+
cookies=cookies,
587+
timeout=timeout,
588+
extensions=extensions,
589+
json=json,
590+
content=content,
591+
data=data,
592+
files=files,
593+
response_model=response_model,
594+
)
595+
480596
@typing.overload
481597
async def patch(
482598
self,
@@ -542,6 +658,37 @@ async def patch( # noqa: PLR0913 — mirrors httpx2 per-method signatures
542658
response_model=response_model,
543659
)
544660

661+
async def patch_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
662+
self,
663+
url: str,
664+
*,
665+
params: typing.Any | None = None,
666+
headers: typing.Any | None = None,
667+
cookies: typing.Any | None = None,
668+
timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
669+
extensions: typing.Any | None = None,
670+
json: typing.Any | None = None,
671+
content: typing.Any | None = None,
672+
data: typing.Any | None = None,
673+
files: typing.Any | None = None,
674+
response_model: type[T],
675+
) -> tuple[httpx2.Response, T]:
676+
"""Send a PATCH request; return (response, decoded body)."""
677+
return await self._request_with_body_with_response(
678+
"PATCH",
679+
url,
680+
params=params,
681+
headers=headers,
682+
cookies=cookies,
683+
timeout=timeout,
684+
extensions=extensions,
685+
json=json,
686+
content=content,
687+
data=data,
688+
files=files,
689+
response_model=response_model,
690+
)
691+
545692
@typing.overload
546693
async def delete(
547694
self,
@@ -607,6 +754,37 @@ async def delete( # noqa: PLR0913 — mirrors httpx2 per-method signatures
607754
response_model=response_model,
608755
)
609756

757+
async def delete_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
758+
self,
759+
url: str,
760+
*,
761+
params: typing.Any | None = None,
762+
headers: typing.Any | None = None,
763+
cookies: typing.Any | None = None,
764+
timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
765+
extensions: typing.Any | None = None,
766+
json: typing.Any | None = None,
767+
content: typing.Any | None = None,
768+
data: typing.Any | None = None,
769+
files: typing.Any | None = None,
770+
response_model: type[T],
771+
) -> tuple[httpx2.Response, T]:
772+
"""Send a DELETE request; return (response, decoded body)."""
773+
return await self._request_with_body_with_response(
774+
"DELETE",
775+
url,
776+
params=params,
777+
headers=headers,
778+
cookies=cookies,
779+
timeout=timeout,
780+
extensions=extensions,
781+
json=json,
782+
content=content,
783+
data=data,
784+
files=files,
785+
response_model=response_model,
786+
)
787+
610788
@typing.overload
611789
async def head(
612790
self,
@@ -773,6 +951,38 @@ async def request( # noqa: PLR0913 — mirrors httpx2 per-method signatures
773951
response_model=response_model,
774952
)
775953

954+
async def request_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
955+
self,
956+
method: str,
957+
url: str,
958+
*,
959+
params: typing.Any | None = None,
960+
headers: typing.Any | None = None,
961+
cookies: typing.Any | None = None,
962+
timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
963+
extensions: typing.Any | None = None,
964+
json: typing.Any | None = None,
965+
content: typing.Any | None = None,
966+
data: typing.Any | None = None,
967+
files: typing.Any | None = None,
968+
response_model: type[T],
969+
) -> tuple[httpx2.Response, T]:
970+
"""Send a request with an explicit method; return (response, decoded body)."""
971+
return await self._request_with_body_with_response(
972+
method,
973+
url,
974+
params=params,
975+
headers=headers,
976+
cookies=cookies,
977+
timeout=timeout,
978+
extensions=extensions,
979+
json=json,
980+
content=content,
981+
data=data,
982+
files=files,
983+
response_model=response_model,
984+
)
985+
776986
@contextlib.asynccontextmanager
777987
async def stream( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural
778988
self,
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Per-verb *_with_response siblings on AsyncClient — (response, decoded) pairs."""
2+
3+
from http import HTTPStatus
4+
5+
import httpx2
6+
import pydantic
7+
import pytest
8+
9+
from httpware import AsyncClient, DecodeError, MissingDecoderError
10+
11+
12+
class _User(pydantic.BaseModel):
13+
id: int
14+
name: str
15+
16+
17+
def _echo_client(
18+
payload: bytes = b'{"id": 1, "name": "ada"}',
19+
*,
20+
headers: dict[str, str] | None = None,
21+
) -> tuple[AsyncClient, list[httpx2.Request]]:
22+
recorded: list[httpx2.Request] = []
23+
response_headers = {"content-type": "application/json"}
24+
if headers is not None:
25+
response_headers.update(headers)
26+
27+
def handler(request: httpx2.Request) -> httpx2.Response:
28+
recorded.append(request)
29+
return httpx2.Response(HTTPStatus.OK, content=payload, headers=response_headers, request=request)
30+
31+
client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)))
32+
return client, recorded
33+
34+
35+
@pytest.mark.parametrize(
36+
("verb", "expected_method"),
37+
[("get", "GET"), ("post", "POST"), ("put", "PUT"), ("patch", "PATCH"), ("delete", "DELETE")],
38+
)
39+
async def test_verb_with_response_returns_pair_and_sends_right_method(verb: str, expected_method: str) -> None:
40+
client, recorded = _echo_client()
41+
method = getattr(client, f"{verb}_with_response")
42+
response, user = await method("https://example.test/u", response_model=_User)
43+
assert isinstance(response, httpx2.Response)
44+
assert user == _User(id=1, name="ada")
45+
assert recorded[0].method == expected_method
46+
47+
48+
async def test_request_with_response_returns_pair() -> None:
49+
client, recorded = _echo_client()
50+
response, user = await client.request_with_response("GET", "https://example.test/u", response_model=_User)
51+
assert isinstance(response, httpx2.Response)
52+
assert user == _User(id=1, name="ada")
53+
assert recorded[0].method == "GET"
54+
55+
56+
async def test_get_with_response_preserves_headers() -> None:
57+
client, _ = _echo_client(headers={"link": '<https://example.test/u?page=2>; rel="next"'})
58+
response, _user = await client.get_with_response("https://example.test/u", response_model=_User)
59+
assert response.headers.get("link") == '<https://example.test/u?page=2>; rel="next"'
60+
61+
62+
async def test_post_with_response_forwards_json_body() -> None:
63+
client, recorded = _echo_client()
64+
await client.post_with_response("https://example.test/u", json={"name": "ada"}, response_model=_User)
65+
assert recorded[0].content == b'{"name":"ada"}'
66+
67+
68+
async def test_with_response_decode_failure_raises_decode_error() -> None:
69+
client, _ = _echo_client(payload=b"null")
70+
with pytest.raises(DecodeError) as exc_info:
71+
await client.get_with_response("https://example.test/u", response_model=_User)
72+
assert exc_info.value.model is _User
73+
74+
75+
async def test_with_response_missing_decoder_before_http_call() -> None:
76+
def handler(_: httpx2.Request) -> httpx2.Response: # pragma: no cover
77+
pytest.fail("transport should not be invoked when MissingDecoderError fires")
78+
79+
client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), decoders=[])
80+
81+
class _Foo:
82+
pass
83+
84+
with pytest.raises(MissingDecoderError):
85+
await client.get_with_response("https://example.test/x", response_model=_Foo)

0 commit comments

Comments
 (0)