Skip to content

Commit 348d884

Browse files
authored
Merge pull request #8212 from sbidoul/pep517-editable-sbi
PEP 660 (build_editable) support
2 parents 8ea2651 + 826d566 commit 348d884

File tree

16 files changed

+526
-100
lines changed

16 files changed

+526
-100
lines changed

news/8212.feature.rst

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Support editable installs for projects that have a ``pyproject.toml`` and use a
2+
build backend that supports :pep:`660`.

src/pip/_internal/commands/install.py

+11-5
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,12 @@ def run(self, options: Values, args: List[str]) -> int:
306306
try:
307307
reqs = self.get_requirements(args, options, finder, session)
308308

309+
# Only when installing is it permitted to use PEP 660.
310+
# In other circumstances (pip wheel, pip download) we generate
311+
# regular (i.e. non editable) metadata and wheels.
312+
for req in reqs:
313+
req.permit_editable_wheels = True
314+
309315
reject_location_related_install_options(reqs, options.install_options)
310316

311317
preparer = self.make_requirement_preparer(
@@ -361,22 +367,22 @@ def run(self, options: Values, args: List[str]) -> int:
361367
global_options=[],
362368
)
363369

364-
# If we're using PEP 517, we cannot do a direct install
370+
# If we're using PEP 517, we cannot do a legacy setup.py install
365371
# so we fail here.
366372
pep517_build_failure_names: List[str] = [
367373
r.name for r in build_failures if r.use_pep517 # type: ignore
368374
]
369375
if pep517_build_failure_names:
370376
raise InstallationError(
371-
"Could not build wheels for {} which use"
372-
" PEP 517 and cannot be installed directly".format(
377+
"Could not build wheels for {}, which is required to "
378+
"install pyproject.toml-based projects".format(
373379
", ".join(pep517_build_failure_names)
374380
)
375381
)
376382

377383
# For now, we just warn about failures building legacy
378-
# requirements, as we'll fall through to a direct
379-
# install for those.
384+
# requirements, as we'll fall through to a setup.py install for
385+
# those.
380386
for r in build_failures:
381387
if not r.use_pep517:
382388
r.legacy_install_reason = 8368

src/pip/_internal/distributions/sdist.py

+44-23
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import logging
2-
from typing import Set, Tuple
2+
from typing import Iterable, Set, Tuple
33

44
from pip._internal.build_env import BuildEnvironment
55
from pip._internal.distributions.base import AbstractDistribution
@@ -37,23 +37,17 @@ def prepare_distribution_metadata(
3737
self.req.prepare_metadata()
3838

3939
def _setup_isolation(self, finder: PackageFinder) -> None:
40-
def _raise_conflicts(
41-
conflicting_with: str, conflicting_reqs: Set[Tuple[str, str]]
42-
) -> None:
43-
format_string = (
44-
"Some build dependencies for {requirement} "
45-
"conflict with {conflicting_with}: {description}."
46-
)
47-
error_message = format_string.format(
48-
requirement=self.req,
49-
conflicting_with=conflicting_with,
50-
description=", ".join(
51-
f"{installed} is incompatible with {wanted}"
52-
for installed, wanted in sorted(conflicting)
53-
),
54-
)
55-
raise InstallationError(error_message)
40+
self._prepare_build_backend(finder)
41+
# Install any extra build dependencies that the backend requests.
42+
# This must be done in a second pass, as the pyproject.toml
43+
# dependencies must be installed before we can call the backend.
44+
if self.req.editable and self.req.permit_editable_wheels:
45+
build_reqs = self._get_build_requires_editable()
46+
else:
47+
build_reqs = self._get_build_requires_wheel()
48+
self._install_build_reqs(finder, build_reqs)
5649

50+
def _prepare_build_backend(self, finder: PackageFinder) -> None:
5751
# Isolate in a BuildEnvironment and install the build-time
5852
# requirements.
5953
pyproject_requires = self.req.pyproject_requires
@@ -67,7 +61,7 @@ def _raise_conflicts(
6761
self.req.requirements_to_check
6862
)
6963
if conflicting:
70-
_raise_conflicts("PEP 517/518 supported requirements", conflicting)
64+
self._raise_conflicts("PEP 517/518 supported requirements", conflicting)
7165
if missing:
7266
logger.warning(
7367
"Missing build requirements in pyproject.toml for %s.",
@@ -78,19 +72,46 @@ def _raise_conflicts(
7872
"pip cannot fall back to setuptools without %s.",
7973
" and ".join(map(repr, sorted(missing))),
8074
)
81-
# Install any extra build dependencies that the backend requests.
82-
# This must be done in a second pass, as the pyproject.toml
83-
# dependencies must be installed before we can call the backend.
75+
76+
def _get_build_requires_wheel(self) -> Iterable[str]:
8477
with self.req.build_env:
8578
runner = runner_with_spinner_message("Getting requirements to build wheel")
8679
backend = self.req.pep517_backend
8780
assert backend is not None
8881
with backend.subprocess_runner(runner):
89-
reqs = backend.get_requires_for_build_wheel()
82+
return backend.get_requires_for_build_wheel()
9083

84+
def _get_build_requires_editable(self) -> Iterable[str]:
85+
with self.req.build_env:
86+
runner = runner_with_spinner_message(
87+
"Getting requirements to build editable"
88+
)
89+
backend = self.req.pep517_backend
90+
assert backend is not None
91+
with backend.subprocess_runner(runner):
92+
return backend.get_requires_for_build_editable()
93+
94+
def _install_build_reqs(self, finder: PackageFinder, reqs: Iterable[str]) -> None:
9195
conflicting, missing = self.req.build_env.check_requirements(reqs)
9296
if conflicting:
93-
_raise_conflicts("the backend dependencies", conflicting)
97+
self._raise_conflicts("the backend dependencies", conflicting)
9498
self.req.build_env.install_requirements(
9599
finder, missing, "normal", "Installing backend dependencies"
96100
)
101+
102+
def _raise_conflicts(
103+
self, conflicting_with: str, conflicting_reqs: Set[Tuple[str, str]]
104+
) -> None:
105+
format_string = (
106+
"Some build dependencies for {requirement} "
107+
"conflict with {conflicting_with}: {description}."
108+
)
109+
error_message = format_string.format(
110+
requirement=self.req,
111+
conflicting_with=conflicting_with,
112+
description=", ".join(
113+
f"{installed} is incompatible with {wanted}"
114+
for installed, wanted in sorted(conflicting_reqs)
115+
),
116+
)
117+
raise InstallationError(error_message)

src/pip/_internal/operations/build/metadata.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ def generate_metadata(build_env: BuildEnvironment, backend: Pep517HookCaller) ->
2323
# Note that Pep517HookCaller implements a fallback for
2424
# prepare_metadata_for_build_wheel, so we don't have to
2525
# consider the possibility that this hook doesn't exist.
26-
runner = runner_with_spinner_message("Preparing wheel metadata")
26+
runner = runner_with_spinner_message(
27+
"Preparing wheel metadata (pyproject.toml)"
28+
)
2729
with backend.subprocess_runner(runner):
2830
distinfo_dir = backend.prepare_metadata_for_build_wheel(metadata_dir)
2931

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Metadata generation logic for source distributions.
2+
"""
3+
4+
import os
5+
6+
from pip._vendor.pep517.wrappers import Pep517HookCaller
7+
8+
from pip._internal.build_env import BuildEnvironment
9+
from pip._internal.utils.subprocess import runner_with_spinner_message
10+
from pip._internal.utils.temp_dir import TempDirectory
11+
12+
13+
def generate_editable_metadata(
14+
build_env: BuildEnvironment, backend: Pep517HookCaller
15+
) -> str:
16+
"""Generate metadata using mechanisms described in PEP 660.
17+
18+
Returns the generated metadata directory.
19+
"""
20+
metadata_tmpdir = TempDirectory(kind="modern-metadata", globally_managed=True)
21+
22+
metadata_dir = metadata_tmpdir.path
23+
24+
with build_env:
25+
# Note that Pep517HookCaller implements a fallback for
26+
# prepare_metadata_for_build_wheel/editable, so we don't have to
27+
# consider the possibility that this hook doesn't exist.
28+
runner = runner_with_spinner_message(
29+
"Preparing editable metadata (pyproject.toml)"
30+
)
31+
with backend.subprocess_runner(runner):
32+
distinfo_dir = backend.prepare_metadata_for_build_editable(metadata_dir)
33+
34+
return os.path.join(metadata_dir, distinfo_dir)

src/pip/_internal/operations/build/metadata_legacy.py

+8-5
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import os
66

77
from pip._internal.build_env import BuildEnvironment
8+
from pip._internal.cli.spinners import open_spinner
89
from pip._internal.exceptions import InstallationError
910
from pip._internal.utils.setuptools_build import make_setuptools_egg_info_args
1011
from pip._internal.utils.subprocess import call_subprocess
@@ -54,11 +55,13 @@ def generate_metadata(
5455
)
5556

5657
with build_env:
57-
call_subprocess(
58-
args,
59-
cwd=source_dir,
60-
command_desc="python setup.py egg_info",
61-
)
58+
with open_spinner("Preparing metadata (setup.py)") as spinner:
59+
call_subprocess(
60+
args,
61+
cwd=source_dir,
62+
command_desc="python setup.py egg_info",
63+
spinner=spinner,
64+
)
6265

6366
# Return the .egg-info directory.
6467
return _find_egg_info(egg_info_dir)

src/pip/_internal/operations/build/wheel.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ def build_wheel_pep517(
2323
try:
2424
logger.debug("Destination directory: %s", tempd)
2525

26-
runner = runner_with_spinner_message(f"Building wheel for {name} (PEP 517)")
26+
runner = runner_with_spinner_message(
27+
f"Building wheel for {name} (pyproject.toml)"
28+
)
2729
with backend.subprocess_runner(runner):
2830
wheel_name = backend.build_wheel(
2931
tempd,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import logging
2+
import os
3+
from typing import Optional
4+
5+
from pip._vendor.pep517.wrappers import HookMissing, Pep517HookCaller
6+
7+
from pip._internal.utils.subprocess import runner_with_spinner_message
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
def build_wheel_editable(
13+
name: str,
14+
backend: Pep517HookCaller,
15+
metadata_directory: str,
16+
tempd: str,
17+
) -> Optional[str]:
18+
"""Build one InstallRequirement using the PEP 660 build process.
19+
20+
Returns path to wheel if successfully built. Otherwise, returns None.
21+
"""
22+
assert metadata_directory is not None
23+
try:
24+
logger.debug("Destination directory: %s", tempd)
25+
26+
runner = runner_with_spinner_message(
27+
f"Building editable for {name} (pyproject.toml)"
28+
)
29+
with backend.subprocess_runner(runner):
30+
try:
31+
wheel_name = backend.build_editable(
32+
tempd,
33+
metadata_directory=metadata_directory,
34+
)
35+
except HookMissing as e:
36+
logger.error(
37+
"Cannot build editable %s because the build "
38+
"backend does not have the %s hook",
39+
name,
40+
e,
41+
)
42+
return None
43+
except Exception:
44+
logger.error("Failed building editable for %s", name)
45+
return None
46+
return os.path.join(tempd, wheel_name)

src/pip/_internal/req/constructors.py

+2-16
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
from pip._internal.models.index import PyPI, TestPyPI
2323
from pip._internal.models.link import Link
2424
from pip._internal.models.wheel import Wheel
25-
from pip._internal.pyproject import make_pyproject_path
2625
from pip._internal.req.req_file import ParsedRequirement
2726
from pip._internal.req.req_install import InstallRequirement
2827
from pip._internal.utils.filetypes import is_archive_file
@@ -75,21 +74,6 @@ def parse_editable(editable_req: str) -> Tuple[Optional[str], str, Set[str]]:
7574
url_no_extras, extras = _strip_extras(url)
7675

7776
if os.path.isdir(url_no_extras):
78-
setup_py = os.path.join(url_no_extras, "setup.py")
79-
setup_cfg = os.path.join(url_no_extras, "setup.cfg")
80-
if not os.path.exists(setup_py) and not os.path.exists(setup_cfg):
81-
msg = (
82-
'File "setup.py" or "setup.cfg" not found. Directory cannot be '
83-
"installed in editable mode: {}".format(os.path.abspath(url_no_extras))
84-
)
85-
pyproject_path = make_pyproject_path(url_no_extras)
86-
if os.path.isfile(pyproject_path):
87-
msg += (
88-
'\n(A "pyproject.toml" file was found, but editable '
89-
"mode currently requires a setuptools-based build.)"
90-
)
91-
raise InstallationError(msg)
92-
9377
# Treating it as code that has already been checked out
9478
url_no_extras = path_to_url(url_no_extras)
9579

@@ -197,6 +181,7 @@ def install_req_from_editable(
197181
options: Optional[Dict[str, Any]] = None,
198182
constraint: bool = False,
199183
user_supplied: bool = False,
184+
permit_editable_wheels: bool = False,
200185
) -> InstallRequirement:
201186

202187
parts = parse_req_from_editable(editable_req)
@@ -206,6 +191,7 @@ def install_req_from_editable(
206191
comes_from=comes_from,
207192
user_supplied=user_supplied,
208193
editable=True,
194+
permit_editable_wheels=permit_editable_wheels,
209195
link=parts.link,
210196
constraint=constraint,
211197
use_pep517=use_pep517,

0 commit comments

Comments
 (0)