Skip to content

Commit 59555f4

Browse files
authored
Merge pull request #13859 from notatallshaw/conflict-priority-simple
Add conflict driven priority reordering when backtracking
2 parents 8eaf0a1 + 4e6144c commit 59555f4

4 files changed

Lines changed: 96 additions & 19 deletions

File tree

docs/html/topics/more-dependency-resolution.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,15 @@ Pip's current implementation of the provider implements
161161
* If Requires-Python is present only consider that
162162
* If there are causes of resolution conflict (backtrack causes) then
163163
only consider them until there are no longer any resolution conflicts
164+
* If any identifiers have appeared unresolved in backtrack causes at
165+
least 5 times, only consider those so they get pinned before other
166+
packages pick a version
164167

165168
Pip's current implementation of the provider implements `get_preference`
166169
for known requirements with the following preferences in the following order:
167170

171+
* Any requirement that has appeared in repeated conflicts (see
172+
``narrow_requirement_selection`` above).
168173
* Any requirement that is "direct", e.g., points to an explicit URL.
169174
* Any requirement that is "pinned", i.e., contains the operator ``===``
170175
or ``==`` without a wildcard.

news/13859.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Speed up dependency resolution when there are complex conflicts.

src/pip/_internal/resolution/resolvelib/provider.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import math
4+
from collections import defaultdict
45
from collections.abc import Iterable, Iterator, Mapping, Sequence
56
from functools import cache
67
from typing import (
@@ -27,6 +28,8 @@
2728
else:
2829
_ProviderBase = AbstractProvider
2930

31+
_CONFLICT_PRIORITY_THRESHOLD = 5
32+
3033
# Notes on the relationship between the provider, the factory, and the
3134
# candidate and requirement classes.
3235
#
@@ -99,6 +102,8 @@ def __init__(
99102
self._ignore_dependencies = ignore_dependencies
100103
self._upgrade_strategy = upgrade_strategy
101104
self._user_requested = user_requested
105+
self._conflict_counts: defaultdict[str, int] = defaultdict(int)
106+
self._conflict_promoted: set[str] = set()
102107

103108
@property
104109
def constraints(self) -> dict[str, Constraint]:
@@ -130,29 +135,42 @@ def narrow_requirement_selection(
130135
Further, the current backtrack causes likely need to be resolved
131136
before other requirements as a resolution can't be found while
132137
there is a conflict.
138+
* Identifiers that repeatedly appear as not-yet-pinned in conflicts
139+
get promoted so they are resolved earlier. This lets their
140+
constraints take effect before other packages pick a version.
133141
"""
134142
backtrack_identifiers = set()
135143
for info in backtrack_causes:
136-
backtrack_identifiers.add(info.requirement.name)
144+
names = [info.requirement.name]
137145
if info.parent is not None:
138-
backtrack_identifiers.add(info.parent.name)
146+
names.append(info.parent.name)
147+
for name in names:
148+
backtrack_identifiers.add(name)
149+
if name not in resolutions:
150+
self._conflict_counts[name] += 1
151+
if self._conflict_counts[name] >= _CONFLICT_PRIORITY_THRESHOLD:
152+
self._conflict_promoted.add(name)
139153

140154
current_backtrack_causes = []
155+
promoted = []
141156
for identifier in identifiers:
142-
# Requires-Python has only one candidate and the check is basically
143-
# free, so we always do it first to avoid needless work if it fails.
144-
# This skips calling get_preference() for all other identifiers.
145157
if identifier == REQUIRES_PYTHON_IDENTIFIER:
146158
return [identifier]
147159

148-
# Check if this identifier is a backtrack cause
149160
if identifier in backtrack_identifiers:
150161
current_backtrack_causes.append(identifier)
151162
continue
152163

164+
if identifier in self._conflict_promoted:
165+
promoted.append(identifier)
166+
continue
167+
153168
if current_backtrack_causes:
154169
return current_backtrack_causes
155170

171+
if promoted:
172+
return promoted
173+
156174
return identifiers
157175

158176
def get_preference(
@@ -223,7 +241,10 @@ def get_preference(
223241
unfree = bool(operators)
224242
requested_order = self._user_requested.get(identifier, math.inf)
225243

244+
conflict_promoted = identifier in self._conflict_promoted
245+
226246
return (
247+
not conflict_promoted,
227248
not direct,
228249
not pinned,
229250
not upper_bounded,

tests/unit/resolution_resolvelib/test_provider.py

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
from pip._internal.resolution.resolvelib.base import Candidate
1313
from pip._internal.resolution.resolvelib.candidates import REQUIRES_PYTHON_IDENTIFIER
1414
from pip._internal.resolution.resolvelib.factory import Factory
15-
from pip._internal.resolution.resolvelib.provider import PipProvider
15+
from pip._internal.resolution.resolvelib.provider import (
16+
_CONFLICT_PRIORITY_THRESHOLD,
17+
PipProvider,
18+
)
1619
from pip._internal.resolution.resolvelib.requirements import (
1720
ExplicitRequirement,
1821
SpecifierRequirement,
@@ -60,55 +63,55 @@ def build_explicit_req_info(
6063
{"pinned-package": [build_req_info("pinned-package==1.0")]},
6164
[],
6265
{},
63-
(True, False, True, math.inf, False, "pinned-package"),
66+
(True, True, False, True, math.inf, False, "pinned-package"),
6467
),
6568
# Star-specified package, i.e. with "*"
6669
(
6770
"star-specified-package",
6871
{"star-specified-package": [build_req_info("star-specified-package==1.*")]},
6972
[],
7073
{},
71-
(True, True, False, math.inf, False, "star-specified-package"),
74+
(True, True, True, False, math.inf, False, "star-specified-package"),
7275
),
7376
# Package that caused backtracking
7477
(
7578
"backtrack-package",
7679
{"backtrack-package": [build_req_info("backtrack-package")]},
7780
[build_req_info("backtrack-package")],
7881
{},
79-
(True, True, True, math.inf, True, "backtrack-package"),
82+
(True, True, True, True, math.inf, True, "backtrack-package"),
8083
),
8184
# Root package requested by user
8285
(
8386
"root-package",
8487
{"root-package": [build_req_info("root-package")]},
8588
[],
8689
{"root-package": 1},
87-
(True, True, True, 1, True, "root-package"),
90+
(True, True, True, True, 1, True, "root-package"),
8891
),
8992
# Unfree package (with specifier operator)
9093
(
9194
"unfree-package",
9295
{"unfree-package": [build_req_info("unfree-package!=1")]},
9396
[],
9497
{},
95-
(True, True, True, math.inf, False, "unfree-package"),
98+
(True, True, True, True, math.inf, False, "unfree-package"),
9699
),
97100
# Free package (no operator)
98101
(
99102
"free-package",
100103
{"free-package": [build_req_info("free-package")]},
101104
[],
102105
{},
103-
(True, True, True, math.inf, True, "free-package"),
106+
(True, True, True, True, math.inf, True, "free-package"),
104107
),
105108
# Test case for "direct" preference (explicit URL)
106109
(
107110
"direct-package",
108111
{"direct-package": [build_explicit_req_info("direct-package")]},
109112
[],
110113
{},
111-
(False, True, True, math.inf, True, "direct-package"),
114+
(True, False, True, True, math.inf, True, "direct-package"),
112115
),
113116
# Upper bounded with <= operator
114117
(
@@ -120,15 +123,15 @@ def build_explicit_req_info(
120123
},
121124
[],
122125
{},
123-
(True, True, False, math.inf, False, "upper-bound-lte-package"),
126+
(True, True, True, False, math.inf, False, "upper-bound-lte-package"),
124127
),
125128
# Upper bounded with < operator
126129
(
127130
"upper-bound-lt-package",
128131
{"upper-bound-lt-package": [build_req_info("upper-bound-lt-package<2.0")]},
129132
[],
130133
{},
131-
(True, True, False, math.inf, False, "upper-bound-lt-package"),
134+
(True, True, True, False, math.inf, False, "upper-bound-lt-package"),
132135
),
133136
# Upper bounded with ~= operator
134137
(
@@ -140,15 +143,23 @@ def build_explicit_req_info(
140143
},
141144
[],
142145
{},
143-
(True, True, False, math.inf, False, "upper-bound-compatible-package"),
146+
(
147+
True,
148+
True,
149+
True,
150+
False,
151+
math.inf,
152+
False,
153+
"upper-bound-compatible-package",
154+
),
144155
),
145156
# Not upper bounded, using only >= operator
146157
(
147158
"lower-bound-package",
148159
{"lower-bound-package": [build_req_info("lower-bound-package>=1.0")]},
149160
[],
150161
{},
151-
(True, True, True, math.inf, False, "lower-bound-package"),
162+
(True, True, True, True, math.inf, False, "lower-bound-package"),
152163
),
153164
],
154165
)
@@ -225,7 +236,8 @@ def test_narrow_requirement_selection(
225236
"""Test that narrow_requirement_selection correctly prioritizes identifiers:
226237
1. REQUIRES_PYTHON_IDENTIFIER (if present)
227238
2. Backtrack causes (if present)
228-
3. All other identifiers (as-is)
239+
3. Conflict-promoted identifiers (if present)
240+
4. All other identifiers (as-is)
229241
"""
230242
provider = PipProvider(
231243
factory=factory,
@@ -240,3 +252,41 @@ def test_narrow_requirement_selection(
240252
)
241253

242254
assert list(result) == expected, f"Expected {expected}, got {list(result)}"
255+
256+
257+
def test_conflict_promotion_after_threshold(provider: PipProvider) -> None:
258+
"""Repeated unresolved backtrack causes get promoted after the threshold."""
259+
narrow = provider.narrow_requirement_selection
260+
cause = [build_req_info("conflict-pkg")]
261+
262+
# Below threshold: no promotion, all identifiers returned.
263+
for i in range(1, _CONFLICT_PRIORITY_THRESHOLD):
264+
result = list(narrow(["other-pkg"], {}, {}, {}, cause))
265+
assert result == ["other-pkg"], f"Unexpected promotion at call {i}"
266+
267+
# At threshold: conflict-pkg is a backtrack cause so it wins on that basis.
268+
result = list(narrow(["other-pkg", "conflict-pkg"], {}, {}, {}, cause))
269+
assert result == ["conflict-pkg"]
270+
271+
# Without active backtrack causes, the promoted package is still preferred.
272+
result = list(narrow(["other-pkg", "conflict-pkg"], {}, {}, {}, []))
273+
assert result == ["conflict-pkg"]
274+
275+
# Backtrack causes still win over promoted-only packages.
276+
other_cause = [build_req_info("other-pkg")]
277+
result = list(narrow(["other-pkg", "conflict-pkg"], {}, {}, {}, other_cause))
278+
assert result == ["other-pkg"]
279+
280+
281+
def test_conflict_promoted_get_preference(provider: PipProvider) -> None:
282+
"""Promoted packages sort before non-promoted in get_preference."""
283+
provider._conflict_promoted.add("promoted-pkg")
284+
285+
info = {
286+
"promoted-pkg": [build_req_info("promoted-pkg")],
287+
"normal-pkg": [build_req_info("normal-pkg")],
288+
}
289+
pref = provider.get_preference("promoted-pkg", {}, {}, info, [])
290+
pref_other = provider.get_preference("normal-pkg", {}, {}, info, [])
291+
292+
assert pref < pref_other

0 commit comments

Comments
 (0)