diff --git a/news/10421.feature.rst b/news/10421.feature.rst new file mode 100644 index 00000000000..a5fec3312d8 --- /dev/null +++ b/news/10421.feature.rst @@ -0,0 +1 @@ +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 29acd9babc6..c7094e97625 100644 --- a/src/pip/_internal/exceptions.py +++ b/src/pip/_internal/exceptions.py @@ -725,3 +725,35 @@ def from_config( exc_info = logger.isEnabledFor(VERBOSE) logger.warning("Failed to read %s", config, exc_info=exc_info) return cls(None) + + +class InvalidEditableRequirement(DiagnosticPipError): + reference = "invalid-editable-requirement" + + def __init__(self, *, requirement: str, vcs_schemes: List[str]) -> None: + 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=( + "It should either be a path to a local project or a VCS URL " + f"(beginning with {', '.join(vcs_schemes)})." + ), + ) + + +class EditableUnsupportedByBackend(DiagnosticPipError): + reference = "editable-mode-unsupported-by-backend" + + def __init__(self, *, requirement: "InstallRequirement") -> None: + super().__init__( + message=Text(f"Cannot install {requirement} in editable mode."), + context=( + "The project has a 'pyproject.toml' and its build backend is missing " + "the 'build_editable' hook.\n" + "Since it does not have a 'setup.py' nor a 'setup.cfg', " + "it cannot be installed in editable mode. " + ), + 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 7e2d0e5b879..c8730916472 100644 --- a/src/pip/_internal/req/constructors.py +++ b/src/pip/_internal/req/constructors.py @@ -18,7 +18,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 @@ -122,11 +122,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 49c88fca187..189dde75bd5 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, @@ -546,13 +550,7 @@ 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." - ) + raise EditableUnsupportedByBackend(requirement=self) def prepare_metadata(self) -> None: """Ensure that project metadata is available. 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