Skip to content

Commit 12fdd34

Browse files
radoeringdimbleby
andcommitted
solver: invert heuristics for choosing the next dependency to resolve so that dependencies with more versions are resolved first
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 98b8ffb commit 12fdd34

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
if len(unsatisfied) == 1:
478483
dependency = unsatisfied[0]

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)