Repository navigation
4.0: context values are required; ContextProvider(default=) replaces per-parameter runtime disposition #475
Description
Activity
- addedready-for-humanRequires human implementationRequires human implementation
on Sep 12, 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.- In
_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. - The
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.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):
def choose_sa_engine( *, primary_engine: AsyncEngine, replica_engine: AsyncEngine | None, request: fastapi.Request | None = None, ) -> AsyncEngine: if replica_engine and request and request.method in REPLICA_METHODS: return replica_engine return primary_engine
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:optional_request = providers.ContextProvider(fastapi.Request, scope=Scope.REQUEST, bound_type=None, default=None) dynamic_engine = providers.Factory( scope=Scope.REQUEST, creator=choose_sa_engine, kwargs={"primary_engine": database_engine, "replica_engine": database_replica_engine, "request": optional_request}, )
Asks for the 4.0 work:
- Confirm a second
ContextProviderfor the samecontext_typewithbound_type=Noneis supported and reads the same registry entry, and cover it with a test. - Document it as the migration for "creator parameter was
X | None = Nonebacked by an integration's context provider", ideally in the 4.0 upgrade notes next to theContextValueNotSetErrorchange. - Consider naming the parameter in the error (as suggested above), since the failing site in this case is a creator default, not the provider.
- Confirm a second
This was generated by AI during triage.
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 aContextProviderhas 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, theNULL/OMITtemplate globals, and the registry-less branch ofArgumentResolutionError.Desired behavior:
- Required by default: a
ContextProvideris an ordinary dependency. Its resolver reads the context registry and raisesContextValueNotSetErrorwhen the value isn't set. - Optional on the provider:
ContextProvider(T, default=X)returns the compile-time constantXwhen 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 generatedbuildalready hasarg_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_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;- 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__gainsdefault=.ContextValueNotSetErrorgets a message that includes the parameter name when there is one.ArgumentResolutionErrorloses the registry-less variant.
Acceptance criteria:
- An unset context value with no
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. - 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.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. - 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'skwargs. 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. - 4.0: close() is terminal; resolving through a closed container raises #476 and 4.0: bind an Alias to its source at compile time #481. They edit the same templates, so this goes after 4.0: close() is terminal; resolving through a closed container raises #476 and never runs in parallel with either.
- addedenhancementNew feature or requestNew feature or requestready-for-agentFully specified, ready for an AFK agentFully specified, ready for an AFK agentand removedready-for-humanRequires human implementationRequires human implementation
on Oct 4, 2026
Problem
When a factory parameter is backed by a
ContextProviderand the context value is absent at resolve time, the resolver decides per parameter, per resolve, whether to omit the argument (creator default applies), injectNone(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, whyabsent_dispositionis consulted at plan time and again at run time, and whyArgumentResolutionErrorhas 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.
ContextProviderbecomes an ordinary dependency: its resolver reads the context registry and raisesContextValueNotSetErrorwhen 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
Noneor its default when the context value is absent will raiseContextValueNotSetError(naming the type, not the parameter) unless theContextProviderdeclaresdefault=. 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.