Skip to content

Commit 1304b2d

Browse files
committed
Narrow resolution to use direct conflicts
1 parent 36ecc54 commit 1304b2d

File tree

1 file changed

+184
-0
lines changed

1 file changed

+184
-0
lines changed

src/pip/_internal/resolution/resolvelib/provider.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
Dict,
77
Iterable,
88
Iterator,
9+
List,
910
Mapping,
1011
Sequence,
12+
Set,
1113
TypeVar,
1214
Union,
1315
)
@@ -76,6 +78,133 @@ def _get_with_identifier(
7678
return default
7779

7880

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+
79208
class PipProvider(_ProviderBase):
80209
"""Pip's provider implementation for resolvelib.
81210
@@ -256,3 +385,58 @@ def is_backtrack_cause(
256385
if backtrack_cause.parent and identifier == backtrack_cause.parent.name:
257386
return True
258387
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

Comments
 (0)