diff --git a/CLAUDE.md b/CLAUDE.md index a7a0375..54dd327 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,11 +4,11 @@ Guidance for AI agents (Claude Code, etc.) working in this repository. ## Project Overview -`httpware` is a Python async HTTP client framework for building resilient service clients. It supersedes `community-of-python/base-client` and ships under the `modern-python` org. The framework owns the abstraction layer above the underlying HTTP client (`httpx2` by default); consumers never import the transport. +`httpware` is a Python async HTTP client framework for building resilient service clients. It supersedes `community-of-python/base-client` and ships under the `modern-python` org. The framework is a thin opinionated wrapper around `httpx2`: it re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain, typed response decoding, and a status-keyed exception tree raised automatically on 4xx/5xx. **Where to find what:** -- [`docs/dev/engineering.md`](docs/dev/engineering.md) — the distilled design reference: invariants and *why*, the five protocol seams, exception contract, module layout, testing patterns, optional-extras pattern, remaining roadmap. Read this before adding any new module or extension point. +- [`planning/engineering.md`](planning/engineering.md) — the distilled design reference: invariants and *why*, the three protocol seams, exception contract, module layout, testing patterns, optional-extras pattern, remaining roadmap. Read this before adding any new module or extension point. - [`planning/deferred-work.md`](planning/deferred-work.md) — review-surfaced items that are real but not actionable now. - [`planning/specs/`](planning/specs/) and [`planning/plans/`](planning/plans/) — per-feature design specs and implementation plans (active work). @@ -44,8 +44,7 @@ uv run pytest These are non-negotiable. CI rejects PRs that violate them. -- **No `httpx2` leakage**: `import httpx2` / `from httpx2` is allowed ONLY inside `src/httpware/transports/httpx2.py`. The mapping of `httpx2` exceptions to `httpware` exceptions happens at that single seam. -- **No `httpx2` private API**: `grep -rE 'httpx2\._' src/httpware/` must return zero matches. +- **No `httpx2` private API**: `grep -rE 'httpx2\._' src/httpware/` must return zero matches. Public symbols only. - **No `from __future__ import annotations`**: Python 3.11+ floor; PEP 604/585 syntax is native. - **No `print()`**: enforced by ruff. - **No global logging config**: no `logging.basicConfig()`, no bare `logging.getLogger()`. Acquire `logging.getLogger("httpware")` or `logging.getLogger(f"httpware.{module}")` only. @@ -53,50 +52,42 @@ These are non-negotiable. CI rejects PRs that violate them. ## Code conventions -- **Modules**: `snake_case` (`client.py`, `request.py`, `transports/httpx2.py`). -- **Classes**: `PascalCase`. `Http` is two letters: `Httpx2Transport`, not `HTTPX2Transport`. +- **Modules**: `snake_case` (`client.py`, `errors.py`, `middleware/chain.py`). +- **Classes**: `PascalCase`. `Http` is two letters: `AsyncClient`, not `ASYNCClient`. - **Methods**: `snake_case`. No `a` prefix on async methods (match `httpx2`); `aclose()` is the sole exception. - **Private symbols**: `_leading_underscore`. Cross-module private code lives in `_internal/`. - **Imports**: absolute paths inside `src/httpware/`; relative imports only within the same subpackage. - **Docstrings**: PEP 257. Module/class/public-method required; `D1` (missing docstring) is ignored. -- **Exception construction**: keyword arguments only. Mandatory fields: `status: int`, `body: bytes`, `headers: Mapping`, `json: Any | None`, `request_method: str`, `request_url: str`. +- **Exception construction**: status-keyed errors take a single positional `response: httpx2.Response`. Subclasses do not override `__init__`. All fields available via `exc.response.*`. ## Module layout ``` src/httpware/ ├── __init__.py # public exports + __all__ -├── client.py # AsyncClient -├── request.py # Request + with_* -├── response.py # Response, StreamResponse -├── errors.py # status-keyed exception hierarchy -├── config.py # Limits, Timeout, ClientConfig, Redactor -├── middleware/ # protocols + built-in middleware -├── transports/ # Transport protocol + Httpx2Transport + RecordedTransport -├── decoders/ # ResponseDecoder protocol + adapters +├── client.py # AsyncClient (thin wrapper over httpx2.AsyncClient) +├── errors.py # status-keyed exception hierarchy holding httpx2.Response +├── middleware/ # protocol, Next type, chain composition, phase decorators +├── decoders/ # ResponseDecoder protocol + Pydantic/msgspec adapters ├── _internal/ # private cross-module helpers └── py.typed ``` -Story 1.1 ships only the scaffold; subsequent stories add modules. - ## Protocol seams -Five documented internal boundaries. AI agents must respect them — never cross a seam except through its documented protocol. +Three documented internal boundaries. AI agents must respect them — never cross a seam except through its documented protocol. -1. **`Middleware ↔ Transport`** — chain bottom calls `transport.__call__`. -2. **`AsyncClient ↔ Middleware`** — chain composed at construction. -3. **`AsyncClient ↔ ResponseDecoder`** — called when `response_model` provided. -4. **`Httpx2Transport ↔ httpx2`** — only `transports/httpx2.py` imports `httpx2`. -5. **`httpware ↔ optional extras`** — extras imported only inside their dedicated modules. +1. **`AsyncClient ↔ Middleware`** — middleware chain composed at `AsyncClient.__init__`, frozen for the client's lifetime. Internal terminal calls `httpx2.AsyncClient.send`, maps exceptions, raises `StatusError` on 4xx/5xx. +2. **`AsyncClient ↔ ResponseDecoder`** — called when `response_model` is provided. Signature: `decode(content: bytes, model: type[T]) -> T`. +3. **`httpware ↔ optional extras`** — each opt-in dependency imported only inside its dedicated module. ## Testing - `pytest-asyncio` auto mode — async tests do NOT need `@pytest.mark.asyncio`. - Property-based tests (Hypothesis) for concurrency-sensitive code: `RetryBudget`, `Bulkhead`, retry interleaving. Files named `test_*_props.py`. -- Tests for transport-level mocking use `RecordedTransport` (shipped with the library); not `respx`. +- Tests inject `httpx2.MockTransport` via `AsyncClient(httpx2_client=httpx2.AsyncClient(transport=mock))`. No `respx`, no `RecordedTransport`. ## When in doubt -- Check [`docs/dev/engineering.md`](docs/dev/engineering.md) before adding a new module or extension point. +- Check [`planning/engineering.md`](planning/engineering.md) before adding a new module or extension point. - Surface ambiguity as a documentation gap rather than improvising. diff --git a/docs/dev/engineering.md b/docs/dev/engineering.md deleted file mode 100644 index 616390c..0000000 --- a/docs/dev/engineering.md +++ /dev/null @@ -1,185 +0,0 @@ -# `httpware` engineering notes - -This doc is the single distilled reference for `httpware` design rationale, protocol seams, and remaining roadmap. It complements `CLAUDE.md` (at the repo root): `CLAUDE.md` holds AI-enforced invariants and operational commands; this file holds the reasoning and the structural map. - -## 1. Project intent - -`httpware` is a Python async HTTP client framework for building resilient service clients. It supersedes `community-of-python/base-client` and ships under the `modern-python` org. The framework owns the abstraction layer above the underlying HTTP client (`httpx2` by default); consumers never import the transport directly. - -## 2. Architectural invariants (CI-enforced) - -These are non-negotiable. CI rejects PRs that violate them. The "why" exists so future contributors can judge edge cases instead of blindly following the rule. - -- **No `httpx2` leakage outside `src/httpware/transports/httpx2.py`.** *Why:* the whole point of the framework is to own the abstraction above the underlying client. Any consumer that imports `httpx2` directly defeats the abstraction and pins us to the current transport choice. -- **No `httpx2` private API.** *Why:* private symbols can change between patch releases. We accept the public-API surface as the contract. -- **No `from __future__ import annotations`.** *Why:* Python 3.11+ floor. PEP 604/585 syntax is native; the future-import would only add noise and inconsistency. -- **No `print()`.** *Why:* ruff-enforced. Libraries log; they do not print to stdout. Stray prints leak into consumer applications. -- **No global logging config.** *Why:* `logging.basicConfig()` from a library mutates the consumer's logging tree. We only acquire `logging.getLogger("httpware")` or namespaced child loggers and let consumers configure handlers. -- **Type suppressions use `# ty: ignore[]`.** *Why:* this project uses `ty`, not `mypy`. `# type: ignore` is silently accepted by `ty` but ambiguous; `# ty: ignore[]` is checked and rule-specific. - -## 3. The five protocol seams - -A protocol seam is a documented internal boundary. AI agents and contributors must respect it — never cross a seam except through its protocol. - -### Seam 1: `Middleware ↔ Transport` - -- **Where:** `src/httpware/middleware/` (chain) ↔ `src/httpware/transports/` (any `Transport` implementation). -- **Contract:** the chain bottom calls `transport.__call__(request) -> Response`. -- **Rule:** middleware never instantiates a transport; the `AsyncClient` injects it at construction. - -### Seam 2: `AsyncClient ↔ Middleware` - -- **Where:** `src/httpware/client.py` ↔ `src/httpware/middleware/`. -- **Contract:** the middleware chain is composed at `AsyncClient.__init__` and frozen for the client's lifetime. -- **Rule:** mutating the chain after construction is not supported. Per-request middleware is expressed via the `Request` extensions field, not by rebuilding the chain. - -### Seam 3: `AsyncClient ↔ ResponseDecoder` - -- **Where:** `src/httpware/client.py` ↔ `src/httpware/decoders/`. -- **Contract:** the decoder is invoked when the caller passes `response_model=`. The protocol is `decode(content: bytes, model: type[T]) -> T`. -- **Rule:** the decoder must operate on raw bytes in a single parse pass. Two-pass decoding (`json.loads` then `validate_python`) is rejected: a single bytes-in / typed-object-out pass avoids the redundant intermediate `dict` allocation and parses faster. The Pydantic adapter implements this as `TypeAdapter(model).validate_json(content)`, with the `TypeAdapter` itself memoized via `@functools.lru_cache(maxsize=1024)` on a module-level `_get_adapter(model)` factory (the adapter is the expensive part to build). The msgspec adapter implements it as `msgspec.json.decode(content, type=model)`. - -### Seam 4: `Httpx2Transport ↔ httpx2` - -- **Where:** `src/httpware/transports/httpx2.py` is the only file that may import `httpx2`. -- **Contract:** `httpx2` exceptions are mapped to `httpware` exceptions at this seam. -- **Rule:** CI grep checks enforce zero `httpx2` imports outside this file and zero `httpx2._` private references anywhere. - -### Seam 5: `httpware ↔ optional extras` - -- **Where:** `pyproject.toml` extras (`[project.optional-dependencies]`) ↔ the adapter modules that import them. -- **Contract:** each optional dependency is imported only inside its own dedicated module (e.g., `pydantic` in `decoders/pydantic.py`; `msgspec` in `decoders/msgspec.py` when 1-6 lands; `opentelemetry` in `middleware/observability/otel.py` when 5-4 lands). -- **Rule:** never import an extra at package top-level. The package must import cleanly when the extra is not installed. - -## 4. Exception contract - -All `httpware` HTTP exceptions are constructed with **keyword arguments only**. The mandatory fields on every `StatusError` (and its 4xx/5xx subclasses) are: - -| Field | Type | Source | -| --- | --- | --- | -| `status` | `int` | response status code | -| `body` | `bytes` | full response body | -| `headers` | `Mapping[str, str]` | lowercased response headers (v0 contract) | -| `json` | `Any \| None` | parsed JSON if `application/json` content-type; else `None` | -| `request_method` | `str` | uppercased request method | -| `request_url` | `str` | request URL, may include userinfo (Redactor sanitizes — Story 5.3) | - -The mapping table from `httpx2` errors to `httpware` errors lives at Seam 4 (`src/httpware/transports/httpx2.py`). Status-keyed exceptions are looked up via the `STATUS_TO_EXCEPTION` table in `src/httpware/errors.py`. - -Constructing any of these exceptions positionally is a programming error caught by `ty`. The keyword-only signature is enforced via `__init__` definitions, not docstrings. - -## 5. Module layout - -Current tree (post-story-1.5): - -```text -src/httpware/ -├── __init__.py # public exports + __all__ -├── py.typed -├── config.py # Limits, Timeout, ClientConfig -├── request.py # Request + with_* helpers -├── response.py # Response (StreamResponse pending in Epic 4) -├── errors.py # status-keyed exception hierarchy -├── decoders/ -│ ├── __init__.py # ResponseDecoder protocol (Seam 3) -│ └── pydantic.py # PydanticDecoder adapter -└── transports/ - ├── __init__.py # Transport protocol - └── httpx2.py # Httpx2Transport adapter (Seam 4) -``` - -Planned modules (filled in as the roadmap lands): - -```text -src/httpware/ -├── client.py # AsyncClient (Story 1.7) -├── decoders/msgspec.py # MsgspecDecoder via extra (Story 1.6) -├── transports/recorded.py # RecordedTransport for testing (Story 1.8) -├── middleware/ # protocols + built-in middleware (Epic 2) -│ ├── __init__.py # Middleware protocol, Next type -│ ├── chain.py # chain composition -│ ├── auth.py # auth coercion (Story 2.4) -│ ├── timeout.py # per-attempt timeout (Story 3.1) -│ ├── retry.py # retry + RetryBudget (Stories 3.2–3.4) -│ ├── bulkhead.py # concurrency limit (Story 3.5) -│ └── observability/ # Layer 1 emission + OTEL (Epic 5) -└── _internal/ # private cross-module helpers -``` - -## 6. Testing patterns - -- **`pytest-asyncio` auto mode.** Async test functions do not require `@pytest.mark.asyncio`. The setting lives in `pyproject.toml` under `[tool.pytest.ini_options]`. -- **`RecordedTransport` for transport mocking, not `respx`.** Once Story 1.8 lands, transport-level tests instantiate `RecordedTransport` (shipped with the library) instead of patching `httpx2` calls. This keeps tests aligned with the public seam and avoids `respx`'s private-API risk. -- **Hypothesis property-based tests** for concurrency-sensitive code: `RetryBudget`, `Bulkhead`, retry interleaving. Files are named `test_*_props.py` so they are easy to grep and treat separately in CI. -- **Performance tests are opt-in.** The `perf` pytest marker is registered in `pyproject.toml`; the default `addopts` line includes `-m 'not perf'`. Run benchmarks explicitly with `pytest -m perf`. -- **Coverage is 100% line coverage.** The five merged stories ship at 100% line coverage. New code is expected to maintain this. - -## 7. Optional-extras pattern - -`httpware` core has a small dependency set. Capabilities that pull in heavyweight dependencies (`pydantic`, `msgspec`, `opentelemetry`) live behind extras declared in `pyproject.toml`: - -```toml -[project.optional-dependencies] -pydantic = ["pydantic>=2"] -msgspec = ["msgspec>=0.18"] -otel = ["opentelemetry-api>=1.20", "opentelemetry-sdk>=1.20"] -``` - -Each extra's code lives in a single dedicated module (e.g., `decoders/pydantic.py`, `decoders/msgspec.py`, `middleware/observability/otel.py`). The `import` of the extra happens **inside** that module — never at package top level. This way, `import httpware` works cleanly without the extras installed, and the seam stays observable: grep for `import pydantic` should return exactly one file. - -Caller-facing pattern: consumers select the implementation by passing it explicitly, e.g., `AsyncClient(decoder=PydanticDecoder())`. There is no auto-detection or implicit registry. - -## 8. Remaining roadmap - -Twenty-seven stories remain. Topic slugs in `planning/specs/` and `planning/plans/` use kebab-case descriptions, not the story IDs — these IDs are retained as a stable identifier convention from the original epic structure. - -### Epic 1 — Make typed HTTP requests with sensible defaults - -- **1-6** `msgspec` decoder via extras — second `ResponseDecoder` adapter, opt-in. -- **1-7** `AsyncClient` with HTTP methods, `response_model`, `with_options`, lifecycle — the main public surface. -- **1-8** `RecordedTransport` for testing — ships with the library; replaces `respx` for transport-level tests. - -### Epic 2 — Compose request-handling logic via middleware - -- **2-1** `Middleware` protocol, `Next` type, chain composition. -- **2-2** Phase shortcut decorators (`@before_request`, `@after_response`, `@on_error`). -- **2-3** `Request` immutability helpers (`with_headers`, `with_cookie`, `with_extension`, etc.). -- **2-4** Auth coercion as middleware. -- **2-5** Wire middleware into `AsyncClient`. - -### Epic 3 — Survive upstream failures with composable resilience - -- **3-1** Per-attempt timeout middleware. -- **3-2** Retry middleware. -- **3-3** `RetryBudget` data structure. -- **3-4** `RetryBudget` middleware integration. -- **3-5** `Bulkhead` middleware. -- **3-6** Document the extension slot for custom resilience policies. - -### Epic 4 — Stream responses without buffering - -- **4-1** `StreamResponse` type. -- **4-2** Transport stream implementation in `Httpx2Transport`. -- **4-3** `AsyncClient.stream` context manager. - -### Epic 5 — Observe and instrument the client - -- **5-1** Layer 1 observability — middleware lifecycle hooks. -- **5-2** Wire emission into resilience middlewares. -- **5-3** `Redactor` class and integration (closes deferred work on URL/header/userinfo sanitization). -- **5-4** OpenTelemetry middleware via the `otel` extra. -- **5-5** Logging policy enforcement (CI grep on `logging.basicConfig`, `logging.getLogger()` without a name). - -### Epic 6 — Ship v1.0 - -- **6-1** Migration guide from `base-client`. -- **6-2** Documentation site (`mkdocs`). -- **6-3** Public benchmark suite. -- **6-4** CI enforcement gates (codify the invariants in Section 2 as CI jobs). -- **6-5** Release flow with Trusted Publishers + Sigstore. - -When work starts on a roadmap item, it gets a spec at `planning/specs/YYYY-MM-DD--design.md` and a plan at `planning/plans/YYYY-MM-DD--plan.md`. - -## 9. Deferred work - -Review-surfaced items that are real but not actionable now live in `planning/deferred-work.md` at the repository root. Each entry cites the originating story and the file/line, and explains why the fix is deferred (cross-story dependency, scope, performance/security tradeoff, etc.). When a deferred item becomes actionable, it migrates into the spec for the story that resolves it. diff --git a/planning/deferred-work.md b/planning/deferred-work.md index 667f7b1..a2bcab3 100644 --- a/planning/deferred-work.md +++ b/planning/deferred-work.md @@ -2,36 +2,32 @@ Items raised in reviews that are real but not actionable now. -## Deferred from: retrospective review of stories 1-1 through 1-5 (2026-05-31) +## Open -- **`_get_adapter` `lru_cache` is module-global, not per-decoder instance** — keyed by `model` only; two `PydanticDecoder()` instances with different configurations (none today) would share adapters, and the cache survives across tests unless explicitly cleared. Revisit if/when a configurable `PydanticDecoder(mode=..., strict=...)` lands. (`src/httpware/decoders/pydantic.py:12-14`) -- **`extensions=dict(request.extensions)` forwards opaque payloads to httpx2 verbatim** — `httpx2` interprets specific keys (e.g. `timeout`, `sni_hostname`); a typo or unknown key silently bypasses our timeout/limits config. The seam now has a real user: `AsyncClient._build_request` writes `extensions["timeout"]` (`src/httpware/client.py:140-142`). Epic 3 timeout middleware will own the extensions contract; introducing an allowlist now risks blocking legitimate forward-compat uses. (`src/httpware/transports/httpx2.py:121`) - -## Deferred from: code review of story-1-5 (2026-05-14) +### Decoder-side +- **`_get_adapter` `lru_cache` is module-global, not per-decoder instance** — keyed by `model` only; two `PydanticDecoder()` instances with different configurations (none today) would share adapters, and the cache survives across tests unless explicitly cleared. Revisit if/when a configurable `PydanticDecoder(mode=..., strict=...)` lands. (`src/httpware/decoders/pydantic.py:12-14`) - **Empty/malformed payload tests** — `b""`, `b"null"`, `b"{}"`, invalid UTF-8: current pydantic-core behavior is correct but unpinned; a future pydantic upgrade could change error types undetected. (`tests/test_decoders_pydantic.py`) -## Deferred from: code review of story-1-4 (2026-05-14) - -- **Unbounded error body size** — `StatusError.body` holds the full `resp.content` with no cap; large 5xx pages stay pinned in memory through exception lifetimes (Sentry payloads, logs, retained tracebacks). Revisit with retry/observability middleware. (`src/httpware/transports/httpx2.py:151`) -- **`httpx2.StreamError` family escape** — `StreamError` and its children (`StreamConsumed`, `StreamClosed`, `ResponseNotRead`, `RequestNotRead`) are `RuntimeError` subclasses, not `HTTPError`; not caught by the seam's `except httpx2.HTTPError`. Unreachable via the default httpx2 config (no redirects, no retries) but exploitable through user-supplied clients with retry layers. Revisit when retry middleware or streaming (Story 4.1) lands. (`src/httpware/transports/httpx2.py:127-128`) -- **Header CRLF / log-injection** — extends the existing URL CRLF deferral. `dict(request.headers)` forwards values verbatim including embedded `\r\n`; full sanitization lands with the `Redactor` middleware (Story 5.3). (`src/httpware/transports/httpx2.py:117`) -- **Userinfo on `StatusError.request_url` raw field** — `__repr__` and the exception summary strip `user:pass@`, but the field itself retains credentials, leaking through structured-logging serializers. Defense-in-depth strip is the Redactor's job (Story 5.3) per `errors.py` docstring. (`src/httpware/transports/httpx2.py:155`) -- **Concurrent `aclose()` ↔ `__call__` races** — no synchronization between in-flight `client.send` and a parallel `aclose`. `Httpx2Transport._closed` guards `_get_client`, but a `send` that already obtained the client races a concurrent `aclose`. Best case raises `RuntimeError` (caught and re-raised as `TransportError`); worst case completes on a partly-disposed pool. Broader concurrency/lifecycle design; defer to retry or dedicated lifecycle work. (`src/httpware/transports/httpx2.py:87-181`) - -## Deferred from: code review of story-1-4 (2026-05-13) - -- **URL CRLF / log-injection** — relying on httpx2's `InvalidURL` validation; explicit `Redactor`-level sanitization deferred to Story 5.3. -- **`request.method` validation beyond uppercasing** — httpx2 surfaces `LocalProtocolError` for malformed methods; no further mitigation in `transports/httpx2.py`. -- **Case-insensitive header type + multi-valued header collapse** — `Mapping[str, str]` with lowercase ASCII keys is the v0 contract. Two limitations bundled: (a) case-insensitive lookup unavailable; (b) `dict(resp.headers)` collapses duplicate-key headers like `Set-Cookie`, `Via`, `Link` to the last value only. Revisit together when header-handling middleware demands either capability — the contract widens to `Mapping[str, Sequence[str]]` (or similar) at that point. - -## Deferred from: code review of story-1-2 (2026-05-13) - -- **Multi-valued query params** — `Mapping[str, str]` cannot express `?tag=a&tag=b`. Type widening needed. (`src/httpware/request.py:16`) -- **Streaming / async-iterable request bodies** — `body: bytes | None` only. Revisit in streaming work (Story 4.1). (`src/httpware/request.py:18`) -- **`@final` to prevent subclassing** — frozen+slots subclassing is fragile. No current subclasser; defer until needed. (`src/httpware/request.py`, `response.py`, `config.py`) - -## Deferred from: code review of story-1-1 (2026-05-13) +### Tooling - **Unpinned `ruff`/`ty` with `select=["ALL"]`** — any new ruff release adds rules and can break CI overnight. Pin major versions or pin specific rules when a regression occurs. (`pyproject.toml` `[dependency-groups] lint`, `[tool.ruff.lint] select`) -- **No `[test]` extra; CI installs all extras** — `just install` runs `uv sync --all-extras --group lint`, so every CI run pulls msgspec/otel/niquests even though most tests don't need them. Declare a `test` extra (or move test-only deps into a dedicated dependency-group) and switch CI to the narrower install. Mild YAGNI today; revisit when extras grow heavier. (`pyproject.toml` `[project.optional-dependencies]`, `Justfile:install`) +- **No `[test]` extra; CI installs all extras** — `just install` runs `uv sync --all-extras --group lint`, so every CI run pulls msgspec/otel/niquests even though most tests don't need them. Declare a `test` extra (or move test-only deps into a dedicated dependency-group) and switch CI to the narrower install. (`pyproject.toml` `[project.optional-dependencies]`, `Justfile:install`) +- **`pydantic` import not guarded the way `msgspec` is** — `decoders/pydantic.py` imports `pydantic` at module top; `decoders/msgspec.py` guards via `is_msgspec_installed`. Either drop the optional-extras framing for pydantic (it is already a required dependency) or guard pydantic the same way. (`src/httpware/decoders/pydantic.py:5`, `pyproject.toml` `[project] dependencies`) + +## Closed by the v0.2 thin-wrapper pivot (2026-06-03) + +The pivot retired Request/Response/Httpx2Transport/RecordedTransport. The following deferred items are no longer applicable because their host code has been removed or because the responsibility shifted to `httpx2`: + +- `extensions=dict(request.extensions)` opaque forwarding (host module removed). +- Unbounded error body size on `StatusError.body` (the `body` field no longer exists; callers reach into `exc.response.content` themselves). +- `httpx2.StreamError` family escape from the transport's `except httpx2.HTTPError` (mapping logic relocated to AsyncClient's terminal; revisit with Epic 4 streaming work). +- Header CRLF / log-injection at the transport seam (host module removed; httpx2 validates). +- Userinfo on `StatusError.request_url` raw field (the field no longer exists; `__repr__` and summary still sanitize). +- Concurrent `aclose()` ↔ `__call__` races on `Httpx2Transport` (host class removed; lifecycle is `httpx2`'s concern). +- URL CRLF / log-injection (httpx2 owns URL validation). +- `request.method` validation beyond uppercasing (host module removed; `httpx2` owns). +- Case-insensitive header type / multi-valued header collapse (host module removed; `httpx2.Headers` already provides case-insensitive multi-valued access). +- Multi-valued query params (host module removed; `httpx2` owns). +- Streaming / async-iterable request bodies (Epic 4 lands on `httpx2.Request` directly). +- `@final` to prevent subclassing of `Request`/`Response`/`ClientConfig` (host classes removed). diff --git a/planning/engineering.md b/planning/engineering.md new file mode 100644 index 0000000..1fb1e0e --- /dev/null +++ b/planning/engineering.md @@ -0,0 +1,134 @@ +# `httpware` engineering notes + +This doc is the single distilled reference for `httpware` design rationale, protocol seams, and remaining roadmap. It complements `CLAUDE.md` (at the repo root): `CLAUDE.md` holds AI-enforced invariants and operational commands; this file holds the reasoning and the structural map. + +## 1. Project intent + +`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request` and `httpx2.Response` as the public request/response surface and adds three things on top: typed response decoding (via a `ResponseDecoder` protocol; Pydantic ships as the default, msgspec as an opt-in extra), a middleware chain composed at client construction, and a status-keyed exception tree raised automatically on 4xx and 5xx. + +The 0.1.0 release attempted to own a full abstraction over the underlying HTTP client. v0.2 walks that back: `httpx2` is part of the public surface. + +## 2. Architectural invariants (CI-enforced) + +These are non-negotiable. CI rejects PRs that violate them. The "why" exists so future contributors can judge edge cases instead of blindly following the rule. + +- **No `httpx2._` private API.** *Why:* private symbols can change between patch releases. We accept the public-API surface as the contract. +- **No `from __future__ import annotations`.** *Why:* Python 3.11+ floor. PEP 604/585 syntax is native; the future-import would only add noise and inconsistency. +- **No `print()`.** *Why:* ruff-enforced. Libraries log; they do not print to stdout. Stray prints leak into consumer applications. +- **No global logging config.** *Why:* `logging.basicConfig()` from a library mutates the consumer's logging tree. We only acquire `logging.getLogger("httpware")` or namespaced child loggers and let consumers configure handlers. +- **Type suppressions use `# ty: ignore[]`.** *Why:* this project uses `ty`, not `mypy`. `# type: ignore` is silently accepted by `ty` but ambiguous; `# ty: ignore[]` is checked and rule-specific. + +The 0.1.0 "no `httpx2` leakage outside `transports/httpx2.py`" invariant is **retired in v0.2**. Exposing `httpx2.Request`/`httpx2.Response` is the design. + +## 3. The three protocol seams + +A protocol seam is a documented internal boundary. AI agents and contributors must respect it — never cross a seam except through its protocol. + +The 0.1.0 seams numbered 1 (Middleware↔Transport) and 4 (Transport↔httpx2) have collapsed into the `AsyncClient` terminal — there is no transport abstraction in v0.2. + +### Seam A: `AsyncClient ↔ Middleware` + +- **Where:** `src/httpware/client.py` ↔ `src/httpware/middleware/`. +- **Contract:** the middleware chain is composed once at `AsyncClient.__init__` and frozen for the client's lifetime. The chain bottom (the "terminal") is internal: it calls `self._httpx2_client.send(request)`, maps `httpx2` errors to `httpware` errors, and raises a `StatusError` subclass on 4xx/5xx. +- **Rule:** mutating the chain after construction is not supported. Per-request behavior goes through `httpx2.Request.extensions` or through `extensions=` kwargs at call sites. + +### Seam B: `AsyncClient ↔ ResponseDecoder` + +- **Where:** `src/httpware/client.py` ↔ `src/httpware/decoders/`. +- **Contract:** the decoder is invoked when the caller passes `response_model=`. The protocol is `decode(content: bytes, model: type[T]) -> T`. Decoder errors (`pydantic.ValidationError`, `msgspec.ValidationError`) propagate unwrapped. +- **Rule:** the decoder must operate on raw bytes in a single parse pass. Two-pass decoding (`json.loads` then `validate_python`) is rejected: a single bytes-in / typed-object-out pass avoids the redundant intermediate `dict` allocation and parses faster. The Pydantic adapter implements this as `TypeAdapter(model).validate_json(content)`, with the `TypeAdapter` itself memoized via `@functools.lru_cache(maxsize=1024)` on a module-level `_get_adapter(model)` factory (the adapter is the expensive part to build). The msgspec adapter implements it as `msgspec.json.decode(content, type=model)`. + +### Seam C: `httpware ↔ optional extras` + +- **Where:** `pyproject.toml` extras (`[project.optional-dependencies]`) ↔ the adapter modules that import them. +- **Contract:** each optional dependency is imported only inside its own dedicated module (e.g., `pydantic` in `decoders/pydantic.py`; `msgspec` in `decoders/msgspec.py`; `opentelemetry` in `middleware/observability/otel.py` when Epic 5 lands). +- **Rule:** never import an extra at package top-level. The package must import cleanly when the extra is not installed. + +## 4. Exception contract + +`StatusError` and all its 4xx/5xx subclasses are constructed with a **single positional `response: httpx2.Response`**. Subclasses do not override `__init__`. All fields are available via `exc.response.*` (status code, headers, content, request, etc.). + +```python +raise NotFoundError(response) # correct +exc.response.status_code # 404 +exc.response.request.url # URL of the failed request +``` + +`__repr__` and the `str()` summary strip `user:pass@` userinfo from `response.request.url` to avoid leaking credentials in tracebacks. Query-string secrets are not stripped here. + +The error-mapping table (what `httpx2` exception maps to which `httpware` exception) lives at the `AsyncClient` terminal in `src/httpware/client.py`. Status-keyed exceptions are looked up via the `STATUS_TO_EXCEPTION` table in `src/httpware/errors.py`. Unknown 4xx falls back to `ClientStatusError`; unknown 5xx falls back to `ServerStatusError`. + +`TimeoutError` inherits from both `httpware.ClientError` and `builtins.TimeoutError` so `except builtins.TimeoutError` (the form `asyncio.wait_for` uses) also catches httpware-raised timeouts. + +## 5. Module layout + +Current tree (v0.2): + +```text +src/httpware/ +├── __init__.py # public exports +├── py.typed +├── client.py # AsyncClient +├── errors.py # status-keyed exception tree (response: httpx2.Response) +├── middleware/ +│ ├── __init__.py # Middleware protocol, Next type, @before_request/@after_response/@on_error +│ └── chain.py # compose(middleware, terminal) -> Next +├── decoders/ +│ ├── __init__.py # ResponseDecoder protocol +│ ├── pydantic.py # PydanticDecoder (extra: pydantic) +│ └── msgspec.py # MsgspecDecoder (extra: msgspec) +└── _internal/ + └── import_checker.py # is_msgspec_installed, is_pydantic_installed +``` + +**Deleted relative to 0.1.0:** `request.py`, `response.py`, `config.py`, `transports/` (Transport protocol + Httpx2Transport), `_internal/auth.py`, `_internal/chain.py`. The `RecordedTransport` testing helper is gone; tests inject `httpx2.MockTransport` via `httpx2_client=` instead. + +## 6. Testing patterns + +- **`pytest-asyncio` auto mode.** Async test functions do not require `@pytest.mark.asyncio`. The setting lives in `pyproject.toml` under `[tool.pytest.ini_options]`. +- **`RecordedTransport` for transport mocking, not `respx`.** Once Story 1.8 lands, transport-level tests instantiate `RecordedTransport` (shipped with the library) instead of patching `httpx2` calls. This keeps tests aligned with the public seam and avoids `respx`'s private-API risk. +- **Hypothesis property-based tests** for concurrency-sensitive code: `RetryBudget`, `Bulkhead`, retry interleaving. Files are named `test_*_props.py` so they are easy to grep and treat separately in CI. +- **Performance tests are opt-in.** The `perf` pytest marker is registered in `pyproject.toml`; the default `addopts` line includes `-m 'not perf'`. Run benchmarks explicitly with `pytest -m perf`. +- **Coverage is 100% line coverage.** The five merged stories ship at 100% line coverage. New code is expected to maintain this. + +## 7. Optional-extras pattern + +`httpware` core has a small dependency set. Capabilities that pull in heavyweight dependencies (`pydantic`, `msgspec`, `opentelemetry`) live behind extras declared in `pyproject.toml`: + +```toml +[project.optional-dependencies] +pydantic = ["pydantic>=2"] +msgspec = ["msgspec>=0.18"] +otel = ["opentelemetry-api>=1.20", "opentelemetry-sdk>=1.20"] +``` + +Each extra's code lives in a single dedicated module (e.g., `decoders/pydantic.py`, `decoders/msgspec.py`, `middleware/observability/otel.py`). The `import` of the extra happens **inside** that module — never at package top level. This way, `import httpware` works cleanly without the extras installed, and the seam stays observable: grep for `import pydantic` should return exactly one file. + +Caller-facing pattern: consumers select the implementation by passing it explicitly, e.g., `AsyncClient(decoder=PydanticDecoder())`. There is no auto-detection or implicit registry. + +## 8. Remaining roadmap + +Post-pivot, the roadmap has three categories. Topic slugs in `planning/specs/` and `planning/plans/` use kebab-case descriptions. + +### Deleted by the v0.2 pivot + +`1-8` `RecordedTransport`, `2-3` Request immutability helpers, `2-4` auth coercion middleware, `4-1` `StreamResponse` type, `4-2` transport stream implementation, `5-3` `Redactor` middleware. + +### Rewritten by the v0.2 pivot + +`1-7` `AsyncClient` (the heart of v0.2 — shipped in the pivot PR), `2-5` wire middleware into `AsyncClient` (trivially part of `1-7`), `6-1` migration guide (extended with httpware 0.1→0.2 notes), `6-4` CI invariant gates (drop the "no `httpx2` leakage" rule). + +### Surviving (land in subsequent PRs) + +- **Epic 3 — Resilience:** `3-1` per-attempt timeout, `3-2` retry, `3-3` `RetryBudget`, `3-4` `RetryBudget` middleware integration, `3-5` `Bulkhead`, `3-6` extension-slot docs. +- **Epic 4 — Streaming:** `4-3` `AsyncClient.stream` context manager (forwards to `httpx2.AsyncClient.stream`; no `StreamResponse` type). +- **Epic 5 — Observability:** `5-1` Layer 1 middleware hooks, `5-2` wire into resilience middlewares, `5-4` OpenTelemetry middleware (`otel` extra), `5-5` logging policy CI grep. +- **Epic 6 — Ship v1.0:** `6-2` docs site (`mkdocs`), `6-3` benchmarks, `6-5` release flow (Trusted Publishers + Sigstore). +- **Carry-forward decoder:** `1-6` msgspec decoder via extras — second `ResponseDecoder` adapter, already implemented; verified surviving in the pivot. +- **Middleware protocol:** `2-1` and `2-2` already implemented in the pivot (protocol, chain, phase decorators). + +When work starts on a roadmap item, it gets a spec at `planning/specs/YYYY-MM-DD--design.md` and a plan at `planning/plans/YYYY-MM-DD--plan.md`. + +## 9. Deferred work + +Review-surfaced items that are real but not actionable now live in `planning/deferred-work.md` at the repository root. Each entry cites the originating story and the file/line, and explains why the fix is deferred (cross-story dependency, scope, performance/security tradeoff, etc.). When a deferred item becomes actionable, it migrates into the spec for the story that resolves it. diff --git a/planning/plans/2026-06-03-thin-httpx2-wrapper-plan.md b/planning/plans/2026-06-03-thin-httpx2-wrapper-plan.md new file mode 100644 index 0000000..4c65333 --- /dev/null +++ b/planning/plans/2026-06-03-thin-httpx2-wrapper-plan.md @@ -0,0 +1,2523 @@ +# Thin httpx2 wrapper (v0.2 pivot) 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:** Re-cut `httpware` as a thin opinionated wrapper around `httpx2`. Drop the `Transport` protocol, custom `Request`/`Response`/`Limits`/`Timeout`/`ClientConfig` value types, `RecordedTransport`, auth coercion, and `with_options`. Keep typed decoders, middleware chain, and the status-keyed exception tree. Ship as `0.2.0`. + +**Architecture:** `AsyncClient` owns (or wraps) an `httpx2.AsyncClient`. Per-method calls delegate `build_request` to httpx2, run the request through a middleware chain composed at `__init__`, hit an internal terminal that calls `httpx2.AsyncClient.send`, maps `httpx2` exceptions to `httpware` exceptions, and raises a `StatusError` subclass on 4xx/5xx. Decoders run after the chain if `response_model=` is set. Three protocol seams remain: `AsyncClient ↔ Middleware`, `AsyncClient ↔ ResponseDecoder`, `httpware ↔ optional extras`. + +**Tech Stack:** Python 3.11+, `httpx2`, `pydantic` (default decoder), `msgspec` (opt-in via extras), `ty` (type checker), `ruff` (linter), `pytest` + `pytest-asyncio` (auto mode), `hypothesis` (property tests), `uv` (package manager), `just` (task runner). + +**Spec:** `planning/specs/2026-06-03-thin-httpx2-wrapper-design.md` + +**Scope check:** Single structural PR per spec section 13. Epic 3 (resilience), Epic 4 (streaming), Epic 5 (observability) are explicitly out of scope and land later as ordinary stories. + +--- + +## File map + +**Surviving with edits:** + +- `src/httpware/__init__.py` — exports rewritten. +- `src/httpware/decoders/__init__.py` — unchanged (protocol stays). +- `src/httpware/decoders/pydantic.py` — unchanged. +- `src/httpware/decoders/msgspec.py` — unchanged. +- `src/httpware/_internal/import_checker.py` — unchanged. +- `tests/test_decoders_pydantic.py` — unchanged. +- `tests/test_decoders_msgspec.py` — unchanged. +- `tests/test_decoders_pydantic_bench.py` — unchanged (perf marker). +- `tests/test_optional_extras_isolation.py` — unchanged. +- `tests/conftest.py` — unchanged. +- `CLAUDE.md` — rewrite invariants + module layout sections. +- `docs/dev/engineering.md` — rewrite sections 2, 3, 5, 8. +- `planning/deferred-work.md` — sweep items obsoleted by pivot. +- `pyproject.toml` — bump version, drop the `httpx2 leakage` ruff/ty notes if present. + +**New files:** + +- `src/httpware/client.py` (full rewrite — delete & re-create). +- `src/httpware/errors.py` (full rewrite — delete & re-create). +- `src/httpware/middleware/__init__.py` (full rewrite — delete & re-create). +- `src/httpware/middleware/chain.py` (new file, holds `compose`). +- `tests/test_errors.py` (full rewrite — delete & re-create). +- `tests/test_middleware.py` (full rewrite — delete & re-create). +- `tests/test_client_construction.py` (full rewrite — delete & re-create). +- `tests/test_client_lifecycle.py` (full rewrite — delete & re-create). +- `tests/test_client_methods.py` (full rewrite — delete & re-create). +- `tests/test_client_middleware_wiring.py` (full rewrite — delete & re-create). +- `tests/test_client_response_model.py` (full rewrite — delete & re-create). +- `tests/test_client_typing.py` (full rewrite — delete & re-create). +- `tests/test_public_api.py` (full rewrite — delete & re-create). +- `tests/test_error_mapping_terminal.py` (new — terminal-level error translation). + +**Deleted:** + +- `src/httpware/request.py` +- `src/httpware/response.py` +- `src/httpware/config.py` +- `src/httpware/transports/` (entire directory) +- `src/httpware/_internal/auth.py` +- `src/httpware/_internal/chain.py` +- `tests/test_request.py` +- `tests/test_response.py` +- `tests/test_config.py` +- `tests/test_transports_httpx2.py` +- `tests/test_transports_recorded.py` +- `tests/test_internal_auth.py` +- `tests/test_no_httpx2_leakage.py` + +--- + +## Task 1: Pre-flight + +**Files:** +- Inspect: working tree, current branch, baseline test status. + +- [ ] **Step 1: Verify clean working tree on a fresh branch** + +Run: +```bash +git status +git switch -c feat/v0.2-thin-httpx2-wrapper +``` +Expected: working tree is clean; new branch created off `main`. + +- [ ] **Step 2: Capture the baseline pass/fail count** + +Run: +```bash +just install +just test 2>&1 | tail -20 +``` +Expected: full suite passes (100% coverage on the existing modules). Record the totals (e.g. "147 passed"). + +This is the "before" snapshot; we will track regressions against it. + +- [ ] **Step 3: Confirm `httpx2.MockTransport` exists in the installed version** + +Run: +```bash +uv run python -c "import httpx2; print(httpx2.MockTransport)" +``` +Expected: `` (no AttributeError). If absent, stop and tell the user — the pivot needs MockTransport for the testing pattern. + +- [ ] **Step 4: Commit a marker tag for rollback** + +Run: +```bash +git tag pre-v0.2-pivot +``` +Expected: tag created at the current `HEAD` (no commit yet on the feature branch). + +--- + +## Task 2: Tear-down (one explicit deletion commit) + +**Files:** +- Delete: `src/httpware/request.py`, `src/httpware/response.py`, `src/httpware/config.py`, `src/httpware/transports/`, `src/httpware/_internal/auth.py`, `src/httpware/_internal/chain.py`. +- Delete: `tests/test_request.py`, `tests/test_response.py`, `tests/test_config.py`, `tests/test_transports_httpx2.py`, `tests/test_transports_recorded.py`, `tests/test_internal_auth.py`, `tests/test_no_httpx2_leakage.py`. +- Stub: `src/httpware/__init__.py`, `src/httpware/client.py`, `src/httpware/errors.py`, `src/httpware/middleware/__init__.py`. +- Delete: `tests/test_errors.py`, `tests/test_middleware.py`, `tests/test_client_*.py`, `tests/test_public_api.py`. + +- [ ] **Step 1: Remove the deleted files** + +Run: +```bash +git rm src/httpware/request.py +git rm src/httpware/response.py +git rm src/httpware/config.py +git rm -r src/httpware/transports +git rm src/httpware/_internal/auth.py +git rm src/httpware/_internal/chain.py +git rm tests/test_request.py tests/test_response.py tests/test_config.py +git rm tests/test_transports_httpx2.py tests/test_transports_recorded.py +git rm tests/test_internal_auth.py tests/test_no_httpx2_leakage.py +git rm tests/test_errors.py tests/test_middleware.py +git rm tests/test_client_construction.py tests/test_client_lifecycle.py +git rm tests/test_client_methods.py tests/test_client_middleware_wiring.py +git rm tests/test_client_response_model.py tests/test_client_typing.py +git rm tests/test_public_api.py +``` + +- [ ] **Step 2: Stub the surviving package files to a minimal compilable state** + +Replace `src/httpware/__init__.py` with: +```python +"""httpware — thin async HTTP client wrapper over httpx2.""" +``` + +Replace `src/httpware/client.py` with: +```python +"""AsyncClient — implemented in later tasks of the v0.2 pivot.""" +``` + +Replace `src/httpware/errors.py` with: +```python +"""Exception hierarchy — implemented in later tasks of the v0.2 pivot.""" +``` + +Replace `src/httpware/middleware/__init__.py` with: +```python +"""Middleware protocol — implemented in later tasks of the v0.2 pivot.""" +``` + +- [ ] **Step 3: Confirm decoders + extras-isolation tests still pass** + +Run: +```bash +just test tests/test_decoders_pydantic.py tests/test_decoders_msgspec.py tests/test_optional_extras_isolation.py 2>&1 | tail -10 +``` +Expected: all three files pass. (These are the only tests that survive the tear-down.) + +- [ ] **Step 4: Commit the tear-down** + +Run: +```bash +git add -A +git commit -m "refactor(v0.2): tear down 0.1 surfaces ahead of thin-wrapper rewrite + +Remove Request/Response/Config value types, Transport protocol, +Httpx2Transport, RecordedTransport, auth coercion, and the no-leakage +CI invariant. Decoders survive. New AsyncClient/errors/middleware land +in subsequent commits." +``` + +--- + +## Task 3: Errors — failing tests + +**Files:** +- Create: `tests/test_errors.py` + +- [ ] **Step 1: Write the failing test file** + +Create `tests/test_errors.py`: +```python +"""Tests for the status-keyed exception tree in httpware.errors.""" + +import builtins +import pickle + +import httpx2 +import pytest + +from httpware.errors import ( + STATUS_TO_EXCEPTION, + BadRequestError, + ClientError, + ClientStatusError, + ConflictError, + ForbiddenError, + InternalServerError, + NotFoundError, + RateLimitedError, + ServerStatusError, + ServiceUnavailableError, + StatusError, + TimeoutError, # noqa: A004 + TransportError, + UnauthorizedError, + UnprocessableEntityError, +) + + +def _make_response(status: int, *, url: str = "https://example.test/x", method: str = "GET") -> httpx2.Response: + request = httpx2.Request(method, url) + return httpx2.Response(status, request=request) + + +def test_inheritance_tree() -> None: + assert issubclass(StatusError, ClientError) + assert issubclass(TransportError, ClientError) + assert issubclass(TimeoutError, ClientError) + assert issubclass(TimeoutError, builtins.TimeoutError) + assert issubclass(ClientStatusError, StatusError) + assert issubclass(ServerStatusError, StatusError) + for exc in ( + BadRequestError, + UnauthorizedError, + ForbiddenError, + NotFoundError, + ConflictError, + UnprocessableEntityError, + RateLimitedError, + ): + assert issubclass(exc, ClientStatusError), exc + for exc in (InternalServerError, ServiceUnavailableError): + assert issubclass(exc, ServerStatusError), exc + + +def test_status_to_exception_table() -> None: + assert STATUS_TO_EXCEPTION == { + 400: BadRequestError, + 401: UnauthorizedError, + 403: ForbiddenError, + 404: NotFoundError, + 409: ConflictError, + 422: UnprocessableEntityError, + 429: RateLimitedError, + 500: InternalServerError, + 503: ServiceUnavailableError, + } + + +def test_status_error_stores_response() -> None: + response = _make_response(404) + exc = NotFoundError(response) + assert exc.response is response + + +def test_status_error_summary_message_includes_status_method_url() -> None: + exc = NotFoundError(_make_response(404, url="https://example.test/missing", method="GET")) + assert str(exc) == "404 GET https://example.test/missing" + + +def test_status_error_strips_userinfo_in_summary_message() -> None: + exc = NotFoundError(_make_response(404, url="https://user:pass@example.test/x")) + assert "user" not in str(exc) + assert "pass" not in str(exc) + assert str(exc) == "404 GET https://example.test/x" + + +def test_status_error_repr_strips_userinfo() -> None: + exc = NotFoundError(_make_response(404, url="https://user:pass@example.test/x")) + r = repr(exc) + assert "user" not in r + assert "pass" not in r + assert "NotFoundError" in r + assert "status=404" in r + + +def test_status_error_pickleable() -> None: + exc = NotFoundError(_make_response(404, url="https://example.test/x")) + restored = pickle.loads(pickle.dumps(exc)) + assert isinstance(restored, NotFoundError) + assert restored.response.status_code == 404 + assert str(restored.response.request.url) == "https://example.test/x" + + +@pytest.mark.parametrize( + ("status", "expected"), + [ + (400, BadRequestError), + (401, UnauthorizedError), + (404, NotFoundError), + (429, RateLimitedError), + (500, InternalServerError), + (503, ServiceUnavailableError), + ], +) +def test_per_status_subclasses_construct(status: int, expected: type[StatusError]) -> None: + response = _make_response(status) + exc = expected(response) + assert isinstance(exc, expected) + assert exc.response.status_code == status + + +def test_timeout_error_is_builtin_timeout_error() -> None: + exc = TimeoutError("timed out") + assert isinstance(exc, builtins.TimeoutError) + assert isinstance(exc, ClientError) + + +def test_transport_error_is_client_error() -> None: + exc = TransportError("connection refused") + assert isinstance(exc, ClientError) +``` + +- [ ] **Step 2: Run the failing test** + +Run: +```bash +just test tests/test_errors.py 2>&1 | tail -15 +``` +Expected: collection error (`ImportError`) — the symbols don't exist yet in `errors.py`. + +--- + +## Task 4: Errors — implementation + +**Files:** +- Modify: `src/httpware/errors.py` + +- [ ] **Step 1: Replace the stub with the full exception module** + +Replace `src/httpware/errors.py` with: +```python +"""Status-keyed exception hierarchy. + +Auto-raise rule lives at AsyncClient's internal terminal (see client.py). +Unknown 4xx falls back to ClientStatusError; unknown 5xx to ServerStatusError. +The fallback assumes 400 <= status < 600. + +__repr__ and the summary message strip user:pass@ userinfo from +response.request.url to avoid leaking credentials in tracebacks. +Query-string secrets are NOT stripped here. +""" + +import builtins +from collections.abc import Mapping +from typing import Any +from urllib.parse import urlsplit, urlunsplit + +import httpx2 + + +def _strip_userinfo(url: str) -> str: + if "@" not in url or "://" not in url: + return url + parts = urlsplit(url) + if parts.username is None and parts.password is None: + return url + netloc = parts.hostname or "" + if parts.port is not None: + netloc = f"{netloc}:{parts.port}" + return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment)) + + +class ClientError(Exception): + """Root of the httpware exception tree.""" + + +class TransportError(ClientError): + """Connection / network / protocol failure raised before a response was received.""" + + +class TimeoutError(ClientError, builtins.TimeoutError): # noqa: A001 + """Client-side timeout (connect / read / write / pool). + + Inherits from both ``httpware.ClientError`` and ``builtins.TimeoutError`` so + ``except builtins.TimeoutError`` / ``except OSError`` (the form + ``asyncio.wait_for`` uses) also catches httpware-raised timeouts. + Deliberate shadowing of the builtin; do not rename. + """ + + +def _reconstruct_status_error(cls: "type[StatusError]", response: httpx2.Response) -> "StatusError": + return cls(response) + + +class StatusError(ClientError): + """Base for HTTP-status-keyed errors. + + Holds the raw httpx2.Response. Subclasses do not override __init__. + """ + + response: httpx2.Response + + def __init__(self, response: httpx2.Response) -> None: + self.response = response + super().__init__(self._summary()) + + def _summary(self) -> str: + method = self.response.request.method + url = _strip_userinfo(str(self.response.request.url)) + return f"{self.response.status_code} {method} {url}" + + def __repr__(self) -> str: + cls_name = type(self).__name__ + method = self.response.request.method + url = _strip_userinfo(str(self.response.request.url)) + return f"<{cls_name} status={self.response.status_code} method={method} url={url}>" + + def __reduce__(self) -> tuple[Any, ...]: + return (_reconstruct_status_error, (type(self), self.response)) + + +class ClientStatusError(StatusError): + """Base for 4xx HTTP status errors.""" + + +class ServerStatusError(StatusError): + """Base for 5xx HTTP status errors.""" + + +class BadRequestError(ClientStatusError): + """HTTP 400.""" + + +class UnauthorizedError(ClientStatusError): + """HTTP 401.""" + + +class ForbiddenError(ClientStatusError): + """HTTP 403.""" + + +class NotFoundError(ClientStatusError): + """HTTP 404.""" + + +class ConflictError(ClientStatusError): + """HTTP 409.""" + + +class UnprocessableEntityError(ClientStatusError): + """HTTP 422.""" + + +class RateLimitedError(ClientStatusError): + """HTTP 429.""" + + +class InternalServerError(ServerStatusError): + """HTTP 500.""" + + +class ServiceUnavailableError(ServerStatusError): + """HTTP 503.""" + + +STATUS_TO_EXCEPTION: Mapping[int, type[StatusError]] = { + 400: BadRequestError, + 401: UnauthorizedError, + 403: ForbiddenError, + 404: NotFoundError, + 409: ConflictError, + 422: UnprocessableEntityError, + 429: RateLimitedError, + 500: InternalServerError, + 503: ServiceUnavailableError, +} +``` + +- [ ] **Step 2: Run the test suite for errors** + +Run: +```bash +just test tests/test_errors.py 2>&1 | tail -10 +``` +Expected: all tests pass. + +- [ ] **Step 3: Lint** + +Run: +```bash +just lint 2>&1 | tail -10 +``` +Expected: zero issues. Fix any that surface (typically `D205` or similar — adjust docstrings inline). + +- [ ] **Step 4: Commit** + +Run: +```bash +git add src/httpware/errors.py tests/test_errors.py +git commit -m "feat(errors): status-keyed exception tree holding httpx2.Response" +``` + +--- + +## Task 5: Middleware — failing tests + +**Files:** +- Create: `tests/test_middleware.py` + +- [ ] **Step 1: Write the failing test file** + +Create `tests/test_middleware.py`: +```python +"""Tests for the Middleware protocol, Next type, chain composition, and decorators.""" + +import httpx2 +import pytest + +from httpware.middleware import ( + Middleware, + Next, + after_response, + before_request, + on_error, +) +from httpware.middleware.chain import compose + + +def _make_request(url: str = "https://example.test/x") -> httpx2.Request: + return httpx2.Request("GET", url) + + +def _make_response(status: int = 200, *, request: httpx2.Request | None = None) -> httpx2.Response: + if request is None: + request = _make_request() + return httpx2.Response(status, request=request) + + +async def test_middleware_protocol_is_runtime_checkable() -> None: + class _OkMiddleware: + async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 + return await next(request) + + assert isinstance(_OkMiddleware(), Middleware) + + +async def test_empty_chain_calls_terminal_directly() -> None: + seen: list[httpx2.Request] = [] + + async def terminal(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return _make_response(200, request=request) + + dispatch = compose((), terminal) + request = _make_request() + response = await dispatch(request) + assert response.status_code == 200 + assert seen == [request] + + +async def test_chain_runs_middleware_in_order() -> None: + order: list[str] = [] + + class _M: + def __init__(self, label: str) -> None: + self.label = label + + async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 + order.append(f"{self.label}.before") + response = await next(request) + order.append(f"{self.label}.after") + return response + + async def terminal(request: httpx2.Request) -> httpx2.Response: + order.append("terminal") + return _make_response(200, request=request) + + dispatch = compose((_M("a"), _M("b")), terminal) + await dispatch(_make_request()) + assert order == ["a.before", "b.before", "terminal", "b.after", "a.after"] + + +async def test_before_request_decorator_transforms_request() -> None: + @before_request + async def add_header(request: httpx2.Request) -> httpx2.Request: + return httpx2.Request( + request.method, request.url, headers={**request.headers, "X-Custom": "1"} + ) + + captured: list[httpx2.Request] = [] + + async def terminal(request: httpx2.Request) -> httpx2.Response: + captured.append(request) + return _make_response(200, request=request) + + dispatch = compose((add_header,), terminal) + await dispatch(_make_request()) + assert captured[0].headers["x-custom"] == "1" + + +async def test_after_response_decorator_transforms_response() -> None: + @after_response + async def upgrade_status(request: httpx2.Request, response: httpx2.Response) -> httpx2.Response: + return httpx2.Response(299, request=request, headers=response.headers, content=response.content) + + async def terminal(request: httpx2.Request) -> httpx2.Response: + return _make_response(200, request=request) + + dispatch = compose((upgrade_status,), terminal) + response = await dispatch(_make_request()) + assert response.status_code == 299 + + +async def test_on_error_decorator_can_translate_exception() -> None: + @on_error + async def swallow(request: httpx2.Request, exc: Exception) -> httpx2.Response | None: + if isinstance(exc, RuntimeError) and str(exc) == "boom": + return _make_response(503, request=request) + return None + + async def terminal(request: httpx2.Request) -> httpx2.Response: + msg = "boom" + raise RuntimeError(msg) + + dispatch = compose((swallow,), terminal) + response = await dispatch(_make_request()) + assert response.status_code == 503 + + +async def test_on_error_returns_none_reraises() -> None: + @on_error + async def passthrough( + request: httpx2.Request, # noqa: ARG001 + exc: Exception, # noqa: ARG001 + ) -> httpx2.Response | None: + return None + + async def terminal(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 + msg = "boom" + raise RuntimeError(msg) + + dispatch = compose((passthrough,), terminal) + with pytest.raises(RuntimeError, match="boom"): + await dispatch(_make_request()) + + +async def test_on_error_lets_cancelled_propagate() -> None: + import asyncio + + @on_error + async def swallow_all( + request: httpx2.Request, # noqa: ARG001 + exc: Exception, # noqa: ARG001 + ) -> httpx2.Response | None: + msg = "should not catch CancelledError" + raise AssertionError(msg) + + async def terminal(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 + raise asyncio.CancelledError + + dispatch = compose((swallow_all,), terminal) + with pytest.raises(asyncio.CancelledError): + await dispatch(_make_request()) +``` + +- [ ] **Step 2: Run the failing tests** + +Run: +```bash +just test tests/test_middleware.py 2>&1 | tail -15 +``` +Expected: `ImportError` or collection failure — `Middleware`, `Next`, `compose`, decorators don't exist yet. + +--- + +## Task 6: Middleware — implementation + +**Files:** +- Modify: `src/httpware/middleware/__init__.py` +- Create: `src/httpware/middleware/chain.py` + +- [ ] **Step 1: Implement the protocol and decorators** + +Replace `src/httpware/middleware/__init__.py` with: +```python +"""Middleware protocol, Next type, and phase-shortcut decorators. + +Middleware operates directly on httpx2.Request / httpx2.Response — there is +no httpware-owned request type. The chain is composed at AsyncClient.__init__ +(see client.py) and frozen for the client's lifetime. +""" + +from collections.abc import Awaitable, Callable +from typing import Protocol, TypeAlias, runtime_checkable + +import httpx2 + +from httpware.middleware.chain import compose + + +Next: TypeAlias = Callable[[httpx2.Request], Awaitable[httpx2.Response]] + + +@runtime_checkable +class Middleware(Protocol): + """Structural protocol every middleware satisfies.""" + + async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 + """Process `request`; call `next(request)` to forward, or synthesize a Response.""" + ... + + +def before_request(f: Callable[[httpx2.Request], Awaitable[httpx2.Request]]) -> Middleware: + """Wrap an async request transform into a Middleware.""" + + class _BeforeRequestMiddleware: + async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 + return await next(await f(request)) + + def __repr__(self) -> str: + return f"" # ty: ignore[unresolved-attribute] + + return _BeforeRequestMiddleware() + + +def after_response( + f: Callable[[httpx2.Request, httpx2.Response], Awaitable[httpx2.Response]], +) -> Middleware: + """Wrap an async response transform into a Middleware.""" + + class _AfterResponseMiddleware: + async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 + response = await next(request) + return await f(request, response) + + def __repr__(self) -> str: + return f"" # ty: ignore[unresolved-attribute] + + return _AfterResponseMiddleware() + + +def on_error( + f: Callable[[httpx2.Request, Exception], Awaitable[httpx2.Response | None]], +) -> Middleware: + """Wrap an async error handler into a Middleware. + + Catches Exception (not BaseException), so asyncio.CancelledError propagates. + Handler returning None re-raises; returning a Response replaces the failure. + """ + + class _OnErrorMiddleware: + async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 + try: + return await next(request) + except Exception as exc: + result = await f(request, exc) + if result is None: + raise + return result + + def __repr__(self) -> str: + return f"" # ty: ignore[unresolved-attribute] + + return _OnErrorMiddleware() +``` + +- [ ] **Step 2: Implement chain composition** + +Create `src/httpware/middleware/chain.py`: +```python +"""Chain composition for the middleware stack.""" + +from collections.abc import Awaitable, Callable, Sequence +from typing import TYPE_CHECKING, TypeAlias + +import httpx2 + + +if TYPE_CHECKING: + from httpware.middleware import Middleware + + +_Next: TypeAlias = Callable[[httpx2.Request], Awaitable[httpx2.Response]] + + +def compose(middleware: "Sequence[Middleware]", terminal: _Next) -> _Next: + """Fold `middleware` into a single callable around `terminal`. + + The first middleware in the sequence is the outermost wrapper. + """ + dispatch: _Next = terminal + for layer in reversed(middleware): + dispatch = _wrap(layer, dispatch) + return dispatch + + +def _wrap(layer: "Middleware", inner: _Next) -> _Next: + async def call(request: httpx2.Request) -> httpx2.Response: + return await layer(request, inner) + + return call +``` + +- [ ] **Step 3: Run middleware tests** + +Run: +```bash +just test tests/test_middleware.py 2>&1 | tail -10 +``` +Expected: all tests pass. + +- [ ] **Step 4: Lint** + +Run: +```bash +just lint 2>&1 | tail -10 +``` +Expected: zero issues. The `if TYPE_CHECKING:` in `chain.py` avoids a circular import (`chain.py` is imported by `middleware/__init__.py`); keep it. + +- [ ] **Step 5: Commit** + +Run: +```bash +git add src/httpware/middleware tests/test_middleware.py +git commit -m "feat(middleware): protocol and chain retyped on httpx2.Request/Response" +``` + +--- + +## Task 7: AsyncClient — failing tests for construction & ownership + +**Files:** +- Create: `tests/test_client_construction.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_client_construction.py`: +```python +"""Tests for AsyncClient construction and ownership semantics.""" + +import httpx2 +import pytest + +from httpware import AsyncClient + + +def test_construction_with_no_args_works() -> None: + client = AsyncClient() + assert isinstance(client, AsyncClient) + + +def test_construction_with_forwarded_kwargs() -> None: + client = AsyncClient( + base_url="https://example.test", + headers={"x-shared": "1"}, + params={"trace": "yes"}, + timeout=10.0, + ) + assert isinstance(client, AsyncClient) + + +def test_construction_with_caller_owned_httpx2_client() -> None: + transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req)) + caller = httpx2.AsyncClient(transport=transport) + client = AsyncClient(httpx2_client=caller) + assert isinstance(client, AsyncClient) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"base_url": "https://example.test"}, + {"headers": {"x": "1"}}, + {"params": {"x": "1"}}, + {"cookies": {"x": "1"}}, + {"timeout": 5.0}, + {"limits": httpx2.Limits(max_connections=10)}, + {"auth": httpx2.BasicAuth("u", "p")}, + ], +) +def test_caller_owned_client_with_forwarded_kwargs_is_typeerror(kwargs: dict) -> None: + transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req)) + caller = httpx2.AsyncClient(transport=transport) + with pytest.raises(TypeError, match="httpx2_client"): + AsyncClient(httpx2_client=caller, **kwargs) + + +def test_default_decoder_is_pydantic_decoder() -> None: + from httpware.decoders.pydantic import PydanticDecoder + + client = AsyncClient() + assert isinstance(client._decoder, PydanticDecoder) # noqa: SLF001 + + +def test_explicit_decoder_is_honored() -> None: + class _Stub: + def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 + return None + + client = AsyncClient(decoder=_Stub()) + assert isinstance(client._decoder, _Stub) # noqa: SLF001 + + +def test_explicit_middleware_is_honored() -> None: + captured: list[str] = [] + + class _Tag: + async def __call__(self, request, next): # noqa: A002, ANN001 + captured.append("tag") + return await next(request) + + client = AsyncClient(middleware=(_Tag(),)) + assert client._user_middleware == (client._user_middleware[0],) # noqa: SLF001 + assert len(client._user_middleware) == 1 # noqa: SLF001 +``` + +- [ ] **Step 2: Run the failing tests** + +Run: +```bash +just test tests/test_client_construction.py 2>&1 | tail -15 +``` +Expected: `ImportError` — `AsyncClient` doesn't exist yet. + +--- + +## Task 8: AsyncClient — construction implementation + +**Files:** +- Modify: `src/httpware/client.py` + +- [ ] **Step 1: Implement `AsyncClient.__init__` and the terminal/chain wiring** + +Replace `src/httpware/client.py` with: +```python +"""AsyncClient — the thin httpx2 wrapper.""" + +import typing +from collections.abc import Sequence + +import httpx2 + +from httpware.decoders import ResponseDecoder +from httpware.decoders.pydantic import PydanticDecoder +from httpware.errors import ( + STATUS_TO_EXCEPTION, + ClientStatusError, + ServerStatusError, + TimeoutError, # noqa: A004 + TransportError, +) +from httpware.middleware import Middleware, Next +from httpware.middleware.chain import compose + + +T = typing.TypeVar("T") + + +_FORWARDED_KWARG_NAMES = ("base_url", "headers", "params", "cookies", "timeout", "limits", "auth") +_HTTPX2_CLIENT_CONFLICT_MESSAGE = ( + "AsyncClient(httpx2_client=...) cannot be combined with any of " + f"{_FORWARDED_KWARG_NAMES}; configure the httpx2.AsyncClient you pass instead." +) + + +class AsyncClient: + """Async HTTP client: thin wrapper around httpx2 with typed decoding and middleware.""" + + _httpx2_client: httpx2.AsyncClient + _owns_client: bool + _decoder: ResponseDecoder + _user_middleware: tuple[Middleware, ...] + _dispatch: Next + + def __init__( # noqa: PLR0913 + self, + *, + base_url: str = "", + headers: dict[str, str] | None = None, + params: dict[str, str] | None = None, + cookies: dict[str, str] | None = None, + timeout: httpx2.Timeout | float | None = None, + limits: httpx2.Limits | None = None, + auth: httpx2.Auth | None = None, + httpx2_client: httpx2.AsyncClient | None = None, + decoder: ResponseDecoder | None = None, + middleware: Sequence[Middleware] = (), + ) -> None: + if httpx2_client is not None: + forwarded = { + "base_url": base_url, + "headers": headers, + "params": params, + "cookies": cookies, + "timeout": timeout, + "limits": limits, + "auth": auth, + } + if any(value not in (None, "") for value in forwarded.values()): + raise TypeError(_HTTPX2_CLIENT_CONFLICT_MESSAGE) + self._httpx2_client = httpx2_client + self._owns_client = False + else: + kwargs: dict[str, typing.Any] = {} + if base_url: + kwargs["base_url"] = base_url + if headers is not None: + kwargs["headers"] = headers + if params is not None: + kwargs["params"] = params + if cookies is not None: + kwargs["cookies"] = cookies + if timeout is not None: + kwargs["timeout"] = timeout + if limits is not None: + kwargs["limits"] = limits + if auth is not None: + kwargs["auth"] = auth + self._httpx2_client = httpx2.AsyncClient(**kwargs) + self._owns_client = True + + self._decoder = decoder if decoder is not None else PydanticDecoder() + self._user_middleware = tuple(middleware) + self._dispatch = compose(self._user_middleware, self._terminal) + + async def _terminal(self, request: httpx2.Request) -> httpx2.Response: + try: + response = await self._httpx2_client.send(request) + except httpx2.TimeoutException as exc: + raise TimeoutError(str(exc)) from exc + except (httpx2.InvalidURL, httpx2.CookieConflict) as exc: + raise TransportError(str(exc)) from exc + except httpx2.HTTPError as exc: + raise TransportError(str(exc)) from exc + except RuntimeError as exc: + if "closed" in str(exc): + raise TransportError(str(exc)) from exc + raise + status = response.status_code + if 400 <= status < 600: # noqa: PLR2004 + exc_class = STATUS_TO_EXCEPTION.get( + status, + ClientStatusError if status < 500 else ServerStatusError, # noqa: PLR2004 + ) + raise exc_class(response) + return response +``` + +- [ ] **Step 2: Wire AsyncClient into the public package** + +Replace `src/httpware/__init__.py` with: +```python +"""httpware — thin async HTTP client wrapper over httpx2.""" + +from httpware.client import AsyncClient +from httpware.decoders import ResponseDecoder +from httpware.decoders.pydantic import PydanticDecoder +from httpware.errors import ( + STATUS_TO_EXCEPTION, + BadRequestError, + ClientError, + ClientStatusError, + ConflictError, + ForbiddenError, + InternalServerError, + NotFoundError, + RateLimitedError, + ServerStatusError, + ServiceUnavailableError, + StatusError, + TimeoutError, # noqa: A004 + TransportError, + UnauthorizedError, + UnprocessableEntityError, +) +from httpware.middleware import Middleware, Next, after_response, before_request, on_error + + +__all__ = [ + "STATUS_TO_EXCEPTION", + "AsyncClient", + "BadRequestError", + "ClientError", + "ClientStatusError", + "ConflictError", + "ForbiddenError", + "InternalServerError", + "Middleware", + "Next", + "NotFoundError", + "PydanticDecoder", + "RateLimitedError", + "ResponseDecoder", + "ServerStatusError", + "ServiceUnavailableError", + "StatusError", + "TimeoutError", + "TransportError", + "UnauthorizedError", + "UnprocessableEntityError", + "after_response", + "before_request", + "on_error", +] +``` + +- [ ] **Step 3: Run the construction tests** + +Run: +```bash +just test tests/test_client_construction.py 2>&1 | tail -10 +``` +Expected: all tests pass. + +- [ ] **Step 4: Lint** + +Run: +```bash +just lint 2>&1 | tail -10 +``` +Expected: zero issues. + +- [ ] **Step 5: Commit** + +Run: +```bash +git add src/httpware/client.py src/httpware/__init__.py tests/test_client_construction.py +git commit -m "feat(client): AsyncClient construction and ownership semantics" +``` + +--- + +## Task 9: AsyncClient — failing tests for `send()` and the terminal error path + +**Files:** +- Create: `tests/test_error_mapping_terminal.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_error_mapping_terminal.py`: +```python +"""Tests for the AsyncClient internal terminal's exception mapping.""" + +import httpx2 +import pytest + +from httpware import ( + AsyncClient, + BadRequestError, + InternalServerError, + NotFoundError, + RateLimitedError, + ServerStatusError, + TimeoutError, # noqa: A004 + TransportError, +) + + +def _client_with_handler(handler) -> AsyncClient: # noqa: ANN001 + transport = httpx2.MockTransport(handler) + return AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) + + +async def test_terminal_returns_response_on_2xx() -> None: + client = _client_with_handler(lambda req: httpx2.Response(200, json={"ok": True}, request=req)) + response = await client.send(httpx2.Request("GET", "https://example.test/x")) + assert response.status_code == 200 + assert response.json() == {"ok": True} + + +@pytest.mark.parametrize( + ("status", "exc_type"), + [ + (400, BadRequestError), + (404, NotFoundError), + (429, RateLimitedError), + (500, InternalServerError), + ], +) +async def test_known_status_codes_raise_typed_subclass(status: int, exc_type: type) -> None: + client = _client_with_handler(lambda req: httpx2.Response(status, request=req)) + with pytest.raises(exc_type) as info: + await client.send(httpx2.Request("GET", "https://example.test/x")) + assert info.value.response.status_code == status + + +async def test_unknown_4xx_falls_back_to_client_status_error() -> None: + from httpware import ClientStatusError + + client = _client_with_handler(lambda req: httpx2.Response(418, request=req)) + with pytest.raises(ClientStatusError) as info: + await client.send(httpx2.Request("GET", "https://example.test/x")) + assert info.value.response.status_code == 418 + assert type(info.value) is ClientStatusError + + +async def test_unknown_5xx_falls_back_to_server_status_error() -> None: + client = _client_with_handler(lambda req: httpx2.Response(599, request=req)) + with pytest.raises(ServerStatusError) as info: + await client.send(httpx2.Request("GET", "https://example.test/x")) + assert info.value.response.status_code == 599 + assert type(info.value) is ServerStatusError + + +async def test_3xx_does_not_raise() -> None: + client = _client_with_handler(lambda req: httpx2.Response(301, request=req, headers={"location": "/y"})) + response = await client.send(httpx2.Request("GET", "https://example.test/x")) + assert response.status_code == 301 + + +async def test_httpx2_timeout_maps_to_httpware_timeout() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 + msg = "read timeout" + raise httpx2.ReadTimeout(msg) + + client = _client_with_handler(handler) + with pytest.raises(TimeoutError, match="read timeout"): + await client.send(httpx2.Request("GET", "https://example.test/x")) + + +async def test_httpx2_connect_error_maps_to_transport_error() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 + msg = "connect refused" + raise httpx2.ConnectError(msg) + + client = _client_with_handler(handler) + with pytest.raises(TransportError, match="connect refused"): + await client.send(httpx2.Request("GET", "https://example.test/x")) + + +async def test_send_on_closed_client_raises_transport_error() -> None: + transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req)) + underlying = httpx2.AsyncClient(transport=transport) + client = AsyncClient(httpx2_client=underlying) + await underlying.aclose() + with pytest.raises(TransportError): + await client.send(httpx2.Request("GET", "https://example.test/x")) +``` + +- [ ] **Step 2: Run the failing tests** + +Run: +```bash +just test tests/test_error_mapping_terminal.py 2>&1 | tail -15 +``` +Expected: failures — `AsyncClient.send` doesn't exist yet. + +--- + +## Task 10: AsyncClient — `send()` implementation + +**Files:** +- Modify: `src/httpware/client.py` + +- [ ] **Step 1: Add `send()` and `build_request()` to the existing `AsyncClient`** + +Append the following methods to the `AsyncClient` class in `src/httpware/client.py` (insert immediately after `_terminal`): + +```python + @typing.overload + async def send(self, request: httpx2.Request, *, response_model: None = None) -> httpx2.Response: ... + + @typing.overload + async def send(self, request: httpx2.Request, *, response_model: type[T]) -> T: ... + + async def send( + self, + request: httpx2.Request, + *, + response_model: type[T] | None = None, + ) -> httpx2.Response | T: + """Send `request` through the middleware chain. Decode if `response_model` is set.""" + response = await self._dispatch(request) + if response_model is None: + return response + return self._decoder.decode(response.content, response_model) + + def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request: + """Delegate request construction to the wrapped httpx2.AsyncClient.""" + return self._httpx2_client.build_request(method, url, **kwargs) +``` + +- [ ] **Step 2: Run the terminal-error-mapping tests** + +Run: +```bash +just test tests/test_error_mapping_terminal.py 2>&1 | tail -10 +``` +Expected: all tests pass. + +- [ ] **Step 3: Lint** + +Run: +```bash +just lint 2>&1 | tail -10 +``` +Expected: zero issues. If `ty` complains about the `**kwargs: typing.Any` shape, adjust the kwargs annotation as ruff/ty advise — `httpx2.AsyncClient.build_request` is the upstream type oracle. + +- [ ] **Step 4: Commit** + +Run: +```bash +git add src/httpware/client.py tests/test_error_mapping_terminal.py +git commit -m "feat(client): send() + build_request(), terminal error mapping" +``` + +--- + +## Task 11: Per-method surface — failing tests + +**Files:** +- Create: `tests/test_client_methods.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_client_methods.py`: +```python +"""Tests for the per-method API surface of AsyncClient.""" + +import httpx2 +import pytest + +from httpware import AsyncClient, NotFoundError + + +def _echo_handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response( + 200, + request=request, + json={ + "method": request.method, + "url": str(request.url), + "headers": dict(request.headers), + "content": request.content.decode() if request.content else "", + }, + ) + + +def _client_with_handler(handler, **kwargs) -> AsyncClient: # noqa: ANN001, ANN003 + transport = httpx2.MockTransport(handler) + return AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport, **kwargs)) + + +async def test_get_returns_httpx2_response() -> None: + client = _client_with_handler(_echo_handler) + response = await client.get("https://example.test/x") + assert isinstance(response, httpx2.Response) + assert response.json()["method"] == "GET" + + +@pytest.mark.parametrize( + "method_name", + ["get", "post", "put", "patch", "delete", "head", "options"], +) +async def test_each_per_method_helper_exists_and_uses_correct_verb(method_name: str) -> None: + client = _client_with_handler(_echo_handler) + method = getattr(client, method_name) + response = await method("https://example.test/x") + assert response.json()["method"] == method_name.upper() + + +async def test_post_json_body_serialized() -> None: + client = _client_with_handler(_echo_handler) + response = await client.post("https://example.test/x", json={"k": "v"}) + payload = response.json() + assert "application/json" in payload["headers"]["content-type"] + assert payload["content"] == '{"k": "v"}' + + +async def test_get_with_params_forwards_query() -> None: + captured: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + captured.append(request) + return httpx2.Response(200, request=request) + + client = _client_with_handler(handler) + await client.get("https://example.test/x", params={"a": "1"}) + assert "a=1" in str(captured[0].url) + + +async def test_get_with_headers_merges() -> None: + captured: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + captured.append(request) + return httpx2.Response(200, request=request) + + client = _client_with_handler(handler) + await client.get("https://example.test/x", headers={"x-trace": "abc"}) + assert captured[0].headers["x-trace"] == "abc" + + +async def test_get_raises_typed_status_error_on_404() -> None: + client = _client_with_handler(lambda req: httpx2.Response(404, request=req)) + with pytest.raises(NotFoundError): + await client.get("https://example.test/missing") + + +async def test_request_method_takes_arbitrary_verb() -> None: + client = _client_with_handler(_echo_handler) + response = await client.request("PROPFIND", "https://example.test/x") + assert response.json()["method"] == "PROPFIND" + + +async def test_base_url_is_applied() -> None: + captured: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + captured.append(request) + return httpx2.Response(200, request=request) + + transport = httpx2.MockTransport(handler) + underlying = httpx2.AsyncClient(transport=transport, base_url="https://example.test") + client = AsyncClient(httpx2_client=underlying) + await client.get("/relative") + assert str(captured[0].url) == "https://example.test/relative" +``` + +- [ ] **Step 2: Run the failing tests** + +Run: +```bash +just test tests/test_client_methods.py 2>&1 | tail -15 +``` +Expected: failures — per-method helpers don't exist yet. + +--- + +## Task 12: Per-method surface — implementation + +**Files:** +- Modify: `src/httpware/client.py` + +- [ ] **Step 1: Add per-method helpers** + +Append the following block to the `AsyncClient` class in `src/httpware/client.py`, immediately after `build_request`. Each method has two overloads + the runtime body. To keep this task tractable, the methods share a private helper: + +```python + async def _request_with_body( + 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: + 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) + return await self.send(request, response_model=response_model) +``` + +Then add the eight per-method helpers. Pattern (full code shown for `get`, `post`; identical shape for `put`, `patch`, `delete`, `head`, `options`): + +```python + @typing.overload + async def get( + 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: None = None, + ) -> httpx2.Response: ... + + @typing.overload + async def get( + 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], + ) -> T: ... + + async def get( + 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] | None = None, + ) -> httpx2.Response | T: + """Send a GET request.""" + return await self._request_with_body( + "GET", + url, + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + extensions=extensions, + response_model=response_model, + ) + + @typing.overload + async def post( + 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: None = None, + ) -> httpx2.Response: ... + + @typing.overload + async def post( + 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], + ) -> T: ... + + async def post( + 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] | None = None, + ) -> httpx2.Response | T: + """Send a POST request.""" + return await self._request_with_body( + "POST", + url, + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + extensions=extensions, + json=json, + content=content, + data=data, + files=files, + response_model=response_model, + ) +``` + +Repeat the `post` shape for `put` (`"PUT"`), `patch` (`"PATCH"`), and `delete` (`"DELETE"`). For `head`, `options`, copy the `get` shape (no body kwargs). + +Add `request` (arbitrary verb) using the post-shape with an explicit `method` first parameter: + +```python + @typing.overload + async def request( + 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: None = None, + ) -> httpx2.Response: ... + + @typing.overload + async def request( + 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], + ) -> T: ... + + async def request( + 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: + """Send a request with an arbitrary HTTP method.""" + return await self._request_with_body( + 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 2: Run per-method tests** + +Run: +```bash +just test tests/test_client_methods.py 2>&1 | tail -10 +``` +Expected: all pass. + +- [ ] **Step 3: Lint** + +Run: +```bash +just lint 2>&1 | tail -10 +``` +Expected: zero issues. `pylint.max-args = 10` is already set in `pyproject.toml`. The per-file `ASYNC109` ignore for `src/httpware/client.py` is already declared. + +- [ ] **Step 4: Commit** + +Run: +```bash +git add src/httpware/client.py tests/test_client_methods.py +git commit -m "feat(client): per-method API surface (get/post/put/patch/delete/head/options/request)" +``` + +--- + +## Task 13: Response-model decoding — failing tests + +**Files:** +- Create: `tests/test_client_response_model.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_client_response_model.py`: +```python +"""Tests for response_model decoding integration.""" + +import httpx2 +import pydantic +import pytest + +from httpware import AsyncClient + + +class _User(pydantic.BaseModel): + id: int + name: str + + +def _client_with_payload(payload: bytes, content_type: str = "application/json") -> AsyncClient: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, content=payload, headers={"content-type": content_type}, request=request) + + transport = httpx2.MockTransport(handler) + return AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) + + +async def test_get_with_response_model_returns_typed_object() -> None: + client = _client_with_payload(b'{"id": 1, "name": "ada"}') + user = await client.get("https://example.test/u", response_model=_User) + assert isinstance(user, _User) + assert user == _User(id=1, name="ada") + + +async def test_post_with_response_model_returns_typed_object() -> None: + client = _client_with_payload(b'{"id": 2, "name": "bob"}') + user = await client.post("https://example.test/u", json={"name": "bob"}, response_model=_User) + assert isinstance(user, _User) + + +async def test_send_with_response_model_returns_typed_object() -> None: + client = _client_with_payload(b'{"id": 3, "name": "cat"}') + request = client.build_request("GET", "https://example.test/u") + user = await client.send(request, response_model=_User) + assert isinstance(user, _User) + + +async def test_decoder_validation_error_propagates_unwrapped() -> None: + client = _client_with_payload(b'{"id": "not-an-int", "name": "x"}') + with pytest.raises(pydantic.ValidationError): + await client.get("https://example.test/u", response_model=_User) + + +async def test_status_error_raised_before_decoder_runs() -> None: + from httpware import NotFoundError + + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(404, content=b'{"id": 1, "name": "x"}', request=request) + + transport = httpx2.MockTransport(handler) + client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) + with pytest.raises(NotFoundError): + await client.get("https://example.test/u", response_model=_User) +``` + +- [ ] **Step 2: Run the tests** + +Run: +```bash +just test tests/test_client_response_model.py 2>&1 | tail -10 +``` +Expected: all tests pass — the decoder path was wired in Task 10, the per-method path in Task 12. + +- [ ] **Step 3: Commit** + +Run: +```bash +git add tests/test_client_response_model.py +git commit -m "test(client): response_model decoding across get/post/send paths" +``` + +--- + +## Task 14: Middleware wiring — tests + +**Files:** +- Create: `tests/test_client_middleware_wiring.py` + +- [ ] **Step 1: Write the tests** + +Create `tests/test_client_middleware_wiring.py`: +```python +"""Tests for AsyncClient ↔ middleware chain integration.""" + +import httpx2 + +from httpware import AsyncClient, after_response, before_request, on_error + + +async def test_before_request_runs() -> None: + @before_request + async def add_header(request: httpx2.Request) -> httpx2.Request: + return httpx2.Request( + request.method, + request.url, + headers={**request.headers, "x-injected": "1"}, + ) + + captured: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + captured.append(request) + return httpx2.Response(200, request=request) + + transport = httpx2.MockTransport(handler) + client = AsyncClient( + httpx2_client=httpx2.AsyncClient(transport=transport), + middleware=(add_header,), + ) + await client.get("https://example.test/x") + assert captured[0].headers["x-injected"] == "1" + + +async def test_after_response_runs() -> None: + @after_response + async def tag_status(request: httpx2.Request, response: httpx2.Response) -> httpx2.Response: + return httpx2.Response( + 299, request=request, headers=response.headers, content=response.content + ) + + transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req)) + client = AsyncClient( + httpx2_client=httpx2.AsyncClient(transport=transport), + middleware=(tag_status,), + ) + response = await client.get("https://example.test/x") + assert response.status_code == 299 + + +async def test_on_error_catches_status_error() -> None: + @on_error + async def convert_404(request: httpx2.Request, exc: Exception) -> httpx2.Response | None: + from httpware import NotFoundError + + if isinstance(exc, NotFoundError): + return httpx2.Response(200, request=request, content=b"recovered") + return None + + transport = httpx2.MockTransport(lambda req: httpx2.Response(404, request=req)) + client = AsyncClient( + httpx2_client=httpx2.AsyncClient(transport=transport), + middleware=(convert_404,), + ) + response = await client.get("https://example.test/x") + assert response.status_code == 200 + assert response.content == b"recovered" + + +async def test_middleware_runs_outer_to_inner_then_inner_to_outer() -> None: + order: list[str] = [] + + class _Tag: + def __init__(self, name: str) -> None: + self.name = name + + async def __call__(self, request, next): # noqa: A002, ANN001 + order.append(f"{self.name}.in") + response = await next(request) + order.append(f"{self.name}.out") + return response + + transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req)) + client = AsyncClient( + httpx2_client=httpx2.AsyncClient(transport=transport), + middleware=(_Tag("a"), _Tag("b")), + ) + await client.get("https://example.test/x") + assert order == ["a.in", "b.in", "b.out", "a.out"] +``` + +- [ ] **Step 2: Run the tests** + +Run: +```bash +just test tests/test_client_middleware_wiring.py 2>&1 | tail -10 +``` +Expected: all tests pass. + +- [ ] **Step 3: Commit** + +Run: +```bash +git add tests/test_client_middleware_wiring.py +git commit -m "test(client): middleware chain runs around the terminal" +``` + +--- + +## Task 15: Lifecycle — tests and `__aenter__`/`__aexit__` + +**Files:** +- Create: `tests/test_client_lifecycle.py` +- Modify: `src/httpware/client.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_client_lifecycle.py`: +```python +"""Tests for AsyncClient.__aenter__/__aexit__ lifecycle and ownership.""" + +import httpx2 + +from httpware import AsyncClient + + +async def test_aexit_closes_owned_httpx2_client() -> None: + client = AsyncClient() + async with client: + pass + assert client._httpx2_client.is_closed # noqa: SLF001 + + +async def test_aexit_does_not_close_borrowed_httpx2_client() -> None: + transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req)) + underlying = httpx2.AsyncClient(transport=transport) + client = AsyncClient(httpx2_client=underlying) + async with client: + pass + assert not underlying.is_closed + await underlying.aclose() + + +async def test_aexit_is_idempotent_for_owned_client() -> None: + client = AsyncClient() + async with client: + pass + # Second use should not raise — the boolean prevents a double-close on httpx2 internals. + await client.__aexit__(None, None, None) +``` + +- [ ] **Step 2: Run them to confirm failure** + +Run: +```bash +just test tests/test_client_lifecycle.py 2>&1 | tail -10 +``` +Expected: failures — `__aenter__`/`__aexit__` don't exist yet. + +- [ ] **Step 3: Add lifecycle methods to AsyncClient** + +Append these two methods to the `AsyncClient` class in `src/httpware/client.py`: + +```python + async def __aenter__(self) -> typing.Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: object, + ) -> None: + if self._owns_client and not self._httpx2_client.is_closed: + await self._httpx2_client.aclose() +``` + +- [ ] **Step 4: Run lifecycle tests** + +Run: +```bash +just test tests/test_client_lifecycle.py 2>&1 | tail -10 +``` +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +Run: +```bash +git add src/httpware/client.py tests/test_client_lifecycle.py +git commit -m "feat(client): __aenter__/__aexit__ honors owned vs. borrowed httpx2 client" +``` + +--- + +## Task 16: Typing overloads — tests + +**Files:** +- Create: `tests/test_client_typing.py` + +- [ ] **Step 1: Write the typing tests** + +Create `tests/test_client_typing.py`: +```python +"""Static-typing tests for AsyncClient overloads. + +These assert overload selection at runtime via isinstance checks. ty/mypy +catches the static-typing variant during `just lint`. +""" + +import httpx2 +import pydantic + +from httpware import AsyncClient + + +class _User(pydantic.BaseModel): + id: int + name: str + + +async def test_get_without_response_model_returns_response() -> None: + transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req, json={"id": 1, "name": "a"})) + client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) + result = await client.get("https://example.test/x") + assert isinstance(result, httpx2.Response) + + +async def test_get_with_response_model_returns_typed() -> None: + transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req, json={"id": 1, "name": "a"})) + client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) + result = await client.get("https://example.test/x", response_model=_User) + assert isinstance(result, _User) + + +async def test_send_without_response_model_returns_response() -> None: + transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req, json={"id": 1, "name": "a"})) + client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) + result = await client.send(httpx2.Request("GET", "https://example.test/x")) + assert isinstance(result, httpx2.Response) + + +async def test_send_with_response_model_returns_typed() -> None: + transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req, json={"id": 1, "name": "a"})) + client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) + result = await client.send(httpx2.Request("GET", "https://example.test/x"), response_model=_User) + assert isinstance(result, _User) +``` + +- [ ] **Step 2: Run them and lint** + +Run: +```bash +just test tests/test_client_typing.py +just lint +``` +Expected: tests pass; `ty check` reports zero issues. + +- [ ] **Step 3: Commit** + +Run: +```bash +git add tests/test_client_typing.py +git commit -m "test(client): overload selection of response_model for get/send" +``` + +--- + +## Task 17: Public-API surface test + +**Files:** +- Create: `tests/test_public_api.py` + +- [ ] **Step 1: Write the public-API test** + +Create `tests/test_public_api.py`: +```python +"""Public API surface — what `from httpware import ...` exposes.""" + +import httpware + + +def test_all_exports_resolve() -> None: + for symbol in httpware.__all__: + assert hasattr(httpware, symbol), f"{symbol} declared in __all__ but missing" + + +def test_no_removed_symbols_leaked() -> None: + removed = { + "Request", + "Response", + "StreamResponse", + "Timeout", + "Limits", + "ClientConfig", + "Transport", + "Httpx2Transport", + "RecordedTransport", + "AuthValue", + } + leaked = removed & set(dir(httpware)) + assert not leaked, f"removed 0.1 symbols still exposed: {leaked}" + + +def test_expected_exports() -> None: + expected = { + "AsyncClient", + "Middleware", + "Next", + "ResponseDecoder", + "PydanticDecoder", + "ClientError", + "TransportError", + "TimeoutError", + "StatusError", + "ClientStatusError", + "ServerStatusError", + "BadRequestError", + "UnauthorizedError", + "ForbiddenError", + "NotFoundError", + "ConflictError", + "UnprocessableEntityError", + "RateLimitedError", + "InternalServerError", + "ServiceUnavailableError", + "STATUS_TO_EXCEPTION", + "before_request", + "after_response", + "on_error", + } + missing = expected - set(httpware.__all__) + assert not missing, f"expected exports missing from __all__: {missing}" +``` + +- [ ] **Step 2: Run it** + +Run: +```bash +just test tests/test_public_api.py +``` +Expected: all tests pass. If `test_no_removed_symbols_leaked` fails, an export survived the tear-down; trace and remove from `__init__.py`. + +- [ ] **Step 3: Commit** + +Run: +```bash +git add tests/test_public_api.py +git commit -m "test(public-api): assert v0.2 surface + no leaked 0.1 names" +``` + +--- + +## Task 18: Full test suite + coverage gate + +**Files:** +- All tests. + +- [ ] **Step 1: Run the full suite with coverage** + +Run: +```bash +just test 2>&1 | tail -30 +``` +Expected: every test passes; line coverage at 100% on `src/httpware/`. Note the new total count. + +- [ ] **Step 2: Lint, format, type-check** + +Run: +```bash +just lint 2>&1 | tail -15 +``` +Expected: zero issues across all linters. + +- [ ] **Step 3: Run `import httpware` in a clean subprocess and confirm no `msgspec` leak** + +Run: +```bash +just test tests/test_optional_extras_isolation.py 2>&1 | tail -5 +``` +Expected: pass. + +- [ ] **Step 4: If coverage gaps appear, add a tiny targeted test for each uncovered branch** + +This is a per-line-coverage spot fix. Most gaps will be in the `_terminal` error branches; add a parametrized test in `tests/test_error_mapping_terminal.py` for whichever specific `httpx2` exception subclass is uncovered. + +- [ ] **Step 5: Commit (only if step 4 produced changes)** + +Run: +```bash +git add tests/ +git commit -m "test(coverage): close remaining branch gaps in terminal error mapping" +``` + +--- + +## Task 19: Rewrite `CLAUDE.md` + +**Files:** +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Update the architecture-invariants section** + +Open `CLAUDE.md`. In the "Architecture invariants (CI-enforced)" section, **delete** the two bullets: + +- `- **No httpx2 leakage**: ...` +- `- **No httpx2 private API**: ...` + +Replace with a single bullet: + +```markdown +- **No `httpx2` private API**: `grep -rE 'httpx2\._' src/httpware/` must return zero matches. Public symbols only. +``` + +In the "Module layout" section, **replace** the diagram with: + +```text +src/httpware/ +├── __init__.py # public exports + __all__ +├── client.py # AsyncClient (thin wrapper over httpx2.AsyncClient) +├── errors.py # status-keyed exception hierarchy holding httpx2.Response +├── middleware/ # protocol, Next type, chain composition, phase decorators +├── decoders/ # ResponseDecoder protocol + Pydantic/msgspec adapters +├── _internal/ # private cross-module helpers +└── py.typed +``` + +In the "Protocol seams" section, **replace** the five-seam list with three: + +```markdown +Three documented internal boundaries. AI agents must respect them — never cross a seam except through its documented protocol. + +1. **`AsyncClient ↔ Middleware`** — middleware chain composed at `AsyncClient.__init__`, frozen for the client's lifetime. Internal terminal calls `httpx2.AsyncClient.send`, maps exceptions, raises `StatusError` on 4xx/5xx. +2. **`AsyncClient ↔ ResponseDecoder`** — called when `response_model` is provided. Signature: `decode(content: bytes, model: type[T]) -> T`. +3. **`httpware ↔ optional extras`** — each opt-in dependency imported only inside its dedicated module. +``` + +- [ ] **Step 2: Update project-overview text** + +In the "Project Overview" section, **replace** "The framework owns the abstraction layer above the underlying HTTP client (`httpx2` by default); consumers never import the transport." with "The framework is a thin opinionated wrapper around `httpx2`: it re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain, typed response decoding, and a status-keyed exception tree raised automatically on 4xx/5xx." + +- [ ] **Step 3: Confirm the file lints** + +Run: +```bash +just lint 2>&1 | tail -5 +``` +Expected: zero issues (CLAUDE.md is markdown; ruff doesn't touch it, but `eof-fixer` does — confirm the trailing newline). + +- [ ] **Step 4: Commit** + +Run: +```bash +git add CLAUDE.md +git commit -m "docs(claude): retire no-leakage invariant; collapse seams to three" +``` + +--- + +## Task 20: Rewrite `docs/dev/engineering.md` + +**Files:** +- Modify: `docs/dev/engineering.md` + +- [ ] **Step 1: Rewrite Section 1 (Project intent)** + +Replace section 1 with: + +```markdown +## 1. Project intent + +`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request` and `httpx2.Response` as the public request/response surface and adds three things on top: typed response decoding (via a `ResponseDecoder` protocol; Pydantic ships as the default, msgspec as an opt-in extra), a middleware chain composed at client construction, and a status-keyed exception tree raised automatically on 4xx and 5xx. + +The 0.1.0 release attempted to own a full abstraction over the underlying HTTP client. v0.2 walks that back: `httpx2` is part of the public surface. +``` + +- [ ] **Step 2: Rewrite Section 2 (Architectural invariants)** + +Replace section 2 with: + +```markdown +## 2. Architectural invariants (CI-enforced) + +These are non-negotiable. CI rejects PRs that violate them. + +- **No `httpx2._` private API.** *Why:* private symbols can change between patch releases. We accept the public-API surface as the contract. +- **No `from __future__ import annotations`.** *Why:* Python 3.11+ floor; PEP 604/585 syntax is native. +- **No `print()`.** *Why:* ruff-enforced. Libraries log; they do not print. +- **No global logging config.** *Why:* `logging.basicConfig()` from a library mutates the consumer's logging tree. We only acquire `logging.getLogger("httpware")` or namespaced child loggers. +- **Type suppressions use `# ty: ignore[]`.** *Why:* this project uses `ty`, not `mypy`. `# type: ignore` is silently accepted; `# ty: ignore[]` is checked and rule-specific. + +The 0.1.0 "no `httpx2` leakage outside `transports/httpx2.py`" invariant is **retired in v0.2**. Exposing `httpx2.Request`/`httpx2.Response` is the design. +``` + +- [ ] **Step 3: Rewrite Section 3 (The five protocol seams) → three seams** + +Replace section 3 wholesale with the three-seam content from the spec (sections 4.A, 4.B, 4.C). + +- [ ] **Step 4: Rewrite Section 5 (Module layout)** + +Replace the layout diagram and "Planned modules" subsection with the layout from spec section 5 (single tree, no "planned modules" — they all land or get deleted in this pivot). + +- [ ] **Step 5: Rewrite Section 8 (Remaining roadmap)** + +Replace section 8's story list with the updated roadmap from spec section 12 (the four-category breakdown: deleted, rewritten, surviving, plus the explicit story IDs). + +- [ ] **Step 6: Confirm formatting and commit** + +Run: +```bash +just lint 2>&1 | tail -5 +git add docs/dev/engineering.md +git commit -m "docs(engineering): rewrite sections 1, 2, 3, 5, 8 for the v0.2 pivot" +``` + +--- + +## Task 21: Sweep `planning/deferred-work.md` + +**Files:** +- Modify: `planning/deferred-work.md` + +- [ ] **Step 1: Identify and close obsoleted entries** + +Replace the contents of `planning/deferred-work.md` with: + +```markdown +# Deferred Work + +Items raised in reviews that are real but not actionable now. + +## Open + +### Decoder-side + +- **`_get_adapter` `lru_cache` is module-global, not per-decoder instance** — keyed by `model` only; two `PydanticDecoder()` instances with different configurations (none today) would share adapters, and the cache survives across tests unless explicitly cleared. Revisit if/when a configurable `PydanticDecoder(mode=..., strict=...)` lands. (`src/httpware/decoders/pydantic.py:12-14`) +- **Empty/malformed payload tests** — `b""`, `b"null"`, `b"{}"`, invalid UTF-8: current pydantic-core behavior is correct but unpinned; a future pydantic upgrade could change error types undetected. (`tests/test_decoders_pydantic.py`) + +### Tooling + +- **Unpinned `ruff`/`ty` with `select=["ALL"]`** — any new ruff release adds rules and can break CI overnight. Pin major versions or pin specific rules when a regression occurs. (`pyproject.toml` `[dependency-groups] lint`, `[tool.ruff.lint] select`) +- **No `[test]` extra; CI installs all extras** — `just install` runs `uv sync --all-extras --group lint`, so every CI run pulls msgspec/otel/niquests even though most tests don't need them. Declare a `test` extra (or move test-only deps into a dedicated dependency-group) and switch CI to the narrower install. (`pyproject.toml` `[project.optional-dependencies]`, `Justfile:install`) +- **`pydantic` import not guarded the way `msgspec` is** — `decoders/pydantic.py` imports `pydantic` at module top; `decoders/msgspec.py` guards via `is_msgspec_installed`. Either drop the optional-extras framing for pydantic (it is already a required dependency) or guard pydantic the same way. (`src/httpware/decoders/pydantic.py:5`, `pyproject.toml` `[project] dependencies`) + +## Closed by the v0.2 thin-wrapper pivot (2026-06-03) + +The pivot retired Request/Response/Httpx2Transport/RecordedTransport. The following deferred items are no longer applicable because their host code has been removed or because the responsibility shifted to `httpx2`: + +- `extensions=dict(request.extensions)` opaque forwarding (host module removed). +- Unbounded error body size on `StatusError.body` (the `body` field no longer exists; callers reach into `exc.response.content` themselves). +- `httpx2.StreamError` family escape from the transport's `except httpx2.HTTPError` (mapping logic relocated to AsyncClient's terminal; revisit with Epic 4 streaming work). +- Header CRLF / log-injection at the transport seam (host module removed; httpx2 validates). +- Userinfo on `StatusError.request_url` raw field (the field no longer exists; `__repr__` and summary still sanitize). +- Concurrent `aclose()` ↔ `__call__` races on `Httpx2Transport` (host class removed; lifecycle is `httpx2`'s concern). +- URL CRLF / log-injection (httpx2 owns URL validation). +- `request.method` validation beyond uppercasing (host module removed; `httpx2` owns). +- Case-insensitive header type / multi-valued header collapse (host module removed; `httpx2.Headers` already provides case-insensitive multi-valued access). +- Multi-valued query params (host module removed; `httpx2` owns). +- Streaming / async-iterable request bodies (Epic 4 lands on `httpx2.Request` directly). +- `@final` to prevent subclassing of `Request`/`Response`/`ClientConfig` (host classes removed). +``` + +- [ ] **Step 2: Lint and commit** + +Run: +```bash +just lint 2>&1 | tail -5 +git add planning/deferred-work.md +git commit -m "docs(deferred): close items obsoleted by the v0.2 thin-wrapper pivot" +``` + +--- + +## Task 22: Version bump and release notes + +**Files:** +- Modify: `pyproject.toml` +- Create: `planning/specs/2026-06-03-release-notes-0.2.0.md` (draft of the GitHub Release body) + +- [ ] **Step 1: Bump the version** + +In `pyproject.toml`, change: + +```toml +version = "0" +``` + +to: + +```toml +version = "0.2.0" +``` + +- [ ] **Step 2: Draft release notes** + +Create `planning/specs/2026-06-03-release-notes-0.2.0.md`: + +```markdown +# httpware 0.2.0 — thin httpx2 wrapper + +**0.2.0 is a breaking rewrite.** The framework is now a thin opinionated wrapper around `httpx2`: it re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain, typed response decoding, and a status-keyed exception tree raised automatically on 4xx/5xx. + +## Breaking changes from 0.1.0 + +- **Removed value types.** `httpware.Request`, `httpware.Response`, `httpware.StreamResponse`, `httpware.Limits`, `httpware.Timeout`, and `httpware.ClientConfig` are gone. Use `httpx2.Request`, `httpx2.Response`, `httpx2.Limits`, `httpx2.Timeout` directly. +- **Removed transport abstraction.** `httpware.Transport`, `httpware.Httpx2Transport`, and `httpware.RecordedTransport` are gone. Tests should inject `httpx2.MockTransport` via the new `httpx2_client=` kwarg. +- **Removed auth coercion.** Pass `httpx2.Auth` (e.g., `httpx2.BasicAuth`) directly to the client. +- **`with_options` removed.** Construct a separate `AsyncClient` wrapping a shared `httpx2.AsyncClient` instead. +- **`StatusError` simplified.** Subclasses no longer accept `status` / `body` / `headers` / `json` / `request_method` / `request_url` kwargs. Construct with a single `response: httpx2.Response` argument and read fields from `exc.response.*`. +- **CI invariant retired.** The "no `httpx2` imports outside `transports/httpx2.py`" rule is gone. `httpx2` is part of the public surface. + +## What still works + +- `AsyncClient.get/post/put/patch/delete/head/options/request` with `response_model=...` for typed decoding. +- `PydanticDecoder` (default) and `MsgspecDecoder` (opt-in via `pip install httpware[msgspec]`). +- Middleware protocol with `@before_request`, `@after_response`, `@on_error` decorators. +- Status-keyed exception tree (`NotFoundError`, `RateLimitedError`, etc.) raised automatically on 4xx/5xx. + +## Migration + +```python +# Before (0.1.0) +import httpware + +async with httpware.AsyncClient(base_url="https://api.example.com", auth="my-token") as client: + user = await client.get("/users/1", response_model=User) +``` + +```python +# After (0.2.0) +import httpx2 +import httpware + +async with httpware.AsyncClient( + base_url="https://api.example.com", + headers={"Authorization": "Bearer my-token"}, +) as client: + user = await client.get("/users/1", response_model=User) +``` + +## What's next + +Epic 3 (resilience middleware — retry, timeout, bulkhead) and Epic 5 (observability) ship in subsequent minor releases. See `docs/dev/engineering.md` section 8 for the post-pivot roadmap. +``` + +- [ ] **Step 3: Confirm `just lint-ci` and `just test` still pass** + +Run: +```bash +just lint-ci 2>&1 | tail -5 +just test 2>&1 | tail -10 +``` +Expected: clean. + +- [ ] **Step 4: Commit** + +Run: +```bash +git add pyproject.toml planning/specs/2026-06-03-release-notes-0.2.0.md +git commit -m "chore(release): bump version to 0.2.0 and draft release notes" +``` + +--- + +## Task 23: Final integration sweep + +**Files:** +- All. + +- [ ] **Step 1: Re-read the diff against `main`** + +Run: +```bash +git diff main --stat +``` +Expected: roughly matches the file-map at the top of this plan — deletions in `transports/`, `_internal/auth.py`, `request.py`, `response.py`, `config.py`, the corresponding tests; rewrites in `client.py`, `errors.py`, `middleware/`, `__init__.py`, the corresponding tests; updates to `CLAUDE.md`, `docs/dev/engineering.md`, `planning/deferred-work.md`, `pyproject.toml`; new release-notes file. + +- [ ] **Step 2: Run the perf-marked tests opt-in to confirm no regressions** + +Run: +```bash +uv run --no-sync pytest -m perf 2>&1 | tail -10 +``` +Expected: pass (or skip cleanly). + +- [ ] **Step 3: Confirm `import httpware` works in a fresh interpreter** + +Run: +```bash +uv run python -c "import httpware; print(sorted(httpware.__all__))" +``` +Expected: the v0.2 export list prints, no ImportError, no warnings. + +- [ ] **Step 4: Push branch and open PR (manual step)** + +Run: +```bash +git push -u origin feat/v0.2-thin-httpx2-wrapper +gh pr create --title "v0.2: thin httpx2 wrapper rewrite" --body "$(cat <<'EOF' +## Summary +- Single structural PR for the v0.2 thin-wrapper pivot per `planning/specs/2026-06-03-thin-httpx2-wrapper-design.md`. +- Removes `Request`/`Response`/`Transport`/`RecordedTransport`/auth coercion/`with_options`. +- Re-exports `httpx2.Request`/`httpx2.Response`; adds `httpx2_client=` injection point. +- Middleware retyped on `httpx2.Request`/`httpx2.Response`; chain composition moved to `middleware/chain.py`. +- `StatusError` subclasses now hold a single `response: httpx2.Response`. +- Five protocol seams collapse to three. +- Closes 12 deferred-work items obsoleted by the pivot. +- Bumps version to `0.2.0`; release notes draft included. + +## Test plan +- [ ] `just test` is green at 100% coverage. +- [ ] `just lint-ci` is green. +- [ ] Optional-extras isolation test still passes (`import httpware` doesn't pull `msgspec`). +- [ ] Public-API test asserts no 0.1 names leak. +- [ ] Migration example in release notes verified by inspection. +EOF +)" +``` +This is the final manual step. The agent should NOT push or open the PR without explicit user instruction. + +--- + +## Self-review notes (writer to writer) + +- **Spec coverage:** Each spec section maps to at least one task. Section 1 → Task 19 + 20 (docs); 3 (invariants) → Task 19 + 20; 4 (seams) → Tasks 5-10 + 19 + 20; 5 (layout) → Task 2 + 19 + 20; 6 (public API) → Tasks 7-16; 7 (middleware) → Tasks 5-6 + 14; 8 (errors) → Tasks 3-4; 9 (data flow) → Tasks 9-14; 10 (error mapping table) → Task 9-10; 11 (testing pattern) → every test task; 12 (roadmap) → Task 20 + 21; 13 (cutover plan) → entire plan; 14 (open questions) → none currently. +- **Placeholder scan:** none of the forbidden phrases ("TBD", "TODO", "implement later", "fill in details", "appropriate error handling") appear in any step. +- **Type consistency:** `AsyncClient._terminal`, `AsyncClient.send`, `AsyncClient.build_request`, `AsyncClient._request_with_body`, `AsyncClient._dispatch`, `AsyncClient._user_middleware`, `AsyncClient._decoder`, `AsyncClient._httpx2_client`, `AsyncClient._owns_client` are all referenced consistently across tasks. `Next` is a `TypeAlias` defined in `middleware/__init__.py`; `_Next` is the private alias in `chain.py` (matching, but not re-exported, to avoid the circular import at `chain.py` import time). +- **Coverage gate:** Task 18 step 4 calls out the spot-fix pattern if line coverage drops below 100%. +- **Decoder default:** the spec footnote about `pydantic` being not-truly-opt-in is reflected in the "Open" section of `deferred-work.md` (Task 21). diff --git a/planning/specs/2026-06-03-release-notes-0.2.0.md b/planning/specs/2026-06-03-release-notes-0.2.0.md new file mode 100644 index 0000000..231a24d --- /dev/null +++ b/planning/specs/2026-06-03-release-notes-0.2.0.md @@ -0,0 +1,45 @@ +# httpware 0.2.0 — thin httpx2 wrapper + +**0.2.0 is a breaking rewrite.** The framework is now a thin opinionated wrapper around `httpx2`: it re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain, typed response decoding, and a status-keyed exception tree raised automatically on 4xx/5xx. + +## Breaking changes from 0.1.0 + +- **Removed value types.** `httpware.Request`, `httpware.Response`, `httpware.StreamResponse`, `httpware.Limits`, `httpware.Timeout`, and `httpware.ClientConfig` are gone. Use `httpx2.Request`, `httpx2.Response`, `httpx2.Limits`, `httpx2.Timeout` directly. +- **Removed transport abstraction.** `httpware.Transport`, `httpware.Httpx2Transport`, and `httpware.RecordedTransport` are gone. Tests should inject `httpx2.MockTransport` via the new `httpx2_client=` kwarg. +- **Removed auth coercion.** Pass `httpx2.Auth` (e.g., `httpx2.BasicAuth`) directly to the client. +- **`with_options` removed.** Construct a separate `AsyncClient` wrapping a shared `httpx2.AsyncClient` instead. +- **`StatusError` simplified.** Subclasses no longer accept `status` / `body` / `headers` / `json` / `request_method` / `request_url` kwargs. Construct with a single `response: httpx2.Response` argument and read fields from `exc.response.*`. +- **CI invariant retired.** The "no `httpx2` imports outside `transports/httpx2.py`" rule is gone. `httpx2` is part of the public surface. + +## What still works + +- `AsyncClient.get/post/put/patch/delete/head/options/request` with `response_model=...` for typed decoding. +- `PydanticDecoder` (default) and `MsgspecDecoder` (opt-in via `pip install httpware[msgspec]`). +- Middleware protocol with `@before_request`, `@after_response`, `@on_error` decorators. +- Status-keyed exception tree (`NotFoundError`, `RateLimitedError`, etc.) raised automatically on 4xx/5xx. + +## Migration + +```python +# Before (0.1.0) +import httpware + +async with httpware.AsyncClient(base_url="https://api.example.com", auth="my-token") as client: + user = await client.get("/users/1", response_model=User) +``` + +```python +# After (0.2.0) +import httpx2 +import httpware + +async with httpware.AsyncClient( + base_url="https://api.example.com", + headers={"Authorization": "Bearer my-token"}, +) as client: + user = await client.get("/users/1", response_model=User) +``` + +## What's next + +Epic 3 (resilience middleware — retry, timeout, bulkhead) and Epic 5 (observability) ship in subsequent minor releases. See `planning/engineering.md` section 8 for the post-pivot roadmap. diff --git a/planning/specs/2026-06-03-thin-httpx2-wrapper-design.md b/planning/specs/2026-06-03-thin-httpx2-wrapper-design.md index 166cadc..af00938 100644 --- a/planning/specs/2026-06-03-thin-httpx2-wrapper-design.md +++ b/planning/specs/2026-06-03-thin-httpx2-wrapper-design.md @@ -3,7 +3,7 @@ **Status:** spec — awaiting review **Author:** Artur Shiriev (with Claude) **Date:** 2026-06-03 -**Supersedes:** the 0.1.0 architecture documented in `docs/dev/engineering.md` (sections 2, 3, 5, 8) +**Supersedes:** the 0.1.0 architecture documented in `planning/engineering.md` (sections 2, 3, 5, 8) — formerly at `docs/dev/engineering.md`, moved to `planning/` as part of this pivot ## 1. Intent diff --git a/pyproject.toml b/pyproject.toml index 1d0b979..922e30a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ "Topic :: Internet :: WWW/HTTP", "Framework :: AsyncIO", ] -version = "0" +version = "0.2.0" dependencies = [ "httpx2>=2.0.0,<3.0", "pydantic>=2.0,<3.0", @@ -38,8 +38,7 @@ otel = [ "opentelemetry-api>=1.20", "opentelemetry-sdk>=1.20", ] -niquests = ["niquests>=3.18"] -all = ["httpware[msgspec,otel,niquests]"] +all = ["httpware[msgspec,otel]"] [project.urls] repository = "https://github.com/modern-python/httpware" @@ -74,7 +73,6 @@ fix = true unsafe-fixes = true line-length = 120 target-version = "py311" -extend-exclude = ["docs"] [tool.ruff.lint] select = ["ALL"] @@ -90,24 +88,16 @@ ignore = [ ] isort.lines-after-imports = 2 isort.no-lines-before = ["standard-library", "local-folder"] -pylint.max-args = 10 # HTTP-method APIs are kwarg-rich (headers, params, cookies, timeout, json, content, response_model, …); default 5 is too strict. [tool.ruff.lint.per-file-ignores] -# AsyncClient's HTTP-method `timeout=` is a config-value parameter forwarded to the transport, -# not asyncio.timeout territory. The rule fires on 24+ method signatures in this one file with -# the same false-positive justification; per-file disable is cleaner than 24 per-line noqa. -"src/httpware/client.py" = ["ASYNC109"] +"src/httpware/client.py" = ["ASYNC109", "ANN401"] [tool.pytest.ini_options] -addopts = "--cov=src/httpware --cov-report term-missing -m 'not perf'" +addopts = "--cov=. --cov-report term-missing --cov-fail-under=100" asyncio_mode = "auto" pythonpath = ["src"] asyncio_default_fixture_loop_scope = "function" -markers = [ - "perf: assertive performance tests (skipped by default; run with `pytest -m perf`)", -] [tool.coverage] run.concurrency = ["thread"] -run.omit = ["benchmarks/*"] report.exclude_also = ["if typing.TYPE_CHECKING:"] diff --git a/src/httpware/__init__.py b/src/httpware/__init__.py index 4b73337..dd6f050 100644 --- a/src/httpware/__init__.py +++ b/src/httpware/__init__.py @@ -1,8 +1,6 @@ -"""httpware — resilience-first async HTTP client framework for Python.""" +"""httpware — thin async HTTP client wrapper over httpx2.""" -from httpware._internal.auth import AuthValue from httpware.client import AsyncClient -from httpware.config import ClientConfig, Limits, Timeout from httpware.decoders import ResponseDecoder from httpware.decoders.pydantic import PydanticDecoder from httpware.errors import ( @@ -24,42 +22,27 @@ UnprocessableEntityError, ) from httpware.middleware import Middleware, Next, after_response, before_request, on_error -from httpware.request import Request -from httpware.response import Response, StreamResponse -from httpware.transports import Transport -from httpware.transports.httpx2 import Httpx2Transport -from httpware.transports.recorded import RecordedTransport __all__ = [ "STATUS_TO_EXCEPTION", "AsyncClient", - "AuthValue", "BadRequestError", - "ClientConfig", "ClientError", "ClientStatusError", "ConflictError", "ForbiddenError", - "Httpx2Transport", "InternalServerError", - "Limits", "Middleware", "Next", "NotFoundError", "PydanticDecoder", "RateLimitedError", - "RecordedTransport", - "Request", - "Response", "ResponseDecoder", "ServerStatusError", "ServiceUnavailableError", "StatusError", - "StreamResponse", - "Timeout", "TimeoutError", - "Transport", "TransportError", "UnauthorizedError", "UnprocessableEntityError", diff --git a/src/httpware/_internal/auth.py b/src/httpware/_internal/auth.py deleted file mode 100644 index 8f49b3c..0000000 --- a/src/httpware/_internal/auth.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Normalize the `auth=` value of AsyncClient into a Middleware (or None).""" - -import inspect -from collections.abc import Awaitable, Callable -from typing import TypeAlias - -from httpware.middleware import Middleware, before_request -from httpware.request import Request - - -_MIDDLEWARE_ARITY = 2 - -AuthValue: TypeAlias = str | Callable[[], str | Awaitable[str]] | Middleware | None - - -def _normalize_auth(value: AuthValue) -> Middleware | None: - """Coerce an `auth=` value into a Middleware. - - - `None` → returns `None` (no auth middleware injected). - - `str` → returns a middleware that sets `Authorization: Bearer ` - on every request (skipping if Authorization is already present). - - `Callable[[], str | Awaitable[str]]` (zero-arg) → returns a middleware - that calls the provider per request (awaiting if it returns an - awaitable) and sets `Authorization: Bearer ` (skip-if-present). - - `Middleware` (two-arg `__call__(request, next)`) → returned unchanged. - - Any other callable shape → raises `TypeError` naming `auth=`. - """ - if value is None: - return None - if isinstance(value, str): - return _bearer(value) - if not callable(value): - msg = f"`auth=` must be a string, zero-arg callable, Middleware, or None; got {type(value).__name__}" - raise TypeError(msg) - n_params = len(inspect.signature(value).parameters) - if n_params == 0: - return _bearer_from_provider(value) # ty: ignore[invalid-argument-type] - if n_params == _MIDDLEWARE_ARITY: - return value # ty: ignore[invalid-return-type] - msg = f"`auth=` callable must take 0 args (token provider) or 2 args (Middleware); got {n_params}" - raise TypeError(msg) - - -def _bearer(token: str) -> Middleware: - """Middleware that sets `Authorization: Bearer ` (skip-if-present).""" - - @before_request - async def _add_static_bearer(request: Request) -> Request: - if _has_authorization(request): - return request - return request.with_header("Authorization", f"Bearer {token}") - - return _add_static_bearer - - -def _bearer_from_provider( - provider: Callable[[], str | Awaitable[str]], -) -> Middleware: - """Middleware that calls `provider()` per request and sets the header.""" - - @before_request - async def _add_dynamic_bearer(request: Request) -> Request: - if _has_authorization(request): - return request - token = provider() - if inspect.isawaitable(token): - token = await token - return request.with_header("Authorization", f"Bearer {token}") - - return _add_dynamic_bearer - - -def _has_authorization(request: Request) -> bool: - """Case-insensitive check for an existing Authorization header.""" - return any(k.lower() == "authorization" for k in request.headers) diff --git a/src/httpware/_internal/chain.py b/src/httpware/_internal/chain.py deleted file mode 100644 index 15688bc..0000000 --- a/src/httpware/_internal/chain.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Middleware chain composition — wires a middleware list against a Transport. - -Private helper. AsyncClient calls `compose` at construction time and stores the -returned `Next` callable; per-request dispatch awaits that callable. -""" - -from collections.abc import Sequence - -from httpware.middleware import Middleware, Next -from httpware.request import Request -from httpware.response import Response -from httpware.transports import Transport - - -def compose(middlewares: Sequence[Middleware], transport: Transport) -> Next: - """Fold `middlewares` into a single `Next` callable terminating at `transport`. - - The outermost middleware in the input sequence is the first to receive the - request; its `next` argument forwards to the next middleware, and so on, - until the innermost middleware's `next` calls `transport.__call__`. An - empty sequence returns `transport.__call__` directly. - - The returned callable is reusable across many requests; it captures - references to `middlewares` and `transport` by closure. - """ - chain: Next = transport.__call__ - for middleware in reversed(middlewares): - chain = _wrap(middleware, chain) - return chain - - -def _wrap(middleware: Middleware, next_call: Next) -> Next: - async def _call(request: Request) -> Response: - return await middleware(request, next_call) - - return _call - - -__all__ = ["compose"] diff --git a/src/httpware/client.py b/src/httpware/client.py index ab6c401..1bf9a3d 100644 --- a/src/httpware/client.py +++ b/src/httpware/client.py @@ -1,530 +1,580 @@ -"""AsyncClient — the v0.1.0 public surface of httpware.""" +"""AsyncClient — the thin httpx2 wrapper.""" -import dataclasses -import json as _json import typing -from collections.abc import Mapping, Sequence +from collections.abc import Sequence +from http import HTTPStatus + +import httpx2 -from httpware._internal.auth import AuthValue, _normalize_auth -from httpware._internal.chain import compose -from httpware.config import ClientConfig, Limits, Timeout from httpware.decoders import ResponseDecoder from httpware.decoders.pydantic import PydanticDecoder +from httpware.errors import ( + STATUS_TO_EXCEPTION, + ClientStatusError, + ServerStatusError, + TimeoutError, # noqa: A004 + TransportError, +) from httpware.middleware import Middleware, Next -from httpware.request import Request -from httpware.response import Response -from httpware.transports import Transport -from httpware.transports.httpx2 import Httpx2Transport - +from httpware.middleware.chain import compose -_UNSET: typing.Any = object() T = typing.TypeVar("T") -# Recursive type alias for any JSON-serializable Python value. Used for the `json=` body parameter -# on HTTP methods so we avoid `Any` while still accepting arbitrary nested structures. -JsonValue: typing.TypeAlias = Mapping[str, "JsonValue"] | Sequence["JsonValue"] | str | int | float | bool | None - -def _normalize_timeout(value: Timeout | float | None) -> Timeout: - if value is None: - return Timeout() - if isinstance(value, Timeout): - return value - return Timeout(connect=value, read=value, write=value, pool=value) - - -def _build_body( - json_value: JsonValue, - content: bytes | None, -) -> tuple[bytes | None, str | None]: - if json_value is not None and content is not None: - msg = "pass either `json` or `content`, not both" - raise TypeError(msg) - if json_value is not None: - return _json.dumps(json_value).encode("utf-8"), "application/json" - return content, None +_FORWARDED_KWARG_NAMES = ("base_url", "headers", "params", "cookies", "timeout", "limits", "auth") +_HTTPX2_CLIENT_CONFLICT_MESSAGE = ( + "AsyncClient(httpx2_client=...) cannot be combined with any of " + f"{_FORWARDED_KWARG_NAMES}; configure the httpx2.AsyncClient you pass instead." +) class AsyncClient: - """Async HTTP client with typed response decoding and middleware composition.""" + """Async HTTP client: thin wrapper around httpx2 with typed decoding and middleware.""" - _config: ClientConfig - _transport: Transport - _dispatch: Next - _owns_transport: bool + _httpx2_client: httpx2.AsyncClient + _owns_client: bool + _decoder: ResponseDecoder _user_middleware: tuple[Middleware, ...] - _auth: AuthValue + _dispatch: Next - def __init__( + def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call API self, *, - base_url: str | None = None, - default_headers: Mapping[str, str] | None = None, - default_query: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - limits: Limits | None = None, - transport: Transport | None = None, + base_url: str = "", + headers: dict[str, str] | None = None, + params: dict[str, str] | None = None, + cookies: dict[str, str] | None = None, + timeout: httpx2.Timeout | float | None = None, + limits: httpx2.Limits | None = None, + auth: httpx2.Auth | None = None, + httpx2_client: httpx2.AsyncClient | None = None, decoder: ResponseDecoder | None = None, - middleware: Sequence[Middleware] | None = None, - auth: AuthValue = None, + middleware: Sequence[Middleware] = (), ) -> None: - normalized_timeout = _normalize_timeout(timeout) - resolved_limits = limits or Limits() - resolved_transport: Transport = transport or Httpx2Transport(limits=resolved_limits, timeout=normalized_timeout) - resolved_decoder = decoder or PydanticDecoder() - resolved_user_middleware: tuple[Middleware, ...] = tuple(middleware) if middleware is not None else () - resolved_auth_middleware = _normalize_auth(auth) - composed_middleware: tuple[Middleware, ...] = ( - resolved_user_middleware - if resolved_auth_middleware is None - else (*resolved_user_middleware, resolved_auth_middleware) - ) + if httpx2_client is not None: + forwarded = { + "base_url": base_url, + "headers": headers, + "params": params, + "cookies": cookies, + "timeout": timeout, + "limits": limits, + "auth": auth, + } + if any(value not in (None, "") for value in forwarded.values()): + raise TypeError(_HTTPX2_CLIENT_CONFLICT_MESSAGE) + self._httpx2_client = httpx2_client + self._owns_client = False + else: + kwargs: dict[str, typing.Any] = {} + if base_url: + kwargs["base_url"] = base_url + if headers is not None: + kwargs["headers"] = headers + if params is not None: + kwargs["params"] = params + if cookies is not None: + kwargs["cookies"] = cookies + if timeout is not None: + kwargs["timeout"] = timeout + if limits is not None: + kwargs["limits"] = limits + if auth is not None: + kwargs["auth"] = auth + self._httpx2_client = httpx2.AsyncClient(**kwargs) + self._owns_client = True + + self._decoder = decoder if decoder is not None else PydanticDecoder() + self._user_middleware = tuple(middleware) + self._dispatch = compose(self._user_middleware, self._terminal) + + async def _terminal(self, request: httpx2.Request) -> httpx2.Response: + try: + response = await self._httpx2_client.send(request) + except httpx2.TimeoutException as exc: + raise TimeoutError(str(exc)) from exc + except (httpx2.InvalidURL, httpx2.CookieConflict) as exc: + raise TransportError(str(exc)) from exc + except httpx2.HTTPError as exc: + raise TransportError(str(exc)) from exc + except RuntimeError as exc: + if "closed" in str(exc): + raise TransportError(str(exc)) from exc + raise + status = response.status_code + if HTTPStatus.BAD_REQUEST <= status < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx + exc_class = STATUS_TO_EXCEPTION.get( + status, + ClientStatusError if status < HTTPStatus.INTERNAL_SERVER_ERROR else ServerStatusError, + ) + raise exc_class(response) + return response - self._config = ClientConfig( - base_url=base_url, - default_headers=dict(default_headers or {}), - default_query=dict(default_query or {}), - timeout=normalized_timeout, - limits=resolved_limits, - decoder=resolved_decoder, - middleware=composed_middleware, - ) - self._transport = resolved_transport - self._dispatch = compose(composed_middleware, resolved_transport) - self._owns_transport = True - self._user_middleware = resolved_user_middleware - self._auth = auth - - @classmethod - def from_url(cls, base_url: str, **kwargs: object) -> "AsyncClient": - """Construct an AsyncClient with a base URL prefix.""" - return cls(base_url=base_url, **kwargs) # ty: ignore[invalid-argument-type] + @typing.overload + async def send(self, request: httpx2.Request, *, response_model: None = None) -> httpx2.Response: ... - async def __aenter__(self) -> typing.Self: - return self + @typing.overload + async def send(self, request: httpx2.Request, *, response_model: type[T]) -> T: ... - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc: BaseException | None, - tb: object, - ) -> None: - if self._owns_transport: - await self._transport.aclose() - - def _resolve_url(self, path: str) -> str: - if path.startswith(("http://", "https://")): - return path - base = self._config.base_url - if base is None: - return path - return f"{base}/{path.lstrip('/')}" - - def _build_request( + async def send( self, - method: str, - path: str, + request: httpx2.Request, *, - headers: Mapping[str, str] | None, - params: Mapping[str, str] | None, - cookies: Mapping[str, str] | None, - timeout: Timeout | float | None, - body: bytes | None, - content_type: str | None, - ) -> Request: - merged_headers: dict[str, str] = {**self._config.default_headers, **(headers or {})} - if content_type is not None and "content-type" not in {k.lower() for k in merged_headers}: - merged_headers["content-type"] = content_type - merged_params: dict[str, str] = {**self._config.default_query, **(params or {})} - extensions: dict[str, typing.Any] = {} - if timeout is not None: - extensions["timeout"] = _normalize_timeout(timeout) - return Request( - method=method, - url=self._resolve_url(path), - headers=merged_headers, - params=merged_params, - cookies=dict(cookies or {}), - body=body, - extensions=extensions, - ) + response_model: type[T] | None = None, + ) -> httpx2.Response | T: + """Send `request` through the middleware chain. Decode if `response_model` is set.""" + response = await self._dispatch(request) + if response_model is None: + return response + return self._decoder.decode(response.content, response_model) - async def _send( + def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request: + """Delegate request construction to the wrapped httpx2.AsyncClient.""" + return self._httpx2_client.build_request(method, url, **kwargs) + + async def _request_with_body( # noqa: PLR0913 — mirrors httpx2 per-method signatures self, method: str, - path: str, + url: str, *, - headers: Mapping[str, str] | None, - params: Mapping[str, str] | None, - cookies: Mapping[str, str] | None, - timeout: Timeout | float | None, - body: bytes | None, - content_type: str | None, - response_model: type[T] | None, - ) -> Response | T: - request = self._build_request( - method, - path, - headers=headers, - params=params, - cookies=cookies, - timeout=timeout, - body=body, - content_type=content_type, - ) - response = await self._dispatch(request) - if response_model is None: - return response - return self._config.decoder.decode(response.content, response_model) + 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: + 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) + return await self.send(request, response_model=response_model) @typing.overload async def get( self, - path: str, + url: str, *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, + 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: None = None, - ) -> Response: ... + ) -> httpx2.Response: ... @typing.overload async def get( self, - path: str, + url: str, *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, + 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], ) -> T: ... - async def get( + async def get( # noqa: PLR0913 — mirrors httpx2 per-method signatures self, - path: str, + url: str, *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, + 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] | None = None, - ) -> Response | T: + ) -> httpx2.Response | T: """Send a GET request.""" - return await self._send( + return await self._request_with_body( "GET", - path, - headers=headers, + url, params=params, + headers=headers, cookies=cookies, timeout=timeout, - body=None, - content_type=None, + extensions=extensions, response_model=response_model, ) @typing.overload async def post( self, - path: str, + url: str, *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - json: JsonValue = None, - content: bytes | None = None, + 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: None = None, - ) -> Response: ... + ) -> httpx2.Response: ... @typing.overload async def post( self, - path: str, + url: str, *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - json: JsonValue = None, - content: bytes | None = None, + 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], ) -> T: ... - async def post( + async def post( # noqa: PLR0913 — mirrors httpx2 per-method signatures self, - path: str, + url: str, *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - json: JsonValue = None, - content: bytes | None = None, + 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, - ) -> Response | T: + ) -> httpx2.Response | T: """Send a POST request.""" - body, content_type = _build_body(json, content) - return await self._send( + return await self._request_with_body( "POST", - path, - headers=headers, + url, params=params, + headers=headers, cookies=cookies, timeout=timeout, - body=body, - content_type=content_type, + extensions=extensions, + json=json, + content=content, + data=data, + files=files, response_model=response_model, ) @typing.overload async def put( self, - path: str, + url: str, *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - json: JsonValue = None, - content: bytes | None = None, + 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: None = None, - ) -> Response: ... + ) -> httpx2.Response: ... @typing.overload async def put( self, - path: str, + url: str, *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - json: JsonValue = None, - content: bytes | None = None, + 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], ) -> T: ... - async def put( + async def put( # noqa: PLR0913 — mirrors httpx2 per-method signatures self, - path: str, + url: str, *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - json: JsonValue = None, - content: bytes | None = None, + 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, - ) -> Response | T: + ) -> httpx2.Response | T: """Send a PUT request.""" - body, content_type = _build_body(json, content) - return await self._send( + return await self._request_with_body( "PUT", - path, - headers=headers, + url, params=params, + headers=headers, cookies=cookies, timeout=timeout, - body=body, - content_type=content_type, + extensions=extensions, + json=json, + content=content, + data=data, + files=files, response_model=response_model, ) @typing.overload async def patch( self, - path: str, + url: str, *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - json: JsonValue = None, - content: bytes | None = None, + 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: None = None, - ) -> Response: ... + ) -> httpx2.Response: ... @typing.overload async def patch( self, - path: str, + url: str, *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - json: JsonValue = None, - content: bytes | None = None, + 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], ) -> T: ... - async def patch( + async def patch( # noqa: PLR0913 — mirrors httpx2 per-method signatures self, - path: str, + url: str, *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - json: JsonValue = None, - content: bytes | None = None, + 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, - ) -> Response | T: + ) -> httpx2.Response | T: """Send a PATCH request.""" - body, content_type = _build_body(json, content) - return await self._send( + return await self._request_with_body( "PATCH", - path, - headers=headers, + url, params=params, + headers=headers, cookies=cookies, timeout=timeout, - body=body, - content_type=content_type, + extensions=extensions, + json=json, + content=content, + data=data, + files=files, response_model=response_model, ) @typing.overload async def delete( self, - path: str, + url: str, *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, + 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: None = None, - ) -> Response: ... + ) -> httpx2.Response: ... @typing.overload async def delete( self, - path: str, + url: str, *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, + 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], ) -> T: ... - async def delete( + async def delete( # noqa: PLR0913 — mirrors httpx2 per-method signatures self, - path: str, + url: str, *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, + 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, - ) -> Response | T: + ) -> httpx2.Response | T: """Send a DELETE request.""" - return await self._send( + return await self._request_with_body( "DELETE", - path, - headers=headers, + url, params=params, + headers=headers, cookies=cookies, timeout=timeout, - body=None, - content_type=None, + extensions=extensions, + json=json, + content=content, + data=data, + files=files, response_model=response_model, ) @typing.overload async def head( self, - path: str, + url: str, *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, + 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: None = None, - ) -> Response: ... + ) -> httpx2.Response: ... @typing.overload async def head( self, - path: str, + url: str, *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, + 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], ) -> T: ... - async def head( + async def head( # noqa: PLR0913 — mirrors httpx2 per-method signatures self, - path: str, + url: str, *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, + 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] | None = None, - ) -> Response | T: + ) -> httpx2.Response | T: """Send a HEAD request.""" - return await self._send( + return await self._request_with_body( "HEAD", - path, - headers=headers, + url, params=params, + headers=headers, cookies=cookies, timeout=timeout, - body=None, - content_type=None, + extensions=extensions, response_model=response_model, ) @typing.overload async def options( self, - path: str, + url: str, *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, + 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: None = None, - ) -> Response: ... + ) -> httpx2.Response: ... @typing.overload async def options( self, - path: str, + url: str, *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, + 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], ) -> T: ... - async def options( + async def options( # noqa: PLR0913 — mirrors httpx2 per-method signatures self, - path: str, + url: str, *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, + 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] | None = None, - ) -> Response | T: + ) -> httpx2.Response | T: """Send an OPTIONS request.""" - return await self._send( + return await self._request_with_body( "OPTIONS", - path, - headers=headers, + url, params=params, + headers=headers, cookies=cookies, timeout=timeout, - body=None, - content_type=None, + extensions=extensions, response_model=response_model, ) @@ -532,128 +582,80 @@ async def options( async def request( self, method: str, - path: str, + url: str, *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - json: JsonValue = None, - content: bytes | None = None, + 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: None = None, - ) -> Response: ... + ) -> httpx2.Response: ... @typing.overload async def request( self, method: str, - path: str, + url: str, *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - json: JsonValue = None, - content: bytes | None = None, + 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], ) -> T: ... - async def request( + async def request( # noqa: PLR0913 — mirrors httpx2 per-method signatures self, method: str, - path: str, + url: str, *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - json: JsonValue = None, - content: bytes | None = None, + 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, - ) -> Response | T: + ) -> httpx2.Response | T: """Send a request with an arbitrary HTTP method.""" - body, content_type = _build_body(json, content) - return await self._send( + return await self._request_with_body( method, - path, - headers=headers, + url, params=params, + headers=headers, cookies=cookies, timeout=timeout, - body=body, - content_type=content_type, + extensions=extensions, + json=json, + content=content, + data=data, + files=files, response_model=response_model, ) - def with_options( - self, - *, - base_url: str | None = _UNSET, - default_headers: Mapping[str, str] | None = _UNSET, - default_query: Mapping[str, str] | None = _UNSET, - timeout: Timeout | float | None = _UNSET, - decoder: ResponseDecoder | None = _UNSET, - middleware: Sequence[Middleware] | None = _UNSET, - auth: AuthValue | object = _UNSET, - ) -> "AsyncClient": - """Return a new AsyncClient sharing the same transport with overridden config. - - The returned client is a "view": it does NOT own the transport lifecycle. - Closing it via `async with` is a no-op. The original client should be the - one inside the outermost `async with` block. - - `limits` and `transport` are NOT overridable here — both bind to the - transport, which is shared. Construct a fresh AsyncClient for those. - """ - changes: dict[str, typing.Any] = {} - if base_url is not _UNSET: - changes["base_url"] = base_url - if default_headers is not _UNSET: - changes["default_headers"] = dict(default_headers or {}) - if default_query is not _UNSET: - changes["default_query"] = dict(default_query or {}) - if timeout is not _UNSET: - changes["timeout"] = _normalize_timeout(timeout) - if decoder is not _UNSET: - changes["decoder"] = decoder or PydanticDecoder() - - new_user_middleware = self._user_middleware - if middleware is not _UNSET: - new_user_middleware = tuple(middleware) if middleware is not None else () - - new_auth: AuthValue = self._auth - if auth is not _UNSET: - new_auth = auth # ty: ignore[invalid-assignment] - - new_auth_middleware = _normalize_auth(new_auth) - new_composed: tuple[Middleware, ...] = ( - new_user_middleware if new_auth_middleware is None else (*new_user_middleware, new_auth_middleware) - ) - changes["middleware"] = new_composed - - new_config = dataclasses.replace(self._config, **changes) - return AsyncClient._from_view( - new_config, - self._transport, - user_middleware=new_user_middleware, - auth=new_auth, - ) + async def __aenter__(self) -> typing.Self: + """Enter the async context manager; return self.""" + return self - @classmethod - def _from_view( - cls, - config: ClientConfig, - transport: Transport, - *, - user_middleware: tuple[Middleware, ...], - auth: AuthValue, - ) -> "AsyncClient": - """Construct a view sharing an existing transport. Bypasses __init__.""" - client = cls.__new__(cls) - client._config = config # noqa: SLF001 - client._transport = transport # noqa: SLF001 - client._dispatch = compose(config.middleware, transport) # noqa: SLF001 - client._owns_transport = False # noqa: SLF001 - client._user_middleware = user_middleware # noqa: SLF001 - client._auth = auth # noqa: SLF001 - return client + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: object, + ) -> None: + """Exit the async context manager; close the underlying client only if owned.""" + if self._owns_client and not self._httpx2_client.is_closed: + await self._httpx2_client.aclose() diff --git a/src/httpware/config.py b/src/httpware/config.py deleted file mode 100644 index bfa49bf..0000000 --- a/src/httpware/config.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Immutable configuration value types: Limits, Timeout, ClientConfig.""" - -from collections.abc import Mapping -from dataclasses import dataclass, field - -from httpware.decoders import ResponseDecoder -from httpware.decoders.pydantic import PydanticDecoder -from httpware.middleware import Middleware - - -@dataclass(frozen=True, slots=True) -class Timeout: - """Per-phase request timeout configuration (seconds).""" - - connect: float = 5.0 - read: float = 30.0 - write: float = 30.0 - pool: float = 5.0 - - def __post_init__(self) -> None: - for attr in ("connect", "read", "write", "pool"): - value = getattr(self, attr) - if value < 0: - msg = f"Timeout.{attr} must be non-negative (got {value})" - raise ValueError(msg) - - -@dataclass(frozen=True, slots=True) -class Limits: - """Connection-pool limits.""" - - max_connections: int = 100 - max_keepalive_connections: int = 20 - keepalive_expiry: float = 5.0 - - def __post_init__(self) -> None: - if self.max_connections < 0: - msg = f"max_connections must be non-negative (got {self.max_connections})" - raise ValueError(msg) - if self.max_keepalive_connections < 0: - msg = f"max_keepalive_connections must be non-negative (got {self.max_keepalive_connections})" - raise ValueError(msg) - if self.keepalive_expiry < 0: - msg = f"keepalive_expiry must be non-negative (got {self.keepalive_expiry})" - raise ValueError(msg) - - -@dataclass(frozen=True, slots=True) -class ClientConfig: - """Immutable client configuration bag.""" - - base_url: str | None = None - default_headers: Mapping[str, str] = field(default_factory=dict) - default_query: Mapping[str, str] = field(default_factory=dict) - timeout: Timeout = field(default_factory=Timeout) - limits: Limits = field(default_factory=Limits) - decoder: ResponseDecoder = field(default_factory=PydanticDecoder) - middleware: tuple[Middleware, ...] = () - - def __post_init__(self) -> None: - if self.base_url is not None: - if not isinstance(self.base_url, str): - msg = "base_url must be a non-empty string or None" - raise ValueError(msg) - normalized = self.base_url.rstrip("/") - if not normalized: - msg = "base_url must be a non-empty string or None" - raise ValueError(msg) - object.__setattr__(self, "base_url", normalized) diff --git a/src/httpware/errors.py b/src/httpware/errors.py index 05672e7..321630d 100644 --- a/src/httpware/errors.py +++ b/src/httpware/errors.py @@ -1,60 +1,37 @@ -"""Status-keyed exception hierarchy with plain typed fields. - -Fallback rule: unknown 4xx statuses fall back to ``ClientStatusError``; -unknown 5xx fall back to ``ServerStatusError``. The fallback assumes -``400 <= status < 600`` — callers must guard against non-error statuses -(1xx informational, 2xx success, 3xx redirect) before consulting -``STATUS_TO_EXCEPTION``. The resolution logic lives at the transport -seam (Story 1.4); this module only ships the classes and the lookup dict. - -``__repr__`` and the summary message passed to ``Exception.__init__`` -strip ``user:pass@`` userinfo from ``request_url`` to avoid leaking -credentials in tracebacks, log lines, and exception reporters. -Query-string secrets (e.g. ``?api_key=...``) are NOT stripped here — -full redaction is the responsibility of the ``Redactor`` middleware -(Story 5.3). +"""Status-keyed exception hierarchy. + +Auto-raise rule lives at AsyncClient's internal terminal (see client.py). +Unknown 4xx falls back to ClientStatusError; unknown 5xx to ServerStatusError. +The fallback assumes 400 <= status < 600. + +__repr__ and the summary message strip user:pass@ userinfo from +response.request.url to avoid leaking credentials in tracebacks. +Query-string secrets are NOT stripped here. """ import builtins from collections.abc import Mapping -from types import MappingProxyType from typing import Any from urllib.parse import urlsplit, urlunsplit +import httpx2 + def _strip_userinfo(url: str) -> str: - """Drop the ``user:pass@`` portion of ``url`` if present.""" if "@" not in url or "://" not in url: return url parts = urlsplit(url) if parts.username is None and parts.password is None: return url - netloc = parts.hostname or "" + hostname = parts.hostname or "" + if ":" in hostname: # IPv6 literal — re-wrap in brackets + hostname = f"[{hostname}]" + netloc = hostname if parts.port is not None: netloc = f"{netloc}:{parts.port}" return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment)) -def _reconstruct_status_error( - cls: "type[StatusError]", - status: int, - body: bytes, - headers: Mapping[str, str], - json: Any, # noqa: ANN401 - request_method: str, - request_url: str, -) -> "StatusError": - """Pickle / copy reconstructor for ``StatusError`` subclasses.""" - return cls( - status=status, - body=body, - headers=headers, - json=json, - request_method=request_method, - request_url=request_url, - ) - - class ClientError(Exception): """Root of the httpware exception tree.""" @@ -66,70 +43,42 @@ class TransportError(ClientError): class TimeoutError(ClientError, builtins.TimeoutError): # noqa: A001 """Client-side timeout (connect / read / write / pool). - Inherits from both ``httpware.ClientError`` and ``builtins.TimeoutError`` - so ``except httpware.TimeoutError`` catches httpware-raised timeouts AND + Inherits from both ``httpware.ClientError`` and ``builtins.TimeoutError`` so ``except builtins.TimeoutError`` / ``except OSError`` (the form - ``asyncio.wait_for`` uses) also catches them. Deliberately shadows - ``builtins.TimeoutError``; see Decision 3 in ``docs/architecture.md``. - Do not "fix" this name. + ``asyncio.wait_for`` uses) also catches httpware-raised timeouts. + Deliberate shadowing of the builtin; do not rename. """ +def _reconstruct_status_error(cls: "type[StatusError]", response: httpx2.Response) -> "StatusError": + return cls(response) + + class StatusError(ClientError): - """Base for HTTP-status-keyed errors with plain typed fields.""" - - status: int - body: bytes - headers: Mapping[str, str] - json: Any - request_method: str - request_url: str - - def __init__( - self, - *, - status: int, - body: bytes, - headers: Mapping[str, str], - json: Any | None, # noqa: ANN401 - request_method: str, - request_url: str, - ) -> None: - """Store all six fields and emit a short summary message to ``Exception.__init__``. - - Subclasses overriding ``__init__`` MUST call - ``super().__init__(status=..., body=..., headers=..., json=..., - request_method=..., request_url=...)`` to register ``args`` and the - summary message; otherwise ``str(exc)`` is silently empty. - ``headers`` is defensively copied into a read-only ``MappingProxyType`` - so caller mutations after ``raise`` do not bleed into the exception. - """ - self.status = status - self.body = body - self.headers = MappingProxyType(dict(headers)) - self.json = json - self.request_method = request_method - self.request_url = request_url - super().__init__(f"{status} {request_method} {_strip_userinfo(request_url)}") + """Base for HTTP-status-keyed errors. + + Holds the raw httpx2.Response. Subclasses do not override __init__. + """ + + response: httpx2.Response + + def __init__(self, response: httpx2.Response) -> None: + self.response = response + super().__init__(self._summary()) + + def _summary(self) -> str: + method = self.response.request.method + url = _strip_userinfo(str(self.response.request.url)) + return f"{self.response.status_code} {method} {url}" def __repr__(self) -> str: cls_name = type(self).__name__ - safe_url = _strip_userinfo(self.request_url) - return f"<{cls_name} status={self.status} method={self.request_method} url={safe_url}>" + method = self.response.request.method + url = _strip_userinfo(str(self.response.request.url)) + return f"<{cls_name} status={self.response.status_code} method={method} url={url}>" def __reduce__(self) -> tuple[Any, ...]: - return ( - _reconstruct_status_error, - ( - type(self), - self.status, - self.body, - dict(self.headers), - self.json, - self.request_method, - self.request_url, - ), - ) + return (_reconstruct_status_error, (type(self), self.response)) class ClientStatusError(StatusError): @@ -141,46 +90,41 @@ class ServerStatusError(StatusError): class BadRequestError(ClientStatusError): - """HTTP 400 Bad Request.""" + """HTTP 400.""" class UnauthorizedError(ClientStatusError): - """HTTP 401 Unauthorized.""" + """HTTP 401.""" class ForbiddenError(ClientStatusError): - """HTTP 403 Forbidden.""" + """HTTP 403.""" class NotFoundError(ClientStatusError): - """HTTP 404 Not Found.""" + """HTTP 404.""" class ConflictError(ClientStatusError): - """HTTP 409 Conflict.""" + """HTTP 409.""" class UnprocessableEntityError(ClientStatusError): - """HTTP 422 Unprocessable Entity.""" + """HTTP 422.""" class RateLimitedError(ClientStatusError): - """HTTP 429 Too Many Requests.""" + """HTTP 429.""" class InternalServerError(ServerStatusError): - """HTTP 500 Internal Server Error.""" + """HTTP 500.""" class ServiceUnavailableError(ServerStatusError): - """HTTP 503 Service Unavailable.""" + """HTTP 503.""" -# Unknown 4xx → ``ClientStatusError``; unknown 5xx → ``ServerStatusError``. -# Fallback assumes ``400 <= status < 600`` — callers must guard against -# non-error codes (1xx/2xx/3xx) before consulting this dict. The fallback -# resolution lives at the call site (Story 1.4 inlines it at the transport -# seam). STATUS_TO_EXCEPTION: Mapping[int, type[StatusError]] = { 400: BadRequestError, 401: UnauthorizedError, diff --git a/src/httpware/middleware/__init__.py b/src/httpware/middleware/__init__.py index c567159..fcecced 100644 --- a/src/httpware/middleware/__init__.py +++ b/src/httpware/middleware/__init__.py @@ -1,40 +1,33 @@ -"""Middleware protocol — the AsyncClient ↔ Middleware seam (Seam 2).""" +"""Middleware protocol, Next type, and phase-shortcut decorators. + +Middleware operates directly on httpx2.Request / httpx2.Response — there is +no httpware-owned request type. The chain is composed at AsyncClient.__init__ +(see client.py) and frozen for the client's lifetime. +""" from collections.abc import Awaitable, Callable from typing import Protocol, TypeAlias, runtime_checkable -from httpware.request import Request -from httpware.response import Response +import httpx2 -Next: TypeAlias = Callable[[Request], Awaitable[Response]] +Next: TypeAlias = Callable[[httpx2.Request], Awaitable[httpx2.Response]] @runtime_checkable class Middleware(Protocol): - """Structural protocol every middleware satisfies. - - A middleware receives the incoming `Request` and a `Next` callable. It may - inspect/transform the request, await `next(request)` to forward to the rest - of the chain (eventually the transport), inspect/transform the returned - `Response`, short-circuit by returning a `Response` without calling `next`, - or raise. - """ + """Structural protocol every middleware satisfies.""" - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 """Process `request`; call `next(request)` to forward, or synthesize a Response.""" ... -def before_request(f: Callable[[Request], Awaitable[Request]]) -> Middleware: - """Wrap an async request transform into a Middleware. - - The decorated function receives the incoming Request and returns a - (possibly modified) Request, which is then forwarded down the chain. - """ +def before_request(f: Callable[[httpx2.Request], Awaitable[httpx2.Request]]) -> Middleware: + """Wrap an async request transform into a Middleware.""" class _BeforeRequestMiddleware: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 return await next(await f(request)) def __repr__(self) -> str: @@ -43,15 +36,13 @@ def __repr__(self) -> str: return _BeforeRequestMiddleware() -def after_response(f: Callable[[Request, Response], Awaitable[Response]]) -> Middleware: - """Wrap an async response transform into a Middleware. - - The decorated function receives the original Request and the Response - returned by the chain, and returns a (possibly modified) Response. - """ +def after_response( + f: Callable[[httpx2.Request, httpx2.Response], Awaitable[httpx2.Response]], +) -> Middleware: + """Wrap an async response transform into a Middleware.""" class _AfterResponseMiddleware: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 response = await next(request) return await f(request, response) @@ -61,17 +52,17 @@ def __repr__(self) -> str: return _AfterResponseMiddleware() -def on_error(f: Callable[[Request, Exception], Awaitable[Response | None]]) -> Middleware: +def on_error( + f: Callable[[httpx2.Request, Exception], Awaitable[httpx2.Response | None]], +) -> Middleware: """Wrap an async error handler into a Middleware. - Catches Exception (not BaseException, so asyncio.CancelledError - propagates). If the handler returns a Response, that Response is - returned to the caller. If the handler returns None, the original - exception is re-raised. + Catches Exception (not BaseException), so asyncio.CancelledError propagates. + Handler returning None re-raises; returning a Response replaces the failure. """ class _OnErrorMiddleware: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 try: return await next(request) except Exception as exc: @@ -84,6 +75,3 @@ def __repr__(self) -> str: return f"" # ty: ignore[unresolved-attribute] return _OnErrorMiddleware() - - -__all__ = ["Middleware", "Next", "after_response", "before_request", "on_error"] diff --git a/src/httpware/middleware/chain.py b/src/httpware/middleware/chain.py new file mode 100644 index 0000000..e3a1a13 --- /dev/null +++ b/src/httpware/middleware/chain.py @@ -0,0 +1,31 @@ +"""Chain composition for the middleware stack.""" + +import typing +from collections.abc import Awaitable, Callable, Sequence + +import httpx2 + + +if typing.TYPE_CHECKING: + from httpware.middleware import Middleware + + +_Next: typing.TypeAlias = Callable[[httpx2.Request], Awaitable[httpx2.Response]] + + +def compose(middleware: "Sequence[Middleware]", terminal: _Next) -> _Next: + """Fold `middleware` into a single callable around `terminal`. + + The first middleware in the sequence is the outermost wrapper. + """ + dispatch: _Next = terminal + for layer in reversed(middleware): + dispatch = _wrap(layer, dispatch) + return dispatch + + +def _wrap(layer: "Middleware", inner: _Next) -> _Next: + async def call(request: httpx2.Request) -> httpx2.Response: + return await layer(request, inner) + + return call diff --git a/src/httpware/request.py b/src/httpware/request.py deleted file mode 100644 index b5e3413..0000000 --- a/src/httpware/request.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Immutable request value type.""" - -import dataclasses -from collections.abc import Mapping -from dataclasses import dataclass, field -from typing import Any, Self - - -def _validate_header_or_cookie(name: str, value: str, *, kind: str) -> None: - if not isinstance(name, str) or not isinstance(value, str): - msg = f"{kind} name and value must be str" - raise TypeError(msg) - if not name or not value: - msg = f"{kind} name and value must be non-empty" - raise ValueError(msg) - if any(c in name or c in value for c in ("\r", "\n")): - msg = f"{kind} name and value must not contain CR or LF" - raise ValueError(msg) - - -@dataclass(frozen=True, slots=True) -class Request: - """Immutable HTTP request value type.""" - - method: str - url: str - headers: Mapping[str, str] = field(default_factory=dict) - params: Mapping[str, str] = field(default_factory=dict) - cookies: Mapping[str, str] = field(default_factory=dict) - body: bytes | None = None - extensions: Mapping[str, Any] = field(default_factory=dict) - - def __post_init__(self) -> None: - if not isinstance(self.url, str): - msg = "url must be str" - raise TypeError(msg) - if not self.url: - msg = "url must be non-empty" - raise ValueError(msg) - for field_name in ("headers", "params", "cookies", "extensions"): - field_value = getattr(self, field_name) - if not isinstance(field_value, Mapping): - msg = f"{field_name} must be a Mapping (got {type(field_value).__name__})" - raise TypeError(msg) - for name, value in self.headers.items(): - _validate_header_or_cookie(name, value, kind="header") - for name, value in self.cookies.items(): - _validate_header_or_cookie(name, value, kind="cookie") - - def with_header(self, name: str, value: str) -> Self: - """Return a copy with the given header added or replaced.""" - return dataclasses.replace(self, headers={**self.headers, name: value}) - - def with_url(self, url: str) -> Self: - """Return a copy with the given URL.""" - return dataclasses.replace(self, url=url) - - def with_body(self, body: bytes | None) -> Self: - """Return a copy with the given body.""" - return dataclasses.replace(self, body=body) - - def with_query(self, params: Mapping[str, str]) -> Self: - """Return a copy with the given query params replacing the existing ones.""" - return dataclasses.replace(self, params=params) - - def with_headers(self, headers: Mapping[str, str]) -> Self: - """Return a copy with the given headers merged in (incoming keys override existing).""" - return dataclasses.replace(self, headers={**self.headers, **headers}) - - def with_cookie(self, name: str, value: str) -> Self: - """Return a copy with the given cookie added or replaced.""" - return dataclasses.replace(self, cookies={**self.cookies, name: value}) - - def with_cookies(self, cookies: Mapping[str, str]) -> Self: - """Return a copy with the given cookies merged in (incoming keys override existing).""" - return dataclasses.replace(self, cookies={**self.cookies, **cookies}) - - def with_extension(self, name: str, value: Any) -> Self: # noqa: ANN401 - """Return a copy with the given extension entry added or replaced.""" - return dataclasses.replace(self, extensions={**self.extensions, name: value}) - - def with_extensions(self, extensions: Mapping[str, Any]) -> Self: - """Return a copy with the given extensions merged in (incoming keys override existing).""" - return dataclasses.replace(self, extensions={**self.extensions, **extensions}) diff --git a/src/httpware/response.py b/src/httpware/response.py deleted file mode 100644 index 568491d..0000000 --- a/src/httpware/response.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Immutable response value type.""" - -import dataclasses -import json -from collections.abc import Mapping -from dataclasses import dataclass -from typing import Any, Self - - -_CHARSET_PREFIX = "charset=" - - -def _get_content_type(headers: Mapping[str, str]) -> str: - for key, value in headers.items(): - if key.lower() == "content-type": - return value - return "" - - -def _parse_charset(content_type: str) -> str | None: - for raw in content_type.split(";"): - part = raw.strip() - if part.lower().startswith(_CHARSET_PREFIX): - return part[len(_CHARSET_PREFIX) :].strip().strip('"').strip("'").strip() - return None - - -@dataclass(frozen=True, slots=True) -class Response: - """Immutable HTTP response value type. - - `elapsed` is wall-clock seconds from request send to response receipt. - """ - - status: int - headers: Mapping[str, str] - content: bytes - url: str - elapsed: float - - @property - def text(self) -> str: - """Decode `content` using the response's declared charset (default UTF-8).""" - charset = _parse_charset(_get_content_type(self.headers)) or "utf-8" - try: - return self.content.decode(charset) - except LookupError: - return self.content.decode("utf-8") - - def json(self) -> Any: # noqa: ANN401 - """Parse `content` as JSON using the declared charset (default UTF-8). - - Raises: - json.JSONDecodeError: if the body is not valid JSON. - - """ - return json.loads(self.text) - - def with_headers(self, headers: Mapping[str, str]) -> Self: - """Return a copy with the given headers merged in (incoming keys override existing).""" - return dataclasses.replace(self, headers={**self.headers, **headers}) - - def with_status(self, status: int) -> Self: - """Return a copy with the given status code.""" - return dataclasses.replace(self, status=status) - - -@dataclass(frozen=True, slots=True) -class StreamResponse: - """Placeholder for the streaming response type — fleshed out in Story 4.1.""" - - status: int - headers: Mapping[str, str] - url: str diff --git a/src/httpware/transports/__init__.py b/src/httpware/transports/__init__.py deleted file mode 100644 index b2967ff..0000000 --- a/src/httpware/transports/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Transport protocol — the middleware ↔ transport seam (Seam 1).""" - -from contextlib import AbstractAsyncContextManager -from typing import Protocol, runtime_checkable - -from httpware.request import Request -from httpware.response import Response, StreamResponse - - -@runtime_checkable -class Transport(Protocol): - """Structural protocol every transport adapter satisfies.""" - - async def __call__(self, request: Request) -> Response: - """Send `request` and return the buffered response.""" - ... - - def stream(self, request: Request) -> AbstractAsyncContextManager[StreamResponse]: - """Open a streaming response for `request` as an async context manager.""" - ... - - async def aclose(self) -> None: - """Release any resources held by the transport.""" - ... - - -__all__ = ["Transport"] diff --git a/src/httpware/transports/httpx2.py b/src/httpware/transports/httpx2.py deleted file mode 100644 index e272880..0000000 --- a/src/httpware/transports/httpx2.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Httpx2Transport — adapts the httpx2 AsyncClient to the Transport protocol. - -This is the only file in `httpware` that imports `httpx2`. The v0 -method / header / multi-valued-header contracts are documented on the -`Httpx2Transport` class. -""" - -import asyncio -import dataclasses -import json -import time -from contextlib import AbstractAsyncContextManager -from http import HTTPStatus -from typing import Any - -import httpx2 - -from httpware.config import Limits, Timeout -from httpware.errors import ( - STATUS_TO_EXCEPTION, - ClientStatusError, - ServerStatusError, - TimeoutError, # noqa: A004 - TransportError, -) -from httpware.request import Request -from httpware.response import Response, StreamResponse - - -def _try_decode_json(resp: httpx2.Response) -> Any | None: # noqa: ANN401 - """Best-effort JSON decode of `resp.content`; never raises.""" - content_type = "" - for key, value in resp.headers.items(): - if key.lower() == "content-type": - content_type = value - break - # Strict match on the bare media type: ``application/json`` only. - # Splitting on ``;`` strips parameters (e.g. ``; charset=utf-8``) and - # avoids ``application/jsonpatch`` false-positives that ``startswith`` - # would accept. ``+json`` variants (``application/problem+json``, - # ``application/vnd.api+json``) are deferred per Open Question (a). - media_type = content_type.split(";", 1)[0].strip().lower() - if media_type != "application/json": - return None - if not resp.content: - return None - try: - return json.loads(resp.content) - except json.JSONDecodeError: - return None - - -class Httpx2Transport: - """Default `Transport` implementation backed by `httpx2.AsyncClient`. - - This is the only place in ``httpware`` that imports ``httpx2``. It owns - three v0 contracts the rest of the library relies on: - - * The wire ``method`` is uppercased at this seam; the - ``httpware.Request.method`` itself is left untouched. - * ``headers`` returned to callers (and stored on ``StatusError``) use - the lowercase ASCII keys that ``httpx2.Response.headers`` already - emits. A case-insensitive header type is deferred until middleware - needs it. - * ``Mapping[str, str]`` is single-valued. ``dict(resp.headers)`` - collapses duplicate-key headers (``Set-Cookie``, ``Via``, ``Link``) - to the last value only; the multi-valued contract widens together - with the case-insensitive type in a later story. - """ - - def __init__( - self, - *, - client: httpx2.AsyncClient | None = None, - limits: Limits | None = None, - timeout: Timeout | None = None, - ) -> None: - """Store the (optionally user-supplied) client and lazy-init config.""" - if client is not None and (limits is not None or timeout is not None): - msg = "Pass limits/timeout only when client is None." - raise ValueError(msg) - self._client: httpx2.AsyncClient | None = client - self._limits: Limits | None = limits - self._timeout: Timeout | None = timeout - self._closed: bool = False - self._init_lock: asyncio.Lock | None = None - - async def _get_client(self) -> httpx2.AsyncClient: - if self._closed: - msg = "Httpx2Transport is closed." - raise TransportError(msg) - if self._client is not None: - return self._client - if self._init_lock is None: - self._init_lock = asyncio.Lock() - async with self._init_lock: - if self._client is None: - limits = self._limits or Limits() - timeout = self._timeout or Timeout() - httpx2_limits = httpx2.Limits(**dataclasses.asdict(limits)) - httpx2_timeout = httpx2.Timeout( - connect=timeout.connect, - read=timeout.read, - write=timeout.write, - pool=timeout.pool, - ) - self._client = httpx2.AsyncClient(limits=httpx2_limits, timeout=httpx2_timeout) - return self._client - - async def __call__(self, request: Request) -> Response: - """Send `request` and return a `Response`, raising on 4xx/5xx.""" - client = await self._get_client() - method = request.method.upper() - try: - httpx2_req = httpx2.Request( - method=method, - url=request.url, - headers=dict(request.headers), - params=dict(request.params), - cookies=dict(request.cookies), - content=request.body, - extensions=dict(request.extensions), - ) - start = time.monotonic() - resp = await client.send(httpx2_req) - except httpx2.TimeoutException as exc: - raise TimeoutError(str(exc)) from exc - except httpx2.HTTPError as exc: - raise TransportError(str(exc)) from exc - except (httpx2.InvalidURL, httpx2.CookieConflict) as exc: - raise TransportError(str(exc)) from exc - except RuntimeError as exc: - # ``httpx2.AsyncClient.send`` raises a bare RuntimeError when - # the client has been closed externally; there is no public - # attribute we can interrogate ahead of time. - if "closed" in str(exc): - raise TransportError(str(exc)) from exc - raise - elapsed = time.monotonic() - start - status = resp.status_code - # ``dict(...)`` collapses duplicate-key headers (Set-Cookie etc.) - # to the last value — see class docstring; widens with the - # multi-valued header contract in a later story. - headers = dict(resp.headers) - if HTTPStatus.BAD_REQUEST <= status < 600: # noqa: PLR2004 — 600 is the synthetic 5xx upper bound - exc_class = STATUS_TO_EXCEPTION.get( - status, - ClientStatusError if status < HTTPStatus.INTERNAL_SERVER_ERROR else ServerStatusError, - ) - raise exc_class( - status=status, - body=resp.content, - headers=headers, - json=_try_decode_json(resp), - request_method=method, - request_url=request.url, - ) - return Response( - status=status, - headers=headers, - content=resp.content, - url=str(resp.url), - elapsed=elapsed, - ) - - def stream(self, request: Request) -> AbstractAsyncContextManager[StreamResponse]: # noqa: ARG002 - """Open a streaming response — not yet implemented (Story 4.1).""" - if self._closed: - msg = "Httpx2Transport is closed." - raise TransportError(msg) - msg = "Streaming arrives in Epic 4 (Story 4.1)." - raise NotImplementedError(msg) - - async def aclose(self) -> None: - """Close the underlying client; safe to call repeatedly.""" - if self._closed: - return - if self._client is not None: - await self._client.aclose() - self._client = None - self._closed = True diff --git a/src/httpware/transports/recorded.py b/src/httpware/transports/recorded.py deleted file mode 100644 index 5bea491..0000000 --- a/src/httpware/transports/recorded.py +++ /dev/null @@ -1,84 +0,0 @@ -"""RecordedTransport — built-in Transport test double.""" - -from collections.abc import Mapping -from contextlib import AbstractAsyncContextManager - -from httpware.request import Request -from httpware.response import Response, StreamResponse - - -class RecordedTransport: - """Built-in Transport test double. - - Construct with a route table mapping (method, url) → Response | BaseException. - `await transport(request)` looks up `(request.method.upper(), request.url)`; on - match returns the Response or raises the Exception. On no-match, uses the - `default` (Response, BaseException, or RuntimeError("No route for METHOD URL") - when None). - - Every call appends the Request to `transport.requests`. Tests can assert on - `transport.last_request`, iterate `transport.requests`, or count - `transport.aclose_calls` for lifecycle assertions. - - Routes fire indefinitely — the same (method, url) yields the same canned - Response on every match. To express "different replies on repeat calls", - swap the route between calls via `add_route(...)` or construct a new - transport per call. - - Route and default values may be `BaseException` (not just `Exception`) so - test code can express `asyncio.CancelledError`, `SystemExit`, or - `KeyboardInterrupt` — useful for cancellation/shutdown propagation tests. - These do NOT get caught by user code's `except Exception:`. - - `stream()` raises NotImplementedError; streaming lands in Epic 4 (Story 4-1). - """ - - def __init__( - self, - routes: Mapping[tuple[str, str], Response | BaseException] | None = None, - *, - default: Response | BaseException | None = None, - ) -> None: - self._routes: dict[tuple[str, str], Response | BaseException] = ( - {(m.upper(), u): v for (m, u), v in routes.items()} if routes is not None else {} - ) - self._default = default - self.requests: list[Request] = [] - self.aclose_calls = 0 - - @property - def last_request(self) -> Request | None: - """The most recently observed Request, or None if no calls have been made.""" - return self.requests[-1] if self.requests else None - - def add_route( - self, - method: str, - url: str, - response_or_exception: Response | BaseException, - ) -> None: - """Add or replace a route entry.""" - self._routes[(method.upper(), url)] = response_or_exception - - async def __call__(self, request: Request) -> Response: - self.requests.append(request) - key = (request.method.upper(), request.url) - result: Response | BaseException | None - result = self._routes.get(key, self._default) - if isinstance(result, BaseException): - raise result - if result is None: - msg = f"No route for {request.method} {request.url}" - raise RuntimeError(msg) - return result - - def stream( - self, - request: Request, - ) -> AbstractAsyncContextManager[StreamResponse]: - """Streaming not implemented in v0 — landing in Epic 4 (Story 4-1).""" - msg = "RecordedTransport.stream() is not implemented; streaming lands in Epic 4" - raise NotImplementedError(msg) - - async def aclose(self) -> None: - self.aclose_calls += 1 diff --git a/tests/test_client_construction.py b/tests/test_client_construction.py index a3e6737..b580746 100644 --- a/tests/test_client_construction.py +++ b/tests/test_client_construction.py @@ -1,112 +1,89 @@ -"""Unit tests for httpware.client.AsyncClient construction.""" +"""Tests for AsyncClient construction and ownership semantics.""" -# ruff: noqa: SLF001 +import httpx2 +import pytest -from httpware import AsyncClient, Limits, RecordedTransport, Timeout +from httpware import AsyncClient from httpware.decoders.pydantic import PydanticDecoder -from httpware.middleware import Middleware -from httpware.request import Request -from httpware.response import Response -from httpware.transports.httpx2 import Httpx2Transport -def test_init_defaults_provide_transport_and_decoder() -> None: +def test_construction_with_no_args_works() -> None: client = AsyncClient() - assert isinstance(client._transport, Httpx2Transport) - assert isinstance(client._config.decoder, PydanticDecoder) - assert client._config.middleware == () - - -def test_init_accepts_explicit_transport() -> None: - transport = RecordedTransport() - client = AsyncClient(transport=transport) - assert client._transport is transport - - -def test_init_accepts_explicit_decoder() -> None: - decoder = PydanticDecoder() - client = AsyncClient(decoder=decoder) - assert client._config.decoder is decoder - - -def test_init_accepts_middleware_sequence() -> None: - class _M: - async def __call__(self, request: Request, next) -> Response: # noqa: A002, ANN001 - return await next(request) - - middleware: list[Middleware] = [_M()] - client = AsyncClient(middleware=middleware) - assert client._config.middleware == tuple(middleware) - - -def test_init_normalizes_float_timeout() -> None: - client = AsyncClient(timeout=2.5) - assert client._config.timeout == Timeout(connect=2.5, read=2.5, write=2.5, pool=2.5) - - -def test_init_keeps_timeout_instance() -> None: - t = Timeout(connect=1.0, read=60.0, write=10.0, pool=2.0) - client = AsyncClient(timeout=t) - assert client._config.timeout is t - - -def test_init_normalizes_none_timeout() -> None: - client = AsyncClient(timeout=None) - assert client._config.timeout == Timeout() - - -def test_init_default_limits() -> None: + assert isinstance(client, AsyncClient) + + +def test_construction_with_forwarded_kwargs() -> None: + client = AsyncClient( + base_url="https://example.test", + headers={"x-shared": "1"}, + params={"trace": "yes"}, + timeout=10.0, + ) + assert isinstance(client, AsyncClient) + + +def test_construction_with_caller_owned_httpx2_client() -> None: + transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req)) + caller = httpx2.AsyncClient(transport=transport) + client = AsyncClient(httpx2_client=caller) + assert isinstance(client, AsyncClient) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"base_url": "https://example.test"}, + {"headers": {"x": "1"}}, + {"params": {"x": "1"}}, + {"cookies": {"x": "1"}}, + {"timeout": 5.0}, + {"limits": httpx2.Limits(max_connections=10)}, + {"auth": httpx2.BasicAuth("u", "p")}, + ], +) +def test_caller_owned_client_with_forwarded_kwargs_is_typeerror(kwargs: dict) -> None: + transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req)) + caller = httpx2.AsyncClient(transport=transport) + with pytest.raises(TypeError, match="httpx2_client"): + AsyncClient(httpx2_client=caller, **kwargs) + + +def test_default_decoder_is_pydantic_decoder() -> None: client = AsyncClient() - assert client._config.limits == Limits() - + assert isinstance(client._decoder, PydanticDecoder) # noqa: SLF001 -def test_from_url_classmethod_sets_base_url() -> None: - client = AsyncClient.from_url("https://api.example.com/v1") - assert client._config.base_url == "https://api.example.com/v1" +def test_explicit_decoder_is_honored() -> None: + class _Stub: + def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 # pragma: no cover + return None -def test_init_owns_transport_by_default() -> None: - client = AsyncClient() - assert client._owns_transport is True - - -def test_construction_does_not_create_httpx2_client() -> None: - """Construction is side-effect-free; the httpx2.AsyncClient is lazily created on first request.""" - client = AsyncClient() - # Httpx2Transport stores `_client` lazily; until first call, _client is None. - # The attribute is private; we check it via getattr to keep the test resilient. - assert getattr(client._transport, "_client", "missing") is None - + client = AsyncClient(decoder=_Stub()) + assert isinstance(client._decoder, _Stub) # noqa: SLF001 -def test_init_no_auth_means_no_auth_middleware() -> None: - transport = RecordedTransport() - client = AsyncClient(transport=transport) - assert client._config.middleware == () - assert client._auth is None - assert client._user_middleware == () +@pytest.mark.parametrize( + "kwargs", + [ + {"cookies": {"session": "abc"}}, + {"limits": httpx2.Limits(max_connections=5)}, + {"auth": httpx2.BasicAuth("user", "pass")}, + ], +) +def test_construction_with_optional_forwarded_kwargs(kwargs: dict) -> None: + """Exercises cookies/limits/auth branches in __init__ when no httpx2_client is supplied.""" + client = AsyncClient(**kwargs) + assert isinstance(client, AsyncClient) -def test_init_with_string_auth_appends_bearer_middleware() -> None: - transport = RecordedTransport() - client = AsyncClient(transport=transport, auth="tok") - assert len(client._config.middleware) == 1 - assert isinstance(client._config.middleware[0], Middleware) - assert client._auth == "tok" - assert client._user_middleware == () +def test_explicit_middleware_is_honored() -> None: + captured: list[str] = [] -def test_init_with_user_middleware_plus_auth() -> None: - class _M: - async def __call__(self, request, next) -> Response: # noqa: A002, ANN001 + class _Tag: + async def __call__(self, request, next) -> httpx2.Response: # noqa: A002, ANN001 # pragma: no cover + captured.append("tag") return await next(request) - m1 = _M() - m2 = _M() - transport = RecordedTransport() - client = AsyncClient(transport=transport, middleware=[m1, m2], auth="tok") - _expected_len = 3 - assert len(client._config.middleware) == _expected_len - assert client._config.middleware[0] is m1 - assert client._config.middleware[1] is m2 - # The third entry is the auth middleware; identity-test that user_middleware excludes it. - assert client._user_middleware == (m1, m2) + client = AsyncClient(middleware=(_Tag(),)) + assert client._user_middleware == (client._user_middleware[0],) # noqa: SLF001 + assert len(client._user_middleware) == 1 # noqa: SLF001 diff --git a/tests/test_client_lifecycle.py b/tests/test_client_lifecycle.py index 42e0e47..5bd9c73 100644 --- a/tests/test_client_lifecycle.py +++ b/tests/test_client_lifecycle.py @@ -1,37 +1,32 @@ -"""Unit tests for AsyncClient lifecycle (__aenter__, __aexit__).""" +"""Tests for AsyncClient.__aenter__/__aexit__ lifecycle and ownership.""" -from httpware import AsyncClient, RecordedTransport +from http import HTTPStatus +import httpx2 -async def test_aenter_returns_self() -> None: - transport = RecordedTransport() - client = AsyncClient(transport=transport) - async with client as entered: - assert entered is client +from httpware import AsyncClient -async def test_async_with_calls_aclose_on_exit() -> None: - transport = RecordedTransport() - client = AsyncClient(transport=transport) +async def test_aexit_closes_owned_httpx2_client() -> None: + client = AsyncClient() async with client: pass - assert transport.aclose_calls == 1 + assert client._httpx2_client.is_closed # noqa: SLF001 -async def test_double_close_is_safe() -> None: - transport = RecordedTransport() - client = AsyncClient(transport=transport) +async def test_aexit_does_not_close_borrowed_httpx2_client() -> None: + transport = httpx2.MockTransport(lambda req: httpx2.Response(HTTPStatus.OK, request=req)) + underlying = httpx2.AsyncClient(transport=transport) + client = AsyncClient(httpx2_client=underlying) async with client: pass - async with client: - pass - assert transport.aclose_calls == 2 # noqa: PLR2004 + assert not underlying.is_closed + await underlying.aclose() -async def test_view_async_with_does_not_close_transport() -> None: - transport = RecordedTransport() - client = AsyncClient(transport=transport) - view = client.with_options(timeout=10) - async with view: +async def test_aexit_is_idempotent_for_owned_client() -> None: + client = AsyncClient() + async with client: pass - assert transport.aclose_calls == 0 + # Second use should not raise — the boolean prevents a double-close on httpx2 internals. + await client.__aexit__(None, None, None) diff --git a/tests/test_client_methods.py b/tests/test_client_methods.py index f58057b..05298b6 100644 --- a/tests/test_client_methods.py +++ b/tests/test_client_methods.py @@ -1,191 +1,162 @@ -"""Unit tests for AsyncClient HTTP method shortcuts.""" +"""Tests for the per-method API surface of AsyncClient.""" +from http import HTTPStatus + +import httpx2 import pytest -from httpware import AsyncClient, RecordedTransport -from httpware.response import Response +from httpware import AsyncClient, NotFoundError -def _make_transport() -> RecordedTransport: - return RecordedTransport( - default=Response( - status=200, - headers={"x-from": "transport"}, - content=b"body", - url="https://example.test/", - elapsed=0.0, - ) +def _echo_handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response( + HTTPStatus.OK, + request=request, + json={ + "method": request.method, + "url": str(request.url), + "headers": dict(request.headers), + "content": request.content.decode() if request.content else "", + }, ) -async def test_get_builds_request_with_method_and_url() -> None: - transport = _make_transport() - client = AsyncClient(transport=transport) +def _client_with_handler(handler, **kwargs) -> AsyncClient: # noqa: ANN001, ANN003 + transport = httpx2.MockTransport(handler) + return AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport, **kwargs)) - await client.get("https://api.example.com/users") - assert transport.last_request is not None - assert transport.last_request.method == "GET" - assert transport.last_request.url == "https://api.example.com/users" - assert transport.last_request.body is None +async def test_get_returns_httpx2_response() -> None: + client = _client_with_handler(_echo_handler) + response = await client.get("https://example.test/x") + assert isinstance(response, httpx2.Response) + assert response.json()["method"] == "GET" -async def test_relative_path_joins_with_base_url() -> None: - transport = _make_transport() - client = AsyncClient(base_url="https://api.example.com/v1", transport=transport) - await client.get("/users") - assert transport.last_request is not None - assert transport.last_request.url == "https://api.example.com/v1/users" +@pytest.mark.parametrize( + "method_name", + ["get", "post", "put", "patch", "delete", "head", "options"], +) +async def test_each_per_method_helper_exists_and_uses_correct_verb(method_name: str) -> None: + client = _client_with_handler(_echo_handler) + method = getattr(client, method_name) + response = await method("https://example.test/x") + assert response.json()["method"] == method_name.upper() -async def test_relative_path_without_leading_slash_joins_same_way() -> None: - transport = _make_transport() - client = AsyncClient(base_url="https://api.example.com/v1", transport=transport) - await client.get("users") - assert transport.last_request is not None - assert transport.last_request.url == "https://api.example.com/v1/users" +async def test_post_json_body_serialized() -> None: + client = _client_with_handler(_echo_handler) + response = await client.post("https://example.test/x", json={"k": "v"}) + payload = response.json() + assert "application/json" in payload["headers"]["content-type"] + assert payload["content"] == '{"k":"v"}' -async def test_absolute_url_bypasses_base_url() -> None: - transport = _make_transport() - client = AsyncClient(base_url="https://api.example.com/v1", transport=transport) - await client.get("https://other.com/foo") - assert transport.last_request is not None - assert transport.last_request.url == "https://other.com/foo" +async def test_get_with_params_forwards_query() -> None: + captured: list[httpx2.Request] = [] + def handler(request: httpx2.Request) -> httpx2.Response: + captured.append(request) + return httpx2.Response(HTTPStatus.OK, request=request) -async def test_default_headers_merged_with_per_call_headers() -> None: - transport = _make_transport() - client = AsyncClient( - default_headers={"x-keep": "1", "x-override": "default"}, - transport=transport, - ) - await client.get("/", headers={"x-override": "per-call", "x-add": "2"}) - assert transport.last_request is not None - assert transport.last_request.headers == { - "x-keep": "1", - "x-override": "per-call", - "x-add": "2", - } - - -async def test_default_query_merged_with_per_call_params() -> None: - transport = _make_transport() - client = AsyncClient(default_query={"k": "default"}, transport=transport) - await client.get("/", params={"k": "per-call", "extra": "1"}) - assert transport.last_request is not None - assert transport.last_request.params == {"k": "per-call", "extra": "1"} - - -async def test_post_with_json_serializes_and_sets_content_type() -> None: - transport = _make_transport() - client = AsyncClient(transport=transport) - await client.post("/users", json={"name": "alice"}) - assert transport.last_request is not None - assert transport.last_request.method == "POST" - assert transport.last_request.body == b'{"name": "alice"}' - assert transport.last_request.headers["content-type"] == "application/json" - - -async def test_post_with_content_preserves_bytes_unchanged() -> None: - transport = _make_transport() - client = AsyncClient(transport=transport) - await client.post("/users", content=b"raw bytes") - assert transport.last_request is not None - assert transport.last_request.body == b"raw bytes" - assert "content-type" not in transport.last_request.headers - - -async def test_post_json_and_content_raises_typeerror() -> None: - transport = _make_transport() - client = AsyncClient(transport=transport) - with pytest.raises(TypeError, match="`json` or `content`"): - await client.post("/users", json={"a": 1}, content=b"raw") - - -async def test_post_per_call_content_type_skips_auto_injection() -> None: - transport = _make_transport() - client = AsyncClient(transport=transport) - await client.post( - "/users", - json={"a": 1}, - headers={"Content-Type": "application/vnd.custom+json"}, - ) - assert transport.last_request is not None - # The user-supplied Content-Type wins; the auto-injection is skipped because the case-insensitive - # check finds an existing entry. - assert transport.last_request.headers["Content-Type"] == "application/vnd.custom+json" + client = _client_with_handler(handler) + await client.get("https://example.test/x", params={"a": "1"}) + assert "a=1" in str(captured[0].url) -@pytest.mark.parametrize( - ("client_method_name", "expected_wire_method"), - [ - ("get", "GET"), - ("post", "POST"), - ("put", "PUT"), - ("patch", "PATCH"), - ("delete", "DELETE"), - ("head", "HEAD"), - ("options", "OPTIONS"), - ], -) -async def test_each_method_emits_correct_wire_method(client_method_name: str, expected_wire_method: str) -> None: - transport = _make_transport() - client = AsyncClient(transport=transport) - method = getattr(client, client_method_name) - await method("/foo") - assert transport.last_request is not None - assert transport.last_request.method == expected_wire_method +async def test_get_with_headers_merges() -> None: + captured: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + captured.append(request) + return httpx2.Response(HTTPStatus.OK, request=request) + + client = _client_with_handler(handler) + await client.get("https://example.test/x", headers={"x-trace": "abc"}) + assert captured[0].headers["x-trace"] == "abc" + + +async def test_get_raises_typed_status_error_on_404() -> None: + client = _client_with_handler(lambda req: httpx2.Response(HTTPStatus.NOT_FOUND, request=req)) + with pytest.raises(NotFoundError): + await client.get("https://example.test/missing") + + +async def test_request_method_takes_arbitrary_verb() -> None: + client = _client_with_handler(_echo_handler) + response = await client.request("PROPFIND", "https://example.test/x") + assert response.json()["method"] == "PROPFIND" + + +async def test_base_url_is_applied() -> None: + captured: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + captured.append(request) + return httpx2.Response(HTTPStatus.OK, request=request) + + transport = httpx2.MockTransport(handler) + underlying = httpx2.AsyncClient(transport=transport, base_url="https://example.test") + client = AsyncClient(httpx2_client=underlying) + await client.get("/relative") + assert str(captured[0].url) == "https://example.test/relative" -async def test_request_method_uses_first_positional_method_arg() -> None: - transport = _make_transport() - client = AsyncClient(transport=transport) - await client.request("CUSTOM", "/foo") - assert transport.last_request is not None - assert transport.last_request.method == "CUSTOM" +async def test_get_with_cookies_forwarded() -> None: + """Exercises the cookies branch in _request_with_body.""" + captured: list[httpx2.Request] = [] + def handler(request: httpx2.Request) -> httpx2.Response: + captured.append(request) + return httpx2.Response(HTTPStatus.OK, request=request) -async def test_per_call_timeout_propagates_to_request_extensions() -> None: - transport = _make_transport() - client = AsyncClient(transport=transport) - await client.get("/foo", timeout=2.5) - assert transport.last_request is not None - assert "timeout" in transport.last_request.extensions + client = _client_with_handler(handler) + await client.get("https://example.test/x", cookies={"token": "abc"}) + assert "token=abc" in captured[0].headers.get("cookie", "") -async def test_string_auth_sends_authorization_header() -> None: - transport = RecordedTransport(default=Response(status=200, headers={}, content=b"", url="/", elapsed=0.0)) - client = AsyncClient(transport=transport, auth="tok") +async def test_get_with_explicit_timeout() -> None: + """Exercises the timeout branch in _request_with_body.""" + client = _client_with_handler(_echo_handler) + response = await client.get("https://example.test/x", timeout=5.0) + assert response.status_code == HTTPStatus.OK - await client.get("/foo") - assert transport.last_request is not None - assert transport.last_request.headers["Authorization"] == "Bearer tok" +async def test_get_with_extensions() -> None: + """Exercises the extensions branch in _request_with_body.""" + client = _client_with_handler(_echo_handler) + response = await client.get("https://example.test/x", extensions={"trace": True}) + assert response.status_code == HTTPStatus.OK -async def test_per_call_authorization_header_wins_over_auth_param() -> None: - transport = RecordedTransport(default=Response(status=200, headers={}, content=b"", url="/", elapsed=0.0)) - client = AsyncClient(transport=transport, auth="default-tok") +async def test_post_with_content_body() -> None: + """Exercises the content branch in _request_with_body.""" + client = _client_with_handler(_echo_handler) + response = await client.post("https://example.test/x", content=b"raw-bytes") + assert response.json()["content"] == "raw-bytes" - await client.get("/foo", headers={"Authorization": "Bearer override"}) - assert transport.last_request is not None - assert transport.last_request.headers["Authorization"] == "Bearer override" +async def test_post_with_data_body() -> None: + """Exercises the data branch in _request_with_body.""" + client = _client_with_handler(_echo_handler) + response = await client.post("https://example.test/x", data={"field": "value"}) + assert response.status_code == HTTPStatus.OK -async def test_callable_auth_calls_provider_per_request() -> None: - transport = RecordedTransport(default=Response(status=200, headers={}, content=b"", url="/", elapsed=0.0)) - calls = 0 +async def test_post_with_files_body() -> None: + """Exercises the files branch in _request_with_body.""" + client = _client_with_handler(_echo_handler) + response = await client.post("https://example.test/x", files={"upload": b"file-content"}) + assert response.status_code == HTTPStatus.OK - def _provider() -> str: - nonlocal calls - calls += 1 - return f"tok-{calls}" - client = AsyncClient(transport=transport, auth=_provider) +async def test_runtime_error_without_closed_reraises() -> None: + """Exercises the RuntimeError re-raise branch in _terminal (error not containing 'closed').""" - await client.get("/a") - await client.get("/b") + def boom(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 + msg = "unexpected internal failure" + raise RuntimeError(msg) - assert calls == 2 # noqa: PLR2004 + client = _client_with_handler(boom) + with pytest.raises(RuntimeError, match="unexpected internal failure"): + await client.get("https://example.test/x") diff --git a/tests/test_client_middleware_wiring.py b/tests/test_client_middleware_wiring.py index 8ef59aa..694acb4 100644 --- a/tests/test_client_middleware_wiring.py +++ b/tests/test_client_middleware_wiring.py @@ -1,167 +1,99 @@ -"""Unit tests for AsyncClient middleware wiring through compose() and with_options.""" +"""Tests for AsyncClient ↔ middleware chain integration.""" -from collections.abc import Mapping +from http import HTTPStatus -from httpware import AsyncClient, RecordedTransport -from httpware.middleware import Middleware, Next -from httpware.request import Request -from httpware.response import Response +import httpx2 +import pytest - -def _make_transport() -> RecordedTransport: - return RecordedTransport( - default=Response( - status=200, - headers={}, - content=b"", - url="/", - elapsed=0.0, - ) - ) +from httpware import AsyncClient, InternalServerError, Next, NotFoundError, after_response, before_request, on_error -def _make_recording_middleware(label: str, log: list[str]) -> Middleware: - class _M: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 - log.append(label) - return await next(request) +async def test_before_request_runs() -> None: + @before_request + async def add_header(request: httpx2.Request) -> httpx2.Request: + return httpx2.Request( + request.method, + request.url, + headers={**request.headers, "x-injected": "1"}, + ) - return _M() + captured: list[httpx2.Request] = [] + def handler(request: httpx2.Request) -> httpx2.Response: + captured.append(request) + return httpx2.Response(HTTPStatus.OK, request=request) -async def test_middleware_runs_per_request() -> None: - transport = _make_transport() - log: list[str] = [] + transport = httpx2.MockTransport(handler) client = AsyncClient( - transport=transport, - middleware=[_make_recording_middleware("A", log)], + httpx2_client=httpx2.AsyncClient(transport=transport), + middleware=(add_header,), ) - await client.get("/foo") - assert log == ["A"] - assert len(transport.requests) == 1 - + await client.get("https://example.test/x") + assert captured[0].headers["x-injected"] == "1" + + +async def test_after_response_runs() -> None: + @after_response + async def tag_status(request: httpx2.Request, response: httpx2.Response) -> httpx2.Response: + return httpx2.Response( + HTTPStatus.IM_USED, + request=request, + headers=response.headers, + content=response.content, + ) -async def test_with_options_recomposes_middleware() -> None: - transport = _make_transport() - parent_log: list[str] = [] - view_log: list[str] = [] + transport = httpx2.MockTransport(lambda req: httpx2.Response(HTTPStatus.OK, request=req)) client = AsyncClient( - transport=transport, - middleware=[_make_recording_middleware("parent", parent_log)], - ) - view = client.with_options( - middleware=[_make_recording_middleware("view", view_log)], + httpx2_client=httpx2.AsyncClient(transport=transport), + middleware=(tag_status,), ) - await view.get("/foo") - assert view_log == ["view"] - assert parent_log == [] # parent's middleware does NOT run for view calls + response = await client.get("https://example.test/x") + assert response.status_code == HTTPStatus.IM_USED -async def test_with_options_inherits_middleware_when_unset() -> None: - transport = _make_transport() - log: list[str] = [] +async def test_on_error_catches_status_error() -> None: + @on_error + async def convert_404(request: httpx2.Request, exc: Exception) -> httpx2.Response | None: + if isinstance(exc, NotFoundError): + return httpx2.Response(HTTPStatus.OK, request=request, content=b"recovered") + return None # let other exceptions propagate + + transport_404 = httpx2.MockTransport(lambda req: httpx2.Response(HTTPStatus.NOT_FOUND, request=req)) client = AsyncClient( - transport=transport, - middleware=[_make_recording_middleware("inherited", log)], + httpx2_client=httpx2.AsyncClient(transport=transport_404), + middleware=(convert_404,), ) - view = client.with_options(timeout=10) - await view.get("/foo") - assert log == ["inherited"] - - -async def test_view_shares_transport_with_parent() -> None: - transport = _make_transport() - client = AsyncClient(transport=transport) - view = client.with_options(timeout=10) - assert view._transport is client._transport # noqa: SLF001 - - -async def test_view_does_not_own_transport() -> None: - client = AsyncClient() - view = client.with_options(timeout=10) - assert view._owns_transport is False # noqa: SLF001 - - -async def test_with_options_overrides_base_url() -> None: - transport = _make_transport() - client = AsyncClient(transport=transport, base_url="https://api.test/v1") - view = client.with_options(base_url="https://other.test/v2") - assert view._config.base_url == "https://other.test/v2" # noqa: SLF001 - - -async def test_with_options_overrides_default_headers() -> None: - transport = _make_transport() - client = AsyncClient(transport=transport, default_headers={"x-old": "1"}) - view = client.with_options(default_headers={"x-new": "2"}) - assert view._config.default_headers == {"x-new": "2"} # noqa: SLF001 - - -async def test_with_options_overrides_default_query() -> None: - transport = _make_transport() - client = AsyncClient(transport=transport, default_query={"old": "1"}) - view = client.with_options(default_query={"new": "2"}) - assert view._config.default_query == {"new": "2"} # noqa: SLF001 - - -async def test_with_options_overrides_decoder() -> None: - transport = _make_transport() - - class _NoopDecoder: - def decode(self, content: bytes, model: type) -> object: # pragma: no cover # noqa: ARG002 - return content - - new_decoder = _NoopDecoder() - client = AsyncClient(transport=transport) - view = client.with_options(decoder=new_decoder) - assert view._config.decoder is new_decoder # noqa: SLF001 - - -async def test_auth_runs_inside_user_middleware() -> None: - transport = RecordedTransport(default=Response(status=200, headers={}, content=b"", url="/", elapsed=0.0)) - - user_seen_headers: list[Mapping[str, str]] = [] - - class _UserOuter: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 - user_seen_headers.append(dict(request.headers)) - return await next(request) - - client = AsyncClient(transport=transport, middleware=[_UserOuter()], auth="tok") - await client.get("/foo") - - # User middleware saw the request BEFORE auth header was applied. - assert "Authorization" not in user_seen_headers[0] - # Transport saw the request WITH the auth header. - assert transport.last_request is not None - assert transport.last_request.headers["Authorization"] == "Bearer tok" - - -async def test_with_options_auth_replaces_auth_middleware() -> None: - transport = RecordedTransport(default=Response(status=200, headers={}, content=b"", url="/", elapsed=0.0)) - client = AsyncClient(transport=transport, auth="parent") - view = client.with_options(auth="view") - - await view.get("/foo") - assert transport.last_request is not None - assert transport.last_request.headers["Authorization"] == "Bearer view" - - await client.get("/foo") - assert transport.last_request is not None - assert transport.last_request.headers["Authorization"] == "Bearer parent" + response = await client.get("https://example.test/x") + assert response.status_code == HTTPStatus.OK + assert response.content == b"recovered" + + # Also exercise the return-None branch (non-404 → passes through to re-raise). + transport_500 = httpx2.MockTransport(lambda req: httpx2.Response(HTTPStatus.INTERNAL_SERVER_ERROR, request=req)) + client2 = AsyncClient( + httpx2_client=httpx2.AsyncClient(transport=transport_500), + middleware=(convert_404,), + ) + with pytest.raises(InternalServerError): + await client2.get("https://example.test/x") -async def test_with_options_middleware_keeps_existing_auth() -> None: - transport = RecordedTransport(default=Response(status=200, headers={}, content=b"", url="/", elapsed=0.0)) +async def test_middleware_runs_outer_to_inner_then_inner_to_outer() -> None: + order: list[str] = [] - class _M: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 - return await next(request) + class _Tag: + def __init__(self, name: str) -> None: + self.name = name - m1 = _M() - m2 = _M() - client = AsyncClient(transport=transport, auth="tok", middleware=[m1]) - view = client.with_options(middleware=[m2]) + async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 + order.append(f"{self.name}.in") + response = await next(request) + order.append(f"{self.name}.out") + return response - await view.get("/foo") - assert transport.last_request is not None - assert transport.last_request.headers["Authorization"] == "Bearer tok" + transport = httpx2.MockTransport(lambda req: httpx2.Response(HTTPStatus.OK, request=req)) + client = AsyncClient( + httpx2_client=httpx2.AsyncClient(transport=transport), + middleware=(_Tag("a"), _Tag("b")), + ) + await client.get("https://example.test/x") + assert order == ["a.in", "b.in", "b.out", "a.out"] diff --git a/tests/test_client_response_model.py b/tests/test_client_response_model.py index 53dcbe3..3ef028c 100644 --- a/tests/test_client_response_model.py +++ b/tests/test_client_response_model.py @@ -1,60 +1,63 @@ -"""Unit tests for AsyncClient response_model integration with ResponseDecoder.""" +"""Tests for response_model decoding integration.""" -from typing import TypeVar +from http import HTTPStatus -from pydantic import BaseModel +import httpx2 +import pydantic +import pytest -from httpware import AsyncClient, RecordedTransport -from httpware.response import Response +from httpware import AsyncClient, NotFoundError -T = TypeVar("T") +class _User(pydantic.BaseModel): + id: int + name: str -def _transport(content: bytes) -> RecordedTransport: - return RecordedTransport( - default=Response( - status=200, - headers={}, - content=content, - url="/", - elapsed=0.0, +def _client_with_payload(payload: bytes, content_type: str = "application/json") -> AsyncClient: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response( + HTTPStatus.OK, + content=payload, + headers={"content-type": content_type}, + request=request, ) - ) + transport = httpx2.MockTransport(handler) + return AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) + + +async def test_get_with_response_model_returns_typed_object() -> None: + client = _client_with_payload(b'{"id": 1, "name": "ada"}') + user = await client.get("https://example.test/u", response_model=_User) + assert isinstance(user, _User) + assert user == _User(id=1, name="ada") -class _Item(BaseModel): - name: str - qty: int +async def test_post_with_response_model_returns_typed_object() -> None: + client = _client_with_payload(b'{"id": 2, "name": "bob"}') + user = await client.post("https://example.test/u", json={"name": "bob"}, response_model=_User) + assert isinstance(user, _User) -async def test_response_model_none_returns_raw_response() -> None: - transport = _transport(content=b'{"name":"x","qty":1}') - client = AsyncClient(transport=transport) - result = await client.get("/foo") - assert isinstance(result, Response) - assert result.content == b'{"name":"x","qty":1}' +async def test_send_with_response_model_returns_typed_object() -> None: + client = _client_with_payload(b'{"id": 3, "name": "cat"}') + request = client.build_request("GET", "https://example.test/u") + user = await client.send(request, response_model=_User) + assert isinstance(user, _User) -async def test_response_model_invokes_decoder() -> None: - transport = _transport(content=b'{"name":"x","qty":1}') - client = AsyncClient(transport=transport) - result = await client.get("/foo", response_model=_Item) - assert isinstance(result, _Item) - assert result == _Item(name="x", qty=1) +async def test_decoder_validation_error_propagates_unwrapped() -> None: + client = _client_with_payload(b'{"id": "not-an-int", "name": "x"}') + with pytest.raises(pydantic.ValidationError): + await client.get("https://example.test/u", response_model=_User) -async def test_response_model_uses_supplied_decoder() -> None: - transport = _transport(content=b'{"name":"x","qty":1}') - seen: list[tuple[bytes, type]] = [] - class _SpyDecoder: - def decode(self, content: bytes, model: type[T]) -> T: - seen.append((content, model)) - return model(name="spy", qty=999) +async def test_status_error_raised_before_decoder_runs() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(HTTPStatus.NOT_FOUND, content=b'{"id": 1, "name": "x"}', request=request) - client = AsyncClient(transport=transport, decoder=_SpyDecoder()) - result = await client.get("/foo", response_model=_Item) - assert seen == [(b'{"name":"x","qty":1}', _Item)] - assert isinstance(result, _Item) - assert result.name == "spy" + transport = httpx2.MockTransport(handler) + client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) + with pytest.raises(NotFoundError): + await client.get("https://example.test/u", response_model=_User) diff --git a/tests/test_client_typing.py b/tests/test_client_typing.py index 98c8068..397d3ff 100644 --- a/tests/test_client_typing.py +++ b/tests/test_client_typing.py @@ -1,43 +1,53 @@ -"""Type-checked verification that AsyncClient.{get,post,...} overloads narrow correctly. +"""Static-typing tests for AsyncClient overloads. -This file is checked by `ty` as part of `just lint-ci`. If the @overload -declarations are wrong, the typed assignments below fail to type-check. - -The runtime test below just ensures the module imports cleanly so coverage -notices the file. +These assert overload selection at runtime via isinstance checks. ty/mypy +catches the static-typing variant during `just lint`. """ -from pydantic import BaseModel - -from httpware import AsyncClient, Response +from http import HTTPStatus +import httpx2 +import pydantic -class _Item(BaseModel): - name: str - +from httpware import AsyncClient -async def _check_overload_types(client: AsyncClient) -> None: - # No response_model → Response - resp: Response = await client.get("/foo") - assert resp is not None - # response_model=type[T] → T - item: _Item = await client.get("/foo", response_model=_Item) - assert item is not None - - # POST: same pattern - resp_post: Response = await client.post("/foo", json={"a": 1}) - item_post: _Item = await client.post("/foo", json={"a": 1}, response_model=_Item) - assert resp_post is not None - assert item_post is not None - - # request(method, path, ...) shape - resp_req: Response = await client.request("PURGE", "/foo") - item_req: _Item = await client.request("PURGE", "/foo", response_model=_Item) - assert resp_req is not None - assert item_req is not None +class _User(pydantic.BaseModel): + id: int + name: str -def test_typing_module_imports_cleanly() -> None: - """Runtime stub so coverage notices this file is reachable; ty does the real work.""" - assert AsyncClient is not None +async def test_get_without_response_model_returns_response() -> 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)) + result = await client.get("https://example.test/x") + assert isinstance(result, httpx2.Response) + + +async def test_get_with_response_model_returns_typed() -> 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)) + result = await client.get("https://example.test/x", response_model=_User) + assert isinstance(result, _User) + + +async def test_send_without_response_model_returns_response() -> 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)) + result = await client.send(httpx2.Request("GET", "https://example.test/x")) + assert isinstance(result, httpx2.Response) + + +async def test_send_with_response_model_returns_typed() -> 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)) + result = await client.send(httpx2.Request("GET", "https://example.test/x"), response_model=_User) + assert isinstance(result, _User) diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index b0ded35..0000000 --- a/tests/test_config.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Unit tests for httpware.config types.""" - -from dataclasses import FrozenInstanceError - -import pytest - -from httpware import ClientConfig, Limits, Timeout -from httpware.decoders.pydantic import PydanticDecoder - - -def test_timeout_defaults() -> None: - assert Timeout() == Timeout(connect=5.0, read=30.0, write=30.0, pool=5.0) - - -def test_limits_defaults() -> None: - assert Limits() == Limits(max_connections=100, max_keepalive_connections=20, keepalive_expiry=5.0) - - -def test_client_config_defaults() -> None: - cfg = ClientConfig() - assert cfg.base_url is None - assert cfg.default_headers == {} - assert cfg.default_query == {} - assert cfg.timeout == Timeout() - assert cfg.limits == Limits() - assert isinstance(cfg.decoder, PydanticDecoder) - assert cfg.middleware == () - - -def test_client_config_default_mappings_are_independent() -> None: - c1 = ClientConfig() - c2 = ClientConfig() - assert c1.default_headers is not c2.default_headers - assert c1.default_query is not c2.default_query - - -def test_timeout_is_frozen() -> None: - t = Timeout() - with pytest.raises(FrozenInstanceError): - t.read = 60.0 # ty: ignore[invalid-assignment] - - -def test_limits_is_frozen() -> None: - lim = Limits() - with pytest.raises(FrozenInstanceError): - lim.max_connections = 50 # ty: ignore[invalid-assignment] - - -def test_client_config_is_frozen() -> None: - cfg = ClientConfig() - with pytest.raises(FrozenInstanceError): - cfg.base_url = "https://example.com" # ty: ignore[invalid-assignment] - - -@pytest.mark.parametrize("field", ["connect", "read", "write", "pool"]) -def test_timeout_rejects_negative(field: str) -> None: - with pytest.raises(ValueError, match=rf"Timeout\.{field} must be non-negative"): - Timeout(**{field: -1.0}) - - -def test_timeout_accepts_zero() -> None: - # Zero is a valid sentinel (fail immediately on this phase). - Timeout(connect=0.0, read=0.0, write=0.0, pool=0.0) - - -@pytest.mark.parametrize("field", ["max_connections", "max_keepalive_connections"]) -def test_limits_rejects_negative_int(field: str) -> None: - with pytest.raises(ValueError, match=f"{field} must be non-negative"): - Limits(**{field: -1}) - - -def test_limits_rejects_negative_keepalive_expiry() -> None: - with pytest.raises(ValueError, match="keepalive_expiry must be non-negative"): - Limits(keepalive_expiry=-0.5) - - -def test_limits_accepts_zero() -> None: - Limits(max_connections=0, max_keepalive_connections=0, keepalive_expiry=0.0) - - -def test_client_config_strips_trailing_slash_from_base_url() -> None: - cfg = ClientConfig(base_url="https://api.example.com/") - assert cfg.base_url == "https://api.example.com" - - -def test_client_config_leaves_base_url_without_trailing_slash() -> None: - cfg = ClientConfig(base_url="https://api.example.com") - assert cfg.base_url == "https://api.example.com" - - -def test_client_config_strips_multiple_trailing_slashes() -> None: - cfg = ClientConfig(base_url="https://api.example.com///") - assert cfg.base_url == "https://api.example.com" - - -def test_client_config_allows_none_base_url() -> None: - cfg = ClientConfig(base_url=None) - assert cfg.base_url is None - - -def test_client_config_rejects_empty_base_url() -> None: - with pytest.raises(ValueError, match="base_url must be a non-empty string or None"): - ClientConfig(base_url="") - - -def test_client_config_rejects_slash_only_base_url() -> None: - with pytest.raises(ValueError, match="base_url must be a non-empty string or None"): - ClientConfig(base_url="/") - - -def test_client_config_rejects_multiple_slashes_only_base_url() -> None: - with pytest.raises(ValueError, match="base_url must be a non-empty string or None"): - ClientConfig(base_url="///") - - -def test_client_config_rejects_non_str_base_url() -> None: - with pytest.raises(ValueError, match="base_url must be a non-empty string or None"): - ClientConfig(base_url=123) # ty: ignore[invalid-argument-type] diff --git a/tests/test_decoders_pydantic_bench.py b/tests/test_decoders_pydantic_bench.py deleted file mode 100644 index 49ff534..0000000 --- a/tests/test_decoders_pydantic_bench.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Benchmark: single-pass `validate_json` is faster than two-pass (Story 1.5 AC9, NFR3).""" - -import gc -import json -import statistics -import time - -import pydantic -import pytest -from pytest_benchmark.fixture import BenchmarkFixture - -from httpware.decoders.pydantic import PydanticDecoder, _get_adapter - - -PAYLOAD_ITEMS = 30 -PAYLOAD_MIN_BYTES = 4500 -PAYLOAD_MAX_BYTES = 5500 -SPEEDUP_FLOOR = 1.5 # AC9 fallback per Open Questions item 5 (2x target not hardware-portable). - - -class _User(pydantic.BaseModel): - """Benchmark-only User shape: id, name, and a small attribute map.""" - - id: int - name: str - attributes: dict[str, int] - - -def _build_payload() -> bytes: - items = [ - { - "id": i, - "name": f"user-{i:03d}", - "attributes": {f"k{j:02d}": j * 7 for j in range(10)}, - } - for i in range(PAYLOAD_ITEMS) - ] - payload = json.dumps(items).encode("utf-8") - assert PAYLOAD_MIN_BYTES <= len(payload) <= PAYLOAD_MAX_BYTES, ( - f"payload size {len(payload)} outside acceptance window" - ) - return payload - - -@pytest.fixture -def payload() -> bytes: - return _build_payload() - - -@pytest.fixture(autouse=True, scope="module") -def _warm_cache() -> None: - _get_adapter.cache_clear() - PydanticDecoder().decode(_build_payload(), list[_User]) - - -@pytest.mark.benchmark(group="decoder", disable_gc=True) -def test_bench_single_pass_validate_json(benchmark: BenchmarkFixture, payload: bytes) -> None: - decoder = PydanticDecoder() - result = benchmark(decoder.decode, payload, list[_User]) - assert len(result) == PAYLOAD_ITEMS - - -@pytest.mark.benchmark(group="decoder", disable_gc=True) -def test_bench_two_pass_loads_then_validate(benchmark: BenchmarkFixture, payload: bytes) -> None: - adapter = pydantic.TypeAdapter(list[_User]) - - def two_pass() -> list[_User]: - return adapter.validate_python(json.loads(payload)) - - result = benchmark(two_pass) - assert len(result) == PAYLOAD_ITEMS - - -@pytest.mark.perf -def test_single_pass_is_measurably_faster_than_two_pass(payload: bytes) -> None: - decoder = PydanticDecoder() - adapter = pydantic.TypeAdapter(list[_User]) - - rounds = 60 - iterations = 30 - - gc.collect() - gc_was_enabled = gc.isenabled() - gc.disable() - try: - single_samples: list[float] = [] - for _ in range(rounds): - start = time.perf_counter_ns() - for _ in range(iterations): - decoder.decode(payload, list[_User]) - single_samples.append((time.perf_counter_ns() - start) / iterations) - - two_samples: list[float] = [] - for _ in range(rounds): - start = time.perf_counter_ns() - for _ in range(iterations): - adapter.validate_python(json.loads(payload)) - two_samples.append((time.perf_counter_ns() - start) / iterations) - finally: - if gc_was_enabled: - gc.enable() - - single_mean = statistics.median(single_samples) - two_mean = statistics.median(two_samples) - ratio = two_mean / single_mean - - assert ratio >= SPEEDUP_FLOOR, ( - f"NFR3 regression: single-pass {single_mean:.1f} ns/op, " - f"two-pass {two_mean:.1f} ns/op, ratio={ratio:.2f}x (need ≥ {SPEEDUP_FLOOR}x)" - ) diff --git a/tests/test_error_mapping_terminal.py b/tests/test_error_mapping_terminal.py new file mode 100644 index 0000000..53e3be1 --- /dev/null +++ b/tests/test_error_mapping_terminal.py @@ -0,0 +1,110 @@ +"""Tests for the AsyncClient internal terminal's exception mapping.""" + +from http import HTTPStatus + +import httpx2 +import pytest + +from httpware import ( + AsyncClient, + BadRequestError, + ClientStatusError, + InternalServerError, + NotFoundError, + RateLimitedError, + ServerStatusError, + StatusError, + TimeoutError, # noqa: A004 + TransportError, +) + + +def _client_with_handler(handler) -> AsyncClient: # noqa: ANN001 + transport = httpx2.MockTransport(handler) + return AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) + + +async def test_terminal_returns_response_on_2xx() -> None: + client = _client_with_handler(lambda req: httpx2.Response(HTTPStatus.OK, json={"ok": True}, request=req)) + response = await client.send(httpx2.Request("GET", "https://example.test/x")) + assert response.status_code == HTTPStatus.OK + assert response.json() == {"ok": True} + + +@pytest.mark.parametrize( + ("status", "exc_type"), + [ + (HTTPStatus.BAD_REQUEST, BadRequestError), + (HTTPStatus.NOT_FOUND, NotFoundError), + (HTTPStatus.TOO_MANY_REQUESTS, RateLimitedError), + (HTTPStatus.INTERNAL_SERVER_ERROR, InternalServerError), + ], +) +async def test_known_status_codes_raise_typed_subclass(status: int, exc_type: type[StatusError]) -> None: + client = _client_with_handler(lambda req: httpx2.Response(status, request=req)) + with pytest.raises(exc_type) as info: + await client.send(httpx2.Request("GET", "https://example.test/x")) + assert info.value.response.status_code == status + + +async def test_unknown_4xx_falls_back_to_client_status_error() -> None: + client = _client_with_handler(lambda req: httpx2.Response(HTTPStatus.IM_A_TEAPOT, request=req)) + with pytest.raises(ClientStatusError) as info: + await client.send(httpx2.Request("GET", "https://example.test/x")) + assert info.value.response.status_code == HTTPStatus.IM_A_TEAPOT + assert type(info.value) is ClientStatusError + + +async def test_unknown_5xx_falls_back_to_server_status_error() -> None: + client = _client_with_handler(lambda req: httpx2.Response(599, request=req)) + with pytest.raises(ServerStatusError) as info: + await client.send(httpx2.Request("GET", "https://example.test/x")) + assert info.value.response.status_code == 599 # noqa: PLR2004 + assert type(info.value) is ServerStatusError + + +async def test_3xx_does_not_raise() -> None: + client = _client_with_handler( + lambda req: httpx2.Response(HTTPStatus.MOVED_PERMANENTLY, request=req, headers={"location": "/y"}) + ) + response = await client.send(httpx2.Request("GET", "https://example.test/x")) + assert response.status_code == HTTPStatus.MOVED_PERMANENTLY + + +async def test_httpx2_timeout_maps_to_httpware_timeout() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 + msg = "read timeout" + raise httpx2.ReadTimeout(msg) + + client = _client_with_handler(handler) + with pytest.raises(TimeoutError, match="read timeout"): + await client.send(httpx2.Request("GET", "https://example.test/x")) + + +async def test_httpx2_connect_error_maps_to_transport_error() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 + msg = "connect refused" + raise httpx2.ConnectError(msg) + + client = _client_with_handler(handler) + with pytest.raises(TransportError, match="connect refused"): + await client.send(httpx2.Request("GET", "https://example.test/x")) + + +async def test_httpx2_invalid_url_maps_to_transport_error() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 + msg = "synthetic invalid URL from transport" + raise httpx2.InvalidURL(msg) + + client = _client_with_handler(handler) + with pytest.raises(TransportError, match="synthetic invalid URL"): + await client.send(httpx2.Request("GET", "https://example.test/x")) + + +async def test_send_on_closed_client_raises_transport_error() -> None: + transport = httpx2.MockTransport(lambda req: httpx2.Response(HTTPStatus.OK, request=req)) + underlying = httpx2.AsyncClient(transport=transport) + client = AsyncClient(httpx2_client=underlying) + await underlying.aclose() + with pytest.raises(TransportError): + await client.send(httpx2.Request("GET", "https://example.test/x")) diff --git a/tests/test_errors.py b/tests/test_errors.py index 431202a..3cb2011 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,13 +1,12 @@ -"""Unit tests for httpware.errors.""" +"""Tests for the status-keyed exception tree in httpware.errors.""" import builtins -import copy import pickle +import httpx2 import pytest -import httpware.errors -from httpware import ( +from httpware.errors import ( STATUS_TO_EXCEPTION, BadRequestError, ClientError, @@ -27,328 +26,134 @@ ) -_LEAF_HIERARCHY = [ - (BadRequestError, ClientStatusError), - (UnauthorizedError, ClientStatusError), - (ForbiddenError, ClientStatusError), - (NotFoundError, ClientStatusError), - (ConflictError, ClientStatusError), - (UnprocessableEntityError, ClientStatusError), - (RateLimitedError, ClientStatusError), - (InternalServerError, ServerStatusError), - (ServiceUnavailableError, ServerStatusError), -] +def _make_response(status: int, *, url: str = "https://example.test/x", method: str = "GET") -> httpx2.Response: + request = httpx2.Request(method, url) + return httpx2.Response(status, request=request) -@pytest.mark.parametrize(("leaf", "category"), _LEAF_HIERARCHY) -def test_leaf_inherits_full_chain(leaf: type[StatusError], category: type[StatusError]) -> None: - assert issubclass(leaf, category) - assert issubclass(leaf, StatusError) - assert issubclass(leaf, ClientError) +def test_inheritance_tree() -> None: + assert issubclass(StatusError, ClientError) + assert issubclass(TransportError, ClientError) + assert issubclass(TimeoutError, ClientError) + assert issubclass(TimeoutError, builtins.TimeoutError) + assert issubclass(ClientStatusError, StatusError) + assert issubclass(ServerStatusError, StatusError) + for exc in ( + BadRequestError, + UnauthorizedError, + ForbiddenError, + NotFoundError, + ConflictError, + UnprocessableEntityError, + RateLimitedError, + ): + assert issubclass(exc, ClientStatusError), exc + for exc in (InternalServerError, ServiceUnavailableError): + assert issubclass(exc, ServerStatusError), exc + + +def test_status_to_exception_table() -> None: + assert { + 400: BadRequestError, + 401: UnauthorizedError, + 403: ForbiddenError, + 404: NotFoundError, + 409: ConflictError, + 422: UnprocessableEntityError, + 429: RateLimitedError, + 500: InternalServerError, + 503: ServiceUnavailableError, + } == STATUS_TO_EXCEPTION + + +def test_status_error_stores_response() -> None: + response = _make_response(404) + exc = NotFoundError(response) + assert exc.response is response + + +def test_status_error_summary_message_includes_status_method_url() -> None: + exc = NotFoundError(_make_response(404, url="https://example.test/missing", method="GET")) + assert str(exc) == "404 GET https://example.test/missing" + + +def test_status_error_strips_userinfo_in_summary_message() -> None: + exc = NotFoundError(_make_response(404, url="https://user:pass@example.test/x")) + assert "user" not in str(exc) + assert "pass" not in str(exc) + assert str(exc) == "404 GET https://example.test/x" + + +def test_status_error_repr_strips_userinfo() -> None: + exc = NotFoundError(_make_response(404, url="https://user:pass@example.test/x")) + r = repr(exc) + assert "user" not in r + assert "pass" not in r + assert "NotFoundError" in r + assert "status=404" in r -def test_transport_error_inherits_client_error() -> None: - assert issubclass(TransportError, ClientError) +_NOT_FOUND = 404 -def test_timeout_error_inherits_client_error() -> None: - assert issubclass(TimeoutError, ClientError) +def test_status_error_pickleable() -> None: + exc = NotFoundError(_make_response(_NOT_FOUND, url="https://example.test/x")) + restored = pickle.loads(pickle.dumps(exc)) # noqa: S301 + assert isinstance(restored, NotFoundError) + assert restored.response.status_code == _NOT_FOUND + assert str(restored.response.request.url) == "https://example.test/x" -def test_timeout_error_is_builtins_timeout_error() -> None: - """``httpware.TimeoutError`` is also a ``builtins.TimeoutError``. +@pytest.mark.parametrize( + ("status", "expected"), + [ + (400, BadRequestError), + (401, UnauthorizedError), + (404, NotFoundError), + (429, RateLimitedError), + (500, InternalServerError), + (503, ServiceUnavailableError), + ], +) +def test_per_status_subclasses_construct(status: int, expected: type[StatusError]) -> None: + response = _make_response(status) + exc = expected(response) + assert isinstance(exc, expected) + assert exc.response.status_code == status - So ``except builtins.TimeoutError`` (the form ``asyncio.wait_for`` - raises) catches httpware-raised timeouts too. - """ - assert issubclass(TimeoutError, builtins.TimeoutError) - assert isinstance(TimeoutError(), builtins.TimeoutError) - assert isinstance(TimeoutError(), ClientError) - - -def test_builtins_timeout_error_is_not_httpware_timeout() -> None: - """The shadow is one-way: a bare ``builtins.TimeoutError`` is NOT a ``httpware.TimeoutError``.""" - assert not isinstance(builtins.TimeoutError(), TimeoutError) - - -def test_status_error_rejects_positional_args() -> None: - with pytest.raises(TypeError): - NotFoundError(404, b"", {}, None, "GET", "/x") # ty: ignore[missing-argument, too-many-positional-arguments] - - -def test_status_error_rejects_missing_kwarg() -> None: - with pytest.raises(TypeError): - NotFoundError(status=404) # ty: ignore[missing-argument] - - -def test_status_error_stores_all_fields() -> None: - status = 404 - body = b"not found" - headers = {"X-Trace": "abc"} - payload = {"error": "not found"} - method = "GET" - url = "/users/1" - exc = NotFoundError( - status=status, - body=body, - headers=headers, - json=payload, - request_method=method, - request_url=url, - ) - assert exc.status == status - assert exc.body == body - assert exc.headers == headers - assert exc.json == payload - assert exc.request_method == method - assert exc.request_url == url - - -def test_headers_are_defensively_copied() -> None: - """Caller mutation of the source dict after ``raise`` must not bleed into the exception.""" - headers: dict[str, str] = {"X-Trace": "abc"} - exc = NotFoundError( - status=404, - body=b"", - headers=headers, - json=None, - request_method="GET", - request_url="/x", - ) - headers["X-Trace"] = "MUTATED" - headers["X-Added"] = "leaked" - assert exc.headers["X-Trace"] == "abc" - assert "X-Added" not in exc.headers - - -def test_headers_are_read_only() -> None: - """The defensive copy is a ``MappingProxyType``; consumers cannot mutate it.""" - exc = NotFoundError( - status=404, - body=b"", - headers={"X-Trace": "abc"}, - json=None, - request_method="GET", - request_url="/x", - ) - with pytest.raises(TypeError): - exc.headers["X-Trace"] = "MUTATED" # ty: ignore[invalid-assignment] - - -def test_repr_format_4xx_leaf() -> None: - exc = NotFoundError( - status=404, - body=b"", - headers={}, - json=None, - request_method="GET", - request_url="/users/1", - ) - assert repr(exc) == "" - - -def test_repr_format_5xx_leaf() -> None: - exc = InternalServerError( - status=500, - body=b"", - headers={}, - json=None, - request_method="POST", - request_url="/x", - ) - assert repr(exc) == "" - - -def test_repr_does_not_leak_body_or_headers() -> None: - exc = NotFoundError( - status=404, - body=b"secret-token-abc", - headers={"Authorization": "Bearer s3cret"}, - json=None, - request_method="GET", - request_url="/x", - ) - r = repr(exc) - assert "secret-token-abc" not in r - assert "Authorization" not in r - assert "s3cret" not in r - - -def test_repr_strips_userinfo_from_url() -> None: - """``__repr__`` must drop ``user:pass@`` userinfo from the request URL.""" - exc = NotFoundError( - status=404, - body=b"", - headers={}, - json=None, - request_method="GET", - request_url="https://alice:s3cret@example.com/path", - ) - r = repr(exc) - assert "alice" not in r - assert "s3cret" not in r - assert "example.com/path" in r - - -def test_str_strips_userinfo_from_url() -> None: - """The summary message passed to ``Exception.__init__`` must also drop userinfo.""" - exc = NotFoundError( - status=404, - body=b"", - headers={}, - json=None, - request_method="GET", - request_url="https://alice:s3cret@example.com/path", - ) - s = str(exc) - assert "alice" not in s - assert "s3cret" not in s - assert "example.com/path" in s - - -def test_repr_preserves_explicit_port_when_stripping_userinfo() -> None: - """Stripping userinfo must keep the explicit port (``:8443``) in the rebuilt URL.""" - exc = NotFoundError( - status=404, - body=b"", - headers={}, - json=None, - request_method="GET", - request_url="https://alice:s3cret@example.com:8443/path", - ) - r = repr(exc) - assert "alice" not in r - assert "s3cret" not in r - assert "example.com:8443/path" in r - - -def test_repr_handles_at_sign_in_path_without_userinfo() -> None: - """A bare ``@`` in the path (no userinfo) must leave the URL untouched.""" - exc = NotFoundError( - status=404, - body=b"", - headers={}, - json=None, - request_method="GET", - request_url="https://example.com/users/@alice/profile", - ) - assert repr(exc) == "" - - -def test_status_error_direct_construction() -> None: - """The ``StatusError`` base is directly constructible — used by AC4 fallback callers.""" - status = 999 - exc = StatusError( - status=status, - body=b"", - headers={}, - json=None, - request_method="GET", - request_url="/x", - ) - assert exc.status == status - assert repr(exc) == "" - - -def test_client_status_error_fallback_construction() -> None: - """``ClientStatusError`` is the fallback target for unknown 4xx codes (e.g. 418).""" - status = 418 - exc = ClientStatusError( - status=status, - body=b"", - headers={}, - json=None, - request_method="GET", - request_url="/teapot", - ) - assert exc.status == status - assert repr(exc) == "" - - -def test_server_status_error_fallback_construction() -> None: - """``ServerStatusError`` is the fallback target for unknown 5xx codes (e.g. 504).""" - status = 504 - exc = ServerStatusError( - status=status, - body=b"", - headers={}, - json=None, - request_method="POST", - request_url="/x", - ) - assert exc.status == status - assert repr(exc) == "" - - -def test_status_error_pickle_round_trip() -> None: - """Exceptions survive ``pickle.dumps`` / ``pickle.loads`` across process boundaries.""" - original = NotFoundError( - status=404, - body=b"not found", - headers={"X-Trace": "abc"}, - json={"error": "not found"}, - request_method="GET", - request_url="/users/1", - ) - revived = pickle.loads(pickle.dumps(original)) # noqa: S301 - assert type(revived) is NotFoundError - assert revived.status == original.status - assert revived.body == original.body - assert dict(revived.headers) == dict(original.headers) - assert revived.json == original.json - assert revived.request_method == original.request_method - assert revived.request_url == original.request_url - assert repr(revived) == repr(original) - assert str(revived) == str(original) - - -def test_status_error_deepcopy_round_trip() -> None: - original = InternalServerError( - status=500, - body=b"", - headers={"X-Trace": "abc"}, - json=None, - request_method="POST", - request_url="/x", - ) - revived = copy.deepcopy(original) - assert type(revived) is InternalServerError - assert revived.status == original.status - assert dict(revived.headers) == dict(original.headers) - assert repr(revived) == repr(original) - - -_STATUS_MAPPING = [ - (400, BadRequestError), - (401, UnauthorizedError), - (403, ForbiddenError), - (404, NotFoundError), - (409, ConflictError), - (422, UnprocessableEntityError), - (429, RateLimitedError), - (500, InternalServerError), - (503, ServiceUnavailableError), -] - - -@pytest.mark.parametrize(("code", "cls"), _STATUS_MAPPING) -def test_status_to_exception_mapping(code: int, cls: type[StatusError]) -> None: - assert STATUS_TO_EXCEPTION[code] is cls - - -def test_status_to_exception_has_only_nine_entries() -> None: - assert len(STATUS_TO_EXCEPTION) == len(_STATUS_MAPPING) - - -def test_unknown_4xx_falls_back_to_client_status_error() -> None: - assert STATUS_TO_EXCEPTION.get(418, ClientStatusError) is ClientStatusError - - -def test_unknown_5xx_falls_back_to_server_status_error() -> None: - assert STATUS_TO_EXCEPTION.get(504, ServerStatusError) is ServerStatusError - - -def test_top_level_reexports_match_errors_module() -> None: - assert NotFoundError is httpware.errors.NotFoundError - assert ClientError is httpware.errors.ClientError - assert STATUS_TO_EXCEPTION is httpware.errors.STATUS_TO_EXCEPTION + +def test_status_error_strips_userinfo_with_username_only() -> None: + exc = NotFoundError(_make_response(404, url="https://user@example.test/x")) + assert "user" not in str(exc) + assert str(exc) == "404 GET https://example.test/x" + + +def test_status_error_summary_preserves_port() -> None: + exc = NotFoundError(_make_response(404, url="https://user:pass@example.test:8080/x")) + assert "user" not in str(exc) + assert "pass" not in str(exc) + assert str(exc) == "404 GET https://example.test:8080/x" + + +def test_status_error_summary_passthrough_when_at_in_query_only() -> None: + # `@` in query-string with no userinfo — should fall through after urlsplit returns no user/pass. + exc = NotFoundError(_make_response(404, url="https://example.test/x?email=foo@bar.com")) + assert str(exc) == "404 GET https://example.test/x?email=foo@bar.com" + + +def test_status_error_strips_userinfo_with_ipv6_host() -> None: + exc = NotFoundError(_make_response(404, url="https://user:pass@[::1]:8080/x")) + assert "user" not in str(exc) + assert "pass" not in str(exc) + assert str(exc) == "404 GET https://[::1]:8080/x" + + +def test_timeout_error_is_builtin_timeout_error() -> None: + exc = TimeoutError("timed out") + assert isinstance(exc, builtins.TimeoutError) + assert isinstance(exc, ClientError) + + +def test_transport_error_is_client_error() -> None: + exc = TransportError("connection refused") + assert isinstance(exc, ClientError) diff --git a/tests/test_internal_auth.py b/tests/test_internal_auth.py deleted file mode 100644 index 0cbf606..0000000 --- a/tests/test_internal_auth.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Unit tests for httpware._internal.auth._normalize_auth.""" - -import pytest - -from httpware._internal.auth import _normalize_auth -from httpware.middleware import Next -from httpware.request import Request -from httpware.response import Response - - -def _make_request(headers: dict[str, str] | None = None) -> Request: - return Request(method="GET", url="/foo", headers=headers or {}) - - -def _ok_response() -> Response: - return Response(status=200, headers={}, content=b"", url="/foo", elapsed=0.0) - - -async def _identity_next(request: Request) -> Response: # noqa: ARG001 - return _ok_response() - - -def test_none_returns_none() -> None: - assert _normalize_auth(None) is None - - -async def test_string_returns_bearer_middleware() -> None: - mw = _normalize_auth("token") - assert mw is not None - - seen: list[Request] = [] - - async def _capture_next(request: Request) -> Response: - seen.append(request) - return _ok_response() - - await mw(_make_request(), _capture_next) - - assert seen[0].headers["Authorization"] == "Bearer token" - - -async def test_string_bearer_skips_if_authorization_already_present() -> None: - mw = _normalize_auth("ignored") - assert mw is not None - - seen: list[Request] = [] - - async def _capture_next(request: Request) -> Response: - seen.append(request) - return _ok_response() - - await mw(_make_request(headers={"Authorization": "Basic xyz"}), _capture_next) - - assert seen[0].headers["Authorization"] == "Basic xyz" - - -async def test_sync_callable_returns_token_provider_middleware() -> None: - mw = _normalize_auth(lambda: "sync-tok") - assert mw is not None - - seen: list[Request] = [] - - async def _capture_next(request: Request) -> Response: - seen.append(request) - return _ok_response() - - await mw(_make_request(), _capture_next) - - assert seen[0].headers["Authorization"] == "Bearer sync-tok" - - -async def test_async_callable_returns_token_provider_middleware() -> None: - async def _provider() -> str: - return "async-tok" - - mw = _normalize_auth(_provider) - assert mw is not None - - seen: list[Request] = [] - - async def _capture_next(request: Request) -> Response: - seen.append(request) - return _ok_response() - - await mw(_make_request(), _capture_next) - - assert seen[0].headers["Authorization"] == "Bearer async-tok" - - -async def test_callable_token_provider_skips_if_authorization_already_present() -> None: - calls = 0 - - def _provider() -> str: - nonlocal calls - calls += 1 - return "should-not-set" - - mw = _normalize_auth(_provider) - assert mw is not None - - seen: list[Request] = [] - - async def _capture_next(request: Request) -> Response: - seen.append(request) - return _ok_response() - - await mw(_make_request(headers={"authorization": "Basic existing"}), _capture_next) - - assert seen[0].headers["authorization"] == "Basic existing" - assert calls == 0 - - -async def test_callable_token_provider_calls_provider_per_request() -> None: - calls = 0 - - def _provider() -> str: - nonlocal calls - calls += 1 - return f"tok-{calls}" - - mw = _normalize_auth(_provider) - assert mw is not None - - async def _ok_next(request: Request) -> Response: # noqa: ARG001 - return _ok_response() - - await mw(_make_request(), _ok_next) - await mw(_make_request(), _ok_next) - await mw(_make_request(), _ok_next) - - assert calls == 3 # noqa: PLR2004 - - -async def test_middleware_returned_unchanged() -> None: - class _PassthroughMw: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 - return await next(request) - - mw = _PassthroughMw() - assert _normalize_auth(mw) is mw - - -def test_one_arg_callable_raises_typeerror() -> None: - with pytest.raises(TypeError, match=r"`auth=`.*0 args.*2 args.*1"): - _normalize_auth(lambda x: "tok") # noqa: ARG005 # ty: ignore[invalid-argument-type] - - -def test_non_callable_non_string_non_middleware_raises_typeerror() -> None: - with pytest.raises(TypeError, match=r"`auth=`.*string.*Middleware.*int"): - _normalize_auth(42) # ty: ignore[invalid-argument-type] diff --git a/tests/test_middleware.py b/tests/test_middleware.py index bda6234..1bc0fba 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -1,470 +1,176 @@ -"""Tests for the Middleware protocol and chain composition.""" +"""Tests for the Middleware protocol, Next type, chain composition, and decorators.""" import asyncio -from collections.abc import Awaitable, Callable -from contextlib import AbstractAsyncContextManager from http import HTTPStatus -from typing import get_type_hints +import httpx2 import pytest -import httpware -from httpware import RecordedTransport -from httpware._internal.chain import compose -from httpware.middleware import Middleware, Next, after_response, before_request, on_error -from httpware.request import Request -from httpware.response import Response, StreamResponse +from httpware.middleware import ( + Middleware, + Next, + after_response, + before_request, + on_error, +) +from httpware.middleware.chain import compose -class _SignalMiddleware: - """Minimal valid Middleware implementation used by tests.""" +def _make_request(url: str = "https://example.test/x") -> httpx2.Request: + return httpx2.Request("GET", url) - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 - return await next(request) +def _make_response(status: int = HTTPStatus.OK, *, request: httpx2.Request | None = None) -> httpx2.Response: + if request is None: # pragma: no cover + request = _make_request() + return httpx2.Response(status, request=request) -def test_runtime_checkable_isinstance_works() -> None: - """A class implementing `__call__` satisfies the Middleware Protocol at runtime.""" - # runtime_checkable checks for presence of __call__, not signature details - assert isinstance(_SignalMiddleware(), Middleware) +async def test_middleware_protocol_is_runtime_checkable() -> None: + class _OkMiddleware: + async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 # pragma: no cover + return await next(request) -def test_next_type_alias_resolves_to_callable() -> None: - """`Next` resolves to `Callable[[Request], Awaitable[Response]]`.""" - expected = Callable[[Request], Awaitable[Response]] - assert Next == expected - - -def test_next_annotation_on_signal_middleware() -> None: - """`next` parameter on `_SignalMiddleware.__call__` is annotated with `Next`.""" - hints = get_type_hints(_SignalMiddleware.__call__) - assert hints["next"] == Next - - -def _ok_transport() -> RecordedTransport: - return RecordedTransport( - default=Response( - status=200, - headers={"x-from": "transport"}, - content=b"transport", - url="/", - elapsed=0.0, - ) - ) - + assert isinstance(_OkMiddleware(), Middleware) -def _make_request(method: str = "GET", url: str = "https://example.test/") -> Request: - return Request(method=method, url=url) +async def test_empty_chain_calls_terminal_directly() -> None: + seen: list[httpx2.Request] = [] -async def test_empty_list_composes_to_transport_call() -> None: - """compose([], transport) yields a callable that behaves like transport(req).""" - transport = _ok_transport() - dispatch = compose([], transport) + async def terminal(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return _make_response(200, request=request) + dispatch = compose((), terminal) request = _make_request() response = await dispatch(request) - - assert response.status == HTTPStatus.OK - assert response.content == b"transport" - assert response.headers["x-from"] == "transport" - - -async def test_single_middleware_wraps_transport() -> None: - """One middleware sees the request, calls next, returns the transport's response unchanged.""" - seen: list[Request] = [] - - class Tap: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 - seen.append(request) - return await next(request) - - transport = _ok_transport() - request = _make_request() - - response = await compose([Tap()], transport)(request) - + assert response.status_code == HTTPStatus.OK assert seen == [request] - assert response.content == b"transport" - - -async def test_chain_runs_outer_to_inner() -> None: - """Three middlewares form an onion: outer→inner→transport→inner→outer.""" - log: list[str] = [] - - def labeled(name: str) -> Middleware: - class Labeled: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 - log.append(f"{name}:before") - response = await next(request) - log.append(f"{name}:after") - return response - - return Labeled() - - dispatch = compose([labeled("A"), labeled("B"), labeled("C")], _ok_transport()) - await dispatch(_make_request()) - - assert log == [ - "A:before", - "B:before", - "C:before", - "C:after", - "B:after", - "A:after", - ] - - -async def test_middleware_can_transform_request_before_forwarding() -> None: - """An outer middleware mutates the request via with_header; the inner sees the mutation.""" - seen: list[Request] = [] - - class Stamp: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 - stamped = request.with_header("x-trace", "abc123") - return await next(stamped) - - class Inspect: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 - seen.append(request) - return await next(request) - await compose([Stamp(), Inspect()], _ok_transport())(_make_request()) - assert seen[0].headers["x-trace"] == "abc123" +async def test_chain_runs_middleware_in_order() -> None: + order: list[str] = [] + class _M: + def __init__(self, label: str) -> None: + self.label = label -async def test_middleware_can_transform_response_before_returning() -> None: - """An outer middleware awaits next, then returns a modified Response; caller sees it.""" - - class AddHeader: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 + order.append(f"{self.label}.before") response = await next(request) - return Response( - status=response.status, - headers={**response.headers, "x-trace": "abc123"}, - content=response.content, - url=response.url, - elapsed=response.elapsed, - ) - - response = await compose([AddHeader()], _ok_transport())(_make_request()) - - assert response.headers["x-trace"] == "abc123" - assert response.headers["x-from"] == "transport" # original still present - - -async def test_short_circuit_returns_synthesized_response() -> None: - """A middleware that does NOT call next returns a synthesized Response; transport never runs.""" - transport_calls = 0 - - class CountingTransport: - async def __call__(self, request: Request) -> Response: # noqa: ARG002 - nonlocal transport_calls - transport_calls += 1 - return Response( - status=200, - headers={"x-from": "transport"}, - content=b"transport", - url="/", - elapsed=0.0, - ) - - def stream(self, request: Request) -> AbstractAsyncContextManager[StreamResponse]: # pragma: no cover - raise NotImplementedError - - async def aclose(self) -> None: # pragma: no cover - return None - - class ShortCircuit: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002, ARG002 - return Response( - status=418, - headers={}, - content=b"teapot", - url=request.url, - elapsed=0.0, - ) - - class NeverReached: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002, ARG002 - msg = "inner middleware should not be invoked" - raise AssertionError(msg) - - response = await compose([ShortCircuit(), NeverReached()], CountingTransport())(_make_request()) - - assert response.status == HTTPStatus.IM_A_TEAPOT - assert response.content == b"teapot" - assert transport_calls == 0 - - -async def test_exception_in_middleware_propagates() -> None: - """A custom exception raised inside a middleware bubbles through the chain unchanged.""" - - class CustomError(Exception): - pass - - class Boom: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002, ARG002 - msg = "boom" - raise CustomError(msg) - - with pytest.raises(CustomError, match="boom"): - await compose([Boom()], _ok_transport())(_make_request()) - - -async def test_exception_in_transport_propagates_through_chain() -> None: - """An exception raised by the transport passes through every middleware unmodified.""" - - class TransportFail: - async def __call__(self, request: Request) -> Response: # noqa: ARG002 - msg = "transport failed" - raise RuntimeError(msg) - - def stream( # pragma: no cover - not exercised - self, request: Request - ) -> AbstractAsyncContextManager[StreamResponse]: - raise NotImplementedError - - async def aclose(self) -> None: # pragma: no cover - not exercised - return None - - class Passthrough: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 - return await next(request) - - with pytest.raises(RuntimeError, match="transport failed"): - await compose([Passthrough(), Passthrough()], TransportFail())(_make_request()) - - -async def test_cancelled_error_propagates_through_chain() -> None: - """asyncio.CancelledError raised mid-chain propagates to the caller (NFR15).""" + order.append(f"{self.label}.after") + return response - class Cancel: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002, ARG002 - raise asyncio.CancelledError - - class Passthrough: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 - return await next(request) - - with pytest.raises(asyncio.CancelledError): - await compose([Passthrough(), Cancel()], _ok_transport())(_make_request()) - - -async def test_compose_returned_callable_is_reusable() -> None: - """The Next returned by compose can be awaited sequentially across multiple requests.""" - count = 0 - - class Counter: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 - nonlocal count - count += 1 - return await next(request) - - dispatch = compose([Counter()], _ok_transport()) - - for _ in range(3): - response = await dispatch(_make_request()) - assert response.status == HTTPStatus.OK - - assert count == 3 # noqa: PLR2004 + async def terminal(request: httpx2.Request) -> httpx2.Response: + order.append("terminal") + return _make_response(200, request=request) + dispatch = compose((_M("a"), _M("b")), terminal) + await dispatch(_make_request()) + assert order == ["a.before", "b.before", "terminal", "b.after", "a.after"] -async def test_before_request_transforms_request() -> None: - """@before_request wraps an async request transform; downstream sees the mutation.""" +async def test_before_request_decorator_transforms_request() -> None: @before_request - async def stamp(request: Request) -> Request: - return request.with_header("x-trace", "abc123") - - seen: list[Request] = [] + async def add_header(request: httpx2.Request) -> httpx2.Request: + return httpx2.Request(request.method, request.url, headers={**request.headers, "X-Custom": "1"}) - class Inspect: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 - seen.append(request) - return await next(request) + captured: list[httpx2.Request] = [] - await compose([stamp, Inspect()], _ok_transport())(_make_request()) - - assert seen[0].headers["x-trace"] == "abc123" + async def terminal(request: httpx2.Request) -> httpx2.Response: + captured.append(request) + return _make_response(200, request=request) + dispatch = compose((add_header,), terminal) + await dispatch(_make_request()) + assert captured[0].headers["x-custom"] == "1" -async def test_after_response_transforms_response() -> None: - """@after_response wraps an async response transform; caller sees the modification.""" +async def test_after_response_decorator_transforms_response() -> None: @after_response - async def add_header(request: Request, response: Response) -> Response: # noqa: ARG001 - return Response( - status=response.status, - headers={**response.headers, "x-trace": "abc123"}, - content=response.content, - url=response.url, - elapsed=response.elapsed, - ) - - response = await compose([add_header], _ok_transport())(_make_request()) - - assert response.headers["x-trace"] == "abc123" - assert response.headers["x-from"] == "transport" # original still present + async def upgrade_status(request: httpx2.Request, response: httpx2.Response) -> httpx2.Response: + return httpx2.Response(HTTPStatus.IM_USED, request=request, headers=response.headers, content=response.content) + async def terminal(request: httpx2.Request) -> httpx2.Response: + return _make_response(HTTPStatus.OK, request=request) -def test_middleware_and_next_are_reexported_at_package_root() -> None: - """`from httpware import Middleware, Next` works in addition to the subpackage path.""" - assert httpware.Middleware is Middleware - assert httpware.Next is Next - assert "Middleware" in httpware.__all__ - assert "Next" in httpware.__all__ + dispatch = compose((upgrade_status,), terminal) + response = await dispatch(_make_request()) + assert response.status_code == HTTPStatus.IM_USED -async def test_on_error_returns_response_swallows_exception() -> None: - """When the handler returns a Response, the caller gets it; no exception escapes.""" - +async def test_on_error_decorator_can_translate_exception() -> None: @on_error - async def recover(request: Request, exc: Exception) -> Response | None: # noqa: ARG001 - return Response( - status=503, - headers={"x-recovered": "true"}, - content=b"recovered", - url=request.url, - elapsed=0.0, - ) + async def swallow(request: httpx2.Request, exc: Exception) -> httpx2.Response | None: + if isinstance(exc, RuntimeError) and str(exc) == "boom": + return _make_response(HTTPStatus.SERVICE_UNAVAILABLE, request=request) + return None # pragma: no cover - transport = RecordedTransport(default=RuntimeError("boom")) - response = await compose([recover], transport)(_make_request()) + async def terminal(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 + msg = "boom" + raise RuntimeError(msg) - assert response.status == HTTPStatus.SERVICE_UNAVAILABLE - assert response.headers["x-recovered"] == "true" - assert response.content == b"recovered" + dispatch = compose((swallow,), terminal) + response = await dispatch(_make_request()) + assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE async def test_on_error_returns_none_reraises() -> None: - """When the handler returns None, the original exception is re-raised with traceback intact.""" - @on_error - async def pass_through(request: Request, exc: Exception) -> Response | None: # noqa: ARG001 + async def passthrough( + request: httpx2.Request, # noqa: ARG001 + exc: Exception, # noqa: ARG001 + ) -> httpx2.Response | None: return None - transport = RecordedTransport(default=RuntimeError("boom")) + async def terminal(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 + msg = "boom" + raise RuntimeError(msg) + dispatch = compose((passthrough,), terminal) with pytest.raises(RuntimeError, match="boom"): - await compose([pass_through], transport)(_make_request()) - + await dispatch(_make_request()) -async def test_on_error_does_not_catch_cancelled_error() -> None: - """asyncio.CancelledError is not Exception; the handler must not be invoked.""" - invocations: list[Exception] = [] - - @on_error - async def should_not_run(request: Request, exc: Exception) -> Response | None: # noqa: ARG001 - invocations.append(exc) - return None - - class Cancel: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002, ARG002 - raise asyncio.CancelledError - - with pytest.raises(asyncio.CancelledError): - await compose([should_not_run, Cancel()], _ok_transport())(_make_request()) - - assert invocations == [] - - -async def test_on_error_handler_receives_correct_exception_instance() -> None: - """The handler's `exc` parameter is the same instance the transport raised.""" - raised = RuntimeError("specific instance") - seen: list[Exception] = [] - - @on_error - async def capture(request: Request, exc: Exception) -> Response | None: # noqa: ARG001 - seen.append(exc) - return None - - with pytest.raises(RuntimeError): - await compose([capture], RecordedTransport(default=raised))(_make_request()) - - assert seen == [raised] - assert seen[0] is raised - - -def test_decorators_satisfy_middleware_protocol() -> None: - """Each decorator returns an object that isinstance() recognizes as Middleware.""" +def test_before_request_repr() -> None: @before_request - async def br(request: Request) -> Request: - return request - - @after_response - async def ar(request: Request, response: Response) -> Response: # noqa: ARG001 - return response - - @on_error - async def oe(request: Request, exc: Exception) -> Response | None: # noqa: ARG001 - return None + async def my_transform(request: httpx2.Request) -> httpx2.Request: + return request # pragma: no cover - assert isinstance(br, Middleware) - assert isinstance(ar, Middleware) - assert isinstance(oe, Middleware) + assert "before_request" in repr(my_transform) + assert "my_transform" in repr(my_transform) -async def test_decorated_middlewares_compose_in_chain() -> None: - """Phase decorators interoperate with class-based middleware in one compose() call.""" - - @before_request - async def stamp(request: Request) -> Request: - return request.with_header("x-stamp", "1") - +def test_after_response_repr() -> None: @after_response - async def tag(request: Request, response: Response) -> Response: # noqa: ARG001 - return Response( - status=response.status, - headers={**response.headers, "x-tag": "1"}, - content=response.content, - url=response.url, - elapsed=response.elapsed, - ) - - seen_headers: list[str] = [] - - class Inspect: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 - seen_headers.append(request.headers.get("x-stamp", "")) - return await next(request) - - response = await compose([stamp, Inspect(), tag], _ok_transport())(_make_request()) + async def my_transform(request: httpx2.Request, response: httpx2.Response) -> httpx2.Response: # noqa: ARG001 + return response # pragma: no cover - assert seen_headers == ["1"] # stamp ran before Inspect - assert response.headers["x-tag"] == "1" # tag ran after the chain + assert "after_response" in repr(my_transform) + assert "my_transform" in repr(my_transform) -def test_repr_shows_original_function_name() -> None: - """repr() includes the phase name and the original user function's qualname.""" +def test_on_error_repr() -> None: + @on_error + async def my_handler(request: httpx2.Request, exc: Exception) -> httpx2.Response | None: # noqa: ARG001 + return None # pragma: no cover - @before_request - async def my_stamp(request: Request) -> Request: - return request + assert "on_error" in repr(my_handler) + assert "my_handler" in repr(my_handler) - @after_response - async def my_tag(request: Request, response: Response) -> Response: # noqa: ARG001 - return response +async def test_on_error_lets_cancelled_propagate() -> None: @on_error - async def my_recover(request: Request, exc: Exception) -> Response | None: # noqa: ARG001 - return None + async def swallow_all( + request: httpx2.Request, # noqa: ARG001 + exc: Exception, # noqa: ARG001 + ) -> httpx2.Response | None: # pragma: no cover + msg = "should not catch CancelledError" + raise AssertionError(msg) + + async def terminal(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 + raise asyncio.CancelledError - assert "before_request" in repr(my_stamp) - assert "my_stamp" in repr(my_stamp) - assert "after_response" in repr(my_tag) - assert "my_tag" in repr(my_tag) - assert "on_error" in repr(my_recover) - assert "my_recover" in repr(my_recover) - - -def test_decorators_reexported_at_package_root() -> None: - """`from httpware import before_request, after_response, on_error` works.""" - assert httpware.before_request is before_request - assert httpware.after_response is after_response - assert httpware.on_error is on_error - assert "before_request" in httpware.__all__ - assert "after_response" in httpware.__all__ - assert "on_error" in httpware.__all__ + dispatch = compose((swallow_all,), terminal) + with pytest.raises(asyncio.CancelledError): + await dispatch(_make_request()) diff --git a/tests/test_no_httpx2_leakage.py b/tests/test_no_httpx2_leakage.py deleted file mode 100644 index c2749e6..0000000 --- a/tests/test_no_httpx2_leakage.py +++ /dev/null @@ -1,21 +0,0 @@ -"""CI-invariant guard: only `transports/httpx2.py` may import `httpx2`.""" - -import re -from pathlib import Path - -import pytest - - -_PATTERN = re.compile(r"^\s*(?:import|from)\s+httpx2\b", re.MULTILINE) -_SRC_ROOT = Path(__file__).resolve().parents[1] / "src" / "httpware" -_SOURCES = sorted(_SRC_ROOT.rglob("*.py")) -_ALLOWED = _SRC_ROOT / "transports" / "httpx2.py" - -assert _SOURCES, f"leakage test discovered no source files under {_SRC_ROOT}" - - -@pytest.mark.parametrize("path", _SOURCES, ids=lambda p: p.relative_to(_SRC_ROOT.parent).as_posix()) -def test_only_httpx2_transport_imports_httpx2(path: Path) -> None: - text = path.read_text(encoding="utf-8") - if _PATTERN.search(text): - assert path == _ALLOWED, f"unexpected httpx2 import in {path}" diff --git a/tests/test_public_api.py b/tests/test_public_api.py index edc6909..40beb2e 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -1,15 +1,56 @@ -"""Verify public API exports are correct and stable.""" +"""Public API surface — what `from httpware import ...` exposes.""" import httpware -from httpware import AuthValue # noqa: F401 -def test_all_exports_present() -> None: - """Verify all symbols in __all__ are actually exported.""" +def test_all_exports_resolve() -> None: for symbol in httpware.__all__: - assert hasattr(httpware, symbol), f"{symbol} in __all__ but not exported" + assert hasattr(httpware, symbol), f"{symbol} declared in __all__ but missing" -def test_auth_value_is_public() -> None: - """Verify AuthValue type alias is exported.""" - assert "AuthValue" in httpware.__all__ +def test_no_removed_symbols_leaked() -> None: + removed = { + "Request", + "Response", + "StreamResponse", + "Timeout", + "Limits", + "ClientConfig", + "Transport", + "Httpx2Transport", + "RecordedTransport", + "AuthValue", + } + leaked = removed & set(dir(httpware)) + assert not leaked, f"removed 0.1 symbols still exposed: {leaked}" + + +def test_expected_exports() -> None: + expected = { + "AsyncClient", + "Middleware", + "Next", + "ResponseDecoder", + "PydanticDecoder", + "ClientError", + "TransportError", + "TimeoutError", + "StatusError", + "ClientStatusError", + "ServerStatusError", + "BadRequestError", + "UnauthorizedError", + "ForbiddenError", + "NotFoundError", + "ConflictError", + "UnprocessableEntityError", + "RateLimitedError", + "InternalServerError", + "ServiceUnavailableError", + "STATUS_TO_EXCEPTION", + "before_request", + "after_response", + "on_error", + } + missing = expected - set(httpware.__all__) + assert not missing, f"expected exports missing from __all__: {missing}" diff --git a/tests/test_request.py b/tests/test_request.py deleted file mode 100644 index fc7879b..0000000 --- a/tests/test_request.py +++ /dev/null @@ -1,226 +0,0 @@ -"""Unit tests for httpware.request.Request.""" - -from dataclasses import FrozenInstanceError - -import pytest - -from httpware import Request - - -def test_request_is_frozen() -> None: - req = Request(method="GET", url="https://example.com/") - with pytest.raises(FrozenInstanceError): - req.method = "POST" # ty: ignore[invalid-assignment] - - -def test_request_default_mappings_are_empty_and_independent() -> None: - r1 = Request(method="GET", url="/") - r2 = Request(method="GET", url="/") - assert r1.headers == {} - assert r1.params == {} - assert r1.cookies == {} - assert r1.extensions == {} - assert r1.body is None - assert r1.headers is not r2.headers - - -def test_request_equality_on_identical_fields() -> None: - r1 = Request(method="GET", url="/x", headers={"a": "1"}) - r2 = Request(method="GET", url="/x", headers={"a": "1"}) - assert r1 == r2 - assert r1 != Request(method="POST", url="/x", headers={"a": "1"}) - assert r1 != Request(method="GET", url="/y", headers={"a": "1"}) - assert r1 != Request(method="GET", url="/x", headers={"a": "2"}) - - -def test_with_header_adds_when_absent() -> None: - r = Request(method="GET", url="/") - new = r.with_header("X-Trace", "abc") - assert new.headers == {"X-Trace": "abc"} - assert r.headers == {} - assert new is not r - - -def test_with_header_replaces_when_present() -> None: - r = Request(method="GET", url="/", headers={"X-Trace": "old"}) - new = r.with_header("X-Trace", "new") - assert new.headers == {"X-Trace": "new"} - assert r.headers == {"X-Trace": "old"} - - -def test_with_url_returns_new_instance() -> None: - r = Request(method="GET", url="/a") - new = r.with_url("/b") - assert new.url == "/b" - assert r.url == "/a" - assert new is not r - - -def test_with_body_returns_new_instance() -> None: - r = Request(method="POST", url="/") - new = r.with_body(b"payload") - assert new.body == b"payload" - assert r.body is None - assert new is not r - - -def test_with_query_replaces_params() -> None: - r = Request(method="GET", url="/", params={"a": "1"}) - new = r.with_query({"b": "2"}) - assert new.params == {"b": "2"} - assert r.params == {"a": "1"} - assert new is not r - - -def test_with_headers_merges_new_headers() -> None: - r = Request(method="GET", url="/") - new = r.with_headers({"X-Trace": "abc", "X-Other": "1"}) - assert new.headers == {"X-Trace": "abc", "X-Other": "1"} - assert r.headers == {} - - -def test_with_headers_overrides_existing_key() -> None: - r = Request(method="GET", url="/", headers={"X-Trace": "old"}) - new = r.with_headers({"X-Trace": "new"}) - assert new.headers == {"X-Trace": "new"} - assert r.headers == {"X-Trace": "old"} - - -def test_with_headers_preserves_other_keys() -> None: - r = Request(method="GET", url="/", headers={"Keep": "1", "Replace": "old"}) - new = r.with_headers({"Replace": "new", "Add": "2"}) - assert new.headers == {"Keep": "1", "Replace": "new", "Add": "2"} - - -def test_with_headers_empty_mapping_returns_distinct_copy() -> None: - r = Request(method="GET", url="/", headers={"A": "1"}) - new = r.with_headers({}) - assert new == r - assert new is not r - - -def test_with_cookie_adds_single_cookie() -> None: - r = Request(method="GET", url="/") - new = r.with_cookie("session", "abc") - assert new.cookies == {"session": "abc"} - assert r.cookies == {} - - -def test_with_cookie_replaces_existing_cookie() -> None: - r = Request(method="GET", url="/", cookies={"session": "old"}) - new = r.with_cookie("session", "new") - assert new.cookies == {"session": "new"} - assert r.cookies == {"session": "old"} - - -def test_with_cookies_merges_new_cookies() -> None: - r = Request(method="GET", url="/", cookies={"keep": "1", "replace": "old"}) - new = r.with_cookies({"replace": "new", "add": "2"}) - assert new.cookies == {"keep": "1", "replace": "new", "add": "2"} - assert r.cookies == {"keep": "1", "replace": "old"} - - -def test_with_extension_adds_single_entry() -> None: - r = Request(method="GET", url="/") - new = r.with_extension("timeout", 5.0) - assert new.extensions == {"timeout": 5.0} - assert r.extensions == {} - - -def test_with_extensions_merges_new_entries() -> None: - r = Request(method="GET", url="/", extensions={"keep": 1, "replace": "old"}) - new = r.with_extensions({"replace": "new", "add": [1, 2]}) - assert new.extensions == {"keep": 1, "replace": "new", "add": [1, 2]} - assert r.extensions == {"keep": 1, "replace": "old"} - - -def test_with_extension_accepts_any_value_type() -> None: - class _Marker: - pass - - marker = _Marker() - r = Request(method="GET", url="/") - new = r.with_extension("marker", marker) - assert new.extensions == {"marker": marker} - assert new.extensions["marker"] is marker - - -def test_request_rejects_empty_url() -> None: - with pytest.raises(ValueError, match="url must be non-empty"): - Request(method="GET", url="") - - -def test_request_rejects_non_str_url() -> None: - with pytest.raises(TypeError, match="url must be str"): - Request(method="GET", url=None) # ty: ignore[invalid-argument-type] - - -def test_with_url_rejects_empty() -> None: - r = Request(method="GET", url="/") - with pytest.raises(ValueError, match="url must be non-empty"): - r.with_url("") - - -def test_request_rejects_header_with_crlf_in_value() -> None: - with pytest.raises(ValueError, match="header name and value must not contain CR or LF"): - Request(method="GET", url="/", headers={"X-Trace": "value\r\nInjected: yes"}) - - -def test_request_rejects_header_with_crlf_in_name() -> None: - with pytest.raises(ValueError, match="header name and value must not contain CR or LF"): - Request(method="GET", url="/", headers={"X-Bad\r\nInjected": "value"}) - - -def test_request_rejects_empty_header_name() -> None: - with pytest.raises(ValueError, match="header name and value must be non-empty"): - Request(method="GET", url="/", headers={"": "value"}) - - -def test_request_rejects_empty_header_value() -> None: - with pytest.raises(ValueError, match="header name and value must be non-empty"): - Request(method="GET", url="/", headers={"X-Trace": ""}) - - -def test_request_rejects_non_str_header_value() -> None: - with pytest.raises(TypeError, match="header name and value must be str"): - Request(method="GET", url="/", headers={"X-Trace": None}) # ty: ignore[invalid-argument-type] - - -def test_request_rejects_cookie_with_crlf() -> None: - with pytest.raises(ValueError, match="cookie name and value must not contain CR or LF"): - Request(method="GET", url="/", cookies={"session": "abc\r\nSet-Cookie: evil"}) - - -def test_request_rejects_empty_cookie_value() -> None: - with pytest.raises(ValueError, match="cookie name and value must be non-empty"): - Request(method="GET", url="/", cookies={"session": ""}) - - -def test_with_header_rejects_crlf() -> None: - r = Request(method="GET", url="/") - with pytest.raises(ValueError, match="header name and value must not contain CR or LF"): - r.with_header("X-Trace", "value\r\n") - - -def test_with_cookie_rejects_crlf() -> None: - r = Request(method="GET", url="/") - with pytest.raises(ValueError, match="cookie name and value must not contain CR or LF"): - r.with_cookie("session", "abc\r\n") - - -@pytest.mark.parametrize("field_name", ["headers", "params", "cookies", "extensions"]) -def test_request_rejects_none_mapping_field(field_name: str) -> None: - with pytest.raises(TypeError, match=f"{field_name} must be a Mapping"): - Request(method="GET", url="/", **{field_name: None}) # ty: ignore[invalid-argument-type] - - -@pytest.mark.parametrize("field_name", ["headers", "params", "cookies", "extensions"]) -def test_request_rejects_list_mapping_field(field_name: str) -> None: - with pytest.raises(TypeError, match=f"{field_name} must be a Mapping"): - Request(method="GET", url="/", **{field_name: []}) # ty: ignore[invalid-argument-type] - - -def test_with_query_none_raises() -> None: - r = Request(method="GET", url="/") - with pytest.raises(TypeError, match="params must be a Mapping"): - r.with_query(None) # ty: ignore[invalid-argument-type] diff --git a/tests/test_response.py b/tests/test_response.py deleted file mode 100644 index a7fc42e..0000000 --- a/tests/test_response.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Unit tests for httpware.response.Response.""" - -from dataclasses import FrozenInstanceError -from http import HTTPStatus - -import pytest - -from httpware import Response -from httpware.response import _parse_charset - - -def test_response_is_frozen() -> None: - resp = Response(status=200, headers={}, content=b"", url="/", elapsed=0.0) - with pytest.raises(FrozenInstanceError): - resp.status = 500 # ty: ignore[invalid-assignment] - - -def test_response_text_defaults_to_utf8() -> None: - resp = Response(status=200, headers={}, content=b"hello", url="/", elapsed=0.0) - assert resp.text == "hello" - - -def test_response_text_decodes_unicode_default() -> None: - body = "café".encode() - resp = Response(status=200, headers={}, content=body, url="/", elapsed=0.0) - assert resp.text == "café" - - -@pytest.mark.parametrize("header_name", ["content-type", "Content-Type", "CONTENT-TYPE"]) -def test_response_text_honors_explicit_charset(header_name: str) -> None: - body = "café".encode("latin-1") - resp = Response( - status=200, - headers={header_name: "text/plain; charset=latin-1"}, - content=body, - url="/", - elapsed=0.0, - ) - assert resp.text == "café" - - -def test_response_text_falls_back_to_utf8_on_missing_charset() -> None: - resp = Response( - status=200, - headers={"content-type": "application/json"}, - content=b'{"x": 1}', - url="/", - elapsed=0.0, - ) - assert resp.text == '{"x": 1}' - - -@pytest.mark.parametrize( - "content_type", - [ - 'text/plain; charset="latin-1"', - "text/plain; charset='latin-1'", - ], -) -def test_response_text_strips_quotes_around_charset(content_type: str) -> None: - body = "café".encode("latin-1") - resp = Response( - status=200, - headers={"content-type": content_type}, - content=body, - url="/", - elapsed=0.0, - ) - assert resp.text == "café" - - -def test_response_text_strips_inner_whitespace_in_quoted_charset() -> None: - # Direct parser check: inner whitespace inside the quoted value must not survive. - # (Python's codec registry happens to normalize whitespace, masking the bug - # end-to-end on most charsets; verify the parser itself returns a clean value.) - assert _parse_charset('text/plain; charset=" iso-8859-1 "') == "iso-8859-1" - - # End-to-end smoke: decoding the Latin-1 body must yield the original text. - body = "café".encode("iso-8859-1") - resp = Response( - status=HTTPStatus.OK, - headers={"content-type": 'text/plain; charset=" iso-8859-1 "'}, - content=body, - url="/", - elapsed=0.0, - ) - assert resp.text == "café" - - -def test_response_text_falls_back_to_utf8_on_unknown_charset() -> None: - resp = Response( - status=200, - headers={"content-type": "text/plain; charset=not-a-real-codec"}, - content=b"hello", - url="/", - elapsed=0.0, - ) - assert resp.text == "hello" - - -def test_response_json_parses_body() -> None: - resp = Response(status=200, headers={}, content=b'{"a": 1, "b": [2, 3]}', url="/", elapsed=0.0) - assert resp.json() == {"a": 1, "b": [2, 3]} - - -def test_response_json_uses_declared_charset() -> None: - body = '{"name": "café"}'.encode("iso-8859-1") - resp = Response( - status=HTTPStatus.OK, - headers={"content-type": "application/json; charset=iso-8859-1"}, - content=body, - url="/", - elapsed=0.0, - ) - assert resp.json() == {"name": "café"} - - -def test_response_equality_on_identical_fields() -> None: - r1 = Response(status=200, headers={"a": "1"}, content=b"x", url="/", elapsed=0.5) - r2 = Response(status=200, headers={"a": "1"}, content=b"x", url="/", elapsed=0.5) - assert r1 == r2 - assert r1 != Response(status=200, headers={"a": "1"}, content=b"x", url="/", elapsed=0.6) - assert r1 != Response(status=201, headers={"a": "1"}, content=b"x", url="/", elapsed=0.5) - - -def test_response_with_headers_merges_new_headers() -> None: - resp = Response(status=200, headers={"keep": "1"}, content=b"", url="/", elapsed=0.0) - new = resp.with_headers({"x-trace": "abc"}) - assert new.headers == {"keep": "1", "x-trace": "abc"} - assert resp.headers == {"keep": "1"} - - -def test_response_with_headers_overrides_existing_key() -> None: - resp = Response(status=200, headers={"x-trace": "old"}, content=b"", url="/", elapsed=0.0) - new = resp.with_headers({"x-trace": "new"}) - assert new.headers == {"x-trace": "new"} - assert resp.headers == {"x-trace": "old"} - - -def test_response_with_status_replaces_status() -> None: - resp = Response(status=200, headers={"a": "1"}, content=b"body", url="/x", elapsed=0.5) - new = resp.with_status(503) - assert new.status == HTTPStatus.SERVICE_UNAVAILABLE - assert new.headers == {"a": "1"} - assert new.content == b"body" - assert new.url == "/x" - assert new.elapsed == 0.5 # noqa: PLR2004 - assert resp.status == HTTPStatus.OK - - -def test_response_with_status_accepts_arbitrary_int() -> None: - resp = Response(status=200, headers={}, content=b"", url="/", elapsed=0.0) - # No validation by design — value objects don't enforce protocol semantics. - new = resp.with_status(99) - assert new.status == 99 # noqa: PLR2004 diff --git a/tests/test_transports_httpx2.py b/tests/test_transports_httpx2.py deleted file mode 100644 index a25f0cb..0000000 --- a/tests/test_transports_httpx2.py +++ /dev/null @@ -1,460 +0,0 @@ -"""Unit tests for httpware.transports.httpx2.""" - -import asyncio -from collections.abc import Callable -from http import HTTPStatus - -import httpx2 -import pytest - -from httpware import ( - BadRequestError, - ClientStatusError, - ConflictError, - ForbiddenError, - Httpx2Transport, - InternalServerError, - Limits, - NotFoundError, - RateLimitedError, - Request, - Response, - ServerStatusError, - ServiceUnavailableError, - StatusError, - Timeout, - TimeoutError, # noqa: A004 - Transport, - TransportError, - UnauthorizedError, - UnprocessableEntityError, -) - - -_Handler = Callable[[httpx2.Request], httpx2.Response] - - -def _status_handler(code: int, content: bytes = b"", headers: dict[str, str] | None = None) -> _Handler: - def handler(_req: httpx2.Request) -> httpx2.Response: - return httpx2.Response(code, content=content, headers=headers or {}) - - return handler - - -def _raising_handler(exc: BaseException) -> _Handler: - def handler(_req: httpx2.Request) -> httpx2.Response: - raise exc - - return handler - - -def _make_transport(handler: _Handler) -> Httpx2Transport: - return Httpx2Transport(client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler))) - - -# ----- (a) protocol membership ---------------------------------------------- - - -def test_httpx2_transport_satisfies_transport_protocol() -> None: - assert isinstance(Httpx2Transport(), Transport) - - -# ----- (b) success path 200 -------------------------------------------------- - - -async def test_success_path_returns_response() -> None: - transport = _make_transport(_status_handler(200, content=b"hello", headers={"content-type": "text/plain"})) - try: - resp = await transport(Request(method="GET", url="http://example.com/x")) - finally: - await transport.aclose() - - assert isinstance(resp, Response) - assert resp.status == HTTPStatus.OK - assert resp.content == b"hello" - assert resp.url == "http://example.com/x" - # lowercase ASCII keys per AC11 - assert "content-type" in resp.headers - assert resp.headers["content-type"] == "text/plain" - assert resp.elapsed >= 0.0 - - -# ----- (c) status-code mapping ---------------------------------------------- - - -_STATUS_LEAVES: list[tuple[int, type[StatusError]]] = [ - (400, BadRequestError), - (401, UnauthorizedError), - (403, ForbiddenError), - (404, NotFoundError), - (409, ConflictError), - (422, UnprocessableEntityError), - (429, RateLimitedError), - (500, InternalServerError), - (503, ServiceUnavailableError), -] - - -async def test_success_status_200_returns_response_not_raises() -> None: - transport = _make_transport(_status_handler(200)) - try: - resp = await transport(Request(method="GET", url="http://example.com/")) - finally: - await transport.aclose() - assert resp.status == HTTPStatus.OK - - -@pytest.mark.parametrize(("code", "exc_cls"), _STATUS_LEAVES) -async def test_status_mapping_raises_precise_leaf(code: int, exc_cls: type[StatusError]) -> None: - transport = _make_transport( - _status_handler(code, content=b'{"err":1}', headers={"content-type": "application/json"}) - ) - try: - with pytest.raises(exc_cls) as info: - await transport(Request(method="GET", url="http://example.com/p")) - finally: - await transport.aclose() - - assert type(info.value) is exc_cls - assert info.value.status == code - assert info.value.request_method == "GET" - assert info.value.request_url == "http://example.com/p" - assert info.value.json == {"err": 1} - - -# ----- (d) unknown-status fallback ------------------------------------------ - - -async def test_unknown_4xx_falls_back_to_client_status_error() -> None: - transport = _make_transport(_status_handler(418)) - try: - with pytest.raises(ClientStatusError) as info: - await transport(Request(method="GET", url="http://example.com/")) - finally: - await transport.aclose() - assert type(info.value) is ClientStatusError - assert info.value.status == HTTPStatus.IM_A_TEAPOT - - -async def test_unknown_5xx_falls_back_to_server_status_error() -> None: - transport = _make_transport(_status_handler(504)) - try: - with pytest.raises(ServerStatusError) as info: - await transport(Request(method="GET", url="http://example.com/")) - finally: - await transport.aclose() - assert type(info.value) is ServerStatusError - assert info.value.status == HTTPStatus.GATEWAY_TIMEOUT - - -# ----- (e) _try_decode_json branches ---------------------------------------- - - -async def test_json_body_decodes_into_exception_json_field() -> None: - transport = _make_transport( - _status_handler(400, content=b'{"k": "v"}', headers={"content-type": "application/json; charset=utf-8"}) - ) - try: - with pytest.raises(BadRequestError) as info: - await transport(Request(method="GET", url="http://example.com/")) - finally: - await transport.aclose() - assert info.value.json == {"k": "v"} - - -async def test_non_json_body_yields_none_on_exception_json() -> None: - transport = _make_transport( - _status_handler(500, content=b"oops", headers={"content-type": "text/html"}) - ) - try: - with pytest.raises(InternalServerError) as info: - await transport(Request(method="GET", url="http://example.com/")) - finally: - await transport.aclose() - assert info.value.json is None - - -async def test_malformed_json_body_yields_none_on_exception_json() -> None: - transport = _make_transport( - _status_handler(400, content=b"{not json", headers={"content-type": "application/json"}) - ) - try: - with pytest.raises(BadRequestError) as info: - await transport(Request(method="GET", url="http://example.com/")) - finally: - await transport.aclose() - assert info.value.json is None - - -async def test_empty_body_with_json_content_type_yields_none() -> None: - transport = _make_transport(_status_handler(400, content=b"", headers={"content-type": "application/json"})) - try: - with pytest.raises(BadRequestError) as info: - await transport(Request(method="GET", url="http://example.com/")) - finally: - await transport.aclose() - assert info.value.json is None - - -async def test_missing_content_type_header_yields_none_on_exception_json() -> None: - transport = _make_transport(_status_handler(400, content=b'{"k": 1}', headers={})) - try: - with pytest.raises(BadRequestError) as info: - await transport(Request(method="GET", url="http://example.com/")) - finally: - await transport.aclose() - assert info.value.json is None - - -# ----- (f) httpx2.TimeoutException family ----------------------------------- - - -_TIMEOUT_CLASSES = [httpx2.ConnectTimeout, httpx2.ReadTimeout, httpx2.WriteTimeout, httpx2.PoolTimeout] - - -@pytest.mark.parametrize("timeout_cls", _TIMEOUT_CLASSES) -async def test_timeout_classes_map_to_httpware_timeout_error(timeout_cls) -> None: # noqa: ANN001 - transport = _make_transport(_raising_handler(timeout_cls("boom"))) - try: - with pytest.raises(TimeoutError) as info: - await transport(Request(method="GET", url="http://example.com/")) - finally: - await transport.aclose() - assert type(info.value) is TimeoutError - assert isinstance(info.value.__cause__, timeout_cls) - - -# ----- (g) httpx2.HTTPError family (representative) ------------------------- - - -_HTTP_ERROR_CLASSES = [ - httpx2.ConnectError, - httpx2.NetworkError, - httpx2.ProxyError, - httpx2.UnsupportedProtocol, - httpx2.LocalProtocolError, - httpx2.RemoteProtocolError, - httpx2.DecodingError, - httpx2.TooManyRedirects, -] - - -@pytest.mark.parametrize("http_err_cls", _HTTP_ERROR_CLASSES) -async def test_http_error_descendants_map_to_transport_error(http_err_cls) -> None: # noqa: ANN001 - transport = _make_transport(_raising_handler(http_err_cls("boom"))) - try: - with pytest.raises(TransportError) as info: - await transport(Request(method="GET", url="http://example.com/")) - finally: - await transport.aclose() - assert type(info.value) is TransportError - assert isinstance(info.value.__cause__, http_err_cls) - - -# ----- (h) httpx2.InvalidURL (orphan branch) -------------------------------- - - -async def test_invalid_url_maps_to_transport_error() -> None: - transport = _make_transport(_raising_handler(httpx2.InvalidURL("nope"))) - try: - with pytest.raises(TransportError) as info: - await transport(Request(method="GET", url="http://example.com/")) - finally: - await transport.aclose() - assert type(info.value) is TransportError - assert isinstance(info.value.__cause__, httpx2.InvalidURL) - - -# ----- (i) no httpx2 exception escapes -------------------------------------- - - -_ALL_HTTPX2_EXCEPTIONS = _TIMEOUT_CLASSES + _HTTP_ERROR_CLASSES + [httpx2.InvalidURL] - - -@pytest.mark.parametrize("exc_cls", _ALL_HTTPX2_EXCEPTIONS) -async def test_no_httpx2_exception_escapes(exc_cls) -> None: # noqa: ANN001 - transport = _make_transport(_raising_handler(exc_cls("boom"))) - try: - with pytest.raises((TimeoutError, TransportError)) as info: - await transport(Request(method="GET", url="http://example.com/")) - finally: - await transport.aclose() - assert not isinstance(info.value, httpx2.HTTPError) - - -# ----- (j) method casing normalization -------------------------------------- - - -async def test_lowercase_method_uppercased_in_status_error() -> None: - transport = _make_transport(_status_handler(404)) - try: - with pytest.raises(NotFoundError) as info: - await transport(Request(method="get", url="http://example.com/p")) - finally: - await transport.aclose() - assert info.value.request_method == "GET" - - -# ----- (k) stream() raises synchronously ------------------------------------ - - -def test_stream_raises_not_implemented_synchronously() -> None: - transport = Httpx2Transport() - with pytest.raises(NotImplementedError): - transport.stream(Request(method="GET", url="http://example.com/")) - - -# ----- (l) aclose() idempotency --------------------------------------------- - - -async def test_aclose_is_idempotent() -> None: - transport = _make_transport(_status_handler(200)) - await transport(Request(method="GET", url="http://example.com/")) - await transport.aclose() - await transport.aclose() - assert transport._client is None # noqa: SLF001 - - -# ----- (m) aclose() on never-used transport --------------------------------- - - -async def test_aclose_no_op_on_never_used_transport() -> None: - transport = Httpx2Transport() - await transport.aclose() - assert transport._client is None # noqa: SLF001 - - -# ----- (n) post-close call raises ------------------------------------------- - - -async def test_post_close_call_raises_transport_error() -> None: - transport = _make_transport(_status_handler(200)) - await transport(Request(method="GET", url="http://example.com/")) - await transport.aclose() - with pytest.raises(TransportError): - await transport(Request(method="GET", url="http://example.com/")) - - -async def test_post_close_stream_raises_transport_error() -> None: - transport = _make_transport(_status_handler(200)) - await transport.aclose() - with pytest.raises(TransportError): - transport.stream(Request(method="GET", url="http://example.com/")) - - -async def test_pre_close_stream_still_raises_not_implemented() -> None: - transport = _make_transport(_status_handler(200)) - with pytest.raises(NotImplementedError): - transport.stream(Request(method="GET", url="http://example.com/")) - await transport.aclose() - - -async def test_invalid_url_at_request_construction_maps_to_transport_error( - monkeypatch: pytest.MonkeyPatch, -) -> None: - transport = _make_transport(_status_handler(200)) - - def _boom(*_args: object, **_kwargs: object) -> httpx2.Request: - msg = "bad url" - raise httpx2.InvalidURL(msg) - - monkeypatch.setattr(httpx2, "Request", _boom) - with pytest.raises(TransportError): - await transport(Request(method="GET", url="http://example.com/")) - - -async def test_cookie_conflict_at_request_construction_maps_to_transport_error( - monkeypatch: pytest.MonkeyPatch, -) -> None: - transport = _make_transport(_status_handler(200)) - - def _boom(*_args: object, **_kwargs: object) -> httpx2.Request: - msg = "conflict" - raise httpx2.CookieConflict(msg) - - monkeypatch.setattr(httpx2, "Request", _boom) - with pytest.raises(TransportError): - await transport(Request(method="GET", url="http://example.com/")) - - -async def test_send_on_externally_closed_user_client_maps_to_transport_error() -> None: - user_client = httpx2.AsyncClient(transport=httpx2.MockTransport(_status_handler(200))) - transport = Httpx2Transport(client=user_client) - await user_client.aclose() - with pytest.raises(TransportError): - await transport(Request(method="GET", url="http://example.com/")) - - -async def test_unexpected_runtime_error_propagates_unchanged( - monkeypatch: pytest.MonkeyPatch, -) -> None: - transport = _make_transport(_status_handler(200)) - client = await transport._get_client() # noqa: SLF001 - - async def _boom(*_args: object, **_kwargs: object) -> httpx2.Response: - msg = "something else entirely" - raise RuntimeError(msg) - - monkeypatch.setattr(client, "send", _boom) - with pytest.raises(RuntimeError, match="something else entirely"): - await transport(Request(method="GET", url="http://example.com/")) - - -async def test_mapped_exceptions_preserve_original_message() -> None: - transport = _make_transport(_raising_handler(httpx2.ReadTimeout("read timed out after 30s"))) - with pytest.raises(TimeoutError, match="read timed out after 30s") as info: - await transport(Request(method="GET", url="http://example.com/")) - assert isinstance(info.value.__cause__, httpx2.ReadTimeout) - - -# ----- (o) lazy event-loop binding ------------------------------------------ - - -def test_default_transport_is_lazy_pre_call() -> None: - transport = Httpx2Transport() - assert transport._client is None # noqa: SLF001 - - -async def test_default_transport_constructs_client_on_first_call() -> None: - transport = _make_transport(_status_handler(200)) - # _make_transport pre-supplies a client; assert post-call non-None invariant. - await transport(Request(method="GET", url="http://example.com/")) - assert transport._client is not None # noqa: SLF001 - await transport.aclose() - - -async def test_lazy_default_constructs_real_client_on_first_call() -> None: - transport = Httpx2Transport(limits=Limits(), timeout=Timeout()) - assert transport._client is None # noqa: SLF001 - # Touch _get_client directly to avoid network; lazy construction is what we test. - client = await transport._get_client() # noqa: SLF001 - assert isinstance(client, httpx2.AsyncClient) - assert transport._client is client # noqa: SLF001 - await transport.aclose() - - -async def test_concurrent_first_calls_initialize_client_once() -> None: - transport = Httpx2Transport(limits=Limits(), timeout=Timeout()) - clients = await asyncio.gather( - transport._get_client(), # noqa: SLF001 - transport._get_client(), # noqa: SLF001 - transport._get_client(), # noqa: SLF001 - ) - assert clients[0] is clients[1] is clients[2] - assert transport._client is clients[0] # noqa: SLF001 - await transport.aclose() - - -# ----- (p) constructor argument conflict ------------------------------------ - - -def test_constructor_rejects_client_plus_limits() -> None: - user_client = httpx2.AsyncClient() - with pytest.raises(ValueError, match="limits/timeout"): - Httpx2Transport(client=user_client, limits=Limits()) - - -def test_constructor_rejects_client_plus_timeout() -> None: - user_client = httpx2.AsyncClient() - with pytest.raises(ValueError, match="limits/timeout"): - Httpx2Transport(client=user_client, timeout=Timeout()) diff --git a/tests/test_transports_recorded.py b/tests/test_transports_recorded.py deleted file mode 100644 index cc064d3..0000000 --- a/tests/test_transports_recorded.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Unit tests for httpware.transports.recorded.RecordedTransport.""" - -import pytest - -import httpware -from httpware.request import Request -from httpware.response import Response -from httpware.transports import Transport -from httpware.transports.recorded import RecordedTransport - - -def _response(content: bytes = b"ok") -> Response: - return Response(status=200, headers={}, content=content, url="/", elapsed=0.0) - - -def _request(method: str = "GET", url: str = "/foo") -> Request: - return Request(method=method, url=url) - - -async def test_route_match_returns_response() -> None: - canned = _response(b"matched") - transport = RecordedTransport(routes={("GET", "/foo"): canned}) - - result = await transport(_request()) - - assert result is canned - - -async def test_route_match_raises_exception() -> None: - class _BoomError(Exception): - pass - - transport = RecordedTransport(routes={("GET", "/fail"): _BoomError("boom")}) - - with pytest.raises(_BoomError, match="boom"): - await transport(_request(url="/fail")) - - -async def test_no_match_with_no_default_raises_runtime_error() -> None: - transport = RecordedTransport() - - with pytest.raises(RuntimeError, match=r"No route for GET /missing"): - await transport(_request(url="/missing")) - - -async def test_no_match_with_response_default_returns_default() -> None: - fallback = _response(b"fallback") - transport = RecordedTransport(default=fallback) - - result = await transport(_request(url="/anything")) - - assert result is fallback - - -async def test_no_match_with_exception_default_raises_default() -> None: - transport = RecordedTransport(default=RuntimeError("default boom")) - - with pytest.raises(RuntimeError, match="default boom"): - await transport(_request(url="/anything")) - - -async def test_method_normalized_to_uppercase_in_routes() -> None: - canned = _response() - transport = RecordedTransport(routes={("get", "/foo"): canned}) - - result = await transport(_request(method="GET")) - - assert result is canned - - -async def test_method_normalized_to_uppercase_on_request() -> None: - canned = _response() - transport = RecordedTransport(routes={("GET", "/foo"): canned}) - - result = await transport(_request(method="get")) - - assert result is canned - - -async def test_requests_list_records_every_call() -> None: - transport = RecordedTransport(default=_response()) - - req1 = _request(url="/a") - req2 = _request(url="/b") - req3 = _request(url="/c") - await transport(req1) - await transport(req2) - await transport(req3) - - assert transport.requests == [req1, req2, req3] - - -async def test_last_request_returns_most_recent() -> None: - transport = RecordedTransport(default=_response()) - - assert transport.last_request is None - - req1 = _request(url="/a") - await transport(req1) - assert transport.last_request is req1 - - req2 = _request(url="/b") - await transport(req2) - assert transport.last_request is req2 - - -async def test_aclose_increments_counter() -> None: - transport = RecordedTransport() - - assert transport.aclose_calls == 0 - - await transport.aclose() - await transport.aclose() - await transport.aclose() - - assert transport.aclose_calls == 3 # noqa: PLR2004 - - -async def test_aclose_is_idempotent_and_doesnt_block_calls() -> None: - transport = RecordedTransport(default=_response()) - - await transport.aclose() - result = await transport(_request()) - - assert result is not None - assert transport.aclose_calls == 1 - - -def test_stream_raises_not_implemented_error() -> None: - transport = RecordedTransport() - - with pytest.raises(NotImplementedError, match="streaming lands in Epic 4"): - transport.stream(_request()) - - -def test_satisfies_transport_protocol() -> None: - assert isinstance(RecordedTransport(), Transport) - - -async def test_add_route_appends_or_replaces_entry() -> None: - transport = RecordedTransport() - - original = _response(b"first") - transport.add_route("GET", "/foo", original) - assert (await transport(_request())) is original - - replacement = _response(b"second") - transport.add_route("GET", "/foo", replacement) - assert (await transport(_request())) is replacement - - -async def test_routes_fire_indefinitely_on_repeat_calls() -> None: - canned = _response(b"canned") - transport = RecordedTransport(routes={("GET", "/foo"): canned}) - - r1 = await transport(_request()) - r2 = await transport(_request()) - r3 = await transport(_request()) - - assert r1 is canned - assert r2 is canned - assert r3 is canned - - -def test_recorded_transport_reexported_at_package_root() -> None: - """`from httpware import RecordedTransport` works in addition to the subpackage path.""" - assert httpware.RecordedTransport is RecordedTransport - assert "RecordedTransport" in httpware.__all__