From 52943147675e12cb888ce8ce0f50b830c3cb15d9 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 21 Aug 2026 22:57:10 +0300 Subject: [PATCH 1/4] refactor(providers): single scope-source field; explicit group-stamp opt-out Replace _scope_defaulted + _stamping_group with one _scope_source field, and give Alias and the container provider a declarative _takes_group_scope = False instead of relying on a placeholder explicit scope to dodge stamping. --- modern_di/providers/abstract.py | 29 ++++++++++++++------- modern_di/providers/alias.py | 7 +++-- modern_di/providers/container_provider.py | 2 ++ tests/test_group.py | 31 +++++++++++++++++++++++ 4 files changed, 56 insertions(+), 13 deletions(-) diff --git a/modern_di/providers/abstract.py b/modern_di/providers/abstract.py index abff31aa..fd15f171 100644 --- a/modern_di/providers/abstract.py +++ b/modern_di/providers/abstract.py @@ -13,8 +13,18 @@ _provider_id_counter = itertools.count() +class _ExplicitScope: + """Scope-source marker for a scope that came from the provider's own ``scope=`` argument.""" + + +_EXPLICIT_SCOPE: typing.Final = _ExplicitScope() + + class AbstractProvider(abc.ABC, typing.Generic[types.T_co]): - __slots__ = ("_registered", "_scope_defaulted", "_stamping_group", "bound_type", "provider_id", "scope") + __slots__ = ("_registered", "_scope_source", "bound_type", "provider_id", "scope") + + _takes_group_scope: typing.ClassVar[bool] = True + """Whether a Group-level default scope applies. False when the effective scope is derived.""" def __init__( self, @@ -22,26 +32,27 @@ def __init__( scope: enum.IntEnum | types.UnsetType, bound_type: type | None, ) -> None: - self._scope_defaulted = isinstance(scope, types.UnsetType) - self.scope: enum.IntEnum = Scope.APP if isinstance(scope, types.UnsetType) else scope - self._stamping_group: str | None = None + explicit_scope = scope if isinstance(scope, enum.IntEnum) else None + self.scope: enum.IntEnum = Scope.APP if explicit_scope is None else explicit_scope + self._scope_source: str | _ExplicitScope | None = None if explicit_scope is None else _EXPLICIT_SCOPE self._registered = False self.bound_type = bound_type self.provider_id: typing.Final = next(_provider_id_counter) def _stamp_group_scope(self, scope: enum.IntEnum, group_name: str) -> None: - """Apply a Group-level default scope; no-op when the provider's scope was chosen explicitly. + """Apply a Group-level default scope; no-op unless this provider's scope is still an unclaimed default. Frozen once registered: a compiled resolver captures `scope`, so a later change would apply only to resolvers compiled after it. """ - if not self._scope_defaulted: + source = self._scope_source + if not self._takes_group_scope or isinstance(source, _ExplicitScope): return - if self._stamping_group is not None: + if source is not None: if self.scope != scope: raise exceptions.GroupScopeConflictError( provider_name=self.display_name, - first_group=self._stamping_group, + first_group=source, first_scope=self.scope, second_group=group_name, second_scope=scope, @@ -55,7 +66,7 @@ def _stamp_group_scope(self, scope: enum.IntEnum, group_name: str) -> None: new_scope=scope, ) self.scope = scope - self._stamping_group = group_name + self._scope_source = group_name @property def display_name(self) -> str: diff --git a/modern_di/providers/alias.py b/modern_di/providers/alias.py index 58f97a2f..73a783bd 100644 --- a/modern_di/providers/alias.py +++ b/modern_di/providers/alias.py @@ -2,7 +2,6 @@ from modern_di import exceptions, types from modern_di.providers.abstract import AbstractProvider -from modern_di.scope import Scope if typing.TYPE_CHECKING: @@ -12,16 +11,16 @@ class Alias(AbstractProvider[types.T_co]): __slots__ = ("_source_type",) + _takes_group_scope = False + def __init__( self, source_type: type[types.T_co], *, bound_type: type | types.UnsetType | None = types.UNSET, ) -> None: - # Always a concrete IntEnum (never UNSET), so `_scope_defaulted` stays False and - # group-default stamping skips aliases. An alias's effective scope is derived from its source. super().__init__( - scope=Scope.APP, bound_type=source_type if isinstance(bound_type, types.UnsetType) else bound_type + scope=types.UNSET, bound_type=source_type if isinstance(bound_type, types.UnsetType) else bound_type ) self._source_type = source_type diff --git a/modern_di/providers/container_provider.py b/modern_di/providers/container_provider.py index d48cdb31..409650d9 100644 --- a/modern_di/providers/container_provider.py +++ b/modern_di/providers/container_provider.py @@ -7,6 +7,8 @@ class _ContainerProvider(AbstractProvider[typing.Any]): __slots__ = () + _takes_group_scope = False + def __init__(self) -> None: super().__init__(scope=Scope.APP, bound_type=None) diff --git a/tests/test_group.py b/tests/test_group.py index 05139c80..f9b6788e 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -322,3 +322,34 @@ class ScopedGroup(Group, scope=Scope.REQUEST): svc = shared assert shared.scope is Scope.REQUEST + + +def test_group_scope_alias_still_resolves_from_the_source_container() -> None: + """INVARIANT: a group default never reaches an Alias; its effective scope derives from its source. + + `Alias._takes_group_scope` is False for this reason. Stamping one would move its stored scope + off the placeholder and make the alias unresolvable from the container its source lives in -- + here, a REQUEST stamp on an APP-scoped source resolved from the APP container. + """ + + class RequestGroup(Group, scope=Scope.REQUEST): + svc = providers.Factory(_Svc, scope=Scope.APP) + alias = providers.Alias(_Svc, bound_type=_Ctx) + + app_container = Container(groups=[RequestGroup]) + assert isinstance(app_container.resolve(_Ctx), _Svc) + + +def test_group_scope_does_not_stamp_the_container_provider() -> None: + """INVARIANT: a group default never reaches the container provider. + + It resolves to whichever container is asking, at every scope, and it is public -- so a group + body may list it. A REQUEST stamp would make the one shared singleton unresolvable from the + APP container for every other group in the process. + """ + + class RequestGroup(Group, scope=Scope.REQUEST): + current = providers.container_provider + + assert providers.container_provider.scope is Scope.APP + assert RequestGroup.get_named_providers()["current"] is providers.container_provider From e0659841f04e85822a267407e79d32d3f2eef6b3 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 22 Aug 2026 11:25:25 +0300 Subject: [PATCH 2/4] chore: rename Justfile to justfile Matches the casing every other tool-config file in the repo uses, and the `just` default. CLAUDE.md's link to it follows; leaving it pointing at the old casing is what `planning/links.py` reports as broken. --- CLAUDE.md | 2 +- Justfile => justfile | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename Justfile => justfile (100%) diff --git a/CLAUDE.md b/CLAUDE.md index 4d00b1a3..cbe772aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Commands This project uses `just` (task runner) and `uv` (package manager). The -[`Justfile`](Justfile) is the source of truth for recipes — run `just --list` +[`Justfile`](justfile) is the source of truth for recipes — run `just --list` or read it for every recipe and its intent. The non-obvious essentials: - `just test [args]` — pytest, **no coverage**; targeted runs won't trip the diff --git a/Justfile b/justfile similarity index 100% rename from Justfile rename to justfile From acde4f957dd8947c1cad95df2d00ace27b4b6268 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 22 Aug 2026 11:25:25 +0300 Subject: [PATCH 3/4] refactor(providers): derive scope from its declared source `scope` was collapsed to `Scope.APP` in `__init__`, which erased the one fact the group-default precedence rule needs: whether a scope was actually chosen. `_scope_defaulted`, and then the `_EXPLICIT_SCOPE` sentinel that replaced it, existed only to carry that erased bit back. Keep the sources instead and derive the answer. `_explicit_scope` holds what `scope=` gave (None when omitted); `_group_claim` holds `(scope, group name)` once a Group stamps it. `scope` becomes a property whose body is the documented precedence list line for line: explicit, else group, else APP. Each field now answers one question, and `scope` cannot drift from its provenance because it is derived from it. Same slot count; no behaviour change (511 tests unchanged). --- modern_di/providers/abstract.py | 40 ++++++++++++++++----------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/modern_di/providers/abstract.py b/modern_di/providers/abstract.py index fd15f171..579b9ac8 100644 --- a/modern_di/providers/abstract.py +++ b/modern_di/providers/abstract.py @@ -13,15 +13,8 @@ _provider_id_counter = itertools.count() -class _ExplicitScope: - """Scope-source marker for a scope that came from the provider's own ``scope=`` argument.""" - - -_EXPLICIT_SCOPE: typing.Final = _ExplicitScope() - - class AbstractProvider(abc.ABC, typing.Generic[types.T_co]): - __slots__ = ("_registered", "_scope_source", "bound_type", "provider_id", "scope") + __slots__ = ("_explicit_scope", "_group_claim", "_registered", "bound_type", "provider_id") _takes_group_scope: typing.ClassVar[bool] = True """Whether a Group-level default scope applies. False when the effective scope is derived.""" @@ -32,28 +25,36 @@ def __init__( scope: enum.IntEnum | types.UnsetType, bound_type: type | None, ) -> None: - explicit_scope = scope if isinstance(scope, enum.IntEnum) else None - self.scope: enum.IntEnum = Scope.APP if explicit_scope is None else explicit_scope - self._scope_source: str | _ExplicitScope | None = None if explicit_scope is None else _EXPLICIT_SCOPE + self._explicit_scope: enum.IntEnum | None = scope if isinstance(scope, enum.IntEnum) else None + self._group_claim: tuple[enum.IntEnum, str] | None = None self._registered = False self.bound_type = bound_type self.provider_id: typing.Final = next(_provider_id_counter) + @property + def scope(self) -> enum.IntEnum: + """The effective scope: the provider's own ``scope=``, else a Group default, else ``Scope.APP``.""" + if self._explicit_scope is not None: + return self._explicit_scope + if self._group_claim is not None: + return self._group_claim[0] + return Scope.APP + def _stamp_group_scope(self, scope: enum.IntEnum, group_name: str) -> None: - """Apply a Group-level default scope; no-op unless this provider's scope is still an unclaimed default. + """Record a Group-level default scope; no-op unless this provider's scope is still an unclaimed default. Frozen once registered: a compiled resolver captures `scope`, so a later change would apply only to resolvers compiled after it. """ - source = self._scope_source - if not self._takes_group_scope or isinstance(source, _ExplicitScope): + if not self._takes_group_scope or self._explicit_scope is not None: return - if source is not None: - if self.scope != scope: + if self._group_claim is not None: + first_scope, first_group = self._group_claim + if first_scope != scope: raise exceptions.GroupScopeConflictError( provider_name=self.display_name, - first_group=source, - first_scope=self.scope, + first_group=first_group, + first_scope=first_scope, second_group=group_name, second_scope=scope, ) @@ -65,8 +66,7 @@ def _stamp_group_scope(self, scope: enum.IntEnum, group_name: str) -> None: current_scope=self.scope, new_scope=scope, ) - self.scope = scope - self._scope_source = group_name + self._group_claim = (scope, group_name) @property def display_name(self) -> str: From 0a8532992fdfc07de1c7bfde27a8a9c190955676 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 22 Aug 2026 11:25:25 +0300 Subject: [PATCH 4/4] perf(compiler): inline the context lookup, reading scope at compile time `_compile_context_provider` delegated to `ContextProvider.resolve`, which read `self.scope` on every resolve. That was free while `scope` was a slot; as a derived property it costs ~11ns on a path the marker injectors hit once per marker per request. Inline the lookup into the compiled closure, as the folded context kwargs already do, with the scope read once at compile time -- the module docstring already asserts a registered ContextProvider's scope is fixed. This also drops the two delegated frames, so the path lands below where it started: a direct context resolve goes 194ns -> 162ns (min-of-11, three processes). `ContextProvider.resolve` goes with it: its only caller was this branch, and the polymorphic `provider.resolve(self)` dispatch it belonged to was retired in 2.29.0. `fetch_context_value`, public since 2.18.0, stays and gains the direct tests it never had. --- modern_di/providers/context_provider.py | 11 +--- modern_di/resolver_compiler.py | 18 ++++-- tests/providers/test_context_provider.py | 70 ++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 15 deletions(-) diff --git a/modern_di/providers/context_provider.py b/modern_di/providers/context_provider.py index 5b702ef7..694ec2fe 100644 --- a/modern_di/providers/context_provider.py +++ b/modern_di/providers/context_provider.py @@ -1,7 +1,7 @@ import enum import typing -from modern_di import exceptions, types +from modern_di import types from modern_di.providers.abstract import AbstractProvider @@ -36,15 +36,6 @@ def __init__( def __repr__(self) -> str: return f"ContextProvider(context_type={self.context_type!r}, scope={self.scope!r})" - def resolve(self, container: "Container") -> types.T_co: - value = self.fetch_context_value(container) - if value is types.UNSET: - resolving = container.find_container(self.scope) - raise exceptions.ContextValueNotSetError(context_type=self.context_type, scope_name=resolving.scope.name) - # `is UNSET` does not narrow in ty (UNSET is a Final instance, not a tracked singleton); - # isinstance would narrow but costs ~10ns on the context resolve path. - return value # ty: ignore[invalid-return-type] - def fetch_context_value(self, container: "Container") -> "types.T_co | types.UnsetType": # Same-scope int compare before the hop, as the compiled Factory closures do: a request # value read from the request container skips `find_container`'s frame. Not the compiler's diff --git a/modern_di/resolver_compiler.py b/modern_di/resolver_compiler.py index b302c8b4..055a4eed 100644 --- a/modern_di/resolver_compiler.py +++ b/modern_di/resolver_compiler.py @@ -428,13 +428,15 @@ def resolve(container: "Container") -> typing.Any: def _compile_context_provider(cp: "ContextProvider[typing.Any]") -> "typing.Callable[[Container], typing.Any]": - """Front-guard the override, then delegate to the bound `ContextProvider.resolve`. + """Front-guard the override, then inline the context lookup at this provider's fixed scope. - Reuses the bound method so the unset-value `ContextValueNotSetError` stays identical, not - reimplemented. + The same inline lookup the folded context kwargs use, with the scope read once here rather than + per resolve (see test_direct_context_resolve_reads_the_scope_only_at_compile_time). + `find_container`, never `_navigate`: nothing prepends a resolution step on the direct path. """ pid = cp.provider_id - resolve_bound = cp.resolve + scope = cp.scope + context_type = cp.context_type def resolve(container: "Container") -> typing.Any: overrides = container.overrides_registry @@ -442,7 +444,13 @@ def resolve(container: "Container") -> typing.Any: override = overrides.fetch_override(pid) if override is not types.UNSET: return override - return resolve_bound(container) + target = container if container.scope == scope else container.find_container(scope) + if target.closed: + target._prepare() + value = target.context_registry.find_context(context_type) + if value is types.UNSET: + raise exceptions.ContextValueNotSetError(context_type=context_type, scope_name=scope.name) + return value return resolve diff --git a/tests/providers/test_context_provider.py b/tests/providers/test_context_provider.py index 7f92dfec..705315ab 100644 --- a/tests/providers/test_context_provider.py +++ b/tests/providers/test_context_provider.py @@ -11,6 +11,8 @@ ContextValueNotSetError, ScopeNotInitializedError, ) +from modern_di.providers.abstract import AbstractProvider +from modern_di.types import UNSET request_context_provider = providers.ContextProvider(scope=Scope.REQUEST, context_type=datetime.datetime) @@ -644,3 +646,71 @@ class G(Group): with pytest.warns(ContainerClosedWarning): assert request.resolve(_CachedNullable).ctx is value + + +def test_direct_context_resolve_reads_the_scope_only_at_compile_time(monkeypatch: pytest.MonkeyPatch) -> None: + """INVARIANT: the compiled resolver for a ContextProvider consults `scope` once, at compile time. + + `scope` is a derived property, so reading it per resolve costs ~11ns on a path the marker + injectors hit once per marker per request. Delegating the lookup back to the provider instead + of inlining it here reintroduces that read. + """ + + class Cfg: ... + + class G(Group): + cfg = providers.ContextProvider(Cfg, scope=Scope.REQUEST) + + app = Container(scope=Scope.APP, groups=[G]) + app.open() + request = app.build_child_container(scope=Scope.REQUEST, context={Cfg: Cfg()}) + assert isinstance(request.resolve(Cfg), Cfg) # compile the resolver + + reads = 0 + original = AbstractProvider.scope.fget + + def counting_scope(self: providers.ContextProvider[object]) -> object: + nonlocal reads + reads += 1 + return original(self) + + monkeypatch.setattr(AbstractProvider, "scope", property(counting_scope)) + assert G.cfg.scope is Scope.REQUEST # positive control: the counter is wired in + assert reads == 1 + + reads = 0 + assert isinstance(request.resolve(Cfg), Cfg) + assert reads == 0 + + +def test_fetch_context_value_reports_an_absent_value_instead_of_raising() -> None: + """The public accessor returns UNSET where a direct resolve of the same provider raises.""" + + class Cfg: ... + + provider = providers.ContextProvider(Cfg, scope=Scope.APP) + app = Container(scope=Scope.APP) + app.add_providers(provider) + app.open() + + assert provider.fetch_context_value(app) is UNSET + with pytest.raises(ContextValueNotSetError): + app.resolve(Cfg) + + +def test_fetch_context_value_hops_to_the_provider_scope_reopening_a_closed_owner() -> None: + """From a deeper container the accessor navigates to the provider's own scope, reopening it if closed.""" + + class Cfg: ... + + cfg = Cfg() + provider = providers.ContextProvider(Cfg, scope=Scope.APP) + app = Container(scope=Scope.APP, context={Cfg: cfg}) + app.add_providers(provider) + app.open() + request = app.build_child_container(scope=Scope.REQUEST) + app.close_sync() + + with pytest.warns(ContainerClosedWarning): + assert provider.fetch_context_value(request) is cfg + assert app.closed is False