diff --git a/AUTHORS.rst b/AUTHORS.rst index 4497340..f1e863e 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -3,3 +3,4 @@ Authors ======= * David Seddon - https://seddonym.me +* Fabien Marty - https://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) ---------------- diff --git a/README.rst b/README.rst index d2ec6b5..29e6d85 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. + --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. @@ -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 diff --git a/justfile b/justfile index 1764d38..d4d4615 100644 --- a/justfile +++ b/justfile @@ -14,6 +14,38 @@ 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 + + # 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..." + 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 > "$dot_output" + grep -q 'digraph' "$dot_output" + # DOT format should not contain HTML tags + ! grep -q '' "$dot_output" + echo "DOT format output: OK" + + echo "Testing --force-console flag..." + uv run impulse drawgraph grimp --force-console > "$force_console_output" + grep -q '' "$force_console_output" + echo "--force-console flag: OK" + + echo "All output redirection tests passed!" # Run tests under all supported Python versions. 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..347a8fe 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( + "--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, 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, ) 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