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
16 changes: 15 additions & 1 deletion architecture/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ attribute name to its provider — respecting inheritance order, de-duplicating
non-provider override mask the parent provider of the same name. `Group.get_providers()` is derived from it as
`list(cls.get_named_providers().values())`, so the traversal and de-duplication rules live in one place.

### Group-level default scope

A `Group` subclass may declare a default scope as a class kwarg — `class RequestGroup(Group, scope=Scope.REQUEST)`.
At class creation, `Group.__init_subclass__` stamps that scope onto every scope-defaulted `Factory`/`ContextProvider`
declared in that class body. Priority: an explicit `scope=` on the provider always wins; otherwise the nearest
group `scope=` kwarg in the MRO applies (a subclass without its own kwarg inherits its ancestor's; a subclass with
its own kwarg overrides it for its own body); otherwise the provider falls back to `Scope.APP`. `Alias` never
participates in stamping — its scope is always derived from its source, never chosen (see below). A
scope-defaulted provider instance shared between two group bodies with different defaults raises
`GroupScopeConflictError` (a `RegistrationError` subclass) at the second group's class-creation time, rather than
letting import order decide; sharing the same instance with the same default scope across groups is a no-op. See
[docs/providers/scopes.md#group-level-default-scope](../docs/providers/scopes.md#group-level-default-scope) for
the user-facing walkthrough.

---

## `Factory` — the universal provider
Expand All @@ -39,7 +53,7 @@ function passed as its `creator` argument) is a `Factory`. Each registerable pro
Factory(
creator: Callable[..., T],
*,
scope: IntEnum = Scope.APP,
scope: IntEnum = UNSET, # defaults to the group's scope, else Scope.APP
bound_type: type | None = UNSET,
kwargs: dict[str, Any] | None = None,
cache: bool | CacheSettings[T] | None = None,
Expand Down
6 changes: 6 additions & 0 deletions docs/providers/errors-and-exceptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ ModernDIError (RuntimeError)
├── RegistrationError
│ ├── DuplicateProviderTypeError
│ ├── ChildContainerRegistrationError
│ ├── GroupScopeConflictError
│ ├── UnknownFactoryKwargError
│ ├── UnsupportedCreatorParameterError
│ └── InvalidScopeDependencyError
Expand Down Expand Up @@ -136,6 +137,11 @@ Catch `RegistrationError` for declaration- and registration-time problems.
container instead. Inspect `.scope` for the offending child container's scope. See
[Container: registering after construction](container.md#registering-providers-after-construction) and
[Troubleshooting: ChildContainerRegistrationError](../troubleshooting/child-container-registration-error.md).
- **`GroupScopeConflictError`** — raised when a scope-defaulted provider (no explicit `scope=`) is
shared by two `Group` subclasses declared with different `scope=` kwargs; the provider's scope
cannot follow both defaults at once, and import order must never be what decides it. Inspect
`.provider_name`, `.first_group`/`.first_scope`, and `.second_group`/`.second_scope`. See
[Troubleshooting: GroupScopeConflictError](../troubleshooting/group-scope-conflict-error.md).
- **`UnknownFactoryKwargError`** — raised when `Factory(kwargs={...})` contains a key that is not a
parameter of the creator's signature; lists the known parameters and "did you mean" hints. See
[Troubleshooting: UnknownFactoryKwargError](../troubleshooting/unknown-factory-kwarg-error.md).
Expand Down
2 changes: 2 additions & 0 deletions docs/providers/factories.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ When creating a Factory provider, you can configure several parameters:

Defines the lifetime (scope) of the dependency. Defaults to `Scope.APP`. The available scopes are `APP → SESSION → REQUEST → ACTION → STEP`; see [Scopes](scopes.md) for the full mental model and the dependency rule.

Groups can declare a default scope for all their members — see [Group-level default scope](scopes.md#group-level-default-scope).

### creator

The callable (function or class) that will be invoked to create instances of the dependency.
Expand Down
36 changes: 36 additions & 0 deletions docs/providers/scopes.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,42 @@ with container.build_child_container(scope=MyScope.TENANT) as tenant_container:

The child scope's integer value must be strictly greater than its parent's. When `scope=` is omitted from `build_child_container`, the auto-derived next scope only advances within the parent's own enum class — to cross enum boundaries (e.g. jump from a built-in `Scope` to `MyScope.TENANT`), pass `scope=` explicitly.

## Group-level default scope

When declaring providers in a `Group` subclass, you can assign a default scope to all members using the class kwarg:

```python
from modern_di import Container, Group, Scope, providers


class UserRepository:
pass


class AuditLog:
pass


class RequestGroup(Group, scope=Scope.REQUEST):
repo = providers.Factory(UserRepository) # inherits group default: REQUEST
audit = providers.Factory(AuditLog, scope=Scope.APP) # explicit scope wins


app_container = Container(groups=[RequestGroup], validate=True)
with app_container.build_child_container(scope=Scope.REQUEST) as request_container:
repo = request_container.resolve(UserRepository)
```

Scope resolution follows a priority order:

1. **Explicit `scope=` on the provider** — always wins
2. **The group's `scope=` kwarg** — inherited via MRO by subclasses; subclasses may override with their own `scope=` kwarg. A subclass's `scope=` applies to providers declared in its own body; inherited providers keep the scope their declaring class gave them.
3. **`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.

## See also

- [Lifecycle](lifecycle.md) — finalizers and `close_async()` work per-scope.
Expand Down
44 changes: 44 additions & 0 deletions docs/troubleshooting/group-scope-conflict-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# GroupScopeConflictError

**Symptom**

Defining a `Group` subclass raises at class-creation (import) time. The error names a provider
and the two groups that disagree about its scope.

**Cause**

A module-level provider instance was created without an explicit `scope=`, so it takes its scope
from whichever `class ...(Group, scope=...)` body stamps it first. When that same instance is
also referenced from a second group whose default scope differs, the two stamps conflict — the
provider cannot have two different scopes, and import order must never be what silently decides
which one wins.

**Fix**

Three ways to resolve it, pick whichever fits:

```python
# 1. Set scope= explicitly on the shared provider — explicit always wins over a group default.
shared = providers.Factory(SomeService, scope=Scope.REQUEST)

# 2. Align the two groups' default scopes so they agree.
class GroupA(Group, scope=Scope.REQUEST):
svc = shared

class GroupB(Group, scope=Scope.REQUEST):
svc = shared

# 3. Give each group its own provider instance instead of sharing one.
class GroupA(Group, scope=Scope.REQUEST):
svc = providers.Factory(SomeService)

class GroupB(Group, scope=Scope.ACTION):
svc = providers.Factory(SomeService)
```

Inspect `.provider_name`, `.first_group`/`.first_scope`, and `.second_group`/`.second_scope` on
the exception to see exactly which provider and groups collided.

## See also

- [Scopes](../providers/scopes.md) — the scope hierarchy and how a provider's scope is chosen.
10 changes: 7 additions & 3 deletions docs/troubleshooting/invalid-scope-type-error.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@

**Symptom**

Raised from the `Container` constructor, naming the value that was passed as `scope=` and its type.
Raised when constructing a `Container` or when defining a `Group` subclass with a `scope=` class kwarg, naming the value that was passed as `scope=` and its type.

**Cause**

`scope=` must be an `enum.IntEnum` member. This fires when a plain `int`, a string, a regular
`enum.Enum` (not `IntEnum`), or any other non-`IntEnum` value is passed instead.
`scope=` must be an `enum.IntEnum` member. This fires in two contexts:

1. When passed to the `Container` constructor — when a plain `int`, a string, a regular `enum.Enum` (not `IntEnum`), or any other non-`IntEnum` value is used.
2. When passed to a `Group` subclass as a class kwarg — same validation applies.

Example invalid uses: `Container(scope=1)`, `Container(scope="APP")`, `class MyGroup(Group, scope=1)`, `class MyGroup(Group, scope="REQUEST")`.

**Fix**

Expand Down
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ nav:
- ContextProvider Has No Value: troubleshooting/context-not-set.md
- Duplicate Type Error: troubleshooting/duplicate-type-error.md
- Child Container Registration: troubleshooting/child-container-registration-error.md
- Group Scope Conflict: troubleshooting/group-scope-conflict-error.md
- Unknown Factory Kwarg: troubleshooting/unknown-factory-kwarg-error.md
- Unsupported Creator Parameter: troubleshooting/unsupported-creator-parameter-error.md
- Scope Chain Violation: troubleshooting/scope-chain.md
Expand Down Expand Up @@ -188,6 +189,7 @@ plugins:
- troubleshooting/context-not-set.md: Diagnosing ContextProvider-has-no-value errors
- troubleshooting/duplicate-type-error.md: Diagnosing duplicate bound_type registration errors
- troubleshooting/child-container-registration-error.md: Diagnosing ChildContainerRegistrationError
- troubleshooting/group-scope-conflict-error.md: Diagnosing GroupScopeConflictError
- troubleshooting/unknown-factory-kwarg-error.md: Diagnosing UnknownFactoryKwargError
- troubleshooting/unsupported-creator-parameter-error.md: Diagnosing UnsupportedCreatorParameterError
- troubleshooting/scope-chain.md: Diagnosing scope chain violation errors
Expand Down
35 changes: 32 additions & 3 deletions modern_di/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,7 @@ class InvalidScopeTypeError(ContainerError):

def __init__(self, *, scope_value: typing.Any) -> None: # noqa: ANN401
self.scope_value = scope_value
super().__init__(
f"Container scope must be an enum.IntEnum member; got {scope_value!r} ({type(scope_value).__name__})."
)
super().__init__(f"Scope must be an enum.IntEnum member; got {scope_value!r} ({type(scope_value).__name__}).")


class ContainerClosedError(ContainerError):
Expand Down Expand Up @@ -417,6 +415,37 @@ def __init__(self, *, scope: enum.IntEnum) -> None:
)


class GroupScopeConflictError(RegistrationError):
"""A scope-defaulted provider is shared by two groups with different default scopes.

Inspect ``.provider_name``, ``.first_group``/``.first_scope``, ``.second_group``/``.second_scope``.
"""

docs_slug = "group-scope-conflict-error"

__slots__ = ("first_group", "first_scope", "provider_name", "second_group", "second_scope")

def __init__(
self,
*,
provider_name: str,
first_group: str,
first_scope: enum.IntEnum,
second_group: str,
second_scope: enum.IntEnum,
) -> None:
self.provider_name = provider_name
self.first_group = first_group
self.first_scope = first_scope
self.second_group = second_group
self.second_scope = second_scope
super().__init__(
f"Provider {provider_name} is shared by groups with conflicting default scopes: "
f"{first_group} (scope {first_scope.name}) and {second_group} (scope {second_scope.name}). "
f"Set scope= explicitly on the provider, or align the group defaults."
)


class UnknownFactoryKwargError(RegistrationError):
"""Factory kwargs had unknown keys. Attrs: ``creator``, ``unknown_keys``, ``known_keys``, ``suggestions``."""

Expand Down
17 changes: 17 additions & 0 deletions modern_di/group.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import enum
import typing

from modern_di import exceptions
Expand All @@ -12,6 +13,22 @@ class Group:
def __new__(cls, *_: typing.Any, **__: typing.Any) -> "typing_extensions.Self": # noqa: ANN401
raise exceptions.GroupInstantiationError(group_name=cls.__name__)

_default_scope: typing.ClassVar["enum.IntEnum | None"] = None

def __init_subclass__(cls, scope: "enum.IntEnum | None" = None, **kwargs: typing.Any) -> None: # noqa: ANN401
"""Record a group-default scope and stamp it onto scope-defaulted providers in this class body."""
super().__init_subclass__(**kwargs)
if scope is not None:
if not isinstance(scope, enum.IntEnum):
raise exceptions.InvalidScopeTypeError(scope_value=scope)
cls._default_scope = scope
default_scope = cls._default_scope
if default_scope is None:
return
for value in cls.__dict__.values():
if isinstance(value, AbstractProvider):
value._stamp_group_scope(default_scope, cls.__name__) # noqa: SLF001

@classmethod
def get_named_providers(cls) -> dict[str, AbstractProvider[typing.Any]]:
seen_names: set[str] = set()
Expand Down
28 changes: 24 additions & 4 deletions modern_di/providers/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import itertools
import typing

from modern_di import types
from modern_di import exceptions, types
from modern_di.scope import Scope


if typing.TYPE_CHECKING:
Expand All @@ -13,18 +14,37 @@


class AbstractProvider(abc.ABC, typing.Generic[types.T_co]):
__slots__ = ("bound_type", "provider_id", "scope")
__slots__ = ("_scope_defaulted", "_stamping_group", "bound_type", "provider_id", "scope")

def __init__(
self,
*,
scope: enum.IntEnum,
scope: enum.IntEnum | types.UnsetType,
bound_type: type | None,
) -> None:
self.scope = scope
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.bound_type = bound_type
self.provider_id: typing.Final = next(_provider_id_counter)

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."""
if not self._scope_defaulted:
return
if self._stamping_group is not None:
if self.scope != scope:
raise exceptions.GroupScopeConflictError(
provider_name=self.display_name,
first_group=self._stamping_group,
first_scope=self.scope,
second_group=group_name,
second_scope=scope,
)
return
self.scope = scope
self._stamping_group = group_name

@property
def display_name(self) -> str:
"""Human-readable name for error messages and resolution steps.
Expand Down
2 changes: 2 additions & 0 deletions modern_di/providers/alias.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ def __init__(
stored_scope: enum.IntEnum = scope
else:
stored_scope = Scope.APP
# Always a concrete IntEnum (never UNSET), so `_scope_defaulted` stays False and
# group-default stamping skips aliases.
super().__init__(
scope=stored_scope, bound_type=source_type if isinstance(bound_type, types.UnsetType) else bound_type
)
Expand Down
3 changes: 1 addition & 2 deletions modern_di/providers/context_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

from modern_di import exceptions, types
from modern_di.providers import AbstractProvider
from modern_di.scope import Scope


if typing.TYPE_CHECKING:
Expand All @@ -27,7 +26,7 @@ def __init__(
self,
context_type: type[types.T_co],
*,
scope: enum.IntEnum = Scope.APP,
scope: enum.IntEnum | types.UnsetType = types.UNSET,
bound_type: type | None | types.UnsetType = types.UNSET,
) -> None:
super().__init__(
Expand Down
3 changes: 1 addition & 2 deletions modern_di/providers/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from modern_di import exceptions, suggester, types
from modern_di.providers import ContextProvider
from modern_di.providers.abstract import AbstractProvider
from modern_di.scope import Scope
from modern_di.types_parser import SignatureItem, parse_creator
from modern_di.wiring import WiringPlan, _Absent, absent_disposition

Expand Down Expand Up @@ -35,7 +34,7 @@ def __init__( # noqa: C901, PLR0913
self,
creator: typing.Callable[..., types.T_co],
*,
scope: enum.IntEnum = Scope.APP,
scope: enum.IntEnum | types.UnsetType = types.UNSET,
bound_type: type | None | types.UnsetType = types.UNSET,
kwargs: dict[str, typing.Any] | None = None,
cache: bool | CacheSettings[types.T_co] | None = None,
Expand Down
Loading