diff --git a/src/pip/_internal/distributions/sdist.py b/src/pip/_internal/distributions/sdist.py index b4e2892931b..30c69de562c 100644 --- a/src/pip/_internal/distributions/sdist.py +++ b/src/pip/_internal/distributions/sdist.py @@ -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() diff --git a/src/pip/_internal/req/req_install.py b/src/pip/_internal/req/req_install.py index 9f5c2eb597e..a0ab1883cb1 100644 --- a/src/pip/_internal/req/req_install.py +++ b/src/pip/_internal/req/req_install.py @@ -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 @@ -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 @@ -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 @@ -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 @@ -505,39 +514,12 @@ def load_pyproject_toml(self) -> None: 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 regular metadata preparation." - ) - else: - self.supports_pyproject_editable = True - return metadata_directory - - # PEP 660 not supported, fall back to regular metadata preparation, - # either using PEP 517 if supported or setup.py egg_info. - return self._generate_metadata() + assert self.use_pep517 + assert self.pep517_backend is not None + return generate_editable_metadata( + build_env=self.build_env, + backend=self.pep517_backend, + ) def _generate_metadata(self) -> str: """Invokes metadata generator functions, with the required arguments.""" @@ -574,7 +556,11 @@ def prepare_metadata(self) -> None: """ assert self.source_dir - if self.editable and self.permit_editable_wheels: + if ( + self.editable + and self.permit_editable_wheels + and self.supports_pyproject_editable() + ): self.metadata_directory = self._generate_editable_metadata() else: self.metadata_directory = self._generate_metadata() diff --git a/src/pip/_internal/wheel_builder.py b/src/pip/_internal/wheel_builder.py index b4855a9a7fb..f01bc4d5a8e 100644 --- a/src/pip/_internal/wheel_builder.py +++ b/src/pip/_internal/wheel_builder.py @@ -71,7 +71,7 @@ def _should_build( return False if req.editable: - if req.use_pep517 and req.supports_pyproject_editable is not False: + if req.supports_pyproject_editable(): return True # we don't build legacy editable requirements return False diff --git a/src/pip/_vendor/pep517/in_process/_in_process.py b/src/pip/_vendor/pep517/in_process/_in_process.py index c7f5f0577f8..6eda469c2a1 100644 --- a/src/pip/_vendor/pep517/in_process/_in_process.py +++ b/src/pip/_vendor/pep517/in_process/_in_process.py @@ -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 @@ -312,6 +317,7 @@ def build_sdist(sdist_directory, config_settings): 'build_editable', 'get_requires_for_build_sdist', 'build_sdist', + '_supports_build_editable', } diff --git a/src/pip/_vendor/pep517/wrappers.py b/src/pip/_vendor/pep517/wrappers.py index 52da22e8257..d812baf5fa5 100644 --- a/src/pip/_vendor/pep517/wrappers.py +++ b/src/pip/_vendor/pep517/wrappers.py @@ -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 diff --git a/tests/unit/test_wheel_builder.py b/tests/unit/test_wheel_builder.py index 2b8ec2fa94d..9562541ff55 100644 --- a/tests/unit/test_wheel_builder.py +++ b/tests/unit/test_wheel_builder.py @@ -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 @@ -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( @@ -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,