Skip to content

Commit 2520466

Browse files
authored
feat: implement conflict promotion logic in requirement selection (#3751)
* feat: implement conflict promotion logic in requirement selection Signed-off-by: Frost Ming <me@frostming.com> * feat: add feature description for speeding up dependency resolution in complex conflicts Signed-off-by: Frost Ming <me@frostming.com>
1 parent d14eebc commit 2520466

3 files changed

Lines changed: 118 additions & 6 deletions

File tree

news/3751.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Speed up dependency resolution when there are complex conflicts.

src/pdm/resolver/providers.py

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import dataclasses
44
import os
5+
from collections import defaultdict
56
from functools import cached_property
67
from typing import TYPE_CHECKING, Callable
78

@@ -39,6 +40,7 @@
3940

4041

4142
_PROVIDER_REGISTRY: dict[str, type[BaseProvider]] = {}
43+
_CONFLICT_PRIORITY_THRESHOLD = 5
4244

4345

4446
def get_provider(strategy: str) -> type[BaseProvider]:
@@ -79,6 +81,8 @@ def __init__(
7981
self.excludes = {normalize_name(k) for k in project.pyproject.resolution.get("excludes", [])}
8082
self.direct_minimal_versions = direct_minimal_versions
8183
self.locked_repository = locked_repository
84+
self._conflict_counts: defaultdict[str, int] = defaultdict(int)
85+
self._conflict_promoted: set[str] = set()
8286

8387
def requirement_preference(self, requirement: Requirement) -> Comparable:
8488
"""Return the preference of a requirement to find candidates.
@@ -97,12 +101,49 @@ def requirement_preference(self, requirement: Requirement) -> Comparable:
97101
def identify(self, requirement_or_candidate: Requirement | Candidate) -> str:
98102
return requirement_or_candidate.identify()
99103

104+
def narrow_requirement_selection(
105+
self,
106+
identifiers: Iterable[str],
107+
resolutions: Mapping[str, Candidate],
108+
candidates: Mapping[str, Iterator[Candidate]],
109+
information: Mapping[str, Iterator[RequirementInformation]],
110+
backtrack_causes: Sequence[RequirementInformation],
111+
) -> Iterable[str]:
112+
backtrack_identifiers: set[str] = set()
113+
for requirement, parent in backtrack_causes:
114+
names = [requirement.identify()]
115+
if parent is not None:
116+
names.append(parent.identify())
117+
for name in names:
118+
backtrack_identifiers.add(name)
119+
if name not in resolutions:
120+
self._conflict_counts[name] += 1
121+
if self._conflict_counts[name] >= _CONFLICT_PRIORITY_THRESHOLD:
122+
self._conflict_promoted.add(name)
123+
124+
current_backtrack_causes: list[str] = []
125+
promoted: list[str] = []
126+
for identifier in identifiers:
127+
if identifier == "python":
128+
return [identifier]
129+
if identifier in backtrack_identifiers:
130+
current_backtrack_causes.append(identifier)
131+
continue
132+
if identifier in self._conflict_promoted:
133+
promoted.append(identifier)
134+
135+
if current_backtrack_causes:
136+
return current_backtrack_causes
137+
if promoted:
138+
return promoted
139+
return identifiers
140+
100141
def get_preference(
101142
self,
102143
identifier: str,
103-
resolutions: dict[str, Candidate],
104-
candidates: dict[str, Iterator[Candidate]],
105-
information: dict[str, Iterator[RequirementInformation]],
144+
resolutions: Mapping[str, Candidate],
145+
candidates: Mapping[str, Iterator[Candidate]],
146+
information: Mapping[str, Iterator[RequirementInformation]],
106147
backtrack_causes: Sequence[RequirementInformation],
107148
) -> tuple[Comparable, ...]:
108149
is_top = any(parent is None for _, parent in information[identifier])
@@ -123,9 +164,11 @@ def get_preference(
123164
is_python = identifier == "python"
124165
is_pinned = any(op[:2] == "==" for op in operators)
125166
constraints = len(operators)
167+
is_conflict_promoted = identifier in self._conflict_promoted
126168
return (
127169
not is_python,
128170
not is_top,
171+
not is_conflict_promoted,
129172
not is_file_or_url,
130173
not is_pinned,
131174
not is_backtrack_cause,
@@ -458,9 +501,9 @@ def get_dependencies(self, candidate: Candidate) -> list[Requirement]:
458501
def get_preference(
459502
self,
460503
identifier: str,
461-
resolutions: dict[str, Candidate],
462-
candidates: dict[str, Iterator[Candidate]],
463-
information: dict[str, Iterator[RequirementInformation]],
504+
resolutions: Mapping[str, Candidate],
505+
candidates: Mapping[str, Iterator[Candidate]],
506+
information: Mapping[str, Iterator[RequirementInformation]],
464507
backtrack_causes: Sequence[RequirementInformation],
465508
) -> tuple[Comparable, ...]:
466509
# Resolve tracking packages so we have a chance to unpin them first.

tests/resolver/test_providers.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Iterator
4+
5+
from resolvelib.resolvers import RequirementInformation
6+
7+
from pdm.models.candidates import Candidate
8+
from pdm.models.requirements import parse_requirement
9+
from pdm.resolver.providers import _CONFLICT_PRIORITY_THRESHOLD
10+
11+
12+
def _build_candidates(identifier: str) -> dict[str, Iterator[Candidate]]:
13+
requirement = parse_requirement(identifier)
14+
candidate = Candidate(requirement, name=requirement.project_name, version="1.0")
15+
return {identifier: iter([candidate])}
16+
17+
18+
def _build_information(identifier: str) -> dict[str, Iterator[RequirementInformation]]:
19+
requirement = parse_requirement(identifier)
20+
return {identifier: iter([RequirementInformation(requirement, None)])}
21+
22+
23+
def test_narrow_requirement_selection_promotes_repeated_conflicts(project, repository):
24+
repository.add_candidate("conflict-pkg", "1.0")
25+
repository.add_candidate("other-pkg", "1.0")
26+
27+
provider = project.get_provider()
28+
narrow = provider.narrow_requirement_selection
29+
causes = [RequirementInformation(parse_requirement("conflict-pkg"), None)]
30+
31+
for _ in range(1, _CONFLICT_PRIORITY_THRESHOLD):
32+
result = list(narrow(["other-pkg"], {}, {}, {}, causes))
33+
assert result == ["other-pkg"]
34+
35+
result = list(narrow(["other-pkg", "conflict-pkg"], {}, {}, {}, causes))
36+
assert result == ["conflict-pkg"]
37+
38+
result = list(narrow(["other-pkg", "conflict-pkg"], {}, {}, {}, []))
39+
assert result == ["conflict-pkg"]
40+
41+
other_causes = [RequirementInformation(parse_requirement("other-pkg"), None)]
42+
result = list(narrow(["other-pkg", "conflict-pkg"], {}, {}, {}, other_causes))
43+
assert result == ["other-pkg"]
44+
45+
46+
def test_get_preference_prioritizes_promoted_conflicts(project, repository):
47+
repository.add_candidate("promoted-pkg", "1.0")
48+
repository.add_candidate("normal-pkg", "1.0")
49+
50+
provider = project.get_provider()
51+
provider._conflict_promoted.add("promoted-pkg")
52+
53+
promoted_preference = provider.get_preference(
54+
"promoted-pkg",
55+
{},
56+
_build_candidates("promoted-pkg"),
57+
_build_information("promoted-pkg"),
58+
[],
59+
)
60+
normal_preference = provider.get_preference(
61+
"normal-pkg",
62+
{},
63+
_build_candidates("normal-pkg"),
64+
_build_information("normal-pkg"),
65+
[],
66+
)
67+
68+
assert promoted_preference < normal_preference

0 commit comments

Comments
 (0)