diff --git a/architecture/providers.md b/architecture/providers.md index c0ed54c..224b3e0 100644 --- a/architecture/providers.md +++ b/architecture/providers.md @@ -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 @@ -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, diff --git a/docs/providers/errors-and-exceptions.md b/docs/providers/errors-and-exceptions.md index 916d3f2..3d19d58 100644 --- a/docs/providers/errors-and-exceptions.md +++ b/docs/providers/errors-and-exceptions.md @@ -31,6 +31,7 @@ ModernDIError (RuntimeError) ├── RegistrationError │ ├── DuplicateProviderTypeError │ ├── ChildContainerRegistrationError +│ ├── GroupScopeConflictError │ ├── UnknownFactoryKwargError │ ├── UnsupportedCreatorParameterError │ └── InvalidScopeDependencyError @@ -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). diff --git a/docs/providers/factories.md b/docs/providers/factories.md index e60ede3..cd86787 100644 --- a/docs/providers/factories.md +++ b/docs/providers/factories.md @@ -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. diff --git a/docs/providers/scopes.md b/docs/providers/scopes.md index c41bdcc..241932c 100644 --- a/docs/providers/scopes.md +++ b/docs/providers/scopes.md @@ -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. diff --git a/docs/troubleshooting/group-scope-conflict-error.md b/docs/troubleshooting/group-scope-conflict-error.md new file mode 100644 index 0000000..9f3838f --- /dev/null +++ b/docs/troubleshooting/group-scope-conflict-error.md @@ -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. diff --git a/docs/troubleshooting/invalid-scope-type-error.md b/docs/troubleshooting/invalid-scope-type-error.md index ecbca89..a4f6d1e 100644 --- a/docs/troubleshooting/invalid-scope-type-error.md +++ b/docs/troubleshooting/invalid-scope-type-error.md @@ -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** diff --git a/mkdocs.yml b/mkdocs.yml index 33ff20c..dd2d812 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 @@ -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 diff --git a/modern_di/exceptions.py b/modern_di/exceptions.py index c3dc8b4..2d5c3cf 100644 --- a/modern_di/exceptions.py +++ b/modern_di/exceptions.py @@ -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): @@ -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``.""" diff --git a/modern_di/group.py b/modern_di/group.py index 455b35f..11d6a6f 100644 --- a/modern_di/group.py +++ b/modern_di/group.py @@ -1,3 +1,4 @@ +import enum import typing from modern_di import exceptions @@ -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() diff --git a/modern_di/providers/abstract.py b/modern_di/providers/abstract.py index d242790..f418893 100644 --- a/modern_di/providers/abstract.py +++ b/modern_di/providers/abstract.py @@ -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: @@ -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. diff --git a/modern_di/providers/alias.py b/modern_di/providers/alias.py index bf99a7c..9e9d9d3 100644 --- a/modern_di/providers/alias.py +++ b/modern_di/providers/alias.py @@ -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 ) diff --git a/modern_di/providers/context_provider.py b/modern_di/providers/context_provider.py index b66cd23..a2dbcc4 100644 --- a/modern_di/providers/context_provider.py +++ b/modern_di/providers/context_provider.py @@ -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: @@ -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__( diff --git a/modern_di/providers/factory.py b/modern_di/providers/factory.py index ac5876f..2c07323 100644 --- a/modern_di/providers/factory.py +++ b/modern_di/providers/factory.py @@ -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 @@ -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, diff --git a/planning/changes/2026-07-08.02-group-default-scope.md b/planning/changes/2026-07-08.02-group-default-scope.md new file mode 100644 index 0000000..13976bb --- /dev/null +++ b/planning/changes/2026-07-08.02-group-default-scope.md @@ -0,0 +1,111 @@ +--- +summary: API-4 — Group subclasses declare a default scope (class RequestGroup(Group, scope=Scope.REQUEST)) stamped onto scope-defaulted Factory/ContextProvider members at class creation; explicit scope= wins; conflicting cross-group stamps raise GroupScopeConflictError. +--- + +# Design: Group-level default scope + +## Summary + +The accepted API-4 item from the 2026-07-05 3.0 UX research. A `Group` +subclass may declare `class RequestGroup(Group, scope=Scope.REQUEST)`; every +`Factory`/`ContextProvider` declared in that class body without an explicit +`scope=` takes the group's scope instead of `Scope.APP`. Explicit `scope=` +always wins. Implemented via `Group.__init_subclass__` plus a +"scope was defaulted, not chosen" marker on providers. Non-breaking: groups +without the kwarg and providers omitting scope keep today's APP behavior. + +## Motivation + +Research item API-4 (verified): every provider in a request-scoped group must +repeat `scope=Scope.REQUEST` on every line, and the APP default silently +applies to any line that forgets it. dishka (`Provider(scope=...)` with +three-level priority), that-depends (`default_scope`), and wireup (default +lifetime on `@injectable`) all provide a declare-once default at the +provider-group level. Combined with the shipped API-5, a request-group line +shrinks to `repo = providers.Factory(UserRepository)`. + +## Design + +### Priority (dishka's three levels, adapted) + +explicit `scope=` on the provider > group `scope=` kwarg (nearest in MRO) > +`Scope.APP`. + +### The defaulted-scope marker + +`Factory` and `ContextProvider` change their scope parameter to +`scope: enum.IntEnum | UnsetType = UNSET`. `AbstractProvider.__init__` +normalizes: UNSET becomes `Scope.APP` immediately and sets a new +`_scope_defaulted = True` slot — the sentinel never leaks; an unstamped +provider is indistinguishable from today's. Two slots are added to +`AbstractProvider`: `_scope_defaulted: bool` and `_stamping_group: str | None`. + +### Stamping at class creation + +`Group.__init_subclass__(scope: enum.IntEnum | None = None)`: + +- validates the kwarg: a non-`IntEnum` value raises `InvalidScopeTypeError` + (its message generalizes from "Container scope must be…" to cover both + sites; slug and page unchanged); +- stores `cls._default_scope`; a subclass without its own kwarg inherits the + nearest ancestor's via normal MRO attribute lookup, so + `class Sub(RequestGroup)` keeps REQUEST for providers declared in Sub's + body; +- for each `AbstractProvider` in `cls.__dict__` (own body only — inherited + attributes were stamped by their declaring class): skip if + `_scope_defaulted` is False (explicit wins); skip `Alias` (scope is + derived from its source; stamping would be decorative); otherwise set + `provider.scope` to the default and record the stamping group's name. + +### Cross-group sharing (maintainer-ruled) + +A provider instance referenced by two group bodies: if both stamp the same +scope, the second stamp is a no-op; if the scopes differ, class creation of +the second group raises `GroupScopeConflictError` (new `RegistrationError` +subclass) naming both groups and both scopes. Silent first-wins would make a +provider's scope depend on import order. Inspect `.provider_name`, +`.first_group`/`.first_scope`, `.second_group`/`.second_scope`. + +### New exception ⇒ registry obligations + +`GroupScopeConflictError` carries a `docs_slug` +(`group-scope-conflict-error`) and ships with +`docs/troubleshooting/group-scope-conflict-error.md` in the same PR — the +docs-slug census test enforces both. + +### Untouched + +Containers, resolution, `validate()`, overrides — stamping completes at +import time, before any container exists. `__repr__` strings unchanged. +`container_provider` is auto-registered outside groups and unaffected. + +## Non-goals + +- No container-level `default_scope` (that-depends style) — per-group is the + accepted granularity. +- No `Alias` participation — its scope is deprecated-and-derived. +- No metaclass — `__init_subclass__` suffices. +- No per-group provider copies ("group membership overrides") — one instance + has one scope. + +## Testing + +TDD; `just test-ci` 100% line coverage. Cases: group default stamps a +defaulted Factory and ContextProvider; explicit `scope=` in a defaulted +group wins; subclass inherits the default for its own body via MRO; subclass +kwarg overrides the inherited default; shared instance across two same-scope +groups is fine; differing scopes raise `GroupScopeConflictError` with both +group names; `Alias` in a defaulted group keeps derived behavior; group +without kwarg behaves exactly as today; non-`IntEnum` kwarg raises +`InvalidScopeTypeError`; census test passes for the new slug/page. +`just lint-ci`, `just docs-build` strict, `just check-planning` green. + +## Risk + +- Mutating shared provider instances at import time — mitigated: stamping is + idempotent for equal scopes, loud for conflicts, and never touches + explicitly-scoped providers. Low. +- Signature widening (`scope: IntEnum | UnsetType = UNSET`) visible to type + checkers/introspection — mitigated: UNSET is normalized before `__init__` + returns; docs state the default as "the group's scope, else Scope.APP". + Low. diff --git a/tests/test_docs_slug_census.py b/tests/test_docs_slug_census.py index e25575c..3043d76 100644 --- a/tests/test_docs_slug_census.py +++ b/tests/test_docs_slug_census.py @@ -13,8 +13,9 @@ _REPO_ROOT = pathlib.Path(__file__).parent.parent _TROUBLESHOOTING_DIR = _REPO_ROOT / "docs" / "troubleshooting" -# The 5 pre-existing troubleshooting pages plus the 16 new ones this change adds. -_EXPECTED_CONCRETE_CLASS_COUNT = 21 +# The 5 pre-existing troubleshooting pages, the 16 added by the error-docs-registry change, +# and GroupScopeConflictError (API-4). +_EXPECTED_CONCRETE_CLASS_COUNT = 22 _BASE_CLASSES = frozenset( { diff --git a/tests/test_group.py b/tests/test_group.py index a957f34..f652af5 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -3,7 +3,12 @@ import pytest from modern_di import Container, Group, Scope, providers -from modern_di.exceptions import DuplicateProviderTypeError, GroupInstantiationError +from modern_di.exceptions import ( + DuplicateProviderTypeError, + GroupInstantiationError, + GroupScopeConflictError, + InvalidScopeTypeError, +) def test_group_cannot_be_instantiated() -> None: @@ -147,3 +152,98 @@ class Child(Base): b = providers.Factory(creator=_B) assert Child.get_providers() == list(Child.get_named_providers().values()) + + +class _Svc: ... + + +class _Ctx: ... + + +def test_group_scope_stamps_defaulted_providers() -> None: + class RequestGroup(Group, scope=Scope.REQUEST): + svc = providers.Factory(_Svc) + ctx = providers.ContextProvider(_Ctx) + + assert RequestGroup.svc.scope is Scope.REQUEST + assert RequestGroup.ctx.scope is Scope.REQUEST + app_container = Container(groups=[RequestGroup], validate=True) + request_container = app_container.build_child_container(scope=Scope.REQUEST) + assert isinstance(request_container.resolve(_Svc), _Svc) + + +def test_group_scope_explicit_provider_scope_wins() -> None: + class RequestGroup(Group, scope=Scope.REQUEST): + app_svc = providers.Factory(_Svc, scope=Scope.APP) + + assert RequestGroup.app_svc.scope is Scope.APP + + +def test_group_scope_alias_keeps_derived_scope() -> None: + class RequestGroup(Group, scope=Scope.REQUEST): + svc = providers.Factory(_Svc) + alias = providers.Alias(_Svc) + + assert RequestGroup.alias.scope is Scope.APP # stored scope untouched; effective scope derives from source + + +def test_group_scope_inherited_by_subclass_body() -> None: + class RequestGroup(Group, scope=Scope.REQUEST): + svc = providers.Factory(_Svc) + + class SubGroup(RequestGroup): + ctx = providers.ContextProvider(_Ctx) + + assert SubGroup.ctx.scope is Scope.REQUEST + + +def test_group_scope_subclass_kwarg_overrides_inherited_default() -> None: + class RequestGroup(Group, scope=Scope.REQUEST): + svc = providers.Factory(_Svc) + + class ActionGroup(RequestGroup, scope=Scope.ACTION): + deep = providers.Factory(_Ctx) + + assert ActionGroup.deep.scope is Scope.ACTION + assert RequestGroup.svc.scope is Scope.REQUEST # parent stamp untouched + + +def test_group_scope_shared_provider_same_scope_ok() -> None: + shared = providers.Factory(_Svc) + + class GroupA(Group, scope=Scope.REQUEST): + svc = shared + + class GroupB(Group, scope=Scope.REQUEST): + svc = shared + + assert shared.scope is Scope.REQUEST + + +def test_group_scope_shared_provider_conflicting_scopes_raises() -> None: + shared = providers.Factory(_Svc) + + class GroupA(Group, scope=Scope.REQUEST): + svc = shared + + with pytest.raises(GroupScopeConflictError, match=r"GroupA.*GroupB|GroupB.*GroupA") as exc_info: + + class GroupB(Group, scope=Scope.ACTION): + svc = shared + + assert exc_info.value.first_scope is Scope.REQUEST + assert exc_info.value.second_scope is Scope.ACTION + + +def test_group_without_scope_kwarg_keeps_app_default() -> None: + class PlainGroup(Group): + svc = providers.Factory(_Svc) + + assert PlainGroup.svc.scope is Scope.APP + + +def test_group_scope_rejects_non_intenum() -> None: + with pytest.raises(InvalidScopeTypeError, match="99"): + + class BadGroup(Group, scope=99): # ty: ignore[invalid-argument-type] + svc = providers.Factory(_Svc)