diff --git a/news/13588.feature.rst b/news/13588.feature.rst new file mode 100644 index 00000000000..87c56881bae --- /dev/null +++ b/news/13588.feature.rst @@ -0,0 +1 @@ +On ``ResolutionImpossible`` errors, include a note about causes with no candidates. diff --git a/src/pip/_internal/resolution/resolvelib/factory.py b/src/pip/_internal/resolution/resolvelib/factory.py index f23e4cd6258..87c586c6ee1 100644 --- a/src/pip/_internal/resolution/resolvelib/factory.py +++ b/src/pip/_internal/resolution/resolvelib/factory.py @@ -711,6 +711,12 @@ def _report_single_requirement_conflict( return DistributionNotFound(f"No matching distribution found for {req}") + def _has_any_candidates(self, project_name: str) -> bool: + """ + Check if there are any candidates available for the project name. + """ + return any(self._finder.find_all_candidates(project_name)) + def get_installation_error( self, e: ResolutionImpossible[Requirement, Candidate], @@ -796,6 +802,22 @@ def describe_trigger(parent: Candidate) -> str: spec = constraints[key].specifier msg += f"\n The user requested (constraint) {key}{spec}" + # Check for causes that had no candidates + causes = set() + for req, _ in e.causes: + causes.add(req.name) + + no_candidates = {c for c in causes if not self._has_any_candidates(c)} + if no_candidates: + msg = ( + msg + + "\n\n" + + "Additionally, some projects in these conflicts have no " + + "matching distributions available for your environment:" + + "\n " + + "\n ".join(sorted(no_candidates)) + ) + msg = ( msg + "\n\n" diff --git a/tests/functional/test_new_resolver_errors.py b/tests/functional/test_new_resolver_errors.py index ebca917d0a5..d136a108d75 100644 --- a/tests/functional/test_new_resolver_errors.py +++ b/tests/functional/test_new_resolver_errors.py @@ -6,6 +6,7 @@ create_basic_wheel_for_package, create_test_package_with_setup, ) +from tests.lib.wheel import make_wheel def test_new_resolver_conflict_requirements_file( @@ -132,3 +133,61 @@ def test_new_resolver_checks_requires_python_before_dependencies( # Setuptools produces wheels with normalized names. assert "pkg_dep" not in result.stderr, str(result) assert "pkg_dep" not in result.stdout, str(result) + + +def test_new_resolver_no_versions_available_hint(script: PipTestEnvironment) -> None: + """ + Test hint that no package candidate is available at all, + when ResolutionImpossible occurs. + """ + wheel_house = script.scratch_path.joinpath("wheelhouse") + wheel_house.mkdir() + + incompatible_dep_wheel = make_wheel( + name="incompatible-dep", + version="1.0.0", + wheel_metadata_updates={"Tag": ["py3-none-fakeplat"]}, + ) + incompatible_dep_wheel.save_to( + wheel_house.joinpath("incompatible_dep-1.0.0-py3-none-fakeplat.whl") + ) + + # Create multiple versions of a package that depend on the incompatible dependency + requesting_pkg_v1 = make_wheel( + name="requesting-pkg", + version="1.0.0", + metadata_updates={"Requires-Dist": ["incompatible-dep==1.0.0"]}, + ) + requesting_pkg_v1.save_to( + wheel_house.joinpath("requesting_pkg-1.0.0-py2.py3-none-any.whl") + ) + + requesting_pkg_v2 = make_wheel( + name="requesting-pkg", + version="2.0.0", + metadata_updates={"Requires-Dist": ["incompatible-dep==1.0.0"]}, + ) + requesting_pkg_v2.save_to( + wheel_house.joinpath("requesting_pkg-2.0.0-py2.py3-none-any.whl") + ) + + # Attempt to install the requesting package + result = script.pip( + "install", + "--no-cache-dir", + "--no-index", + "--find-links", + str(wheel_house), + "requesting-pkg", + expect_error=True, + ) + + # Check that ResolutionImpossible error occurred + assert "ResolutionImpossible" in result.stderr, str(result) + + # Check that the new hint message is present + assert ( + "Additionally, some projects in these conflicts have no " + "matching distributions available for your environment:\n" + " incompatible-dep\n" in result.stdout + ), str(result)