Repository: tox-dev/pipdeptree (python)

Issue:
feature: ability to output mermaid/dot/graphviz dependency graphs
[Mermaid](https://mermaid-js.github.io/mermaid/#/) is an open-souce graph maker that can easily be integrated in any web page and also with documentation build tools (sphinx, markdown based tools,...)

Example of graph:

```mermaid
graph TD;
    A-->B;
    A-->C;
    B-->D;
    C-->D;
```

Note: there is also a feature request to enable mermaid markdown extension on github, which would render it directly.

Pull request description:
Implements Mermaid output
This PR adds a new `--mermaid` to the CLI to produce Mermaid's Markdown output of the dependency graph.

This closes #129.

Patch:
diff --git a/src/pipdeptree/__init__.py b/src/pipdeptree/__init__.py
index 7679212..e269d4c 100644
--- a/src/pipdeptree/__init__.py
+++ b/src/pipdeptree/__init__.py
@@ -10,6 +10,7 @@ from collections import defaultdict, deque
 from collections.abc import Mapping
 from importlib import import_module
 from itertools import chain
+from textwrap import dedent
 
 from pip._vendor import pkg_resources
 
@@ -550,6 +551,44 @@ def render_json_tree(tree, indent):
     return json.dumps([aux(p) for p in nodes], indent=indent)
 
 
+def render_mermaid(tree) -> str:
+    """Produce a Mermaid flowchart from the dependency graph.
+
+    :param dict tree: dependency graph
+    """
+    # Use a sets to avoid duplicate entries.
+    nodes: set[str] = set()
+    edges: set[str] = set()
+
+    for pkg, deps in tree.items():
+        pkg_label = f"{pkg.project_name}\\n{pkg.version}"
+        nodes.add(f"{pkg.key}[{pkg_label}]")
+        for dep in deps:
+            edge_label = dep.version_spec or "any"
+            if dep.is_missing:
+                dep_label = f"{dep.project_name}\\n(missing)"
+                nodes.add(f"{dep.key}[{dep_label}]:::missing")
+                edges.add(f"{pkg.key} -.-> {dep.key}")
+            else:
+                edges.add(f"{pkg.key} -- {edge_label} --> {dep.key}")
+
+    # Produce the Mermaid Markdown.
+    indent = " " * 4
+    output = dedent(
+        f"""\
+        flowchart TD
+        {indent}classDef missing stroke-dasharray: 5
+        """
+    )
+    # Sort the nodes and edges to make the output deterministic.
+    output += indent
+    output += f"\n{indent}".join(node for node in sorted(nodes))
+    output += "\n" + indent
+    output += f"\n{indent}".join(edge for edge in sorted(edges))
+    output += "\n"
+    return output
+
+
 def dump_graphviz(tree, output_format="dot", is_reverse=False):
     """Output dependency graph as one of the supported GraphViz output formats.
 
@@ -775,6 +814,12 @@ def get_parser():
             "This option overrides all other options (except --json)."
         ),
     )
+    parser.add_argument(
+        "--mermaid",
+        action="store_true",
+        default=False,
+        help=("Display dependency tree as a Maermaid graph. " "This option overrides all other options."),
+    )
     parser.add_argument(
         "--graph-output",
         dest="output_format",
@@ -884,6 +929,8 @@ def main():
         print(render_json(tree, indent=4))
     elif args.json_tree:
         print(render_json_tree(tree, indent=4))
+    elif args.output_format:
+        print(render_mermaid(tree))
     elif args.output_format:
         output = dump_graphviz(tree, output_format=args.output_format, is_reverse=args.reverse)
         print_graphviz(output)


Test patch:
diff --git a/tests/test_pipdeptree.py b/tests/test_pipdeptree.py
index 3a7aa01..ef95065 100644
--- a/tests/test_pipdeptree.py
+++ b/tests/test_pipdeptree.py
@@ -306,8 +306,9 @@ def test_render_text(capsys, list_all, reverse, expected_output):
 # Tests for graph outputs
 
 
-def test_render_dot(capsys):
-    # Extract the dependency graph from the package tree fixture and randomize it.
+def randomized_dag_copy(t):
+    """Returns a copy of the package tree fixture with dependencies in randomized order."""
+    # Extract the dependency graph from the package tree and randomize it.
     randomized_graph = {}
     randomized_nodes = list(t._obj.keys())
     random.shuffle(randomized_nodes)
@@ -320,10 +321,42 @@ def test_render_dot(capsys):
     # Create a randomized package tree.
     randomized_dag = p.PackageDAG(randomized_graph)
     assert len(t) == len(randomized_dag)
+    return randomized_dag
+
+
+def test_render_mermaid():
+    # Check both the sorted and randomized package tree produces the same sorted
+    # Mermaid output.
+    for package_tree in (t, randomized_dag_copy(t)):
+        output = p.render_mermaid(package_tree)
+        assert output == dedent(
+            """\
+            flowchart TD
+                classDef missing stroke-dasharray: 5
+                a[a\\n3.4.0]
+                b[b\\n2.3.1]
+                c[c\\n5.10.0]
+                d[d\\n2.35]
+                e[e\\n0.12.1]
+                f[f\\n3.1]
+                g[g\\n6.8.3rc1]
+                a -- >=2.0.0 --> b
+                a -- >=5.7.1 --> c
+                b -- >=2.30,<2.42 --> d
+                c -- >=0.12.1 --> e
+                c -- >=2.30 --> d
+                d -- >=0.9.0 --> e
+                f -- >=2.1.0 --> b
+                g -- >=0.9.0 --> e
+                g -- >=3.0.0 --> f
+            """
+        )
 
+
+def test_render_dot(capsys):
     # Check both the sorted and randomized package tree produces the same sorted
     # graphviz output.
-    for package_tree in (t, randomized_dag):
+    for package_tree in (t, randomized_dag_copy(t)):
         output = p.dump_graphviz(package_tree, output_format="dot")
         p.print_graphviz(output)
         out, _ = capsys.readouterr()
@@ -499,6 +532,14 @@ def test_parser_json_tree():
     assert args.output_format is None
 
 
+def test_parser_mermaid():
+    parser = p.get_parser()
+    args = parser.parse_args(["--mermaid"])
+    assert args.mermaid
+    assert not args.json
+    assert args.output_format is None
+
+
 def test_parser_pdf():
     parser = p.get_parser()
     args = parser.parse_args(["--graph-output", "pdf"])