From 6123712d52e87d6d0de896df0a5185503d615e34 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 6 Sep 2026 20:34:22 +0300 Subject: [PATCH] chore: migrate off the planning/ convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the two-axis planning/ + architecture/ convention with PR-body-as-spec, CONTEXT.md for vocabulary, and docs/adr/ for rejected alternatives, per modern-python/.github#67. - planning/ deleted in full. changes/ and releases/ held nothing git history and GitHub Releases do not; the release notes were byte-identical to the published Release bodies. deferred.md was empty, so no issues needed spawning. - The one decision record becomes docs/adr/0001, keeping its revisit trigger. - architecture/ carried no capability pages. The only claim worth rescuing was the two-symbol public surface, now an INVARIANT test checked against three cases: __all__ growing and an unprefixed public binding both fail it, and narrowing the surface does not. - CONTEXT.md defines only the three terms local to this package; the rest are modern-di's. Its install/inject/register entry reflects real usage (install 12, inject 3, register 2), so the three stragglers in factory.py and README.md were brought to the winner rather than the entry dropped. One is a user-visible ValueError string. - AGENTS.md gains Workflow and Where-a-fact-goes, loses the lanes, and sheds the prose restatement of factory.py that the admission check disallows. - justfile and lint-ci drop index/check-planning/check-links; the offline lychee gate replaces links.py (modern-python/.github#66), reporting 0 errors. Beyond the recipe, and the reason this is chore: and not docs: release.yml sourced the Release body from planning/releases/.md and hard-required one for every stable tag, so deleting planning/ would have failed every future stable release. It now uses GitHub's generated notes, byte-identical to modern-di's post-migration workflow, which dropped the same gate in 1ea74ee ("drop the planning/ directory and the curated-release-notes convention", #449) — one of the five migration PRs #67 cites. Stable releases lose a hard gate; that is the precedent's call, not a new one. --- .github/workflows/_checks.yml | 14 ++ .github/workflows/release.yml | 38 +--- AGENTS.md | 75 ++++--- CONTEXT.md | 32 +++ README.md | 4 +- architecture/README.md | 17 -- .../0001-expose-installs-into-modules-only.md | 30 +++ docs/agents/domain.md | 104 +++++----- justfile | 14 -- modern_di_pytest/factory.py | 6 +- planning/.convention-version | 1 - planning/README.md | 163 ---------------- planning/_templates/change.md | 32 --- planning/_templates/decision.md | 23 --- planning/_templates/design.md | 39 ---- planning/_templates/glossary.md | 15 -- planning/_templates/release.md | 38 ---- planning/changes/.gitkeep | 0 planning/decisions/.gitkeep | 0 ...06-26-expose-installs-into-modules-only.md | 59 ------ planning/deferred.md | 1 - planning/index.py | 183 ------------------ planning/links.py | 148 -------------- planning/releases/.gitkeep | 0 planning/releases/2.0.2.md | 36 ---- planning/releases/2.0.3.md | 11 -- planning/releases/2.1.0.md | 22 --- planning/releases/3.0.0.md | 24 --- planning/releases/3.0.1.md | 39 ---- tests/test_public_surface.py | 22 +++ 30 files changed, 206 insertions(+), 984 deletions(-) create mode 100644 CONTEXT.md delete mode 100644 architecture/README.md create mode 100644 docs/adr/0001-expose-installs-into-modules-only.md delete mode 100644 planning/.convention-version delete mode 100644 planning/README.md delete mode 100644 planning/_templates/change.md delete mode 100644 planning/_templates/decision.md delete mode 100644 planning/_templates/design.md delete mode 100644 planning/_templates/glossary.md delete mode 100644 planning/_templates/release.md delete mode 100644 planning/changes/.gitkeep delete mode 100644 planning/decisions/.gitkeep delete mode 100644 planning/decisions/2026-06-26-expose-installs-into-modules-only.md delete mode 100644 planning/deferred.md delete mode 100644 planning/index.py delete mode 100644 planning/links.py delete mode 100644 planning/releases/.gitkeep delete mode 100644 planning/releases/2.0.2.md delete mode 100644 planning/releases/2.0.3.md delete mode 100644 planning/releases/2.1.0.md delete mode 100644 planning/releases/3.0.0.md delete mode 100644 planning/releases/3.0.1.md create mode 100644 tests/test_public_surface.py 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