From c6d5e959c11ce8b542087e9bf1377b70d8e05295 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 3 Aug 2026 15:38:29 +0300 Subject: [PATCH 1/3] perf(context): fold the context-kwarg lookup into the compiled closures Capture cp.provider_id, cp.scope, cp.context_type and the absent disposition at compile time and inline the whole lookup in both the transient and cached closures, rather than re-reading them per resolve through a shared helper. Folding both call sites lets Factory._resolve_context_value be deleted outright: the logic lives in one place per closure instead of being duplicated against a surviving helper. That is the reason to do both rather than only the hot one. Licensed by a maintainer ruling that a registered ContextProvider's scope and context_type are fixed -- the same shape as the scope freeze, extended to identity. Co-Authored-By: Claude Opus 5 --- modern_di/providers/factory.py | 28 +------- modern_di/resolver_compiler.py | 60 ++++++++++++++---- tests/providers/test_context_provider.py | 81 ++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 38 deletions(-) diff --git a/modern_di/providers/factory.py b/modern_di/providers/factory.py index 1abda72..2a7a340 100644 --- a/modern_di/providers/factory.py +++ b/modern_di/providers/factory.py @@ -6,9 +6,8 @@ from modern_di import exceptions, suggester, types from modern_di.providers.abstract import AbstractProvider -from modern_di.providers.context_provider import ContextProvider from modern_di.types_parser import SignatureItem, parse_creator -from modern_di.wiring import WiringPlan, _Absent, absent_disposition +from modern_di.wiring import WiringPlan if typing.TYPE_CHECKING: @@ -171,31 +170,6 @@ def _plan(self, container: "Container") -> WiringPlan: # (architecture/concurrency.md). return container.providers_registry.plan_for(self, self._parsed_kwargs, self._kwargs) - def _resolve_context_value( - self, container: "Container", arg_name: str, provider: ContextProvider[typing.Any], item: SignatureItem - ) -> typing.Any: # noqa: ANN401 - """Resolve a context-backed parameter live. Returns ``types.UNSET`` to omit the kwarg. - - Absent value falls back to the creator default (omit), ``None`` (nullable), or raises - ``ArgumentResolutionError`` (required). - """ - # Front-guard on `has_overrides` first, as the seven compiled closures do: `fetch_override` - # opens with the same emptiness check, so an ungated call costs a frame to learn nothing. - overrides = container.overrides_registry - if overrides.has_overrides: - override = overrides.fetch_override(provider.provider_id) - if override is not types.UNSET: - return override - value = provider.fetch_context_value(container) - if value is not types.UNSET: - return value - disposition = absent_disposition(item) - if disposition is _Absent.OMIT: - return types.UNSET # omit kwarg; creator default applies - if disposition is _Absent.NULL: - return None - raise self._argument_resolution_error(arg_name=arg_name, item=item) - def get_dependencies(self, container: "Container") -> dict[str, "AbstractProvider[typing.Any]"]: """Return parameter-name → dependency-provider mapping using only the providers registry. diff --git a/modern_di/resolver_compiler.py b/modern_di/resolver_compiler.py index a864ea9..3de07b3 100644 --- a/modern_di/resolver_compiler.py +++ b/modern_di/resolver_compiler.py @@ -3,7 +3,9 @@ Each resolver front-guards its own override, navigates its target once (same-scope deps skip the navigation via an int compare), inlines the kwargs build and creator call, and calls its dependencies' resolvers by reference. Behavior-sensitive helpers (`_resolution_step`, -`_resolve_context_value`, `prepend_step`) are reused, not reimplemented. +`prepend_step`) are reused, not reimplemented. Context kwargs are folded at compile time -- +`ContextProvider.scope` and `.context_type` are fixed once registered, see +architecture/providers.md -- so the whole context lookup is inline here and owns its behaviour. """ import functools @@ -15,6 +17,7 @@ from modern_di.providers.container_provider import container_provider from modern_di.providers.context_provider import ContextProvider from modern_di.providers.factory import Factory +from modern_di.wiring import _Absent, absent_disposition if typing.TYPE_CHECKING: @@ -24,7 +27,9 @@ from modern_di.wiring import WiringPlan _ProvResolvers: typing.TypeAlias = tuple[tuple[str, typing.Callable[[Container], typing.Any]], ...] - _CtxBindings: typing.TypeAlias = tuple[tuple[str, ContextProvider[typing.Any], SignatureItem], ...] + #: name, ContextProvider.provider_id, its scope, its context_type, absent disposition, item. + #: Folded at compile time; the identity of a registered ContextProvider does not change. + _CtxBindings: typing.TypeAlias = tuple[tuple[str, int, typing.Any, type, _Absent, SignatureItem], ...] _SCOPE_ERRORS = (exceptions.ScopeNotInitializedError, exceptions.ScopeSkippedError) _STEP_ERRORS = (exceptions.ResolutionError, *_SCOPE_ERRORS) @@ -76,12 +81,15 @@ def _compile_transient_factory( # noqa: C901, PLR0915 (two hot-path closures: p return _compile_unwireable_factory(f, plan) prov: _ProvResolvers = tuple((name, registry.resolver_for(p)) for name, p in plan.provider_kwargs.items()) static = plan.static_kwargs - ctx: _CtxBindings = tuple((name, cp, item) for name, (cp, item) in plan.context_kwargs.items()) + ctx: _CtxBindings = tuple( + (name, cp.provider_id, cp.scope, cp.context_type, absent_disposition(item), item) + for name, (cp, item) in plan.context_kwargs.items() + ) pure = plan.pure_provider scope = f.scope pid = f.provider_id resolution_step = f._resolution_step - resolve_context = f._resolve_context_value + build_arg_error = f._argument_resolution_error creator = f._creator if _can_call_positionally(f, plan): @@ -116,7 +124,9 @@ def resolve_positional(container: "Container") -> typing.Any: return resolve_positional - def resolve(container: "Container") -> typing.Any: + # The folded context lookup is inline by design: extracting it would cost a Python frame + # per context kwarg, which is the budget this module exists to hold. + def resolve(container: "Container") -> typing.Any: # noqa: C901, PLR0912 overrides = container.overrides_registry if overrides.has_overrides: override = overrides.fetch_override(pid) @@ -129,10 +139,22 @@ def resolve(container: "Container") -> typing.Any: kwargs = {name: r(target) for name, r in prov} if not pure: kwargs.update(static) - for name, cp, item in ctx: - value = resolve_context(target, name, cp, item) + for name, cpid, cscope, ctype, disp, item in ctx: + if overrides.has_overrides: + override = overrides.fetch_override(cpid) + if override is not types.UNSET: + kwargs[name] = override + continue + holder = target if target.scope == cscope else target.find_container(cscope) + if holder.closed: + holder._prepare() + value = holder.context_registry.find_context(ctype) if value is not types.UNSET: kwargs[name] = value + elif disp is _Absent.NULL: + kwargs[name] = None + elif disp is not _Absent.OMIT: + raise build_arg_error(arg_name=name, item=item) except _STEP_ERRORS as exc: exc.prepend_step(resolution_step()) raise @@ -157,12 +179,15 @@ def _compile_cached_factory( # noqa: C901, PLR0915 (cold-miss builder pair: pos return _compile_unwireable_factory(f, plan) prov: _ProvResolvers = tuple((name, registry.resolver_for(p)) for name, p in plan.provider_kwargs.items()) static = plan.static_kwargs - ctx: _CtxBindings = tuple((name, cp, item) for name, (cp, item) in plan.context_kwargs.items()) + ctx: _CtxBindings = tuple( + (name, cp.provider_id, cp.scope, cp.context_type, absent_disposition(item), item) + for name, (cp, item) in plan.context_kwargs.items() + ) pure = plan.pure_provider scope = f.scope pid = f.provider_id resolution_step = f._resolution_step - resolve_context = f._resolve_context_value + build_arg_error = f._argument_resolution_error creator = f._creator # cold-miss only (not hot) call_creator = f._call_creator # cold-miss only; reused (not hot) @@ -197,10 +222,23 @@ def build_kwargs(target: "Container") -> dict[str, typing.Any]: kwargs = {name: r(target) for name, r in prov} if not pure: kwargs.update(static) - for name, cp, item in ctx: - value = resolve_context(target, name, cp, item) + overrides = target.overrides_registry + for name, cpid, cscope, ctype, disp, item in ctx: + if overrides.has_overrides: + override = overrides.fetch_override(cpid) + if override is not types.UNSET: + kwargs[name] = override + continue + holder = target if target.scope == cscope else target.find_container(cscope) + if holder.closed: + holder._prepare() + value = holder.context_registry.find_context(ctype) if value is not types.UNSET: kwargs[name] = value + elif disp is _Absent.NULL: + kwargs[name] = None + elif disp is not _Absent.OMIT: + raise build_arg_error(arg_name=name, item=item) except _STEP_ERRORS as exc: exc.prepend_step(resolution_step()) raise diff --git a/tests/providers/test_context_provider.py b/tests/providers/test_context_provider.py index 5f01c4b..a6f43a5 100644 --- a/tests/providers/test_context_provider.py +++ b/tests/providers/test_context_provider.py @@ -531,3 +531,84 @@ class G2(Group): calls.clear() assert isinstance(request2.resolve(Wider), Wider) assert calls == [Scope.APP] + + +# The context lookup is folded into both compiled closures, so the cached (singleton) copy +# needs its own coverage of every disposition -- the transient copy's tests do not reach it. + + +class _CachedCtx: ... + + +@dataclasses.dataclass(kw_only=True, slots=True) +class _CachedNullable: + ctx: _CachedCtx | None + + +@dataclasses.dataclass(kw_only=True, slots=True) +class _CachedRequired: + ctx: _CachedCtx + + +def test_cached_factory_context_kwarg_uses_override() -> None: + class G(Group): + ctx = providers.ContextProvider(_CachedCtx, scope=Scope.APP) + svc = providers.Factory(creator=_CachedNullable, scope=Scope.APP, cache=True) + + container = Container(scope=Scope.APP, groups=[G]) + container.open() + sentinel = _CachedCtx() + container.override(G.ctx, sentinel) + assert container.resolve(_CachedNullable).ctx is sentinel + + +def test_cached_factory_context_kwarg_absent_and_nullable_injects_none() -> None: + class G(Group): + ctx = providers.ContextProvider(_CachedCtx, scope=Scope.APP) + svc = providers.Factory(creator=_CachedNullable, scope=Scope.APP, cache=True) + + container = Container(scope=Scope.APP, groups=[G]) + container.open() + assert container.resolve(_CachedNullable).ctx is None + + +def test_cached_factory_context_kwarg_absent_and_required_raises() -> None: + class G(Group): + ctx = providers.ContextProvider(_CachedCtx, scope=Scope.APP) + svc = providers.Factory(creator=_CachedRequired, scope=Scope.APP, cache=True) + + container = Container(scope=Scope.APP, groups=[G]) + container.open() + with pytest.raises(ArgumentResolutionError) as exc: + container.resolve(_CachedRequired) + assert exc.value.arg_name == "ctx" + + +def test_cached_factory_context_kwarg_through_closed_holder_warns() -> None: + class G(Group): + ctx = providers.ContextProvider(_CachedCtx, scope=Scope.APP) + svc = providers.Factory(creator=_CachedNullable, scope=Scope.REQUEST, cache=True) + + value = _CachedCtx() + app = Container(scope=Scope.APP, groups=[G], context={_CachedCtx: value}) + app.open() + request = app.build_child_container(scope=Scope.REQUEST) + app.close_sync() + + with pytest.warns(ContainerClosedWarning): + assert request.resolve(_CachedNullable).ctx is value + + +def test_transient_factory_context_kwarg_through_closed_holder_warns() -> None: + class G(Group): + ctx = providers.ContextProvider(_CachedCtx, scope=Scope.APP) + svc = providers.Factory(creator=_CachedNullable, scope=Scope.REQUEST) + + value = _CachedCtx() + app = Container(scope=Scope.APP, groups=[G], context={_CachedCtx: value}) + app.open() + request = app.build_child_container(scope=Scope.REQUEST) + app.close_sync() + + with pytest.warns(ContainerClosedWarning): + assert request.resolve(_CachedNullable).ctx is value From 6245328dffc805f7141dfa5ac59cedd5c15f36fb Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 3 Aug 2026 15:40:24 +0300 Subject: [PATCH 2/3] docs(architecture): the context lookup is folded; identity is fixed at registration Records the ruling that licensed the fold: a registered ContextProvider's scope and context_type are fixed, stated as a contract rather than enforced against direct assignment. resolution.md's 'helpers are reused, not reimplemented' invariant now names the context lookup as its deliberate exception -- the helper was deleted rather than left alongside, so the semantics keep one home per closure instead of two to edit in step. Retires planning/deferred/2026-08-01-context-kwarg-inline.md: all three parts have shipped. Co-Authored-By: Claude Opus 5 --- architecture/performance.md | 9 +++ architecture/providers.md | 14 +++- architecture/resolution.md | 11 ++- .../2026-08-01-context-kwarg-inline.md | 73 ------------------- 4 files changed, 26 insertions(+), 81 deletions(-) delete mode 100644 planning/deferred/2026-08-01-context-kwarg-inline.md diff --git a/architecture/performance.md b/architecture/performance.md index 1908d26..629bc78 100644 --- a/architecture/performance.md +++ b/architecture/performance.md @@ -126,6 +126,15 @@ int compare and **not** the compiler's `_navigate`: that helper prepends a resolution step, and the calling `Factory` closure prepends its own, so the caller would appear twice in the breadcrumb. +A `Factory`'s **context kwargs do not call it at all**. Their `provider_id`, +scope, `context_type` and absent-disposition are folded into the compiled closure, +which does the override guard, the scope compare, the registry read and the +disposition inline — the same int-compare shape, one fewer indirection. Measured +at ~-6% (~42 ns per context kwarg) on `g9_context`. The value itself is still read +live on every resolve; only the binding is frozen, which is licensed by a +registered `ContextProvider`'s identity being fixed +([providers.md](providers.md#contextprovider--runtime-injected-values)). + `Scope._next_deeper` is memoized because it is a constant function of an immutable enum member, consulted per child on the default `build_child_container()` path. diff --git a/architecture/providers.md b/architecture/providers.md index 57301c0..8ab873f 100644 --- a/architecture/providers.md +++ b/architecture/providers.md @@ -137,10 +137,16 @@ and the two paths are independent: `ContextValueNoneWarning` still exists in `exceptions.py` (retained so existing `filterwarnings` configs don't break) but nothing raises it any more. - **As a dependent parameter** of another provider (e.g. a `Factory` constructor argument typed as the context - type): unaffected by the above — `Factory` reads the value via `fetch_context_value` (not `resolve`), so no - exception is raised on this path. `Factory._resolve_context_value` handles the absent-context case live via the - shared `absent_disposition` helper: if the dependent parameter has a default or is nullable it is silently - satisfied; otherwise an `ArgumentResolutionError` is raised. + type): unaffected by the above — no exception is raised on this path. The compiled `Factory` closure does the + whole lookup inline, applying `absent_disposition`'s ruling for an absent value: if the dependent parameter has + a default or is nullable it is silently satisfied; otherwise an `ArgumentResolutionError` is raised. + +**A registered `ContextProvider`'s identity is fixed.** Its `scope` and its `context_type` are read once, when +its consumer's resolver is compiled, and folded into that closure — so changing either after registration applies +only to resolvers compiled afterwards, and silently. This is the same freeze `ProviderScopeFrozenError` enforces +for group-stamped scope, extended to `context_type` and stated as a contract rather than enforced against direct +attribute assignment. Rebinding `provider.context_type` or `provider.scope` on a registered provider is not +supported; construct a second provider instead. Either **declaration route** reaches that dependent-parameter path: matched by type from the registry, or passed explicitly as `Factory(creator, kwargs={"request": the_provider})`. `WiringPlan.build` buckets both diff --git a/architecture/resolution.md b/architecture/resolution.md index d0b6dd5..3f8c9a5 100644 --- a/architecture/resolution.md +++ b/architecture/resolution.md @@ -57,10 +57,13 @@ change, not a refactor. re-read it. - **Errors are built fresh, never memoized.** `prepend_step` *mutates* the exception as it propagates, so a stored instance would accumulate breadcrumbs across repeated or nested resolves. -- **Behaviour-sensitive helpers are reused, not reimplemented.** `_resolution_step`, - `_resolve_context_value`, `prepend_step`, `ContextProvider.resolve`, and - `CreatorCallError.from_type_error` have one home each; the compiler calls them rather than inlining - their semantics. +- **Behaviour-sensitive helpers are reused, not reimplemented.** `_resolution_step`, `prepend_step`, + `ContextProvider.resolve`, and `CreatorCallError.from_type_error` have one home each; the compiler + calls them rather than inlining their semantics. The **context-kwarg lookup is the deliberate + exception**: it is folded into each compiled closure, and the helper it replaced was deleted rather + than left alongside, so the semantics still have exactly one home per closure instead of two homes + to keep in step. Its licence is that a registered `ContextProvider`'s scope and `context_type` are + fixed — see [providers.md](providers.md#contextprovider--runtime-injected-values). - **A new provider type fails loudly.** `compile_resolver` raises `TypeError` for any type it has no branch for — the single place an unsupported provider is rejected. diff --git a/planning/deferred/2026-08-01-context-kwarg-inline.md b/planning/deferred/2026-08-01-context-kwarg-inline.md deleted file mode 100644 index 2fad6e8..0000000 --- a/planning/deferred/2026-08-01-context-kwarg-inline.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -summary: Only the compile-time fold is left: parts (i) and (ii) shipped, and (iii) still needs a ruling that ContextProvider.scope and context_type are frozen after registration -- what it is worth on its own has never been measured apart from the full inline. ---- - -# Inline the context-kwarg path in the Factory closures - -A `Factory` parameter backed by a `ContextProvider` is resolved per resolve -through `Factory._resolve_context_value`, which calls -`ContextProvider.fetch_context_value`, which calls `find_container` and -`ContextRegistry.find_context` — three Python frames per context kwarg when the -resolving container is already at the provider's scope (four before the hop's -int compare shipped, five before the override guard), on the path every framework -integration takes for its per-request values. - -## Why it is open - -Measured on a REQUEST factory with one context kwarg: 312-329 ns → 225 ns -(**~-28%**) for the full inline; a narrower variant measured -9.2%. The work -splits into three parts of increasing invasiveness, and they do not stand or fall -together: - -- **(i) Gate `fetch_override` on `has_overrides`.** ~~`_resolve_context_value` - calls `overrides_registry.fetch_override(...)` unconditionally.~~ **Shipped.** - Measured -41.1 ns (-5.98%) on the no-overrides path, with the override-active - path also improving slightly (-4.4 ns). It needed no ruling and was never - blocked. (ii) has since shipped too; only (iii) remains. -- **(ii) Give the context hop the same-scope int-compare fast path** the Factory - closures already have, instead of always calling `find_container`. **Shipped - (2026-08-03).** It needed no ruling. Measured **-2.5%** on `g9_context` - (~707 → ~690 ns, four A/B/A runs: -3.05, -1.29, -3.98, -1.94%, all negative, - against 0.4-1.7% baseline drift). That percentage is **not** comparable to the - -9.2% recorded above for the narrow variant: this one is measured on - `g9_context` at ~707 ns, that one on a ~312-329 ns benchmark. In absolute terms - (ii) is worth ~17 ns, which with (i)'s -41 ns puts the pair at roughly -18% of - that original baseline — so the narrow variant's target is already met, and - what (iii) adds on its own has not been measured apart from the full inline. - Since the timing sits close to the harness's own drift, the suite asserts the - *structural* claim instead: `test_same_scope_context_hop_does_not_call_find_container`. - The predicted `_navigate` trap was confirmed and avoided — a plain int compare, - never `_navigate`, which prepends a resolution step the caller then prepends - again; `test_scope_error_through_a_context_kwarg_carries_one_breadcrumb_step` - pins that and was verified to fail against the double-prepending shape. -- **(iii) Fold the bindings at compile time**, capturing `cp.context_type`, - `cp.scope`, `cp.provider_id` and `absent_disposition(item)` into the closure - instead of re-reading them per resolve. - -Part (iii) is what is actually blocked. It freezes `cp.scope` and -`cp.context_type` in the closure, and an adversarial review showed that a -`Group` subclass declared after first resolve could restamp a shared -`ContextProvider`'s scope, so the inline would inject an APP-scoped value into a -REQUEST-scoped parameter — silently, with the whole suite green. That specific -hazard has since been closed at its source by `ProviderScopeFrozenError` -(scope is frozen once the provider is registered), which removes the -demonstrated counterexample but does **not** by itself license the capture: -`context_type` is still mutable in principle, and no rule yet says a -`ContextProvider`'s identity is fixed at registration. - -The submitted prototype also failed the gates: +12 executable statements, -coverage 100% → 99% (eight new uncovered lines, five in the cold cached copy), -and two *new* ruff violations (`C901` 15>10, `PLR0912` 14>12) on the hot closure. -The obvious `_navigate`-based implementation of (ii) double-prepended the -resolution-step breadcrumb while CI stayed green — no longer true since (ii) -shipped with `test_scope_error_through_a_context_kwarg_carries_one_breadcrumb_step`, -which turns that shape red. - -## Revisit trigger - -A maintainer ruling that a `ContextProvider`'s `scope` **and** `context_type` are -fixed once it is registered — the same shape as the scope freeze, extended to -identity — **or** context kwargs showing up hot in a profile from a real -integration. That ruling is now the only gate: parts (i) and (ii) have shipped, -so (iii) is all that remains. What it is worth on its own is unmeasured — the --28% above is the full inline, (i) and (ii) included. From f9cb77f9af900335bbf281ae6835b17c4c07493d Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 3 Aug 2026 15:53:25 +0300 Subject: [PATCH 3/3] review fixes: pin both folded copies, correct the freeze contract A 14-mutant sweep found two branches the tests did not actually pin: rewriting the CACHED hop as _navigate passed all 473 tests (only the transient copy was covered), and dropping the TRANSIENT override's "continue" passed too (every transient override test used a parameter with a default, so falling through was a no-op). Both mutants now fail. The breadcrumb test is parametrized over cache=False/True and the override test has a transient twin with a nullable-no-default parameter. The freeze contract overclaimed: ProviderScopeFrozenError only fires for registered providers, but the fold captures scope for any provider reaching plan.context_kwargs -- including one passed only via Factory(kwargs={...}), which is never registered. providers.md now separates what is enforced from what is contract. performance.md hung the _navigate rationale on fetch_context_value, which no Factory closure calls any more; it moves to the folded loops, which also gained the comment in code. Co-Authored-By: Claude Opus 5 --- architecture/performance.md | 29 +++++++++++++----------- architecture/providers.md | 18 ++++++++++----- modern_di/resolver_compiler.py | 3 +++ tests/providers/test_context_provider.py | 22 ++++++++++++++++-- 4 files changed, 51 insertions(+), 21 deletions(-) diff --git a/architecture/performance.md b/architecture/performance.md index 629bc78..902e53b 100644 --- a/architecture/performance.md +++ b/architecture/performance.md @@ -120,21 +120,24 @@ dependencies — the common case — skip navigation entirely via an int compare O(1), and holds ancestors only: a `scope: self` entry would make every container a reference cycle, so none could be freed by refcounting. -`ContextProvider.fetch_context_value` carries the same guard, so a request value -read from the request container costs no navigation frame either. It uses a plain -int compare and **not** the compiler's `_navigate`: that helper prepends a -resolution step, and the calling `Factory` closure prepends its own, so the caller -would appear twice in the breadcrumb. - -A `Factory`'s **context kwargs do not call it at all**. Their `provider_id`, -scope, `context_type` and absent-disposition are folded into the compiled closure, -which does the override guard, the scope compare, the registry read and the -disposition inline — the same int-compare shape, one fewer indirection. Measured -at ~-6% (~42 ns per context kwarg) on `g9_context`. The value itself is still read -live on every resolve; only the binding is frozen, which is licensed by a -registered `ContextProvider`'s identity being fixed +A `Factory`'s **context kwargs** carry the same guard, folded. Each binding's +`provider_id`, scope, `context_type` and absent-disposition are captured at compile +time, and the compiled closure does the override guard, the scope compare, the +registry read and the disposition inline — so a request value read from the request +container costs no navigation frame and no helper frame. Measured at ~-6% (~42 ns +per context kwarg) on `g9_context`. The value itself is still read live on every +resolve; only the binding is frozen, which is licensed by a `ContextProvider`'s +identity being fixed once in use ([providers.md](providers.md#contextprovider--runtime-injected-values)). +Both folded loops use `find_container` and **never** the compiler's `_navigate`: +that helper prepends a resolution step, and the enclosing closure prepends the +factory's own, so the caller would appear twice in the breadcrumb. The loops are +separate copies and each can regress alone, so +`test_scope_error_through_a_context_kwarg_carries_one_breadcrumb_step` is +parametrized over both. `ContextProvider.fetch_context_value` keeps the same +int-compare guard for the direct-resolve path, which is now its only caller. + `Scope._next_deeper` is memoized because it is a constant function of an immutable enum member, consulted per child on the default `build_child_container()` path. diff --git a/architecture/providers.md b/architecture/providers.md index 8ab873f..a24581c 100644 --- a/architecture/providers.md +++ b/architecture/providers.md @@ -141,12 +141,18 @@ and the two paths are independent: whole lookup inline, applying `absent_disposition`'s ruling for an absent value: if the dependent parameter has a default or is nullable it is silently satisfied; otherwise an `ArgumentResolutionError` is raised. -**A registered `ContextProvider`'s identity is fixed.** Its `scope` and its `context_type` are read once, when -its consumer's resolver is compiled, and folded into that closure — so changing either after registration applies -only to resolvers compiled afterwards, and silently. This is the same freeze `ProviderScopeFrozenError` enforces -for group-stamped scope, extended to `context_type` and stated as a contract rather than enforced against direct -attribute assignment. Rebinding `provider.context_type` or `provider.scope` on a registered provider is not -supported; construct a second provider instead. +**A `ContextProvider`'s identity is fixed once something has resolved through it.** Its `scope` and its +`context_type` are read when a consumer's resolver is compiled and folded into that closure, so changing either +afterwards applies only to resolvers compiled later — silently, since neither attribute touches a registry and +so nothing invalidates the memo. How much of that is *enforced* differs by attribute and by route: + +- `scope` on a **registered** provider is enforced against group stamping by `ProviderScopeFrozenError`. +- `scope` on a provider that is never registered — one passed only as `Factory(creator, kwargs={"x": cp})` — + is **not** enforced: `_registered` stays `False`, so a later `Group` may stamp it without error. +- `context_type` is not enforced on either route. + +So this is a contract, not a mechanism: rebinding `provider.scope` or `provider.context_type` on a provider that +is already in use is unsupported. Construct a second provider instead. Either **declaration route** reaches that dependent-parameter path: matched by type from the registry, or passed explicitly as `Factory(creator, kwargs={"request": the_provider})`. `WiringPlan.build` buckets both diff --git a/modern_di/resolver_compiler.py b/modern_di/resolver_compiler.py index 3de07b3..c243670 100644 --- a/modern_di/resolver_compiler.py +++ b/modern_di/resolver_compiler.py @@ -139,6 +139,8 @@ def resolve(container: "Container") -> typing.Any: # noqa: C901, PLR0912 kwargs = {name: r(target) for name, r in prov} if not pure: kwargs.update(static) + # `find_container`, never `_navigate`: that helper prepends a resolution step and + # the `except` below prepends this factory's own, rendering the caller twice. for name, cpid, cscope, ctype, disp, item in ctx: if overrides.has_overrides: override = overrides.fetch_override(cpid) @@ -223,6 +225,7 @@ def build_kwargs(target: "Container") -> dict[str, typing.Any]: if not pure: kwargs.update(static) overrides = target.overrides_registry + # `find_container`, never `_navigate` -- see the transient copy above. for name, cpid, cscope, ctype, disp, item in ctx: if overrides.has_overrides: override = overrides.fetch_override(cpid) diff --git a/tests/providers/test_context_provider.py b/tests/providers/test_context_provider.py index a6f43a5..985afb9 100644 --- a/tests/providers/test_context_provider.py +++ b/tests/providers/test_context_provider.py @@ -465,10 +465,13 @@ def test_kwargs_context_provider_without_parsed_signature_injects_present_value( assert app_container.resolve_provider(_KwargsCtxNoSignatureGroup.out) == f"ctx={now!r}" -def test_scope_error_through_a_context_kwarg_carries_one_breadcrumb_step() -> None: +@pytest.mark.parametrize("cache", [False, True]) +def test_scope_error_through_a_context_kwarg_carries_one_breadcrumb_step(cache: bool) -> None: # The context hop raises a bare scope error and the Factory closure prepends its own step # exactly once. Routing the hop through the compiler's `_navigate`, which prepends a step # itself, would render the factory twice while every other assertion stayed green. + # Parametrized because the lookup is folded into the transient and cached closures + # separately, and each copy can regress on its own. class Cfg: ... @dataclasses.dataclass(kw_only=True, slots=True) @@ -477,7 +480,7 @@ class Svc: class G(Group): cfg = providers.ContextProvider(Cfg, scope=Scope.REQUEST) - svc = providers.Factory(creator=Svc, scope=Scope.APP) + svc = providers.Factory(creator=Svc, scope=Scope.APP, cache=cache) container = Container(scope=Scope.APP, groups=[G]) container.open() @@ -562,6 +565,21 @@ class G(Group): assert container.resolve(_CachedNullable).ctx is sentinel +def test_transient_factory_context_kwarg_uses_override() -> None: + # Twin of the cached test above: the transient closure holds its own copy of the fold, and + # its override branch must `continue`. The parameter is nullable with no default, so falling + # through to the live lookup would overwrite the override with None. + class G(Group): + ctx = providers.ContextProvider(_CachedCtx, scope=Scope.APP) + svc = providers.Factory(creator=_CachedNullable, scope=Scope.APP) + + container = Container(scope=Scope.APP, groups=[G]) + container.open() + sentinel = _CachedCtx() + container.override(G.ctx, sentinel) + assert container.resolve(_CachedNullable).ctx is sentinel + + def test_cached_factory_context_kwarg_absent_and_nullable_injects_none() -> None: class G(Group): ctx = providers.ContextProvider(_CachedCtx, scope=Scope.APP)