diff --git a/planning/releases/3.4.0.md b/planning/releases/3.4.0.md new file mode 100644 index 00000000..9b4c621b --- /dev/null +++ b/planning/releases/3.4.0.md @@ -0,0 +1,111 @@ +# modern-di 3.4.0 — one field per question on the scope path + +A provider's scope is now derived from what was actually declared instead of being +collapsed at construction and reconstructed afterwards. That is an internal change with +no effect on which scope any provider resolves at, but two small pieces of surface go +with it — see **Breaking changes** before upgrading if you touch `ContextProvider` +internals or assign to `provider.scope`. Resolving a `ContextProvider` directly also got +16% faster. + +## Internal refactors + +- **`AbstractProvider` derives its scope rather than storing it.** The constructor used + to collapse an omitted `scope=` to `Scope.APP` on its first line, which erased *why* a + provider was APP-scoped — precisely the fact the precedence rule needs (explicit + `scope=` beats a `Group` default beats `Scope.APP`). A companion flag existed only to + carry that erased bit back, and had to stay in sync with the field recording which + group had stamped the provider. + + The sources are kept instead: `_explicit_scope` holds what `scope=` gave (`None` when + omitted) and `_group_claim` holds `(scope, group name)` once a `Group` stamps it. + `scope` is a property whose body is the documented precedence list line for line. Each + field answers exactly one question, and the effective scope cannot drift from its + provenance because it is computed from it. Slot count is unchanged. + + Every group-scope precedence, conflict and freeze test passes untouched; + `GroupScopeConflictError` and `ProviderScopeFrozenError` fire on exactly the same + inputs as before. + +## Performance + +Measured on an Apple M2 / CPython 3.14.7, `min` of 11 timing repeats, averaged across +three separate processes. + +| Path | 3.3.0 | 3.4.0 | | +|---|---|---|---| +| direct `ContextProvider` resolve | 194.3 ns | **161.6 ns** | −16.8% | + +- **The compiled `ContextProvider` resolver inlines its lookup.** It used to delegate to + `ContextProvider.resolve`, which called `fetch_context_value`, which read + `provider.scope` on every resolve. The compiled closure now performs the override + guard, scope hop, reopen check and context read itself, with the scope read once at + compile time — the same shape the folded context kwargs have used since 3.3.0, and + consistent with the contract that a registered `ContextProvider`'s scope and + `context_type` are fixed. Two delegated frames go with it, which is where most of the + gain comes from. + + This path is what the `Annotated` marker injectors hit when a handler asks for a + context type directly, once per marker per request. + +- **The guard tier is unchanged.** All 25 scenarios sit inside run-to-run noise; the + control scenario moved further than any real one. No guard scenario covers a direct + `ContextProvider` resolve, which is why the number above is measured separately. + +## Breaking changes + +Both are removals of machinery that was never a designed extension point, in the same +class as the provider-type closure shipped in 2.29.0. + +- **`ContextProvider.resolve(container)` is removed.** Its only caller was the compiled + resolver branch above, and the polymorphic `provider.resolve(self)` dispatch it + belonged to was retired in 2.29.0 + ([decision](https://github.com/modern-python/modern-di/blob/main/planning/decisions/2026-07-17-custom-providers-retracted.md)). + Nothing in the docs referenced it. Resolve through the container + (`container.resolve(SomeType)` or `container.resolve_provider(provider)`), which is + unchanged and raises the same `ContextValueNotSetError` with the same message. + + **`ContextProvider.fetch_context_value(container)` is unaffected.** Public since + 2.18.0, it remains the way to read a context value without raising, returning `UNSET` + when nothing is set. It now has direct tests covering the same-scope read, the + cross-scope hop, the closed-owner reopen, and the absent value. + +- **`provider.scope` is read-only.** It is now a property, so assigning to it raises + `AttributeError` where it previously silently succeeded. Reading is unchanged + everywhere, including before any container exists (`MyGroup.svc.scope` still reflects + a group default at class-creation time). Mutating a registered provider's scope was + already unsupported: the group path has raised `ProviderScopeFrozenError` since 2.x, + and 3.3.0 stated the contract that a `ContextProvider`'s identity is fixed once + something resolves through it. Set the scope at construction (`scope=`) or via the + `Group` default; to change it, construct a second provider. + +## What did not change + +Which scope any provider resolves at, the precedence rule, the freeze, every error type, +message and breadcrumb, and the `Alias` / `container_provider` opt-out from group +stamping. Resolution stays sync-only. No public name other than the two above moved, and +no documented statement in `docs/providers/scopes.md` needed editing. + +## Docs + +- `docs/architecture/` is gone. The living truth about behaviour is the code and its + `INVARIANT:`-marked tests, and a behaviour change is reviewed with the diff rather than + promoted to a prose page; the invariant census test now enforces that every citation + pointing at a test resolves to a real one. +- `docs/introduction/performance.md`'s comparative table was republished at 3.3.0, which + it had lagged. It does not yet include this release, whose only measured change is on a + path that table does not cover. + +## Internals + +- 514 tests, 100% line coverage, Python 3.10–3.14 including free-threaded 3.14t; `ruff` + and `ty` clean. +- One invariant test added for the compile-time scope capture, carrying a positive + control so it cannot pass vacuously, plus two for the group-default opt-outs on `Alias` + and `container_provider` — both written and confirmed passing before the refactor, so + they characterise existing behaviour rather than the new code. + +## Downstream + +**No action needed** for any integration that resolves through a `Container`. Check only +if you call `ContextProvider.resolve` directly or assign to `provider.scope`; neither +appears in any sibling integration.