diff --git a/src/pip/_internal/resolution/resolvelib/provider.py b/src/pip/_internal/resolution/resolvelib/provider.py index b554ce83862..24785f5039f 100644 --- a/src/pip/_internal/resolution/resolvelib/provider.py +++ b/src/pip/_internal/resolution/resolvelib/provider.py @@ -18,6 +18,7 @@ from .factory import Factory if TYPE_CHECKING: + from pip._vendor.packaging.specifiers import SpecifierSet from pip._vendor.resolvelib.providers import Preference from pip._vendor.resolvelib.resolvers import RequirementInformation @@ -75,6 +76,117 @@ def _get_with_identifier( return default +def causes_with_conflicting_parent( + causes: Sequence["PreferenceInformation"], +) -> Sequence["PreferenceInformation"]: + """Given causes return which causes conflict because their parent + is not satisfied by another cause, or another causes's parent is + not satisfied by them + """ + # To avoid duplication keeps track of already found conflicting cause by it's id + conflicting_causes_by_id: dict[int, "PreferenceInformation"] = {} + all_causes_by_id = {id(c): c for c in causes} + + # Build a relationship between causes, cause ids, and cause parent names + causes_ids_and_parents_by_parent_name: dict[ + str, list[tuple[int, Candidate]] + ] = collections.defaultdict(list) + for cause_id, cause in all_causes_by_id.items(): + if cause.parent: + causes_ids_and_parents_by_parent_name[cause.parent.name].append( + (cause_id, cause.parent) + ) + + # Check each cause and see if conflicts with the parent of another cause + for cause_id, cause in all_causes_by_id.items(): + if cause_id in conflicting_causes_by_id: + continue + + cause_id_and_parents = causes_ids_and_parents_by_parent_name.get( + cause.requirement.name + ) + if not cause_id_and_parents: + continue + + for other_cause_id, parent in cause_id_and_parents: + if not cause.requirement.is_satisfied_by(parent): + conflicting_causes_by_id[cause_id] = cause + conflicting_causes_by_id[other_cause_id] = all_causes_by_id[ + other_cause_id + ] + + return list(conflicting_causes_by_id.values()) + + +def _check_conflict_between_specifier_sets( + specifier_set1: "SpecifierSet", specifier_set2: "SpecifierSet" +) -> bool: + for specifier1 in specifier_set1: + if specifier_set2.contains(specifier1.version): + return True + return False + + +def _is_mutually_exclusive_specifier_sets( + specifier_set1: "SpecifierSet", specifier_set2: "SpecifierSet" +) -> bool: + return not ( + _check_conflict_between_specifier_sets(specifier_set1, specifier_set2) + or _check_conflict_between_specifier_sets(specifier_set2, specifier_set1) + ) + + +def causes_with_conflicting_specifiers( + causes: Sequence["PreferenceInformation"], +) -> Sequence["PreferenceInformation"]: + """Given causes return which causes conflict because theierspecifier + conflict with another specifier""" + # Group causes by name first to avoid large O(n^2) comparison + causes_by_name: dict[str, list["PreferenceInformation"]] = collections.defaultdict( + list + ) + for cause in causes: + causes_by_name[cause.requirement.project_name].append(cause) + + # Check each cause that has the same name, and check if their specifiers conflict + conflicting_causes: list["PreferenceInformation"] = [] + for causes_list in causes_by_name.values(): + if len(causes_list) < 2: + continue + + while causes_list: + cause = causes_list.pop() + candidate = cause.requirement.get_candidate_lookup()[1] + if candidate is None: + continue + + # If either specifier set provies no restrictions they can be skipped + if len(candidate.specifier) == 0: + continue + + cause_had_conflict = False + for i, other_cause in enumerate(causes_list): + other_candidate = other_cause.requirement.get_candidate_lookup()[1] + if other_candidate is None: + continue + + if len(other_candidate.specifier) == 0: + continue + + # If specifier set are mutually exclusive they can not both + # be fufilled and therefore are a conflict + if _is_mutually_exclusive_specifier_sets( + candidate.specifier, other_candidate.specifier + ): + conflicting_causes.append(causes_list.pop(i)) + cause_had_conflict = True + + if cause_had_conflict: + conflicting_causes.append(cause) + + return conflicting_causes + + class PipProvider(_ProviderBase): """Pip's provider implementation for resolvelib. @@ -243,11 +355,24 @@ def filter_unsatisfied_names( causes: Sequence["PreferenceInformation"], ) -> Iterable[str]: """ - Prefer backtracking on unsatisfied names that are causes + Prefer backtracking on unsatisfied names that are conficting + causes, or secondly are causes """ if not causes: return unsatisfied_names + # Check if causes are conflicting, prefer cause specifier + # conflict first, and a cause parent conflicts second + if len(causes) > 2: + _conflicting_causes = causes_with_conflicting_specifiers(causes) + if _conflicting_causes: + causes = _conflicting_causes + else: + _conflicting_causes = causes_with_conflicting_parent(causes) + if _conflicting_causes: + causes = _conflicting_causes + del _conflicting_causes + # Extract the causes and parents names causes_names = set() for cause in causes: