Repository: tox-dev/pipdeptree
Pull request #491 (2025-07-20 19:20:27)

Allow --exclude to be used with --include
#273 removed the ability to use --include and --exclude together. I now disagree with this change since not only was that a (accidental) breaking change but I now see it makes sense to allow them to be used together (e.g using pattern matching: `pidpeptree -p pytest* -e pytest-mock`).

This change allows them to be used together and adds a few tests to ensure that they continue to do so.


Patch:
diff --git a/src/pipdeptree/__main__.py b/src/pipdeptree/__main__.py
index 2f8a24b..3f8fe0a 100644
--- a/src/pipdeptree/__main__.py
+++ b/src/pipdeptree/__main__.py
@@ -9,6 +9,7 @@ from pipdeptree._cli import get_options
 from pipdeptree._detect_env import detect_active_interpreter
 from pipdeptree._discovery import InterpreterQueryError, get_installed_distributions
 from pipdeptree._models import PackageDAG
+from pipdeptree._models.dag import IncludeExcludeOverlapError, IncludePatternNotFoundError
 from pipdeptree._render import render
 from pipdeptree._validate import validate
 from pipdeptree._warning import WarningPrinter, WarningType, get_warning_printer
@@ -52,13 +53,16 @@ def main(args: Sequence[str] | None = None) -> int | None:
     if options.reverse:
         tree = tree.reverse()
 
-    show_only = options.packages.split(",") if options.packages else None
+    include = options.packages.split(",") if options.packages else None
     exclude = set(options.exclude.split(",")) if options.exclude else None
 
-    if show_only is not None or exclude is not None:
+    if include is not None or exclude is not None:
         try:
-            tree = tree.filter_nodes(show_only, exclude, exclude_deps=options.exclude_dependencies)
-        except ValueError as e:
+            tree = tree.filter_nodes(include, exclude, exclude_deps=options.exclude_dependencies)
+        except IncludeExcludeOverlapError:
+            print("Cannot have --packages and --exclude contain the same entries", file=sys.stderr)  # noqa: T201
+            return 1
+        except IncludePatternNotFoundError as e:
             if warning_printer.should_warn():
                 warning_printer.print_single_line(str(e))
             return _determine_return_code(warning_printer)
diff --git a/src/pipdeptree/_cli.py b/src/pipdeptree/_cli.py
index 8dfdd2d..c3ede5a 100644
--- a/src/pipdeptree/_cli.py
+++ b/src/pipdeptree/_cli.py
@@ -170,8 +170,6 @@ def get_options(args: Sequence[str] | None) -> Options:
     parser = build_parser()
     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")
     if parsed_args.exclude_dependencies and not parsed_args.exclude:
         return parser.error("must use --exclude-dependencies with --exclude")
     if parsed_args.license and parsed_args.freeze:
diff --git a/src/pipdeptree/_models/dag.py b/src/pipdeptree/_models/dag.py
index eda363c..8611f87 100644
--- a/src/pipdeptree/_models/dag.py
+++ b/src/pipdeptree/_models/dag.py
@@ -18,6 +18,14 @@ from pipdeptree._warning import get_warning_printer
 from .package import DistPackage, InvalidRequirementError, ReqPackage
 
 
+class IncludeExcludeOverlapError(Exception):
+    """Include and exclude sets passed as input violate mutual exclusivity requirement."""
+
+
+class IncludePatternNotFoundError(Exception):
+    """Include patterns weren't found when filtering a `PackageDAG`."""
+
+
 def render_invalid_reqs_text(dist_name_to_invalid_reqs_dict: dict[str, list[str]]) -> None:
     for dist_name, invalid_reqs in dist_name_to_invalid_reqs_dict.items():
         print(dist_name, file=sys.stderr)  # noqa: T201
@@ -139,7 +147,8 @@ class PackageDAG(Mapping[DistPackage, list[ReqPackage]]):
 
         :param include: list of node keys to include (or None)
         :param exclude: set of node keys to exclude (or None)
-        :raises ValueError: If include has node keys that do not exist in the graph
+        :raises IncludeExcludeOverlapError: if include and exclude contains the same elements
+        :raises IncludePatternNotFoundError: if include has patterns that do not match anything in the graph
         :returns: filtered version of the graph
 
         """
@@ -153,10 +162,10 @@ class PackageDAG(Mapping[DistPackage, list[ReqPackage]]):
             include = [canonicalize_name(i) for i in include]
         exclude = {canonicalize_name(s) for s in exclude} if exclude else set()
 
-        # Check for mutual exclusion of show_only and exclude sets
+        # Check for mutual exclusion of include and exclude sets
         # after normalizing the values to lowercase
-        if include and exclude:
-            assert not (set(include) & exclude)
+        if include and exclude and (set(include) & exclude):
+            raise IncludeExcludeOverlapError
 
         if exclude_deps:
             exclude = self._build_exclusion_set_with_dependencies(exclude)
@@ -203,7 +212,9 @@ class PackageDAG(Mapping[DistPackage, list[ReqPackage]]):
             i for i in include_with_casing_preserved if canonicalize_name(i) not in matched_includes
         ]
         if non_existent_includes:
-            raise ValueError("No packages matched using the following patterns: " + ", ".join(non_existent_includes))
+            raise IncludePatternNotFoundError(
+                "No packages matched using the following patterns: " + ", ".join(non_existent_includes)
+            )
 
         return self.__class__(m)
 


Test patch:
diff --git a/tests/_models/test_dag.py b/tests/_models/test_dag.py
index d139845..57c5e62 100644
--- a/tests/_models/test_dag.py
+++ b/tests/_models/test_dag.py
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, Callable
 import pytest
 
 from pipdeptree._models import DistPackage, PackageDAG, ReqPackage, ReversedPackageDAG
+from pipdeptree._models.dag import IncludeExcludeOverlapError, IncludePatternNotFoundError
 
 if TYPE_CHECKING:
     from collections.abc import Iterator
@@ -63,13 +64,18 @@ def test_package_dag_filter_fnmatch_exclude_a(t_fnmatch: PackageDAG) -> None:
     assert graph == {"b-a": ["b-b"], "b-b": []}
 
 
-def test_package_dag_filter_include_exclude_both_used(t_fnmatch: PackageDAG) -> None:
-    with pytest.raises(AssertionError):
+def test_package_dag_filter_include_exclude_normal(t_fnmatch: PackageDAG) -> None:
+    graph = dag_to_dict(t_fnmatch.filter_nodes(["a-*"], {"a-a"}))
+    assert graph == {"a-b": ["a-c"]}
+
+
+def test_package_dag_filter_include_exclude_overlap(t_fnmatch: PackageDAG) -> None:
+    with pytest.raises(IncludeExcludeOverlapError):
         t_fnmatch.filter_nodes(["a-a", "a-b"], {"a-b"})
 
 
-def test_package_dag_filter_nonexistent_packages(t_fnmatch: PackageDAG) -> None:
-    with pytest.raises(ValueError, match="No packages matched using the following patterns: x, y, z"):
+def test_package_dag_filter_include_nonexistent_packages(t_fnmatch: PackageDAG) -> None:
+    with pytest.raises(IncludePatternNotFoundError, match="No packages matched using the following patterns: x, y, z"):
         t_fnmatch.filter_nodes(["x", "y", "z"], None)
 
 
diff --git a/tests/test_cli.py b/tests/test_cli.py
index fd7a528..c991715 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -81,28 +81,6 @@ def test_parser_depth(should_be_error: bool, depth_arg: list[str], expected_valu
         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"
-
-
 def test_parser_get_options_license_and_freeze_together_not_supported(capsys: pytest.CaptureFixture[str]) -> None:
     with pytest.raises(SystemExit, match="2"):
         get_options(["--license", "--freeze"])
diff --git a/tests/test_pipdeptree.py b/tests/test_pipdeptree.py
index 1627d52..00e4f52 100644
--- a/tests/test_pipdeptree.py
+++ b/tests/test_pipdeptree.py
@@ -58,3 +58,13 @@ def test_main_log_resolved(tmp_path: Path, mocker: MockFixture, capsys: pytest.C
 
     captured = capsys.readouterr()
     assert captured.err.startswith(f"(resolved python: {tmp_path!s}")
+
+
+def test_main_include_and_exclude_overlap(mocker: MockFixture, capsys: pytest.CaptureFixture[str]) -> None:
+    cmd = ["", "--packages", "a,b,c", "--exclude", "a"]
+    mocker.patch("pipdeptree.__main__.sys.argv", cmd)
+
+    main()
+
+    captured = capsys.readouterr()
+    assert "Cannot have --packages and --exclude contain the same entries" in captured.err