From 093ff7df8afe5b9ff9dd4d3980b0fc73e3613978 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 4 Oct 2026 22:29:52 +0300 Subject: [PATCH 1/2] docs: 4.0 fixes and migration guide gaps (#577) --- docs/integrations/grpc.md | 39 +++++++++++++------ docs/integrations/writing-integrations.md | 16 ++++---- docs/migration/to-4.x.md | 6 +++ .../child-container-registration-error.md | 4 +- docs/troubleshooting/creator-call-error.md | 4 +- .../group-instantiation-error.md | 4 +- .../invalid-child-scope-error.md | 4 +- .../invalid-scope-type-error.md | 4 +- docs/troubleshooting/missing-provider.md | 8 ++-- .../scope-not-initialized-error.md | 4 +- docs/troubleshooting/scope-skipped-error.md | 4 +- .../unknown-factory-kwarg-error.md | 4 +- 12 files changed, 63 insertions(+), 38 deletions(-) diff --git a/docs/integrations/grpc.md b/docs/integrations/grpc.md index 32f96332..d5f65211 100644 --- a/docs/integrations/grpc.md +++ b/docs/integrations/grpc.md @@ -45,13 +45,12 @@ class Settings: class RpcReport: - def __init__(self, settings: Settings, context: grpc.ServicerContext | None = None) -> None: + def __init__(self, settings: Settings, context: grpc.ServicerContext) -> None: self._settings = settings # APP-scoped, injected by type self._context = context # REQUEST context object, injected by type def line(self) -> str: - peer = self._context.peer() if self._context is not None else "unknown" - return f"{self._settings.service_name} <- {peer}" + return f"{self._settings.service_name} <- {self._context.peer()}" class AppGroup(Group): @@ -140,20 +139,38 @@ import grpc from modern_di import Group, Scope, providers -def make_caller(context: grpc.ServicerContext | None = None) -> str: - return context.peer() if context is not None else "unknown" +def make_caller(context: grpc.ServicerContext) -> str: + return context.peer() class AppGroup(Group): caller = providers.Factory(make_caller, scope=Scope.REQUEST) ``` -`validate()` never constructs a provider, so the default isn't needed for -validation, and it does not make the context optional: outside an RPC, where no -context is set, resolving `caller` raises `ContextValueNotSetError`. For a factory -that must also resolve outside an RPC, pass your own -`ContextProvider(grpc.ServicerContext, scope=Scope.REQUEST, bound_type=None, default=None)` -through `kwargs`; see [Optional context](../providers/context.md#optional-context-default). +The interceptor's provider has no default, so the context is required. Outside an RPC no context +is set, and resolving `caller` raises `ContextValueNotSetError`. A parameter default such as +`context: grpc.ServicerContext | None = None` does not change that: once the interceptor has +registered its provider, the parameter is wired to it and the default is never used. + +A factory that must also resolve outside an RPC needs its own optional provider. Declare a +`ContextProvider` for the same type with `default=None`, keep it out of type-based wiring with +`bound_type=None`, and pass it through `kwargs`. It reads the same context value as the +interceptor's provider. See [Optional context](../providers/context.md#optional-context-default). + +```python +def make_caller(context: grpc.ServicerContext | None) -> str: + return context.peer() if context is not None else "unknown" + + +class AppGroup(Group): + optional_context = providers.ContextProvider( + grpc.ServicerContext, scope=Scope.REQUEST, bound_type=None, default=None + ) + caller = providers.Factory( + make_caller, scope=Scope.REQUEST, kwargs={"context": optional_context} + ) +``` + The protobuf request `Message` is not exposed as a provider (that would add a `protobuf` dependency); the request is already a servicer-method argument. diff --git a/docs/integrations/writing-integrations.md b/docs/integrations/writing-integrations.md index 61132cc0..01a4e2c6 100644 --- a/docs/integrations/writing-integrations.md +++ b/docs/integrations/writing-integrations.md @@ -436,13 +436,15 @@ Each official integration is its own repository and PyPI package, mirroring the `ContextProvider` is registered by `setup_di`, so a service that requires it by type would fail `validate()` if that call were placed *before* `setup_di`. Demonstrate context injection in a dedicated "Framework context objects" - section instead. To validate a graph that references the connection object - *before* `setup_di` has registered its provider (a narrower, earlier check), - make the context parameter optional (`request: FrameworkType | None = None`) - so `validate()` skips it regardless of ordering, while the integration still - injects the real value at runtime. That is the pattern the [gRPC - page](grpc.md) uses and [Framework context - objects](../providers/context.md#framework-context-objects) documents. Follow + section instead, with a required parameter (`request: FrameworkType`). + Don't present `request: FrameworkType | None = None` as a way to make the + value optional. Once `setup_di` has registered the connection provider, the + parameter is wired to it and its default is never used, so resolving outside + a connection raises `ContextValueNotSetError`. For a factory that must also + resolve outside a connection, show an app-owned `ContextProvider` with + `bound_type=None, default=None` passed through `kwargs`, as the [gRPC + page](grpc.md#injecting-the-servicercontext) does and [Optional + context](../providers/context.md#optional-context-default) documents. Follow the example with any framework-specific sections, a tailored `## See also` block linking [Testing with overrides](../recipes/testing-overrides.md), [Lifecycle](../providers/lifecycle.md), [Scopes](../providers/scopes.md), and diff --git a/docs/migration/to-4.x.md b/docs/migration/to-4.x.md index caf1b6cf..0c42218f 100644 --- a/docs/migration/to-4.x.md +++ b/docs/migration/to-4.x.md @@ -60,6 +60,10 @@ an ordinary dependency. With no value set it raises `ContextValueNotSetError`, w resolved directly or as a `Factory` argument, and the parameter's default and annotation are ignored. For a `Factory` argument, the message and `.parameter_name` name the parameter. +`ContextValueNotSetError` does not subclass `ArgumentResolutionError`. An +`except ArgumentResolutionError` clause that handled a missing context value in 3.x no longer +catches it; catch `ContextValueNotSetError` instead, or `ResolutionError`, which covers both. + Optional context is declared once, on the provider: `ContextProvider(T, default=X)` returns `X` whenever no value is set. If you own the provider, add `default=` to it: @@ -189,3 +193,5 @@ as before for every provider, and type hints that name `AbstractProvider` need n `find_container(scope)` to reach an ancestor. - `ContextValueNoneWarning` and `UnvalidatedContainerWarning` are removed. Neither has been emitted since 3.0, so delete any `filterwarnings` entry or import that names them. +- The `modern_di.exceptions.warnings` module held only these three warnings and is deleted. An + import from that path raises `ModuleNotFoundError`, so delete it. diff --git a/docs/troubleshooting/child-container-registration-error.md b/docs/troubleshooting/child-container-registration-error.md index 27c79f9f..bf610fdc 100644 --- a/docs/troubleshooting/child-container-registration-error.md +++ b/docs/troubleshooting/child-container-registration-error.md @@ -18,10 +18,10 @@ Call `add_providers()` on the root container instead: app_container = Container(scope=Scope.APP, groups=[MyGroup]) request_container = app_container.build_child_container(scope=Scope.REQUEST) -# Wrong +# Broken request_container.add_providers(late_provider) # raises ChildContainerRegistrationError -# Right +# Works app_container.add_providers(late_provider) ``` diff --git a/docs/troubleshooting/creator-call-error.md b/docs/troubleshooting/creator-call-error.md index 17973c8e..a5a076cb 100644 --- a/docs/troubleshooting/creator-call-error.md +++ b/docs/troubleshooting/creator-call-error.md @@ -25,13 +25,13 @@ def create_service(host: str, port: int) -> Service: ... class Dependencies(Group): - # Wrong: skip_creator_parsing=True but kwargs misses `port` + # Broken: skip_creator_parsing=True but kwargs misses `port` service = providers.Factory( create_service, scope=Scope.APP, skip_creator_parsing=True, bound_type=Service, kwargs={"host": "localhost"}, ) - # Right + # Works service = providers.Factory( create_service, scope=Scope.APP, skip_creator_parsing=True, bound_type=Service, kwargs={"host": "localhost", "port": 5432}, diff --git a/docs/troubleshooting/group-instantiation-error.md b/docs/troubleshooting/group-instantiation-error.md index f47722cd..75c84aea 100644 --- a/docs/troubleshooting/group-instantiation-error.md +++ b/docs/troubleshooting/group-instantiation-error.md @@ -20,10 +20,10 @@ class Dependencies(Group): service = providers.Factory(Service, scope=Scope.APP) -# Wrong +# Broken deps = Dependencies() # raises GroupInstantiationError -# Right +# Works container = Container(groups=[Dependencies]) service = container.resolve_provider(Dependencies.service) ``` diff --git a/docs/troubleshooting/invalid-child-scope-error.md b/docs/troubleshooting/invalid-child-scope-error.md index 21457109..58bfc470 100644 --- a/docs/troubleshooting/invalid-child-scope-error.md +++ b/docs/troubleshooting/invalid-child-scope-error.md @@ -20,11 +20,11 @@ from modern_di import Scope app_container = Container(scope=Scope.APP, groups=[MyGroup]) -# Wrong: SESSION is not deeper than SESSION +# 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 -# Right +# Works good = mid.build_child_container(scope=Scope.REQUEST) ``` diff --git a/docs/troubleshooting/invalid-scope-type-error.md b/docs/troubleshooting/invalid-scope-type-error.md index c33276e7..a45ae642 100644 --- a/docs/troubleshooting/invalid-scope-type-error.md +++ b/docs/troubleshooting/invalid-scope-type-error.md @@ -20,11 +20,11 @@ Use the built-in `Scope` enum, or your own `IntEnum` subclass: ```python from modern_di import Container, Scope -# Wrong +# Broken container = Container(scope=1) # raises InvalidScopeTypeError container = Container(scope="APP") # raises InvalidScopeTypeError -# Right +# Works container = Container(scope=Scope.APP) ``` diff --git a/docs/troubleshooting/missing-provider.md b/docs/troubleshooting/missing-provider.md index b634cc08..72826069 100644 --- a/docs/troubleshooting/missing-provider.md +++ b/docs/troubleshooting/missing-provider.md @@ -42,12 +42,12 @@ startup catches this before the first request. ```python # Broken: cannot resolve by type -def create_engine(...): - return sa_async.create_async_engine(...) +def create_engine(settings: Settings): + return sa_async.create_async_engine(settings.database_url) # Works: return-typed -def create_engine(...) -> sa_async.AsyncEngine: - return sa_async.create_async_engine(...) +def create_engine(settings: Settings) -> sa_async.AsyncEngine: + return sa_async.create_async_engine(settings.database_url) ``` To fix it, add the return annotation, or set `bound_type=SomeType` on the provider explicitly. diff --git a/docs/troubleshooting/scope-not-initialized-error.md b/docs/troubleshooting/scope-not-initialized-error.md index bda3c3bd..2357322d 100644 --- a/docs/troubleshooting/scope-not-initialized-error.md +++ b/docs/troubleshooting/scope-not-initialized-error.md @@ -21,10 +21,10 @@ Build the deeper-scoped container before resolving from it: ```python app_container = Container(scope=Scope.APP, groups=[MyGroup]) -# Wrong: no REQUEST container exists yet +# Broken: no REQUEST container exists yet app_container.resolve(RequestScopedThing) # raises ScopeNotInitializedError -# Right +# Works request_container = app_container.build_child_container(scope=Scope.REQUEST) request_container.resolve(RequestScopedThing) ``` diff --git a/docs/troubleshooting/scope-skipped-error.md b/docs/troubleshooting/scope-skipped-error.md index c0faba8a..fd05072c 100644 --- a/docs/troubleshooting/scope-skipped-error.md +++ b/docs/troubleshooting/scope-skipped-error.md @@ -21,11 +21,11 @@ Build child containers through every intermediate scope your providers need: ```python app_container = Container(scope=Scope.APP, groups=[MyGroup]) -# Wrong: jumps straight past REQUEST +# Broken: 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 +# 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) diff --git a/docs/troubleshooting/unknown-factory-kwarg-error.md b/docs/troubleshooting/unknown-factory-kwarg-error.md index 10593e57..202643db 100644 --- a/docs/troubleshooting/unknown-factory-kwarg-error.md +++ b/docs/troubleshooting/unknown-factory-kwarg-error.md @@ -21,12 +21,12 @@ def create_service(connection_string: str) -> Service: ... class Dependencies(Group): - # Wrong: typo — raises UnknownFactoryKwargError, suggests "connection_string" + # Broken: typo — raises UnknownFactoryKwargError, suggests "connection_string" service = providers.Factory( create_service, scope=Scope.APP, kwargs={"conection_string": "..."} ) - # Right + # Works service = providers.Factory( create_service, scope=Scope.APP, kwargs={"connection_string": "..."} ) From 5195d97aeb472debf60ade57a6d2d6e7fa3d7322 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 4 Oct 2026 22:32:27 +0300 Subject: [PATCH 2/2] docs: argument-resolution-error teaches 4.0 optional context (#577) --- docs/troubleshooting/argument-resolution-error.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/troubleshooting/argument-resolution-error.md b/docs/troubleshooting/argument-resolution-error.md index c23fbfc9..317f58ba 100644 --- a/docs/troubleshooting/argument-resolution-error.md +++ b/docs/troubleshooting/argument-resolution-error.md @@ -33,11 +33,14 @@ class Dependencies(Group): If the missing type is one a framework integration provides at runtime (`fastapi.Request`, `taskiq.TaskiqMessage`, …), its `ContextProvider` is registered by `setup_di()`, so a `container.validate()` call made -*before* `setup_di()` runs sees no provider for it yet and raises. Either call -`validate()` after `setup_di()` (the provider is registered by then), or make the -parameter optional (`request: fastapi.Request | None = None`) so validation skips it -regardless of ordering; the integration still injects the real value at runtime either -way. See [Framework context objects](../providers/context.md#framework-context-objects). +*before* `setup_di()` runs sees no provider for it yet and raises. Call `validate()` after +`setup_di()`, when the provider is registered. A parameter default such as +`request: fastapi.Request | None = None` does not make the value optional: once `setup_di()` +has registered the provider, the parameter is wired to it and the default is never used. For a +factory that must also resolve outside a request, declare your own +`ContextProvider(fastapi.Request, scope=Scope.REQUEST, bound_type=None, default=None)` and pass +it through `kwargs`. See [Framework context objects](../providers/context.md#framework-context-objects) +and [Optional context](../providers/context.md#optional-context-default). Check `.suggestions` on the caught exception for a "did you mean" hint when a similarly-named type is registered instead.