Skip to content

Commit

Permalink
Implement --path argument (#429)
Browse files Browse the repository at this point in the history
Resolves #408.

---------

Co-authored-by: Bernát Gábor <[email protected]>
  • Loading branch information
kemzeb and gaborbernat authored Nov 26, 2024
1 parent 139677b commit c31b641
Show file tree
Hide file tree
Showing 6 changed files with 62 additions and 8 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion src/pipdeptree/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
8 changes: 8 additions & 0 deletions src/pipdeptree/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
class Options(Namespace):
freeze: bool
python: str
path: list[str]
all: bool
local_only: bool
user_only: bool
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)

Expand Down
16 changes: 9 additions & 7 deletions src/pipdeptree/_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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]:
Expand Down
18 changes: 18 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
22 changes: 22 additions & 0 deletions tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

0 comments on commit c31b641

Please sign in to comment.