diff --git a/CHANGELOG.md b/CHANGELOG.md index 37f8ab8..a4b0ef2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,5 +22,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `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). +- `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). [Unreleased]: https://github.com/modern-python/httpware/commits/main diff --git a/docs/superpowers/plans/2026-05-31-msgspec-decoder-via-extras-plan.md b/docs/superpowers/plans/2026-05-31-msgspec-decoder-via-extras-plan.md new file mode 100644 index 0000000..2330afe --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-msgspec-decoder-via-extras-plan.md @@ -0,0 +1,471 @@ +# msgspec decoder via extras 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-6: a `MsgspecDecoder` adapter at `src/httpware/decoders/msgspec.py` backed by `msgspec.json.decode`, gated behind the `[msgspec]` extra via a new `find_spec`-based `import_checker` module borrowed from lite-bootstrap. + +**Architecture:** Two new source modules (an `import_checker` and the decoder), two new test files (decoder behavior + subprocess-based import isolation), and one CHANGELOG entry. No package-root re-export — the decoder honors seam #5 by requiring the explicit `from httpware.decoders.msgspec import MsgspecDecoder` path. + +**Tech Stack:** Python 3.11 floor; `msgspec>=0.18` via the `[msgspec]` extra already declared in `pyproject.toml`; `importlib.util.find_spec` for extra detection. + +**Branch:** `story/1-6-msgspec-decoder-via-extras` (already created; spec commit `b12a989` is on it). + +**Spec:** `docs/superpowers/specs/2026-05-31-msgspec-decoder-via-extras-design.md`. + +--- + +## File Structure + +**New files:** +- `src/httpware/_internal/import_checker.py` — `find_spec`-based detection flags. Initial content: `is_msgspec_installed`. +- `src/httpware/decoders/msgspec.py` — `MsgspecDecoder` class plus `MISSING_DEPENDENCY_MESSAGE` constant. +- `tests/test_decoders_msgspec.py` — 8 behavioral tests for the decoder. +- `tests/test_optional_extras_isolation.py` — subprocess-based test that `import httpware` does not load `msgspec`. + +**Modified files:** +- `CHANGELOG.md` — append Story 1.6 bullet under `[Unreleased]` / `### Added`. + +**Files NOT touched:** +- `pyproject.toml` — `msgspec = ["msgspec>=0.18"]` is already declared from Story 1-1. +- `src/httpware/__init__.py` — no package-root re-export (seam contract). +- `src/httpware/decoders/__init__.py` — `ResponseDecoder` Protocol stays as-is. + +--- + +## Task 1: `_internal/import_checker.py` + +Create the find_spec-based detection module. One line of state, no behavior to TDD — but include a quick sanity test that the flag is True in the test environment (where `msgspec` is installed). + +**Files:** +- Create: `src/httpware/_internal/import_checker.py` + +- [ ] **Step 1: Create the module** + +Create `src/httpware/_internal/import_checker.py`: + +```python +"""Detect optional extras without importing them. Used by adapter modules to gate hard imports.""" + +from importlib.util import find_spec + + +is_msgspec_installed = find_spec("msgspec") is not None +``` + +No `__all__` (project convention — see memory `user-no-all-in-submodules`). + +- [ ] **Step 2: Sanity-check the flag in a Python REPL** + +Run: `uv run python -c "from httpware._internal import import_checker; print(import_checker.is_msgspec_installed)"` +Expected: `True` (msgspec is installed in the dev environment via `--all-extras`). + +- [ ] **Step 3: Lint and ty** + +Run: `uv run ruff check src/httpware/_internal/import_checker.py` +Run: `uv run ty check src/httpware/_internal/import_checker.py` +Expected: both clean. + +- [ ] **Step 4: Commit** + +```bash +git add src/httpware/_internal/import_checker.py +git commit -m "$(cat <<'EOF' +feat(story-1.6): _internal/import_checker.py for find_spec-based extra detection + +Adds is_msgspec_installed flag computed once at module import time via +importlib.util.find_spec. No actual import of msgspec happens — only +the importlib check. Future opt-in extras (otel in Story 5-4, etc.) +extend this module with their own flags. + +Pattern adapted from modern-python/lite-bootstrap. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: `MsgspecDecoder` + 8 decoder tests + +TDD the decoder. Start with the protocol-satisfaction test, then the happy-path decode, then add error and construction-failure tests. + +**Files:** +- Create: `src/httpware/decoders/msgspec.py` +- Create: `tests/test_decoders_msgspec.py` + +- [ ] **Step 1: Add the first failing test (protocol satisfaction)** + +Create `tests/test_decoders_msgspec.py`: + +```python +"""Unit tests for httpware.decoders.msgspec.MsgspecDecoder.""" + +import msgspec +import pytest +from pydantic import BaseModel + +from httpware._internal import import_checker +from httpware.decoders import ResponseDecoder +from httpware.decoders.msgspec import MISSING_DEPENDENCY_MESSAGE, MsgspecDecoder + + +class _Item(msgspec.Struct): + name: str + qty: int + + +class _ItemModel(BaseModel): + name: str + qty: int + + +def test_decoder_satisfies_response_decoder_protocol() -> None: + assert isinstance(MsgspecDecoder(), ResponseDecoder) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_decoders_msgspec.py::test_decoder_satisfies_response_decoder_protocol -v` +Expected: `ModuleNotFoundError: No module named 'httpware.decoders.msgspec'`. + +- [ ] **Step 3: Implement the decoder module** + +Create `src/httpware/decoders/msgspec.py`: + +```python +"""MsgspecDecoder — opt-in ResponseDecoder backed by msgspec.json.decode.""" + +from typing import TypeVar + +from httpware._internal import import_checker + + +if import_checker.is_msgspec_installed: + import msgspec + + +MISSING_DEPENDENCY_MESSAGE = ( + "MsgspecDecoder requires the 'msgspec' extra. " + "Install with: pip install httpware[msgspec]" +) + +T = TypeVar("T") + + +class MsgspecDecoder: + """Decode raw response bytes via `msgspec.json.decode(content, type=model)`. + + Requires the `msgspec` extra: `pip install httpware[msgspec]`. Importing + this module without the extra works (the `msgspec` import is guarded by a + `find_spec` check), but instantiating the decoder raises `ImportError` with + the install hint. + """ + + def __init__(self) -> None: + if not import_checker.is_msgspec_installed: + raise ImportError(MISSING_DEPENDENCY_MESSAGE) + + def decode(self, content: bytes, model: type[T]) -> T: + """Validate `content` as JSON against `model` in a single parse pass.""" + return msgspec.json.decode(content, type=model) +``` + +No `__all__`. + +If `ty check` rejects the `msgspec.json.decode(...)` line because `msgspec` is imported inside a runtime `if` block, add `# ty: ignore[unresolved-reference]` to the `return` line with a brief comment pointing at the `import_checker` guard. Verify via Step 6 first. + +- [ ] **Step 4: Run the protocol test to verify it passes** + +Run: `uv run pytest tests/test_decoders_msgspec.py::test_decoder_satisfies_response_decoder_protocol -v` +Expected: PASS. + +- [ ] **Step 5: Add the remaining 7 tests** + +Append to `tests/test_decoders_msgspec.py`: + +```python +def test_decode_into_msgspec_struct() -> None: + result = MsgspecDecoder().decode(b'{"name":"x","qty":1}', _Item) + assert result == _Item(name="x", qty=1) + + +def test_decode_into_pydantic_model() -> None: + result = MsgspecDecoder().decode(b'{"name":"y","qty":2}', _ItemModel) + assert result == _ItemModel(name="y", qty=2) + + +def test_decode_into_builtin_type() -> None: + result = MsgspecDecoder().decode(b"42", int) + assert result == 42 # noqa: PLR2004 + + +def test_decode_into_list_of_struct() -> None: + result = MsgspecDecoder().decode(b'[{"name":"a","qty":1}]', list[_Item]) + assert result == [_Item(name="a", qty=1)] + + +def test_decode_validation_error_propagates() -> None: + with pytest.raises(msgspec.ValidationError): + MsgspecDecoder().decode(b'{"name":"x","qty":"not-an-int"}', _Item) + + +def test_decode_json_parse_error_propagates() -> None: + with pytest.raises(msgspec.DecodeError): + MsgspecDecoder().decode(b"{", _Item) + + +def test_construction_raises_without_extra_via_monkeypatch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(import_checker, "is_msgspec_installed", False) + with pytest.raises(ImportError, match="MsgspecDecoder requires the 'msgspec' extra"): + MsgspecDecoder() +``` + +- [ ] **Step 6: Run all 8 tests to verify they pass** + +Run: `uv run pytest tests/test_decoders_msgspec.py -v` +Expected: 8 passed. + +- [ ] **Step 7: Lint and ty** + +Run: `uv run ruff check src/httpware/decoders/msgspec.py tests/test_decoders_msgspec.py` +Run: `uv run ty check src/httpware/decoders/msgspec.py` +Expected: both clean. + +If ruff flags `PLR2004` (magic number) on the `42` literal beyond what `# noqa: PLR2004` already covers, add suppressions per the existing test pattern. + +- [ ] **Step 8: Commit** + +```bash +git add src/httpware/decoders/msgspec.py tests/test_decoders_msgspec.py +git commit -m "$(cat <<'EOF' +feat(story-1.6): MsgspecDecoder adapter behind [msgspec] extra + +Adds src/httpware/decoders/msgspec.py with: +- MISSING_DEPENDENCY_MESSAGE constant (module-level, not class attribute) +- MsgspecDecoder class: __init__ raises ImportError with install hint + if msgspec isn't installed; decode() calls msgspec.json.decode( + content, type=model) in a single C-level parse pass. + +The msgspec import is gated by import_checker.is_msgspec_installed, +so the module imports cleanly without the extra — only construction +fails. No __all__ (project convention). No caching (msgspec.json.decode +is a free function with no per-model adapter overhead). + +Eight tests cover: protocol satisfaction, decode-into-Struct, +decode-into-pydantic-model, decode-into-builtin, decode-into-list, +ValidationError propagation, DecodeError propagation, and the +construction-failure path via monkeypatch. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: Subprocess-based import-isolation test + +Add the subprocess test that verifies `import httpware` does NOT transitively load `msgspec`. This is the test for AC4 from the archived spec ("importing httpware without msgspec installed does not import msgspec"). Because `msgspec` IS installed in the test environment, the check must run in a fresh subprocess to see a clean `sys.modules`. + +**Files:** +- Create: `tests/test_optional_extras_isolation.py` + +- [ ] **Step 1: Create the test file** + +Create `tests/test_optional_extras_isolation.py`: + +```python +"""Verify that `import httpware` does not transitively load opt-in extras.""" + +import subprocess +import sys + + +def test_importing_httpware_does_not_import_msgspec() -> None: + """Fresh subprocess: msgspec must NOT appear in sys.modules after `import httpware`. + + msgspec IS installed in the test environment (via `--all-extras`), so this + test runs in a subprocess with a clean interpreter to verify that nothing + in the httpware import chain pulls msgspec in. + """ + result = subprocess.run( # noqa: S603 + [ + sys.executable, + "-c", + "import httpware; import sys; " + "sys.exit(0 if 'msgspec' not in sys.modules else 1)", + ], + check=False, + capture_output=True, + ) + assert result.returncode == 0, ( + "msgspec was loaded transitively by `import httpware`; " + f"stdout={result.stdout!r} stderr={result.stderr!r}" + ) +``` + +The `# noqa: S603` suppresses ruff's "subprocess without shell=False explicit" warning — the call uses a list of args, not shell=True, so it's safe; the rule is overly cautious for this case. If S603 isn't flagged, drop the noqa. + +- [ ] **Step 2: Run the test** + +Run: `uv run pytest tests/test_optional_extras_isolation.py -v` +Expected: 1 passed. + +If it fails, the subprocess output identifies what's pulling msgspec in. Likely culprit: a re-export from `src/httpware/__init__.py`. The spec explicitly forbids this. + +- [ ] **Step 3: Lint** + +Run: `uv run ruff check tests/test_optional_extras_isolation.py` +Expected: clean. + +- [ ] **Step 4: Commit** + +```bash +git add tests/test_optional_extras_isolation.py +git commit -m "$(cat <<'EOF' +test(story-1.6): subprocess-based import-isolation guard for opt-in extras + +Verifies that `import httpware` does not transitively load msgspec. msgspec +is installed in the test env (via --all-extras), so the check runs in a +fresh subprocess with a clean sys.modules. Future stories (5-4 otel) extend +this file with their own subprocess tests for each opt-in extra. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: CHANGELOG bullet + +**Files:** +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Append the bullet** + +Edit `CHANGELOG.md`. The `## [Unreleased]` / `### Added` section currently ends with the Story 2.3 bullet. Append a new bullet immediately after Story 2.3 (still before the `[Unreleased]: ...` reference link line): + +```markdown +- `MsgspecDecoder` opt-in `ResponseDecoder` adapter behind the `[msgspec]` extra; `msgspec.json.decode(content, type=model)` in a single C-level parse pass. Accepts `msgspec.Struct`, pydantic `BaseModel`, and builtin types as `model`. `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). +``` + +- [ ] **Step 2: Commit** + +```bash +git add CHANGELOG.md +git commit -m "$(cat <<'EOF' +docs(story-1.6): CHANGELOG entry for MsgspecDecoder via extras + +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: 207 passed (198 baseline post-2.3 + 8 decoder tests + 1 isolation test), 1 deselected (perf), 100% line coverage including the new `import_checker.py` and `decoders/msgspec.py`. + +If coverage is below 100% on the new modules, identify the uncovered branch. The construction-failure path is exercised by the monkeypatch test; the happy path by the decode tests. + +- [ ] **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-msgspec-decoder-via-extras-plan.md`. + +- [ ] **Step 4: Review the branch diff** + +Run: `git log --oneline main..HEAD` +Expected: five or six commits — spec (`docs(story-1.6): design...`), Task 1, Task 2, Task 3, Task 4. + +Run: `git diff --stat main..HEAD` +Expected: changes to `CHANGELOG.md`, plus the four new files: `docs/superpowers/specs/2026-05-31-msgspec-decoder-via-extras-design.md`, `src/httpware/_internal/import_checker.py`, `src/httpware/decoders/msgspec.py`, `tests/test_decoders_msgspec.py`, `tests/test_optional_extras_isolation.py`. No other source files touched. + +- [ ] **Step 5: Stage and commit the plan file** + +```bash +git add docs/superpowers/plans/2026-05-31-msgspec-decoder-via-extras-plan.md +git commit -m "docs(story-1.6): implementation plan for MsgspecDecoder via extras + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +- [ ] **Step 6: Push the branch** + +Run: `git push -u origin story/1-6-msgspec-decoder-via-extras` +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.6): MsgspecDecoder via the [msgspec] extra" --body "$(cat <<'EOF' +## Summary + +- Adds `src/httpware/decoders/msgspec.py` with `MsgspecDecoder`, the second `ResponseDecoder` adapter. Backed by `msgspec.json.decode(content, type=model)` — single C-level parse pass, no adapter caching needed (msgspec doesn't have pydantic's `TypeAdapter` overhead). +- Adds `src/httpware/_internal/import_checker.py` with `is_msgspec_installed` (`find_spec`-based detection — does NOT import msgspec). Pattern adapted from `modern-python/lite-bootstrap`. Future opt-in extras (otel in Story 5-4, etc.) extend this module. +- Module import is safe without the extra — only `MsgspecDecoder()` construction raises `ImportError` with the install hint. Enables capability-probe code. +- No package-root re-export. Honors seam #5 ("never import an extra at package top-level"). Consumers use `from httpware.decoders.msgspec import MsgspecDecoder`. +- 8 behavioral tests + 1 subprocess-based import-isolation test (in new `tests/test_optional_extras_isolation.py`, which future opt-in extras will extend). +- 207 passing total; 100% coverage on the new modules. + +Out of scope (subsequent stories): `AsyncClient` wiring (Story 1-7), `RecordedTransport` (Story 1-8), follow-up cleanup of legacy `__all__` exports in existing submodules. + +Spec + plan: `docs/superpowers/specs/2026-05-31-msgspec-decoder-via-extras-design.md`, `docs/superpowers/plans/2026-05-31-msgspec-decoder-via-extras-plan.md`. + +## Test plan + +- [x] `just test` — 207 passed, 1 deselected, 100% line coverage including the new modules. +- [x] `just lint-ci` clean. +- [x] `tests/test_no_httpx2_leakage.py` still passes. +- [x] `tests/test_optional_extras_isolation.py::test_importing_httpware_does_not_import_msgspec` passes — subprocess verifies `import httpware` does not load `msgspec`. +- [ ] 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 (`lint`, `pytest (3.11)`, `pytest (3.12)`, `pytest (3.13)`, `pytest (3.14)`) green. + +If `pytest (3.14)` fails on `codecov/codecov-action@v4.0.1` with EPIPE (transient pattern seen 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-6 is complete. Story 1-7 (`AsyncClient` with HTTP methods, `response_model`, `with_options`, lifecycle) is the next normal-flow item in Epic 1. + +--- + +## Definition of done + +- `src/httpware/_internal/import_checker.py` exists with `is_msgspec_installed`. +- `src/httpware/decoders/msgspec.py` exists with `MISSING_DEPENDENCY_MESSAGE` constant and `MsgspecDecoder` class. No `__all__`. +- `tests/test_decoders_msgspec.py` contains 8 passing tests. +- `tests/test_optional_extras_isolation.py` contains the subprocess-based import-isolation test; passes. +- `CHANGELOG.md` has a Story 1.6 bullet under `[Unreleased]` / `### Added`. +- `just test` shows 207 passed, 1 deselected, 100% line coverage. +- `just lint-ci` clean. +- `tests/test_no_httpx2_leakage.py` still passes. +- Story 1-6 lands as a single PR off `main` via the branch `story/1-6-msgspec-decoder-via-extras`. diff --git a/docs/superpowers/specs/2026-05-31-msgspec-decoder-via-extras-design.md b/docs/superpowers/specs/2026-05-31-msgspec-decoder-via-extras-design.md new file mode 100644 index 0000000..f16d177 --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-msgspec-decoder-via-extras-design.md @@ -0,0 +1,196 @@ +# msgspec decoder via extras (design) + +- **Date:** 2026-05-31 +- **Status:** approved, ready for plan +- **Scope:** Story 1-6 (sixth story of Epic 1). Adds the second `ResponseDecoder` adapter — `MsgspecDecoder` — gated behind the `msgspec` extra. Introduces a small private `import_checker` module that future opt-in extras (otel, etc.) will reuse. Out of scope: AsyncClient wiring (Story 1-7), RecordedTransport (Story 1-8). +- **Roadmap pointer:** `docs/engineering.md` §8 "Epic 1 — Make typed HTTP requests with sensible defaults". + +## Why + +Consumers with high-throughput needs want msgspec's validation speed. The `ResponseDecoder` protocol from Story 1-5 was designed for this — pluggable, single-parse-pass, model-driven. Story 1-6 ships the second adapter so when `AsyncClient` lands in 1-7, both decoders are available via `AsyncClient(decoder=PydanticDecoder())` or `AsyncClient(decoder=MsgspecDecoder())`. + +The pattern for handling the optional dependency is borrowed from `modern-python/lite-bootstrap`: a small `import_checker` module uses `importlib.util.find_spec` to detect the extra without importing it. The adapter module then conditionally imports the dependency at module load, and raises a hinted `ImportError` only when the decoder is constructed without the extra installed. This keeps the module itself import-safe (useful for capability-probe patterns) and honors seam #5 ("never import an extra at package top-level"). + +## Decisions + +| Decision | Choice | +| --- | --- | +| Import strategy | `find_spec`-based detection in `_internal/import_checker.py`; conditional `import msgspec` at the top of `decoders/msgspec.py`; `ImportError` raised at `MsgspecDecoder.__init__` if the extra is missing. Mirrors lite-bootstrap. | +| Install hint location | Module-level constant `MISSING_DEPENDENCY_MESSAGE = "..."` in `decoders/msgspec.py`. Not a class attribute. | +| Package-root re-export | None. Consumers import via `from httpware.decoders.msgspec import MsgspecDecoder`. Honors seam #5 (re-exporting would force eager `msgspec` load whenever the extra is installed). | +| Caching layer | None. `msgspec.json.decode` is a free function; no per-model adapter object to memoize, unlike `pydantic.TypeAdapter`. | +| Error propagation | `msgspec.ValidationError` and `msgspec.DecodeError` propagate unchanged. Mirrors `PydanticDecoder`'s `pydantic.ValidationError` handling. | +| `__all__` in `msgspec.py` and `import_checker.py` | None. Submodules don't get `__all__` going forward (project convention shift; existing files keep theirs until a follow-up cleanup). | +| `TYPE_CHECKING` block | None. The conditional `import msgspec` is sufficient for `ty`; no separate type-only import is needed. Fallback: `# ty: ignore[unresolved-reference]` on the `msgspec.json.decode` line if ty rejects, but lite-bootstrap precedent suggests it won't. | +| pyproject.toml | Untouched. `msgspec = ["msgspec>=0.18"]` is already declared (Story 1-1). | + +## File structure + +**New files:** +- `src/httpware/_internal/import_checker.py` — `find_spec`-based detection flags. Initial content is just `is_msgspec_installed`; future extras (otel, etc.) extend it. +- `src/httpware/decoders/msgspec.py` — `MsgspecDecoder` adapter. +- `tests/test_decoders_msgspec.py` — 8 behavioral tests for the decoder. +- `tests/test_optional_extras_isolation.py` — subprocess-based import-time guard. Future stories (5-4 otel) extend this file with their own subprocess checks. + +**Modified files:** +- `CHANGELOG.md` — Story 1.6 bullet under `[Unreleased]` / `### Added`. + +**Files NOT modified:** +- `pyproject.toml` — extras declaration already in place from Story 1-1. +- `src/httpware/__init__.py` — no package-root re-export of `MsgspecDecoder` (seam contract). +- `src/httpware/decoders/__init__.py` — `ResponseDecoder` protocol stays as-is; no new exports. + +## `_internal/import_checker.py` + +```python +"""Detect optional extras without importing them. Used by adapter modules to gate hard imports.""" + +from importlib.util import find_spec + + +is_msgspec_installed = find_spec("msgspec") is not None +``` + +Notes: +- One line of state, one purpose. Future stories add more `is_X_installed` flags as additional extras land (otel in Story 5-4, etc.). +- `find_spec` does NOT import the target module; it only checks whether the importer can find it. Side-effect-free. +- Module-level evaluation: the flag is computed once at import time and cached for the process lifetime. Re-installing the extra after process start is not supported (acceptable — the same caveat applies to lite-bootstrap and any other find_spec-based pattern). +- No `__all__`. The module exports one name; users import it via `from httpware._internal import import_checker` and access `import_checker.is_msgspec_installed`. + +## `decoders/msgspec.py` + +```python +"""MsgspecDecoder — opt-in ResponseDecoder backed by msgspec.json.decode.""" + +from typing import TypeVar + +from httpware._internal import import_checker + + +if import_checker.is_msgspec_installed: + import msgspec + + +MISSING_DEPENDENCY_MESSAGE = ( + "MsgspecDecoder requires the 'msgspec' extra. " + "Install with: pip install httpware[msgspec]" +) + +T = TypeVar("T") + + +class MsgspecDecoder: + """Decode raw response bytes via `msgspec.json.decode(content, type=model)`. + + Requires the `msgspec` extra: `pip install httpware[msgspec]`. Importing + this module without the extra works (the `msgspec` import is guarded by a + `find_spec` check), but instantiating the decoder raises `ImportError` with + the install hint. + """ + + def __init__(self) -> None: + if not import_checker.is_msgspec_installed: + raise ImportError(MISSING_DEPENDENCY_MESSAGE) + + def decode(self, content: bytes, model: type[T]) -> T: + """Validate `content` as JSON against `model` in a single parse pass.""" + return msgspec.json.decode(content, type=model) +``` + +Notes: +- The runtime `import msgspec` is gated by `is_msgspec_installed`; if the flag is False, the `msgspec` name is undefined at module load, but `decode` is never reachable in that case (the constructor raises first). +- No `__all__`. +- The class has no subclassing hooks, but subclassing is permitted; subclasses inherit the same import check. +- Single-pass invariant from `engineering.md` §3 holds: `msgspec.json.decode(content, type=model)` parses bytes and constructs the model in one C-level call. + +## Behavior and edge cases + +**Decode path:** +- Both `msgspec.Struct` subclasses and pydantic `BaseModel` subclasses are valid `model` arguments — msgspec's decoder accepts both natively. +- Builtin types (`int`, `str`, `list[int]`, etc.) also work. +- Generic containers (`list[Item]`, `dict[str, Item]`) work. + +**Error propagation:** +- `msgspec.ValidationError` — validation failure (shape mismatch, type mismatch). +- `msgspec.DecodeError` — JSON parse failure (malformed bytes). +- Both surface unchanged; the user wrote `response_model=X` to validate, and the validation error is the answer. Wrapping would obscure the actual failure. + +**Construction:** +- `MsgspecDecoder()` succeeds when the extra is installed. +- Raises `ImportError(MISSING_DEPENDENCY_MESSAGE)` when missing. + +**Import-time guarantees:** +- `import httpware` does NOT touch `msgspec` — `httpware/__init__.py` does not import from `httpware.decoders.msgspec` (seam contract). +- `import httpware.decoders.msgspec` only imports the `msgspec` C extension if `is_msgspec_installed` is True. The module itself imports successfully regardless. +- `from httpware.decoders.msgspec import MsgspecDecoder` returns the class even when the extra is missing — only construction fails. This enables capability-probe code. + +## Testing + +### `tests/test_decoders_msgspec.py` — 8 tests + +| Test | Verifies | +| --- | --- | +| `test_decoder_satisfies_response_decoder_protocol` | `isinstance(MsgspecDecoder(), ResponseDecoder)`. | +| `test_decode_into_msgspec_struct` | Decode JSON into a `msgspec.Struct` subclass. | +| `test_decode_into_pydantic_model` | Decode JSON into a pydantic `BaseModel` — verifies msgspec handles both natively. | +| `test_decode_into_builtin_type` | `decode(b'42', int) == 42`. | +| `test_decode_into_list_of_struct` | `decode(b'[{"name":"a","qty":1}]', list[Item])` returns `[Item(...)]`. | +| `test_decode_validation_error_propagates` | Schema mismatch (`"qty"` is not an int) raises `msgspec.ValidationError` unchanged. | +| `test_decode_json_parse_error_propagates` | Malformed JSON (`b'{'`) raises `msgspec.DecodeError` unchanged. | +| `test_construction_raises_without_extra_via_monkeypatch` | Monkeypatch `httpware._internal.import_checker.is_msgspec_installed = False`; `MsgspecDecoder()` raises `ImportError` containing the install-hint string. | + +### `tests/test_optional_extras_isolation.py` — 1 test + +| Test | Verifies | +| --- | --- | +| `test_importing_httpware_does_not_import_msgspec` | Subprocess: `python -c "import httpware; import sys; sys.exit(0 if 'msgspec' not in sys.modules else 1)"`. Asserts exit code 0. | + +The subprocess approach is necessary because `msgspec` IS installed in the test environment (via the `[msgspec]` extra and CI's `--all-extras` flag) — an in-process check would see `msgspec` in `sys.modules` from previous tests. The subprocess gets a fresh interpreter. + +Future stories extend this file with their own subprocess tests as new extras land (Story 5-4 OpenTelemetry, etc.). + +### Coverage and constraints + +- **Coverage expectation:** 100% line coverage on `import_checker.py` (one line) and `decoders/msgspec.py` (all branches). +- **No `httpx2` import** in either new source file or new test file. The existing `tests/test_no_httpx2_leakage.py` continues to pass. +- **No `from __future__ import annotations`.** +- **No `print()`, no `logging.basicConfig`.** +- **No `# type: ignore`.** If `ty` rejects `msgspec.json.decode(...)` because of the conditional `import msgspec`, fallback is `# ty: ignore[unresolved-reference]` on that one line with an explanatory comment. Lite-bootstrap precedent suggests this won't be needed. + +## CHANGELOG entry + +Under `[Unreleased]` / `### Added`: + +```markdown +- `MsgspecDecoder` opt-in `ResponseDecoder` adapter behind the `[msgspec]` extra; `msgspec.json.decode(content, type=model)` in a single C-level parse pass. Accepts `msgspec.Struct`, pydantic `BaseModel`, and builtin types as `model`. `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). +``` + +## Constraints and invariants + +- Honors the five protocol seams; specifically seam #5 (optional extras isolated to their own modules; no package-root re-export). +- Decoder satisfies the `ResponseDecoder` protocol structurally (no nominal inheritance required). +- Module-level `import msgspec` (when reached) is the SOLE `msgspec` import in the project — the same single-seam rule that applies to `httpx2`. Future test addition could mirror `tests/test_no_httpx2_leakage.py` if msgspec confinement becomes a CI-enforced invariant; deferred for now (msgspec is not as broadly used as httpx2 across the codebase). +- `import_checker.py` itself never imports the modules it checks; only `find_spec`. + +## Risks and mitigations + +| Risk | Mitigation | +| --- | --- | +| `ty` rejects `msgspec.json.decode(...)` because `msgspec` is imported inside a runtime `if`. | Add `# ty: ignore[unresolved-reference]` on the call site with a comment pointing at the `import_checker` guard. Decided at implementation time; not expected based on lite-bootstrap precedent. | +| Subprocess isolation test is flaky on the CI runner (e.g., `python` not on PATH). | Use `sys.executable` instead of bare `python` in the subprocess invocation. Standard pytest pattern. | +| A future story breaks the "no package-root re-export" invariant by adding `from httpware.decoders.msgspec import MsgspecDecoder` to `httpware/__init__.py`. | The subprocess isolation test catches this regression: `import httpware` would then transitively load `msgspec`, and the assertion would fail. | +| `MsgspecDecoder()` construction inside an `AsyncClient(decoder=MsgspecDecoder())` argument expression raises `ImportError` from deep inside a config-building stack when the extra is missing. | This is the intended behavior — the user is explicitly asking for the decoder. The install-hint message names the right extra and command. AsyncClient (Story 1-7) does not need to wrap or intercept. | +| msgspec API drift (`msgspec.json.decode` signature change in a future major). | `msgspec>=0.18,<1.0` is the implicit constraint via the `[msgspec]` extra. Pin upper bound explicitly in pyproject if drift becomes a concern. Deferred. | + +## Definition of done + +- `src/httpware/_internal/import_checker.py` exists with `is_msgspec_installed`. +- `src/httpware/decoders/msgspec.py` exists with `MsgspecDecoder` and `MISSING_DEPENDENCY_MESSAGE`. No `__all__`. +- `tests/test_decoders_msgspec.py` contains 8 passing tests; 100% line coverage on the new source files. +- `tests/test_optional_extras_isolation.py` contains the subprocess-based import-isolation test; passes. +- `CHANGELOG.md` has a Story 1.6 bullet under `[Unreleased]` / `### Added`. +- `just test` shows the increment from the post-2.3 baseline of 198 → 207 passed, 1 deselected, 100% coverage. +- `just lint-ci` clean. +- `tests/test_no_httpx2_leakage.py` still passes. +- `import httpware` (via subprocess) does not load `msgspec` into `sys.modules`. +- Story 1-6 lands as a single PR off `main` via the branch `story/1-6-msgspec-decoder-via-extras`. diff --git a/src/httpware/_internal/import_checker.py b/src/httpware/_internal/import_checker.py new file mode 100644 index 0000000..93eec09 --- /dev/null +++ b/src/httpware/_internal/import_checker.py @@ -0,0 +1,6 @@ +"""Detect optional extras without importing them. Used by adapter modules to gate hard imports.""" + +from importlib.util import find_spec + + +is_msgspec_installed = find_spec("msgspec") is not None diff --git a/src/httpware/decoders/msgspec.py b/src/httpware/decoders/msgspec.py new file mode 100644 index 0000000..be05b7c --- /dev/null +++ b/src/httpware/decoders/msgspec.py @@ -0,0 +1,32 @@ +"""MsgspecDecoder — opt-in ResponseDecoder backed by msgspec.json.decode.""" + +from typing import TypeVar + +from httpware._internal import import_checker + + +if import_checker.is_msgspec_installed: + import msgspec + + +MISSING_DEPENDENCY_MESSAGE = "MsgspecDecoder requires the 'msgspec' extra. Install with: pip install httpware[msgspec]" + +T = TypeVar("T") + + +class MsgspecDecoder: + """Decode raw response bytes via `msgspec.json.decode(content, type=model)`. + + Requires the `msgspec` extra: `pip install httpware[msgspec]`. Importing + this module without the extra works (the `msgspec` import is guarded by a + `find_spec` check), but instantiating the decoder raises `ImportError` with + the install hint. + """ + + def __init__(self) -> None: + if not import_checker.is_msgspec_installed: + raise ImportError(MISSING_DEPENDENCY_MESSAGE) + + def decode(self, content: bytes, model: type[T]) -> T: + """Validate `content` as JSON against `model` in a single parse pass.""" + return msgspec.json.decode(content, type=model) diff --git a/tests/test_decoders_msgspec.py b/tests/test_decoders_msgspec.py new file mode 100644 index 0000000..71b328f --- /dev/null +++ b/tests/test_decoders_msgspec.py @@ -0,0 +1,50 @@ +"""Unit tests for httpware.decoders.msgspec.MsgspecDecoder.""" + +import msgspec +import pytest + +from httpware._internal import import_checker +from httpware.decoders import ResponseDecoder +from httpware.decoders.msgspec import MsgspecDecoder + + +class _Item(msgspec.Struct): + name: str + qty: int + + +def test_decoder_satisfies_response_decoder_protocol() -> None: + assert isinstance(MsgspecDecoder(), ResponseDecoder) + + +def test_decode_into_msgspec_struct() -> None: + result = MsgspecDecoder().decode(b'{"name":"x","qty":1}', _Item) + assert result == _Item(name="x", qty=1) + + +def test_decode_into_builtin_type() -> None: + result = MsgspecDecoder().decode(b"42", int) + assert result == 42 # noqa: PLR2004 + + +def test_decode_into_list_of_struct() -> None: + result = MsgspecDecoder().decode(b'[{"name":"a","qty":1}]', list[_Item]) + assert result == [_Item(name="a", qty=1)] + + +def test_decode_validation_error_propagates() -> None: + with pytest.raises(msgspec.ValidationError): + MsgspecDecoder().decode(b'{"name":"x","qty":"not-an-int"}', _Item) + + +def test_decode_json_parse_error_propagates() -> None: + with pytest.raises(msgspec.DecodeError): + MsgspecDecoder().decode(b"{", _Item) + + +def test_construction_raises_without_extra_via_monkeypatch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(import_checker, "is_msgspec_installed", False) + with pytest.raises(ImportError, match="MsgspecDecoder requires the 'msgspec' extra"): + MsgspecDecoder() diff --git a/tests/test_optional_extras_isolation.py b/tests/test_optional_extras_isolation.py new file mode 100644 index 0000000..f9af1e0 --- /dev/null +++ b/tests/test_optional_extras_isolation.py @@ -0,0 +1,25 @@ +"""Verify that `import httpware` does not transitively load opt-in extras.""" + +import subprocess +import sys + + +def test_importing_httpware_does_not_import_msgspec() -> None: + """Fresh subprocess: msgspec must NOT appear in sys.modules after `import httpware`. + + msgspec IS installed in the test environment (via `--all-extras`), so this + test runs in a subprocess with a clean interpreter to verify that nothing + in the httpware import chain pulls msgspec in. + """ + result = subprocess.run( + [ + sys.executable, + "-c", + "import httpware; import sys; sys.exit(0 if 'msgspec' not in sys.modules else 1)", + ], + check=False, + capture_output=True, + ) + assert result.returncode == 0, ( + f"msgspec was loaded transitively by `import httpware`; stdout={result.stdout!r} stderr={result.stderr!r}" + )