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'
19 changes: 6 additions & 13 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,15 @@ jobs:
# Publisher on the modern-di-fastapi 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 @@ -56,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
79 changes: 61 additions & 18 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,70 @@
# AGENTS.md

Guidance for agents working in `modern-di-fastapi` — the
[modern-di](https://modern-di.modern-python.org) integration for FastAPI.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Workflow
## Project Overview

`modern-di-fastapi` is the FastAPI integration for
[`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.

Before making a change, follow the **Quick path** in
[`planning/README.md`](planning/README.md) — the authoritative planning
convention. Pick a lane (Full / Lightweight / Tiny), create the change file
under `planning/changes/` when the lane calls for one, and run
`just check-planning` before pushing.
## Commands

`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

[`architecture/`](architecture/) holds the living truth about what the system
does **now** — one file per capability, plus `glossary.md`. **When a change
alters a capability's behavior, update the matching
`architecture/<capability>.md` in the same PR**, alongside the code; the *why*
stays in the change file under `planning/changes/`.
All implementation is `modern_di_fastapi/main.py`, short enough to read whole. Read it.

### Testing patterns

`tests/dependencies.py` is the model every test builds on: one `Group` spanning four scopes, plus
two factories that read the live `Request` / `WebSocket` to exercise the context providers. Routes
are declared **inside the test body**, decorating the `app` fixture after `client` has already built
its `TestClient` — FastAPI picks them up, and it keeps each test's route next to its assertions.

## Workflow

**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.

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:

| Home | Holds |
|---|---|
| `modern_di_fastapi/` | 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 |

Before writing a line anywhere:

> Can an agent get this by reading `modern_di_fastapi/`? → **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.

## Build & checks
**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.

- `just lint` / `just lint-ci` — format, ruff, `ty`; `lint-ci` also runs
`check-planning`.
- `just test` — pytest (100% coverage required via `just test-ci`).
- `just index` — print the generated planning index.
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.
32 changes: 32 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# modern-di-fastapi

The [`modern-di`](https://github.com/modern-python/modern-di) integration for FastAPI: it opens the
container across the app's lifespan, opens a scoped child container per connection, and resolves
declared dependencies into route and WebSocket parameters through FastAPI's own `Depends`.

## 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` (root and child), `Provider`, `Group`, `Scope`,
`Resolution`, `Override`, `Connection`. That project's `CONTEXT.md` is the authority for all of
them; nothing here redefines one. FastAPI's are FastAPI's: dependency, `Depends`, router, request,
WebSocket. The two below are this package's own.

**Per-connection container**:
The child container `build_di_container` opens for one connection and closes when it ends —
`REQUEST`-scoped for a `fastapi.Request`, `SESSION`-scoped for a `fastapi.WebSocket`. Every `FromDI`
parameter in one handler resolves from the same one.
_Avoid_: per-request container, request container. A WebSocket's is `SESSION`-scoped, so "request"
names one of the two kinds while reading as the wrong scope for the other.

**Composed lifespan**:
The app's own lifespan with the container's open/close nested inside it, installed by `setup_di`
onto `app.router.lifespan_context`. The original stays the outer context and its yielded state
passes through untouched.
_Avoid_: merged lifespan — "merge" is FastAPI's own word for the private helper this package
deliberately does not use, which also merges the two yielded states
([ADR-0001](docs/adr/0001-own-the-lifespan-composition.md)). Also avoid appended lifespan: the
container's open/close runs *inside* the app's, not after it.
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ uv add modern-di-fastapi # or: pip install modern-di-fastapi

## Usage

`setup_di` registers the container and builds a per-request child container automatically; `FromDI` resolves a provider (or type) into a route parameter.
`setup_di` registers the container and builds a per-connection child container automatically; `FromDI` resolves a provider (or type) into a route parameter.

```python
import dataclasses
Expand Down Expand Up @@ -73,10 +73,10 @@ The framework `Request` / `WebSocket` are resolvable within DI via the pre-built

| Symbol | Description |
|---|---|
| `setup_di(app, container)` | Stores the container on `app.state` and appends a lifespan that closes it on shutdown (merges with any existing `lifespan=`) |
| `FromDI(provider, *, use_cache=True)` | FastAPI `Depends` that resolves a provider (or type) from the per-request child container |
| `setup_di(app, container)` | Stores the container on `app.state` and composes the container's open/close into the app lifespan (nesting inside any existing `lifespan=`) |
| `FromDI(provider, *, use_cache=True)` | FastAPI `Depends` that resolves a provider (or type) from the per-connection child container |
| `fetch_di_container(app)` | Returns the app-scoped container from `app.state` |
| `build_di_container(connection)` | FastAPI `Depends` callable that yields the per-request child container — `REQUEST` scope for an HTTP request, `SESSION` scope for a WebSocket |
| `build_di_container(connection)` | FastAPI `Depends` callable that yields the per-connection child container — `REQUEST` scope for an HTTP request, `SESSION` scope for a WebSocket |
| `fastapi_request_provider` | `ContextProvider` for the current `fastapi.Request` |
| `fastapi_websocket_provider` | `ContextProvider` for the current `fastapi.WebSocket` |

Expand Down
22 changes: 0 additions & 22 deletions architecture/README.md

This file was deleted.

67 changes: 0 additions & 67 deletions architecture/container-lifecycle.md

This file was deleted.

48 changes: 0 additions & 48 deletions architecture/dependency-resolution.md

This file was deleted.

50 changes: 0 additions & 50 deletions architecture/glossary.md

This file was deleted.

Loading
Loading