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 3b31f05
Show file tree
Hide file tree
Showing 7 changed files with 82 additions and 90 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.
6 changes: 5 additions & 1 deletion src/pip/_internal/distributions/sdist.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ def _setup_isolation(self, finder: PackageFinder) -> None:
# 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
138 changes: 56 additions & 82 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,58 @@ 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
def prepare_metadata(self) -> None:
"""Ensure that project metadata is available.
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.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)
):
# Most other project configuration sanity checks are done in
# load_pyproject_toml. This specific one cannot be done earlier because we
# do a 'setup.py develop' fallback also for projects with pyproject.toml and
# setup.cfg without setup.py, and to decide if this is valid we must have
# determined that the build backend does not support PEP 660.
raise InstallationError(
f"File 'setup.py' or 'setup.cfg' not found "
f"for legacy project {self}. "
f"It cannot be installed in editable mode."
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 _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(
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,
)
except HookMissing as e:
raise InstallationError(
f"Project {self} has a pyproject.toml but its build "
f"backend is missing the required {e} hook."
else:
self.metadata_directory = generate_metadata(
build_env=self.build_env,
backend=self.pep517_backend,
)
elif not os.path.exists(self.setup_py_path):
raise InstallationError(
f"File 'setup.py' not found for legacy project {self}."
)

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 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()
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 3b31f05

Please sign in to comment.