Skip to content

Commit 51474c7

Browse files
authored
chore(context7): drop the stale planning/ exclusion (#119)
* chore: migrate off the planning/ convention Replaces planning/ (127 files) and architecture/ (10 pages) with CONTEXT.md, docs/adr/, and two named invariant tests. Nothing was lost: - planning/releases/ (24 files) — the published GitHub Releases are the record. Spot-checked 0.16.0 (byte-identical), 0.13.0 and 0.8.1 (both drifted locally after publication: reformatted code comments, and internal paths rewritten by later directory reorgs). The Release bodies are the authoritative copies. - planning/changes/ (96), audits/ (8), retros/ (3) — git history is the record. - planning/decisions/ was empty; the rejected alternatives were buried in the change files, the retros and the architecture pages, and are rescued below. - planning/agents/ moved to docs/agents/, with domain.md repointed at CONTEXT.md and docs/adr/. Twelve ADRs were already drafted on this branch and are kept as 0001-0012. Five more were extracted from planning/ and architecture/ in this pass: - 0013 httpx2 is the public surface — the 0.2.0 withdrawal of httpware's own Request/Response/Transport/ClientConfig layer (changes/2026-06-03.02, retros/2026-06-04) - 0014 decoders claim broadly, list order resolves ties, can_decode must not raise (retros/2026-06-10, changes/2026-06-10.01) - 0015 the body cap counts decoded bytes; Content-Length rejects but never admits; stream() iteration is uncapped (changes/2026-06-23.03) - 0016 stream() bypasses the middleware chain (changes/2026-06-05.04) - 0017 shared sync/async logic stays a module-local function, not a class and not _internal/ (the 2026-07-13 extraction series) Two candidates were declined because the fact already has a durable home and an ADR would rot beside it: the msgspec type_info/CustomType probe (the rationale is the _probe_can_decode docstring) and MockTransport-over-respx (docs/testing.md "Why not respx?"). The circuit breaker's raw-read `state` property was declined as too narrow. Invariant tests. ADRs 0003, 0009 and 0011 as drafted cited two tests that did not exist — tests/test_client_parity.py and a _KeywordReduceMixin precondition check. Both are now written, so the ADRs cite real enforcement: - tests/test_client_parity.py — public methods, per-method parameters, the constructors, and the resilience suite's sync/async pairing with Timeout as the sole named exception. - tests/test_errors.py — the mixin's __dict__-mirrors-__init__ precondition, and a census so a seventh mixin class cannot be added unchecked. Every assertion was verified by breaking it: an async-only public method, a keyword added to AsyncClient.get alone, a constructor keyword on one world, an async-only resilience middleware, a seventh mixin subclass, and an attribute stored beyond the init keywords each turn the matching test red, and green again on restore. Each was also confirmed not to trip on a benign narrowing — an annotation tightened on one world only, a private helper added to one client, and a field annotation narrowed from float to int all stay green. Only parameter names, order and kind are compared, because the two worlds legitimately annotate middleware and every return type differently. Everything else in architecture/ was mechanism prose with nothing enforceable and is simply deleted; no test was invented to justify keeping the directory. Glossary audit. All four _Avoid_ entries survived — each has a real synonym in use. Two source edits were forced: - docs/errors.md called a third-party decoder an "adapter", the one spelling the Decoder entry rejects. Fixed; user-visible docs prose, no API change. - ResponseTooLargeError.limit is a public field naming the cap, which contradicts the Cap entry's _Avoid_: limit. It cannot be renamed without a breaking change, so CONTEXT.md now records it as the one sanctioned exception rather than asserting a rule the public API violates. Stale cross-references into the deleted directories were removed from seven module docstrings, three test docstrings, and seven docs pages. The docs ones were absolute github.com URLs, which lychee --offline excludes — they would have rotted silently past the new gate. release.yml. Copied from modern-di verbatim (only the PyPI project name differs). This retires the mandatory-curated-release-notes policy: the "Require curated release notes" gate is gone and the body now comes from generate_release_notes. Without this, deleting planning/releases/ would have broken every future stable release, since the gate read planning/releases/${GITHUB_REF_NAME}.md. justfile loses the index and check-planning recipes and the planning/index.py line from lint-ci. _checks.yml gains the offline lychee links job. mkdocs.yml excludes docs/adr/ and docs/agents/ from the site build, as modern-di does — without it, --strict fails on the new unnavigated pages. Verification: just lint-ci clean; just test 811 passed, 100% coverage; just docs-build clean under --strict; lychee --offline 56 errors before, 0 after (all 56 were inside planning/). Deferred items with no ADR home — 3.13t free-threaded CI, blocked on a msgspec cp313t wheel, and the circuit breaker's force_open/force_closed — are drafted to DRAFT-ISSUES-httpware.md, not opened. * chore(context7): drop the stale planning/ exclusion The sibling commit on this branch deletes planning/, so the exclusion now names a directory that does not exist. Dropping the key rather than leaving an empty list: Context7's schema (https://context7.com/schema/context7.json) declares no required properties and gives excludeFolders a default of [], so an absent key and an empty list are equivalent. Absent matches the repos that never carried the line.
1 parent cad5aaa commit 51474c7

182 files changed

Lines changed: 860 additions & 21393 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/_checks.yml‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,17 @@ jobs:
6060
- run: just install
6161
- run: uv run --no-sync python -c "import sys; assert not sys._is_gil_enabled(), 'GIL is enabled'"
6262
- run: just test --cov-report xml
63+
64+
links:
65+
runs-on: ubuntu-latest
66+
steps:
67+
- uses: actions/checkout@v6
68+
# --offline blocks network requests and excludes every external URL, so this gate
69+
# is deterministic: it fails only on a relative link or file path a diff broke.
70+
- name: Check local links
71+
uses: lycheeverse/lychee-action@v2
72+
with:
73+
args: >-
74+
--offline
75+
--no-progress
76+
'**/*.md'

‎.github/workflows/release.yml‎

Lines changed: 6 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -25,47 +25,22 @@ jobs:
2525
- uses: extractions/setup-just@v4
2626
- uses: astral-sh/setup-uv@v7
2727

28-
# Curated release notes are MANDATORY for a stable tag. This runs BEFORE
29-
# `just publish` (which is irreversible) so a missing notes file aborts
30-
# the release before anything reaches PyPI — rather than silently shipping
31-
# with GitHub's auto-generated notes. Pre-release tags (a letter in the
32-
# name, e.g. 2.0.0rc1) are exempt and keep the auto-generated fallback.
33-
- name: Require curated release notes (stable tags)
34-
run: |
35-
set -euo pipefail
36-
if [[ "$GITHUB_REF_NAME" =~ [a-z] ]]; then
37-
echo "Pre-release ${GITHUB_REF_NAME}: curated notes not required."
38-
exit 0
39-
fi
40-
notes="planning/releases/${GITHUB_REF_NAME}.md"
41-
if [ ! -f "$notes" ]; then
42-
echo "::error::Stable tag ${GITHUB_REF_NAME} has no curated release notes at ${notes}. Write the notes, commit to main, and re-tag." >&2
43-
exit 1
44-
fi
45-
echo "Found curated release notes: ${notes}"
46-
4728
# PyPI is irreversible, so it runs FIRST: if it fails the job stops and no
4829
# GitHub Release is created advertising a version that never reached PyPI.
4930
# `just publish` derives the version from $GITHUB_REF_NAME (the tag name).
5031
# Auth via PyPI Trusted Publishing (OIDC); no PYPI_TOKEN. Needs a Trusted
5132
# Publisher on the httpware PyPI project (env: pypi, workflow: release.yml).
5233
- run: just publish
5334

54-
# Description source: planning/releases/<tag>.md if present (verbatim, no
55-
# auto-changelog appended); otherwise GitHub's generated notes. A tag with
56-
# a letter (2.0.0rc1) is a pre-release -> flagged so GitHub won't mark it
57-
# "Latest".
35+
# The Release body is GitHub's generated notes, rendered from the squashed
36+
# PR titles since the previous tag — so a conventional-commit title is what
37+
# a reader gets. A release wanting prose is edited after the fact with
38+
# `gh release edit <tag> --notes-file`. A tag with a letter (2.0.0rc1) is a
39+
# pre-release -> flagged so GitHub won't mark it "Latest".
5840
- name: Resolve release metadata
5941
id: meta
6042
run: |
6143
set -euo pipefail
62-
notes="planning/releases/${GITHUB_REF_NAME}.md"
63-
if [ -f "$notes" ]; then
64-
echo "body_path=$notes" >> "$GITHUB_OUTPUT"
65-
echo "generate_notes=false" >> "$GITHUB_OUTPUT"
66-
else
67-
echo "generate_notes=true" >> "$GITHUB_OUTPUT"
68-
fi
6944
if [[ "$GITHUB_REF_NAME" =~ [a-z] ]]; then
7045
echo "prerelease=true" >> "$GITHUB_OUTPUT"
7146
else
@@ -75,7 +50,6 @@ jobs:
7550
- name: Publish GitHub Release
7651
uses: softprops/action-gh-release@v3
7752
with:
78-
body_path: ${{ steps.meta.outputs.body_path }}
79-
generate_release_notes: ${{ steps.meta.outputs.generate_notes }}
53+
generate_release_notes: true
8054
prerelease: ${{ steps.meta.outputs.prerelease }}
8155
draft: false

‎AGENTS.md‎

Lines changed: 101 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,57 +4,118 @@ Guidance for AI agents (Claude Code, etc.) working in this repository.
44

55
## Project Overview
66

7-
`httpware` is a Python HTTP client framework with sync and async clients for building resilient service clients. It ships under the `modern-python` org and is a thin opinionated wrapper around `httpx2`: it re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain, typed response decoding, and a status-keyed exception tree raised automatically on 4xx/5xx.
8-
9-
**Where to find what:**
10-
11-
- [`architecture/`](architecture/) (repo root) — the per-capability living truth, one file per capability ([`architecture/README.md`](architecture/README.md) is the index). The promotion target on every ship. **Read the relevant file before changing that capability.**
12-
- [`planning/README.md`](planning/README.md) — the planning convention (Quick path + the two-axis convention) and the generated change Index.
13-
- [`planning/changes/<YYYY-MM-DD.NN-slug>.md`](planning/changes/) — flat change files, one per change (design template for the full lane, change template for the lightweight lane).
14-
- [`planning/decisions/`](planning/decisions/), [`planning/audits/`](planning/audits/), [`planning/retros/`](planning/retros/), [`planning/releases/`](planning/releases/), [`planning/deferred.md`](planning/deferred.md) — decisions, findings sweeps, retrospectives, release notes, and deferred items.
15-
16-
## Workflow
17-
18-
Planning follows the portable two-axis convention — `architecture/` (repo root) is the living **truth home** and promotion target; `planning/changes/` holds the flat change files. **Start at the [Quick path](planning/README.md#quick-path-start-here)** in `planning/README.md` to choose a lane (Full / Lightweight / Tiny), create a change file, and ship — that file is the authoritative spec. Run `just check-planning` to validate changes and `just index` to print the change listing.
7+
`httpware` is a thin, opinionated wrapper around `httpx2` with sync and async clients for building
8+
resilient service clients; [`CONTEXT.md`](CONTEXT.md) opens with what it does and owns the
9+
vocabulary — read it before naming a concept in code, a test name, or an issue title. It ships
10+
under the `modern-python` org.
1911

2012
## Commands
2113

22-
This project uses `just` (task runner) and `uv` (package manager). `just --list` is the source of truth; non-obvious notes:
23-
24-
- `just lint` auto-fixes; `just lint-ci` is the read-only CI variant (and runs the planning validator).
25-
- `just test` runs pytest with coverage and forwards extra args: `just test tests/test_client.py -k test_name`.
26-
- Without `just`: `uv run ruff format . && uv run ruff check . --fix && uv run ty check && uv run pytest`.
14+
`just` (task runner) and `uv` (package manager). The [`justfile`](justfile) is the source of truth —
15+
`just --list`, or read it. The one thing it does not say: `just docs-build` runs `mkdocs --strict`
16+
over the site only, and `docs/adr/` and `docs/agents/` are excluded from it (`exclude_docs` in
17+
`mkdocs.yml`) because they are read on GitHub rather than published. Their links are covered
18+
instead by the `links` job in `.github/workflows/_checks.yml`, which runs lychee `--offline` over
19+
every `*.md` in the repo.
2720

2821
## Architecture
2922

30-
> Quick orientation. The authoritative, code-current account of each capability lives in [`architecture/`](architecture/). **When a change alters a capability's behavior, update the matching `architecture/<capability>.md` in the same PR** — that promotion is what keeps `architecture/` true; code that changes without it silently rots the truth home.
23+
`httpx2` is public surface, not an abstraction to hide: `httpx2.Request` and `httpx2.Response` are
24+
re-exported as-is. Three protocol seams — client ↔ middleware chain, client ↔ decoder list, and
25+
httpware ↔ optional extras — are defined in `CONTEXT.md` and named **A**, **B** and **C** in the
26+
module docstrings that implement them. Never cross a seam except through its protocol.
27+
28+
Behavior detail has no prose home — it lives in the code and its `INVARIANT:`-marked tests. Before
29+
writing prose about a capability, run the admission check in **Where a fact goes** below.
30+
31+
### Key files
32+
33+
Every module under `src/httpware/` is named for what it does; read it. What a single-file read will
34+
**not** tell you:
35+
36+
- `client.py` holds **both worlds** — `Client` and `AsyncClient` — and they are hand-maintained at
37+
parity, not generated. A feature added to one must be mirrored to the other;
38+
`tests/test_client_parity.py` says why, and names the sole exception (`AsyncTimeout` has no sync
39+
sibling).
40+
- `errors.py` owns the tree and both construction rules. Status-keyed `StatusError` subclasses take
41+
a single positional `response` and never define `__init__`; the six non-status subclasses are
42+
keyword-only and inherit `__reduce__` from `_KeywordReduceMixin`, whose precondition is enforced
43+
by `tests/test_errors.py::test_keyword_reduce_classes_dict_mirrors_their_init_parameters`. Adding
44+
a class means picking a side, and both sides are checked.
45+
- `decoders/_resolver.py` is the **single dispatch path** for Seam B: `_DecoderResolver.resolve`
46+
walks the frozen decoder tuple, raises `MissingDecoderError` *before* the HTTP call when nothing
47+
claims the model, and returns a `_BoundDecoder` whose `decode` wraps any decoder-side failure as
48+
`DecodeError`. A decoder's `can_decode` runs outside that wrap, so it must never raise.
49+
- `_internal/` is the cross-module private home; `_internal/observability.py:_emit_event` is the
50+
single fan-out to both a logging record and an OTel span event, and the event names it takes are
51+
a public contract (see `CONTEXT.md`).
52+
- `middleware/resilience/` keeps the shared logic objects (`_RetryPolicy`, `_CircuitBreakerState`,
53+
`RetryBudget`, `_backoff`, `_event_loop_guard`) written once and driven by both worlds. New
54+
resilience logic goes in a shared object, not into each world's shell.
55+
56+
**House invariants, review-only** — nothing in CI catches these: no `httpx2._` private API (ruff
57+
`SLF001` flags private *attribute* access but not a used private *import*); no
58+
`from __future__ import annotations` (3.11+ floor); no global logging config —
59+
`logging.getLogger("httpware")` and namespaced children only.
60+
61+
### Testing patterns
62+
63+
Transport mocking is `httpx2.MockTransport` passed as `httpx2_client=`, never `respx` — `respx`
64+
targets `httpx`, not `httpx2`, and patches its internals. Concurrency-sensitive components carry
65+
Hypothesis property tests in `test_*_props.py`, and `stress`-marked tests drive real thread
66+
parallelism: they run under the GIL for coverage, but the proof comes from the free-threaded
67+
`3.14t` CI job.
3168

32-
`httpware` is a thin wrapper over `httpx2` (`httpx2.Request`/`httpx2.Response` are public surface, not abstracted away) built around three documented protocol seams. **Invariants that must not break:**
69+
## Workflow
3370

34-
- **Three protocol seams, crossed only through their protocols.** Seam A: `Client`/`AsyncClient` ↔ middleware chain, composed at `__init__` and frozen for the client's lifetime; the internal terminal calls `httpx2.*.send`, maps exceptions, and raises `StatusError` on 4xx/5xx. Seam B: clients ↔ `decoders: Sequence[ResponseDecoder] | None` — first decoder whose `can_decode` is True runs; `MissingDecoderError` raises *before* the HTTP call if none claims the model; decoder failures wrap as `DecodeError`. Seam C: `httpware` ↔ optional extras — each opt-in dependency imported only inside its dedicated module.
35-
- **Sync/async parity.** `Client` and `AsyncClient` carry identical features (typed decoding, middleware, resilience, `stream()`); a change to one surface must mirror to the other.
36-
- **House invariants** (most review-only, not CI-checked): no `httpx2._` private API; no `from __future__ import annotations` (3.11+ floor); no `print()` (ruff `T201`); no global logging config (`logging.getLogger("httpware")` / namespaced children only); type suppressions use `# ty: ignore[<rule>]`, never `# type: ignore`.
37-
- **Errors.** Status-keyed `StatusError` subclasses take a single positional `response` and never override `__init__`; non-status `ClientError` subclasses (`DecodeError`, `MissingDecoderError`, `BulkheadFullError`, `RetryBudgetExhaustedError`, `CircuitOpenError`, `ResponseTooLargeError`) do.
71+
Two things outlive the PR, and there are exactly two places to put them: an alternative
72+
**rejected** with reasoning becomes an ADR in [`docs/adr/`](docs/adr/) (`NNNN-slug.md`,
73+
sequential), and real work **not scheduled** becomes a GitHub issue. There is no third state, and
74+
no separate truth-home directory — a behaviour change is reviewed with the diff, not promoted to a
75+
page.
3876

39-
| Capability | File |
40-
|---|---|
41-
| What httpware is + architectural invariants + module layout | [`architecture/overview.md`](architecture/overview.md) |
42-
| Sync `Client` / async `AsyncClient` parity, `stream()` | [`architecture/client.md`](architecture/client.md) |
43-
| Seam A — middleware protocol, `Next`, chain composition | [`architecture/middleware.md`](architecture/middleware.md) |
44-
| Seam B — `ResponseDecoder` protocol, pydantic/msgspec resolution | [`architecture/decoders.md`](architecture/decoders.md) |
45-
| Status-keyed exception tree, construction invariant, redaction | [`architecture/errors.md`](architecture/errors.md) |
46-
| Resilience suite (retry, budget, bulkhead, circuit breaker, timeout) | [`architecture/resilience.md`](architecture/resilience.md) |
47-
| Seam C — optional extras isolation | [`architecture/extras.md`](architecture/extras.md) |
48-
| House code conventions (naming, imports, docstrings) | [`architecture/conventions.md`](architecture/conventions.md) |
49-
| Testing conventions | [`architecture/testing.md`](architecture/testing.md) |
77+
### Where a fact goes
5078

51-
## When in doubt
79+
Four homes, one owner each:
5280

53-
- Check the relevant [`architecture/`](architecture/) capability file before adding a new module or extension point.
54-
- Surface ambiguity as a documentation gap rather than improvising.
81+
| Home | Holds |
82+
|---|---|
83+
| `src/httpware/` | anything readable from the module — the default |
84+
| a named test | an **invariant**: must stay true, and a change could silently break it |
85+
| `docs/adr/` | a rejected alternative, with the reasoning that would otherwise be re-litigated |
86+
| `docs/` | anything a user needs |
87+
88+
Before writing a line anywhere:
89+
90+
> Can an agent get this by reading `src/httpware/`? → **don't write it.**
91+
> Would a wrong change here fail a test? → it belongs **in the test**, not in prose.
92+
> Does a user need it? → **`docs/`**.
93+
> Otherwise it does not get written.
94+
95+
**Prose about mechanism has no home. There is no file to add a paragraph to.** This file included:
96+
it is always loaded, so a line that restates a docstring, a justfile comment, or `pyproject.toml`
97+
costs every turn and rots in two places at once.
98+
99+
An invariant is a test whose name is the claim, with a docstring opening `INVARIANT:` and a second
100+
paragraph naming **what breaks it** — design rationale, not a report of what this one test catches;
101+
a sibling test may be the one that trips. Both ADRs and `INVARIANT:` docstrings ratchet: nothing
102+
prunes a record once its call is settled. Keeping them lean is a standing habit.
103+
104+
## Code Style
105+
106+
- Design principle: thin wrapper, small public surface. `httpware.__all__` is checked against an
107+
explicit expected set in `tests/test_public_api.py`, so a symbol cannot appear unnoticed
108+
- `Http` is two letters in a class name (`AsyncClient`, not `ASYNCClient`); no `a` prefix on async
109+
methods, matching `httpx2` — `aclose()` is the sole exception
110+
- Type suppressions are `# ty: ignore[<rule>]`, never `# type: ignore`: this project checks with
111+
`ty`, which silently accepts the latter without checking the rule
112+
- Docstrings: public API documents the contract; internal helpers get a one-line contract, plus at
113+
most 1–2 lines for a genuinely non-obvious constraint. Never narrate implementation or justify
114+
code to a reviewer — cross-file rationale lives in an `INVARIANT:` test docstring or an ADR
55115

56116
## Agent skills
57117

58-
- **Issue tracker** — Issues live in GitHub Issues (`modern-python/httpware`), managed via the `gh` CLI; external PRs are not a triage surface. See `planning/agents/issue-tracker.md`.
59-
- **Triage labels** — Canonical defaults: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. See `planning/agents/triage-labels.md`.
60-
- **Domain docs** — Single-context: one `CONTEXT.md` at the repo root + ADRs under `planning/adr/`. See `planning/agents/domain.md`.
118+
- **Issues and specs** — GitHub Issues on `modern-python/httpware`, via `gh`:
119+
[`docs/agents/issue-tracker.md`](docs/agents/issue-tracker.md)
120+
- **Triage labels** — the five canonical roles: [`docs/agents/triage-labels.md`](docs/agents/triage-labels.md)
121+
- **Domain docs** — single-context, `CONTEXT.md` + `docs/adr/`: [`docs/agents/domain.md`](docs/agents/domain.md)

0 commit comments

Comments
 (0)