diff --git a/architecture/resolution.md b/architecture/resolution.md index 346037b..c49c89c 100644 --- a/architecture/resolution.md +++ b/architecture/resolution.md @@ -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. diff --git a/architecture/validation.md b/architecture/validation.md index 2059f66..667083c 100644 --- a/architecture/validation.md +++ b/architecture/validation.md @@ -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 diff --git a/docs/troubleshooting/circular-dependency.md b/docs/troubleshooting/circular-dependency.md index 3db4e75..2e4b7e4 100644 --- a/docs/troubleshooting/circular-dependency.md +++ b/docs/troubleshooting/circular-dependency.md @@ -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 diff --git a/docs/troubleshooting/scope-chain.md b/docs/troubleshooting/scope-chain.md index 38ef330..0995b57 100644 --- a/docs/troubleshooting/scope-chain.md +++ b/docs/troubleshooting/scope-chain.md @@ -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`. diff --git a/docs/troubleshooting/scope-not-initialized-error.md b/docs/troubleshooting/scope-not-initialized-error.md index fa01df8..85382f1 100644 --- a/docs/troubleshooting/scope-not-initialized-error.md +++ b/docs/troubleshooting/scope-not-initialized-error.md @@ -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** diff --git a/docs/troubleshooting/scope-skipped-error.md b/docs/troubleshooting/scope-skipped-error.md index 74eae79..07fbf92 100644 --- a/docs/troubleshooting/scope-skipped-error.md +++ b/docs/troubleshooting/scope-skipped-error.md @@ -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** diff --git a/modern_di/container.py b/modern_di/container.py index 5483ba0..98597f2 100644 --- a/modern_di/container.py +++ b/modern_di/container.py @@ -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() @@ -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 @@ -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: @@ -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) diff --git a/modern_di/exceptions.py b/modern_di/exceptions.py index 2d5c3cf..bf0d0cb 100644 --- a/modern_di/exceptions.py +++ b/modern_di/exceptions.py @@ -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): @@ -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) @@ -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.") diff --git a/modern_di/providers/abstract.py b/modern_di/providers/abstract.py index f418893..ef99f14 100644 --- a/modern_di/providers/abstract.py +++ b/modern_di/providers/abstract.py @@ -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 diff --git a/modern_di/providers/factory.py b/modern_di/providers/factory.py index 2c07323..5f48fd6 100644 --- a/modern_di/providers/factory.py +++ b/modern_di/providers/factory.py @@ -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, @@ -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( @@ -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 diff --git a/planning/changes/2026-07-08.04-definition-sites.md b/planning/changes/2026-07-08.04-definition-sites.md new file mode 100644 index 0000000..5310928 --- /dev/null +++ b/planning/changes/2026-07-08.04-definition-sites.md @@ -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. diff --git a/tests/providers/test_alias.py b/tests/providers/test_alias.py index 70e9459..8c90a3b 100644 --- a/tests/providers/test_alias.py +++ b/tests/providers/test_alias.py @@ -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: ... diff --git a/tests/providers/test_context_provider.py b/tests/providers/test_context_provider.py index 8d50c7c..c297f83 100644 --- a/tests/providers/test_context_provider.py +++ b/tests/providers/test_context_provider.py @@ -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) diff --git a/tests/providers/test_factory.py b/tests/providers/test_factory.py index 5275fa9..90e53d9 100644 --- a/tests/providers/test_factory.py +++ b/tests/providers/test_factory.py @@ -1,6 +1,9 @@ import contextlib import dataclasses +import functools +import inspect import re +import typing import unittest.mock import warnings @@ -728,3 +731,100 @@ class G(Group): def test_factory_rejects_creator_passed_twice() -> None: with pytest.raises(TypeError, match="creator"): providers.Factory(SimpleCreator, creator=SimpleCreator) # ty: ignore[parameter-already-assigned] + + +def _definition_site_func() -> str: + return "x" + + +def test_definition_site_function_creator() -> None: + assert _definition_site_func() == "x" # exercise body for coverage + factory = providers.Factory(_definition_site_func, bound_type=None) + expected = f"{_definition_site_func.__module__}:{_definition_site_func.__code__.co_firstlineno}" + assert factory.definition_site == expected + + +def test_definition_site_class_creator() -> None: + factory = providers.Factory(SimpleCreator, kwargs={"dep1": "x"}) + lineno = inspect.getsourcelines(SimpleCreator)[1] + assert factory.definition_site == f"{SimpleCreator.__module__}:{lineno}" + + +def test_definition_site_c_callable_is_none() -> None: + factory = providers.Factory(dict, bound_type=None, skip_creator_parsing=True) + assert factory.definition_site is None + + +def test_definition_site_partial_is_none() -> None: + factory = providers.Factory(functools.partial(SimpleCreator, dep1="x"), bound_type=None, skip_creator_parsing=True) + assert factory.definition_site is None + + +def test_definition_site_memoized(monkeypatch: pytest.MonkeyPatch) -> None: + factory = providers.Factory(SimpleCreator, kwargs={"dep1": "x"}) + first = factory.definition_site + assert first is not None + + def _boom(_obj: object) -> tuple[list[str], int]: + raise AssertionError # pragma: no cover — must not run; memoization short-circuits before inspect + + monkeypatch.setattr("modern_di.providers.factory.inspect.getsourcelines", _boom) + assert factory.definition_site == first # cached; inspect not called again + + +class _NoModuleCreator: + def __call__(self) -> str: + return "x" + + +def test_definition_site_creator_without_module_is_none() -> None: + # A creator whose __module__ can't be determined (e.g. a dynamically built callable): + # _compute_definition_site must bail out before touching __code__/inspect. + creator = _NoModuleCreator() + assert creator() == "x" # exercise body for coverage + creator.__module__ = None # ty: ignore[invalid-assignment] + factory = providers.Factory(creator, bound_type=str, skip_creator_parsing=True) + assert factory.definition_site is None + + +def test_definition_site_pathological_creator_is_none() -> None: + class _Pathological: + __module__ = "mymod" + + def __getattr__(self, name: str) -> typing.NoReturn: + msg = f"boom on {name}" + raise RuntimeError(msg) + + def __call__(self) -> int: + return 1 + + creator = _Pathological() + assert creator() == 1 # exercise body for coverage + factory = providers.Factory(creator, bound_type=None, skip_creator_parsing=True) + assert factory.definition_site is None + + +def test_definition_site_recursion_error_propagates_for_guard_retry() -> None: + # 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. + class _StackExhausted: + __module__ = "mymod" + + def __getattr__(self, name: str) -> typing.NoReturn: + raise RecursionError + + def __call__(self) -> int: + return 1 + + creator = _StackExhausted() + assert creator() == 1 # exercise body for coverage + factory = providers.Factory(creator, bound_type=None, skip_creator_parsing=True) + with pytest.raises(RecursionError): + _ = factory.definition_site + # Nothing memoized on the raise path: a second access must recompute (and raise) again, + # so the guard's shallower retry gets a fresh computation, not a cached None. + with pytest.raises(RecursionError): + _ = factory.definition_site diff --git a/tests/test_container.py b/tests/test_container.py index 439f91e..2f04a7c 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -1,5 +1,6 @@ import copy import dataclasses +import inspect import pathlib import re import typing @@ -142,6 +143,14 @@ def test_validate_on_creation() -> None: assert isinstance(issue, CircularDependencyError) +def test_cycle_path_carries_definition_sites() -> None: + with pytest.raises(ValidationFailedError) as exc_info: + Container(groups=[CycleGroup], validate=True) + rendered = str(exc_info.value) + lineno = inspect.getsourcelines(CycleA)[1] + assert f"({CycleA.__module__}:{lineno})" in rendered + + def test_validate_detects_cycle() -> None: container = Container(groups=[CycleGroup]) with pytest.raises(ValidationFailedError) as exc: diff --git a/tests/test_dependency_path.py b/tests/test_dependency_path.py index 4b88e0d..a76c874 100644 --- a/tests/test_dependency_path.py +++ b/tests/test_dependency_path.py @@ -1,4 +1,5 @@ import dataclasses +import inspect import pytest @@ -7,7 +8,6 @@ ArgumentResolutionError, ContainerError, ProviderNotRegisteredError, - ResolutionStep, ScopeNotInitializedError, ) @@ -40,9 +40,11 @@ def test_chain_appears_when_arg_unresolvable() -> None: exc = exc_info.value # The structured path is the stable contract; the rendered string is checked via substrings # rather than exact whitespace so cosmetic formatting can evolve without churning this test. - assert exc.dependency_path == [ - ResolutionStep(scope=Scope.APP, name="MyService"), - ResolutionStep(scope=Scope.APP, name="Repository"), + # `location` is asserted separately (test_breadcrumb_line_carries_definition_site); ignored + # here so this test doesn't hardcode fixture line numbers. + assert [(step.scope, step.name) for step in exc.dependency_path] == [ + (Scope.APP, "MyService"), + (Scope.APP, "Repository"), ] rendered = str(exc) assert rendered.startswith("Cannot resolve dependency chain:") @@ -157,3 +159,18 @@ def test_scope_error_still_caught_as_container_error() -> None: def test_dependency_path_mixin_is_not_an_exception() -> None: # Guards the ruling: DependencyPathMixin must never become except-catchable on its own. assert not issubclass(exceptions.DependencyPathMixin, BaseException) + + +def test_breadcrumb_line_carries_definition_site() -> None: + @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) + class _Anchored: + pass + + class _G(Group): + anchored = providers.Factory(_Anchored, scope=Scope.REQUEST) + + container = Container(groups=[_G], validate=False) + with pytest.raises(ScopeNotInitializedError) as exc_info: + container.resolve(_Anchored) + lineno = inspect.getsourcelines(_Anchored)[1] + assert f"({_Anchored.__module__}:{lineno})" in str(exc_info.value) diff --git a/tests/test_runtime_cycle_guard.py b/tests/test_runtime_cycle_guard.py index 41c26c0..42c6a5f 100644 --- a/tests/test_runtime_cycle_guard.py +++ b/tests/test_runtime_cycle_guard.py @@ -7,6 +7,7 @@ """ import dataclasses +import inspect import sys import pytest @@ -82,6 +83,11 @@ def _assert_simple_cycle(exc: exceptions.CircularDependencyError) -> None: assert exc.cycle_path[0] == exc.cycle_path[-1] assert set(exc.cycle_path) == {"NodeA", "NodeB"} assert isinstance(exc.__cause__, RecursionError) + # Isolate the cycle's own rendering (after "caused by: ") from the breadcrumb prefix, which + # already carries per-hop anchors (ERR-6 task 1) and would make this assertion pass regardless. + lineno = inspect.getsourcelines(NodeA)[1] + cycle_rendering = str(exc).rsplit("caused by: ", 1)[-1] + assert f"({NodeA.__module__}:{lineno})" in cycle_rendering def test_unvalidated_cycle_raises_circular_dependency_error() -> None: