Skip to content

Commit 6a8956d

Browse files
committed
Merge pull request pypa#8932 from uranusjr/new-resolver-lazy-sequence
1 parent 4aec7e8 commit 6a8956d

File tree

6 files changed

+186
-107
lines changed

6 files changed

+186
-107
lines changed

news/8023.feature.rst

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
New resolver: Avoid accessing indexes when the installed candidate is preferred
2+
and considered good enough.

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

+28-31
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
import collections
21
import logging
32

4-
from pip._vendor import six
53
from pip._vendor.packaging.utils import canonicalize_name
64

75
from pip._internal.exceptions import (
@@ -30,6 +28,7 @@
3028
LinkCandidate,
3129
RequiresPythonCandidate,
3230
)
31+
from .found_candidates import FoundCandidates
3332
from .requirements import (
3433
ExplicitRequirement,
3534
RequiresPythonRequirement,
@@ -41,6 +40,7 @@
4140
FrozenSet,
4241
Dict,
4342
Iterable,
43+
Iterator,
4444
List,
4545
Optional,
4646
Sequence,
@@ -102,7 +102,7 @@ def __init__(
102102
if not ignore_installed:
103103
self._installed_dists = {
104104
canonicalize_name(dist.project_name): dist
105-
for dist in get_installed_distributions()
105+
for dist in get_installed_distributions(local_only=False)
106106
}
107107
else:
108108
self._installed_dists = {}
@@ -156,6 +156,7 @@ def _iter_found_candidates(
156156
ireqs, # type: Sequence[InstallRequirement]
157157
specifier, # type: SpecifierSet
158158
hashes, # type: Hashes
159+
prefers_installed, # type: bool
159160
):
160161
# type: (...) -> Iterable[Candidate]
161162
if not ireqs:
@@ -174,54 +175,49 @@ def _iter_found_candidates(
174175
hashes &= ireq.hashes(trust_internet=False)
175176
extras |= frozenset(ireq.extras)
176177

177-
# We use this to ensure that we only yield a single candidate for
178-
# each version (the finder's preferred one for that version). The
179-
# requirement needs to return only one candidate per version, so we
180-
# implement that logic here so that requirements using this helper
181-
# don't all have to do the same thing later.
182-
candidates = collections.OrderedDict() # type: VersionCandidates
183-
184178
# Get the installed version, if it matches, unless the user
185179
# specified `--force-reinstall`, when we want the version from
186180
# the index instead.
187-
installed_version = None
188181
installed_candidate = None
189182
if not self._force_reinstall and name in self._installed_dists:
190183
installed_dist = self._installed_dists[name]
191-
installed_version = installed_dist.parsed_version
192-
if specifier.contains(installed_version, prereleases=True):
184+
if specifier.contains(installed_dist.version, prereleases=True):
193185
installed_candidate = self._make_candidate_from_dist(
194186
dist=installed_dist,
195187
extras=extras,
196188
template=template,
197189
)
198190

199-
found = self._finder.find_best_candidate(
200-
project_name=name,
201-
specifier=specifier,
202-
hashes=hashes,
203-
)
204-
for ican in found.iter_applicable():
205-
if ican.version == installed_version and installed_candidate:
206-
candidate = installed_candidate
207-
else:
208-
candidate = self._make_candidate_from_link(
191+
def iter_index_candidates():
192+
# type: () -> Iterator[Candidate]
193+
result = self._finder.find_best_candidate(
194+
project_name=name,
195+
specifier=specifier,
196+
hashes=hashes,
197+
)
198+
# PackageFinder returns earlier versions first, so we reverse.
199+
for ican in reversed(list(result.iter_applicable())):
200+
yield self._make_candidate_from_link(
209201
link=ican.link,
210202
extras=extras,
211203
template=template,
212204
name=name,
213205
version=ican.version,
214206
)
215-
candidates[ican.version] = candidate
216-
217-
# Yield the installed version even if it is not found on the index.
218-
if installed_version and installed_candidate:
219-
candidates[installed_version] = installed_candidate
220207

221-
return six.itervalues(candidates)
208+
return FoundCandidates(
209+
iter_index_candidates,
210+
installed_candidate,
211+
prefers_installed,
212+
)
222213

223-
def find_candidates(self, requirements, constraint):
224-
# type: (Sequence[Requirement], Constraint) -> Iterable[Candidate]
214+
def find_candidates(
215+
self,
216+
requirements, # type: Sequence[Requirement]
217+
constraint, # type: Constraint
218+
prefers_installed, # type: bool
219+
):
220+
# type: (...) -> Iterable[Candidate]
225221
explicit_candidates = set() # type: Set[Candidate]
226222
ireqs = [] # type: List[InstallRequirement]
227223
for req in requirements:
@@ -238,6 +234,7 @@ def find_candidates(self, requirements, constraint):
238234
ireqs,
239235
constraint.specifier,
240236
constraint.hashes,
237+
prefers_installed,
241238
)
242239

243240
if constraint:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import itertools
2+
import operator
3+
4+
from pip._vendor.six.moves import collections_abc # type: ignore
5+
6+
from pip._internal.utils.compat import lru_cache
7+
from pip._internal.utils.typing import MYPY_CHECK_RUNNING
8+
9+
if MYPY_CHECK_RUNNING:
10+
from typing import Callable, Iterator, Optional, Set
11+
12+
from pip._vendor.packaging.version import _BaseVersion
13+
14+
from .base import Candidate
15+
16+
17+
def _deduplicated_by_version(candidates):
18+
# type: (Iterator[Candidate]) -> Iterator[Candidate]
19+
returned = set() # type: Set[_BaseVersion]
20+
for candidate in candidates:
21+
if candidate.version in returned:
22+
continue
23+
returned.add(candidate.version)
24+
yield candidate
25+
26+
27+
def _insert_installed(installed, others):
28+
# type: (Candidate, Iterator[Candidate]) -> Iterator[Candidate]
29+
"""Iterator for ``FoundCandidates``.
30+
31+
This iterator is used when the resolver prefers to upgrade an
32+
already-installed package. Candidates from index are returned in their
33+
normal ordering, except replaced when the version is already installed.
34+
35+
Since candidates from index are already sorted by reverse version order,
36+
`sorted()` here would keep the ordering mostly intact, only shuffling the
37+
already-installed candidate into the correct position. We put the already-
38+
installed candidate in front of those from the index, so it's put in front
39+
after sorting due to Python sorting's stableness guarentee.
40+
"""
41+
candidates = sorted(
42+
itertools.chain([installed], others),
43+
key=operator.attrgetter("version"),
44+
reverse=True,
45+
)
46+
return iter(candidates)
47+
48+
49+
class FoundCandidates(collections_abc.Sequence):
50+
"""A lazy sequence to provide candidates to the resolver.
51+
52+
The intended usage is to return this from `find_matches()` so the resolver
53+
can iterate through the sequence multiple times, but only access the index
54+
page when remote packages are actually needed. This improve performances
55+
when suitable candidates are already installed on disk.
56+
"""
57+
def __init__(
58+
self,
59+
get_others, # type: Callable[[], Iterator[Candidate]]
60+
installed, # type: Optional[Candidate]
61+
prefers_installed, # type: bool
62+
):
63+
self._get_others = get_others
64+
self._installed = installed
65+
self._prefers_installed = prefers_installed
66+
67+
def __getitem__(self, index):
68+
# type: (int) -> Candidate
69+
# Implemented to satisfy the ABC check. This is not needed by the
70+
# resolver, and should not be used by the provider either (for
71+
# performance reasons).
72+
raise NotImplementedError("don't do this")
73+
74+
def __iter__(self):
75+
# type: () -> Iterator[Candidate]
76+
if not self._installed:
77+
candidates = self._get_others()
78+
elif self._prefers_installed:
79+
candidates = itertools.chain([self._installed], self._get_others())
80+
else:
81+
candidates = _insert_installed(self._installed, self._get_others())
82+
return _deduplicated_by_version(candidates)
83+
84+
def __len__(self):
85+
# type: () -> int
86+
# Implemented to satisfy the ABC check. This is not needed by the
87+
# resolver, and should not be used by the provider either (for
88+
# performance reasons).
89+
raise NotImplementedError("don't do this")
90+
91+
@lru_cache(maxsize=1)
92+
def __bool__(self):
93+
# type: () -> bool
94+
if self._prefers_installed and self._installed:
95+
return True
96+
return any(self)
97+
98+
__nonzero__ = __bool__ # XXX: Python 2.

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

+24-73
Original file line numberDiff line numberDiff line change
@@ -54,30 +54,26 @@ def __init__(
5454
self._upgrade_strategy = upgrade_strategy
5555
self._user_requested = user_requested
5656

57-
def _sort_matches(self, matches):
58-
# type: (Iterable[Candidate]) -> Sequence[Candidate]
59-
60-
# The requirement is responsible for returning a sequence of potential
61-
# candidates, one per version. The provider handles the logic of
62-
# deciding the order in which these candidates should be passed to
63-
# the resolver.
64-
65-
# The `matches` argument is a sequence of candidates, one per version,
66-
# which are potential options to be installed. The requirement will
67-
# have already sorted out whether to give us an already-installed
68-
# candidate or a version from PyPI (i.e., it will deal with options
69-
# like --force-reinstall and --ignore-installed).
70-
71-
# We now work out the correct order.
72-
#
73-
# 1. If no other considerations apply, later versions take priority.
74-
# 2. An already installed distribution is preferred over any other,
75-
# unless the user has requested an upgrade.
76-
# Upgrades are allowed when:
77-
# * The --upgrade flag is set, and
78-
# - The project was specified on the command line, or
79-
# - The project is a dependency and the "eager" upgrade strategy
80-
# was requested.
57+
def identify(self, dependency):
58+
# type: (Union[Requirement, Candidate]) -> str
59+
return dependency.name
60+
61+
def get_preference(
62+
self,
63+
resolution, # type: Optional[Candidate]
64+
candidates, # type: Sequence[Candidate]
65+
information # type: Sequence[Tuple[Requirement, Candidate]]
66+
):
67+
# type: (...) -> Any
68+
transitive = all(parent is not None for _, parent in information)
69+
return (transitive, bool(candidates))
70+
71+
def find_matches(self, requirements):
72+
# type: (Sequence[Requirement]) -> Iterable[Candidate]
73+
if not requirements:
74+
return []
75+
name = requirements[0].name
76+
8177
def _eligible_for_upgrade(name):
8278
# type: (str) -> bool
8379
"""Are upgrades allowed for this project?
@@ -96,56 +92,11 @@ def _eligible_for_upgrade(name):
9692
return (name in self._user_requested)
9793
return False
9894

99-
def sort_key(c):
100-
# type: (Candidate) -> int
101-
"""Return a sort key for the matches.
102-
103-
The highest priority should be given to installed candidates that
104-
are not eligible for upgrade. We use the integer value in the first
105-
part of the key to sort these before other candidates.
106-
107-
We only pull the installed candidate to the bottom (i.e. most
108-
preferred), but otherwise keep the ordering returned by the
109-
requirement. The requirement is responsible for returning a list
110-
otherwise sorted for the resolver, taking account for versions
111-
and binary preferences as specified by the user.
112-
"""
113-
if c.is_installed and not _eligible_for_upgrade(c.name):
114-
return 1
115-
return 0
116-
117-
return sorted(matches, key=sort_key)
118-
119-
def identify(self, dependency):
120-
# type: (Union[Requirement, Candidate]) -> str
121-
return dependency.name
122-
123-
def get_preference(
124-
self,
125-
resolution, # type: Optional[Candidate]
126-
candidates, # type: Sequence[Candidate]
127-
information # type: Sequence[Tuple[Requirement, Optional[Candidate]]]
128-
):
129-
# type: (...) -> Any
130-
"""Return a sort key to determine what dependency to look next.
131-
132-
A smaller value makes a dependency higher priority. We put direct
133-
(user-requested) dependencies first since they may contain useful
134-
user-specified version ranges. Users tend to expect us to catch
135-
problems in them early as well.
136-
"""
137-
transitive = all(parent is not None for _, parent in information)
138-
return (transitive, len(candidates))
139-
140-
def find_matches(self, requirements):
141-
# type: (Sequence[Requirement]) -> Iterable[Candidate]
142-
if not requirements:
143-
return []
144-
constraint = self._constraints.get(
145-
requirements[0].name, Constraint.empty(),
95+
return self._factory.find_candidates(
96+
requirements,
97+
constraint=self._constraints.get(name, Constraint.empty()),
98+
prefers_installed=(not _eligible_for_upgrade(name)),
14699
)
147-
candidates = self._factory.find_candidates(requirements, constraint)
148-
return reversed(self._sort_matches(candidates))
149100

150101
def is_satisfied_by(self, requirement, candidate):
151102
# type: (Requirement, Candidate) -> bool

tests/functional/test_new_resolver.py

+26
Original file line numberDiff line numberDiff line change
@@ -1020,3 +1020,29 @@ def test_new_resolver_no_deps_checks_requires_python(script):
10201020
"{}.{}.{} not in '<2'".format(*sys.version_info[:3])
10211021
)
10221022
assert message in result.stderr
1023+
1024+
1025+
def test_new_resolver_prefers_installed_in_upgrade_if_latest(script):
1026+
create_basic_wheel_for_package(script, "pkg", "1")
1027+
local_pkg = create_test_package_with_setup(script, name="pkg", version="2")
1028+
1029+
# Install the version that's not on the index.
1030+
script.pip(
1031+
"install",
1032+
"--use-feature=2020-resolver",
1033+
"--no-cache-dir",
1034+
"--no-index",
1035+
local_pkg,
1036+
)
1037+
1038+
# Now --upgrade should still pick the local version because it's "better".
1039+
script.pip(
1040+
"install",
1041+
"--use-feature=2020-resolver",
1042+
"--no-cache-dir",
1043+
"--no-index",
1044+
"--find-links", script.scratch_path,
1045+
"--upgrade",
1046+
"pkg",
1047+
)
1048+
assert_installed(script, pkg="2")

tests/unit/resolution_resolvelib/test_requirement.py

+8-3
Original file line numberDiff line numberDiff line change
@@ -58,16 +58,21 @@ def test_new_resolver_correct_number_of_matches(test_cases, factory):
5858
"""Requirements should return the correct number of candidates"""
5959
for spec, _, match_count in test_cases:
6060
req = factory.make_requirement_from_spec(spec, comes_from=None)
61-
matches = factory.find_candidates([req], Constraint.empty())
62-
assert len(list(matches)) == match_count
61+
matches = factory.find_candidates(
62+
[req], Constraint.empty(), prefers_installed=False,
63+
)
64+
assert sum(1 for _ in matches) == match_count
6365

6466

6567
def test_new_resolver_candidates_match_requirement(test_cases, factory):
6668
"""Candidates returned from find_candidates should satisfy the requirement
6769
"""
6870
for spec, _, _ in test_cases:
6971
req = factory.make_requirement_from_spec(spec, comes_from=None)
70-
for c in factory.find_candidates([req], Constraint.empty()):
72+
candidates = factory.find_candidates(
73+
[req], Constraint.empty(), prefers_installed=False,
74+
)
75+
for c in candidates:
7176
assert isinstance(c, Candidate)
7277
assert req.is_satisfied_by(c)
7378

0 commit comments

Comments
 (0)