diff --git a/news/8212.feature.rst b/news/8212.feature.rst new file mode 100644 index 00000000000..dec2240e0bb --- /dev/null +++ b/news/8212.feature.rst @@ -0,0 +1,2 @@ +Support editable installs for projects that have a ``pyproject.toml`` and use a +build backend that supports :pep:`660`. diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py index a427c6c5929..3d87e1af12d 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -306,6 +306,12 @@ def run(self, options: Values, args: List[str]) -> int: 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( @@ -361,22 +367,22 @@ def run(self, options: Values, args: List[str]) -> int: global_options=[], ) - # If we're using PEP 517, we cannot do a direct install + # If we're using PEP 517, we cannot do a legacy setup.py install # so we fail here. pep517_build_failure_names: List[str] = [ r.name for r in build_failures if r.use_pep517 # type: ignore ] if pep517_build_failure_names: raise InstallationError( - "Could not build wheels for {} which use" - " PEP 517 and cannot be installed directly".format( + "Could not build wheels for {}, which is required to " + "install pyproject.toml-based projects".format( ", ".join(pep517_build_failure_names) ) ) # For now, we just warn about failures building legacy - # requirements, as we'll fall through to a direct - # install for those. + # requirements, as we'll fall through to a setup.py install for + # those. for r in build_failures: if not r.use_pep517: r.legacy_install_reason = 8368 diff --git a/src/pip/_internal/distributions/sdist.py b/src/pip/_internal/distributions/sdist.py index 573fefae7ba..b4e2892931b 100644 --- a/src/pip/_internal/distributions/sdist.py +++ b/src/pip/_internal/distributions/sdist.py @@ -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 @@ def prepare_distribution_metadata( 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 @@ def _raise_conflicts( 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 @@ def _raise_conflicts( "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) diff --git a/src/pip/_internal/operations/build/metadata.py b/src/pip/_internal/operations/build/metadata.py index e7672352715..e99af4697c9 100644 --- a/src/pip/_internal/operations/build/metadata.py +++ b/src/pip/_internal/operations/build/metadata.py @@ -23,7 +23,9 @@ def generate_metadata(build_env: BuildEnvironment, backend: Pep517HookCaller) -> # Note that Pep517HookCaller implements a fallback for # prepare_metadata_for_build_wheel, 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 wheel metadata (pyproject.toml)" + ) with backend.subprocess_runner(runner): distinfo_dir = backend.prepare_metadata_for_build_wheel(metadata_dir) diff --git a/src/pip/_internal/operations/build/metadata_editable.py b/src/pip/_internal/operations/build/metadata_editable.py new file mode 100644 index 00000000000..13de75f6c98 --- /dev/null +++ b/src/pip/_internal/operations/build/metadata_editable.py @@ -0,0 +1,34 @@ +"""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 (pyproject.toml)" + ) + with backend.subprocess_runner(runner): + distinfo_dir = backend.prepare_metadata_for_build_editable(metadata_dir) + + return os.path.join(metadata_dir, distinfo_dir) diff --git a/src/pip/_internal/operations/build/metadata_legacy.py b/src/pip/_internal/operations/build/metadata_legacy.py index 2013046fcf2..ff52de9c4cf 100644 --- a/src/pip/_internal/operations/build/metadata_legacy.py +++ b/src/pip/_internal/operations/build/metadata_legacy.py @@ -5,6 +5,7 @@ import os from pip._internal.build_env import BuildEnvironment +from pip._internal.cli.spinners import open_spinner from pip._internal.exceptions import InstallationError from pip._internal.utils.setuptools_build import make_setuptools_egg_info_args from pip._internal.utils.subprocess import call_subprocess @@ -54,11 +55,13 @@ def generate_metadata( ) with build_env: - call_subprocess( - args, - cwd=source_dir, - command_desc="python setup.py egg_info", - ) + with open_spinner("Preparing metadata (setup.py)") as spinner: + call_subprocess( + args, + cwd=source_dir, + command_desc="python setup.py egg_info", + spinner=spinner, + ) # Return the .egg-info directory. return _find_egg_info(egg_info_dir) diff --git a/src/pip/_internal/operations/build/wheel.py b/src/pip/_internal/operations/build/wheel.py index 6249a9bfdb1..b0d2fc9eadb 100644 --- a/src/pip/_internal/operations/build/wheel.py +++ b/src/pip/_internal/operations/build/wheel.py @@ -23,7 +23,9 @@ def build_wheel_pep517( try: logger.debug("Destination directory: %s", tempd) - runner = runner_with_spinner_message(f"Building wheel for {name} (PEP 517)") + runner = runner_with_spinner_message( + f"Building wheel for {name} (pyproject.toml)" + ) with backend.subprocess_runner(runner): wheel_name = backend.build_wheel( tempd, diff --git a/src/pip/_internal/operations/build/wheel_editable.py b/src/pip/_internal/operations/build/wheel_editable.py new file mode 100644 index 00000000000..cf7b01aed5a --- /dev/null +++ b/src/pip/_internal/operations/build/wheel_editable.py @@ -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) diff --git a/src/pip/_internal/req/constructors.py b/src/pip/_internal/req/constructors.py index b9b18139aaf..0a6b3367e1e 100644 --- a/src/pip/_internal/req/constructors.py +++ b/src/pip/_internal/req/constructors.py @@ -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 @@ -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, diff --git a/src/pip/_internal/req/req_install.py b/src/pip/_internal/req/req_install.py index 0df0ff6f438..933b2ea6709 100644 --- a/src/pip/_internal/req/req_install.py +++ b/src/pip/_internal/req/req_install.py @@ -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 @@ -24,6 +24,7 @@ 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.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 @@ def __init__( 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 @@ def __init__( # 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 @@ 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}" @@ -486,29 +504,79 @@ def load_pyproject_toml(self) -> None: 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 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, + ) + 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 + logger.info( + "Build backend does not support editables, " + "falling back to setup.py egg_info." + ) + 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." + ) + + 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}", + ) - if not os.path.exists(self.setup_py_path): + 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'File "setup.py" not found for legacy project {self}.' + f"Project {self} has a pyproject.toml but its build " + f"backend is missing the required {e} hook." ) - - 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}", + elif not os.path.exists(self.setup_py_path): + raise InstallationError( + f"File 'setup.py' not found for legacy project {self}." ) - 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 prepare_metadata(self) -> None: @@ -520,7 +588,10 @@ def prepare_metadata(self) -> None: 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 +799,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, @@ -747,7 +818,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, diff --git a/src/pip/_internal/resolution/resolvelib/candidates.py b/src/pip/_internal/resolution/resolvelib/candidates.py index 5c6f9a04a97..60fad55db00 100644 --- a/src/pip/_internal/resolution/resolvelib/candidates.py +++ b/src/pip/_internal/resolution/resolvelib/candidates.py @@ -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, diff --git a/src/pip/_internal/utils/direct_url_helpers.py b/src/pip/_internal/utils/direct_url_helpers.py index 088e977b5b1..0e8e5e1608b 100644 --- a/src/pip/_internal/utils/direct_url_helpers.py +++ b/src/pip/_internal/utils/direct_url_helpers.py @@ -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 @@ -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: diff --git a/src/pip/_internal/wheel_builder.py b/src/pip/_internal/wheel_builder.py index cf039fe763b..b4855a9a7fb 100644 --- a/src/pip/_internal/wheel_builder.py +++ b/src/pip/_internal/wheel_builder.py @@ -16,6 +16,7 @@ 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. diff --git a/tests/functional/test_install.py b/tests/functional/test_install.py index 3af75cb5639..368973d9b99 100644 --- a/tests/functional/test_install.py +++ b/tests/functional/test_install.py @@ -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 diff --git a/tests/functional/test_pep660.py b/tests/functional/test_pep660.py new file mode 100644 index 00000000000..b7d97c8d8d8 --- /dev/null +++ b/tests/functional/test_pep660.py @@ -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" diff --git a/tests/unit/test_wheel_builder.py b/tests/unit/test_wheel_builder.py index 30b4060212d..2b8ec2fa94d 100644 --- a/tests/unit/test_wheel_builder.py +++ b/tests/unit/test_wheel_builder.py @@ -39,6 +39,7 @@ def __init__( 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 @@ def __init__( 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 @@ def __init__( (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),