Skip to content

Commit 4bca8ce

Browse files
authored
Split freeze and text output into their own implementations (#459)
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.
1 parent e4d4bb3 commit 4bca8ce

File tree

6 files changed

+234
-41
lines changed

6 files changed

+234
-41
lines changed

src/pipdeptree/_render/__init__.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from typing import TYPE_CHECKING
44

5+
from .freeze import render_freeze
56
from .graphviz import render_graphviz
67
from .json import render_json
78
from .json_tree import render_json_tree
@@ -22,13 +23,14 @@ def render(options: Options, tree: PackageDAG) -> None:
2223
print(render_mermaid(tree)) # noqa: T201
2324
elif options.output_format:
2425
render_graphviz(tree, output_format=options.output_format, reverse=options.reverse)
26+
elif options.freeze:
27+
render_freeze(tree, max_depth=options.depth, list_all=options.all)
2528
else:
2629
render_text(
2730
tree,
2831
max_depth=options.depth,
2932
encoding=options.encoding,
3033
list_all=options.all,
31-
frozen=options.freeze,
3234
include_license=options.license,
3335
)
3436

src/pipdeptree/_render/freeze.py

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from __future__ import annotations
2+
3+
from itertools import chain
4+
from typing import TYPE_CHECKING, Any
5+
6+
from .text import get_top_level_nodes
7+
8+
if TYPE_CHECKING:
9+
from pipdeptree._models.dag import PackageDAG
10+
from pipdeptree._models.package import DistPackage, ReqPackage
11+
12+
13+
def render_freeze(tree: PackageDAG, *, max_depth: float, list_all: bool = True) -> None:
14+
nodes = get_top_level_nodes(tree, list_all=list_all)
15+
16+
def aux(
17+
node: DistPackage | ReqPackage,
18+
parent: DistPackage | ReqPackage | None = None,
19+
indent: int = 0,
20+
cur_chain: list[str] | None = None,
21+
depth: int = 0,
22+
) -> list[Any]:
23+
cur_chain = cur_chain or []
24+
node_str = node.render(parent, frozen=True)
25+
if parent:
26+
prefix = " " * indent
27+
node_str = prefix + node_str
28+
result = [node_str]
29+
children = [
30+
aux(c, node, indent=indent + 2, cur_chain=[*cur_chain, c.project_name], depth=depth + 1)
31+
for c in tree.get_children(node.key)
32+
if c.project_name not in cur_chain and depth + 1 <= max_depth
33+
]
34+
result += list(chain.from_iterable(children))
35+
return result
36+
37+
lines = chain.from_iterable([aux(p) for p in nodes])
38+
print("\n".join(lines)) # noqa: T201
39+
40+
41+
__all__ = [
42+
"render_freeze",
43+
]

src/pipdeptree/_render/text.py

+28-34
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,39 @@
77
from pipdeptree._models import DistPackage, PackageDAG, ReqPackage
88

99

10-
def render_text( # noqa: PLR0913
10+
def render_text(
1111
tree: PackageDAG,
1212
*,
1313
max_depth: float,
1414
encoding: str,
1515
list_all: bool = True,
16-
frozen: bool = False,
1716
include_license: bool = False,
1817
) -> None:
1918
"""
2019
Print tree as text on console.
2120
2221
:param tree: the package tree
23-
:param list_all: whether to list all the pgks at the root level or only those that are the sub-dependencies
24-
:param frozen: show the names of the pkgs in the output that's favorable to pip --freeze
22+
:param max_depth: the maximum depth of the dependency tree
23+
:param encoding: encoding to use (use "utf-8", "utf-16", "utf-32" for unicode or anything else for legacy output)
24+
:param list_all: whether to list all the pkgs at the root level or only those that are the sub-dependencies
25+
:param include_license: provide license information
2526
:returns: None
2627
28+
"""
29+
nodes = get_top_level_nodes(tree, list_all=list_all)
30+
31+
if encoding in {"utf-8", "utf-16", "utf-32"}:
32+
_render_text_with_unicode(tree, nodes, max_depth, include_license)
33+
else:
34+
_render_text_without_unicode(tree, nodes, max_depth, include_license)
35+
36+
37+
def get_top_level_nodes(tree: PackageDAG, *, list_all: bool) -> list[DistPackage]:
38+
"""
39+
Get a list of nodes that will appear at the first depth of the dependency tree.
40+
41+
:param tree: the package tree
42+
:param list_all: whether to list all the pkgs at the root level or only those that are the sub-dependencies
2743
"""
2844
tree = tree.sort()
2945
nodes = list(tree.keys())
@@ -32,23 +48,15 @@ def render_text( # noqa: PLR0913
3248
if not list_all:
3349
nodes = [p for p in nodes if p.key not in branch_keys]
3450

35-
if encoding in {"utf-8", "utf-16", "utf-32"}:
36-
_render_text_with_unicode(tree, nodes, max_depth, frozen, include_license)
37-
else:
38-
_render_text_without_unicode(tree, nodes, max_depth, frozen, include_license)
51+
return nodes
3952

4053

4154
def _render_text_with_unicode(
4255
tree: PackageDAG,
4356
nodes: list[DistPackage],
4457
max_depth: float,
45-
frozen: bool, # noqa: FBT001
4658
include_license: bool, # noqa: FBT001
4759
) -> None:
48-
assert not (frozen and include_license)
49-
50-
use_bullets = not frozen
51-
5260
def aux( # noqa: PLR0913, PLR0917
5361
node: DistPackage | ReqPackage,
5462
parent: DistPackage | ReqPackage | None = None,
@@ -61,7 +69,7 @@ def aux( # noqa: PLR0913, PLR0917
6169
parent_is_last_child: bool = False, # noqa: FBT001, FBT002
6270
) -> list[Any]:
6371
cur_chain = cur_chain or []
64-
node_str = node.render(parent, frozen=frozen)
72+
node_str = node.render(parent, frozen=False)
6573
next_prefix = ""
6674
next_indent = indent + 2
6775

@@ -70,21 +78,14 @@ def aux( # noqa: PLR0913, PLR0917
7078
if is_last_child:
7179
bullet = "└── "
7280

73-
line_char = "│"
74-
if not use_bullets:
75-
line_char = ""
76-
# Add 2 spaces so direct dependencies to a project are indented
77-
bullet = " "
78-
7981
if has_grand_parent:
8082
next_indent -= 1
8183
if parent_is_last_child:
82-
offset = 0 if len(line_char) == 1 else 1
83-
prefix += " " * (indent + 1 - offset - depth)
84+
prefix += " " * (indent + 1 - depth)
8485
else:
85-
prefix += line_char + " " * (indent - depth)
86+
prefix += "│" + " " * (indent - depth)
8687
# Without this extra space, bullets will point to the space just before the project name
87-
prefix += " " if use_bullets else ""
88+
prefix += " "
8889
next_prefix = prefix
8990
node_str = prefix + bullet + node_str
9091
elif include_license:
@@ -120,13 +121,8 @@ def _render_text_without_unicode(
120121
tree: PackageDAG,
121122
nodes: list[DistPackage],
122123
max_depth: float,
123-
frozen: bool, # noqa: FBT001
124124
include_license: bool, # noqa: FBT001
125125
) -> None:
126-
assert not (frozen and include_license)
127-
128-
use_bullets = not frozen
129-
130126
def aux(
131127
node: DistPackage | ReqPackage,
132128
parent: DistPackage | ReqPackage | None = None,
@@ -135,9 +131,9 @@ def aux(
135131
depth: int = 0,
136132
) -> list[Any]:
137133
cur_chain = cur_chain or []
138-
node_str = node.render(parent, frozen=frozen)
134+
node_str = node.render(parent, frozen=False)
139135
if parent:
140-
prefix = " " * indent + ("- " if use_bullets else "")
136+
prefix = " " * indent + "- "
141137
node_str = prefix + node_str
142138
elif include_license:
143139
node_str += " " + node.licenses()
@@ -154,6 +150,4 @@ def aux(
154150
print("\n".join(lines)) # noqa: T201
155151

156152

157-
__all__ = [
158-
"render_text",
159-
]
153+
__all__ = ["get_top_level_nodes", "render_text"]

tests/render/test_freeze.py

+150
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
from __future__ import annotations
2+
3+
from math import inf
4+
from typing import TYPE_CHECKING
5+
from unittest.mock import PropertyMock
6+
7+
import pytest
8+
9+
from pipdeptree._freeze import PipBaseDistributionAdapter
10+
from pipdeptree._render.freeze import render_freeze
11+
12+
if TYPE_CHECKING:
13+
from pipdeptree._models.dag import PackageDAG
14+
15+
16+
@pytest.fixture
17+
def patch_pip_adapter(monkeypatch: pytest.MonkeyPatch) -> None:
18+
"""
19+
Patches `PipBaseDistributionAdapter` such that `editable` returns `False` and `direct_url` returns `None`.
20+
21+
This will have the pip API always return a frozen req in the "name==version" format.
22+
"""
23+
monkeypatch.setattr(PipBaseDistributionAdapter, "editable", PropertyMock(return_value=False))
24+
monkeypatch.setattr(PipBaseDistributionAdapter, "direct_url", PropertyMock(return_value=None))
25+
26+
27+
@pytest.mark.parametrize(
28+
("list_all", "expected_output"),
29+
[
30+
(
31+
True,
32+
[
33+
"a==3.4.0",
34+
" b==2.3.1",
35+
" d==2.35",
36+
" e==0.12.1",
37+
" c==5.10.0",
38+
" d==2.35",
39+
" e==0.12.1",
40+
" e==0.12.1",
41+
"b==2.3.1",
42+
" d==2.35",
43+
" e==0.12.1",
44+
"c==5.10.0",
45+
" d==2.35",
46+
" e==0.12.1",
47+
" e==0.12.1",
48+
"d==2.35",
49+
" e==0.12.1",
50+
"e==0.12.1",
51+
"f==3.1",
52+
" b==2.3.1",
53+
" d==2.35",
54+
" e==0.12.1",
55+
"g==6.8.3rc1",
56+
" e==0.12.1",
57+
" f==3.1",
58+
" b==2.3.1",
59+
" d==2.35",
60+
" e==0.12.1",
61+
],
62+
),
63+
(
64+
False,
65+
[
66+
"a==3.4.0",
67+
" b==2.3.1",
68+
" d==2.35",
69+
" e==0.12.1",
70+
" c==5.10.0",
71+
" d==2.35",
72+
" e==0.12.1",
73+
" e==0.12.1",
74+
"g==6.8.3rc1",
75+
" e==0.12.1",
76+
" f==3.1",
77+
" b==2.3.1",
78+
" d==2.35",
79+
" e==0.12.1",
80+
],
81+
),
82+
],
83+
)
84+
@pytest.mark.usefixtures("patch_pip_adapter")
85+
def test_render_freeze(
86+
example_dag: PackageDAG,
87+
capsys: pytest.CaptureFixture[str],
88+
list_all: bool,
89+
expected_output: list[str],
90+
) -> None:
91+
render_freeze(example_dag, max_depth=inf, list_all=list_all)
92+
captured = capsys.readouterr()
93+
assert "\n".join(expected_output).strip() == captured.out.strip()
94+
95+
96+
@pytest.mark.parametrize(
97+
("depth", "expected_output"),
98+
[
99+
(
100+
0,
101+
[
102+
"a==3.4.0",
103+
"b==2.3.1",
104+
"c==5.10.0",
105+
"d==2.35",
106+
"e==0.12.1",
107+
"f==3.1",
108+
"g==6.8.3rc1",
109+
],
110+
),
111+
(
112+
2,
113+
[
114+
"a==3.4.0",
115+
" b==2.3.1",
116+
" d==2.35",
117+
" c==5.10.0",
118+
" d==2.35",
119+
" e==0.12.1",
120+
"b==2.3.1",
121+
" d==2.35",
122+
" e==0.12.1",
123+
"c==5.10.0",
124+
" d==2.35",
125+
" e==0.12.1",
126+
" e==0.12.1",
127+
"d==2.35",
128+
" e==0.12.1",
129+
"e==0.12.1",
130+
"f==3.1",
131+
" b==2.3.1",
132+
" d==2.35",
133+
"g==6.8.3rc1",
134+
" e==0.12.1",
135+
" f==3.1",
136+
" b==2.3.1",
137+
],
138+
),
139+
],
140+
)
141+
@pytest.mark.usefixtures("patch_pip_adapter")
142+
def test_render_freeze_given_depth(
143+
example_dag: PackageDAG,
144+
capsys: pytest.CaptureFixture[str],
145+
depth: int,
146+
expected_output: list[str],
147+
) -> None:
148+
render_freeze(example_dag, max_depth=depth)
149+
captured = capsys.readouterr()
150+
assert "\n".join(expected_output).strip() == captured.out.strip()

tests/render/test_render.py

+7-3
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ def test_grahpviz_routing(mocker: MockerFixture) -> None:
3737
def test_text_routing(mocker: MockerFixture) -> None:
3838
render = mocker.patch("pipdeptree._render.render_text")
3939
main([])
40-
render.assert_called_once_with(
41-
ANY, encoding="utf-8", frozen=False, list_all=False, max_depth=inf, include_license=False
42-
)
40+
render.assert_called_once_with(ANY, encoding="utf-8", max_depth=inf, list_all=False, include_license=False)
41+
42+
43+
def test_freeze_routing(mocker: MockerFixture) -> None:
44+
render = mocker.patch("pipdeptree._render.render_freeze")
45+
main(["--freeze"])
46+
render.assert_called_once_with(ANY, max_depth=inf, list_all=False)

tests/render/test_text.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ def test_render_text(
248248
) -> None:
249249
tree = example_dag.reverse() if reverse else example_dag
250250
encoding = "utf-8" if unicode else "ascii"
251-
render_text(tree, max_depth=float("inf"), encoding=encoding, list_all=list_all, frozen=False)
251+
render_text(tree, max_depth=float("inf"), encoding=encoding, list_all=list_all)
252252
captured = capsys.readouterr()
253253
assert "\n".join(expected_output).strip() == captured.out.strip()
254254

@@ -437,7 +437,7 @@ def test_render_text_encoding(
437437
expected_output: list[str],
438438
example_dag: PackageDAG,
439439
) -> None:
440-
render_text(example_dag, max_depth=level, encoding=encoding, list_all=True, frozen=False)
440+
render_text(example_dag, max_depth=level, encoding=encoding, list_all=True)
441441
captured = capsys.readouterr()
442442
assert "\n".join(expected_output).strip() == captured.out.strip()
443443

@@ -458,7 +458,7 @@ def test_render_text_list_all_and_packages_options_used(
458458
# NOTE: Mimicking the --packages option being used here.
459459
package_dag = package_dag.filter_nodes(["examplePy"], None)
460460

461-
render_text(package_dag, max_depth=float("inf"), encoding="utf-8", list_all=True, frozen=False)
461+
render_text(package_dag, max_depth=float("inf"), encoding="utf-8", list_all=True)
462462
captured = capsys.readouterr()
463463
expected_output = [
464464
"examplePy==1.2.3",

0 commit comments

Comments
 (0)