Skip to content

Commit

Permalink
Early detection of build backend with build_editable support
Browse files Browse the repository at this point in the history
  • Loading branch information
sbidoul committed Oct 13, 2021
1 parent 4233916 commit 1077342
Show file tree
Hide file tree
Showing 7 changed files with 89 additions and 89 deletions.
5 changes: 5 additions & 0 deletions news/10573.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
When installing projects with a ``pyproject.toml`` in editable mode, and the build
backend does not support :pep:`660`, prepare metadata using
``prepare_metadata_for_build_wheel`` instead of ``setup.py egg_info``. Also, refuse
installing projects that only have a ``setup.cfg`` and no ``setup.py`` nor
``pyproject.toml``. These restore the pre-21.3 behaviour.
13 changes: 12 additions & 1 deletion src/pip/_internal/distributions/sdist.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,21 @@ def prepare_distribution_metadata(

def _setup_isolation(self, finder: PackageFinder) -> None:
self._prepare_build_backend(finder)
# Check that if the requirement is editable, it either supports PEP 660
# or has a setup.py or a setup.cfg. This cannot be done earlier because
# we need to setup the build backend to verify it supports build_editable,
# nor can it be done later, because merely calling _get_build_requires_wheel()
# for the setuptools __legacy__ backend creates the egg-info directory,
# including UNKNOWN.egg-info if it is misconfigured, and we don't want that.
self.req.isolated_editable_sanity_check()
# Install any extra build dependencies that the backend requests.
# This must be done in a second pass, as the pyproject.toml
# dependencies must be installed before we can call the backend.
if self.req.editable and self.req.permit_editable_wheels:
if (
self.req.editable
and self.req.permit_editable_wheels
and self.req.supports_pyproject_editable()
):
build_reqs = self._get_build_requires_editable()
else:
build_reqs = self._get_build_requires_wheel()
Expand Down
137 changes: 56 additions & 81 deletions src/pip/_internal/req/req_install.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# The following comment should be removed at some point in the future.
# mypy: strict-optional=False

import functools
import logging
import os
import shutil
Expand All @@ -16,7 +17,7 @@
from pip._vendor.packaging.utils import canonicalize_name
from pip._vendor.packaging.version import Version
from pip._vendor.packaging.version import parse as parse_version
from pip._vendor.pep517.wrappers import HookMissing, Pep517HookCaller
from pip._vendor.pep517.wrappers import Pep517HookCaller
from pip._vendor.pkg_resources import Distribution

from pip._internal.build_env import BuildEnvironment, NoOpBuildEnvironment
Expand Down Expand Up @@ -53,6 +54,7 @@
redact_auth_from_url,
)
from pip._internal.utils.packaging import get_metadata
from pip._internal.utils.subprocess import runner_with_spinner_message
from pip._internal.utils.temp_dir import TempDirectory, tempdir_kinds
from pip._internal.utils.virtualenv import running_under_virtualenv
from pip._internal.vcs import vcs
Expand Down Expand Up @@ -196,11 +198,6 @@ def __init__(
# but after loading this flag should be treated as read only.
self.use_pep517 = use_pep517

# supports_pyproject_editable will be set to True or False when we try
# to prepare editable metadata or build an editable wheel. None means
# "we don't know yet".
self.supports_pyproject_editable: Optional[bool] = None

# This requirement needs more preparation before it can be built
self.needs_more_preparation = False

Expand Down Expand Up @@ -247,6 +244,18 @@ def name(self) -> Optional[str]:
return None
return pkg_resources.safe_name(self.req.name)

@functools.lru_cache() # use cached_property in python 3.8+
def supports_pyproject_editable(self) -> bool:
if not self.use_pep517:
return False
assert self.pep517_backend
with self.build_env:
runner = runner_with_spinner_message(
"Checking if build backend supports build_editable"
)
with self.pep517_backend.subprocess_runner(runner):
return self.pep517_backend._supports_build_editable()

@property
def specifier(self) -> SpecifierSet:
return self.req.specifier
Expand Down Expand Up @@ -503,93 +512,59 @@ def load_pyproject_toml(self) -> None:
backend_path=backend_path,
)

def _generate_editable_metadata(self) -> str:
"""Invokes metadata generator functions, with the required arguments."""
if self.use_pep517:
assert self.pep517_backend is not None
try:
metadata_directory = generate_editable_metadata(
build_env=self.build_env,
backend=self.pep517_backend,
)
except HookMissing as e:
self.supports_pyproject_editable = False
if not os.path.exists(self.setup_py_path) and not os.path.exists(
self.setup_cfg_path
):
raise InstallationError(
f"Project {self} has a 'pyproject.toml' and its build "
f"backend is missing the {e} hook. Since it does not "
f"have a 'setup.py' nor a 'setup.cfg', "
f"it cannot be installed in editable mode. "
f"Consider using a build backend that supports PEP 660."
)
# At this point we have determined that the build_editable hook
# is missing, and there is a setup.py or setup.cfg
# so we fallback to the legacy metadata generation
logger.info(
"Build backend does not support editables, "
"falling back to setup.py egg_info."
)
else:
self.supports_pyproject_editable = True
return metadata_directory
elif not os.path.exists(self.setup_py_path) and not os.path.exists(
self.setup_cfg_path
):
raise InstallationError(
f"File 'setup.py' or 'setup.cfg' not found "
f"for legacy project {self}. "
f"It cannot be installed in editable mode."
)

return generate_metadata_legacy(
build_env=self.build_env,
setup_py_path=self.setup_py_path,
source_dir=self.unpacked_source_directory,
isolated=self.isolated,
details=self.name or f"from {self.link}",
)
def isolated_editable_sanity_check(self) -> None:
"""Check that an editable requirement if valid for use with PEP 517/518.
def _generate_metadata(self) -> str:
"""Invokes metadata generator functions, with the required arguments."""
if self.use_pep517:
assert self.pep517_backend is not None
try:
return generate_metadata(
build_env=self.build_env,
backend=self.pep517_backend,
)
except HookMissing as e:
raise InstallationError(
f"Project {self} has a pyproject.toml but its build "
f"backend is missing the required {e} hook."
)
elif not os.path.exists(self.setup_py_path):
This verifies that an editable that has a pyproject.toml either supports PEP 660
or as a setup.py or a setup.cfg
"""
if (
self.editable
and self.use_pep517
and not self.supports_pyproject_editable()
and not os.path.isfile(self.setup_py_path)
and not os.path.isfile(self.setup_cfg_path)
):
raise InstallationError(
f"File 'setup.py' not found for legacy project {self}."
f"Project {self} has a 'pyproject.toml' and its build "
f"backend is missing the 'build_editable' hook. Since it does not "
f"have a 'setup.py' nor a 'setup.cfg', "
f"it cannot be installed in editable mode. "
f"Consider using a build backend that supports PEP 660."
)

return generate_metadata_legacy(
build_env=self.build_env,
setup_py_path=self.setup_py_path,
source_dir=self.unpacked_source_directory,
isolated=self.isolated,
details=self.name or f"from {self.link}",
)

def prepare_metadata(self) -> None:
"""Ensure that project metadata is available.
Under PEP 517, call the backend hook to prepare the metadata.
Under PEP 517 and PEP 660, call the backend hook to prepare the metadata.
Under legacy processing, call setup.py egg-info.
"""
assert self.source_dir

if self.editable and self.permit_editable_wheels:
self.metadata_directory = self._generate_editable_metadata()
if self.use_pep517:
assert self.pep517_backend is not None
if (
self.editable
and self.permit_editable_wheels
and self.supports_pyproject_editable()
):
self.metadata_directory = generate_editable_metadata(
build_env=self.build_env,
backend=self.pep517_backend,
)
else:
self.metadata_directory = generate_metadata(
build_env=self.build_env,
backend=self.pep517_backend,
)
else:
self.metadata_directory = self._generate_metadata()
self.metadata_directory = generate_metadata_legacy(
build_env=self.build_env,
setup_py_path=self.setup_py_path,
source_dir=self.unpacked_source_directory,
isolated=self.isolated,
details=self.name or f"from {self.link}",
)

# Act on the newly generated metadata, based on the name and version.
if not self.name:
Expand Down
6 changes: 2 additions & 4 deletions src/pip/_internal/wheel_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,8 @@ def _should_build(
return False

if req.editable:
if req.use_pep517 and req.supports_pyproject_editable is not False:
return True
# we don't build legacy editable requirements
return False
# we only build PEP 660 editable requirements
return req.supports_pyproject_editable()

if req.use_pep517:
return True
Expand Down
6 changes: 6 additions & 0 deletions src/pip/_vendor/pep517/in_process/_in_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ def _build_backend():
return obj


def _supports_build_editable():
backend = _build_backend()
return hasattr(backend, "build_editable")


def get_requires_for_build_wheel(config_settings):
"""Invoke the optional get_requires_for_build_wheel hook
Expand Down Expand Up @@ -312,6 +317,7 @@ def build_sdist(sdist_directory, config_settings):
'build_editable',
'get_requires_for_build_sdist',
'build_sdist',
'_supports_build_editable',
}


Expand Down
3 changes: 3 additions & 0 deletions src/pip/_vendor/pep517/wrappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ def subprocess_runner(self, runner):
finally:
self._subprocess_runner = prev

def _supports_build_editable(self):
return self._call_hook('_supports_build_editable', {})

def get_requires_for_build_wheel(self, config_settings=None):
"""Identify packages required for building a wheel
Expand Down
8 changes: 5 additions & 3 deletions tests/unit/test_wheel_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def __init__(
constraint: bool = False,
source_dir: Optional[str] = "/tmp/pip-install-123/pendulum",
use_pep517: bool = True,
supports_pyproject_editable: Optional[bool] = None,
supports_pyproject_editable: bool = False,
) -> None:
self.name = name
self.is_wheel = is_wheel
Expand All @@ -48,7 +48,10 @@ def __init__(
self.constraint = constraint
self.source_dir = source_dir
self.use_pep517 = use_pep517
self.supports_pyproject_editable = supports_pyproject_editable
self._supports_pyproject_editable = supports_pyproject_editable

def supports_pyproject_editable(self) -> bool:
return self._supports_pyproject_editable


@pytest.mark.parametrize(
Expand All @@ -66,7 +69,6 @@ def __init__(
# We don't build reqs that are already wheels.
(ReqMock(is_wheel=True), False, False),
(ReqMock(editable=True, use_pep517=False), False, False),
(ReqMock(editable=True, use_pep517=True), False, True),
(
ReqMock(editable=True, use_pep517=True, supports_pyproject_editable=True),
False,
Expand Down

0 comments on commit 1077342

Please sign in to comment.