diff --git a/ruff.toml b/ruff.toml index 403c13f..661aec9 100644 --- a/ruff.toml +++ b/ruff.toml @@ -7,6 +7,9 @@ select = [ ignore = ["COM812", "D203", "D213", "EM101", "EM102", "TRY003", "RUF012"] per-file-ignores = {"tests/*.py" = ["D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107", "UP", "ANN", "S101", "S603"]} +[lint.mccabe] +max-complexity = 12 + [format] quote-style = "double" indent-style = "space" diff --git a/src/picopip.py b/src/picopip.py index b735626..a2db0b5 100644 --- a/src/picopip.py +++ b/src/picopip.py @@ -26,7 +26,9 @@ log = logging.getLogger(__name__) -def get_site_package_paths(venv_path: str) -> List[Path]: +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"): @@ -59,20 +61,30 @@ def get_site_package_paths(venv_path: str) -> List[Path]: ) continue - # 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) + 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) return scan_paths -def get_packages_from_env(venv_path: str) -> List[Tuple[str, str]]: +def get_packages_from_env( + venv_path: str, *, ignore_system_packages: bool = False +) -> List[Tuple[str, str]]: """Return a list of (name, version) for all installed packages in the given venv.""" + + def _canonical_name(name: str) -> str: + """PEP 503 normalization plus dashes as underscores.""" + return re.sub(r"[-_.]+", "-", name).lower().replace("-", "_") + seen = set() packages = [] - for path in get_site_package_paths(venv_path): + for path in get_site_package_paths( + venv_path, include_system_packages=not ignore_system_packages + ): log.debug(f"Scanning {path} for installed packages...") for dist_info in itertools.chain( path.glob("*.dist-info"), path.glob("*.egg-info") @@ -88,7 +100,7 @@ def get_packages_from_env(venv_path: str) -> List[Tuple[str, str]]: dist_info, ) continue - name = raw_name.lower() + name = _canonical_name(raw_name) if name not in seen: seen.add(name) packages.append((raw_name, version)) diff --git a/tests/test_packages_discovery.py b/tests/test_packages_discovery.py index b266382..77f8781 100644 --- a/tests/test_packages_discovery.py +++ b/tests/test_packages_discovery.py @@ -153,6 +153,18 @@ def test_get_packages_from_env_egg_info(fake_venv): assert ("legacy-pkg", "2.1.0") in pkgs +def test_get_packages_from_env_canonical_dedup(fake_venv): + """Packages differing only by dashes/underscores should be deduped.""" + venv, site = fake_venv + make_dist_info(site, "pure-eval", "0.2.2") + extra = (site / "../dupe_extra").absolute() + extra.mkdir(parents=True) + make_dist_info(extra, "pure_eval", "0.2.3") + (site / "dupe_extra.pth").write_text("../dupe_extra\n") + pkgs = get_packages_from_env(str(venv)) + assert pkgs == [("pure-eval", "0.2.2")] + + def test_get_packages_from_env_mixed_formats(fake_venv): """Test that both dist-info and egg-info packages are discovered together.""" venv, site = fake_venv @@ -176,6 +188,26 @@ def test_get_packages_from_env_egg_info_with_pth(fake_venv): assert ("external-legacy", "0.9.5") in pkgs +def test_get_packages_from_env_ignore_system_packages(tmp_path, monkeypatch): + """System packages should be omitted when ignore_system_packages is True.""" + venv_dir = tmp_path / "venv" + site_dir = venv_dir / "lib" / "python3.11" / "site-packages" + site_dir.mkdir(parents=True) + (venv_dir / "pyvenv.cfg").write_text("include-system-site-packages = true\n") + + system_site = tmp_path / "system_site" + system_site.mkdir() + make_dist_info(system_site, "system-pkg", "0.1.0") + + monkeypatch.setattr("picopip.site.getsitepackages", lambda: [str(system_site)]) + + pkgs = get_packages_from_env(str(venv_dir)) + assert ("system-pkg", "0.1.0") in pkgs + + pkgs = get_packages_from_env(str(venv_dir), ignore_system_packages=True) + assert ("system-pkg", "0.1.0") not in pkgs + + def test_e2e_readme_example(): with tempfile.TemporaryDirectory() as tmpdir: venv.create(tmpdir, with_pip=True)