Skip to content

Commit 3b31f05

Browse files
committed
Early detection of build backend with build_editable support
1 parent 4233916 commit 3b31f05

File tree

7 files changed

+82
-90
lines changed

7 files changed

+82
-90
lines changed

news/10573.bugfix.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
When installing projects with a ``pyproject.toml`` in editable mode, and the build
2+
backend does not support :pep:`660`, prepare metadata using
3+
``prepare_metadata_for_build_wheel`` instead of ``setup.py egg_info``. Also, refuse
4+
installing projects that only have a ``setup.cfg`` and no ``setup.py`` nor
5+
``pyproject.toml``. These restore the pre-21.3 behaviour.

src/pip/_internal/distributions/sdist.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,11 @@ def _setup_isolation(self, finder: PackageFinder) -> None:
4141
# Install any extra build dependencies that the backend requests.
4242
# This must be done in a second pass, as the pyproject.toml
4343
# dependencies must be installed before we can call the backend.
44-
if self.req.editable and self.req.permit_editable_wheels:
44+
if (
45+
self.req.editable
46+
and self.req.permit_editable_wheels
47+
and self.req.supports_pyproject_editable()
48+
):
4549
build_reqs = self._get_build_requires_editable()
4650
else:
4751
build_reqs = self._get_build_requires_wheel()

src/pip/_internal/req/req_install.py

Lines changed: 56 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# The following comment should be removed at some point in the future.
22
# mypy: strict-optional=False
33

4+
import functools
45
import logging
56
import os
67
import shutil
@@ -16,7 +17,7 @@
1617
from pip._vendor.packaging.utils import canonicalize_name
1718
from pip._vendor.packaging.version import Version
1819
from pip._vendor.packaging.version import parse as parse_version
19-
from pip._vendor.pep517.wrappers import HookMissing, Pep517HookCaller
20+
from pip._vendor.pep517.wrappers import Pep517HookCaller
2021
from pip._vendor.pkg_resources import Distribution
2122

2223
from pip._internal.build_env import BuildEnvironment, NoOpBuildEnvironment
@@ -53,6 +54,7 @@
5354
redact_auth_from_url,
5455
)
5556
from pip._internal.utils.packaging import get_metadata
57+
from pip._internal.utils.subprocess import runner_with_spinner_message
5658
from pip._internal.utils.temp_dir import TempDirectory, tempdir_kinds
5759
from pip._internal.utils.virtualenv import running_under_virtualenv
5860
from pip._internal.vcs import vcs
@@ -196,11 +198,6 @@ def __init__(
196198
# but after loading this flag should be treated as read only.
197199
self.use_pep517 = use_pep517
198200

199-
# supports_pyproject_editable will be set to True or False when we try
200-
# to prepare editable metadata or build an editable wheel. None means
201-
# "we don't know yet".
202-
self.supports_pyproject_editable: Optional[bool] = None
203-
204201
# This requirement needs more preparation before it can be built
205202
self.needs_more_preparation = False
206203

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

247+
@functools.lru_cache() # use cached_property in python 3.8+
248+
def supports_pyproject_editable(self) -> bool:
249+
if not self.use_pep517:
250+
return False
251+
assert self.pep517_backend
252+
with self.build_env:
253+
runner = runner_with_spinner_message(
254+
"Checking if build backend supports build_editable"
255+
)
256+
with self.pep517_backend.subprocess_runner(runner):
257+
return self.pep517_backend._supports_build_editable()
258+
250259
@property
251260
def specifier(self) -> SpecifierSet:
252261
return self.req.specifier
@@ -503,93 +512,58 @@ def load_pyproject_toml(self) -> None:
503512
backend_path=backend_path,
504513
)
505514

506-
def _generate_editable_metadata(self) -> str:
507-
"""Invokes metadata generator functions, with the required arguments."""
508-
if self.use_pep517:
509-
assert self.pep517_backend is not None
510-
try:
511-
metadata_directory = generate_editable_metadata(
512-
build_env=self.build_env,
513-
backend=self.pep517_backend,
514-
)
515-
except HookMissing as e:
516-
self.supports_pyproject_editable = False
517-
if not os.path.exists(self.setup_py_path) and not os.path.exists(
518-
self.setup_cfg_path
519-
):
520-
raise InstallationError(
521-
f"Project {self} has a 'pyproject.toml' and its build "
522-
f"backend is missing the {e} hook. Since it does not "
523-
f"have a 'setup.py' nor a 'setup.cfg', "
524-
f"it cannot be installed in editable mode. "
525-
f"Consider using a build backend that supports PEP 660."
526-
)
527-
# At this point we have determined that the build_editable hook
528-
# is missing, and there is a setup.py or setup.cfg
529-
# so we fallback to the legacy metadata generation
530-
logger.info(
531-
"Build backend does not support editables, "
532-
"falling back to setup.py egg_info."
533-
)
534-
else:
535-
self.supports_pyproject_editable = True
536-
return metadata_directory
537-
elif not os.path.exists(self.setup_py_path) and not os.path.exists(
538-
self.setup_cfg_path
515+
def prepare_metadata(self) -> None:
516+
"""Ensure that project metadata is available.
517+
518+
Under PEP 517 and PEP 660, call the backend hook to prepare the metadata.
519+
Under legacy processing, call setup.py egg-info.
520+
"""
521+
assert self.source_dir
522+
523+
if (
524+
self.editable
525+
and self.use_pep517
526+
and not self.supports_pyproject_editable()
527+
and not os.path.isfile(self.setup_py_path)
528+
and not os.path.isfile(self.setup_cfg_path)
539529
):
530+
# Most other project configuration sanity checks are done in
531+
# load_pyproject_toml. This specific one cannot be done earlier because we
532+
# do a 'setup.py develop' fallback also for projects with pyproject.toml and
533+
# setup.cfg without setup.py, and to decide if this is valid we must have
534+
# determined that the build backend does not support PEP 660.
540535
raise InstallationError(
541-
f"File 'setup.py' or 'setup.cfg' not found "
542-
f"for legacy project {self}. "
543-
f"It cannot be installed in editable mode."
536+
f"Project {self} has a 'pyproject.toml' and its build "
537+
f"backend is missing the 'build_editable' hook. Since it does not "
538+
f"have a 'setup.py' nor a 'setup.cfg', "
539+
f"it cannot be installed in editable mode. "
540+
f"Consider using a build backend that supports PEP 660."
544541
)
545542

546-
return generate_metadata_legacy(
547-
build_env=self.build_env,
548-
setup_py_path=self.setup_py_path,
549-
source_dir=self.unpacked_source_directory,
550-
isolated=self.isolated,
551-
details=self.name or f"from {self.link}",
552-
)
553-
554-
def _generate_metadata(self) -> str:
555-
"""Invokes metadata generator functions, with the required arguments."""
556543
if self.use_pep517:
557544
assert self.pep517_backend is not None
558-
try:
559-
return generate_metadata(
545+
if (
546+
self.editable
547+
and self.permit_editable_wheels
548+
and self.supports_pyproject_editable()
549+
):
550+
self.metadata_directory = generate_editable_metadata(
560551
build_env=self.build_env,
561552
backend=self.pep517_backend,
562553
)
563-
except HookMissing as e:
564-
raise InstallationError(
565-
f"Project {self} has a pyproject.toml but its build "
566-
f"backend is missing the required {e} hook."
554+
else:
555+
self.metadata_directory = generate_metadata(
556+
build_env=self.build_env,
557+
backend=self.pep517_backend,
567558
)
568-
elif not os.path.exists(self.setup_py_path):
569-
raise InstallationError(
570-
f"File 'setup.py' not found for legacy project {self}."
571-
)
572-
573-
return generate_metadata_legacy(
574-
build_env=self.build_env,
575-
setup_py_path=self.setup_py_path,
576-
source_dir=self.unpacked_source_directory,
577-
isolated=self.isolated,
578-
details=self.name or f"from {self.link}",
579-
)
580-
581-
def prepare_metadata(self) -> None:
582-
"""Ensure that project metadata is available.
583-
584-
Under PEP 517, call the backend hook to prepare the metadata.
585-
Under legacy processing, call setup.py egg-info.
586-
"""
587-
assert self.source_dir
588-
589-
if self.editable and self.permit_editable_wheels:
590-
self.metadata_directory = self._generate_editable_metadata()
591559
else:
592-
self.metadata_directory = self._generate_metadata()
560+
self.metadata_directory = generate_metadata_legacy(
561+
build_env=self.build_env,
562+
setup_py_path=self.setup_py_path,
563+
source_dir=self.unpacked_source_directory,
564+
isolated=self.isolated,
565+
details=self.name or f"from {self.link}",
566+
)
593567

594568
# Act on the newly generated metadata, based on the name and version.
595569
if not self.name:

src/pip/_internal/wheel_builder.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,8 @@ def _should_build(
7171
return False
7272

7373
if req.editable:
74-
if req.use_pep517 and req.supports_pyproject_editable is not False:
75-
return True
76-
# we don't build legacy editable requirements
77-
return False
74+
# we only build PEP 660 editable requirements
75+
return req.supports_pyproject_editable()
7876

7977
if req.use_pep517:
8078
return True

src/pip/_vendor/pep517/in_process/_in_process.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ def _build_backend():
103103
return obj
104104

105105

106+
def _supports_build_editable():
107+
backend = _build_backend()
108+
return hasattr(backend, "build_editable")
109+
110+
106111
def get_requires_for_build_wheel(config_settings):
107112
"""Invoke the optional get_requires_for_build_wheel hook
108113
@@ -312,6 +317,7 @@ def build_sdist(sdist_directory, config_settings):
312317
'build_editable',
313318
'get_requires_for_build_sdist',
314319
'build_sdist',
320+
'_supports_build_editable',
315321
}
316322

317323

src/pip/_vendor/pep517/wrappers.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,9 @@ def subprocess_runner(self, runner):
154154
finally:
155155
self._subprocess_runner = prev
156156

157+
def _supports_build_editable(self):
158+
return self._call_hook('_supports_build_editable', {})
159+
157160
def get_requires_for_build_wheel(self, config_settings=None):
158161
"""Identify packages required for building a wheel
159162

tests/unit/test_wheel_builder.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def __init__(
3939
constraint: bool = False,
4040
source_dir: Optional[str] = "/tmp/pip-install-123/pendulum",
4141
use_pep517: bool = True,
42-
supports_pyproject_editable: Optional[bool] = None,
42+
supports_pyproject_editable: bool = False,
4343
) -> None:
4444
self.name = name
4545
self.is_wheel = is_wheel
@@ -48,7 +48,10 @@ def __init__(
4848
self.constraint = constraint
4949
self.source_dir = source_dir
5050
self.use_pep517 = use_pep517
51-
self.supports_pyproject_editable = supports_pyproject_editable
51+
self._supports_pyproject_editable = supports_pyproject_editable
52+
53+
def supports_pyproject_editable(self) -> bool:
54+
return self._supports_pyproject_editable
5255

5356

5457
@pytest.mark.parametrize(
@@ -66,7 +69,6 @@ def __init__(
6669
# We don't build reqs that are already wheels.
6770
(ReqMock(is_wheel=True), False, False),
6871
(ReqMock(editable=True, use_pep517=False), False, False),
69-
(ReqMock(editable=True, use_pep517=True), False, True),
7072
(
7173
ReqMock(editable=True, use_pep517=True, supports_pyproject_editable=True),
7274
False,

0 commit comments

Comments
 (0)