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 @@ 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 @@ 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]: 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"