Add PEP 660 support (build_editable)

This commit is contained in:
Stéphane Bidoul 2021-08-01 13:05:21 +02:00
parent d91fecdb5a
commit e5be3f796e
No known key found for this signature in database
GPG Key ID: BCAB2555446B5B92
13 changed files with 503 additions and 90 deletions

2
news/8212.feature.rst Normal file
View File

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

View File

@ -306,6 +306,12 @@ class InstallCommand(RequirementCommand):
try:
reqs = self.get_requirements(args, options, finder, session)
# Only when installing is it permitted to use PEP 660.
# In other circumstances (pip wheel, pip download) we generate
# regular (i.e. non editable) metadata and wheels.
for req in reqs:
req.permit_editable_wheels = True
reject_location_related_install_options(reqs, options.install_options)
preparer = self.make_requirement_preparer(

View File

@ -1,5 +1,5 @@
import logging
from typing import Set, Tuple
from typing import Iterable, Set, Tuple
from pip._internal.build_env import BuildEnvironment
from pip._internal.distributions.base import AbstractDistribution
@ -37,23 +37,17 @@ class SourceDistribution(AbstractDistribution):
self.req.prepare_metadata()
def _setup_isolation(self, finder: PackageFinder) -> None:
def _raise_conflicts(
conflicting_with: str, conflicting_reqs: Set[Tuple[str, str]]
) -> None:
format_string = (
"Some build dependencies for {requirement} "
"conflict with {conflicting_with}: {description}."
)
error_message = format_string.format(
requirement=self.req,
conflicting_with=conflicting_with,
description=", ".join(
f"{installed} is incompatible with {wanted}"
for installed, wanted in sorted(conflicting)
),
)
raise InstallationError(error_message)
self._prepare_build_backend(finder)
# 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:
build_reqs = self._get_build_requires_editable()
else:
build_reqs = self._get_build_requires_wheel()
self._install_build_reqs(finder, build_reqs)
def _prepare_build_backend(self, finder: PackageFinder) -> None:
# Isolate in a BuildEnvironment and install the build-time
# requirements.
pyproject_requires = self.req.pyproject_requires
@ -67,7 +61,7 @@ class SourceDistribution(AbstractDistribution):
self.req.requirements_to_check
)
if conflicting:
_raise_conflicts("PEP 517/518 supported requirements", conflicting)
self._raise_conflicts("PEP 517/518 supported requirements", conflicting)
if missing:
logger.warning(
"Missing build requirements in pyproject.toml for %s.",
@ -78,19 +72,46 @@ class SourceDistribution(AbstractDistribution):
"pip cannot fall back to setuptools without %s.",
" and ".join(map(repr, sorted(missing))),
)
# 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.
def _get_build_requires_wheel(self) -> Iterable[str]:
with self.req.build_env:
runner = runner_with_spinner_message("Getting requirements to build wheel")
backend = self.req.pep517_backend
assert backend is not None
with backend.subprocess_runner(runner):
reqs = backend.get_requires_for_build_wheel()
return backend.get_requires_for_build_wheel()
def _get_build_requires_editable(self) -> Iterable[str]:
with self.req.build_env:
runner = runner_with_spinner_message(
"Getting requirements to build editable"
)
backend = self.req.pep517_backend
assert backend is not None
with backend.subprocess_runner(runner):
return backend.get_requires_for_build_editable()
def _install_build_reqs(self, finder: PackageFinder, reqs: Iterable[str]) -> None:
conflicting, missing = self.req.build_env.check_requirements(reqs)
if conflicting:
_raise_conflicts("the backend dependencies", conflicting)
self._raise_conflicts("the backend dependencies", conflicting)
self.req.build_env.install_requirements(
finder, missing, "normal", "Installing backend dependencies"
)
def _raise_conflicts(
self, conflicting_with: str, conflicting_reqs: Set[Tuple[str, str]]
) -> None:
format_string = (
"Some build dependencies for {requirement} "
"conflict with {conflicting_with}: {description}."
)
error_message = format_string.format(
requirement=self.req,
conflicting_with=conflicting_with,
description=", ".join(
f"{installed} is incompatible with {wanted}"
for installed, wanted in sorted(conflicting_reqs)
),
)
raise InstallationError(error_message)

View File

@ -0,0 +1,32 @@
"""Metadata generation logic for source distributions.
"""
import os
from pip._vendor.pep517.wrappers import Pep517HookCaller
from pip._internal.build_env import BuildEnvironment
from pip._internal.utils.subprocess import runner_with_spinner_message
from pip._internal.utils.temp_dir import TempDirectory
def generate_editable_metadata(
build_env: BuildEnvironment, backend: Pep517HookCaller
) -> str:
"""Generate metadata using mechanisms described in PEP 660.
Returns the generated metadata directory.
"""
metadata_tmpdir = TempDirectory(kind="modern-metadata", globally_managed=True)
metadata_dir = metadata_tmpdir.path
with build_env:
# Note that Pep517HookCaller implements a fallback for
# 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 editable metadata")
with backend.subprocess_runner(runner):
distinfo_dir = backend.prepare_metadata_for_build_editable(metadata_dir)
return os.path.join(metadata_dir, distinfo_dir)

View File

@ -0,0 +1,46 @@
import logging
import os
from typing import Optional
from pip._vendor.pep517.wrappers import HookMissing, Pep517HookCaller
from pip._internal.utils.subprocess import runner_with_spinner_message
logger = logging.getLogger(__name__)
def build_wheel_editable(
name: str,
backend: Pep517HookCaller,
metadata_directory: str,
tempd: str,
) -> Optional[str]:
"""Build one InstallRequirement using the PEP 660 build process.
Returns path to wheel if successfully built. Otherwise, returns None.
"""
assert metadata_directory is not None
try:
logger.debug("Destination directory: %s", tempd)
runner = runner_with_spinner_message(
f"Building editable for {name} (pyproject.toml)"
)
with backend.subprocess_runner(runner):
try:
wheel_name = backend.build_editable(
tempd,
metadata_directory=metadata_directory,
)
except HookMissing as e:
logger.error(
"Cannot build editable %s because the build "
"backend does not have the %s hook",
name,
e,
)
return None
except Exception:
logger.error("Failed building editable for %s", name)
return None
return os.path.join(tempd, wheel_name)

View File

@ -22,7 +22,6 @@ from pip._internal.exceptions import InstallationError
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
@ -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)
@ -197,6 +181,7 @@ def install_req_from_editable(
options: Optional[Dict[str, Any]] = None,
constraint: bool = False,
user_supplied: bool = False,
permit_editable_wheels: bool = False,
) -> InstallRequirement:
parts = parse_req_from_editable(editable_req)
@ -206,6 +191,7 @@ def install_req_from_editable(
comes_from=comes_from,
user_supplied=user_supplied,
editable=True,
permit_editable_wheels=permit_editable_wheels,
link=parts.link,
constraint=constraint,
use_pep517=use_pep517,

View File

@ -16,7 +16,7 @@ from pip._vendor.packaging.specifiers import SpecifierSet
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
@ -24,6 +24,7 @@ from pip._internal.exceptions import InstallationError
from pip._internal.locations import get_scheme
from pip._internal.models.link import Link
from pip._internal.operations.build.metadata import generate_metadata
from pip._internal.operations.build.metadata_editable import generate_editable_metadata
from pip._internal.operations.build.metadata_legacy import (
generate_metadata as generate_metadata_legacy,
)
@ -36,7 +37,10 @@ from pip._internal.operations.install.wheel import install_wheel
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 (
@ -105,12 +109,14 @@ class InstallRequirement:
constraint: bool = False,
extras: Collection[str] = (),
user_supplied: bool = False,
permit_editable_wheels: 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.permit_editable_wheels = permit_editable_wheels
self.legacy_install_reason: Optional[int] = None
# source_dir is the local directory where the linked requirement is
@ -191,6 +197,11 @@ class InstallRequirement:
# 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
@ -455,6 +466,13 @@ class InstallRequirement:
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}"
@ -486,29 +504,75 @@ class InstallRequirement:
backend_path=backend_path,
)
def _generate_metadata(self) -> str:
def _generate_editable_metadata(self) -> str:
"""Invokes metadata generator functions, with the required arguments."""
if not self.use_pep517:
assert self.unpacked_source_directory
if not os.path.exists(self.setup_py_path):
raise InstallationError(
f'File "setup.py" not found for legacy project {self}.'
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,
)
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}",
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
else:
self.supports_pyproject_editable = True
return metadata_directory
elif 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."
)
assert self.pep517_backend is not None
return generate_metadata(
return generate_metadata_legacy(
build_env=self.build_env,
backend=self.pep517_backend,
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,
)
except HookMissing as e:
raise InstallationError(
f"Project {self} has a pyproject.toml but its build "
f"backend is missing the required {e} hook."
)
elif not os.path.exists(self.setup_py_path):
raise InstallationError(
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,
source_dir=self.unpacked_source_directory,
isolated=self.isolated,
details=self.name or f"from {self.link}",
)
def prepare_metadata(self) -> None:
@ -520,7 +584,10 @@ class InstallRequirement:
assert self.source_dir
with indent_log():
self.metadata_directory = self._generate_metadata()
if self.editable and self.permit_editable_wheels:
self.metadata_directory = self._generate_editable_metadata()
else:
self.metadata_directory = self._generate_metadata()
# Act on the newly generated metadata, based on the name and version.
if not self.name:
@ -728,7 +795,7 @@ class InstallRequirement:
)
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,
@ -747,7 +814,9 @@ class InstallRequirement:
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,

View File

@ -82,6 +82,7 @@ def make_install_req_from_editable(
use_pep517=template.use_pep517,
isolated=template.isolated,
constraint=template.constraint,
permit_editable_wheels=template.permit_editable_wheels,
options=dict(
install_options=template.install_options,
global_options=template.global_options,

View File

@ -2,6 +2,7 @@ from typing import Optional
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
@ -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:

View File

@ -16,6 +16,7 @@ from pip._internal.metadata import FilesystemWheel, get_wheel_distribution
from pip._internal.models.link import Link
from pip._internal.models.wheel import Wheel
from pip._internal.operations.build.wheel import build_wheel_pep517
from pip._internal.operations.build.wheel_editable import build_wheel_editable
from pip._internal.operations.build.wheel_legacy import build_wheel_legacy
from pip._internal.req.req_install import InstallRequirement
from pip._internal.utils.logging import indent_log
@ -66,7 +67,13 @@ def _should_build(
# From this point, this concerns the pip install command only
# (need_wheel=False).
if req.editable or not req.source_dir:
if not req.source_dir:
return False
if req.editable:
if req.use_pep517 and req.supports_pyproject_editable is not False:
return True
# we don't build legacy editable requirements
return False
if req.use_pep517:
@ -194,16 +201,19 @@ def _build_one(
verify: bool,
build_options: List[str],
global_options: List[str],
editable: bool,
) -> Optional[str]:
"""Build one wheel.
:return: The filename of the built wheel, or None if the build failed.
"""
artifact = "editable" if editable else "wheel"
try:
ensure_dir(output_dir)
except OSError as e:
logger.warning(
"Building wheel for %s failed: %s",
"Building %s for %s failed: %s",
artifact,
req.name,
e,
)
@ -212,13 +222,13 @@ def _build_one(
# Install build deps into temporary directory (PEP 518)
with req.build_env:
wheel_path = _build_one_inside_env(
req, output_dir, build_options, global_options
req, output_dir, build_options, global_options, editable
)
if wheel_path and verify:
try:
_verify_one(req, wheel_path)
except (InvalidWheelFilename, UnsupportedWheel) as e:
logger.warning("Built wheel for %s is invalid: %s", req.name, e)
logger.warning("Built %s for %s is invalid: %s", artifact, req.name, e)
return None
return wheel_path
@ -228,6 +238,7 @@ def _build_one_inside_env(
output_dir: str,
build_options: List[str],
global_options: List[str],
editable: bool,
) -> Optional[str]:
with TempDirectory(kind="wheel") as temp_dir:
assert req.name
@ -242,12 +253,20 @@ def _build_one_inside_env(
logger.warning(
"Ignoring --build-option when building %s using PEP 517", req.name
)
wheel_path = build_wheel_pep517(
name=req.name,
backend=req.pep517_backend,
metadata_directory=req.metadata_directory,
tempd=temp_dir.path,
)
if editable:
wheel_path = build_wheel_editable(
name=req.name,
backend=req.pep517_backend,
metadata_directory=req.metadata_directory,
tempd=temp_dir.path,
)
else:
wheel_path = build_wheel_pep517(
name=req.name,
backend=req.pep517_backend,
metadata_directory=req.metadata_directory,
tempd=temp_dir.path,
)
else:
wheel_path = build_wheel_legacy(
name=req.name,
@ -324,9 +343,15 @@ def build(
with indent_log():
build_successes, build_failures = [], []
for req in requirements:
assert req.name
cache_dir = _get_cache_dir(req, wheel_cache)
wheel_file = _build_one(
req, cache_dir, verify, build_options, global_options
req,
cache_dir,
verify,
build_options,
global_options,
req.editable and req.permit_editable_wheels,
)
if wheel_file:
# Update the link for this.

View File

@ -644,7 +644,7 @@ def test_install_from_local_directory_with_no_setup_py(script, data):
assert "Neither 'setup.py' nor 'pyproject.toml' found." in result.stderr
def test_editable_install__local_dir_no_setup_py(script, data, deprecated_python):
def test_editable_install__local_dir_no_setup_py(script, data):
"""
Test installing in editable mode from a local directory with no setup.py.
"""
@ -652,16 +652,12 @@ def test_editable_install__local_dir_no_setup_py(script, data, deprecated_python
assert not result.files_created
msg = result.stderr
if deprecated_python:
assert 'File "setup.py" or "setup.cfg" not found. ' in msg
else:
assert msg.startswith('ERROR: File "setup.py" or "setup.cfg" not found. ')
assert msg.startswith("ERROR: File 'setup.py' or 'setup.cfg' not found ")
assert "cannot be installed in editable mode" in msg
assert "pyproject.toml" not in msg
def test_editable_install__local_dir_no_setup_py_with_pyproject(
script, deprecated_python
):
def test_editable_install__local_dir_no_setup_py_with_pyproject(script):
"""
Test installing in editable mode from a local directory with no setup.py
but that does have pyproject.toml.
@ -675,11 +671,9 @@ def test_editable_install__local_dir_no_setup_py_with_pyproject(
assert not result.files_created
msg = result.stderr
if deprecated_python:
assert 'File "setup.py" or "setup.cfg" not found. ' in msg
else:
assert msg.startswith('ERROR: File "setup.py" or "setup.cfg" not found. ')
assert 'A "pyproject.toml" file was found' in msg
assert "has a 'pyproject.toml'" in msg
assert "does not have a 'setup.py' nor a 'setup.cfg'" in msg
assert "cannot be installed in editable mode" in msg
@pytest.mark.network

View File

@ -0,0 +1,209 @@
import os
import tomli_w
from pip._internal.utils.urls import path_to_url
SETUP_PY = """
from setuptools import setup
setup()
"""
SETUP_CFG = """
[metadata]
name = project
version = 1.0.0
"""
BACKEND_WITHOUT_PEP660 = """
from setuptools.build_meta import (
build_wheel as _build_wheel,
prepare_metadata_for_build_wheel as _prepare_metadata_for_build_wheel,
get_requires_for_build_wheel as _get_requires_for_build_wheel,
)
def get_requires_for_build_wheel(config_settings=None):
with open("log.txt", "a") as f:
print(":get_requires_for_build_wheel called", file=f)
return _get_requires_for_build_wheel(config_settings)
def prepare_metadata_for_build_wheel(metadata_directory, config_settings=None):
with open("log.txt", "a") as f:
print(":prepare_metadata_for_build_wheel called", file=f)
return _prepare_metadata_for_build_wheel(metadata_directory, config_settings)
def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):
with open("log.txt", "a") as f:
print(":build_wheel called", file=f)
return _build_wheel(wheel_directory, config_settings, metadata_directory)
"""
# fmt: off
BACKEND_WITH_PEP660 = BACKEND_WITHOUT_PEP660 + """
def get_requires_for_build_editable(config_settings=None):
with open("log.txt", "a") as f:
print(":get_requires_for_build_editable called", file=f)
return _get_requires_for_build_wheel(config_settings)
def prepare_metadata_for_build_editable(metadata_directory, config_settings=None):
with open("log.txt", "a") as f:
print(":prepare_metadata_for_build_editable called", file=f)
return _prepare_metadata_for_build_wheel(metadata_directory, config_settings)
def build_editable(wheel_directory, config_settings=None, metadata_directory=None):
with open("log.txt", "a") as f:
print(":build_editable called", file=f)
return _build_wheel(wheel_directory, config_settings, metadata_directory)
"""
# fmt: on
def _make_project(tmpdir, backend_code, with_setup_py):
project_dir = tmpdir / "project"
project_dir.mkdir()
project_dir.joinpath("setup.cfg").write_text(SETUP_CFG)
if with_setup_py:
project_dir.joinpath("setup.py").write_text(SETUP_PY)
if backend_code:
buildsys = {"requires": ["setuptools", "wheel"]}
buildsys["build-backend"] = "test_backend"
buildsys["backend-path"] = ["."]
data = tomli_w.dumps({"build-system": buildsys})
project_dir.joinpath("pyproject.toml").write_text(data)
project_dir.joinpath("test_backend.py").write_text(backend_code)
project_dir.joinpath("log.txt").touch()
return project_dir
def _assert_hook_called(project_dir, hook):
log = project_dir.joinpath("log.txt").read_text()
assert f":{hook} called" in log, f"{hook} has not been called"
def _assert_hook_not_called(project_dir, hook):
log = project_dir.joinpath("log.txt").read_text()
assert f":{hook} called" not in log, f"{hook} should not have been called"
def test_install_pep517_basic(tmpdir, script, with_wheel):
"""
Check that the test harness we have in this file is sane.
"""
project_dir = _make_project(tmpdir, BACKEND_WITHOUT_PEP660, with_setup_py=False)
script.pip(
"install",
"--use-feature=in-tree-build",
"--no-index",
"--no-build-isolation",
project_dir,
)
_assert_hook_called(project_dir, "prepare_metadata_for_build_wheel")
_assert_hook_called(project_dir, "build_wheel")
def test_install_pep660_basic(tmpdir, script, with_wheel):
"""
Test with backend that supports build_editable.
"""
project_dir = _make_project(tmpdir, BACKEND_WITH_PEP660, with_setup_py=False)
result = script.pip(
"install",
"--no-index",
"--no-build-isolation",
"--editable",
project_dir,
)
_assert_hook_called(project_dir, "prepare_metadata_for_build_editable")
_assert_hook_called(project_dir, "build_editable")
assert (
result.test_env.site_packages.joinpath("project.egg-link")
not in result.files_created
), "a .egg-link file should not have been created"
def test_install_no_pep660_setup_py_fallback(tmpdir, script, with_wheel):
"""
Test that we fall back to setuptools develop when using a backend that
does not support build_editable .
"""
project_dir = _make_project(tmpdir, BACKEND_WITHOUT_PEP660, with_setup_py=True)
result = script.pip(
"install",
"--no-index",
"--no-build-isolation",
"--editable",
project_dir,
allow_stderr_warning=False,
)
assert (
result.test_env.site_packages.joinpath("project.egg-link")
in result.files_created
), "a .egg-link file should have been created"
def test_install_no_pep660_setup_cfg_fallback(tmpdir, script, with_wheel):
"""
Test that we fall back to setuptools develop when using a backend that
does not support build_editable .
"""
project_dir = _make_project(tmpdir, BACKEND_WITHOUT_PEP660, with_setup_py=False)
result = script.pip(
"install",
"--no-index",
"--no-build-isolation",
"--editable",
project_dir,
allow_stderr_warning=False,
)
print(result.stdout, result.stderr)
assert (
result.test_env.site_packages.joinpath("project.egg-link")
in result.files_created
), ".egg-link file should have been created"
def test_wheel_editable_pep660_basic(tmpdir, script, with_wheel):
"""
Test 'pip wheel' of an editable pep 660 project.
It must *not* call prepare_metadata_for_build_editable.
"""
project_dir = _make_project(tmpdir, BACKEND_WITH_PEP660, with_setup_py=False)
wheel_dir = tmpdir / "dist"
script.pip(
"wheel",
"--no-index",
"--no-build-isolation",
"--editable",
project_dir,
"-w",
wheel_dir,
)
_assert_hook_not_called(project_dir, "prepare_metadata_for_build_editable")
_assert_hook_not_called(project_dir, "build_editable")
_assert_hook_called(project_dir, "prepare_metadata_for_build_wheel")
_assert_hook_called(project_dir, "build_wheel")
assert len(os.listdir(str(wheel_dir))) == 1, "a wheel should have been created"
def test_download_editable_pep660_basic(tmpdir, script, with_wheel):
"""
Test 'pip download' of an editable pep 660 project.
It must *not* call prepare_metadata_for_build_editable.
"""
project_dir = _make_project(tmpdir, BACKEND_WITH_PEP660, with_setup_py=False)
reqs_file = tmpdir / "requirements.txt"
reqs_file.write_text(f"-e {path_to_url(project_dir)}\n")
download_dir = tmpdir / "download"
script.pip(
"download",
"--no-index",
"--no-build-isolation",
"-r",
reqs_file,
"-d",
download_dir,
)
_assert_hook_not_called(project_dir, "prepare_metadata_for_build_editable")
_assert_hook_called(project_dir, "prepare_metadata_for_build_wheel")
assert len(os.listdir(str(download_dir))) == 1, "a zip should have been created"

View File

@ -39,6 +39,7 @@ class ReqMock:
constraint: bool = False,
source_dir: Optional[str] = "/tmp/pip-install-123/pendulum",
use_pep517: bool = True,
supports_pyproject_editable: Optional[bool] = None,
) -> None:
self.name = name
self.is_wheel = is_wheel
@ -47,6 +48,7 @@ class ReqMock:
self.constraint = constraint
self.source_dir = source_dir
self.use_pep517 = use_pep517
self.supports_pyproject_editable = supports_pyproject_editable
@pytest.mark.parametrize(
@ -63,8 +65,18 @@ class ReqMock:
(ReqMock(constraint=True), False, False),
# We don't build reqs that are already wheels.
(ReqMock(is_wheel=True), False, False),
# We don't build editables.
(ReqMock(editable=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,
True,
),
(
ReqMock(editable=True, use_pep517=True, supports_pyproject_editable=False),
False,
False,
),
(ReqMock(source_dir=None), False, False),
# By default (i.e. when binaries are allowed), VCS requirements
# should be built in install mode.
@ -108,7 +120,8 @@ def test_should_build_for_install_command(
(ReqMock(), True),
(ReqMock(constraint=True), False),
(ReqMock(is_wheel=True), False),
(ReqMock(editable=True), True),
(ReqMock(editable=True, use_pep517=False), True),
(ReqMock(editable=True, use_pep517=True), True),
(ReqMock(source_dir=None), True),
(ReqMock(link=Link("git+https://g.c/org/repo")), True),
],
@ -145,7 +158,8 @@ def test_should_build_legacy_wheel_installed(is_wheel_installed: mock.Mock) -> N
@pytest.mark.parametrize(
"req, expected",
[
(ReqMock(editable=True), False),
(ReqMock(editable=True, use_pep517=False), False),
(ReqMock(editable=True, use_pep517=True), False),
(ReqMock(source_dir=None), False),
(ReqMock(link=Link("git+https://g.c/org/repo")), False),
(ReqMock(link=Link("https://g.c/dist.tgz")), False),