Repository: tox-dev/pipdeptree (python)

Issue:
support non-default /site-packages/
### Describe the feature

Like `pip freeze --path=<...>` allows so that packages installed from an old version of Python are supported.

Pull request description:
Implement --path argument
Resolves #408.

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 935db77..f77fe38 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -24,7 +24,7 @@ repos:
     hooks:
       - id: pyproject-fmt
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: "v0.7.4"
+    rev: "v0.8.0"
     hooks:
       - id: ruff-format
       - id: ruff
diff --git a/README.md b/README.md
index 28e463c..a8b50b6 100644
--- a/README.md
+++ b/README.md
@@ -240,6 +240,7 @@ select:
   choose what to render
 
   --python PYTHON     Python interpreter to inspect (default: /usr/local/bin/python)
+  --path PATH         Passes a path used to restrict where packages should be looked for (can be used multiple times) (default: None)
   -p P, --packages P  comma separated list of packages to show - wildcards are supported, like 'somepackage.*' (default: None)
   -e P, --exclude P   comma separated list of packages to not show - wildcards are supported, like 'somepackage.*'. (cannot combine with -p or -a) (default: None)
   -a, --all           list all deps at top level (default: False)
diff --git a/src/pipdeptree/__main__.py b/src/pipdeptree/__main__.py
index ff12bcb..fca21b9 100644
--- a/src/pipdeptree/__main__.py
+++ b/src/pipdeptree/__main__.py
@@ -31,7 +31,10 @@ def main(args: Sequence[str] | None = None) -> int | None:
         print(f"(resolved python: {resolved_path})", file=sys.stderr)  # noqa: T201
 
     pkgs = get_installed_distributions(
-        interpreter=options.python, local_only=options.local_only, user_only=options.user_only
+        interpreter=options.python,
+        supplied_paths=options.path or None,
+        local_only=options.local_only,
+        user_only=options.user_only,
     )
     tree = PackageDAG.from_pkgs(pkgs)
 
diff --git a/src/pipdeptree/_cli.py b/src/pipdeptree/_cli.py
index 29e10f9..46a984a 100644
--- a/src/pipdeptree/_cli.py
+++ b/src/pipdeptree/_cli.py
@@ -13,6 +13,7 @@ from .version import __version__
 class Options(Namespace):
     freeze: bool
     python: str
+    path: list[str]
     all: bool
     local_only: bool
     user_only: bool
@@ -60,6 +61,11 @@ def build_parser() -> ArgumentParser:
             " it can't."
         ),
     )
+    select.add_argument(
+        "--path",
+        help="Passes a path used to restrict where packages should be looked for (can be used multiple times)",
+        action="append",
+    )
     select.add_argument(
         "-p",
         "--packages",
@@ -157,6 +163,8 @@ def get_options(args: Sequence[str] | None) -> Options:
         return parser.error("cannot use --exclude with --packages or --all")
     if parsed_args.license and parsed_args.freeze:
         return parser.error("cannot use --license with --freeze")
+    if parsed_args.path and (parsed_args.local_only or parsed_args.user_only):
+        return parser.error("cannot use --path with --user-only or --local-only")
 
     return cast(Options, parsed_args)
 
diff --git a/src/pipdeptree/_discovery.py b/src/pipdeptree/_discovery.py
index 33f0fbc..ba23498 100644
--- a/src/pipdeptree/_discovery.py
+++ b/src/pipdeptree/_discovery.py
@@ -15,19 +15,21 @@ from pipdeptree._warning import get_warning_printer
 
 def get_installed_distributions(
     interpreter: str = str(sys.executable),
+    supplied_paths: list[str] | None = None,
     local_only: bool = False,  # noqa: FBT001, FBT002
     user_only: bool = False,  # noqa: FBT001, FBT002
 ) -> list[Distribution]:
-    # We assign sys.path here as it used by both importlib.metadata.PathDistribution and pip by default.
-    paths = sys.path
+    # This will be the default since it's used by both importlib.metadata.PathDistribution and pip by default.
+    computed_paths = supplied_paths or sys.path
 
     # See https://docs.python.org/3/library/venv.html#how-venvs-work for more details.
     in_venv = sys.prefix != sys.base_prefix
 
     py_path = Path(interpreter).absolute()
     using_custom_interpreter = py_path != Path(sys.executable).absolute()
+    should_query_interpreter = using_custom_interpreter and not supplied_paths
 
-    if using_custom_interpreter:
+    if should_query_interpreter:
         # We query the interpreter directly to get its `sys.path`. If both --python and --local-only are given, only
         # snatch metadata associated to the interpreter's environment.
         if local_only:
@@ -37,14 +39,14 @@ def get_installed_distributions(
 
         args = [str(py_path), "-c", cmd]
         result = subprocess.run(args, stdout=subprocess.PIPE, check=False, text=True)  # noqa: S603
-        paths = ast.literal_eval(result.stdout)
+        computed_paths = ast.literal_eval(result.stdout)
     elif local_only and in_venv:
-        paths = [p for p in paths if p.startswith(sys.prefix)]
+        computed_paths = [p for p in computed_paths if p.startswith(sys.prefix)]
 
     if user_only:
-        paths = [p for p in paths if p.startswith(site.getusersitepackages())]
+        computed_paths = [p for p in computed_paths if p.startswith(site.getusersitepackages())]
 
-    return filter_valid_distributions(distributions(path=paths))
+    return filter_valid_distributions(distributions(path=computed_paths))
 
 
 def filter_valid_distributions(iterable_dists: Iterable[Distribution]) -> list[Distribution]:


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index d2c3c62..a7e95d4 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -112,6 +112,24 @@ def test_parser_get_options_license_and_freeze_together_not_supported(capsys: py
     assert "cannot use --license with --freeze" in err
 
 
+@pytest.mark.parametrize(
+    "args",
+    [
+        pytest.param(["--path", "/random/path", "--local-only"], id="path-with-local"),
+        pytest.param(["--path", "/random/path", "--user-only"], id="path-with-user"),
+    ],
+)
+def test_parser_get_options_path_with_either_local_or_user_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 --path with --user-only or --local-only" in err
+
+
 @pytest.mark.parametrize(("bad_type"), [None, str])
 def test_enum_action_type_argument(bad_type: Any) -> None:
     with pytest.raises(TypeError, match="type must be a subclass of Enum"):
diff --git a/tests/test_discovery.py b/tests/test_discovery.py
index 7e94fe5..dc9e117 100644
--- a/tests/test_discovery.py
+++ b/tests/test_discovery.py
@@ -162,3 +162,25 @@ def test_invalid_metadata(
         f"{fake_site_dir}\n"
         "------------------------------------------------------------------------\n"
     )
+
+
+def test_paths(fake_dist: Path) -> None:
+    fake_site_dir = str(fake_dist.parent)
+    mocked_path = [fake_site_dir]
+
+    dists = get_installed_distributions(supplied_paths=mocked_path)
+    assert len(dists) == 1
+    assert dists[0].name == "bar"
+
+
+def test_paths_when_in_virtual_env(tmp_path: Path, fake_dist: Path) -> None:
+    # tests to ensure that we use only the user-supplied path, not paths in the virtual env
+    fake_site_dir = str(fake_dist.parent)
+    mocked_path = [fake_site_dir]
+
+    venv_path = str(tmp_path / "venv")
+    s = virtualenv.cli_run([venv_path, "--activators", ""])
+
+    dists = get_installed_distributions(interpreter=str(s.creator.exe), supplied_paths=mocked_path)
+    assert len(dists) == 1
+    assert dists[0].name == "bar"