Repository: tox-dev/pipdeptree (python)

Issue:
Top level dependencies don't show when it is already listed as a subdependency when freezing
Not sure how common of a use case this is -

We have a script to verify the top level dependencies using pipdeptree. However, if a higher level dependency includes another dependency, the lower one is not listed as a top level dependency in the tree.

We can change our script to no longer do this, but was wondering if this was valid bug before proceeding.
It could also be a change in behavior in `pip` itself, but not sure.
The requirements.txt are slightly different, but `future` is listed as a top dependency in both files.

Simplest Example
pipdeptree 1.0.0 + Python2.7 (Notice that future is listed as a top dependency)
```
$ pipdeptree -w silence -f -p oic,future
future==0.18.2
oic==0.15.1
  alabaster==0.7.12
  Beaker==1.11.0
    funcsigs==1.0.2
  cryptography==3.3.2
    cffi==1.14.5
      pycparser==2.20
    enum34==1.1.10
    ipaddress==1.0.23
    six==1.15.0
  future==0.18.2
  Mako==1.1.1
    MarkupSafe==1.1.1
  pycryptodomex==3.9.8
  pyjwkest==1.4.2
    future==0.18.2
    pycryptodomex==3.9.8
    requests==2.25.0
      certifi==2021.5.30
      chardet==3.0.4
      idna==2.10
      urllib3==1.26.5
    six==1.15.0
  requests==2.25.0
    certifi==2021.5.30
    chardet==3.0.4
    idna==2.10
    urllib3==1.26.5
  six==1.15.0
$ pipdeptree -w silence -f -p future
future==0.18.2
```

pipdeptree 2.2.1 + python3.9
```
$ pipdeptree -w silence -f -p future,oic
oic==1.2.1
  Beaker==1.11.0
  cryptography==3.3.2
    cffi==1.14.5
      pycparser==2.20
    six==1.15.0
  defusedxml==0.6.0
  Mako==1.1.3
    MarkupSafe==2.0.1
  pycryptodomex==3.9.8
  pyjwkest==1.4.2
    future==0.18.2
    pycryptodomex==3.9.8
    requests==2.26.0
      certifi==2021.10.8
      charset-normalizer==2.0.7
      idna==3.3
      urllib3==1.26.7
    six==1.15.0
  requests==2.26.0
    certifi==2021.10.8
    charset-normalizer==2.0.7
    idna==3.3
    urllib3==1.26.7
  typing_extensions==4.0.1
$ pipdeptree -w silence -f -p future
future==0.18.2
```


Pull request description:
Have --packages and --all work together
Resolves #161.

Had trouble using the `argparse` mutual exclusion API. I couldn't find an approach to get -e to be mutually exclusive to -p and -a without the latter two being mutually exclusive to each other. Therefore, I ended up doing so manually in `get_options()`.

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 038412d..565a442 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -24,7 +24,7 @@ repos:
       - id: prettier
         args: ["--print-width=120", "--prose-wrap=always"]
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: "v0.0.278"
+    rev: "v0.0.280"
     hooks:
       - id: ruff
         args: [--fix, --exit-non-zero-on-fix]
diff --git a/src/pipdeptree/_cli.py b/src/pipdeptree/_cli.py
index a997aeb..1224902 100644
--- a/src/pipdeptree/_cli.py
+++ b/src/pipdeptree/_cli.py
@@ -61,29 +61,29 @@ def build_parser() -> ArgumentParser:
 
     select = parser.add_argument_group(title="select", description="choose what to render")
     select.add_argument("--python", default=sys.executable, help="Python interpreter to inspect")
-    scope = select.add_mutually_exclusive_group()
-    scope.add_argument(
-        "-l",
-        "--local-only",
-        action="store_true",
-        help="if in a virtualenv that has global access do not show globally installed packages",
-    )
-    scope.add_argument("-u", "--user-only", action="store_true", help="only show installations in the user site dir")
-
-    package = select.add_mutually_exclusive_group()
-    package.add_argument(
+    select.add_argument(
         "-p",
         "--packages",
         help="comma separated list of packages to show - wildcards are supported, like 'somepackage.*'",
         metavar="P",
     )
-    package.add_argument(
+    select.add_argument(
         "-e",
         "--exclude",
-        help="comma separated list of packages to not show - wildcards are supported, like 'somepackage.*'",
+        help="comma separated list of packages to not show - wildcards are supported, like 'somepackage.*'. "
+        "(cannot combine with -p or -a)",
         metavar="P",
     )
-    package.add_argument("-a", "--all", action="store_true", help="list all deps at top level")
+    select.add_argument("-a", "--all", action="store_true", help="list all deps at top level")
+
+    scope = select.add_mutually_exclusive_group()
+    scope.add_argument(
+        "-l",
+        "--local-only",
+        action="store_true",
+        help="if in a virtualenv that has global access do not show globally installed packages",
+    )
+    scope.add_argument("-u", "--user-only", action="store_true", help="only show installations in the user site dir")
 
     render = parser.add_argument_group(
         title="render",
@@ -137,7 +137,12 @@ def build_parser() -> ArgumentParser:
 
 def get_options(args: Sequence[str] | None) -> Options:
     parser = build_parser()
-    return cast(Options, parser.parse_args(args))
+    parsed_args = parser.parse_args(args)
+
+    if parsed_args.exclude and (parsed_args.all or parsed_args.packages):
+        return parser.error("cannot use --exclude with --packages or --all")
+
+    return cast(Options, parsed_args)
 
 
 __all__ = [


Test patch:
diff --git a/tests/render/test_text.py b/tests/render/test_text.py
index 5accf0b..e136bbe 100644
--- a/tests/render/test_text.py
+++ b/tests/render/test_text.py
@@ -1,13 +1,16 @@
 from __future__ import annotations
 
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Callable, Iterator
 
 import pytest
 
+from pipdeptree._models import PackageDAG
 from pipdeptree._render.text import render_text
 
 if TYPE_CHECKING:
-    from pipdeptree._models import PackageDAG
+    from unittest.mock import Mock
+
+    from tests.our_types import MockGraph
 
 
 @pytest.mark.parametrize(
@@ -506,3 +509,32 @@ def test_render_text_encoding(
     render_text(example_dag, max_depth=level, encoding=encoding, list_all=True, frozen=False)
     captured = capsys.readouterr()
     assert "\n".join(expected_output).strip() == captured.out.strip()
+
+
+def test_render_text_list_all_and_packages_options_used(
+    capsys: pytest.CaptureFixture[str],
+    mock_pkgs: Callable[[MockGraph], Iterator[Mock]],
+) -> None:
+    graph: dict[tuple[str, str], list[tuple[str, list[tuple[str, str]]]]] = {
+        ("examplePy", "1.2.3"): [("hellopy", [(">=", "2.0.0")]), ("worldpy", [(">=", "0.0.2")])],
+        ("HelloPy", "2.0.0"): [],
+        ("worldpy", "0.0.2"): [],
+        ("anotherpy", "0.1.2"): [("hellopy", [(">=", "2.0.0")])],
+        ("YetAnotherPy", "3.1.2"): [],
+    }
+    package_dag = PackageDAG.from_pkgs(list(mock_pkgs(graph)))
+
+    # 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)
+    captured = capsys.readouterr()
+    expected_output = [
+        "examplePy==1.2.3",
+        "├── HelloPy [required: >=2.0.0, installed: 2.0.0]",
+        "└── worldpy [required: >=0.0.2, installed: 0.0.2]",
+        "HelloPy==2.0.0",
+        "worldpy==0.0.2",
+    ]
+
+    assert "\n".join(expected_output).strip() == captured.out.strip()
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 84d6271..75a26d3 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -2,7 +2,7 @@ from __future__ import annotations
 
 import pytest
 
-from pipdeptree._cli import build_parser
+from pipdeptree._cli import build_parser, get_options
 
 
 def test_parser_default() -> None:
@@ -75,3 +75,25 @@ def test_parser_depth(should_be_error: bool, depth_arg: list[str], expected_valu
     else:
         args = parser.parse_args(depth_arg)
         assert args.depth == expected_value
+
+
+@pytest.mark.parametrize(
+    "args",
+    [
+        pytest.param(["--exclude", "py", "--all"], id="exclude-all"),
+        pytest.param(["-e", "py", "--packages", "py2"], id="exclude-packages"),
+        pytest.param(["-e", "py", "-p", "py2", "-a"], id="exclude-packages-all"),
+    ],
+)
+def test_parser_get_options_exclude_combine_not_supported(args: list[str], capsys: pytest.CaptureFixture[str]) -> None:
+    with pytest.raises(SystemExit, match="2"):
+        get_options(args)
+
+    out, err = capsys.readouterr()
+    assert not out
+    assert "cannot use --exclude with --packages or --all" in err
+
+
+def test_parser_get_options_exclude_only() -> None:
+    parsed_args = get_options(["--exclude", "py"])
+    assert parsed_args.exclude == "py"