Repository: tox-dev/pipdeptree (python)

Issue:
[NEW CLIP OPTION IDEA] cli option to control DEPTH of the output tree
Getting inspiration from the same option of the `tree` shell command , I think it could be worth to implement a new cli option flag to control the depth of the output.

```{shell}
$ pipdeptree 

backcall==0.2.0
  - ipython==8.13.1 [requires: backcall]
Brotli==1.0.9
cachetools==5.3.0
  - google-auth==2.17.3 [requires: cachetools>=2.0.0,<6.0]
    - google-api-core==2.11.0 [requires: google-auth>=2.14.1,<3.0dev]
      - google-api-python-client==2.86.0 [requires: google-api-core>=1.31.5,<3.0.0dev,!=2.3.0,!=2.2.*,!=2.1.*,!=2.0.*]
    - google-api-python-client==2.86.0 [requires: google-auth>=1.19.0,<3.0.0dev]
    - google-auth-httplib2==0.1.0 [requires: google-auth]
      - google-api-python-client==2.86.0 [requires: google-auth-httplib2>=0.1.0]
    - google-auth-oauthlib==1.0.0 [requires: google-auth>=2.15.0]
```

**Now with the new CLI option**

```{shell}
$ pipdeptree -L 2 

backcall==0.2.0
  - ipython==8.13.1 [requires: backcall]
Brotli==1.0.9
cachetools==5.3.0
  - google-auth==2.17.3 [requires: cachetools>=2.0.0,<6.0]
```

```{shell}
$ pipdeptree -L 3

backcall==0.2.0
  - ipython==8.13.1 [requires: backcall]
Brotli==1.0.9
cachetools==5.3.0
  - google-auth==2.17.3 [requires: cachetools>=2.0.0,<6.0]
    - google-api-core==2.11.0 [requires: google-auth>=2.14.1,<3.0dev]
    - google-api-python-client==2.86.0 [requires: google-auth>=1.19.0,<3.0.0dev]
    - google-auth-httplib2==0.1.0 [requires: google-auth]
    - google-auth-oauthlib==1.0.0 [requires: google-auth>=2.15.0]
```

Pull request description:
Implement -d/--depth argument
Resolves #230.

This commit allows dependency trees to be displayed up to a certain depth >= 0. This argument only applies to the default plain-text display. All other display options will ignore this argument.

A few examples using Scrapy:
```console
(venv) ➜  pipdeptree git:(implement-depth-arg) ✗ pipdeptree -p Scrapy -d 1
Scrapy==2.9.0
├── cryptography [required: >=3.4.6, installed: 41.0.1]
├── cssselect [required: >=0.9.1, installed: 1.2.0]
├── itemadapter [required: >=0.1.0, installed: 0.8.0]
├── itemloaders [required: >=1.0.1, installed: 1.1.0]
├── lxml [required: >=4.3.0, installed: 4.9.2]
├── packaging [required: Any, installed: 23.1]
├── parsel [required: >=1.5.0, installed: 1.8.1]
├── protego [required: >=0.1.15, installed: 0.2.1]
├── PyDispatcher [required: >=2.0.5, installed: 2.0.7]
├── pyOpenSSL [required: >=21.0.0, installed: 23.2.0]
├── queuelib [required: >=1.4.2, installed: 1.6.2]
├── service-identity [required: >=18.1.0, installed: 21.1.0]
├── setuptools [required: Any, installed: 65.5.0]
├── tldextract [required: Any, installed: 3.4.4]
├── Twisted [required: >=18.9.0, installed: 22.10.0]
├── w3lib [required: >=1.17.0, installed: 2.1.1]
└── zope.interface [required: >=5.1.0, installed: 6.0]
(venv) ➜  pipdeptree git:(implement-depth-arg) ✗ pipdeptree -p Scrapy -d 2
Scrapy==2.9.0
├── cryptography [required: >=3.4.6, installed: 41.0.1]
│   └── cffi [required: >=1.12, installed: 1.15.1]
├── cssselect [required: >=0.9.1, installed: 1.2.0]
├── itemadapter [required: >=0.1.0, installed: 0.8.0]
├── itemloaders [required: >=1.0.1, installed: 1.1.0]
│   ├── itemadapter [required: >=0.1.0, installed: 0.8.0]
│   ├── jmespath [required: >=0.9.5, installed: 1.0.1]
│   ├── parsel [required: >=1.5.0, installed: 1.8.1]
│   └── w3lib [required: >=1.17.0, installed: 2.1.1]
├── lxml [required: >=4.3.0, installed: 4.9.2]
├── packaging [required: Any, installed: 23.1]
├── parsel [required: >=1.5.0, installed: 1.8.1]
│   ├── cssselect [required: >=0.9, installed: 1.2.0]
│   ├── jmespath [required: Any, installed: 1.0.1]
│   ├── lxml [required: Any, installed: 4.9.2]
│   ├── packaging [required: Any, installed: 23.1]
│   └── w3lib [required: >=1.19.0, installed: 2.1.1]
├── protego [required: >=0.1.15, installed: 0.2.1]
│   └── six [required: Any, installed: 1.16.0]
├── PyDispatcher [required: >=2.0.5, installed: 2.0.7]
├── pyOpenSSL [required: >=21.0.0, installed: 23.2.0]
│   └── cryptography [required: >=38.0.0,<42,!=40.0.1,!=40.0.0, installed: 41.0.1]
├── queuelib [required: >=1.4.2, installed: 1.6.2]
├── service-identity [required: >=18.1.0, installed: 21.1.0]
│   ├── attrs [required: >=19.1.0, installed: 23.1.0]
│   ├── cryptography [required: Any, installed: 41.0.1]
│   ├── pyasn1 [required: Any, installed: 0.5.0]
│   ├── pyasn1-modules [required: Any, installed: 0.3.0]
│   └── six [required: Any, installed: 1.16.0]
├── setuptools [required: Any, installed: 65.5.0]
├── tldextract [required: Any, installed: 3.4.4]
│   ├── filelock [required: >=3.0.8, installed: 3.12.0]
│   ├── idna [required: Any, installed: 3.4]
│   ├── requests [required: >=2.1.0, installed: 2.31.0]
│   └── requests-file [required: >=1.4, installed: 1.5.1]
├── Twisted [required: >=18.9.0, installed: 22.10.0]
│   ├── attrs [required: >=19.2.0, installed: 23.1.0]
│   ├── Automat [required: >=0.8.0, installed: 22.10.0]
│   ├── constantly [required: >=15.1, installed: 15.1.0]
│   ├── hyperlink [required: >=17.1.1, installed: 21.0.0]
│   ├── incremental [required: >=21.3.0, installed: 22.10.0]
│   ├── typing-extensions [required: >=3.6.5, installed: 4.6.3]
│   └── zope.interface [required: >=4.4.2, installed: 6.0]
├── w3lib [required: >=1.17.0, installed: 2.1.1]
└── zope.interface [required: >=5.1.0, installed: 6.0]
    └── setuptools [required: Any, installed: 65.5.0]
(venv) ➜  pipdeptree git:(implement-depth-arg) ✗
```

Patch:
diff --git a/src/pipdeptree/__init__.py b/src/pipdeptree/__init__.py
index 8b9fa09..0e85016 100644
--- a/src/pipdeptree/__init__.py
+++ b/src/pipdeptree/__init__.py
@@ -455,7 +455,7 @@ class ReversedPackageDAG(PackageDAG):
         return PackageDAG(dict(m))
 
 
-def render_text(tree, list_all=True, frozen=False):
+def render_text(tree, max_depth, list_all=True, frozen=False):
     """Print tree as text on console
 
     :param dict tree: the package tree
@@ -471,12 +471,12 @@ def render_text(tree, list_all=True, frozen=False):
         nodes = [p for p in nodes if p.key not in branch_keys]
 
     if sys.stdout.encoding.lower() in ("utf-8", "utf-16", "utf-32"):
-        _render_text_with_unicode(tree, nodes, frozen)
+        _render_text_with_unicode(tree, nodes, max_depth, frozen)
     else:
-        _render_text_without_unicode(tree, nodes, frozen)
+        _render_text_without_unicode(tree, nodes, max_depth, frozen)
 
 
-def _render_text_with_unicode(tree, nodes, frozen):
+def _render_text_with_unicode(tree, nodes, max_depth, frozen):
     use_bullets = not frozen
 
     def aux(
@@ -533,7 +533,7 @@ def _render_text_with_unicode(tree, nodes, frozen):
                 parent_is_last_child=is_last_child,
             )
             for c in children
-            if c.project_name not in cur_chain
+            if c.project_name not in cur_chain and depth + 1 <= max_depth
         ]
 
         result += list(chain.from_iterable(children_strings))
@@ -543,10 +543,10 @@ def _render_text_with_unicode(tree, nodes, frozen):
     print("\n".join(lines))
 
 
-def _render_text_without_unicode(tree, nodes, frozen):
+def _render_text_without_unicode(tree, nodes, max_depth, frozen):
     use_bullets = not frozen
 
-    def aux(node, parent=None, indent=0, cur_chain=None):
+    def aux(node, parent=None, indent=0, cur_chain=None, depth=0):
         cur_chain = cur_chain or []
         node_str = node.render(parent, frozen)
         if parent:
@@ -554,9 +554,9 @@ def _render_text_without_unicode(tree, nodes, frozen):
             node_str = prefix + node_str
         result = [node_str]
         children = [
-            aux(c, node, indent=indent + 2, cur_chain=cur_chain + [c.project_name])
+            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
+            if c.project_name not in cur_chain and depth + 1 <= max_depth
         ]
         result += list(chain.from_iterable(children))
         return result
@@ -971,6 +971,16 @@ def get_parser():
             "GraphViz, e.g.: dot, jpeg, pdf, png, svg"
         ),
     )
+    parser.add_argument(
+        "-d",
+        "--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=(
+            "Display dependency tree up to a depth >=0 using the default text display. All other display options"
+            " ignore this argument."
+        ),
+    )
     return parser
 
 
@@ -1077,6 +1087,6 @@ def main():
         output = dump_graphviz(tree, output_format=args.output_format, is_reverse=args.reverse)
         print_graphviz(output)
     else:
-        render_text(tree, args.all, args.freeze)
+        render_text(tree, args.depth, args.all, args.freeze)
 
     return return_code


Test patch:
diff --git a/tests/test_pipdeptree.py b/tests/test_pipdeptree.py
index 473eaa3..f50094d 100644
--- a/tests/test_pipdeptree.py
+++ b/tests/test_pipdeptree.py
@@ -468,7 +468,104 @@ def test_render_text(capsys, list_all, reverse, unicode, expected_output):
     tree = t.reverse() if reverse else t
     encoding = "utf-8" if unicode else "ascii"
     with mock.patch("sys.stdout", MockStdout(encoding)):
-        p.render_text(tree, list_all=list_all, frozen=False)
+        p.render_text(tree, float("inf"), list_all=list_all, frozen=False)
+        captured = capsys.readouterr()
+        assert "\n".join(expected_output).strip() == captured.out.strip()
+
+
+@pytest.mark.parametrize(
+    ("unicode", "level", "expected_output"),
+    [
+        (
+            True,
+            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",
+            ],
+        ),
+        (
+            False,
+            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",
+            ],
+        ),
+        (
+            True,
+            2,
+            [
+                "a==3.4.0",
+                "├── b [required: >=2.0.0, installed: 2.3.1]",
+                "│   └── d [required: >=2.30,<2.42, installed: 2.35]",
+                "└── c [required: >=5.7.1, installed: 5.10.0]",
+                "    ├── d [required: >=2.30, installed: 2.35]",
+                "    └── e [required: >=0.12.1, installed: 0.12.1]",
+                "b==2.3.1",
+                "└── d [required: >=2.30,<2.42, installed: 2.35]",
+                "    └── e [required: >=0.9.0, installed: 0.12.1]",
+                "c==5.10.0",
+                "├── d [required: >=2.30, installed: 2.35]",
+                "│   └── e [required: >=0.9.0, installed: 0.12.1]",
+                "└── e [required: >=0.12.1, installed: 0.12.1]",
+                "d==2.35",
+                "└── e [required: >=0.9.0, installed: 0.12.1]",
+                "e==0.12.1",
+                "f==3.1",
+                "└── b [required: >=2.1.0, installed: 2.3.1]",
+                "    └── d [required: >=2.30,<2.42, installed: 2.35]",
+                "g==6.8.3rc1",
+                "├── e [required: >=0.9.0, installed: 0.12.1]",
+                "└── f [required: >=3.0.0, installed: 3.1]",
+                "    └── b [required: >=2.1.0, installed: 2.3.1]",
+            ],
+        ),
+        (
+            False,
+            2,
+            [
+                "a==3.4.0",
+                "  - b [required: >=2.0.0, installed: 2.3.1]",
+                "    - d [required: >=2.30,<2.42, installed: 2.35]",
+                "  - c [required: >=5.7.1, installed: 5.10.0]",
+                "    - d [required: >=2.30, installed: 2.35]",
+                "    - e [required: >=0.12.1, installed: 0.12.1]",
+                "b==2.3.1",
+                "  - d [required: >=2.30,<2.42, installed: 2.35]",
+                "    - e [required: >=0.9.0, installed: 0.12.1]",
+                "c==5.10.0",
+                "  - d [required: >=2.30, installed: 2.35]",
+                "    - e [required: >=0.9.0, installed: 0.12.1]",
+                "  - e [required: >=0.12.1, installed: 0.12.1]",
+                "d==2.35",
+                "  - e [required: >=0.9.0, installed: 0.12.1]",
+                "e==0.12.1",
+                "f==3.1",
+                "  - b [required: >=2.1.0, installed: 2.3.1]",
+                "    - d [required: >=2.30,<2.42, installed: 2.35]",
+                "g==6.8.3rc1",
+                "  - e [required: >=0.9.0, installed: 0.12.1]",
+                "  - f [required: >=3.0.0, installed: 3.1]",
+                "    - b [required: >=2.1.0, installed: 2.3.1]",
+            ],
+        ),
+    ],
+)
+def test_render_text_given_depth(capsys, unicode, level, expected_output):
+    encoding = "utf-8" if unicode else "ascii"
+    with mock.patch("sys.stdout", MockStdout(encoding)):
+        p.render_text(t, level)
         captured = capsys.readouterr()
         assert "\n".join(expected_output).strip() == captured.out.strip()
 
@@ -776,6 +873,27 @@ def test_parser_svg():
     assert not args.json
 
 
+@pytest.mark.parametrize(
+    ("should_be_error", "depth_arg", "expected_value"),
+    [
+        (True, ["-d", "-1"], None),
+        (True, ["--depth", "string"], None),
+        (False, ["-d", "0"], 0),
+        (False, ["--depth", "8"], 8),
+        (False, [], float("inf")),
+    ],
+)
+def test_parser_depth(should_be_error, depth_arg, expected_value):
+    parser = p.get_parser()
+
+    if should_be_error:
+        with pytest.raises(SystemExit):
+            parser.parse_args(depth_arg)
+    else:
+        args = parser.parse_args(depth_arg)
+        assert args.depth == expected_value
+
+
 @pytest.mark.parametrize("args_joined", [True, False])
 def test_custom_interpreter(tmp_path, monkeypatch, capfd, args_joined):
     result = virtualenv.cli_run([str(tmp_path), "--activators", ""])