diff --git a/architecture/validation.md b/architecture/validation.md index bf02a46..2059f66 100644 --- a/architecture/validation.md +++ b/architecture/validation.md @@ -118,3 +118,8 @@ Two edge cases in `Alias.effective_scope` are handled safely: | `CircularDependencyError` | `ResolutionError` | recorded inside `validate()` on cycle detection | | `InvalidScopeDependencyError` | `RegistrationError` | recorded inside `validate()` on inverted scope edge | | `ArgumentResolutionError` | `ResolutionError` | yielded by `Factory.iter_validation_issues()` | + +Every concrete `ModernDIError` subclass — these three included — carries a class-level `docs_slug` +naming its page under `docs/troubleshooting/`; the census test (`tests/test_docs_slug_census.py`) +pins both that every slug is set and unique and that its page actually exists, so a new exception +cannot ship without a matching troubleshooting page. diff --git a/docs/providers/errors-and-exceptions.md b/docs/providers/errors-and-exceptions.md index 36b111e..916d3f2 100644 --- a/docs/providers/errors-and-exceptions.md +++ b/docs/providers/errors-and-exceptions.md @@ -51,27 +51,32 @@ Catch `ContainerError` for any container/scope failure. - **`InvalidChildScopeError`** — raised when `build_child_container(scope=...)` is given a scope that is not deeper than the parent's (or the constructor receives a parent at an equal/shallower - scope). The error lists the scopes that *are* allowed. + scope). The error lists the scopes that *are* allowed. See + [Troubleshooting: InvalidChildScopeError](../troubleshooting/invalid-child-scope-error.md). - **`MaxScopeReachedError`** — 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. + 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). - **`ScopeNotInitializedError`** — 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). Like `ResolutionError`, it carries a breadcrumb `dependency_path`: a runtime *captive dependency* (a shallower-scoped provider depending, directly or transitively, on this deeper-scoped one) names both the capturing provider and the one that actually - failed, not just the two scope names. See [Troubleshooting: Scope chain](../troubleshooting/scope-chain.md). + failed, not just the two scope names. See + [Troubleshooting: ScopeNotInitializedError](../troubleshooting/scope-not-initialized-error.md). - **`ScopeSkippedError`** — 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). Carries the same breadcrumb `dependency_path` as `ScopeNotInitializedError`. See - [Troubleshooting: Scope chain](../troubleshooting/scope-chain.md). + [Troubleshooting: ScopeSkippedError](../troubleshooting/scope-skipped-error.md). - **`InvalidScopeTypeError`** — raised by the `Container` constructor when `scope` is not an - `enum.IntEnum`. + `enum.IntEnum`. See + [Troubleshooting: InvalidScopeTypeError](../troubleshooting/invalid-scope-type-error.md). - **`ContainerClosedError`** — raised in modern-di **3.0** when you resolve from, or build a child of, a closed container. Until then that reuse emits a **`ContainerClosedWarning`** (a `DeprecationWarning`) and the container self-reopens — see [Lifecycle: closing and reopening](lifecycle.md#closing-and-reopening). Escalate the warning today via the [readiness recipe](../migration/to-3.x.md#readiness-recipe-escalating-warnings-to-errors-with-filterwarnings). + See [Troubleshooting: ContainerClosedError](../troubleshooting/container-closed-error.md). - **`ValidationFailedError`** — raised by `Container.validate()` (and `Container(..., validate=True)`) when the graph has problems. Catch this for validation results; its `.errors` attribute holds the list of individual issues (each itself a `ResolutionError` or `RegistrationError`), and `str()` @@ -79,7 +84,8 @@ Catch `ContainerError` for any container/scope failure. check (as before) but emits **`UnvalidatedContainerWarning`** (a `FutureWarning`) — modern-di **3.0** runs `validate()` at root construction by default; pass `validate=True` to adopt that now or `validate=False` to keep validation off permanently. See - [Migration: To 3.x](../migration/to-3.x.md). + [Migration: To 3.x](../migration/to-3.x.md) and + [Troubleshooting: ValidationFailedError](../troubleshooting/validation-failed-error.md). ## `ResolutionError` — failures while resolving a type @@ -94,17 +100,20 @@ the same `dependency_path` — the breadcrumb machinery is shared, not duplicate the type. The message includes "did you mean…" suggestions when a close match exists. See [Troubleshooting: Missing provider](../troubleshooting/missing-provider.md). - **`AliasSourceNotRegisteredError`** — raised when an `Alias` points at a `source_type` that has no - registered provider (eagerly during `validate()`, or at resolution time). + registered provider (eagerly during `validate()`, or at resolution time). See + [Troubleshooting: AliasSourceNotRegisteredError](../troubleshooting/alias-source-not-registered-error.md). - **`ArgumentResolutionError`** — raised when a creator parameter cannot be resolved: no provider matches its annotated type, or the parameter is unannotated. See - [Troubleshooting: Context not set](../troubleshooting/context-not-set.md). + [Troubleshooting: ArgumentResolutionError](../troubleshooting/argument-resolution-error.md). - **`CircularDependencyError`** — 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 — see [Troubleshooting: Circular dependency](../troubleshooting/circular-dependency.md#the-runtime-cycle-guard-without-validate). -- **`CreatorCallError`** — raised when a creator's dependencies all resolved but the creator itself - raised while being called. The original exception is preserved on `.original_error` (and as the - `__cause__`). +- **`CreatorCallError`** — 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 propagate unchanged, + never wrapped. The binding `TypeError` is preserved on `.original_error` (and as the `__cause__`). + See [Troubleshooting: CreatorCallError](../troubleshooting/creator-call-error.md). - **`ContextValueNotSetError`** — raised in modern-di **3.0** when an unset `ContextProvider` is resolved *directly* (`container.resolve(SomeContextType)` with no value set). Until then that resolve emits **`ContextValueNoneWarning`** (a `DeprecationWarning`) and returns `None` — @@ -112,7 +121,7 @@ the same `dependency_path` — the breadcrumb machinery is shared, not duplicate [readiness recipe](../migration/to-3.x.md#readiness-recipe-escalating-warnings-to-errors-with-filterwarnings). Only the direct-resolve path is affected — a `Factory` parameter backed by the same `ContextProvider` keeps following its own default/nullable/required disposition. Inspect - `.context_type`. + `.context_type`. See [Troubleshooting: Context not set](../troubleshooting/context-not-set.md). ## `RegistrationError` — declaration / registration problems @@ -125,14 +134,17 @@ Catch `RegistrationError` for declaration- and registration-time problems. 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 `.scope` for the offending child container's scope. See - [Container: registering after construction](container.md#registering-providers-after-construction). + [Container: registering after construction](container.md#registering-providers-after-construction) and + [Troubleshooting: ChildContainerRegistrationError](../troubleshooting/child-container-registration-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. + 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`** — raised when a creator's signature has a parameter - `modern-di` cannot wire (e.g. an unsupported kind); names the parameter and the reason. + `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`** — 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()`. + `validate()`. See [Troubleshooting: Scope chain](../troubleshooting/scope-chain.md). ## Direct `ModernDIError` subclasses @@ -141,13 +153,16 @@ These don't fit the register/resolve/validate grouping: - **`FinalizerError`** — 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. `.finalizer_errors` holds the list and `.is_async` records which close path ran. See - [Lifecycle](lifecycle.md#close-failure-semantics). + [Lifecycle](lifecycle.md#close-failure-semantics) and + [Troubleshooting: FinalizerError](../troubleshooting/finalizer-error.md). - **`AsyncFinalizerInSyncCloseError`** — raised when `close_sync()` reaches a cached resource whose finalizer is async. Because `close_sync()` aggregates, this arrives *wrapped inside a* `FinalizerError` (as an entry in `.finalizer_errors`), not on its own. The cache is retained so a - later `await close_async()` can finalize it. See [Lifecycle](lifecycle.md#close-failure-semantics). + 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`** — raised when a `Group` subclass is instantiated. Groups are - namespaces and must never be created as objects. + namespaces and must never be created as objects. See + [Troubleshooting: GroupInstantiationError](../troubleshooting/group-instantiation-error.md). ## Security note diff --git a/docs/troubleshooting/alias-source-not-registered-error.md b/docs/troubleshooting/alias-source-not-registered-error.md new file mode 100644 index 0000000..7fbd9dd --- /dev/null +++ b/docs/troubleshooting/alias-source-not-registered-error.md @@ -0,0 +1,36 @@ +# AliasSourceNotRegisteredError + +**Symptom** + +Raised naming the `source_type` an `Alias` points at, saying no provider is registered for it. + +**Cause** + +`Alias(source_type=X)` was declared, but no provider's `bound_type` resolves to `X` — either the +provider for `X` was never defined, its group wasn't passed to `Container(groups=[...])`, or it was +declared with `bound_type=None` (making it unresolvable by type, which an alias also can't reach). +This is checked eagerly during `validate()`, and again at resolve time if validation was skipped. + +**Fix** + +Register (and include) a provider for the source type before defining the alias: + +```python +from modern_di import Group, Scope, providers + + +class Dependencies(Group): + # The alias's source must resolve by type — no bound_type=None here. + impl = providers.Factory(scope=Scope.APP, creator=Implementation) + + interface_alias = providers.Alias(source_type=Implementation) +``` + +If the source provider lives in a different `Group`, make sure that group is also passed to +`Container(groups=[...])`. Run with `validate=True` so this is caught at startup rather than on first +resolve. + +## See also + +- [Alias](../providers/alias.md) — binding one type to an already-registered provider. +- [No provider registered for type](missing-provider.md) — the same "unregistered type" problem, without an alias in the way. diff --git a/docs/troubleshooting/argument-resolution-error.md b/docs/troubleshooting/argument-resolution-error.md new file mode 100644 index 0000000..b9ac79a --- /dev/null +++ b/docs/troubleshooting/argument-resolution-error.md @@ -0,0 +1,39 @@ +# ArgumentResolutionError + +**Symptom** + +Raised naming a creator's parameter and the type it's annotated with, saying the argument couldn't be +resolved while building a given dependency — often rendered as a dependency-chain trace with a +`caused by:` line naming the specific parameter. + +**Cause** + +A creator parameter has no registered provider for its annotated type, no default value, and no +matching `kwargs` entry — so `modern-di` has nothing to inject. This also covers an unannotated +parameter with none of those escape routes, and a `ContextProvider`-backed parameter whose context +value is unset and required (not optional, no default). + +**Fix** + +Pick whichever applies: register a provider for the missing type, give the parameter a default, or +pass it explicitly via `kwargs`: + +```python +class Dependencies(Group): + # missing: no provider for `Clock` anywhere + service = providers.Factory(scope=Scope.APP, creator=Service) # Service(clock: Clock) + + # fix option 1: register a provider + clock = providers.Factory(scope=Scope.APP, creator=SystemClock, bound_type=Clock) + + # fix option 2: pass explicitly + service2 = providers.Factory(scope=Scope.APP, creator=Service, kwargs={"clock": clock}) +``` + +Check `.suggestions` on the caught exception for a "did you mean" hint when a similarly-named type is +registered instead. + +## See also + +- [No provider registered for type](missing-provider.md) — the direct-resolve form of this same gap. +- [Factories](../providers/factories.md#creator) — how parameters are parsed and wired. diff --git a/docs/troubleshooting/async-finalizer-in-sync-close-error.md b/docs/troubleshooting/async-finalizer-in-sync-close-error.md new file mode 100644 index 0000000..b5b5dab --- /dev/null +++ b/docs/troubleshooting/async-finalizer-in-sync-close-error.md @@ -0,0 +1,38 @@ +# AsyncFinalizerInSyncCloseError + +**Symptom** + +Arrives wrapped inside a `FinalizerError` (as one entry in `.finalizer_errors`), naming the type whose +cached instance has an async finalizer. + +**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 — and, unlike a normal finalizer failure, keeps the resource's cache entry intact rather than +discarding it. + +**Fix** + +Use `close_async()` (or `async with container:`) for containers that hold any resource with an async +finalizer — it's the only path that can actually run that cleanup: + +```python +container.resolve(AsyncResource) # has an async finalizer + +try: + container.close_sync() +except exceptions.FinalizerError as exc: + # exc.finalizer_errors contains an AsyncFinalizerInSyncCloseError — cache retained, not lost + ... + +await container.close_async() # recovers: runs the async finalizer, completes cleanup +``` + +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. + +## See also + +- [Lifecycle: close-failure semantics](../providers/lifecycle.md#close-failure-semantics). diff --git a/docs/troubleshooting/child-container-registration-error.md b/docs/troubleshooting/child-container-registration-error.md new file mode 100644 index 0000000..78b2889 --- /dev/null +++ b/docs/troubleshooting/child-container-registration-error.md @@ -0,0 +1,34 @@ +# ChildContainerRegistrationError + +**Symptom** + +Raised from `Container.add_providers()`, naming the scope of the child container it was called on. + +**Cause** + +`add_providers()` was called 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 — this is disallowed rather than done +implicitly. + +**Fix** + +Call `add_providers()` on the root container instead: + +```python +app_container = Container(scope=Scope.APP, groups=[MyGroup]) +request_container = app_container.build_child_container(scope=Scope.REQUEST) + +# Wrong +request_container.add_providers(late_provider) # raises ChildContainerRegistrationError + +# Right +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`. + +## See also + +- [Container: registering providers after construction](../providers/container.md#registering-providers-after-construction). diff --git a/docs/troubleshooting/container-closed-error.md b/docs/troubleshooting/container-closed-error.md new file mode 100644 index 0000000..12d8035 --- /dev/null +++ b/docs/troubleshooting/container-closed-error.md @@ -0,0 +1,39 @@ +# ContainerClosedError + +**Symptom** + +Raised when resolving from, or building a child of, a container after it was closed. + +**Cause** + +`container.close_sync()` / `close_async()` (or exiting a `with container:` block) marks the container +closed. In modern-di **3.0**, any further `resolve()`, `resolve_provider()`, or +`build_child_container()` call on that container raises this error instead of quietly working. + +**Fix** + +Re-enter the container before reusing it: + +```python +with container: + container.resolve(Settings) +# closed here + +with container: # reopened cleanly + container.resolve(Settings) +``` + +Or call `container.open()` explicitly if a context manager doesn't fit your flow. Build a fresh +container per unit of work (e.g. one per request) instead of trying to reuse a closed one across +units of work. + +**Escape hatches** + +Today (2.x), this reuse doesn't raise yet — it emits `ContainerClosedWarning` and self-reopens the +container so the call still succeeds. Escalate that warning to an error now to catch the pattern +before upgrading, using the readiness recipe on the migration page below. + +## See also + +- [Migration: closed containers raise instead of self-healing](../migration/to-3.x.md#1-closed-containers-raise-instead-of-self-healing). +- [Lifecycle: closing and reopening](../providers/lifecycle.md#closing-and-reopening). diff --git a/docs/troubleshooting/creator-call-error.md b/docs/troubleshooting/creator-call-error.md new file mode 100644 index 0000000..e4797d8 --- /dev/null +++ b/docs/troubleshooting/creator-call-error.md @@ -0,0 +1,47 @@ +# CreatorCallError + +**Symptom** + +Raised naming the creator that could not be called and the underlying `TypeError`, with a pointer to +check `kwargs` and `skip_creator_parsing` usage. + +**Cause** + +Argument binding failed when calling the creator: the set of arguments `modern-di` assembled (static +`kwargs` plus resolved dependencies) doesn't match the creator's signature — a required argument is +missing, or an unexpected one was passed. This typically happens with `skip_creator_parsing=True` +(where every required argument must be covered by `kwargs`) or a `kwargs` dict that drifted from the +signature. This is a **wiring problem, not a bug inside your constructor** — an exception raised +inside the creator's body (even a `TypeError`) propagates unchanged as itself, never wrapped in this +error. + +**Fix** + +Make `kwargs` cover exactly what the signature requires. `.original_error` (also the `__cause__`) +holds the binding `TypeError` naming the mismatched argument: + +```python +def create_service(host: str, port: int) -> Service: ... + + +class Dependencies(Group): + # Wrong: skip_creator_parsing=True but kwargs misses `port` + service = providers.Factory( + scope=Scope.APP, creator=create_service, skip_creator_parsing=True, + bound_type=Service, kwargs={"host": "localhost"}, + ) + + # Right + service = providers.Factory( + scope=Scope.APP, creator=create_service, skip_creator_parsing=True, + bound_type=Service, kwargs={"host": "localhost", "port": 5432}, + ) +``` + +Without `skip_creator_parsing`, unknown `kwargs` keys are caught earlier, at declaration time — see +the page below. + +## See also + +- [Unknown factory kwarg](unknown-factory-kwarg-error.md) — the declaration-time form of a kwargs mismatch. +- [Factories: skip_creator_parsing](../providers/factories.md#skip_creator_parsing). diff --git a/docs/troubleshooting/finalizer-error.md b/docs/troubleshooting/finalizer-error.md new file mode 100644 index 0000000..6892f83 --- /dev/null +++ b/docs/troubleshooting/finalizer-error.md @@ -0,0 +1,38 @@ +# FinalizerError + +**Symptom** + +Raised by `close_sync()` / `close_async()`, embedding the list of finalizer exceptions that occurred +during cleanup and whether the close was sync or async. + +**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 all of them rather +than surfacing just one. + +**Fix** + +Inspect `.finalizer_errors` for the individual exceptions and fix the offending finalizer(s): + +```python +try: + container.close_sync() +except exceptions.FinalizerError as exc: + for err in exc.finalizer_errors: + print(type(err).__name__, err) +``` + +Because every finalizer still ran, a broken one doesn't leak a resource a later finalizer would have +closed — only the exceptions themselves need attention, not the cleanup order. `.is_async` tells you +whether `close_sync()` or `close_async()` produced the error. + +**Escape hatches** + +If one entry in `.finalizer_errors` is an `AsyncFinalizerInSyncCloseError`, that specific resource's +cache was retained (not lost) — calling `await container.close_async()` afterward finalizes it and +completes cleanup. + +## See also + +- [Lifecycle: close-failure semantics](../providers/lifecycle.md#close-failure-semantics). diff --git a/docs/troubleshooting/group-instantiation-error.md b/docs/troubleshooting/group-instantiation-error.md new file mode 100644 index 0000000..bed67da --- /dev/null +++ b/docs/troubleshooting/group-instantiation-error.md @@ -0,0 +1,37 @@ +# GroupInstantiationError + +**Symptom** + +Raised naming the `Group` subclass someone tried to instantiate, saying it cannot be created as an +object. + +**Cause** + +A `Group` subclass was called like a constructor (`MyGroup()`). Groups are namespaces for declaring +providers as class attributes — they're never meant to be instantiated, only passed by class +reference to `Container(groups=[MyGroup])` or read via `MyGroup.some_provider`. + +**Fix** + +Use the class itself, not an instance: + +```python +class Dependencies(Group): + service = providers.Factory(scope=Scope.APP, creator=Service) + + +# Wrong +deps = Dependencies() # raises GroupInstantiationError + +# Right +container = Container(groups=[Dependencies]) +service = container.resolve_provider(Dependencies.service) +``` + +This usually happens from a habit carried over from frameworks where a container/module *is* +instantiated, or from accidentally writing `Dependencies()` instead of `Dependencies` in a type +annotation or default value. + +## See also + +- [Multi-Group organization](../recipes/multi-group.md) — organizing providers across several `Group` classes. diff --git a/docs/troubleshooting/invalid-child-scope-error.md b/docs/troubleshooting/invalid-child-scope-error.md new file mode 100644 index 0000000..610f99b --- /dev/null +++ b/docs/troubleshooting/invalid-child-scope-error.md @@ -0,0 +1,39 @@ +# InvalidChildScopeError + +**Symptom** + +Raised from `Container(...)` or `build_child_container(scope=...)`, naming the parent scope, the +requested child scope, and the list of scopes that would have been accepted. + +**Cause** + +A child's scope must be strictly deeper (a higher `IntEnum` value) than the parent's. Passing an +explicit `scope=` that is equal to the parent's (e.g. `Scope.SESSION` from a `SESSION` parent) or +shallower (e.g. `Scope.APP` from a `SESSION` parent) raises this error. + +**Fix** + +Pass a scope whose value is strictly greater than the parent's: + +```python +from modern_di import Scope + +app_container = Container(scope=Scope.APP, groups=[MyGroup]) + +# Wrong: 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 + +# Right +good = mid.build_child_container(scope=Scope.REQUEST) +``` + +**Escape hatches** + +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. + +## See also + +- [Scopes](../providers/scopes.md#the-scope-dependency-rule) — the scope hierarchy and ordering rule. diff --git a/docs/troubleshooting/invalid-scope-type-error.md b/docs/troubleshooting/invalid-scope-type-error.md new file mode 100644 index 0000000..ecbca89 --- /dev/null +++ b/docs/troubleshooting/invalid-scope-type-error.md @@ -0,0 +1,32 @@ +# InvalidScopeTypeError + +**Symptom** + +Raised from the `Container` constructor, 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. + +**Fix** + +Use the built-in `Scope` enum, or your own `IntEnum` subclass: + +```python +from modern_di import Container, Scope + +# Wrong +container = Container(scope=1) # raises InvalidScopeTypeError +container = Container(scope="APP") # raises InvalidScopeTypeError + +# Right +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`. + +## See also + +- [Scopes](../providers/scopes.md) — the `IntEnum` hierarchy and why membership is required. diff --git a/docs/troubleshooting/max-scope-reached-error.md b/docs/troubleshooting/max-scope-reached-error.md new file mode 100644 index 0000000..e6fcb8f --- /dev/null +++ b/docs/troubleshooting/max-scope-reached-error.md @@ -0,0 +1,43 @@ +# MaxScopeReachedError + +**Symptom** + +Raised from `build_child_container()` called with no explicit `scope=` argument, naming the parent +scope that has no deeper scope to advance to. + +**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. + +**Fix** + +Define a custom `IntEnum` scope with a member deeper than `STEP` and build the child with that scope +explicitly: + +```python +import enum + +from modern_di import Scope + + +class ExtendedScope(enum.IntEnum): + APP = Scope.APP + SESSION = Scope.SESSION + REQUEST = Scope.REQUEST + ACTION = Scope.ACTION + STEP = Scope.STEP + SUBSTEP = 6 + + +step_container = Container(scope=ExtendedScope.STEP, parent_container=action_container) +sub_container = step_container.build_child_container(scope=ExtendedScope.SUBSTEP) +``` + +Root containers rarely need this — reconsider whether the provider actually needs a scope deeper than +`STEP`, or whether it belongs at an existing shallower scope instead. + +## See also + +- [Scopes](../providers/scopes.md) — the built-in hierarchy and how to extend it with a custom `IntEnum`. diff --git a/docs/troubleshooting/scope-not-initialized-error.md b/docs/troubleshooting/scope-not-initialized-error.md new file mode 100644 index 0000000..fa01df8 --- /dev/null +++ b/docs/troubleshooting/scope-not-initialized-error.md @@ -0,0 +1,37 @@ +# ScopeNotInitializedError + +**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. + +**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. + +**Fix** + +Build the deeper-scoped container before resolving from it: + +```python +app_container = Container(scope=Scope.APP, groups=[MyGroup], validate=True) + +# Wrong: no REQUEST container exists yet +app_container.resolve(RequestScopedThing) # raises ScopeNotInitializedError + +# Right +request_container = app_container.build_child_container(scope=Scope.REQUEST) +request_container.resolve(RequestScopedThing) +``` + +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`. + +## See also + +- [Scope chain violation](scope-chain.md) — the related, statically-detected form of this problem. +- [Scopes: the scope dependency rule](../providers/scopes.md#the-scope-dependency-rule). diff --git a/docs/troubleshooting/scope-skipped-error.md b/docs/troubleshooting/scope-skipped-error.md new file mode 100644 index 0000000..74eae79 --- /dev/null +++ b/docs/troubleshooting/scope-skipped-error.md @@ -0,0 +1,39 @@ +# ScopeSkippedError + +**Symptom** + +A resolution fails naming a provider's scope and the current container's scope, 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. + +**Cause** + +The container chain skipped an intermediate scope when it was built. For example, a chain built +`APP → ACTION` (skipping `SESSION` and `REQUEST` entirely) has no `REQUEST` container to satisfy a +`REQUEST`-scoped provider, even though `REQUEST` is shallower than the current `ACTION` container. + +**Fix** + +Build child containers through every intermediate scope your providers need, rather than jumping +straight to a deep one: + +```python +app_container = Container(scope=Scope.APP, groups=[MyGroup], validate=True) + +# Wrong: jumps straight past REQUEST +action_container = app_container.build_child_container(scope=Scope.ACTION) +action_container.resolve(RequestScopedThing) # raises ScopeSkippedError + +# Right: 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) +``` + +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. + +## See also + +- [Scope chain violation](scope-chain.md) — the related, statically-detected form of this problem. +- [Scopes](../providers/scopes.md) — how container chains map to the scope hierarchy. diff --git a/docs/troubleshooting/unknown-factory-kwarg-error.md b/docs/troubleshooting/unknown-factory-kwarg-error.md new file mode 100644 index 0000000..73fac39 --- /dev/null +++ b/docs/troubleshooting/unknown-factory-kwarg-error.md @@ -0,0 +1,40 @@ +# UnknownFactoryKwargError + +**Symptom** + +Raised at `Factory(...)` declaration time, listing the `kwargs` key(s) that don't match the creator's +signature, the known parameter names, and a "did you mean" suggestion when a close match exists. + +**Cause** + +A key in `kwargs={...}` doesn't correspond to any parameter of the creator — usually a typo, or a key +left over after the creator's signature was renamed/refactored. The creator has no `**kwargs` +catch-all, so `modern-di` can validate the keys eagerly at declaration time rather than only failing +at call time. + +**Fix** + +Match the `kwargs` keys to the creator's actual parameter names: + +```python +def create_service(connection_string: str) -> Service: ... + + +class Dependencies(Group): + # Wrong: typo — raises UnknownFactoryKwargError, suggests "connection_string" + service = providers.Factory( + scope=Scope.APP, creator=create_service, kwargs={"conection_string": "..."} + ) + + # Right + service = providers.Factory( + scope=Scope.APP, creator=create_service, kwargs={"connection_string": "..."} + ) +``` + +If the creator genuinely accepts arbitrary keyword arguments (`**kwargs` in its signature), this check +is skipped automatically — no escape hatch needed. + +## See also + +- [Factories: kwargs](../providers/factories.md#kwargs). diff --git a/docs/troubleshooting/unsupported-creator-parameter-error.md b/docs/troubleshooting/unsupported-creator-parameter-error.md new file mode 100644 index 0000000..d82c48a --- /dev/null +++ b/docs/troubleshooting/unsupported-creator-parameter-error.md @@ -0,0 +1,43 @@ +# UnsupportedCreatorParameterError + +**Symptom** + +Raised at `Factory(...)` declaration time, naming the creator, the parameter, and the reason it can't +be wired automatically. + +**Cause** + +The creator has a parameter shape `modern-di` cannot resolve by type: a positional-only parameter with +no default (`def f(x, /)`), or a parameterized generic annotation (`list[X]`, `dict[str, Y]`, etc.) +with no default and no matching `kwargs` entry. Both are declaration-time checks, not resolve-time +ones. + +**Fix** + +Pick one of three escape routes, in order of preference: + +```python +def create_thing(items: list[Item], /) -> Thing: ... + + +class Dependencies(Group): + # 1. Give the parameter a default + # def create_thing(items: list[Item] = ()) -> Thing: ... + + # 2. Supply the value via kwargs at declaration time + thing = providers.Factory(scope=Scope.APP, creator=create_thing, kwargs={"items": []}) + + # 3. Skip creator parsing entirely and supply every argument via kwargs + thing2 = providers.Factory( + scope=Scope.APP, creator=create_thing, skip_creator_parsing=True, kwargs={"items": []} + ) +``` + +**Escape hatches** + +`skip_creator_parsing=True` bypasses signature parsing altogether (option 3 above) — use it when a +creator has several unsupported parameter shapes rather than fixing each one individually. + +## See also + +- [Factories: creator-signature support matrix](../providers/factories.md#creator-signature-support-matrix). diff --git a/docs/troubleshooting/validation-failed-error.md b/docs/troubleshooting/validation-failed-error.md new file mode 100644 index 0000000..ebd4971 --- /dev/null +++ b/docs/troubleshooting/validation-failed-error.md @@ -0,0 +1,38 @@ +# ValidationFailedError + +**Symptom** + +Raised by `Container.validate()` (or `Container(..., validate=True)`), rendering a report grouped by +error class name, with the count of each kind and every individual issue indented underneath. + +**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 `.errors` (a +`list[Exception]`) may hold several distinct exception types at once. + +**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 +remedy: + +```python +try: + container.validate() +except exceptions.ValidationFailedError as exc: + for error in exc.errors: + print(type(error).__name__, error) +``` + +Running `validate()` (or passing `validate=True`) at startup, before the first real request, is the +whole point — it turns graph bugs into a single startup-time failure instead of scattered runtime +surprises. + +## See also + +- [Lifecycle: validation](../providers/lifecycle.md#validation). +- [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. diff --git a/mkdocs.yml b/mkdocs.yml index d6b9dfb..33ff20c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -37,11 +37,27 @@ nav: - Request-scoped engine selection: recipes/request-scoped-engine.md - Good and bad practices: recipes/good-and-bad-practices.md - Troubleshooting: + - Invalid Child Scope: troubleshooting/invalid-child-scope-error.md + - Max Scope Reached: troubleshooting/max-scope-reached-error.md + - Scope Not Initialized: troubleshooting/scope-not-initialized-error.md + - Scope Skipped: troubleshooting/scope-skipped-error.md + - Invalid Scope Type: troubleshooting/invalid-scope-type-error.md + - Container Closed: troubleshooting/container-closed-error.md + - Validation Failed: troubleshooting/validation-failed-error.md + - Missing Provider For Type: troubleshooting/missing-provider.md + - Alias Source Not Registered: troubleshooting/alias-source-not-registered-error.md + - Argument Resolution Error: troubleshooting/argument-resolution-error.md - Circular Dependency: troubleshooting/circular-dependency.md + - Creator Call Error: troubleshooting/creator-call-error.md + - 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 + - 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 - - Missing Provider For Type: troubleshooting/missing-provider.md - - ContextProvider Has No Value: troubleshooting/context-not-set.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 - Migration: - To 1.x: migration/to-1.x.md - To 2.x: migration/to-2.x.md @@ -157,11 +173,27 @@ plugins: - recipes/request-scoped-engine.md: Routing requests to different engines by scope - recipes/good-and-bad-practices.md: Common DI footguns and how modern-di catches them Troubleshooting: + - troubleshooting/invalid-child-scope-error.md: Diagnosing InvalidChildScopeError + - troubleshooting/max-scope-reached-error.md: Diagnosing MaxScopeReachedError + - troubleshooting/scope-not-initialized-error.md: Diagnosing ScopeNotInitializedError + - troubleshooting/scope-skipped-error.md: Diagnosing ScopeSkippedError + - troubleshooting/invalid-scope-type-error.md: Diagnosing InvalidScopeTypeError + - troubleshooting/container-closed-error.md: Diagnosing ContainerClosedError + - troubleshooting/validation-failed-error.md: Diagnosing ValidationFailedError + - troubleshooting/missing-provider.md: Diagnosing missing provider registration errors + - troubleshooting/alias-source-not-registered-error.md: Diagnosing AliasSourceNotRegisteredError + - troubleshooting/argument-resolution-error.md: Diagnosing ArgumentResolutionError - troubleshooting/circular-dependency.md: Diagnosing circular dependency errors + - troubleshooting/creator-call-error.md: Diagnosing CreatorCallError + - 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/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/missing-provider.md: Diagnosing missing provider registration errors - - troubleshooting/context-not-set.md: Diagnosing ContextProvider-has-no-value errors + - troubleshooting/finalizer-error.md: Diagnosing FinalizerError + - troubleshooting/async-finalizer-in-sync-close-error.md: Diagnosing AsyncFinalizerInSyncCloseError + - troubleshooting/group-instantiation-error.md: Diagnosing GroupInstantiationError Migration: - migration/to-1.x.md: Migrating from modern-di 0.x to 1.x - migration/to-2.x.md: Migrating from modern-di 1.x to 2.x diff --git a/modern_di/container.py b/modern_di/container.py index 7a9a344..fa3ade3 100644 --- a/modern_di/container.py +++ b/modern_di/container.py @@ -150,7 +150,8 @@ def __init__( # noqa: PLR0913 "This root container was created without an explicit `validate` argument. " "modern-di 3.0 runs validate() at root construction by default. Pass validate=True " "to adopt the 3.0 behavior now, or validate=False to keep validation off. " - "See https://modern-di.modern-python.org/migration/to-3.x/.", + "See: https://modern-di.modern-python.org/migration/to-3.x/" + "#4-validate-runs-by-default-at-root-construction", exceptions.UnvalidatedContainerWarning, stacklevel=2, ) @@ -382,7 +383,9 @@ def _warn_and_reopen_if_closed(self) -> None: warnings.warn( f"Container (scope {self.scope.name}) is closed; resolving from it or building a child " "is deprecated and will raise ContainerClosedError in modern-di 3.0. Re-enter the " - "container with `with`/`async with`, or call `open()`, before reusing it.", + "container with `with`/`async with`, or call `open()`, before reusing it. " + "See: https://modern-di.modern-python.org/migration/to-3.x/" + "#1-closed-containers-raise-instead-of-self-healing", exceptions.ContainerClosedWarning, stacklevel=2, ) diff --git a/modern_di/exceptions.py b/modern_di/exceptions.py index b6960af..c3dc8b4 100644 --- a/modern_di/exceptions.py +++ b/modern_di/exceptions.py @@ -4,6 +4,7 @@ SUGGESTION_HEADER = "Did you mean:" +_TROUBLESHOOTING_BASE_URL = "https://modern-di.modern-python.org/troubleshooting" if typing.TYPE_CHECKING: @@ -25,18 +26,38 @@ class ResolutionStep: class ModernDIError(RuntimeError): - """Base class for all modern-di errors. Inherits from RuntimeError for backwards compatibility.""" + """Base class for all modern-di errors. Inherits from RuntimeError for backwards compatibility. + + ``docs_slug`` names this class's page under ``docs/troubleshooting/``; ``__str__`` appends it + as a trailing ``See: `` line, always the final line of the rendered message. Base classes + (this one, :class:`ContainerError`, :class:`ResolutionError`, :class:`RegistrationError`) are + never raised directly and keep ``docs_slug`` unset; every concrete subclass sets one (enforced + by the census test in ``tests/test_docs_slug_census.py``). + """ + + docs_slug: typing.ClassVar[str | None] = None __slots__ = () + def _render_body(self) -> str: + """Message body without the docs trailer. Subclasses with custom rendering override this, not `__str__`.""" + return RuntimeError.__str__(self) + + def __str__(self) -> str: + body = self._render_body() + if self.docs_slug is None: + return body + return f"{body}\nSee: {_TROUBLESHOOTING_BASE_URL}/{self.docs_slug}/" + class DependencyPathMixin: """Breadcrumb machinery shared by :class:`ResolutionError` and the runtime scope errors. - Owns `prepend_step` and the chain-rendering `__str__`, so any error raised inside a - resolution frame can accumulate the chain of provider names as it propagates back up to the - caller. With an empty `dependency_path` (the error never passed through a resolution frame) - `__str__` returns the base message unchanged. + Owns `prepend_step` and the chain-rendering `_render_body` (the body `ModernDIError.__str__` + appends the docs trailer to), so any error raised inside a resolution frame can accumulate the + chain of provider names as it propagates back up to the caller. With an empty `dependency_path` + (the error never passed through a resolution frame) `_render_body` returns the base message + unchanged. Empty `__slots__`: each concrete error declares the `_base_message`/`dependency_path` slots itself, avoiding the `TypeError: multiple bases have instance lay-out conflict` that a slotted @@ -56,7 +77,7 @@ def prepend_step(self, step: ResolutionStep) -> None: self.dependency_path.insert(0, step) self.args = (str(self),) - def __str__(self) -> str: + def _render_body(self) -> str: if not self.dependency_path: return self._base_message @@ -78,6 +99,8 @@ class ContainerError(ModernDIError): class InvalidChildScopeError(ContainerError): """Child scope is not deeper than the parent. Inspect ``.parent_scope``, ``.child_scope``, ``.allowed_scopes``.""" + docs_slug = "invalid-child-scope-error" + __slots__ = ("allowed_scopes", "child_scope", "parent_scope") def __init__(self, *, parent_scope: enum.IntEnum, child_scope: enum.IntEnum, allowed_scopes: list[str]) -> None: @@ -94,6 +117,8 @@ def __init__(self, *, parent_scope: enum.IntEnum, child_scope: enum.IntEnum, all class MaxScopeReachedError(ContainerError): """No scope deeper than ``.parent_scope`` exists, so no child scope can be auto-derived.""" + docs_slug = "max-scope-reached-error" + __slots__ = ("parent_scope",) def __init__(self, *, parent_scope: enum.IntEnum) -> None: @@ -111,6 +136,8 @@ class ScopeNotInitializedError(DependencyPathMixin, ContainerError): runtime dependency names both the failing provider and the one that captured it. """ + docs_slug = "scope-not-initialized-error" + __slots__ = ("_base_message", "container_scope", "dependency_path", "provider_scope") def __init__(self, *, provider_scope: enum.IntEnum, container_scope: enum.IntEnum) -> None: @@ -128,6 +155,8 @@ class ScopeSkippedError(DependencyPathMixin, ContainerError): runtime dependency names both the failing provider and the one that captured it. """ + docs_slug = "scope-skipped-error" + __slots__ = ("_base_message", "container_scope", "dependency_path", "provider_scope") def __init__(self, *, provider_scope: enum.IntEnum, container_scope: enum.IntEnum) -> None: @@ -143,6 +172,8 @@ def __init__(self, *, provider_scope: enum.IntEnum, container_scope: enum.IntEnu class InvalidScopeTypeError(ContainerError): """A non-``IntEnum`` value was passed as a scope. Inspect ``.scope_value``.""" + docs_slug = "invalid-scope-type-error" + __slots__ = ("scope_value",) def __init__(self, *, scope_value: typing.Any) -> None: # noqa: ANN401 @@ -159,6 +190,8 @@ class ContainerClosedError(ContainerError): and self-reopens. """ + docs_slug = "container-closed-error" + __slots__ = ("container_scope",) def __init__(self, *, container_scope: enum.IntEnum) -> None: @@ -207,6 +240,8 @@ class ResolutionError(DependencyPathMixin, ModernDIError): class ProviderNotRegisteredError(ResolutionError): """No provider registered for the requested type. Inspect ``.provider_type`` and ``.suggestions``.""" + docs_slug = "missing-provider" + __slots__ = ("provider_type", "suggestions") def __init__( @@ -226,6 +261,8 @@ def __init__( class AliasSourceNotRegisteredError(ResolutionError): """An ``Alias`` points at a ``.source_type`` that has no registered provider.""" + docs_slug = "alias-source-not-registered-error" + __slots__ = ("source_type",) def __init__(self, *, source_type: type) -> None: @@ -239,6 +276,8 @@ def __init__(self, *, source_type: type) -> None: class ArgumentResolutionError(ResolutionError): """Creator parameter could not be wired. Attrs: ``arg_name``, ``arg_type``, ``bound_type``, ``suggestions``.""" + docs_slug = "argument-resolution-error" + __slots__ = ("arg_name", "arg_type", "bound_type", "suggestions") def __init__( @@ -274,7 +313,9 @@ def __init__( class CreatorCallError(ResolutionError): - """A creator's dependencies resolved but the creator itself raised. Inspect ``.creator`` and ``.original_error``.""" + """Argument binding failed when calling the creator (kwargs mismatch). Inspect ``.creator``, ``.original_error``.""" + + docs_slug = "creator-call-error" __slots__ = ("creator", "original_error") @@ -294,6 +335,8 @@ class CircularDependencyError(ResolutionError): ``__cause__`` carries the original ``RecursionError``. """ + docs_slug = "circular-dependency" + __slots__ = ("cycle_path",) def __init__(self, *, cycle_path: list[str]) -> None: @@ -312,6 +355,8 @@ class ContextValueNotSetError(ResolutionError): ``.context_type``. """ + docs_slug = "context-not-set" + __slots__ = ("context_type",) def __init__(self, *, context_type: type, scope_name: str) -> None: @@ -342,6 +387,8 @@ class RegistrationError(ModernDIError): class DuplicateProviderTypeError(RegistrationError): """Two providers were registered for the same ``.provider_type``.""" + docs_slug = "duplicate-type-error" + __slots__ = ("provider_type",) def __init__(self, *, provider_type: type) -> None: @@ -350,14 +397,15 @@ def __init__(self, *, provider_type: type) -> None: f"Provider is duplicated by type {provider_type}. " "To resolve this issue:\n" "1. Set bound_type=None on one of the providers to make it unresolvable by type\n" - "2. Explicitly pass dependencies via the kwargs parameter to avoid automatic resolution\n" - "See https://modern-di.modern-python.org/troubleshooting/duplicate-type-error/ for more details" + "2. Explicitly pass dependencies via the kwargs parameter to avoid automatic resolution" ) class ChildContainerRegistrationError(RegistrationError): """``add_providers`` was called on a child container; registration is root-only. Inspect ``.scope``.""" + docs_slug = "child-container-registration-error" + __slots__ = ("scope",) def __init__(self, *, scope: enum.IntEnum) -> None: @@ -372,6 +420,8 @@ def __init__(self, *, scope: enum.IntEnum) -> None: class UnknownFactoryKwargError(RegistrationError): """Factory kwargs had unknown keys. Attrs: ``creator``, ``unknown_keys``, ``known_keys``, ``suggestions``.""" + docs_slug = "unknown-factory-kwarg-error" + __slots__ = ("creator", "known_keys", "suggestions", "unknown_keys") def __init__( @@ -401,6 +451,8 @@ def __init__( class UnsupportedCreatorParameterError(RegistrationError): """A creator parameter cannot be wired by type. Inspect ``.creator``, ``.parameter_name``, ``.reason``.""" + docs_slug = "unsupported-creator-parameter-error" + __slots__ = ("creator", "parameter_name", "reason") def __init__(self, *, creator: typing.Any, parameter_name: str, reason: str) -> None: # noqa: ANN401 @@ -414,6 +466,8 @@ def __init__(self, *, creator: typing.Any, parameter_name: str, reason: str) -> class InvalidScopeDependencyError(RegistrationError): """A provider depends on a deeper-scoped one. Inspect ``.provider``, ``.parameter_name``, ``.dep_provider``.""" + docs_slug = "scope-chain" + __slots__ = ("dep_provider", "parameter_name", "provider") def __init__( @@ -437,7 +491,15 @@ def __init__( class ValidationFailedError(ContainerError): - """``validate()`` found one or more issues. Inspect ``.errors`` (the list of underlying exceptions).""" + """``validate()`` found one or more issues. Inspect ``.errors`` (the list of underlying exceptions). + + Sub-errors render trailer-free inside the grouped report below (see ``_render_body``) — only + this error's own docs trailer appears, as the report's final line. Repeating each sub-error's + "See: ..." line would be noise (the same URL once per error of a given kind) and would break + the "one trailer, always last line" rule. + """ + + docs_slug = "validation-failed-error" __slots__ = ("errors",) @@ -446,8 +508,8 @@ def __init__(self, *, errors: list[Exception]) -> None: kinds = ", ".join(sorted({type(e).__name__ for e in errors})) super().__init__(f"Container.validate() found {len(errors)} issue(s): {kinds}") - def __str__(self) -> str: - lines = [super().__str__()] + def _render_body(self) -> str: + lines = [RuntimeError.__str__(self)] by_kind: dict[str, list[Exception]] = {} for error in self.errors: by_kind.setdefault(type(error).__name__, []).append(error) @@ -455,7 +517,8 @@ def __str__(self) -> str: errors = by_kind[kind] lines.append(f"\n{kind} ({len(errors)}):") for error in errors: - first, *rest = str(error).splitlines() or [""] + rendered = error._render_body() if isinstance(error, ModernDIError) else str(error) # noqa: SLF001 + first, *rest = rendered.splitlines() or [""] lines.append(f" - {first}".rstrip()) lines.extend(f" {line}" for line in rest) return "\n".join(lines) @@ -464,6 +527,8 @@ def __str__(self) -> str: class FinalizerError(ModernDIError): """One or more finalizers raised during close. Inspect ``.finalizer_errors`` and ``.is_async``.""" + docs_slug = "finalizer-error" + __slots__ = ("finalizer_errors", "is_async") def __init__(self, *, finalizer_errors: list[BaseException], is_async: bool) -> None: @@ -476,6 +541,8 @@ def __init__(self, *, finalizer_errors: list[BaseException], is_async: bool) -> class AsyncFinalizerInSyncCloseError(ModernDIError): """Raised when ``close_sync`` encounters a cached resource with an async finalizer.""" + docs_slug = "async-finalizer-in-sync-close-error" + __slots__ = ("finalizer_type",) def __init__(self, *, finalizer_type: type) -> None: @@ -489,6 +556,8 @@ def __init__(self, *, finalizer_type: type) -> None: class GroupInstantiationError(ModernDIError): """A ``Group`` subclass was instantiated. Inspect ``.group_name``; groups are namespaces, never objects.""" + docs_slug = "group-instantiation-error" + __slots__ = ("group_name",) def __init__(self, *, group_name: str) -> None: diff --git a/modern_di/providers/context_provider.py b/modern_di/providers/context_provider.py index 3b2e9d9..2329c49 100644 --- a/modern_di/providers/context_provider.py +++ b/modern_di/providers/context_provider.py @@ -44,7 +44,8 @@ def resolve(self, container: "Container") -> types.T_co | None: warnings.warn( f"No context value is set for {self.context_type!r} (scope {self.scope.name}); returning None. " "modern-di 3.0 raises ContextValueNotSetError here. Pass context={...} to the container or call " - "set_context(). See https://modern-di.modern-python.org/migration/to-3.x/.", + "set_context(). See: https://modern-di.modern-python.org/migration/to-3.x/" + "#5-direct-resolve-of-an-unset-contextprovider-raises", exceptions.ContextValueNoneWarning, stacklevel=2, ) diff --git a/planning/changes/2026-07-07.06-error-docs-registry.md b/planning/changes/2026-07-07.06-error-docs-registry.md new file mode 100644 index 0000000..7af35c6 --- /dev/null +++ b/planning/changes/2026-07-07.06-error-docs-registry.md @@ -0,0 +1,98 @@ +--- +summary: ERR-4 + DOC-6 — every concrete exception carries a stable troubleshooting URL in its message (class-level docs_slug, uniform "See:" trailer); 16 new troubleshooting pages complete the registry; warnings link the to-3.x guide. +--- + +# Design: Error-docs registry — stable per-exception URLs + +## Summary + +The accepted ERR-4/DOC-6 workstream from the 3.0 UX research. Mechanism half: +`ModernDIError` gains a class-level `docs_slug`; `__str__` appends a uniform +trailing `See: https://modern-di.modern-python.org/troubleshooting//` +line. Docs half: the troubleshooting registry grows from 5 pages to one page +per concrete exception (21 classes), each from a fixed template. The three +transitional warnings append a link to their `to-3.x` guide section instead +(ruled). Message text changes; no hierarchy, attribute, or raise-site +behavior changes. + +## Motivation + +Research items 18–19 (verified): remediation is ad hoc — of ~20 exception +classes, only `DuplicateProviderTypeError` carries a docs URL; a user holding +a production traceback has no stable link to follow. Precedents: Angular's +NGxxxx registry, Spring's Description/Action reports, wireup's remedy+URL +messages. The in-repo pattern already exists (that one URL) — this completes +it systematically. + +## Design + +### Mechanism (ERR-4) + +- `ModernDIError.docs_slug: typing.ClassVar[str | None] = None`; concrete + classes set their slug. Base classes (`ModernDIError`, `ContainerError`, + `ResolutionError`, `RegistrationError`) keep `None` — never raised + directly, no pages. +- `ModernDIError.__str__` appends `\nSee: https://modern-di.modern-python.org/troubleshooting//` + when the slug is set. `ResolutionError`'s dependency-path `__str__` + override composes: path first, trailer last (one trailer, always last + line). `DuplicateProviderTypeError`'s hand-rolled inline URL is replaced + by the mechanism (same target URL — its existing page keeps its slug). +- Slugs: the 5 existing pages keep their filenames as slugs + (`circular-dependency`, `duplicate-type-error`, `missing-provider`, + `context-not-set`, `scope-chain`); the 16 new classes get kebab-cased + class-derived slugs (e.g. `creator-call-error` — exact list settled in + the census test). +- **Completeness gate:** a test walks `modern_di.exceptions`, finds every + concrete `ModernDIError` subclass, and asserts (a) `docs_slug` is set and + unique, (b) `docs/troubleshooting/.md` exists in the repo. New + exceptions cannot ship without a page. +- Warnings (ruled): the three transitional warnings are stdlib warning + categories, not `ModernDIError`s — their `warnings.warn(...)` message + strings gain a trailing `See: https://modern-di.modern-python.org/migration/to-3.x/` + (section-anchored where a stable anchor exists). + +### Registry (DOC-6) + +- 16 new pages under `docs/troubleshooting/`, fixed template: **Symptom** + (the message shape — paraphrased, not verbatim-quoted, per the no-verbatim + rot rule), **Cause**, **Fix** (actionable without leaving the page), + **Escape hatches** where real, and a link to the canonical concept page + (dedupe discipline: no re-explanations). Target ≤250 words each. +- Classes: InvalidChildScopeError, MaxScopeReachedError, + ScopeNotInitializedError, ScopeSkippedError, InvalidScopeTypeError, + ContainerClosedError, AliasSourceNotRegisteredError, + ArgumentResolutionError, CreatorCallError, ContextValueNotSetError has a + page (`context-not-set`) — confirm mapping; ChildContainerRegistrationError, + UnknownFactoryKwargError, UnsupportedCreatorParameterError, + ValidationFailedError, FinalizerError, AsyncFinalizerInSyncCloseError, + GroupInstantiationError. (Implementer reconciles the exact 16 against the + 5 existing mappings; the census test is the arbiter.) +- `mkdocs.yml`: nav (Troubleshooting section, alphabetical or + hierarchy-ordered to match `errors-and-exceptions.md`) + `llmstxt` + sections entries per new page. +- `docs/providers/errors-and-exceptions.md`: each catalog entry links its + troubleshooting page (entries stay one-paragraph; the page is the deep + dive). + +## Non-goals + +- No message-wording changes beyond the appended trailer. +- No sibling-repo changes (their tests asserting exact messages may need + `match=` updates when they bump — noted in release notes, as 2.24.0 did). +- No hint/Action text inside messages — the Action slot lives in docs. + +## Testing + +TDD; `just test-ci` 100% coverage; the census/completeness test as above; +trailer-composition tests (plain error, dependency-path error, the three +warnings); existing exact-message assertions updated (`match=` substrings +preferred); `just docs-build` --strict green (validates every slug URL has a +page via the nav); `just lint-ci` green. + +## Risk + +- Exact-message assertions downstream break on the trailer — mitigated: the + trailer is a separate final line (substring matches survive), release + notes will carry the 2.24.0-style note. +- Page rot vs message drift — mitigated by the no-verbatim-quote template + rule and the completeness test pinning slug↔page existence. diff --git a/tests/providers/test_context_provider.py b/tests/providers/test_context_provider.py index 4386470..b30e4eb 100644 --- a/tests/providers/test_context_provider.py +++ b/tests/providers/test_context_provider.py @@ -286,6 +286,17 @@ def test_unset_context_provider_direct_resolve_warns_and_returns_none() -> None: assert app_container.resolve_provider(MyGroup.context_provider) is None +def test_unset_context_provider_warning_links_migration_guide_anchor() -> None: + # The trailer names the section covering this specific switch, not just the guide's landing page. + app_container = Container(groups=[MyGroup], validate=False) + with pytest.warns( + ContextValueNoneWarning, + match=r"See: https://modern-di\.modern-python\.org/migration/to-3\.x/" + r"#5-direct-resolve-of-an-unset-contextprovider-raises$", + ): + app_container.resolve_provider(MyGroup.context_provider) + + def test_set_context_provider_direct_resolve_does_not_warn() -> None: now = datetime.datetime.now(tz=datetime.timezone.utc) app_container = Container(groups=[MyGroup], context={datetime.datetime: now}, validate=False) diff --git a/tests/test_container.py b/tests/test_container.py index 82353fc..cd08c24 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -368,6 +368,17 @@ def test_constructor_rejects_parent_with_non_increasing_scope() -> None: Container(scope=Scope.APP, parent_container=request) +def test_container_closed_warning_links_migration_guide_anchor() -> None: + container = Container(scope=Scope.APP) + container.close_sync() + with pytest.warns( + ContainerClosedWarning, + match=r"See: https://modern-di\.modern-python\.org/migration/to-3\.x/" + r"#1-closed-containers-raise-instead-of-self-healing$", + ): + container.resolve(Container) + + def test_closed_container_warns_and_reopens_on_resolve_and_child_building() -> None: container = Container(scope=Scope.APP) container.close_sync() @@ -532,6 +543,15 @@ def test_root_container_without_validate_arg_warns_about_3_0_default() -> None: Container(scope=Scope.APP) +def test_unvalidated_container_warning_links_migration_guide_anchor() -> None: + with pytest.warns( + exceptions.UnvalidatedContainerWarning, + match=r"See: https://modern-di\.modern-python\.org/migration/to-3\.x/" + r"#4-validate-runs-by-default-at-root-construction$", + ): + Container(scope=Scope.APP) + + def test_explicit_validate_false_never_warns() -> None: with warnings.catch_warnings(): warnings.simplefilter("error") diff --git a/tests/test_dependency_path.py b/tests/test_dependency_path.py index b752211..4b88e0d 100644 --- a/tests/test_dependency_path.py +++ b/tests/test_dependency_path.py @@ -49,6 +49,8 @@ def test_chain_appears_when_arg_unresolvable() -> None: assert "MyService" in rendered assert "└─> Repository" in rendered assert "caused by: Argument db" in rendered + # The trailer is always the final line, even when the message is the path-block rendering. + assert rendered.endswith("See: https://modern-di.modern-python.org/troubleshooting/argument-resolution-error/") def test_no_chain_when_top_level_provider_missing() -> None: @@ -100,7 +102,10 @@ def test_scope_error_at_direct_container_call_has_empty_path() -> None: exc = exc_info.value assert exc.dependency_path == [] - assert str(exc) == "Provider of scope REQUEST cannot be resolved in container of scope APP." + assert str(exc) == ( + "Provider of scope REQUEST cannot be resolved in container of scope APP.\n" + "See: https://modern-di.modern-python.org/troubleshooting/scope-not-initialized-error/" + ) def test_captive_dependency_names_both_ends() -> None: diff --git a/tests/test_docs_slug_census.py b/tests/test_docs_slug_census.py new file mode 100644 index 0000000..e25575c --- /dev/null +++ b/tests/test_docs_slug_census.py @@ -0,0 +1,68 @@ +"""Census of docs_slug coverage across modern_di.exceptions (ERR-4/DOC-6). + +Every concrete `ModernDIError` subclass sets a unique `docs_slug`, and every slug has a +matching page under `docs/troubleshooting/.md` — the full completeness gate. See +planning/changes/2026-07-07.06-error-docs-registry.md. +""" + +import pathlib + +from modern_di import exceptions + + +_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 + +_BASE_CLASSES = frozenset( + { + exceptions.ModernDIError, + exceptions.ContainerError, + exceptions.ResolutionError, + exceptions.RegistrationError, + } +) + + +def _concrete_error_classes() -> list[type[exceptions.ModernDIError]]: + # Walking modern_di/exceptions.py covers every error class by repo convention: all of them live there. + return [ + obj + for obj in vars(exceptions).values() + if isinstance(obj, type) and issubclass(obj, exceptions.ModernDIError) and obj not in _BASE_CLASSES + ] + + +def test_every_concrete_error_has_a_unique_docs_slug() -> None: + classes = _concrete_error_classes() + assert classes, "the walk over modern_di.exceptions found no concrete ModernDIError subclasses" + + missing = sorted(cls.__name__ for cls in classes if not cls.docs_slug) + assert not missing, f"classes missing docs_slug: {missing}" + + slugs = [cls.docs_slug for cls in classes] + assert len(slugs) == len(set(slugs)), "docs_slug values must be unique across all concrete error classes" + + +def test_every_docs_slug_has_a_troubleshooting_page() -> None: + missing = sorted( + cls.docs_slug + for cls in _concrete_error_classes() + if cls.docs_slug and not (_TROUBLESHOOTING_DIR / f"{cls.docs_slug}.md").exists() + ) + assert not missing, f"docs_slug values with no matching docs/troubleshooting/.md page: {missing}" + + +def test_base_classes_have_no_docs_slug() -> None: + # Base classes are never raised directly and never get a troubleshooting page. + for base in _BASE_CLASSES: + assert base.docs_slug is None + + +def test_census_pins_the_concrete_class_count() -> None: + # Guards against a class silently falling out of (or into) the walk above — if this count + # changes, a class was added/removed and the slug table (and, eventually, its page) must + # follow in the same change. + assert len(_concrete_error_classes()) == _EXPECTED_CONCRETE_CLASS_COUNT diff --git a/tests/test_error_rendering.py b/tests/test_error_rendering.py index 6b511ef..249d6d7 100644 --- a/tests/test_error_rendering.py +++ b/tests/test_error_rendering.py @@ -1,15 +1,29 @@ from modern_di import exceptions +def test_error_without_docs_slug_renders_body_unchanged() -> None: + # Base classes (ModernDIError itself, ContainerError, ResolutionError, RegistrationError) + # keep docs_slug unset and are never raised directly, so __str__ must not append a trailer. + error = exceptions.ModernDIError("boom") + assert error.docs_slug is None + assert str(error) == "boom" + + def test_circular_dependency_error_renders_cycle_as_arrow_chain() -> None: error = exceptions.CircularDependencyError(cycle_path=["A", "B", "A"]) assert error.cycle_path == ["A", "B", "A"] assert str(error) == ( - "Circular dependency detected:\n A\n └─> B\n └─> A\nCheck your provider graph for unintended cycles." + "Circular dependency detected:\n A\n └─> B\n └─> A\nCheck your provider graph for unintended cycles.\n" + "See: https://modern-di.modern-python.org/troubleshooting/circular-dependency/" ) def test_validation_failed_error_groups_by_kind_and_indents_multiline() -> None: + # Sub-errors (here CircularDependencyError, which carries its own docs_slug) render + # trailer-free inside the grouped report — only the outer ValidationFailedError report + # carries a trailer, and it is the report's own final line. Repeating each sub-error's + # "See: ..." line would be noise (same URL N times for N errors of one kind) and would + # break "one trailer, always last line." cycle = exceptions.CircularDependencyError(cycle_path=["A", "B", "A"]) boom = RuntimeError("boom") error = exceptions.ValidationFailedError(errors=[boom, cycle]) @@ -25,13 +39,28 @@ def test_validation_failed_error_groups_by_kind_and_indents_multiline() -> None: " Check your provider graph for unintended cycles.\n" "\n" "RuntimeError (1):\n" - " - boom" + " - boom\n" + "See: https://modern-di.modern-python.org/troubleshooting/validation-failed-error/" ) def test_validation_failed_error_renders_message_less_sub_error() -> None: error = exceptions.ValidationFailedError(errors=[RuntimeError()]) - assert str(error) == ("Container.validate() found 1 issue(s): RuntimeError\n\nRuntimeError (1):\n -") + assert str(error) == ( + "Container.validate() found 1 issue(s): RuntimeError\n\nRuntimeError (1):\n -\n" + "See: https://modern-di.modern-python.org/troubleshooting/validation-failed-error/" + ) + + +def test_duplicate_provider_type_error_url_unchanged_by_mechanism() -> None: + # DuplicateProviderTypeError used to hand-roll its own inline URL; the docs_slug mechanism + # now generates the trailer instead. The remediation steps stay put, and the URL the user + # lands on is unchanged even though the surrounding sentence isn't verbatim-identical. + error = exceptions.DuplicateProviderTypeError(provider_type=str) + rendered = str(error) + assert "https://modern-di.modern-python.org/troubleshooting/duplicate-type-error/" in rendered + assert rendered.endswith("See: https://modern-di.modern-python.org/troubleshooting/duplicate-type-error/") + assert rendered.count("https://modern-di.modern-python.org/troubleshooting/duplicate-type-error/") == 1 def test_context_value_not_set_error_message_and_hierarchy() -> None: @@ -39,5 +68,6 @@ def test_context_value_not_set_error_message_and_hierarchy() -> None: assert isinstance(error, exceptions.ResolutionError) assert str(error) == ( "No context value is set for (scope APP). " - "Pass context={...} to the container or call set_context()." + "Pass context={...} to the container or call set_context().\n" + "See: https://modern-di.modern-python.org/troubleshooting/context-not-set/" ) diff --git a/tests/test_suggestions.py b/tests/test_suggestions.py index b4bc46d..9092198 100644 --- a/tests/test_suggestions.py +++ b/tests/test_suggestions.py @@ -33,7 +33,8 @@ class G(Group): assert str(exc_info.value) == ( "Provider of type is not registered in providers registry.\n" "Did you mean:\n" - " - PostgresDatabase (registered subclass, scope=APP)" + " - PostgresDatabase (registered subclass, scope=APP)\n" + "See: https://modern-di.modern-python.org/troubleshooting/missing-provider/" ) @@ -48,7 +49,8 @@ class G(Group): assert str(exc_info.value) == ( "Provider of type is not registered in providers registry.\n" "Did you mean:\n" - " - Database (registered base class, scope=APP)" + " - Database (registered base class, scope=APP)\n" + "See: https://modern-di.modern-python.org/troubleshooting/missing-provider/" ) @@ -86,7 +88,8 @@ class G(Group): assert str(exc_info.value) == ( "Provider of type is not registered in providers registry.\n" "Did you mean:\n" - " - PostgresDatabase (registered subclass, scope=REQUEST)" + " - PostgresDatabase (registered subclass, scope=REQUEST)\n" + "See: https://modern-di.modern-python.org/troubleshooting/missing-provider/" ) @@ -95,7 +98,10 @@ def test_no_suggestions_when_nothing_matches() -> None: with pytest.raises(ProviderNotRegisteredError) as exc_info: container.resolve(int) - assert str(exc_info.value) == "Provider of type is not registered in providers registry." + assert str(exc_info.value) == ( + "Provider of type is not registered in providers registry.\n" + "See: https://modern-di.modern-python.org/troubleshooting/missing-provider/" + ) def test_suggestions_capped_at_three() -> None: @@ -135,7 +141,8 @@ class G(Group): "Did you mean:\n" " - A1 (registered subclass, scope=APP)\n" " - A2 (registered subclass, scope=APP)\n" - " - A3 (registered subclass, scope=APP)" + " - A3 (registered subclass, scope=APP)\n" + "See: https://modern-di.modern-python.org/troubleshooting/missing-provider/" ) @@ -244,5 +251,6 @@ class G(Group): "Provider of type is not registered in providers registry.\n" "Did you mean:\n" " - PostgresDatabase (registered subclass, scope=APP)\n" - " - Databse (similar name, scope=APP)" + " - Databse (similar name, scope=APP)\n" + "See: https://modern-di.modern-python.org/troubleshooting/missing-provider/" )