Skip to content
Merged
Show file tree
Hide file tree
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
File renamed without changes.
35 changes: 23 additions & 12 deletions modern_di/providers/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,35 +14,47 @@


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,
*,
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,
)
Expand All @@ -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:
Expand Down
7 changes: 3 additions & 4 deletions modern_di/providers/alias.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions modern_di/providers/container_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
11 changes: 1 addition & 10 deletions modern_di/providers/context_provider.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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
Expand Down
18 changes: 13 additions & 5 deletions modern_di/resolver_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,21 +428,29 @@ 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
if overrides.has_overrides:
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

Expand Down
70 changes: 70 additions & 0 deletions tests/providers/test_context_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
31 changes: 31 additions & 0 deletions tests/test_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading