diff --git a/news/9925.feature.rst b/news/9925.feature.rst new file mode 100644 index 00000000000..8c2401f6085 --- /dev/null +++ b/news/9925.feature.rst @@ -0,0 +1,3 @@ +New resolver: A distribution's ``Requires-Python`` metadata is now checked +before its Python dependencies. This makes the resolver fail quicker when +there's an interpreter version conflict. diff --git a/src/pip/_internal/resolution/resolvelib/candidates.py b/src/pip/_internal/resolution/resolvelib/candidates.py index da516ad3c87..e496e10dde8 100644 --- a/src/pip/_internal/resolution/resolvelib/candidates.py +++ b/src/pip/_internal/resolution/resolvelib/candidates.py @@ -32,6 +32,9 @@ "LinkCandidate", ] +# Avoid conflicting with the PyPI package "Python". +REQUIRES_PYTHON_IDENTIFIER = cast(NormalizedName, "") + def as_base_candidate(candidate: Candidate) -> Optional[BaseCandidate]: """The runtime version of BaseCandidate.""" @@ -578,13 +581,12 @@ def __str__(self): @property def project_name(self): # type: () -> NormalizedName - # Avoid conflicting with the PyPI package "Python". - return cast(NormalizedName, "") + return REQUIRES_PYTHON_IDENTIFIER @property def name(self): # type: () -> str - return self.project_name + return REQUIRES_PYTHON_IDENTIFIER @property def version(self): diff --git a/src/pip/_internal/resolution/resolvelib/provider.py b/src/pip/_internal/resolution/resolvelib/provider.py index 0be58fd3ba8..9a8c29980a5 100644 --- a/src/pip/_internal/resolution/resolvelib/provider.py +++ b/src/pip/_internal/resolution/resolvelib/provider.py @@ -3,6 +3,7 @@ from pip._vendor.resolvelib.providers import AbstractProvider from .base import Candidate, Constraint, Requirement +from .candidates import REQUIRES_PYTHON_IDENTIFIER from .factory import Factory if TYPE_CHECKING: @@ -121,6 +122,10 @@ def _get_restrictive_rating(requirements): rating = _get_restrictive_rating(r for r, _ in information[identifier]) order = self._user_requested.get(identifier, float("inf")) + # Requires-Python has only one candidate and the check is basically + # free, so we always do it first to avoid needless work if it fails. + requires_python = identifier == REQUIRES_PYTHON_IDENTIFIER + # HACK: Setuptools have a very long and solid backward compatibility # track record, and extremely few projects would request a narrow, # non-recent version range of it since that would break a lot things. @@ -131,7 +136,7 @@ def _get_restrictive_rating(requirements): # while we work on "proper" branch pruning techniques. delay_this = identifier == "setuptools" - return (delay_this, rating, order, identifier) + return (not requires_python, delay_this, rating, order, identifier) def find_matches( self, diff --git a/tests/functional/test_new_resolver_errors.py b/tests/functional/test_new_resolver_errors.py index b4d63a99695..b2e7af7c616 100644 --- a/tests/functional/test_new_resolver_errors.py +++ b/tests/functional/test_new_resolver_errors.py @@ -1,3 +1,4 @@ +import pathlib import sys from tests.lib import create_basic_wheel_for_package, create_test_package_with_setup @@ -73,3 +74,34 @@ def test_new_resolver_requires_python_error(script): # conflict, not the compatible one. assert incompatible_python in result.stderr, str(result) assert compatible_python not in result.stderr, str(result) + + +def test_new_resolver_checks_requires_python_before_dependencies(script): + incompatible_python = "<{0.major}.{0.minor}".format(sys.version_info) + + pkg_dep = create_basic_wheel_for_package( + script, + name="pkg-dep", + version="1", + ) + create_basic_wheel_for_package( + script, + name="pkg-root", + version="1", + # Refer the dependency by URL to prioritise it as much as possible, + # to test that Requires-Python is *still* inspected first. + depends=[f"pkg-dep@{pathlib.Path(pkg_dep).as_uri()}"], + requires_python=incompatible_python, + ) + + result = script.pip( + "install", "--no-cache-dir", + "--no-index", "--find-links", script.scratch_path, + "pkg-root", + expect_error=True, + ) + + # Resolution should fail because of pkg-a's Requires-Python. + # This check should be done before pkg-b, so pkg-b should never be pulled. + assert incompatible_python in result.stderr, str(result) + assert "pkg-b" not in result.stderr, str(result)