Skip to content

4.0: context values are required; ContextProvider(default=) replaces per-parameter runtime disposition #475

Description

@lesnik512

Problem

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.

Activity

  1. added this to the 4.0 milestone on Sep 12, 2026
  2. lesnik512 commented on Sep 27, 2026

    @lesnik512
    MemberAuthor

    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.

  3. lesnik512 commented on Sep 27, 2026

    @lesnik512
    MemberAuthor

    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_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:

    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:

    1. 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.
    2. 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.
    3. Consider naming the parameter in the error (as suggested above), since the failing site in this case is a creator default, not the provider.
  4. lesnik512 commented on Oct 4, 2026

    @lesnik512
    MemberAuthor

    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 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:

  5. added
    enhancementNew feature or request
    ready-for-agentFully specified, ready for an AFK agent
    and removed on Oct 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestready-for-agentFully specified, ready for an AFK agent

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions