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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,34 @@ You can use `get_packages_from_env` to list all installed packages in a Python v
- `get_packages_from_env(<venv_path>)` returns a list of `(name, version)` tuples for all installed packages in the given virtual environment.
- You can use any venv path, and the function will find all packages, including those installed via pip.

### Scan a target directory (`pip install --target`)

By default `get_packages_from_env` expects a full virtual environment layout
(`lib/pythonX.Y/site-packages`, `.pth` expansion, system and `PYTHONPATH`
discovery). If you instead installed packages into a flat directory with
`pip install --target <dir>`, pass `path_as_target=True` to scan that directory
directly.

```sh
pip install --target ./vendor requests
```

```python
>>> from picopip import get_packages_from_env
>>>
>>> pkgs = get_packages_from_env("./vendor", path_as_target=True)
>>> print(pkgs)
[('certifi', '2025.4.26'), ('charset-normalizer', '3.4.2'), ('idna', '3.10'),
('requests', '2.32.3'), ('urllib3', '2.4.0')]
```

With `path_as_target=True`:

- `venv_path` is treated as the site-packages directory itself, so no
`lib/pythonX.Y/site-packages` structure is required.
- `.pth` files are not expanded and `PYTHONPATH` is not read.
- `ignore_system_packages` is ignored (system packages are never scanned).

### Get version of a package

If you only need the version of a specific package, you can get it
Expand Down
48 changes: 35 additions & 13 deletions src/picopip.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,16 @@ def get_site_package_paths(
venv_path: str, *, include_system_packages: bool = True
) -> List[Path]:
"""Return all directories where packages might be installed for the given venv."""
for version_dir in (Path(venv_path) / "lib").iterdir():
if version_dir.name.startswith("python"):
site_packages = version_dir / "site-packages"
break
else:
msg = "Cannot locate site-packages in lib/pythonX.Y"
raise RuntimeError(msg)
matches = sorted((Path(venv_path) / "lib").glob("python*/site-packages"))
if not matches:
raise NotADirectoryError(
f"Cannot locate site-packages in {venv_path}/lib/python*/site-packages"
)
if len(matches) > 1:
raise RuntimeError(
f"Multiple python site-packages found under {venv_path}/lib: {matches}"
)
site_packages = matches[0]

seen = {site_packages}
scan_paths = [site_packages]
Expand Down Expand Up @@ -71,27 +74,46 @@ def get_site_package_paths(


def get_packages_from_env(
venv_path: str, *, ignore_system_packages: bool = False
venv_path: str,
*,
ignore_system_packages: bool = False,
path_as_target: bool = False,
) -> List[Tuple[str, str]]:
"""Return a list of (name, version) for all installed packages in the given venv."""
"""Return a list of (name, version) for all installed packages in the given venv.

:param str venv_path: Path to a virtual environment, or to a flat directory
of installed packages when ``path_as_target`` is True.
:param bool ignore_system_packages: Exclude system site-packages and
PYTHONPATH entries. Ignored when ``path_as_target`` is True.
:param bool path_as_target: Treat ``venv_path`` as the site-packages
directory itself (e.g. the output of ``pip install --target <dir>``).
Skips venv layout, ``.pth`` expansion, system, and PYTHONPATH discovery.
"""

def _canonical_name(name: str) -> str:
"""PEP 503 normalization plus dashes as underscores."""
return re.sub(r"[-_.]+", "-", name).lower().replace("-", "_")

if path_as_target:
scan_paths = [Path(venv_path)]
else:
scan_paths = get_site_package_paths(
venv_path, include_system_packages=not ignore_system_packages
)
Comment thread
amol- marked this conversation as resolved.

seen = set()
packages = []
for path in get_site_package_paths(
venv_path, include_system_packages=not ignore_system_packages
):
for path in scan_paths:
if not path.is_dir():
raise NotADirectoryError(f"Not a directory: {path}")
log.debug(f"Scanning {path} for installed packages...")
Comment thread
amol- marked this conversation as resolved.
for dist_info in itertools.chain(
path.glob("*.dist-info"), path.glob("*.egg-info")
):
log.debug(f"Found distribution info: {dist_info}")
try:
dist = PathDistribution(dist_info)
raw_name = dist.metadata["Name"]
raw_name = dist.metadata.get("Name")
version = dist.version
if not raw_name:
log.error(
Expand Down
68 changes: 68 additions & 0 deletions tests/test_packages_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,74 @@ def test_get_packages_from_env_ignores_pythonpath_with_ignore_system(
assert ("external-pkg", "2.0.0") not in pkgs


def test_get_packages_from_env_path_as_target(tmp_path):
"""path_as_target scans the given directory for .dist-info and .egg-info."""
target = tmp_path / "target"
target.mkdir()
make_dist_info(target, "foo", "1.2.3")
make_egg_info(target, "legacy", "0.9.0")
pkgs = get_packages_from_env(str(target), path_as_target=True)
assert pkgs == [("foo", "1.2.3"), ("legacy", "0.9.0")]


def test_get_packages_from_env_path_as_target_skips_venv_layout(tmp_path):
"""path_as_target must not require a lib/pythonX.Y/site-packages structure."""
target = tmp_path / "flat_target"
target.mkdir()
# No lib/pythonX.Y/site-packages here; the default mode would raise.
make_dist_info(target, "foo", "1.0.0")
pkgs = get_packages_from_env(str(target), path_as_target=True)
assert pkgs == [("foo", "1.0.0")]


def test_get_packages_from_env_path_as_target_ignores_pth_and_pythonpath(
tmp_path, monkeypatch
):
"""path_as_target must not expand .pth files or read PYTHONPATH."""
target = tmp_path / "target"
target.mkdir()
make_dist_info(target, "foo", "1.0.0")

# A sibling directory referenced from a .pth file must be ignored.
extra = tmp_path / "extra"
extra.mkdir()
make_dist_info(extra, "from-pth", "9.9.9")
(target / "extra.pth").write_text("../extra\n")

# PYTHONPATH must also be ignored.
pythonpath_dir = tmp_path / "pp"
pythonpath_dir.mkdir()
make_dist_info(pythonpath_dir, "from-pythonpath", "1.1.1")
monkeypatch.setenv("PYTHONPATH", str(pythonpath_dir))

pkgs = get_packages_from_env(str(target), path_as_target=True)
assert pkgs == [("foo", "1.0.0")]


def test_get_site_package_paths_missing_layout_raises(tmp_path):
"""An invalid venv (no lib/pythonX.Y/site-packages) must fail loudly."""
bogus = tmp_path / "not_a_venv"
bogus.mkdir()
with pytest.raises(NotADirectoryError):
get_site_package_paths(str(bogus))


def test_get_site_package_paths_multiple_versions_raises(tmp_path):
"""A venv with more than one python site-packages is ambiguous."""
venv = tmp_path / "venv"
(venv / "lib" / "python3.11" / "site-packages").mkdir(parents=True)
(venv / "lib" / "python3.12" / "site-packages").mkdir(parents=True)
with pytest.raises(RuntimeError):
get_site_package_paths(str(venv))


def test_get_packages_from_env_path_as_target_missing_dir_raises(tmp_path):
"""A nonexistent target path must raise instead of silently returning []."""
missing = tmp_path / "does_not_exist"
with pytest.raises(NotADirectoryError):
get_packages_from_env(str(missing), path_as_target=True)


def test_e2e_readme_example():
with tempfile.TemporaryDirectory() as tmpdir:
venv.create(tmpdir, with_pip=True)
Expand Down
Loading