diff --git a/src/pipdeptree/__main__.py b/src/pipdeptree/__main__.py index dcd738e..5bf3f65 100644 --- a/src/pipdeptree/__main__.py +++ b/src/pipdeptree/__main__.py @@ -8,7 +8,6 @@ from pipdeptree._cli import get_options from pipdeptree._discovery import get_installed_distributions from pipdeptree._models import PackageDAG -from pipdeptree._non_host import handle_non_host_target from pipdeptree._render import render from pipdeptree._validate import validate @@ -16,11 +15,10 @@ def main(args: Sequence[str] | None = None) -> None | int: """CLI - The main function called as entry point.""" options = get_options(args) - result = handle_non_host_target(options) - if result is not None: - return result - pkgs = get_installed_distributions(local_only=options.local_only, user_only=options.user_only) + pkgs = get_installed_distributions( + interpreter=options.python, local_only=options.local_only, user_only=options.user_only + ) tree = PackageDAG.from_pkgs(pkgs) is_text_output = not any([options.json, options.json_tree, options.output_format]) diff --git a/src/pipdeptree/_discovery.py b/src/pipdeptree/_discovery.py index 44e0a13..56e24fd 100644 --- a/src/pipdeptree/_discovery.py +++ b/src/pipdeptree/_discovery.py @@ -1,26 +1,44 @@ from __future__ import annotations +import ast import site +import subprocess # noqa: S404 import sys from importlib.metadata import Distribution, distributions +from pathlib import Path from typing import Iterable, Tuple from packaging.utils import canonicalize_name def get_installed_distributions( + interpreter: str = str(sys.executable), local_only: bool = False, # noqa: FBT001, FBT002 user_only: bool = False, # noqa: FBT001, FBT002 ) -> list[Distribution]: # See https://docs.python.org/3/library/venv.html#how-venvs-work for more details. in_venv = sys.prefix != sys.base_prefix original_dists: Iterable[Distribution] = [] + py_path = Path(interpreter).absolute() + using_custom_interpreter = py_path != Path(sys.executable).absolute() - if local_only and in_venv: - venv_site_packages = site.getsitepackages([sys.prefix]) - original_dists = distributions(path=venv_site_packages) - elif user_only: + if user_only: original_dists = distributions(path=[site.getusersitepackages()]) + elif using_custom_interpreter: + # We query the interpreter directly to get its `sys.path` list to be used by `distributions()`. + # If --python and --local-only are given, we ensure that we are only using paths associated to the interpreter's + # environment. + if local_only: + cmd = "import sys; print([p for p in sys.path if p.startswith(sys.prefix)])" + else: + cmd = "import sys; print(sys.path)" + + args = [str(py_path), "-c", cmd] + result = subprocess.run(args, stdout=subprocess.PIPE, check=False, text=True) # noqa: S603 + original_dists = distributions(path=ast.literal_eval(result.stdout)) + elif local_only and in_venv: + venv_site_packages = [p for p in sys.path if p.startswith(sys.prefix)] + original_dists = distributions(path=venv_site_packages) else: original_dists = distributions() diff --git a/src/pipdeptree/_non_host.py b/src/pipdeptree/_non_host.py deleted file mode 100644 index 519275e..0000000 --- a/src/pipdeptree/_non_host.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations - -import os -import sys -from inspect import getsourcefile -from pathlib import Path -from shutil import copytree -from subprocess import call # noqa: S404 -from tempfile import TemporaryDirectory -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from ._cli import Options - - -def handle_non_host_target(args: Options) -> int | None: - # if target is not current python re-invoke it under the actual host - py_path = Path(args.python).absolute() - if py_path != Path(sys.executable).absolute(): - # there's no way to guarantee that graphviz is available, so refuse - if args.output_format: - print( # noqa: T201 - "graphviz functionality is not supported when querying non-host python", - file=sys.stderr, - ) - raise SystemExit(1) - argv = sys.argv[1:] # remove current python executable - for py_at, value in enumerate(argv): - if value == "--python": - del argv[py_at] - del argv[py_at] - elif value.startswith("--python"): - del argv[py_at] - - src = getsourcefile(sys.modules[__name__]) - assert src is not None - our_root = Path(src).parent - - with TemporaryDirectory() as project: - dest = Path(project) - copytree(our_root, dest / "pipdeptree") - - # We need to also make the Python executable aware of packaging since we use it. - packaging_src = getsourcefile(sys.modules["packaging"]) - assert packaging_src is not None - packaging_root = Path(packaging_src).parent - copytree(packaging_root, dest / "packaging") - - cmd = [str(py_path), "-m", "pipdeptree", *argv] - env = os.environ.copy() - - # The cwd is prepended to `sys.path` when executing __main__ using `python -m` (meaning we prepend the tmp - # directory `project` here). - # See https://docs.python.org/3/library/sys.html#sys.path - return call(cmd, cwd=project, env=env) # noqa: S603 - return None - - -__all__ = [ - "handle_non_host_target", -] diff --git a/tests/_models/test_package.py b/tests/_models/test_package.py index d0fb28e..4f83dc6 100644 --- a/tests/_models/test_package.py +++ b/tests/_models/test_package.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sys from importlib.metadata import PackageNotFoundError from pathlib import Path from typing import TYPE_CHECKING, Any @@ -26,11 +27,13 @@ def test_guess_version_setuptools(mocker: MockerFixture) -> None: assert result == "?" -def test_package_as_frozen_repr(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_package_as_frozen_repr(tmp_path: Path, mocker: MockerFixture) -> None: file_path = tmp_path / "foo.egg-link" with Path(file_path).open("w") as f: f.write("/A/B/foo") - monkeypatch.syspath_prepend(str(tmp_path)) + mock_path = sys.path.copy() + mock_path.append(str(tmp_path)) + mocker.patch("pipdeptree._discovery.sys.path", mock_path) json_text = '{"dir_info": {"editable": true}}' foo = Mock(metadata={"Name": "foo"}, version="20.4.1") foo.read_text = Mock(return_value=json_text) diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 76a9435..140473d 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -16,7 +16,7 @@ from pytest_mock import MockerFixture -def test_local_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capfd: pytest.CaptureFixture[str]) -> None: +def test_local_only(tmp_path: Path, mocker: MockerFixture, capfd: pytest.CaptureFixture[str]) -> None: venv_path = str(tmp_path / "venv") result = virtualenv.cli_run([venv_path, "--activators", ""]) venv_site_packages = site.getsitepackages([venv_path]) @@ -27,8 +27,11 @@ def test_local_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capfd: pyte f.write("Metadata-Version: 2.3\n" "Name: foo\n" "Version: 1.2.5\n") cmd = [str(result.creator.exe.parent / "python3"), "--local-only"] - monkeypatch.setattr(sys, "prefix", venv_path) - monkeypatch.setattr(sys, "argv", cmd) + mocker.patch("pipdeptree._discovery.sys.prefix", venv_path) + sys_path = sys.path.copy() + mock_path = sys_path + venv_site_packages + mocker.patch("pipdeptree._discovery.sys.path", mock_path) + mocker.patch("pipdeptree._discovery.sys.argv", cmd) main() out, _ = capfd.readouterr() found = {i.split("==")[0] for i in out.splitlines()} @@ -39,16 +42,16 @@ def test_local_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capfd: pyte assert found == expected -def test_user_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capfd: pytest.CaptureFixture[str]) -> None: +def test_user_only(tmp_path: Path, mocker: MockerFixture, capfd: pytest.CaptureFixture[str]) -> None: fake_dist = Path(tmp_path) / "foo-1.2.5.dist-info" fake_dist.mkdir() fake_metadata = Path(fake_dist) / "METADATA" with Path(fake_metadata).open("w") as f: f.write("Metadata-Version: 2.3\n" "Name: foo\n" "Version: 1.2.5\n") - monkeypatch.setattr(site, "getusersitepackages", Mock(return_value=str(tmp_path))) cmd = [sys.executable, "--user-only"] - monkeypatch.setattr(sys, "argv", cmd) + mocker.patch("pipdeptree._discovery.site.getusersitepackages", Mock(return_value=str(tmp_path))) + mocker.patch("pipdeptree._discovery.sys.argv", cmd) main() out, _ = capfd.readouterr() found = {i.split("==")[0] for i in out.splitlines()} diff --git a/tests/test_non_host.py b/tests/test_non_host.py index 782d3c5..c63c0b0 100644 --- a/tests/test_non_host.py +++ b/tests/test_non_host.py @@ -12,10 +12,13 @@ if TYPE_CHECKING: from pathlib import Path + from pytest_mock import MockerFixture + @pytest.mark.parametrize("args_joined", [True, False]) def test_custom_interpreter( tmp_path: Path, + mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, capfd: pytest.CaptureFixture[str], args_joined: bool, @@ -25,7 +28,7 @@ def test_custom_interpreter( monkeypatch.chdir(tmp_path) py = str(result.creator.exe.relative_to(tmp_path)) cmd += [f"--python={result.creator.exe}"] if args_joined else ["--python", py] - monkeypatch.setattr(sys, "argv", cmd) + mocker.patch("pipdeptree._discovery.sys.argv", cmd) main() out, _ = capfd.readouterr() found = {i.split("==")[0] for i in out.splitlines()} @@ -40,10 +43,29 @@ def test_custom_interpreter( expected -= {"setuptools", "wheel"} assert found == expected, out - monkeypatch.setattr(sys, "argv", [*cmd, "--graph-output", "something"]) - with pytest.raises(SystemExit) as context: - main() - out, err = capfd.readouterr() - assert context.value.code == 1 - assert not out - assert err == "graphviz functionality is not supported when querying non-host python\n" + +def test_custom_interpreter_with_local_only( + tmp_path: Path, + mocker: MockerFixture, + capfd: pytest.CaptureFixture[str], +) -> None: + venv_path = str(tmp_path / "venv") + + result = virtualenv.cli_run([venv_path, "--system-site-packages", "--activators", ""]) + + cmd = ["", f"--python={result.creator.exe}", "--local-only"] + mocker.patch("pipdeptree._discovery.sys.prefix", venv_path) + mocker.patch("pipdeptree._discovery.sys.argv", cmd) + main() + out, _ = capfd.readouterr() + found = {i.split("==")[0] for i in out.splitlines()} + implementation = python_implementation() + if implementation == "CPython": + expected = {"pip", "setuptools", "wheel"} + elif implementation == "PyPy": # pragma: no cover + expected = {"cffi", "greenlet", "pip", "readline", "setuptools", "wheel"} # pragma: no cover + else: + raise ValueError(implementation) # pragma: no cover + if sys.version_info >= (3, 12): + expected -= {"setuptools", "wheel"} # pragma: no cover + assert found == expected, out