Skip to content

Commit ea5da5f

Browse files
committed
Add nominate_cycle_breakers method
1 parent e239dcd commit ea5da5f

5 files changed

Lines changed: 278 additions & 2 deletions

File tree

CHANGELOG.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
Changelog
33
=========
44

5+
latest
6+
------
7+
8+
* Add `nominate_cycle_breakers` method.
9+
510
3.12 (2025-10-09)
611
-----------------
712

docs/usage.rst

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ Methods for analysing direct imports
198198
199199
Find all direct imports matching the passed import expression.
200200

201-
The imports are returned are in the following form::
201+
The imports returned are in the following form::
202202

203203
[
204204
{
@@ -505,6 +505,40 @@ Higher level analysis
505505

506506
``frozenset[str]``: Imported modules at the end of the chain.
507507

508+
.. py:function:: ImportGraph.nominate_cycle_breakers(package)
509+
510+
Choose an approximately minimal set of dependencies that, if removed, would make the package locally acyclic.
511+
512+
- 'Acyclic' means that there are no direct dependency cycles between the package's children. Indirect
513+
dependencies (i.e. ones involving modules outside the supplied package) are disregarded,
514+
as are imports between the package and its children.
515+
- 'Dependency cycles' mean cycles between the *squashed* children (see `Terminology`_ above).
516+
517+
Multiple sets of cycle breakers can exist for a given package. To arrive at this particular set, the following
518+
approach is used:
519+
520+
1. Create a graph whose nodes are each child of the package.
521+
2. For each pair of children, add directed edges corresponding to whether there are imports between those two
522+
children (as packages, rather than individual modules). The edges are weighted according to the number of
523+
_dependencies_ they represent: this is usually the same as the number of imports, but if a module imports
524+
another module in multiple places, it will be treated as a single dependency.
525+
3. Calculate the approximately
526+
`minimum weighted feedback arc set <https://en.wikipedia.org/wiki/Feedback_arc_set>`_.
527+
This attempts to find a set of edges with the smallest total weight that can be removed from the graph in order
528+
to make it acyclic. It uses the greedy cycle-breaking heuristic of Eades, Lin and Smyth: not guaranteed
529+
to find the optimal solution, but it is faster than alternatives.
530+
4. These edges are then used to look up all the concrete imports in the fully unsquashed graph, which are returned.
531+
For example, an edge discovered in step 3. of ``mypackage.foo -> mypackage.bar``, with a weight 3, might correspond
532+
to these three imports: ``mypackage.foo.blue -> mypackage.bar.green``,
533+
``mypackage.foo.blue.one -> mypackage.bar.green.two``, and ``mypackage.foo.blue -> mypackage.bar.green.three``.
534+
535+
:param str package: The package in the graph to check for cycles. If a module with no children is passed,
536+
an empty set is be returned.
537+
:return: A set of imports that, if removed, would make the imports between the the children of the supplied
538+
package acyclic.
539+
:rtype: ``set[tuple[str, str]]``. In each import tuple, the first element is the importer module and the second
540+
is the imported.
541+
508542
Methods for manipulating the graph
509543
----------------------------------
510544

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ authors = [
1616
]
1717
requires-python = ">=3.9"
1818
dependencies = [
19+
"igraph>=0.11.9",
1920
"typing-extensions>=3.10.0.0",
2021
]
2122
classifiers = [

src/grimp/application/graph.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from __future__ import annotations
2+
import itertools
23
from typing import List, Optional, Sequence, Set, Tuple, TypedDict
34
from grimp.domain.analysis import PackageDependency, Route
45
from grimp.domain.valueobjects import Layer
5-
6+
import igraph as ig # type: ignore
67
from grimp import _rustgrimp as rust # type: ignore[attr-defined]
78
from grimp.exceptions import (
89
ModuleNotPresent,
@@ -17,6 +18,11 @@ class Import(TypedDict):
1718
imported: str
1819

1920

21+
# Corresponds to importer, imported.
22+
# Prefer this form to Import, as it's both more lightweight, and hashable.
23+
ImportTuple = Tuple[str, str]
24+
25+
2026
class DetailedImport(Import):
2127
line_number: int
2228
line_contents: str
@@ -440,6 +446,57 @@ def find_illegal_dependencies_for_layers(
440446

441447
return _dependencies_from_tuple(result)
442448

449+
def nominate_cycle_breakers(self, package: str) -> set[ImportTuple]:
450+
"""
451+
Identify a set of imports that, if removed, would make the package locally acyclic.
452+
"""
453+
children = self.find_children(package)
454+
if len(children) < 2:
455+
return set()
456+
igraph = ig.Graph(directed=True)
457+
igraph.add_vertices([package, *children])
458+
edges: list[tuple[str, str]] = []
459+
weights: list[int] = []
460+
for downstream, upstream in itertools.permutations(children, r=2):
461+
total_imports = 0
462+
for expression in (
463+
f"{downstream} -> {upstream}",
464+
f"{downstream}.** -> {upstream}",
465+
f"{downstream} -> {upstream}.**",
466+
f"{downstream}.** -> {upstream}.**",
467+
):
468+
total_imports += len(self.find_matching_direct_imports(expression))
469+
if total_imports:
470+
edges.append((downstream, upstream))
471+
weights.append(total_imports)
472+
473+
igraph.add_edges(edges)
474+
igraph.es["weight"] = weights
475+
476+
arc_set = igraph.feedback_arc_set(weights="weight")
477+
478+
squashed_imports: list[Import] = []
479+
for edge_id in arc_set:
480+
edge = igraph.es[edge_id]
481+
squashed_imports.append(
482+
{
483+
"importer": edge.source_vertex["name"],
484+
"imported": edge.target_vertex["name"],
485+
}
486+
)
487+
488+
unsquashed_imports: list[Import] = []
489+
for squashed_import in squashed_imports:
490+
for pattern in (
491+
f"{squashed_import['importer']} -> {squashed_import['imported']}",
492+
f"{squashed_import['importer']}.** -> {squashed_import['imported']}",
493+
f"{squashed_import['importer']} -> {squashed_import['imported']}.**",
494+
f"{squashed_import['importer']}.** -> {squashed_import['imported']}.**",
495+
):
496+
unsquashed_imports.extend(self.find_matching_direct_imports(pattern))
497+
498+
return {(i["importer"], i["imported"]) for i in unsquashed_imports}
499+
443500
# Dunder methods
444501
# --------------
445502

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
from grimp.application.graph import ImportGraph
2+
import pytest
3+
4+
5+
class TestNominateCycleBreakers:
6+
def test_empty_graph(self):
7+
graph = ImportGraph()
8+
graph.add_module("pkg")
9+
10+
result = graph.nominate_cycle_breakers("pkg")
11+
12+
assert result == set()
13+
14+
@pytest.mark.parametrize(
15+
"module",
16+
(
17+
"pkg",
18+
"pkg.foo",
19+
"pkg.foo.blue",
20+
),
21+
)
22+
def test_graph_with_no_imports(self, module: str):
23+
graph = self._build_graph_with_no_imports()
24+
25+
result = graph.nominate_cycle_breakers(module)
26+
27+
assert result == set()
28+
29+
@pytest.mark.parametrize(
30+
"module",
31+
(
32+
"pkg",
33+
"pkg.bar",
34+
"pkg.foo.blue",
35+
"pkg.foo.green", # Leaf package.
36+
),
37+
)
38+
def test_acyclic_graph(self, module: str):
39+
graph = self._build_acyclic_graph()
40+
41+
result = graph.nominate_cycle_breakers(module)
42+
43+
assert result == set()
44+
45+
def test_one_breaker(self):
46+
graph = self._build_acyclic_graph()
47+
importer, imported = "pkg.bar.red.four", "pkg.foo.blue.two"
48+
graph.add_import(importer=importer, imported=imported)
49+
result = graph.nominate_cycle_breakers("pkg")
50+
51+
assert result == {(importer, imported)}
52+
53+
def test_three_breakers(self):
54+
graph = self._build_acyclic_graph()
55+
imports = {
56+
("pkg.bar.red.four", "pkg.foo.blue.two"),
57+
("pkg.bar.yellow", "pkg.foo.blue.three"),
58+
("pkg.bar", "pkg.foo.blue.three"),
59+
}
60+
for importer, imported in imports:
61+
graph.add_import(importer=importer, imported=imported)
62+
63+
result = graph.nominate_cycle_breakers("pkg")
64+
65+
assert result == imports
66+
67+
def test_nominated_based_on_dependencies_rather_than_imports(self):
68+
graph = self._build_acyclic_graph()
69+
# Add lots of imports from a single module - this will be treated as
70+
# a single dependency.
71+
importer, imported = "pkg.bar.red.four", "pkg.foo.blue.two"
72+
for i in range(1, 30):
73+
graph.add_import(
74+
importer=importer, imported=imported, line_number=i, line_contents="-"
75+
)
76+
77+
graph.add_import(importer=importer, imported=imported)
78+
79+
result = graph.nominate_cycle_breakers("pkg")
80+
81+
assert result == {(importer, imported)}
82+
83+
def test_imports_between_passed_package_and_children_are_disregarded(self):
84+
graph = self._build_acyclic_graph()
85+
parent, child = "pkg.foo.blue", "pkg.foo"
86+
graph.add_import(importer=parent, imported=child)
87+
graph.add_import(importer=child, imported=parent)
88+
89+
result = graph.nominate_cycle_breakers(parent)
90+
91+
assert result == set()
92+
93+
def test_on_child_of_root(self):
94+
graph = self._build_acyclic_graph()
95+
imports = {
96+
("pkg.bar.red.five", "pkg.bar.yellow.eight"),
97+
("pkg.bar.red", "pkg.bar.yellow"),
98+
}
99+
for importer, imported in imports:
100+
graph.add_import(importer=importer, imported=imported)
101+
102+
result = graph.nominate_cycle_breakers("pkg.bar")
103+
104+
assert result == imports
105+
106+
def test_on_grandchild_of_root(self):
107+
graph = self._build_acyclic_graph()
108+
imports = {
109+
("pkg.bar.orange.ten.gamma", "pkg.bar.orange.nine.alpha"),
110+
("pkg.bar.orange.ten", "pkg.bar.orange.nine.alpha"),
111+
}
112+
for importer, imported in imports:
113+
graph.add_import(importer=importer, imported=imported)
114+
115+
result = graph.nominate_cycle_breakers("pkg.bar.orange")
116+
117+
assert result == imports
118+
119+
def test_on_package_with_one_child(self):
120+
graph = self._build_acyclic_graph()
121+
graph.add_module("pkg.bar.orange.ten.gamma.onechild")
122+
123+
result = graph.nominate_cycle_breakers("pkg.bar.orange.ten.gamma")
124+
125+
assert result == set()
126+
127+
def _build_graph_with_no_imports(self) -> ImportGraph:
128+
graph = ImportGraph()
129+
for module in (
130+
"pkg",
131+
"pkg.foo",
132+
"pkg.foo.blue",
133+
"pkg.foo.blue.one",
134+
"pkg.foo.blue.two",
135+
"pkg.foo.green",
136+
"pkg.bar",
137+
"pkg.bar.red",
138+
"pkg.bar.red.three",
139+
"pkg.bar.red.four",
140+
"pkg.bar.red.five",
141+
"pkg.bar.red.six",
142+
"pkg.bar.red.seven",
143+
"pkg.bar.yellow",
144+
"pkg.bar.yellow.eight",
145+
"pkg.bar.orange",
146+
"pkg.bar.orange.nine",
147+
"pkg.bar.orange.nine.alpha",
148+
"pkg.bar.orange.nine.beta",
149+
"pkg.bar.orange.ten",
150+
"pkg.bar.orange.ten.gamma",
151+
"pkg.bar.orange.ten.delta",
152+
):
153+
graph.add_module(module)
154+
return graph
155+
156+
def _build_acyclic_graph(self) -> ImportGraph:
157+
graph = self._build_graph_with_no_imports()
158+
# Add imports that make:
159+
# pkg.foo -> pkg.bar
160+
# pkg.bar.yellow -> pkg.foo.red
161+
# pkg.bar.orange.nine -> pkg.bar.orange.ten
162+
for importer, imported in (
163+
("pkg.foo", "pkg.bar.red"),
164+
("pkg.foo.green", "pkg.bar.yellow"),
165+
("pkg.foo.blue.two", "pkg.bar.red.three"),
166+
("pkg.foo.blue.two", "pkg.bar.red.four"),
167+
("pkg.foo.blue.two", "pkg.bar.red.five"),
168+
("pkg.foo.blue.two", "pkg.bar.red.six"),
169+
("pkg.foo.blue.two", "pkg.bar.red.seven"),
170+
("pkg.bar.yellow", "pkg.bar.red"),
171+
("pkg.bar.yellow.eight", "pkg.bar.red.three"),
172+
("pkg.bar.yellow.eight", "pkg.bar.red.four"),
173+
("pkg.bar.yellow.eight", "pkg.bar.red.five"),
174+
("pkg.bar.orange.nine", "pkg.bar.orange.ten.gamma"),
175+
("pkg.bar.orange.nine.alpha", "pkg.bar.orange.ten.gamma"),
176+
("pkg.bar.orange.nine.beta", "pkg.bar.orange.ten.delta"),
177+
):
178+
graph.add_import(importer=importer, imported=imported)
179+
return graph

0 commit comments

Comments
 (0)