Skip to content

Commit 3fe826c

Browse files
authored
Merge pull request #8932 from uranusjr/new-resolver-lazy-sequence
2 parents 063f2ae + b921db8 commit 3fe826c

File tree

6 files changed

+187
-114
lines changed

6 files changed

+187
-114
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

+29-38
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
Dict,
4241
FrozenSet,
4342
Iterable,
43+
Iterator,
4444
List,
4545
Optional,
4646
Sequence,
@@ -98,15 +98,9 @@ def __init__(
9898
self._editable_candidate_cache = {} # type: Cache[EditableCandidate]
9999

100100
if not ignore_installed:
101-
packages = get_installed_distributions(
102-
local_only=False,
103-
include_editables=True,
104-
editables_only=False,
105-
user_only=False,
106-
paths=None,
107-
)
108101
self._installed_dists = {
109-
canonicalize_name(p.key): p for p in packages
102+
canonicalize_name(dist.project_name): dist
103+
for dist in get_installed_distributions(local_only=False)
110104
}
111105
else:
112106
self._installed_dists = {}
@@ -160,6 +154,7 @@ def _iter_found_candidates(
160154
ireqs, # type: Sequence[InstallRequirement]
161155
specifier, # type: SpecifierSet
162156
hashes, # type: Hashes
157+
prefers_installed, # type: bool
163158
):
164159
# type: (...) -> Iterable[Candidate]
165160
if not ireqs:
@@ -178,54 +173,49 @@ def _iter_found_candidates(
178173
hashes &= ireq.hashes(trust_internet=False)
179174
extras |= frozenset(ireq.extras)
180175

181-
# We use this to ensure that we only yield a single candidate for
182-
# each version (the finder's preferred one for that version). The
183-
# requirement needs to return only one candidate per version, so we
184-
# implement that logic here so that requirements using this helper
185-
# don't all have to do the same thing later.
186-
candidates = collections.OrderedDict() # type: VersionCandidates
187-
188176
# Get the installed version, if it matches, unless the user
189177
# specified `--force-reinstall`, when we want the version from
190178
# the index instead.
191-
installed_version = None
192179
installed_candidate = None
193180
if not self._force_reinstall and name in self._installed_dists:
194181
installed_dist = self._installed_dists[name]
195-
installed_version = installed_dist.parsed_version
196-
if specifier.contains(installed_version, prereleases=True):
182+
if specifier.contains(installed_dist.version, prereleases=True):
197183
installed_candidate = self._make_candidate_from_dist(
198184
dist=installed_dist,
199185
extras=extras,
200186
template=template,
201187
)
202188

203-
found = self._finder.find_best_candidate(
204-
project_name=name,
205-
specifier=specifier,
206-
hashes=hashes,
207-
)
208-
for ican in found.iter_applicable():
209-
if ican.version == installed_version and installed_candidate:
210-
candidate = installed_candidate
211-
else:
212-
candidate = self._make_candidate_from_link(
189+
def iter_index_candidates():
190+
# type: () -> Iterator[Candidate]
191+
result = self._finder.find_best_candidate(
192+
project_name=name,
193+
specifier=specifier,
194+
hashes=hashes,
195+
)
196+
# PackageFinder returns earlier versions first, so we reverse.
197+
for ican in reversed(list(result.iter_applicable())):
198+
yield self._make_candidate_from_link(
213199
link=ican.link,
214200
extras=extras,
215201
template=template,
216202
name=name,
217203
version=ican.version,
218204
)
219-
candidates[ican.version] = candidate
220205

221-
# Yield the installed version even if it is not found on the index.
222-
if installed_version and installed_candidate:
223-
candidates[installed_version] = installed_candidate
224-
225-
return six.itervalues(candidates)
206+
return FoundCandidates(
207+
iter_index_candidates,
208+
installed_candidate,
209+
prefers_installed,
210+
)
226211

227-
def find_candidates(self, requirements, constraint):
228-
# type: (Sequence[Requirement], Constraint) -> Iterable[Candidate]
212+
def find_candidates(
213+
self,
214+
requirements, # type: Sequence[Requirement]
215+
constraint, # type: Constraint
216+
prefers_installed, # type: bool
217+
):
218+
# type: (...) -> Iterable[Candidate]
229219
explicit_candidates = set() # type: Set[Candidate]
230220
ireqs = [] # type: List[InstallRequirement]
231221
for req in requirements:
@@ -242,6 +232,7 @@ def find_candidates(self, requirements, constraint):
242232
ireqs,
243233
constraint.specifier,
244234
constraint.hashes,
235+
prefers_installed,
245236
)
246237

247238
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
@@ -45,30 +45,26 @@ def __init__(
4545
self._upgrade_strategy = upgrade_strategy
4646
self._user_requested = user_requested
4747

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

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

14192
def is_satisfied_by(self, requirement, candidate):
14293
# 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")

0 commit comments

Comments
 (0)