diff --git a/docs/html/topics/more-dependency-resolution.md b/docs/html/topics/more-dependency-resolution.md index c048acd8528..61d85cc2ed6 100644 --- a/docs/html/topics/more-dependency-resolution.md +++ b/docs/html/topics/more-dependency-resolution.md @@ -161,10 +161,15 @@ Pip's current implementation of the provider implements * If Requires-Python is present only consider that * If there are causes of resolution conflict (backtrack causes) then only consider them until there are no longer any resolution conflicts +* If any identifiers have appeared unresolved in backtrack causes at + least 5 times, only consider those so they get pinned before other + packages pick a version Pip's current implementation of the provider implements `get_preference` for known requirements with the following preferences in the following order: +* Any requirement that has appeared in repeated conflicts (see + ``narrow_requirement_selection`` above). * Any requirement that is "direct", e.g., points to an explicit URL. * Any requirement that is "pinned", i.e., contains the operator ``===`` or ``==`` without a wildcard. diff --git a/news/13859.feature.rst b/news/13859.feature.rst new file mode 100644 index 00000000000..4e31324646b --- /dev/null +++ b/news/13859.feature.rst @@ -0,0 +1 @@ +Speed up dependency resolution when there are complex conflicts. diff --git a/src/pip/_internal/resolution/resolvelib/provider.py b/src/pip/_internal/resolution/resolvelib/provider.py index 994748dba4f..6641d912490 100644 --- a/src/pip/_internal/resolution/resolvelib/provider.py +++ b/src/pip/_internal/resolution/resolvelib/provider.py @@ -1,6 +1,7 @@ from __future__ import annotations import math +from collections import defaultdict from collections.abc import Iterable, Iterator, Mapping, Sequence from functools import cache from typing import ( @@ -27,6 +28,8 @@ else: _ProviderBase = AbstractProvider +_CONFLICT_PRIORITY_THRESHOLD = 5 + # Notes on the relationship between the provider, the factory, and the # candidate and requirement classes. # @@ -99,6 +102,8 @@ def __init__( self._ignore_dependencies = ignore_dependencies self._upgrade_strategy = upgrade_strategy self._user_requested = user_requested + self._conflict_counts: defaultdict[str, int] = defaultdict(int) + self._conflict_promoted: set[str] = set() @property def constraints(self) -> dict[str, Constraint]: @@ -130,29 +135,42 @@ def narrow_requirement_selection( Further, the current backtrack causes likely need to be resolved before other requirements as a resolution can't be found while there is a conflict. + * Identifiers that repeatedly appear as not-yet-pinned in conflicts + get promoted so they are resolved earlier. This lets their + constraints take effect before other packages pick a version. """ backtrack_identifiers = set() for info in backtrack_causes: - backtrack_identifiers.add(info.requirement.name) + names = [info.requirement.name] if info.parent is not None: - backtrack_identifiers.add(info.parent.name) + names.append(info.parent.name) + for name in names: + backtrack_identifiers.add(name) + if name not in resolutions: + self._conflict_counts[name] += 1 + if self._conflict_counts[name] >= _CONFLICT_PRIORITY_THRESHOLD: + self._conflict_promoted.add(name) current_backtrack_causes = [] + promoted = [] for identifier in identifiers: - # 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. - # This skips calling get_preference() for all other identifiers. if identifier == REQUIRES_PYTHON_IDENTIFIER: return [identifier] - # Check if this identifier is a backtrack cause if identifier in backtrack_identifiers: current_backtrack_causes.append(identifier) continue + if identifier in self._conflict_promoted: + promoted.append(identifier) + continue + if current_backtrack_causes: return current_backtrack_causes + if promoted: + return promoted + return identifiers def get_preference( @@ -223,7 +241,10 @@ def get_preference( unfree = bool(operators) requested_order = self._user_requested.get(identifier, math.inf) + conflict_promoted = identifier in self._conflict_promoted + return ( + not conflict_promoted, not direct, not pinned, not upper_bounded, diff --git a/tests/unit/resolution_resolvelib/test_provider.py b/tests/unit/resolution_resolvelib/test_provider.py index 92a709d62f1..8e63d9b1d6a 100644 --- a/tests/unit/resolution_resolvelib/test_provider.py +++ b/tests/unit/resolution_resolvelib/test_provider.py @@ -12,7 +12,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.provider import ( + _CONFLICT_PRIORITY_THRESHOLD, + PipProvider, +) from pip._internal.resolution.resolvelib.requirements import ( ExplicitRequirement, SpecifierRequirement, @@ -60,7 +63,7 @@ def build_explicit_req_info( {"pinned-package": [build_req_info("pinned-package==1.0")]}, [], {}, - (True, False, True, math.inf, False, "pinned-package"), + (True, True, False, True, math.inf, False, "pinned-package"), ), # Star-specified package, i.e. with "*" ( @@ -68,7 +71,7 @@ def build_explicit_req_info( {"star-specified-package": [build_req_info("star-specified-package==1.*")]}, [], {}, - (True, True, False, math.inf, False, "star-specified-package"), + (True, True, True, False, math.inf, False, "star-specified-package"), ), # Package that caused backtracking ( @@ -76,7 +79,7 @@ def build_explicit_req_info( {"backtrack-package": [build_req_info("backtrack-package")]}, [build_req_info("backtrack-package")], {}, - (True, True, True, math.inf, True, "backtrack-package"), + (True, True, True, True, math.inf, True, "backtrack-package"), ), # Root package requested by user ( @@ -84,7 +87,7 @@ def build_explicit_req_info( {"root-package": [build_req_info("root-package")]}, [], {"root-package": 1}, - (True, True, True, 1, True, "root-package"), + (True, True, True, True, 1, True, "root-package"), ), # Unfree package (with specifier operator) ( @@ -92,7 +95,7 @@ def build_explicit_req_info( {"unfree-package": [build_req_info("unfree-package!=1")]}, [], {}, - (True, True, True, math.inf, False, "unfree-package"), + (True, True, True, True, math.inf, False, "unfree-package"), ), # Free package (no operator) ( @@ -100,7 +103,7 @@ def build_explicit_req_info( {"free-package": [build_req_info("free-package")]}, [], {}, - (True, True, True, math.inf, True, "free-package"), + (True, True, True, True, math.inf, True, "free-package"), ), # Test case for "direct" preference (explicit URL) ( @@ -108,7 +111,7 @@ def build_explicit_req_info( {"direct-package": [build_explicit_req_info("direct-package")]}, [], {}, - (False, True, True, math.inf, True, "direct-package"), + (True, False, True, True, math.inf, True, "direct-package"), ), # Upper bounded with <= operator ( @@ -120,7 +123,7 @@ def build_explicit_req_info( }, [], {}, - (True, True, False, math.inf, False, "upper-bound-lte-package"), + (True, True, True, False, math.inf, False, "upper-bound-lte-package"), ), # Upper bounded with < operator ( @@ -128,7 +131,7 @@ def build_explicit_req_info( {"upper-bound-lt-package": [build_req_info("upper-bound-lt-package<2.0")]}, [], {}, - (True, True, False, math.inf, False, "upper-bound-lt-package"), + (True, True, True, False, math.inf, False, "upper-bound-lt-package"), ), # Upper bounded with ~= operator ( @@ -140,7 +143,15 @@ def build_explicit_req_info( }, [], {}, - (True, True, False, math.inf, False, "upper-bound-compatible-package"), + ( + True, + True, + True, + False, + math.inf, + False, + "upper-bound-compatible-package", + ), ), # Not upper bounded, using only >= operator ( @@ -148,7 +159,7 @@ def build_explicit_req_info( {"lower-bound-package": [build_req_info("lower-bound-package>=1.0")]}, [], {}, - (True, True, True, math.inf, False, "lower-bound-package"), + (True, True, True, True, math.inf, False, "lower-bound-package"), ), ], ) @@ -225,7 +236,8 @@ def test_narrow_requirement_selection( """Test that narrow_requirement_selection correctly prioritizes identifiers: 1. REQUIRES_PYTHON_IDENTIFIER (if present) 2. Backtrack causes (if present) - 3. All other identifiers (as-is) + 3. Conflict-promoted identifiers (if present) + 4. All other identifiers (as-is) """ provider = PipProvider( factory=factory, @@ -240,3 +252,41 @@ def test_narrow_requirement_selection( ) assert list(result) == expected, f"Expected {expected}, got {list(result)}" + + +def test_conflict_promotion_after_threshold(provider: PipProvider) -> None: + """Repeated unresolved backtrack causes get promoted after the threshold.""" + narrow = provider.narrow_requirement_selection + cause = [build_req_info("conflict-pkg")] + + # Below threshold: no promotion, all identifiers returned. + for i in range(1, _CONFLICT_PRIORITY_THRESHOLD): + result = list(narrow(["other-pkg"], {}, {}, {}, cause)) + assert result == ["other-pkg"], f"Unexpected promotion at call {i}" + + # At threshold: conflict-pkg is a backtrack cause so it wins on that basis. + result = list(narrow(["other-pkg", "conflict-pkg"], {}, {}, {}, cause)) + assert result == ["conflict-pkg"] + + # Without active backtrack causes, the promoted package is still preferred. + result = list(narrow(["other-pkg", "conflict-pkg"], {}, {}, {}, [])) + assert result == ["conflict-pkg"] + + # Backtrack causes still win over promoted-only packages. + other_cause = [build_req_info("other-pkg")] + result = list(narrow(["other-pkg", "conflict-pkg"], {}, {}, {}, other_cause)) + assert result == ["other-pkg"] + + +def test_conflict_promoted_get_preference(provider: PipProvider) -> None: + """Promoted packages sort before non-promoted in get_preference.""" + provider._conflict_promoted.add("promoted-pkg") + + info = { + "promoted-pkg": [build_req_info("promoted-pkg")], + "normal-pkg": [build_req_info("normal-pkg")], + } + pref = provider.get_preference("promoted-pkg", {}, {}, info, []) + pref_other = provider.get_preference("normal-pkg", {}, {}, info, []) + + assert pref < pref_other