diff --git a/poetry.lock b/poetry.lock index 09b2db1ac09..60be8dc3f16 100644 --- a/poetry.lock +++ b/poetry.lock @@ -326,6 +326,14 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "installer" +version = "0.5.1" +description = "A library for installing Python wheels." +category = "main" +optional = false +python-versions = ">=3.7" + [[package]] name = "jeepney" version = "0.8.0" @@ -972,7 +980,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "2ff2a3b8ed43465e36528d1ac314e24aaecb0b0316b7b777b8011fe691e09e35" +content-hash = "b9bbdf11865d9d75102e8de1c35e898b20a6316bbf62fe7f2e93870afc264f90" [metadata.files] atomicwrites = [ @@ -1229,6 +1237,10 @@ iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] +installer = [ + {file = "installer-0.5.1-py3-none-any.whl", hash = "sha256:1d6c8d916ed82771945b9c813699e6f57424ded970c9d8bf16bbc23e1e826ed3"}, + {file = "installer-0.5.1.tar.gz", hash = "sha256:f970995ec2bb815e2fdaf7977b26b2091e1e386f0f42eafd5ac811953dc5d445"}, +] jeepney = [ {file = "jeepney-0.8.0-py3-none-any.whl", hash = "sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755"}, {file = "jeepney-0.8.0.tar.gz", hash = "sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806"}, diff --git a/pyproject.toml b/pyproject.toml index 871276f9cee..d57657e19ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,7 @@ xattr = { version = "^0.9.7", markers = "sys_platform == 'darwin'" } urllib3 = "^1.26.0" dulwich = "^0.20.44" build = "^0.8.0" +installer = "^0.5.1" [tool.poetry.dev-dependencies] tox = "^3.18" diff --git a/src/poetry/installation/wheel_installer.py b/src/poetry/installation/wheel_installer.py new file mode 100644 index 00000000000..f0c9176b306 --- /dev/null +++ b/src/poetry/installation/wheel_installer.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import os +import platform +import sys + +from pathlib import Path +from typing import TYPE_CHECKING + +from installer.destinations import SchemeDictionaryDestination # type: ignore[import] +from installer.sources import WheelFile # type: ignore[import] + +from poetry import __version__ +from poetry.utils._compat import WINDOWS + + +if TYPE_CHECKING: + from typing import BinaryIO + + from installer.records import RecordEntry # type: ignore[import] + from installer.utils import Scheme # type: ignore[import] + + from poetry.utils.env import Env + + +class WheelDestination(SchemeDictionaryDestination): # type: ignore[misc] + """ """ + + def write_to_fs( + self, + scheme: Scheme, + path: Path | str, + stream: BinaryIO, + is_executable: bool, + ) -> RecordEntry: + from installer.records import Hash + from installer.records import RecordEntry + from installer.utils import copyfileobj_with_hashing + from installer.utils import make_file_executable + + target_path = os.path.join(self.scheme_dict[scheme], path) + if os.path.exists(target_path): + # Contrary to the base library we don't raise an error + # here since it can break namespace packages (like Poetry's) + pass + + parent_folder = os.path.dirname(target_path) + if not os.path.exists(parent_folder): + os.makedirs(parent_folder) + + with open(target_path, "wb") as f: + hash_, size = copyfileobj_with_hashing(stream, f, self.hash_algorithm) + + if is_executable: + make_file_executable(target_path) + + return RecordEntry(path, Hash(self.hash_algorithm, hash_), size) + + def for_source(self, source: WheelFile) -> WheelDestination: + scheme_dict = self.scheme_dict.copy() + + scheme_dict["headers"] = os.path.join( + scheme_dict["headers"], source.distribution + ) + + return self.__class__( + scheme_dict, interpreter=self.interpreter, script_kind=self.script_kind + ) + + +class WheelInstaller: + def __init__(self, env: Env) -> None: + self._env = env + + if not WINDOWS: + script_kind = "posix" + else: + if platform.uname()[4].startswith("arm"): + script_kind = "win-arm64" if sys.maxsize > 2**32 else "win-arm" + else: + script_kind = "win-amd64" if sys.maxsize > 2**32 else "win-ia32" + + schemes = self._env.paths + schemes["headers"] = schemes["include"] + + self._destination = WheelDestination( + schemes, interpreter=self._env.python, script_kind=script_kind + ) + + def install(self, wheel: Path) -> None: + from installer import install # type: ignore[import] + + with WheelFile.open(Path(wheel.as_posix())) as source: + install( + source=source, + destination=self._destination.for_source(source), + # Additional metadata that is generated by the installation tool. + additional_metadata={ + "INSTALLER": f"Poetry {__version__}".encode(), + }, + ) diff --git a/src/poetry/utils/env.py b/src/poetry/utils/env.py index ab645bd7c08..0b81916e794 100644 --- a/src/poetry/utils/env.py +++ b/src/poetry/utils/env.py @@ -58,7 +58,6 @@ from poetry.poetry import Poetry - GET_SYS_TAGS = f""" import importlib.util import json @@ -83,7 +82,6 @@ ) """ - GET_ENVIRONMENT_INFO = """\ import json import os @@ -154,7 +152,6 @@ def _version_nodot(version): print(json.dumps(env)) """ - GET_BASE_PREFIX = """\ import sys @@ -1362,6 +1359,16 @@ def paths(self) -> dict[str, str]: if self._paths is None: self._paths = self.get_paths() + if self.is_venv(): + # We copy pip's logic here for the `include` path + self._paths["include"] = str( + self.path.joinpath( + "include", + "site", + f"python{self.version_info[0]}.{self.version_info[1]}", + ) + ) + return self._paths @property diff --git a/tests/utils/test_env.py b/tests/utils/test_env.py index 32fb5cb0865..f7bb4d44cf4 100644 --- a/tests/utils/test_env.py +++ b/tests/utils/test_env.py @@ -1017,7 +1017,7 @@ def test_create_venv_uses_patch_version_to_detect_compatibility_with_executable( del os.environ["VIRTUAL_ENV"] version = Version.from_parts(*sys.version_info[:3]) - poetry.package.python_versions = f"~{version.major}.{version.minor-1}.0" + poetry.package.python_versions = f"~{version.major}.{version.minor - 1}.0" venv_name = manager.generate_env_name("simple-project", str(poetry.file.parent)) check_output = mocker.patch( @@ -1131,6 +1131,7 @@ def test_system_env_has_correct_paths(): assert paths.get("platlib") is not None assert paths.get("scripts") is not None assert env.site_packages.path == Path(paths["purelib"]) + assert paths["include"] is not None @pytest.mark.parametrize( @@ -1152,6 +1153,11 @@ def test_venv_has_correct_paths(tmp_venv: VirtualEnv): assert paths.get("platlib") is not None assert paths.get("scripts") is not None assert tmp_venv.site_packages.path == Path(paths["purelib"]) + assert paths["include"] == str( + tmp_venv.path.joinpath( + f"include/site/python{tmp_venv.version_info[0]}.{tmp_venv.version_info[1]}" + ) + ) def test_env_system_packages(tmp_path: Path, poetry: Poetry):