Commit 4914c265 by Randy Döring Committed by Bjorn Neergaard

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.
parent ebee342c
...@@ -362,31 +362,50 @@ class VersionSolver: ...@@ -362,31 +362,50 @@ class VersionSolver:
if not unsatisfied: if not unsatisfied:
return None return None
class Preference:
"""
Preference is one of the criteria for choosing which dependency to solve
first. A higher value means that there are "more options" to satisfy
a dependency. A lower value takes precedence.
"""
DIRECT_ORIGIN = 0
NO_CHOICE = 1
USE_LATEST = 2
LOCKED = 3
DEFAULT = 4
# Prefer packages with as few remaining versions as possible, # Prefer packages with as few remaining versions as possible,
# so that if a conflict is necessary it's forced quickly. # so that if a conflict is necessary it's forced quickly.
def _get_min(dependency: Dependency) -> tuple[bool, int]: # In order to provide results that are as deterministic as possible
# and consistent between `poetry lock` and `poetry update`, the return value
# of two different dependencies should not be equal if possible.
def _get_min(dependency: Dependency) -> tuple[bool, int, int]:
# Direct origin dependencies must be handled first: we don't want to resolve # Direct origin dependencies must be handled first: we don't want to resolve
# a regular dependency for some package only to find later that we had a # a regular dependency for some package only to find later that we had a
# direct-origin dependency. # direct-origin dependency.
if dependency.is_direct_origin(): if dependency.is_direct_origin():
return False, -1 return False, Preference.DIRECT_ORIGIN, 1
if dependency.name in self._provider.use_latest: is_specific_marker = not dependency.marker.is_any()
# If we're forced to use the latest version of a package, it effectively
# only has one version to choose from.
return not dependency.marker.is_any(), 1
use_latest = dependency.name in self._provider.use_latest
if not use_latest:
locked = self._provider.get_locked(dependency) locked = self._provider.get_locked(dependency)
if locked: if locked:
return not dependency.marker.is_any(), 1 return is_specific_marker, Preference.LOCKED, 1
try: try:
return ( num_packages = len(self._dependency_cache.search_for(dependency))
not dependency.marker.is_any(),
len(self._dependency_cache.search_for(dependency)),
)
except ValueError: except ValueError:
return not dependency.marker.is_any(), 0 num_packages = 0
if num_packages < 2:
preference = Preference.NO_CHOICE
elif use_latest:
preference = Preference.USE_LATEST
else:
preference = Preference.DEFAULT
return is_specific_marker, preference, num_packages
if len(unsatisfied) == 1: if len(unsatisfied) == 1:
dependency = unsatisfied[0] dependency = unsatisfied[0]
......
...@@ -3716,3 +3716,72 @@ def test_solver_yanked_warning( ...@@ -3716,3 +3716,72 @@ def test_solver_yanked_warning(
) )
assert error.count("is a yanked version") == 2 assert error.count("is a yanked version") == 2
assert error.count("Reason for being yanked") == 1 assert error.count("Reason for being yanked") == 1
@pytest.mark.parametrize("is_locked", [False, True])
def test_update_with_use_latest_vs_lock(
package: ProjectPackage, repo: Repository, pool: Pool, io: NullIO, is_locked: bool
):
"""
A1 depends on B2, A2 and A3 depend on B1. Same for C.
B1 depends on A2/C2, B2 depends on A1/C1.
Because there are fewer versions B than of A and C, B is resolved first
so that latest version of B is used.
There shouldn't be a difference between `poetry lock` (not is_locked)
and `poetry update` (is_locked + use_latest)
"""
# B added between A and C (and also alphabetically between)
# to ensure that neither the first nor the last one is resolved first
package.add_dependency(Factory.create_dependency("A", "*"))
package.add_dependency(Factory.create_dependency("B", "*"))
package.add_dependency(Factory.create_dependency("C", "*"))
package_a1 = get_package("A", "1")
package_a1.add_dependency(Factory.create_dependency("B", "2"))
package_a2 = get_package("A", "2")
package_a2.add_dependency(Factory.create_dependency("B", "1"))
package_a3 = get_package("A", "3")
package_a3.add_dependency(Factory.create_dependency("B", "1"))
package_c1 = get_package("C", "1")
package_c1.add_dependency(Factory.create_dependency("B", "2"))
package_c2 = get_package("C", "2")
package_c2.add_dependency(Factory.create_dependency("B", "1"))
package_c3 = get_package("C", "3")
package_c3.add_dependency(Factory.create_dependency("B", "1"))
package_b1 = get_package("B", "1")
package_b1.add_dependency(Factory.create_dependency("A", "2"))
package_b1.add_dependency(Factory.create_dependency("C", "2"))
package_b2 = get_package("B", "2")
package_b2.add_dependency(Factory.create_dependency("A", "1"))
package_b2.add_dependency(Factory.create_dependency("C", "1"))
repo.add_package(package_a1)
repo.add_package(package_a2)
repo.add_package(package_a3)
repo.add_package(package_b1)
repo.add_package(package_b2)
repo.add_package(package_c1)
repo.add_package(package_c2)
repo.add_package(package_c3)
if is_locked:
locked = [package_a1, package_b2, package_c1]
use_latest = [package.name for package in locked]
else:
locked = []
use_latest = []
solver = Solver(package, pool, [], locked, io)
transaction = solver.solve(use_latest)
check_solver_result(
transaction,
[
{"job": "install", "package": package_c1},
{"job": "install", "package": package_b2},
{"job": "install", "package": package_a1},
],
)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment