diff --git a/news/10421.feature.rst b/news/10421.feature.rst new file mode 100644 index 00000000000..af09dae1d86 --- /dev/null +++ b/news/10421.feature.rst @@ -0,0 +1,2 @@ +Present clearer errors when an invalid editable requirement is given or +when a project's build backend does not support editable installs (PEP 660). diff --git a/src/pip/_internal/exceptions.py b/src/pip/_internal/exceptions.py index 2587740f73a..eb72865e99b 100644 --- a/src/pip/_internal/exceptions.py +++ b/src/pip/_internal/exceptions.py @@ -9,6 +9,7 @@ import contextlib import locale import logging +import os import pathlib import re import sys @@ -775,3 +776,42 @@ def __init__(self, *, distribution: "BaseDistribution") -> None: ), hint_stmt=None, ) + + +class InvalidEditableRequirement(DiagnosticPipError): + reference = "invalid-editable-requirement" + + def __init__(self, *, requirement: str, vcs_schemes: List[str]) -> None: + if os.path.sep in requirement and "://" not in requirement: + hint = ( + "It appears to be a path, but does not exist. Was the right path given?" + ) + else: + hint = ( + "It should either be a path to a local project or a VCS URL " + f"(beginning with {', '.join(vcs_schemes)})." + ) + + super().__init__( + message=Text(f"{requirement} is not a valid editable requirement"), + context=( + "There would be no source tree that can be edited after installation." + ), + hint_stmt=hint, + ) + + +class EditableUnsupportedByBackend(DiagnosticPipError): + reference = "editable-mode-unsupported-by-backend" + + def __init__(self, *, requirement: "InstallRequirement", backend: str) -> None: + super().__init__( + message=f"Cannot install {requirement} in editable mode", + context=( + f"The project's build backend ([magenta]{backend}[/]) does " + "not support editable installs.\n" + "Falling back to a setuptools legacy editable install is " + "unsupported as neither a 'setup.py' nor a 'setup.cfg' was found." + ), + hint_stmt=Text("Consider using a build backend that supports PEP 660."), + ) diff --git a/src/pip/_internal/req/constructors.py b/src/pip/_internal/req/constructors.py index b8e170f2a70..b4acaeb32ce 100644 --- a/src/pip/_internal/req/constructors.py +++ b/src/pip/_internal/req/constructors.py @@ -19,7 +19,7 @@ from pip._vendor.packaging.requirements import InvalidRequirement, Requirement from pip._vendor.packaging.specifiers import Specifier -from pip._internal.exceptions import InstallationError +from pip._internal.exceptions import InstallationError, InvalidEditableRequirement from pip._internal.models.index import PyPI, TestPyPI from pip._internal.models.link import Link from pip._internal.models.wheel import Wheel @@ -123,11 +123,8 @@ def parse_editable(editable_req: str) -> Tuple[Optional[str], str, Set[str]]: link = Link(url) if not link.is_vcs: - backends = ", ".join(vcs.all_schemes) - raise InstallationError( - f"{editable_req} is not a valid editable requirement. " - f"It should either be a path to a local project or a VCS URL " - f"(beginning with {backends})." + raise InvalidEditableRequirement( + requirement=editable_req, vcs_schemes=vcs.all_schemes ) package_name = link.egg_fragment diff --git a/src/pip/_internal/req/req_install.py b/src/pip/_internal/req/req_install.py index 213278588d2..8173ec3128c 100644 --- a/src/pip/_internal/req/req_install.py +++ b/src/pip/_internal/req/req_install.py @@ -18,7 +18,11 @@ from pip._vendor.pyproject_hooks import BuildBackendHookCaller from pip._internal.build_env import BuildEnvironment, NoOpBuildEnvironment -from pip._internal.exceptions import InstallationError, PreviousBuildDirError +from pip._internal.exceptions import ( + EditableUnsupportedByBackend, + InstallationError, + PreviousBuildDirError, +) from pip._internal.locations import get_scheme from pip._internal.metadata import ( BaseDistribution, @@ -541,12 +545,9 @@ def isolated_editable_sanity_check(self) -> None: and not os.path.isfile(self.setup_py_path) and not os.path.isfile(self.setup_cfg_path) ): - raise InstallationError( - f"Project {self} has a 'pyproject.toml' and its build " - f"backend is missing the 'build_editable' 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." + assert self.pep517_backend is not None, "backend should be loaded!" + raise EditableUnsupportedByBackend( + requirement=self, backend=self.pep517_backend.build_backend ) def prepare_metadata(self) -> None: diff --git a/tests/functional/test_install.py b/tests/functional/test_install.py index eaea12a163c..49c2f7aaa16 100644 --- a/tests/functional/test_install.py +++ b/tests/functional/test_install.py @@ -879,9 +879,8 @@ def test_editable_install_legacy__local_dir_no_setup_py_with_pyproject( assert not result.files_created msg = result.stderr - 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 + assert "editable-mode-unsupported-by-backend" in msg + assert "setuptools.build_meta" in msg def test_editable_install__local_dir_setup_requires_with_pyproject( diff --git a/tests/functional/test_pep660.py b/tests/functional/test_pep660.py index d562d0750db..e23a5deecc1 100644 --- a/tests/functional/test_pep660.py +++ b/tests/functional/test_pep660.py @@ -62,11 +62,17 @@ def build_editable(wheel_directory, config_settings=None, metadata_directory=Non def _make_project( - tmpdir: Path, backend_code: str, with_setup_py: bool, with_pyproject: bool = True + tmpdir: Path, + backend_code: str, + *, + with_setup_py: bool, + with_setup_cfg: bool = True, + with_pyproject: bool = True, ) -> Path: project_dir = tmpdir / "project" project_dir.mkdir() - project_dir.joinpath("setup.cfg").write_text(SETUP_CFG) + if with_setup_cfg: + 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: @@ -259,3 +265,19 @@ def test_download_editable_pep660_basic( _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" + + +def test_install_editable_unsupported_by_backend( + tmpdir: Path, script: PipTestEnvironment +) -> None: + """ + Check that pip errors out when installing a project whose backend does not + support PEP 660 and falling back to a legacy editable install is impossible + (no 'setup.py' or 'setup.py.cfg'). + """ + project_dir = _make_project( + tmpdir, BACKEND_WITHOUT_PEP660, with_setup_py=False, with_setup_cfg=False + ) + result = script.pip("install", "--editable", project_dir, expect_error=True) + assert "editable-mode-unsupported-by-backend" in result.stderr + assert "Consider using a build backend that supports PEP 660" in result.stderr