Skip to content

Commit d42c448

Browse files
authored
Merge pull request #8234 from pfmoore/refactor_find_matches
Split find_matches into generation and sorting
2 parents a879bc4 + 96b3377 commit d42c448

File tree

7 files changed

+151
-63
lines changed

7 files changed

+151
-63
lines changed

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ def version(self):
4444
# type: () -> _BaseVersion
4545
raise NotImplementedError("Override in subclass")
4646

47+
@property
48+
def is_installed(self):
49+
# type: () -> bool
50+
raise NotImplementedError("Override in subclass")
51+
4752
def get_dependencies(self):
4853
# type: () -> Sequence[Requirement]
4954
raise NotImplementedError("Override in subclass")

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ def make_install_req_from_dist(dist, parent):
102102

103103

104104
class _InstallRequirementBackedCandidate(Candidate):
105+
# These are not installed
106+
is_installed = False
107+
105108
def __init__(
106109
self,
107110
link, # type: Link
@@ -271,6 +274,8 @@ def _prepare_abstract_distribution(self):
271274

272275

273276
class AlreadyInstalledCandidate(Candidate):
277+
is_installed = True
278+
274279
def __init__(
275280
self,
276281
dist, # type: Distribution
@@ -392,6 +397,11 @@ def version(self):
392397
# type: () -> _BaseVersion
393398
return self.base.version
394399

400+
@property
401+
def is_installed(self):
402+
# type: () -> _BaseVersion
403+
return self.base.is_installed
404+
395405
def get_dependencies(self):
396406
# type: () -> Sequence[Requirement]
397407
factory = self.base._factory
@@ -428,6 +438,8 @@ def get_install_requirement(self):
428438

429439

430440
class RequiresPythonCandidate(Candidate):
441+
is_installed = False
442+
431443
def __init__(self, py_version_info):
432444
# type: (Optional[Tuple[int, ...]]) -> None
433445
if py_version_info is not None:

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

Lines changed: 33 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,6 @@
4242

4343

4444
class Factory(object):
45-
_allowed_strategies = {"eager", "only-if-needed", "to-satisfy-only"}
46-
4745
def __init__(
4846
self,
4947
finder, # type: PackageFinder
@@ -52,21 +50,16 @@ def __init__(
5250
force_reinstall, # type: bool
5351
ignore_installed, # type: bool
5452
ignore_requires_python, # type: bool
55-
upgrade_strategy, # type: str
5653
py_version_info=None, # type: Optional[Tuple[int, ...]]
5754
):
5855
# type: (...) -> None
59-
assert upgrade_strategy in self._allowed_strategies
6056

6157
self.finder = finder
6258
self.preparer = preparer
6359
self._python_candidate = RequiresPythonCandidate(py_version_info)
6460
self._make_install_req_from_spec = make_install_req
6561
self._force_reinstall = force_reinstall
6662
self._ignore_requires_python = ignore_requires_python
67-
self._upgrade_strategy = upgrade_strategy
68-
69-
self.root_reqs = set() # type: Set[str]
7063

7164
self._link_candidate_cache = {} # type: Cache[LinkCandidate]
7265
self._editable_candidate_cache = {} # type: Cache[EditableCandidate]
@@ -118,64 +111,52 @@ def _make_candidate_from_link(
118111
return ExtrasCandidate(base, extras)
119112
return base
120113

121-
def _eligible_for_upgrade(self, dist_name):
122-
# type: (str) -> bool
123-
if self._upgrade_strategy == "eager":
124-
return True
125-
elif self._upgrade_strategy == "only-if-needed":
126-
return (dist_name in self.root_reqs)
127-
return False
128-
129114
def iter_found_candidates(self, ireq, extras):
130115
# type: (InstallRequirement, Set[str]) -> Iterator[Candidate]
131116
name = canonicalize_name(ireq.req.name)
132-
if not self._force_reinstall:
133-
installed_dist = self._installed_dists.get(name)
134-
can_upgrade = self._eligible_for_upgrade(name)
135-
else:
136-
installed_dist = None
137-
can_upgrade = False
117+
118+
# We use this to ensure that we only yield a single candidate for
119+
# each version (the finder's preferred one for that version). The
120+
# requirement needs to return only one candidate per version, so we
121+
# implement that logic here so that requirements using this helper
122+
# don't all have to do the same thing later.
123+
seen_versions = set() # type: Set[_BaseVersion]
124+
125+
# Yield the installed version, if it matches, unless the user
126+
# specified `--force-reinstall`, when we want the version from
127+
# the index instead.
128+
if not self._force_reinstall and name in self._installed_dists:
129+
installed_dist = self._installed_dists[name]
130+
installed_version = installed_dist.parsed_version
131+
if ireq.req.specifier.contains(
132+
installed_version,
133+
prereleases=True
134+
):
135+
seen_versions.add(installed_version)
136+
yield self._make_candidate_from_dist(
137+
dist=installed_dist,
138+
extras=extras,
139+
parent=ireq,
140+
)
138141

139142
found = self.finder.find_best_candidate(
140143
project_name=ireq.req.name,
141144
specifier=ireq.req.specifier,
142145
hashes=ireq.hashes(trust_internet=False),
143146
)
144147
for ican in found.iter_applicable():
145-
if (installed_dist is not None and
146-
installed_dist.parsed_version == ican.version):
147-
if can_upgrade:
148-
yield self._make_candidate_from_dist(
149-
dist=installed_dist,
150-
extras=extras,
151-
parent=ireq,
152-
)
153-
continue
154-
yield self._make_candidate_from_link(
155-
link=ican.link,
156-
extras=extras,
157-
parent=ireq,
158-
name=name,
159-
version=ican.version,
160-
)
161-
162-
# Return installed distribution if it matches the specifier. This is
163-
# done last so the resolver will prefer it over downloading links.
164-
if can_upgrade or installed_dist is None:
165-
return
166-
installed_version = installed_dist.parsed_version
167-
if ireq.req.specifier.contains(installed_version, prereleases=True):
168-
yield self._make_candidate_from_dist(
169-
dist=installed_dist,
170-
extras=extras,
171-
parent=ireq,
172-
)
148+
if ican.version not in seen_versions:
149+
seen_versions.add(ican.version)
150+
yield self._make_candidate_from_link(
151+
link=ican.link,
152+
extras=extras,
153+
parent=ireq,
154+
name=name,
155+
version=ican.version,
156+
)
173157

174158
def make_requirement_from_install_req(self, ireq):
175159
# type: (InstallRequirement) -> Requirement
176-
if ireq.is_direct and ireq.name:
177-
self.root_reqs.add(canonicalize_name(ireq.name))
178-
179160
if ireq.link:
180161
# TODO: Get name and version from ireq, if possible?
181162
# Specifically, this might be needed in "name @ URL"

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

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,106 @@
44
from pip._internal.utils.typing import MYPY_CHECK_RUNNING
55

66
if MYPY_CHECK_RUNNING:
7-
from typing import Any, Dict, Optional, Sequence, Tuple, Union
7+
from typing import Any, Dict, Optional, Sequence, Set, Tuple, Union
88

99
from pip._internal.req.req_install import InstallRequirement
10+
from pip._vendor.packaging.version import _BaseVersion
1011

1112
from .base import Requirement, Candidate
1213
from .factory import Factory
1314

15+
# Notes on the relationship between the provider, the factory, and the
16+
# candidate and requirement classes.
17+
#
18+
# The provider is a direct implementation of the resolvelib class. Its role
19+
# is to deliver the API that resolvelib expects.
20+
#
21+
# Rather than work with completely abstract "requirement" and "candidate"
22+
# concepts as resolvelib does, pip has concrete classes implementing these two
23+
# ideas. The API of Requirement and Candidate objects are defined in the base
24+
# classes, but essentially map fairly directly to the equivalent provider
25+
# methods. In particular, `find_matches` and `is_satisfied_by` are
26+
# requirement methods, and `get_dependencies` is a candidate method.
27+
#
28+
# The factory is the interface to pip's internal mechanisms. It is stateless,
29+
# and is created by the resolver and held as a property of the provider. It is
30+
# responsible for creating Requirement and Candidate objects, and provides
31+
# services to those objects (access to pip's finder and preparer).
32+
1433

1534
class PipProvider(AbstractProvider):
1635
def __init__(
1736
self,
1837
factory, # type: Factory
1938
constraints, # type: Dict[str, SpecifierSet]
2039
ignore_dependencies, # type: bool
40+
upgrade_strategy, # type: str
41+
user_requested, # type: Set[str]
2142
):
2243
# type: (...) -> None
2344
self._factory = factory
2445
self._constraints = constraints
2546
self._ignore_dependencies = ignore_dependencies
47+
self._upgrade_strategy = upgrade_strategy
48+
self.user_requested = user_requested
49+
50+
def _sort_matches(self, matches):
51+
# type: (Sequence[Candidate]) -> Sequence[Candidate]
52+
53+
# The requirement is responsible for returning a sequence of potential
54+
# candidates, one per version. The provider handles the logic of
55+
# deciding the order in which these candidates should be passed to
56+
# the resolver.
57+
58+
# The `matches` argument is a sequence of candidates, one per version,
59+
# which are potential options to be installed. The requirement will
60+
# have already sorted out whether to give us an already-installed
61+
# candidate or a version from PyPI (i.e., it will deal with options
62+
# like --force-reinstall and --ignore-installed).
63+
64+
# We now work out the correct order.
65+
#
66+
# 1. If no other considerations apply, later versions take priority.
67+
# 2. An already installed distribution is preferred over any other,
68+
# unless the user has requested an upgrade.
69+
# Upgrades are allowed when:
70+
# * The --upgrade flag is set, and
71+
# - The project was specified on the command line, or
72+
# - The project is a dependency and the "eager" upgrade strategy
73+
# was requested.
74+
75+
def _eligible_for_upgrade(name):
76+
# type: (str) -> bool
77+
"""Are upgrades allowed for this project?
78+
79+
This checks the upgrade strategy, and whether the project was one
80+
that the user specified in the command line, in order to decide
81+
whether we should upgrade if there's a newer version available.
82+
83+
(Note that we don't need access to the `--upgrade` flag, because
84+
an upgrade strategy of "to-satisfy-only" means that `--upgrade`
85+
was not specified).
86+
"""
87+
if self._upgrade_strategy == "eager":
88+
return True
89+
elif self._upgrade_strategy == "only-if-needed":
90+
return (name in self.user_requested)
91+
return False
92+
93+
def sort_key(c):
94+
# type: (Candidate) -> Tuple[int, _BaseVersion]
95+
"""Return a sort key for the matches.
96+
97+
The highest priority should be given to installed candidates that
98+
are not eligible for upgrade. We use the integer value in the first
99+
part of the key to sort these before other candidates.
100+
"""
101+
if c.is_installed and not _eligible_for_upgrade(c.name):
102+
return (1, c.version)
103+
104+
return (0, c.version)
105+
106+
return sorted(matches, key=sort_key)
26107

27108
def get_install_requirement(self, c):
28109
# type: (Candidate) -> Optional[InstallRequirement]
@@ -45,7 +126,8 @@ def get_preference(
45126
def find_matches(self, requirement):
46127
# type: (Requirement) -> Sequence[Candidate]
47128
constraint = self._constraints.get(requirement.name, SpecifierSet())
48-
return requirement.find_matches(constraint)
129+
matches = requirement.find_matches(constraint)
130+
return self._sort_matches(matches)
49131

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

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ def name(self):
7676

7777
def find_matches(self, constraint):
7878
# type: (SpecifierSet) -> Sequence[Candidate]
79+
80+
# We should only return one candidate per version, but
81+
# iter_found_candidates does that for us, so we don't need
82+
# to do anything special here.
7983
return [
8084
c
8185
for c in self._factory.iter_found_candidates(

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

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232

3333

3434
class Resolver(BaseResolver):
35+
_allowed_strategies = {"eager", "only-if-needed", "to-satisfy-only"}
36+
3537
def __init__(
3638
self,
3739
preparer, # type: RequirementPreparer
@@ -47,30 +49,27 @@ def __init__(
4749
py_version_info=None, # type: Optional[Tuple[int, ...]]
4850
):
4951
super(Resolver, self).__init__()
52+
53+
assert upgrade_strategy in self._allowed_strategies
54+
5055
self.factory = Factory(
5156
finder=finder,
5257
preparer=preparer,
5358
make_install_req=make_install_req,
5459
force_reinstall=force_reinstall,
5560
ignore_installed=ignore_installed,
5661
ignore_requires_python=ignore_requires_python,
57-
upgrade_strategy=upgrade_strategy,
5862
py_version_info=py_version_info,
5963
)
6064
self.ignore_dependencies = ignore_dependencies
65+
self.upgrade_strategy = upgrade_strategy
6166
self._result = None # type: Optional[Result]
6267

6368
def resolve(self, root_reqs, check_supported_wheels):
6469
# type: (List[InstallRequirement], bool) -> RequirementSet
6570

66-
# The factory should not have retained state from any previous usage.
67-
# In theory this could only happen if self was reused to do a second
68-
# resolve, which isn't something we do at the moment. We assert here
69-
# in order to catch the issue if that ever changes.
70-
# The persistent state that we care about is `root_reqs`.
71-
assert len(self.factory.root_reqs) == 0, "Factory is being re-used"
72-
7371
constraints = {} # type: Dict[str, SpecifierSet]
72+
user_requested = set() # type: Set[str]
7473
requirements = []
7574
for req in root_reqs:
7675
if req.constraint:
@@ -82,6 +81,8 @@ def resolve(self, root_reqs, check_supported_wheels):
8281
else:
8382
constraints[name] = req.specifier
8483
else:
84+
if req.is_direct and req.name:
85+
user_requested.add(canonicalize_name(req.name))
8586
requirements.append(
8687
self.factory.make_requirement_from_install_req(req)
8788
)
@@ -90,6 +91,8 @@ def resolve(self, root_reqs, check_supported_wheels):
9091
factory=self.factory,
9192
constraints=constraints,
9293
ignore_dependencies=self.ignore_dependencies,
94+
upgrade_strategy=self.upgrade_strategy,
95+
user_requested=user_requested,
9396
)
9497
reporter = BaseReporter()
9598
resolver = RLResolver(provider, reporter)

tests/unit/resolution_resolvelib/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ def factory(finder, preparer):
5555
force_reinstall=False,
5656
ignore_installed=False,
5757
ignore_requires_python=False,
58-
upgrade_strategy="to-satisfy-only",
5958
py_version_info=None,
6059
)
6160

@@ -66,4 +65,6 @@ def provider(factory):
6665
factory=factory,
6766
constraints={},
6867
ignore_dependencies=False,
68+
upgrade_strategy="to-satisfy-only",
69+
user_requested=set(),
6970
)

0 commit comments

Comments
 (0)