Skip to content

Commit e5be3f7

Browse files
committed
Add PEP 660 support (build_editable)
1 parent d91fecd commit e5be3f7

File tree

13 files changed

+501
-88
lines changed

13 files changed

+501
-88
lines changed

news/8212.feature.rst

Lines changed: 2 additions & 0 deletions
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

Lines changed: 6 additions & 0 deletions
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(

src/pip/_internal/distributions/sdist.py

Lines changed: 44 additions & 23 deletions
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)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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("Preparing editable metadata")
29+
with backend.subprocess_runner(runner):
30+
distinfo_dir = backend.prepare_metadata_for_build_editable(metadata_dir)
31+
32+
return os.path.join(metadata_dir, distinfo_dir)
Lines changed: 46 additions & 0 deletions
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

Lines changed: 2 additions & 16 deletions
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)