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
5 changes: 5 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/providers/factories.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 47 additions & 1 deletion docs/providers/scopes.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,14 +220,60 @@ 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.

A scope-defaulted provider instance that is shared between two `Group` subclasses with different defaults raises [`GroupScopeConflictError`](../troubleshooting/group-scope-conflict-error.md) at class-creation time. Sharing the same provider instance with the same default scope across multiple groups is allowed.

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.
Expand Down
14 changes: 13 additions & 1 deletion modern_di/providers/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class AbstractProvider(typing.Generic[types.T_co]):
"_declared_as",
"_explicit_scope",
"_group_claim",
"_inline_scope",
"_provider_id",
"_registered",
)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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.

Expand Down
49 changes: 47 additions & 2 deletions modern_di/registries/providers_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@

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


if typing.TYPE_CHECKING:
from modern_di import Container
from modern_di.providers.factory import Factory


_K = typing.TypeVar("_K")
Expand Down Expand Up @@ -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:
Expand All @@ -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)
184 changes: 184 additions & 0 deletions tests/test_inline_scope.py
Original file line number Diff line number Diff line change
@@ -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
Loading