Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/_checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
38 changes: 6 additions & 32 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,47 +25,22 @@ 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).
# Auth via PyPI Trusted Publishing (OIDC); no PYPI_TOKEN. Needs a Trusted
# Publisher on the modern-di-pytest PyPI project (env: pypi, workflow: release.yml).
- run: just publish

# Description source: planning/releases/<tag>.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 <tag> --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
Expand All @@ -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
75 changes: 53 additions & 22 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <expr>`)
- 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/<capability>.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)
32 changes: 32 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 0 additions & 17 deletions architecture/README.md

This file was deleted.

30 changes: 30 additions & 0 deletions docs/adr/0001-expose-installs-into-modules-only.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading