diff --git a/modern_di/container.py b/modern_di/container.py index 638a7d7..fdfef94 100644 --- a/modern_di/container.py +++ b/modern_di/container.py @@ -37,8 +37,8 @@ def _handle_recursion_error( ) -> typing.NoReturn: """Convert an escaped `RecursionError` to `CircularDependencyError`, or re-raise it unchanged. - Split out of `resolve_provider` into its own call so the coverage tracer gets a fresh call - boundary to re-arm on before raising. + A separate call, not inlined into `resolve_provider`: the coverage tracer re-arms on the + fresh call boundary before this raises. """ reg = container.providers_registry if reg.is_validated(): @@ -49,8 +49,7 @@ def _handle_recursion_error( raise build_cycle_error(cycle, container) from exc -# Trailing separator included: without it the prefix test also swallows sibling packages -# (`modern_di_fastapi/`, `modern_di_pytest/`, ...), attributing a warning past the integration. +# Trailing separator: without it the prefix test also swallows `modern_di_fastapi/` and friends. _PACKAGE_DIR = str(pathlib.Path(__file__).parent) + os.sep @@ -67,10 +66,9 @@ def _caller_stacklevel() -> int: class Container: """DI container — the central object that resolves providers within a scope. - A root container is created with ``Container(scope=Scope.APP, groups=[...])``; - child containers come from :meth:`build_child_container`. A child shares the - parent's ``providers_registry`` and ``overrides_registry`` but owns its own - ``cache_registry`` and ``context_registry``. + A root is built as ``Container(scope=Scope.APP, groups=[...])``; children come from + :meth:`build_child_container` and share the parent's providers and overrides registries + while owning their own cache and context. """ __slots__ = ( @@ -94,20 +92,16 @@ def __init__( # noqa: PLR0913, PLR0917 use_lock: bool = True, validate: bool | None = None, ) -> None: - """Build a container at ``scope``. + """Build a container at ``scope``, open and ready to :meth:`resolve`. - A container is open from construction — no separate startup step is required - before the first :meth:`resolve`. :meth:`open` and ``with`` / ``async with`` - stay available for reopening a closed container deliberately and for running - finalizers on the way out. - - ``validate`` is ignored and deprecated: passing it (either value) emits + ``context`` seeds the context registry. A root binds :class:`Container` itself, so + ``resolve(Container)`` returns the resolving container. ``validate`` is deprecated and + ignored: passing it emits :class:`~modern_di.exceptions.ValidateArgumentWarning` and changes nothing. - Graph validation (cycles, scope ordering, missing dependencies) runs only - when :meth:`validate` is called explicitly — construction, ``open()``, and - ``resolve()`` never trigger it. ``context`` seeds this container's context - registry. A root container owns fresh registries; a child shares the - parent's providers/overrides registries and inherits its scope map. + + Raises :class:`~modern_di.exceptions.InvalidScopeTypeError` when ``scope`` is not an + ``IntEnum``, and :class:`~modern_di.exceptions.InvalidChildScopeError` when it is not + deeper than ``parent_container``'s. """ if validate is not None: warnings.warn(exceptions.ValidateArgumentWarning(), stacklevel=2) @@ -119,9 +113,8 @@ def __init__( # noqa: PLR0913, PLR0917 self.closed = False self.scope = scope self.parent_container = parent_container - # Ancestors only, never self: a `scope: self` entry would make every container a reference - # cycle, so none could be freed by refcounting. `find_container` short-circuits on its own - # scope before consulting this map, so the self-entry was never read anyway. + # Ancestors only, never self: a `scope: self` entry is a reference cycle, so no container + # would ever be freed by refcounting. self._scope_map: dict[enum.IntEnum, typing_extensions.Self] = ( {**parent_container._scope_map, parent_container.scope: parent_container} # noqa: SLF001 if parent_container @@ -131,9 +124,7 @@ def __init__( # noqa: PLR0913, PLR0917 self.context_registry = ContextRegistry(context=context or {}) self.providers_registry: ProvidersRegistry self.overrides_registry: OverridesRegistry - # Inlined, not a helper: __init__ is on the per-request child-build path - # (see test_resolve_costs_exactly_one_resolver_frame_per_node). A root seeds - # container_provider so `Container` resolves to the resolving container. + # Inlined rather than a helper: this runs per child build (benchmark `test_g6_build_child_container`). if parent_container: self.providers_registry = parent_container.providers_registry self.overrides_registry = parent_container.overrides_registry @@ -154,14 +145,10 @@ def build_child_container( context: dict[type[typing.Any], typing.Any] | None = None, ) -> "typing_extensions.Self": if scope is None: - # `_next_deeper` is the smallest member deeper than this one, so non-contiguous - # custom enums (e.g. TENANT=6, JOB=10) work, not just `value + 1`. scope = _next_deeper(self.scope) if scope is None: raise exceptions.MaxScopeReachedError(parent_scope=self.scope) - # An explicitly-passed scope is not checked here: __init__ rejects a scope that is not - # deeper than its parent's, raising an identical InvalidChildScopeError. return self.__class__(scope=scope, parent_container=self, context=context, use_lock=self._lock is not None) def find_container(self, scope: enum.IntEnum) -> "typing_extensions.Self": @@ -206,12 +193,7 @@ def resolve(self, dependency_type: type[types.T]) -> types.T: _handle_recursion_error(registry._providers[dependency_type], self, exc) # noqa: SLF001 def resolve_dependency(self, dependency: "AbstractProvider[types.T] | type[types.T]") -> types.T: - """Resolve a provider reference or a type — the marker-dispatch entry point for integrations. - - A provider argument goes to :meth:`resolve_provider`; a type argument goes to - :meth:`resolve`. Overrides, caching, and did-you-mean suggestions are inherited - from whichever of the two it dispatches to. - """ + """Resolve a provider reference via :meth:`resolve_provider`, or a type via :meth:`resolve`.""" if isinstance(dependency, AbstractProvider): return self.resolve_provider(dependency) return self.resolve(dependency) @@ -234,7 +216,6 @@ def _walk_errors(self) -> list[Exception]: errors: list[Exception] = [] graph = DependencyGraph() for event in graph.walk(self.providers_registry, self): - # Event is a closed 4-variant union — every variant handled below. match event: case NodeEntered(provider): errors.extend(provider.iter_validation_issues(self)) @@ -257,14 +238,14 @@ def _walk_errors(self) -> list[Exception]: def validate(self) -> None: """Walk the static provider graph and raise on any wiring error. - Checks cycles, transitive scope ordering, and missing/unresolvable dependencies; - every error found is aggregated into a single :class:`~modern_di.exceptions.ValidationFailedError` - rather than raising on the first one. This is the only thing that validates — - construction, :meth:`open`, ``add_providers``, and ``resolve`` never do. + Checks cycles, transitive scope ordering and unresolvable dependencies, aggregating every + error into one :class:`~modern_di.exceptions.ValidationFailedError`. The only thing that + validates — construction, :meth:`open`, :meth:`add_providers` and :meth:`resolve` never + do. A clean walk is memoized until the registry changes. """ reg = self.providers_registry if reg.is_validated(): - return # already validated at this registry state — no re-walk + return validation_errors = self._walk_errors() if validation_errors: @@ -272,15 +253,13 @@ def validate(self) -> None: reg.mark_validated() def add_providers(self, *providers: AbstractProvider[typing.Any]) -> None: - """Register providers on this (root) container after construction. - - The blessed seam for framework integrations that discover providers after the - container is built. Root-only: on a child this raises - :class:`~modern_di.exceptions.ChildContainerRegistrationError`, since the registry - it mutates is shared tree-wide. Registration does not validate; the mutation clears - the registry's validated flag, so a later :meth:`validate` re-walks the new graph. - Registration is a startup-time operation: concurrent calls on the same root are not - coordinated beyond the registry's internal lock. + """Register providers on this root container after construction. + + Root-only: on a child this raises + :class:`~modern_di.exceptions.ChildContainerRegistrationError`, because the registry is + shared tree-wide. Does not validate, but clears the validated flag so a later + :meth:`validate` re-walks. A startup-time operation, not coordinated with concurrent + resolves. """ if self.parent_container is not None: raise exceptions.ChildContainerRegistrationError(scope=self.scope) @@ -305,10 +284,8 @@ def close_sync(self) -> None: def override(self, provider: AbstractProvider[types.T], override_object: types.T) -> OverrideHandle[types.T]: """Apply an override immediately, tree-wide. - Use the returned handle as a context manager to auto-restore the prior state. An override - is compiled in: applying or resetting one drops the compiled resolvers, and the next - resolve recompiles. Overriding is a test-time operation, not coordinated with concurrent - resolves on other threads. + Use the returned handle as a context manager to restore the prior state. A test-time + operation, not coordinated with concurrent resolves on other threads. """ prior = self.overrides_registry.fetch_override(provider.provider_id) self.overrides_registry.override(provider.provider_id, override_object) @@ -325,11 +302,9 @@ def reset_override(self, provider: AbstractProvider[types.T] | None = None) -> N def set_context(self, context_type: type[types.T], obj: types.T) -> None: """Register a runtime context value on *this* container. - Context never propagates between parent and child containers — set it - on the container whose scope matches the ``ContextProvider``. A - **cached** provider (``Factory(cache=...)``) is built once and its - instance is *not* rebuilt by a later ``set_context``; set the context - before its first resolve. + Context never propagates between parent and child — set it on the container whose scope + matches the ``ContextProvider``. A cached provider is built once and is not rebuilt by a + later ``set_context``; set the context before its first resolve. """ self.context_registry.set_context(context_type, obj) @@ -340,20 +315,15 @@ def __repr__(self) -> str: return f"Container(scope={self.scope.name}, parent={parent}, providers={n_providers}, cached={n_cached})" def open(self) -> None: - """Open the container, silently. + """Reopen a closed container silently; a no-op on an open one, and never validates. - Optional: a constructed container is already open. Use it to reopen a closed - container deliberately — an implicit reuse reopens too, but warns. Opening an - open container is a no-op. Validation is not run here; call :meth:`validate`. + Optional: a constructed container is already open, and an implicit reuse reopens too, + with a warning. """ self.closed = False def _prepare(self) -> None: - """Reopen a closed container on implicit reuse, warning the caller. - - Callers guard with ``if closed``. Unlocked: threads racing a closed container - may each warn, but every one of them writes the same ``closed = False``. - """ + """Reopen a closed container on implicit reuse, warning the caller. Callers guard on ``closed``.""" warnings.warn( exceptions.ContainerClosedWarning(container_scope=self.scope), stacklevel=_caller_stacklevel(), diff --git a/modern_di/dependency_graph.py b/modern_di/dependency_graph.py index a62c864..dad2d7f 100644 --- a/modern_di/dependency_graph.py +++ b/modern_di/dependency_graph.py @@ -1,17 +1,8 @@ """Iterative depth-first walk of the static provider graph, emitted as an event stream. -``DependencyGraph.walk`` is the single traversal that other capabilities (validation, -the runtime cycle guard) consume. It is deliberately *explicit-stack* — no recursion — -because a later caller runs it inside a ``RecursionError`` handler near CPython's stack -limit, where headroom for a recursive walk is not guaranteed. - -The graph it walks is ``WiringPlan.edges``: every provider a plan resolves, however that -dependency was declared. Type-matched parameters and providers supplied via -``kwargs={...}`` are edges alike — so what ``validate()`` traverses is exactly what -``resolve()`` follows. - -Import discipline: this module must not import ``Container`` (nor any concrete provider) -at runtime — ``container.py`` imports this module, so a runtime back-import would cycle. +Explicit-stack, never recursive: a caller runs it inside a ``RecursionError`` handler near +CPython's stack limit. Must not import ``Container`` or a concrete provider at runtime — +``container.py`` imports this module, so a back-import would cycle. """ import enum @@ -59,13 +50,10 @@ class DependenciesError(NamedTuple): def terminal_chain( provider: "AbstractProvider[typing.Any]", container: "Container" ) -> "list[AbstractProvider[typing.Any]]": - """Follow ``redirect_target`` hops from ``provider``, returning every provider passed through. + """Follow ``redirect_target`` hops from ``provider``, ``provider`` first. - The single home of the redirect walk: ``validate()``, the compiled alias resolver and the cycle - renderer all read a redirect's terminal from here. Element 0 is always ``provider``; a provider - that redirects nowhere yields a one-element chain. A redirect cycle is broken via the ``seen`` - guard, ending the chain on the starting provider rather than looping forever; ``walk()`` reports - that cycle separately. + A redirect cycle collapses the chain to the single provider the repeat was detected at, so + ``effective_scope`` reports that provider's own scope; ``walk()`` reports the cycle itself. """ chain = [provider] seen: set[int] = set() @@ -84,11 +72,7 @@ def effective_scope(provider: "AbstractProvider[typing.Any]", container: "Contai def redirect_step(provider: "AbstractProvider[typing.Any]", container: "Container") -> "exceptions.ResolutionStep": - """Draw a chain step for a provider that may redirect, at the scope it actually resolves at. - - A redirect declares no scope of its own — ``Alias`` reports the ``Scope.APP`` default — so - rendering ``provider.scope`` would put a band on the chain that nothing in the graph chose. - """ + """Draw a chain step at the scope a possibly-redirecting provider resolves at, not its own default.""" return exceptions.ResolutionStep( scope=effective_scope(provider, container), name=provider.display_name, @@ -102,15 +86,13 @@ def build_cycle_error( ) -> "exceptions.CircularDependencyError": """Build a ``CircularDependencyError`` from a cycle's providers (first node repeated last). - Rotated to start at the minimum-``provider_id`` node before rendering: the seed node the walk - starts from depends on which frame's ``resolve_provider`` caught the ``RecursionError``, but a - rotation of the same ring is the same cycle — anchoring on a stable per-process id makes the - rendered message path- and seed-independent. + Rotated to start at the lowest ``provider_id``, so the message does not depend on which + frame caught the ``RecursionError``. """ - ring = providers[:-1] # drop the repeated first node - lead = min(range(len(ring)), key=lambda i: ring[i].provider_id) # lowest-id node becomes the canonical lead + ring = providers[:-1] + lead = min(range(len(ring)), key=lambda i: ring[i].provider_id) rotated = [*ring[lead:], *ring[:lead]] - canonical = [*rotated, rotated[0]] # re-close the ring on the lead node + canonical = [*rotated, rotated[0]] return exceptions.CircularDependencyError( steps=[ exceptions.ResolutionStep( @@ -129,12 +111,7 @@ def walk( roots: "typing.Iterable[AbstractProvider[typing.Any]]", container: "Container", ) -> "typing.Iterator[Event]": - """Pre-order DFS from each root, emitting the event stream. - - ``visiting``/``visited`` are shared across all roots: a node reached under an - earlier root is neither re-entered nor re-descended when it reappears, and a root - already visited is skipped entirely. All bookkeeping is keyed on ``provider_id``. - """ + """Pre-order DFS from each root; bookkeeping is shared across roots, keyed on ``provider_id``.""" visiting: set[int] = set() visited: set[int] = set() for root in roots: @@ -145,11 +122,7 @@ def find_cycle_from( start: "AbstractProvider[typing.Any]", container: "Container", ) -> "list[AbstractProvider[typing.Any]] | None": - """Return the first cycle reachable from ``start``, or None when that subgraph is acyclic. - - Built on the iterative ``walk``, so it is safe to call from a ``RecursionError`` - handler near CPython's stack limit. - """ + """Return the first cycle reachable from ``start``, or None when that subgraph is acyclic.""" for event in self.walk([start], container): if isinstance(event, Cycle): return event.providers @@ -197,11 +170,7 @@ def _enter( path: "list[AbstractProvider[typing.Any]]", stack: "list[typing.Iterator[tuple[str, AbstractProvider[typing.Any]]]]", ) -> "typing.Iterator[Event]": - """Push ``provider`` onto the active path: emit NodeEntered, then read its deps. - - A ``ResolutionError`` from ``get_dependencies`` is emitted as ``DependenciesError`` - and the node is treated as having no dependencies (the walk continues). - """ + """Push ``provider`` onto the active path; a ``ResolutionError`` from it becomes ``DependenciesError``.""" visiting.add(provider.provider_id) path.append(provider) yield NodeEntered(provider) diff --git a/modern_di/integrations.py b/modern_di/integrations.py index 650ad0e..6e6362f 100644 --- a/modern_di/integrations.py +++ b/modern_di/integrations.py @@ -1,13 +1,8 @@ """Framework-agnostic primitives for building a modern-di integration. -Layer 1 (`bind`, `classify_connection`) derives a child container's scope and -context from one or more `ContextProvider`s. Neither wraps -`build_child_container` — the caller's own call to it stays the single -blessed way to open a child; these functions only decide what to pass it. -Layer 2 (`Marker`, `from_di`, `parse_markers`, `resolve_markers`) is the -`Annotated`-marker injector shared by every integration without a native -per-handler injection seam. `is_injected`/`mark_injected` guard against -double-wrapping a handler an auto-inject sweep visits more than once. +`bind` and `classify_connection` decide what to pass `build_child_container`; they never open a +child themselves. The rest is the `Annotated`-marker injector for integrations with no native +per-handler seam. """ import dataclasses @@ -35,23 +30,14 @@ class ConnectionMatch: def bind(provider: "ContextProvider[typing.Any]", connection: object) -> ConnectionMatch: - """Derive a child's scope and context from one connection bound to one provider. - - `context` is keyed by `provider.context_type` — the same convention - `build_child_container(context=...)` expects. - """ + """Derive a child's scope and context, keyed as `build_child_container(context=...)` expects.""" return ConnectionMatch(scope=provider.scope, context={provider.context_type: connection}) def classify_connection( connection: object, providers: "tuple[ContextProvider[typing.Any], ...]" ) -> ConnectionMatch | None: - """Pick the first provider `connection` is an instance of and `bind` it. - - Returns `None` on no match rather than raising — the caller decides the - fallback, matching every dispatch adapter's existing behavior of building - an auto-scoped, context-less child when nothing matches. - """ + """Pick the first provider `connection` is an instance of and `bind` it; `None` when none matches.""" for provider in providers: if isinstance(connection, provider.context_type): return bind(provider, connection) @@ -70,21 +56,18 @@ def resolve(self, container: "Container") -> types.T_co: def from_di(dependency: "AbstractProvider[types.T] | type[types.T]") -> types.T: - """Marker factory for dependency injection. + """Build the marker for one injected parameter. - Default factory: `Annotated[T, from_di(dep)]` type-checks as `T`. - Integrations with their own per-handler injection seam (native `Depends`) - define their own factory instead; the rest re-export this one. + `Annotated[T, from_di(dep)]` type-checks as `T`. """ return typing.cast(types.T, Marker(dependency)) def parse_markers(func: typing.Callable[..., typing.Any]) -> dict[str, Marker[typing.Any]]: - """Scan `func`'s `Annotated` parameter hints for `Marker`s. + """Scan `func`'s `Annotated` parameter hints for `Marker`s — at decoration time, not per call. - Call once at decoration time, not per call. The first `Marker` found in a - parameter's metadata wins; `return` is never scanned. Unresolvable forward - references propagate `get_type_hints`'s own error unchanged. + The first `Marker` in a parameter's metadata wins; `return` is never scanned. An unresolvable + forward reference propagates `get_type_hints`'s own error. """ hints = typing.get_type_hints(func, include_extras=True) markers: dict[str, Marker[typing.Any]] = {} diff --git a/modern_di/providers/abstract.py b/modern_di/providers/abstract.py index 579b9ac..f7d2d8c 100644 --- a/modern_di/providers/abstract.py +++ b/modern_di/providers/abstract.py @@ -41,10 +41,9 @@ def scope(self) -> enum.IntEnum: return Scope.APP def _stamp_group_scope(self, scope: enum.IntEnum, group_name: str) -> None: - """Record a Group-level default scope; no-op unless this provider's scope is still an unclaimed default. + """Record a Group-level default scope; a no-op unless the scope is still an unclaimed default. - Frozen once registered: a compiled resolver captures `scope`, so a later change would apply - only to resolvers compiled after it. + Frozen once registered: a compiled resolver captures `scope`. """ if not self._takes_group_scope or self._explicit_scope is not None: return @@ -70,11 +69,7 @@ def _stamp_group_scope(self, scope: enum.IntEnum, group_name: str) -> None: @property def display_name(self) -> str: - """Human-readable name for error messages and resolution steps. - - The bound type's name when known, else the provider's repr. ``Factory`` overrides - this to fall back to the creator's name. - """ + """Human-readable name for error messages and resolution steps: the bound type's, else the repr.""" return self.bound_type.__name__ if self.bound_type else repr(self) @property diff --git a/modern_di/providers/context_provider.py b/modern_di/providers/context_provider.py index 694ec2f..9f9bd25 100644 --- a/modern_di/providers/context_provider.py +++ b/modern_di/providers/context_provider.py @@ -10,13 +10,11 @@ class ContextProvider(AbstractProvider[types.T_co]): - """Provider for a runtime value supplied at container-build time. + """Provider for a runtime value passed as ``build_child_container(context={SomeType: value})``. - The value is passed via ``build_child_container(context={SomeType: value})`` - and looked up from the context registry at this provider's bound scope. - Resolving it directly when no value is set raises ``ContextValueNotSetError``; - injecting it into a non-nullable, no-default ``Factory`` parameter instead - raises ``ArgumentResolutionError``. + The value is read from the context registry at this provider's scope. Resolving it with none + set raises ``ContextValueNotSetError``; injecting it into a non-nullable, no-default + ``Factory`` parameter raises ``ArgumentResolutionError`` instead. """ __slots__ = ("context_type",) @@ -37,9 +35,7 @@ def __repr__(self) -> str: return f"ContextProvider(context_type={self.context_type!r}, scope={self.scope!r})" def fetch_context_value(self, container: "Container") -> "types.T_co | types.UnsetType": - # Same-scope int compare before the hop, as the compiled Factory closures do: a request - # value read from the request container skips `find_container`'s frame. Not the compiler's - # `_navigate` — that prepends a resolution step, which the caller then prepends again. + """Read this provider's context value at its own scope, or UNSET when none is set.""" if container.scope != self.scope: container = container.find_container(self.scope) if container.closed: # guarded: `_prepare()` warns and reopens unconditionally diff --git a/modern_di/providers/factory.py b/modern_di/providers/factory.py index 51eb90a..be46e68 100644 --- a/modern_di/providers/factory.py +++ b/modern_di/providers/factory.py @@ -126,11 +126,11 @@ def definition_site(self) -> str | None: return self._cached_definition_site def _compute_definition_site(self) -> str | None: - # The anchor machinery's contract is "never raise": even a pathological creator whose - # attribute access blows up must degrade to an anchor-less step, not mask the real error. - # Sole carve-out: a fresh RecursionError propagates (nothing memoized), because the runtime - # cycle guard computes anchors inside its own RecursionError handler with the stack still - # near-exhausted, and its retry ladder re-converts one frame up with more headroom. + """Compute the creator's ``module:line``, degrading to None rather than masking a real error. + + A ``RecursionError`` is the carve-out: the runtime cycle guard computes anchors inside + its own handler and retries one frame up. + """ try: module = getattr(self._creator, "__module__", None) if module is None: @@ -151,7 +151,6 @@ def _resolution_step(self) -> exceptions.ResolutionStep: def _argument_resolution_error( self, *, arg_name: str, item: SignatureItem, registry: "ProvidersRegistry | None" = None ) -> exceptions.ArgumentResolutionError: - # The context path passes no registry, so absent-context errors carry no suggestions. suggestions = ( suggester.suggest(item.arg_type, registry) if registry is not None and item.arg_type is not None else [] ) @@ -164,18 +163,11 @@ def _argument_resolution_error( ) def _plan(self, container: "Container") -> WiringPlan: - # Memoized on the shared providers registry, so a deeper-scope factory builds its plan once - # tree-wide (see test_resolve_costs_exactly_one_resolver_frame_per_node). Building runs - # outside the container lock — a deterministic function of the registry's contents, so a - # race at worst repeats the build (see tests/test_free_threading.py). + """Return this factory's wiring plan, memoized on the tree-wide providers registry.""" return container.providers_registry.plan_for(self, self._parsed_kwargs, self._kwargs) def get_dependencies(self, container: "Container") -> dict[str, "AbstractProvider[typing.Any]"]: - """Return parameter-name → dependency-provider mapping using only the providers registry. - - Pure lookup: no scope check, no cache touch, no context-value lookup. Used by - Container.validate() to traverse the static graph. - """ + """Return parameter name → dependency provider: a pure registry lookup, no scope or cache touched.""" return self._plan(container).edges def iter_validation_issues(self, container: "Container") -> typing.Iterable[Exception]: diff --git a/modern_di/registries/cache_registry.py b/modern_di/registries/cache_registry.py index 241e9b6..d0c48af 100644 --- a/modern_di/registries/cache_registry.py +++ b/modern_di/registries/cache_registry.py @@ -33,11 +33,8 @@ def get_or_create( ) -> tuple[_V, bool]: """Return the memoized singleton, or resolve-and-create it once under `lock`. - Two phases: `resolve()` runs unlocked (recursive dependency resolution must not - hold the lock); creation and the store run under `lock`, double-checked so at - most one caller creates. `lock` is the resolving container's `RLock` (or None - when the container was built with `use_lock=False`). Returns `(value, created)`; - `created` is True only when this call ran `create`. + `resolve()` runs unlocked — recursive resolution must not hold the lock; creation and + the store are double-checked under it. `created` is True only for the caller that built. """ if self.cache is not types.UNSET: return self.cache, False @@ -86,11 +83,7 @@ def cached_count(self) -> int: return sum(1 for item in self._items.values() if item.cache is not types.UNSET) def fetch_cache_item(self, provider: Factory[types.T_co]) -> CacheItem: - # Get before setdefault: a plain setdefault eagerly builds a throwaway CacheItem on every - # hit (see test_cached_resolver_has_no_cell_on_the_warm_path). The creation path keeps - # setdefault, whose atomicity is what makes concurrent first-resolvers share one CacheItem - # — and it runs outside the container lock, because the singleton cache and its - # double-checked lock live on that object. + # Get before setdefault: a bare setdefault builds a throwaway CacheItem on every hit. item = self._items.get(provider.provider_id) if item is not None: return item diff --git a/modern_di/registries/context_registry.py b/modern_di/registries/context_registry.py index b75ab7b..77483f6 100644 --- a/modern_di/registries/context_registry.py +++ b/modern_di/registries/context_registry.py @@ -9,8 +9,7 @@ class ContextRegistry: context: dict[type[typing.Any], typing.Any] def find_context(self, context_type: type[types.T]) -> "types.T | types.UnsetType": - # `in` + `[]` rather than `.get(key, UNSET)`: two specialized opcodes beat one method call - # with a default, and they keep honouring a dict subclass's `__contains__`/`__getitem__`. + # Not `.get(key, UNSET)`: that skips a dict subclass's `__contains__`/`__getitem__`. if context_type in self.context: return self.context[context_type] return types.UNSET diff --git a/modern_di/registries/overrides_registry.py b/modern_di/registries/overrides_registry.py index b48fe17..fff0729 100644 --- a/modern_di/registries/overrides_registry.py +++ b/modern_di/registries/overrides_registry.py @@ -7,11 +7,10 @@ @dataclasses.dataclass(kw_only=True, slots=True) class OverridesRegistry: - """Test-time replacement values by provider id. + """Test-time replacement values by provider id, applied at compile time. - Overrides are applied at compile time: every change calls ``on_change`` so the owning - providers registry drops its compiled resolvers, and the next resolve recompiles with the - override baked in. Nothing consults this registry on the resolve path. + Every change calls ``on_change``, the owning registry drops its compiled resolvers, and the + next resolve recompiles with the override baked in. Nothing reads this on the resolve path. """ on_change: typing.Callable[[], None] @@ -35,11 +34,7 @@ def fetch_override(self, provider_id: int) -> object: class OverrideHandle(typing.Generic[types.T]): - """Context-manager handle returned by ``Container.override``. - - The override is already active when the handle is created; ``__exit__`` restores the - snapshot taken at creation — the prior override, or no override. Single-use contract. - """ + """Single-use context-manager handle from ``Container.override``; ``__exit__`` restores the prior state.""" __slots__ = ("_prior", "_provider_id", "_registry", "override_object") diff --git a/modern_di/registries/providers_registry.py b/modern_di/registries/providers_registry.py index d1b9d8b..8a0a74c 100644 --- a/modern_di/registries/providers_registry.py +++ b/modern_di/registries/providers_registry.py @@ -34,7 +34,7 @@ def __init__(self) -> None: self._resolvers: dict[int, typing.Callable[[Container], typing.Any]] = {} self._resolvers_by_type: dict[type, typing.Callable[[Container], typing.Any]] = {} self.overrides = OverridesRegistry(on_change=self.drop_resolvers) - self._building = threading.local() # per-thread compile-in-flight set; the cycle guard is per-call-stack + self._building = threading.local() self._validated = False self._generation = 0 @@ -63,17 +63,13 @@ def plan_for( ) -> "WiringPlan": """Return `provider`'s memoized wiring plan, building it on a miss. - A plan is a pure function of the provider and this registry's contents, memoized per - `provider_id` and cleared whenever the registry mutates (`register` / `add_providers`). - Shared tree-wide: a container and every child share one registry, so a - deeper-scope provider builds its plan once, not once per child. Build inputs are passed - by value (not a closure) so the hot cache-hit path allocates nothing. + The memo is tree-wide and dropped on every registry mutation. """ provider_id = provider.provider_id cached = self._plans.get(provider_id) if cached is not None: return cached - generation = self._generation # read before building; a mutation during it bumps this + generation = self._generation plan = WiringPlan.build(parsed_kwargs=parsed_kwargs, kwargs=kwargs, registry=self, owner=provider) with self._lock: if self._generation == generation: @@ -81,11 +77,7 @@ def plan_for( return plan def _building_set(self) -> set[int]: - """Return the current thread's in-flight-compile set (the cycle guard). - - Per-call-stack: a concurrent first-resolve of the same provider on another thread compiles it - independently (an idempotent duplicate) instead of being misread as a dependency cycle. - """ + """Return this thread's in-flight-compile set; per-thread, so a concurrent compile is not a cycle.""" building: set[int] | None = getattr(self._building, "value", None) if building is None: building = set() @@ -93,12 +85,10 @@ def _building_set(self) -> set[int]: return building def resolver_for(self, provider: "AbstractProvider[typing.Any]") -> "typing.Callable[[Container], typing.Any]": - """Return `provider`'s memoized compiled resolver, building it cycle-safely on a miss. + """Return `provider`'s memoized compiled resolver, building it on a miss. - Memoized per `provider_id` and cleared on registry mutation, exactly like `plan_for`. A - back-edge to a provider whose resolver is still being built (a cycle) captures a thunk that - routes through the runtime `resolve_provider`, so a genuine cycle still raises - `RecursionError` -> `CircularDependencyError`. + A back-edge to a provider still being compiled captures a thunk routed through the + runtime `resolve_provider`, so a genuine cycle still raises `CircularDependencyError`. """ pid = provider.provider_id cached = self._resolvers.get(pid) @@ -106,26 +96,22 @@ def resolver_for(self, provider: "AbstractProvider[typing.Any]") -> "typing.Call return cached building = self._building_set() if pid in building: - return lambda c: c.resolve_provider(provider) # back-edge: route the cycle through runtime + return lambda c: c.resolve_provider(provider) building.add(pid) - generation = self._generation # read before compiling; a mutation during it bumps this + generation = self._generation try: resolver = compile_resolver(provider, self) finally: building.discard(pid) with self._lock: - # Publish only if no mutation landed while we compiled. Otherwise this resolver was - # built against a registry that no longer exists, and memoizing it would strand it - # past the `_invalidate()` that was supposed to drop it. + # Publish only if no mutation landed while we compiled; memoizing a resolver built + # against the old registry would strand it past the `_invalidate()` meant to drop it. if self._generation == generation: self._resolvers[pid] = resolver return resolver def resolver_for_type(self, dependency_type: type) -> "typing.Callable[[Container], typing.Any]": - """Return the memoized resolver for the provider bound to `dependency_type`, compiling it on a miss. - - Raises `ProviderNotRegisteredError` (with did-you-mean suggestions) when nothing is bound. - """ + """Return the resolver bound to `dependency_type`; raises `ProviderNotRegisteredError` when unbound.""" generation = self._generation provider = self._providers.get(dependency_type) if provider is None: @@ -139,10 +125,7 @@ def resolver_for_type(self, dependency_type: type) -> "typing.Callable[[Containe return resolver def drop_resolvers(self) -> None: - """Drop the compiled resolvers so the next resolve recompiles — the overrides changed. - - Plans and the validation flag survive: overrides alter neither the wiring nor the static graph. - """ + """Drop the compiled resolvers — the overrides changed. Plans and the validation flag survive.""" with self._lock: self._resolvers.clear() self._resolvers_by_type.clear() @@ -170,20 +153,14 @@ def add_providers(self, *args: AbstractProvider[typing.Any]) -> None: if provider_type in self._providers: raise exceptions.DuplicateProviderTypeError(provider_type=provider_type) self._providers.update(new_providers) - # Only once the registration has actually succeeded, and over `args` rather than - # `new_providers`: a reference-only provider never enters `_providers`, but its - # resolver is still compiled and still captures its scope. + # Over `args`, not `new_providers`: a reference-only provider never enters + # `_providers`, but its resolver is still compiled and still captures its scope. for provider in args: provider._registered = True # noqa: SLF001 self._invalidate() def _invalidate(self) -> None: - """Drop the memoized plans/resolvers and the validation flag — the registry changed. - - Called under `self._lock` by every mutation. Clearing has the same breadth the old version - bump did (a bump invalidated every memo anyway) and frees stale entries eagerly. Sound - because mutation is a single-threaded configure-phase operation (see tests/test_free_threading.py). - """ + """Drop every memo and the validation flag — the registry changed. Called under `self._lock`.""" self._plans.clear() self._resolvers.clear() self._resolvers_by_type.clear() diff --git a/modern_di/scope.py b/modern_di/scope.py index 01220c6..4a11849 100644 --- a/modern_di/scope.py +++ b/modern_di/scope.py @@ -2,12 +2,11 @@ class Scope(enum.IntEnum): - """Lifetime bands, ordered shallow → deep by integer value. + """The scopes a provider can be bound to, ordered shallow → deep by integer value. - A provider bound to a scope resolves only from a container at the same or a - deeper scope (higher integer); resolving it from a shallower container raises - ``ScopeNotInitializedError``. The members below are the defaults — the - ordering rule is what matters, and custom ``IntEnum`` scopes are allowed. + A provider resolves only from a container at the same or a deeper scope; from a shallower one + it raises ``ScopeNotInitializedError``. These members are the defaults — the ordering rule is + what matters, and any custom ``IntEnum`` works as a scope. """ APP = 1 @@ -18,30 +17,19 @@ class Scope(enum.IntEnum): def _deeper_members(scope: enum.IntEnum) -> list[enum.IntEnum]: - """Members of ``scope``'s own enum that are deeper than it, shallowest first. - - Takes any ``IntEnum``, not just :class:`Scope`: Python forbids extending an enum that - has members, so a custom scope is a standalone ``IntEnum`` and this rule could never - reach it as a method. - """ + """Members of ``scope``'s own enum that are deeper than it, shallowest first.""" return sorted(member for member in type(scope) if member > scope) -# Memo for `_next_deeper`: a constant function of an immutable enum member, called per child -# on the default `build_child_container()` (auto-increment) path — uncached it re-sorts the -# whole enum every time. Keyed by `(type(scope), scope)`, NOT the bare member: `IntEnum` -# members compare and hash by integer value, so two custom scopes reusing a value (TENANT=6 -# in one enum, 6 in another) would collide under a plain member key; the type disambiguates. -# Bounded by the finite set of scope members ever passed. Concurrent writes are benign — the -# value is deterministic, so a race just stores the same result twice (dict setitem is atomic). +# Keyed by the enum type as well as the member: `IntEnum` members hash by integer value, so +# two custom scopes reusing a value (TENANT=6 in one enum, 6 in another) would collide. _next_deeper_memo: dict[tuple[type[enum.IntEnum], enum.IntEnum], enum.IntEnum | None] = {} def _next_deeper(scope: enum.IntEnum) -> enum.IntEnum | None: """Return the next deeper member, or None when ``scope`` is the deepest. - Returns None rather than raising ``MaxScopeReachedError`` so this module stays - dependency-free: ``exceptions`` imports it, so importing ``exceptions`` back would cycle. + None rather than ``MaxScopeReachedError``: ``exceptions`` imports this module. """ key = (type(scope), scope) if key not in _next_deeper_memo: diff --git a/modern_di/suggester.py b/modern_di/suggester.py index 06d904b..f2b34de 100644 --- a/modern_di/suggester.py +++ b/modern_di/suggester.py @@ -14,12 +14,10 @@ @dataclasses.dataclass(frozen=True, slots=True) class Suggestion: - """A candidate the caller probably meant, as data. + """A candidate the caller probably meant, as data; ``exceptions`` owns the rendering. - Carries no formatting: rendering a suggestion to a bullet is ``exceptions``' job, so - one module owns the glyphs. ``scope`` is None when the suggestion names something with - no provider behind it (a creator's keyword argument); ``reason`` is None when there is - nothing to say beyond the name. + ``scope`` is None when the name has no provider behind it, ``reason`` when there is nothing + to say beyond the name. """ name: str @@ -28,12 +26,10 @@ class Suggestion: def suggest(requested_type: type, providers: "typing.Iterable[AbstractProvider[typing.Any]]") -> list[Suggestion]: - """Candidates the caller may have meant for ``requested_type``, best first, as data. + """Candidates the caller may have meant for ``requested_type``, best first, capped at three. - Class hierarchy hints (a registered subclass or base class of the requested type) come - first, then fuzzy name matches, capped at ``_MAX_SUGGESTIONS``. Rendering belongs to - ``exceptions``; this returns records, never bullets. ``providers`` is read by duck typing - on ``bound_type``/``scope`` (annotated under ``TYPE_CHECKING`` to avoid an import cycle). + A registered subclass or base class first, then fuzzy name matches. ``providers`` is read by + duck typing on ``bound_type``/``scope`` to avoid an import cycle. """ requested_is_class = inspect.isclass(requested_type) requested_name = getattr(requested_type, "__name__", str(requested_type)) @@ -83,9 +79,5 @@ def _hierarchy_hint(requested_type: type, provider: "AbstractProvider[typing.Any def close_matches(target: str, candidates: typing.Iterable[str], *, n: int, cutoff: float = 0.6) -> list[str]: - """Fuzzy-match ``target`` against ``candidates``; best ``n`` at/above ``cutoff``. - - Thin wrapper over ``difflib.get_close_matches``. Shared by ``suggest`` (provider name - typos) and ``UnknownFactoryKwargError`` (kwarg-key typos). - """ + """Fuzzy-match ``target`` against ``candidates``; the best ``n`` at or above ``cutoff``.""" return difflib.get_close_matches(target, list(candidates), n=n, cutoff=cutoff) diff --git a/modern_di/types.py b/modern_di/types.py index d4518fb..e7c971e 100644 --- a/modern_di/types.py +++ b/modern_di/types.py @@ -7,10 +7,9 @@ class UnsetType: - """Sentinel type for parameters that distinguish 'not passed' from 'explicitly None'. + """Sentinel type separating 'not passed' from 'explicitly None'. - The :data:`UNSET` module-level instance is the canonical sentinel. Use - ``isinstance(value, UnsetType)`` or ``value is UNSET`` to detect it. + :data:`UNSET` is the canonical instance; detect it with ``value is UNSET``. """ def __repr__(self) -> str: diff --git a/modern_di/types_parser.py b/modern_di/types_parser.py index cf140b4..ac0a479 100644 --- a/modern_di/types_parser.py +++ b/modern_di/types_parser.py @@ -20,9 +20,8 @@ class SignatureItem: @classmethod def from_type(cls, type_: type, default: object = UNSET) -> "SignatureItem": if type_ is types.NoneType: - # The degenerate nullable — a union with zero non-None members. Handled here - # rather than by the union branch below, which would take it for a plain type - # and try to resolve `NoneType` from the registry. + # The degenerate nullable: the union branch below would take it for a plain type and + # try to resolve `NoneType` from the registry. return cls(default=default, is_nullable=True) # typing.Annotated @@ -33,9 +32,8 @@ def from_type(cls, type_: type, default: object = UNSET) -> "SignatureItem": # union type if isinstance(type_, types.UnionType) or typing.get_origin(type_) is typing.Union: - # A parameterized generic member degrades to its origin (list[str] -> list); the - # element type is not enforced. Intentional asymmetry, not a wiring guarantee -- - # see test_union_member_degrades_to_bare_origin. + # A parameterized generic member degrades to its origin (list[str] -> list); see + # test_union_member_degrades_to_bare_origin. union_members = [typing.get_origin(x) or x for x in typing.get_args(type_)] non_none_members = [member for member in union_members if member is not types.NoneType] if len(non_none_members) != len(union_members): @@ -64,8 +62,7 @@ def _parse_parameter( ) -> SignatureItem | None: if param.kind is inspect.Parameter.POSITIONAL_ONLY: if param.default is not param.empty: - # None is a signal, not "no item": parse_creator reads it as a positional-only gap - # (drops the param and sets has_positional_only_gap); the creator's own default fills it. + # None is a signal, not "no item": parse_creator reads it as a positional-only gap. return None raise exceptions.UnsupportedCreatorParameterError( creator=creator, @@ -85,8 +82,6 @@ def _parse_parameter( else: item = SignatureItem(default=default) if param.kind is inspect.Parameter.KEYWORD_ONLY: - # The one param-kind signal the compiled positional fast path consults: a keyword-only - # parameter can never be passed positionally, so its provider must stay on the kwargs call. return dataclasses.replace(item, is_keyword_only=True) return item @@ -126,8 +121,8 @@ def parse_creator( continue item = _parse_parameter(creator, param_name, param, type_hints) if item is None: - # positional-only-with-default: dropped from param_hints, so a positional creator() - # call would bind a later dependency into this slot -> fast path must keep **kwargs. + # Dropped from param_hints, so a positional creator() call would bind a later + # dependency into this slot; the fast path must keep **kwargs. has_positional_only_gap = True continue param_hints[param_name] = item diff --git a/modern_di/wiring.py b/modern_di/wiring.py index 2614643..a4ccf7a 100644 --- a/modern_di/wiring.py +++ b/modern_di/wiring.py @@ -1,10 +1,4 @@ -"""Kwarg-wiring decision for Factory providers. - -``WiringPlan`` partitions a creator's parsed parameters into provider -lookups, static values, and context lookups. It is a pure function of its -inputs — no cache, no scope, no live context — so it is exercisable without -a Container. -""" +"""Kwarg-wiring decision for Factory providers: a pure function of the signature and the registry.""" import dataclasses import enum @@ -28,10 +22,7 @@ class _Absent(enum.Enum): def absent_disposition(item: SignatureItem) -> _Absent: - """Decide the disposition for a parameter with no matching provider. - - Precedence: default before nullable before unwirable. - """ + """Disposition for a parameter with no matching provider: default, then nullable, then unwirable.""" if item.default is not UNSET: return _Absent.OMIT if item.is_nullable: @@ -44,11 +35,7 @@ def find_dep_provider( owner: "Factory[typing.Any]", item: SignatureItem, ) -> "AbstractProvider[typing.Any] | None": - """Look up a dependency provider for *item* in *registry*, excluding *owner* itself. - - Prefers ``arg_type``; falls back to the first matching type in ``args`` - (union members). - """ + """Look up a dependency provider for *item*, excluding *owner*: ``arg_type``, else a union member.""" if item.arg_type is not None: provider = registry.find_provider(item.arg_type) if provider is owner: @@ -63,22 +50,11 @@ def find_dep_provider( @dataclasses.dataclass(frozen=True, slots=True) class WiringPlan: - """Immutable result of partitioning a creator's parameters. - - Attributes: - provider_kwargs: name → provider resolved live each resolve call. - static_kwargs: name → literal value (including nullable-None). - context_kwargs: name → (ContextProvider, SignatureItem) looked up live. - unwireable: UNWIRABLE parameters as (param-name, SignatureItem) - records rather than pre-built exceptions, so a fresh - ``ArgumentResolutionError`` can be constructed at - each raise/yield site without ``prepend_step`` - mutations compounding across resolves of the same - memoized plan. - pure_provider: True when the plan has no static and no context - kwargs, so resolve can build the kwargs dict from - provider_kwargs alone — the common fast path. + """Immutable result of partitioning a creator's parameters into wiring buckets. + ``pure_provider`` means no static and no context kwargs, so the call can be built from + ``provider_kwargs`` alone. ``unwireable`` holds records rather than pre-built exceptions: a + plan is memoized, and ``prepend_step`` mutates the error it is called on. """ provider_kwargs: dict[str, "AbstractProvider[typing.Any]"] @@ -89,12 +65,7 @@ class WiringPlan: @property def edges(self) -> dict[str, "AbstractProvider[typing.Any]"]: - """Every provider this plan resolves — the graph ``validate()`` traverses. - - Derived from the buckets ``resolve()`` reads, so the validated graph cannot - drift from the resolved one. Providers supplied via ``kwargs={...}`` are edges - like any other: only the *declaration* differs, not the dependency. - """ + """Every provider this plan resolves — derived from the buckets ``resolve()`` reads.""" return { **self.provider_kwargs, **{name: provider for name, (provider, _item) in self.context_kwargs.items()}, @@ -109,11 +80,7 @@ def build( registry: "ProvidersRegistry", owner: "Factory[typing.Any]", ) -> "WiringPlan": - """Partition *parsed_kwargs* into wiring buckets. Never raises. - - Two phases: a by-type pass over ``parsed_kwargs``, then an overlay pass for any - explicit ``kwargs={...}`` entries — each bucketing into the same four dicts. - """ + """Partition *parsed_kwargs* by type, then overlay ``kwargs={...}``. Never raises.""" provider_kwargs, static_kwargs, context_kwargs, unwireable = cls._wire_by_type( parsed_kwargs=parsed_kwargs, kwargs=kwargs, @@ -151,11 +118,7 @@ def _wire_by_type( dict[str, "tuple[ContextProvider[typing.Any], SignatureItem]"], "list[tuple[str, SignatureItem]]", ]: - """Bucket each parsed parameter by resolving its type; the overlay pass runs after. - - Returns the four buckets ``(provider, static, context, unwireable)``. A name also present - in explicit ``kwargs={...}`` is skipped here — ``_apply_overlay`` owns it. - """ + """Bucket each parsed parameter by type; a name in ``kwargs={...}`` is left to the overlay.""" provider_kwargs: dict[str, AbstractProvider[typing.Any]] = {} static_kwargs: dict[str, typing.Any] = {} context_kwargs: dict[str, tuple[ContextProvider[typing.Any], SignatureItem]] = {} @@ -163,7 +126,7 @@ def _wire_by_type( for name, item in parsed_kwargs.items(): if kwargs and name in kwargs: - continue # supplied as a static kwarg by the overlay pass + continue provider = find_dep_provider(registry, owner, item) if provider is not None: @@ -179,7 +142,6 @@ def _wire_by_type( if disposition is _Absent.NULL: static_kwargs[name] = None continue - # UNWIRABLE: record the (name, item) pair but do not raise unwireable.append((name, item)) return provider_kwargs, static_kwargs, context_kwargs, unwireable @@ -193,12 +155,10 @@ def _apply_overlay( static_kwargs: dict[str, typing.Any], context_kwargs: dict[str, "tuple[ContextProvider[typing.Any], SignatureItem]"], ) -> None: - """Bucket each explicit ``kwargs={...}`` entry into the buckets built by the by-type pass. + """Bucket each explicit ``kwargs={...}`` entry into the buckets the by-type pass built. - A ``ContextProvider`` joins ``context_kwargs`` with its parameter's ``SignatureItem``, so an - unset value honors the default/nullable exactly as the by-type route does. With no parsed - item (a ``**kwargs`` creator, ``skip_creator_parsing=True``) there is no default to honor, so - it stays a plain provider and keeps the direct-resolve semantics. + A ``ContextProvider`` carries its ``SignatureItem`` so an unset value honors the default + or nullable; with no parsed item it stays a plain provider and resolves directly. """ for name, value in kwargs.items(): item = parsed_kwargs.get(name) diff --git a/tests/providers/test_container_provider.py b/tests/providers/test_container_provider.py index e33f8a8..b76d433 100644 --- a/tests/providers/test_container_provider.py +++ b/tests/providers/test_container_provider.py @@ -28,8 +28,8 @@ class MyGroup(Group): def test_container_provider_override_direct() -> None: - # Overriding the container provider and resolving it directly exercises the compiled - # container-provider resolver's own override front-guard (dispatch no longer checks centrally). + # An override of the container provider compiles to a constant resolver, so resolving it + # directly returns the override rather than the resolving container. app_container = Container() app_container.open() app_container.override(providers.container_provider, "mock-container") diff --git a/tests/providers/test_context_provider.py b/tests/providers/test_context_provider.py index 705315a..8929a53 100644 --- a/tests/providers/test_context_provider.py +++ b/tests/providers/test_context_provider.py @@ -381,9 +381,8 @@ def test_context_provider_rejects_context_type_passed_twice() -> None: def test_context_provider_override_direct_short_circuits() -> None: - # Overriding a ContextProvider and resolving it DIRECTLY exercises the compiled context-provider - # resolver's own override front-guard: the override wins with no ContextValueNotSetError raised, - # even though no value is set in the context registry. + # An override of a ContextProvider compiles to a constant resolver, so resolving it directly + # returns the override with no ContextValueNotSetError, even with nothing in the registry. override_value = datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc) app_container = Container(groups=[MyGroup]) app_container.open() @@ -479,9 +478,10 @@ def test_kwargs_context_provider_without_parsed_signature_injects_present_value( def test_scope_error_through_a_context_kwarg_carries_one_breadcrumb_step(cache: bool) -> None: """INVARIANT: a scope error through a context kwarg carries exactly one breadcrumb step. - The folded loops call `find_container`, never the compiler's `_navigate` -- that helper prepends - a step and the enclosing closure prepends the factory's own, so the caller would appear twice. - The cached and transient loops are separate copies, which is why this is parametrized. + The context fold calls `find_container`, never the compiler's `_navigate` -- that helper + prepends a step and the generated resolver prepends the factory's own, so the caller would + appear twice. The cached and transient templates carry separate copies of the fold, which is + why this is parametrized. """ class Cfg: ... @@ -506,7 +506,7 @@ class G(Group): def test_same_scope_context_hop_does_not_call_find_container(monkeypatch: pytest.MonkeyPatch) -> None: """INVARIANT: a same-scope context kwarg costs no navigation. - The compiled closure folds the scope compare inline. Replacing it with an unconditional + The generated resolver folds the scope compare inline. Replacing it with an unconditional `find_container` call adds a frame per context kwarg to the hottest path. """ @@ -531,8 +531,8 @@ class G(Group): assert isinstance(request.resolve(Svc), Svc) assert calls == [] - # The cross-scope hop must still route through find_container, which is the blessed - # extension point 2026-08-01-scope-map-inline-declined.md protects. + # The cross-scope hop must still route through find_container: a Container subclass may + # redirect navigation, which an inlined `_scope_map` read would bypass. class AppCfg: ... @dataclasses.dataclass(kw_only=True, slots=True) @@ -552,8 +552,8 @@ class G2(Group): 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. +# The context fold is generated into both the cached and the transient template, so the cached +# copy needs its own coverage of every disposition -- the transient copy's tests do not reach it. class _CachedCtx: ... @@ -582,9 +582,9 @@ class G(Group): 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. + # Twin of the cached test above, against the transient template's own copy of the fold. An + # overridden context kwarg is compiled into `static`, not into the fold; the parameter is + # nullable with no default, so leaking it into the fold would overwrite the override with None. class G(Group): ctx = providers.ContextProvider(_CachedCtx, scope=Scope.APP) svc = providers.Factory(creator=_CachedNullable, scope=Scope.APP) diff --git a/tests/providers/test_factory.py b/tests/providers/test_factory.py index 55803b9..6778a1f 100644 --- a/tests/providers/test_factory.py +++ b/tests/providers/test_factory.py @@ -876,11 +876,10 @@ def __call__(self) -> int: def test_definition_site_recursion_error_propagates_for_guard_retry() -> None: - # A fresh RecursionError must NOT be swallowed (unlike every other exception): the runtime + # A fresh RecursionError must NOT be swallowed, unlike every other exception: the runtime # cycle guard computes anchors inside its own `except RecursionError` handler with the stack - # still near-exhausted, and its retry ladder (resolve_provider re-converting one frame up) - # only works if the fresh RecursionError propagates. Swallowing it here would memoize None - # and permanently strip the anchors off runtime-detected cycles. + # still near-exhausted, and retries one frame up. Swallowing it here would memoize None and + # permanently strip the anchors off runtime-detected cycles. class _StackExhausted: __module__ = "mymod" @@ -1122,8 +1121,8 @@ class G(Group): def test_unwireable_factory_override_short_circuits() -> None: - # An unwireable factory (missing required `dep1: str`) can still be overridden with a mock: the - # compiled unwireable resolver's own override front-guard returns it instead of raising. + # An unwireable factory (missing required `dep1: str`) can still be overridden: the override + # compiles to a constant resolver, so the always-raising one is never built. class G(Group): thing = providers.Factory(creator=SimpleCreator, bound_type=None) @@ -1162,8 +1161,8 @@ class G(Group): def test_transient_positional_binding_typeerror_wraps() -> None: # Transient mirror of test_cached_positional_binding_typeerror_wraps: skip_creator_parsing -> 0 - # parsed args -> positional-eligible, but the creator needs one. resolve_positional must wrap the - # binding TypeError. Fills the one matrix cell (transient positional, binding) left uncovered. + # parsed args -> positional-eligible, but the creator needs one. The transient template's + # creator call must wrap the binding TypeError. class G(Group): thing = providers.Factory( creator=_cov_needs_one_arg, diff --git a/tests/test_container.py b/tests/test_container.py index e6fb5e7..e941524 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -997,7 +997,7 @@ def test_construction_never_validates() -> None: Constructing a container from a cyclic group raises nothing; only the later `validate()` call surfaces `ValidationFailedError`. An `__init__` that walked eagerly is the split-validation - machinery `2026-07-26-explicit-only-validation.md` built and discarded. + machinery "Validation is explicit" (docs/introduction/design-decisions.md) discarded. """ container = Container(scope=Scope.APP, groups=[CycleGroup]) # a cycle: no raise here any more with pytest.raises(ValidationFailedError): @@ -1010,7 +1010,7 @@ def test_add_providers_never_validates_and_does_not_roll_back() -> None: `add_providers` registers `Broken` quietly -- no raise, no rollback -- even onto a registry already marked validated; only the next explicit `validate()` call surfaces `ValidationFailedError`. An `add_providers` that walked eagerly is the rollback path - `2026-07-26-explicit-only-validation.md` built and discarded. + "Validation is explicit" (docs/introduction/design-decisions.md) discarded. """ @dataclasses.dataclass(kw_only=True, slots=True) @@ -1035,9 +1035,9 @@ def test_open_never_validates() -> None: """INVARIANT: `validate()` is the only thing that walks the graph. `open()` on a cyclic graph raises nothing, neither called directly nor entered via the context - manager. Binding validation to `open()` was 3.0's design, discarded per - `2026-07-26-explicit-only-validation.md` after the root's open hook not firing in some execution - contexts caused six production defects. + manager. Binding validation to `open()` was 3.0's design, discarded per "Validation is + explicit" (docs/introduction/design-decisions.md) after the root's open hook not firing in + some execution contexts caused six production defects. """ container = Container(scope=Scope.APP, groups=[CycleGroup]) container.open() # no raise @@ -1050,8 +1050,8 @@ def test_resolve_never_validates() -> None: Resolving `_DeferBrokenService` on an unvalidated broken graph raises `ArgumentResolutionError` for the one missing dependency, not `ValidationFailedError` for the whole graph -- proving - `resolve()` never walks looking for other errors. Making it validate first is the per-resolve tax - `2026-07-26-explicit-only-validation.md` rejected. + `resolve()` never walks looking for other errors. Making it validate first is the per-resolve + tax "Validation is explicit" (docs/introduction/design-decisions.md) rejected. """ container = Container(scope=Scope.APP, groups=[_DeferBrokenGroup]) with pytest.raises(ArgumentResolutionError): diff --git a/tests/test_resolver_compiler.py b/tests/test_resolver_compiler.py index b4449da..b0a06c4 100644 --- a/tests/test_resolver_compiler.py +++ b/tests/test_resolver_compiler.py @@ -185,10 +185,8 @@ class G(Group): @pytest.mark.parametrize("arity", [0, 1, 2, 3, 4]) def test_positional_path_binds_args_in_signature_order_at_every_arity(arity: int) -> None: - # The positional path compiles a separate closure at arity 0 and 1, plus the generic - # star-call for 2+; each binds its own arguments and can regress alone. Arities 2, 3 and 4 - # all exercise that one generic closure -- kept because binding order at higher arity is - # worth asserting, not because they are separate rungs. The types are distinct, so a + # Each arity is its own generated source: the template unrolls one `a{i} = r{i}(target)` line + # per dependency and calls the creator with them in that order. The types are distinct, so a # misordered binding lands a _P1 in .p0 and the assertion fails. types_ = [_P0, _P1, _P2, _P3][:arity] params = ", ".join(f"p{i}: _P{i}" for i in range(arity))