diff --git a/architecture/client.md b/architecture/client.md index 74001a2..118d3bb 100644 --- a/architecture/client.md +++ b/architecture/client.md @@ -12,6 +12,12 @@ The sync and async surfaces are kept at parity. Shared state is thread-safe wher The async middleware surface uses the `Async*`/`async_*` prefix, aligning with httpx2's convention. +## `send_with_response` and per-verb siblings + +`send_with_response(request, *, response_model)` returns `(httpx2.Response, T)` atomically — the decoded body and the raw response together. This is the building block for cases where response metadata (headers, status) is needed alongside the typed body, such as Link-header pagination. + +The per-verb `*_with_response` siblings — `get_with_response`, `post_with_response`, `put_with_response`, `patch_with_response`, `delete_with_response`, and `request_with_response` — are the one-call ergonomic form: `response_model` is required, they return `tuple[httpx2.Response, T]`, and they accept the same keyword arguments as their non-`_with_response` counterparts; there is no `head_with_response` or `options_with_response` — use `request_with_response` for those methods. + ## Streaming Both `Client.stream()` (sync) and `AsyncClient.stream()` (async) provide a context-manager API for chunked response bodies. Both bypass the middleware chain by design. diff --git a/docs/recipes/link-header-pagination.md b/docs/recipes/link-header-pagination.md index 82b787a..75d7510 100644 --- a/docs/recipes/link-header-pagination.md +++ b/docs/recipes/link-header-pagination.md @@ -30,10 +30,26 @@ async def main() -> None: `process` and `next_link` are caller-defined. Pick a Link-header parser that fits your project — there are several on PyPI, and the format is small enough to hand-roll. +## Shorthand: per-verb `*_with_response` + +When you do not need a pre-built `Request` object, the per-verb siblings collapse the `build_request` + `send_with_response` two-step into a single call: + +```python +# two-step (pre-built request, required when you need full Request control) +request = client.build_request("GET", url, params=params) +response, tags = await client.send_with_response(request, response_model=list[Tag]) + +# one-call shorthand (equivalent for the simple case) +response, tags = await client.get_with_response(url, params=params, response_model=list[Tag]) +``` + +The full set of siblings is `get_with_response`, `post_with_response`, `put_with_response`, `patch_with_response`, `delete_with_response`, and `request_with_response`. There is no `head_with_response` or `options_with_response` — use `request_with_response` for those methods. + ## When to use which API - **Body only, high-level verb:** `client.get(..., response_model=...)` - **Body only, custom `Request`:** `client.send(request, response_model=...)` -- **Body + response metadata:** `client.send_with_response(request, response_model=...)` +- **Body + response metadata, simple URL:** `client.get_with_response(url, response_model=...)` +- **Body + response metadata, pre-built `Request`:** `client.send_with_response(request, response_model=...)` -`send_with_response` is not for streaming responses — use [`stream()`](../index.md#streaming-responses) for those. +`send_with_response` and the `*_with_response` siblings are not for streaming responses — use [`stream()`](../index.md#streaming-responses) for those. diff --git a/planning/README.md b/planning/README.md index 545cef8..16bda74 100644 --- a/planning/README.md +++ b/planning/README.md @@ -74,6 +74,7 @@ _None._ ### Archived (shipped) +- **[per-verb-with-response](changes/archive/2026-06-16.01-per-verb-with-response/design.md)** (#68, 2026-06-16) — Added `get_with_response` … `request_with_response` siblings (required `response_model`, returns `(Response, T)`) to both clients. Shipped 0.12.0; closed the deferred "Per-verb-with-response siblings" item. - **[custom-decoder-guide](changes/archive/2026-06-15.01-custom-decoder-guide/change.md)** (#67, 2026-06-15) — Docs: a "write your own `ResponseDecoder`" guide for Seam B, mirroring `docs/middleware.md`. Closed deferred item G6. - **[audit-doc-fixes](changes/archive/2026-06-14.06-audit-doc-fixes/change.md)** (#66, 2026-06-14) — Closed the [deep-audit](audits/2026-06-14-deep-audit.md) doc-accuracy findings: `Client.stream()` docs, terminal-call attribution, the four auto-raise sites, the pydantic upper bound, and root import paths. - **[audit-test-quality](changes/archive/2026-06-14.05-audit-test-quality/change.md)** (#65, 2026-06-14) — Closed 11 [deep-audit](audits/2026-06-14-deep-audit.md) test-quality findings: sync-terminal + CookieConflict coverage, the `StatusError.__init__` invariant, missing status constructions, sync mirrors, typing overloads, a deterministic bulkhead barrier, a pinned budget clock, an observability assertion, and the `TimeoutError` circuit trigger. diff --git a/planning/changes/archive/2026-06-16.01-per-verb-with-response/design.md b/planning/changes/archive/2026-06-16.01-per-verb-with-response/design.md new file mode 100644 index 0000000..951f921 --- /dev/null +++ b/planning/changes/archive/2026-06-16.01-per-verb-with-response/design.md @@ -0,0 +1,167 @@ +--- +status: shipped +date: 2026-06-16 +slug: per-verb-with-response +supersedes: null +superseded_by: null +pr: 68 +outcome: Shipped 0.12.0 — 6 per-verb *_with_response siblings (get/post/put/patch/delete/request) on both clients, returning (Response, T). Closed the deferred "Per-verb-with-response siblings" item. +--- + +# Design: Per-verb `*_with_response` siblings + +## Summary + +Add `get_with_response`, `post_with_response`, `put_with_response`, +`patch_with_response`, `delete_with_response`, and `request_with_response` to +both `AsyncClient` and `Client`. Each takes a **required** `response_model` and +returns `tuple[httpx2.Response, T]` — the per-verb ergonomic form of the +existing `send_with_response`, for the "I need response metadata *and* a typed +body" case without the `build_request(...)` + `send_with_response(...)` +two-step. Additive, no breaking change; ships as 0.12.0. + +## Motivation + +`send_with_response` (shipped 0.8.2) covers the headers-plus-typed-body case — +Link-header pagination, ETag / rate-limit-header reads alongside a decoded +payload. But it forces a two-call shape: + +```python +request = client.build_request("GET", "/users", params={"page": 2}) +response, users = await client.send_with_response(request, response_model=list[User]) +``` + +The plain verbs (`get`, `post`, …) already collapse `build_request` + `send` +into one call. There is no one-call form for the *with-response* path, so the +common "GET a page, read its `Link` header, decode its body" flow is wordier +than the body-only flow it sits beside. This was parked in +[`deferred.md`](../../deferred.md) under "Per-verb-with-response siblings" +pending concrete demand; the revisit trigger is now met. + +The deferred note estimated "~400 LOC of overload boilerplate per side." That +estimate assumed each sibling needs the same 3-overload block the plain verbs +carry. It does not — see Design §1. + +## Non-goals + +- **No `head`/`options` siblings.** HEAD bodies are empty by definition; + OPTIONS bodies are rarely decoded. `request_with_response("HEAD", ...)` is the + escape hatch if ever needed. +- **No streaming variant.** `*_with_response` decodes `response.content`, which + requires a fully-read body — same constraint as `send_with_response`. Use + `stream()` for streaming. +- **No new top-level exports.** These are methods on existing classes; nothing + joins `httpware.__all__`. +- **No change to `send_with_response` itself.** The siblings delegate to it. + +## Design + +### 1. No overloads — one call shape per sibling + +The plain verbs carry three signatures each (two `@typing.overload` stubs for +`response_model: None → Response` and `response_model: type[T] → T`, plus the +implementation) because they have two return shapes. `send_with_response` has +**one** shape: `response_model` is required and the return is always +`tuple[Response, T]`. It is a single concrete method, no overloads — and the +verb siblings mirror that. Each sibling is one method: a body-kwargs signature +plus a one-line delegation (~15-25 lines), not a 3-overload block. + +### 2. Extract `_prepare_request`, add a parallel with-response helper + +`AsyncClient._request_with_body` (and its sync twin) currently inlines two +concerns: assembling the non-`None` kwargs dict + setting `STREAMING_BODY_MARKER`, +then calling `self.send(...)`. Split the first concern out so both paths share +it: + +```python +def _prepare_request(self, method, url, *, params=None, headers=None, + cookies=None, timeout=USE_CLIENT_DEFAULT, extensions=None, + json=None, content=None, data=None, files=None) -> httpx2.Request: + # kwargs-dict assembly + streaming-body marker (moved verbatim from + # _request_with_body); returns the built httpx2.Request. + +async def _request_with_body(self, method, url, *, ..., response_model=None): + request = self._prepare_request(method, url, ...) + return await self.send(request, response_model=response_model) + +async def _request_with_body_with_response(self, method, url, *, ..., response_model: type[T]): + request = self._prepare_request(method, url, ...) + return await self.send_with_response(request, response_model=response_model) +``` + +The six verb siblings delegate to `_request_with_body_with_response` exactly as +the plain verbs delegate to `_request_with_body`. `_prepare_request` takes the +full body-kwarg superset; `get_with_response` simply does not pass the body +kwargs (mirroring plain `get`). + +### 3. Sibling signatures + +Each mirrors its plain verb's kwargs, with `response_model` promoted to required +keyword-only and the return type changed: + +| Sibling | Kwargs (beyond `response_model`) | Returns | +|---|---|---| +| `get_with_response(url, *, ...)` | params, headers, cookies, timeout, extensions | `tuple[Response, T]` | +| `post_with_response(url, *, ...)` | …+ json, content, data, files | `tuple[Response, T]` | +| `put_with_response(url, *, ...)` | …+ json, content, data, files | `tuple[Response, T]` | +| `patch_with_response(url, *, ...)` | …+ json, content, data, files | `tuple[Response, T]` | +| `delete_with_response(url, *, ...)` | …+ json, content, data, files | `tuple[Response, T]` | +| `request_with_response(method, url, *, ...)` | …+ json, content, data, files | `tuple[Response, T]` | + +`response_model: type[T]` is required (no default). Reuse the existing +`# noqa: PLR0913 — mirrors httpx2 per-method signatures` justification on the +wide-signature methods. Docstrings follow the plain verbs ("Send a GET request; +return (response, decoded)."). + +### 4. Inherited behavior (free via delegation) + +Because every sibling bottoms out at `send_with_response`, it inherits without +new code: + +- `MissingDecoderError` raised **before** the HTTP call when no decoder claims + the model. +- `DecodeError` wrapping a decoder failure on a malformed body. +- `STREAMING_BODY_MARKER` handling for streaming request bodies (set in + `_prepare_request`). + +No new error paths are introduced. + +### 5. Sync parity + +`Client` gains the identical six siblings + the same helper split, sync flavor. +Sync and async surfaces stay at parity (an architecture invariant). + +## Testing + +- **Per-verb parity, async + sync** (12 verb × client combinations): each + sibling returns a `(response, decoded)` tuple where `response` is the + `httpx2.Response` (headers reachable, e.g. a seeded `Link` header) and the + second element is the decoded model instance. Inject via `httpx2.MockTransport` + per the testing convention. +- **Pre-flight `MissingDecoderError`:** calling a sibling with a model no + decoder claims raises before the transport is hit (assert the mock saw zero + requests). +- **`DecodeError` on bad payload:** a sibling against a malformed body raises + `DecodeError` carrying `response`/`model`/`original`. +- **Typed-usage check:** one test (or a `ty`-checked usage) confirming the + declared return is `tuple[Response, Model]` — no overloads to exercise, just + the concrete return. +- `just test` green; `just lint` clean. + +## Risk + +- **Surface duplication drift (low × medium).** Six near-identical methods per + side invite copy-paste errors (a wrong verb string, a dropped kwarg). The + `_prepare_request` + `_request_with_body_with_response` extraction confines the + logic to one place; the verb methods are pure delegation, and per-verb tests + catch a wrong method string. The `_request_with_body` refactor is behavior- + preserving and covered by the existing plain-verb suite. +- **`_prepare_request` regression (low × high).** Moving the kwargs/marker logic + could subtly change request construction. Mitigated by running the full + existing suite before adding siblings — the plain verbs exercise the moved + code unchanged. + +## Out of scope + +`head_with_response` / `options_with_response`; a streaming with-response +variant; any change to `send`, `send_with_response`, or the decoder seam. diff --git a/planning/changes/archive/2026-06-16.01-per-verb-with-response/plan.md b/planning/changes/archive/2026-06-16.01-per-verb-with-response/plan.md new file mode 100644 index 0000000..47ca9c9 --- /dev/null +++ b/planning/changes/archive/2026-06-16.01-per-verb-with-response/plan.md @@ -0,0 +1,553 @@ +--- +status: shipped +date: 2026-06-16 +slug: per-verb-with-response +spec: per-verb-with-response +pr: 68 +--- + +# per-verb-with-response — 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:** Add `get_with_response` … `request_with_response` (6 verbs) to both +`AsyncClient` and `Client`, each returning `(httpx2.Response, T)` with a +required `response_model`. + +**Architecture:** Extract the request-building logic from `_request_with_body` +into a shared `_prepare_request`, add a parallel `_request_with_body_with_response` +helper that delegates to `send_with_response`, and add six one-line-delegation +verb methods per client. No overloads (one call shape). Sync mirrors async. + +**Tech Stack:** Python 3.11+, `httpx2`, `pytest` (asyncio auto mode), `pydantic` +(test models), `httpx2.MockTransport` for injection. + +**Spec:** [`design.md`](./design.md) + +**Branch:** `feat/per-verb-with-response` + +**Commit strategy:** Per-task commits. + +--- + +### Task 1: Extract `_prepare_request` (behavior-preserving refactor) + +Split request-building out of `_request_with_body` on both clients so the plain +and with-response paths can share it. No behavior change — the existing +plain-verb suite is the safety net. + +**Files:** +- Modify: `src/httpware/client.py` (`AsyncClient._request_with_body` ~231-269; + `Client._request_with_body` ~1006-1044) + +- [ ] **Step 1: Add `AsyncClient._prepare_request`** + + Insert this method just above `AsyncClient._request_with_body` (after + `build_request`, ~line 230): + + ```python + def _prepare_request( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural + self, + method: str, + url: str, + *, + params: typing.Any | None = None, + headers: typing.Any | None = None, + cookies: typing.Any | None = None, + timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, + extensions: typing.Any | None = None, + json: typing.Any | None = None, + content: typing.Any | None = None, + data: typing.Any | None = None, + files: typing.Any | None = None, + ) -> httpx2.Request: + kwargs: dict[str, typing.Any] = {} + if params is not None: + kwargs["params"] = params + if headers is not None: + kwargs["headers"] = headers + if cookies is not None: + kwargs["cookies"] = cookies + if timeout is not httpx2.USE_CLIENT_DEFAULT: + kwargs["timeout"] = timeout + if extensions is not None: + kwargs["extensions"] = extensions + if json is not None: + kwargs["json"] = json + if content is not None: + kwargs["content"] = content + if data is not None: + kwargs["data"] = data + if files is not None: + kwargs["files"] = files + request = self._httpx2_client.build_request(method, url, **kwargs) + if _is_streaming_body_async(content) or _is_streaming_body_async(data) or _is_streaming_body_async(files): + request.extensions[STREAMING_BODY_MARKER] = True + return request + ``` + +- [ ] **Step 2: Rewire `AsyncClient._request_with_body` to use it** + + Replace the body of `_request_with_body` (the kwargs-assembly + marker + + `send` block) with delegation. Drop `C901` from its `# noqa` — the branching + now lives in `_prepare_request`, so only `PLR0913` (param count) remains: + + ```python + async def _request_with_body( # noqa: PLR0913 — mirrors httpx2 per-method signatures + self, + method: str, + url: str, + *, + params: typing.Any | None = None, + headers: typing.Any | None = None, + cookies: typing.Any | None = None, + timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, + extensions: typing.Any | None = None, + json: typing.Any | None = None, + content: typing.Any | None = None, + data: typing.Any | None = None, + files: typing.Any | None = None, + response_model: type[T] | None = None, + ) -> httpx2.Response | T: + request = self._prepare_request( + method, url, params=params, headers=headers, cookies=cookies, + timeout=timeout, extensions=extensions, json=json, content=content, + data=data, files=files, + ) + return await self.send(request, response_model=response_model) + ``` + +- [ ] **Step 3: Mirror both edits on `Client`** + + Add `Client._prepare_request` (identical, but the streaming check uses + `_is_streaming_body_sync` instead of `_is_streaming_body_async`) and rewire + `Client._request_with_body` to delegate (calling the synchronous `self.send`, + no `await`). + +- [ ] **Step 4: Run the full suite — refactor must be invisible** + + Run: `just test` + Expected: PASS, same count as before this task (the plain verbs exercise the + moved code). + +- [ ] **Step 5: Lint** + + Run: `just lint` + Expected: clean. (If ruff flags an unused `C901`, the rewrite already dropped + it.) + +- [ ] **Step 6: Commit** + + ```bash + git add src/httpware/client.py + git commit -m "refactor(client): extract _prepare_request from _request_with_body + + Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +### Task 2: Async `*_with_response` siblings + +Add the with-response helper and the six async verb methods, test-first. + +**Files:** +- Modify: `src/httpware/client.py` (`AsyncClient`) +- Test: `tests/test_client_per_verb_with_response.py` (create) + +- [ ] **Step 1: Write the failing tests** + + Create `tests/test_client_per_verb_with_response.py`: + + ```python + """Per-verb *_with_response siblings on AsyncClient — (response, decoded) pairs.""" + + from http import HTTPStatus + + import httpx2 + import pydantic + import pytest + + from httpware import AsyncClient, DecodeError, MissingDecoderError + + + class _User(pydantic.BaseModel): + id: int + name: str + + + def _echo_client( + payload: bytes = b'{"id": 1, "name": "ada"}', + *, + headers: dict[str, str] | None = None, + ) -> tuple[AsyncClient, list[httpx2.Request]]: + recorded: list[httpx2.Request] = [] + response_headers = {"content-type": "application/json"} + if headers is not None: + response_headers.update(headers) + + def handler(request: httpx2.Request) -> httpx2.Response: + recorded.append(request) + return httpx2.Response(HTTPStatus.OK, content=payload, headers=response_headers, request=request) + + client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler))) + return client, recorded + + + @pytest.mark.parametrize( + ("verb", "expected_method"), + [("get", "GET"), ("post", "POST"), ("put", "PUT"), ("patch", "PATCH"), ("delete", "DELETE")], + ) + async def test_verb_with_response_returns_pair_and_sends_right_method(verb: str, expected_method: str) -> None: + client, recorded = _echo_client() + method = getattr(client, f"{verb}_with_response") + response, user = await method("https://example.test/u", response_model=_User) + assert isinstance(response, httpx2.Response) + assert user == _User(id=1, name="ada") + assert recorded[0].method == expected_method + + + async def test_request_with_response_returns_pair() -> None: + client, recorded = _echo_client() + response, user = await client.request_with_response("GET", "https://example.test/u", response_model=_User) + assert isinstance(response, httpx2.Response) + assert user == _User(id=1, name="ada") + assert recorded[0].method == "GET" + + + async def test_get_with_response_preserves_headers() -> None: + client, _ = _echo_client(headers={"link": '; rel="next"'}) + response, _user = await client.get_with_response("https://example.test/u", response_model=_User) + assert response.headers.get("link") == '; rel="next"' + + + async def test_post_with_response_forwards_json_body() -> None: + client, recorded = _echo_client() + await client.post_with_response("https://example.test/u", json={"name": "ada"}, response_model=_User) + assert recorded[0].content == b'{"name": "ada"}' + + + async def test_with_response_decode_failure_raises_decode_error() -> None: + client, _ = _echo_client(payload=b"null") + with pytest.raises(DecodeError) as exc_info: + await client.get_with_response("https://example.test/u", response_model=_User) + assert exc_info.value.model is _User + + + async def test_with_response_missing_decoder_before_http_call() -> None: + def handler(_: httpx2.Request) -> httpx2.Response: # pragma: no cover + pytest.fail("transport should not be invoked when MissingDecoderError fires") + + client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), decoders=[]) + + class _Foo: + pass + + with pytest.raises(MissingDecoderError): + await client.get_with_response("https://example.test/x", response_model=_Foo) + ``` + +- [ ] **Step 2: Run the tests to verify they fail** + + Run: `just test tests/test_client_per_verb_with_response.py` + Expected: FAIL — `AttributeError: 'AsyncClient' object has no attribute 'get_with_response'`. + +- [ ] **Step 3: Add `AsyncClient._request_with_body_with_response`** + + Insert directly after `AsyncClient._request_with_body`: + + ```python + async def _request_with_body_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + self, + method: str, + url: str, + *, + params: typing.Any | None = None, + headers: typing.Any | None = None, + cookies: typing.Any | None = None, + timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, + extensions: typing.Any | None = None, + json: typing.Any | None = None, + content: typing.Any | None = None, + data: typing.Any | None = None, + files: typing.Any | None = None, + response_model: type[T], + ) -> tuple[httpx2.Response, T]: + request = self._prepare_request( + method, url, params=params, headers=headers, cookies=cookies, + timeout=timeout, extensions=extensions, json=json, content=content, + data=data, files=files, + ) + return await self.send_with_response(request, response_model=response_model) + ``` + +- [ ] **Step 4: Add `get_with_response` (no body kwargs)** + + Insert after the plain `get` implementation: + + ```python + async def get_with_response( + self, + url: str, + *, + params: typing.Any | None = None, + headers: typing.Any | None = None, + cookies: typing.Any | None = None, + timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, + extensions: typing.Any | None = None, + response_model: type[T], + ) -> tuple[httpx2.Response, T]: + """Send a GET request; return (response, decoded body).""" + return await self._request_with_body_with_response( + "GET", url, params=params, headers=headers, cookies=cookies, + timeout=timeout, extensions=extensions, response_model=response_model, + ) + ``` + +- [ ] **Step 5: Add `post_with_response` (full body kwargs)** + + Insert after the plain `post` implementation: + + ```python + async def post_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + self, + url: str, + *, + params: typing.Any | None = None, + headers: typing.Any | None = None, + cookies: typing.Any | None = None, + timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, + extensions: typing.Any | None = None, + json: typing.Any | None = None, + content: typing.Any | None = None, + data: typing.Any | None = None, + files: typing.Any | None = None, + response_model: type[T], + ) -> tuple[httpx2.Response, T]: + """Send a POST request; return (response, decoded body).""" + return await self._request_with_body_with_response( + "POST", url, params=params, headers=headers, cookies=cookies, + timeout=timeout, extensions=extensions, json=json, content=content, + data=data, files=files, response_model=response_model, + ) + ``` + +- [ ] **Step 6: Add `put_with_response`, `patch_with_response`, `delete_with_response`** + + Each is a copy of `post_with_response` (Step 5) — identical signature and body + — changing only the method name and the verb string passed to the helper: + `put_with_response` → `"PUT"`, `patch_with_response` → `"PATCH"`, + `delete_with_response` → `"DELETE"`. Place each after its plain-verb sibling. + Update each docstring to name the verb. + +- [ ] **Step 7: Add `request_with_response` (leading `method` arg)** + + Insert after the plain `request` implementation: + + ```python + async def request_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + self, + method: str, + url: str, + *, + params: typing.Any | None = None, + headers: typing.Any | None = None, + cookies: typing.Any | None = None, + timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, + extensions: typing.Any | None = None, + json: typing.Any | None = None, + content: typing.Any | None = None, + data: typing.Any | None = None, + files: typing.Any | None = None, + response_model: type[T], + ) -> tuple[httpx2.Response, T]: + """Send a request with an explicit method; return (response, decoded body).""" + return await self._request_with_body_with_response( + method, url, params=params, headers=headers, cookies=cookies, + timeout=timeout, extensions=extensions, json=json, content=content, + data=data, files=files, response_model=response_model, + ) + ``` + +- [ ] **Step 8: Run the tests to verify they pass** + + Run: `just test tests/test_client_per_verb_with_response.py` + Expected: PASS (all parametrized verbs + request + headers + body + DecodeError + + MissingDecoderError). + +- [ ] **Step 9: Commit** + + ```bash + git add src/httpware/client.py tests/test_client_per_verb_with_response.py + git commit -m "feat(client): add async per-verb *_with_response siblings + + Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +### Task 3: Sync `*_with_response` siblings + +Mirror Task 2 on `Client`. + +**Files:** +- Modify: `src/httpware/client.py` (`Client`) +- Test: `tests/test_client_per_verb_with_response_sync.py` (create) + +- [ ] **Step 1: Write the failing tests** + + Create `tests/test_client_per_verb_with_response_sync.py` as a sync copy of the + Task 2 test file: import `Client` (not `AsyncClient`), build with + `Client(httpx2_client=httpx2.Client(transport=httpx2.MockTransport(handler)))`, + drop every `async`/`await`, and call the sync methods. Keep the same test + names, the `_User` model, the `(verb, expected_method)` parametrization, the + header/body/DecodeError/MissingDecoderError cases. + +- [ ] **Step 2: Run the tests to verify they fail** + + Run: `just test tests/test_client_per_verb_with_response_sync.py` + Expected: FAIL — `Client` has no `get_with_response`. + +- [ ] **Step 3: Add `Client._request_with_body_with_response` + the six siblings** + + Mirror Task 2 Steps 3-7 on `Client`: same signatures, no `async`/`await`, each + delegating to the synchronous `self.send_with_response`. Place each verb sibling + after its plain-verb counterpart in `Client`. + +- [ ] **Step 4: Run the tests to verify they pass** + + Run: `just test tests/test_client_per_verb_with_response_sync.py` + Expected: PASS. + +- [ ] **Step 5: Full suite + lint** + + Run: `just test && just lint` + Expected: all green, lint clean. + +- [ ] **Step 6: Commit** + + ```bash + git add src/httpware/client.py tests/test_client_per_verb_with_response_sync.py + git commit -m "feat(client): add sync per-verb *_with_response siblings + + Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +### Task 4: Typed-return check + docs + +Confirm the declared return types via `ty`, and document the new surface. + +**Files:** +- Test: `tests/test_client_typing.py` (modify) +- Modify: `docs/recipes/link-header-pagination.md`, `architecture/client.md` + +- [ ] **Step 1: Add a typed-usage assertion** + + Append to `tests/test_client_typing.py` (match the file's existing + `typing.assert_type` / reveal-type style — open it first to mirror the pattern): + + ```python + async def test_get_with_response_return_type(async_client: AsyncClient) -> None: + response, user = await async_client.get_with_response("https://e.test/u", response_model=_User) + typing.assert_type(response, httpx2.Response) + typing.assert_type(user, _User) + ``` + + Use whatever fixture/model names that file already defines; if it has no + fixture, build a client inline as in Task 2. The point is that `ty check` + validates the `tuple[Response, T]` destructuring. + +- [ ] **Step 2: Run ty over the typing test** + + Run: `uv run ty check` + Expected: clean (no `assert_type` mismatch). + +- [ ] **Step 3: Update the pagination recipe** + + In `docs/recipes/link-header-pagination.md`, add a short note that + `get_with_response("/path", response_model=...)` collapses the + `build_request` + `send_with_response` two-step into one call, with a + one-line before/after. Keep the existing `send_with_response` example (still + valid for pre-built requests). + +- [ ] **Step 4: Note the siblings in architecture/client.md** + + Add one sentence to `architecture/client.md` where `send_with_response` is + described: the per-verb `*_with_response` siblings (get/post/put/patch/delete/ + request) are the one-call ergonomic form, `response_model` required, returning + `(Response, T)`; no `head`/`options` variant. + +- [ ] **Step 5: Verify docs build** + + Run: `uvx --with-requirements docs/requirements.txt mkdocs build --strict` + Expected: clean; then `rm -rf site`. + +- [ ] **Step 6: Commit** + + ```bash + git add tests/test_client_typing.py docs/recipes/link-header-pagination.md architecture/client.md + git commit -m "docs(client): document per-verb *_with_response siblings + + Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +### Task 5: Version bump, release notes, close deferred item + +Cut 0.12.0 and retire the deferred entry this change closes. + +**Files:** +- Create: `planning/releases/0.12.0.md` +- Modify: `planning/deferred.md` +- (`pyproject.toml` is NOT touched — version is tag-driven; see Step 1.) + +- [ ] **Step 1: Version is tag-driven — do NOT edit `pyproject.toml`** + + Releases are tag-driven: `publish.yml` runs `uv version $GITHUB_REF_NAME` + from the `v0.12.0` git tag at publish time, and the static `version` field in + `pyproject.toml` is deliberately kept at the placeholder `"0"` (the 0.11.0 + release commit `c27c163` reset it for exactly this reason). Leave the field at + `"0"`. The version bump happens via the tag, not this file. + +- [ ] **Step 2: Write the release notes** + + Create `planning/releases/0.12.0.md` modeled on `planning/releases/0.11.0.md`: + minor, additive-only; new methods `get_with_response`, `post_with_response`, + `put_with_response`, `patch_with_response`, `delete_with_response`, + `request_with_response` on both `AsyncClient` and `Client`; each requires + `response_model` and returns `(httpx2.Response, T)`; no `head`/`options` + variant; no breaking changes. + +- [ ] **Step 3: Remove the closed deferred item** + + In `planning/deferred.md`, delete the **"Per-verb-with-response siblings"** + bullet under "Client API surface" (it is now shipped). + +- [ ] **Step 4: Full suite + lint one more time** + + Run: `just test && just lint` + Expected: all green, clean. + +- [ ] **Step 5: Commit** + + ```bash + git add pyproject.toml planning/releases/0.12.0.md planning/deferred.md + git commit -m "chore(release): 0.12.0 — per-verb *_with_response siblings + + Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +## Ship bookkeeping (after merge) + +Not a task — done when the PR merges, per the planning convention: set this +bundle's `design.md` + `plan.md` frontmatter to `status: shipped` with the PR +number, move `changes/active/2026-06-16.01-per-verb-with-response/` to +`changes/archive/`, and flip its Index line from Active to Archived. diff --git a/planning/deferred.md b/planning/deferred.md index 1e5c21e..d752b1a 100644 --- a/planning/deferred.md +++ b/planning/deferred.md @@ -6,10 +6,6 @@ As of 0.7.0, all planned epics (3, 4, 5, 6) are closed — see the [change Index ## Open -### Client API surface - -- **Per-verb-with-response siblings** (`get_with_response`, `post_with_response`, `request_with_response`) — the v0.8.2 spec deliberately ships only `send_with_response`; the verb-method shape would add ~400 LOC of overload boilerplate per side for a pattern (response headers + typed body) that's almost always paired with a GET and `build_request`. Revisit if a concrete consumer demand surfaces. (`src/httpware/client.py`) - ### Resilience - **CircuitBreaker v2 — rolling-window / failure-rate mode** (`src/httpware/middleware/resilience/circuit_breaker.py`) — the 0.10.0 breaker ships only the *classic consecutive-failure* model (open after N counted failures in a row; any success resets the streak). That can't catch *partial* degradation (e.g. a steady 50% error rate that alternates success/fail never trips). Deferred to v2 in the 0.10.0 spec; the config was shaped so a rate mode is purely additive (a new opt-in `failure_rate_threshold` + window + `minimum_calls`, with classic remaining the default). Demand-gated: build when someone needs rate-based tripping. diff --git a/planning/releases/0.12.0.md b/planning/releases/0.12.0.md new file mode 100644 index 0000000..4abb83b --- /dev/null +++ b/planning/releases/0.12.0.md @@ -0,0 +1,60 @@ +# httpware 0.12.0 — per-verb *_with_response siblings + +**Minor release. Additive only — no breaking changes.** + +This release adds ergonomic per-verb shortcuts for the common pattern of needing +both the raw `httpx2.Response` (headers, status, request URL) and a typed body in +a single call — without having to pair `build_request(...)` with +`send_with_response(...)`. + +## New public names + +Six methods on both `AsyncClient` and `Client`: + +```python +from httpware import AsyncClient + +client = AsyncClient(base_url="https://api.example.com", decoders=[...]) + +# One call — response metadata and typed body together +response, users = await client.get_with_response( + "/users", params={"page": 2}, response_model=list[User] +) +next_url = response.headers.get("Link") +etag = response.headers.get("ETag") +``` + +| Method | Verb | +|---|---| +| `get_with_response` | GET | +| `post_with_response` | POST | +| `put_with_response` | PUT | +| `patch_with_response` | PATCH | +| `delete_with_response` | DELETE | +| `request_with_response` | any | + +## Details + +**Signature:** each method requires `response_model` (keyword-only) and returns +`tuple[httpx2.Response, T]`. All other kwargs (`params`, `headers`, `json`, +`content`, `timeout`, …) pass through to `httpx2` unchanged — identical to the +non-`_with_response` siblings. + +**Use case:** response metadata alongside a typed body — Link-header pagination, +ETag caching, rate-limit reads (`X-RateLimit-Remaining`), request URL logging on +redirect. When you don't need the raw response, the existing `get` / `post` / +… methods remain the preferred form. + +**Scope:** no `head_with_response` or `options_with_response` — HEAD is +bodiless and OPTIONS is rarely decoded. `request_with_response(method, url, …)` +is the escape hatch for any other verb. + +**Inherited behavior:** `MissingDecoderError` is raised *before* the HTTP call +when no decoder claims the model type; `DecodeError` wraps a failure inside +`decode()`. Both propagate unchanged through the new methods, identical to +`send_with_response`. + +## Shipped via + +PR #68 — per-verb `*_with_response` siblings on `AsyncClient` and `Client` +(`src/httpware/client.py`). diff --git a/src/httpware/client.py b/src/httpware/client.py index cf33ad0..fd5c066 100644 --- a/src/httpware/client.py +++ b/src/httpware/client.py @@ -228,7 +228,7 @@ def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.R """Delegate request construction to the wrapped httpx2.AsyncClient.""" return self._httpx2_client.build_request(method, url, **kwargs) - async def _request_with_body( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural + def _prepare_request( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural self, method: str, url: str, @@ -242,8 +242,7 @@ async def _request_with_body( # noqa: PLR0913, C901 — mirrors httpx2 per-meth content: typing.Any | None = None, data: typing.Any | None = None, files: typing.Any | None = None, - response_model: type[T] | None = None, - ) -> httpx2.Response | T: + ) -> httpx2.Request: kwargs: dict[str, typing.Any] = {} if params is not None: kwargs["params"] = params @@ -266,8 +265,70 @@ async def _request_with_body( # noqa: PLR0913, C901 — mirrors httpx2 per-meth request = self._httpx2_client.build_request(method, url, **kwargs) if _is_streaming_body_async(content) or _is_streaming_body_async(data) or _is_streaming_body_async(files): request.extensions[STREAMING_BODY_MARKER] = True + return request + + async def _request_with_body( # noqa: PLR0913 — mirrors httpx2 per-method signatures + self, + method: str, + url: str, + *, + params: typing.Any | None = None, + headers: typing.Any | None = None, + cookies: typing.Any | None = None, + timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, + extensions: typing.Any | None = None, + json: typing.Any | None = None, + content: typing.Any | None = None, + data: typing.Any | None = None, + files: typing.Any | None = None, + response_model: type[T] | None = None, + ) -> httpx2.Response | T: + request = self._prepare_request( + method, + url, + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + extensions=extensions, + json=json, + content=content, + data=data, + files=files, + ) return await self.send(request, response_model=response_model) + async def _request_with_body_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + self, + method: str, + url: str, + *, + params: typing.Any | None = None, + headers: typing.Any | None = None, + cookies: typing.Any | None = None, + timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, + extensions: typing.Any | None = None, + json: typing.Any | None = None, + content: typing.Any | None = None, + data: typing.Any | None = None, + files: typing.Any | None = None, + response_model: type[T], + ) -> tuple[httpx2.Response, T]: + request = self._prepare_request( + method, + url, + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + extensions=extensions, + json=json, + content=content, + data=data, + files=files, + ) + return await self.send_with_response(request, response_model=response_model) + @typing.overload async def get( self, @@ -317,6 +378,29 @@ async def get( # noqa: PLR0913 — mirrors httpx2 per-method signatures response_model=response_model, ) + async def get_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + self, + url: str, + *, + params: typing.Any | None = None, + headers: typing.Any | None = None, + cookies: typing.Any | None = None, + timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, + extensions: typing.Any | None = None, + response_model: type[T], + ) -> tuple[httpx2.Response, T]: + """Send a GET request; return (response, decoded body).""" + return await self._request_with_body_with_response( + "GET", + url, + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + extensions=extensions, + response_model=response_model, + ) + @typing.overload async def post( self, @@ -382,6 +466,37 @@ async def post( # noqa: PLR0913 — mirrors httpx2 per-method signatures response_model=response_model, ) + async def post_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + self, + url: str, + *, + params: typing.Any | None = None, + headers: typing.Any | None = None, + cookies: typing.Any | None = None, + timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, + extensions: typing.Any | None = None, + json: typing.Any | None = None, + content: typing.Any | None = None, + data: typing.Any | None = None, + files: typing.Any | None = None, + response_model: type[T], + ) -> tuple[httpx2.Response, T]: + """Send a POST request; return (response, decoded body).""" + return await self._request_with_body_with_response( + "POST", + url, + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + extensions=extensions, + json=json, + content=content, + data=data, + files=files, + response_model=response_model, + ) + @typing.overload async def put( self, @@ -447,6 +562,37 @@ async def put( # noqa: PLR0913 — mirrors httpx2 per-method signatures response_model=response_model, ) + async def put_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + self, + url: str, + *, + params: typing.Any | None = None, + headers: typing.Any | None = None, + cookies: typing.Any | None = None, + timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, + extensions: typing.Any | None = None, + json: typing.Any | None = None, + content: typing.Any | None = None, + data: typing.Any | None = None, + files: typing.Any | None = None, + response_model: type[T], + ) -> tuple[httpx2.Response, T]: + """Send a PUT request; return (response, decoded body).""" + return await self._request_with_body_with_response( + "PUT", + url, + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + extensions=extensions, + json=json, + content=content, + data=data, + files=files, + response_model=response_model, + ) + @typing.overload async def patch( self, @@ -512,6 +658,37 @@ async def patch( # noqa: PLR0913 — mirrors httpx2 per-method signatures response_model=response_model, ) + async def patch_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + self, + url: str, + *, + params: typing.Any | None = None, + headers: typing.Any | None = None, + cookies: typing.Any | None = None, + timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, + extensions: typing.Any | None = None, + json: typing.Any | None = None, + content: typing.Any | None = None, + data: typing.Any | None = None, + files: typing.Any | None = None, + response_model: type[T], + ) -> tuple[httpx2.Response, T]: + """Send a PATCH request; return (response, decoded body).""" + return await self._request_with_body_with_response( + "PATCH", + url, + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + extensions=extensions, + json=json, + content=content, + data=data, + files=files, + response_model=response_model, + ) + @typing.overload async def delete( self, @@ -577,6 +754,37 @@ async def delete( # noqa: PLR0913 — mirrors httpx2 per-method signatures response_model=response_model, ) + async def delete_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + self, + url: str, + *, + params: typing.Any | None = None, + headers: typing.Any | None = None, + cookies: typing.Any | None = None, + timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, + extensions: typing.Any | None = None, + json: typing.Any | None = None, + content: typing.Any | None = None, + data: typing.Any | None = None, + files: typing.Any | None = None, + response_model: type[T], + ) -> tuple[httpx2.Response, T]: + """Send a DELETE request; return (response, decoded body).""" + return await self._request_with_body_with_response( + "DELETE", + url, + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + extensions=extensions, + json=json, + content=content, + data=data, + files=files, + response_model=response_model, + ) + @typing.overload async def head( self, @@ -743,6 +951,38 @@ async def request( # noqa: PLR0913 — mirrors httpx2 per-method signatures response_model=response_model, ) + async def request_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + self, + method: str, + url: str, + *, + params: typing.Any | None = None, + headers: typing.Any | None = None, + cookies: typing.Any | None = None, + timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, + extensions: typing.Any | None = None, + json: typing.Any | None = None, + content: typing.Any | None = None, + data: typing.Any | None = None, + files: typing.Any | None = None, + response_model: type[T], + ) -> tuple[httpx2.Response, T]: + """Send a request with an explicit method; return (response, decoded body).""" + return await self._request_with_body_with_response( + method, + url, + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + extensions=extensions, + json=json, + content=content, + data=data, + files=files, + response_model=response_model, + ) + @contextlib.asynccontextmanager async def stream( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural self, @@ -1003,7 +1243,7 @@ def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.R """Delegate request construction to the wrapped httpx2.Client.""" return self._httpx2_client.build_request(method, url, **kwargs) - def _request_with_body( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural + def _prepare_request( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural self, method: str, url: str, @@ -1017,8 +1257,7 @@ def _request_with_body( # noqa: PLR0913, C901 — mirrors httpx2 per-method sig content: typing.Any | None = None, data: typing.Any | None = None, files: typing.Any | None = None, - response_model: type[T] | None = None, - ) -> httpx2.Response | T: + ) -> httpx2.Request: kwargs: dict[str, typing.Any] = {} if params is not None: kwargs["params"] = params @@ -1041,8 +1280,70 @@ def _request_with_body( # noqa: PLR0913, C901 — mirrors httpx2 per-method sig request = self._httpx2_client.build_request(method, url, **kwargs) if _is_streaming_body_sync(content) or _is_streaming_body_sync(data) or _is_streaming_body_sync(files): request.extensions[STREAMING_BODY_MARKER] = True + return request + + def _request_with_body( # noqa: PLR0913 — mirrors httpx2 per-method signatures + self, + method: str, + url: str, + *, + params: typing.Any | None = None, + headers: typing.Any | None = None, + cookies: typing.Any | None = None, + timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, + extensions: typing.Any | None = None, + json: typing.Any | None = None, + content: typing.Any | None = None, + data: typing.Any | None = None, + files: typing.Any | None = None, + response_model: type[T] | None = None, + ) -> httpx2.Response | T: + request = self._prepare_request( + method, + url, + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + extensions=extensions, + json=json, + content=content, + data=data, + files=files, + ) return self.send(request, response_model=response_model) + def _request_with_body_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + self, + method: str, + url: str, + *, + params: typing.Any | None = None, + headers: typing.Any | None = None, + cookies: typing.Any | None = None, + timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, + extensions: typing.Any | None = None, + json: typing.Any | None = None, + content: typing.Any | None = None, + data: typing.Any | None = None, + files: typing.Any | None = None, + response_model: type[T], + ) -> tuple[httpx2.Response, T]: + request = self._prepare_request( + method, + url, + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + extensions=extensions, + json=json, + content=content, + data=data, + files=files, + ) + return self.send_with_response(request, response_model=response_model) + @typing.overload def get( self, @@ -1092,6 +1393,29 @@ def get( # noqa: PLR0913 — mirrors httpx2 per-method signatures response_model=response_model, ) + def get_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + self, + url: str, + *, + params: typing.Any | None = None, + headers: typing.Any | None = None, + cookies: typing.Any | None = None, + timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, + extensions: typing.Any | None = None, + response_model: type[T], + ) -> tuple[httpx2.Response, T]: + """Send a GET request; return (response, decoded body).""" + return self._request_with_body_with_response( + "GET", + url, + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + extensions=extensions, + response_model=response_model, + ) + @typing.overload def post( self, @@ -1157,6 +1481,37 @@ def post( # noqa: PLR0913 — mirrors httpx2 per-method signatures response_model=response_model, ) + def post_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + self, + url: str, + *, + params: typing.Any | None = None, + headers: typing.Any | None = None, + cookies: typing.Any | None = None, + timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, + extensions: typing.Any | None = None, + json: typing.Any | None = None, + content: typing.Any | None = None, + data: typing.Any | None = None, + files: typing.Any | None = None, + response_model: type[T], + ) -> tuple[httpx2.Response, T]: + """Send a POST request; return (response, decoded body).""" + return self._request_with_body_with_response( + "POST", + url, + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + extensions=extensions, + json=json, + content=content, + data=data, + files=files, + response_model=response_model, + ) + @typing.overload def put( self, @@ -1222,6 +1577,37 @@ def put( # noqa: PLR0913 — mirrors httpx2 per-method signatures response_model=response_model, ) + def put_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + self, + url: str, + *, + params: typing.Any | None = None, + headers: typing.Any | None = None, + cookies: typing.Any | None = None, + timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, + extensions: typing.Any | None = None, + json: typing.Any | None = None, + content: typing.Any | None = None, + data: typing.Any | None = None, + files: typing.Any | None = None, + response_model: type[T], + ) -> tuple[httpx2.Response, T]: + """Send a PUT request; return (response, decoded body).""" + return self._request_with_body_with_response( + "PUT", + url, + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + extensions=extensions, + json=json, + content=content, + data=data, + files=files, + response_model=response_model, + ) + @typing.overload def patch( self, @@ -1287,6 +1673,37 @@ def patch( # noqa: PLR0913 — mirrors httpx2 per-method signatures response_model=response_model, ) + def patch_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + self, + url: str, + *, + params: typing.Any | None = None, + headers: typing.Any | None = None, + cookies: typing.Any | None = None, + timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, + extensions: typing.Any | None = None, + json: typing.Any | None = None, + content: typing.Any | None = None, + data: typing.Any | None = None, + files: typing.Any | None = None, + response_model: type[T], + ) -> tuple[httpx2.Response, T]: + """Send a PATCH request; return (response, decoded body).""" + return self._request_with_body_with_response( + "PATCH", + url, + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + extensions=extensions, + json=json, + content=content, + data=data, + files=files, + response_model=response_model, + ) + @typing.overload def delete( self, @@ -1352,6 +1769,37 @@ def delete( # noqa: PLR0913 — mirrors httpx2 per-method signatures response_model=response_model, ) + def delete_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + self, + url: str, + *, + params: typing.Any | None = None, + headers: typing.Any | None = None, + cookies: typing.Any | None = None, + timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, + extensions: typing.Any | None = None, + json: typing.Any | None = None, + content: typing.Any | None = None, + data: typing.Any | None = None, + files: typing.Any | None = None, + response_model: type[T], + ) -> tuple[httpx2.Response, T]: + """Send a DELETE request; return (response, decoded body).""" + return self._request_with_body_with_response( + "DELETE", + url, + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + extensions=extensions, + json=json, + content=content, + data=data, + files=files, + response_model=response_model, + ) + @typing.overload def head( self, @@ -1518,6 +1966,38 @@ def request( # noqa: PLR0913 — mirrors httpx2 per-method signatures response_model=response_model, ) + def request_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures + self, + method: str, + url: str, + *, + params: typing.Any | None = None, + headers: typing.Any | None = None, + cookies: typing.Any | None = None, + timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, + extensions: typing.Any | None = None, + json: typing.Any | None = None, + content: typing.Any | None = None, + data: typing.Any | None = None, + files: typing.Any | None = None, + response_model: type[T], + ) -> tuple[httpx2.Response, T]: + """Send a request with an explicit method; return (response, decoded body).""" + return self._request_with_body_with_response( + method, + url, + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + extensions=extensions, + json=json, + content=content, + data=data, + files=files, + response_model=response_model, + ) + @contextlib.contextmanager def stream( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural self, diff --git a/tests/test_client_per_verb_with_response.py b/tests/test_client_per_verb_with_response.py new file mode 100644 index 0000000..5c040ff --- /dev/null +++ b/tests/test_client_per_verb_with_response.py @@ -0,0 +1,85 @@ +"""Per-verb *_with_response siblings on AsyncClient — (response, decoded) pairs.""" + +from http import HTTPStatus + +import httpx2 +import pydantic +import pytest + +from httpware import AsyncClient, DecodeError, MissingDecoderError + + +class _User(pydantic.BaseModel): + id: int + name: str + + +def _echo_client( + payload: bytes = b'{"id": 1, "name": "ada"}', + *, + headers: dict[str, str] | None = None, +) -> tuple[AsyncClient, list[httpx2.Request]]: + recorded: list[httpx2.Request] = [] + response_headers = {"content-type": "application/json"} + if headers is not None: + response_headers.update(headers) + + def handler(request: httpx2.Request) -> httpx2.Response: + recorded.append(request) + return httpx2.Response(HTTPStatus.OK, content=payload, headers=response_headers, request=request) + + client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler))) + return client, recorded + + +@pytest.mark.parametrize( + ("verb", "expected_method"), + [("get", "GET"), ("post", "POST"), ("put", "PUT"), ("patch", "PATCH"), ("delete", "DELETE")], +) +async def test_verb_with_response_returns_pair_and_sends_right_method(verb: str, expected_method: str) -> None: + client, recorded = _echo_client() + method = getattr(client, f"{verb}_with_response") + response, user = await method("https://example.test/u", response_model=_User) + assert isinstance(response, httpx2.Response) + assert user == _User(id=1, name="ada") + assert recorded[0].method == expected_method + + +async def test_request_with_response_returns_pair() -> None: + client, recorded = _echo_client() + response, user = await client.request_with_response("GET", "https://example.test/u", response_model=_User) + assert isinstance(response, httpx2.Response) + assert user == _User(id=1, name="ada") + assert recorded[0].method == "GET" + + +async def test_get_with_response_preserves_headers() -> None: + client, _ = _echo_client(headers={"link": '; rel="next"'}) + response, _user = await client.get_with_response("https://example.test/u", response_model=_User) + assert response.headers.get("link") == '; rel="next"' + + +async def test_post_with_response_forwards_json_body() -> None: + client, recorded = _echo_client() + await client.post_with_response("https://example.test/u", json={"name": "ada"}, response_model=_User) + assert recorded[0].content == b'{"name":"ada"}' + + +async def test_with_response_decode_failure_raises_decode_error() -> None: + client, _ = _echo_client(payload=b"null") + with pytest.raises(DecodeError) as exc_info: + await client.get_with_response("https://example.test/u", response_model=_User) + assert exc_info.value.model is _User + + +async def test_with_response_missing_decoder_before_http_call() -> None: + def handler(_: httpx2.Request) -> httpx2.Response: # pragma: no cover + pytest.fail("transport should not be invoked when MissingDecoderError fires") + + client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), decoders=[]) + + class _Foo: + pass + + with pytest.raises(MissingDecoderError): + await client.get_with_response("https://example.test/x", response_model=_Foo) diff --git a/tests/test_client_per_verb_with_response_sync.py b/tests/test_client_per_verb_with_response_sync.py new file mode 100644 index 0000000..5bd9ab5 --- /dev/null +++ b/tests/test_client_per_verb_with_response_sync.py @@ -0,0 +1,85 @@ +"""Per-verb *_with_response siblings on Client — (response, decoded) pairs.""" + +from http import HTTPStatus + +import httpx2 +import pydantic +import pytest + +from httpware import Client, DecodeError, MissingDecoderError + + +class _User(pydantic.BaseModel): + id: int + name: str + + +def _echo_client( + payload: bytes = b'{"id": 1, "name": "ada"}', + *, + headers: dict[str, str] | None = None, +) -> tuple[Client, list[httpx2.Request]]: + recorded: list[httpx2.Request] = [] + response_headers = {"content-type": "application/json"} + if headers is not None: + response_headers.update(headers) + + def handler(request: httpx2.Request) -> httpx2.Response: + recorded.append(request) + return httpx2.Response(HTTPStatus.OK, content=payload, headers=response_headers, request=request) + + client = Client(httpx2_client=httpx2.Client(transport=httpx2.MockTransport(handler))) + return client, recorded + + +@pytest.mark.parametrize( + ("verb", "expected_method"), + [("get", "GET"), ("post", "POST"), ("put", "PUT"), ("patch", "PATCH"), ("delete", "DELETE")], +) +def test_verb_with_response_returns_pair_and_sends_right_method(verb: str, expected_method: str) -> None: + client, recorded = _echo_client() + method = getattr(client, f"{verb}_with_response") + response, user = method("https://example.test/u", response_model=_User) + assert isinstance(response, httpx2.Response) + assert user == _User(id=1, name="ada") + assert recorded[0].method == expected_method + + +def test_request_with_response_returns_pair() -> None: + client, recorded = _echo_client() + response, user = client.request_with_response("GET", "https://example.test/u", response_model=_User) + assert isinstance(response, httpx2.Response) + assert user == _User(id=1, name="ada") + assert recorded[0].method == "GET" + + +def test_get_with_response_preserves_headers() -> None: + client, _ = _echo_client(headers={"link": '; rel="next"'}) + response, _user = client.get_with_response("https://example.test/u", response_model=_User) + assert response.headers.get("link") == '; rel="next"' + + +def test_post_with_response_forwards_json_body() -> None: + client, recorded = _echo_client() + client.post_with_response("https://example.test/u", json={"name": "ada"}, response_model=_User) + assert recorded[0].content == b'{"name":"ada"}' + + +def test_with_response_decode_failure_raises_decode_error() -> None: + client, _ = _echo_client(payload=b"null") + with pytest.raises(DecodeError) as exc_info: + client.get_with_response("https://example.test/u", response_model=_User) + assert exc_info.value.model is _User + + +def test_with_response_missing_decoder_before_http_call() -> None: + def handler(_: httpx2.Request) -> httpx2.Response: # pragma: no cover + pytest.fail("transport should not be invoked when MissingDecoderError fires") + + client = Client(httpx2_client=httpx2.Client(transport=httpx2.MockTransport(handler)), decoders=[]) + + class _Foo: + pass + + with pytest.raises(MissingDecoderError): + client.get_with_response("https://example.test/x", response_model=_Foo) diff --git a/tests/test_client_typing.py b/tests/test_client_typing.py index 12f36cc..6f5d654 100644 --- a/tests/test_client_typing.py +++ b/tests/test_client_typing.py @@ -1,9 +1,13 @@ """Static-typing tests for AsyncClient and Client overloads. -These assert overload selection at runtime via isinstance checks. ty/mypy -catches the static-typing variant during `just lint`. +Assertions come in two forms: +- Runtime ``isinstance`` checks that verify the overload selected at call + time (send / send_with_response with and without response_model). +- Static ``typing.assert_type`` calls that let ty/mypy verify the inferred + return type during ``just lint``. """ +import typing from http import HTTPStatus import httpx2 @@ -92,3 +96,32 @@ def test_sync_send_with_response_model_returns_typed() -> None: client = Client(httpx2_client=httpx2.Client(transport=transport)) result = client.send(httpx2.Request("GET", "https://example.test/x"), response_model=_User) assert isinstance(result, _User) + + +# --------------------------------------------------------------------------- +# *_with_response static-type assertions — validates tuple[Response, T] destructuring +# --------------------------------------------------------------------------- + + +async def test_get_with_response_return_type() -> None: + transport = httpx2.MockTransport( + lambda req: httpx2.Response(HTTPStatus.OK, request=req, json={"id": 1, "name": "a"}) + ) + client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) + response, user = await client.get_with_response("https://example.test/x", response_model=_User) + typing.assert_type(response, httpx2.Response) + typing.assert_type(user, _User) + assert isinstance(response, httpx2.Response) + assert isinstance(user, _User) + + +def test_sync_get_with_response_return_type() -> None: + transport = httpx2.MockTransport( + lambda req: httpx2.Response(HTTPStatus.OK, request=req, json={"id": 1, "name": "a"}) + ) + client = Client(httpx2_client=httpx2.Client(transport=transport)) + response, user = client.get_with_response("https://example.test/x", response_model=_User) + typing.assert_type(response, httpx2.Response) + typing.assert_type(user, _User) + assert isinstance(response, httpx2.Response) + assert isinstance(user, _User)