From f85764c3ac79369caa03c7ef1791de778940a70c Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 19 Oct 2026 09:08:54 +0300 Subject: [PATCH 1/2] fix: give inline providers in Factory kwargs the scope of the providers using them A provider written inline in a Factory's kwargs was never stamped and always resolved at APP scope. An inline cache=True Factory under a REQUEST parent was silently shared by every request, and inline providers over request-scoped sources failed validate(). At registration, each inline provider without its own scope now takes the shallowest scope among the providers that use it, and is frozen there. Closes #732 --- CONTEXT.md | 5 + docs/providers/factories.md | 4 +- docs/providers/scopes.md | 44 +++++- modern_di/providers/abstract.py | 14 +- modern_di/registries/providers_registry.py | 44 +++++- tests/test_inline_scope.py | 173 +++++++++++++++++++++ 6 files changed, 280 insertions(+), 4 deletions(-) create mode 100644 tests/test_inline_scope.py diff --git a/CONTEXT.md b/CONTEXT.md index eeaa9328..c3b105d9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -49,6 +49,11 @@ _Avoid_: registered type, return type A provider with no bound type, resolvable only by passing the provider itself. _Avoid_: unbound provider, anonymous provider +**Inline provider**: +A provider written as a value in another factory's `kwargs` instead of declared as a group class +attribute. Without its own scope it takes the shallowest scope among the providers that use it. +_Avoid_: nested provider + **Wiring plan**: The partition of a creator's parameters by how each is satisfied. _Avoid_: compiled kwargs diff --git a/docs/providers/factories.md b/docs/providers/factories.md index f98c934d..4db54cda 100644 --- a/docs/providers/factories.md +++ b/docs/providers/factories.md @@ -77,7 +77,9 @@ them, for example `bound_type=None` to resolve each by reference. Before modern- The provider's lifetime. Without `scope=`, the provider takes its group's default scope, or `Scope.APP` when the group sets none; see -[Group-level default scope](scopes.md#group-level-default-scope). +[Group-level default scope](scopes.md#group-level-default-scope). A provider written inline in +another factory's `kwargs` takes the scope of the providers that use it; see +[Inline providers](scopes.md#inline-providers). [Scopes](scopes.md) covers the five scopes and the dependency rule. ### bound_type diff --git a/docs/providers/scopes.md b/docs/providers/scopes.md index aebb71b7..bc23bd94 100644 --- a/docs/providers/scopes.md +++ b/docs/providers/scopes.md @@ -220,7 +220,8 @@ Scope resolution follows a priority order: 1. An explicit `scope=` on the provider always wins. 2. Then the group's `scope=` kwarg, inherited via MRO by subclasses. A subclass may override it with its own `scope=` kwarg, which applies to the providers declared in its own body; inherited providers keep the scope their declaring class gave them. -3. Otherwise `Scope.APP`, the final default. +3. For a provider written inline in another factory's `kwargs`, the scope of the providers that use it; see [Inline providers](#inline-providers). +4. Otherwise `Scope.APP`, the final default. `Alias` providers do not participate in group-level scope defaults; an alias's scope always derives from its source. @@ -228,6 +229,47 @@ A scope-defaulted provider instance that is shared between two `Group` subclasse A group declared without a `scope=` kwarg stamps nothing, so a provider listed only in such a group keeps the `Scope.APP` default and can still be stamped by a later group, but only until it is registered with a container. After that, a group that would *change* its scope raises [`ProviderScopeFrozenError`](../troubleshooting/provider-scope-frozen-error.md), because resolvers compiled before the change already captured the old scope. Declare every group that lists a provider before building the container, or set `scope=` on the provider explicitly. +## Inline providers + +A provider written inline in a factory's `kwargs` is not a class attribute, so no group stamps it. +Without its own `scope=`, it takes the scope of the provider it sits in when the container registers +them: + +```python +from modern_di import Container, Group, Scope, providers + + +class DbSession: + pass + + +class Handler: + def __init__(self, session: DbSession) -> None: + self.session = session + + +class Handlers(Group, scope=Scope.REQUEST): + handler = providers.Factory(Handler, kwargs={"session": providers.Factory(DbSession, cache=True)}) + + +handlers_container = Container(groups=[Handlers]) +with handlers_container.build_child_container(scope=Scope.REQUEST) as first: + first_session = first.resolve(Handler).session +with handlers_container.build_child_container(scope=Scope.REQUEST) as second: + assert second.resolve(Handler).session is not first_session # one DbSession per request +``` + +When one inline provider object sits in the `kwargs` of several providers, it takes the shallowest of +their scopes, so that each of them can resolve it. A module-level provider for a settings field +shared by an `APP` engine and a `REQUEST` session gets `APP`. + +An explicit `scope=` on the inline provider wins, as it does everywhere. A provider that is a class +attribute of some group keeps its own scope wherever it is referenced. An inline provider's scope is +frozen when it is first registered. A later `add_providers()` call never changes it, so a new +provider shallower than that scope fails `validate()` with `InvalidScopeDependencyError`, and a group +declared later that would change it raises +[`ProviderScopeFrozenError`](../troubleshooting/provider-scope-frozen-error.md). + ## See also - [Lifecycle](lifecycle.md): finalizers and `close_async()` work per-scope. diff --git a/modern_di/providers/abstract.py b/modern_di/providers/abstract.py index b47703b6..ba94a2d0 100644 --- a/modern_di/providers/abstract.py +++ b/modern_di/providers/abstract.py @@ -24,6 +24,7 @@ class AbstractProvider(typing.Generic[types.T_co]): "_declared_as", "_explicit_scope", "_group_claim", + "_inline_scope", "_provider_id", "_registered", ) @@ -50,6 +51,7 @@ def __init__( """Set the shared state; an unset ``bound_type`` falls back to ``inferred_bound_type``.""" self._explicit_scope: enum.IntEnum | None = scope if isinstance(scope, enum.IntEnum) else None self._group_claim: tuple[enum.IntEnum, str] | None = None + self._inline_scope: enum.IntEnum | None = None self._registered = False self._bound_type = inferred_bound_type if isinstance(bound_type, types.UnsetType) else bound_type self._provider_id = next(_provider_id_counter) @@ -80,7 +82,9 @@ def provider_id(self) -> int: @property def scope(self) -> enum.IntEnum: - """The declared scope: the provider's own ``scope=``, else a Group default, else ``Scope.APP``. + """The declared scope: own ``scope=``, else a Group default, else an inline scope, else ``Scope.APP``. + + An inline provider's scope is the shallowest scope among the providers using it. For a redirect such as ``Alias`` this is a ``Scope.APP`` placeholder, not the effective scope: an alias resolves at its source's scope. @@ -89,8 +93,16 @@ def scope(self) -> enum.IntEnum: return self._explicit_scope if self._group_claim is not None: return self._group_claim[0] + if self._inline_scope is not None: + return self._inline_scope return Scope.APP + def _stamp_inline_scope(self, scope: enum.IntEnum) -> None: + """Record the scope an inline provider takes from the providers using it, then freeze it.""" + if not self._registered and self._takes_group_scope and self._explicit_scope is None: + self._inline_scope = scope + self._registered = True + def _stamp_group_scope(self, scope: enum.IntEnum, group_name: str) -> None: """Record a Group-level default scope; a no-op unless the scope is still an unclaimed default. diff --git a/modern_di/registries/providers_registry.py b/modern_di/registries/providers_registry.py index 58823fd3..a9fcc488 100644 --- a/modern_di/registries/providers_registry.py +++ b/modern_di/registries/providers_registry.py @@ -6,6 +6,7 @@ from modern_di.dependency_graph import terminal_chain from modern_di.providers.abstract import AbstractProvider from modern_di.providers.alias import Alias +from modern_di.providers.factory import Factory from modern_di.registries.overrides_registry import OverridesRegistry from modern_di.resolver_compiler import compile_resolver from modern_di.wiring import WiringPlan @@ -13,7 +14,6 @@ if typing.TYPE_CHECKING: from modern_di import Container - from modern_di.providers.factory import Factory _K = typing.TypeVar("_K") @@ -186,6 +186,7 @@ def _add( raise exceptions.DuplicateProviderTypeError( provider_type=provider_type, first_provider=existing, second_provider=provider ) + _stamp_inline_scopes(registered) self._providers.update(new_providers) bound = {id(provider) for provider in new_providers.values()} for provider in registered: @@ -205,3 +206,44 @@ def _drop_resolvers(self) -> None: self._resolvers.clear() self._resolvers_by_type.clear() self._generation += 1 + + +def _inline_children(provider: AbstractProvider[typing.Any]) -> list[AbstractProvider[typing.Any]]: + """Return the providers written inline in ``provider``'s kwargs: none of them is a class attribute.""" + if type(provider) is not Factory or not provider._kwargs: # noqa: SLF001 + return [] + return [ + value + for value in provider._kwargs.values() # noqa: SLF001 + if isinstance(value, AbstractProvider) and value._declared_as is None # noqa: SLF001 + ] + + +def _stamp_inline_scopes(registered: tuple[AbstractProvider[typing.Any], ...]) -> None: + """Give each scope-defaulted inline provider the shallowest scope among the providers using it. + + A scope set by an earlier registration is kept. + """ + roots = {id(provider) for provider in registered} + children_of: dict[int, list[AbstractProvider[typing.Any]]] = {} + parents_of: dict[int, list[AbstractProvider[typing.Any]]] = {} + stack = list(registered) + while stack: + parent = stack.pop() + if id(parent) in children_of: + continue + children = [child for child in _inline_children(parent) if id(child) not in roots] + children_of[id(parent)] = children + for child in children: + parents_of.setdefault(id(child), []).append(parent) + stack.append(child) + waiting = {child_id: len(parents) for child_id, parents in parents_of.items()} + ready = list(registered) + while ready: + parent = ready.pop() + for child in children_of[id(parent)]: + waiting[id(child)] -= 1 + if waiting[id(child)]: + continue + child._stamp_inline_scope(min(p.scope for p in parents_of[id(child)])) # noqa: SLF001 + ready.append(child) diff --git a/tests/test_inline_scope.py b/tests/test_inline_scope.py new file mode 100644 index 00000000..90d58002 --- /dev/null +++ b/tests/test_inline_scope.py @@ -0,0 +1,173 @@ +import dataclasses + +import pytest + +from modern_di import Container, Group, Scope, providers +from modern_di.exceptions import ProviderScopeFrozenError, ValidationFailedError + + +class _Session: ... + + +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class _Request: + user: str + + +class _Handler: + def __init__(self, session: _Session) -> None: + self.session = session + + +def _sessions_of_two_requests(container: Container) -> tuple[_Session, _Session]: + with container.build_child_container(scope=Scope.REQUEST) as first: + a = first.resolve(_Handler).session + with container.build_child_container(scope=Scope.REQUEST) as second: + b = second.resolve(_Handler).session + return a, b + + +def test_inline_cached_factory_takes_group_default_scope() -> None: + class Dependencies(Group, scope=Scope.REQUEST): + handler = providers.Factory(_Handler, kwargs={"session": providers.Factory(_Session, cache=True)}) + + a, b = _sessions_of_two_requests(Container(groups=[Dependencies])) + + assert a is not b + + +def test_inline_cached_factory_takes_explicit_parent_scope() -> None: + class Dependencies(Group): + handler = providers.Factory( + _Handler, scope=Scope.REQUEST, kwargs={"session": providers.Factory(_Session, cache=True)} + ) + + a, b = _sessions_of_two_requests(Container(groups=[Dependencies])) + + assert a is not b + + +def test_inline_factory_over_request_source_validates_and_resolves() -> None: + class Dependencies(Group, scope=Scope.REQUEST): + request = providers.ContextProvider(_Request) + user_name = providers.Factory( + str, kwargs={"object": providers.Factory(lambda r: r.user, kwargs={"r": request})} + ) + + container = Container(groups=[Dependencies]) + container.validate() + + with container.build_child_container(scope=Scope.REQUEST, context={_Request: _Request(user="alice")}) as child: + assert child.resolve_provider(Dependencies.user_name) == "alice" + + +def test_inline_context_provider_reads_request_context() -> None: + class Dependencies(Group, scope=Scope.REQUEST): + user = providers.Factory(lambda r: r.user, kwargs={"r": providers.ContextProvider(_Request)}) + + container = Container(groups=[Dependencies]) + + with container.build_child_container(scope=Scope.REQUEST, context={_Request: _Request(user="alice")}) as child: + assert child.resolve_provider(Dependencies.user) == "alice" + + +class _Engine: + def __init__(self, dsn: str) -> None: + self.dsn = dsn + + +class _DbConnection: + def __init__(self, dsn: str) -> None: + self.dsn = dsn + + +def test_inline_provider_shared_by_app_and_request_parents_takes_app_scope() -> None: + dsn = providers.Factory(lambda: "postgres://") + + class Dependencies(Group): + db_connection = providers.Factory(_DbConnection, scope=Scope.REQUEST, kwargs={"dsn": dsn}) + engine = providers.Factory(_Engine, kwargs={"dsn": dsn}, cache=True) + + container = Container(groups=[Dependencies]) + container.validate() + + assert dsn.scope is Scope.APP + with container.build_child_container(scope=Scope.REQUEST) as child: + assert child.resolve(_DbConnection).dsn == child.resolve(_Engine).dsn == "postgres://" + + +def test_inline_provider_scope_is_fixed_at_first_registration() -> None: + dsn = providers.Factory(lambda: "postgres://") + + class Dependencies(Group): + db_connection = providers.Factory(_DbConnection, scope=Scope.REQUEST, kwargs={"dsn": dsn}) + + container = Container(groups=[Dependencies]) + container.add_providers(providers.Factory(_Engine, kwargs={"dsn": dsn}, cache=True)) + + assert dsn.scope is Scope.REQUEST + with pytest.raises(ValidationFailedError, match="InvalidScopeDependencyError"): + container.validate() + + +def test_inline_provider_with_explicit_scope_keeps_it() -> None: + class Dependencies(Group, scope=Scope.REQUEST): + handler = providers.Factory( + _Handler, kwargs={"session": providers.Factory(_Session, scope=Scope.APP, cache=True)} + ) + + a, b = _sessions_of_two_requests(Container(groups=[Dependencies])) + + assert a is b + + +def test_class_attribute_of_another_group_keeps_its_scope() -> None: + class Shared(Group): + session = providers.Factory(_Session, cache=True) + + class Dependencies(Group, scope=Scope.REQUEST): + handler = providers.Factory(_Handler, kwargs={"session": Shared.session}) + + a, b = _sessions_of_two_requests(Container(groups=[Shared, Dependencies])) + + assert Shared.session.scope is Scope.APP + assert a is b + + +class _Outer: + def __init__(self, handler: _Handler) -> None: + self.handler = handler + + +def test_provider_inline_in_an_inline_provider_takes_the_scope_down_the_chain() -> None: + class Dependencies(Group, scope=Scope.REQUEST): + outer = providers.Factory( + _Outer, + kwargs={ + "handler": providers.Factory(_Handler, kwargs={"session": providers.Factory(_Session, cache=True)}) + }, + ) + + container = Container(groups=[Dependencies]) + with container.build_child_container(scope=Scope.REQUEST) as first: + a = first.resolve(_Outer).handler.session + with container.build_child_container(scope=Scope.REQUEST) as second: + b = second.resolve(_Outer).handler.session + + assert a is not b + + +def test_group_cannot_change_scope_of_registered_inline_provider() -> None: + session = providers.Factory(_Session, cache=True) + + class Dependencies(Group, scope=Scope.REQUEST): + handler = providers.Factory(_Handler, kwargs={"session": session}) + + Container(groups=[Dependencies]) + + with pytest.raises(ProviderScopeFrozenError, match="Later"): + + class Later(Group, scope=Scope.SESSION): + shared_session = session + + assert session.scope is Scope.REQUEST From f4388f7670027c845a0c70e57bb04d4a9787df10 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 19 Oct 2026 09:24:22 +0300 Subject: [PATCH 2/2] fix: pass the inline scope through a derived provider to its inline source --- docs/providers/scopes.md | 4 ++++ modern_di/registries/providers_registry.py | 21 ++++++++++++--------- tests/test_inline_scope.py | 11 +++++++++++ 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/docs/providers/scopes.md b/docs/providers/scopes.md index bc23bd94..3e092e4a 100644 --- a/docs/providers/scopes.md +++ b/docs/providers/scopes.md @@ -263,6 +263,10 @@ When one inline provider object sits in the `kwargs` of several providers, it ta their scopes, so that each of them can resolve it. A module-level provider for a settings field shared by an `APP` engine and a `REQUEST` session gets `APP`. +A [derived provider](factories.md#derived-values) has no scope of its own, so the rule passes through +it: in `kwargs={"session": providers.Factory(DbSession, cache=True).derive(fn)}`, the inline +`DbSession` factory takes the scope of the provider that uses the derived one. + An explicit `scope=` on the inline provider wins, as it does everywhere. A provider that is a class attribute of some group keeps its own scope wherever it is referenced. An inline provider's scope is frozen when it is first registered. A later `add_providers()` call never changes it, so a new diff --git a/modern_di/registries/providers_registry.py b/modern_di/registries/providers_registry.py index a9fcc488..f5c144e0 100644 --- a/modern_di/registries/providers_registry.py +++ b/modern_di/registries/providers_registry.py @@ -4,7 +4,7 @@ from modern_di import exceptions, suggester, types from modern_di.dependency_graph import terminal_chain -from modern_di.providers.abstract import AbstractProvider +from modern_di.providers.abstract import AbstractProvider, _Derived from modern_di.providers.alias import Alias from modern_di.providers.factory import Factory from modern_di.registries.overrides_registry import OverridesRegistry @@ -209,14 +209,14 @@ def _drop_resolvers(self) -> None: def _inline_children(provider: AbstractProvider[typing.Any]) -> list[AbstractProvider[typing.Any]]: - """Return the providers written inline in ``provider``'s kwargs: none of them is a class attribute.""" - if type(provider) is not Factory or not provider._kwargs: # noqa: SLF001 + """Return the providers written inline in ``provider``'s kwargs or derive source: none is a class attribute.""" + if type(provider) is _Derived: + values: typing.Iterable[typing.Any] = (provider._source,) # noqa: SLF001 + elif type(provider) is Factory and provider._kwargs: # noqa: SLF001 + values = provider._kwargs.values() # noqa: SLF001 + else: return [] - return [ - value - for value in provider._kwargs.values() # noqa: SLF001 - if isinstance(value, AbstractProvider) and value._declared_as is None # noqa: SLF001 - ] + return [value for value in values if isinstance(value, AbstractProvider) and value._declared_as is None] # noqa: SLF001 def _stamp_inline_scopes(registered: tuple[AbstractProvider[typing.Any], ...]) -> None: @@ -238,6 +238,7 @@ def _stamp_inline_scopes(registered: tuple[AbstractProvider[typing.Any], ...]) - parents_of.setdefault(id(child), []).append(parent) stack.append(child) waiting = {child_id: len(parents) for child_id, parents in parents_of.items()} + passed_down = {id(provider): provider.scope for provider in registered} ready = list(registered) while ready: parent = ready.pop() @@ -245,5 +246,7 @@ def _stamp_inline_scopes(registered: tuple[AbstractProvider[typing.Any], ...]) - waiting[id(child)] -= 1 if waiting[id(child)]: continue - child._stamp_inline_scope(min(p.scope for p in parents_of[id(child)])) # noqa: SLF001 + scope = min(passed_down[id(p)] for p in parents_of[id(child)]) + child._stamp_inline_scope(scope) # noqa: SLF001 + passed_down[id(child)] = scope if type(child) is _Derived else child.scope ready.append(child) diff --git a/tests/test_inline_scope.py b/tests/test_inline_scope.py index 90d58002..aebb7a80 100644 --- a/tests/test_inline_scope.py +++ b/tests/test_inline_scope.py @@ -171,3 +171,14 @@ class Later(Group, scope=Scope.SESSION): shared_session = session assert session.scope is Scope.REQUEST + + +def test_inline_source_of_inline_derived_provider_takes_parent_scope() -> None: + class Dependencies(Group, scope=Scope.REQUEST): + handler = providers.Factory( + _Handler, kwargs={"session": providers.Factory(_Session, cache=True).derive(lambda s: s)} + ) + + a, b = _sessions_of_two_requests(Container(groups=[Dependencies])) + + assert a is not b