Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions planning/releases/3.4.0.md
Original file line number Diff line number Diff line change
@@ -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.
Loading