Skip to content
Draft
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
30 changes: 30 additions & 0 deletions src/poetry/utils/env/site_packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import contextlib
import itertools
import sys

from importlib import metadata
from pathlib import Path
Expand All @@ -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,
Expand Down
135 changes: 135 additions & 0 deletions tests/utils/env/test_env_site_packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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 ``<entry>,,`` (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)
Loading