Skip to content

Commit 1f8c31c

Browse files
jskladanclaude
authored andcommitted
fix(dependency_graph): remove orphaned children in remove_dependency
When a node is removed, child nodes that have no remaining parents are now recursively removed as well, preventing orphaned nodes from lingering in the graph. Closes: #1041 Signed-off-by: Josef Skladanka <jskladan@redhat.com> Co-Authored-By: Claude <claude@anthropic.com>
1 parent bc300d4 commit 1f8c31c

2 files changed

Lines changed: 183 additions & 5 deletions

File tree

src/fromager/dependency_graph.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -346,12 +346,10 @@ def remove_dependency(
346346
req_name: NormalizedName,
347347
req_version: Version,
348348
) -> None:
349-
"""Remove a dependency node from the graph.
349+
"""Remove a dependency node and any orphaned descendants from the graph.
350350
351-
Removes the node and all edges pointing to it. This includes:
352-
- Back-references in child nodes' parents lists
353-
- Forward edges in parent nodes' children lists
354-
Child nodes of the removed node are kept if referenced elsewhere.
351+
Removes the node and all edges pointing to it. Child nodes that have
352+
no remaining parents after removal are recursively removed as well.
355353
356354
Args:
357355
req_name: Canonical name of the package
@@ -366,6 +364,8 @@ def remove_dependency(
366364

367365
deleted_node = self.nodes[key]
368366

367+
children = [edge.destination_node for edge in deleted_node.children]
368+
369369
# Clean up back-references (parents) in nodes that were children of the removed node
370370
for child_edge in deleted_node.children:
371371
child_node = child_edge.destination_node
@@ -386,6 +386,11 @@ def remove_dependency(
386386
node.children.clear()
387387
node.children.extend(filtered_children)
388388

389+
# Recursively remove children that have no remaining parents
390+
for child in children:
391+
if child.key != ROOT and child.key in self.nodes and not child.parents:
392+
self.remove_dependency(child.canonicalized_name, child.version)
393+
389394
def get_dependency_edges(
390395
self, match_dep_types: list[RequirementType] | None = None
391396
) -> typing.Iterable[DependencyEdge]:

tests/test_dependency_graph.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,3 +468,176 @@ def test_tracking_topology_sorter_empty_graph() -> None:
468468
with pytest.raises(ValueError) as excinfo:
469469
topo.get_available()
470470
assert "topology is not active" in str(excinfo.value)
471+
472+
473+
def _build_graph(*edges: tuple[str, str, str]) -> DependencyGraph:
474+
"""Build a DependencyGraph from (parent, child, req_type) triples.
475+
476+
Use "ROOT" as parent name for top-level dependencies.
477+
"""
478+
graph = DependencyGraph()
479+
for parent_name, child_name, req_type_str in edges:
480+
parent_n = None if parent_name == "ROOT" else canonicalize_name(parent_name)
481+
parent_v = None if parent_name == "ROOT" else Version("1.0")
482+
graph.add_dependency(
483+
parent_name=parent_n,
484+
parent_version=parent_v,
485+
req_type=RequirementType(req_type_str),
486+
req=Requirement(f"{child_name}==1.0"),
487+
req_version=Version("1.0"),
488+
)
489+
return graph
490+
491+
492+
def test_remove_dependency_basic() -> None:
493+
"""Removing a leaf node cleans it from nodes and parent's children."""
494+
graph = _build_graph(
495+
("ROOT", "a", "toplevel"),
496+
("a", "b", "install"),
497+
)
498+
assert "b==1.0" in graph.nodes
499+
assert len(graph.nodes["a==1.0"].children) == 1
500+
501+
graph.remove_dependency(canonicalize_name("b"), Version("1.0"))
502+
503+
assert "b==1.0" not in graph.nodes
504+
assert len(graph.nodes["a==1.0"].children) == 0
505+
506+
507+
def test_remove_dependency_cascades_orphans() -> None:
508+
"""Removing a node recursively removes its orphaned descendants."""
509+
# ROOT -> a -> b -> c (linear chain)
510+
graph = _build_graph(
511+
("ROOT", "a", "toplevel"),
512+
("a", "b", "install"),
513+
("b", "c", "install"),
514+
)
515+
assert "a==1.0" in graph.nodes
516+
assert "b==1.0" in graph.nodes
517+
assert "c==1.0" in graph.nodes
518+
519+
graph.remove_dependency(canonicalize_name("a"), Version("1.0"))
520+
521+
assert "a==1.0" not in graph.nodes
522+
assert "b==1.0" not in graph.nodes
523+
assert "c==1.0" not in graph.nodes
524+
assert len(graph) == 0
525+
526+
527+
def test_remove_dependency_keeps_shared_children() -> None:
528+
"""Children with other parents are kept; only the stale parent edge is removed."""
529+
# ROOT -> a -> shared
530+
# ROOT -> b -> shared
531+
graph = _build_graph(
532+
("ROOT", "a", "toplevel"),
533+
("ROOT", "b", "toplevel"),
534+
("a", "shared", "install"),
535+
("b", "shared", "install"),
536+
)
537+
shared_node = graph.nodes["shared==1.0"]
538+
assert len(shared_node.parents) == 2
539+
540+
graph.remove_dependency(canonicalize_name("a"), Version("1.0"))
541+
542+
assert "a==1.0" not in graph.nodes
543+
assert "shared==1.0" in graph.nodes
544+
assert len(shared_node.parents) == 1
545+
assert shared_node.parents[0].destination_node.key == "b==1.0"
546+
547+
548+
def test_remove_dependency_diamond_sequential() -> None:
549+
"""Diamond: shared child survives first removal, cleaned up by second."""
550+
# ROOT -> a -> c
551+
# ROOT -> b -> c
552+
graph = _build_graph(
553+
("ROOT", "a", "toplevel"),
554+
("ROOT", "b", "toplevel"),
555+
("a", "c", "install"),
556+
("b", "c", "install"),
557+
)
558+
559+
graph.remove_dependency(canonicalize_name("a"), Version("1.0"))
560+
561+
assert "a==1.0" not in graph.nodes
562+
assert "c==1.0" in graph.nodes
563+
assert len(graph.nodes["c==1.0"].parents) == 1
564+
565+
graph.remove_dependency(canonicalize_name("b"), Version("1.0"))
566+
567+
assert "b==1.0" not in graph.nodes
568+
assert "c==1.0" not in graph.nodes
569+
assert len(graph) == 0
570+
571+
572+
def test_remove_dependency_already_removed_child() -> None:
573+
"""Removing a node whose child was already removed is safe."""
574+
# ROOT -> a -> b -> c
575+
graph = _build_graph(
576+
("ROOT", "a", "toplevel"),
577+
("a", "b", "install"),
578+
("b", "c", "install"),
579+
)
580+
581+
graph.remove_dependency(canonicalize_name("c"), Version("1.0"))
582+
graph.remove_dependency(canonicalize_name("b"), Version("1.0"))
583+
584+
assert "b==1.0" not in graph.nodes
585+
assert "c==1.0" not in graph.nodes
586+
assert "a==1.0" in graph.nodes
587+
assert len(graph.nodes["a==1.0"].children) == 0
588+
589+
590+
def test_remove_dependency_mid_graph_cascades() -> None:
591+
"""Removing a mid-graph node cascades to its exclusive subtree.
592+
593+
Verifies both that orphaned nodes are gone and that the surviving
594+
tree structure (edges in both directions) is fully intact.
595+
"""
596+
# ROOT -> a -> b -> d
597+
# b -> e
598+
# a -> c
599+
graph = _build_graph(
600+
("ROOT", "a", "toplevel"),
601+
("a", "b", "install"),
602+
("a", "c", "install"),
603+
("b", "d", "install"),
604+
("b", "e", "install"),
605+
)
606+
607+
graph.remove_dependency(canonicalize_name("b"), Version("1.0"))
608+
609+
# Removed subtree is gone
610+
assert "b==1.0" not in graph.nodes
611+
assert "d==1.0" not in graph.nodes
612+
assert "e==1.0" not in graph.nodes
613+
614+
# Exactly ROOT, a, c remain
615+
assert set(graph.nodes.keys()) == {"", "a==1.0", "c==1.0"}
616+
617+
# ROOT -> a edge intact
618+
root = graph.get_root_node()
619+
assert len(root.children) == 1
620+
assert root.children[0].destination_node.key == "a==1.0"
621+
622+
# a -> c is the only surviving child edge, and a's parent is ROOT
623+
node_a = graph.nodes["a==1.0"]
624+
assert len(node_a.children) == 1
625+
assert node_a.children[0].destination_node.key == "c==1.0"
626+
assert len(node_a.parents) == 1
627+
assert node_a.parents[0].destination_node.key == ""
628+
629+
# c has a as parent, no children
630+
node_c = graph.nodes["c==1.0"]
631+
assert len(node_c.children) == 0
632+
assert len(node_c.parents) == 1
633+
assert node_c.parents[0].destination_node.key == "a==1.0"
634+
635+
636+
def test_remove_dependency_nonexistent() -> None:
637+
"""Removing a node not in the graph is a no-op."""
638+
graph = _build_graph(("ROOT", "a", "toplevel"))
639+
node_count = len(graph.nodes)
640+
641+
graph.remove_dependency(canonicalize_name("nonexistent"), Version("1.0"))
642+
643+
assert len(graph.nodes) == node_count

0 commit comments

Comments
 (0)