diff --git a/README.md b/README.md index 2a01306..2b54375 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,34 @@ You can use `get_packages_from_env` to list all installed packages in a Python v - `get_packages_from_env()` 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 `, 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 diff --git a/src/picopip.py b/src/picopip.py index c39273c..2888bbf 100644 --- a/src/picopip.py +++ b/src/picopip.py @@ -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] @@ -71,19 +74,38 @@ 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 ``). + 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 + ) + 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...") for dist_info in itertools.chain( path.glob("*.dist-info"), path.glob("*.egg-info") @@ -91,7 +113,7 @@ def _canonical_name(name: str) -> str: 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( diff --git a/tests/test_packages_discovery.py b/tests/test_packages_discovery.py index b7826c8..6490a35 100644 --- a/tests/test_packages_discovery.py +++ b/tests/test_packages_discovery.py @@ -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)