From b71e43bc74d5eb9cdbcf7cd87e9ec4f1bf4eb427 Mon Sep 17 00:00:00 2001 From: Fabien MARTY Date: Fri, 2 Jan 2026 14:03:01 +0100 Subject: [PATCH 1/7] ability to write to dot files Closes: #30 --- src/impulse/adapters.py | 322 +++++++++++++++++++++------------------- src/impulse/cli.py | 29 +++- 2 files changed, 199 insertions(+), 152 deletions(-) diff --git a/src/impulse/adapters.py b/src/impulse/adapters.py index c404048..8289c05 100644 --- a/src/impulse/adapters.py +++ b/src/impulse/adapters.py @@ -6,163 +6,166 @@ import importlib +def get_html_content(dot: dotfile.DotGraph) -> str: + download_file_stem = f"{dot.title.replace('.', '_')}_graph" + + return dedent(f""" + + + + {dot.title} | Impulse + + + +
+

Import graph for {dot.title}

+
+ +
+ +
+ + + + """) + + class BrowserGraphViewer(ports.GraphViewer): """ Graph viewer that generates an HTML file with viz-js and opens it in a browser. """ def view(self, dot: dotfile.DotGraph) -> None: - download_file_stem = f"{dot.title.replace('.', '_')}_graph" - - html_content = dedent(f""" - - - - {dot.title} | Impulse - - - -
-

Import graph for {dot.title}

-
- -
- -
- - - - """) - + html_content = get_html_content(dot) # Create a temporary HTML file - fd, html_path = tempfile.mkstemp( + _, html_path = tempfile.mkstemp( suffix=".html", prefix=f"impulse_{dot.title.replace('.', '_')}_" ) with open(html_path, "w", encoding="utf-8") as f: @@ -172,6 +175,25 @@ def view(self, dot: dotfile.DotGraph) -> None: webbrowser.open(f"file://{html_path}") +class ConsoleGraphViewer(ports.GraphViewer): + """ + Graph viewer that prints the HTML content to the console. + """ + + def view(self, dot: dotfile.DotGraph) -> None: + html_content = get_html_content(dot) + print(html_content) + + +class ConsoleDotViewer(ports.GraphViewer): + """ + Graph viewer that prints the DOT content to the console. + """ + + def view(self, dot: dotfile.DotGraph) -> None: + print(dot.render()) + + def get_top_level_package(module_name: str) -> str: """ Returns the top-level package name from the given module name. diff --git a/src/impulse/cli.py b/src/impulse/cli.py index fee13f9..5fa383a 100644 --- a/src/impulse/cli.py +++ b/src/impulse/cli.py @@ -5,6 +5,7 @@ from impulse.application import use_cases from impulse import adapters +from impulse import ports import grimp @@ -27,8 +28,32 @@ def main(): "and display them as dashed lines." ), ) +@click.option("--force-console", is_flag=True, help="Force the use of the console output.") +@click.option( + "--format", + type=click.Choice(["html", "dot"]), + default="html", + help="Output format (default to html).", +) @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, + force_console: bool, + format: str, +) -> None: + viewer: ports.GraphViewer + if format == "html": + if not force_console and sys.stdout.isatty(): + # the output is not redirected to a file + viewer = adapters.BrowserGraphViewer() + else: + viewer = adapters.ConsoleGraphViewer() + elif format == "dot": + viewer = adapters.ConsoleDotViewer() + else: + raise ValueError(f"Invalid format: {format}") use_cases.draw_graph( module_name=module_name, show_import_totals=show_import_totals, @@ -37,5 +62,5 @@ def drawgraph(module_name: str, show_import_totals: bool, show_cycle_breakers: b current_directory=os.getcwd(), get_top_level_package=adapters.get_top_level_package, build_graph=grimp.build_graph, - viewer=adapters.BrowserGraphViewer(), + viewer=viewer, ) From 7dbf7d9619a767dcd032e2cdb09456c95244bf8f Mon Sep 17 00:00:00 2001 From: Fabien MARTY Date: Fri, 2 Jan 2026 14:11:51 +0100 Subject: [PATCH 2/7] updated AUTHORS/CHANGELOG --- AUTHORS.rst | 1 + CHANGELOG.rst | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/AUTHORS.rst b/AUTHORS.rst index 4497340..1c32de9 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -3,3 +3,4 @@ Authors ======= * David Seddon - https://seddonym.me +* Fabien Marty - fab@fabien-marty.dev diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f9ba2d1..c9f46c5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,11 @@ Changelog ========= +2.3 (not released yet) +---------------------- +* Add --format option to control the output format. +* Add --force-console option to force the use of the console output. + 2.2 (2025-12-12) ---------------- From 58110986c17e62d5a2c48c56b291329573742490 Mon Sep 17 00:00:00 2001 From: Fabien MARTY Date: Tue, 13 Jan 2026 21:22:14 +0100 Subject: [PATCH 3/7] add documentation --- README.rst | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index d2ec6b5..00e47af 100644 --- a/README.rst +++ b/README.rst @@ -64,6 +64,8 @@ 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. + --force-console Force the use of the console output. + --format [html|dot] Output format (default to html). --help Show this message and exit. Draw a graph of the dependencies within any installed Python package or subpackage. @@ -119,4 +121,52 @@ 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 `_. + +Output formats +************** + +By default, Impulse renders the graph as an interactive HTML page and opens it in your browser. +You can change the output format using the ``--format`` option. + +**HTML format (default)** + +.. code-block:: text + + impulse drawgraph django.db --format html + +Generates an HTML page with an interactive graph visualization, including download links for SVG and PNG. +This is the default format when no ``--format`` option is specified. + +**DOT format** + +.. code-block:: text + + impulse drawgraph django.db --format dot + +Outputs the graph in `DOT format `_, which can be used with +Graphviz or other tools that support this format. This is useful for custom rendering or integration +with other systems. + +For example, to generate a PNG using Graphviz: + +.. code-block:: text + + impulse drawgraph django.db --format dot | dot -Tpng -o graph.png + +Redirecting output +****************** + +When you redirect the output to a file or pipe it to another command, Impulse automatically switches +from opening a browser to printing to the console: + +.. code-block:: text + + impulse drawgraph django.db > graph.html + +If you want to force console output even when running interactively (e.g., for scripting purposes), +use the ``--force-console`` flag: + +.. code-block:: text + + impulse drawgraph django.db --force-console From 661d3f35c863b5a74de7fb137b04c83c138759bc Mon Sep 17 00:00:00 2001 From: Fabien MARTY Date: Tue, 13 Jan 2026 21:36:55 +0100 Subject: [PATCH 4/7] add tests --- justfile | 26 ++++++++++++++++++++ tests/unit/test_adapters.py | 47 +++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 tests/unit/test_adapters.py diff --git a/justfile b/justfile index 1764d38..15114f3 100644 --- a/justfile +++ b/justfile @@ -14,6 +14,32 @@ 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 + @just test-output-redirection + +# Test output redirection (HTML and DOT formats) +test-output-redirection: + #!/usr/bin/env bash + set -euo pipefail + echo "Testing HTML output redirection..." + uv run impulse drawgraph grimp > /tmp/impulse_test_output.html + grep -q '' /tmp/impulse_test_output.html + grep -q 'digraph' /tmp/impulse_test_output.html + echo "HTML output redirection: OK" + + echo "Testing DOT format output..." + uv run impulse drawgraph grimp --format=dot > /tmp/impulse_test_output.dot + grep -q 'digraph' /tmp/impulse_test_output.dot + # DOT format should not contain HTML tags + grep -q '' /tmp/impulse_test_output.dot && exit 1 + echo "DOT format output: OK" + + echo "Testing --force-console flag..." + uv run impulse drawgraph grimp --force-console > /tmp/impulse_test_force_console.html + grep -q '' /tmp/impulse_test_force_console.html + echo "--force-console flag: OK" + + rm -f /tmp/impulse_test_output.html /tmp/impulse_test_output.dot /tmp/impulse_test_force_console.html + echo "All output redirection tests passed!" # Run tests under all supported Python versions. diff --git a/tests/unit/test_adapters.py b/tests/unit/test_adapters.py new file mode 100644 index 0000000..b6cfc78 --- /dev/null +++ b/tests/unit/test_adapters.py @@ -0,0 +1,47 @@ +from io import StringIO +from unittest import mock + +from impulse import adapters +from impulse.dotfile import DotGraph, Edge + + +class TestConsoleGraphViewer: + def test_view_prints_html_to_stdout(self): + """Test that ConsoleGraphViewer prints HTML content to stdout.""" + dot = DotGraph(title="test.module") + dot.add_node("test.module.foo") + dot.add_node("test.module.bar") + dot.add_edge(Edge("test.module.foo", "test.module.bar")) + + viewer = adapters.ConsoleGraphViewer() + + with mock.patch("sys.stdout", new_callable=StringIO) as mock_stdout: + viewer.view(dot) + output = mock_stdout.getvalue() + + # Verify it's HTML content + assert "" in output + assert "" in output + assert "test.module" in output + assert "Impulse" in output + + +class TestConsoleDotViewer: + def test_view_prints_dot_to_stdout(self): + """Test that ConsoleDotViewer prints DOT content to stdout.""" + dot = DotGraph(title="test.module") + dot.add_node("test.module.foo") + dot.add_node("test.module.bar") + dot.add_edge(Edge("test.module.foo", "test.module.bar")) + + viewer = adapters.ConsoleDotViewer() + + with mock.patch("sys.stdout", new_callable=StringIO) as mock_stdout: + viewer.view(dot) + output = mock_stdout.getvalue() + + # Verify it's DOT content + assert "digraph" in output + assert ".foo" in output + assert ".bar" in output + assert "->" in output From 5526f3e38191a39425310c5da46784f6e1d0c83c Mon Sep 17 00:00:00 2001 From: Fabien MARTY Date: Thu, 15 Jan 2026 08:47:15 +0100 Subject: [PATCH 5/7] some review comments --- AUTHORS.rst | 2 +- justfile | 24 +++++++++++++++--------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index 1c32de9..f1e863e 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -3,4 +3,4 @@ Authors ======= * David Seddon - https://seddonym.me -* Fabien Marty - fab@fabien-marty.dev +* Fabien Marty - https://fabien-marty.dev diff --git a/justfile b/justfile index 15114f3..114b3d8 100644 --- a/justfile +++ b/justfile @@ -20,25 +20,31 @@ test: test-output-redirection: #!/usr/bin/env bash set -euo pipefail + + # Create unique temporary files + html_output=$(mktemp --suffix=.html) + dot_output=$(mktemp --suffix=.dot) + force_console_output=$(mktemp --suffix=.html) + trap 'rm -f "$html_output" "$dot_output" "$force_console_output"' EXIT + echo "Testing HTML output redirection..." - uv run impulse drawgraph grimp > /tmp/impulse_test_output.html - grep -q '' /tmp/impulse_test_output.html - grep -q 'digraph' /tmp/impulse_test_output.html + uv run impulse drawgraph grimp > "$html_output" + grep -q '' "$html_output" + grep -q 'digraph' "$html_output" echo "HTML output redirection: OK" echo "Testing DOT format output..." - uv run impulse drawgraph grimp --format=dot > /tmp/impulse_test_output.dot - grep -q 'digraph' /tmp/impulse_test_output.dot + uv run impulse drawgraph grimp --format=dot > "$dot_output" + grep -q 'digraph' "$dot_output" # DOT format should not contain HTML tags - grep -q '' /tmp/impulse_test_output.dot && exit 1 + ! grep -q '' "$dot_output" echo "DOT format output: OK" echo "Testing --force-console flag..." - uv run impulse drawgraph grimp --force-console > /tmp/impulse_test_force_console.html - grep -q '' /tmp/impulse_test_force_console.html + uv run impulse drawgraph grimp --force-console > "$force_console_output" + grep -q '' "$force_console_output" echo "--force-console flag: OK" - rm -f /tmp/impulse_test_output.html /tmp/impulse_test_output.dot /tmp/impulse_test_force_console.html echo "All output redirection tests passed!" From 3cebae865e0628eae1ea29331f1f0012a759a1cb Mon Sep 17 00:00:00 2001 From: Fabien MARTY Date: Thu, 15 Jan 2026 08:49:20 +0100 Subject: [PATCH 6/7] fix cli option order --- README.rst | 2 +- src/impulse/cli.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 00e47af..29e6d85 100644 --- a/README.rst +++ b/README.rst @@ -64,8 +64,8 @@ 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. - --force-console Force the use of the console output. --format [html|dot] Output format (default to html). + --force-console Force the use of the console output. --help Show this message and exit. Draw a graph of the dependencies within any installed Python package or subpackage. diff --git a/src/impulse/cli.py b/src/impulse/cli.py index 5fa383a..347a8fe 100644 --- a/src/impulse/cli.py +++ b/src/impulse/cli.py @@ -28,13 +28,13 @@ def main(): "and display them as dashed lines." ), ) -@click.option("--force-console", is_flag=True, help="Force the use of the console output.") @click.option( "--format", type=click.Choice(["html", "dot"]), default="html", help="Output format (default to html).", ) +@click.option("--force-console", is_flag=True, help="Force the use of the console output.") @click.argument("module_name", type=str) def drawgraph( module_name: str, From dc074eb1ec2b77cab09af1cad0863aa35a5ce600 Mon Sep 17 00:00:00 2001 From: Fabien MARTY Date: Fri, 16 Jan 2026 10:56:45 +0100 Subject: [PATCH 7/7] osx/linux compatibility --- justfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/justfile b/justfile index 114b3d8..d4d4615 100644 --- a/justfile +++ b/justfile @@ -21,10 +21,10 @@ test-output-redirection: #!/usr/bin/env bash set -euo pipefail - # Create unique temporary files - html_output=$(mktemp --suffix=.html) - dot_output=$(mktemp --suffix=.dot) - force_console_output=$(mktemp --suffix=.html) + # Create unique temporary files (using -t for macOS compatibility) + html_output=$(mktemp -t impulse_test.XXXXXX.html) + dot_output=$(mktemp -t impulse_test.XXXXXX.dot) + force_console_output=$(mktemp -t impulse_test.XXXXXX.html) trap 'rm -f "$html_output" "$dot_output" "$force_console_output"' EXIT echo "Testing HTML output redirection..."