diff --git a/src/poetry/inspection/info.py b/src/poetry/inspection/info.py index 32575c4bd99..8ddd0455e5d 100644 --- a/src/poetry/inspection/info.py +++ b/src/poetry/inspection/info.py @@ -4,6 +4,7 @@ import functools import glob import logging +import tempfile from pathlib import Path from typing import TYPE_CHECKING @@ -13,7 +14,7 @@ import pkginfo -from poetry.core.factory import Factory +from build import BuildBackendException from poetry.core.packages.dependency import Dependency from poetry.core.packages.package import Package from poetry.core.pyproject.toml import PyProjectTOML @@ -22,9 +23,9 @@ from poetry.core.version.markers import InvalidMarker from poetry.core.version.requirements import InvalidRequirement -from poetry.utils.env import EnvCommandError -from poetry.utils.env import ephemeral_environment +from poetry.factory import Factory from poetry.utils.helpers import extractall +from poetry.utils.isolated_build import isolated_builder from poetry.utils.setup_reader import SetupReader @@ -38,25 +39,6 @@ logger = logging.getLogger(__name__) -PEP517_META_BUILD = """\ -import build -import build.env -import pyproject_hooks - -source = '{source}' -dest = '{dest}' - -with build.env.DefaultIsolatedEnv() as env: - builder = build.ProjectBuilder.from_isolated_env( - env, source, runner=pyproject_hooks.quiet_subprocess_runner - ) - env.install(builder.build_system_requires) - env.install(builder.get_requires_for_build('wheel')) - builder.metadata_path(dest) -""" - -PEP517_META_BUILD_DEPS = ["build==1.1.1", "pyproject_hooks==1.0.0"] - class PackageInfoError(ValueError): def __init__(self, path: Path, *reasons: BaseException | str) -> None: @@ -577,28 +559,15 @@ def get_pep517_metadata(path: Path) -> PackageInfo: if all(x is not None for x in (info.version, info.name, info.requires_dist)): return info - with ephemeral_environment( - flags={"no-pip": False, "no-setuptools": True, "no-wheel": True} - ) as venv: - # TODO: cache PEP 517 build environment corresponding to each project venv - dest_dir = venv.path.parent / "dist" - dest_dir.mkdir() + with tempfile.TemporaryDirectory() as dist: + try: + dest = Path(dist) - pep517_meta_build_script = PEP517_META_BUILD.format( - source=path.as_posix(), dest=dest_dir.as_posix() - ) + with isolated_builder(path, "wheel") as builder: + builder.metadata_path(dest) - try: - venv.run_pip( - "install", - "--disable-pip-version-check", - "--ignore-installed", - "--no-input", - *PEP517_META_BUILD_DEPS, - ) - venv.run_python_script(pep517_meta_build_script) - info = PackageInfo.from_metadata_directory(dest_dir) - except EnvCommandError as e: + info = PackageInfo.from_metadata_directory(dest) + except BuildBackendException as e: logger.debug("PEP517 build failed: %s", e) raise PackageInfoError(path, e, "PEP517 build failed") diff --git a/src/poetry/installation/chef.py b/src/poetry/installation/chef.py index 5f847f64e13..79225c085a0 100644 --- a/src/poetry/installation/chef.py +++ b/src/poetry/installation/chef.py @@ -1,27 +1,20 @@ from __future__ import annotations -import os import tempfile -from contextlib import redirect_stdout -from io import StringIO from pathlib import Path from typing import TYPE_CHECKING from build import BuildBackendException -from build import ProjectBuilder -from build.env import IsolatedEnv as BaseIsolatedEnv from poetry.core.utils.helpers import temporary_directory -from pyproject_hooks import quiet_subprocess_runner # type: ignore[import-untyped] from poetry.utils._compat import decode -from poetry.utils.env import ephemeral_environment from poetry.utils.helpers import extractall +from poetry.utils.isolated_build import IsolatedBuildError +from poetry.utils.isolated_build import isolated_builder if TYPE_CHECKING: - from collections.abc import Collection - from poetry.repositories import RepositoryPool from poetry.utils.cache import ArtifactCache from poetry.utils.env import Env @@ -30,78 +23,6 @@ class ChefError(Exception): ... -class ChefBuildError(ChefError): ... - - -class ChefInstallError(ChefError): - def __init__(self, requirements: Collection[str], output: str, error: str) -> None: - message = "\n\n".join( - ( - f"Failed to install {', '.join(requirements)}.", - f"Output:\n{output}", - f"Error:\n{error}", - ) - ) - super().__init__(message) - self._requirements = requirements - - @property - def requirements(self) -> Collection[str]: - return self._requirements - - -class IsolatedEnv(BaseIsolatedEnv): - def __init__(self, env: Env, pool: RepositoryPool) -> None: - self._env = env - self._pool = pool - - @property - def python_executable(self) -> str: - return str(self._env.python) - - def make_extra_environ(self) -> dict[str, str]: - path = os.environ.get("PATH") - scripts_dir = str(self._env._bin_dir) - return { - "PATH": ( - os.pathsep.join([scripts_dir, path]) - if path is not None - else scripts_dir - ) - } - - def install(self, requirements: Collection[str]) -> None: - from cleo.io.buffered_io import BufferedIO - from poetry.core.packages.dependency import Dependency - from poetry.core.packages.project_package import ProjectPackage - - from poetry.config.config import Config - from poetry.installation.installer import Installer - from poetry.packages.locker import Locker - from poetry.repositories.installed_repository import InstalledRepository - - # We build Poetry dependencies from the requirements - package = ProjectPackage("__root__", "0.0.0") - package.python_versions = ".".join(str(v) for v in self._env.version_info[:3]) - for requirement in requirements: - dependency = Dependency.create_from_pep_508(requirement) - package.add_dependency(dependency) - - io = BufferedIO() - installer = Installer( - io, - self._env, - package, - Locker(self._env.path.joinpath("poetry.lock"), {}), - self._pool, - Config.create(), - InstalledRepository.load(self._env), - ) - installer.update(True) - if installer.run() != 0: - raise ChefInstallError(requirements, io.fetch_output(), io.fetch_error()) - - class Chef: def __init__( self, artifact_cache: ArtifactCache, env: Env, pool: RepositoryPool @@ -127,46 +48,36 @@ def _prepare( ) -> Path: from subprocess import CalledProcessError - with ephemeral_environment( - self._env.python, - flags={"no-pip": True, "no-setuptools": True, "no-wheel": True}, - ) as venv: - env = IsolatedEnv(venv, self._pool) - builder = ProjectBuilder.from_isolated_env( - env, directory, runner=quiet_subprocess_runner - ) - env.install(builder.build_system_requires) - - stdout = StringIO() - error: Exception | None = None - try: - with redirect_stdout(stdout): - dist_format = "wheel" if not editable else "editable" - env.install( - builder.build_system_requires - | builder.get_requires_for_build(dist_format) - ) - path = Path( - builder.build( - dist_format, - destination.as_posix(), - ) + distribution = "wheel" if not editable else "editable" + error: Exception | None = None + + try: + with isolated_builder( + source=directory, + distribution=distribution, + python_executable=self._env.python, + pool=self._pool, + ) as builder: + return Path( + builder.build( + distribution, + destination.as_posix(), ) - except BuildBackendException as e: - message_parts = [str(e)] - if isinstance(e.exception, CalledProcessError): - text = e.exception.stderr or e.exception.stdout - if text is not None: - message_parts.append(decode(text)) - else: - message_parts.append(str(e.exception)) + ) + except BuildBackendException as e: + message_parts = [str(e)] - error = ChefBuildError("\n\n".join(message_parts)) + if isinstance(e.exception, CalledProcessError): + text = e.exception.stderr or e.exception.stdout + if text is not None: + message_parts.append(decode(text)) + else: + message_parts.append(str(e.exception)) - if error is not None: - raise error from None + error = IsolatedBuildError("\n\n".join(message_parts)) - return path + if error is not None: + raise error from None def _prepare_sdist(self, archive: Path, destination: Path | None = None) -> Path: from poetry.core.packages.utils.link import Link diff --git a/src/poetry/installation/executor.py b/src/poetry/installation/executor.py index 84af089e445..b9fb149c4ed 100644 --- a/src/poetry/installation/executor.py +++ b/src/poetry/installation/executor.py @@ -18,8 +18,6 @@ from poetry.core.packages.utils.link import Link from poetry.installation.chef import Chef -from poetry.installation.chef import ChefBuildError -from poetry.installation.chef import ChefInstallError from poetry.installation.chooser import Chooser from poetry.installation.operations import Install from poetry.installation.operations import Uninstall @@ -34,6 +32,8 @@ from poetry.utils.helpers import get_highest_priority_hash_type from poetry.utils.helpers import pluralize from poetry.utils.helpers import remove_directory +from poetry.utils.isolated_build import IsolatedBuildError +from poetry.utils.isolated_build import IsolatedBuildInstallError from poetry.utils.pip import pip_install @@ -310,7 +310,7 @@ def _execute_operation(self, operation: Operation) -> None: trace = ExceptionTrace(e) trace.render(io) pkg = operation.package - if isinstance(e, ChefBuildError): + if isinstance(e, IsolatedBuildError): pip_command = "pip wheel --no-cache-dir --use-pep517" if pkg.develop: requirement = pkg.source_url @@ -328,7 +328,7 @@ def _execute_operation(self, operation: Operation) -> None: f" running '{pip_command} \"{requirement}\"'." "" ) - elif isinstance(e, ChefInstallError): + elif isinstance(e, IsolatedBuildInstallError): message = ( "" "Cannot install build-system.requires" diff --git a/src/poetry/utils/isolated_build.py b/src/poetry/utils/isolated_build.py new file mode 100644 index 00000000000..a58e1c61c3f --- /dev/null +++ b/src/poetry/utils/isolated_build.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import os + +from contextlib import contextmanager +from contextlib import redirect_stdout +from io import StringIO +from typing import TYPE_CHECKING +from typing import Collection +from typing import Iterator + +from build.env import IsolatedEnv as BaseIsolatedEnv + +from poetry.utils.env import Env +from poetry.utils.env import EnvManager +from poetry.utils.env import ephemeral_environment + + +if TYPE_CHECKING: + from pathlib import Path + + from build import ProjectBuilder + + from poetry.repositories import RepositoryPool + + +class IsolatedBuildBaseError(Exception): ... + + +class IsolatedBuildError(IsolatedBuildBaseError): ... + + +class IsolatedBuildInstallError(IsolatedBuildBaseError): + def __init__(self, requirements: Collection[str], output: str, error: str) -> None: + message = "\n\n".join( + ( + f"Failed to install {', '.join(requirements)}.", + f"Output:\n{output}", + f"Error:\n{error}", + ) + ) + super().__init__(message) + self._requirements = requirements + + @property + def requirements(self) -> Collection[str]: + return self._requirements + + +class IsolatedEnv(BaseIsolatedEnv): + def __init__(self, env: Env, pool: RepositoryPool) -> None: + self._env = env + self._pool = pool + + @property + def python_executable(self) -> str: + return str(self._env.python) + + def make_extra_environ(self) -> dict[str, str]: + path = os.environ.get("PATH") + scripts_dir = str(self._env._bin_dir) + return { + "PATH": ( + os.pathsep.join([scripts_dir, path]) + if path is not None + else scripts_dir + ) + } + + def install(self, requirements: Collection[str]) -> None: + from cleo.io.buffered_io import BufferedIO + from poetry.core.packages.dependency import Dependency + from poetry.core.packages.project_package import ProjectPackage + + from poetry.config.config import Config + from poetry.installation.installer import Installer + from poetry.packages.locker import Locker + from poetry.repositories.installed_repository import InstalledRepository + + # We build Poetry dependencies from the requirements + package = ProjectPackage("__root__", "0.0.0") + package.python_versions = ".".join(str(v) for v in self._env.version_info[:3]) + + for requirement in requirements: + dependency = Dependency.create_from_pep_508(requirement) + package.add_dependency(dependency) + + io = BufferedIO() + + installer = Installer( + io, + self._env, + package, + Locker(self._env.path.joinpath("poetry.lock"), {}), + self._pool, + Config.create(), + InstalledRepository.load(self._env), + ) + installer.update(True) + + if installer.run() != 0: + raise IsolatedBuildInstallError( + requirements, io.fetch_output(), io.fetch_error() + ) + + +@contextmanager +def isolated_builder( + source: Path, + distribution: str = "wheel", + python_executable: Path | None = None, + pool: RepositoryPool | None = None, +) -> Iterator[ProjectBuilder]: + from build import ProjectBuilder + from pyproject_hooks import quiet_subprocess_runner # type: ignore[import-untyped] + + from poetry.factory import Factory + + # we recreate the project's Poetry instance in order to retrieve the correct repository pool + # when a pool is not provided + pool = pool or Factory().create_poetry().pool + + python_executable = ( + python_executable or EnvManager.get_system_env(naive=True).python + ) + + with ephemeral_environment( + executable=python_executable, + flags={"no-pip": True, "no-setuptools": True, "no-wheel": True}, + ) as venv: + env = IsolatedEnv(venv, pool) + stdout = StringIO() + + builder = ProjectBuilder.from_isolated_env( + env, source, runner=quiet_subprocess_runner + ) + + with redirect_stdout(stdout): + env.install(builder.build_system_requires) + + # we repeat the build system requirements to avoid poetry installer from removing them + env.install( + builder.build_system_requires + | builder.get_requires_for_build(distribution) + ) + + yield builder diff --git a/tests/inspection/test_info.py b/tests/inspection/test_info.py index b986043924e..32962ff7e52 100644 --- a/tests/inspection/test_info.py +++ b/tests/inspection/test_info.py @@ -4,15 +4,17 @@ from subprocess import CalledProcessError from typing import TYPE_CHECKING +from typing import Iterator from zipfile import ZipFile import pytest +from build import BuildBackendException +from build import ProjectBuilder from packaging.metadata import parse_email from poetry.inspection.info import PackageInfo from poetry.inspection.info import PackageInfoError -from poetry.utils.env import EnvCommandError from poetry.utils.env import VirtualEnv @@ -23,6 +25,7 @@ from pytest_mock import MockerFixture from tests.types import FixtureDirGetter + from tests.types import SetProjectContext @pytest.fixture(autouse=True) @@ -133,6 +136,12 @@ def demo_setup_complex_calls_script( return source_dir +@pytest.fixture(autouse=True) +def use_project_context(set_project_context: SetProjectContext) -> Iterator[None]: + with set_project_context("sample_project"): + yield + + def demo_check_info(info: PackageInfo, requires_dist: set[str] | None = None) -> None: assert info.name == "demo" assert info.version == "0.1.0" @@ -296,9 +305,9 @@ def test_info_setup_complex_pep517_error( mocker: MockerFixture, demo_setup_complex: Path ) -> None: mocker.patch( - "poetry.utils.env.VirtualEnv.run", + "build.ProjectBuilder.from_isolated_env", autospec=True, - side_effect=EnvCommandError(CalledProcessError(1, "mock", output="mock")), + side_effect=BuildBackendException(CalledProcessError(1, "mock", output="mock")), ) with pytest.raises(PackageInfoError): @@ -343,7 +352,7 @@ def test_info_setup_missing_mandatory_should_trigger_pep517( setup_py = source_dir / "setup.py" setup_py.write_text(setup) - spy = mocker.spy(VirtualEnv, "run") + spy = mocker.spy(ProjectBuilder, "from_isolated_env") _ = PackageInfo.from_directory(source_dir) assert spy.call_count == 1 diff --git a/tests/installation/test_chef.py b/tests/installation/test_chef.py index 2925d387935..62504e1e36d 100644 --- a/tests/installation/test_chef.py +++ b/tests/installation/test_chef.py @@ -2,7 +2,6 @@ import os import shutil -import sys import tempfile from pathlib import Path @@ -16,18 +15,11 @@ from poetry.factory import Factory from poetry.installation.chef import Chef -from poetry.installation.chef import ChefInstallError -from poetry.installation.chef import IsolatedEnv -from poetry.puzzle.exceptions import SolverProblemError -from poetry.puzzle.provider import IncompatibleConstraintsError from poetry.repositories import RepositoryPool from poetry.utils.env import EnvManager -from poetry.utils.env import ephemeral_environment if TYPE_CHECKING: - from collections.abc import Collection - from pytest_mock import MockerFixture from poetry.repositories.pypi_repository import PyPiRepository @@ -50,41 +42,6 @@ def setup(mocker: MockerFixture, pool: RepositoryPool) -> None: mocker.patch.object(Factory, "create_pool", return_value=pool) -def test_isolated_env_install_success(pool: RepositoryPool) -> None: - with ephemeral_environment(Path(sys.executable)) as venv: - env = IsolatedEnv(venv, pool) - assert "poetry-core" not in venv.run("pip", "freeze") - env.install({"poetry-core"}) - assert "poetry-core" in venv.run("pip", "freeze") - - -@pytest.mark.parametrize( - ("requirements", "exception"), - [ - ({"poetry-core==1.5.0", "poetry-core==1.6.0"}, IncompatibleConstraintsError), - ({"black==19.10b0", "attrs==17.4.0"}, SolverProblemError), - ], -) -def test_isolated_env_install_error( - requirements: Collection[str], exception: type[Exception], pool: RepositoryPool -) -> None: - with ephemeral_environment(Path(sys.executable)) as venv: - env = IsolatedEnv(venv, pool) - with pytest.raises(exception): - env.install(requirements) - - -def test_isolated_env_install_failure( - pool: RepositoryPool, mocker: MockerFixture -) -> None: - mocker.patch("poetry.installation.installer.Installer.run", return_value=1) - with ephemeral_environment(Path(sys.executable)) as venv: - env = IsolatedEnv(venv, pool) - with pytest.raises(ChefInstallError) as e: - env.install({"a", "b>1"}) - assert e.value.requirements == {"a", "b>1"} - - def test_prepare_sdist( config: Config, config_cache_dir: Path, diff --git a/tests/installation/test_executor.py b/tests/installation/test_executor.py index b862d3f5141..1be9c22fbd7 100644 --- a/tests/installation/test_executor.py +++ b/tests/installation/test_executor.py @@ -1263,7 +1263,7 @@ def test_build_backend_errors_are_reported_correctly_if_caused_by_subprocess( - Installing {package_name} ({package_version} {package_url}) - ChefBuildError + IsolatedBuildError hide the original error \ @@ -1409,7 +1409,7 @@ def test_build_system_requires_install_failure( - Installing {package_name} ({package_version} {package_url}) - ChefInstallError + IsolatedBuildInstallError Failed to install poetry-core>=1.1.0a7. \ @@ -1426,6 +1426,7 @@ def test_build_system_requires_install_failure( mocker.stopall() # to get real output output = io.fetch_output().strip() + print(output) assert output.startswith(expected_start) assert output.endswith(expected_end) diff --git a/tests/utils/test_isolated_build.py b/tests/utils/test_isolated_build.py new file mode 100644 index 00000000000..bd5e2ab3c19 --- /dev/null +++ b/tests/utils/test_isolated_build.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import sys + +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from poetry.factory import Factory +from poetry.puzzle.exceptions import SolverProblemError +from poetry.puzzle.provider import IncompatibleConstraintsError +from poetry.repositories import RepositoryPool +from poetry.repositories.installed_repository import InstalledRepository +from poetry.utils.env import ephemeral_environment +from poetry.utils.isolated_build import IsolatedBuildInstallError +from poetry.utils.isolated_build import IsolatedEnv +from tests.helpers import get_dependency + + +if TYPE_CHECKING: + from collections.abc import Collection + + from pytest_mock import MockerFixture + + from poetry.repositories.pypi_repository import PyPiRepository + + +@pytest.fixture() +def pool(pypi_repository: PyPiRepository) -> RepositoryPool: + pool = RepositoryPool() + + pool.add_repository(pypi_repository) + + return pool + + +@pytest.fixture(autouse=True) +def setup(mocker: MockerFixture, pool: RepositoryPool) -> None: + mocker.patch.object(Factory, "create_pool", return_value=pool) + + +def test_isolated_env_install_success(pool: RepositoryPool) -> None: + with ephemeral_environment(Path(sys.executable)) as venv: + env = IsolatedEnv(venv, pool) + assert not InstalledRepository.load(venv).find_packages( + get_dependency("poetry-core") + ) + + env.install({"poetry-core"}) + assert InstalledRepository.load(venv).find_packages( + get_dependency("poetry-core") + ) + + +@pytest.mark.parametrize( + ("requirements", "exception"), + [ + ({"poetry-core==1.5.0", "poetry-core==1.6.0"}, IncompatibleConstraintsError), + ({"black==19.10b0", "attrs==17.4.0"}, SolverProblemError), + ], +) +def test_isolated_env_install_error( + requirements: Collection[str], exception: type[Exception], pool: RepositoryPool +) -> None: + with ephemeral_environment(Path(sys.executable)) as venv: + env = IsolatedEnv(venv, pool) + with pytest.raises(exception): + env.install(requirements) + + +def test_isolated_env_install_failure( + pool: RepositoryPool, mocker: MockerFixture +) -> None: + mocker.patch("poetry.installation.installer.Installer.run", return_value=1) + with ephemeral_environment(Path(sys.executable)) as venv: + env = IsolatedEnv(venv, pool) + with pytest.raises(IsolatedBuildInstallError) as e: + env.install({"a", "b>1"}) + assert e.value.requirements == {"a", "b>1"}