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
14 changes: 14 additions & 0 deletions docs/migration/to-4.x.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,20 @@ error raised by `resolve()` and `resolve_provider()`. `except ContainerError` st
handles these three errors; reorder the clauses if the `ContainerError` handler should run. See
[Errors and exceptions](../providers/errors-and-exceptions.md).

### Scopes match by enum member

In 3.x a provider found its container by integer value, so a provider at a custom
`Tenancy.TENANT = 2` resolved and cached in a `Scope.SESSION` container. In 4.0 a provider resolves
only in a container built at the same enum member, and the same-valued scope of another enum raises
`ScopeSkippedError` or `ScopeNotInitializedError`. Group default scopes compare members too: two
groups that give one provider `Scope.SESSION` and `Tenancy.TENANT` raise `GroupScopeConflictError`.
A group that restamps a registered provider to another enum's member with the same value now raises
`ProviderScopeFrozenError`; 3.x accepted it silently. `validate()` reports a dependency on the
same-valued scope of another enum as `ScopeEnumMismatchError`, since it can never resolve.
Ordering is unchanged, so a child container still needs a higher integer value than its parent,
whichever enum each scope comes from. If a provider relied on the old match, give it the scope
member of the container it should resolve in. See [Custom scopes](../providers/scopes.md#custom-scopes).

### Context values are required unless the provider sets `default=`

In 3.x, when a `Factory` parameter was backed by a `ContextProvider` and no context value was set,
Expand Down
12 changes: 10 additions & 2 deletions docs/providers/errors-and-exceptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ ModernDIError (RuntimeError)
│ ├── ProviderScopeFrozenError
│ ├── UnknownFactoryKwargError
│ ├── UnsupportedCreatorParameterError
│ └── InvalidScopeDependencyError
│ ├── InvalidScopeDependencyError
│ └── ScopeEnumMismatchError
├── FinalizerError (also an ExceptionGroup)
├── AsyncFinalizerInSyncCloseError
└── GroupInstantiationError
Expand Down Expand Up @@ -139,7 +140,8 @@ to render the chain programmatically.
## `RegistrationError`: declaration / registration problems

Catch `RegistrationError` for declaration mistakes. Each is detected when the provider or group is
declared or registered, or by `validate()`, which reports `InvalidScopeDependencyError`.
declared or registered, or by `validate()`, which reports `InvalidScopeDependencyError` and
`ScopeEnumMismatchError`.

- `DuplicateProviderTypeError` is raised when two providers are registered for the same bound type
(within one group, across groups passed together, or against an already-registered type). See
Expand Down Expand Up @@ -173,6 +175,12 @@ declared or registered, or by `validate()`, which reports `InvalidScopeDependenc
`validate()`. Renders the chain from the depender to the provider that supplies the dependency;
`.dep_chain` carries that chain, with `.dep_provider` and `.dep_terminal` as its ends. See
[Troubleshooting: Scope chain](../troubleshooting/scope-chain.md).
- `ScopeEnumMismatchError` is raised when a provider depends on another provider whose scope has
the same integer value but comes from a different enum, such as `Scope.SESSION` and a custom
`Tenancy.TENANT = 2`. Each child container's value is higher than its parent's, so the two scopes
can never be in one container chain. Surfaced by `validate()`. Inspect `.provider`,
`.parameter_name` and `.dep_chain`, with `.dep_provider` and `.dep_terminal` as its ends. See
[Troubleshooting: ScopeEnumMismatchError](../troubleshooting/scope-enum-mismatch-error.md).

## Direct `ModernDIError` subclasses

Expand Down
2 changes: 2 additions & 0 deletions docs/providers/scopes.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ 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.

A provider resolves only in a container built at the same enum member. Members of different enums that share an integer value are different scopes: with `class Tenancy(IntEnum): TENANT = 2`, a provider at `Tenancy.TENANT` does not resolve in a `Scope.SESSION` container, and raises `ScopeSkippedError` there. `validate()` reports a provider that depends on such a scope as [`ScopeEnumMismatchError`](../troubleshooting/scope-enum-mismatch-error.md). Ordering still compares integer values, which is why `MyScope.TENANT = 6` can be a child of `Scope.APP`.

## Group-level default scope

When declaring providers in a `Group` subclass, you can assign a default scope to all members using the class kwarg:
Expand Down
56 changes: 56 additions & 0 deletions docs/troubleshooting/scope-enum-mismatch-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# ScopeEnumMismatchError

## Symptom

`container.validate()` raises `ValidationFailedError`, and one of its groups is
`ScopeEnumMismatchError`:

```
Container.validate() found 1 issue(s): ScopeEnumMismatchError

ScopeEnumMismatchError (1):
- Provider at a same-valued scope of another enum reached through this chain:
SESSION UserSession (myapp.providers:15)
TENANT └─> TenantSettings (myapp.providers:11)
caused by: UserSession (scope Scope.SESSION) declares parameter 'settings' typed as a provider of TenantSettings at scope Tenancy.TENANT. Both scopes have the value 2 but belong to different enums, so they can never be in one container chain. Give the dependency the same scope member as UserSession or a shallower one.
```

## Cause

A provider matches its container by enum member. Scopes from different enums can share a value,
like `Scope.SESSION` and `Tenancy.TENANT` here, which are both 2. A container chain holds at most
one container per value, because each child's value is higher than its parent's. The `SESSION`
container is the one at value 2, so no `TENANT` container can exist in its chain, and
`UserSession` can never resolve its `settings` dependency. Resolving it raises
`ScopeSkippedError`.

A dependency on a *shallower* scope from another enum is fine. A `Scope.REQUEST` provider can depend
on a `Tenancy.TENANT` provider when the chain is built `APP → TENANT → REQUEST`.

## Fix

Give the dependency the same scope member as the provider that needs it, or a shallower one:

```python
class Tenancy(IntEnum):
TENANT = 2


class Dependencies(Group):
# Broken: TENANT and SESSION are both 2
settings = providers.Factory(TenantSettings, scope=Tenancy.TENANT)
session = providers.Factory(UserSession, scope=Scope.SESSION)

# Works: the dependency is shallower than the provider
settings = providers.Factory(TenantSettings, scope=Scope.APP)
session = providers.Factory(UserSession, scope=Scope.SESSION)
```

Inspect `.provider`, `.parameter_name` and `.dep_chain` on the exception. `.dep_provider` and
`.dep_terminal` are the ends of the chain; they differ when the dependency is reached through an
`Alias`.

## See also

- [Scopes](../providers/scopes.md#custom-scopes) explains how scopes from different enums mix in one tree.
- [Scope chain violation](scope-chain.md) covers a dependency on a deeper scope.
6 changes: 3 additions & 3 deletions docs/troubleshooting/validation-failed-error.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ issue across the whole graph in one pass rather than stopping at the first one,
## Fix

Inspect `.errors` to see every underlying issue, or read the grouped `str()` report directly. Each
group is one of `CircularDependencyError`, `InvalidScopeDependencyError`, `ArgumentResolutionError`,
or `AliasSourceNotRegisteredError` today. Fix each one; their own pages cover the specific cause and
group is one of `CircularDependencyError`, `InvalidScopeDependencyError`, `ScopeEnumMismatchError`,
`ArgumentResolutionError`, or `AliasSourceNotRegisteredError` today. Fix each one; their own pages cover the specific cause and
remedy:

```python
Expand All @@ -35,4 +35,4 @@ construction, not `open()`, not `resolve()`.
## See also

- [Lifecycle: validation](../providers/lifecycle.md#validation).
- The underlying issue kinds: [Circular dependency](circular-dependency.md), [Scope chain violation](scope-chain.md), [Argument resolution error](argument-resolution-error.md), [Alias source not registered](alias-source-not-registered-error.md).
- The underlying issue kinds: [Circular dependency](circular-dependency.md), [Scope chain violation](scope-chain.md), [Scope enum mismatch](scope-enum-mismatch-error.md), [Argument resolution error](argument-resolution-error.md), [Alias source not registered](alias-source-not-registered-error.md).
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ nav:
- 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
- Scope enum mismatch: troubleshooting/scope-enum-mismatch-error.md
- Finalizer error: troubleshooting/finalizer-error.md
- Async finalizer in sync close: troubleshooting/async-finalizer-in-sync-close-error.md
- Group instantiation error: troubleshooting/group-instantiation-error.md
Expand Down Expand Up @@ -214,6 +215,7 @@ plugins:
- 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
- troubleshooting/scope-enum-mismatch-error.md: Diagnosing ScopeEnumMismatchError
- troubleshooting/finalizer-error.md: Diagnosing FinalizerError
- troubleshooting/async-finalizer-in-sync-close-error.md: Diagnosing AsyncFinalizerInSyncCloseError
- troubleshooting/group-instantiation-error.md: Diagnosing GroupInstantiationError
Expand Down
4 changes: 2 additions & 2 deletions modern_di/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,10 @@ def find_container(self, scope: enum.IntEnum) -> typing.Self:
than this container, and :class:`~modern_di.exceptions.ScopeSkippedError` when no
ancestor was built at ``scope``.
"""
if scope == self.scope:
if scope is self.scope:
return self
target = self._scope_map.get(scope)
if target is None:
if target is None or target.scope is not scope:
if scope > self.scope:
raise exceptions.ScopeNotInitializedError(provider_scope=scope, container_scope=self.scope)
raise exceptions.ScopeSkippedError(provider_scope=scope, container_scope=self.scope)
Expand Down
12 changes: 11 additions & 1 deletion modern_di/dependency_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,14 +187,24 @@ def collect_errors(container: "Container", registry: "ProvidersRegistry") -> lis
errors.append(error)
case Edge(parent, name, dep):
dep_chain = terminal_chain(dep, container)
if dep_chain[-1].scope > effective_scope(parent, container):
dep_scope = dep_chain[-1].scope
parent_scope = effective_scope(parent, container)
if dep_scope > parent_scope:
errors.append(
exceptions.InvalidScopeDependencyError(
provider=parent,
parameter_name=name,
dep_chain=dep_chain,
)
)
elif dep_scope == parent_scope and dep_scope is not parent_scope:
errors.append(
exceptions.ScopeEnumMismatchError(
provider=parent,
parameter_name=name,
dep_chain=dep_chain,
)
)
case Cycle(providers):
errors.append(build_cycle_error(providers, container))
return errors
2 changes: 2 additions & 0 deletions modern_di/exceptions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
InvalidScopeDependencyError,
ProviderScopeFrozenError,
RegistrationError,
ScopeEnumMismatchError,
UnknownFactoryKwargError,
UnsupportedCreatorParameterError,
)
Expand Down Expand Up @@ -62,6 +63,7 @@
"RegistrationError",
"ResolutionError",
"ResolutionStep",
"ScopeEnumMismatchError",
"ScopeNotInitializedError",
"ScopeSkippedError",
"UnknownFactoryKwargError",
Expand Down
58 changes: 58 additions & 0 deletions modern_di/exceptions/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,61 @@ def _render_body(self) -> str:
f" caused by: {RuntimeError.__str__(self)}",
]
return "\n".join(lines)


def _qualified(scope: enum.IntEnum) -> str:
return f"{type(scope).__name__}.{scope.name}"


class ScopeEnumMismatchError(RegistrationError):
"""A provider depends on one whose scope has the same value but comes from another enum.

Inspect ``.provider``, ``.parameter_name``, ``.dep_chain``. Two members with one value can never
be in one container chain, because each child's value is higher than its parent's.
"""

docs_slug = "scope-enum-mismatch-error"

__slots__ = ("dep_chain", "parameter_name", "provider")

def __init__(
self,
*,
provider: "AbstractProvider[typing.Any]",
parameter_name: str,
dep_chain: "list[AbstractProvider[typing.Any]]",
) -> None:
self.provider = provider
self.parameter_name = parameter_name
self.dep_chain = dep_chain
dep_scope = self.dep_terminal.scope
super().__init__(
f"{provider.display_name} (scope {_qualified(provider.scope)}) declares parameter "
f"{parameter_name!r} typed as a provider of {self.dep_terminal.display_name} at scope "
f"{_qualified(dep_scope)}. Both scopes have the value {int(dep_scope)} but belong to different "
f"enums, so they can never be in one container chain. Give the dependency the same scope member "
f"as {provider.display_name} or a shallower one."
)

@property
def dep_provider(self) -> "AbstractProvider[typing.Any]":
"""The dependency as declared: the type the parameter is annotated with."""
return self.dep_chain[0]

@property
def dep_terminal(self) -> "AbstractProvider[typing.Any]":
"""The provider that actually supplies the dependency, once redirects are followed."""
return self.dep_chain[-1]

def _render_body(self) -> str:
effective_scope = self.dep_terminal.scope
steps = [
self.provider._resolution_step(), # noqa: SLF001
*(p._resolution_step(effective_scope) for p in self.dep_chain), # noqa: SLF001
]
lines = [
"Provider at a same-valued scope of another enum reached through this chain:",
*render_chain(steps),
f" caused by: {RuntimeError.__str__(self)}",
]
return "\n".join(lines)
4 changes: 2 additions & 2 deletions modern_di/providers/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def _stamp_group_scope(self, scope: enum.IntEnum, group_name: str) -> None:
return
if self._group_claim is not None:
first_scope, first_group = self._group_claim
if first_scope != scope:
if first_scope is not scope:
raise exceptions.GroupScopeConflictError(
provider_name=self.display_name,
first_group=first_group,
Expand All @@ -61,7 +61,7 @@ def _stamp_group_scope(self, scope: enum.IntEnum, group_name: str) -> None:
second_scope=scope,
)
return
if self._registered and self.scope != scope:
if self._registered and self.scope is not scope:
raise exceptions.ProviderScopeFrozenError(
provider_name=self.display_name,
group_name=group_name,
Expand Down
15 changes: 9 additions & 6 deletions modern_di/resolver_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@ def compile_resolver(provider: "AbstractProvider[typing.Any]", registry: "Provid


_NAVIGATE = """\
if container.scope == scope:
if container.scope is scope:
target = container
else:
target = container._scope_map.get(scope)
if target is None:
if target is None or target.scope is not scope:
target = _navigate(container, scope, resolution_step)
if target._closed:
raise ContainerClosedError(container_scope=target.scope)
Expand Down Expand Up @@ -210,7 +210,7 @@ def _compile_unwireable_factory(f: "Factory[typing.Any]", plan: "WiringPlan") ->
arg_name, item = plan.unwireable[0]

def resolve(container: "Container") -> typing.Any:
target = container if container.scope == scope else _navigate(container, scope, resolution_step)
target = container if container.scope is scope else _navigate(container, scope, resolution_step)
if target._closed:
raise exceptions.ContainerClosedError(container_scope=target.scope)
error = build_error(arg_name=arg_name, item=item, registry=target._providers_registry)
Expand Down Expand Up @@ -246,11 +246,11 @@ def _compile_context_provider(cp: "ContextProvider[typing.Any]") -> "Resolver":
resolution_step = cp._resolution_step

def resolve(container: "Container") -> typing.Any:
if container.scope == scope:
if container.scope is scope:
target = container
else:
target = container._scope_map.get(scope)
if target is None:
if target is None or target.scope is not scope:
target = _navigate(container, scope, resolution_step)
if target._closed:
raise exceptions.ContainerClosedError(container_scope=target.scope)
Expand All @@ -270,7 +270,10 @@ def _navigate(
scope: enum.IntEnum,
resolution_step: "typing.Callable[[], exceptions.ResolutionStep]",
) -> "Container":
"""Miss path for a scope absent from `_scope_map`; the scope error carries this provider's resolution step."""
"""Miss path for a scope absent from `_scope_map`, or held there by another enum's same-valued member.

The scope error carries this provider's resolution step.
"""
try:
return container.find_container(scope)
except _SCOPE_ERRORS as exc:
Expand Down
Loading
Loading