You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When a factory parameter is backed by a ContextProvider and the context value is absent at resolve time, the resolver decides per parameter, per resolve, whether to omit the argument (creator default applies), inject None (nullable annotation), or raise. That single feature is why the wiring plan has a context bucket next to the provider bucket, why the compiler template carries a context-fold block that loops at resolve time, why absent_disposition is consulted at plan time and again at run time, and why ArgumentResolutionError has a registry-less variant with no suggestions. Ablated in the research behind #470, it is the largest remaining single source of complexity in the resolve path.
Proposal
A context value is required at its scope. ContextProvider becomes an ordinary dependency: its resolver reads the context registry and raises ContextValueNotSetError when unset. Optional context is spelled once, on the provider: ContextProvider(Request, default=None), a compile-time constant returned when the value is unset. A factory whose parameter has a creator default and no provider is unchanged (that is plan-time, not run-time).
What it deletes
The context bucket of the wiring plan and its special case in the explicit-kwargs overlay; the context-fold template block; the run-time use of the absent-disposition enum; the registry-less branch of the argument-resolution error. Roughly 80 lines and one concept ("runtime disposition") from the glossary.
What it changes for users
A factory parameter that today receives None or its default when the context value is absent will raise ContextValueNotSetError (naming the type, not the parameter) unless the ContextProvider declares default=. 13 tests in the context-provider and factory suites describe the current semantics and would be rewritten to the new rule. Migration is one argument on the provider.
Measured
G9 (context resolve, the request-injection path every integration takes) −7% with the fold removed. The motive is simplicity, not speed.
Decision needed
Whether default= covers the real optional-context use cases (a handler usable outside a request is the one the tests describe), and whether the error should name the parameter as well as the type. Ships in 4.0 only.
Related
ADR-0011 declined folding the context registry into the container; this proposal is about the disposition rule, not the registry, and does not reopen it.
Read against main at c2fa904 with the wiring plan, the templates and the sibling integrations in front of me. Agree with the proposal; it is the largest readability win on the milestone. On the two open questions and what else falls out:
default= covers the described cases. The 13 tests all reduce to "a handler usable outside a request". No modern-di-* package resolves an unset context value on purpose; every integration sets the connection before the first resolve.
Name the parameter, and it costs nothing. The frame that raises is the parent's generated build, whose namespace already holds build_arg_error(arg_name=..., item=...). What becomes deletable is the registry-less variant of ArgumentResolutionError (the member_types-only and no-annotation branches in exceptions/resolution.py:78-90), not the parameter name. So (a) yes and (b) yes are compatible.
What else the fold takes with it:
_Absent and absent_disposition in wiring.py. After the fold the enum is consulted exactly once, at plan time in _wire_by_type. Two reads on SignatureItem (item.default is not UNSET, item.is_nullable) express the same three-way decision inline; the enum, the helper and the NULL / OMIT template globals go.
ContextProvider.fetch_context_value has only test callers. It duplicates _compile_context_provider once the fold is gone; delete it and its SLF001 suppression.
In _compile_factory, the loop that checks an override on each context provider disappears, because a context provider becomes an ordinary dependency and compiles through resolver_for like any other, override included.
The context bit of _Shape halves the enumerated shape count in test_every_resolver_shape_compiles_and_resolves.
Rough size: wiring.py 170 → ~120, the templates lose _CONTEXT_FOLD and three namespace entries. This edits the same template strings as #476, #481 and #534; serialise them.
dynamic_engine is a request-scoped factory over this creator, and the session is built on it. In the templates the no-request path is only exercised by a test. But a service that shares one ioc between an HTTP app and FastStream consumers resolves the same session provider from the consumers, where no Request is ever set, and depends on getting the primary engine there. The claim that "no modern-di-* package resolves an unset context value on purpose" holds for the packages, not for their users.
Under this proposal that path raises ContextValueNotSetError, and "migration is one argument on the provider" does not apply directly: fastapi_request_provider / litestar_request_provider belong to the integration, and should stay required.
The app-level migration I'd expect is an app-owned provider with default=None, kept out of type autowiring with bound_type=None (so it does not collide with the integration's provider) and passed explicitly:
Confirm a second ContextProvider for the same context_type with bound_type=None is supported and reads the same registry entry, and cover it with a test.
Document it as the migration for "creator parameter was X | None = None backed by an integration's context provider", ideally in the 4.0 upgrade notes next to the ContextValueNotSetError change.
Consider naming the parameter in the error (as suggested above), since the failing site in this case is a creator default, not the provider.
Category: enhancement Summary: A context value is required at its scope. ContextProvider(..., default=) is the only way to make it optional, and the per-parameter runtime disposition is removed.
Current behavior:
When a factory parameter backed by a ContextProvider has no context value at resolve time, the resolver decides per parameter, on every resolve, between three outcomes:
leave the argument out, so the creator's default applies;
inject None, for a nullable annotation;
raise.
That decision is what the following exist for: the wiring plan's context bucket, the compiler's context-fold template block, the runtime uses of absent_disposition / _Absent, the NULL / OMIT template globals, and the registry-less branch of ArgumentResolutionError.
Desired behavior:
Required by default: a ContextProvider is an ordinary dependency. Its resolver reads the context registry and raises ContextValueNotSetError when the value isn't set.
Optional on the provider:ContextProvider(T, default=X) returns the compile-time constant X when the value isn't set. That is the only way to make context optional.
The error names the parameter:ContextValueNotSetError, raised while resolving a factory argument, names the parameter as well as the type. The parent's generated build already has arg_name.
Plain defaults unchanged: a factory parameter with a creator default and no provider behaves as now. That's decided at plan time.
Deleted:
the context bucket and its special case in the explicit-kwargs overlay;
the _CONTEXT_FOLD template block and its namespace entries;
_Absent / absent_disposition and the NULL / OMIT globals. The plan-time decision is expressed inline from SignatureItem.
ContextProvider.fetch_context_value, which only tests call;
the registry-less branch of ArgumentResolutionError.
Overrides: a context provider is overridden like any other dependency, because it now compiles through the normal resolver path.
Key interfaces:
ContextProvider.__init__ gains default=.
ContextValueNotSetError gets a message that includes the parameter name when there is one.
ArgumentResolutionError loses the registry-less variant.
Acceptance criteria:
An unset context value with no default= raises ContextValueNotSetError, and when resolved as a factory argument, the error names the parameter.
ContextProvider(T, default=None) and a non-None default return the default when the value is unset, and the set value when it is set.
A test covers the app-level pattern from the 09-27 comment, shown below. It needs both cases: the request set (the replica choice sees it), and no request set (it gets None, and the integration's provider still raises when resolved directly).
The 13 tests describing the old per-parameter semantics are rewritten to the new rule, not deleted.
docs/migration/to-4.x.md describes the change, with that app-owned optional provider as the migration for "a creator parameter X | None = None backed by an integration's context provider". The ContextProvider docs describe default=.
test_every_resolver_shape_compiles_and_resolves drops the context shape bit, and every remaining shape still compiles and resolves.
Full suite and lint pass. Record the G9 benchmark in the PR.
The app-level pattern for the test above: a second ContextProvider(fastapi.Request, scope=Scope.REQUEST, bound_type=None, default=None) for the same context type, passed explicitly through a factory's kwargs. It reads the same registry entry as the integration's own provider.
Out of scope:
Folding the context registry into the container. That is declined by ADR-0011 and not reopened here.
Changing any modern-di-* integration's own request provider, which stays required.
Activity
lesnik512 commentedon Sep 27, 2026
Read against
mainatc2fa904with the wiring plan, the templates and the sibling integrations in front of me. Agree with the proposal; it is the largest readability win on the milestone. On the two open questions and what else falls out:default=covers the described cases. The 13 tests all reduce to "a handler usable outside a request". Nomodern-di-*package resolves an unset context value on purpose; every integration sets the connection before the first resolve.Name the parameter, and it costs nothing. The frame that raises is the parent's generated
build, whose namespace already holdsbuild_arg_error(arg_name=..., item=...). What becomes deletable is the registry-less variant ofArgumentResolutionError(themember_types-only and no-annotation branches inexceptions/resolution.py:78-90), not the parameter name. So (a) yes and (b) yes are compatible.What else the fold takes with it:
_Absentandabsent_dispositioninwiring.py. After the fold the enum is consulted exactly once, at plan time in_wire_by_type. Two reads onSignatureItem(item.default is not UNSET,item.is_nullable) express the same three-way decision inline; the enum, the helper and theNULL/OMITtemplate globals go.ContextProvider.fetch_context_valuehas only test callers. It duplicates_compile_context_provideronce the fold is gone; delete it and itsSLF001suppression._compile_factory, the loop that checks an override on each context provider disappears, because a context provider becomes an ordinary dependency and compiles throughresolver_forlike any other, override included.contextbit of_Shapehalves the enumerated shape count intest_every_resolver_shape_compiles_and_resolves.Rough size:
wiring.py170 → ~120, the templates lose_CONTEXT_FOLDand three namespace entries. This edits the same template strings as #476, #481 and #534; serialise them.lesnik512 commentedon Sep 27, 2026
One use case the analysis above does not cover: apps (not integrations) that resolve an unset context value on purpose.
Both templates now route reads to a replica by request method (modern-python/fastapi-sqlalchemy-template#71, modern-python/litestar-sqlalchemy-template#46):
dynamic_engineis a request-scoped factory over this creator, and the session is built on it. In the templates the no-request path is only exercised by a test. But a service that shares oneiocbetween an HTTP app and FastStream consumers resolves the same session provider from the consumers, where noRequestis ever set, and depends on getting the primary engine there. The claim that "nomodern-di-*package resolves an unset context value on purpose" holds for the packages, not for their users.Under this proposal that path raises
ContextValueNotSetError, and "migration is one argument on the provider" does not apply directly:fastapi_request_provider/litestar_request_providerbelong to the integration, and should stay required.The app-level migration I'd expect is an app-owned provider with
default=None, kept out of type autowiring withbound_type=None(so it does not collide with the integration's provider) and passed explicitly:Asks for the 4.0 work:
ContextProviderfor the samecontext_typewithbound_type=Noneis supported and reads the same registry entry, and cover it with a test.X | None = Nonebacked by an integration's context provider", ideally in the 4.0 upgrade notes next to theContextValueNotSetErrorchange.lesnik512 commentedon Oct 4, 2026
Agent Brief
Category: enhancement
Summary: A context value is required at its scope.
ContextProvider(..., default=)is the only way to make it optional, and the per-parameter runtime disposition is removed.Current behavior:
When a factory parameter backed by a
ContextProviderhas no context value at resolve time, the resolver decides per parameter, on every resolve, between three outcomes:None, for a nullable annotation;That decision is what the following exist for: the wiring plan's context bucket, the compiler's context-fold template block, the runtime uses of
absent_disposition/_Absent, theNULL/OMITtemplate globals, and the registry-less branch ofArgumentResolutionError.Desired behavior:
ContextProvideris an ordinary dependency. Its resolver reads the context registry and raisesContextValueNotSetErrorwhen the value isn't set.ContextProvider(T, default=X)returns the compile-time constantXwhen the value isn't set. That is the only way to make context optional.ContextValueNotSetError, raised while resolving a factory argument, names the parameter as well as the type. The parent's generatedbuildalready hasarg_name._CONTEXT_FOLDtemplate block and its namespace entries;_Absent/absent_dispositionand theNULL/OMITglobals. The plan-time decision is expressed inline fromSignatureItem.ContextProvider.fetch_context_value, which only tests call;ArgumentResolutionError.Key interfaces:
ContextProvider.__init__gainsdefault=.ContextValueNotSetErrorgets a message that includes the parameter name when there is one.ArgumentResolutionErrorloses the registry-less variant.Acceptance criteria:
default=raisesContextValueNotSetError, and when resolved as a factory argument, the error names the parameter.ContextProvider(T, default=None)and a non-Nonedefault return the default when the value is unset, and the set value when it is set.None, and the integration's provider still raises when resolved directly).docs/migration/to-4.x.mddescribes the change, with that app-owned optional provider as the migration for "a creator parameterX | None = Nonebacked by an integration's context provider". TheContextProviderdocs describedefault=.test_every_resolver_shape_compiles_and_resolvesdrops thecontextshape bit, and every remaining shape still compiles and resolves.The app-level pattern for the test above: a second
ContextProvider(fastapi.Request, scope=Scope.REQUEST, bound_type=None, default=None)for the same context type, passed explicitly through a factory'skwargs. It reads the same registry entry as the integration's own provider.Out of scope:
modern-di-*integration's own request provider, which stays required.