diff --git a/.github/workflows/_checks.yml b/.github/workflows/_checks.yml index ee26d59..054eb07 100644 --- a/.github/workflows/_checks.yml +++ b/.github/workflows/_checks.yml @@ -38,3 +38,17 @@ jobs: - run: uv python pin ${{ matrix.python-version }} - run: just install - run: just test-ci + + links: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + # --offline blocks network requests and excludes every external URL, so this gate + # is deterministic: it fails only on a relative link or file path a diff broke. + - name: Check local links + uses: lycheeverse/lychee-action@v2 + with: + args: >- + --offline + --no-progress + '**/*.md' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5846a1..4d49a8e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,25 +25,6 @@ jobs: - uses: extractions/setup-just@v4 - uses: astral-sh/setup-uv@v7 - # Curated release notes are MANDATORY for a stable tag. This runs BEFORE - # `just publish` (which is irreversible) so a missing notes file aborts - # the release before anything reaches PyPI — rather than silently shipping - # with GitHub's auto-generated notes. Pre-release tags (a letter in the - # name, e.g. 2.0.0rc1) are exempt and keep the auto-generated fallback. - - name: Require curated release notes (stable tags) - run: | - set -euo pipefail - if [[ "$GITHUB_REF_NAME" =~ [a-z] ]]; then - echo "Pre-release ${GITHUB_REF_NAME}: curated notes not required." - exit 0 - fi - notes="planning/releases/${GITHUB_REF_NAME}.md" - if [ ! -f "$notes" ]; then - echo "::error::Stable tag ${GITHUB_REF_NAME} has no curated release notes at ${notes}. Write the notes, commit to main, and re-tag." >&2 - exit 1 - fi - echo "Found curated release notes: ${notes}" - # PyPI is irreversible, so it runs FIRST: if it fails the job stops and no # GitHub Release is created advertising a version that never reached PyPI. # `just publish` derives the version from $GITHUB_REF_NAME (the tag name). @@ -51,21 +32,15 @@ jobs: # Publisher on the modern-di-pytest PyPI project (env: pypi, workflow: release.yml). - run: just publish - # Description source: planning/releases/.md if present (verbatim, no - # auto-changelog appended); otherwise GitHub's generated notes. A tag with - # a letter (2.0.0rc1) is a pre-release -> flagged so GitHub won't mark it - # "Latest". + # The Release body is GitHub's generated notes, rendered from the squashed + # PR titles since the previous tag — so a conventional-commit title is what + # a reader gets. A release wanting prose is edited after the fact with + # `gh release edit --notes-file`. A tag with a letter (2.0.0rc1) is a + # pre-release -> flagged so GitHub won't mark it "Latest". - name: Resolve release metadata id: meta run: | set -euo pipefail - notes="planning/releases/${GITHUB_REF_NAME}.md" - if [ -f "$notes" ]; then - echo "body_path=$notes" >> "$GITHUB_OUTPUT" - echo "generate_notes=false" >> "$GITHUB_OUTPUT" - else - echo "generate_notes=true" >> "$GITHUB_OUTPUT" - fi if [[ "$GITHUB_REF_NAME" =~ [a-z] ]]; then echo "prerelease=true" >> "$GITHUB_OUTPUT" else @@ -75,7 +50,6 @@ jobs: - name: Publish GitHub Release uses: softprops/action-gh-release@v3 with: - body_path: ${{ steps.meta.outputs.body_path }} - generate_release_notes: ${{ steps.meta.outputs.generate_notes }} + generate_release_notes: true prerelease: ${{ steps.meta.outputs.prerelease }} draft: false diff --git a/AGENTS.md b/AGENTS.md index 9e23001..af512cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,44 +2,75 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Project Overview + +`modern-di-pytest` is a pytest adapter over +[`modern-di`](https://github.com/modern-python/modern-di); [`CONTEXT.md`](CONTEXT.md) opens with +what it does and owns the vocabulary — read it before naming a concept in code, a test name, or an +issue title. It is one of that project's integrations, each of which lives in a separate repository +and ships as a separate PyPI package. + ## Commands -This project uses `just` and `uv`. See `justfile` for the source of truth. +`just` (task runner) and `uv` (package manager). The [`justfile`](justfile) is the source of truth — +`just --list`, or read it. The one thing it does not say: a `ty` suppression is written +`# ty: ignore`, never `# type: ignore`. + +## Architecture + +All implementation is `modern_di_pytest/factory.py`, short enough to read whole. Read it. -- `just install` — `uv lock --upgrade` then `uv sync --all-extras --frozen --group lint` -- `just lint` — runs `eof-fixer`, `ruff format`, `ruff check --fix`, then `ty check` (writes) -- `just lint-ci` — same checks in non-mutating mode (`--check`, `--no-fix`) -- `just test` — `uv run --no-sync pytest`, forwards extra args; no coverage (`addopts` is empty) -- `just test-ci` — gated run: coverage with `--cov-fail-under=100` (the 100% line-coverage gate) -- `just test-branch` — like `test-ci` plus `--cov-branch` -- Run a single test: `just test tests/test_expose.py::test_expose_generates_repo_fixture` (or `-k `) -- Type checker is `ty`; suppress with `# ty: ignore` (not `# type: ignore`) +### Testing patterns + +`tests/sample.py` is the fixture model every test builds on: a `Group` spanning two scopes, plus +non-Provider attributes that exist to exercise the skip path. ## Workflow -Changes follow the planning convention in [`planning/README.md`](planning/README.md) — -start at its **Quick path** to pick a lane (Full / Lightweight / Tiny) before -making a change. `just check-planning` validates planning changes; `just index` prints the -change/decision index. The applied convention version is in -`planning/.convention-version`. +**The spec for a change is its PR body**, not a committed file: why, design, non-goals, +verification, reviewed with the diff. There is no change file and no lane to choose. A trivial PR +(typo, dep bump, formatter, CI tweak) ships a conventional-commit title with no body ceremony. -## Architecture +Two things outlive the PR, and there are exactly two places to put them: an alternative **rejected** +with reasoning becomes an ADR in [`docs/adr/`](docs/adr/) (`NNNN-slug.md`, sequential, with a +revisit trigger), and real work **not scheduled** becomes a GitHub issue. There is no third state, +and no separate truth-home directory — a behaviour change is reviewed with the diff, not promoted +to a page. + +### Where a fact goes + +Four homes, one owner each: -This package is a thin pytest adapter over [`modern-di`](https://github.com/modern-python/modern-di). All implementation lives in `modern_di_pytest/factory.py` and exposes exactly two public symbols: +| Home | Holds | +|---|---| +| `modern_di_pytest/` | anything readable from the module — the default | +| a named test | an **invariant**: must stay true, and a change could silently break it | +| `docs/adr/` | a rejected alternative, with the reasoning that would otherwise be re-litigated | +| `README.md` | anything a user needs | -- `modern_di_fixture(dependency, *, container_fixture="di_container", name=None, pytest_scope="function")` — wraps a single type or `AbstractProvider` in a `@pytest.fixture`. At fixture time it calls `request.getfixturevalue(container_fixture)`, then delegates to `container.resolve_dependency(dependency)` — the type-or-provider dispatch lives in modern-di itself. -- `expose(*groups, container_fixture="di_container", pytest_scope="function", module=None)` — variadic: accepts one or more `Group` subclasses. For each, iterates `vars(group)` and for every attribute that is an `AbstractProvider` instance, builds a `modern_di_fixture` and `setattr`s it onto the target module under the attribute's name. Non-Provider attributes (strings, ints, underscored, etc.) are silently skipped. A duplicate attribute name across the given groups raises `ValueError`; calling with no groups raises `TypeError`. When `module` is omitted, the caller's module is located via `inspect.stack()[1]` — `expose` therefore only works when called from module scope of a `conftest.py` / test module, not from inside a function. +Before writing a line anywhere: -Key contract: this package does **not** own the container. The user defines a `di_container` pytest fixture (any scope) that yields a `modern_di.Container`. Child-scoped containers (e.g. `REQUEST`) are accessed by passing a different `container_fixture=` name — see `tests/conftest.py` for the `di_container` / `di_request_container` pattern. Overrides are not re-implemented here; users call `Container.override()` / `reset_override()` directly. +> Can an agent get this by reading `modern_di_pytest/`? → **don't write it.** +> Would a wrong change here fail a test? → it belongs **in the test**, not in prose. +> Does a user need it? → **`README.md`**. +> Otherwise it does not get written. -`tests/sample.py` is the reference fixture model: a `Group` subclass holding `providers.Factory` instances at `APP` and `REQUEST` scopes, plus deliberately non-Provider attributes to exercise the skip path in `expose`. +**Prose about mechanism has no home. There is no file to add a paragraph to.** This file included: +it is always loaded, so a line that restates a docstring, a justfile comment, or `pyproject.toml` +costs every turn and rots in two places at once. A package this small tempts a full restatement of +its own source; that is the failure mode to watch for here. -When a change alters a capability's behavior, update the matching `architecture/.md` in the same PR. +An invariant is a test whose name is the claim, with a docstring opening `INVARIANT:` and a second +paragraph naming **what breaks it** — design rationale, not a report of what this one test catches. +Nothing enforces that docstring shape; it is read at review time. A relative link to an ADR *is* +checked — CI runs lychee `--offline` over every `.md` — but a path named in a docstring or a +comment is not. Both ADRs and `INVARIANT:` docstrings ratchet: nothing prunes a record once its +call is settled. Keeping them lean is a standing habit. ## Agent skills - **Issues and specs** — GitHub Issues on `modern-python/modern-di-pytest`, via `gh`: [`docs/agents/issue-tracker.md`](docs/agents/issue-tracker.md) - **Triage labels** — the five canonical roles: [`docs/agents/triage-labels.md`](docs/agents/triage-labels.md) -- **Domain docs** — single-context, `architecture/` + `planning/`: +- **Domain docs** — single-context, `CONTEXT.md` + `docs/adr/`: [`docs/agents/domain.md`](docs/agents/domain.md) diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..9e66a3e --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,32 @@ +# modern-di-pytest + +A pytest adapter over [`modern-di`](https://github.com/modern-python/modern-di): it turns +providers declared on a `Group` into pytest fixtures, one per provider, resolved through a +container the test suite owns. + +## Language + +A term is listed only when there is a synonym to reject, or a meaning subtle enough that code and +docs must agree on it. General programming vocabulary does not belong here, however heavily this +package uses it. + +The domain terms are `modern-di`'s — `Container`, `Provider`, `Group`, `Scope`, `Resolution`, +`Override`. That project's `CONTEXT.md` is the authority for all of them; nothing here redefines +one. The three below are this package's own. + +**Install**: +Binding a generated fixture onto a module as a module-level attribute, which is what makes pytest +collect it. `expose()` installs; `modern_di_fixture()` returns a fixture the caller assigns itself. +_Avoid_: inject, register — both have been used for this in the same breath as `install`, and +`inject` additionally collides with the DI sense of injection, which is `modern-di`'s word for +passing a resolved value into a callable. + +**Generated fixture**: +A pytest fixture this package builds from a provider or a type. It resolves at fixture time, never +at import time. + +**Container fixture**: +The user's own pytest fixture yielding the `modern_di.Container` a generated fixture resolves +from — named by `container_fixture=`, defaulting to `di_container`. This package never defines one; +pointing a fixture at a child scope means naming a different container fixture, not a different +package API. diff --git a/README.md b/README.md index 9e48fe0..e8bd9dc 100644 --- a/README.md +++ b/README.md @@ -131,8 +131,8 @@ type or a Provider; the generated fixture resolves it through ### `expose(*groups, container_fixture="di_container", pytest_scope="function", module=None)` -Walk each ``Group`` subclass in ``groups`` and inject one pytest fixture per -Provider class attribute into the caller's module. Fixture names equal the +Walk each ``Group`` subclass in ``groups`` and install one pytest fixture per +Provider class attribute onto the caller's module. Fixture names equal the class-attribute names. Non-Provider class attributes are skipped. A duplicate attribute name across groups raises ``ValueError``, and calling it with no groups raises ``TypeError``. Pass ``module=`` explicitly when stack diff --git a/architecture/README.md b/architecture/README.md deleted file mode 100644 index 602f401..0000000 --- a/architecture/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# Architecture - -The living truth about what `modern-di-pytest` does **now** — one file per -capability, plain prose, no frontmatter (dated by git history). The *why* and -the history live in [`planning/changes/`](../planning/changes/); this directory -holds only the current contract. - -## Promotion rule - -When a change alters a capability's behavior, the matching -`architecture/.md` is hand-edited **in the same PR** as the code, so -the doc rides in the same diff and is reviewed with it — never as a separate -post-merge step. - -No capability files exist yet. Add one the first time a change touches a -capability worth recording as living truth (e.g. `architecture/expose.md` for -fixture exposure, `architecture/fixtures.md` for `modern_di_fixture`). diff --git a/docs/adr/0001-expose-installs-into-modules-only.md b/docs/adr/0001-expose-installs-into-modules-only.md new file mode 100644 index 0000000..6a6d43f --- /dev/null +++ b/docs/adr/0001-expose-installs-into-modules-only.md @@ -0,0 +1,30 @@ +# `expose` installs into modules only; no `FixtureSet` install seam + +**Decision:** `expose()` installs fixtures into modules only; we will not introduce a `FixtureSet` +(or equivalent installer) seam. + +`expose()` does two things: it **decides** which providers become fixtures (discovery, the +skip-non-Provider rule, cross-group collision detection) and it **installs** the resulting fixtures +onto a module. The decision half was extracted into the private, pure `_collect_fixtures`, which +captured the durable value — the rules became testable through a return value, and the test suite +collapsed accordingly. + +The recurring follow-up is to extract the *install* half too: wrap the `name -> provider` mapping in +a `FixtureSet` exposing `.install(into=...)`, turning the install target into a seam. + +The architectural test for introducing a seam is **one adapter = hypothetical seam, two = real +one**. The install targets that actually exist are the caller's module (default, located via +`inspect.stack()`) and an explicit module passed as `module=`. Both are `types.ModuleType` — the +*same* adapter type exercised with two instances, not two adapters. There is no concrete non-module +install target — a pytest class namespace, a programmatic consumer, an ecosystem integration — +now or clearly coming. The library is, and is expected to remain, a conftest-level adapter that +installs fixtures into modules. + +So a `FixtureSet` would fail the deletion test: delete it and no complexity reappears, because the +`setattr` loop simply inlines back into `expose()`. It would add an interface without adding +behaviour. The install step stays a plain `setattr` loop over the mapping `_collect_fixtures` +returns, and the public surface stays at two symbols. + +**Revisit trigger:** a real second install target appears — two genuinely different adapters at the +install point, such as installing onto a pytest test-class namespace, or handing the mapping to a +programmatic consumer. At that moment the seam becomes real and a `FixtureSet` earns its keep. diff --git a/docs/agents/domain.md b/docs/agents/domain.md index 7b9b2bb..0b9df28 100644 --- a/docs/agents/domain.md +++ b/docs/agents/domain.md @@ -1,80 +1,64 @@ # Domain Docs -How the engineering skills should consume this repo's domain documentation when exploring -the codebase. +How the engineering skills should consume this repo's domain documentation when exploring the +codebase. This repo is **single-context**. -This repo does **not** use the stock `CONTEXT.md` + `docs/adr/` layout. It follows the -two-axis planning convention (applied version in `planning/.convention-version`), which -already has a home for each role. Use the repo's own files — do not create `CONTEXT.md`, -`CONTEXT-MAP.md`, or `docs/adr/`. - -## Layout: single-context +## Before exploring, read these -| Stock skill concept | This repo | -| ---------------------------------- | ----------------------------------------------- | -| `CONTEXT.md` (ubiquitous language) | `architecture/glossary.md` | -| what the system does now | `architecture/.md`, one per capability | -| `docs/adr/` (durable decisions) | `planning/decisions/-.md` | -| the *why* behind a shipped change | `planning/changes/.NN-.md` | +- **`CONTEXT.md`** at the repo root: what this package is, and the glossary. +- **`docs/adr/`**: read the decision records that touch the area you're about to work in. -`AGENTS.md` carries the current public contract of `modern_di_pytest/factory.py` under its -`## Architecture` heading. Read it before proposing anything about `modern_di_fixture` or -`expose`; it is more specific than anything in `architecture/` today. +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest +creating them upfront. The `/domain-modeling` skill creates them lazily when terms or decisions +actually get resolved. -## Before exploring, read these +## File structure -- `AGENTS.md` — `## Architecture` states the two public symbols and their contract. -- `architecture/README.md`, then any `architecture/.md` touching your area. - No capability files exist yet; the directory explains when to add the first. -- `architecture/glossary.md` — the ubiquitous language. -- `planning/decisions/*.md` whose subject touches your area. Each carries - `status: accepted | superseded`; a superseded decision is history, follow - `superseded_by`. -- `planning/changes/*.md` for the rationale behind a specific past change. - `just index` prints the change/decision listing. +``` +/ +├── CONTEXT.md +├── docs/adr/ +│ └── 0001-….md +├── modern_di_pytest/ ← the whole implementation, one module +└── tests/ +``` -If any of these don't exist, **proceed silently**. Don't flag their absence; don't suggest -creating them upfront. `architecture/glossary.md` in particular is authored lazily — it -appears when the first term is worth pinning down. +There is no `CONTEXT-MAP.md` and no per-package `CONTEXT.md`: one repo, one context. There is also +no `architecture/` and no `planning/` — the present is the source, and what must stay true is a test +whose docstring opens `INVARIANT:`. ## Use the glossary's vocabulary -When your output names a domain concept (an issue title, a refactor proposal, a -hypothesis, a test name), use the term as defined in `architecture/glossary.md`, and honor -its `_Avoid_:` lines — those synonyms are rejected on purpose. +When your output names a domain concept (an issue title, a refactor proposal, a hypothesis, a test +name), use the term as defined in `CONTEXT.md`, and honor its `_Avoid_:` lines — those synonyms are +rejected on purpose. Write `install` and not `inject` or `register`. + +This package is a thin adapter over `modern-di`, so most domain terms are that project's, not this +one's: `Container`, `Provider`, `Group`, `Scope`, `Resolution`, `Override`. Its `CONTEXT.md` is the +upstream authority; do not redefine a term here that `modern-di` already defines. -This package is a thin adapter over `modern-di`, so most domain terms are that project's, -not this one's: `Container`, `Provider`, `Group`, `Scope`, `Resolution`, `Override`. Its -glossary is the upstream authority; do not redefine a term here that `modern-di` already -defines. +If the concept you need is in neither, that's a signal: either you're inventing language the project +doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`). -If the concept you need is in neither, that's a signal: either you're inventing language -the project doesn't use (reconsider) or there's a real gap (note it for -`/domain-modeling`). +## Where a new fact goes -## Writing back +Run the admission check in `AGENTS.md` before writing anything down. In short: derivable from +`modern_di_pytest/` → don't write it; enforceable → a named test with an `INVARIANT:` docstring; a +user needs it → `README.md`; a rejected alternative → an ADR under `docs/adr/`, with its revisit +trigger; real work you are not doing now → a GitHub issue. Nothing else gets written. -Domain docs here are written under the planning convention, not freehand: +## Link style inside `docs/` -- **A new or sharpened term** → edit `architecture/glossary.md` in the same PR as the - change. No frontmatter; each entry is a term, a one-or-two-sentence definition of what - it *is*, and an optional `_Avoid_:` line. Seed a new file from - `planning/_templates/glossary.md`. -- **A behavior change** → hand-edit the affected `architecture/.md` in the - implementing PR, alongside the code. Never as a separate post-merge step. -- **A design decision, especially a rejected option** → a new - `planning/decisions/-.md` from `planning/_templates/decision.md`, - including its **Revisit trigger**. -- Run `just check-planning` and `just check-links` before pushing. The link checker walks - every relative Markdown link and heading anchor in the repository, including this file. +The files under `docs/` are read on GitHub, and CI runs an offline link check over every Markdown +file in the repo. Between files inside `docs/`, use a plain relative `.md` link — from one ADR to +another, that is `[ADR-NNNN](NNNN-slug.md)`. -## Flag conflicts +## Flag ADR conflicts -If your output contradicts an accepted decision or a capability page, surface it -explicitly rather than silently overriding: +If your output contradicts an existing decision record, surface it explicitly rather than silently +overriding: -> _Contradicts `planning/decisions/2026-06-26-expose-installs-into-modules-only.md`, but -> worth reopening because…_ +> _Contradicts ADR-NNNN (its title), but worth reopening because…_ -A decision's **Revisit trigger** names the concrete signal that should reopen it. If that -signal has fired, say so. +A decision's **Revisit trigger** names the concrete signal that should reopen it. If that signal has +fired, say so. diff --git a/justfile b/justfile index 46ce05c..f048347 100644 --- a/justfile +++ b/justfile @@ -15,20 +15,6 @@ lint-ci: uv run ruff format --check uv run ruff check --no-fix uv run ty check - uv run python planning/index.py --check - uv run python planning/links.py - -index: - uv run python planning/index.py - -check-planning: - uv run python planning/index.py --check - -# Check every relative Markdown link and heading anchor. Nothing else validates them: -# every .md here is read on GitHub, where a rotted link stays invisible until someone -# clicks it. -check-links: - uv run python planning/links.py test *args: uv run --no-sync pytest {{ args }} diff --git a/modern_di_pytest/factory.py b/modern_di_pytest/factory.py index 5cc646a..6a3ce08 100644 --- a/modern_di_pytest/factory.py +++ b/modern_di_pytest/factory.py @@ -75,7 +75,7 @@ def _collect_fixtures(*groups: type[Group]) -> dict[str, AbstractProvider[typing if attr_name in source: prior = source[attr_name] msg = ( - f"expose() cannot register {attr_name!r} from " + f"expose() cannot install {attr_name!r} from " f"{group.__name__}: already provided by {prior.__name__}." ) raise ValueError(msg) @@ -90,7 +90,7 @@ def expose( pytest_scope: _PytestScope = "function", module: types.ModuleType | None = None, ) -> None: - """Register one pytest fixture per Provider across one or more groups. + """Install one pytest fixture per Provider across one or more groups. Each generated fixture is named after the class attribute it came from. @@ -100,7 +100,7 @@ def expose( duplicate raises ``ValueError``. container_fixture: Name of the pytest fixture yielding the container. pytest_scope: pytest fixture scope applied to every generated fixture. - module: Module to inject fixtures into. Defaults to the caller's module + module: Module to install fixtures onto. Defaults to the caller's module (located via ``inspect.stack()``). Example (in ``conftest.py``):: diff --git a/planning/.convention-version b/planning/.convention-version deleted file mode 100644 index ccbccc3..0000000 --- a/planning/.convention-version +++ /dev/null @@ -1 +0,0 @@ -2.2.0 diff --git a/planning/README.md b/planning/README.md deleted file mode 100644 index 31062f8..0000000 --- a/planning/README.md +++ /dev/null @@ -1,163 +0,0 @@ -# Planning - -Living planning docs for `modern-di-pytest`. The convention below is portable — -see [`.convention-version`](.convention-version) for the applied version. - -## Quick path (start here) - -> The fast lane for making a change. The full reference is in -> [Conventions](#conventions) below — read it only when this isn't enough. - -**1. Choose a lane — first matching rule wins:** - -1. Any of: needs design judgment · new file/module · public-API change · - cross-cutting or multi-file · non-trivial test design → **Full** (design template) -2. Purely mechanical: typo · dep bump · linter/formatter/CI tweak · - mechanical rename · single-line config → **Tiny** (no change file, conventional - commit) -3. Small-but-real, none of the above: ≲30 LOC net · ≤2 files · no new file · - no public-API change · one straightforward test → **Lightweight** (change template) - -Ambiguous between two? Take the heavier. A lightweight change file that outgrows its lane is rewritten from the design template. - -**2. Create the change file** (Full / Lightweight only): -`planning/changes/YYYY-MM-DD.NN-.md`, where `.NN` is a zero-padded -intra-day counter — copied from the matching template (design or change) in -[`_templates/`](_templates/). - -**3. Ship in the implementing PR:** hand-edit the affected -`architecture/.md`, finalize the change file's `summary:` to the -realized result, and run `just check-planning` before pushing. - -## Conventions - -> This is the portable convention, sourced from the canonical repo -> [`lesnik512/planning-convention`](https://github.com/lesnik512/planning-convention) -> (applied version in `.convention-version`, beside this file). To update -> it, run that repo's `APPLY.md` flow. The generated change index (`just index`) -> and the `## Other` pointers below are repo-local. `just check-links` validates every -> relative Markdown link and heading anchor in the repo, including the trees a site -> builder never sees. - -### Two axes, never mixed - -- **`architecture/` (repo root) — the present.** One file per capability, plus - a single `glossary.md` (the ubiquitous language); living prose, updated in the - same PR that ships the change. The truth home. -- **`planning/changes/` — the past-and-pending.** One file per change, - kept in place after ship. - -A change **promotes** its conclusions into the affected -`architecture/.md` by hand **in the implementing PR, alongside the -code** — the edit rides in the same diff and is reviewed with it, never applied -as a separate post-merge step. That hand-edit is what keeps `architecture/` -true; the change file stays in `changes/` as the *why*. - -### Glossary - -`architecture/glossary.md` is the project's **ubiquitous language** — one page -defining the domain terms that code, specs, and capability pages all share. Like -the capability files beside it, it is living prose with **no frontmatter**, dated -by git, and authored lazily: it appears when the first term is worth pinning down. - -Each entry is a term, a one-or-two-sentence definition of what it *is* (not what -it does), and an optional `_Avoid_:` line naming the synonyms to reject: - -```md -**Timer**: -A scheduled future delivery, identified by a timer id. -_Avoid_: job, task, alarm -``` - -Keep it a glossary, not a spec — no implementation detail. A change that -introduces or sharpens a term updates `glossary.md` in the same PR, the same way -a behavior change promotes into a capability file. - -### Change files - -A change is a file `changes/YYYY-MM-DD.NN-.md`: - -- `YYYY-MM-DD` — proposal date; `.NN` — zero-padded intra-day counter - (`.01`, `.02`, …) that breaks same-date ties so the timeline sorts stably. -- `` — kebab-case description, not a story ID. - -`summary` is written when the change is created (the intent one-liner) and -**finalized at ship** to state the realized result — set in the implementing -PR, alongside the code and the `architecture/` promotion. No post-merge -bookkeeping, no file move. `date` and `slug` are never written — they are -read from the file name. - -### Three lanes - -| Lane | Artifacts | Use when | -|------|-----------|----------| -| **Full** | one change file from the design template | design judgment; new file/module; public-API change; cross-cutting/multi-file; non-trivial test design | -| **Lightweight** | one change file from the change template | small-but-real: ≲30 LOC net, ≤2 files, no new file, no public-API change, single straightforward test | -| **Tiny** | none — conventional commit | typo, dep bump, linter/formatter/CI tweak, mechanical rename, single-line config | - -Heavier lane wins on ambiguity. A lightweight change file that outgrows its lane is rewritten from the design template. - -### Plans are ephemeral - -The executable plan — task checklists, embedded code, commit sequences, -whatever the executor needs — is a working artifact, not history. Keep it out -of `changes/` and out of version control (git-ignored scratch, e.g. -`.superpowers/`). Once the change ships, the diff and the PR are the record -of execution; a committed plan duplicates them. `check-planning` rejects -anything in `changes/` that is not a flat change file. - -### Lean specs - -The change file is the single home of a change's rationale: - -- The PR body summarizes and links to the change file — it never restates it. -- Rejected alternatives live in `decisions/` and are referenced, not retold. -- Show a sketch when the design needs code; never the full diff-to-be. -- Delete template sections that don't apply — an empty section is ceremony. -- Most designs fit well under ~700 words; length must buy information. - -### Artifacts at a glance - -- **design template** — the spec: the *thinking* (why, design, trade-offs, - scope); the change file it produces is the single home of rationale (see - [Lean specs](#lean-specs)). -- **change template** — the condensed spec for the lightweight lane. -- **`releases/.md`** — per-release user-facing notes. -- **`audits/-.md`** — findings from a code/docs/bug-hunt sweep; - spawns fix changes. -- **`retros/-.md`** — what we learned after a body of work. -- **`deferred.md`** — real-but-unscheduled items, each with a revisit trigger. -- **`decisions/-.md`** — one file per design decision taken - (especially options *rejected*), each with a revisit trigger; listed by - `just index`. - -Templates live in [`_templates/`](_templates/). - -### Frontmatter - -`date` and `slug` are **derived from the file name** — never -repeated in frontmatter. So: - -- `changes/*.md`: `summary` (single line) only. -- `decisions/*.md`: `status` (accepted|superseded), `summary`, and optional - `supersedes` / `superseded_by`. -- Files in `architecture/` carry **no** frontmatter — living prose, dated by git. - -**`summary`** is one line: written at creation as the intent, then **finalized -at ship** to state the realized result — what shipped and its effect. It is the -only field the index renders. - -## Index - -Run `just index` to print the generated change + decision listing (newest-first). -It is a query over the files in `changes/` and `decisions/`, never a committed -artifact. - -## Other - -- [`architecture/`](../architecture/) — the living truth about what the package - does now (one file per capability). -- [`_templates/`](_templates/) — copy the matching template when opening a change. -- [`deferred.md`](deferred.md) — real-but-unscheduled items with revisit triggers. -- [`decisions/`](decisions/) — design decisions taken (especially rejected - options), each with a revisit trigger; listed by `just index`. diff --git a/planning/_templates/change.md b/planning/_templates/change.md deleted file mode 100644 index 5aa7e81..0000000 --- a/planning/_templates/change.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -summary: One line — shown in the generated index. Written at creation; finalize at ship to state the realized result. ---- - -# Change: One-line capitalized title - -**Lane:** lightweight — ≲30 LOC net, ≤2 files, no new file, no public-API -change, a single straightforward test. If it outgrows this, rewrite it from -the design template. - -## Goal - -One or two sentences: what changes and why. - -## Approach - -The shape of the change in brief — enough that a reviewer sees the design -without a full spec. Link the truth home (`architecture/.md`) if a -capability contract moves. - -## Files - -- `path/to/file.py` — what changes -- `tests/test_x.py` — test added / updated - -## Verification - -- [ ] Failing test first — command + expected error. -- [ ] Apply the change. -- [ ] Test passes — command. -- [ ] `just test` — full suite green. -- [ ] `just lint` — clean. diff --git a/planning/_templates/decision.md b/planning/_templates/decision.md deleted file mode 100644 index 45ccaf0..0000000 --- a/planning/_templates/decision.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -status: accepted # accepted | superseded -summary: One line — shown in `just index`. -supersedes: null -superseded_by: null ---- - -# One-line capitalized title - -**Decision:** What was decided, in a sentence. - -## Context - -Why this came up; the options that were on the table. - -## Decision & rationale - -The call and why — including why the alternatives were rejected. Enough that a -future explorer doesn't re-litigate it. - -## Revisit trigger - -The concrete signal that should reopen this decision. diff --git a/planning/_templates/design.md b/planning/_templates/design.md deleted file mode 100644 index 17dbee1..0000000 --- a/planning/_templates/design.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -summary: One line — shown in the generated index. Written at creation; finalize at ship to state the realized result. ---- - -# Design: One-line capitalized title - - - -## Summary - -One paragraph. What changes, at the level a reader needs to decide if this -spec is worth reading in full. - -## Motivation - -Why now. What is broken or missing. Concrete observations / numbers, not -abstract complaints. - -## Design - -What changes, in enough detail that a reader who has not seen the codebase -can follow. Sketches and interface fragments welcome; never the full -diff-to-be. Reference rejected alternatives in `decisions/` instead of -retelling them. - -## Non-goals - -What is deliberately out of scope and (when nontrivial) why. One line each. - -## Testing - -How we know it landed correctly. Be specific: the command and the expected -signal. - -## Risk - -What could go wrong, ranked by likelihood × impact. Mitigations. diff --git a/planning/_templates/glossary.md b/planning/_templates/glossary.md deleted file mode 100644 index 82385c3..0000000 --- a/planning/_templates/glossary.md +++ /dev/null @@ -1,15 +0,0 @@ -# Glossary - -The project's ubiquitous language — the domain terms that code, specs, and -capability pages share. Living prose, no frontmatter, dated by git. Each entry is -a term, what it *is* (not what it does), and the synonyms to avoid. No -implementation detail; this is a glossary, not a spec. - -**Term**: -A one-or-two-sentence definition of what it is. -_Avoid_: rejected-synonym, another-one - -**Another term**: -Define what it is, tightly. Group related terms under `##` subheadings when -natural clusters emerge; a flat list is fine when they don't. -_Avoid_: … diff --git a/planning/_templates/release.md b/planning/_templates/release.md deleted file mode 100644 index 5081187..0000000 --- a/planning/_templates/release.md +++ /dev/null @@ -1,38 +0,0 @@ -# - - - - - -## Feature - -- **.** What it adds and how to use it. - -## Fix - -- **.** What was broken, now fixed (reference the issue/regression). - -## Internal refactors - -- **.** What changed under the hood, stated as no behavior change. - -## Packaging - -- Metadata / build / dependency changes visible to installers. - -## Why - -Context a reader needs for the headline change. Omit for small releases. - -## Downstream - -What dependents must do — e.g. bump their version floor — or "No action -needed" when there is no API change. Omit if the project has no downstreams. - -## Internals - -- Coverage / tooling notes. diff --git a/planning/changes/.gitkeep b/planning/changes/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/planning/decisions/.gitkeep b/planning/decisions/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/planning/decisions/2026-06-26-expose-installs-into-modules-only.md b/planning/decisions/2026-06-26-expose-installs-into-modules-only.md deleted file mode 100644 index ad90973..0000000 --- a/planning/decisions/2026-06-26-expose-installs-into-modules-only.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -status: accepted -summary: expose() installs fixtures into modules only — no FixtureSet/installer seam; the install point has one adapter (module). -supersedes: null -superseded_by: null ---- - -# Expose installs into modules only; no FixtureSet install seam - -**Decision:** `expose()` installs fixtures into modules only; we will not -introduce a `FixtureSet` (or equivalent installer) seam. - -## Context - -`expose()` does two things: it **decides** which providers become fixtures -(discovery, the skip-non-Provider rule, cross-group collision detection) and it -**installs** the resulting fixtures by `setattr`-ing them onto a module. - -The decision half was extracted into the private, pure -`_collect_fixtures(*groups) -> dict[str, AbstractProvider]` in -`modern_di_pytest/factory.py`. That captured the durable value: the rules are -now testable through a return value, and the test suite collapsed accordingly. - -A recurring follow-up suggestion is to also extract the *install* half — wrap -the `name -> provider` mapping in a `FixtureSet` object exposing -`.install(into=...)`, turning the install target into a seam: - -```python -fixtures = collect_fixtures(Dependencies, Auth) # a FixtureSet -fixtures.install(into=module) -``` - -## Decision & rationale - -The architectural test for introducing a seam is **one adapter = hypothetical -seam, two = real one**. The install targets that actually exist are: - -- the **caller's module** (default, located via `inspect.stack()`), and -- an **explicit module** passed as `module=`. - -Both are `types.ModuleType` — the *same* adapter type exercised with two -instances, not two different adapters. There is no concrete non-module install -target (a pytest class namespace, a programmatic/inspection consumer, a -modern-di ecosystem integration), now or clearly coming. The library is, and is -expected to remain, a conftest-level adapter that installs fixtures into modules. - -So a `FixtureSet` would fail the deletion test: delete it and no complexity -reappears — the `setattr` loop simply inlines back into `expose()`. It would add -an interface without adding behaviour. The install step therefore stays as a -plain `setattr` loop inside `expose()`, over the mapping returned by -`_collect_fixtures`. The public surface stays at two symbols (`expose`, -`modern_di_fixture`). - -## Revisit trigger - -A real second install target appears — two genuinely different adapters at the -install point (e.g. installing onto a pytest test-class namespace, or handing -the mapping to a programmatic consumer). At that moment the seam becomes real -and a `FixtureSet` earns its keep. diff --git a/planning/deferred.md b/planning/deferred.md deleted file mode 100644 index 4385f30..0000000 --- a/planning/deferred.md +++ /dev/null @@ -1 +0,0 @@ -# Deferred — real-but-unscheduled items, each with a revisit trigger diff --git a/planning/index.py b/planning/index.py deleted file mode 100644 index 2d70ac3..0000000 --- a/planning/index.py +++ /dev/null @@ -1,183 +0,0 @@ -# ruff: noqa: INP001 # planning/ is not a Python package (this file is vendored into consumers' planning/) -"""Generate the planning index from frontmatter. - -Run via ``just index``. Globs ``planning/changes/*.md`` and -``planning/decisions/*.md``, reads their frontmatter, and prints a Markdown -listing to stdout — changes then decisions, newest-first. Never writes a file: -the listing is a query over the files, not a committed artifact. - -``date`` and ``slug`` are derived from the file name, not -frontmatter — the name is the single source of truth for both. -""" - -import pathlib -import re -import sys - - -ROOT = pathlib.Path(__file__).parent -VALID_DECISION_STATUS = {"accepted", "superseded"} -CHANGE_RE = re.compile(r"^(?P\d{4}-\d{2}-\d{2})\.\d{2}-(?P.+)$") -DECISION_RE = re.compile(r"^(?P\d{4}-\d{2}-\d{2})-(?P.+)$") -SPEC_REQUIRED = ("summary",) -DECISION_REQUIRED = ("status", "summary") - - -def parse_frontmatter(text: str) -> dict[str, str]: - """Parse a single-line-scalar YAML frontmatter block into a dict.""" - lines = text.splitlines() - if not lines or lines[0].strip() != "---": - return {} - fields: dict[str, str] = {} - for line in lines[1:]: - if line.strip() == "---": - break - if line[:1] in (" ", "\t"): - continue - key, sep, value = line.partition(": ") - if not sep: - continue - cleaned = value.strip().strip('"').strip("'") - fields[key.strip()] = "" if cleaned == "null" else cleaned - return fields - - -def _named(fields: dict[str, str], name: str, pattern: re.Pattern[str]) -> dict[str, str]: - """Inject ``date``/``slug`` derived from a file name into ``fields``.""" - match = pattern.match(name) - if match: - fields["date"] = match.group("date") - fields["slug"] = match.group("slug") - return fields - - -def load_changes(root: pathlib.Path) -> list[dict[str, str]]: - """Read each change file's summary; derive date/slug from the file name.""" - changes_dir = root / "changes" - changes: list[dict[str, str]] = [] - if not changes_dir.is_dir(): - return changes - for path in sorted(changes_dir.glob("*.md")): - if path.name == "README.md" or path.name.startswith(("_", ".")): - continue - fields = _named(parse_frontmatter(path.read_text(encoding="utf-8")), path.stem, CHANGE_RE) - fields["path"] = f"changes/{path.name}" - fields["name"] = path.stem - changes.append(fields) - return changes - - -def load_decisions(root: pathlib.Path) -> list[dict[str, str]]: - """Read each decision's frontmatter; derive date/slug from the file name.""" - decisions_dir = root / "decisions" - decisions: list[dict[str, str]] = [] - if not decisions_dir.is_dir(): - return decisions - for path in sorted(decisions_dir.glob("*.md")): - if path.name == "README.md" or path.name.startswith("_"): - continue - fields = _named(parse_frontmatter(path.read_text(encoding="utf-8")), path.stem, DECISION_RE) - fields["path"] = f"decisions/{path.name}" - fields["name"] = path.stem - decisions.append(fields) - return decisions - - -def format_row(row: dict[str, str]) -> str: - """Render one change or decision as a Markdown list item.""" - slug = row.get("slug", "?") - path = row.get("path", "") - date = row.get("date", "") - summary = row.get("summary") or "(no summary)" - line = f"- **[{slug}]({path})** ({date}) — {summary}" - if row.get("supersedes"): - line += f" _(supersedes {row['supersedes']})_" - if row.get("superseded_by"): - line += f" _(superseded by {row['superseded_by']})_" - return line - - -def render(changes: list[dict[str, str]], decisions: list[dict[str, str]]) -> str: - """Render the full Markdown listing: changes then decisions, newest-first.""" - out = ["# Planning index", "", "_Generated by `just index` — do not edit._", "", "## Changes", ""] - change_rows = sorted(changes, key=lambda b: b.get("name", ""), reverse=True) - out += [format_row(b) for b in change_rows] if change_rows else ["_None._"] - out += ["", "## Decisions", ""] - decision_rows = sorted(decisions, key=lambda d: d.get("name", ""), reverse=True) - out += [format_row(d) for d in decision_rows] if decision_rows else ["_None._"] - out.append("") - return "\n".join(out).rstrip() + "\n" - - -def _require(fields: dict[str, str], keys: tuple[str, ...], rel: str, violations: list[str]) -> None: - """Append a violation for each required key that is absent or empty.""" - violations.extend(f"{rel}: missing or empty frontmatter key '{key}'" for key in keys if not fields.get(key)) - - -def _check_change(path: pathlib.Path, violations: list[str]) -> None: - """Validate one change file (requires `summary`).""" - rel = f"changes/{path.name}" - if CHANGE_RE.match(path.stem) is None: - violations.append(f"{rel}: file name is not 'YYYY-MM-DD.NN-slug.md'") - fields = parse_frontmatter(path.read_text(encoding="utf-8")) - _require(fields, SPEC_REQUIRED, rel, violations) - - -def _check_decision(path: pathlib.Path, violations: list[str]) -> None: - """Validate one decision file (requires `status` + `summary`).""" - rel = f"decisions/{path.name}" - if DECISION_RE.match(path.stem) is None: - violations.append(f"{rel}: file name is not 'YYYY-MM-DD-slug.md'") - fields = parse_frontmatter(path.read_text(encoding="utf-8")) - _require(fields, DECISION_REQUIRED, rel, violations) - status = fields.get("status", "") - if status and status not in VALID_DECISION_STATUS: - violations.append(f"{rel}: invalid status '{status}' (allowed: {', '.join(sorted(VALID_DECISION_STATUS))})") - - -def check(root: pathlib.Path) -> list[str]: - """Validate every change file and decision; return the list of violation strings.""" - violations: list[str] = [] - changes_dir = root / "changes" - decisions_dir = root / "decisions" - if changes_dir.is_dir(): - for path in sorted(changes_dir.iterdir()): - if path.is_dir(): - violations.append( - f"changes/{path.name}: directory found — convention 2.0.0 uses flat change files " - f"(changes/YYYY-MM-DD.NN-slug.md; see CHANGELOG 2.0.0 for the migration)" - ) - continue - if path.name == "README.md" or path.name.startswith(("_", ".")): - continue - if path.suffix != ".md": - violations.append(f"changes/{path.name}: unexpected non-md file in changes/") - else: - _check_change(path, violations) - if decisions_dir.is_dir(): - for path in sorted(decisions_dir.glob("*.md")): - if path.name == "README.md" or path.name.startswith("_"): - continue - _check_decision(path, violations) - return violations - - -def main(argv: list[str] | None = None, root: pathlib.Path | None = None) -> int: - """Print the listing to stdout, or validate change files and decisions with --check.""" - argv = sys.argv[1:] if argv is None else argv - root = ROOT if root is None else root - if "--check" in argv: - violations = check(root) - if violations: - sys.stderr.write(f"planning: {len(violations)} violation(s)\n") - for violation in violations: - sys.stderr.write(f" - {violation}\n") - return 1 - sys.stdout.write("planning: OK\n") - return 0 - sys.stdout.write(render(load_changes(root), load_decisions(root))) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/planning/links.py b/planning/links.py deleted file mode 100644 index c11f861..0000000 --- a/planning/links.py +++ /dev/null @@ -1,148 +0,0 @@ -# ruff: noqa: INP001 # planning/ is not a Python package (this file is vendored into consumers' planning/) -"""Check every relative Markdown link and heading anchor in the repository. - -Run via ``just check-links``. Exists because a site builder only validates the -directory it publishes: a repo's ``architecture/`` and ``planning/`` trees usually -sit outside it, are read on GitHub, and rot silently. In the repo this convention -came from, anchors in ``architecture/`` broke three times in one week, each caught -only by a human re-deriving slugs by hand. - -Slugs follow **GitHub's** algorithm, because that is where these files are read — -including the ones a site builder also publishes. Where the two disagree, the fix -is to change the heading rather than to teach this checker both dialects: a heading -containing an em dash yields ``a--b`` on GitHub (the dash is dropped, both spaces -become hyphens) and ``a-b`` under python-markdown (the whitespace run collapses). - -External links are not fetched; this checks the repository's internal consistency. -A relative link that resolves outside the repository is reported rather than followed: -it is a 404 on GitHub, and whether it resolves on disk depends on what the author -happens to have cloned next to the repo — a verdict a lint gate must never depend on. -""" - -import argparse -import collections -import pathlib -import re -import sys - - -SKIP_DIRS = frozenset({".git", ".venv", ".tox", "site", "node_modules", "__pycache__", ".ruff_cache", ".superpowers"}) -FENCE = re.compile(r"^\s*(```|~~~)") -INLINE_CODE = re.compile(r"(`+).+?\1") # any run of backticks delimits a span: `x`, ``a`b`` -HEADING = re.compile(r"^(#{1,6})\s+(.+?)\s*$") -LINK = re.compile(r"\[[^\]]*\]\(\s*([^)\s]+)(?:\s+\"[^\"]*\")?\s*\)") -EXTERNAL = re.compile(r"^(?:[a-z][a-z0-9+.-]*:|//)", re.IGNORECASE) - - -def repo_root(start: pathlib.Path) -> pathlib.Path: - """Nearest ancestor holding ``.git``, else ``start``. - - Found rather than computed because this file has two homes: the canonical repo's - root, and a consumer's ``planning/`` — a fixed relative depth is wrong in one of them. - """ - for candidate in [start, *start.parents]: - if (candidate / ".git").exists(): - return candidate - return start - - -def strip_fences(text: str) -> str: - """Blank out fenced blocks, keeping line count, so code is never read as a heading.""" - out, fenced = [], False - for line in text.splitlines(): - if FENCE.match(line): - fenced = not fenced - out.append("") - continue - out.append("" if fenced else line) - return "\n".join(out) - - -def link_lines(text: str) -> list[str]: - """Lines with fenced blocks and inline spans removed — what to scan for real links. - - Only link scanning strips inline spans. A page documenting the markup an author should - copy is not linking anywhere, while a heading's backticked content is part of its slug. - """ - return [INLINE_CODE.sub("", line) for line in strip_fences(text).splitlines()] - - -def slugify(heading: str) -> str: - """GitHub's heading slug: drop formatting and punctuation, lowercase, spaces to hyphens.""" - text = re.sub(r"`([^`]*)`", r"\1", heading) - text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text) - # `*` and `~` are emphasis; `_` is kept because GitHub keeps it and headings name - # identifiers (`bound_type`) far more often than they use underscore-italics. - text = re.sub(r"[*~]", "", text) - text = "".join(ch for ch in text.lower() if ch.isalnum() or ch in " -_") - return text.strip().replace(" ", "-") - - -def anchors(text: str) -> set[str]: - """Every anchor a reader can target, including GitHub's ``-1``/``-2`` duplicate suffixes.""" - seen: collections.Counter[str] = collections.Counter() - found: set[str] = set() - for line in strip_fences(text).splitlines(): - match = HEADING.match(line) - if not match: - continue - base = slugify(match.group(2)) - found.add(base if not seen[base] else f"{base}-{seen[base]}") - seen[base] += 1 - return found - - -def check(root: pathlib.Path) -> list[str]: - """Return one message per broken link; empty means every internal link resolves.""" - root = root.resolve() - files = sorted(p for p in root.rglob("*.md") if not SKIP_DIRS & set(p.relative_to(root).parts)) - cache: dict[pathlib.Path, set[str]] = {} - violations: list[str] = [] - for path in files: - text = path.read_text(encoding="utf-8") - for line_no, line in enumerate(link_lines(text), 1): - for target in LINK.findall(line): - if EXTERNAL.match(target): - continue - rel, _, fragment = target.partition("#") - # A bare `#frag` targets this same file — the anchor is still checkable, - # and a same-page link rots exactly like a cross-page one. - dest = (path.parent / rel).resolve() if rel else path - where = f"{path.relative_to(root)}:{line_no}" - if dest != root and root not in dest.parents: - # Judged before existence: a sibling repo cloned alongside this one makes - # ../../../other-repo/… resolve on one machine and nowhere else, and it is - # a 404 on GitHub either way. The verdict must not depend on the checkout layout. - violations.append(f"{where}: leaves the repository -> {rel}") - continue - if not dest.exists(): - violations.append(f"{where}: no such file -> {rel}") - continue - if not fragment or dest.suffix != ".md": - continue - if dest not in cache: - cache[dest] = anchors(dest.read_text(encoding="utf-8")) - if fragment.lower() not in cache[dest]: - violations.append(f"{where}: no such anchor -> {target}") - return violations - - -def main(argv: list[str] | None = None, root: pathlib.Path | None = None) -> int: - """Report every broken link; return 1 if any, else 0.""" - parser = argparse.ArgumentParser(description="Check Markdown links and heading anchors.") - parser.add_argument("--root", type=pathlib.Path, default=None) - args = parser.parse_args(sys.argv[1:] if argv is None else argv) - - target = args.root or root or repo_root(pathlib.Path(__file__).resolve().parent) - violations = check(target) - if violations: - sys.stderr.write(f"links: {len(violations)} broken\n") - for violation in violations: - sys.stderr.write(f" - {violation}\n") - return 1 - sys.stdout.write("links: OK\n") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/planning/releases/.gitkeep b/planning/releases/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/planning/releases/2.0.2.md b/planning/releases/2.0.2.md deleted file mode 100644 index b1f55b8..0000000 --- a/planning/releases/2.0.2.md +++ /dev/null @@ -1,36 +0,0 @@ -# modern-di-pytest 2.0.2 — internal refactor, no API changes - -A maintenance release. No public API changes and nothing new to import — -`expose` and `modern_di_fixture` keep the same signatures. The only -user-observable difference is on the error path (atomic collision, below). - -## Internal refactors - -- **`_collect_fixtures` seam.** `expose()` is split into a pure decision — - `_collect_fixtures(*groups)` (discovery, skip-non-Provider, cross-group - collision detection; returns a `name -> provider` mapping) — and a thin - installer that builds fixtures and `setattr`s them onto the module. The rules - are now verified through a return value instead of module side effects, so the - expose test suite collapsed from four near-identical files to three. A - successful `expose()` call behaves exactly as before. - -## Behavior - -- **Atomic collision.** A duplicate fixture name across groups now raises - *before* any fixture is installed, so a failed `expose()` leaves the target - module untouched. Previously the first group's fixtures were already - `setattr`-ed by the time the `ValueError` fired. - -## Downstream - -No action needed — no API change. The collision behavior differs only on a call -that was already going to raise. - -## Internals - -- Adopted the portable - [`planning-convention`](https://github.com/lesnik512/planning-convention): - `planning/` (changes, decisions, releases, templates) and `architecture/`, - with `just check-planning` wired into `lint-ci`. The sole ADR moved into - `planning/decisions/`. Repo-only — none of it ships in the wheel. -- Coverage gate stays at 100%. diff --git a/planning/releases/2.0.3.md b/planning/releases/2.0.3.md deleted file mode 100644 index 7ec3090..0000000 --- a/planning/releases/2.0.3.md +++ /dev/null @@ -1,11 +0,0 @@ -# modern-di-pytest 2.0.3 — release pipeline on PyPI Trusted Publishing - -No library changes. The package is identical to 2.0.2; this release exercises the new publish path end-to-end. - -## CI - -- Releases now authenticate to PyPI via **Trusted Publishing (OIDC)** instead of a long-lived `PYPI_TOKEN` secret. `uv publish` auto-detects the GitHub Actions id-token; the release job runs under a `pypi` environment that scopes the trusted publisher (#20). - -## Downstream - -No action required. Nothing about the installed package changes. diff --git a/planning/releases/2.1.0.md b/planning/releases/2.1.0.md deleted file mode 100644 index 605320c..0000000 --- a/planning/releases/2.1.0.md +++ /dev/null @@ -1,22 +0,0 @@ -# modern-di-pytest 2.1.0 — adopt modern-di's resolve_dependency seam - -A maintenance release. No public API changes — `expose` and `modern_di_fixture` -keep the same signatures and behavior. - -## Internal refactors - -- **`modern_di_fixture` delegates to `container.resolve_dependency`.** The - inline `isinstance(dependency, AbstractProvider)` dispatch — `resolve_provider` - for a Provider, `resolve` for a type — is now `container.resolve_dependency(dependency)`, - the single provider-or-type entry point modern-di 2.25 added for integrations - (`add_providers`/`resolve_dependency`, [modern-python/modern-di#283](https://github.com/modern-python/modern-di/pull/283)). - Overrides, caching, scope checks, and "did you mean" suggestions behave - exactly as before. -- The `modern-di` floor is bumped to `>=2.25,<3` accordingly. This plugin never - reached into `container.providers_registry`, so the sibling `add_providers` - seam does not apply here. - -## Downstream - -Upgrade `modern-di` to `>=2.25` (or let this bump pull it in). No code changes -needed — the fixture and `expose()` contracts are unchanged. diff --git a/planning/releases/3.0.0.md b/planning/releases/3.0.0.md deleted file mode 100644 index 1be4f0e..0000000 --- a/planning/releases/3.0.0.md +++ /dev/null @@ -1,24 +0,0 @@ -# modern-di-pytest 3.0.0 — modern-di 3.x - -Requires **modern-di >= 3, < 4**. **No public API change** — `expose` and -`modern_di_fixture` keep their signatures and behavior. This is a pytest -fixture integration, not a framework adapter — there is no per-connection -container lifecycle to drive. The `tests/conftest.py` fixtures already opened -their containers with `with`, so the fixture layer is unchanged for -[modern-di 3.0's mandatory-open lifecycle](https://modern-di.modern-python.org/migration/to-3.x/). - -## Packaging - -- **Bumped the `modern-di` dependency to `>=3,<4`.** Requires modern-di 3.x; - drops support for modern-di 2.x. - -## Downstream - -No action needed beyond raising your own `modern-di` floor to `>=3`. If your -code builds child containers, open them with `with` / `async with` (or -`open()`) before resolving — see the -[modern-di 3.x migration guide](https://modern-di.modern-python.org/migration/to-3.x/). - -## Internals - -- 100% line coverage; `ruff`, `ty` clean. diff --git a/planning/releases/3.0.1.md b/planning/releases/3.0.1.md deleted file mode 100644 index 3d23a32..0000000 --- a/planning/releases/3.0.1.md +++ /dev/null @@ -1,39 +0,0 @@ -# modern-di-pytest 3.0.1 — documentation catches up with modern-di 3.1 - -**No code changes.** The installed package is byte-identical to 3.0.0; nothing -in `modern_di_pytest/` moved. This release exists to publish the corrected -documentation, because the README a user reads on PyPI is rendered from the -released distribution — so the fix below was invisible until now. - -## Fix - -- **The documented setup no longer passes `validate=True`.** modern-di 3.1 made - `Container(validate=...)` a deprecated no-op that emits - `ValidateArgumentWarning`. The README example therefore emitted a - `DeprecationWarning` on every construction, and — more quietly — silently - stopped validating anything, because that argument *was* the explicit opt-in - to graph validation. This package pins `modern-di>=3`, so users reached that - state on a routine upgrade. - - The docs now build the container plain and call `container.validate()` - explicitly, after `setup_di`. That ordering matters and is unchanged: - `setup_di` registers this integration's providers, so validating first fails - any provider with a non-optional dependency on them. - -## Internals - -- Adopted `ruff` 0.16.0 formatting and lint rules. - -## Downstream - -Nothing is required of you. If you copied the old README snippet, drop -`validate=True` — it does nothing on modern-di 3.1 and warns: - -```python -container = Container(groups=[Dependencies]) -setup_di(...) -container.validate() # optional fail-fast, after setup_di registers its providers -``` - -Keeping it works, but a suite configured with `error::DeprecationWarning` will -fail on it. The argument is removed in modern-di 4.0. diff --git a/tests/test_public_surface.py b/tests/test_public_surface.py new file mode 100644 index 0000000..466e3bc --- /dev/null +++ b/tests/test_public_surface.py @@ -0,0 +1,22 @@ +import types + +import modern_di_pytest + + +def test_public_surface_is_exactly_expose_and_modern_di_fixture() -> None: + """INVARIANT: the package exports exactly ``expose`` and ``modern_di_fixture``. + + Broken by promoting a helper to a public name, in ``__all__`` or as an unprefixed + binding in ``__init__`` -- the latter is public whether or not it was meant to be. + Two symbols are the whole semver contract of an adapter this thin, and being thin is + the point: every name added here is one a major release has to keep working, and the + surface is the only place that cost is visible before it is paid. + """ + public = sorted( + name + for name, value in vars(modern_di_pytest).items() + if not name.startswith("_") and not isinstance(value, types.ModuleType) + ) + + assert public == ["expose", "modern_di_fixture"] + assert modern_di_pytest.__all__ == public