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