Skip to content

Commit

Permalink
solver: make results of poetry update more deterministic and simila…
Browse files Browse the repository at this point in the history
…r 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.
  • Loading branch information
radoering authored and neersighted committed Sep 17, 2022
1 parent ebee342 commit 4914c26
Show file tree
Hide file tree
Showing 2 changed files with 102 additions and 14 deletions.
47 changes: 33 additions & 14 deletions src/poetry/mixology/version_solver.py
Expand Up @@ -362,31 +362,50 @@ def _choose_package_version(self) -> str | None:
if not unsatisfied:
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,
# 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
# a regular dependency for some package only to find later that we had a
# direct-origin dependency.
if dependency.is_direct_origin():
return False, -1
return False, Preference.DIRECT_ORIGIN, 1

if dependency.name in self._provider.use_latest:
# 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
is_specific_marker = not dependency.marker.is_any()

locked = self._provider.get_locked(dependency)
if locked:
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)
if locked:
return is_specific_marker, Preference.LOCKED, 1

try:
return (
not dependency.marker.is_any(),
len(self._dependency_cache.search_for(dependency)),
)
num_packages = len(self._dependency_cache.search_for(dependency))
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:
dependency = unsatisfied[0]
Expand Down
69 changes: 69 additions & 0 deletions tests/puzzle/test_solver.py
Expand Up @@ -3716,3 +3716,72 @@ def test_solver_yanked_warning(
)
assert error.count("is a yanked version") == 2
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},
],
)

0 comments on commit 4914c26

Please sign in to comment.