Skip to content

Commit dea9f2e

Browse files
committed
solver: make results of poetry update more deterministic and similar to results of poetry lock
When running `poetry lock`, dependencies with less candidates are chosen first. Prior to this change when running `poetry update`, all whitelisted dependencies (aka `use_latest`) got the same priority which results in a more or less random resolution order.
1 parent 7e0e7b4 commit dea9f2e

File tree

2 files changed

+96
-14
lines changed

2 files changed

+96
-14
lines changed

src/poetry/mixology/version_solver.py

+27-14
Original file line numberDiff line numberDiff line change
@@ -362,31 +362,44 @@ def _choose_package_version(self) -> str | None:
362362
if not unsatisfied:
363363
return None
364364

365+
class Preference:
366+
DIRECT_ORIGIN = 0
367+
NO_CANDIDATES = 1
368+
ONE_CANDIDATE = 2
369+
USE_LATEST = 3
370+
LOCKED = 4
371+
DEFAULT = 5
372+
365373
# Prefer packages with as few remaining versions as possible,
366374
# so that if a conflict is necessary it's forced quickly.
367-
def _get_min(dependency: Dependency) -> tuple[bool, int]:
375+
def _get_min(dependency: Dependency) -> tuple[bool, int, int]:
368376
# Direct origin dependencies must be handled first: we don't want to resolve
369377
# a regular dependency for some package only to find later that we had a
370378
# direct-origin dependency.
371379
if dependency.is_direct_origin():
372-
return False, -1
380+
return False, Preference.DIRECT_ORIGIN, 1
373381

374-
if dependency.name in self._provider.use_latest:
375-
# If we're forced to use the latest version of a package, it effectively
376-
# only has one version to choose from.
377-
return not dependency.marker.is_any(), 1
382+
is_specific_marker = not dependency.marker.is_any()
378383

379-
locked = self._provider.get_locked(dependency)
380-
if locked:
381-
return not dependency.marker.is_any(), 1
384+
use_latest = dependency.name in self._provider.use_latest
385+
if not use_latest:
386+
locked = self._provider.get_locked(dependency)
387+
if locked:
388+
return is_specific_marker, Preference.LOCKED, 1
382389

383390
try:
384-
return (
385-
not dependency.marker.is_any(),
386-
len(self._dependency_cache.search_for(dependency)),
387-
)
391+
num_packages = len(self._dependency_cache.search_for(dependency))
388392
except ValueError:
389-
return not dependency.marker.is_any(), 0
393+
return is_specific_marker, 0, 0
394+
if num_packages == 0:
395+
preference = Preference.NO_CANDIDATES
396+
elif num_packages == 1:
397+
preference = Preference.ONE_CANDIDATE
398+
elif use_latest:
399+
preference = Preference.USE_LATEST
400+
else:
401+
preference = Preference.DEFAULT
402+
return is_specific_marker, preference, num_packages
390403

391404
if len(unsatisfied) == 1:
392405
dependency = unsatisfied[0]

tests/puzzle/test_solver.py

+69
Original file line numberDiff line numberDiff line change
@@ -3716,3 +3716,72 @@ def test_solver_yanked_warning(
37163716
)
37173717
assert error.count("is a yanked version") == 2
37183718
assert error.count("Reason for being yanked") == 1
3719+
3720+
3721+
@pytest.mark.parametrize("is_locked", [False, True])
3722+
def test_update_with_use_latest_vs_lock(
3723+
package: ProjectPackage, repo: Repository, pool: Pool, io: NullIO, is_locked: bool
3724+
):
3725+
"""
3726+
A1 depends on B2, A2 and A3 depend on B1. Same for C.
3727+
B1 depends on A2/C2, B2 depends on A1/C1.
3728+
3729+
Because there are fewer versions B than of A and C, B is resolved first
3730+
so that latest version of B is used.
3731+
There shouldn't be a difference between `poetry lock` (not is_locked)
3732+
and `poetry update` (is_locked + use_latest)
3733+
"""
3734+
# B added between A and C (and also alphabetically between)
3735+
# to ensure that neither the first nor the last one is resolved first
3736+
package.add_dependency(Factory.create_dependency("A", "*"))
3737+
package.add_dependency(Factory.create_dependency("B", "*"))
3738+
package.add_dependency(Factory.create_dependency("C", "*"))
3739+
3740+
package_a1 = get_package("A", "1")
3741+
package_a1.add_dependency(Factory.create_dependency("B", "2"))
3742+
package_a2 = get_package("A", "2")
3743+
package_a2.add_dependency(Factory.create_dependency("B", "1"))
3744+
package_a3 = get_package("A", "3")
3745+
package_a3.add_dependency(Factory.create_dependency("B", "1"))
3746+
3747+
package_c1 = get_package("C", "1")
3748+
package_c1.add_dependency(Factory.create_dependency("B", "2"))
3749+
package_c2 = get_package("C", "2")
3750+
package_c2.add_dependency(Factory.create_dependency("B", "1"))
3751+
package_c3 = get_package("C", "3")
3752+
package_c3.add_dependency(Factory.create_dependency("B", "1"))
3753+
3754+
package_b1 = get_package("B", "1")
3755+
package_b1.add_dependency(Factory.create_dependency("A", "2"))
3756+
package_b1.add_dependency(Factory.create_dependency("C", "2"))
3757+
package_b2 = get_package("B", "2")
3758+
package_b2.add_dependency(Factory.create_dependency("A", "1"))
3759+
package_b2.add_dependency(Factory.create_dependency("C", "1"))
3760+
3761+
repo.add_package(package_a1)
3762+
repo.add_package(package_a2)
3763+
repo.add_package(package_a3)
3764+
repo.add_package(package_b1)
3765+
repo.add_package(package_b2)
3766+
repo.add_package(package_c1)
3767+
repo.add_package(package_c2)
3768+
repo.add_package(package_c3)
3769+
3770+
if is_locked:
3771+
locked = [package_a1, package_b2, package_c1]
3772+
use_latest = [package.name for package in locked]
3773+
else:
3774+
locked = []
3775+
use_latest = []
3776+
3777+
solver = Solver(package, pool, [], locked, io)
3778+
transaction = solver.solve(use_latest)
3779+
3780+
check_solver_result(
3781+
transaction,
3782+
[
3783+
{"job": "install", "package": package_c1},
3784+
{"job": "install", "package": package_b2},
3785+
{"job": "install", "package": package_a1},
3786+
],
3787+
)

0 commit comments

Comments
 (0)