Skip to content

Commit fe21a65

Browse files
committed
Narrow resolution to use direct conflicts
1 parent bbd1015 commit fe21a65

File tree

1 file changed

+180
-0
lines changed

1 file changed

+180
-0
lines changed

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

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
Dict,
66
Iterable,
77
Iterator,
8+
List,
89
Mapping,
910
Sequence,
11+
Set,
1012
TypeVar,
1113
Union,
1214
)
@@ -75,6 +77,133 @@ def _get_with_identifier(
7577
return default
7678

7779

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+
78207
class PipProvider(_ProviderBase):
79208
"""Pip's provider implementation for resolvelib.
80209
@@ -253,3 +382,54 @@ def is_backtrack_cause(
253382
if backtrack_cause.parent and identifier == backtrack_cause.parent.name:
254383
return True
255384
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

Comments
 (0)