From 02504672bdd129ea181b6509eadd6160930d61d9 Mon Sep 17 00:00:00 2001 From: Tomas Bayer Date: Sun, 28 Dec 2025 13:21:54 +0100 Subject: [PATCH 1/5] Separate edge detection from edge construction in dot graph building Previously, build_edge() had two responsibilities: deciding whether an edge should exist and constructing the dot-specific Edge object. This change extracts the adjacency check into a dedicated has_edge() predicate, decoupling graph construction from dot rendering and enabling future inspection and manipulation before rendering. --- src/impulse/application/use_cases.py | 62 +++++++++++++++------------- 1 file changed, 34 insertions(+), 28 deletions(-) diff --git a/src/impulse/application/use_cases.py b/src/impulse/application/use_cases.py index bf79454..fa83005 100644 --- a/src/impulse/application/use_cases.py +++ b/src/impulse/application/use_cases.py @@ -50,7 +50,8 @@ def build(self, module_name: str, grimp_graph: grimp.ImportGraph) -> dotfile.Dot for child in children: dot.add_node(child) for upstream, downstream in itertools.permutations(children, r=2): - if edge := self.build_edge(grimp_graph, upstream, downstream): + if self.has_edge(grimp_graph, upstream, downstream): + edge = self.build_edge(grimp_graph, upstream, downstream) dot.add_edge(edge) return dot @@ -61,9 +62,12 @@ def should_concentrate(self) -> bool: def prepare_graph(self, grimp_graph: grimp.ImportGraph, children: Set[str]) -> None: pass + def has_edge(self, grimp_graph: grimp.ImportGraph, upstream: str, downstream: str) -> bool: + raise NotImplementedError + def build_edge( self, grimp_graph: grimp.ImportGraph, upstream: str, downstream: str - ) -> dotfile.Edge | None: + ) -> dotfile.Edge: raise NotImplementedError @@ -74,12 +78,13 @@ def prepare_graph(self, grimp_graph: grimp.ImportGraph, children: Set[str]) -> N for child in children: grimp_graph.squash_module(child) + def has_edge(self, grimp_graph: grimp.ImportGraph, upstream: str, downstream: str) -> bool: + return grimp_graph.direct_import_exists(importer=downstream, imported=upstream) + def build_edge( self, grimp_graph: grimp.ImportGraph, upstream: str, downstream: str - ) -> dotfile.Edge | None: - if grimp_graph.direct_import_exists(importer=downstream, imported=upstream): - return dotfile.Edge(source=downstream, destination=upstream) - return None + ) -> dotfile.Edge: + return dotfile.Edge(source=downstream, destination=upstream) class _ImportExpressionBuildStrategy(_DotGraphBuildStrategy): @@ -128,31 +133,32 @@ def _get_self_or_ancestor(candidate: str, ancestors: Set[str]) -> str | None: return ancestor return None + def has_edge(self, grimp_graph: grimp.ImportGraph, upstream: str, downstream: str) -> bool: + return grimp_graph.direct_import_exists( + importer=downstream, imported=upstream, as_packages=True + ) + def build_edge( self, grimp_graph: grimp.ImportGraph, upstream: str, downstream: str - ) -> dotfile.Edge | None: - if grimp_graph.direct_import_exists( - importer=downstream, imported=upstream, as_packages=True - ): - if self.show_import_totals: - number_of_imports = self._count_imports_between_packages( - grimp_graph, importer=downstream, imported=upstream - ) - label = str(number_of_imports) - else: - label = "" - - if self.show_cycle_breakers: - assert self.cycle_breakers is not None - is_cycle_breaker = (downstream, upstream) in self.cycle_breakers - emphasized = is_cycle_breaker - else: - emphasized = False - - return dotfile.Edge( - source=downstream, destination=upstream, label=label, emphasized=emphasized + ) -> dotfile.Edge: + if self.show_import_totals: + number_of_imports = self._count_imports_between_packages( + grimp_graph, importer=downstream, imported=upstream ) - return None + label = str(number_of_imports) + else: + label = "" + + if self.show_cycle_breakers: + assert self.cycle_breakers is not None + is_cycle_breaker = (downstream, upstream) in self.cycle_breakers + emphasized = is_cycle_breaker + else: + emphasized = False + + return dotfile.Edge( + source=downstream, destination=upstream, label=label, emphasized=emphasized + ) @staticmethod def _count_imports_between_packages( From 1f3051ca0f1b2fde678fbb534194edaeee4996ae Mon Sep 17 00:00:00 2001 From: Tomas Bayer Date: Sun, 28 Dec 2025 13:25:24 +0100 Subject: [PATCH 2/5] Introduce a directed graph model This change introduces a minimal directed graph as an intermediate layer between grimp's ImportGraph and DotGraph. This graph represents what is presented to the user and allows future analysis and manipulation independently of rendering. --- src/impulse/application/use_cases.py | 23 +++++---- src/impulse/graph.py | 27 +++++++++++ tests/unit/test_graph.py | 70 ++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 src/impulse/graph.py create mode 100644 tests/unit/test_graph.py diff --git a/src/impulse/application/use_cases.py b/src/impulse/application/use_cases.py index fa83005..1870948 100644 --- a/src/impulse/application/use_cases.py +++ b/src/impulse/application/use_cases.py @@ -1,8 +1,7 @@ from collections.abc import Set from collections.abc import Callable -import itertools import grimp -from impulse import ports, dotfile +from impulse import ports, dotfile, graph def draw_graph( @@ -46,13 +45,21 @@ def build(self, module_name: str, grimp_graph: grimp.ImportGraph) -> dotfile.Dot self.prepare_graph(grimp_graph, children) + presentation_graph = graph.DirectedGraphWithoutLoops.from_adjacency_condition( + vertices=children, + is_adjacent=lambda upstream, downstream: self.has_edge( + grimp_graph, upstream, downstream + ), + ) + dot = dotfile.DotGraph(title=module_name, concentrate=self.should_concentrate()) - for child in children: - dot.add_node(child) - for upstream, downstream in itertools.permutations(children, r=2): - if self.has_edge(grimp_graph, upstream, downstream): - edge = self.build_edge(grimp_graph, upstream, downstream) - dot.add_edge(edge) + + for vertex in presentation_graph.vertices: + dot.add_node(vertex) + + for upstream, downstream in presentation_graph.iter_edges(): + dot_edge = self.build_edge(grimp_graph, upstream, downstream) + dot.add_edge(dot_edge) return dot diff --git a/src/impulse/graph.py b/src/impulse/graph.py new file mode 100644 index 0000000..b930d02 --- /dev/null +++ b/src/impulse/graph.py @@ -0,0 +1,27 @@ +import dataclasses +from typing import Callable, Generic, Iterator, Mapping, TypeVar + +V = TypeVar("V") # type for graph vertices + + +@dataclasses.dataclass(frozen=True) +class DirectedGraphWithoutLoops(Generic[V]): + vertices: frozenset[V] + _adjacency_map: Mapping[V, set[V]] + + @classmethod + def from_adjacency_condition(cls, vertices: set[V], is_adjacent: Callable[[V, V], bool]): + adjacency_map = { + from_vertex: { + to_vertex + for to_vertex in vertices + if from_vertex != to_vertex and is_adjacent(from_vertex, to_vertex) + } + for from_vertex in vertices + } + return cls(vertices=frozenset(vertices), _adjacency_map=adjacency_map) + + def iter_edges(self) -> Iterator[tuple[V, V]]: + for from_vertex in self._adjacency_map: + for to_vertex in self._adjacency_map[from_vertex]: + yield from_vertex, to_vertex diff --git a/tests/unit/test_graph.py b/tests/unit/test_graph.py new file mode 100644 index 0000000..04a8144 --- /dev/null +++ b/tests/unit/test_graph.py @@ -0,0 +1,70 @@ +from impulse import graph + + +class TestDirectedGraphWithoutLoops: + def test_from_adjacency(self): + vertices = {"A", "B", "C"} + + def is_adjacent(from_vertex, to_vertex): + return from_vertex < to_vertex + + test_graph = graph.DirectedGraphWithoutLoops.from_adjacency_condition( + vertices, is_adjacent + ) + + # Self-loops should be excluded + assert test_graph._adjacency_map == {"A": {"B", "C"}, "B": {"C"}, "C": set()} + + def test_from_adjacency_for_complete_graph(self): + vertices = {"A", "B", "C"} + + def is_adjacent(from_vertex, to_vertex): + return True + + test_graph = graph.DirectedGraphWithoutLoops.from_adjacency_condition( + vertices, is_adjacent + ) + + assert test_graph._adjacency_map == { + "A": {"B", "C"}, + "B": {"A", "C"}, + "C": {"A", "B"}, + } + + def test_from_adjacency_for_empty_graph(self): + vertices = {"A", "B", "C"} + + def is_adjacent(from_vertex, to_vertex): + return False + + test_graph = graph.DirectedGraphWithoutLoops.from_adjacency_condition( + vertices, is_adjacent + ) + + assert test_graph._adjacency_map == {"A": set(), "B": set(), "C": set()} + + def test_from_adjacency_empty_vertices(self): + vertices = set() + + def is_adjacent(from_vertex, to_vertex): + return True + + test_graph = graph.DirectedGraphWithoutLoops.from_adjacency_condition( + vertices, is_adjacent + ) + + assert test_graph.vertices == set() + assert test_graph._adjacency_map == {} + + def test_iter_edges(self): + vertices = {"A", "B", "C"} + edges = { + "A": {"B", "C"}, + "B": {"C"}, + "C": set(), + } + g = graph.DirectedGraphWithoutLoops(vertices, edges) + + result = set(g.iter_edges()) + expected = {("A", "B"), ("A", "C"), ("B", "C")} + assert result == expected From 45c261eed330f75f545402df7591595360847fb6 Mon Sep 17 00:00:00 2001 From: Tomas Bayer Date: Sun, 28 Dec 2025 14:29:47 +0100 Subject: [PATCH 3/5] Add support for removing vertices from the presentation graph --- src/impulse/graph.py | 10 ++++++++++ tests/unit/test_graph.py | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/impulse/graph.py b/src/impulse/graph.py index b930d02..c9ea3da 100644 --- a/src/impulse/graph.py +++ b/src/impulse/graph.py @@ -25,3 +25,13 @@ def iter_edges(self) -> Iterator[tuple[V, V]]: for from_vertex in self._adjacency_map: for to_vertex in self._adjacency_map[from_vertex]: yield from_vertex, to_vertex + + def remove_vertices(self, vertices_to_remove: set[V]) -> "DirectedGraphWithoutLoops[V]": + new_vertices = frozenset(self.vertices - vertices_to_remove) + return self.__class__( + vertices=new_vertices, + _adjacency_map={ + from_vertex: self._adjacency_map[from_vertex] - vertices_to_remove + for from_vertex in new_vertices + }, + ) diff --git a/tests/unit/test_graph.py b/tests/unit/test_graph.py index 04a8144..2793858 100644 --- a/tests/unit/test_graph.py +++ b/tests/unit/test_graph.py @@ -1,3 +1,4 @@ +import pytest from impulse import graph @@ -68,3 +69,27 @@ def test_iter_edges(self): result = set(g.iter_edges()) expected = {("A", "B"), ("A", "C"), ("B", "C")} assert result == expected + + @pytest.mark.parametrize( + ("vertices_to_remove", "expected_vertices", "expected_adjacency_map"), + [ + (set(), {"A", "B", "C"}, {"A": {"B"}, "B": {"C"}, "C": set()}), + ({"C"}, {"A", "B"}, {"A": {"B"}, "B": set()}), + ({"B"}, {"A", "C"}, {"A": set(), "C": set()}), + ({"A", "B", "C"}, set(), {}), + ], + ) + def test_remove_vertices( + self, + vertices_to_remove: set[str], + expected_vertices: set[str], + expected_adjacency_map: dict[str, set[str]], + ): + vertices = {"A", "B", "C"} + adjacency_map = {"A": {"B"}, "B": {"C"}, "C": set()} + test_graph = graph.DirectedGraphWithoutLoops(frozenset(vertices), adjacency_map) + + result_graph = test_graph.remove_vertices(vertices_to_remove) + + assert result_graph.vertices == expected_vertices + assert result_graph._adjacency_map == expected_adjacency_map From 8e76df091386982b59843611ab129e91bf7002ab Mon Sep 17 00:00:00 2001 From: Tomas Bayer Date: Sun, 28 Dec 2025 14:31:09 +0100 Subject: [PATCH 4/5] Add acyclic vertices detection logic This change uses Tarjan's algorithm to detect strongly connected components, with a small variation that returns only vertices belonging to a strongly connected component of size 1. --- src/impulse/graph.py | 67 +++++++++++++++++++++++++++++++ tests/unit/test_graph.py | 85 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/src/impulse/graph.py b/src/impulse/graph.py index c9ea3da..7e5c64b 100644 --- a/src/impulse/graph.py +++ b/src/impulse/graph.py @@ -1,4 +1,5 @@ import dataclasses +import itertools from typing import Callable, Generic, Iterator, Mapping, TypeVar V = TypeVar("V") # type for graph vertices @@ -35,3 +36,69 @@ def remove_vertices(self, vertices_to_remove: set[V]) -> "DirectedGraphWithoutLo for from_vertex in new_vertices }, ) + + def find_acyclic_vertices(self) -> set[V]: + """ + Find all vertices that are not part of any cycle. + + This function uses Tarjan's strongly connected components algorithm. The + algorithm performs a single depth first search of the graph and groups + vertices into strongly connected components (SCCs), where each vertex in + an SCC is reachable from every other vertex in the same SCC. + + Under the assumption that the graph contains no self loops, a vertex is + not part of a cycle if and only if the SCC it is part of contains no + other vertices. + + Returns: + A set of vertices that are not part of any cycle. + """ + # Vertices are assigned indices in the order they are encountered + index_generator: Iterator[int] = itertools.count() + index_map: dict[V, int] = {} + lowest_reachable_index: dict[V, int] = {} + + active_stack: list[V] = [] + # Mirror of active_stack for O(1) membership checks + active_stack_set: set[V] = set() + + acyclic_vertices: set[V] = set() + + def visit(v: V) -> None: + index = next(index_generator) + + index_map[v] = index + lowest_reachable_index[v] = index + + active_stack.append(v) + active_stack_set.add(v) + + for w in self._adjacency_map.get(v, {}): + if w not in index_map: + visit(w) + lowest_reachable_index[v] = min( + lowest_reachable_index[v], lowest_reachable_index[w] + ) + elif w in active_stack_set: + lowest_reachable_index[v] = min(lowest_reachable_index[v], index_map[w]) + + if lowest_reachable_index[v] == index_map[v]: + scc: list[V] = [] + while True: + w = active_stack.pop() + active_stack_set.remove(w) + scc.append(w) + if w == v: + break + + if len(scc) == 1: + acyclic_vertices.update(scc) + + for vertex in self.vertices: + if vertex not in index_map: + visit(vertex) + + return acyclic_vertices + + def remove_acyclic_vertices(self) -> "DirectedGraphWithoutLoops[V]": + return self.remove_vertices(self.find_acyclic_vertices()) diff --git a/tests/unit/test_graph.py b/tests/unit/test_graph.py index 2793858..32c3396 100644 --- a/tests/unit/test_graph.py +++ b/tests/unit/test_graph.py @@ -93,3 +93,88 @@ def test_remove_vertices( assert result_graph.vertices == expected_vertices assert result_graph._adjacency_map == expected_adjacency_map + + def test_find_acyclic_vertices_empty_graph(self): + g = graph.DirectedGraphWithoutLoops(set(), {}) + assert g.find_acyclic_vertices() == set() + + def test_find_acyclic_vertices_simple_acyclic(self): + vertices = {"A", "B", "C"} + adjacency_map = {"A": {"B", "C"}, "B": {"C"}, "C": set()} + g = graph.DirectedGraphWithoutLoops(vertices, adjacency_map) + assert g.find_acyclic_vertices() == {"A", "B", "C"} + + def test_find_acyclic_vertices_simple_cycle(self): + vertices = {"A", "B", "C"} + adjacency_map = {"A": {"B"}, "B": {"C"}, "C": {"A"}} + g = graph.DirectedGraphWithoutLoops(vertices, adjacency_map) + assert g.find_acyclic_vertices() == set() + + def test_find_acyclic_vertices_with_branches(self): + vertices = {"A", "B", "C", "D", "E", "F"} + adjacency_map = { + "A": {"B", "C"}, # A branches to B and C + "B": {"D"}, # B -> D -> E -> B (cycle) + "C": {"F"}, # C -> F (acyclic branch) + "D": {"E"}, + "E": {"B"}, + "F": set(), + } + g = graph.DirectedGraphWithoutLoops(vertices, adjacency_map) + assert g.find_acyclic_vertices() == {"A", "C", "F"} + + def test_find_acyclic_vertices_isolated_vertices(self): + """Isolated vertices (no edges) are acyclic.""" + vertices = {"A", "B", "C"} + adjacency_map = {"A": set(), "B": set(), "C": set()} + g = graph.DirectedGraphWithoutLoops(vertices, adjacency_map) + assert g.find_acyclic_vertices() == {"A", "B", "C"} + + def test_find_acyclic_vertices_multiple_cycles(self): + """Multiple disconnected cycles.""" + vertices = {"A", "B", "C", "D", "E", "F"} + adjacency_map = { + "A": {"B"}, # A -> B -> A (cycle 1) + "B": {"A"}, + "C": set(), # C is isolated (acyclic) + "D": {"E"}, # D -> E -> F -> D (cycle 2) + "E": {"F"}, + "F": {"D"}, + } + g = graph.DirectedGraphWithoutLoops(vertices, adjacency_map) + assert g.find_acyclic_vertices() == {"C"} + + def test_find_acyclic_vertices_mixed_graph(self): + """Graph with cycles, acyclic chains, and isolated vertices.""" + vertices = {"A", "B", "C", "D", "E", "F", "G"} + adjacency_map = { + "A": {"B"}, # A -> B -> C (acyclic chain) + "B": {"C"}, + "C": set(), + "D": {"E"}, # D -> E -> D (cycle) + "E": {"D"}, + "F": set(), # F isolated + "G": {"A"}, # G -> A (connects to acyclic chain) + } + g = graph.DirectedGraphWithoutLoops(vertices, adjacency_map) + assert g.find_acyclic_vertices() == {"A", "B", "C", "F", "G"} + + def test_remove_acyclic_vertices_returns_only_cycles(self): + """remove_acyclic_vertices should return a graph with only cyclic vertices.""" + vertices = {"A", "B", "C", "D", "E"} + adjacency_map = { + "A": {"B"}, # A -> B (acyclic) + "B": set(), + "C": {"D"}, # C -> D -> E -> C (cycle) + "D": {"E"}, + "E": {"C"}, + } + g = graph.DirectedGraphWithoutLoops(vertices, adjacency_map) + result = g.remove_acyclic_vertices() + + assert result.vertices == {"C", "D", "E"} + assert result._adjacency_map == { + "C": {"D"}, + "D": {"E"}, + "E": {"C"}, + } From 7c5978be16ba0b0e7188be3c9272a339156ee159 Mon Sep 17 00:00:00 2001 From: Tomas Bayer Date: Sun, 28 Dec 2025 14:39:30 +0100 Subject: [PATCH 5/5] Add option to hide acyclic vertices to usecase and CLI --- CHANGELOG.rst | 4 +++ README.rst | 17 +++++++++- .../_static/images/django.db.hide-acyclic.png | Bin 0 -> 23717 bytes justfile | 1 + src/impulse/application/use_cases.py | 24 +++++++++++++-- src/impulse/cli.py | 13 +++++++- tests/unit/application/test_use_cases.py | 29 ++++++++++++++++++ 7 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 docs/_static/images/django.db.hide-acyclic.png diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f9ba2d1..2111d8e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,10 @@ Changelog ========= +latest +---------------- +* Add --hide-acyclic flag. + 2.2 (2025-12-12) ---------------- diff --git a/README.rst b/README.rst index d2ec6b5..0e9a8b3 100644 --- a/README.rst +++ b/README.rst @@ -64,6 +64,7 @@ There is currently only one command. --show-cycle-breakers Identify a set of dependencies that, if removed, would make the graph acyclic, and display them as dashed lines. + --hide-acyclic Hide submodules that are not part of a cycle. --help Show this message and exit. Draw a graph of the dependencies within any installed Python package or subpackage. @@ -119,4 +120,18 @@ within ``django.db.utils``. Here you can see that two of the dependencies are shown as a dashed line. If these dependencies were to be removed, the graph would be acyclic. To decide on the cycle breakers, Impulse uses the -`nominate_cycle_breakers method provided by Grimp `_. \ No newline at end of file +`nominate_cycle_breakers method provided by Grimp `_. + +**Example with hide acyclic** + +.. code-block:: text + + impulse drawgraph django.db --hide-acyclic + +.. image:: https://raw.githubusercontent.com/seddonym/impulse/master/docs/_static/images/django.db.hide-acyclic.png + :align: center + :alt: Graph of django.db package with acyclic submodules hidden. + +This hides all submodules that do not form part of any cycle, making it easier to focus on circular +dependencies in larger packages. Combine this with ``--show-cycle-breakers`` for a condensed view +of cycles and how they can be broken. diff --git a/docs/_static/images/django.db.hide-acyclic.png b/docs/_static/images/django.db.hide-acyclic.png new file mode 100644 index 0000000000000000000000000000000000000000..046bc4c6fb90895ec1e7a6e5bbef32c3de5c6e94 GIT binary patch literal 23717 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 literal 0 HcmV?d00001 diff --git a/justfile b/justfile index 1764d38..c0f561a 100644 --- a/justfile +++ b/justfile @@ -14,6 +14,7 @@ test: @uv run --with=google-cloud-audit-log impulse drawgraph google.cloud.audit @uv run impulse drawgraph grimp --show-import-totals @uv run --with=django impulse drawgraph django.db --show-cycle-breakers + @uv run --with=django impulse drawgraph django.db --hide-acyclic # Run tests under all supported Python versions. diff --git a/src/impulse/application/use_cases.py b/src/impulse/application/use_cases.py index 1870948..ae8e85f 100644 --- a/src/impulse/application/use_cases.py +++ b/src/impulse/application/use_cases.py @@ -1,5 +1,6 @@ from collections.abc import Set from collections.abc import Callable + import grimp from impulse import ports, dotfile, graph @@ -8,6 +9,7 @@ def draw_graph( module_name: str, show_import_totals: bool, show_cycle_breakers: bool, + hide_acyclic: bool, sys_path: list[str], current_directory: str, get_top_level_package: Callable[[str], str], @@ -20,6 +22,7 @@ def draw_graph( module_name: the package or subpackage name of any importable Python package. show_import_totals: whether to label the arrows with the total number of imports they represent. show_cycle_breakers: marks a set of dependencies that, if removed, would make the graph acyclic. + hide_acyclic: whether to hide submodules that are not part of a cycle. sys_path: the sys.path list (or a test double). current_directory: the current working directory. get_top_level_package: the function to retrieve the top level package name. This will usually be the first part @@ -34,13 +37,24 @@ def draw_graph( top_level_package = get_top_level_package(module_name) grimp_graph = build_graph(top_level_package) - dot = _build_dot(grimp_graph, module_name, show_import_totals, show_cycle_breakers) + dot = _build_dot( + grimp_graph, + module_name, + show_import_totals, + show_cycle_breakers, + hide_acyclic, + ) viewer.view(dot) class _DotGraphBuildStrategy: - def build(self, module_name: str, grimp_graph: grimp.ImportGraph) -> dotfile.DotGraph: + def build( + self, + module_name: str, + grimp_graph: grimp.ImportGraph, + hide_acyclic: bool, + ) -> dotfile.DotGraph: children = grimp_graph.find_children(module_name) self.prepare_graph(grimp_graph, children) @@ -52,6 +66,9 @@ def build(self, module_name: str, grimp_graph: grimp.ImportGraph) -> dotfile.Dot ), ) + if hide_acyclic: + presentation_graph = presentation_graph.remove_acyclic_vertices() + dot = dotfile.DotGraph(title=module_name, concentrate=self.should_concentrate()) for vertex in presentation_graph.vertices: @@ -196,6 +213,7 @@ def _build_dot( module_name: str, show_import_totals: bool, show_cycle_breakers: bool, + hide_acyclic: bool, ) -> dotfile.DotGraph: strategy: _DotGraphBuildStrategy if show_import_totals or show_cycle_breakers: @@ -207,4 +225,4 @@ def _build_dot( else: strategy = _ModuleSquashingBuildStrategy() - return strategy.build(module_name, grimp_graph) + return strategy.build(module_name, grimp_graph, hide_acyclic) diff --git a/src/impulse/cli.py b/src/impulse/cli.py index fee13f9..186abdc 100644 --- a/src/impulse/cli.py +++ b/src/impulse/cli.py @@ -27,12 +27,23 @@ def main(): "and display them as dashed lines." ), ) +@click.option( + "--hide-acyclic", + is_flag=True, + help="Hide submodules that are not part of a cycle.", +) @click.argument("module_name", type=str) -def drawgraph(module_name: str, show_import_totals: bool, show_cycle_breakers: bool) -> None: +def drawgraph( + module_name: str, + show_import_totals: bool, + show_cycle_breakers: bool, + hide_acyclic: bool, +) -> None: use_cases.draw_graph( module_name=module_name, show_import_totals=show_import_totals, show_cycle_breakers=show_cycle_breakers, + hide_acyclic=hide_acyclic, sys_path=sys.path, current_directory=os.getcwd(), get_top_level_package=adapters.get_top_level_package, diff --git a/tests/unit/application/test_use_cases.py b/tests/unit/application/test_use_cases.py index 95badd0..550810f 100644 --- a/tests/unit/application/test_use_cases.py +++ b/tests/unit/application/test_use_cases.py @@ -75,6 +75,7 @@ def test_draw_graph(self): SOME_MODULE, show_import_totals=False, show_cycle_breakers=False, + hide_acyclic=False, sys_path=sys_path, current_directory=current_directory, get_top_level_package=fake_get_top_level_package_non_namespace, @@ -120,6 +121,7 @@ def asserting_build_graph(top_level_package: str) -> grimp.ImportGraph: "some.namespace.foo.blue", show_import_totals=False, show_cycle_breakers=False, + hide_acyclic=False, sys_path=[], current_directory="/cwd", get_top_level_package=get_top_level_package, @@ -134,6 +136,7 @@ def test_draw_graph_show_import_totals(self): SOME_MODULE, show_import_totals=True, show_cycle_breakers=False, + hide_acyclic=False, sys_path=[], current_directory="/cwd", get_top_level_package=fake_get_top_level_package_non_namespace, @@ -156,6 +159,7 @@ def test_draw_graph_show_cycle_breakers(self): SOME_MODULE, show_import_totals=False, show_cycle_breakers=True, + hide_acyclic=False, sys_path=[], current_directory="/cwd", get_top_level_package=fake_get_top_level_package_non_namespace, @@ -179,3 +183,28 @@ def test_draw_graph_show_cycle_breakers(self): ), Edge("mypackage.foo.red", "mypackage.foo.blue", emphasized=True), } + + def test_draw_graph_hide_acyclic(self): + viewer = SpyGraphViewer() + + use_cases.draw_graph( + SOME_MODULE, + show_import_totals=False, + show_cycle_breakers=False, + hide_acyclic=True, + sys_path=[], + current_directory="/cwd", + get_top_level_package=fake_get_top_level_package_non_namespace, + build_graph=build_fake_graph, + viewer=viewer, + ) + + # The only cycle in the test graph is formed by blue and red. + assert viewer.called_with_dot.nodes == { + "mypackage.foo.blue", + "mypackage.foo.red", + } + assert viewer.called_with_dot.edges == { + Edge("mypackage.foo.blue", "mypackage.foo.red"), + Edge("mypackage.foo.red", "mypackage.foo.blue"), + }