Repository: pypa/pip
Pull request #13244 (2025-02-25 01:28:24)

Fix direct preference in `PipProvider.get_preference`
Fixes https://github.com/pypa/pip/issues/12975
Also see https://github.com/pypa/pip/issues/13164 for real world impact with a reproducible example that is fixed by this PR.

Most of the effort in this PR is extending the unit tests to include testing a requirement with an `ExplicitRequirement`, probably there was a simpler way to do this, I intend to keep extending and improving the unit tests in following PRs.


notatallshaw: Merged main and simplified new test construction

Patch:
diff --git a/news/13244.bugfix.rst b/news/13244.bugfix.rst
new file mode 100644
index 000000000..10944cf55
--- /dev/null
+++ b/news/13244.bugfix.rst
@@ -0,0 +1,2 @@
+While resolving dependencies prefer if any of the known requirements are
+"direct", e.g. points to an explicit URL.
diff --git a/src/pip/_internal/resolution/resolvelib/provider.py b/src/pip/_internal/resolution/resolvelib/provider.py
index 617a993cb..1efd245bc 100644
--- a/src/pip/_internal/resolution/resolvelib/provider.py
+++ b/src/pip/_internal/resolution/resolvelib/provider.py
@@ -6,16 +6,21 @@ from typing import (
     Iterable,
     Iterator,
     Mapping,
+    Optional,
     Sequence,
+    Tuple,
     TypeVar,
     Union,
 )
 
 from pip._vendor.resolvelib.providers import AbstractProvider
 
+from pip._internal.req.req_install import InstallRequirement
+
 from .base import Candidate, Constraint, Requirement
 from .candidates import REQUIRES_PYTHON_IDENTIFIER
 from .factory import Factory
+from .requirements import ExplicitRequirement
 
 if TYPE_CHECKING:
     from pip._vendor.resolvelib.providers import Preference
@@ -185,11 +190,20 @@ class PipProvider(_ProviderBase):
         else:
             has_information = True
 
-        if has_information:
-            lookups = (r.get_candidate_lookup() for r, _ in information[identifier])
-            candidate, ireqs = zip(*lookups)
+        if not has_information:
+            direct = False
+            ireqs: Tuple[Optional[InstallRequirement], ...] = ()
         else:
-            candidate, ireqs = None, ()
+            # Go through the information and for each requirement,
+            # check if it's explicit (e.g., a direct link) and get the
+            # InstallRequirement (the second element) from get_candidate_lookup()
+            directs, ireqs = zip(
+                *(
+                    (isinstance(r, ExplicitRequirement), r.get_candidate_lookup()[1])
+                    for r, _ in information[identifier]
+                )
+            )
+            direct = any(directs)
 
         operators: list[tuple[str, str]] = [
             (specifier.operator, specifier.version)
@@ -197,7 +211,6 @@ class PipProvider(_ProviderBase):
             for specifier in specifier_set
         ]
 
-        direct = candidate is not None
         pinned = any(((op[:2] == "==") and ("*" not in ver)) for op, ver in operators)
         upper_bounded = any(
             ((op in ("<", "<=", "~=")) or (op == "==" and "*" in ver))


Test patch:
diff --git a/tests/unit/resolution_resolvelib/test_provider.py b/tests/unit/resolution_resolvelib/test_provider.py
index 8db40ddc2..5399f6062 100644
--- a/tests/unit/resolution_resolvelib/test_provider.py
+++ b/tests/unit/resolution_resolvelib/test_provider.py
@@ -10,7 +10,10 @@ from pip._internal.resolution.resolvelib.base import Candidate
 from pip._internal.resolution.resolvelib.candidates import REQUIRES_PYTHON_IDENTIFIER
 from pip._internal.resolution.resolvelib.factory import Factory
 from pip._internal.resolution.resolvelib.provider import PipProvider
-from pip._internal.resolution.resolvelib.requirements import SpecifierRequirement
+from pip._internal.resolution.resolvelib.requirements import (
+    ExplicitRequirement,
+    SpecifierRequirement,
+)
 
 if TYPE_CHECKING:
     from pip._vendor.resolvelib.providers import Preference
@@ -20,6 +23,12 @@ if TYPE_CHECKING:
     PreferenceInformation = RequirementInformation[Requirement, Candidate]
 
 
+class FakeCandidate(Candidate):
+    """A minimal fake candidate for testing purposes."""
+
+    def __init__(self, *args: object, **kwargs: object) -> None: ...
+
+
 def build_req_info(
     name: str, parent: Optional[Candidate] = None
 ) -> "PreferenceInformation":
@@ -33,6 +42,14 @@ def build_req_info(
     return requirement_information
 
 
+def build_explicit_req_info(
+    url: str, parent: Optional[Candidate] = None
+) -> "PreferenceInformation":
+    """Build a direct requirement using a minimal FakeCandidate."""
+    direct_requirement = ExplicitRequirement(FakeCandidate(url))
+    return RequirementInformation(requirement=direct_requirement, parent=parent)
+
+
 @pytest.mark.parametrize(
     "identifier, information, backtrack_causes, user_requested, expected",
     [
@@ -42,7 +59,7 @@ def build_req_info(
             {"pinned-package": [build_req_info("pinned-package==1.0")]},
             [],
             {},
-            (False, False, True, math.inf, False, "pinned-package"),
+            (True, False, True, math.inf, False, "pinned-package"),
         ),
         # Star-specified package, i.e. with "*"
         (
@@ -50,7 +67,7 @@ def build_req_info(
             {"star-specified-package": [build_req_info("star-specified-package==1.*")]},
             [],
             {},
-            (False, True, False, math.inf, False, "star-specified-package"),
+            (True, True, False, math.inf, False, "star-specified-package"),
         ),
         # Package that caused backtracking
         (
@@ -58,7 +75,7 @@ def build_req_info(
             {"backtrack-package": [build_req_info("backtrack-package")]},
             [build_req_info("backtrack-package")],
             {},
-            (False, True, True, math.inf, True, "backtrack-package"),
+            (True, True, True, math.inf, True, "backtrack-package"),
         ),
         # Root package requested by user
         (
@@ -66,7 +83,7 @@ def build_req_info(
             {"root-package": [build_req_info("root-package")]},
             [],
             {"root-package": 1},
-            (False, True, True, 1, True, "root-package"),
+            (True, True, True, 1, True, "root-package"),
         ),
         # Unfree package (with specifier operator)
         (
@@ -74,7 +91,7 @@ def build_req_info(
             {"unfree-package": [build_req_info("unfree-package!=1")]},
             [],
             {},
-            (False, True, True, math.inf, False, "unfree-package"),
+            (True, True, True, math.inf, False, "unfree-package"),
         ),
         # Free package (no operator)
         (
@@ -82,7 +99,15 @@ def build_req_info(
             {"free-package": [build_req_info("free-package")]},
             [],
             {},
-            (False, True, True, math.inf, True, "free-package"),
+            (True, True, True, math.inf, True, "free-package"),
+        ),
+        # Test case for "direct" preference (explicit URL)
+        (
+            "direct-package",
+            {"direct-package": [build_explicit_req_info("direct-package")]},
+            [],
+            {},
+            (False, True, True, math.inf, True, "direct-package"),
         ),
         # Upper bounded with <= operator
         (
@@ -94,7 +119,7 @@ def build_req_info(
             },
             [],
             {},
-            (False, True, False, math.inf, False, "upper-bound-lte-package"),
+            (True, True, False, math.inf, False, "upper-bound-lte-package"),
         ),
         # Upper bounded with < operator
         (
@@ -102,7 +127,7 @@ def build_req_info(
             {"upper-bound-lt-package": [build_req_info("upper-bound-lt-package<2.0")]},
             [],
             {},
-            (False, True, False, math.inf, False, "upper-bound-lt-package"),
+            (True, True, False, math.inf, False, "upper-bound-lt-package"),
         ),
         # Upper bounded with ~= operator
         (
@@ -114,7 +139,7 @@ def build_req_info(
             },
             [],
             {},
-            (False, True, False, math.inf, False, "upper-bound-compatible-package"),
+            (True, True, False, math.inf, False, "upper-bound-compatible-package"),
         ),
         # Not upper bounded, using only >= operator
         (
@@ -122,7 +147,7 @@ def build_req_info(
             {"lower-bound-package": [build_req_info("lower-bound-package>=1.0")]},
             [],
             {},
-            (False, True, True, math.inf, False, "lower-bound-package"),
+            (True, True, True, math.inf, False, "lower-bound-package"),
         ),
     ],
 )