Skip to content

Unify provider-graph traversal behind a DependencyGraph module - #308

Merged
lesnik512 merged 12 commits into
mainfrom
feat/dependency-graph-traversal
Jul 13, 2026
Merged

lesnik512 merged 12 commits into
mainfrom
feat/dependency-graph-traversal

Conversation

@lesnik512

Copy link
Copy Markdown
Member

Unifies the four independent walks of the provider dependency graph behind a single deep module. Full rationale, design, and trade-offs live in the change file — this PR does not restate them:

What changed

  • New modern_di/dependency_graph.py — one iterative, event-stream DependencyGraph.walk (NodeEntered/Edge/Cycle/DependenciesError), plus find_cycle_from and terminal_scope.
  • validate() is now a fold over walk; the runtime RecursionError guard routes through find_cycle_from; _find_reachable_cycle/_convert_recursion_error deleted.
  • Alias.effective_scope replaced by a generic redirect_target node hook + DependencyGraph.terminal_scope, keeping the module Alias-agnostic.
  • New registry-level validated_version stamp: a validated (acyclic) graph skips the runtime cycle walk, and a repeat validate() is free. Un-stamped on every registry mutation.

Heads-up for review

This reverses the "deliberate duplication" stance documented in architecture/validation.md — on the extraction axis only (the two cycle policies stay distinct). The reasoning and revisit triggers are in the decision record. architecture/validation.md/containers.md/providers.md were promoted in this PR to match.

Verification

  • just test-ci — 358 passed, 100% line coverage.
  • just lint-ci and just check-planning — clean.
  • validate() output is byte-identical to before (characterization tests pin cycle-path ordering, error kinds, and order).

🤖 Generated with Claude Code

lesnik512 and others added 12 commits July 12, 2026 22:53
…tion

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Locks today's behavior of Container.validate() (cycle detection shape,
collecting multiple error kinds in one pass) and the runtime resolve
cycle guard, against the public API only, so a later DependencyGraph
refactor can be checked for parity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a `validated_version` marker (int | None, default None) reset to
None on every mutation (register, add_providers, _remove_providers).
Later work will let validate() set it on success and short-circuit
re-validation when the registry hasn't changed since.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lets a later DependencyGraph module follow alias chains without
knowing about the concrete Alias type. Alias overrides it to return
its source, or None when the source is unregistered (dangling).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add modern_di/dependency_graph.py with the NodeEntered/Edge/Cycle/
DependenciesError event NamedTuples, the Event union, and
DependencyGraph.walk: a single explicit-stack (non-recursive) pre-order DFS
of the static provider graph. Event order mirrors Container.validate's
recursive _visit exactly (Edge before a possible Cycle; one NodeEntered per
node; visiting/visited shared across roots), so a later task can reproduce
validate() output byte-for-byte and reuse the walk inside a RecursionError
handler near the stack limit. Imports only AbstractProvider and exceptions
at runtime; Container stays behind TYPE_CHECKING to avoid an import cycle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
find_cycle_from reuses the iterative walk to locate the first cycle
reachable from a provider, staying safe near the stack limit for a
later RecursionError handler. terminal_scope follows redirect_target
hops to the terminal provider's scope, guarding against alias cycles
via a seen-id set. Both will replace the hand-rolled chain walk in
Alias.effective_scope in a later task.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewrite Container.validate() as a fold over the shared DependencyGraph.walk
event stream instead of a bespoke recursive DFS, and short-circuit when the
providers registry has already been validated at its current version. Remove
AbstractProvider.effective_scope and Alias.effective_scope; the scope-ordering
check now uses DependencyGraph.terminal_scope on both sides of each edge.

Event emission order (NodeEntered -> DependenciesError -> Edge -> Cycle in
registry order) reproduces the old _visit output byte-for-byte, so the
characterization/parity suites stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…find_reachable_cycle

Rewire resolve_provider's RecursionError handler to reuse the shared
DependencyGraph.find_cycle_from walker instead of a second explicit-stack
DFS, and short-circuit (re-raise untouched) when the registry is already
validated. Add dependency_graph.build_cycle_error so validate() and the
guard construct CircularDependencyError from one place, removing the last
verbatim duplication. Delete the now-redundant _find_reachable_cycle and
_convert_recursion_error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both `_explode` monkeypatch helpers intentionally never execute their
body (they exist to fail the test loudly if the patched method IS
called). Mark them `# pragma: no cover` per the convention already
used in test_runtime_cycle_guard.py, so line coverage doesn't count
dead-by-design guard bodies against the 100% gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CPython suspends the coverage tracer for a few frames while unwinding a
RecursionError, so a re-raise written inline in the except block is never
recorded as covered. Route the handling back through a separate module-level
function so the tracer gets a fresh call boundary to re-arm on, restoring the
100% coverage gate without changing any resolve behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the recursive-DFS/effective_scope narration with the shipped
DependencyGraph model: validate() as a fold over one iterative walk, the
runtime guard sharing that walk via find_cycle_from, terminal_scope following
the redirect_target node hook, and the registry-level validated_version
short-circuit. effective_scope no longer exists in code and is now absent from
all architecture and docs prose.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rename test_effective_scope_handles_mutual_alias_cycle to
test_terminal_scope_handles_mutual_alias_cycle so it's grep-discoverable
under the current terminal_scope API, and add a one-line comment noting
the Event match in Container.validate() is exhaustive by construction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lesnik512
lesnik512 merged commit 1609a81 into main Jul 13, 2026
7 checks passed
@lesnik512
lesnik512 deleted the feat/dependency-graph-traversal branch July 13, 2026 05:39
lesnik512 added a commit that referenced this pull request Jul 14, 2026
Candidate 3 (make SignatureItem opaque) is superseded by the graph-traversal
unification (#308); Candidate 5 (bless a provider-introspection seam for
grpc) is declined as a hypothetical seam (grpc is the sole consumer). Both
record load-bearing reasons so a future architecture review does not
re-suggest them.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lesnik512 added a commit that referenced this pull request Jul 14, 2026
`WiringPlan` built a third dict, `dependencies`, alongside the buckets the
runtime resolves from. The declaration-time `kwargs={...}` overlay wrote to
`provider_kwargs` but not to `dependencies`, so a provider supplied that way
was a real runtime edge that validation could not see.

Consequences, both reproduced: a cycle routed through `kwargs=` passed
`validate()` and then surfaced as a bare `RecursionError` (the runtime guard's
`find_cycle_from` saw no edge either), and a scope inversion routed through
`kwargs=` passed `validate()` and failed at resolve time with
`ScopeNotInitializedError`.

Delete the `dependencies` field; derive `WiringPlan.edges` from
`provider_kwargs` + `context_kwargs` instead. validate() now traverses exactly
what resolve() follows, so the two graphs cannot drift apart again. What differs
between the two ways of declaring a dependency is how the edge is declared,
never whether it exists.

Newly reported configs were already broken at runtime, so there are no false
positives — but a dormant broken config can turn a green validate() red.

Also: rewrite dependency_graph's module docstring, which described the event
order against `Container.validate`'s `_visit` (deleted in #308), and rename
test_dependency_graph_parity.py -> _contract.py; it tests no parity, and there
is no second implementation left to be at parity with.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant