Skip to content

Commit

Permalink
use poetry to manage deps for isolated build envs
Browse files Browse the repository at this point in the history
Previously, isolated build environments needed for both package
installation from sdist and for fallback package metadata  inspection
used to rely on inline scripts and delegated management of build
requirements to the build package.

With this change, Poetry manages the build requirements. This enables
the use of build requirements from the current projects source,
Poetry's configurations and also cache. We also, reduce the use of
subprocesses and thereby increasing speeds of such builds.
  • Loading branch information
abn committed Mar 19, 2024
1 parent 38ff03d commit 5cb5668
Show file tree
Hide file tree
Showing 8 changed files with 286 additions and 212 deletions.
53 changes: 11 additions & 42 deletions src/poetry/inspection/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import functools
import glob
import logging
import tempfile

from pathlib import Path
from typing import TYPE_CHECKING
Expand All @@ -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
Expand All @@ -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


Expand All @@ -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:
Expand Down Expand Up @@ -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")

Expand Down
145 changes: 28 additions & 117 deletions src/poetry/installation/chef.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions src/poetry/installation/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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


Expand Down Expand Up @@ -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
Expand All @@ -328,7 +328,7 @@ def _execute_operation(self, operation: Operation) -> None:
f" running '{pip_command} \"{requirement}\"'."
"</info>"
)
elif isinstance(e, ChefInstallError):
elif isinstance(e, IsolatedBuildInstallError):
message = (
"<error>"
"Cannot install build-system.requires"
Expand Down
Loading

0 comments on commit 5cb5668

Please sign in to comment.