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 diff --git a/modern_di/providers/abstract.py b/modern_di/providers/abstract.py index abff31aa..579b9ac8 100644 --- a/modern_di/providers/abstract.py +++ b/modern_di/providers/abstract.py @@ -14,7 +14,10 @@ class AbstractProvider(abc.ABC, typing.Generic[types.T_co]): - __slots__ = ("_registered", "_scope_defaulted", "_stamping_group", "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.""" def __init__( self, @@ -22,27 +25,36 @@ 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 + 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 when the provider's scope was chosen explicitly. + """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. """ - if not self._scope_defaulted: + if not self._takes_group_scope or self._explicit_scope is not None: return - if self._stamping_group 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=self._stamping_group, - first_scope=self.scope, + first_group=first_group, + first_scope=first_scope, second_group=group_name, second_scope=scope, ) @@ -54,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._stamping_group = group_name + self._group_claim = (scope, 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/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 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