diff --git a/src/poetry/mixology/version_solver.py b/src/poetry/mixology/version_solver.py index 7650ebf086f..06ae39443e0 100644 --- a/src/poetry/mixology/version_solver.py +++ b/src/poetry/mixology/version_solver.py @@ -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] diff --git a/tests/puzzle/test_solver.py b/tests/puzzle/test_solver.py index 2bfd4f8313a..4a670c93df7 100644 --- a/tests/puzzle/test_solver.py +++ b/tests/puzzle/test_solver.py @@ -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}, + ], + )