Skip to content

Commit

Permalink
Add PEP 660 support (build_wheel_for_editable)
Browse files Browse the repository at this point in the history
  • Loading branch information
sbidoul committed Sep 21, 2021
1 parent 558d86b commit ae0bda5
Show file tree
Hide file tree
Showing 13 changed files with 375 additions and 69 deletions.
2 changes: 2 additions & 0 deletions news/8212.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Support editable installs for projects that have a ``pyproject.toml`` and use a
build backend that supports `PEP 660 <https://www.python.org/dev/peps/pep-0660/>`_.
5 changes: 5 additions & 0 deletions src/pip/_internal/commands/wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ def run(self, options: Values, args: List[str]) -> int:

reqs = self.get_requirements(args, options, finder, session)

# Since we want to build regular wheels, disable the building of
# editable wheels.
for req in reqs:
req.no_build_editable = True

preparer = self.make_requirement_preparer(
temp_build_dir=directory,
options=options,
Expand Down
11 changes: 9 additions & 2 deletions src/pip/_internal/distributions/sdist.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,18 @@ def _raise_conflicts(
# This must be done in a second pass, as the pyproject.toml
# dependencies must be installed before we can call the backend.
with self.req.build_env:
runner = runner_with_spinner_message("Getting requirements to build wheel")
runner = runner_with_spinner_message(
"Getting requirements to build {}".format(
"editable" if self.req.editable else "wheel"
)
)
backend = self.req.pep517_backend
assert backend is not None
with backend.subprocess_runner(runner):
reqs = backend.get_requires_for_build_wheel()
if self.req.editable and not self.req.no_build_editable:
reqs = backend.get_requires_for_build_editable()
else:
reqs = backend.get_requires_for_build_wheel()

conflicting, missing = self.req.build_env.check_requirements(reqs)
if conflicting:
Expand Down
17 changes: 12 additions & 5 deletions src/pip/_internal/operations/build/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
from pip._internal.utils.temp_dir import TempDirectory


def generate_metadata(build_env: BuildEnvironment, backend: Pep517HookCaller) -> str:
"""Generate metadata using mechanisms described in PEP 517.
def generate_metadata(
build_env: BuildEnvironment, backend: Pep517HookCaller, editable: bool
) -> str:
"""Generate metadata using mechanisms described in PEP 517 and PEP 660.
Returns the generated metadata directory.
"""
Expand All @@ -21,10 +23,15 @@ def generate_metadata(build_env: BuildEnvironment, backend: Pep517HookCaller) ->

with build_env:
# Note that Pep517HookCaller implements a fallback for
# prepare_metadata_for_build_wheel, so we don't have to
# prepare_metadata_for_build_wheel/editable, so we don't have to
# consider the possibility that this hook doesn't exist.
runner = runner_with_spinner_message("Preparing wheel metadata")
runner = runner_with_spinner_message(
"Preparing {} metadata".format("editable" if editable else "wheel")
)
with backend.subprocess_runner(runner):
distinfo_dir = backend.prepare_metadata_for_build_wheel(metadata_dir)
if editable:
distinfo_dir = backend.prepare_metadata_for_build_editable(metadata_dir)
else:
distinfo_dir = backend.prepare_metadata_for_build_wheel(metadata_dir)

return os.path.join(metadata_dir, distinfo_dir)
35 changes: 28 additions & 7 deletions src/pip/_internal/operations/build/wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import os
from typing import Optional

from pip._vendor.pep517.wrappers import Pep517HookCaller
from pip._vendor.pep517.wrappers import HookMissing, Pep517HookCaller

from pip._internal.utils.subprocess import runner_with_spinner_message

Expand All @@ -14,6 +14,7 @@ def build_wheel_pep517(
backend: Pep517HookCaller,
metadata_directory: str,
tempd: str,
editable: bool,
) -> Optional[str]:
"""Build one InstallRequirement using the PEP 517 build process.
Expand All @@ -23,13 +24,33 @@ def build_wheel_pep517(
try:
logger.debug("Destination directory: %s", tempd)

runner = runner_with_spinner_message(f"Building wheel for {name} (PEP 517)")
artifact = "editable" if editable else "wheel"
pep = "660" if editable else "517"
runner = runner_with_spinner_message(
f"Building {artifact} for {name} (PEP {pep})"
)
with backend.subprocess_runner(runner):
wheel_name = backend.build_wheel(
tempd,
metadata_directory=metadata_directory,
)
if editable:
try:
wheel_name = backend.build_editable(
tempd,
metadata_directory=metadata_directory,
)
except HookMissing as e:
logger.warning(
"Cannot build %s %s because the build "
"backend does not have the %s hook",
artifact,
name,
e,
)
return None
else:
wheel_name = backend.build_wheel(
tempd,
metadata_directory=metadata_directory,
)
except Exception:
logger.error("Failed building wheel for %s", name)
logger.exception("Failed building %s for %s", artifact, name, exc_info=True)
return None
return os.path.join(tempd, wheel_name)
18 changes: 2 additions & 16 deletions src/pip/_internal/req/constructors.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
from pip._internal.models.index import PyPI, TestPyPI
from pip._internal.models.link import Link
from pip._internal.models.wheel import Wheel
from pip._internal.pyproject import make_pyproject_path
from pip._internal.req.req_file import ParsedRequirement
from pip._internal.req.req_install import InstallRequirement
from pip._internal.utils.filetypes import is_archive_file
Expand Down Expand Up @@ -75,21 +74,6 @@ def parse_editable(editable_req: str) -> Tuple[Optional[str], str, Set[str]]:
url_no_extras, extras = _strip_extras(url)

if os.path.isdir(url_no_extras):
setup_py = os.path.join(url_no_extras, "setup.py")
setup_cfg = os.path.join(url_no_extras, "setup.cfg")
if not os.path.exists(setup_py) and not os.path.exists(setup_cfg):
msg = (
'File "setup.py" or "setup.cfg" not found. Directory cannot be '
"installed in editable mode: {}".format(os.path.abspath(url_no_extras))
)
pyproject_path = make_pyproject_path(url_no_extras)
if os.path.isfile(pyproject_path):
msg += (
'\n(A "pyproject.toml" file was found, but editable '
"mode currently requires a setuptools-based build.)"
)
raise InstallationError(msg)

# Treating it as code that has already been checked out
url_no_extras = path_to_url(url_no_extras)

Expand Down Expand Up @@ -197,6 +181,7 @@ def install_req_from_editable(
options: Optional[Dict[str, Any]] = None,
constraint: bool = False,
user_supplied: bool = False,
no_build_editable: bool = False,
) -> InstallRequirement:

parts = parse_req_from_editable(editable_req)
Expand All @@ -206,6 +191,7 @@ def install_req_from_editable(
comes_from=comes_from,
user_supplied=user_supplied,
editable=True,
no_build_editable=no_build_editable,
link=parts.link,
constraint=constraint,
use_pep517=use_pep517,
Expand Down
102 changes: 85 additions & 17 deletions src/pip/_internal/req/req_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,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 Pep517HookCaller
from pip._vendor.pep517.wrappers import HookMissing, Pep517HookCaller
from pip._vendor.pkg_resources import Distribution

from pip._internal.build_env import BuildEnvironment, NoOpBuildEnvironment
Expand All @@ -36,7 +36,10 @@
from pip._internal.pyproject import load_pyproject_toml, make_pyproject_path
from pip._internal.req.req_uninstall import UninstallPathSet
from pip._internal.utils.deprecation import deprecated
from pip._internal.utils.direct_url_helpers import direct_url_from_link
from pip._internal.utils.direct_url_helpers import (
direct_url_for_editable,
direct_url_from_link,
)
from pip._internal.utils.hashes import Hashes
from pip._internal.utils.logging import indent_log
from pip._internal.utils.misc import (
Expand Down Expand Up @@ -105,12 +108,14 @@ def __init__(
constraint: bool = False,
extras: Iterable[str] = (),
user_supplied: bool = False,
no_build_editable: bool = False,
) -> None:
assert req is None or isinstance(req, Requirement), req
self.req = req
self.comes_from = comes_from
self.constraint = constraint
self.editable = editable
self.no_build_editable = no_build_editable
self.legacy_install_reason: Optional[int] = None

# source_dir is the local directory where the linked requirement is
Expand Down Expand Up @@ -190,6 +195,9 @@ def __init__(
# Setting an explicit value before loading pyproject.toml is supported,
# but after loading this flag should be treated as read only.
self.use_pep517 = use_pep517
# Supports_pep660 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_pep660: Optional[bool] = None

# This requirement needs more preparation before it can be built
self.needs_more_preparation = False
Expand Down Expand Up @@ -455,6 +463,13 @@ def setup_py_path(self) -> str:

return setup_py

@property
def setup_cfg_path(self) -> str:
assert self.source_dir, f"No source dir for {self}"
setup_cfg = os.path.join(self.unpacked_source_directory, "setup.cfg")

return setup_cfg

@property
def pyproject_toml_path(self) -> str:
assert self.source_dir, f"No source dir for {self}"
Expand Down Expand Up @@ -486,16 +501,71 @@ def load_pyproject_toml(self) -> None:
backend_path=backend_path,
)

def _generate_metadata(self) -> str:
def _generate_metadata_for_editable(self) -> str:
"""Invokes metadata generator functions, with the required arguments."""
if not self.use_pep517:
assert self.unpacked_source_directory
if self.use_pep517:
assert self.pep517_backend is not None
try:
metadata_directory = generate_metadata(
build_env=self.build_env,
backend=self.pep517_backend,
editable=True,
)
self.supports_pep660 = True
return metadata_directory
except HookMissing as e:
self.supports_pep660 = 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
else:
if not os.path.exists(self.setup_py_path) and not os.path.exists(
self.setup_cfg_path
):
raise InstallationError(
f"File 'setup.py' or 'setup.cfg' not found "
f"for legacy project {self}. "
f"It cannot be installed in editable mode."
)

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(
build_env=self.build_env,
backend=self.pep517_backend,
editable=False,
)
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:
if not os.path.exists(self.setup_py_path):
raise InstallationError(
f'File "setup.py" not found for legacy project {self}.'
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,
Expand All @@ -504,13 +574,6 @@ def _generate_metadata(self) -> str:
details=self.name or f"from {self.link}",
)

assert self.pep517_backend is not None

return generate_metadata(
build_env=self.build_env,
backend=self.pep517_backend,
)

def prepare_metadata(self) -> None:
"""Ensure that project metadata is available.
Expand All @@ -520,7 +583,10 @@ def prepare_metadata(self) -> None:
assert self.source_dir

with indent_log():
self.metadata_directory = self._generate_metadata()
if self.editable and not self.no_build_editable:
self.metadata_directory = self._generate_metadata_for_editable()
else:
self.metadata_directory = self._generate_metadata()

# Act on the newly generated metadata, based on the name and version.
if not self.name:
Expand Down Expand Up @@ -728,7 +794,7 @@ def install(
)

global_options = global_options if global_options is not None else []
if self.editable:
if self.editable and not self.is_wheel:
install_editable_legacy(
install_options,
global_options,
Expand All @@ -747,7 +813,9 @@ def install(
if self.is_wheel:
assert self.local_file_path
direct_url = None
if self.original_link:
if self.editable:
direct_url = direct_url_for_editable(self.unpacked_source_directory)
elif self.original_link:
direct_url = direct_url_from_link(
self.original_link,
self.source_dir,
Expand Down
1 change: 1 addition & 0 deletions src/pip/_internal/resolution/resolvelib/candidates.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def make_install_req_from_editable(
use_pep517=template.use_pep517,
isolated=template.isolated,
constraint=template.constraint,
no_build_editable=template.no_build_editable,
options=dict(
install_options=template.install_options,
global_options=template.global_options,
Expand Down
8 changes: 8 additions & 0 deletions src/pip/_internal/utils/direct_url_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from pip._internal.models.direct_url import ArchiveInfo, DirectUrl, DirInfo, VcsInfo
from pip._internal.models.link import Link
from pip._internal.utils.urls import path_to_url
from pip._internal.vcs import vcs


Expand All @@ -28,6 +29,13 @@ def direct_url_as_pep440_direct_reference(direct_url: DirectUrl, name: str) -> s
return requirement


def direct_url_for_editable(source_dir: str) -> DirectUrl:
return DirectUrl(
url=path_to_url(source_dir),
info=DirInfo(editable=True),
)


def direct_url_from_link(
link: Link, source_dir: Optional[str] = None, link_is_in_wheel_cache: bool = False
) -> DirectUrl:
Expand Down
Loading

0 comments on commit ae0bda5

Please sign in to comment.