Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions src/picopip.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import itertools
import logging
import os
import re
import site
from importlib.metadata import PathDistribution
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Comment on lines +145 to +146
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Latest docs says...

In addition to normal directories, individual PYTHONPATH entries may refer to zipfiles containing pure Python modules (in either source or compiled form). Extension modules cannot be imported from zipfiles.

Do we need to support zipfiles here?

https://docs.python.org/3/using/cmdline.html#environment-variables

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

potentially - though I'd prefer to follow-up w/ a new issue and get this merged, we just tested in their dev environment and the patch worked.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#7

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 = []
Expand Down
87 changes: 87 additions & 0 deletions tests/test_packages_discovery.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
import subprocess
import tempfile
import venv
Expand Down Expand Up @@ -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)
Expand Down
Loading