diff --git a/src/poetry/utils/env/site_packages.py b/src/poetry/utils/env/site_packages.py index 74337117b32..3d70d8720bf 100644 --- a/src/poetry/utils/env/site_packages.py +++ b/src/poetry/utils/env/site_packages.py @@ -2,6 +2,7 @@ import contextlib import itertools +import sys from importlib import metadata from pathlib import Path @@ -10,15 +11,44 @@ from typing import Literal from typing import overload +from poetry.utils._compat import WINDOWS from poetry.utils.helpers import is_dir_writable from poetry.utils.helpers import paths_csv from poetry.utils.helpers import remove_directory if TYPE_CHECKING: + import os + from collections.abc import Iterable +if WINDOWS and sys.version_info < (3, 12): + # See https://github.com/python/importlib_metadata/issues/535 + # Although Poetry does not depend on importlib-metadata and thus does not use + # importlib-metadata directly, it is a transitive dependency at the moment + # and replaces importlib.metadata in sys.meta_path so that distributions obtained via + # ``SitePackages.distributions()`` are of the type ``importlib_metadata.Distribution`` + # instead of ``importlib.metadata.Distribution``. + try: + import importlib_metadata + + from importlib_metadata import SimplePath + except ImportError: + pass + else: + if ( + path_dist_class := getattr(importlib_metadata, "PathDistribution", None) + ) and hasattr(path_dist_class, "locate_file"): + + def _patched_locate_file( + self: importlib_metadata.PathDistribution, path: str | os.PathLike[str] + ) -> SimplePath: + return self._path.parent / str(path) + + path_dist_class.locate_file = _patched_locate_file + + class SitePackages: def __init__( self, diff --git a/tests/utils/env/test_env_site_packages.py b/tests/utils/env/test_env_site_packages.py index 7bf24cc0680..d4fd5cbfaf4 100644 --- a/tests/utils/env/test_env_site_packages.py +++ b/tests/utils/env/test_env_site_packages.py @@ -5,10 +5,15 @@ from pathlib import Path from typing import TYPE_CHECKING +import pytest + +from poetry.utils._compat import WINDOWS from poetry.utils.env import SitePackages if TYPE_CHECKING: + from importlib import metadata + from pytest_mock import MockerFixture @@ -47,3 +52,133 @@ def test_env_site_select_first(tmp_path: Path) -> None: assert not (fallback / "hello.txt").exists() assert len(site_packages.find(Path("hello.txt"))) == 1 + + +class TestDistributionFiles: + """Regression tests for importlib.metadata.Distribution.files. + + Poetry relies on Distribution.files to contain all files, as listed in its RECORD. + + Distribution.files is known to be unreliable on Windows with Python 3.10/3.11 + when RECORD contains absolute paths + -- see https://github.com/python/importlib_metadata/issues/535. + """ + + @staticmethod + def _build_distribution( + site_packages: Path, record_entries: list[str] + ) -> metadata.Distribution: + """Create a ``foo-1.0.dist-info/RECORD`` under ``site_packages`` listing the given raw + RECORD path entries and return the corresponding distribution. + + The distribution is obtained via ``SitePackages.distributions()`` -- i.e. through the + same discovery path Poetry actually uses -- rather than constructed directly. That way an + ``importlib_metadata`` Distribution can be returned instead of an ``importlib.metadata`` + one if the backport is installed, which is exactly the scenario this guards against. + + Each entry is written as ``,,`` (no hash/size). The caller must materialize the + referenced files on disk first -- ``Distribution.files`` filters out entries whose target + does not exist. + """ + dist_info = site_packages / "foo-1.0.dist-info" + dist_info.mkdir(parents=True, exist_ok=True) + record_lines = [*record_entries, "foo-1.0.dist-info/RECORD"] + (dist_info / "RECORD").write_text( + "".join(f"{entry},,\n" for entry in record_lines), encoding="utf-8" + ) + distribution = SitePackages(site_packages).find_distribution("foo") + assert distribution is not None + return distribution + + @staticmethod + def _resolved_files(distribution: metadata.Distribution) -> set[Path]: + """Resolve every entry of ``Distribution.files`` through ``locate_file`` to an absolute + path, mirroring how Poetry consumes them.""" + resolved = set() + for file in distribution.files or []: + located = distribution.locate_file(file) + assert isinstance(located, Path) + resolved.add(located.resolve()) + return resolved + + def test_distribution_files_relative_paths(self, tmp_path: Path) -> None: + site_packages = tmp_path / "venv" / "site-packages" + relative_entries = ["foo/__init__.py", "foo/sub/bar.py"] + + expected = set() + for entry in relative_entries: + target = site_packages / Path(entry) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("", encoding="utf-8") + expected.add(target.resolve()) + + distribution = self._build_distribution(site_packages, relative_entries) + + assert expected <= self._resolved_files(distribution) + + def test_distribution_files_absolute_path(self, tmp_path: Path) -> None: + site_packages = tmp_path / "venv" / "site-packages" + external = tmp_path / "external" / "data.bin" + external.parent.mkdir(parents=True) + external.write_text("", encoding="utf-8") + + distribution = self._build_distribution(site_packages, [str(external)]) + + assert external.resolve() in self._resolved_files(distribution) + + def test_distribution_files_script_relative_path(self, tmp_path: Path) -> None: + venv = tmp_path / "venv" + site_packages = venv / "site-packages" + scripts = venv / ("Scripts" if WINDOWS else "bin") + script = scripts / "foo" + scripts.mkdir(parents=True) + script.write_text("", encoding="utf-8") + + # Console scripts are recorded relative to site-packages with a ".." traversal. + distribution = self._build_distribution( + site_packages, [f"../{scripts.name}/foo"] + ) + + assert script.resolve() in self._resolved_files(distribution) + + def test_distribution_files_script_absolute_path(self, tmp_path: Path) -> None: + venv = tmp_path / "venv" + site_packages = venv / "site-packages" + scripts = venv / ("Scripts" if WINDOWS else "bin") + script = scripts / "foo" + scripts.mkdir(parents=True) + script.write_text("", encoding="utf-8") + + distribution = self._build_distribution(site_packages, [str(script)]) + + assert script.resolve() in self._resolved_files(distribution) + + @pytest.mark.skipif(not WINDOWS, reason="Windows path separators") + @pytest.mark.parametrize("sep", ["/", "\\"]) + @pytest.mark.parametrize("kind", ["package", "script", "absolute"]) + def test_distribution_files_windows_separators( + self, tmp_path: Path, kind: str, sep: str + ) -> None: + venv = tmp_path / "venv" + site_packages = venv / "site-packages" + + if kind == "package": + target = site_packages / "foo" / "bar.py" + target.parent.mkdir(parents=True) + target.write_text("", encoding="utf-8") + entry = sep.join(("foo", "bar.py")) + elif kind == "absolute": + target = venv / "Scripts" / "foo.exe" + target.parent.mkdir(parents=True) + target.write_text("", encoding="utf-8") + # Same absolute path spelled with native backslashes or with forward slashes. + entry = str(target) if sep == "\\" else target.as_posix() + else: # script: relative ".." traversal into the Scripts directory + target = venv / "Scripts" / "foo.exe" + target.parent.mkdir(parents=True) + target.write_text("", encoding="utf-8") + entry = sep.join(("..", "Scripts", "foo.exe")) + + distribution = self._build_distribution(site_packages, [entry]) + + assert target.resolve() in self._resolved_files(distribution)