Skip to content

Commit

Permalink
Mimic pip when handling --user-only in a virtual environment (#364)
Browse files Browse the repository at this point in the history
In v2.16.1, we would not print any user-site packages if we were:
- In a virtual environment
- Using a custom interpreter

This makes since as we should be isolated away from the system
environment. The only case where we should be seeing user-site packages
is when these environments have system site packages enabled. This patch
brings back this behavior.
  • Loading branch information
kemzeb committed May 7, 2024
1 parent 1476bf7 commit e88356f
Show file tree
Hide file tree
Showing 4 changed files with 155 additions and 24 deletions.
27 changes: 14 additions & 13 deletions src/pipdeptree/_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import sys
from importlib.metadata import Distribution, distributions
from pathlib import Path
from typing import Iterable, Tuple
from typing import Tuple

from packaging.utils import canonicalize_name

Expand All @@ -18,32 +18,33 @@ def get_installed_distributions(
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

# 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 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 using_custom_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:
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))
paths = 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()
paths = [p for p in paths if p.startswith(sys.prefix)]

if user_only:
paths = [p for p in paths if p.startswith(site.getusersitepackages())]

original_dists = distributions(path=paths)
warning_printer = get_warning_printer()

# Since importlib.metadata.distributions() can return duplicate packages, we need to handle this. pip's approach is
Expand Down
14 changes: 14 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from pathlib import Path
from random import shuffle
from typing import TYPE_CHECKING, Callable, Iterator
from unittest.mock import Mock
Expand Down Expand Up @@ -65,3 +66,16 @@ def randomized_example_dag(example_dag: PackageDAG) -> PackageDAG:
randomized_dag = PackageDAG(randomized_graph)
assert len(example_dag) == len(randomized_dag)
return randomized_dag


@pytest.fixture()
def fake_dist(tmp_path: Path) -> Path:
"""Creates a fake site package (that you get using Path.parent) and a fake dist-info called bar-2.4.5.dist-info."""
fake_site_pkgs = tmp_path / "site-packages"
fake_dist_path = fake_site_pkgs / "bar-2.4.5.dist-info"
fake_dist_path.mkdir(parents=True)
fake_metadata = Path(fake_dist_path) / "METADATA"
with Path(fake_metadata).open("w") as f:
f.write("Metadata-Version: 2.3\n" "Name: bar\n" "Version: 2.4.5\n")

return fake_dist_path
82 changes: 71 additions & 11 deletions tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,20 +42,80 @@ def test_local_only(tmp_path: Path, mocker: MockerFixture, capfd: pytest.Capture
assert found == expected


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")
def test_user_only(fake_dist: Path, mocker: MockerFixture, capfd: pytest.CaptureFixture[str]) -> None:
# Make a fake user site.
fake_user_site = str(fake_dist.parent)
mocker.patch("pipdeptree._discovery.site.getusersitepackages", Mock(return_value=fake_user_site))

cmd = [sys.executable, "--user-only"]
mocker.patch("pipdeptree._discovery.site.getusersitepackages", Mock(return_value=str(tmp_path)))
mocker.patch("pipdeptree._discovery.sys.argv", cmd)
# Add fake user site directory into a fake sys.path (normal environments will have the user site in sys.path).
fake_sys_path = [*sys.path, fake_user_site]
mocker.patch("pipdeptree._discovery.sys.path", fake_sys_path)

cmd = ["", "--user-only"]
mocker.patch("pipdeptree.__main__.sys.argv", cmd)
main()
out, _ = capfd.readouterr()

out, err = capfd.readouterr()
assert not err
found = {i.split("==")[0] for i in out.splitlines()}
expected = {"bar"}

assert found == expected


def test_user_only_when_in_virtual_env(
tmp_path: Path, mocker: MockerFixture, capfd: pytest.CaptureFixture[str]
) -> None:
# ensures that we follow `pip list` by not outputting anything when --user-only is set and pipdeptree is running in
# a virtual environment

# Create a virtual environment and mock sys.path to point to the venv's site packages.
venv_path = str(tmp_path / "venv")
virtualenv.cli_run([venv_path, "--activators", ""])
venv_site_packages = site.getsitepackages([venv_path])
mocker.patch("pipdeptree._discovery.sys.path", venv_site_packages)
mocker.patch("pipdeptree._discovery.sys.prefix", venv_path)

cmd = ["", "--user-only"]
mocker.patch("pipdeptree.__main__.sys.argv", cmd)
main()

out, err = capfd.readouterr()
assert not err

# Here we expect 1 element because print() adds a newline.
found = out.splitlines()
assert len(found) == 1
assert not found[0]


def test_user_only_when_in_virtual_env_and_system_site_pkgs_enabled(
tmp_path: Path, fake_dist: Path, mocker: MockerFixture, capfd: pytest.CaptureFixture[str]
) -> None:
# ensures that we provide user site metadata when --user-only is set and we're in a virtual env with system site
# packages enabled

# Make a fake user site directory since we don't know what to expect from the real one.
fake_user_site = str(fake_dist.parent)
mocker.patch("pipdeptree._discovery.site.getusersitepackages", Mock(return_value=fake_user_site))

# Create a temporary virtual environment. Add the fake user site to path (since user site packages should normally
# be there).
venv_path = str(tmp_path / "venv")
virtualenv.cli_run([venv_path, "--system-site-packages", "--activators", ""])
venv_site_packages = site.getsitepackages([venv_path])
mock_path = sys.path + venv_site_packages + [fake_user_site]
mocker.patch("pipdeptree._discovery.sys.path", mock_path)
mocker.patch("pipdeptree._discovery.sys.prefix", venv_path)

cmd = ["", "--user-only"]
mocker.patch("pipdeptree.__main__.sys.argv", cmd)
main()

out, err = capfd.readouterr()
assert not err
found = {i.split("==")[0] for i in out.splitlines()}
expected = {"foo"}
expected = {"bar"}

assert found == expected

Expand Down
56 changes: 56 additions & 0 deletions tests/test_non_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import sys
from platform import python_implementation
from typing import TYPE_CHECKING
from unittest.mock import Mock

import pytest
import virtualenv
Expand Down Expand Up @@ -74,6 +75,61 @@ def test_custom_interpreter_with_local_only(
assert found == expected, out


def test_custom_interpreter_with_user_only(
tmp_path: Path, mocker: MockerFixture, capfd: pytest.CaptureFixture[str]
) -> None:
# ensures there is no output when --user-only and --python are passed

venv_path = str(tmp_path / "venv")

result = virtualenv.cli_run([venv_path, "--activators", ""])

cmd = ["", f"--python={result.creator.exe}", "--user-only"]
mocker.patch("pipdeptree.__main__.sys.argv", cmd)
main()
out, err = capfd.readouterr()
assert not err

# Here we expect 1 element because print() adds a newline.
found = out.splitlines()
assert len(found) == 1
assert not found[0]


def test_custom_interpreter_with_user_only_and_system_site_pkgs_enabled(
tmp_path: Path,
fake_dist: Path,
mocker: MockerFixture,
monkeypatch: pytest.MonkeyPatch,
capfd: pytest.CaptureFixture[str],
) -> None:
# ensures that we provide user site metadata when --user-only and --python are passed and the custom interpreter has
# system site packages enabled

# Make a fake user site directory since we don't know what to expect from the real one.
fake_user_site = str(fake_dist.parent)
mocker.patch("pipdeptree._discovery.site.getusersitepackages", Mock(return_value=fake_user_site))

# Create a temporary virtual environment.
venv_path = str(tmp_path / "venv")
result = virtualenv.cli_run([venv_path, "--activators", ""])

# Use $PYTHONPATH to add the fake user site into the custom interpreter's environment so that it will include it in
# its sys.path.
monkeypatch.setenv("PYTHONPATH", str(fake_user_site))

cmd = ["", f"--python={result.creator.exe}", "--user-only"]
mocker.patch("pipdeptree.__main__.sys.argv", cmd)
main()

out, err = capfd.readouterr()
assert not err
found = {i.split("==")[0] for i in out.splitlines()}
expected = {"bar"}

assert found == expected


def test_custom_interpreter_ensure_pythonpath_envar_is_honored(
tmp_path: Path,
mocker: MockerFixture,
Expand Down

0 comments on commit e88356f

Please sign in to comment.