diff --git a/CONTEXT.md b/CONTEXT.md index 02616031..c1a8bfd3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -54,6 +54,11 @@ _Avoid_: computed (implies caching), selector (picks a provider by key in other 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 1ae792ec..6178ce92 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..3e092e4a 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,51 @@ 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`. + +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 +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 e7c700bf..050248b3 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", ) @@ -54,6 +55,7 @@ def __init__( """ 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) @@ -83,7 +85,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. @@ -92,8 +96,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..f5c144e0 100644 --- a/modern_di/registries/providers_registry.py +++ b/modern_di/registries/providers_registry.py @@ -4,8 +4,9 @@ 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 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,47 @@ 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 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 values 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()} + passed_down = {id(provider): provider.scope for provider in registered} + ready = list(registered) + while ready: + parent = ready.pop() + for child in children_of[id(parent)]: + waiting[id(child)] -= 1 + if waiting[id(child)]: + continue + 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 new file mode 100644 index 00000000..aebb7a80 --- /dev/null +++ b/tests/test_inline_scope.py @@ -0,0 +1,184 @@ +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 + + +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