Skip to content

Commit cf6eb3e

Browse files
committed
WIP Initial implementation of find_cycle_breakers
(Needs more tests.)
1 parent 7d4020b commit cf6eb3e

3 files changed

Lines changed: 123 additions & 5 deletions

File tree

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: 48 additions & 3 deletions
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,
@@ -444,8 +445,52 @@ def find_cycle_breakers(self, package: str) -> list[Import]:
444445
"""
445446
Identify a set of imports that, if removed, would make the package locally acyclic.
446447
"""
447-
# TODO
448-
return []
448+
children = self.find_children(package)
449+
if len(children) < 2:
450+
return []
451+
igraph = ig.Graph(directed=True)
452+
igraph.add_vertices([package, *children])
453+
edges: list[tuple[str, str]] = []
454+
weights: list[int] = []
455+
for downstream, upstream in itertools.permutations(children):
456+
total_imports = 0
457+
for expression in (
458+
f"{downstream} -> {upstream}",
459+
f"{downstream}.** -> {upstream}",
460+
f"{downstream} -> {upstream}.**",
461+
f"{downstream}.** -> {upstream}.**",
462+
):
463+
total_imports += len(self.find_matching_direct_imports(expression))
464+
if total_imports:
465+
edges.append((downstream, upstream))
466+
weights.append(total_imports)
467+
468+
igraph.add_edges(edges)
469+
igraph.es["weight"] = weights
470+
471+
arc_set = igraph.feedback_arc_set(weights="weight")
472+
473+
squashed_imports: list[Import] = []
474+
for edge_id in arc_set:
475+
edge = igraph.es[edge_id]
476+
squashed_imports.append(
477+
{
478+
"importer": edge.source_vertex["name"],
479+
"imported": edge.target_vertex["name"],
480+
}
481+
)
482+
483+
unsquashed_imports: list[Import] = []
484+
for squashed_import in squashed_imports:
485+
for pattern in (
486+
f"{squashed_import['importer']} -> {squashed_import['imported']}",
487+
f"{squashed_import['importer']}.** -> {squashed_import['imported']}",
488+
f"{squashed_import['importer']} -> {squashed_import['imported']}.**",
489+
f"{squashed_import['importer']}.** -> {squashed_import['imported']}.**",
490+
):
491+
unsquashed_imports.extend(self.find_matching_direct_imports(pattern))
492+
493+
return unsquashed_imports
449494

450495
# Dunder methods
451496
# --------------
Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,83 @@
11
from grimp.application.graph import ImportGraph
2+
import pytest
23

34

45
class TestFindCycleBreakers:
56
def test_empty_graph(self):
67
graph = ImportGraph()
7-
graph.add_module("mypackage")
8+
graph.add_module("pkg")
89

9-
result = graph.find_cycle_breakers("mypackage")
10+
result = graph.find_cycle_breakers("pkg")
1011

1112
assert result == []
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.find_cycle_breakers(module)
26+
27+
assert result == []
28+
29+
@pytest.mark.parametrize(
30+
"module",
31+
(
32+
"pkg",
33+
"pkg.foo",
34+
"pkg.foo.blue",
35+
),
36+
)
37+
def test_acyclic_graph(self, module: str):
38+
graph = self._build_acyclic_graph()
39+
40+
result = graph.find_cycle_breakers(module)
41+
42+
assert result == []
43+
44+
def test_one_breaker(self):
45+
graph = self._build_acyclic_graph()
46+
importer, imported = "pkg.bar.red.four", "pkg.foo.blue.two"
47+
graph.add_import(importer=importer, imported=imported)
48+
result = graph.find_cycle_breakers("pkg")
49+
50+
assert result == [
51+
{
52+
"importer": importer,
53+
"imported": imported,
54+
}
55+
]
56+
57+
def _build_graph_with_no_imports(self) -> ImportGraph:
58+
graph = ImportGraph()
59+
for module in (
60+
"pkg",
61+
"pkg.foo",
62+
"pkg.foo.blue",
63+
"pkg.foo.blue.one",
64+
"pkg.foo.blue.two",
65+
"pkg.foo.green",
66+
"pkg.bar",
67+
"pkg.bar.red",
68+
"pkg.bar.red.three",
69+
"pkg.bar.red.four",
70+
"pkg.bar.yellow",
71+
):
72+
graph.add_module(module)
73+
return graph
74+
75+
def _build_acyclic_graph(self) -> ImportGraph:
76+
graph = self._build_graph_with_no_imports()
77+
for importer, imported in (
78+
("pkg.foo", "pkg.bar.red"),
79+
("pkg.foo.green", "pkg.bar.yellow"),
80+
("pkg.foo.blue.two", "pkg.bar.red.three"),
81+
):
82+
graph.add_import(importer=importer, imported=imported)
83+
return graph

0 commit comments

Comments
 (0)