From 5bcf9a4b68a36f397ef3a1681988140dee529e9a Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 31 May 2026 18:23:36 +0300 Subject: [PATCH 1/7] docs(story-2.3): design for Request/Response immutability helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 5 helpers to Request (with_headers, with_cookie, with_cookies, with_extension, with_extensions) and 2 to Response (with_headers, with_status) so middleware can rewrite requests and responses ergonomically without falling back to dataclasses.replace. Pragmatic scope per engineering.md roadmap: archive's list plus cookies and extensions. Existing helpers (with_header, with_url, with_body, with_query) untouched — including with_query's REPLACE semantics, which the spec justifies vs with_headers' MERGE on three grounds (usage patterns, HTTP semantics, escape-hatch availability). Strict epic boundary — auth coercion (2-4), AsyncClient wiring (2-5), StreamResponse helpers (4-1) deferred. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-31-request-immutability-helpers-design.md | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-31-request-immutability-helpers-design.md diff --git a/docs/superpowers/specs/2026-05-31-request-immutability-helpers-design.md b/docs/superpowers/specs/2026-05-31-request-immutability-helpers-design.md new file mode 100644 index 0000000..4bbb59d --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-request-immutability-helpers-design.md @@ -0,0 +1,159 @@ +# Request / Response immutability helper expansion (design) + +- **Date:** 2026-05-31 +- **Status:** approved, ready for plan +- **Scope:** Story 2-3 (third story of Epic 2). Extends the existing `with_*` helper grid on `Request` and adds the missing helpers on `Response`. Out of scope: auth coercion (2-4), AsyncClient wiring (2-5), streaming (Epic 4). +- **Roadmap pointer:** `docs/engineering.md` §8 "Epic 2 — Compose request-handling logic via middleware". + +## Why + +Middleware (Story 2-1) and the phase decorators (Story 2-2) now exist. The remaining gap before middleware-driven request rewriting is ergonomic: `Request` currently exposes `with_header`, `with_url`, `with_body`, `with_query` (Story 1-2), but no plural `with_headers`, no `with_cookie`/`with_cookies`, no `with_extension`/`with_extensions`. `Response` has no `with_*` helpers at all. Middleware authors can technically work around the gaps via `dataclasses.replace`, but the framework should ship the ergonomic API directly. + +The archived epic spec (`docs/archive/epics.md` Story 2.3) calls for `with_headers` on `Request`, plus `with_headers` and `with_status` on `Response`. `docs/engineering.md` §8 broadens the scope to include `with_cookie` and `with_extension`. This spec adopts the broader scope. + +## Decisions + +| Decision | Choice | +| --- | --- | +| Scope | Pragmatic — archive's list plus cookies and extensions. 5 new on `Request`, 2 new on `Response`. | +| Naming convention | `with_X(name, value)` singular → set/replace one entry. `with_Xs(items)` plural → merge `items` into collection; incoming keys override existing. | +| `with_query` legacy semantics | Untouched. Still REPLACES all params. Asymmetric with `with_headers` (which merges); the asymmetry is justified by usage patterns (see rationale below). | +| Merge implementation | `{**existing, **incoming}` for all plural merges. Naive dict merge, no case normalization, no validation. | +| Existing `with_header` etc. | Untouched. No signature changes, no semantic changes. | +| Validation | None. Value objects don't enforce protocol semantics; `with_status(99)` is allowed. | +| Short-circuit on empty input | None. `with_headers({})` allocates a fresh instance via `dataclasses.replace`. Micro-cost not worth the conditional. | +| Case-insensitive header keys | Out of scope. Existing v0 contract assumes lowercase ASCII keys; the broader case-insensitive `Mapping[str, str]` redesign is in `deferred-work.md`. | +| `StreamResponse.with_*` | Out of scope. Designed in Story 4-1 alongside the streaming type itself. | +| `__all__` updates | None. `Request` and `Response` are already exported; their methods come along. | + +### Rationale for `with_query` REPLACE vs `with_headers` MERGE + +Three points map the asymmetry to real differences in how the two collections are used: + +1. **Common operation differs.** Headers are *added on top* of an already-large set (5–20+ entries, most owned by the framework — `Content-Type`, `Accept`, `User-Agent`, auth, transport encoding). Middleware adds trace IDs / signatures without disturbing them. Query params are *constructed* from a small user-owned set (0–5 items) — pagination cursors, search filters, etc. Easier to rebuild wholesale than to merge. +2. **HTTP semantics.** Repeated headers carry meaning (`Set-Cookie`, `Via`, `Link`). Silent loss via REPLACE would break correctness. Query strings have no analogous protocol-level repetition semantics; replacement is safe. +3. **Singular escape hatches exist for headers, not query.** `with_header(name, value)` covers "set one." If `with_headers` also REPLACED, middleware would write `with_headers({**req.headers, "x-trace": "abc"})` everywhere — clumsy. For query params, REPLACE is the common path; the rarer "add one without losing the rest" can be expressed when needed. + +## File structure + +**Modified files:** + +``` +src/httpware/request.py # add 5 helpers (~20 lines added) +src/httpware/response.py # add 2 helpers (~10 lines added) + Self import +tests/test_request.py # append 10 new tests +tests/test_response.py # append 4 new tests +CHANGELOG.md # append Story 2.3 bullet under [Unreleased] / ### Added +``` + +**Files not touched:** every other source/test file. Purely additive. + +## Request helpers — implementation + +Append to `src/httpware/request.py`, after the existing `with_query` method: + +```python +def with_headers(self, headers: Mapping[str, str]) -> Self: + """Return a copy with the given headers merged in (incoming keys override existing).""" + return dataclasses.replace(self, headers={**self.headers, **headers}) + +def with_cookie(self, name: str, value: str) -> Self: + """Return a copy with the given cookie added or replaced.""" + return dataclasses.replace(self, cookies={**self.cookies, name: value}) + +def with_cookies(self, cookies: Mapping[str, str]) -> Self: + """Return a copy with the given cookies merged in (incoming keys override existing).""" + return dataclasses.replace(self, cookies={**self.cookies, **cookies}) + +def with_extension(self, name: str, value: Any) -> Self: # noqa: ANN401 + """Return a copy with the given extension entry added or replaced.""" + return dataclasses.replace(self, extensions={**self.extensions, name: value}) + +def with_extensions(self, extensions: Mapping[str, Any]) -> Self: + """Return a copy with the given extensions merged in (incoming keys override existing).""" + return dataclasses.replace(self, extensions={**self.extensions, **extensions}) +``` + +No new imports needed — `dataclasses`, `Mapping`, `Any`, `Self` are already imported at the top of the file. + +## Response helpers — implementation + +Append to `src/httpware/response.py`, after the existing `json` method. Two import additions: + +1. The class needs `Self`: change `from typing import Any` to `from typing import Any, Self`. +2. The class needs `dataclasses.replace`: add a top-level `import dataclasses` line (alongside the existing `from dataclasses import dataclass`). This matches the pattern in `src/httpware/request.py`, which has both `import dataclasses` and `from dataclasses import dataclass, field`. + +```python +def with_headers(self, headers: Mapping[str, str]) -> Self: + """Return a copy with the given headers merged in (incoming keys override existing).""" + return dataclasses.replace(self, headers={**self.headers, **headers}) + +def with_status(self, status: int) -> Self: + """Return a copy with the given status code.""" + return dataclasses.replace(self, status=status) +``` + +The two new helpers go on `Response`, not on `StreamResponse`. + +## Testing + +### `tests/test_request.py` — 10 new tests + +| Test | Verifies | +| --- | --- | +| `test_with_headers_merges_new_headers` | `req.with_headers({"a": "1"})` adds the entry; `req.headers` unchanged. | +| `test_with_headers_overrides_existing_key` | Incoming key replaces existing value. | +| `test_with_headers_preserves_other_keys` | Existing keys not in the incoming mapping survive. | +| `test_with_headers_empty_mapping_returns_distinct_copy` | `with_headers({})` returns a new instance equal-but-not-identical to the original. | +| `test_with_cookie_adds_single_cookie` | `with_cookie("session", "abc")` adds; original `cookies` unchanged. | +| `test_with_cookie_replaces_existing_cookie` | Setting an existing cookie name replaces the value. | +| `test_with_cookies_merges_new_cookies` | Plural merges; incoming overrides. | +| `test_with_extension_adds_single_entry` | `with_extension("timeout", 5.0)` adds to extensions. | +| `test_with_extensions_merges_new_entries` | Plural merges. | +| `test_with_extension_accepts_any_value_type` | Extensions accept `int`, `dict`, custom object instance — `Any`-typed. | + +### `tests/test_response.py` — 4 new tests + +| Test | Verifies | +| --- | --- | +| `test_with_headers_merges_new_headers` | `resp.with_headers({"x-trace": "abc"})` adds; original unchanged. | +| `test_with_headers_overrides_existing_key` | Merge override semantics. | +| `test_with_status_replaces_status` | `resp.with_status(503)` replaces status; other fields unchanged. | +| `test_with_status_accepts_arbitrary_int` | `with_status(99)` works without validation. | + +### Cross-cutting test patterns + +Each test follows the same template: construct a baseline `Request` / `Response`, call the helper, assert the returned instance has the expected change, assert the original is unchanged, assert the returned instance is a distinct object (`returned is not original`). + +No new fixtures, no async tests, no transport interaction. + +**Coverage expectation:** 100% line coverage on the seven new helper bodies. Each helper is one line; each test exercises one helper. + +## Constraints and invariants + +- **No `httpx2` import.** Neither modified file imports `httpx2`. +- **No `from __future__ import annotations`.** PEP 604/585 syntax is native. +- **No `print()`, no `logging.basicConfig`.** Value-object helpers do no logging. +- **No `# type: ignore`.** `# noqa: ANN401` on `with_extension`'s `value: Any` parameter is the only suppression; intentional and matches `Response.json`'s existing pattern. +- **Existing helpers untouched.** `with_header`, `with_url`, `with_body`, `with_query` keep their current signatures and semantics. + +## Risks and mitigations + +| Risk | Mitigation | +| --- | --- | +| `ty` flags `dict[str, str]` (the literal merged dict) as not assignable to `Mapping[str, str]` field. | `dict` is a `Mapping`; ty should accept the assignment. If it doesn't, cast at the assignment site with `Mapping[str, str]` annotation — but no cast expected. Story 1-2's existing `with_header` uses the same pattern (`{**self.headers, name: value}`) and passes ty cleanly. | +| Caller mixes `"X-Trace"` and `"x-trace"` keys via `with_headers`. | Documented v0 limitation; the merged dict will have both. Same behavior as the existing `with_header` and tracked in `docs/deferred-work.md` under the broader case-insensitive Mapping work. Don't try to fix here. | +| Future call sites expect `with_headers` to REPLACE rather than MERGE. | Docstring is explicit ("merged in"). The phase decorators in Story 2-2 already follow this convention implicitly (e.g., `@after_response` rebuilds `Response(...)` rather than calling a non-existent helper). Anyone reading the docstring will see the semantics. | +| `Self` import on `Response` triggers ruff `I001` (import-sorting). | The import line `from typing import Any, Self` is alphabetic. ruff format will resolve any ordering. | + +## Definition of done + +- `src/httpware/request.py` exports 5 new helpers: `with_headers`, `with_cookie`, `with_cookies`, `with_extension`, `with_extensions`. +- `src/httpware/response.py` exports 2 new helpers: `with_headers`, `with_status`, and imports `Self` from `typing`. +- `tests/test_request.py` contains 10 new tests; all pass. +- `tests/test_response.py` contains 4 new tests; all pass. +- `CHANGELOG.md` has a Story 2.3 bullet under `[Unreleased]` / `### Added`. +- `just test` shows the increment from baseline; 100% line coverage on the new helpers. +- `just lint-ci` clean. +- `tests/test_no_httpx2_leakage.py` still passes. +- Story 2-3 lands as a single PR off `main` via the branch `story/2-3-request-immutability-helpers`. From 704999d64a3d9014d5acaeaa2d627e61e11b6256 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 31 May 2026 18:33:04 +0300 Subject: [PATCH 2/7] feat(story-2.3): Request.with_headers merge helper Adds Request.with_headers(headers: Mapping[str, str]) -> Self that merges the incoming mapping into the existing headers; incoming keys override existing. Four tests cover add, override, preserve-others, and empty-input semantics. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/httpware/request.py | 4 ++++ tests/test_request.py | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/httpware/request.py b/src/httpware/request.py index 91ae7e1..1cea539 100644 --- a/src/httpware/request.py +++ b/src/httpware/request.py @@ -33,3 +33,7 @@ def with_body(self, body: bytes | None) -> Self: def with_query(self, params: Mapping[str, str]) -> Self: """Return a copy with the given query params replacing the existing ones.""" return dataclasses.replace(self, params=params) + + def with_headers(self, headers: Mapping[str, str]) -> Self: + """Return a copy with the given headers merged in (incoming keys override existing).""" + return dataclasses.replace(self, headers={**self.headers, **headers}) diff --git a/tests/test_request.py b/tests/test_request.py index 1af85d4..d644e43 100644 --- a/tests/test_request.py +++ b/tests/test_request.py @@ -70,3 +70,30 @@ def test_with_query_replaces_params() -> None: assert new.params == {"b": "2"} assert r.params == {"a": "1"} assert new is not r + + +def test_with_headers_merges_new_headers() -> None: + r = Request(method="GET", url="/") + new = r.with_headers({"X-Trace": "abc", "X-Other": "1"}) + assert new.headers == {"X-Trace": "abc", "X-Other": "1"} + assert r.headers == {} + + +def test_with_headers_overrides_existing_key() -> None: + r = Request(method="GET", url="/", headers={"X-Trace": "old"}) + new = r.with_headers({"X-Trace": "new"}) + assert new.headers == {"X-Trace": "new"} + assert r.headers == {"X-Trace": "old"} + + +def test_with_headers_preserves_other_keys() -> None: + r = Request(method="GET", url="/", headers={"Keep": "1", "Replace": "old"}) + new = r.with_headers({"Replace": "new", "Add": "2"}) + assert new.headers == {"Keep": "1", "Replace": "new", "Add": "2"} + + +def test_with_headers_empty_mapping_returns_distinct_copy() -> None: + r = Request(method="GET", url="/", headers={"A": "1"}) + new = r.with_headers({}) + assert new == r + assert new is not r From 8b865ae6e6dd2ca87943abaa1b8d93aa773e2477 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 31 May 2026 18:34:26 +0300 Subject: [PATCH 3/7] feat(story-2.3): Request.with_cookie and with_cookies helpers Adds Request.with_cookie(name, value) -> Self and Request.with_cookies(cookies: Mapping) -> Self. Singular adds/replaces one cookie; plural merges a mapping with incoming keys overriding. Three tests cover the add, replace, and merge cases. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/httpware/request.py | 8 ++++++++ tests/test_request.py | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/httpware/request.py b/src/httpware/request.py index 1cea539..f49e295 100644 --- a/src/httpware/request.py +++ b/src/httpware/request.py @@ -37,3 +37,11 @@ def with_query(self, params: Mapping[str, str]) -> Self: def with_headers(self, headers: Mapping[str, str]) -> Self: """Return a copy with the given headers merged in (incoming keys override existing).""" return dataclasses.replace(self, headers={**self.headers, **headers}) + + def with_cookie(self, name: str, value: str) -> Self: + """Return a copy with the given cookie added or replaced.""" + return dataclasses.replace(self, cookies={**self.cookies, name: value}) + + def with_cookies(self, cookies: Mapping[str, str]) -> Self: + """Return a copy with the given cookies merged in (incoming keys override existing).""" + return dataclasses.replace(self, cookies={**self.cookies, **cookies}) diff --git a/tests/test_request.py b/tests/test_request.py index d644e43..155a5cb 100644 --- a/tests/test_request.py +++ b/tests/test_request.py @@ -97,3 +97,24 @@ def test_with_headers_empty_mapping_returns_distinct_copy() -> None: new = r.with_headers({}) assert new == r assert new is not r + + +def test_with_cookie_adds_single_cookie() -> None: + r = Request(method="GET", url="/") + new = r.with_cookie("session", "abc") + assert new.cookies == {"session": "abc"} + assert r.cookies == {} + + +def test_with_cookie_replaces_existing_cookie() -> None: + r = Request(method="GET", url="/", cookies={"session": "old"}) + new = r.with_cookie("session", "new") + assert new.cookies == {"session": "new"} + assert r.cookies == {"session": "old"} + + +def test_with_cookies_merges_new_cookies() -> None: + r = Request(method="GET", url="/", cookies={"keep": "1", "replace": "old"}) + new = r.with_cookies({"replace": "new", "add": "2"}) + assert new.cookies == {"keep": "1", "replace": "new", "add": "2"} + assert r.cookies == {"keep": "1", "replace": "old"} From 3f83216d2af7c0801be89e42d2a2fe40764458b7 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 31 May 2026 18:35:51 +0300 Subject: [PATCH 4/7] feat(story-2.3): Request.with_extension and with_extensions helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Request.with_extension(name, value: Any) -> Self and Request.with_extensions(extensions: Mapping[str, Any]) -> Self. Extensions hold opaque user payloads (transport hints, debug attachments) — the Any value type is intentional and noqa'd. Three tests cover add-single, merge-plural, and Any-value-type behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/httpware/request.py | 8 ++++++++ tests/test_request.py | 25 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/httpware/request.py b/src/httpware/request.py index f49e295..218c663 100644 --- a/src/httpware/request.py +++ b/src/httpware/request.py @@ -45,3 +45,11 @@ def with_cookie(self, name: str, value: str) -> Self: def with_cookies(self, cookies: Mapping[str, str]) -> Self: """Return a copy with the given cookies merged in (incoming keys override existing).""" return dataclasses.replace(self, cookies={**self.cookies, **cookies}) + + def with_extension(self, name: str, value: Any) -> Self: # noqa: ANN401 + """Return a copy with the given extension entry added or replaced.""" + return dataclasses.replace(self, extensions={**self.extensions, name: value}) + + def with_extensions(self, extensions: Mapping[str, Any]) -> Self: + """Return a copy with the given extensions merged in (incoming keys override existing).""" + return dataclasses.replace(self, extensions={**self.extensions, **extensions}) diff --git a/tests/test_request.py b/tests/test_request.py index 155a5cb..47b746d 100644 --- a/tests/test_request.py +++ b/tests/test_request.py @@ -118,3 +118,28 @@ def test_with_cookies_merges_new_cookies() -> None: new = r.with_cookies({"replace": "new", "add": "2"}) assert new.cookies == {"keep": "1", "replace": "new", "add": "2"} assert r.cookies == {"keep": "1", "replace": "old"} + + +def test_with_extension_adds_single_entry() -> None: + r = Request(method="GET", url="/") + new = r.with_extension("timeout", 5.0) + assert new.extensions == {"timeout": 5.0} + assert r.extensions == {} + + +def test_with_extensions_merges_new_entries() -> None: + r = Request(method="GET", url="/", extensions={"keep": 1, "replace": "old"}) + new = r.with_extensions({"replace": "new", "add": [1, 2]}) + assert new.extensions == {"keep": 1, "replace": "new", "add": [1, 2]} + assert r.extensions == {"keep": 1, "replace": "old"} + + +def test_with_extension_accepts_any_value_type() -> None: + class _Marker: + pass + + marker = _Marker() + r = Request(method="GET", url="/") + new = r.with_extension("marker", marker) + assert new.extensions == {"marker": marker} + assert new.extensions["marker"] is marker From 913d0a48b1bb31faadb9ace70d5436104b54c491 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 31 May 2026 18:37:49 +0300 Subject: [PATCH 5/7] feat(story-2.3): Response.with_headers and with_status helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Response.with_headers(headers: Mapping[str, str]) -> Self and Response.with_status(status: int) -> Self for ergonomic Response rewriting from middleware. Both use the existing dataclasses.replace pattern. with_status applies no validation by design — value objects don't enforce protocol semantics. Adds `import dataclasses` and `Self` to response.py's typing imports. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/httpware/response.py | 11 ++++++++++- tests/test_response.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/httpware/response.py b/src/httpware/response.py index 2d082d2..64ce2e0 100644 --- a/src/httpware/response.py +++ b/src/httpware/response.py @@ -1,9 +1,10 @@ """Immutable response value type.""" +import dataclasses import json from collections.abc import Mapping from dataclasses import dataclass -from typing import Any +from typing import Any, Self _CHARSET_PREFIX = "charset=" @@ -50,6 +51,14 @@ def json(self) -> Any: # noqa: ANN401 """Parse `content` as JSON.""" return json.loads(self.content) + def with_headers(self, headers: Mapping[str, str]) -> Self: + """Return a copy with the given headers merged in (incoming keys override existing).""" + return dataclasses.replace(self, headers={**self.headers, **headers}) + + def with_status(self, status: int) -> Self: + """Return a copy with the given status code.""" + return dataclasses.replace(self, status=status) + @dataclass(frozen=True, slots=True) class StreamResponse: diff --git a/tests/test_response.py b/tests/test_response.py index fa5eb17..50ee60a 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -89,3 +89,35 @@ def test_response_equality_on_identical_fields() -> None: assert r1 == r2 assert r1 != Response(status=200, headers={"a": "1"}, content=b"x", url="/", elapsed=0.6) assert r1 != Response(status=201, headers={"a": "1"}, content=b"x", url="/", elapsed=0.5) + + +def test_response_with_headers_merges_new_headers() -> None: + resp = Response(status=200, headers={"keep": "1"}, content=b"", url="/", elapsed=0.0) + new = resp.with_headers({"x-trace": "abc"}) + assert new.headers == {"keep": "1", "x-trace": "abc"} + assert resp.headers == {"keep": "1"} + + +def test_response_with_headers_overrides_existing_key() -> None: + resp = Response(status=200, headers={"x-trace": "old"}, content=b"", url="/", elapsed=0.0) + new = resp.with_headers({"x-trace": "new"}) + assert new.headers == {"x-trace": "new"} + assert resp.headers == {"x-trace": "old"} + + +def test_response_with_status_replaces_status() -> None: + resp = Response(status=200, headers={"a": "1"}, content=b"body", url="/x", elapsed=0.5) + new = resp.with_status(503) + assert new.status == 503 # noqa: PLR2004 + assert new.headers == {"a": "1"} + assert new.content == b"body" + assert new.url == "/x" + assert new.elapsed == 0.5 # noqa: PLR2004 + assert resp.status == 200 # noqa: PLR2004 + + +def test_response_with_status_accepts_arbitrary_int() -> None: + resp = Response(status=200, headers={}, content=b"", url="/", elapsed=0.0) + # No validation by design — value objects don't enforce protocol semantics. + new = resp.with_status(99) + assert new.status == 99 # noqa: PLR2004 From 059d2e180a15fe31fcc0d52c96133fddff322034 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 31 May 2026 18:39:04 +0300 Subject: [PATCH 6/7] docs(story-2.3): CHANGELOG entry for immutability helper expansion Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc32377..37f8ab8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,5 +21,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `ResponseDecoder` protocol (`@runtime_checkable`) and default `PydanticDecoder` adapter — single-parse-pass JSON decoding via `pydantic.TypeAdapter.validate_json(bytes)`; a module-level `@functools.lru_cache(maxsize=None)` factory (`_get_adapter`) memoizes one `TypeAdapter` per `response_model` across the process so warm-path requests pay zero adapter-construction cost; `pydantic.ValidationError` surfaces unchanged to the caller (Story 1.5). - `Middleware` protocol (`@runtime_checkable`) and `Next` callable type alias (`Callable[[Request], Awaitable[Response]]`); private `compose(middlewares, transport)` chain composer at `httpware._internal.chain` using a recursive closure fold with `transport.__call__` as the bottom of the chain. No exception handling inside `compose`, so `asyncio.CancelledError` and user-raised exceptions propagate untouched (Story 2.1). - Phase-shortcut decorators `@before_request`, `@after_response`, `@on_error` for lifecycle hooks without authoring a full `Middleware` class. `@on_error` catches `Exception` only (so `asyncio.CancelledError` propagates); its handler may return a `Response` to recover or `None` to re-raise (Story 2.2). +- 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). [Unreleased]: https://github.com/modern-python/httpware/commits/main From 96cb627516b0902f50ae273a6ff074aa6d7f19ef Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 31 May 2026 18:39:30 +0300 Subject: [PATCH 7/7] docs(story-2.3): implementation plan for immutability helpers Co-Authored-By: Claude Opus 4.7 (1M context) --- ...05-31-request-immutability-helpers-plan.md | 543 ++++++++++++++++++ 1 file changed, 543 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-31-request-immutability-helpers-plan.md diff --git a/docs/superpowers/plans/2026-05-31-request-immutability-helpers-plan.md b/docs/superpowers/plans/2026-05-31-request-immutability-helpers-plan.md new file mode 100644 index 0000000..3ab1141 --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-request-immutability-helpers-plan.md @@ -0,0 +1,543 @@ +# Request / Response immutability helper expansion 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 2-3: add 5 new `with_*` helpers to `Request` (`with_headers`, `with_cookie`, `with_cookies`, `with_extension`, `with_extensions`) and 2 to `Response` (`with_headers`, `with_status`). + +**Architecture:** All seven helpers are one-line `dataclasses.replace(...)` calls following the existing Story-1-2 pattern (`with_header`, `with_url`, `with_body`, `with_query`). Plural helpers merge: `{**existing, **incoming}`. Singular helpers (`with_cookie`, `with_extension`) take `(name, value)` and add/replace one entry. No validation, no case normalization. + +**Tech Stack:** Python 3.11 floor; `dataclasses.replace` on frozen+slots dataclasses. No new dependencies. + +**Branch:** `story/2-3-request-immutability-helpers` (already created; spec commit `5bcf9a4` is on it). + +**Spec:** `docs/superpowers/specs/2026-05-31-request-immutability-helpers-design.md`. + +--- + +## File Structure + +**Modified files:** +- `src/httpware/request.py` — append 5 helper methods (~20 lines added). +- `src/httpware/response.py` — append 2 helper methods + add `Self` and `dataclasses` imports (~10 lines added). +- `tests/test_request.py` — append 10 new tests. +- `tests/test_response.py` — append 4 new tests. +- `CHANGELOG.md` — append Story 2.3 bullet under `[Unreleased]` / `### Added`. + +**Files not touched:** everything else. Purely additive. + +--- + +## Task 1: `Request.with_headers` (merge headers) + +TDD cycle for the plural-merge helper on Request. Four tests cover add, override, preserve, and empty-input cases. + +**Files:** +- Modify: `src/httpware/request.py` (append method) +- Modify: `tests/test_request.py` (append 4 tests) + +- [ ] **Step 1: Add the failing tests** + +Append to `tests/test_request.py`: + +```python +def test_with_headers_merges_new_headers() -> None: + r = Request(method="GET", url="/") + new = r.with_headers({"X-Trace": "abc", "X-Other": "1"}) + assert new.headers == {"X-Trace": "abc", "X-Other": "1"} + assert r.headers == {} + + +def test_with_headers_overrides_existing_key() -> None: + r = Request(method="GET", url="/", headers={"X-Trace": "old"}) + new = r.with_headers({"X-Trace": "new"}) + assert new.headers == {"X-Trace": "new"} + assert r.headers == {"X-Trace": "old"} + + +def test_with_headers_preserves_other_keys() -> None: + r = Request(method="GET", url="/", headers={"Keep": "1", "Replace": "old"}) + new = r.with_headers({"Replace": "new", "Add": "2"}) + assert new.headers == {"Keep": "1", "Replace": "new", "Add": "2"} + + +def test_with_headers_empty_mapping_returns_distinct_copy() -> None: + r = Request(method="GET", url="/", headers={"A": "1"}) + new = r.with_headers({}) + assert new == r + assert new is not r +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_request.py -k "with_headers" -v` +Expected: 4 errors with `AttributeError: 'Request' object has no attribute 'with_headers'`. + +- [ ] **Step 3: Implement `with_headers`** + +Append to `src/httpware/request.py`, immediately after the existing `with_query` method (i.e., as the last method of the `Request` class): + +```python + def with_headers(self, headers: Mapping[str, str]) -> Self: + """Return a copy with the given headers merged in (incoming keys override existing).""" + return dataclasses.replace(self, headers={**self.headers, **headers}) +``` + +(Note: four-space indentation since this is a class method.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_request.py -k "with_headers" -v` +Expected: 4 passed. + +- [ ] **Step 5: Lint and ty** + +Run: `uv run ruff check src/httpware/request.py tests/test_request.py` +Run: `uv run ty check src/httpware/request.py` +Expected: both clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/httpware/request.py tests/test_request.py +git commit -m "$(cat <<'EOF' +feat(story-2.3): Request.with_headers merge helper + +Adds Request.with_headers(headers: Mapping[str, str]) -> Self that +merges the incoming mapping into the existing headers; incoming keys +override existing. Four tests cover add, override, preserve-others, +and empty-input semantics. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: `Request.with_cookie` and `Request.with_cookies` + +The cookies pair mirrors `with_header` / `with_headers`: singular adds/replaces one entry, plural merges. + +**Files:** +- Modify: `src/httpware/request.py` (append two methods) +- Modify: `tests/test_request.py` (append 3 tests) + +- [ ] **Step 1: Add the failing tests** + +Append to `tests/test_request.py`: + +```python +def test_with_cookie_adds_single_cookie() -> None: + r = Request(method="GET", url="/") + new = r.with_cookie("session", "abc") + assert new.cookies == {"session": "abc"} + assert r.cookies == {} + + +def test_with_cookie_replaces_existing_cookie() -> None: + r = Request(method="GET", url="/", cookies={"session": "old"}) + new = r.with_cookie("session", "new") + assert new.cookies == {"session": "new"} + assert r.cookies == {"session": "old"} + + +def test_with_cookies_merges_new_cookies() -> None: + r = Request(method="GET", url="/", cookies={"keep": "1", "replace": "old"}) + new = r.with_cookies({"replace": "new", "add": "2"}) + assert new.cookies == {"keep": "1", "replace": "new", "add": "2"} + assert r.cookies == {"keep": "1", "replace": "old"} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_request.py -k "with_cookie" -v` +Expected: 3 errors with `AttributeError: 'Request' object has no attribute 'with_cookie'` (and `with_cookies`). + +- [ ] **Step 3: Implement both methods** + +Append to `src/httpware/request.py`, immediately after `with_headers`: + +```python + def with_cookie(self, name: str, value: str) -> Self: + """Return a copy with the given cookie added or replaced.""" + return dataclasses.replace(self, cookies={**self.cookies, name: value}) + + def with_cookies(self, cookies: Mapping[str, str]) -> Self: + """Return a copy with the given cookies merged in (incoming keys override existing).""" + return dataclasses.replace(self, cookies={**self.cookies, **cookies}) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_request.py -k "with_cookie" -v` +Expected: 3 passed. + +- [ ] **Step 5: Lint and ty** + +Run: `uv run ruff check src/httpware/request.py tests/test_request.py` +Run: `uv run ty check src/httpware/request.py` +Expected: both clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/httpware/request.py tests/test_request.py +git commit -m "$(cat <<'EOF' +feat(story-2.3): Request.with_cookie and with_cookies helpers + +Adds Request.with_cookie(name, value) -> Self and +Request.with_cookies(cookies: Mapping) -> Self. Singular adds/replaces +one cookie; plural merges a mapping with incoming keys overriding. +Three tests cover the add, replace, and merge cases. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: `Request.with_extension` and `Request.with_extensions` + +The extensions pair mirrors `with_cookie` / `with_cookies` but values are `Any` (extensions are opaque user payloads passed to the transport). + +**Files:** +- Modify: `src/httpware/request.py` (append two methods) +- Modify: `tests/test_request.py` (append 3 tests) + +- [ ] **Step 1: Add the failing tests** + +Append to `tests/test_request.py`: + +```python +def test_with_extension_adds_single_entry() -> None: + r = Request(method="GET", url="/") + new = r.with_extension("timeout", 5.0) + assert new.extensions == {"timeout": 5.0} + assert r.extensions == {} + + +def test_with_extensions_merges_new_entries() -> None: + r = Request(method="GET", url="/", extensions={"keep": 1, "replace": "old"}) + new = r.with_extensions({"replace": "new", "add": [1, 2]}) + assert new.extensions == {"keep": 1, "replace": "new", "add": [1, 2]} + assert r.extensions == {"keep": 1, "replace": "old"} + + +def test_with_extension_accepts_any_value_type() -> None: + class _Marker: + pass + + marker = _Marker() + r = Request(method="GET", url="/") + new = r.with_extension("marker", marker) + assert new.extensions == {"marker": marker} + assert new.extensions["marker"] is marker +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_request.py -k "with_extension" -v` +Expected: 3 errors with `AttributeError: 'Request' object has no attribute 'with_extension'` (and `with_extensions`). + +- [ ] **Step 3: Implement both methods** + +Append to `src/httpware/request.py`, immediately after `with_cookies`: + +```python + def with_extension(self, name: str, value: Any) -> Self: # noqa: ANN401 + """Return a copy with the given extension entry added or replaced.""" + return dataclasses.replace(self, extensions={**self.extensions, name: value}) + + def with_extensions(self, extensions: Mapping[str, Any]) -> Self: + """Return a copy with the given extensions merged in (incoming keys override existing).""" + return dataclasses.replace(self, extensions={**self.extensions, **extensions}) +``` + +The `# noqa: ANN401` on `with_extension`'s `value: Any` is intentional — extensions are opaque user payloads. Matches the existing `# noqa: ANN401` pattern on `Response.json()`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_request.py -k "with_extension" -v` +Expected: 3 passed. + +- [ ] **Step 5: Full Request test pass + lint** + +Run: `uv run pytest tests/test_request.py -v` +Expected: All previously-passing tests plus 10 new ones (4 from Task 1 + 3 from Task 2 + 3 from Task 3) pass. + +Run: `uv run ruff check src/httpware/request.py tests/test_request.py` +Run: `uv run ty check src/httpware/request.py` +Expected: both clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/httpware/request.py tests/test_request.py +git commit -m "$(cat <<'EOF' +feat(story-2.3): Request.with_extension and with_extensions helpers + +Adds Request.with_extension(name, value: Any) -> Self and +Request.with_extensions(extensions: Mapping[str, Any]) -> Self. +Extensions hold opaque user payloads (transport hints, debug +attachments) — the Any value type is intentional and noqa'd. Three +tests cover add-single, merge-plural, and Any-value-type behavior. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: `Response.with_headers` and `Response.with_status` + +Response gets the same merge-headers helper as Request, plus `with_status` for status code replacement. + +**Files:** +- Modify: `src/httpware/response.py` (add imports + two methods) +- Modify: `tests/test_response.py` (append 4 tests) + +- [ ] **Step 1: Add the failing tests** + +Append to `tests/test_response.py`: + +```python +def test_response_with_headers_merges_new_headers() -> None: + resp = Response(status=200, headers={"keep": "1"}, content=b"", url="/", elapsed=0.0) + new = resp.with_headers({"x-trace": "abc"}) + assert new.headers == {"keep": "1", "x-trace": "abc"} + assert resp.headers == {"keep": "1"} + + +def test_response_with_headers_overrides_existing_key() -> None: + resp = Response(status=200, headers={"x-trace": "old"}, content=b"", url="/", elapsed=0.0) + new = resp.with_headers({"x-trace": "new"}) + assert new.headers == {"x-trace": "new"} + assert resp.headers == {"x-trace": "old"} + + +def test_response_with_status_replaces_status() -> None: + resp = Response(status=200, headers={"a": "1"}, content=b"body", url="/x", elapsed=0.5) + new = resp.with_status(503) + assert new.status == 503 + assert new.headers == {"a": "1"} + assert new.content == b"body" + assert new.url == "/x" + assert new.elapsed == 0.5 + assert resp.status == 200 + + +def test_response_with_status_accepts_arbitrary_int() -> None: + resp = Response(status=200, headers={}, content=b"", url="/", elapsed=0.0) + # No validation by design — value objects don't enforce protocol semantics. + new = resp.with_status(99) + assert new.status == 99 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_response.py -k "with_" -v` +Expected: 4 errors with `AttributeError: 'Response' object has no attribute 'with_headers'` (and `with_status`). + +- [ ] **Step 3: Add imports to `src/httpware/response.py`** + +Edit the top of `src/httpware/response.py`. The current imports are: + +```python +import json +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any +``` + +Change them to: + +```python +import dataclasses +import json +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Self +``` + +(`import dataclasses` is added so that `dataclasses.replace(...)` works inside the new methods. `Self` is added to `typing` for the return type.) + +- [ ] **Step 4: Implement both methods on `Response`** + +In `src/httpware/response.py`, append to the `Response` class (after the existing `json` method, before the `StreamResponse` class): + +```python + def with_headers(self, headers: Mapping[str, str]) -> Self: + """Return a copy with the given headers merged in (incoming keys override existing).""" + return dataclasses.replace(self, headers={**self.headers, **headers}) + + def with_status(self, status: int) -> Self: + """Return a copy with the given status code.""" + return dataclasses.replace(self, status=status) +``` + +(Four-space indentation for class methods.) + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `uv run pytest tests/test_response.py -v` +Expected: All previously-passing tests plus 4 new ones pass. + +- [ ] **Step 6: Lint and ty** + +Run: `uv run ruff check src/httpware/response.py tests/test_response.py` +Run: `uv run ty check src/httpware/response.py` +Expected: both clean. + +- [ ] **Step 7: Commit** + +```bash +git add src/httpware/response.py tests/test_response.py +git commit -m "$(cat <<'EOF' +feat(story-2.3): Response.with_headers and with_status helpers + +Adds Response.with_headers(headers: Mapping[str, str]) -> Self and +Response.with_status(status: int) -> Self for ergonomic Response +rewriting from middleware. Both use the existing dataclasses.replace +pattern. with_status applies no validation by design — value objects +don't enforce protocol semantics. + +Adds `import dataclasses` and `Self` to response.py's typing imports. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: CHANGELOG bullet + +Record the Story 2.3 surface under `[Unreleased]` / `### Added`. + +**Files:** +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Append the CHANGELOG bullet** + +Edit `CHANGELOG.md`. The `## [Unreleased]` / `### Added` section currently ends with the Story 2.2 bullet about the phase-shortcut decorators. Append a new bullet immediately after the Story 2.2 bullet (still before the `[Unreleased]: ...` reference link at the bottom): + +```markdown +- 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). +``` + +- [ ] **Step 2: Commit** + +```bash +git add CHANGELOG.md +git commit -m "$(cat <<'EOF' +docs(story-2.3): CHANGELOG entry for immutability helper expansion + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: Verify, push, PR, merge + +End-to-end sanity check on the branch, push, open PR, wait for CI, merge. + +- [ ] **Step 1: Run the full test suite with coverage** + +Run: `just test` +Expected: 198 passed (184 baseline post-2-2 + 14 new), 1 deselected (perf), 100% line coverage including the seven new helpers. + +If coverage is below 100% on `request.py` or `response.py`, identify the uncovered line. The new helpers are all one-line bodies that are exercised by their dedicated tests — uncovered lines indicate a missing test. + +- [ ] **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-request-immutability-helpers-plan.md`. + +- [ ] **Step 4: Review the branch diff** + +Run: `git log --oneline main..HEAD` +Expected: six or seven commits — the spec commit (`docs(story-2.3): design...`), Task 1, Task 2, Task 3, Task 4, Task 5. + +Run: `git diff --stat main..HEAD` +Expected: changes to `CHANGELOG.md`, `docs/superpowers/specs/2026-05-31-request-immutability-helpers-design.md`, `src/httpware/request.py`, `src/httpware/response.py`, `tests/test_request.py`, `tests/test_response.py`. No other files touched. + +- [ ] **Step 5: Stage and commit the plan file** + +```bash +git add docs/superpowers/plans/2026-05-31-request-immutability-helpers-plan.md +git commit -m "docs(story-2.3): implementation plan for immutability helpers + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +- [ ] **Step 6: Push the branch** + +Run: `git push -u origin story/2-3-request-immutability-helpers` +Expected: push succeeds; GitHub prints a "Create a pull request for ..." URL. + +- [ ] **Step 7: Open the PR** + +```bash +gh pr create --title "feat(story-2.3): Request/Response immutability helper expansion" --body "$(cat <<'EOF' +## Summary + +- Adds 5 helpers to \`Request\`: \`with_headers\` (merge), \`with_cookie\` / \`with_cookies\` (singular add/replace + plural merge), \`with_extension\` / \`with_extensions\` (same pattern, value type \`Any\`). +- Adds 2 helpers to \`Response\`: \`with_headers\` (merge) and \`with_status\` (replace). +- Convention: singular \`with_X(name, value)\` adds/replaces one entry; plural \`with_Xs(items)\` merges with incoming keys overriding. +- Existing helpers untouched, including \`with_query\`'s REPLACE semantics — the asymmetry vs \`with_headers\` MERGE is justified by usage patterns, HTTP semantics, and the singular-helper escape hatch (full rationale in the spec). +- 14 new tests (10 on Request, 4 on Response); 100% line coverage on new helpers; \`just test\` shows 198 passed. + +Out of scope (subsequent stories): auth coercion (2-4), AsyncClient wiring (2-5), \`StreamResponse.with_*\` (Story 4-1), case-insensitive header keys (existing deferred-work entry). + +Spec + plan: \`docs/superpowers/specs/2026-05-31-request-immutability-helpers-design.md\`, \`docs/superpowers/plans/2026-05-31-request-immutability-helpers-plan.md\`. + +## Test plan + +- [x] \`just test\` — 198 passed, 1 deselected, 100% line coverage. +- [x] \`just lint-ci\` clean. +- [x] \`tests/test_no_httpx2_leakage.py\` still passes. +- [ ] 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 ` (the number is printed by `gh pr create`). +Expected: all five jobs green. + +If `pytest (3.14)` fails on the `codecov/codecov-action@v4.0.1` step (transient EPIPE has been observed twice in 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 2-3 is complete. Story 2-4 (auth coercion as middleware) is the next normal-flow item. + +--- + +## Definition of done + +- `src/httpware/request.py` has 5 new methods: `with_headers`, `with_cookie`, `with_cookies`, `with_extension`, `with_extensions`. Existing methods untouched. +- `src/httpware/response.py` has 2 new methods: `with_headers`, `with_status`. Imports updated to include `dataclasses` and `Self`. +- `tests/test_request.py` contains 10 new tests; all pass. +- `tests/test_response.py` contains 4 new tests; all pass. +- `CHANGELOG.md` has a Story 2.3 bullet under `[Unreleased]` / `### Added`. +- `just test` shows 198 passed, 1 deselected, 100% line coverage. +- `just lint-ci` clean. +- `tests/test_no_httpx2_leakage.py` still passes. +- Both spec and plan committed on `story/2-3-request-immutability-helpers` and land via a single PR.