Skip to content

Commit 67ec0b9

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

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
@@ -1313,6 +1313,37 @@ def _request_with_body( # noqa: PLR0913 — mirrors httpx2 per-method signature
13131313
)
13141314
return self.send(request, response_model=response_model)
13151315

1316+
def _request_with_body_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
1317+
self,
1318+
method: str,
1319+
url: str,
1320+
*,
1321+
params: typing.Any | None = None,
1322+
headers: typing.Any | None = None,
1323+
cookies: typing.Any | None = None,
1324+
timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
1325+
extensions: typing.Any | None = None,
1326+
json: typing.Any | None = None,
1327+
content: typing.Any | None = None,
1328+
data: typing.Any | None = None,
1329+
files: typing.Any | None = None,
1330+
response_model: type[T],
1331+
) -> tuple[httpx2.Response, T]:
1332+
request = self._prepare_request(
1333+
method,
1334+
url,
1335+
params=params,
1336+
headers=headers,
1337+
cookies=cookies,
1338+
timeout=timeout,
1339+
extensions=extensions,
1340+
json=json,
1341+
content=content,
1342+
data=data,
1343+
files=files,
1344+
)
1345+
return self.send_with_response(request, response_model=response_model)
1346+
13161347
@typing.overload
13171348
def get(
13181349
self,
@@ -1362,6 +1393,29 @@ def get( # noqa: PLR0913 — mirrors httpx2 per-method signatures
13621393
response_model=response_model,
13631394
)
13641395

1396+
def get_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
1397+
self,
1398+
url: str,
1399+
*,
1400+
params: typing.Any | None = None,
1401+
headers: typing.Any | None = None,
1402+
cookies: typing.Any | None = None,
1403+
timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
1404+
extensions: typing.Any | None = None,
1405+
response_model: type[T],
1406+
) -> tuple[httpx2.Response, T]:
1407+
"""Send a GET request; return (response, decoded body)."""
1408+
return self._request_with_body_with_response(
1409+
"GET",
1410+
url,
1411+
params=params,
1412+
headers=headers,
1413+
cookies=cookies,
1414+
timeout=timeout,
1415+
extensions=extensions,
1416+
response_model=response_model,
1417+
)
1418+
13651419
@typing.overload
13661420
def post(
13671421
self,
@@ -1427,6 +1481,37 @@ def post( # noqa: PLR0913 — mirrors httpx2 per-method signatures
14271481
response_model=response_model,
14281482
)
14291483

1484+
def post_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
1485+
self,
1486+
url: str,
1487+
*,
1488+
params: typing.Any | None = None,
1489+
headers: typing.Any | None = None,
1490+
cookies: typing.Any | None = None,
1491+
timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
1492+
extensions: typing.Any | None = None,
1493+
json: typing.Any | None = None,
1494+
content: typing.Any | None = None,
1495+
data: typing.Any | None = None,
1496+
files: typing.Any | None = None,
1497+
response_model: type[T],
1498+
) -> tuple[httpx2.Response, T]:
1499+
"""Send a POST request; return (response, decoded body)."""
1500+
return self._request_with_body_with_response(
1501+
"POST",
1502+
url,
1503+
params=params,
1504+
headers=headers,
1505+
cookies=cookies,
1506+
timeout=timeout,
1507+
extensions=extensions,
1508+
json=json,
1509+
content=content,
1510+
data=data,
1511+
files=files,
1512+
response_model=response_model,
1513+
)
1514+
14301515
@typing.overload
14311516
def put(
14321517
self,
@@ -1492,6 +1577,37 @@ def put( # noqa: PLR0913 — mirrors httpx2 per-method signatures
14921577
response_model=response_model,
14931578
)
14941579

1580+
def put_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
1581+
self,
1582+
url: str,
1583+
*,
1584+
params: typing.Any | None = None,
1585+
headers: typing.Any | None = None,
1586+
cookies: typing.Any | None = None,
1587+
timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
1588+
extensions: typing.Any | None = None,
1589+
json: typing.Any | None = None,
1590+
content: typing.Any | None = None,
1591+
data: typing.Any | None = None,
1592+
files: typing.Any | None = None,
1593+
response_model: type[T],
1594+
) -> tuple[httpx2.Response, T]:
1595+
"""Send a PUT request; return (response, decoded body)."""
1596+
return self._request_with_body_with_response(
1597+
"PUT",
1598+
url,
1599+
params=params,
1600+
headers=headers,
1601+
cookies=cookies,
1602+
timeout=timeout,
1603+
extensions=extensions,
1604+
json=json,
1605+
content=content,
1606+
data=data,
1607+
files=files,
1608+
response_model=response_model,
1609+
)
1610+
14951611
@typing.overload
14961612
def patch(
14971613
self,
@@ -1557,6 +1673,37 @@ def patch( # noqa: PLR0913 — mirrors httpx2 per-method signatures
15571673
response_model=response_model,
15581674
)
15591675

1676+
def patch_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
1677+
self,
1678+
url: str,
1679+
*,
1680+
params: typing.Any | None = None,
1681+
headers: typing.Any | None = None,
1682+
cookies: typing.Any | None = None,
1683+
timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
1684+
extensions: typing.Any | None = None,
1685+
json: typing.Any | None = None,
1686+
content: typing.Any | None = None,
1687+
data: typing.Any | None = None,
1688+
files: typing.Any | None = None,
1689+
response_model: type[T],
1690+
) -> tuple[httpx2.Response, T]:
1691+
"""Send a PATCH request; return (response, decoded body)."""
1692+
return self._request_with_body_with_response(
1693+
"PATCH",
1694+
url,
1695+
params=params,
1696+
headers=headers,
1697+
cookies=cookies,
1698+
timeout=timeout,
1699+
extensions=extensions,
1700+
json=json,
1701+
content=content,
1702+
data=data,
1703+
files=files,
1704+
response_model=response_model,
1705+
)
1706+
15601707
@typing.overload
15611708
def delete(
15621709
self,
@@ -1622,6 +1769,37 @@ def delete( # noqa: PLR0913 — mirrors httpx2 per-method signatures
16221769
response_model=response_model,
16231770
)
16241771

1772+
def delete_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
1773+
self,
1774+
url: str,
1775+
*,
1776+
params: typing.Any | None = None,
1777+
headers: typing.Any | None = None,
1778+
cookies: typing.Any | None = None,
1779+
timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
1780+
extensions: typing.Any | None = None,
1781+
json: typing.Any | None = None,
1782+
content: typing.Any | None = None,
1783+
data: typing.Any | None = None,
1784+
files: typing.Any | None = None,
1785+
response_model: type[T],
1786+
) -> tuple[httpx2.Response, T]:
1787+
"""Send a DELETE request; return (response, decoded body)."""
1788+
return self._request_with_body_with_response(
1789+
"DELETE",
1790+
url,
1791+
params=params,
1792+
headers=headers,
1793+
cookies=cookies,
1794+
timeout=timeout,
1795+
extensions=extensions,
1796+
json=json,
1797+
content=content,
1798+
data=data,
1799+
files=files,
1800+
response_model=response_model,
1801+
)
1802+
16251803
@typing.overload
16261804
def head(
16271805
self,
@@ -1788,6 +1966,38 @@ def request( # noqa: PLR0913 — mirrors httpx2 per-method signatures
17881966
response_model=response_model,
17891967
)
17901968

1969+
def request_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures
1970+
self,
1971+
method: str,
1972+
url: str,
1973+
*,
1974+
params: typing.Any | None = None,
1975+
headers: typing.Any | None = None,
1976+
cookies: typing.Any | None = None,
1977+
timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
1978+
extensions: typing.Any | None = None,
1979+
json: typing.Any | None = None,
1980+
content: typing.Any | None = None,
1981+
data: typing.Any | None = None,
1982+
files: typing.Any | None = None,
1983+
response_model: type[T],
1984+
) -> tuple[httpx2.Response, T]:
1985+
"""Send a request with an explicit method; return (response, decoded body)."""
1986+
return self._request_with_body_with_response(
1987+
method,
1988+
url,
1989+
params=params,
1990+
headers=headers,
1991+
cookies=cookies,
1992+
timeout=timeout,
1993+
extensions=extensions,
1994+
json=json,
1995+
content=content,
1996+
data=data,
1997+
files=files,
1998+
response_model=response_model,
1999+
)
2000+
17912001
@contextlib.contextmanager
17922002
def stream( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural
17932003
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 Client — (response, decoded) pairs."""
2+
3+
from http import HTTPStatus
4+
5+
import httpx2
6+
import pydantic
7+
import pytest
8+
9+
from httpware import Client, 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[Client, 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 = Client(httpx2_client=httpx2.Client(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+
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 = 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+
def test_request_with_response_returns_pair() -> None:
49+
client, recorded = _echo_client()
50+
response, user = 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+
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 = 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+
def test_post_with_response_forwards_json_body() -> None:
63+
client, recorded = _echo_client()
64+
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+
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+
client.get_with_response("https://example.test/u", response_model=_User)
72+
assert exc_info.value.model is _User
73+
74+
75+
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 = Client(httpx2_client=httpx2.Client(transport=httpx2.MockTransport(handler)), decoders=[])
80+
81+
class _Foo:
82+
pass
83+
84+
with pytest.raises(MissingDecoderError):
85+
client.get_with_response("https://example.test/x", response_model=_Foo)

0 commit comments

Comments
 (0)