Repository: tox-dev/pipdeptree
Pull request #459 (2025-03-10 01:11:56)

Split freeze and text output into their own implementations
This snapshot also adds tests for the freeze output (since they did not exist before). This refactor is done because of the following:
- The freeze output is not bound to change
- The text output is bound to change. This has been evident with features like unicode support, license metadata, and future support for more metadata and listing the deps of explicit extras. Having to ensure that freeze is still working as expected after such changes makes it harder to maintain

Though there is some code that is similar to the text output implementation, splitting this up will ensure that changes to the text output implementation won't cause problems to the freeze output implementation.


Patch:
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/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",