|
6 | 6 | Dict, |
7 | 7 | Iterable, |
8 | 8 | Iterator, |
| 9 | + List, |
9 | 10 | Mapping, |
10 | 11 | Sequence, |
| 12 | + Set, |
11 | 13 | TypeVar, |
12 | 14 | Union, |
13 | 15 | ) |
@@ -76,6 +78,133 @@ def _get_with_identifier( |
76 | 78 | return default |
77 | 79 |
|
78 | 80 |
|
| 81 | +def _extract_names_from_causes_and_parents( |
| 82 | + causes: Iterable["PreferenceInformation"], |
| 83 | +) -> Set[str]: |
| 84 | + """ |
| 85 | + Utility function to extract names from the causes and their parent packages |
| 86 | +
|
| 87 | + :params causes: An iterable of PreferenceInformation |
| 88 | +
|
| 89 | + Returns a set of strings, each representing the name of a requirement or |
| 90 | + its parent package that was in causes |
| 91 | + """ |
| 92 | + causes_names = set() |
| 93 | + for cause in causes: |
| 94 | + causes_names.add(cause.requirement.name) |
| 95 | + if cause.parent: |
| 96 | + causes_names.add(cause.parent.name) |
| 97 | + |
| 98 | + return causes_names |
| 99 | + |
| 100 | + |
| 101 | +def _causes_with_conflicting_parent( |
| 102 | + causes: Iterable["PreferenceInformation"], |
| 103 | +) -> List["PreferenceInformation"]: |
| 104 | + """ |
| 105 | + Identifies causes that conflict because their parent package requirements |
| 106 | + are not satisfied by another cause, or vice versa. |
| 107 | +
|
| 108 | + :params causes: An iterable sequence of PreferenceInformation |
| 109 | +
|
| 110 | + Returns a list of PreferenceInformation objects that represent the causes |
| 111 | + where their parent conflicts |
| 112 | + """ |
| 113 | + # Avoid duplication by keeping track of already identified conflicting |
| 114 | + # causes by their id |
| 115 | + conflicting_causes_by_id: dict[int, "PreferenceInformation"] = {} |
| 116 | + all_causes_by_id = {id(c): c for c in causes} |
| 117 | + |
| 118 | + # Map cause IDs and parent packages by parent name for quick lookup |
| 119 | + causes_ids_and_parents_by_parent_name: dict[ |
| 120 | + str, list[tuple[int, Candidate]] |
| 121 | + ] = collections.defaultdict(list) |
| 122 | + for cause_id, cause in all_causes_by_id.items(): |
| 123 | + if cause.parent: |
| 124 | + causes_ids_and_parents_by_parent_name[cause.parent.name].append( |
| 125 | + (cause_id, cause.parent) |
| 126 | + ) |
| 127 | + |
| 128 | + # Identify a cause's requirement conflicts with another cause's parent |
| 129 | + for cause_id, cause in all_causes_by_id.items(): |
| 130 | + if cause_id in conflicting_causes_by_id: |
| 131 | + continue |
| 132 | + |
| 133 | + cause_id_and_parents = causes_ids_and_parents_by_parent_name.get( |
| 134 | + cause.requirement.name |
| 135 | + ) |
| 136 | + if not cause_id_and_parents: |
| 137 | + continue |
| 138 | + |
| 139 | + for other_cause_id, parent in cause_id_and_parents: |
| 140 | + if not cause.requirement.is_satisfied_by(parent): |
| 141 | + conflicting_causes_by_id[cause_id] = cause |
| 142 | + conflicting_causes_by_id[other_cause_id] = all_causes_by_id[ |
| 143 | + other_cause_id |
| 144 | + ] |
| 145 | + |
| 146 | + return list(conflicting_causes_by_id.values()) |
| 147 | + |
| 148 | + |
| 149 | +def _first_causes_with_no_candidates( |
| 150 | + causes: Sequence["PreferenceInformation"], |
| 151 | + candidates: Mapping[str, Iterator[Candidate]], |
| 152 | +) -> List["PreferenceInformation"]: |
| 153 | + """ |
| 154 | + Checks for causes that have no possible candidates to satisfy their |
| 155 | + requirements. Returns first causes found as iterating candidates can |
| 156 | + be expensive due to downloading and building packages. |
| 157 | +
|
| 158 | + :params causes: A sequence of PreferenceInformation |
| 159 | + :params candidates: A mapping of package names to iterators of their candidates |
| 160 | +
|
| 161 | + Returns a list containing the first pair of PreferenceInformation objects |
| 162 | + that were found which had no satisfying candidates, else if all causes |
| 163 | + had at least some satisfying candidate an empty list is returned. |
| 164 | + """ |
| 165 | + # Group causes by package name to reduce the comparison complexity. |
| 166 | + causes_by_name: dict[str, list["PreferenceInformation"]] = collections.defaultdict( |
| 167 | + list |
| 168 | + ) |
| 169 | + for cause in causes: |
| 170 | + causes_by_name[cause.requirement.project_name].append(cause) |
| 171 | + |
| 172 | + # Check for cause pairs within the same package that have incompatible specifiers. |
| 173 | + for cause_name, causes_list in causes_by_name.items(): |
| 174 | + if len(causes_list) < 2: |
| 175 | + continue |
| 176 | + |
| 177 | + while causes_list: |
| 178 | + cause = causes_list.pop() |
| 179 | + candidate = cause.requirement.get_candidate_lookup()[1] |
| 180 | + if candidate is None: |
| 181 | + continue |
| 182 | + |
| 183 | + for other_cause in causes_list: |
| 184 | + other_candidate = other_cause.requirement.get_candidate_lookup()[1] |
| 185 | + if other_candidate is None: |
| 186 | + continue |
| 187 | + |
| 188 | + # Check if no candidate can match the combined specifier |
| 189 | + combined_specifier = candidate.specifier & other_candidate.specifier |
| 190 | + possible_candidates = candidates.get(cause_name) |
| 191 | + |
| 192 | + # If no candidates have been provided then by default the |
| 193 | + # causes have no candidates |
| 194 | + if possible_candidates is None: |
| 195 | + return [cause, other_cause] |
| 196 | + |
| 197 | + # Use any and contains version here instead of filter so |
| 198 | + # if a version is found that matches it will short curcuit |
| 199 | + # iterating through possible candidates |
| 200 | + if not any( |
| 201 | + combined_specifier.contains(c.version) for c in possible_candidates |
| 202 | + ): |
| 203 | + return [cause, other_cause] |
| 204 | + |
| 205 | + return [] |
| 206 | + |
| 207 | + |
79 | 208 | class PipProvider(_ProviderBase): |
80 | 209 | """Pip's provider implementation for resolvelib. |
81 | 210 |
|
@@ -256,3 +385,58 @@ def is_backtrack_cause( |
256 | 385 | if backtrack_cause.parent and identifier == backtrack_cause.parent.name: |
257 | 386 | return True |
258 | 387 | return False |
| 388 | + |
| 389 | + def narrow_requirement_selection( |
| 390 | + self, |
| 391 | + identifiers: Iterable[str], |
| 392 | + resolutions: Mapping[str, Candidate], |
| 393 | + candidates: Mapping[str, Iterator[Candidate]], |
| 394 | + information: Mapping[str, Iterable["PreferenceInformation"]], |
| 395 | + backtrack_causes: Sequence["PreferenceInformation"], |
| 396 | + ) -> Iterable[str]: |
| 397 | + """ |
| 398 | + Narrows down the selection of requirements to consider for the next |
| 399 | + resolution step. |
| 400 | +
|
| 401 | + This method uses principles of conflict-driven clause learning (CDCL) |
| 402 | + to focus on the closest conflicts first. |
| 403 | +
|
| 404 | + :params identifiers: Iterable of requirement names currently under |
| 405 | + consideration. |
| 406 | + :params resolutions: Current mapping of resolved package identifiers |
| 407 | + to their selected candidates. |
| 408 | + :params candidates: Mapping of each package's possible candidates. |
| 409 | + :params information: Mapping of requirement information for each package. |
| 410 | + :params backtrack_causes: Sequence of requirements, if non-empty, |
| 411 | + were the cause of the resolver backtracking |
| 412 | +
|
| 413 | + Returns: |
| 414 | + An iterable of requirement names that the resolver will use to |
| 415 | + limit the next step of resolution |
| 416 | + """ |
| 417 | + |
| 418 | + # If there are 2 or less causes then finding conflicts between |
| 419 | + # them is not required as there will always be a minumum of two |
| 420 | + # conflicts |
| 421 | + if len(backtrack_causes) < 3: |
| 422 | + return identifiers |
| 423 | + |
| 424 | + # First, try to resolve direct causes based on conflicting parent packages |
| 425 | + direct_causes = _causes_with_conflicting_parent(backtrack_causes) |
| 426 | + if not direct_causes: |
| 427 | + # If no conflicting parent packages found try to find some causes |
| 428 | + # that share the same requirement name but no common candidate, |
| 429 | + # we take the first one of these as iterating through candidates |
| 430 | + # is potentially expensive |
| 431 | + direct_causes = _first_causes_with_no_candidates( |
| 432 | + backtrack_causes, candidates |
| 433 | + ) |
| 434 | + if direct_causes: |
| 435 | + backtrack_causes = direct_causes |
| 436 | + |
| 437 | + # Filter identifiers based on the narrowed down causes. |
| 438 | + unsatisfied_causes_names = set( |
| 439 | + identifiers |
| 440 | + ) & _extract_names_from_causes_and_parents(backtrack_causes) |
| 441 | + |
| 442 | + return unsatisfied_causes_names if unsatisfied_causes_names else identifiers |
0 commit comments