Repository: tox-dev/pipdeptree
Pull request #460 (2025-03-10 01:54:38)

Remove unused graphviz library code
We currently expect graphviz>=0.20.3, so the try-except branch that was removed in this snapshot shouldn't ever execute.

kemzeb: Need to update the README doc during my free time since it mentions the deprecation warning.

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 7f9c5d5..ed3109b 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -5,7 +5,7 @@ repos:
       - id: end-of-file-fixer
       - id: trailing-whitespace
   - repo: https://github.com/python-jsonschema/check-jsonschema
-    rev: 0.31.2
+    rev: 0.31.3
     hooks:
       - id: check-github-workflows
         args: ["--verbose"]
@@ -23,7 +23,7 @@ repos:
     hooks:
       - id: pyproject-fmt
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: "v0.9.9"
+    rev: "v0.9.10"
     hooks:
       - id: ruff-format
       - id: ruff
diff --git a/src/pipdeptree/_render/__init__.py b/src/pipdeptree/_render/__init__.py
index 075bc9d..ad17199 100644
--- a/src/pipdeptree/_render/__init__.py
+++ b/src/pipdeptree/_render/__init__.py
@@ -2,6 +2,7 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING
 
+from .freeze import render_freeze
 from .graphviz import render_graphviz
 from .json import render_json
 from .json_tree import render_json_tree
@@ -22,13 +23,14 @@ def render(options: Options, tree: PackageDAG) -> None:
         print(render_mermaid(tree))  # noqa: T201
     elif options.output_format:
         render_graphviz(tree, output_format=options.output_format, reverse=options.reverse)
+    elif options.freeze:
+        render_freeze(tree, max_depth=options.depth, list_all=options.all)
     else:
         render_text(
             tree,
             max_depth=options.depth,
             encoding=options.encoding,
             list_all=options.all,
-            frozen=options.freeze,
             include_license=options.license,
         )
 
diff --git a/src/pipdeptree/_render/freeze.py b/src/pipdeptree/_render/freeze.py
new file mode 100644
index 0000000..a67ae57
--- /dev/null
+++ b/src/pipdeptree/_render/freeze.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+from itertools import chain
+from typing import TYPE_CHECKING, Any
+
+from .text import get_top_level_nodes
+
+if TYPE_CHECKING:
+    from pipdeptree._models.dag import PackageDAG
+    from pipdeptree._models.package import DistPackage, ReqPackage
+
+
+def render_freeze(tree: PackageDAG, *, max_depth: float, list_all: bool = True) -> None:
+    nodes = get_top_level_nodes(tree, list_all=list_all)
+
+    def aux(
+        node: DistPackage | ReqPackage,
+        parent: DistPackage | ReqPackage | None = None,
+        indent: int = 0,
+        cur_chain: list[str] | None = None,
+        depth: int = 0,
+    ) -> list[Any]:
+        cur_chain = cur_chain or []
+        node_str = node.render(parent, frozen=True)
+        if parent:
+            prefix = " " * indent
+            node_str = prefix + node_str
+        result = [node_str]
+        children = [
+            aux(c, node, indent=indent + 2, cur_chain=[*cur_chain, c.project_name], depth=depth + 1)
+            for c in tree.get_children(node.key)
+            if c.project_name not in cur_chain and depth + 1 <= max_depth
+        ]
+        result += list(chain.from_iterable(children))
+        return result
+
+    lines = chain.from_iterable([aux(p) for p in nodes])
+    print("\n".join(lines))  # noqa: T201
+
+
+__all__ = [
+    "render_freeze",
+]
diff --git a/src/pipdeptree/_render/graphviz.py b/src/pipdeptree/_render/graphviz.py
index cfd579a..6d945f5 100644
--- a/src/pipdeptree/_render/graphviz.py
+++ b/src/pipdeptree/_render/graphviz.py
@@ -10,7 +10,7 @@ if TYPE_CHECKING:
     from pipdeptree._models import PackageDAG
 
 
-def dump_graphviz(  # noqa: C901, PLR0912
+def dump_graphviz(  # noqa: C901
     tree: PackageDAG,
     output_format: str = "dot",
     is_reverse: bool = False,  # noqa: FBT001, FBT002
@@ -23,7 +23,6 @@ def dump_graphviz(  # noqa: C901, PLR0912
     :param bool is_reverse: reverse or not
     :returns: representation of tree in the specified output format
     :rtype: str or binary representation depending on the output format
-
     """
     try:
         from graphviz import Digraph  # noqa: PLC0415
@@ -34,19 +33,9 @@ def dump_graphviz(  # noqa: C901, PLR0912
         )
         raise SystemExit(1) from exc
 
-    try:
-        from graphviz import parameters  # noqa: PLC0415
-    except ImportError:
-        from graphviz import backend  # noqa: PLC0415 # pragma: no cover
+    from graphviz import parameters  # noqa: PLC0415
 
-        valid_formats = backend.FORMATS
-        print(  # noqa: T201
-            "Deprecation warning! Please upgrade graphviz to version >=0.18.0 "
-            "Support for older versions will be removed in upcoming release",
-            file=sys.stderr,
-        )
-    else:
-        valid_formats = parameters.FORMATS
+    valid_formats = parameters.FORMATS
 
     if output_format not in valid_formats:
         print(f"{output_format} is not a supported output format.", file=sys.stderr)  # noqa: T201
diff --git a/src/pipdeptree/_render/text.py b/src/pipdeptree/_render/text.py
index efa5a02..cd5f8bb 100644
--- a/src/pipdeptree/_render/text.py
+++ b/src/pipdeptree/_render/text.py
@@ -7,23 +7,39 @@ if TYPE_CHECKING:
     from pipdeptree._models import DistPackage, PackageDAG, ReqPackage
 
 
-def render_text(  # noqa: PLR0913
+def render_text(
     tree: PackageDAG,
     *,
     max_depth: float,
     encoding: str,
     list_all: bool = True,
-    frozen: bool = False,
     include_license: bool = False,
 ) -> None:
     """
     Print tree as text on console.
 
     :param tree: the package tree
-    :param list_all: whether to list all the pgks at the root level or only those that are the sub-dependencies
-    :param frozen: show the names of the pkgs in the output that's favorable to pip --freeze
+    :param max_depth: the maximum depth of the dependency tree
+    :param encoding: encoding to use (use "utf-8", "utf-16", "utf-32" for unicode or anything else for legacy output)
+    :param list_all: whether to list all the pkgs at the root level or only those that are the sub-dependencies
+    :param include_license: provide license information
     :returns: None
 
+    """
+    nodes = get_top_level_nodes(tree, list_all=list_all)
+
+    if encoding in {"utf-8", "utf-16", "utf-32"}:
+        _render_text_with_unicode(tree, nodes, max_depth, include_license)
+    else:
+        _render_text_without_unicode(tree, nodes, max_depth, include_license)
+
+
+def get_top_level_nodes(tree: PackageDAG, *, list_all: bool) -> list[DistPackage]:
+    """
+    Get a list of nodes that will appear at the first depth of the dependency tree.
+
+    :param tree: the package tree
+    :param list_all: whether to list all the pkgs at the root level or only those that are the sub-dependencies
     """
     tree = tree.sort()
     nodes = list(tree.keys())
@@ -32,23 +48,15 @@ def render_text(  # noqa: PLR0913
     if not list_all:
         nodes = [p for p in nodes if p.key not in branch_keys]
 
-    if encoding in {"utf-8", "utf-16", "utf-32"}:
-        _render_text_with_unicode(tree, nodes, max_depth, frozen, include_license)
-    else:
-        _render_text_without_unicode(tree, nodes, max_depth, frozen, include_license)
+    return nodes
 
 
 def _render_text_with_unicode(
     tree: PackageDAG,
     nodes: list[DistPackage],
     max_depth: float,
-    frozen: bool,  # noqa: FBT001
     include_license: bool,  # noqa: FBT001
 ) -> None:
-    assert not (frozen and include_license)
-
-    use_bullets = not frozen
-
     def aux(  # noqa: PLR0913, PLR0917
         node: DistPackage | ReqPackage,
         parent: DistPackage | ReqPackage | None = None,
@@ -61,7 +69,7 @@ def _render_text_with_unicode(
         parent_is_last_child: bool = False,  # noqa: FBT001, FBT002
     ) -> list[Any]:
         cur_chain = cur_chain or []
-        node_str = node.render(parent, frozen=frozen)
+        node_str = node.render(parent, frozen=False)
         next_prefix = ""
         next_indent = indent + 2
 
@@ -70,21 +78,14 @@ def _render_text_with_unicode(
             if is_last_child:
                 bullet = "└── "
 
-            line_char = "│"
-            if not use_bullets:
-                line_char = ""
-                # Add 2 spaces so direct dependencies to a project are indented
-                bullet = "  "
-
             if has_grand_parent:
                 next_indent -= 1
                 if parent_is_last_child:
-                    offset = 0 if len(line_char) == 1 else 1
-                    prefix += " " * (indent + 1 - offset - depth)
+                    prefix += " " * (indent + 1 - depth)
                 else:
-                    prefix += line_char + " " * (indent - depth)
+                    prefix += "│" + " " * (indent - depth)
                 # Without this extra space, bullets will point to the space just before the project name
-                prefix += " " if use_bullets else ""
+                prefix += " "
             next_prefix = prefix
             node_str = prefix + bullet + node_str
         elif include_license:
@@ -120,13 +121,8 @@ def _render_text_without_unicode(
     tree: PackageDAG,
     nodes: list[DistPackage],
     max_depth: float,
-    frozen: bool,  # noqa: FBT001
     include_license: bool,  # noqa: FBT001
 ) -> None:
-    assert not (frozen and include_license)
-
-    use_bullets = not frozen
-
     def aux(
         node: DistPackage | ReqPackage,
         parent: DistPackage | ReqPackage | None = None,
@@ -135,9 +131,9 @@ def _render_text_without_unicode(
         depth: int = 0,
     ) -> list[Any]:
         cur_chain = cur_chain or []
-        node_str = node.render(parent, frozen=frozen)
+        node_str = node.render(parent, frozen=False)
         if parent:
-            prefix = " " * indent + ("- " if use_bullets else "")
+            prefix = " " * indent + "- "
             node_str = prefix + node_str
         elif include_license:
             node_str += " " + node.licenses()
@@ -154,6 +150,4 @@ def _render_text_without_unicode(
     print("\n".join(lines))  # noqa: T201
 
 
-__all__ = [
-    "render_text",
-]
+__all__ = ["get_top_level_nodes", "render_text"]


Test patch:
diff --git a/tests/render/test_freeze.py b/tests/render/test_freeze.py
new file mode 100644
index 0000000..b5cf966
--- /dev/null
+++ b/tests/render/test_freeze.py
@@ -0,0 +1,150 @@
+from __future__ import annotations
+
+from math import inf
+from typing import TYPE_CHECKING
+from unittest.mock import PropertyMock
+
+import pytest
+
+from pipdeptree._freeze import PipBaseDistributionAdapter
+from pipdeptree._render.freeze import render_freeze
+
+if TYPE_CHECKING:
+    from pipdeptree._models.dag import PackageDAG
+
+
+@pytest.fixture
+def patch_pip_adapter(monkeypatch: pytest.MonkeyPatch) -> None:
+    """
+    Patches `PipBaseDistributionAdapter` such that `editable` returns `False` and `direct_url` returns `None`.
+
+    This will have the pip API always return a frozen req in the "name==version" format.
+    """
+    monkeypatch.setattr(PipBaseDistributionAdapter, "editable", PropertyMock(return_value=False))
+    monkeypatch.setattr(PipBaseDistributionAdapter, "direct_url", PropertyMock(return_value=None))
+
+
+@pytest.mark.parametrize(
+    ("list_all", "expected_output"),
+    [
+        (
+            True,
+            [
+                "a==3.4.0",
+                "  b==2.3.1",
+                "    d==2.35",
+                "      e==0.12.1",
+                "  c==5.10.0",
+                "    d==2.35",
+                "      e==0.12.1",
+                "    e==0.12.1",
+                "b==2.3.1",
+                "  d==2.35",
+                "    e==0.12.1",
+                "c==5.10.0",
+                "  d==2.35",
+                "    e==0.12.1",
+                "  e==0.12.1",
+                "d==2.35",
+                "  e==0.12.1",
+                "e==0.12.1",
+                "f==3.1",
+                "  b==2.3.1",
+                "    d==2.35",
+                "      e==0.12.1",
+                "g==6.8.3rc1",
+                "  e==0.12.1",
+                "  f==3.1",
+                "    b==2.3.1",
+                "      d==2.35",
+                "        e==0.12.1",
+            ],
+        ),
+        (
+            False,
+            [
+                "a==3.4.0",
+                "  b==2.3.1",
+                "    d==2.35",
+                "      e==0.12.1",
+                "  c==5.10.0",
+                "    d==2.35",
+                "      e==0.12.1",
+                "    e==0.12.1",
+                "g==6.8.3rc1",
+                "  e==0.12.1",
+                "  f==3.1",
+                "    b==2.3.1",
+                "      d==2.35",
+                "        e==0.12.1",
+            ],
+        ),
+    ],
+)
+@pytest.mark.usefixtures("patch_pip_adapter")
+def test_render_freeze(
+    example_dag: PackageDAG,
+    capsys: pytest.CaptureFixture[str],
+    list_all: bool,
+    expected_output: list[str],
+) -> None:
+    render_freeze(example_dag, max_depth=inf, list_all=list_all)
+    captured = capsys.readouterr()
+    assert "\n".join(expected_output).strip() == captured.out.strip()
+
+
+@pytest.mark.parametrize(
+    ("depth", "expected_output"),
+    [
+        (
+            0,
+            [
+                "a==3.4.0",
+                "b==2.3.1",
+                "c==5.10.0",
+                "d==2.35",
+                "e==0.12.1",
+                "f==3.1",
+                "g==6.8.3rc1",
+            ],
+        ),
+        (
+            2,
+            [
+                "a==3.4.0",
+                "  b==2.3.1",
+                "    d==2.35",
+                "  c==5.10.0",
+                "    d==2.35",
+                "    e==0.12.1",
+                "b==2.3.1",
+                "  d==2.35",
+                "    e==0.12.1",
+                "c==5.10.0",
+                "  d==2.35",
+                "    e==0.12.1",
+                "  e==0.12.1",
+                "d==2.35",
+                "  e==0.12.1",
+                "e==0.12.1",
+                "f==3.1",
+                "  b==2.3.1",
+                "    d==2.35",
+                "g==6.8.3rc1",
+                "  e==0.12.1",
+                "  f==3.1",
+                "    b==2.3.1",
+            ],
+        ),
+    ],
+)
+@pytest.mark.usefixtures("patch_pip_adapter")
+def test_render_freeze_given_depth(
+    example_dag: PackageDAG,
+    capsys: pytest.CaptureFixture[str],
+    depth: int,
+    expected_output: list[str],
+) -> None:
+    render_freeze(example_dag, max_depth=depth)
+    captured = capsys.readouterr()
+    assert "\n".join(expected_output).strip() == captured.out.strip()
diff --git a/tests/render/test_render.py b/tests/render/test_render.py
index 28de3d6..c2fd7d6 100644
--- a/tests/render/test_render.py
+++ b/tests/render/test_render.py
@@ -37,6 +37,10 @@ def test_grahpviz_routing(mocker: MockerFixture) -> None:
 def test_text_routing(mocker: MockerFixture) -> None:
     render = mocker.patch("pipdeptree._render.render_text")
     main([])
-    render.assert_called_once_with(
-        ANY, encoding="utf-8", frozen=False, list_all=False, max_depth=inf, include_license=False
-    )
+    render.assert_called_once_with(ANY, encoding="utf-8", max_depth=inf, list_all=False, include_license=False)
+
+
+def test_freeze_routing(mocker: MockerFixture) -> None:
+    render = mocker.patch("pipdeptree._render.render_freeze")
+    main(["--freeze"])
+    render.assert_called_once_with(ANY, max_depth=inf, list_all=False)
diff --git a/tests/render/test_text.py b/tests/render/test_text.py
index 0a7322f..4ff460a 100644
--- a/tests/render/test_text.py
+++ b/tests/render/test_text.py
@@ -248,7 +248,7 @@ def test_render_text(
 ) -> None:
     tree = example_dag.reverse() if reverse else example_dag
     encoding = "utf-8" if unicode else "ascii"
-    render_text(tree, max_depth=float("inf"), encoding=encoding, list_all=list_all, frozen=False)
+    render_text(tree, max_depth=float("inf"), encoding=encoding, list_all=list_all)
     captured = capsys.readouterr()
     assert "\n".join(expected_output).strip() == captured.out.strip()
 
@@ -437,7 +437,7 @@ def test_render_text_encoding(
     expected_output: list[str],
     example_dag: PackageDAG,
 ) -> None:
-    render_text(example_dag, max_depth=level, encoding=encoding, list_all=True, frozen=False)
+    render_text(example_dag, max_depth=level, encoding=encoding, list_all=True)
     captured = capsys.readouterr()
     assert "\n".join(expected_output).strip() == captured.out.strip()
 
@@ -458,7 +458,7 @@ def test_render_text_list_all_and_packages_options_used(
     # NOTE: Mimicking the --packages option being used here.
     package_dag = package_dag.filter_nodes(["examplePy"], None)
 
-    render_text(package_dag, max_depth=float("inf"), encoding="utf-8", list_all=True, frozen=False)
+    render_text(package_dag, max_depth=float("inf"), encoding="utf-8", list_all=True)
     captured = capsys.readouterr()
     expected_output = [
         "examplePy==1.2.3",