From a5cec6a3610b0305fd76b51b6824e5d65b5963b5 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 5 Oct 2026 13:18:22 +0300 Subject: [PATCH 1/3] fix!: match scopes by enum member, not by integer value (#572) --- docs/migration/to-4.x.md | 11 ++++ docs/providers/scopes.md | 5 ++ modern_di/container.py | 4 +- modern_di/providers/abstract.py | 4 +- modern_di/resolver_compiler.py | 15 +++--- tests/test_custom_scope.py | 94 +++++++++++++++++++++++++++++++++ tests/test_group.py | 39 ++++++++++++++ 7 files changed, 162 insertions(+), 10 deletions(-) diff --git a/docs/migration/to-4.x.md b/docs/migration/to-4.x.md index 3cf7b402..6e4f702a 100644 --- a/docs/migration/to-4.x.md +++ b/docs/migration/to-4.x.md @@ -51,6 +51,17 @@ 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`. +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, diff --git a/docs/providers/scopes.md b/docs/providers/scopes.md index 52257b3c..20a9c989 100644 --- a/docs/providers/scopes.md +++ b/docs/providers/scopes.md @@ -114,6 +114,11 @@ 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: a provider at `Tenancy.TENANT = 2` does not +resolve in a `Scope.SESSION` container, and raises `ScopeSkippedError` there. 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: diff --git a/modern_di/container.py b/modern_di/container.py index 446600b5..0f0e9909 100644 --- a/modern_di/container.py +++ b/modern_di/container.py @@ -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) diff --git a/modern_di/providers/abstract.py b/modern_di/providers/abstract.py index c172a7f2..2c5ab58e 100644 --- a/modern_di/providers/abstract.py +++ b/modern_di/providers/abstract.py @@ -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, @@ -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, diff --git a/modern_di/resolver_compiler.py b/modern_di/resolver_compiler.py index c1a8f4d9..cabfbc9e 100644 --- a/modern_di/resolver_compiler.py +++ b/modern_di/resolver_compiler.py @@ -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) @@ -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) @@ -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) @@ -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: diff --git a/tests/test_custom_scope.py b/tests/test_custom_scope.py index 72b2a7af..1f6954b1 100644 --- a/tests/test_custom_scope.py +++ b/tests/test_custom_scope.py @@ -221,3 +221,97 @@ def test_scope_modules_import_only_enum(module: types.ModuleType) -> None: assert _module_level_imports("from . import exceptions\n") == {"exceptions"} # And the absolute `from x import y` form, so both `ImportFrom` branches are genuinely exercised. assert _module_level_imports("from enum import IntEnum\n") == {"enum"} + + +class Tenancy(enum.IntEnum): + TENANT = 2 + + +class _Unregistered: ... + + +@dataclasses.dataclass(kw_only=True, slots=True) +class _NeedsUnregistered: + dep: _Unregistered + + +@pytest.mark.parametrize("cache", [False, True]) +def test_same_valued_scope_of_another_enum_does_not_resolve_in_this_container(cache: bool) -> None: + class TenancyGroup(Group): + svc = providers.Factory(scope=Tenancy.TENANT, creator=TenantService, cache=cache) + + session = Container(groups=[TenancyGroup]).build_child_container(scope=Scope.SESSION) + with pytest.raises(ScopeSkippedError, match="TENANT") as exc: + session.resolve(TenantService) + assert exc.value.provider_scope is Tenancy.TENANT + assert exc.value.dependency_path[0].scope is Tenancy.TENANT + assert session._cache_registry.cached_count() == 0 + + +@pytest.mark.parametrize("cache", [False, True]) +def test_same_valued_ancestor_of_another_enum_does_not_resolve(cache: bool) -> None: + class TenancyGroup(Group): + svc = providers.Factory(scope=Tenancy.TENANT, creator=TenantService, cache=cache) + + session = Container(groups=[TenancyGroup]).build_child_container(scope=Scope.SESSION) + request = session.build_child_container(scope=Scope.REQUEST) + with pytest.raises(ScopeSkippedError, match="TENANT") as exc: + request.resolve(TenantService) + assert exc.value.provider_scope is Tenancy.TENANT + assert exc.value.dependency_path[0].scope is Tenancy.TENANT + assert session._cache_registry.cached_count() == 0 + + +@pytest.mark.parametrize("scope", [Scope.SESSION, Scope.REQUEST], ids=["SESSION", "REQUEST"]) +def test_context_provider_ignores_same_valued_scope_of_another_enum(scope: Scope) -> None: + class TenancyGroup(Group): + ctx = providers.ContextProvider(TenantService, scope=Tenancy.TENANT) + + session = Container(groups=[TenancyGroup]).build_child_container( + scope=Scope.SESSION, context={TenantService: TenantService()} + ) + container = session if scope is Scope.SESSION else session.build_child_container(scope=scope) + with pytest.raises(ScopeSkippedError, match="TENANT"): + container.resolve(TenantService) + + +def test_unwireable_factory_ignores_same_valued_scope_of_another_enum() -> None: + class TenancyGroup(Group): + svc = providers.Factory(scope=Tenancy.TENANT, creator=_NeedsUnregistered) + + session = Container(groups=[TenancyGroup]).build_child_container(scope=Scope.SESSION) + with pytest.raises(ScopeSkippedError, match="TENANT"): + session.resolve(_NeedsUnregistered) + + +def test_find_container_matches_the_enum_member_not_its_value() -> None: + session = Container().build_child_container(scope=Scope.SESSION) + request = session.build_child_container(scope=Scope.REQUEST) + assert request.find_container(Scope.SESSION) is session + with pytest.raises(ScopeSkippedError): + session.find_container(Tenancy.TENANT) + with pytest.raises(ScopeSkippedError): + request.find_container(Tenancy.TENANT) + + +@dataclasses.dataclass(kw_only=True, slots=True) +class _AppSettings: + pass + + +@dataclasses.dataclass(kw_only=True, slots=True) +class _TenantRepo: + settings: _AppSettings + + +def test_documented_mixed_enum_tree_resolves() -> None: + class MixedGroup(Group): + settings = providers.Factory(creator=_AppSettings, cache=True) + repo = providers.Factory(creator=_TenantRepo, scope=MyScope.TENANT, cache=True) + + app_container = Container(groups=[MixedGroup]) + app_container.validate() + with app_container.build_child_container(scope=MyScope.TENANT) as tenant_container: + repo = tenant_container.resolve(_TenantRepo) + assert tenant_container.resolve(_TenantRepo) is repo + assert repo.settings is app_container.resolve(_AppSettings) diff --git a/tests/test_group.py b/tests/test_group.py index 1ee49fe4..ea653e84 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -1,4 +1,5 @@ import dataclasses +import enum import pytest @@ -348,3 +349,41 @@ class RequestGroup(Group, scope=Scope.REQUEST): assert providers.container_provider.scope is Scope.APP assert RequestGroup.get_named_providers()["current"] is providers.container_provider + + +class _Tenancy(enum.IntEnum): + TENANT = 2 + + +class _Root(enum.IntEnum): + ROOT = 1 + + +def test_group_scope_conflict_tells_apart_same_valued_scopes_of_different_enums() -> None: + shared = providers.Factory(_Svc) + + class GroupA(Group, scope=Scope.SESSION): + svc = shared + + with pytest.raises(GroupScopeConflictError) as exc_info: + + class GroupB(Group, scope=_Tenancy.TENANT): + svc = shared + + assert exc_info.value.first_scope is Scope.SESSION + assert exc_info.value.second_scope is _Tenancy.TENANT + + +def test_registered_provider_scope_frozen_against_same_valued_scope_of_another_enum() -> None: + shared = providers.Factory(_Svc) + + class PlainGroup(Group): + svc = shared + + Container(scope=Scope.APP, groups=[PlainGroup]).resolve_provider(shared) + with pytest.raises(ProviderScopeFrozenError): + + class ScopedGroup(Group, scope=_Root.ROOT): + svc = shared + + assert shared.scope is Scope.APP From a4e30000989cef9a6405637a733c05f41a033b7b Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 5 Oct 2026 13:22:41 +0300 Subject: [PATCH 2/3] docs: cover ProviderScopeFrozenError and reuse test enums for scope identity (#572) --- docs/migration/to-4.x.md | 2 ++ docs/providers/scopes.md | 5 +--- tests/test_custom_scope.py | 48 +++++++++++++++++--------------------- tests/test_group.py | 15 +++++------- 4 files changed, 31 insertions(+), 39 deletions(-) diff --git a/docs/migration/to-4.x.md b/docs/migration/to-4.x.md index 6e4f702a..ed820c1d 100644 --- a/docs/migration/to-4.x.md +++ b/docs/migration/to-4.x.md @@ -58,6 +58,8 @@ In 3.x a provider found its container by integer value, so a provider at a custo 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. 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). diff --git a/docs/providers/scopes.md b/docs/providers/scopes.md index 20a9c989..92201399 100644 --- a/docs/providers/scopes.md +++ b/docs/providers/scopes.md @@ -114,10 +114,7 @@ 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: a provider at `Tenancy.TENANT = 2` does not -resolve in a `Scope.SESSION` container, and raises `ScopeSkippedError` there. Ordering still compares -integer values, which is why `MyScope.TENANT = 6` can be a child of `Scope.APP`. +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. Ordering still compares integer values, which is why `MyScope.TENANT = 6` can be a child of `Scope.APP`. ## Group-level default scope diff --git a/tests/test_custom_scope.py b/tests/test_custom_scope.py index 1f6954b1..c94ac0f5 100644 --- a/tests/test_custom_scope.py +++ b/tests/test_custom_scope.py @@ -223,10 +223,6 @@ def test_scope_modules_import_only_enum(module: types.ModuleType) -> None: assert _module_level_imports("from enum import IntEnum\n") == {"enum"} -class Tenancy(enum.IntEnum): - TENANT = 2 - - class _Unregistered: ... @@ -237,50 +233,50 @@ class _NeedsUnregistered: @pytest.mark.parametrize("cache", [False, True]) def test_same_valued_scope_of_another_enum_does_not_resolve_in_this_container(cache: bool) -> None: - class TenancyGroup(Group): - svc = providers.Factory(scope=Tenancy.TENANT, creator=TenantService, cache=cache) + class ConflictingGroup(Group): + svc = providers.Factory(scope=ConflictingScope.LOWER_THAN_REQUEST, creator=TenantService, cache=cache) - session = Container(groups=[TenancyGroup]).build_child_container(scope=Scope.SESSION) - with pytest.raises(ScopeSkippedError, match="TENANT") as exc: + session = Container(groups=[ConflictingGroup]).build_child_container(scope=Scope.SESSION) + with pytest.raises(ScopeSkippedError, match="LOWER_THAN_REQUEST") as exc: session.resolve(TenantService) - assert exc.value.provider_scope is Tenancy.TENANT - assert exc.value.dependency_path[0].scope is Tenancy.TENANT + assert exc.value.provider_scope is ConflictingScope.LOWER_THAN_REQUEST + assert exc.value.dependency_path[0].scope is ConflictingScope.LOWER_THAN_REQUEST assert session._cache_registry.cached_count() == 0 @pytest.mark.parametrize("cache", [False, True]) def test_same_valued_ancestor_of_another_enum_does_not_resolve(cache: bool) -> None: - class TenancyGroup(Group): - svc = providers.Factory(scope=Tenancy.TENANT, creator=TenantService, cache=cache) + class ConflictingGroup(Group): + svc = providers.Factory(scope=ConflictingScope.LOWER_THAN_REQUEST, creator=TenantService, cache=cache) - session = Container(groups=[TenancyGroup]).build_child_container(scope=Scope.SESSION) + session = Container(groups=[ConflictingGroup]).build_child_container(scope=Scope.SESSION) request = session.build_child_container(scope=Scope.REQUEST) - with pytest.raises(ScopeSkippedError, match="TENANT") as exc: + with pytest.raises(ScopeSkippedError, match="LOWER_THAN_REQUEST") as exc: request.resolve(TenantService) - assert exc.value.provider_scope is Tenancy.TENANT - assert exc.value.dependency_path[0].scope is Tenancy.TENANT + assert exc.value.provider_scope is ConflictingScope.LOWER_THAN_REQUEST + assert exc.value.dependency_path[0].scope is ConflictingScope.LOWER_THAN_REQUEST assert session._cache_registry.cached_count() == 0 @pytest.mark.parametrize("scope", [Scope.SESSION, Scope.REQUEST], ids=["SESSION", "REQUEST"]) def test_context_provider_ignores_same_valued_scope_of_another_enum(scope: Scope) -> None: - class TenancyGroup(Group): - ctx = providers.ContextProvider(TenantService, scope=Tenancy.TENANT) + class ConflictingGroup(Group): + ctx = providers.ContextProvider(TenantService, scope=ConflictingScope.LOWER_THAN_REQUEST) - session = Container(groups=[TenancyGroup]).build_child_container( + session = Container(groups=[ConflictingGroup]).build_child_container( scope=Scope.SESSION, context={TenantService: TenantService()} ) container = session if scope is Scope.SESSION else session.build_child_container(scope=scope) - with pytest.raises(ScopeSkippedError, match="TENANT"): + with pytest.raises(ScopeSkippedError, match="LOWER_THAN_REQUEST"): container.resolve(TenantService) def test_unwireable_factory_ignores_same_valued_scope_of_another_enum() -> None: - class TenancyGroup(Group): - svc = providers.Factory(scope=Tenancy.TENANT, creator=_NeedsUnregistered) + class ConflictingGroup(Group): + svc = providers.Factory(scope=ConflictingScope.LOWER_THAN_REQUEST, creator=_NeedsUnregistered) - session = Container(groups=[TenancyGroup]).build_child_container(scope=Scope.SESSION) - with pytest.raises(ScopeSkippedError, match="TENANT"): + session = Container(groups=[ConflictingGroup]).build_child_container(scope=Scope.SESSION) + with pytest.raises(ScopeSkippedError, match="LOWER_THAN_REQUEST"): session.resolve(_NeedsUnregistered) @@ -289,9 +285,9 @@ def test_find_container_matches_the_enum_member_not_its_value() -> None: request = session.build_child_container(scope=Scope.REQUEST) assert request.find_container(Scope.SESSION) is session with pytest.raises(ScopeSkippedError): - session.find_container(Tenancy.TENANT) + session.find_container(ConflictingScope.LOWER_THAN_REQUEST) with pytest.raises(ScopeSkippedError): - request.find_container(Tenancy.TENANT) + request.find_container(ConflictingScope.LOWER_THAN_REQUEST) @dataclasses.dataclass(kw_only=True, slots=True) diff --git a/tests/test_group.py b/tests/test_group.py index ea653e84..a12d0192 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -351,12 +351,9 @@ class RequestGroup(Group, scope=Scope.REQUEST): assert RequestGroup.get_named_providers()["current"] is providers.container_provider -class _Tenancy(enum.IntEnum): - TENANT = 2 - - -class _Root(enum.IntEnum): - ROOT = 1 +class _SameValueScope(enum.IntEnum): + APP_VALUE = 1 + SESSION_VALUE = 2 def test_group_scope_conflict_tells_apart_same_valued_scopes_of_different_enums() -> None: @@ -367,11 +364,11 @@ class GroupA(Group, scope=Scope.SESSION): with pytest.raises(GroupScopeConflictError) as exc_info: - class GroupB(Group, scope=_Tenancy.TENANT): + class GroupB(Group, scope=_SameValueScope.SESSION_VALUE): svc = shared assert exc_info.value.first_scope is Scope.SESSION - assert exc_info.value.second_scope is _Tenancy.TENANT + assert exc_info.value.second_scope is _SameValueScope.SESSION_VALUE def test_registered_provider_scope_frozen_against_same_valued_scope_of_another_enum() -> None: @@ -383,7 +380,7 @@ class PlainGroup(Group): Container(scope=Scope.APP, groups=[PlainGroup]).resolve_provider(shared) with pytest.raises(ProviderScopeFrozenError): - class ScopedGroup(Group, scope=_Root.ROOT): + class ScopedGroup(Group, scope=_SameValueScope.APP_VALUE): svc = shared assert shared.scope is Scope.APP From 686a29e86110efdac9675460f8d77d629034d3b4 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 5 Oct 2026 16:01:32 +0300 Subject: [PATCH 3/3] feat: report same-valued scopes of different enums in validate() (#572) --- docs/migration/to-4.x.md | 3 +- docs/providers/errors-and-exceptions.md | 12 +++- docs/providers/scopes.md | 2 +- .../scope-enum-mismatch-error.md | 56 ++++++++++++++++++ .../validation-failed-error.md | 6 +- mkdocs.yml | 2 + modern_di/dependency_graph.py | 12 +++- modern_di/exceptions/__init__.py | 2 + modern_di/exceptions/registration.py | 58 +++++++++++++++++++ tests/test_custom_scope.py | 46 +++++++++++++++ tests/test_error_rendering.py | 27 +++++++++ 11 files changed, 218 insertions(+), 8 deletions(-) create mode 100644 docs/troubleshooting/scope-enum-mismatch-error.md diff --git a/docs/migration/to-4.x.md b/docs/migration/to-4.x.md index ed820c1d..32f0d98a 100644 --- a/docs/migration/to-4.x.md +++ b/docs/migration/to-4.x.md @@ -59,7 +59,8 @@ only in a container built at the same enum member, and the same-valued scope of `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. +`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). diff --git a/docs/providers/errors-and-exceptions.md b/docs/providers/errors-and-exceptions.md index d820f4ad..ca237fbe 100644 --- a/docs/providers/errors-and-exceptions.md +++ b/docs/providers/errors-and-exceptions.md @@ -42,7 +42,8 @@ ModernDIError (RuntimeError) │ ├── ProviderScopeFrozenError │ ├── UnknownFactoryKwargError │ ├── UnsupportedCreatorParameterError -│ └── InvalidScopeDependencyError +│ ├── InvalidScopeDependencyError +│ └── ScopeEnumMismatchError ├── FinalizerError (also an ExceptionGroup) ├── AsyncFinalizerInSyncCloseError └── GroupInstantiationError @@ -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 @@ -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 diff --git a/docs/providers/scopes.md b/docs/providers/scopes.md index 92201399..26efd794 100644 --- a/docs/providers/scopes.md +++ b/docs/providers/scopes.md @@ -114,7 +114,7 @@ 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. Ordering still compares integer values, which is why `MyScope.TENANT = 6` can be a child of `Scope.APP`. +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 diff --git a/docs/troubleshooting/scope-enum-mismatch-error.md b/docs/troubleshooting/scope-enum-mismatch-error.md new file mode 100644 index 00000000..6c815a52 --- /dev/null +++ b/docs/troubleshooting/scope-enum-mismatch-error.md @@ -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. diff --git a/docs/troubleshooting/validation-failed-error.md b/docs/troubleshooting/validation-failed-error.md index 18950ea5..19cb4019 100644 --- a/docs/troubleshooting/validation-failed-error.md +++ b/docs/troubleshooting/validation-failed-error.md @@ -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 @@ -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). diff --git a/mkdocs.yml b/mkdocs.yml index 3ed1649b..2b228cca 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 @@ -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 diff --git a/modern_di/dependency_graph.py b/modern_di/dependency_graph.py index 273e619b..8f287fd7 100644 --- a/modern_di/dependency_graph.py +++ b/modern_di/dependency_graph.py @@ -187,7 +187,9 @@ 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, @@ -195,6 +197,14 @@ def collect_errors(container: "Container", registry: "ProvidersRegistry") -> lis 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 diff --git a/modern_di/exceptions/__init__.py b/modern_di/exceptions/__init__.py index a7f2eb6c..ee4c94a3 100644 --- a/modern_di/exceptions/__init__.py +++ b/modern_di/exceptions/__init__.py @@ -23,6 +23,7 @@ InvalidScopeDependencyError, ProviderScopeFrozenError, RegistrationError, + ScopeEnumMismatchError, UnknownFactoryKwargError, UnsupportedCreatorParameterError, ) @@ -62,6 +63,7 @@ "RegistrationError", "ResolutionError", "ResolutionStep", + "ScopeEnumMismatchError", "ScopeNotInitializedError", "ScopeSkippedError", "UnknownFactoryKwargError", diff --git a/modern_di/exceptions/registration.py b/modern_di/exceptions/registration.py index 673423cd..66c0541d 100644 --- a/modern_di/exceptions/registration.py +++ b/modern_di/exceptions/registration.py @@ -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) diff --git a/tests/test_custom_scope.py b/tests/test_custom_scope.py index c94ac0f5..b8199d1a 100644 --- a/tests/test_custom_scope.py +++ b/tests/test_custom_scope.py @@ -13,9 +13,12 @@ from modern_di._scope_algebra import deeper_members, next_deeper from modern_di.exceptions import ( InvalidChildScopeError, + InvalidScopeDependencyError, MaxScopeReachedError, + ScopeEnumMismatchError, ScopeNotInitializedError, ScopeSkippedError, + ValidationFailedError, ) @@ -311,3 +314,46 @@ class MixedGroup(Group): repo = tenant_container.resolve(_TenantRepo) assert tenant_container.resolve(_TenantRepo) is repo assert repo.settings is app_container.resolve(_AppSettings) + + +@dataclasses.dataclass(kw_only=True, slots=True) +class _Session: + service: TenantService + + +def test_validate_reports_dependency_on_same_valued_scope_of_another_enum() -> None: + class MismatchGroup(Group): + service = providers.Factory(scope=ConflictingScope.LOWER_THAN_REQUEST, creator=TenantService) + session = providers.Factory(scope=Scope.SESSION, creator=_Session) + + container = Container(groups=[MismatchGroup]) + with pytest.raises(ValidationFailedError) as exc: + container.validate() + (issue,) = exc.value.errors + assert isinstance(issue, ScopeEnumMismatchError) + assert issue.provider is MismatchGroup.session + assert issue.parameter_name == "service" + assert issue.dep_chain == [MismatchGroup.service] + + +def test_validate_accepts_dependency_on_shallower_scope_of_another_enum() -> None: + class MixedGroup(Group): + service = providers.Factory(scope=ConflictingScope.LOWER_THAN_REQUEST, creator=TenantService) + session = providers.Factory(scope=Scope.REQUEST, creator=_Session) + + app_container = Container(groups=[MixedGroup]) + app_container.validate() + middle = app_container.build_child_container(scope=ConflictingScope.LOWER_THAN_REQUEST) + request = middle.build_child_container(scope=Scope.REQUEST) + assert isinstance(request.resolve(_Session).service, TenantService) + + +def test_validate_keeps_reporting_a_deeper_scope_of_another_enum_as_invalid_scope_dependency() -> None: + class DeeperGroup(Group): + service = providers.Factory(scope=ConflictingScope.LOWER_THAN_REQUEST, creator=TenantService) + session = providers.Factory(scope=Scope.APP, creator=_Session) + + with pytest.raises(ValidationFailedError) as exc: + Container(groups=[DeeperGroup]).validate() + (issue,) = exc.value.errors + assert isinstance(issue, InvalidScopeDependencyError) diff --git a/tests/test_error_rendering.py b/tests/test_error_rendering.py index 9b47e76f..6546311f 100644 --- a/tests/test_error_rendering.py +++ b/tests/test_error_rendering.py @@ -1,3 +1,4 @@ +import enum import inspect import pytest @@ -177,6 +178,10 @@ def test_context_value_not_set_error_names_the_argument() -> None: ) +class _RenderScope(enum.IntEnum): + SESSION_TWIN = 2 + + class _RenderTerminal: ... @@ -186,6 +191,28 @@ class _RenderIface: ... class _RenderCaptor: ... +def test_scope_enum_mismatch_error_names_both_enum_members() -> None: + terminal = providers.Factory(scope=Scope.SESSION, creator=_RenderTerminal) + captor = providers.Factory(scope=_RenderScope.SESSION_TWIN, creator=_RenderCaptor) + + error = exceptions.ScopeEnumMismatchError(provider=captor, parameter_name="dep", dep_chain=[terminal]) + + captor_at = f"{__name__}:{inspect.getsourcelines(_RenderCaptor)[1]}" + terminal_at = f"{__name__}:{inspect.getsourcelines(_RenderTerminal)[1]}" + assert error.dep_provider is terminal + assert error.dep_terminal is terminal + assert str(error) == ( + "Provider at a same-valued scope of another enum reached through this chain:\n" + f" SESSION_TWIN _RenderCaptor ({captor_at})\n" + f" SESSION └─> _RenderTerminal ({terminal_at})\n" + " caused by: _RenderCaptor (scope _RenderScope.SESSION_TWIN) declares parameter 'dep' typed as a " + "provider of _RenderTerminal at scope Scope.SESSION. 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 _RenderCaptor or a shallower one.\n" + "See: https://modern-di.modern-python.org/troubleshooting/scope-enum-mismatch-error/" + ) + + def test_invalid_scope_dependency_error_draws_the_chain_that_reached_the_terminal() -> None: terminal = providers.Factory(scope=Scope.REQUEST, creator=_RenderTerminal) iface = providers.Alias(source_type=_RenderTerminal, bound_type=_RenderIface)