Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions architecture/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,23 @@ 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** 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.
Expand Down
20 changes: 16 additions & 4 deletions architecture/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,22 @@ 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 `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
Expand Down
11 changes: 7 additions & 4 deletions architecture/resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
28 changes: 1 addition & 27 deletions modern_di/providers/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand Down
63 changes: 52 additions & 11 deletions modern_di/resolver_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand All @@ -129,10 +139,24 @@ 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)
# `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)
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
Expand All @@ -157,12 +181,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)

Expand Down Expand Up @@ -197,10 +224,24 @@ 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
# `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)
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
Expand Down
73 changes: 0 additions & 73 deletions planning/deferred/2026-08-01-context-kwarg-inline.md

This file was deleted.

Loading
Loading