Skip to content

feat(story-1.7): AsyncClient — the v0.1.0 public surface#12

Merged
lesnik512 merged 13 commits into
mainfrom
story/1-7-asyncclient
May 31, 2026
Merged

feat(story-1.7): AsyncClient — the v0.1.0 public surface#12
lesnik512 merged 13 commits into
mainfrom
story/1-7-asyncclient

Conversation

@lesnik512
Copy link
Copy Markdown
Member

Summary

  • Adds `src/httpware/client.py` with `AsyncClient`, the central public class. Eight HTTP method shortcuts (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`, `request`) with typed `response_model` overloads validated by `ty`. Body params: `json` (auto-encoded, typed as recursive `JsonValue` alias instead of `Any`) and `content` (raw bytes); mutually exclusive. Per-call overrides: `headers`, `params`, `cookies`, `timeout`.
  • httpx-style prefix join for `base_url` + path; absolute URLs bypass.
  • Middleware composition via `compose()` at construction (Story 2-1). The composed chain is stored as `self._dispatch`.
  • Lifecycle: original AsyncClient owns the transport and closes it on `aexit`. Views from `with_options(...)` share the transport and are no-ops on close. Simpler than the archived Decision-9 ref-counting model; ref-counting can be added later without breaking the public API.
  • `from_url(base_url, **kwargs)` classmethod factory.
  • `ClientConfig` extended with `decoder` and `middleware` fields (backwards-compatible — both have defaults).
  • 43 new tests across six test files; 251 total passing.
  • Lint discipline (per user feedback): `json: Any` refactored to `JsonValue` recursive type alias (no suppression); `**kwargs: object` for forwarded args; `pylint.max-args = 10` globally (HTTP APIs are naturally kwarg-rich); single per-file-ignore for `ASYNC109` on `client.py` (24+ HTTP-method `timeout=` parameters; rule's intent doesn't apply).

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

  • `just test` — 251 passed, 1 deselected, 100% line coverage on the new and extended source files.
  • `just lint-ci` clean.
  • `tests/test_no_httpx2_leakage.py` still passes — no `httpx2` import in `client.py`.
  • `tests/test_optional_extras_isolation.py` still passes.
  • `tests/test_client_typing.py` — `ty` validates the typed overload narrowing across `get`, `post`, and `request`.
  • CI green on all matrix entries (3.11/3.12/3.13/3.14 + lint).

🤖 Generated with Claude Code

lesnik512 and others added 13 commits May 31, 2026 21:18
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>
@lesnik512 lesnik512 merged commit ffdc25f into main May 31, 2026
5 checks passed
@lesnik512 lesnik512 deleted the story/1-7-asyncclient branch May 31, 2026 19:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant