From 7a010ebfe7103a97c9ff1ecdfa9362720e96d03c Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 2 Jun 2026 23:38:04 +0300 Subject: [PATCH 1/7] build: guard just publish against missing env vars Refuse to run when GITHUB_REF_NAME or PYPI_TOKEN is unset, so local invocations cannot corrupt pyproject.toml via uv version "". Closes deferred-work entry: "just publish lacks env-var validation". Co-Authored-By: Claude Opus 4.7 (1M context) --- Justfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Justfile b/Justfile index 0853917..493f3b3 100644 --- a/Justfile +++ b/Justfile @@ -23,6 +23,8 @@ test-branch: @just test --cov-branch publish: + @test -n "${GITHUB_REF_NAME:-}" || (echo "GITHUB_REF_NAME is required; refusing to run outside CI" >&2; exit 1) + @test -n "${PYPI_TOKEN:-}" || (echo "PYPI_TOKEN is required; refusing to run outside CI" >&2; exit 1) rm -rf dist uv version $GITHUB_REF_NAME uv build From 627492af7bc0fa0357c4f3f8a7362d91e9e1a87d Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 2 Jun 2026 23:50:12 +0300 Subject: [PATCH 2/7] build: widen uv_build band to <1.0 Accept all 0.x releases; stops the every-minor bump treadmill. An incompatible 0.x bump (hypothetical) surfaces as a loud build error in CI, not a silent regression. Closes deferred-work entry: "uv_build>=0.11,<0.12 narrow window". Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2283552..1d0b979 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ repository = "https://github.com/modern-python/httpware" docs = "https://httpware.readthedocs.io" [build-system] -requires = ["uv_build>=0.11,<0.12"] +requires = ["uv_build>=0.11,<1.0"] build-backend = "uv_build" [tool.uv.build-backend] From 0f37f9ccde1413e70b124618aa45646d39da6bda Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 2 Jun 2026 23:56:16 +0300 Subject: [PATCH 3/7] test: use http.HTTPStatus constants for status-code assertions Replaces 11 instances of `assert status == # noqa: PLR2004` with `assert status == HTTPStatus.` across three test files. Each substitution removes a noqa; HTTPStatus members are IntEnum so behavior is unchanged. Non-status-code PLR2004 noqas (counts, elapsed, intentionally-invalid status==99) are out of scope. Partial: deferred-work "PLR2004 per-file-ignores" entry. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_middleware.py | 9 +++++---- tests/test_response.py | 5 +++-- tests/test_transports_httpx2.py | 9 +++++---- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/tests/test_middleware.py b/tests/test_middleware.py index b005f28..bda6234 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import Awaitable, Callable from contextlib import AbstractAsyncContextManager +from http import HTTPStatus from typing import get_type_hints import pytest @@ -64,7 +65,7 @@ async def test_empty_list_composes_to_transport_call() -> None: request = _make_request() response = await dispatch(request) - assert response.status == 200 # noqa: PLR2004 + assert response.status == HTTPStatus.OK assert response.content == b"transport" assert response.headers["x-from"] == "transport" @@ -192,7 +193,7 @@ async def __call__(self, request: Request, next: Next) -> Response: # noqa: A00 response = await compose([ShortCircuit(), NeverReached()], CountingTransport())(_make_request()) - assert response.status == 418 # noqa: PLR2004 + assert response.status == HTTPStatus.IM_A_TEAPOT assert response.content == b"teapot" assert transport_calls == 0 @@ -265,7 +266,7 @@ async def __call__(self, request: Request, next: Next) -> Response: # noqa: A00 for _ in range(3): response = await dispatch(_make_request()) - assert response.status == 200 # noqa: PLR2004 + assert response.status == HTTPStatus.OK assert count == 3 # noqa: PLR2004 @@ -332,7 +333,7 @@ async def recover(request: Request, exc: Exception) -> Response | None: # noqa: transport = RecordedTransport(default=RuntimeError("boom")) response = await compose([recover], transport)(_make_request()) - assert response.status == 503 # noqa: PLR2004 + assert response.status == HTTPStatus.SERVICE_UNAVAILABLE assert response.headers["x-recovered"] == "true" assert response.content == b"recovered" diff --git a/tests/test_response.py b/tests/test_response.py index 50ee60a..83bb776 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -1,6 +1,7 @@ """Unit tests for httpware.response.Response.""" from dataclasses import FrozenInstanceError +from http import HTTPStatus import pytest @@ -108,12 +109,12 @@ def test_response_with_headers_overrides_existing_key() -> None: 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.status == HTTPStatus.SERVICE_UNAVAILABLE assert new.headers == {"a": "1"} assert new.content == b"body" assert new.url == "/x" assert new.elapsed == 0.5 # noqa: PLR2004 - assert resp.status == 200 # noqa: PLR2004 + assert resp.status == HTTPStatus.OK def test_response_with_status_accepts_arbitrary_int() -> None: diff --git a/tests/test_transports_httpx2.py b/tests/test_transports_httpx2.py index faacfc7..a25f0cb 100644 --- a/tests/test_transports_httpx2.py +++ b/tests/test_transports_httpx2.py @@ -2,6 +2,7 @@ import asyncio from collections.abc import Callable +from http import HTTPStatus import httpx2 import pytest @@ -69,7 +70,7 @@ async def test_success_path_returns_response() -> None: await transport.aclose() assert isinstance(resp, Response) - assert resp.status == 200 # noqa: PLR2004 + assert resp.status == HTTPStatus.OK assert resp.content == b"hello" assert resp.url == "http://example.com/x" # lowercase ASCII keys per AC11 @@ -100,7 +101,7 @@ async def test_success_status_200_returns_response_not_raises() -> None: resp = await transport(Request(method="GET", url="http://example.com/")) finally: await transport.aclose() - assert resp.status == 200 # noqa: PLR2004 + assert resp.status == HTTPStatus.OK @pytest.mark.parametrize(("code", "exc_cls"), _STATUS_LEAVES) @@ -132,7 +133,7 @@ async def test_unknown_4xx_falls_back_to_client_status_error() -> None: finally: await transport.aclose() assert type(info.value) is ClientStatusError - assert info.value.status == 418 # noqa: PLR2004 + assert info.value.status == HTTPStatus.IM_A_TEAPOT async def test_unknown_5xx_falls_back_to_server_status_error() -> None: @@ -143,7 +144,7 @@ async def test_unknown_5xx_falls_back_to_server_status_error() -> None: finally: await transport.aclose() assert type(info.value) is ServerStatusError - assert info.value.status == 504 # noqa: PLR2004 + assert info.value.status == HTTPStatus.GATEWAY_TIMEOUT # ----- (e) _try_decode_json branches ---------------------------------------- From bb567f0dcbf74933f48e3a81125f4b7b70d13386 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 3 Jun 2026 00:01:50 +0300 Subject: [PATCH 4/7] refactor: use HTTPStatus constants in transport status dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces 400 → HTTPStatus.BAD_REQUEST and 500 → HTTPStatus.INTERNAL_SERVER_ERROR in the 4xx/5xx exception dispatch block. The < 600 synthetic upper bound has no stdlib equivalent, so its PLR2004 noqa stays — now with an inline justification. Closes deferred-work entry: "PLR2004 per-file-ignores" (for status codes; non-status instances remain open). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/httpware/transports/httpx2.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/httpware/transports/httpx2.py b/src/httpware/transports/httpx2.py index 1ab32a0..e272880 100644 --- a/src/httpware/transports/httpx2.py +++ b/src/httpware/transports/httpx2.py @@ -10,6 +10,7 @@ import json import time from contextlib import AbstractAsyncContextManager +from http import HTTPStatus from typing import Any import httpx2 @@ -141,10 +142,10 @@ async def __call__(self, request: Request) -> Response: # to the last value — see class docstring; widens with the # multi-valued header contract in a later story. headers = dict(resp.headers) - if 400 <= status < 600: # noqa: PLR2004 + if HTTPStatus.BAD_REQUEST <= status < 600: # noqa: PLR2004 — 600 is the synthetic 5xx upper bound exc_class = STATUS_TO_EXCEPTION.get( status, - ClientStatusError if status < 500 else ServerStatusError, # noqa: PLR2004 + ClientStatusError if status < HTTPStatus.INTERNAL_SERVER_ERROR else ServerStatusError, ) raise exc_class( status=status, From 60789e380e01a319cf8fdb71dcaf1a49c094d732 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 3 Jun 2026 00:06:01 +0300 Subject: [PATCH 5/7] fix: Response.json() honors declared charset Routes the body through self.text instead of json.loads(self.content), so a declared charset (e.g. iso-8859-1) is respected before JSON parsing. ASCII / UTF-8 bodies are unchanged. Docstring now explicitly documents the json.JSONDecodeError raise contract. Wrapping JSONDecodeError in a domain exception is left to a future response-API revision. Closes deferred-work entries: "Response.json() raises raw and ignores charset" (retro) and "Response.json() honor declared charset" (1-2). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/httpware/response.py | 9 +++++++-- tests/test_response.py | 12 ++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/httpware/response.py b/src/httpware/response.py index 64ce2e0..6c9069b 100644 --- a/src/httpware/response.py +++ b/src/httpware/response.py @@ -48,8 +48,13 @@ def text(self) -> str: return self.content.decode("utf-8") def json(self) -> Any: # noqa: ANN401 - """Parse `content` as JSON.""" - return json.loads(self.content) + """Parse `content` as JSON using the declared charset (default UTF-8). + + Raises: + json.JSONDecodeError: if the body is not valid JSON. + + """ + return json.loads(self.text) def with_headers(self, headers: Mapping[str, str]) -> Self: """Return a copy with the given headers merged in (incoming keys override existing).""" diff --git a/tests/test_response.py b/tests/test_response.py index 83bb776..192a711 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -84,6 +84,18 @@ def test_response_json_parses_body() -> None: assert resp.json() == {"a": 1, "b": [2, 3]} +def test_response_json_uses_declared_charset() -> None: + body = '{"name": "café"}'.encode("iso-8859-1") + resp = Response( + status=HTTPStatus.OK, + headers={"content-type": "application/json; charset=iso-8859-1"}, + content=body, + url="/", + elapsed=0.0, + ) + assert resp.json() == {"name": "café"} + + def test_response_equality_on_identical_fields() -> None: r1 = Response(status=200, headers={"a": "1"}, content=b"x", url="/", elapsed=0.5) r2 = Response(status=200, headers={"a": "1"}, content=b"x", url="/", elapsed=0.5) From ded740dfab32ca9d69db2634a05cbb5af4ee889b Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 3 Jun 2026 00:12:00 +0300 Subject: [PATCH 6/7] docs: close deferred-work entries resolved by hygiene tidy PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes four entries closed by this PR (just publish guard, uv_build band, Response.json() charset+raise — last one was duplicated across the retro section and the original Story 1-2 review). Rewords the PLR2004 entry to document what was actually done (status codes migrated to http.HTTPStatus) and what remains open (~11 non- status noqas on counts and primitive values). Also fixes pre-existing missing trailing newline. Co-Authored-By: Claude Opus 4.7 (1M context) --- planning/deferred-work.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/planning/deferred-work.md b/planning/deferred-work.md index c605912..a01de17 100644 --- a/planning/deferred-work.md +++ b/planning/deferred-work.md @@ -7,12 +7,11 @@ Items raised in reviews that are real but not actionable now. - **`PydanticDecoder.decode` `TypeError` fallback is unreachable through normal usage** — the `except TypeError` branch only triggers when `_get_adapter(model)` raises during `lru_cache` lookup, which requires `model` to be unhashable; every concrete `type[T]` is hashable. The branch is now deliberately exercised via mock at `tests/test_decoders_pydantic.py:141-156`, so the contract is pinned even though no production caller hits it. (`src/httpware/decoders/pydantic.py:22-26`) - **`_get_adapter` `lru_cache` is module-global, not per-decoder instance** — keyed by `model` only; two `PydanticDecoder()` instances with different configurations (none today) would share adapters, and the cache survives across tests unless explicitly cleared. Revisit if/when a configurable `PydanticDecoder(mode=..., strict=...)` lands. (`src/httpware/decoders/pydantic.py:12-14`) - **`extensions=dict(request.extensions)` forwards opaque payloads to httpx2 verbatim** — `httpx2` interprets specific keys (e.g. `timeout`, `sni_hostname`); a typo or unknown key silently bypasses our timeout/limits config. The seam now has a real user: `AsyncClient._build_request` writes `extensions["timeout"]` (`src/httpware/client.py:140-142`). Epic 3 timeout middleware will own the extensions contract; introducing an allowlist now risks blocking legitimate forward-compat uses. (`src/httpware/transports/httpx2.py:121`) -- **`Response.json()` raises raw `JSONDecodeError` and ignores declared charset** — `json.loads(self.content)` propagates `json.JSONDecodeError` to callers and ignores any declared charset (`text` honors it); inconsistent with `_try_decode_json` in the transport which never raises. `AsyncClient` doesn't call `.json()` (it goes through `decoder.decode`), but end users do. Two-line fix; bundle with the next response-API touch. (`src/httpware/response.py:50-52`) ## Deferred from: code review of story-1-5 (2026-05-14) - **Empty/malformed payload tests** — `b""`, `b"null"`, `b"{}"`, invalid UTF-8: current pydantic-core behavior is correct but unpinned; a future pydantic upgrade could change error types undetected. (`tests/test_decoders_pydantic.py`) -- **`PLR2004` per-file-ignores** — `# noqa: PLR2004` repeated 5× in this test file; idiomatic fix is `tool.ruff.lint.per-file-ignores` for `tests/*`. Project-wide lint-config tidy. (`tests/test_decoders_pydantic.py:63,67,83,107,153`) +- **`PLR2004` noqas on non-status-code literals** — status-code instances were migrated to `http.HTTPStatus` constants (no noqa needed). 13 instances remain on counts, list lengths, primitive-decode assertions, `elapsed` floats, and intentionally-invalid status values across `tests/test_decoders_pydantic.py`, `tests/test_decoders_msgspec.py`, `tests/test_client_methods.py`, `tests/test_client_lifecycle.py`, `tests/test_internal_auth.py`, `tests/test_middleware.py`, `tests/test_response.py`, and `tests/test_transports_recorded.py`. No stdlib constant exists for "I made two calls in this test"; either accept the bare noqas or add per-line justifications. Per the user's lint-suppression hierarchy, `per-file-ignores` is the *least-preferred* form and should not be used. ## Deferred from: code review of story-1-4 (2026-05-14) @@ -41,7 +40,5 @@ Items raised in reviews that are real but not actionable now. ## Deferred from: code review of story-1-1 (2026-05-13) -- **`just publish` lacks env-var validation** — recipe assumes `GITHUB_REF_NAME` and `PYPI_TOKEN` are set; running locally could corrupt the version. Add `test -n "$GITHUB_REF_NAME"` guard before release work. (`Justfile:25-29`) -- **`uv_build>=0.11,<0.12` narrow window** — single-minor band will expire as soon as uv_build 0.12 ships; bump when that happens. (`pyproject.toml:49`) - **Unpinned `ruff`/`ty` with `select=["ALL"]`** — any new ruff release adds rules and can break CI overnight. Pin major versions or pin specific rules when a regression occurs. (`pyproject.toml` `[dependency-groups] lint`, `[tool.ruff.lint] select`) -- **No `[test]` extra; CI installs all extras** — `just install` runs `uv sync --all-extras --group lint`, so every CI run pulls msgspec/otel/niquests even though most tests don't need them. Declare a `test` extra (or move test-only deps into a dedicated dependency-group) and switch CI to the narrower install. Mild YAGNI today; revisit when extras grow heavier. (`pyproject.toml` `[project.optional-dependencies]`, `Justfile:install`) \ No newline at end of file +- **No `[test]` extra; CI installs all extras** — `just install` runs `uv sync --all-extras --group lint`, so every CI run pulls msgspec/otel/niquests even though most tests don't need them. Declare a `test` extra (or move test-only deps into a dedicated dependency-group) and switch CI to the narrower install. Mild YAGNI today; revisit when extras grow heavier. (`pyproject.toml` `[project.optional-dependencies]`, `Justfile:install`) From 5593f21cf82b21a31220a6f25b384260b652c723 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 3 Jun 2026 00:45:17 +0300 Subject: [PATCH 7/7] docs: drop two non-actionable deferred-work entries - PydanticDecoder.decode TypeError fallback: branch is deliberately exercised via mock in tests; contract is pinned, no action pending. Steady state, not deferred work. - PLR2004 noqas on non-status-code literals: decision made in spec discussion (bare noqas or per-line justifications acceptable; per-file-ignores rejected per user's lint-suppression hierarchy). Documenting current state, not deferred work. Co-Authored-By: Claude Opus 4.7 (1M context) --- planning/deferred-work.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/planning/deferred-work.md b/planning/deferred-work.md index a01de17..0a74b00 100644 --- a/planning/deferred-work.md +++ b/planning/deferred-work.md @@ -4,14 +4,12 @@ Items raised in reviews that are real but not actionable now. ## Deferred from: retrospective review of stories 1-1 through 1-5 (2026-05-31) -- **`PydanticDecoder.decode` `TypeError` fallback is unreachable through normal usage** — the `except TypeError` branch only triggers when `_get_adapter(model)` raises during `lru_cache` lookup, which requires `model` to be unhashable; every concrete `type[T]` is hashable. The branch is now deliberately exercised via mock at `tests/test_decoders_pydantic.py:141-156`, so the contract is pinned even though no production caller hits it. (`src/httpware/decoders/pydantic.py:22-26`) - **`_get_adapter` `lru_cache` is module-global, not per-decoder instance** — keyed by `model` only; two `PydanticDecoder()` instances with different configurations (none today) would share adapters, and the cache survives across tests unless explicitly cleared. Revisit if/when a configurable `PydanticDecoder(mode=..., strict=...)` lands. (`src/httpware/decoders/pydantic.py:12-14`) - **`extensions=dict(request.extensions)` forwards opaque payloads to httpx2 verbatim** — `httpx2` interprets specific keys (e.g. `timeout`, `sni_hostname`); a typo or unknown key silently bypasses our timeout/limits config. The seam now has a real user: `AsyncClient._build_request` writes `extensions["timeout"]` (`src/httpware/client.py:140-142`). Epic 3 timeout middleware will own the extensions contract; introducing an allowlist now risks blocking legitimate forward-compat uses. (`src/httpware/transports/httpx2.py:121`) ## Deferred from: code review of story-1-5 (2026-05-14) - **Empty/malformed payload tests** — `b""`, `b"null"`, `b"{}"`, invalid UTF-8: current pydantic-core behavior is correct but unpinned; a future pydantic upgrade could change error types undetected. (`tests/test_decoders_pydantic.py`) -- **`PLR2004` noqas on non-status-code literals** — status-code instances were migrated to `http.HTTPStatus` constants (no noqa needed). 13 instances remain on counts, list lengths, primitive-decode assertions, `elapsed` floats, and intentionally-invalid status values across `tests/test_decoders_pydantic.py`, `tests/test_decoders_msgspec.py`, `tests/test_client_methods.py`, `tests/test_client_lifecycle.py`, `tests/test_internal_auth.py`, `tests/test_middleware.py`, `tests/test_response.py`, and `tests/test_transports_recorded.py`. No stdlib constant exists for "I made two calls in this test"; either accept the bare noqas or add per-line justifications. Per the user's lint-suppression hierarchy, `per-file-ignores` is the *least-preferred* form and should not be used. ## Deferred from: code review of story-1-4 (2026-05-14)