Skip to content

Commit 805fcd0

Browse files
authored
Merge pull request #2 from modern-python/review/stories-1-1-and-1-2-patches
Review/stories 1 1 and 1 2 patches
2 parents 256075c + 02d4b54 commit 805fcd0

10 files changed

Lines changed: 122 additions & 17 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ jobs:
4242
cache-dependency-glob: "**/pyproject.toml"
4343
- run: uv python install ${{ matrix.python-version }}
4444
- run: just install
45-
- run: just test --cov=src/httpware --cov-report xml
45+
- run: just test --cov-report xml
4646
- uses: codecov/codecov-action@v4.0.1
4747
env:
4848
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
1010

1111
- Initial project scaffold: `src/httpware/` package, `py.typed` marker, `pyproject.toml` with `uv_build` backend.
1212
- Org conventions ported from `modern-python/modern-di`: `Justfile`, `.github/workflows/ci.yml`, `[tool.ruff]` config, `[tool.pytest.ini_options]`, dev and lint dep groups.
13-
- Declared dependencies: `httpx2>=2.0.0b1,<3.0`, `pydantic>=2.0,<3.0`.
13+
- Declared dependencies: `httpx2>=2.0.0,<3.0`, `pydantic>=2.0,<3.0`.
1414
- Declared install extras: `[msgspec]`, `[otel]`, `[niquests]`, `[all]`.
1515
- `SECURITY.md` with 90-day private-disclosure window.
1616
- `CONTRIBUTING.md` with development workflow.
1717
- `CLAUDE.md` with AI-agent guidance.
1818
- Core data types: `Request`, `Response`, `Limits`, `Timeout`, `ClientConfig` — frozen+slotted dataclasses with `with_*` immutability helpers on `Request` and computed `text`/`json()` accessors on `Response` (Story 1.2).
1919

20-
[Unreleased]: https://github.com/modern-python/httpware/compare/HEAD...HEAD
20+
[Unreleased]: https://github.com/modern-python/httpware/commits/main

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Optional extras:
2222
```bash
2323
pip install httpware[msgspec] # msgspec ResponseDecoder
2424
pip install httpware[otel] # OpenTelemetry instrumentation
25+
pip install httpware[niquests] # niquests transport
2526
pip install httpware[all] # all of the above
2627
```
2728

docs/deferred-work.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Deferred Work
2+
3+
Items raised in reviews that are real but not actionable now.
4+
5+
## Deferred from: code review of story-1-2 (2026-05-13)
6+
7+
- **Charset parser robustness** — quoted whitespace, mismatched quotes, multi-`charset=` directives, substring false-positives (e.g. `boundary` containing `charset=`). (`src/httpware/response.py:21-26`)
8+
- **Header name/value validation**`with_header` accepts CR/LF (injection), `None`, empty string. Lands with header-handling story (2.3 or later). (`src/httpware/request.py:21-23`)
9+
- **URL validation**`with_url("")` accepts empty; `base_url` has no trailing-slash normalization. (`src/httpware/request.py:25-27`, `src/httpware/config.py:27-33`)
10+
- **`with_query(None)` handling** — currently accepted and breaks downstream iteration. (`src/httpware/request.py:33-35`)
11+
- **`Timeout` / `Limits` negative-value validation** — no `__post_init__` guard; nonsensical values silently accepted. (`src/httpware/config.py:10-22`)
12+
- **Multi-valued query params**`Mapping[str, str]` cannot express `?tag=a&tag=b`. Type widening needed. (`src/httpware/request.py:8`)
13+
- **Streaming / async-iterable request bodies**`body: bytes | None` only. Revisit in transport stories. (`src/httpware/request.py:11`)
14+
- **`with_headers` / `with_cookie` / `with_extension` merge helpers** — only `with_header` (single) and `with_query` (replace) exist. Story 2.3 will fill this in. (`src/httpware/request.py:20-35`)
15+
- **`Response.json()` honor declared charset**`json.loads(bytes)` auto-detects only UTF-8/16/32. Real APIs vary. (`src/httpware/response.py:44-45`)
16+
- **`@final` to prevent subclassing** — frozen+slots subclassing is fragile. No current subclasser; defer until needed. (`src/httpware/request.py`, `response.py`, `config.py`)
17+
18+
## Deferred from: code review of story-1-1 (2026-05-13)
19+
20+
- **Codecov upload fails on fork PRs** — fork PRs cannot access `CODECOV_TOKEN`; matches modern-di pattern, accepted tradeoff. (`.github/workflows/ci.yml:46-52`)
21+
- **`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`)
22+
- **`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:54`)
23+
- **Python 3.14 wheel availability risk**`httpx2` / `pydantic` / `uv_build` may not have 3.14 wheels yet, breaking the matrix entry. Watch CI red on 3.14. (`.github/workflows/ci.yml:30-33`)
24+
- **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:70-72, 84-85`)
25+
- **No `[test]` extra; CI uses `--all-extras`** — future heavy extras will be installed in every CI run. Declare a `test` extra and switch CI to `--extra test`. (`pyproject.toml:35-47`)

docs/stories/1-1-project-scaffold-and-tooling.md

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ story_key: 1-1-project-scaffold-and-tooling
33
epic: 1
44
story: 1
55
title: Project scaffold and tooling
6-
status: review
6+
status: done
77
created: 2026-05-12
88
completed: 2026-05-13
99
input_documents:
@@ -168,7 +168,7 @@ input_documents:
168168

169169
## File List
170170

171-
Files added (15 total):
171+
Files added (14 total):
172172

173173
- `.github/workflows/ci.yml` — CI workflow (ruff/ty/pytest, Python 3.11-3.14 matrix)
174174
- `.gitignore` — modern-di convention + project-specific ignores
@@ -201,4 +201,25 @@ Generated/transient (not committed):
201201

202202
## Status
203203

204-
`review`
204+
`done`
205+
206+
### Review Findings
207+
208+
_Code review run: 2026-05-13. Reviewers: Blind Hunter, Edge Case Hunter, Acceptance Auditor. 6 patches applied, 3 dismissed by maintainer (not errors), 6 deferred, 20 dismissed as noise (2 decision-needed items were resolved → dismissed: `.gitignore plan.md` blacklist is intentional convention; AC6 lint-on-single-version matches modern-di canon and is accepted)._
209+
210+
- [x] [Review][Patch] CHANGELOG declared `httpx2>=2.0.0b1,<3.0` while pyproject shipped `>=2.0.0,<3.0`. [`CHANGELOG.md:13`] — applied
211+
- [x] [Review][Patch] CHANGELOG `[Unreleased]` link was `compare/HEAD...HEAD` — replaced with `commits/main` until first tag. [`CHANGELOG.md:20`] — applied
212+
- [x] [Review][Dismissed] `version = "0"` placeholder. [`pyproject.toml:29`] — dismissed by maintainer, not an error
213+
- [x] [Review][Patch] `[all]` extra refactored to self-reference siblings: `all = ["httpware[msgspec,otel,niquests]"]`. [`pyproject.toml:42-47`] — applied
214+
- [x] [Review][Patch] README "Optional extras" snippet — added `pip install httpware[niquests]`. [`README.md:22-26`] — applied
215+
- [x] [Review][Dismissed] CI `timeout-minutes`. [`.github/workflows/ci.yml:14, 26`] — dismissed by maintainer, not an error
216+
- [x] [Review][Dismissed] CI explicit `permissions:` block. [`.github/workflows/ci.yml:1-12`] — dismissed by maintainer, not an error
217+
- [x] [Review][Patch] Duplicate `--cov` flag — dropped `--cov=src/httpware` from CI invocation; `addopts` is now the single source of `--cov` source. [`.github/workflows/ci.yml:45`] — applied
218+
- [x] [Review][Patch] File List header `15 total``14 total`. [`docs/stories/1-1-project-scaffold-and-tooling.md:171`] — applied
219+
220+
- [x] [Review][Defer] Codecov upload fails on fork PRs without `CODECOV_TOKEN`; matches modern-di pattern, accepted tradeoff. [`.github/workflows/ci.yml:46-52`] — deferred, pre-existing
221+
- [x] [Review][Defer] `just publish` does not validate `GITHUB_REF_NAME` / `PYPI_TOKEN`; local invocation could corrupt the version. [`Justfile:25-29`] — deferred, release-flow hygiene
222+
- [x] [Review][Defer] `uv_build>=0.11,<0.12` is a one-minor-version window that will expire fast. [`pyproject.toml:54`] — deferred, will bump on release
223+
- [x] [Review][Defer] Python 3.14 in CI matrix; httpx2 / pydantic / uv_build wheels may not yet exist on 3.14, causing the matrix entry to fail. [`.github/workflows/ci.yml:30-33`] — deferred, wait-and-see
224+
- [x] [Review][Defer] `[tool.ruff.lint] select = ["ALL"]` paired with unpinned `ruff`/`ty` — any new ruff release adds rules and breaks CI overnight. [`pyproject.toml:70-72, 84-85`] — deferred, matches modern-di
225+
- [x] [Review][Defer] No `[test]` extra declared; CI relies on `--all-extras`, so any future heavy extra is pulled into every CI run. [`pyproject.toml:35-47`] — deferred, scope creep concern

docs/stories/1-2-core-data-types.md

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ story_key: 1-2-core-data-types
33
epic: 1
44
story: 2
55
title: Core data types
6-
status: ready-for-dev
6+
status: done
77
created: 2026-05-13
88
input_documents:
99
- docs/prd.md
@@ -197,7 +197,7 @@ Followed TDD per module: write failing tests → confirm collection failure (RED
197197
- **AC5**`Timeout`, `Limits`, `ClientConfig` in `src/httpware/config.py` are all `@dataclass(frozen=True, slots=True)`. Defaults exactly match the spec. `ClientConfig.timeout` and `.limits` use `field(default_factory=Timeout)` / `field(default_factory=Limits)` — never bare instances. `Redactor` is intentionally not present (Story 5.3).
198198
- **AC6**`src/httpware/__init__.py` re-exports all five via explicit absolute imports; `__all__` is the sorted list `["ClientConfig", "Limits", "Request", "Response", "Timeout"]`. Smoke import succeeds.
199199
- **AC7**`just lint-ci` reports zero diagnostics across `eof-fixer`, `ruff format --check`, `ruff check --no-fix`, `ty check`.
200-
- **AC8** ✓ 24 tests cover every documented case: frozen on all 4 dataclasses, `with_*` immutability + new-instance identity + replace-semantics on `with_header`, equality, independent default mappings, charset case-insensitivity (parametrized over three casings), UTF-8 / latin-1 / missing-charset paths, JSON round-trip, default constructor returns documented defaults. `pytest` exits 0; coverage 100%.
200+
- **AC8** ✓ 24 tests cover every documented case: frozen on all 5 dataclasses, `with_*` immutability + new-instance identity + replace-semantics on `with_header`, equality, independent default mappings, charset case-insensitivity (parametrized over three casings), UTF-8 / latin-1 / missing-charset paths, JSON round-trip, default constructor returns documented defaults. `pytest` exits 0; coverage 100%.
201201

202202
**Deviations from the story spec (worth flagging for reviewer):**
203203

@@ -229,4 +229,26 @@ Followed TDD per module: write failing tests → confirm collection failure (RED
229229

230230
## Status
231231

232-
`review`
232+
`done`
233+
234+
### Review Findings
235+
236+
_Code review run: 2026-05-13. Reviewers: Blind Hunter, Edge Case Hunter, Acceptance Auditor. Acceptance Auditor reported **no AC violations**. 5 patches applied, 11 deferred, 36 dismissed as noise. Post-patch verification: `just lint-ci` clean, 27 tests pass (+3 new), 100% coverage retained._
237+
238+
- [x] [Review][Patch] `Response.text` now wraps `bytes.decode` in `try/except LookupError` and falls back to UTF-8 when the declared charset is unknown. [`src/httpware/response.py:42-46`] — applied
239+
- [x] [Review][Patch] `Response` class docstring now documents `elapsed` as wall-clock seconds from request send to response receipt. [`src/httpware/response.py:29-32`] — applied
240+
- [x] [Review][Patch] Added `test_response_text_strips_quotes_around_charset` (parametrized over `"latin-1"` and `'latin-1'`) covering the quote-stripping branch. [`tests/test_response.py`] — applied
241+
- [x] [Review][Patch] Equality tests now also assert `!=` against per-field variants (Request and Response). [`tests/test_request.py`, `tests/test_response.py`] — applied
242+
- [x] [Review][Patch] Completion Notes AC8 — "frozen on all 4 dataclasses" → "all 5". [`docs/stories/1-2-core-data-types.md:200`] — applied
243+
244+
- [x] [Review][Defer] Charset parser robustness — whitespace inside quotes, mismatched quotes, multiple `charset=` directives, `charset=` substring inside a quoted boundary param. [`src/httpware/response.py:21-26`] — deferred, pragmatic v0; revisit when transport tests reveal real-world breakage
245+
- [x] [Review][Defer] Header name/value validation (CR/LF injection, `None`, empty string) on `Request.with_header`. [`src/httpware/request.py:21-23`] — deferred to header-handling story (2.3 or later)
246+
- [x] [Review][Defer] URL validation — `with_url("")` accepts empty, `base_url` has no trailing-slash normalization. [`src/httpware/request.py:25-27`, `src/httpware/config.py:27-33`] — deferred, lands when transport composes URLs
247+
- [x] [Review][Defer] `with_query(None)` is currently accepted and breaks downstream iteration. [`src/httpware/request.py:33-35`] — deferred, type system already says `Mapping[str, str]`
248+
- [x] [Review][Defer] `Timeout` / `Limits` accept negative or zero values silently (no `__post_init__`). [`src/httpware/config.py:10-22`] — deferred, validation lands with transport integration
249+
- [x] [Review][Defer] `params: Mapping[str, str]` cannot express multi-valued query strings (`?tag=a&tag=b`). [`src/httpware/request.py:8`] — deferred, type widening tracked separately
250+
- [x] [Review][Defer] `body: bytes | None` precludes streaming / async-iterable bodies. [`src/httpware/request.py:11`] — deferred, intentional minimal scope; revisit in transport stories
251+
- [x] [Review][Defer] No `with_headers` / `with_cookie` / `with_extension` merge helpers; only `with_header` (single) and `with_query` (replace). [`src/httpware/request.py:20-35`] — deferred to Story 2.3 (merge/case-insensitive helpers)
252+
- [x] [Review][Defer] `Response.json()` ignores declared charset; `json.loads(bytes)` auto-detects only UTF-8/16/32 by BOM. [`src/httpware/response.py:44-45`] — deferred, most APIs are UTF-8; revisit if a real backend breaks
253+
- [x] [Review][Defer] `Request.body` / `Response.content` (bytes) render verbatim in the default dataclass `__repr__`, leaking large payloads / secrets into logs. [`src/httpware/request.py`, `src/httpware/response.py`] — deferred to Story 5.3 (`Redactor`)
254+
- [x] [Review][Defer] No `@final` decorator — frozen+slots subclassing is fragile and the spec doesn't bless it; an explicit `@final` would prevent the footgun. [`src/httpware/request.py`, `src/httpware/response.py`, `src/httpware/config.py`] — deferred, no current subclasser

pyproject.toml

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,7 @@ otel = [
3939
"opentelemetry-sdk>=1.20",
4040
]
4141
niquests = ["niquests>=3.18"]
42-
all = [
43-
"msgspec>=0.18",
44-
"opentelemetry-api>=1.20",
45-
"opentelemetry-sdk>=1.20",
46-
"niquests>=3.18",
47-
]
42+
all = ["httpware[msgspec,otel,niquests]"]
4843

4944
[project.urls]
5045
repository = "https://github.com/modern-python/httpware"

src/httpware/response.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ def _parse_charset(content_type: str) -> str | None:
2626

2727
@dataclass(frozen=True, slots=True)
2828
class Response:
29-
"""Immutable HTTP response value type."""
29+
"""Immutable HTTP response value type.
30+
31+
`elapsed` is wall-clock seconds from request send to response receipt.
32+
"""
3033

3134
status: int
3235
headers: Mapping[str, str]
@@ -38,7 +41,10 @@ class Response:
3841
def text(self) -> str:
3942
"""Decode `content` using the response's declared charset (default UTF-8)."""
4043
charset = _parse_charset(_get_content_type(self.headers)) or "utf-8"
41-
return self.content.decode(charset)
44+
try:
45+
return self.content.decode(charset)
46+
except LookupError:
47+
return self.content.decode("utf-8")
4248

4349
def json(self) -> Any: # noqa: ANN401
4450
"""Parse `content` as JSON."""

tests/test_request.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ def test_request_equality_on_identical_fields() -> None:
2828
r1 = Request(method="GET", url="/x", headers={"a": "1"})
2929
r2 = Request(method="GET", url="/x", headers={"a": "1"})
3030
assert r1 == r2
31+
assert r1 != Request(method="POST", url="/x", headers={"a": "1"})
32+
assert r1 != Request(method="GET", url="/y", headers={"a": "1"})
33+
assert r1 != Request(method="GET", url="/x", headers={"a": "2"})
3134

3235

3336
def test_with_header_adds_when_absent() -> None:

tests/test_response.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,36 @@ def test_response_text_falls_back_to_utf8_on_missing_charset() -> None:
4848
assert resp.text == '{"x": 1}'
4949

5050

51+
@pytest.mark.parametrize(
52+
"content_type",
53+
[
54+
'text/plain; charset="latin-1"',
55+
"text/plain; charset='latin-1'",
56+
],
57+
)
58+
def test_response_text_strips_quotes_around_charset(content_type: str) -> None:
59+
body = "café".encode("latin-1")
60+
resp = Response(
61+
status=200,
62+
headers={"content-type": content_type},
63+
content=body,
64+
url="/",
65+
elapsed=0.0,
66+
)
67+
assert resp.text == "café"
68+
69+
70+
def test_response_text_falls_back_to_utf8_on_unknown_charset() -> None:
71+
resp = Response(
72+
status=200,
73+
headers={"content-type": "text/plain; charset=not-a-real-codec"},
74+
content=b"hello",
75+
url="/",
76+
elapsed=0.0,
77+
)
78+
assert resp.text == "hello"
79+
80+
5181
def test_response_json_parses_body() -> None:
5282
resp = Response(status=200, headers={}, content=b'{"a": 1, "b": [2, 3]}', url="/", elapsed=0.0)
5383
assert resp.json() == {"a": 1, "b": [2, 3]}
@@ -57,3 +87,5 @@ def test_response_equality_on_identical_fields() -> None:
5787
r1 = Response(status=200, headers={"a": "1"}, content=b"x", url="/", elapsed=0.5)
5888
r2 = Response(status=200, headers={"a": "1"}, content=b"x", url="/", elapsed=0.5)
5989
assert r1 == r2
90+
assert r1 != Response(status=200, headers={"a": "1"}, content=b"x", url="/", elapsed=0.6)
91+
assert r1 != Response(status=201, headers={"a": "1"}, content=b"x", url="/", elapsed=0.5)

0 commit comments

Comments
 (0)