feat(story-1.7): AsyncClient — the v0.1.0 public surface#12
Merged
Conversation
The v0.1.0 entry point of httpware: a single AsyncClient class wiring together the substrate from Stories 1-2 through 1-6 plus the middleware infrastructure from 2-1/2-2/2-3. Pragmatic scope: middleware wired via compose(); auth=, data=, files=, transport ref-counting, and streaming all deferred. Decisions: - src/httpware/client.py is the new module. - Keyword-only __init__ with sensible defaults (Httpx2Transport, PydanticDecoder, empty middleware). - 8 HTTP methods (get/post/put/patch/delete/head/options/request) with @overload-based response_model typing — ty validates. - httpx-style prefix join for base_url; per-call params override defaults. - ClientConfig extended with decoder + middleware fields (backwards- compatible). - Lifecycle: original owns transport, views from with_options are no-op on close (simpler than archive Decision 9; ref-counting deferred). - with_options accepts a keyword allowlist; limits/transport excluded. ~38 tests across six new test files. CHANGELOG bullet documents the public surface plus the explicit out-of-scope items. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds two new fields to ClientConfig: - decoder: ResponseDecoder (default: PydanticDecoder()) - middleware: tuple[Middleware, ...] (default: ()) Both fields have defaults so existing construction paths are unchanged. The PydanticDecoder default factory introduces a constructor-time dependency from config.py on decoders/pydantic.py — acceptable since pydantic is a hard dep. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds src/httpware/client.py with the AsyncClient skeleton: - keyword-only __init__ resolving defaults for transport (Httpx2Transport), decoder (PydanticDecoder), middleware (()), timeout (Timeout()), and limits (Limits()) - _normalize_timeout helper for float→Timeout coercion - _build_body helper for the upcoming HTTP method shortcuts - _UNSET sentinel for the upcoming with_options method - from_url classmethod factory - middleware chain composed via compose() at construction; result stored in self._dispatch - _owns_transport flag set to True (views from with_options will set False) No HTTP methods yet (Task 3). Construction is side-effect-free — Httpx2Transport's lazy init means no httpx2.AsyncClient() is created until the first request. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the eight public HTTP method shortcuts plus the helpers they share: - _resolve_url for httpx-style base_url prefix join - _build_request for default+per-call header/param merging - _send for the dispatch + optional decode wiring - 8 methods × 2 @overload declarations each (None vs type[T] for the response_model parameter) — total 16 overload stubs - get/head/options/delete take no body params; post/put/patch add json and content (mutually exclusive — TypeError if both) - request takes a leading positional method parameter Lint-rule alignment: - json: Any is refactored to a recursive `JsonValue` type alias rather than suppressed - **kwargs: object on from_url (forwards heterogeneous args, doesn't read them) — no `Any` needed - _UNSET sentinel typed `object` instead of `Any` - `import typing` + `typing.Any` for the genuinely-heterogeneous internal `dict[str, typing.Any]` in extensions - ASYNC109 suppressed per-line on each `timeout: Timeout | float | None` parameter with a justification comment (HTTP-method config-value timeout is not asyncio.timeout territory) — global ignore avoided so future code that should use asyncio.timeout still gets caught - pylint.max-args raised to 10 globally (HTTP API methods naturally have many kwargs) 18 tests cover: URL resolution (relative, absolute, no base_url), default merging (headers, params), body resolution (json→serialized, content→passthrough, both→TypeError), per-call Content-Type override, and one parametrized test per method confirming the wire-method string. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ignore The previous commit suppressed ASYNC109 per-line on each of the 24 HTTP method signatures plus _send — same justification 25 times. Replaced with a single per-file-ignores entry in pyproject.toml that centralizes the "HTTP-method config-value timeout is not asyncio.timeout territory" justification in one comment. Same scope of suppression, less noise. Future code in other files that has `async def f(..., timeout=...)` without justification still gets flagged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three tests verify the decoder wiring established in Task 3: - response_model=None returns the raw Response (no decoder call) - response_model=Foo invokes the configured decoder and returns Foo - a user-supplied decoder= overrides the default PydanticDecoder No production code changes; the plumbing was implemented as part of the HTTP method shortcuts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds tests/test_client_typing.py with typed assignments that exercise each overload variant on get, post, and request. Wrong @overload declarations would cause ty to reject the assignments. Runs as part of `just lint-ci`'s ty check pass. Includes a one-line runtime test so coverage sees the file is reachable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds __aenter__ (returns self) and __aexit__ (calls transport.aclose() if self._owns_transport). Three tests pass: aenter returns self, the context manager closes the transport on exit, and double-close is safe (Httpx2Transport.aclose is idempotent). The view test (test_view_async_with_does_not_close_transport) stays failing until Task 7 implements with_options. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds with_options(...) returning a new AsyncClient sharing the same transport with selected config overrides. Uses the _UNSET sentinel so None is a valid override value distinct from "not specified". Adds _from_view classmethod that bypasses __init__ to construct a view client (sets _owns_transport=False so __aexit__ is a no-op). The view's middleware chain is re-composed against the shared transport. with_options allowlist: base_url, default_headers, default_query, timeout, decoder, middleware. limits and transport are intentionally NOT overridable (both bind to the transport, which is shared). Five new wiring tests cover: middleware execution per request, view re- composes with new middleware, view inherits parent middleware when unset, view shares the transport reference, view _owns_transport is False. The previously-failing lifecycle test (view __aexit__ no-op) now passes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Side effects of raising pylint.max-args = 10 globally: - errors.py: two existing `# noqa: PLR0913` are now redundant (function signatures with 6-7 args are under the new threshold). - test_client_response_model.py: `# ty: ignore[unknown-argument]` was defensive and turned out unused with the actual pydantic model used. Also runs ruff format on client.py and test_client_methods.py. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…imeout Adds tests for the previously-uncovered with_options override paths (base_url, default_headers, default_query, decoder) plus a per-call timeout test that verifies `timeout=` propagates into the Request's extensions dict. Brings src/httpware/client.py to 100% line coverage, addressing the final review's coverage concern. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Out of scope and deferred: `auth=` (Story 2-4), `data=`/`files=` body params, transport reference-counting, streaming (Epic 4), observability (Epic 5).
Spec + plan: `docs/superpowers/specs/2026-05-31-asyncclient-design.md`, `docs/superpowers/plans/2026-05-31-asyncclient-plan.md`.
Test plan
🤖 Generated with Claude Code