diff --git a/src/picopip.py b/src/picopip.py index c5fce2d..0babb20 100644 --- a/src/picopip.py +++ b/src/picopip.py @@ -17,6 +17,7 @@ import itertools import logging +import os import re import site from importlib.metadata import PathDistribution @@ -62,11 +63,8 @@ def get_site_package_paths( continue if include_system_packages: - # Append system packages at the end, so that venv site-packages take precedence - for system_path in _find_system_packages(venv_path): - if system_path not in seen: - scan_paths.append(system_path) - seen.add(system_path) + _extend_unique(scan_paths, seen, _find_system_packages(venv_path)) + _extend_unique(scan_paths, seen, _find_pythonpath_packages()) return scan_paths @@ -125,6 +123,30 @@ def get_package_version_from_env(venv_path: str, package_name: str) -> Optional[ return None +def _extend_unique(scan_paths: List[Path], seen: set, new_paths: List[Path]) -> None: + """Append paths to scan_paths that are not already in seen.""" + for path in new_paths: + if path not in seen: + scan_paths.append(path) + seen.add(path) + + +def _find_pythonpath_packages() -> List[Path]: + """Return existing directories listed in the PYTHONPATH environment variable. + + PYTHONPATH is read from the process running picopip, which may differ from + the interpreter that will import from the venv. Results are only meaningful + when the two processes share the same PYTHONPATH value. + """ + scan_paths = [] + for entry in os.environ.get("PYTHONPATH", "").split(os.pathsep): + if entry: + pp = Path(entry).resolve() + if pp.exists() and pp.is_dir(): + scan_paths.append(pp) + return scan_paths + + def _find_system_packages(venv_path: str) -> List[Path]: """Return scan paths for system packages if enabled in the venv.""" scan_paths = [] diff --git a/tests/test_packages_discovery.py b/tests/test_packages_discovery.py index 77f8781..b7826c8 100644 --- a/tests/test_packages_discovery.py +++ b/tests/test_packages_discovery.py @@ -1,3 +1,4 @@ +import os import subprocess import tempfile import venv @@ -208,6 +209,92 @@ def test_get_packages_from_env_ignore_system_packages(tmp_path, monkeypatch): assert ("system-pkg", "0.1.0") not in pkgs +def test_get_site_package_paths_includes_pythonpath(fake_venv, tmp_path, monkeypatch): + """PYTHONPATH directories should be included in scan paths.""" + venv, _site = fake_venv + pythonpath_dir = tmp_path / "pythonpath_packages" + pythonpath_dir.mkdir() + monkeypatch.setenv("PYTHONPATH", str(pythonpath_dir)) + paths = get_site_package_paths(str(venv)) + assert pythonpath_dir.resolve() in [p.resolve() for p in paths] + + +def test_get_site_package_paths_excludes_pythonpath_without_system( + fake_venv, tmp_path, monkeypatch +): + """PYTHONPATH should be excluded when include_system_packages is False.""" + venv, _site = fake_venv + pythonpath_dir = tmp_path / "pythonpath_packages" + pythonpath_dir.mkdir() + monkeypatch.setenv("PYTHONPATH", str(pythonpath_dir)) + paths = get_site_package_paths(str(venv), include_system_packages=False) + assert pythonpath_dir.resolve() not in [p.resolve() for p in paths] + + +def test_get_site_package_paths_pythonpath_empty(fake_venv, monkeypatch): + """Empty PYTHONPATH should not cause errors.""" + venv, _site = fake_venv + monkeypatch.setenv("PYTHONPATH", "") + paths = get_site_package_paths(str(venv)) + assert len(paths) >= 1 + + +def test_get_site_package_paths_pythonpath_unset(fake_venv, monkeypatch): + """Unset PYTHONPATH should not cause errors.""" + venv, _site = fake_venv + monkeypatch.delenv("PYTHONPATH", raising=False) + paths = get_site_package_paths(str(venv)) + assert len(paths) >= 1 + + +def test_get_site_package_paths_pythonpath_nonexistent(fake_venv, monkeypatch): + """Nonexistent PYTHONPATH directories should be silently ignored.""" + venv, _site = fake_venv + monkeypatch.setenv("PYTHONPATH", "/nonexistent/path/that/does/not/exist") + paths = get_site_package_paths(str(venv)) + assert not any(str(p) == "/nonexistent/path/that/does/not/exist" for p in paths) + + +def test_get_site_package_paths_pythonpath_multiple(fake_venv, tmp_path, monkeypatch): + """Multiple PYTHONPATH entries should all be included.""" + venv, _site = fake_venv + dir_a = tmp_path / "path_a" + dir_b = tmp_path / "path_b" + dir_a.mkdir() + dir_b.mkdir() + monkeypatch.setenv("PYTHONPATH", os.pathsep.join([str(dir_a), str(dir_b)])) + paths = get_site_package_paths(str(venv)) + resolved = [p.resolve() for p in paths] + assert dir_a.resolve() in resolved + assert dir_b.resolve() in resolved + + +def test_get_packages_from_env_finds_pythonpath_packages( + fake_venv, tmp_path, monkeypatch +): + """Packages in PYTHONPATH directories should be discovered.""" + venv, _site = fake_venv + pythonpath_dir = tmp_path / "pythonpath_packages" + pythonpath_dir.mkdir() + make_dist_info(pythonpath_dir, "external-pkg", "2.0.0") + monkeypatch.setenv("PYTHONPATH", str(pythonpath_dir)) + pkgs = get_packages_from_env(str(venv)) + assert ("external-pkg", "2.0.0") in pkgs + + +def test_get_packages_from_env_ignores_pythonpath_with_ignore_system( + fake_venv, tmp_path, monkeypatch +): + """PYTHONPATH packages should be excluded when ignore_system_packages is True.""" + venv, _site = fake_venv + pythonpath_dir = tmp_path / "pythonpath_packages" + pythonpath_dir.mkdir() + make_dist_info(pythonpath_dir, "external-pkg", "2.0.0") + monkeypatch.setenv("PYTHONPATH", str(pythonpath_dir)) + pkgs = get_packages_from_env(str(venv), ignore_system_packages=True) + assert ("external-pkg", "2.0.0") not in pkgs + + def test_e2e_readme_example(): with tempfile.TemporaryDirectory() as tmpdir: venv.create(tmpdir, with_pip=True)