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
11 changes: 11 additions & 0 deletions architecture/resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,17 @@ statically for a missing provider, just evaluated live instead of once at plan-b

The recursion bottoms out at providers with no dependencies or at already-cached instances.

### Breadcrumb definition sites

Each step a `Factory` prepends onto a breadcrumb chain may carry an optional definition site — the
creator's declaration point, rendered as a trailing `module:line` anchor — alongside the provider
name. The site is captured lazily, only when a step is actually being built on an error path, and
memoized per provider so a repeated failure never re-inspects the creator. Capture is best-effort:
a plain function or method resolves for free from its code object, a class falls back to source
inspection, and anything without an inspectable source (C callables, `functools.partial`, and the
like) yields no site rather than raising. `Alias` steps never carry a definition site, since an
alias has no creator of its own to anchor.

## Step 6 — Creator call and caching

`Factory` calls the creator with `resolved_kwargs`. With no `cache_settings`, the instance is returned immediately.
Expand Down
7 changes: 7 additions & 0 deletions architecture/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ type names showing the loop (e.g., `["A", "B", "A"]`). The recursive walk does *
but the rest of the graph continues to be checked. `CircularDependencyError.__str__` renders `.cycle_path` as a
multi-line arrow chain, not an inline `A -> B -> A` string — see `CircularDependencyError` in `exceptions.py`.

Each node in that chain may also carry an optional definition site — the creator's declaration
point — rendered as a trailing `module:line` anchor alongside the provider name, using the same
lazy, memoized, best-effort capture described for breadcrumb steps in
[resolution.md](resolution.md#breadcrumb-definition-sites). The public `.cycle_path` stays the bare
list of type names; the parallel locations live on a separate `.cycle_locations` attribute, kept in
sync at both construction sites (the DFS above and the runtime `RecursionError` conversion below).

> **Runtime resolution has a cycle guard too — but `validate()` remains the way to see all errors up front.**
> `Container.resolve_provider` wraps the final `provider.resolve(self)` in `try/except RecursionError`. When an
> unvalidated circular graph's first resolve overflows the stack, the handler iteratively re-walks the static
Expand Down
2 changes: 1 addition & 1 deletion docs/troubleshooting/circular-dependency.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ CircularDependencyError (1):
Check your provider graph for unintended cycles.
```

It means the listed providers form a cycle that cannot be resolved.
It means the listed providers form a cycle that cannot be resolved. Each hop in the arrow chain may also end with a pointer to where that provider was declared (module and line number), making it easier to locate the offending provider in a large codebase.

## How to Detect

Expand Down
2 changes: 2 additions & 0 deletions docs/troubleshooting/scope-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ InvalidScopeDependencyError (1):

The fix is always to make the depender's scope equal to or shorter than the dependee's. In the example above, `UserCache` should be REQUEST-scoped, not APP-scoped.

This particular message is a single static check with no chain attached; if the same violation instead surfaces at runtime as `ScopeNotInitializedError` or `ScopeSkippedError`, their breadcrumb lines may end with a pointer to where the offending provider was declared (module and line number).

## Common cases

1. **Forgot `scope=Scope.REQUEST` on a repository.** Defaults to `Scope.APP` if omitted. A repository that holds a session needs `scope=Scope.REQUEST`.
Expand Down
4 changes: 3 additions & 1 deletion docs/troubleshooting/scope-not-initialized-error.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
**Symptom**

A resolution fails naming a provider's scope and the current container's scope, optionally with a
dependency-path breadcrumb when the failing provider was captured by a shallower one.
dependency-path breadcrumb when the failing provider was captured by a shallower one. Each
breadcrumb line may end with a pointer to where that provider was declared (module and line
number), so you can jump straight to the declaration.

**Cause**

Expand Down
4 changes: 3 additions & 1 deletion docs/troubleshooting/scope-skipped-error.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

A resolution fails naming a provider's scope and the current container's scope, optionally with a
dependency-path breadcrumb — the requested scope is shallower than the current container, but no
container at that scope exists anywhere in this chain.
container at that scope exists anywhere in this chain. Each breadcrumb line may end with a pointer
to where that provider was declared (module and line number), so you can jump straight to the
declaration.

**Cause**

Expand Down
26 changes: 17 additions & 9 deletions modern_di/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,15 @@
import typing_extensions


def _find_reachable_cycle(start: AbstractProvider[typing.Any], container: "Container") -> list[str] | None:
def _find_reachable_cycle(
start: AbstractProvider[typing.Any], container: "Container"
) -> list[AbstractProvider[typing.Any]] | None:
"""Re-walk the static graph from `start`, looking for a cycle reachable from it.

Explicit-stack DFS — no recursion, since this runs inside a ``RecursionError`` handler
near CPython's stack limit (headroom for a recursive walk is not guaranteed there).
Returns `None` if no static cycle is reachable from `start`.
Returns the cycle's providers (closed by repeating the first), or `None` if no static
cycle is reachable from `start`.
"""
visiting: set[int] = {start.provider_id}
visited: set[int] = set()
Expand All @@ -42,9 +45,7 @@ def _find_reachable_cycle(start: AbstractProvider[typing.Any], container: "Conta

if dep.provider_id in visiting:
cycle_start = next(i for i, p in enumerate(path) if p.provider_id == dep.provider_id)
cycle_names = [p.display_name for p in path[cycle_start:]]
cycle_names.append(cycle_names[0])
return cycle_names
return [*path[cycle_start:], path[cycle_start]]
if dep.provider_id in visited:
continue

Expand All @@ -66,7 +67,10 @@ def _convert_recursion_error(
cycle = _find_reachable_cycle(provider, container)
if cycle is None:
raise exc
raise exceptions.CircularDependencyError(cycle_path=cycle) from exc
raise exceptions.CircularDependencyError(
cycle_path=[p.display_name for p in cycle],
cycle_locations=[p.definition_site for p in cycle],
) from exc


class Container:
Expand Down Expand Up @@ -255,9 +259,13 @@ def _visit(provider: AbstractProvider[typing.Any]) -> None:
return
if pid in visiting:
cycle_start = next(i for i, p in enumerate(path) if p.provider_id == pid)
cycle_names = [p.display_name for p in path[cycle_start:]]
cycle_names.append(cycle_names[0])
validation_errors.append(exceptions.CircularDependencyError(cycle_path=cycle_names))
cycle_providers = [*path[cycle_start:], path[cycle_start]]
validation_errors.append(
exceptions.CircularDependencyError(
cycle_path=[p.display_name for p in cycle_providers],
cycle_locations=[p.definition_site for p in cycle_providers],
)
)
return

visiting.add(pid)
Expand Down
24 changes: 16 additions & 8 deletions modern_di/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ class ResolutionStep:
Attributes:
scope: the scope of the provider at this step of the resolution chain.
name: the provider's display name (bound type or creator name).
location: the provider's declaration site as ``module:line``, when known.

"""

scope: enum.IntEnum
name: str
location: str | None = None


class ModernDIError(RuntimeError):
Expand Down Expand Up @@ -85,7 +87,8 @@ def _render_body(self) -> str:
lines = ["Cannot resolve dependency chain:"]
for i, step in enumerate(self.dependency_path):
prefix = "" if i == 0 else " " * (i - 1) + "└─> "
lines.append(f" {step.scope.name:<{scope_width}} {prefix}{step.name}")
label = f"{step.name} ({step.location})" if step.location else step.name
lines.append(f" {step.scope.name:<{scope_width}} {prefix}{label}")
lines.append(f" caused by: {self._base_message}")
return "\n".join(lines)

Expand Down Expand Up @@ -329,19 +332,24 @@ def __init__(self, *, creator: typing.Any, original_error: Exception) -> None:
class CircularDependencyError(ResolutionError):
"""A dependency cycle was detected by ``validate()`` or the runtime resolve guard.

Inspect ``.cycle_path`` (the loop as type names). When raised at resolve time,
``__cause__`` carries the original ``RecursionError``.
Inspect ``.cycle_path`` (the loop as type names) and ``.cycle_locations`` (parallel
``module:line`` anchors, when known). When raised at resolve time, ``__cause__`` carries
the original ``RecursionError``.
"""

docs_slug = "circular-dependency"

__slots__ = ("cycle_path",)
__slots__ = ("cycle_locations", "cycle_path")

def __init__(self, *, cycle_path: list[str]) -> None:
def __init__(self, *, cycle_path: list[str], cycle_locations: list[str | None] | None = None) -> None:
self.cycle_path = cycle_path
rendered = "\n".join(
f" {' ' * (i - 1)}└─> {name}" if i else f" {name}" for i, name in enumerate(cycle_path)
)
self.cycle_locations = cycle_locations
locations = cycle_locations if cycle_locations is not None else [None] * len(cycle_path)
lines = []
for i, (name, location) in enumerate(zip(cycle_path, locations, strict=True)):
label = f"{name} ({location})" if location else name
lines.append(f" {' ' * (i - 1)}└─> {label}" if i else f" {label}")
rendered = "\n".join(lines)
super().__init__(f"Circular dependency detected:\n{rendered}\nCheck your provider graph for unintended cycles.")


Expand Down
5 changes: 5 additions & 0 deletions modern_di/providers/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ def display_name(self) -> str:
"""
return self.bound_type.__name__ if self.bound_type else repr(self)

@property
def definition_site(self) -> str | None:
"""``module:line`` of the provider's declaration when known; None by default (no creator)."""
return None

@abc.abstractmethod
def resolve(self, container: "Container") -> typing.Any: ... # noqa: ANN401

Expand Down
32 changes: 30 additions & 2 deletions modern_di/providers/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def __post_init__(self) -> None:


class Factory(AbstractProvider[types.T_co]):
__slots__ = ("_creator", "_kwargs", "_parsed_kwargs", "cache_settings")
__slots__ = ("_cached_definition_site", "_creator", "_kwargs", "_parsed_kwargs", "cache_settings")

def __init__( # noqa: C901, PLR0913
self,
Expand Down Expand Up @@ -92,6 +92,7 @@ def __init__( # noqa: C901, PLR0913
self._creator = creator
self.cache_settings = resolved_cache
self._kwargs = kwargs
self._cached_definition_site: str | None | types.UnsetType = types.UNSET

@staticmethod
def _validate_kwargs_against_signature(
Expand Down Expand Up @@ -130,8 +131,35 @@ def display_name(self) -> str:
return self.bound_type.__name__
return getattr(self._creator, "__name__", repr(self._creator))

@property
def definition_site(self) -> str | None:
"""The creator's declaration site as ``module:line``; None when undeterminable. Memoized."""
if isinstance(self._cached_definition_site, types.UnsetType):
self._cached_definition_site = self._compute_definition_site()
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.
try:
module = getattr(self._creator, "__module__", None)
if module is None:
return None
code = getattr(self._creator, "__code__", None)
if code is not None:
return f"{module}:{code.co_firstlineno}"
_, lineno = inspect.getsourcelines(self._creator)
except RecursionError:
raise
except Exception: # noqa: BLE001
return None
return f"{module}:{lineno}"

def _resolution_step(self) -> exceptions.ResolutionStep:
return exceptions.ResolutionStep(scope=self.scope, name=self.display_name)
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 | None" = None
Expand Down
100 changes: 100 additions & 0 deletions planning/changes/2026-07-08.04-definition-sites.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
---
summary: ERR-6 — breadcrumb steps and cycle paths carry the creator's definition site as a trailing module:line anchor; lazy tiered capture (co_firstlineno free, inspect for classes) computed only on the error path, silent fallback for C callables.
---

# Design: Definition sites in breadcrumbs and cycle paths

## Summary

The accepted ERR-6 item from the 2026-07-05 3.0 UX research.
`ResolutionStep` gains an optional `location` field and cycle rendering
gains per-node anchors: each breadcrumb line and cycle hop may end with the
creator's definition site as ` (app.services:17)` — module:line
(maintainer-ruled format). Capture is lazy and tiered: free
`creator.__code__.co_firstlineno` for functions, `inspect` for classes,
silent `None` for C callables — computed only on the error path and
memoized per provider. Additive; `cycle_path` stays `list[str]`.

## Motivation

Research item ERR-6 (verified): `ResolutionStep` carries only
(scope, name) and cycle paths carry type names only — in a large codebase
"UserService" does not localize which Group attribute or creator declared
the provider. The field's best traces carry source anchors: uber-go/dig and
Fx print "provided by pkg.NewFoo (file.go:42)" per hop; Dagger prints the
injection site. Together with ERR-4's URLs the message then carries the
what, the why-link, and the where.

## Design

### Capture (lazy, tiered, memoized)

- `AbstractProvider.definition_site: str | None` property, default `None`.
- `Factory` overrides it: creator has `__code__` (function/lambda) →
`f"{creator.__module__}:{creator.__code__.co_firstlineno}"` — attribute
reads only; otherwise (classes, the dominant creator kind) →
`inspect.getsourcelines` in `try/except (OSError, TypeError)` →
module:line, or `None` on failure (C callables, `functools.partial`,
builtins — silent, never raises from the anchor machinery).
- Memoized on the provider (an UNSET-sentinel cache slot distinguishes
"not computed" from "computed None"). The property is only reached from
`_resolution_step()` and cycle construction — both already error-path
exclusive — so declaration/import cost is exactly zero.

### Breadcrumbs

- `ResolutionStep` gains `location: str | None = None` (frozen dataclass,
defaulted — every existing construction keeps working).
- `DependencyPathMixin._render_body` appends ` ({location})` to a line
when set.
- `Factory._resolution_step()` passes `location=self.definition_site`.
`Alias`'s step stays location-less (no creator); `ContextProvider`
constructs no steps.

### Cycle paths

- `CircularDependencyError` gains keyword-only
`cycle_locations: list[str | None] | None = None` (new slot); rendering
zips anchors onto the existing arrow-chain lines. Public `.cycle_path`
stays `list[str]` (ruled).
- Both construction sites pass locations from the provider objects they
already hold: `validate()`'s DFS (`path[cycle_start:]`) and the
runtime `RecursionError` conversion (`_find_reachable_cycle`).

### Docs

The `scope-not-initialized-error`, `scope-skipped-error`, and
`circular-dependency` troubleshooting pages' Symptom sections mention the
optional trailing anchor (paraphrased, per the no-verbatim rule);
`scope-chain` — a validation-time error carrying no breadcrumb chain —
instead notes it has none and points to the runtime siblings that do.
`architecture/resolution.md` (breadcrumb steps) and
`architecture/validation.md` (cycle rendering) promote the invariant.

## Non-goals

- No file-path anchors (module:line ruled; stable across machines/cwd).
- No location for `Alias`/`ContextProvider` — no creator to anchor.
- No change to `display_name`, reprs, or "did you mean" suggestions.
- No eager capture at declaration time.

## Testing

TDD; `just test-ci` 100% line coverage. Cases: function creator anchored
(module:line matches `__code__`); class creator anchored via inspect;
`dict` and `functools.partial(...)` creators render anchor-less, silently;
breadcrumb rendering with and without location; memoization (second access
returns the cached string, no re-inspect — assert via monkeypatched
inspect); cycle rendering with mixed anchored/anchor-less nodes at both
construction sites; existing exact-message assertions updated
(`match=` substrings preferred). `just lint-ci`, `just docs-build` strict,
`just check-planning` green.

## Risk

- Downstream exact-message assertions break on the new trailing anchors —
standard 2.24.0-style release note; anchors are trailing additions, so
substring matches survive. Low.
- `inspect` cost on the error path — bounded: memoized per provider,
error-path only, and swallowed on failure. Low.
- Anchors leak absolute paths — avoided by the module:line format. Nil.
5 changes: 5 additions & 0 deletions tests/providers/test_alias.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ def test_alias_repr() -> None:
)


def test_alias_has_no_definition_site() -> None:
alias = providers.Alias(source_type=PostgresRepository, bound_type=AbstractRepository)
assert alias.definition_site is None


class _NotRegisteredSource: ...


Expand Down
5 changes: 5 additions & 0 deletions tests/providers/test_context_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ def test_context_provider_exposes_context_type() -> None:
assert provider.context_type is datetime.datetime


def test_context_provider_has_no_definition_site() -> None:
provider = providers.ContextProvider(context_type=datetime.datetime, scope=Scope.REQUEST)
assert provider.definition_site is None


@pytest.mark.parametrize("value", [0, False, "", [], {}, 0.0])
def test_context_provider_returns_falsy_values(value: object) -> None:
context_type = type(value)
Expand Down
Loading