Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/pipdeptree/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ def build_parser() -> ArgumentParser:
"--depth",
type=lambda x: int(x) if x.isdigit() and (int(x) >= 0) else parser.error("Depth must be a number that is >= 0"),
default=float("inf"),
help="limit the depth of the tree (text and freeze render only)",
help="limit the depth of the tree (text, freeze, and graphviz render only)",
metavar="D",
)
render.add_argument(
Expand Down
4 changes: 3 additions & 1 deletion src/pipdeptree/_render/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ def render(options: Options, tree: PackageDAG) -> None:
elif output_format == "freeze":
render_freeze(tree, max_depth=options.depth, list_all=options.all)
elif output_format.startswith("graphviz-"):
render_graphviz(tree, output_format=output_format[len("graphviz-") :], reverse=options.reverse)
render_graphviz(
tree, output_format=output_format[len("graphviz-") :], reverse=options.reverse, max_depth=options.depth
)
else:
render_text(
tree,
Expand Down
102 changes: 89 additions & 13 deletions src/pipdeptree/_render/graphviz.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from __future__ import annotations

import math
import os
import sys
from collections import deque
from typing import TYPE_CHECKING

from pipdeptree._models import DistPackage, ReqPackage
Expand All @@ -10,17 +12,27 @@
from pipdeptree._models import PackageDAG


def dump_graphviz( # noqa: C901
def _get_all_dep_keys(tree: PackageDAG) -> set[str]:
"""Return the set of keys that appear as dependencies of some package."""
dep_keys: set[str] = set()
for deps in tree.values():
dep_keys.update(dep.key for dep in deps)
return dep_keys


def dump_graphviz( # noqa: C901, PLR0912, PLR0915
tree: PackageDAG,
output_format: str = "dot",
is_reverse: bool = False, # noqa: FBT001, FBT002
max_depth: float = math.inf,
) -> str | bytes:
"""
Output dependency graph as one of the supported GraphViz output formats.

:param dict tree: dependency graph
:param string output_format: output format
:param bool is_reverse: reverse or not
:param float max_depth: maximum depth of the dependency tree to include
:returns: representation of tree in the specified output format
:rtype: str or binary representation depending on the output format
"""
Expand All @@ -44,16 +56,80 @@ def dump_graphviz( # noqa: C901

graph = Digraph(format=output_format)

if is_reverse:
for dep_rev, parents in tree.items():
assert isinstance(dep_rev, ReqPackage)
dep_label = f"{dep_rev.project_name}\\n{dep_rev.installed_version}"
graph.node(dep_rev.key, label=dep_label)
for parent in parents:
# req reference of the dep associated with this particular parent package
assert isinstance(parent, DistPackage)
edge_label = (parent.req.version_spec if parent.req is not None else None) or "any"
graph.edge(dep_rev.key, parent.key, label=edge_label)
if is_reverse: # noqa: PLR1702
if max_depth < math.inf:
# BFS from leaf nodes (those that are not parents of any other node)
parent_keys = _get_all_dep_keys(tree)
root_keys = {dep_rev.key for dep_rev in tree if dep_rev.key not in parent_keys}
visited: dict[str, int] = {}
queue: deque[tuple[str, int]] = deque((k, 0) for k in root_keys)
while queue:
key, depth = queue.popleft()
if key in visited:
continue
visited[key] = depth
if depth < max_depth:
for pkg in tree:
if pkg.key == key:
parents = tree[pkg]
if parents is not None:
for parent in parents:
if parent.key not in visited:
queue.append((parent.key, depth + 1))
break
for dep_rev, parents in tree.items():
if dep_rev.key not in visited:
continue
assert isinstance(dep_rev, ReqPackage)
dep_label = f"{dep_rev.project_name}\\n{dep_rev.installed_version}"
graph.node(dep_rev.key, label=dep_label)
if visited[dep_rev.key] < max_depth:
for parent in parents:
assert isinstance(parent, DistPackage)
if parent.key in visited:
edge_label = (parent.req.version_spec if parent.req is not None else None) or "any"
graph.edge(dep_rev.key, parent.key, label=edge_label)
else:
for dep_rev, parents in tree.items():
assert isinstance(dep_rev, ReqPackage)
dep_label = f"{dep_rev.project_name}\\n{dep_rev.installed_version}"
graph.node(dep_rev.key, label=dep_label)
for parent in parents:
# req reference of the dep associated with this particular parent package
assert isinstance(parent, DistPackage)
edge_label = (parent.req.version_spec if parent.req is not None else None) or "any"
graph.edge(dep_rev.key, parent.key, label=edge_label)
elif max_depth < math.inf:
# BFS from root nodes (packages that are not dependencies of any other)
dep_keys = _get_all_dep_keys(tree)
root_keys = {pkg.key for pkg in tree if pkg.key not in dep_keys}
visited_depths: dict[str, int] = {}
queue_bfs: deque[tuple[str, int]] = deque((k, 0) for k in root_keys)
while queue_bfs:
key, depth = queue_bfs.popleft()
if key in visited_depths:
continue
visited_depths[key] = depth
if depth < max_depth:
children = tree.get_children(key)
for dep in children:
if dep.key not in visited_depths:
queue_bfs.append((dep.key, depth + 1))
for pkg, deps in tree.items():
if pkg.key not in visited_depths:
continue
pkg_label = f"{pkg.project_name}\\n{pkg.version}"
graph.node(pkg.key, label=pkg_label)
if visited_depths[pkg.key] < max_depth:
for dep in deps:
if dep.key in visited_depths:
edge_label = dep.version_spec or "any"
if dep.is_missing:
dep_label = f"{dep.project_name}\\n(missing)"
graph.node(dep.key, label=dep_label, style="dashed")
graph.edge(pkg.key, dep.key, style="dashed")
else:
graph.edge(pkg.key, dep.key, label=edge_label)
else:
for pkg, deps in tree.items():
pkg_label = f"{pkg.project_name}\\n{pkg.version}"
Expand Down Expand Up @@ -97,8 +173,8 @@ def print_graphviz(dump_output: str | bytes) -> None:
bytestream.write(dump_output)


def render_graphviz(tree: PackageDAG, *, output_format: str, reverse: bool) -> None:
output = dump_graphviz(tree, output_format=output_format, is_reverse=reverse)
def render_graphviz(tree: PackageDAG, *, output_format: str, reverse: bool, max_depth: float = math.inf) -> None:
output = dump_graphviz(tree, output_format=output_format, is_reverse=reverse, max_depth=max_depth)
print_graphviz(output)


Expand Down
24 changes: 24 additions & 0 deletions tests/render/test_graphviz.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,27 @@ def test_render_svg(capsys: pytest.CaptureFixture[str], example_dag: PackageDAG)
assert out.startswith("<?xml")
assert "<svg" in out
assert out.strip().endswith("</svg>")


def test_render_dot_with_depth(example_dag: PackageDAG) -> None:
output = dump_graphviz(example_dag, output_format="dot", max_depth=1)
assert isinstance(output, str)
# Roots are a and g (depth 0), their direct deps are at depth 1
# Deeper deps (d) should not appear
assert "a ->" in output
assert "g ->" in output
assert 'd [label="d' not in output # d is only reachable at depth 2+
# Direct deps of roots should appear
assert 'b [label="b' in output
assert 'c [label="c' in output
assert 'e [label="e' in output
assert 'f [label="f' in output


def test_render_dot_with_depth_zero(example_dag: PackageDAG) -> None:
output = dump_graphviz(example_dag, output_format="dot", max_depth=0)
assert isinstance(output, str)
# Depth 0 means only root nodes, no edges
assert "a [label=" in output
assert "g [label=" in output
assert "->" not in output
2 changes: 1 addition & 1 deletion tests/render/test_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def test_mermaid_routing(option: list[str], mocker: MockerFixture) -> None:
def test_grahpviz_routing(option: list[str], mocker: MockerFixture) -> None:
render = mocker.patch("pipdeptree._render.render_graphviz")
main(option)
render.assert_called_once_with(ANY, output_format="dot", reverse=False)
render.assert_called_once_with(ANY, output_format="dot", reverse=False, max_depth=inf)


@pytest.mark.parametrize("option", [[], ["--output", "text"]])
Expand Down