diff --git a/docs/providers/errors-and-exceptions.md b/docs/providers/errors-and-exceptions.md index 66c711ec..23a1dbc0 100644 --- a/docs/providers/errors-and-exceptions.md +++ b/docs/providers/errors-and-exceptions.md @@ -1,30 +1,15 @@ # Errors and exceptions -Every exception `modern-di` raises lives in `modern_di.exceptions` and descends from a single -root, `ModernDIError`. The hierarchy is grouped by *when* the failure happens (registering -providers, validating the graph, resolving a type, or closing a container), so you can catch a -whole category with one `except`. +Every exception `modern-di` raises lives in `modern_di.exceptions` and descends from one root, +`ModernDIError`, which subclasses `RuntimeError` (see +[Design decisions](../introduction/design-decisions.md#7-errors-are-runtimeerrors)). The hierarchy +groups errors by when they happen: registering providers, validating the graph, resolving a type, or +closing a container. Each concrete error has a troubleshooting page with its cause and fix, and its +printed message ends with a `See:` line linking to that page. -The class hierarchy and each error's structured attributes (`.dependency_type`, `.cycle_path`, -`.suggestions`, `.dependency_path`, ...) are the contract. The rendered message text is diagnostic -output and may change in any release; read an attribute, never parse the message. - -```python -from modern_di import exceptions -``` - -## Pickling and copying - -Every exception can be pickled and copied, so it can cross a process pool or a task queue. The -unpickled error has the same class, `str()` and `args`, and keeps each attribute whose value -pickles. An attribute that does not, such as a lambda `creator` or a class defined inside a -function, comes back as its `repr()` string. A `FinalizerError` entry that does not pickle comes -back as a `RuntimeError` holding its `repr()`. As with any Python exception, `__cause__` and the -traceback are dropped. - -The check that decides whether an attribute pickles runs in the process that pickles the error. A -value that only loads there can still fail to load in another process. A class defined in -`__main__` is the common case: the receiving process has a different `__main__`. +The class hierarchy and each error's attributes (`.dependency_type`, `.cycle_path`, `.suggestions`, +`.dependency_path`, ...) are the contract. The message text is diagnostic output and may change in +any release, so read an attribute instead of parsing the message. ## Hierarchy @@ -63,173 +48,141 @@ ModernDIError (RuntimeError) └── GroupInstantiationError ``` -## Root - -- `ModernDIError` is the base class for every error the library raises. It subclasses - `RuntimeError`, so `except RuntimeError` catches it too (see - [Design decisions](../introduction/design-decisions.md#7-errors-are-runtimeerrors)). Catch - `ModernDIError` to handle any framework error in one place. - -## `ContainerError`: container and scope problems - -Catch `ContainerError` for any container/scope failure. `ScopeNotInitializedError`, -`ScopeSkippedError` and `ContainerClosedError` are both a `ContainerError` and a `ResolutionError`, -so either `except` catches them; they are described under `ResolutionError` below. - -- `InvalidChildScopeError` is raised when `build_child_container(scope=...)` is given a scope - that is not deeper than the parent's. The error lists the scopes that *are* allowed. See - [Troubleshooting: InvalidChildScopeError](../troubleshooting/invalid-child-scope-error.md). -- `MaxScopeReachedError` is raised by `build_child_container()` with no explicit `scope` when the - parent is already at the deepest scope (`STEP`), so there is no next level to advance to. See - [Troubleshooting: MaxScopeReachedError](../troubleshooting/max-scope-reached-error.md). -- `InvalidScopeTypeError` is raised by the `Container` constructor, by `build_child_container()`, - and by a `Group` subclass declared as `class G(Group, scope=...)`, when `scope` is not an - `enum.IntEnum`. See - [Troubleshooting: InvalidScopeTypeError](../troubleshooting/invalid-scope-type-error.md). -- `ValidationFailedError` is raised only by `Container.validate()`. Catch this for validation - results. It is an `ExceptionGroup`: `.exceptions` holds the individual issues (each itself a - `ResolutionError` or `RegistrationError`), and `except*` catches them by type. `str()` is one - line with the count of each kind; a traceback shows every issue in full below it. - Nothing validates automatically (not construction, not `open()`, not `add_providers`, not - `resolve()`), so call `validate()` explicitly whenever you want the whole graph checked; an - integration that registers its own providers after construction (via `add_providers`) should call - it after that registration. See - [Lifecycle: validation](lifecycle.md#validation), - [Migration: To 3.x](../migration/to-3.x.md) and - [Troubleshooting: ValidationFailedError](../troubleshooting/validation-failed-error.md). - -## `ResolutionError`: failures while resolving a type - -Catch `ResolutionError` for any resolution failure. These carry a `dependency_path` that is -accumulated as the error propagates, so the message shows the full chain from the requested type -down to the failing dependency. `dependency_path` is a `list[ResolutionStep]`, where each -`ResolutionStep` (importable from `modern_di.exceptions`) has a `.scope` and a `.name`; inspect it -to render the chain programmatically. - -- `ScopeNotInitializedError` is raised during resolution when a provider needs a scope *deeper* - than the current container's, and no container at that scope exists in the chain (e.g. resolving a - `REQUEST`-scoped provider from the `APP` container). For a runtime *captive dependency* (a - shallower-scoped provider depending, directly or transitively, on this deeper-scoped one), its - `dependency_path` names both the capturing provider and the one that failed. See - [Troubleshooting: ScopeNotInitializedError](../troubleshooting/scope-not-initialized-error.md). -- `ScopeSkippedError` is raised during resolution when the target scope is *shallower* than the - current container but is missing from the scope chain (a level was skipped when building children). - See [Troubleshooting: ScopeSkippedError](../troubleshooting/scope-skipped-error.md). -- `ContainerClosedError` is raised by `resolve()` / `resolve_provider()` when the call reaches a - closed container: the one you called, or an ancestor whose scope a child's resolve reaches back - into. Its `.container_scope` names the closed one, and its `dependency_path` is always empty. A - container is open from construction and is closed by `close_sync()`, `close_async()`, or leaving - `with` / `async with`. It stays closed until you call `container.open()` or enter it again with - `with` / `async with`, which calls `open()`. - `build_child_container()` never checks or touches any container's open/closed state, so building - a child of a closed parent does not raise by itself. See - [Lifecycle: closing and reopening](lifecycle.md#closing-and-reopening) and - [Troubleshooting: ContainerClosedError](../troubleshooting/container-closed-error.md). -- `ProviderNotRegisteredError` is raised by `resolve(SomeType)` when no provider is registered for - the type. The message includes "did you mean…" suggestions when a close match exists. See - [Troubleshooting: Missing provider](../troubleshooting/missing-provider.md). -- `AliasSourceNotRegisteredError` is raised when an `Alias` points at a `source_type` that has no - registered provider (eagerly during `validate()`, or at resolution time). See - [Troubleshooting: AliasSourceNotRegisteredError](../troubleshooting/alias-source-not-registered-error.md). -- `ArgumentResolutionError` is raised when a creator parameter cannot be resolved: no provider - matches its annotated type, or the parameter is unannotated. Inspect `.parameter_name`, - `.parameter_type` (or `.member_types` for a union), `.bound_type`, and `.creator`. See - [Troubleshooting: ArgumentResolutionError](../troubleshooting/argument-resolution-error.md). -- `CircularDependencyError` is raised when the provider graph contains a cycle (A → B → A); the - message shows the cycle path. Raised eagerly by `validate()`, and also by a bare `resolve()` on an - unvalidated cyclic graph via a runtime guard. The guard covers one thread resolving: concurrent - first resolves of an unvalidated cyclic graph can block instead, so call `validate()` at startup. See - [Troubleshooting: Circular dependency](../troubleshooting/circular-dependency.md#the-runtime-cycle-guard-without-validate). -- `CreatorCallError` is raised when a creator's dependencies all resolved but argument binding - failed while calling it (the assembled arguments don't match the signature, typically a `kwargs` / - `skip_creator_parsing` mismatch). Exceptions raised *inside* the creator body are never wrapped. - Most propagate unchanged; a `ResolutionError` from a `container.resolve()` call in the body gains - the creator's step in its chain. The binding `TypeError` is preserved on `.original_error` (and as the `__cause__`). - See [Troubleshooting: CreatorCallError](../troubleshooting/creator-call-error.md). -- `ContextValueNotSetError` is raised when a `ContextProvider` with no `default=` is resolved and - no value is set, either directly (`container.resolve(SomeContextType)`) or as the argument for a - required `Factory` parameter. A parameter that is nullable or has a default gets that default, or - `None`, instead. See - [Migration: To 4.x](../migration/to-4.x.md#a-missing-context-value-for-a-required-parameter-raises-contextvaluenotseterror). - Inspect `.context_type`, `.provider_scope`, and `.parameter_name` (the parameter, or `None` for a - direct resolve). See - [Troubleshooting: Context not set](../troubleshooting/context-not-set.md). - -## `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` 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). - `.first_provider` and `.second_provider` hold the two providers in registration order. See - [Troubleshooting: Duplicate type](../troubleshooting/duplicate-type-error.md). -- `AliasBoundToSourceError` is raised when an `Alias` is declared with `bound_type` equal to its - `source_type`. Such an alias would resolve to itself. - Inspect `.source_type`. See - [Troubleshooting: AliasBoundToSourceError](../troubleshooting/alias-bound-to-source-error.md). -- `ChildContainerRegistrationError` is raised by `Container.add_providers()` when called on a child - container. Registration is root-only because the providers registry is shared tree-wide, so - registering from a child would mutate every container in the tree. Call `add_providers` on the root - container instead. Inspect `.container_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` is 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`, `.first_group`/`.first_scope`, and `.second_group`/`.second_scope`. See - [Troubleshooting: GroupScopeConflictError](../troubleshooting/group-scope-conflict-error.md). -- `ProviderScopeFrozenError` is raised when a `Group` would change the scope of a provider that - is already registered with a container. Resolvers compiled before the change captured the old - scope, so applying it would make the same provider resolve differently through an existing - container than through a fresh one. Inspect `.provider`, `.group_name`, `.current_scope`, - `.new_scope`. See - [Troubleshooting: ProviderScopeFrozenError](../troubleshooting/provider-scope-frozen-error.md). -- `UnknownFactoryKwargError` is 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). -- `UnsupportedCreatorParameterError` is raised when a creator's signature has a parameter - `modern-di` cannot wire (e.g. an unsupported kind); names the parameter and the reason. See - [Troubleshooting: UnsupportedCreatorParameterError](../troubleshooting/unsupported-creator-parameter-error.md). -- `InvalidScopeDependencyError` is raised when a provider depends on another provider bound to a - *deeper* scope than its own (a longer-lived provider depending on a shorter-lived one). Surfaced by - `validate()`. Renders the chain from the depender to the provider that supplies the dependency; - `.dependency_chain` carries that chain, with `.dependency_provider` and `.dependency_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 `.dependency_chain`, with `.dependency_provider` and `.dependency_terminal` - as its ends. See - [Troubleshooting: ScopeEnumMismatchError](../troubleshooting/scope-enum-mismatch-error.md). - -## Direct `ModernDIError` subclasses - -These don't fit the register/resolve/validate grouping: - -- `FinalizerError` is raised by `close_sync()` / `close_async()` when one or more finalizers raised - during cleanup. The remaining finalizers still run; all errors are aggregated into this single - exception. It is also an `ExceptionGroup`, so `except*` can catch the finalizer errors by type. - `.exceptions` holds them as a tuple and `.is_async` records which close path ran. See - [Lifecycle](lifecycle.md#close-failure-semantics) and - [Troubleshooting: FinalizerError](../troubleshooting/finalizer-error.md). -- `AsyncFinalizerInSyncCloseError` is raised when `close_sync()` reaches a cached resource whose - finalizer is async. Because `close_sync()` aggregates, this arrives inside a `FinalizerError` (as - an entry in `.exceptions`), so catch it with `except* AsyncFinalizerInSyncCloseError`. The cache is retained so a - later `await close_async()` can finalize it. See [Lifecycle](lifecycle.md#close-failure-semantics) and - [Troubleshooting: AsyncFinalizerInSyncCloseError](../troubleshooting/async-finalizer-in-sync-close-error.md). -- `GroupInstantiationError` is raised when a `Group` subclass is instantiated. Groups are - namespaces and must never be created as objects. See - [Troubleshooting: GroupInstantiationError](../troubleshooting/group-instantiation-error.md). +## Catching errors + +Catch `ModernDIError` to handle any error from the library in one place, or a category base to +handle one group. `ScopeNotInitializedError`, `ScopeSkippedError` and `ContainerClosedError` are both +a `ContainerError` and a `ResolutionError`, so either `except` catches them: + +```python +from modern_di import Container, Group, Scope, exceptions, providers + + +class Session: ... + + +class Dependencies(Group): + session = providers.Factory(Session, scope=Scope.REQUEST) + + +container = Container(groups=[Dependencies]) + +try: + container.resolve(Session) # no REQUEST container in this chain +except exceptions.ResolutionError as exc: + print(type(exc).__name__, isinstance(exc, exceptions.ContainerError)) + print(exc.provider_scope.name, exc.container_scope.name) +``` + +`ValidationFailedError` and `FinalizerError` are also `ExceptionGroup`s, so `except*` picks out the +entries of one kind. What it does not catch is raised again as the same class, holding only the +remaining entries: + +```python +class Missing: ... + + +class NeedsMissing: + def __init__(self, missing: Missing) -> None: + self.missing = missing + + +class Unresolvable(Group): + needs_missing = providers.Factory(NeedsMissing) + + +try: + Container(groups=[Unresolvable]).validate() +except* exceptions.ArgumentResolutionError as group: + for error in group.exceptions: + print("unresolvable parameter:", error.parameter_name) +``` + +## Where each error comes from + +Each table row names what raises the error and the attributes to read. The linked page has the +printed message, the cause and the fix. + +### `ContainerError`: building containers and validating + +| Error | Raised by | Inspect | +|---|---|---| +| [`InvalidChildScopeError`](../troubleshooting/invalid-child-scope-error.md) | `build_child_container(scope=...)` with a scope that is not deeper than the parent's | `.parent_scope`, `.child_scope`, `.allowed_scopes` | +| [`MaxScopeReachedError`](../troubleshooting/max-scope-reached-error.md) | `build_child_container()` without `scope=` when the parent's enum has no deeper member | `.parent_scope` | +| [`InvalidScopeTypeError`](../troubleshooting/invalid-scope-type-error.md) | `Container(scope=...)`, `build_child_container(scope=...)` or `class G(Group, scope=...)` with a value that is not an `IntEnum` member | `.scope_value` | +| [`ValidationFailedError`](../troubleshooting/validation-failed-error.md) | `Container.validate()`, which is the only thing that validates | `.exceptions` | + +The three errors that are also a `ResolutionError` are in the next table. + +### `ResolutionError`: resolving a type + +| Error | Raised by | Inspect | +|---|---|---| +| [`ScopeNotInitializedError`](../troubleshooting/scope-not-initialized-error.md) | a resolve that needs a scope deeper than every container in the chain | `.provider_scope`, `.container_scope` | +| [`ScopeSkippedError`](../troubleshooting/scope-skipped-error.md) | a resolve that needs a shallower scope with no container in the chain | `.provider_scope`, `.container_scope`, `.root_scope` | +| [`ContainerClosedError`](../troubleshooting/container-closed-error.md) | a resolve that reaches a closed container | `.container_scope` | +| [`ProviderNotRegisteredError`](../troubleshooting/missing-provider.md) | `resolve(SomeType)` when no provider is registered for the type | `.dependency_type`, `.suggestions` | +| [`AliasSourceNotRegisteredError`](../troubleshooting/alias-source-not-registered-error.md) | `validate()` or a resolve, for an `Alias` whose source type has no provider | `.source_type` | +| [`ArgumentResolutionError`](../troubleshooting/argument-resolution-error.md) | `validate()` or a resolve, for a creator parameter with no matching provider or no annotation | `.parameter_name`, `.parameter_type`, `.member_types`, `.bound_type`, `.creator`, `.suggestions` | +| [`CircularDependencyError`](../troubleshooting/circular-dependency.md) | `validate()`, or the runtime guard in a resolve, for a cycle in the graph | `.steps`, `.cycle_path`, `.cycle_locations` | +| [`CreatorCallError`](../troubleshooting/creator-call-error.md) | a resolve whose arguments do not bind to the creator's signature, typically a `kwargs` or `skip_creator_parsing` mismatch | `.creator`, `.original_error` | +| [`ContextValueNotSetError`](../troubleshooting/context-not-set.md) | a resolve of a `ContextProvider` with no value set and no `default=`, directly or for a required parameter | `.context_type`, `.provider_scope`, `.parameter_name` | + +Every `ResolutionError` also has `.dependency_path`, a list of `ResolutionStep` objects (importable +from `modern_di.exceptions`), each with a `.scope`, a `.name` and a `.location`. A resolve fills it in +as the error propagates, from the type you asked for down to the one that failed. While it is +non-empty, the message starts with `Cannot resolve dependency chain:`, draws one line per step, and +ends the chain with a `caused by:` line holding the error's own message. `ContainerClosedError` +always has an empty path, and `CircularDependencyError` draws its cycle from `.steps` instead. + +### `RegistrationError`: declaring and registering providers + +| Error | Raised by | Inspect | +|---|---|---| +| [`DuplicateProviderTypeError`](../troubleshooting/duplicate-type-error.md) | registering two providers for the same bound type | `.provider_type`, `.first_provider`, `.second_provider` | +| [`AliasBoundToSourceError`](../troubleshooting/alias-bound-to-source-error.md) | declaring an `Alias` whose `bound_type` is its `source_type` | `.source_type` | +| [`ChildContainerRegistrationError`](../troubleshooting/child-container-registration-error.md) | `add_providers()` on a child container | `.container_scope` | +| [`GroupScopeConflictError`](../troubleshooting/group-scope-conflict-error.md) | sharing a provider with no `scope=` between two groups declared with different `scope=` | `.provider`, `.first_group`, `.first_scope`, `.second_group`, `.second_scope` | +| [`ProviderScopeFrozenError`](../troubleshooting/provider-scope-frozen-error.md) | a `Group` that would change the scope of a provider already registered with a container | `.provider`, `.group_name`, `.current_scope`, `.new_scope` | +| [`UnknownFactoryKwargError`](../troubleshooting/unknown-factory-kwarg-error.md) | `Factory(kwargs={...})` with a key that is not a parameter of the creator | `.creator`, `.unknown_keys`, `.known_keys`, `.suggestions` | +| [`UnsupportedCreatorParameterError`](../troubleshooting/unsupported-creator-parameter-error.md) | a creator parameter `modern-di` cannot wire | `.creator`, `.parameter_name`, `.reason` | +| [`InvalidScopeDependencyError`](../troubleshooting/scope-chain.md) | `validate()`, for a provider that depends on a deeper-scoped one | `.provider`, `.parameter_name`, `.dependency_chain`, `.dependency_provider`, `.dependency_terminal` | +| [`ScopeEnumMismatchError`](../troubleshooting/scope-enum-mismatch-error.md) | `validate()`, for a dependency at a same-valued scope of another enum | `.provider`, `.parameter_name`, `.dependency_chain`, `.dependency_provider`, `.dependency_terminal` | + +### Direct `ModernDIError` subclasses + +| Error | Raised by | Inspect | +|---|---|---| +| [`FinalizerError`](../troubleshooting/finalizer-error.md) | `close_sync()` or `close_async()` after one or more finalizers raised | `.exceptions`, `.is_async` | +| [`AsyncFinalizerInSyncCloseError`](../troubleshooting/async-finalizer-in-sync-close-error.md) | `close_sync()` reaching an async finalizer; it arrives as an entry inside a `FinalizerError` | `.instance_type` | +| [`GroupInstantiationError`](../troubleshooting/group-instantiation-error.md) | instantiating a `Group` subclass | `.group_name` | + +For `ValidationFailedError` and `FinalizerError`, `str()` is two lines: a summary naming each kind of +entry with its count, then the `See:` line. A traceback, or `logger.exception`, prints every entry in +full below it. + +## Pickling and copying + +Every exception can be pickled and copied, so it can cross a process pool or a task queue. The +unpickled error has the same class, `str()` and `args`, and keeps each attribute whose value +pickles. An attribute that does not, such as a lambda `creator` or a class defined inside a +function, comes back as its `repr()` string. A `FinalizerError` entry that does not pickle comes +back as a `RuntimeError` holding its `repr()`. As with any Python exception, `__cause__` and the +traceback are dropped. + +The check that decides whether an attribute pickles runs in the process that pickles the error. A +value that only loads there can still fail to load in another process. A class defined in +`__main__` is the common case: the receiving process has a different `__main__`. ## Security note -`modern-di` exception messages are intended for developers (logs, tracebacks during wiring). A -`CreatorCallError` embeds the wrapped exception's text, and a `FinalizerError` traceback shows every -finalizer exception. So if a creator or finalizer raises an error whose message contains sensitive -runtime data, that text becomes part of what `modern-di` reports. The DI-specific errors themselves are -conservative (type names and provider reprs only; context values are keyed by type and never repr'd). -Applications must not echo raw exception strings to untrusted clients. +`modern-di` messages are written for developers reading logs and tracebacks. The errors about the +graph name types, parameters, scopes and providers. A context value never appears in a message, +because context is keyed by type. Two errors can carry your runtime data: + +- `InvalidScopeTypeError` shows the `repr()` of the value passed as `scope=`. +- `FinalizerError` holds the exceptions your finalizers raised, and a traceback prints each one with + its message. + +An exception raised inside a creator's body propagates unchanged, with whatever message your code +gave it. Do not echo raw exception strings to untrusted clients. diff --git a/docs/troubleshooting/async-finalizer-in-sync-close-error.md b/docs/troubleshooting/async-finalizer-in-sync-close-error.md index ad3086ad..25f27c02 100644 --- a/docs/troubleshooting/async-finalizer-in-sync-close-error.md +++ b/docs/troubleshooting/async-finalizer-in-sync-close-error.md @@ -2,21 +2,36 @@ ## Symptom -Arrives inside a `FinalizerError` (as one entry in its `.exceptions`), naming the type whose cached -instance has an async finalizer. `.instance_type` holds that type. A traceback shows it below the -`FinalizerError`, and `except* AsyncFinalizerInSyncCloseError` catches it. +`close_sync()` raises a [`FinalizerError`](finalizer-error.md), and one of its `.exceptions` is an +`AsyncFinalizerInSyncCloseError`: + +```text + + Exception Group Traceback (most recent call last): + | ... + | modern_di.exceptions.lifecycle.FinalizerError: Container.close_sync() found 1 finalizer error(s): AsyncFinalizerInSyncCloseError (1) + | See: https://modern-di.modern-python.org/troubleshooting/finalizer-error/ + +-+---------------- 1 ---------------- + | modern_di.exceptions.lifecycle.AsyncFinalizerInSyncCloseError: Cannot run async finalizer for AsyncResource during sync close. Use `await container.close_async()` (or `async with container:`) instead. + | See: https://modern-di.modern-python.org/troubleshooting/async-finalizer-in-sync-close-error/ + +------------------------------------ +``` + +`.instance_type` holds the type of the cached instance, and `except* AsyncFinalizerInSyncCloseError` +catches it. ## Cause -`close_sync()` cannot `await` anything. When it reaches a cached resource whose `CacheSettings` -finalizer is an async function, it can't run it synchronously, so it records this error for that entry -instead. Unlike a normal finalizer failure, it keeps the resource's cache entry intact rather than -discarding it. +`close_sync()` cannot `await`. It records this error for a cached instance whose finalizer is an +`async def`, and for one whose sync finalizer returns an awaitable. In both cases it keeps the +instance cached, unlike a finalizer that raised, whose instance is dropped. While the instance stays +cached, a repeated `close_sync()` reports it again, and resolving after a reopen returns the same +instance. ## Fix -Use `close_async()` (or `async with container:`) for containers that hold any resource with an async -finalizer, since it's the only path that can actually run that cleanup: +Close the container with `close_async()` or `async with container:`, the only paths that can run an +async finalizer. After a `close_sync()` that reported this error, `await container.close_async()` +runs the finalizer and finishes the cleanup: ```python -container.resolve(AsyncResource) # has an async finalizer +container.resolve(AsyncResource) try: container.close_sync() except* exceptions.AsyncFinalizerInSyncCloseError: - # cache retained, not lost - ... + pass # AsyncResource is still cached -await container.close_async() # recovers: runs the async finalizer, completes cleanup +await container.close_async() # runs close_resource ``` -Prefer `async with container:` (or `await close_async()`) by default whenever any provider might have -an async finalizer, and treat `close_sync()` as a fallback only for containers you know are entirely -sync. +Use `close_sync()` only for containers you know hold no async finalizer. ## See also -- [Lifecycle: close-failure semantics](../providers/lifecycle.md#close-failure-semantics). +- [Lifecycle: async finalizers and `close_sync()`](../providers/lifecycle.md#async-finalizers-and-close_sync) + — how `close_sync()` treats an async finalizer. +- [FinalizerError](finalizer-error.md) — the group this error arrives in. diff --git a/docs/troubleshooting/child-container-registration-error.md b/docs/troubleshooting/child-container-registration-error.md index c3753f68..6063c0bf 100644 --- a/docs/troubleshooting/child-container-registration-error.md +++ b/docs/troubleshooting/child-container-registration-error.md @@ -2,18 +2,24 @@ ## Symptom -Raised from `Container.add_providers()` on a child container. It names the child container's scope. +`Container.add_providers()` raises it when called on a child container: + +```text +modern_di.exceptions.registration.ChildContainerRegistrationError: Providers can only be registered on a root container: the providers registry is shared tree-wide, so registering on a child container (scope REQUEST) would mutate every container in the tree. Pass groups= to the root Container or call add_providers on it instead. +See: https://modern-di.modern-python.org/troubleshooting/child-container-registration-error/ +``` + +`.container_scope` holds the child container's scope. ## Cause -Providers were registered on a child container rather than the root. The providers registry is -shared tree-wide (every container in the chain points at the same registry), so registering from a -child would silently mutate every container in the tree, so the call is disallowed. +Every container in a tree shares one providers registry, so a registration on a child would change +what every other container in the tree resolves. `add_providers()` accepts calls only on a root. ## Fix -Register on the root container instead, either with `groups=` when you build it or with -`add_providers()` later: +Register on the root container, either with `groups=` when you build it or with `add_providers()` +afterwards: ```python -app_container = Container(scope=Scope.APP, groups=[MyGroup]) -request_container = app_container.build_child_container(scope=Scope.REQUEST) - # Broken -request_container.add_providers(late_provider) # raises ChildContainerRegistrationError +request_container.add_providers(late_provider) +``` +```python # Works app_container.add_providers(late_provider) ``` -If you only have a reference to the child container at the call site, keep a reference to the root -container around (e.g. store it at app startup) instead of walking up via `parent_container`. +Any root works, whatever its scope. Register at startup, because `add_providers()` is not +coordinated with resolves running at the same time on other threads. ## See also diff --git a/docs/troubleshooting/container-closed-error.md b/docs/troubleshooting/container-closed-error.md index fe6297c5..99a19a49 100644 --- a/docs/troubleshooting/container-closed-error.md +++ b/docs/troubleshooting/container-closed-error.md @@ -6,7 +6,8 @@ Raised from `resolve()` or `resolve_provider()` (and so from `resolve_dependency integration's `FromDI`) when the call reaches a container that was closed: ```text -Container (scope APP) is closed. Reopen it with `open()` or by re-entering `with`/`async with` before resolving from it or from any of its child containers. +modern_di.exceptions.container.ContainerClosedError: Container (scope APP) is closed. Reopen it with `open()` or by re-entering `with`/`async with` before resolving from it or from any of its child containers. +See: https://modern-di.modern-python.org/troubleshooting/container-closed-error/ ``` `.container_scope` names the closed container. When a child's resolve reaches back into a closed @@ -26,9 +27,8 @@ A fresh container is open from construction, so this never means "you forgot to ## Fix Reopen the container before resolving from it again. Call `container.open()`, or enter it again -with `with` / `async with`: `__enter__` and `__aenter__` call `open()` for you. This is how a test -harness enters the same container twice, how a broker stops and starts, and how a framework -lifespan runs more than once in one process. Integrations call `open()` in their startup hook. +with `with` / `async with`, whose `__enter__` and `__aenter__` call `open()` for you. Integrations +call `open()` in their startup hook. ```python -container = Container(groups=[Dependencies]) - with container: container.resolve(Settings) # closed here: finalizers ran -container.resolve(Settings) # Broken: raises ContainerClosedError +# Broken +container.resolve(Settings) +``` -with container: # Works: __enter__ reopens it +```python +# Works: __enter__ reopens the container +with container: container.resolve(Settings) ``` @@ -63,9 +68,10 @@ comes from and stop it outliving the container. Reopening there would hide the l cached after the reopen would never be finalized, because the shutdown that should close it has already run. -A finalizer that resolves from its own container also raises, because the container counts as -closed while its finalizers run. Do not reopen the container from a finalizer. Give the cached -instance what its finalizer needs when it is created. +A finalizer that resolves from its own container fails the same way, because the container counts +as closed while its finalizers run. That `ContainerClosedError` arrives as an entry inside the +[`FinalizerError`](finalizer-error.md) that `close_sync()` or `close_async()` raises. Do not reopen +the container from a finalizer. Give the cached instance what its finalizer needs when it is created. ## See also diff --git a/docs/troubleshooting/finalizer-error.md b/docs/troubleshooting/finalizer-error.md index 2f37a4c1..2ad25fee 100644 --- a/docs/troubleshooting/finalizer-error.md +++ b/docs/troubleshooting/finalizer-error.md @@ -5,23 +5,25 @@ Raised by `close_sync()` / `close_async()` when finalizers fail during cleanup. The message's first line names each kind of error with its count: -``` +```text modern_di.exceptions.lifecycle.FinalizerError: Container.close_sync() found 2 finalizer error(s): ConnectionError (1), ValueError (1) See: https://modern-di.modern-python.org/troubleshooting/finalizer-error/ ``` It is an `ExceptionGroup`, so a traceback shows each finalizer exception below it, with a note naming the type of the cached instance whose finalizer raised it, such as -`raised by the finalizer of a cached Database`. +`raised by the finalizer of a cached Database`. `.is_async` tells you whether `close_sync()` or +`close_async()` raised it. ## Cause -One or more cached providers' finalizers raised while the container was closing. Closing never stops -at the first failure: every finalizer runs regardless, so this error aggregates every failure. +One or more finalizers of cached providers raised while the container was closing. Finalizers run +newest first, in the reverse of the order their instances were created, and closing never stops at +the first failure. Every finalizer runs, and this error collects every failure. ## Fix -Inspect `.exceptions` for the individual exceptions and fix the offending finalizer(s): +Inspect `.exceptions` for the individual exceptions and fix the finalizers that raised: ```python -from modern_di import Scope - -app_container = Container(scope=Scope.APP, groups=[MyGroup]) - # Broken: SESSION is not deeper than SESSION -mid = app_container.build_child_container(scope=Scope.SESSION) -bad = mid.build_child_container(scope=Scope.SESSION) # raises InvalidChildScopeError +session_container.build_child_container(scope=Scope.SESSION) +``` +```python # Works -good = mid.build_child_container(scope=Scope.REQUEST) +request_container = session_container.build_child_container(scope=Scope.REQUEST) ``` -## Escape hatches +You can also omit `scope=`. `build_child_container()` then takes the next deeper member of the +parent's enum and never raises this error. When the enum has no deeper member it raises +[`MaxScopeReachedError`](max-scope-reached-error.md) instead. + +To go deeper than the parent's enum reaches, pass a member of your own `IntEnum` with a higher value: + +```python +import enum + + +class MyScope(enum.IntEnum): + SUBSTEP = Scope.STEP + 1 -Omit `scope=` entirely. `build_child_container()` derives the next deeper scope automatically, so -this error can only occur when you explicitly pin a scope value. Inspect `.allowed_scopes` on the -caught exception for the exact list of valid choices at that point in the tree, as members of the -parent's scope enum. + +step_container = Container(scope=Scope.STEP) +substep_container = step_container.build_child_container(scope=MyScope.SUBSTEP) +``` ## See also -- [Scopes](../providers/scopes.md#the-scope-dependency-rule) explains the scope hierarchy and ordering rule. +- [Scopes: custom scopes](../providers/scopes.md#custom-scopes) — how members of different enums mix + in one container tree. +- [MaxScopeReachedError](max-scope-reached-error.md) — the error from `build_child_container()` + without `scope=` at the last member of an enum. diff --git a/docs/troubleshooting/invalid-scope-type-error.md b/docs/troubleshooting/invalid-scope-type-error.md index 2b432fda..4624d2cb 100644 --- a/docs/troubleshooting/invalid-scope-type-error.md +++ b/docs/troubleshooting/invalid-scope-type-error.md @@ -2,21 +2,25 @@ ## Symptom -Raised when constructing a `Container`, when calling `build_child_container()`, or when defining a `Group` subclass with a `scope=` class kwarg, naming the value that was passed as `scope=` and its type. +Raised when a value passed as `scope=` is not an `enum.IntEnum` member. The message shows the value +and its type: -## Cause +```text +modern_di.exceptions.container.InvalidScopeTypeError: Scope must be an enum.IntEnum member; got 1 (int). +See: https://modern-di.modern-python.org/troubleshooting/invalid-scope-type-error/ +``` -`scope=` must be an `enum.IntEnum` member. This fires in three contexts: +`.scope_value` holds the value that was passed. -1. When passed to the `Container` constructor, with a plain `int`, a string, a regular `enum.Enum` (not `IntEnum`), or any other non-`IntEnum` value. -2. When passed to `build_child_container()`; the same validation applies. -3. When passed to a `Group` subclass as a class kwarg; the same validation applies. +## Cause -Example invalid uses: `Container(scope=1)`, `Container(scope="APP")`, `container.build_child_container(scope=3)`, `class MyGroup(Group, scope=1)`, `class MyGroup(Group, scope="REQUEST")`. +`Container(scope=...)`, `build_child_container(scope=...)` and a group declared as +`class MyGroup(Group, scope=...)` all check the value, and each rejects an `int`, a `str`, a member of +a plain `enum.Enum`, and a member of an `enum.IntFlag`. ## Fix -Use the built-in `Scope` enum, or your own `IntEnum` subclass: +Pass a member of `Scope` or of your own `IntEnum`: @@ -24,16 +28,25 @@ Use the built-in `Scope` enum, or your own `IntEnum` subclass: from modern_di import Container, Scope # Broken -container = Container(scope=1) # raises InvalidScopeTypeError -container = Container(scope="APP") # raises InvalidScopeTypeError +container = Container(scope=1) +``` +```python # Works container = Container(scope=Scope.APP) ``` -If you need scopes beyond the five built-in ones, define your own `enum.IntEnum` whose members' -values are ordered the way you want the hierarchy to resolve, and use that instead of `Scope`. +When the scope comes from configuration as a number or a name, convert it to a member first: + +```python +assert Scope(3) is Scope.REQUEST +assert Scope["REQUEST"] is Scope.REQUEST +``` + +For scopes beyond the five built-in ones, define your own `enum.IntEnum` and order its values the +way the hierarchy should nest. ## See also -- [Scopes](../providers/scopes.md) explains the `IntEnum` hierarchy and why membership is required. +- [Scopes: custom scopes](../providers/scopes.md#custom-scopes) — using your own `IntEnum` as a + scope. diff --git a/docs/troubleshooting/max-scope-reached-error.md b/docs/troubleshooting/max-scope-reached-error.md index 0456e872..f2ca5ab1 100644 --- a/docs/troubleshooting/max-scope-reached-error.md +++ b/docs/troubleshooting/max-scope-reached-error.md @@ -2,18 +2,27 @@ ## Symptom -Raised from `build_child_container()` called with no explicit `scope=` argument, naming the parent -scope that has no deeper scope to advance to. +`build_child_container()` called without `scope=` raises it when the parent is at the last member of +its scope enum: + +```text +modern_di.exceptions.container.MaxScopeReachedError: Max scope of STEP is reached. To go deeper, build a child container with a custom IntEnum scope whose value is higher. +See: https://modern-di.modern-python.org/troubleshooting/max-scope-reached-error/ +``` + +`.parent_scope` holds the parent's scope. ## Cause -`build_child_container()` without an explicit `scope=` auto-derives the next deeper scope by picking -the smallest enum member greater than the parent's. The built-in `Scope` enum ends at `STEP`; calling -`build_child_container()` on a `STEP`-scope container has nowhere further to go. +Without `scope=`, `build_child_container()` picks the smallest member of the parent's own enum that +is greater than the parent's scope. It never looks at another enum. The built-in `Scope` enum ends at +`STEP`, so a `STEP` container has no next member to advance to. ## Fix -Define an `IntEnum` with a member deeper than `STEP` and build the child with that scope explicitly: +First check whether the provider needs a scope deeper than `STEP`, or whether it belongs at an +existing scope. If it does need one, define an `IntEnum` with a member deeper than `STEP` and pass it +explicitly: + + ```python -class Dependencies(Group): +# Broken: UserRepository defaults to APP and needs the REQUEST-scoped session +class BrokenDependencies(Group): session = providers.Factory( create_session, scope=Scope.REQUEST, cache=providers.CacheSettings(finalizer=close_session), ) - - # Broken: APP-scoped, fails validation user_repository = providers.Factory(UserRepository) - # Works: REQUEST-scoped, matches session's lifetime - user_repository = providers.Factory( - UserRepository, + +Container(groups=[BrokenDependencies]).validate() +``` + +```python +# Works: UserRepository lives as long as the session +class Dependencies(Group): + session = providers.Factory( + create_session, scope=Scope.REQUEST, + cache=providers.CacheSettings(finalizer=close_session), ) + user_repository = providers.Factory(UserRepository, scope=Scope.REQUEST) + + +Container(groups=[Dependencies]).validate() ``` +When the dependency does not need its shorter lifetime, the other fix is to give it the depender's +scope or a shallower one. + ## See also -- [Scopes](../providers/scopes.md#the-scope-dependency-rule) explains the lifetime model and the "max of dependencies' scopes" rule. -- [Lifecycle](../providers/lifecycle.md) covers `container.validate()` and other startup checks. +- [Scopes: how to choose a scope](../providers/scopes.md#how-to-choose-a-scope) — the shallowest + scope a provider can have, given its dependencies. +- [Captive dependency](../recipes/good-and-bad-practices.md#1-captive-dependency-a-wide-scoped-provider-holding-a-narrow-scoped-one) + — the mistake this error reports, and its fix. +- [Lifecycle: validation](../providers/lifecycle.md#validation) — when to call `validate()` and what + it checks. diff --git a/docs/troubleshooting/scope-enum-mismatch-error.md b/docs/troubleshooting/scope-enum-mismatch-error.md index 342017b3..f9c621eb 100644 --- a/docs/troubleshooting/scope-enum-mismatch-error.md +++ b/docs/troubleshooting/scope-enum-mismatch-error.md @@ -5,28 +5,32 @@ `container.validate()` raises `ValidationFailedError`, and one of its `.exceptions` is a `ScopeEnumMismatchError`: -``` +```text + Exception Group Traceback (most recent call last): | ... | modern_di.exceptions.container.ValidationFailedError: Container.validate() found 1 issue(s): ScopeEnumMismatchError (1) | See: https://modern-di.modern-python.org/troubleshooting/validation-failed-error/ +-+---------------- 1 ---------------- | modern_di.exceptions.registration.ScopeEnumMismatchError: Provider at a same-valued scope of another enum reached through this chain: - | SESSION UserSession (myapp.providers:15) - | TENANT └─> TenantSettings (myapp.providers:11) + | SESSION UserSession (myapp.providers:13) + | TENANT └─> TenantSettings (myapp.providers:10) | 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. | See: https://modern-di.modern-python.org/troubleshooting/scope-enum-mismatch-error/ +------------------------------------ ``` +Inspect `.provider`, `.parameter_name` and `.dependency_chain` on the exception. +`.dependency_provider` and `.dependency_terminal` are the ends of the chain; they differ when the +dependency is reached through an `Alias`. + ## 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`. +`UserSession` can never resolve its `settings` dependency. Resolving it without `validate()` raises +[`ScopeSkippedError`](scope-skipped-error.md). 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`. @@ -38,7 +42,7 @@ Give the dependency the same scope member as the provider that needs it, or a sh + + ```python class Tenancy(IntEnum): TENANT = 2 -class Dependencies(Group): - # Broken: TENANT and SESSION are both 2 +# Broken: TENANT and SESSION are both 2 +class BrokenDependencies(Group): settings = providers.Factory(TenantSettings, scope=Tenancy.TENANT) session = providers.Factory(UserSession, scope=Scope.SESSION) - # Works: the dependency is shallower than the provider + +Container(groups=[BrokenDependencies]).validate() +``` + +```python +# Works: the dependency is shallower than the provider +class Dependencies(Group): settings = providers.Factory(TenantSettings, scope=Scope.APP) session = providers.Factory(UserSession, scope=Scope.SESSION) -``` -Inspect `.provider`, `.parameter_name` and `.dependency_chain` on the exception. -`.dependency_provider` and `.dependency_terminal` are the ends of the chain; they differ when the -dependency is reached through an `Alias`. + +Container(groups=[Dependencies]).validate() +``` ## 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. +- [Scopes: custom scopes](../providers/scopes.md#custom-scopes) — how scopes from different enums + mix in one tree. +- [InvalidScopeDependencyError](scope-chain.md) — a dependency on a deeper scope. diff --git a/docs/troubleshooting/scope-not-initialized-error.md b/docs/troubleshooting/scope-not-initialized-error.md index 37c9e7ce..847041a1 100644 --- a/docs/troubleshooting/scope-not-initialized-error.md +++ b/docs/troubleshooting/scope-not-initialized-error.md @@ -2,51 +2,83 @@ ## Symptom -A resolution fails naming a provider's scope and the current container's scope, optionally with a -dependency-path breadcrumb when the failing provider was captured by a shallower one. Each -breadcrumb line may end with a pointer to where that provider was declared (module and line -number), so you can jump straight to the declaration. +`resolve()` or `resolve_provider()` raises it when a provider's scope is deeper than the container +that has to build it. Resolving a `REQUEST`-scoped provider from the `APP` container prints: + +```text +modern_di.exceptions.container.ScopeNotInitializedError: Cannot resolve dependency chain: + REQUEST Session (myapp.providers:7) + caused by: Provider of scope REQUEST cannot be resolved in container of scope APP. +See: https://modern-di.modern-python.org/troubleshooting/scope-not-initialized-error/ +``` + +When a shallower provider depends on the deeper one, the chain starts at the shallower provider: + +```text +modern_di.exceptions.container.ScopeNotInitializedError: Cannot resolve dependency chain: + APP UserCache (myapp.providers:10) + REQUEST └─> Session (myapp.providers:7) + caused by: Provider of scope REQUEST cannot be resolved in container of scope APP. +See: https://modern-di.modern-python.org/troubleshooting/scope-not-initialized-error/ +``` + +Each chain line names a provider's scope and type and, when it can be found, the `module:line` where +its creator is defined (the class or function, not the `Factory(...)` line). `.provider_scope` and +`.container_scope` hold the two scopes in the `caused by` line. ## Cause -A provider's scope is deeper than any container currently in the chain: you resolved (directly or -transitively) a provider whose scope has no matching container built yet. For example, a -`REQUEST`-scoped provider resolved straight from the `APP` container, with no `REQUEST` child ever -built. +The container that has to build the provider is shallower than the provider's scope, in one of two +ways: + +- You resolved a provider from a container shallower than the provider's scope, and no container at + that scope exists below it yet. The first sample is `container.resolve(Session)` on the `APP` + container, with no `REQUEST` child built. +- A provider depends on a deeper-scoped one, a captive dependency. An `APP`-scoped `UserCache` is + built in the `APP` container even when you call `resolve(UserCache)` on a `REQUEST` child, and the + `APP` container cannot build the `REQUEST`-scoped `Session` it needs. That is why the second sample + names container scope `APP` although the call went to a `REQUEST` container. ## Fix -Build the deeper-scoped container before resolving from it: +In the first case, build the deeper container and resolve from it: ```python -app_container = Container(scope=Scope.APP, groups=[MyGroup]) - # Broken: no REQUEST container exists yet -app_container.resolve(RequestScopedThing) # raises ScopeNotInitializedError +app_container.resolve(Session) +``` +```python # Works request_container = app_container.build_child_container(scope=Scope.REQUEST) -request_container.resolve(RequestScopedThing) +request_container.resolve(Session) ``` -When the breadcrumb shows a captive dependency (a shallower provider depending on this deeper one), -the real fix is usually to move the *depending* provider to the deeper scope instead. See the scope -dependency rule below, which `validate()` catches ahead of time as `InvalidScopeDependencyError`. +In the captive case a deeper container does not help. Move the depending provider to the deeper +scope, or give the dependency a shallower scope if its lifetime allows. `container.validate()` finds +every captive dependency at startup and reports it as +[`InvalidScopeDependencyError`](scope-chain.md), whose page shows both fixes. ## See also -- [Scope chain violation](scope-chain.md) covers the related, statically-detected form of this problem. -- [Scopes: the scope dependency rule](../providers/scopes.md#the-scope-dependency-rule). +- [InvalidScopeDependencyError](scope-chain.md) — the same problem reported by `validate()`. +- [Scopes: the scope dependency rule](../providers/scopes.md#the-scope-dependency-rule) — why a + provider cannot depend on a deeper scope. +- [Captive dependency](../recipes/good-and-bad-practices.md#1-captive-dependency-a-wide-scoped-provider-holding-a-narrow-scoped-one) + — the bug this error usually points at. diff --git a/docs/troubleshooting/scope-skipped-error.md b/docs/troubleshooting/scope-skipped-error.md index f32103d6..e5fcb8e0 100644 --- a/docs/troubleshooting/scope-skipped-error.md +++ b/docs/troubleshooting/scope-skipped-error.md @@ -2,57 +2,81 @@ ## Symptom -A resolution fails naming a provider's scope and where the container chain starts, optionally with -a dependency-path breadcrumb: the requested scope is shallower than the current container, but no -container at that scope exists anywhere in this chain. Each breadcrumb line may end with a pointer -to where that provider was declared (module and line number), so you can jump straight to the -declaration. The exception carries `.provider_scope`, `.container_scope` (the resolving container) -and `.root_scope` (the root of its chain). +`resolve()` or `resolve_provider()` raises it when a provider's scope is shallower than the container +you resolve from, but no container at that scope exists in the chain. The message has two forms. When +the chain skipped an intermediate scope: + +```text +modern_di.exceptions.container.ScopeSkippedError: Cannot resolve dependency chain: + REQUEST Session (myapp.providers:7) + caused by: No REQUEST-scope container exists in this chain, which runs from APP to ACTION. Add a container at scope REQUEST to the chain. +See: https://modern-di.modern-python.org/troubleshooting/scope-skipped-error/ +``` + +When the root container is deeper than the provider: + +```text +modern_di.exceptions.container.ScopeSkippedError: Cannot resolve dependency chain: + APP Settings (myapp.providers:4) + caused by: No APP-scope container exists in this chain, which starts at SESSION. Build the root container at scope APP. +See: https://modern-di.modern-python.org/troubleshooting/scope-skipped-error/ +``` + +Each chain line names a provider's scope and type and, when it can be found, the `module:line` where +its creator is defined. `.provider_scope` is the scope with no container, `.container_scope` the +container you resolved from, and `.root_scope` the root of its chain. ## Cause -The chain has no container at the provider's scope, for one of two reasons: +The chain has no container at the provider's scope, for one of three reasons: -- The chain skipped an intermediate scope when it was built. A chain built `APP → ACTION` (skipping - `SESSION` and `REQUEST`) has no `REQUEST` container to satisfy a `REQUEST`-scoped provider, even - though `REQUEST` is shallower than the current `ACTION` container. The message says - "Add a container at scope REQUEST to the chain." +- The chain skipped an intermediate scope when it was built. A chain built `APP → ACTION` has no + `REQUEST` container for a `REQUEST`-scoped provider, even though `REQUEST` is shallower than + `ACTION`. - The root container is deeper than the provider's scope. A chain whose root is a `SESSION` - container has no `APP` container, so an `APP`-scoped provider cannot resolve anywhere in it. The - message says "Build the root container at scope APP." + container has no `APP` container, so an `APP`-scoped provider cannot resolve anywhere in it. +- The provider's scope comes from a different enum than the chain's containers. A provider resolves + only in a container built at the same enum member, so a provider at `Scope.APP` does not resolve in + a container at `AppScope.APP` from your own enum. The message then names the same scope twice, as + in `No APP-scope container exists in this chain, which starts at APP.`, because both members are + named `APP`. A provider that depends on another at a same-valued member of a different enum is + reported by `validate()` as [`ScopeEnumMismatchError`](scope-enum-mismatch-error.md). ## Fix -Build child containers through every intermediate scope your providers need: +Build a child container at every scope your providers use: ```python -app_container = Container(scope=Scope.APP, groups=[MyGroup]) - -# Broken: jumps straight past REQUEST +# Broken: the chain jumps from APP straight to ACTION action_container = app_container.build_child_container(scope=Scope.ACTION) -action_container.resolve(RequestScopedThing) # raises ScopeSkippedError +action_container.resolve(Session) +``` +```python # Works: build through REQUEST first request_container = app_container.build_child_container(scope=Scope.REQUEST) action_container = request_container.build_child_container(scope=Scope.ACTION) -action_container.resolve(RequestScopedThing) +action_container.resolve(Session) ``` When the root is too deep, build the root at the provider's scope and derive the deeper containers @@ -62,21 +86,28 @@ from it: ```python # Broken: the chain starts at SESSION, so there is no APP container -session_container = Container(scope=Scope.SESSION, groups=[MyGroup]) +session_container = Container(scope=Scope.SESSION, groups=[Dependencies]) request_container = session_container.build_child_container(scope=Scope.REQUEST) -request_container.resolve(AppScopedThing) # raises ScopeSkippedError +request_container.resolve(Settings) +``` +```python # Works: the root is the APP container -app_container = Container(scope=Scope.APP, groups=[MyGroup]) +app_container = Container(scope=Scope.APP, groups=[Dependencies]) session_container = app_container.build_child_container(scope=Scope.SESSION) request_container = session_container.build_child_container(scope=Scope.REQUEST) -request_container.resolve(AppScopedThing) +request_container.resolve(Settings) ``` -If a framework integration builds the chain for you, check which scopes it actually instantiates per -request/message and align your providers to those, not to the full built-in hierarchy. +When the provider's scope comes from another enum, declare it at a member of the enum the containers +use. + +If a framework integration builds the chain for you, check which scopes it builds for each request or +message, and give your providers those scopes. ## See also -- [Scope chain violation](scope-chain.md) covers the related, statically-detected form of this problem. -- [Scopes](../providers/scopes.md) explains how container chains map to the scope hierarchy. +- [InvalidScopeDependencyError](scope-chain.md) — a provider that depends on a deeper scope, found by + `validate()`. +- [Scopes: building child containers](../providers/scopes.md#building-child-containers) — how a + container chain maps to the scope hierarchy. diff --git a/docs/troubleshooting/validation-failed-error.md b/docs/troubleshooting/validation-failed-error.md index 2beb4c48..d39501f2 100644 --- a/docs/troubleshooting/validation-failed-error.md +++ b/docs/troubleshooting/validation-failed-error.md @@ -6,41 +6,45 @@ Raised by `Container.validate()`. Its message is one line naming each kind of is followed by a link to this page. It is an `ExceptionGroup`, so a traceback shows each issue in full below that line: -``` +```text + Exception Group Traceback (most recent call last): | ... | modern_di.exceptions.container.ValidationFailedError: Container.validate() found 2 issue(s): ArgumentResolutionError (1), InvalidScopeDependencyError (1) | See: https://modern-di.modern-python.org/troubleshooting/validation-failed-error/ +-+---------------- 1 ---------------- - | modern_di.exceptions.resolution.ArgumentResolutionError: Argument missing of type cannot be resolved. Trying to build dependency . + | modern_di.exceptions.resolution.ArgumentResolutionError: Argument missing of type cannot be resolved. Trying to build dependency . | Did you mean: | - NeedsMissing (similar name, scope=APP) | See: https://modern-di.modern-python.org/troubleshooting/argument-resolution-error/ +---------------- 2 ---------------- | modern_di.exceptions.registration.InvalidScopeDependencyError: Provider at a deeper scope reached through this chain: - | APP Shallow - | REQUEST └─> Deep + | APP Shallow (myapp.providers:15) + | REQUEST └─> Deep (myapp.providers:12) | caused by: Shallow (scope APP) declares parameter 'deep' typed as a provider of Deep at deeper scope REQUEST. A provider cannot depend on a deeper-scoped provider. | See: https://modern-di.modern-python.org/troubleshooting/scope-chain/ +------------------------------------ ``` `logger.exception` prints the same tree. A plain `print(exc)` or `str(exc)` gives only the summary -line. +line and the `See:` line. ## Cause -The provider graph has one or more problems: a circular dependency, a provider depending on a -deeper-scoped one, a creator parameter with no way to be resolved, or an alias whose source type has -no registered provider. `validate()` collects every -issue across the whole graph in one pass rather than stopping at the first one, so `.exceptions` -may hold several distinct exception types at once. +The provider graph has one or more problems. `validate()` walks the whole graph in one pass and +collects every issue, so `.exceptions` may hold several kinds at once. Each entry is one of these: + +- [`CircularDependencyError`](circular-dependency.md) — a cycle in the graph. +- [`InvalidScopeDependencyError`](scope-chain.md) — a provider that depends on a deeper-scoped one. +- [`ScopeEnumMismatchError`](scope-enum-mismatch-error.md) — a dependency at a same-valued scope of + another enum. +- [`ArgumentResolutionError`](argument-resolution-error.md) — a creator parameter that nothing can + resolve. +- [`AliasSourceNotRegisteredError`](alias-source-not-registered-error.md) — an alias whose source + type has no provider. ## Fix -Inspect `.exceptions` to see every underlying issue, or read the traceback. Each issue is one of `CircularDependencyError`, `InvalidScopeDependencyError`, `ScopeEnumMismatchError`, -`ArgumentResolutionError`, or `AliasSourceNotRegisteredError` today. Fix each one; their own pages cover the specific cause and -remedy: +Read the traceback or inspect `.exceptions`, then fix each issue as its own page describes: