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
2 changes: 1 addition & 1 deletion benchmarks/test_guard_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def test_g6_build_child_container(benchmark):


def test_g6b_build_child_container_auto_scope(benchmark):
# Default path: no explicit scope -> auto-increment via _next_deeper. G6 passes an explicit
# Default path: no explicit scope -> auto-increment via next_deeper. G6 passes an explicit
# scope and never exercises it; this guards the memoized auto-increment step against regressing.
app = Container(scope=Scope.APP, groups=[BuildGroup])
app.open()
Expand Down
25 changes: 25 additions & 0 deletions modern_di/_scope_algebra.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Walk the members of a scope enum: which scopes are deeper than a given one."""

import enum


def deeper_members(scope: enum.IntEnum) -> list[enum.IntEnum]:
"""Members of ``scope``'s own enum that are deeper than it, shallowest first."""
return sorted(member for member in type(scope) if member > scope)


# 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.

None rather than ``MaxScopeReachedError``: ``exceptions`` imports this module.
"""
key = (type(scope), scope)
if key not in _next_deeper_memo:
members = deeper_members(scope)
_next_deeper_memo[key] = members[0] if members else None
return _next_deeper_memo[key]
5 changes: 3 additions & 2 deletions modern_di/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import typing

from modern_di import exceptions, types
from modern_di._scope_algebra import next_deeper
from modern_di.dependency_graph import DependencyGraph, build_cycle_error, collect_errors, redirect_hops
from modern_di.group import Group
from modern_di.providers.abstract import AbstractProvider
Expand All @@ -13,7 +14,7 @@
from modern_di.registries.overrides_registry import OverrideHandle
from modern_di.registries.providers_registry import ProvidersRegistry
from modern_di.resolver_compiler import STEP_ERRORS
from modern_di.scope import Scope, _next_deeper
from modern_di.scope import Scope


def _handle_recursion_error(
Expand Down Expand Up @@ -126,7 +127,7 @@ def build_child_container(
:class:`~modern_di.exceptions.InvalidChildScopeError` when ``scope`` is not deeper.
"""
if scope is None:
scope = _next_deeper(self.scope)
scope = next_deeper(self.scope)
if scope is None:
raise exceptions.MaxScopeReachedError(parent_scope=self.scope)

Expand Down
16 changes: 7 additions & 9 deletions modern_di/dependency_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
from typing import NamedTuple

from modern_di import exceptions
from modern_di.exceptions.rendering import redirect_steps
from modern_di.providers.abstract import AbstractProvider


Expand Down Expand Up @@ -76,8 +75,12 @@ def effective_scope(provider: "AbstractProvider[typing.Any]", container: "Contai
def redirect_hops(
provider: "AbstractProvider[typing.Any]", container: "Container"
) -> "list[exceptions.ResolutionStep]":
"""Return the chain steps for the redirects between ``provider`` and its terminal, terminal excluded."""
return redirect_steps(terminal_chain(provider, container))
"""Return the chain steps for the redirects between ``provider`` and its terminal, terminal excluded.

A redirect owns no lifetime of its own, so each hop is drawn at the scope the terminal resolves at.
"""
*hops, terminal = terminal_chain(provider, container)
return [p._resolution_step(terminal.scope) for p in hops] # noqa: SLF001


def build_cycle_error(
Expand All @@ -94,12 +97,7 @@ def build_cycle_error(
rotated = [*ring[lead:], *ring[:lead]]
canonical = [*rotated, rotated[0]]
return exceptions.CircularDependencyError(
steps=[
exceptions.ResolutionStep(
scope=effective_scope(p, container), name=p.display_name, location=p.definition_site
)
for p in canonical
]
steps=[p._resolution_step(effective_scope(p, container)) for p in canonical] # noqa: SLF001
)


Expand Down
4 changes: 2 additions & 2 deletions modern_di/exceptions/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import typing

from modern_di.exceptions.rendering import ResolutionStep, _render_chain
from modern_di.exceptions.rendering import ResolutionStep, render_chain


_TROUBLESHOOTING_BASE_URL = "https://modern-di.modern-python.org/troubleshooting"
Expand Down Expand Up @@ -61,7 +61,7 @@ def _render_body(self) -> str:

lines = [
"Cannot resolve dependency chain:",
*_render_chain(self.dependency_path),
*render_chain(self.dependency_path),
f" caused by: {self._base_message}",
]
return "\n".join(lines)
4 changes: 2 additions & 2 deletions modern_di/exceptions/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

import enum

from modern_di._scope_algebra import deeper_members
from modern_di.exceptions.base import ModernDIError
from modern_di.exceptions.rendering import ResolutionStep
from modern_di.exceptions.resolution import ResolutionError
from modern_di.scope import _deeper_members


class ContainerError(ModernDIError):
Expand All @@ -26,7 +26,7 @@ def __init__(self, *, parent_scope: enum.IntEnum, child_scope: enum.IntEnum) ->
self.child_scope = child_scope
# Derived, not handed over: the allowed scopes are a pure function of the parent's
# own enum class, so a raise site has nothing to add.
self.allowed_scopes = [member.name for member in _deeper_members(parent_scope)]
self.allowed_scopes = [member.name for member in deeper_members(parent_scope)]
super().__init__(
f"Scope of child container cannot be {child_scope.name} if parent scope is {parent_scope.name} "
f"(child scope value must be strictly greater than parent scope value). "
Expand Down
12 changes: 6 additions & 6 deletions modern_di/exceptions/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from modern_di import suggester
from modern_di.exceptions.base import ModernDIError
from modern_di.exceptions.rendering import _render_chain, _render_suggestion_lines, provider_step, redirect_steps
from modern_di.exceptions.rendering import render_chain, render_suggestion_lines


if typing.TYPE_CHECKING:
Expand Down Expand Up @@ -144,7 +144,7 @@ def __init__(
creator_name = getattr(creator, "__name__", repr(creator))
parts = [
f"Factory kwargs contain unknown key(s) not in {creator_name} signature:",
*_render_suggestion_lines(self.suggestions),
*render_suggestion_lines(self.suggestions),
f"Known parameters: {known_keys}",
]
super().__init__("\n".join(parts))
Expand Down Expand Up @@ -205,14 +205,14 @@ def dep_terminal(self) -> "AbstractProvider[typing.Any]":
return self.dep_chain[-1]

def _render_body(self) -> str:
effective_scope = self.dep_terminal.scope
steps = [
provider_step(self.provider, self.provider.scope),
*redirect_steps(self.dep_chain),
provider_step(self.dep_terminal, self.dep_terminal.scope),
self.provider._resolution_step(), # noqa: SLF001
*(p._resolution_step(effective_scope) for p in self.dep_chain), # noqa: SLF001
]
lines = [
"Provider at a deeper scope reached through this chain:",
*_render_chain(steps),
*render_chain(steps),
f" caused by: {RuntimeError.__str__(self)}",
]
return "\n".join(lines)
29 changes: 5 additions & 24 deletions modern_di/exceptions/rendering.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,10 @@

import dataclasses
import enum
import typing

from modern_di import suggester


if typing.TYPE_CHECKING:
from modern_di.providers.abstract import AbstractProvider


SUGGESTION_HEADER = "Did you mean:"


Expand All @@ -19,7 +14,7 @@ class ResolutionStep:
"""One entry in a chain-shaped error: a provider, as this module needs to draw it.

Used both for a :class:`ResolutionError`'s ``dependency_path`` and for a
:class:`CircularDependencyError`'s cycle, so both render through ``_render_chain``.
:class:`CircularDependencyError`'s cycle, so both render through ``render_chain``.

Attributes:
scope: the scope of the provider at this step of the chain.
Expand All @@ -33,21 +28,7 @@ class ResolutionStep:
location: str | None = None


def provider_step(provider: "AbstractProvider[typing.Any]", scope: enum.IntEnum) -> ResolutionStep:
"""Draw `provider` as a chain step at `scope`."""
return ResolutionStep(scope=scope, name=provider.display_name, location=provider.definition_site)


def redirect_steps(chain: "list[AbstractProvider[typing.Any]]") -> list[ResolutionStep]:
"""Draw every hop of a redirect chain except its terminal, at the scope the terminal resolves at.

A redirect owns no lifetime of its own, so its declared scope is a default it never resolves at.
"""
scope = chain[-1].scope
return [provider_step(p, scope) for p in chain[:-1]]


def _render_chain(steps: "list[ResolutionStep]") -> list[str]:
def render_chain(steps: "list[ResolutionStep]") -> list[str]:
"""Draw a provider chain as an indented arrow tree, one line per step.

The single home of the chain glyphs — used by every chain-shaped error, so a
Expand All @@ -62,7 +43,7 @@ def _render_chain(steps: "list[ResolutionStep]") -> list[str]:
return lines


def _render_suggestion_lines(suggestions: "list[suggester.Suggestion]") -> list[str]:
def render_suggestion_lines(suggestions: "list[suggester.Suggestion]") -> list[str]:
"""Draw each suggestion as a bullet. The single home of the suggestion glyphs."""
lines = []
for suggestion in suggestions:
Expand All @@ -76,8 +57,8 @@ def _scope_detail(scope: enum.IntEnum | None) -> str | None:
return None if scope is None else f"scope={scope.name}"


def _render_suggestions(suggestions: "list[suggester.Suggestion]") -> str:
def render_suggestions(suggestions: "list[suggester.Suggestion]") -> str:
"""Render the full ``Did you mean:`` block, or an empty string when there is nothing to suggest."""
if not suggestions:
return ""
return "\n".join([SUGGESTION_HEADER, *_render_suggestion_lines(suggestions)])
return "\n".join([SUGGESTION_HEADER, *render_suggestion_lines(suggestions)])
16 changes: 7 additions & 9 deletions modern_di/exceptions/resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from modern_di import suggester
from modern_di.exceptions.base import DependencyPathMixin, ModernDIError
from modern_di.exceptions.rendering import ResolutionStep, _render_chain, _render_suggestions
from modern_di.exceptions.rendering import ResolutionStep, render_chain, render_suggestions


class ResolutionError(DependencyPathMixin, ModernDIError):
Expand Down Expand Up @@ -36,7 +36,7 @@ def __init__(
self.provider_type = provider_type
self.suggestions = suggestions or []
message = f"Provider of type {provider_type} is not registered in providers registry."
if block := _render_suggestions(self.suggestions):
if block := render_suggestions(self.suggestions):
message += "\n" + block
super().__init__(message)

Expand Down Expand Up @@ -95,7 +95,7 @@ def __init__( # noqa: PLR0913
f"Argument {parameter_name} has no usable type annotation, so it cannot be resolved by type. "
f"Pass it via the kwargs parameter or add a type annotation. {building}"
)
if block := _render_suggestions(self.suggestions):
if block := render_suggestions(self.suggestions):
message += "\n" + block
super().__init__(message)

Expand Down Expand Up @@ -150,16 +150,14 @@ class CircularDependencyError(ResolutionError):

def __init__(self, *, steps: list[ResolutionStep]) -> None:
self.steps = steps
rendered = "\n".join(_render_chain(steps))
rendered = "\n".join(render_chain(steps))
super().__init__(f"Circular dependency detected:\n{rendered}\nCheck your provider graph for unintended cycles.")

def prepend_step(self, *steps: ResolutionStep) -> None:
"""No-op: the canonical cycle (set at construction) is already self-contained.
"""No-op: the canonical cycle set at construction already names every provider in the loop.

Every provider in the loop is named by ``steps``, so an outer resolution frame has nothing
to add — accumulating a breadcrumb would only repeat the same nodes. This also keeps the two
resolve paths identical: the interpreted path unwinds through intermediate ``resolve_provider``
frames (each would otherwise prepend a step), while the compiled path converts once at the top.
An outer resolver frame that catches the error has nothing to add, and prepending its step would
only repeat a node of the cycle.
"""

@property
Expand Down
6 changes: 6 additions & 0 deletions modern_di/providers/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ def definition_site(self) -> str | None:
"""``module:line`` of the provider's declaration when known; None by default (no creator)."""
return None

def _resolution_step(self, scope: enum.IntEnum | None = None) -> exceptions.ResolutionStep:
"""Return this provider as a chain step at ``scope``, its own scope by default."""
return exceptions.ResolutionStep(
scope=self.scope if scope is None else scope, name=self.display_name, location=self.definition_site
)

def _get_dependencies(self, container: "Container") -> dict[str, "AbstractProvider[typing.Any]"]: # noqa: ARG002
return {}

Expand Down
3 changes: 0 additions & 3 deletions modern_di/providers/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,6 @@ def _compute_definition_site(self) -> str | None:
return None
return f"{module}:{lineno}"

def _resolution_step(self) -> exceptions.ResolutionStep:
return exceptions.ResolutionStep(scope=self.scope, name=self.display_name, location=self.definition_site)

def _argument_resolution_error(
self, *, arg_name: str, item: SignatureItem, registry: "ProvidersRegistry"
) -> exceptions.ArgumentResolutionError:
Expand Down
Loading
Loading