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
4 changes: 2 additions & 2 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ effect. State the numbers, not "benchmarked".
`modern_di/` → don't write it; enforceable → a test; a user needs it →
`docs/`; otherwise it does not get written.
- [ ] **Rejected an alternative** with reasoning that would otherwise be
re-litigated? File it in [`planning/decisions/`](../planning/decisions/)
with a revisit trigger — not here.
re-litigated? File it as an ADR in [`docs/adr/`](../docs/adr/), numbered
`NNNN-slug.md`, with a revisit trigger — not here.
- [ ] **Found real work you are not doing now?** File it in
[`planning/deferred/`](../planning/deferred/), self-contained, with a
revisit trigger — not here.
Expand Down
20 changes: 10 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ or read it for every recipe and its intent. The non-obvious essentials:
- `just test [args]` — pytest, **no coverage**; targeted runs won't trip the
gate. Passes args through: `just test tests/providers/test_factory.py -k <substring>`.
- `just test-ci` — the **gated** full run (100% line coverage); this is what CI runs.
- `just lint` (autofix) / `just lint-ci` (no autofix; also validates planning bundles).
- `just check-planning` validates `planning/deferred/` + `planning/decisions/` frontmatter; `just index` prints that listing.
- `just lint` (autofix) / `just lint-ci` (no autofix; also runs the repo-wide link check).
- `just check-links` validates every relative Markdown link and heading anchor in the repo, including the trees `mkdocs --strict` never sees.

## Architecture

- **Scope** — `IntEnum`, `APP=1 → SESSION=2 → REQUEST=3 → ACTION=4 → STEP=5`. A provider resolves only from a container of the same or deeper (higher-int) scope; otherwise a clear error is raised.
- **Container** — the central object. Root: `Container(scope=Scope.APP, groups=[MyGroup])`; children via `container.build_child_container(scope=Scope.REQUEST, context={...})`. Children share the parent's providers/overrides registries; cache/context are per-container. Pass `validate=True` (or call `container.validate()`) for cycle + transitive-scope checks.
- **Container** — the central object. Root: `Container(scope=Scope.APP, groups=[MyGroup])`; children via `container.build_child_container(scope=Scope.REQUEST, context={...})`. Children share the parent's providers/overrides registries; cache/context are per-container. Call `container.validate()` for cycle + transitive-scope checks; it is the only trigger — `Container(validate=...)` is accepted, ignored, and warns.

There is no separate capability-page home for behavior detail — it lives in the code and its
`INVARIANT:`-marked tests. Before writing prose about a capability, run the admission check in
Expand Down Expand Up @@ -67,15 +67,15 @@ verification); it is reviewed with the diff. There is no change file and no lane
to choose. A trivial PR (typo, dep bump, formatter) deletes the template and
ships a conventional-commit title.

Two things outlive the PR and are committed under `planning/`: an alternative
**rejected** with reasoning goes to `planning/decisions/`, and real work **not
Two things outlive the PR: an alternative **rejected** with reasoning becomes an
ADR in [`docs/adr/`](docs/adr/) (`NNNN-slug.md`, sequential — see
[`docs/agents/domain.md`](docs/agents/domain.md)), and real work **not
scheduled** goes to `planning/deferred/` (self-contained, with a revisit
trigger). There is no separate truth-home directory — the living truth about
behaviour is the code and its `INVARIANT:`-marked tests, and a behaviour change
is reviewed with the diff, not promoted to a page. See
[`planning/README.md`](planning/README.md) for the full convention, including
the admission check that decides where a given fact belongs; it is a documented
local deviation from `planning-convention` 2.2.0.
[`planning/README.md`](planning/README.md) for the admission check that decides
where a given fact belongs.

- **Cutting a release (maintainers)** is tag-driven via
[`.github/workflows/release.yml`](.github/workflows/release.yml): write the
Expand Down Expand Up @@ -103,8 +103,8 @@ local deviation from `planning-convention` 2.2.0.
- Docstrings: public API documents the contract; internal helpers get a
one-line contract, plus at most 1–2 lines for a genuinely non-obvious
constraint. Never narrate implementation or justify code to a reviewer —
cross-file rationale lives in an `INVARIANT:` test docstring or
`planning/decisions/`.
cross-file rationale lives in an `INVARIANT:` test docstring or an ADR under
`docs/adr/`.

## Vocabulary

Expand Down
24 changes: 24 additions & 0 deletions docs/adr/0001-sync-async-close-separate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Keep sync and async `close` paths separate

**Decision:** `close_sync` / `close_async` stay explicit pairs at all three layers (`Container`,
`CacheRegistry`, `CacheItem`); they are not unified into single parametrized methods.

"Six near-identical `close_*` methods" is a surface reading. Only the `Container` pair is
near-identical (one line differs). The other two diverge intrinsically:

- **`CacheItem`** — async awaits the finalizer's result; sync **cannot await**, so it detects an
async finalizer, `.close()`s the coroutine to suppress the never-awaited warning, and raises
`AsyncFinalizerInSyncCloseError`.
- **`CacheRegistry`** — async clears `_creation_order` entirely; sync **preserves** the items that
raised `AsyncFinalizerInSyncCloseError` so a later `close_async()` can finish them.

The genuinely shared code is a ~4-line wrapper, a one-line guard, and an iterate-collect-raise
skeleton; unifying would re-introduce the sync-can't-await and preserve-for-later behaviours as
conditional branches, **adding** complexity rather than concentrating it. Those branches also carry
every historical finalizer fix — LIFO teardown (`3f9a64b`), await/reject sync finalizers
(`19e7c72`), async-finalizer rejection (`faf2108`), `clear_cache` finalizer-dedup (`8ce0ff4`), all
shipped in 2.15.0 — and the area has been stable since.

**Revisit trigger:** finalizer/close bugs start recurring (the signal that the explicit pairs are
*causing* errors rather than encoding them), or a third distinct sync/async-spanning consumer
appears that would share real logic.
22 changes: 22 additions & 0 deletions docs/adr/0002-cache-arg-over-singleton-class.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Ergonomic caching toggle: `cache=` argument, not a `Singleton` class

**Decision:** `Factory`'s caching toggle is one `cache` argument accepting `bool | CacheSettings |
None` — absent/`None`/`False` off, `True` on with defaults, `CacheSettings(...)` on and tuned. One
argument, one mental model, one place caching is expressed. `CacheSettings` is unchanged as the
tuning object; the sugar normalizes into the existing `self.cache_settings` attribute.

Rejected alternatives, all forms of expressing caching twice:

- **A `Singleton` provider class.** Says "caching is on" in the class name *and* in a still-required
`cache_settings` for finalizers (the most common advanced case); the two can drift, and forbidding
`cache_settings` on a `Singleton` would strand finalizers. It also reverses the 2.x "no separate
`Singleton` class" call.
- **A `cached=True` flag alongside `cache_settings=`.** Two arguments meaning "cache" needs a
both-passed conflict rule, and `cached=True` has no path to a finalizer without switching forms.
- **Overloading `cache_settings=` to accept `True`.** Works and needs no new name, but the noun-y
argument reads wrong (`settings=True`); renaming keeps the single-axis model and reads naturally
in both forms.

**Revisit trigger:** a caching mode a single `bool | CacheSettings` argument cannot express cleanly.
The other half of the original trigger has fired and resolved: 3.0 dropped the `cache_settings=`
alias, so `cache=` is the only spelling.
17 changes: 17 additions & 0 deletions docs/adr/0003-no-enter-scope-alias.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# No `enter_scope` alias for `build_child_container`

**Decision:** `build_child_container` remains the single scope-entry spelling; no `enter_scope`
alias and no rename.

Every peer names this operation by intent — wireup `enter_scope({Type: obj})`, .NET
`CreateScope()`, dishka's callable container — and `build_child_container` names the mechanism and
is the longest scope-entry spelling in the studied field. Rejected anyway: in modern-di the
mechanism *is* the concept. Child containers are real, user-visible objects with their own cache and
context registries, and "enter scope" vocabulary would hide exactly the mental model the docs work
to teach. A second spelling of the most-written call after `resolve()` also conflicts with the
conservative-feature-set constraint, with wireup's serial renames as the cautionary precedent.

**Revisit trigger:** recurring user feedback that scope entry is hard to discover — issues asking
"how do I enter a request scope". Closing the migrant-familiarity gap in the docs, with a
vocabulary-table row mapping `enter_scope` / `CreateScope` to `build_child_container`, is the
cheaper response and has not been written yet.
18 changes: 18 additions & 0 deletions docs/adr/0004-no-generator-creators.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# No generator creators in core `Factory`

**Decision:** `Factory` does not auto-detect generator creators and turn post-`yield` code into a
finalizer; `CacheSettings(finalizer=)` remains the only teardown spelling in core.

Every Python peer — dishka, wireup, svcs, FastAPI yield-dependencies, that-depends `Resource` —
spells teardown as code after `yield`, making it the strongest muscle-memory delta for migrants. It
was rejected because the change is breaking (`Factory(creator=generator_fn)` is legal today and
resolves to the raw generator object) and carries open design complexity: per-instance finalizer
records for non-cached factories, declaration-time rejection of async generators, and `bound_type`
extraction from `Iterator[T]`. That is a large addition against a modest ergonomic win, and the
capability is reachable without core changes — a `Factory` subclass in userland or a sibling package
can wrap a generator creator and register the continuation through `CacheSettings(finalizer=)`. One
explicit teardown spelling also preserves the property that async finalizers work under sync
resolution, which the generator form cannot express.

**Revisit trigger:** recurring user requests for yield-based teardown, or a community-built
generator-factory subclass demonstrating both the demand and a settled design.
26 changes: 26 additions & 0 deletions docs/adr/0005-no-multibinding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# No multibinding or collection injection

**Decision:** `modern-di` does not support registering several providers for one type and injecting
them together as a collection. There is no `multi=True`, no `list[T]` fan-in, no binder-set API.
Field precedent is broad (MEDI's `IEnumerable<T>`, Spring's `List<T>`, `injector`'s multibind,
Angular's `multi: true`), and the usual motivation is plugin-style extension.

The registry is a **type → provider map**. Multibinding turns it into type → *collection of*
providers, which changes every operation built on it:

- Registration stops being "this type is now wired" and becomes "this type has one more
contributor", so `DuplicateProviderTypeError` has to become conditional on an opt-in flag.
- Resolution by type stops having one answer: `resolve(T)` and `resolve(list[T])` would resolve
different things from one registration, and the wiring plan would need a third parameter category.
- Overrides get ambiguous — overriding `T` either replaces the collection or one contributor, and
both readings are defensible.
- Validation loses the property that a missing type is unambiguous: an empty collection is
indistinguishable from a wiring mistake.

That is permanent, structural registry cost for demand inferred from other ecosystems rather than
observed in this one. The workaround costs a user one provider: a `Factory` that takes the
individual dependencies and returns the list.

**Revisit trigger:** concrete user demand — a real plugin-style use case the one-Factory workaround
does not serve. Field precedent alone is not the trigger; it is what was already weighed here. If
reopened, the registry-semantics consequences above are the design problem, not the API spelling.
20 changes: 20 additions & 0 deletions docs/adr/0006-no-redirect-plugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# No redirect plugin — merged-page URLs 404

**Decision:** no dependency on `mkdocs-redirects` or any redirect plugin. The two URLs orphaned by
the docs-dedupe merges (`testing/fixtures/`, `introduction/that-depends-or-modern-di/`) 404.

`mkdocs-redirects` 1.2.3 (2026-03-28) is a hostile release: it adds a dependency on `properdocs` — a
MkDocs fork whose code hooks into every build to print scare-marketing urging users to switch to the
fork — and caps `mkdocs<=1.6.1`, fighting this repo's `mkdocs>=1.6,<2` pin. The prior release,
1.2.2 (2024-11-07), is clean, and there has been no clean release since. Two 404s is a small,
contained cost; carrying a supply-chain-compromised dependency to avoid it is not.

- **Pinning `==1.2.2`** freezes the immediate problem but leaves an untrustworthy upstream in the
chain: any loosened resolution re-admits 1.2.3+, and the pin is a standing note-to-self that has
to survive every future audit.
- **A local mkdocs hook** avoids the dependency but adds build-time code to maintain for two URLs.
- **Committed meta-refresh stub pages** work without a plugin but add permanently maintained files
for a problem that exists only because of the merge.

**Revisit trigger:** MkDocs gains native redirect support, or `mkdocs-redirects` changes hands /
publishes a clean release dropping the `properdocs` dependency and the `mkdocs<=1.6.1` cap.
29 changes: 29 additions & 0 deletions docs/adr/0007-unify-graph-traversal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Extract the shared provider-graph traversal, keep the two cycle policies

**Decision:** one `DependencyGraph` module owns the provider-graph traversal and cycle extraction;
`validate()`, the runtime `RecursionError` guard, and alias scope-resolution all call it. The two
*policies* stay distinct (collect-all vs first-cycle).

Before it, both detectors re-implemented the same traversal — the `path[cycle_start:]` slice and
`CircularDependencyError` construction appeared verbatim in each — and `Alias.effective_scope`
hand-rolled a third chain-walk. With the traversal shared, deleting `DependencyGraph` makes
cycle-detection complexity reappear across all four callers: a real seam, not a hypothetical one.
dishka confirms the one-walk-many-concerns model; it can drop its runtime guard only because it
makes validation effectively mandatory.

Rejected alternatives:

- **A pure cycle-extraction helper.** Removes the verbatim copy but leaves the DFS structure written
twice; fails the deletion test.
- **Two traversal methods in the module** (recursive walk + iterative find). Relocates the
duplication rather than removing it.
- **Type-checking `Alias` inside `DependencyGraph`** to follow the chain. Reintroduces concrete-type
import coupling; a generic `redirect_target` node hook keeps the module Alias-agnostic.
- **A per-container validated stamp.** A child would not inherit the root's validation; the graph is
shared, so the stamp is registry-level.
- **Folding scope-inversion into reachability (dishka-style).** Simpler, but yields a less precise
error (`missing` vs `inverted`); the dedicated `InvalidScopeDependencyError` is worth more.

**Revisit trigger:** benchmarks show the `walk()` event-stream indirection measurably slows
`validate()` or the guard. The original second limb — validation becoming mandatory, collapsing the
seam to one caller — is retired: 3.1 went the other way, so the runtime guard is permanent.
29 changes: 29 additions & 0 deletions docs/adr/0008-integration-kit-shape.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Integration kit is low-level primitives in core, and outliers bypass it

**Decision:** the shared adapter skeleton lives in a framework-agnostic module inside core, exposing
only low-level primitives; genuine outliers call `build_child_container` directly rather than the
primitives growing parameters to swallow them.

- **In core, not a new package.** The skeleton imports only stdlib + `modern_di`, so it does not
threaten the zero-dependency stance, and it deepens the `add_providers`/`resolve_dependency` seam
core already blesses. A 14th repo would add coordinated-release cost for agnostic code every
adapter already reaches through its `modern-di` dependency.
- **Low-level primitives only.** A `make_inject` convenience fails the deletion test: the adapters'
wrapper shapes are not identical (each fetches the child differently — request, ASGI scope, `g`,
contextvar), so the convenience would take a `get_container` callable and wrap three primitives —
a shallow module, exactly what the extraction exists to remove.
- **Outliers bypass.** Each absorbing parameter (scope-resolver callable, post-build `set_context`
hook, no-context mode) is needed by exactly one adapter — a hypothetical seam. Adding them taxes
the ten common-case adapters; keeping weird logic in the weird adapter is better locality.

Reading all 13 adapters concretely narrowed what "bypass" means: only **typer** is a true Layer-1
bypass (it binds no connection at all). aiohttp and grpc both use `bind(provider, connection)` and
only skip `classify_connection` — aiohttp because both its providers share one type so `isinstance`
cannot dispatch, grpc because it has one provider and no dispatch to do; grpc's `_build_child`
collapses to one `bind()` call, dropping its post-hoc `set_context`. aiogram's context is a
multi-provider merge with a hardcoded scope, a third shape `bind()` does not fit, so it stays a
two-line literal. No primitive grew a parameter for any of them.

**Revisit trigger:** a third adapter needs the same non-`isinstance` scope-dispatch (making the
absorb-it seam real under the two-adapter rule), or adapter authors request the convenience layer
because the residual `inject` glue proves non-trivial in practice.
23 changes: 23 additions & 0 deletions docs/adr/0009-error-text-is-not-a-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Rendered error text is not a public contract

**Decision:** the *rendered* text of a `ModernDIError` is diagnostic output and may change in any
release. The **structured attributes** each error carries (`.provider_type`, `.cycle_path`,
`.suggestions`, `.dependency_path`, …) and the **class hierarchy** callers catch on are the public
contract, and those change only with the usual care.

The forcing case: unifying the two chain drawers means `CircularDependencyError` renders through the
same path as `DependencyPathMixin`, which prints an aligned scope column. Either the cycle message
gains that column, or the shared drawer carries a `show_scope` flag forever to keep output
byte-identical. Freezing the bytes buys nothing real and costs compounding — every renderer grows a
compatibility flag and the formatting can never improve — so the cycle message gains the column and
the drawer needs no flag.

The attributes are the other half of the split: a caller who wants to *act* on an error should read
`.cycle_path`, not regex the message. Pre-rendering suggestions into `.suggestions` violated that by
forcing a programmatic consumer to parse glyphs back out; structured `Suggestion` records fix the
contract rather than break it. This licenses a message *improving* without a deprecation cycle, and
it means message-text assertions in tests pin an implementation detail, not a promise.

**Revisit trigger:** a downstream consumer — an integration, or a user in an issue — is found
parsing `str(exc)` to recover structured facts. That means the attribute surface is missing
something: add the attribute, and keep this decision.
Loading
Loading