Skip to content

Commit 36332d2

Browse files
radoeringdimbleby
andauthored
solver: invert heuristics for choosing the next dependency to resolve so that dependencies with more versions are resolved first (#8256)
Although the original algorithm proposes to choose dependencies with fewer versions first, we don't know about any real-world examples where this is important. However, we know about real-world examples (especially boto3 vs. urllib3, but also Sphinx vs. docutils) where it's better to choose dependencies with more versions first. Co-authored-by: David Hotham <[email protected]>
1 parent 5fe3a91 commit 36332d2

File tree

3 files changed

+74
-43
lines changed

3 files changed

+74
-43
lines changed

src/poetry/mixology/version_solver.py

+10-5
Original file line numberDiff line numberDiff line change
@@ -440,8 +440,13 @@ class Preference:
440440
LOCKED = 3
441441
DEFAULT = 4
442442

443-
# Prefer packages with as few remaining versions as possible,
444-
# so that if a conflict is necessary it's forced quickly.
443+
# The original algorithm proposes to prefer packages with as few remaining
444+
# versions as possible, so that if a conflict is necessary it's forced quickly.
445+
# https://github.com/dart-lang/pub/blob/master/doc/solver.md#decision-making
446+
# However, this leads to the famous boto3 vs. urllib3 issue, so we prefer
447+
# packages with more remaining versions (see
448+
# https://github.com/python-poetry/poetry/pull/8255#issuecomment-1657198242
449+
# for more details).
445450
# In order to provide results that are as deterministic as possible
446451
# and consistent between `poetry lock` and `poetry update`, the return value
447452
# of two different dependencies should not be equal if possible.
@@ -450,15 +455,15 @@ def _get_min(dependency: Dependency) -> tuple[bool, int, int]:
450455
# a regular dependency for some package only to find later that we had a
451456
# direct-origin dependency.
452457
if dependency.is_direct_origin():
453-
return False, Preference.DIRECT_ORIGIN, 1
458+
return False, Preference.DIRECT_ORIGIN, -1
454459

455460
is_specific_marker = not dependency.marker.is_any()
456461

457462
use_latest = dependency.name in self._provider.use_latest
458463
if not use_latest:
459464
locked = self._provider.get_locked(dependency)
460465
if locked:
461-
return is_specific_marker, Preference.LOCKED, 1
466+
return is_specific_marker, Preference.LOCKED, -1
462467

463468
num_packages = len(
464469
self._dependency_cache.search_for(
@@ -472,7 +477,7 @@ def _get_min(dependency: Dependency) -> tuple[bool, int, int]:
472477
preference = Preference.USE_LATEST
473478
else:
474479
preference = Preference.DEFAULT
475-
return is_specific_marker, preference, num_packages
480+
return is_specific_marker, preference, -num_packages
476481

477482
dependency = min(unsatisfied, key=_get_min)
478483

tests/mixology/version_solver/test_backtracking.py

+55-27
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def test_backjumps_after_partial_satisfier(
6767
add_to_repo(repo, "y", "1.0.0")
6868
add_to_repo(repo, "y", "2.0.0")
6969

70-
check_solver_result(root, provider, {"c": "1.0.0", "y": "2.0.0"}, tries=2)
70+
check_solver_result(root, provider, {"c": "1.0.0", "y": "2.0.0"}, tries=4)
7171

7272

7373
def test_rolls_back_leaf_versions_first(
@@ -132,32 +132,6 @@ def test_backjump_to_nearer_unsatisfied_package(
132132
)
133133

134134

135-
def test_traverse_into_package_with_fewer_versions_first(
136-
root: ProjectPackage, provider: Provider, repo: Repository
137-
) -> None:
138-
# Dependencies are ordered so that packages with fewer versions are tried
139-
# first. Here, there are two valid solutions (either a or b must be
140-
# downgraded once). The chosen one depends on which dep is traversed first.
141-
# Since b has fewer versions, it will be traversed first, which means a will
142-
# come later. Since later selections are revised first, a gets downgraded.
143-
root.add_dependency(Factory.create_dependency("a", "*"))
144-
root.add_dependency(Factory.create_dependency("b", "*"))
145-
146-
add_to_repo(repo, "a", "1.0.0", deps={"c": "*"})
147-
add_to_repo(repo, "a", "2.0.0", deps={"c": "*"})
148-
add_to_repo(repo, "a", "3.0.0", deps={"c": "*"})
149-
add_to_repo(repo, "a", "4.0.0", deps={"c": "*"})
150-
add_to_repo(repo, "a", "5.0.0", deps={"c": "1.0.0"})
151-
add_to_repo(repo, "b", "1.0.0", deps={"c": "*"})
152-
add_to_repo(repo, "b", "2.0.0", deps={"c": "*"})
153-
add_to_repo(repo, "b", "3.0.0", deps={"c": "*"})
154-
add_to_repo(repo, "b", "4.0.0", deps={"c": "2.0.0"})
155-
add_to_repo(repo, "c", "1.0.0")
156-
add_to_repo(repo, "c", "2.0.0")
157-
158-
check_solver_result(root, provider, {"a": "4.0.0", "b": "4.0.0", "c": "2.0.0"})
159-
160-
161135
def test_backjump_past_failed_package_on_disjoint_constraint(
162136
root: ProjectPackage, provider: Provider, repo: Repository
163137
) -> None:
@@ -176,3 +150,57 @@ def test_backjump_past_failed_package_on_disjoint_constraint(
176150
add_to_repo(repo, "foo", "2.0.4")
177151

178152
check_solver_result(root, provider, {"a": "1.0.0", "foo": "2.0.4"})
153+
154+
155+
def test_backtracking_performance_level_1(
156+
root: ProjectPackage, provider: Provider, repo: Repository
157+
) -> None:
158+
"""
159+
This test takes quite long if an unfavorable heuristics is chosen
160+
to select the next package to resolve.
161+
162+
B depends on A, but does not support the latest version of A.
163+
B has a lot more versions than A.
164+
165+
Test for boto3/botocore vs. urllib3 issue in its simple form.
166+
"""
167+
root.add_dependency(Factory.create_dependency("a", "*"))
168+
root.add_dependency(Factory.create_dependency("b", "*"))
169+
170+
add_to_repo(repo, "a", "1")
171+
add_to_repo(repo, "a", "2")
172+
173+
b_max = 500
174+
for i in range(1, b_max + 1):
175+
add_to_repo(repo, "b", str(i), deps={"a": "<=1"})
176+
177+
check_solver_result(root, provider, {"a": "1", "b": str(b_max)})
178+
179+
180+
def test_backtracking_performance_level_2(
181+
root: ProjectPackage, provider: Provider, repo: Repository
182+
) -> None:
183+
"""
184+
Similar to test_backtracking_performance_level_1,
185+
but with one more level of dependencies.
186+
187+
C depends on B depends on A, but B does not support the latest version of A.
188+
The root dependency only requires A and C so there is no direct dependency between
189+
these two.
190+
B and C have a lot more versions than A.
191+
192+
Test for boto3/botocore vs. urllib3 issue in its more complex form.
193+
"""
194+
root.add_dependency(Factory.create_dependency("a", "*"))
195+
root.add_dependency(Factory.create_dependency("c", "*"))
196+
197+
add_to_repo(repo, "a", "1")
198+
add_to_repo(repo, "a", "2")
199+
200+
bc_max = 500
201+
for i in range(1, bc_max + 1):
202+
add_to_repo(repo, "b", str(i), deps={"a": "<=1"})
203+
for i in range(1, bc_max + 1):
204+
add_to_repo(repo, "c", str(i), deps={"b": f"<={i}"})
205+
206+
check_solver_result(root, provider, {"a": "1", "b": str(bc_max), "c": str(bc_max)})

tests/puzzle/test_solver.py

+9-11
Original file line numberDiff line numberDiff line change
@@ -4265,7 +4265,7 @@ def test_update_with_use_latest_vs_lock(
42654265
A1 depends on B2, A2 and A3 depend on B1. Same for C.
42664266
B1 depends on A2/C2, B2 depends on A1/C1.
42674267
4268-
Because there are fewer versions B than of A and C, B is resolved first
4268+
Because there are more versions of B than of A and C, B is resolved first
42694269
so that latest version of B is used.
42704270
There shouldn't be a difference between `poetry lock` (not is_locked)
42714271
and `poetry update` (is_locked + use_latest)
@@ -4277,37 +4277,35 @@ def test_update_with_use_latest_vs_lock(
42774277
package.add_dependency(Factory.create_dependency("C", "*"))
42784278

42794279
package_a1 = get_package("A", "1")
4280-
package_a1.add_dependency(Factory.create_dependency("B", "2"))
4280+
package_a1.add_dependency(Factory.create_dependency("B", "3"))
42814281
package_a2 = get_package("A", "2")
42824282
package_a2.add_dependency(Factory.create_dependency("B", "1"))
4283-
package_a3 = get_package("A", "3")
4284-
package_a3.add_dependency(Factory.create_dependency("B", "1"))
42854283

42864284
package_c1 = get_package("C", "1")
4287-
package_c1.add_dependency(Factory.create_dependency("B", "2"))
4285+
package_c1.add_dependency(Factory.create_dependency("B", "3"))
42884286
package_c2 = get_package("C", "2")
42894287
package_c2.add_dependency(Factory.create_dependency("B", "1"))
4290-
package_c3 = get_package("C", "3")
4291-
package_c3.add_dependency(Factory.create_dependency("B", "1"))
42924288

42934289
package_b1 = get_package("B", "1")
42944290
package_b1.add_dependency(Factory.create_dependency("A", "2"))
42954291
package_b1.add_dependency(Factory.create_dependency("C", "2"))
42964292
package_b2 = get_package("B", "2")
42974293
package_b2.add_dependency(Factory.create_dependency("A", "1"))
42984294
package_b2.add_dependency(Factory.create_dependency("C", "1"))
4295+
package_b3 = get_package("B", "3")
4296+
package_b3.add_dependency(Factory.create_dependency("A", "1"))
4297+
package_b3.add_dependency(Factory.create_dependency("C", "1"))
42994298

43004299
repo.add_package(package_a1)
43014300
repo.add_package(package_a2)
4302-
repo.add_package(package_a3)
43034301
repo.add_package(package_b1)
43044302
repo.add_package(package_b2)
4303+
repo.add_package(package_b3)
43054304
repo.add_package(package_c1)
43064305
repo.add_package(package_c2)
4307-
repo.add_package(package_c3)
43084306

43094307
if is_locked:
4310-
locked = [package_a1, package_b2, package_c1]
4308+
locked = [package_a1, package_b3, package_c1]
43114309
use_latest = [package.name for package in locked]
43124310
else:
43134311
locked = []
@@ -4320,7 +4318,7 @@ def test_update_with_use_latest_vs_lock(
43204318
transaction,
43214319
[
43224320
{"job": "install", "package": package_c1},
4323-
{"job": "install", "package": package_b2},
4321+
{"job": "install", "package": package_b3},
43244322
{"job": "install", "package": package_a1},
43254323
],
43264324
)

0 commit comments

Comments
 (0)