From 60d2e0c3aae515e2acc4caef7c0e7a996e55eb02 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 31 May 2026 22:49:31 +0300 Subject: [PATCH 1/7] docs(story-1.8): design for RecordedTransport (Epic 1 closer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pragmatic Transport test double consolidating the five drifting in-tree stubs (_OkTransport, _FailingTransport, _FakeTransport, two _RecordingTransport variants, _TrackingTransport). Routes keyed by (method.upper(), url) → Response | BaseException with a configurable default for the no-match case (None → archive's RuntimeError). Routes fire indefinitely on repeat matches. requests: list[Request] + last_request property + aclose_calls counter cover the observability patterns spread across the existing stubs. In-tree stub replacement bundled as a single follow-up commit on the same branch. Out of scope: URL pattern matching, cassette files, streaming (Epic 4). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-31-recordedtransport-design.md | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-31-recordedtransport-design.md diff --git a/docs/superpowers/specs/2026-05-31-recordedtransport-design.md b/docs/superpowers/specs/2026-05-31-recordedtransport-design.md new file mode 100644 index 0000000..62cf102 --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-recordedtransport-design.md @@ -0,0 +1,281 @@ +# RecordedTransport (design) + +- **Date:** 2026-05-31 +- **Status:** approved, ready for plan +- **Scope:** Story 1-8 (eighth and final story of Epic 1). Ships a built-in `Transport` test double at `src/httpware/transports/recorded.py` plus follow-up commits that replace the five existing in-tree test stubs (`_OkTransport`, `_FailingTransport`, `_FakeTransport`, two distinct `_RecordingTransport` variants, `_TrackingTransport`) with this new class. Out of scope: URL pattern matching / globs, cassette files loaded from JSON, streaming (Epic 4). +- **Roadmap pointer:** `docs/engineering.md` §8 "Epic 1 — Make typed HTTP requests with sensible defaults". + +## Why + +Stories 1-1 through 1-7 each accumulated their own in-tree transport stubs — five distinct file-local classes that all reimplement the `Transport` protocol shape. They drift apart: some have `last_request`, some count `aclose_calls`, some count `calls`, one raises chosen exceptions. A single shared `RecordedTransport` consolidates the patterns and lets future stories (Epic 2 middleware tests, Epic 3 resilience tests, Epic 5 observability tests) reach for one well-documented test double instead of writing yet another stub. + +The archived epic AC describes a minimal version (route table + raise on no-match). This spec is pragmatic: routes + observed-requests recording + close tracking, so the class can actually replace the existing stubs. + +## Decisions + +| Decision | Choice | +| --- | --- | +| Scope | Pragmatic — routes, observed requests, close tracking. | +| Module location | `src/httpware/transports/recorded.py`. | +| Public exports | Re-exported at package root: `from httpware import RecordedTransport`. | +| Route table type | `Mapping[tuple[str, str], Response \| BaseException]`. Method is uppercased on insert and lookup. URL match is byte-exact. | +| `BaseException` vs `Exception` | `BaseException` — covers `asyncio.CancelledError`, `SystemExit`, etc. Test code legitimately wants to express any of these. | +| No-match behavior | Configurable `default: Response \| BaseException \| None = None`. `None` → raises `RuntimeError(f"No route for {method} {url}")` per archive AC. `Response` → returned. `BaseException` → raised. | +| Multi-call semantics | Routes fire indefinitely. Same `(method, url)` yields the same canned response on every call. Tests asserting different replies on repeat calls swap the route via `add_route(...)` between calls. | +| Observed-request recording | `requests: list[Request]` populated on every `__call__`, in order. `last_request: Request \| None` is a property reading `requests[-1] if requests else None`. | +| `aclose()` tracking | `aclose_calls: int` counter; idempotent (multiple closes are allowed). | +| `aclose()` lockout | None. Calls after `aclose()` continue to work — matches test-double conventions; production transports may differ. | +| `stream()` method | Raises `NotImplementedError` with message pointing to Epic 4 / Story 4-1. | +| `add_route(method, url, response_or_exception)` | Public method for incremental route setup; lets tests build routes after construction. | +| `__all__` | Not added on `recorded.py` (project convention — only `__init__.py` files get `__all__`). Re-export in `httpware/__init__.py` adds to its `__all__`. | +| In-tree stub replacement | Five test files updated to use `RecordedTransport` instead of file-local stubs. Lands as a follow-up commit on this branch, bundled in the same PR. | + +## File structure + +**New files:** +- `src/httpware/transports/recorded.py` — `RecordedTransport` class (~50 lines). +- `tests/test_transports_recorded.py` — 15 behavioral tests for the new class. + +**Modified files:** +- `src/httpware/__init__.py` — export `RecordedTransport`, add to `__all__`. +- `CHANGELOG.md` — Story 1.8 bullet. +- `tests/test_middleware.py` — replace `_OkTransport` and `_FailingTransport` with `RecordedTransport`. +- `tests/test_client_construction.py` — replace `_FakeTransport` with `RecordedTransport()`. +- `tests/test_client_methods.py` — replace local `_RecordingTransport` with `RecordedTransport`. +- `tests/test_client_response_model.py` — replace local `_RecordingTransport` with `RecordedTransport`. +- `tests/test_client_lifecycle.py` — replace `_TrackingTransport` with `RecordedTransport`. +- `tests/test_client_middleware_wiring.py` — replace local `_RecordingTransport` with `RecordedTransport`. + +**Files NOT touched:** +- `pyproject.toml`. +- `src/httpware/transports/__init__.py` (`Transport` Protocol stays as-is). +- `src/httpware/transports/httpx2.py`. + +## Public surface + +```python +from collections.abc import Mapping +from contextlib import AbstractAsyncContextManager + +from httpware.request import Request +from httpware.response import Response, StreamResponse + + +class RecordedTransport: + def __init__( + self, + routes: Mapping[tuple[str, str], Response | BaseException] | None = None, + *, + default: Response | BaseException | None = None, + ) -> None: ... + + requests: list[Request] # appended on every __call__ + aclose_calls: int # incremented on every aclose + + @property + def last_request(self) -> Request | None: ... + + def add_route( + self, + method: str, + url: str, + response_or_exception: Response | BaseException, + ) -> None: ... + + async def __call__(self, request: Request) -> Response: ... + def stream(self, request: Request) -> AbstractAsyncContextManager[StreamResponse]: ... + async def aclose(self) -> None: ... +``` + +Usage examples documented in the class docstring: + +```python +# Simple canned response for a single endpoint. +transport = RecordedTransport(routes={ + ("GET", "/users"): Response(status=200, headers={}, content=b"[]", url="/users", elapsed=0.0), +}) + +# Same canned response for every request — useful for AsyncClient construction tests. +transport = RecordedTransport(default=Response(status=200, headers={}, content=b"", url="", elapsed=0.0)) + +# Raise on no-match (archive AC default). +transport = RecordedTransport() # RuntimeError("No route for ...") + +# Raise a specific exception on a route. +transport = RecordedTransport(routes={ + ("GET", "/error"): RuntimeError("upstream down"), +}) + +# Inspect observed requests after the test. +client = AsyncClient(transport=transport) +await client.get("/users") +assert transport.last_request is not None +assert transport.last_request.method == "GET" +``` + +## Implementation + +```python +"""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. + + `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[key] if key in self._routes else 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 +``` + +Notes: +- Method uppercased on both insert (constructor + `add_route`) and lookup. Tests using `"get"` or `"GET"` in route keys behave the same. +- The constructor's dict comprehension preserves the original mapping unchanged (no surprises if the caller's `routes` is a `dict`). +- The `result: Response | BaseException | None = ...` line uses a single-pass conditional to avoid two dict accesses; the explicit `key in self._routes` keeps the type-system simple compared to `dict.get(key, self._default)` (which would force a wider union). +- `BaseException` is preferred over `Exception` so test code can express `asyncio.CancelledError`, `SystemExit`, `KeyboardInterrupt` if needed for cancellation/shutdown tests. +- No `__all__` in `recorded.py` (project convention). + +## In-tree stub replacement + +After landing `RecordedTransport`, replace each of the five existing in-tree stubs in a single bundled commit on this branch: + +| Test file | Current stub | Replacement | +| --- | --- | --- | +| `tests/test_middleware.py` | `_OkTransport`, `_FailingTransport` | `RecordedTransport(default=Response(status=200, ...))` and `RecordedTransport(default=SomeError())`. | +| `tests/test_client_construction.py` | `_FakeTransport` | `RecordedTransport()`. | +| `tests/test_client_methods.py` | `_RecordingTransport` | `RecordedTransport(default=Response(...))`; tests already read `transport.last_request`. | +| `tests/test_client_response_model.py` | `_RecordingTransport` | `RecordedTransport(default=Response(status=200, content=..., ...))`. | +| `tests/test_client_lifecycle.py` | `_TrackingTransport` | `RecordedTransport()`; tests read `transport.aclose_calls`. | +| `tests/test_client_middleware_wiring.py` | `_RecordingTransport` (counts `calls`) | `RecordedTransport(default=Response(...))`; tests use `len(transport.requests)` instead of `.calls`. | + +The replacements are mechanical. Each ~5–15 line stub class drops out, replaced by one-line construction. Test bodies that read `last_request` / `aclose_calls` keep working unchanged. + +This consolidation is the structural payoff of the story: one canonical test double instead of five drifting variants. + +## Testing + +`tests/test_transports_recorded.py` — 15 tests: + +| Test | Verifies | +| --- | --- | +| `test_route_match_returns_response` | Matching `(method, url)` returns canned Response. | +| `test_route_match_raises_exception` | Route configured with `BaseException` raises it. | +| `test_no_match_with_no_default_raises_runtime_error` | Archive default: `RuntimeError("No route for METHOD URL")`. | +| `test_no_match_with_response_default_returns_default` | `default=Response(...)` returned on no-match. | +| `test_no_match_with_exception_default_raises_default` | `default=SomeError()` raised on no-match. | +| `test_method_normalized_to_uppercase_in_routes` | Constructor `routes={("get", "/foo"): r}` matches `Request(method="GET", ...)`. | +| `test_method_normalized_to_uppercase_on_request` | `Request(method="get")` matches a route keyed `("GET", "/foo")`. | +| `test_requests_list_records_every_call` | `transport.requests` grows by one per call, in order. | +| `test_last_request_returns_most_recent` | `last_request` is `requests[-1]`; `None` initially. | +| `test_aclose_increments_counter` | Each `await aclose()` bumps the counter. | +| `test_aclose_is_idempotent_and_doesnt_block_calls` | After `aclose()`, the next `__call__` still works. | +| `test_stream_raises_not_implemented_error` | `transport.stream(request)` raises `NotImplementedError`. | +| `test_satisfies_transport_protocol` | `isinstance(RecordedTransport(), Transport)` is True. | +| `test_add_route_appends_or_replaces_entry` | `add_route(method, url, resp)` works for new entries and replacements. | +| `test_routes_fire_indefinitely_on_repeat_calls` | Three calls with the same `(method, url)` return the same canned Response three times. | + +Coverage expectation: 100% line coverage on `src/httpware/transports/recorded.py`. + +The stub-replacement commit must not regress any existing test. After the replacements, `just test` should still pass at the same count (now mostly 256 from Story 1-7 — the in-tree stub replacements don't add or remove tests). + +## Constraints and invariants + +- **No `httpx2` import.** `tests/test_no_httpx2_leakage.py` continues to pass. +- **No `from __future__ import annotations`.** +- **No `print()`, no `logging.basicConfig`.** +- **No `# type: ignore`.** `# ty: ignore[]` not expected. +- **No `__all__` in `recorded.py`** (project convention). +- **`# pragma: no cover` on `stream()`** is acceptable if coverage flags the raise — but the test `test_stream_raises_not_implemented_error` should exercise it, so likely not needed. + +## Risks and mitigations + +| Risk | Mitigation | +| --- | --- | +| `_routes` is a public-ish attribute reached by tests; future changes break callers. | Documented as private (`_routes` underscore prefix). Tests use `add_route(...)`. The internal storage shape can change without breaking the public API. | +| `BaseException` route values surprise users who expected `Exception` — they pass a `CancelledError` and it leaks past `except Exception:`. | This is the intended behavior. Documented in the docstring: "BaseException covers all exception types, including asyncio.CancelledError. Test code that wants cancellation propagation should use this." | +| Replacing five in-tree stubs in one commit creates a large diff that hides regressions. | Run `just test` after each file's replacement and only commit when green. The commit message lists each file. | +| `RecordedTransport(default=Response(...))` returns the same Response for every request, so tests asserting "GET /users returned this response" can pass even if the client sent the wrong request. | This is the trade-off for the convenience default. Tests that need stricter matching configure routes explicitly. Documented in the docstring. | + +## Definition of done + +- `src/httpware/transports/recorded.py` exists with `RecordedTransport` class. No `__all__`. +- `tests/test_transports_recorded.py` contains 15 tests; all pass; 100% line coverage on the new module. +- `src/httpware/__init__.py` exports `RecordedTransport` at the package root. +- All five existing in-tree stub classes are removed and their tests updated to use `RecordedTransport`. The total test count is unchanged or strictly greater (no test removed except the stub class definitions themselves). +- `just test` shows the expected count, 1 deselected (perf), 100% line coverage including the new module. +- `just lint-ci` clean. +- `tests/test_no_httpx2_leakage.py` still passes. +- `tests/test_optional_extras_isolation.py` still passes. +- CHANGELOG bullet under `[Unreleased]` / `### Added` describes the public surface plus the stub-consolidation outcome. +- Story 1-8 lands as a single PR off `main` via the branch `story/1-8-recordedtransport`. Epic 1 is complete after this merge. From 07deaba1854e4bb0dc5bce3904b037b63b85980e Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 31 May 2026 23:00:16 +0300 Subject: [PATCH 2/7] feat(story-1.8): RecordedTransport test double with core route matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds src/httpware/transports/recorded.py with RecordedTransport: - routes: Mapping[(method, url), Response | BaseException] with method uppercased on insert - default: Response | BaseException | None — None raises RuntimeError per archive AC; otherwise the default is returned or raised - requests: list[Request] populated on every __call__ - last_request property reading requests[-1] - aclose_calls counter - add_route(method, url, response_or_exception) for incremental setup - stream() raises NotImplementedError (lands in Epic 4) Five tests cover: route match returns Response, route raises Exception, no-match raises RuntimeError, default Response returned on no-match, default Exception raised on no-match. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/httpware/transports/recorded.py | 81 +++++++++++++++++++++++++++++ tests/test_transports_recorded.py | 57 ++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 src/httpware/transports/recorded.py create mode 100644 tests/test_transports_recorded.py diff --git a/src/httpware/transports/recorded.py b/src/httpware/transports/recorded.py new file mode 100644 index 0000000..1726366 --- /dev/null +++ b/src/httpware/transports/recorded.py @@ -0,0 +1,81 @@ +"""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. + + `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_transports_recorded.py b/tests/test_transports_recorded.py new file mode 100644 index 0000000..c7eae19 --- /dev/null +++ b/tests/test_transports_recorded.py @@ -0,0 +1,57 @@ +"""Unit tests for httpware.transports.recorded.RecordedTransport.""" + +import pytest + +from httpware.request import Request +from httpware.response import Response +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")) From 6e0cb1454abb8f2226946c40f24ac73962b7c473 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 31 May 2026 23:01:47 +0300 Subject: [PATCH 3/7] =?UTF-8?q?test(story-1.8):=20method=20norm,=20observa?= =?UTF-8?q?bility,=20lifecycle,=20protocol=20=E2=80=94=20full=20suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten additional tests bring RecordedTransport to 15 total: - method normalization in both directions (route key vs request) - requests list captures every call in order; last_request property - aclose counter; idempotent close that doesn't block subsequent calls - stream() raises NotImplementedError pointing to Epic 4 - isinstance(RecordedTransport(), Transport) — protocol satisfaction - add_route adds and replaces entries - routes fire indefinitely on repeat matching calls 100% line coverage on src/httpware/transports/recorded.py. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_transports_recorded.py | 104 ++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/tests/test_transports_recorded.py b/tests/test_transports_recorded.py index c7eae19..ca2a8f1 100644 --- a/tests/test_transports_recorded.py +++ b/tests/test_transports_recorded.py @@ -4,6 +4,7 @@ from httpware.request import Request from httpware.response import Response +from httpware.transports import Transport from httpware.transports.recorded import RecordedTransport @@ -55,3 +56,106 @@ async def test_no_match_with_exception_default_raises_default() -> None: 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 From 4a0b126b71f05736089274c1d46b4e81cf151ce0 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 31 May 2026 23:03:43 +0300 Subject: [PATCH 4/7] feat(story-1.8): re-export RecordedTransport at httpware package root Adds RecordedTransport to httpware/__init__.py imports and __all__ so consumers can `from httpware import RecordedTransport` in addition to the subpackage path. CHANGELOG records the Story 1.8 surface and notes the in-tree stub consolidation (Task 4). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + src/httpware/__init__.py | 2 ++ tests/test_transports_recorded.py | 7 +++++++ 3 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ccf918..5cd4f54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,5 +24,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Request and Response immutability helper expansion: `Request.with_headers`, `with_cookie`, `with_cookies`, `with_extension`, `with_extensions`; `Response.with_headers`, `with_status`. Plural helpers merge mappings (incoming keys override existing); singular helpers add or replace a single entry. No validation, no header-key normalization — matches the existing `with_header` semantics from Story 1.2 (Story 2.3). - `MsgspecDecoder` opt-in `ResponseDecoder` adapter behind the `[msgspec]` extra at `httpware.decoders.msgspec`; `msgspec.json.decode(content, type=model)` in a single C-level parse pass. Accepts `msgspec.Struct`, dataclasses, attrs, NamedTuples, TypedDicts, and builtin/container types as `model` (pydantic models use `PydanticDecoder` instead). `msgspec.ValidationError` and `msgspec.DecodeError` propagate unchanged. Module import is safe without the extra (gated by `httpware._internal.import_checker.is_msgspec_installed`); only `MsgspecDecoder()` construction raises `ImportError` with an install hint when the extra is missing. `import httpware` does NOT eagerly load `msgspec` — `MsgspecDecoder` is reachable only via `from httpware.decoders.msgspec import MsgspecDecoder` (Story 1.6). - `AsyncClient` — the v0.1.0 public surface. Construct with keyword-only `base_url`, `default_headers`, `default_query`, `timeout` (accepts `Timeout` instance, float seconds, or `None`), `limits`, `transport` (defaults to `Httpx2Transport`), `decoder` (defaults to `PydanticDecoder`), and `middleware` (`Sequence[Middleware]`, composed via `httpware._internal.chain.compose` at construction). Eight HTTP method shortcuts (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`, `request`) with `@typing.overload`-based `response_model` typing — passing `response_model=type[T]` returns `T`, otherwise `Response`. Per-call overrides for `headers`, `params`, `cookies`, `timeout`; body params `json` (auto-encoded with `Content-Type: application/json`, typed as `JsonValue` recursive alias) and `content` (raw bytes; mutually exclusive). `base_url` joins with the path using an httpx-style prefix; absolute URLs (`http(s)://`) bypass. `from_url(base_url, **kwargs)` classmethod factory. Async context-manager lifecycle: the original client owns the transport and closes it on `__aexit__`; views returned by `with_options(**overrides)` share the transport and are no-ops on close. `with_options` accepts a keyword allowlist (`base_url`, `default_headers`, `default_query`, `timeout`, `decoder`, `middleware`); `limits` and `transport` are not overridable. Out of scope and deferred: `auth=` (Story 2.4), `data=`/`files=` body params, transport reference-counting, streaming (Epic 4), observability (Epic 5) (Story 1.7). +- `RecordedTransport` built-in `Transport` test double at `httpware.transports.recorded` (also re-exported as `httpware.RecordedTransport`). Construct with `routes: Mapping[(method, url), Response | BaseException]` and a configurable `default` for the no-match case (`None` → `RuntimeError("No route for METHOD URL")` per archive AC; `Response` → returned; `BaseException` → raised). Method names are uppercased on insert and lookup. Routes fire indefinitely on repeat matches. Exposes `transport.requests: list[Request]`, `transport.last_request` (property), and `transport.aclose_calls: int` for assertion patterns. `add_route(method, url, response_or_exception)` allows incremental setup. `stream()` raises `NotImplementedError` — streaming lands in Epic 4 (Story 4-1). Replaces the five in-tree test stubs (`_FakeTransport`, `_OkTransport`, `_FailingTransport`, two `_RecordingTransport` variants, `_TrackingTransport`) accumulated through Stories 2-1 and 1-7 (Story 1.8). [Unreleased]: https://github.com/modern-python/httpware/commits/main diff --git a/src/httpware/__init__.py b/src/httpware/__init__.py index 65af08b..3b83ff2 100644 --- a/src/httpware/__init__.py +++ b/src/httpware/__init__.py @@ -27,6 +27,7 @@ from httpware.response import Response, StreamResponse from httpware.transports import Transport from httpware.transports.httpx2 import Httpx2Transport +from httpware.transports.recorded import RecordedTransport __all__ = [ @@ -46,6 +47,7 @@ "NotFoundError", "PydanticDecoder", "RateLimitedError", + "RecordedTransport", "Request", "Response", "ResponseDecoder", diff --git a/tests/test_transports_recorded.py b/tests/test_transports_recorded.py index ca2a8f1..cc064d3 100644 --- a/tests/test_transports_recorded.py +++ b/tests/test_transports_recorded.py @@ -2,6 +2,7 @@ import pytest +import httpware from httpware.request import Request from httpware.response import Response from httpware.transports import Transport @@ -159,3 +160,9 @@ async def test_routes_fire_indefinitely_on_repeat_calls() -> None: 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__ From de0c4156cf9452485c3f57998bc980a5b3e9f566 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 31 May 2026 23:10:38 +0300 Subject: [PATCH 5/7] test(story-1.8): consolidate five in-tree transport stubs into RecordedTransport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the file-local _FakeTransport (test_client_construction.py), _OkTransport and _FailingTransport (test_middleware.py), two distinct _RecordingTransport variants (test_client_methods.py, test_client_response_model.py, test_client_middleware_wiring.py), and _TrackingTransport (test_client_lifecycle.py) with one shared RecordedTransport class. Each replacement is mechanical: - _FakeTransport() → RecordedTransport() - _OkTransport() → RecordedTransport(default=Response(...)) - _FailingTransport(exc) → RecordedTransport(default=exc) - _RecordingTransport (last_request flavor) → RecordedTransport(default=Response(...)) - _RecordingTransport (calls counter) → RecordedTransport(default=Response(...)) with .calls → len(transport.requests) - _TrackingTransport → RecordedTransport() (aclose_calls attribute preserved) Also fixes a pre-existing ruff format violation in recorded.py (ternary expression on three lines instead of one). No test behavior changes. Total test count unchanged from this commit's edits. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/httpware/transports/recorded.py | 4 +- tests/test_client_construction.py | 23 +------ tests/test_client_lifecycle.py | 32 ++-------- tests/test_client_methods.py | 55 ++++++---------- tests/test_client_middleware_wiring.py | 45 +++++--------- tests/test_client_response_model.py | 34 ++++------ tests/test_middleware.py | 86 +++++++++++--------------- 7 files changed, 91 insertions(+), 188 deletions(-) diff --git a/src/httpware/transports/recorded.py b/src/httpware/transports/recorded.py index 1726366..285d947 100644 --- a/src/httpware/transports/recorded.py +++ b/src/httpware/transports/recorded.py @@ -35,9 +35,7 @@ def __init__( 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 {} + {(m.upper(), u): v for (m, u), v in routes.items()} if routes is not None else {} ) self._default = default self.requests: list[Request] = [] diff --git a/tests/test_client_construction.py b/tests/test_client_construction.py index 0188428..b31b9fa 100644 --- a/tests/test_client_construction.py +++ b/tests/test_client_construction.py @@ -2,31 +2,14 @@ # ruff: noqa: SLF001 -from contextlib import AbstractAsyncContextManager - -from httpware import AsyncClient, Limits, Timeout +from httpware import AsyncClient, Limits, RecordedTransport, Timeout from httpware.decoders.pydantic import PydanticDecoder from httpware.middleware import Middleware from httpware.request import Request -from httpware.response import Response, StreamResponse +from httpware.response import Response from httpware.transports.httpx2 import Httpx2Transport -class _FakeTransport: - """Minimal Transport for construction tests; never actually called.""" - - async def __call__(self, request: Request) -> Response: # pragma: no cover - not used - raise NotImplementedError - - def stream( # pragma: no cover - not used - self, request: Request - ) -> AbstractAsyncContextManager[StreamResponse]: - raise NotImplementedError - - async def aclose(self) -> None: # pragma: no cover - not used - return None - - def test_init_defaults_provide_transport_and_decoder() -> None: client = AsyncClient() assert isinstance(client._transport, Httpx2Transport) @@ -35,7 +18,7 @@ def test_init_defaults_provide_transport_and_decoder() -> None: def test_init_accepts_explicit_transport() -> None: - transport = _FakeTransport() + transport = RecordedTransport() client = AsyncClient(transport=transport) assert client._transport is transport diff --git a/tests/test_client_lifecycle.py b/tests/test_client_lifecycle.py index 7ca030f..42e0e47 100644 --- a/tests/test_client_lifecycle.py +++ b/tests/test_client_lifecycle.py @@ -1,39 +1,17 @@ """Unit tests for AsyncClient lifecycle (__aenter__, __aexit__).""" -from contextlib import AbstractAsyncContextManager - -from httpware import AsyncClient -from httpware.request import Request -from httpware.response import Response, StreamResponse - - -class _TrackingTransport: - """Counts aclose() invocations.""" - - def __init__(self) -> None: - self.aclose_calls = 0 - - async def __call__(self, request: Request) -> Response: # pragma: no cover - not used - raise NotImplementedError - - def stream( # pragma: no cover - not used - self, request: Request - ) -> AbstractAsyncContextManager[StreamResponse]: - raise NotImplementedError - - async def aclose(self) -> None: - self.aclose_calls += 1 +from httpware import AsyncClient, RecordedTransport async def test_aenter_returns_self() -> None: - transport = _TrackingTransport() + transport = RecordedTransport() client = AsyncClient(transport=transport) async with client as entered: assert entered is client async def test_async_with_calls_aclose_on_exit() -> None: - transport = _TrackingTransport() + transport = RecordedTransport() client = AsyncClient(transport=transport) async with client: pass @@ -41,7 +19,7 @@ async def test_async_with_calls_aclose_on_exit() -> None: async def test_double_close_is_safe() -> None: - transport = _TrackingTransport() + transport = RecordedTransport() client = AsyncClient(transport=transport) async with client: pass @@ -51,7 +29,7 @@ async def test_double_close_is_safe() -> None: async def test_view_async_with_does_not_close_transport() -> None: - transport = _TrackingTransport() + transport = RecordedTransport() client = AsyncClient(transport=transport) view = client.with_options(timeout=10) async with view: diff --git a/tests/test_client_methods.py b/tests/test_client_methods.py index 3b35457..47a09c6 100644 --- a/tests/test_client_methods.py +++ b/tests/test_client_methods.py @@ -1,42 +1,25 @@ """Unit tests for AsyncClient HTTP method shortcuts.""" -from contextlib import AbstractAsyncContextManager - import pytest -from httpware import AsyncClient -from httpware.request import Request -from httpware.response import Response, StreamResponse - +from httpware import AsyncClient, RecordedTransport +from httpware.response import Response -class _RecordingTransport: - """Captures the last-seen Request and returns a canned Response.""" - def __init__(self) -> None: - self.last_request: Request | None = None - self.canned = Response( +def _make_transport() -> RecordedTransport: + return RecordedTransport( + default=Response( status=200, headers={"x-from": "transport"}, content=b"body", url="https://example.test/", elapsed=0.0, ) - - async def __call__(self, request: Request) -> Response: - self.last_request = request - return self.canned - - 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 + ) async def test_get_builds_request_with_method_and_url() -> None: - transport = _RecordingTransport() + transport = _make_transport() client = AsyncClient(transport=transport) await client.get("https://api.example.com/users") @@ -48,7 +31,7 @@ async def test_get_builds_request_with_method_and_url() -> None: async def test_relative_path_joins_with_base_url() -> None: - transport = _RecordingTransport() + 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 @@ -56,7 +39,7 @@ async def test_relative_path_joins_with_base_url() -> None: async def test_relative_path_without_leading_slash_joins_same_way() -> None: - transport = _RecordingTransport() + 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 @@ -64,7 +47,7 @@ async def test_relative_path_without_leading_slash_joins_same_way() -> None: async def test_absolute_url_bypasses_base_url() -> None: - transport = _RecordingTransport() + 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 @@ -72,7 +55,7 @@ async def test_absolute_url_bypasses_base_url() -> None: async def test_default_headers_merged_with_per_call_headers() -> None: - transport = _RecordingTransport() + transport = _make_transport() client = AsyncClient( default_headers={"x-keep": "1", "x-override": "default"}, transport=transport, @@ -87,7 +70,7 @@ async def test_default_headers_merged_with_per_call_headers() -> None: async def test_default_query_merged_with_per_call_params() -> None: - transport = _RecordingTransport() + 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 @@ -95,7 +78,7 @@ async def test_default_query_merged_with_per_call_params() -> None: async def test_post_with_json_serializes_and_sets_content_type() -> None: - transport = _RecordingTransport() + transport = _make_transport() client = AsyncClient(transport=transport) await client.post("/users", json={"name": "alice"}) assert transport.last_request is not None @@ -105,7 +88,7 @@ async def test_post_with_json_serializes_and_sets_content_type() -> None: async def test_post_with_content_preserves_bytes_unchanged() -> None: - transport = _RecordingTransport() + transport = _make_transport() client = AsyncClient(transport=transport) await client.post("/users", content=b"raw bytes") assert transport.last_request is not None @@ -114,14 +97,14 @@ async def test_post_with_content_preserves_bytes_unchanged() -> None: async def test_post_json_and_content_raises_typeerror() -> None: - transport = _RecordingTransport() + 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 = _RecordingTransport() + transport = _make_transport() client = AsyncClient(transport=transport) await client.post( "/users", @@ -147,7 +130,7 @@ async def test_post_per_call_content_type_skips_auto_injection() -> None: ], ) async def test_each_method_emits_correct_wire_method(client_method_name: str, expected_wire_method: str) -> None: - transport = _RecordingTransport() + transport = _make_transport() client = AsyncClient(transport=transport) method = getattr(client, client_method_name) await method("/foo") @@ -156,7 +139,7 @@ async def test_each_method_emits_correct_wire_method(client_method_name: str, ex async def test_request_method_uses_first_positional_method_arg() -> None: - transport = _RecordingTransport() + transport = _make_transport() client = AsyncClient(transport=transport) await client.request("CUSTOM", "/foo") assert transport.last_request is not None @@ -164,7 +147,7 @@ async def test_request_method_uses_first_positional_method_arg() -> None: async def test_per_call_timeout_propagates_to_request_extensions() -> None: - transport = _RecordingTransport() + transport = _make_transport() client = AsyncClient(transport=transport) await client.get("/foo", timeout=2.5) assert transport.last_request is not None diff --git a/tests/test_client_middleware_wiring.py b/tests/test_client_middleware_wiring.py index 903bed1..d860337 100644 --- a/tests/test_client_middleware_wiring.py +++ b/tests/test_client_middleware_wiring.py @@ -1,34 +1,21 @@ """Unit tests for AsyncClient middleware wiring through compose() and with_options.""" -from contextlib import AbstractAsyncContextManager - -from httpware import AsyncClient +from httpware import AsyncClient, RecordedTransport from httpware.middleware import Middleware, Next from httpware.request import Request -from httpware.response import Response, StreamResponse - +from httpware.response import Response -class _RecordingTransport: - def __init__(self) -> None: - self.calls = 0 - async def __call__(self, request: Request) -> Response: - self.calls += 1 - return Response( +def _make_transport() -> RecordedTransport: + return RecordedTransport( + default=Response( status=200, headers={}, content=b"", - url=request.url, + url="/", elapsed=0.0, ) - - def stream( # pragma: no cover - self, request: Request - ) -> AbstractAsyncContextManager[StreamResponse]: - raise NotImplementedError - - async def aclose(self) -> None: # pragma: no cover - return None + ) def _make_recording_middleware(label: str, log: list[str]) -> Middleware: @@ -41,7 +28,7 @@ async def __call__(self, request: Request, next: Next) -> Response: # noqa: A00 async def test_middleware_runs_per_request() -> None: - transport = _RecordingTransport() + transport = _make_transport() log: list[str] = [] client = AsyncClient( transport=transport, @@ -49,11 +36,11 @@ async def test_middleware_runs_per_request() -> None: ) await client.get("/foo") assert log == ["A"] - assert transport.calls == 1 + assert len(transport.requests) == 1 async def test_with_options_recomposes_middleware() -> None: - transport = _RecordingTransport() + transport = _make_transport() parent_log: list[str] = [] view_log: list[str] = [] client = AsyncClient( @@ -69,7 +56,7 @@ async def test_with_options_recomposes_middleware() -> None: async def test_with_options_inherits_middleware_when_unset() -> None: - transport = _RecordingTransport() + transport = _make_transport() log: list[str] = [] client = AsyncClient( transport=transport, @@ -81,7 +68,7 @@ async def test_with_options_inherits_middleware_when_unset() -> None: async def test_view_shares_transport_with_parent() -> None: - transport = _RecordingTransport() + transport = _make_transport() client = AsyncClient(transport=transport) view = client.with_options(timeout=10) assert view._transport is client._transport # noqa: SLF001 @@ -94,28 +81,28 @@ async def test_view_does_not_own_transport() -> None: async def test_with_options_overrides_base_url() -> None: - transport = _RecordingTransport() + 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 = _RecordingTransport() + 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 = _RecordingTransport() + 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 = _RecordingTransport() + transport = _make_transport() class _NoopDecoder: def decode(self, content: bytes, model: type) -> object: # pragma: no cover # noqa: ARG002 diff --git a/tests/test_client_response_model.py b/tests/test_client_response_model.py index 5d3f349..53dcbe3 100644 --- a/tests/test_client_response_model.py +++ b/tests/test_client_response_model.py @@ -1,38 +1,26 @@ """Unit tests for AsyncClient response_model integration with ResponseDecoder.""" -from contextlib import AbstractAsyncContextManager from typing import TypeVar from pydantic import BaseModel -from httpware import AsyncClient -from httpware.request import Request -from httpware.response import Response, StreamResponse +from httpware import AsyncClient, RecordedTransport +from httpware.response import Response T = TypeVar("T") -class _RecordingTransport: - def __init__(self, content: bytes) -> None: - self._content = content - - async def __call__(self, request: Request) -> Response: - return Response( +def _transport(content: bytes) -> RecordedTransport: + return RecordedTransport( + default=Response( status=200, headers={}, - content=self._content, - url=request.url, + content=content, + url="/", elapsed=0.0, ) - - def stream( # pragma: no cover - self, request: Request - ) -> AbstractAsyncContextManager[StreamResponse]: - raise NotImplementedError - - async def aclose(self) -> None: # pragma: no cover - return None + ) class _Item(BaseModel): @@ -41,7 +29,7 @@ class _Item(BaseModel): async def test_response_model_none_returns_raw_response() -> None: - transport = _RecordingTransport(content=b'{"name":"x","qty":1}') + transport = _transport(content=b'{"name":"x","qty":1}') client = AsyncClient(transport=transport) result = await client.get("/foo") assert isinstance(result, Response) @@ -49,7 +37,7 @@ async def test_response_model_none_returns_raw_response() -> None: async def test_response_model_invokes_decoder() -> None: - transport = _RecordingTransport(content=b'{"name":"x","qty":1}') + transport = _transport(content=b'{"name":"x","qty":1}') client = AsyncClient(transport=transport) result = await client.get("/foo", response_model=_Item) assert isinstance(result, _Item) @@ -57,7 +45,7 @@ async def test_response_model_invokes_decoder() -> None: async def test_response_model_uses_supplied_decoder() -> None: - transport = _RecordingTransport(content=b'{"name":"x","qty":1}') + transport = _transport(content=b'{"name":"x","qty":1}') seen: list[tuple[bytes, type]] = [] class _SpyDecoder: diff --git a/tests/test_middleware.py b/tests/test_middleware.py index d3c4f82..b005f28 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -8,6 +8,7 @@ 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 @@ -39,25 +40,16 @@ def test_next_annotation_on_signal_middleware() -> None: assert hints["next"] == Next -class _OkTransport: - """Minimal Transport: returns a fixed Response, no streaming, no aclose work.""" - - async def __call__(self, request: Request) -> Response: - return Response( +def _ok_transport() -> RecordedTransport: + return RecordedTransport( + default=Response( status=200, headers={"x-from": "transport"}, content=b"transport", - url=request.url, + url="/", elapsed=0.0, ) - - def stream( # pragma: no cover - not exercised in 2-1 - self, request: Request - ) -> AbstractAsyncContextManager[StreamResponse]: - raise NotImplementedError - - async def aclose(self) -> None: # pragma: no cover - not exercised in 2-1 - return None + ) def _make_request(method: str = "GET", url: str = "https://example.test/") -> Request: @@ -66,7 +58,7 @@ def _make_request(method: str = "GET", url: str = "https://example.test/") -> Re async def test_empty_list_composes_to_transport_call() -> None: """compose([], transport) yields a callable that behaves like transport(req).""" - transport = _OkTransport() + transport = _ok_transport() dispatch = compose([], transport) request = _make_request() @@ -86,7 +78,7 @@ async def __call__(self, request: Request, next: Next) -> Response: # noqa: A00 seen.append(request) return await next(request) - transport = _OkTransport() + transport = _ok_transport() request = _make_request() response = await compose([Tap()], transport)(request) @@ -109,7 +101,7 @@ async def __call__(self, request: Request, next: Next) -> Response: # noqa: A00 return Labeled() - dispatch = compose([labeled("A"), labeled("B"), labeled("C")], _OkTransport()) + dispatch = compose([labeled("A"), labeled("B"), labeled("C")], _ok_transport()) await dispatch(_make_request()) assert log == [ @@ -136,7 +128,7 @@ async def __call__(self, request: Request, next: Next) -> Response: # noqa: A00 seen.append(request) return await next(request) - await compose([Stamp(), Inspect()], _OkTransport())(_make_request()) + await compose([Stamp(), Inspect()], _ok_transport())(_make_request()) assert seen[0].headers["x-trace"] == "abc123" @@ -155,7 +147,7 @@ async def __call__(self, request: Request, next: Next) -> Response: # noqa: A00 elapsed=response.elapsed, ) - response = await compose([AddHeader()], _OkTransport())(_make_request()) + response = await compose([AddHeader()], _ok_transport())(_make_request()) assert response.headers["x-trace"] == "abc123" assert response.headers["x-from"] == "transport" # original still present @@ -165,11 +157,23 @@ 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(_OkTransport): - async def __call__(self, request: Request) -> Response: + class CountingTransport: + async def __call__(self, request: Request) -> Response: # noqa: ARG002 nonlocal transport_calls transport_calls += 1 - return await super().__call__(request) + 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 @@ -205,7 +209,7 @@ async def __call__(self, request: Request, next: Next) -> Response: # noqa: A00 raise CustomError(msg) with pytest.raises(CustomError, match="boom"): - await compose([Boom()], _OkTransport())(_make_request()) + await compose([Boom()], _ok_transport())(_make_request()) async def test_exception_in_transport_propagates_through_chain() -> None: @@ -244,7 +248,7 @@ async def __call__(self, request: Request, next: Next) -> Response: # noqa: A00 return await next(request) with pytest.raises(asyncio.CancelledError): - await compose([Passthrough(), Cancel()], _OkTransport())(_make_request()) + await compose([Passthrough(), Cancel()], _ok_transport())(_make_request()) async def test_compose_returned_callable_is_reusable() -> None: @@ -257,7 +261,7 @@ async def __call__(self, request: Request, next: Next) -> Response: # noqa: A00 count += 1 return await next(request) - dispatch = compose([Counter()], _OkTransport()) + dispatch = compose([Counter()], _ok_transport()) for _ in range(3): response = await dispatch(_make_request()) @@ -280,7 +284,7 @@ async def __call__(self, request: Request, next: Next) -> Response: # noqa: A00 seen.append(request) return await next(request) - await compose([stamp, Inspect()], _OkTransport())(_make_request()) + await compose([stamp, Inspect()], _ok_transport())(_make_request()) assert seen[0].headers["x-trace"] == "abc123" @@ -298,7 +302,7 @@ async def add_header(request: Request, response: Response) -> Response: # noqa: elapsed=response.elapsed, ) - response = await compose([add_header], _OkTransport())(_make_request()) + response = await compose([add_header], _ok_transport())(_make_request()) assert response.headers["x-trace"] == "abc123" assert response.headers["x-from"] == "transport" # original still present @@ -312,24 +316,6 @@ def test_middleware_and_next_are_reexported_at_package_root() -> None: assert "Next" in httpware.__all__ -class _FailingTransport: - """Transport whose __call__ raises a chosen exception.""" - - def __init__(self, exc: BaseException) -> None: - self._exc = exc - - async def __call__(self, request: Request) -> Response: # noqa: ARG002 - raise self._exc - - def stream( # pragma: no cover - not exercised in 2-2 - self, request: Request - ) -> AbstractAsyncContextManager[StreamResponse]: - raise NotImplementedError - - async def aclose(self) -> None: # pragma: no cover - not exercised in 2-2 - return None - - async def test_on_error_returns_response_swallows_exception() -> None: """When the handler returns a Response, the caller gets it; no exception escapes.""" @@ -343,7 +329,7 @@ async def recover(request: Request, exc: Exception) -> Response | None: # noqa: elapsed=0.0, ) - transport = _FailingTransport(RuntimeError("boom")) + transport = RecordedTransport(default=RuntimeError("boom")) response = await compose([recover], transport)(_make_request()) assert response.status == 503 # noqa: PLR2004 @@ -358,7 +344,7 @@ async def test_on_error_returns_none_reraises() -> None: async def pass_through(request: Request, exc: Exception) -> Response | None: # noqa: ARG001 return None - transport = _FailingTransport(RuntimeError("boom")) + transport = RecordedTransport(default=RuntimeError("boom")) with pytest.raises(RuntimeError, match="boom"): await compose([pass_through], transport)(_make_request()) @@ -378,7 +364,7 @@ async def __call__(self, request: Request, next: Next) -> Response: # noqa: A00 raise asyncio.CancelledError with pytest.raises(asyncio.CancelledError): - await compose([should_not_run, Cancel()], _OkTransport())(_make_request()) + await compose([should_not_run, Cancel()], _ok_transport())(_make_request()) assert invocations == [] @@ -394,7 +380,7 @@ async def capture(request: Request, exc: Exception) -> Response | None: # noqa: return None with pytest.raises(RuntimeError): - await compose([capture], _FailingTransport(raised))(_make_request()) + await compose([capture], RecordedTransport(default=raised))(_make_request()) assert seen == [raised] assert seen[0] is raised @@ -444,7 +430,7 @@ async def __call__(self, request: Request, next: Next) -> Response: # noqa: A00 seen_headers.append(request.headers.get("x-stamp", "")) return await next(request) - response = await compose([stamp, Inspect(), tag], _OkTransport())(_make_request()) + response = await compose([stamp, Inspect(), tag], _ok_transport())(_make_request()) assert seen_headers == ["1"] # stamp ran before Inspect assert response.headers["x-tag"] == "1" # tag ran after the chain From 40781d86012743410077676cc80a7c3c7006b975 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 31 May 2026 23:11:18 +0300 Subject: [PATCH 6/7] docs(story-1.8): implementation plan for RecordedTransport Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-31-recordedtransport-plan.md | 1043 +++++++++++++++++ 1 file changed, 1043 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-31-recordedtransport-plan.md diff --git a/docs/superpowers/plans/2026-05-31-recordedtransport-plan.md b/docs/superpowers/plans/2026-05-31-recordedtransport-plan.md new file mode 100644 index 0000000..f9221b1 --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-recordedtransport-plan.md @@ -0,0 +1,1043 @@ +# RecordedTransport 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:** Ship Story 1-8: a built-in `RecordedTransport` test double at `src/httpware/transports/recorded.py`, then consolidate the five in-tree stubs (`_FakeTransport`, `_OkTransport`, `_FailingTransport`, two `_RecordingTransport` variants, `_TrackingTransport`) by replacing them with `RecordedTransport`. Closes Epic 1. + +**Architecture:** Single new module (~50 lines) implementing the `Transport` protocol with a route table, observed-request list, and `aclose_calls` counter. Routes keyed by `(method.upper(), url)` → `Response | BaseException`. Configurable `default` for the no-match case. After the class lands, six existing test files swap their file-local stubs for `RecordedTransport` construction in a single mechanical commit. + +**Tech Stack:** Python 3.11 floor; stdlib only (`collections.abc.Mapping`, `contextlib.AbstractAsyncContextManager`). No new dependencies. + +**Branch:** `story/1-8-recordedtransport` (already created; spec commit `60d2e0c` is on it). + +**Spec:** `docs/superpowers/specs/2026-05-31-recordedtransport-design.md`. + +--- + +## File Structure + +**New files:** +- `src/httpware/transports/recorded.py` — `RecordedTransport` class. +- `tests/test_transports_recorded.py` — 15 behavioral tests for the new class. + +**Modified files:** +- `src/httpware/__init__.py` — export `RecordedTransport`, add to `__all__`. +- `CHANGELOG.md` — Story 1.8 bullet. +- `tests/test_middleware.py` — replace `_OkTransport` and `_FailingTransport` with `RecordedTransport`. +- `tests/test_client_construction.py` — replace `_FakeTransport` with `RecordedTransport()`. +- `tests/test_client_methods.py` — replace local `_RecordingTransport` with `RecordedTransport`. +- `tests/test_client_response_model.py` — replace local `_RecordingTransport` with `RecordedTransport`. +- `tests/test_client_lifecycle.py` — replace `_TrackingTransport` with `RecordedTransport`. +- `tests/test_client_middleware_wiring.py` — replace local `_RecordingTransport` with `RecordedTransport`. + +**Files NOT touched:** +- `pyproject.toml`, `Justfile`, CI workflows. +- `src/httpware/transports/__init__.py` (the `Transport` Protocol stays as-is). +- `src/httpware/transports/httpx2.py`. + +--- + +## Task 1: `RecordedTransport` module with core route-matching tests + +TDD cycle for the core behavior: route matching, default handling, exception propagation. Implementation lands once tests are in place. + +**Files:** +- Create: `src/httpware/transports/recorded.py` +- Create: `tests/test_transports_recorded.py` + +- [ ] **Step 1: Add the first failing test (route match returns response)** + +Create `tests/test_transports_recorded.py`: + +```python +"""Unit tests for httpware.transports.recorded.RecordedTransport.""" + +import pytest + +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 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_transports_recorded.py::test_route_match_returns_response -v` +Expected: `ModuleNotFoundError: No module named 'httpware.transports.recorded'`. + +- [ ] **Step 3: Implement `RecordedTransport`** + +Create `src/httpware/transports/recorded.py`: + +```python +"""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. + + `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[key] if key in self._routes else 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 +``` + +No `__all__` (project convention). + +- [ ] **Step 4: Run the first test to verify it passes** + +Run: `uv run pytest tests/test_transports_recorded.py::test_route_match_returns_response -v` +Expected: PASS. + +- [ ] **Step 5: Add the remaining 4 core tests (exception routes, defaults)** + +Append to `tests/test_transports_recorded.py`: + +```python +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")) +``` + +- [ ] **Step 6: Run tests to verify all 5 pass** + +Run: `uv run pytest tests/test_transports_recorded.py -v` +Expected: 5 passed. + +- [ ] **Step 7: Lint and ty** + +Run: `uv run ruff check src/httpware/transports/recorded.py tests/test_transports_recorded.py` +Run: `uv run ty check src/httpware/transports/recorded.py` +Expected: both clean. + +- [ ] **Step 8: Commit** + +```bash +git add src/httpware/transports/recorded.py tests/test_transports_recorded.py +git commit -m "$(cat <<'EOF' +feat(story-1.8): RecordedTransport test double with core route matching + +Adds src/httpware/transports/recorded.py with RecordedTransport: +- routes: Mapping[(method, url), Response | BaseException] with method + uppercased on insert +- default: Response | BaseException | None — None raises RuntimeError per + archive AC; otherwise the default is returned or raised +- requests: list[Request] populated on every __call__ +- last_request property reading requests[-1] +- aclose_calls counter +- add_route(method, url, response_or_exception) for incremental setup +- stream() raises NotImplementedError (lands in Epic 4) + +Five tests cover: route match returns Response, route raises Exception, +no-match raises RuntimeError, default Response returned on no-match, +default Exception raised on no-match. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: Method normalization, observability, lifecycle, stream, protocol satisfaction tests + +Ten more tests covering case normalization, request recording, `aclose`, `stream`, protocol satisfaction, `add_route`, and multi-call semantics. No production code changes expected. + +**Files:** +- Modify: `tests/test_transports_recorded.py` (append 10 tests) + +- [ ] **Step 1: Add the method-normalization tests** + +Append to `tests/test_transports_recorded.py`: + +```python +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 +``` + +- [ ] **Step 2: Add the requests-recording tests** + +Append: + +```python +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 +``` + +- [ ] **Step 3: Add the aclose tests** + +Append: + +```python +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 +``` + +- [ ] **Step 4: Add the stream and protocol-satisfaction tests** + +Append: + +```python +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) +``` + +- [ ] **Step 5: Add the add_route and multi-call tests** + +Append: + +```python +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 +``` + +- [ ] **Step 6: Run all 15 tests** + +Run: `uv run pytest tests/test_transports_recorded.py -v` +Expected: 15 passed. + +- [ ] **Step 7: Lint** + +Run: `uv run ruff check tests/test_transports_recorded.py` +Expected: clean. + +- [ ] **Step 8: Verify 100% coverage on the new module** + +Run: `uv run pytest tests/test_transports_recorded.py --cov=src/httpware/transports/recorded --cov-report=term-missing` +Expected: 100% coverage on `recorded.py`. + +If any line is missed, identify which test should exercise it. The whole class body (~50 lines) is covered by the test suite as-written. + +- [ ] **Step 9: Commit** + +```bash +git add tests/test_transports_recorded.py +git commit -m "$(cat <<'EOF' +test(story-1.8): method norm, observability, lifecycle, protocol — full suite + +Ten additional tests bring RecordedTransport to 15 total: +- method normalization in both directions (route key vs request) +- requests list captures every call in order; last_request property +- aclose counter; idempotent close that doesn't block subsequent calls +- stream() raises NotImplementedError pointing to Epic 4 +- isinstance(RecordedTransport(), Transport) — protocol satisfaction +- add_route adds and replaces entries +- routes fire indefinitely on repeat matching calls + +100% line coverage on src/httpware/transports/recorded.py. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: Public exports and CHANGELOG + +Wire `RecordedTransport` into the package root and add the Story 1.8 bullet. + +**Files:** +- Modify: `src/httpware/__init__.py` +- Modify: `CHANGELOG.md` +- Modify: `tests/test_transports_recorded.py` (add a re-export test) + +- [ ] **Step 1: Add the failing re-export test** + +Append to `tests/test_transports_recorded.py`: + +```python +def test_recorded_transport_reexported_at_package_root() -> None: + """`from httpware import RecordedTransport` works in addition to the subpackage path.""" + import httpware + + assert httpware.RecordedTransport is RecordedTransport + assert "RecordedTransport" in httpware.__all__ +``` + +Move the `import httpware` to the top of the file alongside the other imports (memory: in-function imports are a code smell). If the test file doesn't yet import `httpware`, add it at the top. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_transports_recorded.py::test_recorded_transport_reexported_at_package_root -v` +Expected: `AttributeError: module 'httpware' has no attribute 'RecordedTransport'`. + +- [ ] **Step 3: Update `src/httpware/__init__.py`** + +Edit `src/httpware/__init__.py`. Find the existing `from httpware.transports.httpx2 import Httpx2Transport` line and add the import for `RecordedTransport` immediately after (or wherever ruff prefers alphabetically): + +```python +from httpware.transports.recorded import RecordedTransport +``` + +In `__all__`, add `"RecordedTransport"` to the list. Ruff `RUF022` will sort. The correct ASCII-order position is between `"RateLimitedError"` and `"Request"`. If unsure, add anywhere and run `uv run ruff check --fix src/httpware/__init__.py`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_transports_recorded.py::test_recorded_transport_reexported_at_package_root -v` +Expected: PASS. + +- [ ] **Step 5: Append CHANGELOG bullet** + +Edit `CHANGELOG.md`. The `## [Unreleased]` / `### Added` section currently ends with the Story 1.7 bullet about `AsyncClient`. Append a new bullet immediately after it (before the `[Unreleased]: ...` reference link at the bottom): + +```markdown +- `RecordedTransport` built-in `Transport` test double at `httpware.transports.recorded` (also re-exported as `httpware.RecordedTransport`). Construct with `routes: Mapping[(method, url), Response | BaseException]` and a configurable `default` for the no-match case (`None` → `RuntimeError("No route for METHOD URL")` per archive AC; `Response` → returned; `BaseException` → raised). Method names are uppercased on insert and lookup. Routes fire indefinitely on repeat matches. Exposes `transport.requests: list[Request]`, `transport.last_request` (property), and `transport.aclose_calls: int` for assertion patterns. `add_route(method, url, response_or_exception)` allows incremental setup. `stream()` raises `NotImplementedError` — streaming lands in Epic 4 (Story 4-1). Replaces the five in-tree test stubs (`_FakeTransport`, `_OkTransport`, `_FailingTransport`, two `_RecordingTransport` variants, `_TrackingTransport`) accumulated through Stories 2-1 and 1-7 (Story 1.8). +``` + +- [ ] **Step 6: Lint and ty** + +Run: `uv run ruff check src/httpware/__init__.py tests/test_transports_recorded.py` +Run: `uv run ty check src/httpware/__init__.py` +Expected: both clean. + +- [ ] **Step 7: Commit** + +```bash +git add src/httpware/__init__.py tests/test_transports_recorded.py CHANGELOG.md +git commit -m "$(cat <<'EOF' +feat(story-1.8): re-export RecordedTransport at httpware package root + +Adds RecordedTransport to httpware/__init__.py imports and __all__ so +consumers can `from httpware import RecordedTransport` in addition to +the subpackage path. CHANGELOG records the Story 1.8 surface and notes +the in-tree stub consolidation (Task 4). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: Replace the five in-tree test stubs with `RecordedTransport` + +Mechanical refactor across six test files. Each stub class drops out; `RecordedTransport(...)` construction takes its place. The replacements are bundled into one commit (per the spec's "stub-replacement commit must not regress any existing test"). + +**Files:** +- Modify: `tests/test_middleware.py` (drop `_OkTransport`, `_FailingTransport`) +- Modify: `tests/test_client_construction.py` (drop `_FakeTransport`) +- Modify: `tests/test_client_methods.py` (drop local `_RecordingTransport`) +- Modify: `tests/test_client_response_model.py` (drop local `_RecordingTransport`) +- Modify: `tests/test_client_lifecycle.py` (drop `_TrackingTransport`) +- Modify: `tests/test_client_middleware_wiring.py` (drop local `_RecordingTransport`; tests use `len(transport.requests)` instead of `.calls`) + +For each file, the workflow is the same: + +1. Add the import: `from httpware import RecordedTransport`. +2. Delete the stub class definition (and any unused `from contextlib import AbstractAsyncContextManager` or `from httpware.response import StreamResponse` imports left behind). +3. Replace each stub construction with `RecordedTransport(...)`. +4. Adapt any access to fields the stub had but `RecordedTransport` exposes differently (e.g., `.calls` → `len(transport.requests)`). +5. Run that file's tests. +6. Once all six files pass, commit. + +The detailed per-file replacement strategy follows. + +### 4.1 `tests/test_client_construction.py` + +The `_FakeTransport` is constructed in `test_init_accepts_explicit_transport` and stored on the client; it's never called. + +- [ ] **Step 1: Delete the `_FakeTransport` class definition** + +Find this block at the top of `tests/test_client_construction.py`: + +```python +class _FakeTransport: + """Minimal Transport for construction tests; never actually called.""" + + async def __call__(self, request: Request) -> Response: # pragma: no cover - not used + raise NotImplementedError + + def stream( # pragma: no cover - not used + self, request: Request + ) -> AbstractAsyncContextManager[StreamResponse]: + raise NotImplementedError + + async def aclose(self) -> None: # pragma: no cover - not used + return None +``` + +Delete it. + +- [ ] **Step 2: Replace `_FakeTransport()` usage** + +In `test_init_accepts_explicit_transport`, change `transport = _FakeTransport()` to: + +```python +transport = RecordedTransport() +``` + +- [ ] **Step 3: Add the import** + +At the top of the file's imports (alphabetically): + +```python +from httpware import AsyncClient, Limits, RecordedTransport, Timeout +``` + +(Modify the existing `from httpware import AsyncClient, Limits, Timeout` line to include `RecordedTransport`.) + +- [ ] **Step 4: Drop unused imports** + +If `Response`, `StreamResponse`, `AbstractAsyncContextManager` were imported only for `_FakeTransport`, drop them. Verify by inspecting other tests in the file. + +- [ ] **Step 5: Run this file's tests** + +Run: `uv run pytest tests/test_client_construction.py -v` +Expected: all pass (same count as before). + +### 4.2 `tests/test_client_lifecycle.py` + +The `_TrackingTransport` exposes `aclose_calls`; `RecordedTransport` has the same attribute name. + +- [ ] **Step 1: Delete the `_TrackingTransport` class definition** + +Find and delete: + +```python +class _TrackingTransport: + """Counts aclose() invocations.""" + + def __init__(self) -> None: + self.aclose_calls = 0 + + async def __call__(self, request: Request) -> Response: # pragma: no cover - not used + raise NotImplementedError + + def stream( # pragma: no cover - not used + self, request: Request + ) -> AbstractAsyncContextManager[StreamResponse]: + raise NotImplementedError + + async def aclose(self) -> None: + self.aclose_calls += 1 +``` + +- [ ] **Step 2: Replace `_TrackingTransport()` with `RecordedTransport()`** + +In each test, change `transport = _TrackingTransport()` to: + +```python +transport = RecordedTransport() +``` + +The tests already access `transport.aclose_calls`; that attribute is present on `RecordedTransport`. + +- [ ] **Step 3: Add the import** + +```python +from httpware import AsyncClient, RecordedTransport +``` + +- [ ] **Step 4: Drop unused imports** + +Remove `Request`, `Response`, `StreamResponse`, `AbstractAsyncContextManager` if they were imported only for the stub. + +- [ ] **Step 5: Run this file's tests** + +Run: `uv run pytest tests/test_client_lifecycle.py -v` +Expected: all pass. + +### 4.3 `tests/test_client_methods.py` + +The `_RecordingTransport` here has `last_request` and a canned 200 response. `RecordedTransport(default=...)` provides both. + +- [ ] **Step 1: Delete the local `_RecordingTransport` class** + +Find and delete: + +```python +class _RecordingTransport: + """Captures the last-seen Request and returns a canned Response.""" + + def __init__(self) -> None: + self.last_request: Request | None = None + self.canned = Response( + status=200, + headers={"x-from": "transport"}, + content=b"body", + url="https://example.test/", + elapsed=0.0, + ) + + async def __call__(self, request: Request) -> Response: + self.last_request = request + return self.canned + + 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 +``` + +- [ ] **Step 2: Replace constructions** + +For each `transport = _RecordingTransport()`, replace with: + +```python +transport = RecordedTransport( + default=Response( + status=200, + headers={"x-from": "transport"}, + content=b"body", + url="https://example.test/", + elapsed=0.0, + ) +) +``` + +Tests already access `transport.last_request`; the property is present on `RecordedTransport`. + +- [ ] **Step 3: Add the import** + +```python +from httpware import AsyncClient, RecordedTransport +``` + +- [ ] **Step 4: Drop unused imports** + +Remove `StreamResponse`, `AbstractAsyncContextManager` if no longer needed. + +- [ ] **Step 5: Run this file's tests** + +Run: `uv run pytest tests/test_client_methods.py -v` +Expected: all pass. + +### 4.4 `tests/test_client_response_model.py` + +The `_RecordingTransport` here takes a `content` argument and returns a canned 200 with that content. + +- [ ] **Step 1: Delete the local `_RecordingTransport` class** + +Find and delete the class definition at the top of the file. + +- [ ] **Step 2: Replace constructions** + +For each `transport = _RecordingTransport(content=b'...')`, replace with: + +```python +transport = RecordedTransport( + default=Response( + status=200, + headers={}, + content=b'...', + url="/", + elapsed=0.0, + ) +) +``` + +(Use the same content bytes the test was originally constructing.) + +- [ ] **Step 3: Add the import** + +```python +from httpware import AsyncClient, RecordedTransport, Response +``` + +- [ ] **Step 4: Drop unused imports** + +Remove `StreamResponse`, `AbstractAsyncContextManager`, and the `Request` import if no longer used. + +- [ ] **Step 5: Run this file's tests** + +Run: `uv run pytest tests/test_client_response_model.py -v` +Expected: 3 passed. + +### 4.5 `tests/test_client_middleware_wiring.py` + +The `_RecordingTransport` here counts `.calls`. Replace with `RecordedTransport(default=...)`; tests that used `transport.calls == N` switch to `len(transport.requests) == N`. + +- [ ] **Step 1: Delete the local `_RecordingTransport` class** + +Find and delete the class. + +- [ ] **Step 2: Replace constructions** + +For each `transport = _RecordingTransport()`, replace with: + +```python +transport = RecordedTransport( + default=Response( + status=200, + headers={}, + content=b"", + url="/", + elapsed=0.0, + ) +) +``` + +Note: the original stub returned a Response with `url=request.url`. `RecordedTransport(default=Response(url="/"))` returns a fixed URL. Most tests don't read `response.url`; if any do, adjust them to read `transport.last_request.url` instead. + +- [ ] **Step 3: Switch `transport.calls` to `len(transport.requests)`** + +Search for `transport.calls` in the file. Each occurrence: + +```python +# Before: +assert transport.calls == 1 + +# After: +assert len(transport.requests) == 1 +``` + +- [ ] **Step 4: Add the import** + +```python +from httpware import AsyncClient, RecordedTransport, Response +``` + +- [ ] **Step 5: Drop unused imports** + +Remove `StreamResponse`, `AbstractAsyncContextManager`, `Request` if unused. + +- [ ] **Step 6: Run this file's tests** + +Run: `uv run pytest tests/test_client_middleware_wiring.py -v` +Expected: all pass. + +### 4.6 `tests/test_middleware.py` + +The file has TWO stubs: `_OkTransport` (returns fixed 200) and `_FailingTransport` (raises chosen exception). Replace both. + +- [ ] **Step 1: Delete `_OkTransport` and `_FailingTransport`** + +Find and delete: + +```python +class _OkTransport: + """Minimal Transport: returns a fixed Response, no streaming, no aclose work.""" + + async def __call__(self, request: Request) -> Response: + return Response( + status=200, + headers={"x-from": "transport"}, + content=b"transport", + url=request.url, + elapsed=0.0, + ) + + def stream( # pragma: no cover - not exercised in 2-1 + ... + ) -> AbstractAsyncContextManager[StreamResponse]: + raise NotImplementedError + + async def aclose(self) -> None: # pragma: no cover - not exercised in 2-1 + return None +``` + +And `_FailingTransport`: + +```python +class _FailingTransport: + """Transport whose __call__ raises a chosen exception.""" + + def __init__(self, exc: BaseException) -> None: + self._exc = exc + + async def __call__(self, request: Request) -> Response: # noqa: ARG002 + raise self._exc + + def stream( # pragma: no cover - not exercised in 2-2 + self, request: Request + ) -> AbstractAsyncContextManager[StreamResponse]: + raise NotImplementedError + + async def aclose(self) -> None: # pragma: no cover - not exercised in 2-2 + return None +``` + +- [ ] **Step 2: Replace `_OkTransport()` constructions** + +Each `_OkTransport()` becomes: + +```python +RecordedTransport( + default=Response( + status=200, + headers={"x-from": "transport"}, + content=b"transport", + url="/", + elapsed=0.0, + ) +) +``` + +Note: the original stub returned `url=request.url`. If any test reads `response.url` from the dispatched response and expects it to match the request URL, switch the assertion to read `transport.last_request.url`. Most tests in `test_middleware.py` check headers / status / content rather than url. + +- [ ] **Step 3: Replace `_FailingTransport(exc)` constructions** + +Each `_FailingTransport(some_error)` becomes: + +```python +RecordedTransport(default=some_error) +``` + +- [ ] **Step 4: Add the import** + +```python +from httpware import RecordedTransport +``` + +(plus the existing `Middleware`, `Next`, etc. imports.) + +- [ ] **Step 5: Drop unused imports** + +Remove `StreamResponse` and `AbstractAsyncContextManager` if no longer needed after the stub removal. + +- [ ] **Step 6: Run this file's tests** + +Run: `uv run pytest tests/test_middleware.py -v` +Expected: all 24 tests pass. + +### 4.7 Final commit for Task 4 + +- [ ] **Step 1: Run the full test suite** + +Run: `just test` +Expected: same count as the post-Task-3 baseline plus the 16 RecordedTransport tests (15 + 1 reexport), now ~272 passed, 1 deselected. 100% coverage maintained. + +- [ ] **Step 2: Lint** + +Run: `just lint-ci` +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_middleware.py tests/test_client_construction.py tests/test_client_methods.py tests/test_client_response_model.py tests/test_client_lifecycle.py tests/test_client_middleware_wiring.py +git commit -m "$(cat <<'EOF' +test(story-1.8): consolidate five in-tree transport stubs into RecordedTransport + +Replaces the file-local _FakeTransport (test_client_construction.py), +_OkTransport and _FailingTransport (test_middleware.py), two distinct +_RecordingTransport variants (test_client_methods.py, +test_client_response_model.py, test_client_middleware_wiring.py), and +_TrackingTransport (test_client_lifecycle.py) with one shared +RecordedTransport class. + +Each replacement is mechanical: +- _FakeTransport() → RecordedTransport() +- _OkTransport() → RecordedTransport(default=Response(...)) +- _FailingTransport(exc) → RecordedTransport(default=exc) +- _RecordingTransport (last_request flavor) → RecordedTransport(default=Response(...)) +- _RecordingTransport (calls counter) → RecordedTransport(default=Response(...)) + with .calls → len(transport.requests) +- _TrackingTransport → RecordedTransport() (aclose_calls attribute preserved) + +No test behavior changes. Total test count unchanged from this commit's +edits; only the new 16 RecordedTransport tests from Tasks 1-3 increase the +overall count. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: Verify, push, PR, merge + +End-to-end sanity check, push, open PR, wait for CI, merge. + +- [ ] **Step 1: Run the full test suite with coverage** + +Run: `just test` +Expected: ~272 passed (256 baseline post-1.7 + 15 RecordedTransport behavioral tests + 1 reexport test), 1 deselected (perf), 100% line coverage including `src/httpware/transports/recorded.py`. + +- [ ] **Step 2: Run full lint and type checks** + +Run: `just lint-ci` +Expected: `eof-fixer`, `ruff format --check`, `ruff check --no-fix`, `ty check` all clean. + +- [ ] **Step 3: Confirm the working tree is clean** + +Run: `git status --short` +Expected: only the untracked plan file `docs/superpowers/plans/2026-05-31-recordedtransport-plan.md`. + +- [ ] **Step 4: Review the branch diff** + +Run: `git log --oneline main..HEAD` +Expected: five commits — spec, Task 1, Task 2, Task 3, Task 4. + +Run: `git diff --stat main..HEAD` +Expected: new files `src/httpware/transports/recorded.py`, `tests/test_transports_recorded.py`, the spec, the plan; modifications to `CHANGELOG.md`, `src/httpware/__init__.py`, and the six test files; no source files outside this scope. + +- [ ] **Step 5: Stage and commit the plan file** + +```bash +git add docs/superpowers/plans/2026-05-31-recordedtransport-plan.md +git commit -m "docs(story-1.8): implementation plan for RecordedTransport + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +- [ ] **Step 6: Push the branch** + +Run: `git push -u origin story/1-8-recordedtransport` +Expected: push succeeds; GitHub prints a "Create a pull request for ..." URL. + +- [ ] **Step 7: Open the PR** + +```bash +gh pr create --title "feat(story-1.8): RecordedTransport — built-in Transport test double" --body "$(cat <<'EOF' +## Summary + +- Adds `src/httpware/transports/recorded.py` with `RecordedTransport`, a built-in `Transport` test double. Route table maps `(method, url)` to `Response | BaseException`; configurable `default` for the no-match case (`None` → `RuntimeError` per archive AC; `Response` → returned; `BaseException` → raised). Method names are uppercased on insert and lookup. Routes fire indefinitely on repeat matches. +- Exposes `transport.requests: list[Request]`, `transport.last_request` (property), and `transport.aclose_calls: int` for assertion patterns. `add_route(method, url, response_or_exception)` for incremental setup. `stream()` raises `NotImplementedError` — streaming lands in Epic 4 (Story 4-1). +- Re-exported as `httpware.RecordedTransport`. +- **Consolidation:** the five in-tree test stubs accumulated through Stories 1-7 and 2-1 (`_FakeTransport`, `_OkTransport`, `_FailingTransport`, two `_RecordingTransport` variants, `_TrackingTransport`) are replaced with one canonical class. Mechanical refactor; no test behavior changes. +- 16 new tests (15 behavioral + 1 reexport) in `tests/test_transports_recorded.py`; 100% line coverage on the new module. + +This closes Epic 1. + +Out of scope (subsequent stories): URL pattern matching / globs, cassette files loaded from JSON, streaming responses (Epic 4). + +Spec + plan: `docs/superpowers/specs/2026-05-31-recordedtransport-design.md`, `docs/superpowers/plans/2026-05-31-recordedtransport-plan.md`. + +## Test plan + +- [x] `just test` — ~272 passed, 1 deselected, 100% line coverage on the new module. +- [x] `just lint-ci` clean. +- [x] `tests/test_no_httpx2_leakage.py` still passes. +- [x] `tests/test_optional_extras_isolation.py` still passes. +- [x] All six existing test files (`test_middleware.py`, `test_client_construction.py`, `test_client_methods.py`, `test_client_response_model.py`, `test_client_lifecycle.py`, `test_client_middleware_wiring.py`) pass after the stub-replacement commit. +- [ ] CI green on all matrix entries (3.11/3.12/3.13/3.14 + lint). + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +- [ ] **Step 8: Wait for CI** + +Run: `gh pr checks ` +Expected: all five jobs green. + +If `pytest (3.14)` fails on the `codecov/codecov-action@v4.0.1` step with EPIPE (transient pattern observed earlier on this repo), re-run with `gh run rerun --failed`. + +- [ ] **Step 9: Merge** + +Once CI is green: + +Run: `gh pr merge --merge --delete-branch` +Run: `git checkout main && git pull --ff-only && git log --oneline -3` + +Story 1-8 is complete. **Epic 1 is complete.** Story 2-4 (auth coercion as middleware) becomes the next normal-flow item in Epic 2. + +--- + +## Definition of done + +- `src/httpware/transports/recorded.py` exists with the `RecordedTransport` class. No `__all__`. +- `tests/test_transports_recorded.py` contains 16 tests (15 behavioral + 1 reexport); all pass; 100% line coverage on `recorded.py`. +- `src/httpware/__init__.py` exports `RecordedTransport` at the package root and adds it to `__all__`. +- All five existing in-tree stub classes (`_FakeTransport`, `_OkTransport`, `_FailingTransport`, the two `_RecordingTransport` variants, `_TrackingTransport`) are removed from their respective test files; those tests now use `RecordedTransport(...)` construction. Total test count unchanged from before the stub-replacement commit (only Tasks 1-3 add new tests). +- `just test` shows ~272 passed, 1 deselected, 100% line coverage. +- `just lint-ci` clean. +- `tests/test_no_httpx2_leakage.py` and `tests/test_optional_extras_isolation.py` still pass. +- CHANGELOG bullet under `[Unreleased]` / `### Added` describes the public surface plus the stub-consolidation outcome. +- Story 1-8 lands as a single PR off `main` via the branch `story/1-8-recordedtransport`. Epic 1 is complete after this merge. From 40d3190ff97f7a30f4afe2d193d714f1679f8206 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 31 May 2026 23:14:39 +0300 Subject: [PATCH 7/7] docs(story-1.8): RecordedTransport docstring notes BaseException rationale Final review concern: the docstring named the route/default type (`Response | BaseException`) without explaining why BaseException (not Exception) is the chosen union. Adds a one-paragraph note: the choice lets test code express `asyncio.CancelledError`, `SystemExit`, etc.; surfaces that these bypass `except Exception:`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/httpware/transports/recorded.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/httpware/transports/recorded.py b/src/httpware/transports/recorded.py index 285d947..5bea491 100644 --- a/src/httpware/transports/recorded.py +++ b/src/httpware/transports/recorded.py @@ -25,6 +25,11 @@ class RecordedTransport: 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). """