diff --git a/AGENTS.md b/AGENTS.md index b1aa80fc..a432eef1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,8 +33,8 @@ Every module under `modern_di/` is named for what it does; read it. What a singl template per resolver shape and `exec`'d with the factory's constants as globals; the other provider types compile to closures. A new provider type must add a branch here or `compile_resolver` raises. Nothing in the template calls a helper on the hot path: the per-node frame budget is the point, and - `test_resolve_costs_exactly_one_resolver_frame_per_node` says why. Overrides are compiled in - (`docs/adr/0030-exec-template-resolver.md`): an override change drops the compiled resolvers. + `test_resolve_costs_exactly_one_resolver_frame_per_node` says why. Overrides are compiled in: an + override change drops the compiled resolvers. - `exceptions.py` owns **every message and every glyph**. A raise site passes structured facts, never formatting; the class renders its own f-string and sets a `docs_slug` (its page under `docs/troubleshooting/`, enforced by `tests/test_docs_slug_census.py`). Add a message, a glyph, or a @@ -79,5 +79,5 @@ a sibling test may be the one that trips. - **Issues and specs** — GitHub Issues on `modern-python/modern-di`, 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, `CONTEXT.md` + `docs/adr/`: [`docs/agents/domain.md`](docs/agents/domain.md) +- **Domain docs** — single-context, `CONTEXT.md`; public choices on the design-decisions page, internal ones in `docs/adr/`: [`docs/agents/domain.md`](docs/agents/domain.md) - **Cutting a release** (maintainers) — [`docs/agents/release.md`](docs/agents/release.md) diff --git a/docs/adr/0001-resolver-hot-path-generated-source.md b/docs/adr/0001-resolver-hot-path-generated-source.md new file mode 100644 index 00000000..7d33d65d --- /dev/null +++ b/docs/adr/0001-resolver-hot-path-generated-source.md @@ -0,0 +1,19 @@ +# The resolve hot path is generated source, one frame per node + +A `Factory` resolver is generated from a source template specialised to the factory's resolver +shape and `exec`'d with the factory's constants as its globals. Nothing on the hot path calls a +shared helper, and `test_resolve_costs_exactly_one_resolver_frame_per_node` enforces it. Every +all-Python single-copy design was measured first and lost 25-80% on a chain: folding the arity +ladder into one closure costs even for the branches not taken (closure size is paid on every call +on CPython 3.12+), and a shared `build()` or `call()` helper is a frame per node. So the +duplication the template removes cannot be removed with functions. Overrides are compiled in for +the same reason: an override change drops the compiled resolvers instead of every resolver +checking the overrides registry. + +## Consequences + +- The hot path is a string. `ruff`, `ty` and coverage do not see it; the shape-enumeration test + in `tests/test_resolver_compiler.py` is the gate for a template / namespace mismatch. +- One code object per shape, not per provider. Per provider is 20-30% faster on chains (call sites + stay monomorphic) but costs ~70 µs of `compile()` per provider, which a suite building a + container per test pays per test. diff --git a/docs/adr/0001-sync-async-close-separate.md b/docs/adr/0001-sync-async-close-separate.md deleted file mode 100644 index 0b32db15..00000000 --- a/docs/adr/0001-sync-async-close-separate.md +++ /dev/null @@ -1,24 +0,0 @@ -# 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. diff --git a/docs/adr/0002-cache-arg-over-singleton-class.md b/docs/adr/0002-cache-arg-over-singleton-class.md deleted file mode 100644 index d0bd64ab..00000000 --- a/docs/adr/0002-cache-arg-over-singleton-class.md +++ /dev/null @@ -1,22 +0,0 @@ -# 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. diff --git a/docs/adr/0003-no-enter-scope-alias.md b/docs/adr/0003-no-enter-scope-alias.md deleted file mode 100644 index 76e61d05..00000000 --- a/docs/adr/0003-no-enter-scope-alias.md +++ /dev/null @@ -1,17 +0,0 @@ -# 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. diff --git a/docs/adr/0004-no-generator-creators.md b/docs/adr/0004-no-generator-creators.md deleted file mode 100644 index 1ebe6f72..00000000 --- a/docs/adr/0004-no-generator-creators.md +++ /dev/null @@ -1,18 +0,0 @@ -# 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. diff --git a/docs/adr/0005-no-multibinding.md b/docs/adr/0005-no-multibinding.md deleted file mode 100644 index d54a0e55..00000000 --- a/docs/adr/0005-no-multibinding.md +++ /dev/null @@ -1,26 +0,0 @@ -# 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`, Spring's `List`, `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. diff --git a/docs/adr/0006-no-redirect-plugin.md b/docs/adr/0006-no-redirect-plugin.md deleted file mode 100644 index cd20d82f..00000000 --- a/docs/adr/0006-no-redirect-plugin.md +++ /dev/null @@ -1,20 +0,0 @@ -# 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. diff --git a/docs/adr/0007-unify-graph-traversal.md b/docs/adr/0007-unify-graph-traversal.md deleted file mode 100644 index b5bc32f5..00000000 --- a/docs/adr/0007-unify-graph-traversal.md +++ /dev/null @@ -1,29 +0,0 @@ -# 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. diff --git a/docs/adr/0008-integration-kit-shape.md b/docs/adr/0008-integration-kit-shape.md deleted file mode 100644 index 9920e1d5..00000000 --- a/docs/adr/0008-integration-kit-shape.md +++ /dev/null @@ -1,29 +0,0 @@ -# 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. diff --git a/docs/adr/0009-error-text-is-not-a-contract.md b/docs/adr/0009-error-text-is-not-a-contract.md deleted file mode 100644 index ea2a51f9..00000000 --- a/docs/adr/0009-error-text-is-not-a-contract.md +++ /dev/null @@ -1,23 +0,0 @@ -# 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. diff --git a/docs/adr/0010-grpc-registry-introspection-declined.md b/docs/adr/0010-grpc-registry-introspection-declined.md deleted file mode 100644 index 30d812f1..00000000 --- a/docs/adr/0010-grpc-registry-introspection-declined.md +++ /dev/null @@ -1,20 +0,0 @@ -# No blessed provider-introspection seam for grpc's registry drill - -**Decision:** no `Container.is_registered(type)` and no idempotent-`add_providers` mode for adapters -to query registration state. `modern-di-grpc` keeps its local `_ensure_context_provider` guard, -which checks `find_provider(ServicerContext) is None` before registering — grpc has no `setup_di` -(constructing an interceptor *is* the setup) and both interceptors may be built on one container, so -the guard prevents a `DuplicateProviderTypeError`. - -The deciding evidence: **grpc is the only consumer.** A grep across every `modern-di-*` adapter and -its tests found no other reader of `providers_registry` / `find_provider`, and by the standing rule -(one adapter is a hypothetical seam, two is a real one — the same principle that kept the -[integration-kit](0008-integration-kit-shape.md) outliers local) a single consumer does not justify -new core API. Reinforcing it: `container.providers_registry` and `find_provider` are both public, so -grpc is using a lower-level public API rather than breaching encapsulation; and `add_providers`' -strictness is a deliberate feature catching accidental double-registration, which an -`ignore_existing` mode would loosen globally to serve one adapter. - -**Revisit trigger:** a **second** adapter needs to query registration state, or a decision to -privatize `container.providers_registry` — at which point a blessed `is_registered` becomes grpc's -migration path. diff --git a/docs/adr/0011-fold-context-registry-declined.md b/docs/adr/0011-fold-context-registry-declined.md deleted file mode 100644 index 4798b0ef..00000000 --- a/docs/adr/0011-fold-context-registry-declined.md +++ /dev/null @@ -1,22 +0,0 @@ -# Keep `ContextRegistry` as its own module - -**Decision:** `modern_di/registries/context_registry.py` is not folded into `Container`. -`ContextRegistry` stays a named registry and `ContextProvider` keeps reading it via -`container.context_registry.find_context(...)`. - -It is the shallowest of the four registries — ~18 lines, a `dict[type, Any]` behind `find_context` -and `set_context` — and the deletion test on the *code* passes: fold it, and `Container` gains a -`self._context` dict plus a `find_context` method for the two touch points. What the deletion test -misses is that the conceptual slot does not vanish. The four registries are organised by a real -axis, stated in `AGENTS.md`'s registries entry: shared tree-wide (`providers_registry`, -`overrides_registry`) versus per-container (`cache_registry`, `context_registry`). `ContextRegistry` -sits symmetric with `CacheRegistry`; its shallowness in line count reflects having less mechanism, -not a broken abstraction. Folding trades a uniform 2×2 model for ~18 fewer lines and grows the -already-largest file, with zero actual friction: no bug hides in the one-line delegation, and -context is not a change hot-path. A predictable four-registry pattern navigates better than one with -an exception. - -**Revisit trigger:** the four-registry model is restructured — any registry folded, or the -shared-vs-per-container framing abandoned — since the symmetry is the load-bearing reason; or -concrete friction emerges, a bug in the `Container` → `ContextRegistry` delegation, or the -indirection repeatedly obstructing context-related work. diff --git a/docs/adr/0012-provider-facing-seam-declined.md b/docs/adr/0012-provider-facing-seam-declined.md deleted file mode 100644 index 592ce9dc..00000000 --- a/docs/adr/0012-provider-facing-seam-declined.md +++ /dev/null @@ -1,28 +0,0 @@ -# No provider-facing seam on `Container` - -**Decision:** no `ResolutionContext` view handed to providers, and no promotion of `Container`'s -provider-facing members into a declared interface. Resolution runs against the `Container` directly -and the existing `# noqa: SLF001` reaches stay. - -The deciding evidence: **there is exactly one `Container` implementation.** Everything that resolves -does so against the same collaborator, so a `ResolutionContext` would be one interface with one -implementation — a hypothetical seam under the standing one-adapter/two-adapter rule, the same -reasoning that declined [the grpc introspection seam](0010-grpc-registry-introspection-declined.md). - -The crossings it would formalize have since gone to zero. At decision time there were three -(`Factory._lock`, plus `_warn_and_reopen_if_closed` on `Factory` and `ContextProvider`); the -single-path compiled resolver dissolved `Factory.resolve` and `Alias.resolve`, moving the lock and -closed-state reaches into `resolver_compiler.py` — the compiler's business, ruled on in -[the per-provider compile seam](0014-per-provider-compile-seam-declined.md). The last one, -`ContextProvider.fetch_context_value` calling `container._prepare()`, has had no production caller -since #425. Option (a) would also have changed `AbstractProvider.resolve(container)` — then the -documented public extension contract — to `resolve(ctx)`: a large blast radius to formalize a -boundary only core code crosses, where `docs/providers/advanced-api.md` already declares which -members are supported (`find_container`) and which are internal (`_lock`, `_scope_map`, -`parent_container`). - -**Revisit trigger:** a **second `Container` implementation** appears, making `ResolutionContext` a -real two-adapter seam. The original second limb — a custom-provider author blocked on "do not build -on" internals — is retired: [the provider set is closed](0013-custom-providers-retracted.md), so -that author does not exist. If the set reopens, the migration path is the polymorphic `compile()` -hook, not a provider-facing view of `Container`. diff --git a/docs/adr/0013-custom-providers-retracted.md b/docs/adr/0013-custom-providers-retracted.md deleted file mode 100644 index 9c2cc0d8..00000000 --- a/docs/adr/0013-custom-providers-retracted.md +++ /dev/null @@ -1,33 +0,0 @@ -# Custom providers are not an extension point; the provider set is closed - -**Decision:** `modern-di` supports exactly four provider types — `Factory`, `Alias`, -`ContextProvider`, and the pre-built `container_provider`. Subclassing `AbstractProvider` (or -`Factory`) to add a provider type is **not** supported; the `docs/providers/advanced-api.md` section -promising it was retracted rather than honoured. - -Custom provider support was never designed. Until 2.28.0, `resolve_provider` ended in -`provider.resolve(self)`, so any subclass implementing `resolve()` worked — an emergent property of -Python inheritance that the docs then wrote down. The single-path compiled resolver (#334) replaced -that with `compile_resolver`, which selects a closure by exact type identity and raises otherwise, -and deleted `AbstractProvider.resolve` and `Alias.resolve`, so no hook survives to fall through to. -The closure was deliberate — asserted by -`tests/test_container.py::test_resolve_provider_raises_for_unhandled_provider_type`. Only the docs -were left behind. - -The blast radius and failure mode: `type(x) is Factory` is identity, not `isinstance`, so a -`LoggingFactory(Factory)` that overrides nothing fails exactly like a from-scratch provider; -`validate()` cannot catch it, since compilation is lazy, so a container reports clean and then -raises `TypeError` at first resolve under traffic. Against that: **zero consumers** — all 13 sibling -`modern-di-*` repos, the two templates, and `lite-bootstrap` contain no `AbstractProvider` subclass. - -Rejected: a 2.x fallback behind a `DeprecationWarning`, matching the `ContainerClosedWarning` / -`ContextValueNoneWarning` / `UnvalidatedContainerWarning` ramps. Those guard capabilities users -demonstrably rely on; this one would guard an audience believed empty, at the cost of resurrecting -the exact indirection #334 removed and carrying it through the 2.x line. The residual risk is -accepted knowingly, and the 2.29.0 notes carry it under a breaking-change heading. Also rejected: -holding the whole post-2.28.0 backlog for an unscoped 3.0. - -**Revisit trigger:** a real user reports a broken custom provider or `Factory` subclass, falsifying -the zero-audience premise. The migration path is then the polymorphic `compile()` hook named in -[the per-provider compile seam](0014-per-provider-compile-seam-declined.md), not a restored -interpreted fallback. diff --git a/docs/adr/0014-per-provider-compile-seam-declined.md b/docs/adr/0014-per-provider-compile-seam-declined.md deleted file mode 100644 index 202c674f..00000000 --- a/docs/adr/0014-per-provider-compile-seam-declined.md +++ /dev/null @@ -1,30 +0,0 @@ -# No per-provider `compile()` seam - -**Decision:** `resolver_compiler`'s per-type closure builders stay where they are; they do not move -into a `provider.compile(registry) -> resolver` method on each provider class. The -`compile_resolver` type-dispatch and its `# noqa: SLF001` reaches into `Factory`/`Alias` privates -stay. - -The deciding evidence mirrors [the provider-facing seam](0012-provider-facing-seam-declined.md): -**there is exactly one compiler.** `resolver_compiler` is the sole consumer of those provider -privates and nothing varies across the proposed seam, so `compile()` is cleanliness, not a swap -point. Reinforcing it: - -- **The ~16 `SLF001` reaches are intra-package intimacy, not a leaked abstraction.** The compiler - co-evolves with the classes it compiles; they ship and change together, and the markers are honest - labels on a deliberate friendship. -- **The provider-type set is closed and tiny.** Polymorphic dispatch buys extensibility for types - that are essentially never added; an `if type() is` chain over four types is not worse. -- **Concentration is a deliberate property.** Every perf-critical closure lives in one file, - reviewed together, sharing the positional/kwargs and two-phase-error patterns. The full seam - sacrifices that; the middle form (a `compile()` that extracts fields for shared flat builders) - preserves it only by adding an indirection that earns nothing while the seam stays hypothetical. - -The one genuine defect was doc-rot: three docstrings and two inline comments named interpreted -methods that no longer existed. Those were rewritten to describe the behaviour directly, which is -the whole of the fix. - -**Revisit trigger:** a **second consumer of provider compile-time privates** appears (a distinct -compiler, an alternate resolver backend), or provider types become an open, user-extended set where -adding one must not require editing a central dispatch — at which point the polymorphic `compile()` -hook becomes the migration path. diff --git a/docs/adr/0015-warm-singleton-memo-swap-dropped.md b/docs/adr/0015-warm-singleton-memo-swap-dropped.md deleted file mode 100644 index 17b3d8c9..00000000 --- a/docs/adr/0015-warm-singleton-memo-swap-dropped.md +++ /dev/null @@ -1,37 +0,0 @@ -# Drop the warm-singleton resolver memo-swap - -**Decision:** the resolver memo is never self-modified after a singleton is built; a warm -singleton hit keeps paying the normal compiled-resolver path. The mechanism was built in full, -measured, and reverted. - -The lever it was aimed at: after the single-path compiled resolver (#334), the warm singleton hit -was 292 ns, against dishka ~245 ns, wireup ~98 ns, that-depends ~85 ns, and dependency-injector -~61 ns — modern-di paid `resolver_for` dispatch, the override front-guard, and `fetch_cache_item` on -every warm hit. - -wireup's technique — swap a cached provider's stored resolver for a bare `return value` closure -once the value exists — is sound here only for **APP** scope, where one registry ↔ one APP -container ↔ one tree-wide value holds; deeper scopes cache per child container, so a -registry-level constant would be wrong. It was run as a measurement-gated spike with a -pre-committed gate: ship iff the warm hit reached ≤ ~146 ns (at least halved) *and* beat dishka, -with zero correctness regression and a green free-threaded stress test. - -**Measured** (best-of-3, stable medians, machine-relative): guard g2 warm hit 584 → 375 ns; -comparative C2 333 → 208 ns. A consistent **~1.6x**, enough to pass dishka, short of the -≤146 ns gate. The shortfall is structural: `resolve_provider`'s dispatch floor — the -closed-container shim frame, the `resolver_for` lookup, the version check — sits upstream of the -closure body the swap replaces, so "near-free" was never architecturally reachable this way. A -~1.6x win does not buy a permanent cross-cutting invalidation invariant: a bypass resolver -spanning `override()` / `close()` / `add_providers()`, plus a second source of truth in -`_warm_swapped`. - -Two things from the attempt were kept: the free-threaded stress test surfaced that -close-during-resolve was not tear-free, and the research it triggered is now stated in -[design decisions](../introduction/design-decisions.md#the-thread-safety-boundary); and it pointed -at the dispatch-floor simplification (invalidate-on-mutation instead of a per-resolve version -stamp), which *removes* per-resolve work and shipped in #347. - -**Revisit trigger:** a user-reported warm-singleton bottleneck **plus** a design that removes the -dispatch floor itself — the swap alone provably cannot clear the bar, so re-proposing it unchanged -is settled. This record governs the memo-swap technique only; a that-depends-style -per-APP-container slot array was not pre-authorized here and would need its own measurement. diff --git a/docs/adr/0016-child-lazy-alloc-declined.md b/docs/adr/0016-child-lazy-alloc-declined.md deleted file mode 100644 index b266a2bf..00000000 --- a/docs/adr/0016-child-lazy-alloc-declined.md +++ /dev/null @@ -1,34 +0,0 @@ -# Decline lazy-allocation of child-container registries - -**Decision:** `Container.__init__` keeps eagerly building the per-child `RLock`, `CacheRegistry`, -and `ContextRegistry`. They are not lazy-allocated. - -After the `_next_deeper` memo (#348) took ~40% off default child-build, the remaining per-child cost -was three eager allocations: `RLock` (~195-214 ns), `CacheRegistry` (~217 ns), `ContextRegistry` -(~189 ns). - -**Measured** ceiling (`use_lock=True` vs `False`, which already skips the RLock alloc; py3.10, -guidance), starting from the strongest candidate — the `RLock`, since REQUEST children rarely create -singletons: - -- Isolated child build: RLock alloc ≈ **195 ns/child**. -- **Realistic caching request cycle** (build child → resolve a REQUEST-cached resource → close, the - C4/G7 shape): saving ≈ **0 (0.4%)** — a caching child *uses* the lock, so lazy only defers the - allocation and adds a `None`-check. -- Narrow **no-cache child** (transient/APP deps only): saving ≈ **67 ns (3.5%)**, and that is the - ceiling before any cost. - -Real integration request children inject context and cache a request-scoped resource — the C4/G7/G9 -scenarios all do — so the trio is used and lazy-allocation saves nothing there while taxing the hot -path. Against a 0-to-3.5% narrow win it costs a `None`-check on the cached-resolve hot path plus a -`_use_lock` slot, and **re-introduces the singleton-creation race the lock exists to prevent**: -lazy lock creation must itself be atomic, so it needs a guard lock or a CAS-style publish, a new -concurrency-correctness surface against the documented Beta contract in -[design decisions](../introduction/design-decisions.md#the-thread-safety-boundary). The -`CacheRegistry` / `ContextRegistry` variants are weaker still — used more often in realistic -children, so they save even less. - -**Revisit trigger:** a profile of a *realistic* request cycle (context + caching) showing these -allocations — not `_next_deeper` — dominating, or a user-reported per-request construction -bottleneck in a build-heavy, cache-free workload. Re-measure the net against `G6b` + `G1-G3` + -`C4/G7/G9`, and solve the lazy-lock atomicity, before reopening. diff --git a/docs/adr/0017-exec-hot-path-declined.md b/docs/adr/0017-exec-hot-path-declined.md deleted file mode 100644 index 93692f69..00000000 --- a/docs/adr/0017-exec-hot-path-declined.md +++ /dev/null @@ -1,34 +0,0 @@ -# Re-decline `exec` codegen on the resolve hot path - -> **Superseded by [ADR-0030](0030-exec-template-resolver.md).** The measurement below compared -> `exec` against the closures as an optimisation; 0030 adopts it to remove the closures' duplication -> and records that the warm path is not slower. - -**Decision:** the shipped closure-compiled resolver stays the single resolve path. No `exec`-based -source-generation codegen, additive or otherwise. - -The reframe that reopened this holds: `exec` is a stdlib builtin, so `dataclasses`/`attrs`-style -codegen would not touch the zero-*dependency* guarantee — "it adds a dependency" was never the real -objection. Unbundled, four claims remain: - -- **Debuggability** — mitigable, but only via the attrs `linecache` discipline (script-builder, - hygiene rules, unique-filename scheme). -- **Maintainability / audit trust** — real, no neutralizer; a fixed standing cost and a second - mental model, independent of how small the win is. -- **Free-threading / nogil** — real, open, and modern-di-specific: it swaps captured cells for - generated-module globals under a concurrency contract still at Beta, and cannot be retired without - out-of-scope parallel-resolution work. -- **Deployment / `exec` bans** — mitigable via an additive fallback resolver, but that doubles the - resolve surface and deepens the maintainability cost rather than escaping it. - -**Measured**, the prize is bounded before any of that: `exec` is 0-4% faster than a hand-unrolled -closure at fixed arity (inside the noise band), with its only exclusive win — **~1.3-1.9x** — -confined to high-arity nodes and deep singleton/scoped chains, where closures already capture -~80-90% of the ceiling. Every path that neutralizes an objection pays for it in the maintainability -row, and dissolving the dependency-purity framing manufactures no win the measurement denies. - -**Revisit trigger:** a user-reported, real-world resolve bottleneck on a high-arity node or a deep -singleton/scoped chain — the two forms where `exec` could pay — that the closure resolver provably -cannot close. A synthetic micro-benchmark or a hypothetical does not qualify. This is the -codegen-ceiling half of the open warm-singleton perf-headroom question, -[issue #434](https://github.com/modern-python/modern-di/issues/434). diff --git a/docs/adr/0018-no-static-wiring-checker.md b/docs/adr/0018-no-static-wiring-checker.md deleted file mode 100644 index 552acbf1..00000000 --- a/docs/adr/0018-no-static-wiring-checker.md +++ /dev/null @@ -1,29 +0,0 @@ -# No static / compile-time wiring checker - -**Decision:** modern-di ships no static or compile-time dependency-graph checker and no type-checker -plugin (mypy, pyright, or `ty`). Whole-graph verification stays the opt-in runtime `validate()`, -backed by declaration-time signature parsing that already fails early on an unwireable creator. - -Three verified findings decide it: - -1. **True compile-time wiring verification exists only in compiled-language toolchains** — Dagger's - annotation processor, Google Wire's build-time codegen, Koin's K2 compiler plugin (GA June 2026). - Angular's "no provider" (NG0201) is a runtime error, .NET's scope validation is runtime/startup, - Spring's autowiring correctness is an IDE inspection. Runtime/startup validation is the - mainstream field standard, not a second-class fallback. -2. **Where compile-time validation exists, it *replaces* runtime verification rather than extending - it** — Koin's own docs tell users to delete their `verify()`/`checkModules()` tests once the - plugin is on. A static layer here would duplicate `validate()`, not reach past it. -3. **A Python type-checker plugin is infeasible for a conservative zero-dep library.** pyright - refuses third-party plugins on principle; `ty` — the checker modern-di itself uses — has no - plugin system (astral-sh/ty#291 closed "not planned"); only mypy exposes a plugin API, documented - as experimental with backwards-incompatible changes shipped without a deprecation period. - -So a checker would duplicate `validate()`, serve mypy users only, carry a permanent liability -against an unstable API, and not even help modern-di's own `ty` toolchain. The one in-constraint win -the research pointed at — injection markers that type-check to the concrete `T` — already ships: -`resolve(type[T]) -> T` and `Annotated[T, from_di(dep)]` both preserve the concrete static type. - -**Revisit trigger:** `ty` (or pyright) ships a **stable, supported** third-party plugin API **and** a -concrete user-reported wiring-safety need that runtime `validate()` demonstrably cannot meet (e.g. -per-call-site checking without executing `validate()`). Both conditions, not either alone. diff --git a/docs/adr/0019-except-body-creator-error-helper.md b/docs/adr/0019-except-body-creator-error-helper.md deleted file mode 100644 index f7f4150a..00000000 --- a/docs/adr/0019-except-body-creator-error-helper.md +++ /dev/null @@ -1,25 +0,0 @@ -# Extract the creator-call error rule via an except-body-only helper - -**Decision:** the creator-call `TypeError` rule lives in one `CreatorCallError.from_type_error` -classmethod, called from inside each site's `except TypeError` block. This supersedes the earlier -drift-lock work's "do not extract a shared helper" non-goal, which locked four copies of the rule -with a cross-path equivalence test instead. - -That rejection weighed exactly one helper shape: a helper wrapping the whole `creator(...)` call, -which adds a Python frame on **every** resolve — the success path the single-path compiled resolver -(#334) exists to keep frame-free. It did not weigh extracting only the `except` body (the `tb_next` -discriminate, the `CreatorCallError` construction, the `prepend_step`) while leaving -`try: return creator(...)` at each site, which runs only on the already-failing raise path. Under -that form: - -- The hot path stays `return creator(*args)` byte-for-byte — no frame is restored, confirmed before - ship by a `--benchmark-compare-fail=mean:5%` resolve-bench gate. -- The rule gets one home; changing it is one edit, not four. -- The equivalence test that existed only to police the copies is retired — one source cannot drift - from itself. -- Traceback fidelity is preserved: the return-or-`None` contract keeps the bare `raise` at each - site, so a creator-body `TypeError` propagates with its traceback unchanged. - -**Revisit trigger:** the resolve hot path regresses after this lands (meaning the success path was -not as frame-free as argued), **or** a future change needs the creator-call rule to differ per site -again, making a single shared rule wrong. diff --git a/docs/adr/0020-d3-root-lifecycle-inherent.md b/docs/adr/0020-d3-root-lifecycle-inherent.md deleted file mode 100644 index 4dbf6d6d..00000000 --- a/docs/adr/0020-d3-root-lifecycle-inherent.md +++ /dev/null @@ -1,53 +0,0 @@ -# D3 root-lifecycle gaps are inherent — no integration code changes - -**Decision:** the eight integrations whose `setup_di` does not own both the root open/close and the -per-unit-of-work child keep their current lifecycle handling. The gaps are inherent framework limits -plus the deliberate caller-owns-root contract, so the treatment is this rationale, not code. - -| Integration | Root open/close | Why inherent | -|---|---|---| -| fastapi | `setup_di` owns both | ASGI lifespan is optional — a mounted sub-app / `lifespan="off"` never fires it | -| starlette | `setup_di` owns both | Same ASGI-lifespan-optional caveat | -| faststream | `setup_di` owns both | `TestBroker`/`TestApp` deliberately skip `on_startup` | -| taskiq | `setup_di` owns both | `run_receiver_task(run_startup=False)` skips the startup hook by default | -| celery | root owned; per-task child owned by `@inject`/`DITask` | `task_always_eager` bypasses the worker signals that open the root | -| flask | child owned; root is the caller's | Flask has **no app-shutdown hook**, so the root *close* is unavoidably the caller's | -| grpc | per-RPC child owned; root is the caller's | `start()`/`stop()` is caller-owned; the integration's seam is the interceptor, not the server lifecycle | -| typer | neither owned by `setup_di` | A Click callback hook *does* exist — the one fixable case, see below | - -**The first five have nothing to fix.** `setup_di` already owns both sides; each falls short only -because of a documented execution-context caveat, and every caveat is real framework behaviour. No -code closes a caveat the framework itself imposes; they are captured in the -[lifecycle rules](../integrations/writing-integrations.md#lifecycle-rules) and in each integration -page's deployment caveats. - -**flask and grpc give the root to the caller by design.** Owning the root open in Flask's -`setup_di`, or adding a gRPC server-wrapper helper, would add machinery and revisit a contract the -lifecycle rules state deliberately — *if the framework offers no lifecycle hook at all, the root's -open/close is the caller's to own; document it.* Removing one `open()`/`with` line the caller writes -once does not justify new API surface. - -**typer is the one fixable case, deferred.** A Typer/Click callback could open the root and close it -via `ctx.call_on_close`, but the command child *is* already owned inside `@inject`, an explicit -`with container:` is the right idiom for a process that exits in milliseconds, and an -integration-injected callback adds hidden control flow that must compose with a user's own -`@app.callback()` — non-trivial in Click. - -**One-call-setup scores follow from this, not from anything separate.** Where a second wiring action -is required (flask, grpc, typer), that action *is* the manual root `open()` ruled inherent above — -there is no independent setup fix. Under these rulings the ceiling for integrations that own their -whole lifecycle is four: litestar, aiogram, aiohttp, arq. The other eight are each gated by a -framework-inherent root-lifecycle limit, with the revisit trigger below. - -**Amendment (2026-07-26).** The trigger fired: maintainer-reported root-lifecycle friction — the -hard `ContainerClosedError` failure mode every caveat here relies on — was addressed by making -`open()` optional in core (3.1: a root is open from construction, and reuse after an explicit close -warns and reopens). That landed in `modern_di.Container`, not in any integration's wiring, so the -conclusion stands: every caveat changes failure mode (a hard raise becomes "finalizers silently do -not run") rather than disappearing, the deployment notes were reworded, and no integration's -lifecycle code changed. - -**Revisit trigger:** a real user reporting friction with a specific integration's root-lifecycle -ergonomics — most plausibly typer, where the callback fix would then be worth its composition cost. -Also: a flask/grpc-shaped framework gaining a startup/shutdown hook it currently lacks, at which -point its `setup_di` should own the root and its row reopens. diff --git a/docs/adr/0021-inject-asymmetry-inherent.md b/docs/adr/0021-inject-asymmetry-inherent.md deleted file mode 100644 index 1ac643ac..00000000 --- a/docs/adr/0021-inject-asymmetry-inherent.md +++ /dev/null @@ -1,49 +0,0 @@ -# The @inject asymmetry is inherent — do not unify - -**Decision:** the four integrations that resolve `FromDI` decorator-free (fastapi, litestar, -faststream, taskiq) and the eight that require `@inject` (flask, starlette, aiohttp, celery, arq, -aiogram, typer, grpc) keep their current shapes. An adapter can drop `@inject` only where the host -framework evaluates a parameter *default* as a provider, and the eight offer no such seam, so there -is nothing to unify. - -| Integration | Per-parameter provider seam | Verdict | -|---|---|---| -| fastapi | `fastapi.Depends` | decorator-free | -| litestar | `Provide` | decorator-free | -| faststream | `faststream.Depends` | decorator-free | -| taskiq | `TaskiqDepends` | decorator-free | -| flask | none — view is a plain callable | inherent | -| starlette | none — endpoint is a plain ASGI callable | inherent | -| aiohttp | none — handler is `async def handler(request)` | inherent | -| celery | none — task is a plain callable with its own args | inherent | -| arq | none — `coroutine(ctx, …)`, `ctx` a plain dict | inherent | -| aiogram | name-based `data` injection, **not** provider-evaluation (closest call) | inherent | -| typer | none — defaults are CLI parsing (`Option`/`Argument`) | inherent | -| grpc | none — fixed `(request, context)` servicer signature | inherent | - -**aiogram is the one close call.** Its middleware `data` dict is matched to handler kwargs by -parameter *name* and never evaluates a default as a provider, so it cannot consume a `FromDI` -marker; the adapter uses it only to pass the child container. - -**Positioning follows.** The defensible claim is *no `@provide` ever, and no `@inject` in the four -biggest integrations* (where dishka needs `@inject` even for FastAPI/Litestar), not "decorator-free" -unqualified, which a single `grep` refutes. The adapter-side `auto_inject` (Flask) and `DITask` -(Celery) helpers apply `@inject` under the hood for convenience; they are not framework seams. - -**Quickstart length follows from this, not from anything separate.** The decorator-free floor for a -minimal single-dependency example is 7 DI-specific lines (two imports, a `Group` with one provider -and its dependency, `Container(...)`, `setup_di`), and a minimal example needs both providers to -demonstrate DI at all, so the floor cannot drop. Against the merged examples: aiogram, aiohttp and -arq are at 8 — the floor plus the `@inject` line; flask and typer at 9 — plus the manual root -`open()`/`with` ruled inherent in [the root-lifecycle record](0020-d3-root-lifecycle-inherent.md); -grpc at 10, plus `close_sync()`. Nothing is trimmable without deleting an inherent element, so there -is no independent quickstart fix. - -Same call as [the D3 root-lifecycle gaps](0020-d3-root-lifecycle-inherent.md) and -[the exec hot-path re-decline](0017-exec-hot-path-declined.md): where a gap reflects a framework -limitation rather than a modern-di shortfall, document the stance instead of adding machinery. - -**Revisit trigger:** an `@inject`-requiring framework gains a per-parameter dependency hook (a -future Flask/Starlette DI feature) — its integration should then bind `FromDI` to that hook and drop -`@inject`, reopening its row. Or a user reports the `@inject` requirement as real adoption friction -in a specific integration. diff --git a/docs/adr/0022-explicit-only-validation.md b/docs/adr/0022-explicit-only-validation.md deleted file mode 100644 index fc4d6137..00000000 --- a/docs/adr/0022-explicit-only-validation.md +++ /dev/null @@ -1,40 +0,0 @@ -# Validation is explicit-only; implicit validation was built and discarded - -**Decision:** `container.validate()` is the only thing that walks the graph. Neither `__init__` nor -`open()` nor `add_providers` nor `resolve()` ever validates, and `Container(validate=...)` is an -accepted-and-ignored no-op that raises `ValidateArgumentWarning` until 4.0 — 3.0 callers pass -`validate=False` widely, including this repo's own benchmark guards. Shipped as 3.1.0. - -3.0 made `open()` mandatory and the sole validation trigger. Both tightenings caused trouble: the -mandatory open produced six production defects across integrations, all one root cause (the root's -open hook does not fire in some execution contexts, so the first unit of work raises); and binding -validation to `open()` produced an authoring rule that existed only because of that binding — open -the root *after* `setup_di`, or a by-type dependency on a not-yet-registered connection fails. - -The alternative that kept validation implicit was implemented and worked: split the walk, checking -cycles and inverted scopes eagerly at construction (they are *monotone* — more providers can only -add such an error), and holding completeness on the shared registry to raise at first use (the only -class a later `add_providers` can legitimately fix). It was discarded for the machinery it dragged -in: a two-flag container lifecycle, validation state parked on `ProvidersRegistry`, a -monotone/completeness classification threaded through the walk, and an `add_providers` rollback -path. That is a large permanent surface for a startup-time property, and the cheap way to keep the -guarantee without it — a per-resolve check — taxes the hot path for a concern that matters once, at -boot. - -`add_providers` is now a plain register with no rollback; the mutation clears `_validated`, which -`ProvidersRegistry` keeps purely as a memo of a clean walk — it gates nothing, but still -short-circuits the `RecursionError`-to-`CircularDependencyError` guard. - -**Measured:** because 3.0 ran a default `validate=True` walk at `open()`, dropping it made -construction markedly cheaper — roughly **2.6 µs against 15.5 µs** for a depth-6 chain -(`Container(...)` + `open()`, default arguments), matching the `test_g10_validate_deep_chain` guard -cost 3.0's `open()` paid. The resolve tier was unchanged, as expected. - -**The accepted cost:** the default safety posture drops silently. A broken graph previously raised at -`open()`; now it surfaces from an explicit `validate()`, or at resolve time as -`ArgumentResolutionError`. - -**Revisit trigger:** reports of graphs reaching production broken in a way an implicit walk would -have caught at boot — evidence that opt-in `validate()` is under-adopted. Reopen with the adoption -evidence, not with a new mechanism: any replacement must avoid both the four-part machinery above -and a per-resolve check. diff --git a/docs/adr/0023-debug-resolution-tracing-declined.md b/docs/adr/0023-debug-resolution-tracing-declined.md deleted file mode 100644 index b902037b..00000000 --- a/docs/adr/0023-debug-resolution-tracing-declined.md +++ /dev/null @@ -1,50 +0,0 @@ -# Decline opt-in DEBUG resolution tracing - -**Decision:** no module-level `logging.getLogger("modern_di")` narrating resolution at DEBUG level. -No resolution tracing ships in any form — neither the runtime-guarded logger nor the -compile-time-gated variant that would avoid its cost. - -Field precedent was real (Uber Fx narrates lifecycle events, Koin exposes an opt-in -`logger(Level.DEBUG)`), and a pluggable structured event-logger subsystem had already been rejected -on the conservative-feature-set principle; the shape that survived was stdlib logging and nothing -else. It rested on one never-measured estimate: "one `isEnabledFor(DEBUG)` boolean per chokepoint." - -The guard is not a boolean. `logger.isEnabledFor(DEBUG)` is an attribute load plus a dict lookup -inside a `try`, measuring **~19 ns net** (21.2 against a 2.25 ns loop floor) — roughly **10x** a bare -module-global bool check (~1.8 ns net). Against a per-node budget of ~120-140 ns, one guard is ~15% -of a node, and a cached factory needs two. Measured by patching the shipped closures with exactly -the proposed design and re-running the guard tier **with tracing off** — the cost every user pays -for a feature they never enable: - -| Scenario | base | traced | delta | -|---|---|---|---| -| G2 cached resolve (warm hit) | 140 ns | 192 ns | **+37%** | -| G16 by-type resolve | 181 ns | 237 ns | **+31%** | -| G4 wide, 10 siblings | 1333 ns | 1709 ns | **+28%** | -| G17 by-type, 200-provider registry | 188 ns | 235 ns | +26% | -| G12 override active, depth 6 | 1017 ns | 1187 ns | +17% | -| G3 deep chain, depth 6 | 833 ns | 958 ns | +15% | -| G9 context resolve | 625 ns | 708 ns | +13% | -| G1 transient | 333 ns | 375 ns | +13% | -| G5 cross-scope | 375 ns | 417 ns | +11% | - -It lands where it hurts most: hardest on the **warm cached hit**, the cheapest operation and the one -the singleton pattern makes most common, and it multiplies by graph size, since every node runs its -own guards — G4's +376 ns is 11 nodes each paying. - -A compile-time gate would have been free (resolvers are memoized closures and `_invalidate()` -already exists to drop them), and was declined on the feature-set principle rather than on cost: -activation becomes an explicit modern-di call that invalidates the resolver memo, so the feature -stops being "stdlib logging" — the one property that justified this shape over the event subsystem -already rejected — and becomes a second public activation API plus a compile mode to keep correct -forever. Diagnostics remain the job of the error messages, which already carry the resolution -breadcrumb chain (see `docs/troubleshooting/`) at zero hot-path cost. - -**Revisit trigger:** a user-reported diagnostic dead end the existing breadcrumb chain provably -cannot answer — a real issue where reporter and maintainer both failed to determine *why* the -container resolved as it did from the error alone. A preference for narration over breadcrumbs does -not qualify. - -*Measured on Python 3.14.6, Apple M4 (`perf_counter` resolution 41.67 ns). Guard-tier medians are -quantized to one timer tick, so a single delta carries that granularity; direction and magnitude -held across all nine scenarios and a repeat run.* diff --git a/docs/adr/0024-scope-map-inline-declined.md b/docs/adr/0024-scope-map-inline-declined.md deleted file mode 100644 index 06967024..00000000 --- a/docs/adr/0024-scope-map-inline-declined.md +++ /dev/null @@ -1,33 +0,0 @@ -# Declined: inlining `_scope_map` at the resolver navigation sites - -**Decision:** the compiled resolvers keep calling `_navigate` → `Container.find_container` for a -cross-scope hop. The `_scope_map` lookup is not inlined into the closures. - -Four independent lenses proposed replacing the ternary at the four navigation sites with an inlined -`container._scope_map.get(scope)`, falling back to `_navigate` on a miss — the same -hand-inlined-memo-hit pattern already used for `resolver_for` and `fetch_cache_item`. **Measured** -cross-scope resolve 185.4 → 140.7 ns (**-24%**), with a flat same-scope control. Reproduced, and -declined on the invariant rather than the number. - -**The argument audits a function body while the code being changed is a dispatch.** `find_container` -is a public method on a subclassable class, and `Container.__init__` builds children via -`self.__class__`, so a subclass is carried down the whole tree. Inlining the hit path means a -subclass that overrides `find_container` is **silently bypassed** — its override runs on the miss -path only; `unittest.mock.patch.object` shows calls recorded on the override going from -`['APP', 'APP']` to `[]`. - -**The consequence is worse than a missed hook.** The container returned by navigation is the one -whose `cache_registry` receives the singleton, so bypassing an override that redirects navigation -relocates *cached-instance ownership* — a different container's `close_async()` then runs that -instance's finalizer. That is a lifecycle bug, and nothing in the suite would catch it. It also -contradicts a standing decision: `find_container` is a blessed extension point that -[the provider-facing seam decline](0012-provider-facing-seam-declined.md) rests on, and demoting it -should be argued on its own terms, not absorbed as a side effect of an optimisation. - -It also failed the gates as submitted: coverage 99% (two unreachable lines where the fallback never -fires) and `lint-ci` red, at +20 lines with none deleted, six new rules for a maintainer to hold, -and `ContextProvider` still calling `find_container` — two navigation conventions in one codebase. - -**Revisit trigger:** `find_container` stops being an extension point — an explicit decision that -`Container` subclasses may not redirect navigation, with the lifecycle consequence above stated and -accepted. Then this becomes a plain inlining and the measured 24% is available. diff --git a/docs/adr/0025-alias-binds-nothing.md b/docs/adr/0025-alias-binds-nothing.md deleted file mode 100644 index ce7803bd..00000000 --- a/docs/adr/0025-alias-binds-nothing.md +++ /dev/null @@ -1,44 +0,0 @@ -# The alias hop inlines, but binds nothing - -**Decision:** `_compile_alias`'s closure reads `container.providers_registry` per resolve and inlines -both the source lookup and the source's resolver-memo read. It holds no reference to the source, its -resolver, or the registry. Inline-only ships at **~322 → ~252 ns (-22%)** and 4 frames → 1, giving up -roughly a third of the available win: an eager bind (resolve the source at compile time, close over -its resolver) measured **305.5 → 192.0 ns (-36%)**, reproduced by two independent verifiers, and a -lazy bind behind a `bound is None` branch has the same steady-state cost. - -**A bind buys an invalidation invariant; the inline buys none.** Both bind variants are sound only -because `ProvidersRegistry._invalidate()` clears `_resolvers`, so a stale binding dies with the -closure holding it. True today, but a *second* place the invariant has to hold — stated, defended, -and re-checked by anyone who later touches memo publication. The inline re-reads the live registry -and cannot go stale by construction. Same reasoning that dropped the -[warm-singleton memo swap](0015-warm-singleton-memo-swap-dropped.md): a bounded win does not buy a -permanent cross-cutting invariant. - -**Eager bind additionally escapes the override front-guard**, compiling the alias's whole source -subtree even when the alias is overridden and the source is never touched — the `modern-di-pytest` -mock pattern. `len(_resolvers)` after resolving an overridden alias goes from 1 to 1+depth (11 at -depth 10), cold cost +404%; it also raises `TypeError` eagerly for a source type `compile_resolver` -does not know, and drops the maximum pure alias chain from 494 to 329 hops. Lazy bind avoids all of -this; only the invariant argument rules it out. - -**Capturing the registry was declined on the same grounds one level down.** The first shipped form -took the registry as a compile-time parameter, saving one attribute load per hop. Since the registry -memoizes the closure in `_resolvers`, that made this the only compiled resolver forming -`registry → _resolvers → closure → cell → registry` — freeable then only by cyclic GC, never by -refcounting. Not a leak, but the repo already took the opposite position for containers (`64b7cec`), -and registries are per-root-container, so a suite building a container per test builds one per test. -Reading the registry off the `container` argument removed the cycle and measured **free** (250 → 249 -ns, inside noise), which also puts the alias in the shape every other closure in the module uses. - -Pinned by `test_alias_hop_costs_exactly_one_resolver_frame`, -`test_no_compiled_resolver_closes_over_its_registry`, -`test_overridden_alias_compiles_nothing_of_its_source`, and -`test_alias_picks_up_a_source_registered_after_a_failed_resolve` — that last catches only a -*negative* cache; a success-path cache is undetectable by construction, since a registered type's -provider can never be replaced and any registration clears `_resolvers`. - -**Revisit trigger:** an alias hop shows up hot in a profile from a real integration, **and** the -`_invalidate()`-clears-`_resolvers` invariant has acquired an explicit owner and test of its own — at -which point lazy bind (never eager) is worth the remaining ~60 ns. A second compiled closure needing -the registry at resolve time would reopen the capture question separately. diff --git a/docs/adr/0026-resolve-provider-not-a-seam.md b/docs/adr/0026-resolve-provider-not-a-seam.md deleted file mode 100644 index f5d6198f..00000000 --- a/docs/adr/0026-resolve-provider-not-a-seam.md +++ /dev/null @@ -1,38 +0,0 @@ -# `resolve_provider` is not an interception seam - -**Decision:** `Container.resolve_provider` is an entry point, not a hook. Overriding it in a -`Container` subclass is not a supported way to observe or intercept resolution, and the resolve path -is free to bypass it — which licenses inlining its body into `Container.resolve`, worth **-19% -(~38 ns) on every by-type resolve**, the path every `@inject` marker and framework integration takes. -`find_container` is **not** affected and remains a blessed extension point. - -**It is already not a seam, and that is measurable rather than arguable.** Since the compiled -resolvers shipped in 2.29.0, a resolver calls its dependencies' resolvers directly; nothing routes a -nested node through `resolve_provider`. Its only callers are `resolve()`, `resolve_dependency()`, and -the cycle back-edge thunk in `ProvidersRegistry.resolver_for`. Demonstrated on `main` before the -change: a subclass overriding `resolve_provider` and resolving a **4-node chain** records exactly -**1** call — the top-level one. An override has never seen the graph. What a subclass can still do is -instrument the *entry points* by overriding `resolve` and `resolve_provider`, which keeps working. - -**Deliberately narrower than [the `_scope_map` ruling](0024-scope-map-inline-declined.md), which -stands.** `find_container` is consulted on every cross-scope hop and the container it returns owns -the cached instance and runs its finalizer, so bypassing an override there silently relocates -lifecycle ownership — a bug, not a missed hook. Bypassing a `resolve_provider` override loses -observation, not correctness. **Field check:** an audit of all 13 sibling integration wheels found -zero `Container` subclasses and zero `resolve_provider` overrides, and `Container` subclassing was -never documented as an extension point. - -**Accepted costs**, disclosed rather than discovered later: a genuinely duplicated ~8-line body -(closed check, memo hit, `resolver_for` fallback, resolver call, `RecursionError` conversion) now -lives in both `resolve` and `resolve_provider` and must be edited in lockstep — the real, permanent -price; an exception raised through `resolve()` loses one traceback frame (5 → 4); recursion headroom -moves by one frame in the benign direction. - -**Consequence worth naming.** Together with -[the tracing decline](0023-debug-resolution-tracing-declined.md), modern-di offers no built-in way to -observe *per-node* resolution. That was already true — the compiled resolvers removed the last -interior call — and this records it rather than creating it. - -**Revisit trigger:** a concrete request for per-resolve interception from a real integration or user. -The answer then is a designed seam with a stated contract, not a re-blessing of subclass overrides, -which the compiled resolve path stopped honouring in 2.29.0. diff --git a/docs/adr/0027-no-click-integration.md b/docs/adr/0027-no-click-integration.md deleted file mode 100644 index a9c4051a..00000000 --- a/docs/adr/0027-no-click-integration.md +++ /dev/null @@ -1,24 +0,0 @@ -# No Click integration — Typer already covers the CLI entrypoint - -**Decision:** no `modern-di-click` adapter. The CLI entrypoint is covered by `modern-di-typer`, and -a second adapter for the same layer would be redundant rather than additive. - -Typer is built on Click: a Typer application *is* a Click application, and `modern-di-typer` already -covers the CLI wiring seam — `setup_di` attaches the app-scoped container, `@inject` opens a -`REQUEST` child per command invocation and resolves `FromDI` parameters from it, while the root's -open/close stays the caller's `with container:` by the ruling in -[ADR-0020](0020-d3-root-lifecycle-inherent.md). A separate Click adapter would re-derive that same -contract against the lower-level API for no entrypoint that is not already reachable, while adding a -repository, a release cadence, and a compatibility matrix to maintain. That cost is the one the -[separate-repo integration model](../introduction/design-decisions.md) exists to keep proportionate -to the coverage bought. - -- **Vendoring Click support inside `modern-di-typer`** would let a plain Click app reuse the adapter, - but it makes that package's public surface depend on which of the two APIs the user built against, - and Typer's own Click version is an implementation detail it is free to move. -- **A community-maintained adapter** stays available: nothing here forbids one existing outside the - `modern-python` org, on the same footing as the other frameworks nobody has volunteered for. - -**Revisit trigger:** a Click-only application that `modern-di-typer` provably cannot wire — a -`click.Group` composed at runtime, or a Click-native plugin system Typer does not expose — reported -by someone hitting it, not hypothesized. diff --git a/docs/adr/0029-scope-violations-draw-the-redirect-chain.md b/docs/adr/0029-scope-violations-draw-the-redirect-chain.md deleted file mode 100644 index babca1fb..00000000 --- a/docs/adr/0029-scope-violations-draw-the-redirect-chain.md +++ /dev/null @@ -1,57 +0,0 @@ -# A scope violation draws the chain that reached it, at effective scope - -**Decision:** `InvalidScopeDependencyError` renders the same arrow tree as every other chain-shaped -error, and each redirect hop in that tree is drawn at the scope it **resolves** at, not the scope the -provider object reports. The redirect walk moves to one module-level `terminal_chain()` in -`dependency_graph.py`; `.dep_chain` joins the public attribute surface, with `.dep_provider` and -`.dep_terminal` as its two ends. - -**The blind spot was specific to redirects, and `Alias` is how abstract-to-implementation binding is -spelled.** `_walk_errors` compared `terminal_scope(dep)` against `terminal_scope(parent)`, then built -a message naming `dep.display_name` — the alias's bound type — beside a scope belonging to the -provider at the far end of the chain. Two facts about two objects, presented as one edge, with the -terminal named nowhere. A three-hop `Protocol` chain reported `provider of IA at deeper scope -REQUEST` where `IA` declares no scope at all and `Impl`, the provider to change, never appeared. The -source was not recoverable programmatically either: `.dep_provider` was the alias, and its source -type is private. - -**Its sibling out of the same walk already did this right.** A cycle through the same alias drew -every hop with definition sites via `_render_chain`, because `build_cycle_error` keeps the providers -it walked. Two errors from one `validate()` call, one drawing the chain and one printing a line, is -the drift [0009](0009-error-text-is-not-a-contract.md) unified the drawers to prevent — and 0009 is -also what licenses changing this text with no deprecation cycle. The tree pays even with no alias -present: the old message carried no `module:line` for either provider. - -**Effective scope, not declared scope, and not a blank.** `Alias` sets `_takes_group_scope = False` -and passes `scope=UNSET`, so `AbstractProvider.scope` returns the `Scope.APP` default for every -alias. Three renderings were compared on a three-hop chain. Drawing `provider.scope` (the shipped -behaviour, and still what the cycle renderer did) prints `APP APP APP REQUEST`, which reads as "the -aliases are fine, only the source is deep" and points at the wrong fix. Blanking the column for -providers with no scope of their own is honest but supplies nothing: the reader still scans to the -bottom. Drawing each hop at its terminal's scope puts the APP/REQUEST boundary on the offending edge, -which is the fix the reader is looking for. `Alias.scope` keeps returning `APP` — scope ordering -depends on it — so this is a rendering rule, deliberately not a change to the provider. - -**The walk moved rather than being copied.** `Alias` cannot import `dependency_graph` -(`dependency_graph` → `providers.abstract` → `providers/__init__` → `alias`), so a container-aware -step on the provider would have re-hand-rolled the chain walk that -[0007](0007-unify-graph-traversal.md) deleted. `resolver_compiler` has no such cycle, so the alias -resolver builds its step through `redirect_step()` and `Alias._resolution_step` is gone. That keeps -0007 intact: the walk stays in the traversal module and providers still expose only the generic -`redirect_target` hook. Cost is off the hot path — the step is built inside `except`, and the -compiled closure binds nothing new. - -**`RegistrationError` stays the base**, though nothing registers when this fires and the error is -aggregated into a `ContainerError`. Under 0009 the hierarchy *is* the contract: reparenting would -silently break a caller catching `RegistrationError`, to fix a misfiling that costs nobody anything. - -Pinned by `test_validate_and_runtime_name_the_same_chain_for_one_scope_violation` (the two detectors -must agree, which is the defect stated as a property), -`test_alias_scope_violation_names_the_source_behind_the_alias` (the terminal is recoverable without -parsing the message) and `test_invalid_scope_dependency_error_draws_the_chain_that_reached_the_terminal` -(the alias hop draws REQUEST while its own `.scope` is APP). - -**Revisit trigger:** a second provider type implements `redirect_target`. Effective-scope rendering -assumes a redirect is a pure forward with no lifetime of its own, which is true of `Alias` and was -never true of anything else; a redirect that *does* own a scope would make the terminal's scope the -wrong thing to draw, and the column would need the hop's own band back. diff --git a/docs/adr/0030-exec-template-resolver.md b/docs/adr/0030-exec-template-resolver.md deleted file mode 100644 index 6d399bb4..00000000 --- a/docs/adr/0030-exec-template-resolver.md +++ /dev/null @@ -1,78 +0,0 @@ -# Generate `Factory` resolvers from a source template; compile overrides in - -**Decision:** a `Factory` resolver is generated from a source template, specialised to the -factory's *resolver shape* (arity or kwarg names, static kwargs, context kwargs, cached) and -`exec`'d with the factory's constants as the function's globals. One code object is compiled per -shape and shared by every factory of that shape. Overrides are compiled in: an overridden provider -compiles to `return value`, and any override change drops the compiled resolvers so the next -resolve recompiles. Nothing on the resolve path consults the overrides registry. `Container.resolve` -memoizes type → resolver directly. Supersedes [ADR-0017](0017-exec-hot-path-declined.md). - -**The motive is readability, and 0017 measured the wrong comparison.** The closure compiler held -seven near-identical closures (an arity ladder of three plus a kwargs path for transient factories, -three more for cached ones) with the context-fold loop copied verbatim twice, and a comment on -every copy explaining why it could not be shared. 0017 compared `exec` against those closures as an -*optimisation* and found 0-4%; it did not ask what the closures cost to read. Every all-Python -single-copy design was built and measured before this one, and every one pays: - -| Design | G1 transient | G3 chain depth 6 | G4 wide | Why | -|---|---|---|---|---| -| Arity ladder folded into one closure with branches | +31% | +26% | +35% | closure size: every *untaken* branch costs (bisected: +7%, +12%, +20%, +27% as branches accumulate) | -| Shared `build()` helper, call inline | +66% | +47% | +47% | one frame per node | -| Shared `build()` and `call()` helpers | +79% | +62% | +58% | two frames per node | -| Template, one code object per **provider** | −2% | −21% | −30% | monomorphic call sites; `compile()` ≈ 70 µs per provider (G8 cold +1850%) | -| **Template, one code object per shape** (this ADR) | **−5%** | **+4%** | **−13%** | cold first-resolve +55% (≈ 2 µs per provider, once per registry) | - -Warm paths are at baseline or better; the frame-per-node invariant -(`test_resolve_costs_exactly_one_resolver_frame_per_node`) holds by construction, since generated -functions have no free variables at all. Unrolling every arity (no list build, no `CALL_FUNCTION_EX`) -is what pays for G4. - -**Two facts the closures were hiding.** On CPython 3.14 a closure's *size* costs on every call, -not only its frames: the bisect above added branches that never executed. And code objects -shared across providers turn every call site polymorphic for the specialising interpreter; the -per-provider row is 20-30% faster on chains for that reason alone. This ADR takes the per-shape -setting because a pytest suite building a container per test would pay the per-provider -`compile()` per test; per-provider stays available as a later opt-in. - -**Overrides compiled in.** The front-guard (`has_overrides`, `fetch_override`) ran at the top of -every resolver on every resolve, so that a test could swap a value under a compiled graph. The -registry already drops every compiled resolver on mutation; treating an override as a mutation -reuses that path and removes the guard from all five resolver kinds. Measured against the template -with the guard: G1 −7%, G3 −6%, G16 −5%, and G12 (a chain resolved while an unrelated override is -active) −32%, which is the path every test using `override()` takes. All override behaviours -(handles, nesting, prior restore, propagation through an alias, scope bypass) pass unchanged. A -reset that changes nothing drops nothing, so `close()` on a root does not churn the memo. - -**Shipped together, against `main` on the same machine** (CPython 3.14, medians): G1 transient −10%, -G2 cached −11%, G3 chain −3%, G4 wide −19%, G5 cross-scope −7%, G9 context −16%, G12 override-active -−30%, G16/G17 by-type −23%, G18 alias −12%, G7 request lifecycle −6%; G8 cold first-resolve +12%, -G8b cold cached +1%, child build unchanged. - -**Accepted costs**, disclosed: - -- The hot path is a string: no `ruff`, no `ty`, no syntax highlighting, whitespace by hand. The - template is complete functions (`resolve`, and `build`/`create` for the cached form), never - fragments, so it reads as the resolver one would write. The template and its namespace are - coupled by name; `test_every_resolver_shape_compiles_and_resolves` enumerates every shape so a - mismatch is a test failure, not a `NameError` in a user's application. -- Coverage cannot see generated lines. The behavioural suite covers them; the 100% line gate now - measures the generator, not the resolver. -- `exec` (`S102`) appears once. Kwarg names enter the source only as `repr` string keys in a dict - literal, never as identifiers; `test_kwarg_names_that_are_not_identifiers_are_quoted_into_the_source` - pins it with a key containing a quote and a hyphen. -- Tracebacks: generated source is registered in `linecache` per shape, so a creator's exception shows - the resolver's line. `__qualname__` is `resolve[]` for profilers. -- Cold first-resolve is ≈ 2 µs per provider slower (G8 +55%); the shape cache is bounded by the - number of distinct shapes, at worst one per provider. -- An override change recompiles lazily: ≈ 2-5 µs per provider actually resolved afterwards. Overriding - is a test-time operation and is not coordinated with concurrent resolves on other threads, which - the `Container.override` docstring now says. -- 0017's free-threading concern (generated-module globals instead of closure cells) does not - apply as stated: each resolver's namespace dict is written once by `exec` and read-only after, - the same immutability cells had. `tests/test_free_threading.py` passes; a free-threaded build has - not been run. - -**Revisit trigger:** a measured cold-start or per-test regression attributable to `compile()`, -which is the per-provider code-object question above; or a CPython release where the per-shape and -per-provider rows converge, at which point the shape cache is complexity without a payoff. diff --git a/docs/agents/domain.md b/docs/agents/domain.md index 137f1fce..ad72bf3a 100644 --- a/docs/agents/domain.md +++ b/docs/agents/domain.md @@ -6,20 +6,20 @@ codebase. This repo is **single-context**. ## Before exploring, read these - **`CONTEXT.md`** at the repo root: the domain glossary. -- **`docs/adr/`**: read the decision records that touch the area you're about to work in. - -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. +- **`docs/introduction/design-decisions.md`**: the deliberate choices behind the public API and + the non-goals. Anything a user can observe is decided there, in user-facing terms. +- **`docs/adr/`**: decisions about internals only, the ones a maintainer would otherwise "fix": + the shape of the resolve path, registry memo invalidation, and the like. An ADR earns its place + only when the decision is hard to reverse, surprising without context, and a real trade-off; + most internal choices are none of these and need no record. ## File structure ``` / ├── CONTEXT.md -├── docs/adr/ -│ ├── 0001-….md -│ └── 0002-….md +├── docs/introduction/design-decisions.md ← public API choices and non-goals +├── docs/adr/ ← internal design decisions ├── modern_di/ └── tests/ ``` @@ -42,14 +42,14 @@ the project doesn't use (reconsider) or there's a real gap (note it for `/domain working in both renderings: - **Between files inside `docs/`, use a plain relative `.md` link.** MkDocs rewrites it to a site - URL and GitHub follows it as a file. From one ADR to another, that is `[ADR-NNNN](NNNN-slug.md)`. + URL and GitHub follows it as a file. - **Never link from a file inside `docs/` to a path outside it.** It cannot resolve in both renderings: MkDocs emits `links.not_found` and ships the link verbatim, so it 404s on the site. Cite `modern_di/...`, `tests/...`, and root files as inline code, never as links. -## Flag ADR conflicts +## Flag conflicts with a recorded decision -If your output contradicts an existing decision record, surface it explicitly rather than silently -overriding: +If your output contradicts a choice on the design-decisions page or an ADR, surface it explicitly +rather than silently overriding: -> _Contradicts ADR-NNNN (its title), but worth reopening because…_ +> _Contradicts design decision "…" (or ADR-NNNN), but worth reopening because…_ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md index 83a985b6..d1faa8c0 100644 --- a/docs/agents/issue-tracker.md +++ b/docs/agents/issue-tracker.md @@ -39,13 +39,6 @@ Create a GitHub issue. Run `gh issue view --comments`. -## Rejected work: read and write `docs/adr/`, not `.out-of-scope/` - -Where a skill says `.out-of-scope/`, this repo means `docs/adr/`. A rejected enhancement is recorded -there as a decision record, and the prior-rejection check during triage reads that directory. Do not -create `.out-of-scope/`: this repo keeps one home for a rejected alternative, and a second one would -split the corpus that the check depends on. - ## Wayfinding operations Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets. diff --git a/docs/integrations/typer.md b/docs/integrations/typer.md index 4cffe260..09ad1625 100644 --- a/docs/integrations/typer.md +++ b/docs/integrations/typer.md @@ -1,5 +1,8 @@ # Usage with `Typer` +A Typer application is a Click application, so this adapter is also the answer for Click; there is +no separate `modern-di-click`. + ## How to use ### 1. Install `modern-di-typer` diff --git a/docs/integrations/writing-integrations.md b/docs/integrations/writing-integrations.md index 7135e605..ffcc9846 100644 --- a/docs/integrations/writing-integrations.md +++ b/docs/integrations/writing-integrations.md @@ -283,6 +283,11 @@ that calls your resolver with the request container. Some frameworks have none: a Typer/Click command, an argparse handler, or a plain task callable receives only what the framework's argument parser binds. There is nowhere to inject. +The rule: an integration is decorator-free only where the framework evaluates a parameter +default as a provider (FastAPI and FastStream `Depends`, Litestar `Provide`, taskiq +`TaskiqDepends`). Flask, Starlette, aiohttp, Celery, arq, Typer and gRPC hand the handler a plain +callable, and aiogram matches its `data` dict by parameter name, so those need `@inject`. + For these, `FromDI` becomes an inert annotation marker and a **decorator** does the work native DI would have. [`modern-di-typer`](typer.md)'s `@inject` is the reference implementation — reach for this shape whenever the framework runs @@ -402,7 +407,7 @@ Each official integration is its own repository and PyPI package, mirroring the directly under `Full guide:`. - **Mirror `modern-di`'s** `AGENTS.md` and `justfile`. Keep behavioural invariants in named tests rather than in a prose truth home, and record rejected - alternatives as ADRs under `docs/adr/`. Keep resolution sync-only and add no + alternatives on the [design decisions](../introduction/design-decisions.md#non-goals) page. Keep resolution sync-only and add no runtime dependency beyond the framework and `modern-di`. `ruff` is unpinned and CI floats it forward, so keep `CPY001` (no per-file copyright header) in the lint `ignore` and reflow any pre-existing Markdown-embedded code fences the diff --git a/docs/introduction/design-decisions.md b/docs/introduction/design-decisions.md index d8bc1e18..aebf9cec 100644 --- a/docs/introduction/design-decisions.md +++ b/docs/introduction/design-decisions.md @@ -46,41 +46,49 @@ The codebase is type-checked with `ty` and linted with ruff's full rule set (`se New features get added only when existing primitives genuinely cannot solve the task. The core has three concrete provider types (`Factory`, `Alias`, `ContextProvider`), plus the `AbstractProvider` base and the pre-built `container_provider` singleton — most other DI frameworks have two to three times that. This is deliberate: a small, composable core is easier to learn, easier to test, and easier to keep correct. +The provider set is closed. `AbstractProvider` is the shared base that appears in signatures, not a hook: resolution compiles a resolver per known provider type, so a subclass of `AbstractProvider` or `Factory` raises `TypeError` at its first resolve. Compose behaviour in a creator function or an `Alias` instead. + +Caching is one argument, `Factory(cache=True | CacheSettings(...))`, rather than a `Singleton` class: a class would say "cached" in its name and again in the settings it still needs for a finalizer, and the two can drift. + +## 6. Validation is explicit + +`container.validate()` is the only thing that walks the graph. Construction, `open()`, `add_providers` and `resolve()` never validate. 3.0 tied validation to a mandatory `open()`, and that produced six production defects with one root cause (the root's open hook does not fire in every execution context) plus an ordering rule that existed only because of the binding. An implicit scheme was built and discarded for the machinery it needed; a per-resolve check would tax the hot path for a property that matters once, at boot. The cost is that a broken graph surfaces from an explicit `validate()` or at resolve time. + ## Non-goals -Beyond the choices above, four more things are deliberately out of scope. Naming them here is meant to save you from filing (or us from re-litigating) the same feature request. +Beyond the choices above, these are deliberately out of scope. Naming them here is meant to save you from filing (or us from re-litigating) the same request. ### Auto-binding / auto-registration -**What:** modern-di never registers a provider for a type you didn't declare, and never infers wiring by scanning your codebase (import scanning, decorator scanning, `auto_bind`-style fallbacks some frameworks offer). +modern-di never registers a provider for a type you did not declare and never infers wiring by scanning your code. Auto-binding defers a missing-provider error from declaration time, where `UnsupportedCreatorParameterError` already raises, to whichever request first exercises the untested path. Register the provider in a `Group`; if the boilerplate is real, a small helper that builds several `Factory` instances from a list of classes is application code, not a framework feature. -**Why:** Auto-binding defers a missing-provider error from declaration time — where modern-di already raises `UnsupportedCreatorParameterError` — to whichever request first exercises the untested path. That's the opposite of the framework's declaration-time-failure bet, and it invites automagic wiring nobody can trace back to a source. +### In-package framework integrations -**Alternative:** Register the provider explicitly in a `Group`. If the boilerplate is real, write a small helper that builds several `Factory` instances from a list of classes — that's application code, not a framework feature. +The core package ships no framework-specific code; every integration is a separate `modern-di-*` package with its own release cadence. Bundling them would couple core's releases to every framework's churn and erode the zero-dependency guarantee. See [Writing an integration](../integrations/writing-integrations.md). -### In-package framework integrations +### Multibinding / collection injection -**What:** The core `modern-di` package ships no framework-specific code. Each integration (aiohttp, FastAPI, FastStream, Litestar, Starlette, Typer, Flask, gRPC, Celery, arq, taskiq, aiogram, pytest) is a separate `modern-di-*` package with its own release cadence. +One type, one provider. Registering several providers for one type and injecting them as a `list[T]` turns the registry from a type → provider map into type → collection, which changes every operation on it: duplicate registration becomes conditional, `resolve(T)` and `resolve(list[T])` resolve different things, overriding `T` is ambiguous, and validation can no longer tell an empty collection from a wiring mistake. A `Factory` that takes the individual dependencies and returns the list costs one provider. -**Why:** Bundling integrations into core would couple the library's release cadence to every framework's own churn, and would erode the zero-dependency guarantee that lets `modern-di` itself stay dependency-free. The separate-repo model is a standing architectural decision (see [`writing-integrations.md`](../integrations/writing-integrations.md)), not an oversight. +### Generator creators (teardown after `yield`) -**Alternative:** Install the matching adapter package — see the [Quickstart](../index.md) for the current list — or write your own following [Writing an integration](../integrations/writing-integrations.md). +`Factory` does not treat a generator creator as "yield the value, run the rest as a finalizer"; `CacheSettings(finalizer=)` is the only teardown spelling. The generator form is breaking (a generator creator resolves to the generator today), needs per-instance finalizer records for uncached factories and `bound_type` extraction from `Iterator[T]`, and cannot express an async finalizer under sync resolution, which the explicit form can. A `Factory` subclass in application code can wrap a generator creator and register the continuation as a finalizer. -### Graph rendering / visualization tooling +### An `enter_scope` alias for `build_child_container` -**What:** modern-di has no built-in way to render the dependency graph as a picture — no ASCII art, no bundled renderer, no `plot()`/`render()` call, no image output. +Peers name scope entry by intent (`enter_scope`, `CreateScope`); modern-di names the mechanism, because here the mechanism is the concept: a child container is a real object with its own cache and context, and "entering a scope" would hide that model. One spelling for the most-written call after `resolve()`. -**Why:** Rendering is a standalone subsystem (choosing, drawing, and maintaining a diagram toolchain) rather than an extension of an existing primitive, so it sits outside the conservative feature set and the zero-dependency guarantee. `validate()`'s aggregated, all-errors-at-once text report already surfaces the graph's problems without a new dependency or output format. +### Resolution tracing / logging -**Alternative:** None shipped today. If you need a picture of the graph, walk `Group.get_providers()` yourself and feed the edges to the diagram tool of your choice. +No `logging.getLogger("modern_di")` narrating resolution. The guard alone (`isEnabledFor(DEBUG)`) measured about 19 ns, roughly 10x a bare boolean, and a cached factory needs two; patched into the resolvers with tracing *off* it cost +37% on a warm cached hit and +31% on a by-type resolve, paid by every user for a feature they never enable. A compile-time gate would be free but adds a second activation API. Diagnostics are the job of the error messages, which carry the resolution chain at no hot-path cost. -### Static / compile-time wiring verification (a type-checker plugin) +### Graph rendering / visualization tooling -**What:** modern-di ships no static dependency-graph checker and no type-checker plugin (mypy, pyright, or `ty`). Whole-graph verification is the opt-in runtime [`validate()`](../providers/lifecycle.md), which walks the graph for missing providers, scope-direction violations, and cycles and reports them all at once — on top of the declaration-time `UnsupportedCreatorParameterError` that already fires when a creator can't be wired. +No built-in way to render the dependency graph as a picture. Rendering is a standalone subsystem rather than an extension of an existing primitive, so it sits outside the conservative feature set and the zero-dependency guarantee. Walk `Group.get_providers()` yourself and feed the edges to the diagram tool of your choice. -**Why:** In the wider field, true compile-time wiring checks are a property of compiled-language toolchains — Dagger's annotation processor, Google Wire's codegen, Koin's K2 compiler plugin — and where they exist they *replace* runtime verification rather than extend it (Koin's docs tell users to delete their `verify()` tests). A Python type-checker plugin can't cheaply emulate that: pyright supports no third-party plugins by design, `ty` (which modern-di itself uses) has none either, and only mypy exposes one — a plugin API its own docs call experimental, changed without deprecation. Such a plugin would serve only mypy users, duplicate `validate()`, and not even help modern-di's own toolchain. +### Static / compile-time wiring verification (a type-checker plugin) -**Alternative:** Call `container.validate()` explicitly in a startup path or a single test — it is runtime, so it works identically under mypy, pyright, and `ty`, with no plugin to install. +No static dependency-graph checker and no type-checker plugin. True compile-time wiring checks are a property of compiled-language toolchains (Dagger, Wire, Koin's compiler plugin), and where they exist they replace runtime verification rather than extend it. A Python plugin is infeasible here: pyright supports no third-party plugins, `ty` (which modern-di uses) has none, and mypy's plugin API is experimental. Call [`validate()`](../providers/lifecycle.md) in a startup path or a single test; it works identically under every checker. ## See also diff --git a/docs/introduction/performance.md b/docs/introduction/performance.md index 505196f1..65073f32 100644 --- a/docs/introduction/performance.md +++ b/docs/introduction/performance.md @@ -3,8 +3,8 @@ This page compares modern-di's resolution performance against four other Python DI frameworks, states the method, and gives a command to reproduce the numbers. modern-di has no runtime dependencies; each `Factory` resolver is generated from a -source template (`docs/adr/0030-exec-template-resolver.md` in the repository records -why and the measurements). The comparison set includes two other frameworks that use +source template ([why, and the designs that were rejected](#why-the-results-look-this-way)). +The comparison set includes two other frameworks that use `exec` codegen (dishka, wireup), one with a Cython-compiled core (dependency-injector), and one pure-Python framework (that-depends). @@ -285,8 +285,21 @@ constants as globals, one code object per shape. Every arity is unrolled, so a w builds a list and star-calls its creator (the C3 and by-type movement above). The same change compiled overrides in, so no resolver checks the overrides registry on the hot path, and gave `Container.resolve` a direct type → resolver memo, which is what closed the by-type surcharge. -The measurements and the rejected designs are recorded in `docs/adr/0030-exec-template-resolver.md` -in the repository. +Every all-Python single-copy design was measured before the template, on the guard tier: + +| Design | G1 transient | G3 chain (6) | G4 wide (10) | +|---|---|---|---| +| Arity ladder folded into one closure with branches | +31% | +26% | +35% | +| Shared `build()` helper | +66% | +47% | +47% | +| Shared `build()` and `call()` helpers | +79% | +62% | +58% | +| Template, one code object per provider | −2% | −21% | −30% | +| **Template, one code object per shape** (shipped) | −5% | +4% | −13% | + +The folded ladder lost a quarter with every added branch *untaken*: on CPython 3.12+ a closure's +size costs on every call, not only its frames. The per-provider row is faster because a code +object shared across providers turns its call sites polymorphic for the specialising interpreter; +per shape shipped anyway, because `compile()` costs ~70 µs per provider and a test suite building +a container per test would pay it per test. ## Reproduce it yourself diff --git a/docs/providers/advanced-api.md b/docs/providers/advanced-api.md index 6ca23bd4..eb1723ca 100644 --- a/docs/providers/advanced-api.md +++ b/docs/providers/advanced-api.md @@ -15,7 +15,7 @@ inspect or iterate all providers declared on a group hierarchy. `Factory`, `Alias`, `ContextProvider`, and the pre-built `container_provider` are the only provider types. `AbstractProvider` is their shared base and the type that appears in public signatures (`resolve_dependency`, `kwargs=`), but it is **not** a hook for - adding your own: resolution compiles one closure per known provider type, so a subclass + adding your own: resolution compiles a resolver per known provider type, so a subclass of `AbstractProvider` — or of `Factory` — raises `TypeError` at its first resolve, and `validate()` does not catch it. Compose behavior in a creator function, or use `Alias`, instead of introducing a provider type. @@ -34,6 +34,12 @@ raising `ScopeNotInitializedError` or `ScopeSkippedError` if the scope is absent It is the primitive the compiled resolvers use to locate the container at a provider's scope when it differs from the resolving container's. +It is also the one method a `Container` subclass may meaningfully override: children are built +through `self.__class__`, so an override travels down the tree, and the container it returns is +the one whose cache receives a singleton and runs its finalizer. `resolve` and `resolve_provider` +are entry points, not hooks: a compiled resolver calls its dependencies' resolvers directly, so an +override of either sees only the top-level call. + ## Container internals — no stability guarantee !!! warning "Internal surface" diff --git a/docs/providers/errors-and-exceptions.md b/docs/providers/errors-and-exceptions.md index 3da2a6a0..a9cc1069 100644 --- a/docs/providers/errors-and-exceptions.md +++ b/docs/providers/errors-and-exceptions.md @@ -5,6 +5,10 @@ root, `ModernDIError`. The hierarchy is grouped by *when* the failure happens providers, validating the graph, resolving a type, or closing a container — so you can catch a whole category with one `except`. +The class hierarchy and each error's structured attributes (`.provider_type`, `.cycle_path`, +`.suggestions`, `.dependency_path`, ...) are the contract. The rendered message text is diagnostic +output and may change in any release; read an attribute, never parse the message. + ```python from modern_di import exceptions ``` diff --git a/docs/requirements.txt b/docs/requirements.txt index 00e39a49..1b29cf30 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,4 @@ +# No mkdocs-redirects: 1.2.3 pulls in `properdocs` and caps mkdocs<=1.6.1; the two merged-page URLs 404 instead. mkdocs>=1.6,<2 mkdocs-material>=9,<10 mkdocs-llmstxt>=0.5,<0.6 diff --git a/mkdocs.yml b/mkdocs.yml index 14295aa3..75cf7137 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -119,8 +119,8 @@ theme: icon: material/brightness-4 name: Switch to system preference -# docs/agents/ is agent configuration, not user documentation; docs/adr/ is an -# internal decision record. Neither is published. +# docs/agents/ is agent configuration, not user documentation; docs/adr/ records +# internal design decisions. Neither is published. exclude_docs: | /agents/ /adr/ diff --git a/modern_di/resolver_compiler.py b/modern_di/resolver_compiler.py index 086f769b..033ff736 100644 --- a/modern_di/resolver_compiler.py +++ b/modern_di/resolver_compiler.py @@ -4,7 +4,8 @@ :class:`_Shape`, and ``exec``'d with the factory's constants as its globals. Every other provider type compiles to a small closure. An overridden provider compiles to its override value, so the resolvers never consult the overrides registry; applying an override drops the compiled -resolvers instead (see ``ProvidersRegistry.drop_resolvers``). +resolvers instead (see ``ProvidersRegistry.drop_resolvers``). Why a template and not shared +helpers: every all-Python single-copy design measured 25-80% slower (docs/introduction/performance.md). """ import dataclasses @@ -252,6 +253,7 @@ def resolve(container: "Container") -> typing.Any: def _compile_alias(a: "Alias[typing.Any]") -> "Resolver": """Call the source's resolver directly; a source registered later is picked up on the next resolve.""" + # Not bound to the source's resolver at compile time: the alias step in error chains needs this frame. source_type = a._source_type find_source = a._find_source @@ -298,6 +300,7 @@ def _navigate( resolution_step: "typing.Callable[[], exceptions.ResolutionStep]", ) -> "Container": """Cross-scope target lookup; a scope error carries this provider's resolution step.""" + # `find_container`, never an inlined `_scope_map` read: a Container subclass may redirect navigation. try: return container.find_container(scope) except _SCOPE_ERRORS as exc: diff --git a/pyproject.toml b/pyproject.toml index 88b1b5be..343579d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,11 +77,8 @@ isort.lines-after-imports = 2 isort.no-lines-before = ["standard-library", "local-folder"] [tool.ruff.lint.per-file-ignores] -# The single resolve path compiles closures over provider internals: reading `Factory._creator` -# and friends is the design, not an incident, and every compiled resolver returns `typing.Any`. -# Suppressed per-file rather than line by line. Handing the compiler a compile-spec instead was -# declined in docs/adr/0014-per-provider-compile-seam-declined.md; its revisit trigger is a second -# consumer of these privates, not a count of them. +# The resolver compiler reads `Factory._creator` and friends by design (one compiler, closed provider +# set), and every resolver returns `typing.Any`. "modern_di/resolver_compiler.py" = ["SLF001", "ANN401"] # White-box tests assert on container and registry internals; that is what they are for. "tests/**" = ["SLF001"]