Skip to content

Commit 14cb4f4

Browse files
uranusjrcjerdonek
authored andcommitted
Isolate, reuse PackageFinder best candidate logic (#5971)
Split out how PackageFinder finds the best candidate, and reuse it in the self version check, to avoid the latter duplicating (and incorrectly implementing) the same logic.
1 parent 54b6a91 commit 14cb4f4

File tree

4 files changed

+111
-48
lines changed

4 files changed

+111
-48
lines changed

news/5175.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Make pip's self version check avoid recommending upgrades to prereleases if the currently-installed version is stable.

src/pip/_internal/index.py

Lines changed: 97 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@
5555
BuildTag = Tuple[Any, ...] # either empty tuple or Tuple[int, str]
5656
CandidateSortingKey = Tuple[int, _BaseVersion, BuildTag, Optional[int]]
5757

58-
__all__ = ['FormatControl', 'PackageFinder']
58+
59+
__all__ = ['FormatControl', 'FoundCandidates', 'PackageFinder']
5960

6061

6162
SECURE_ORIGINS = [
@@ -254,6 +255,67 @@ def _get_html_page(link, session=None):
254255
return None
255256

256257

258+
class FoundCandidates(object):
259+
"""A collection of candidates, returned by `PackageFinder.find_candidates`.
260+
261+
Arguments:
262+
263+
* `candidates`: A sequence of all available candidates found.
264+
* `specifier`: Specifier to filter applicable versions.
265+
* `prereleases`: Whether prereleases should be accounted. Pass None to
266+
infer from the specifier.
267+
* `sort_key`: A callable used as the key function when choosing the best
268+
candidate.
269+
"""
270+
def __init__(
271+
self,
272+
candidates, # type: List[InstallationCandidate]
273+
specifier, # type: specifiers.BaseSpecifier
274+
prereleases, # type: Optional[bool]
275+
sort_key, # type: Callable[[InstallationCandidate], Any]
276+
):
277+
# type: (...) -> None
278+
self._candidates = candidates
279+
self._specifier = specifier
280+
self._prereleases = prereleases
281+
self._sort_key = sort_key
282+
283+
def iter_all(self):
284+
# type: () -> Iterable[InstallationCandidate]
285+
"""Iterate through all candidates.
286+
"""
287+
return iter(self._candidates)
288+
289+
def iter_applicable(self):
290+
# type: () -> Iterable[InstallationCandidate]
291+
"""Iterate through candidates matching the given specifier.
292+
"""
293+
# Filter out anything which doesn't match our specifier.
294+
versions = set(self._specifier.filter(
295+
# We turn the version object into a str here because otherwise
296+
# when we're debundled but setuptools isn't, Python will see
297+
# packaging.version.Version and
298+
# pkg_resources._vendor.packaging.version.Version as different
299+
# types. This way we'll use a str as a common data interchange
300+
# format. If we stop using the pkg_resources provided specifier
301+
# and start using our own, we can drop the cast to str().
302+
[str(c.version) for c in self._candidates],
303+
prereleases=self._prereleases,
304+
))
305+
# Again, converting to str to deal with debundling.
306+
return (c for c in self._candidates if str(c.version) in versions)
307+
308+
def get_best(self):
309+
# type: () -> Optional[InstallationCandidate]
310+
"""Return the best candidate available, or None if no applicable
311+
candidates are found.
312+
"""
313+
candidates = list(self.iter_applicable())
314+
if not candidates:
315+
return None
316+
return max(candidates, key=self._sort_key)
317+
318+
257319
class PackageFinder(object):
258320
"""This finds packages.
259321
@@ -628,6 +690,25 @@ def find_all_candidates(self, project_name):
628690
# This is an intentional priority ordering
629691
return file_versions + find_links_versions + page_versions
630692

693+
def find_candidates(
694+
self,
695+
project_name, # type: str
696+
specifier=specifiers.SpecifierSet(), # type: specifiers.BaseSpecifier
697+
):
698+
"""Find matches for the given project and specifier.
699+
700+
If given, `specifier` should implement `filter` to allow version
701+
filtering (e.g. ``packaging.specifiers.SpecifierSet``).
702+
703+
Returns a `FoundCandidates` instance.
704+
"""
705+
return FoundCandidates(
706+
self.find_all_candidates(project_name),
707+
specifier=specifier,
708+
prereleases=(self.allow_all_prereleases or None),
709+
sort_key=self._candidate_sort_key,
710+
)
711+
631712
def find_requirement(self, req, upgrade):
632713
# type: (InstallRequirement, bool) -> Optional[Link]
633714
"""Try to find a Link matching req
@@ -636,52 +717,28 @@ def find_requirement(self, req, upgrade):
636717
Returns a Link if found,
637718
Raises DistributionNotFound or BestVersionAlreadyInstalled otherwise
638719
"""
639-
all_candidates = self.find_all_candidates(req.name)
640-
641-
# Filter out anything which doesn't match our specifier
642-
compatible_versions = set(
643-
req.specifier.filter(
644-
# We turn the version object into a str here because otherwise
645-
# when we're debundled but setuptools isn't, Python will see
646-
# packaging.version.Version and
647-
# pkg_resources._vendor.packaging.version.Version as different
648-
# types. This way we'll use a str as a common data interchange
649-
# format. If we stop using the pkg_resources provided specifier
650-
# and start using our own, we can drop the cast to str().
651-
[str(c.version) for c in all_candidates],
652-
prereleases=(
653-
self.allow_all_prereleases
654-
if self.allow_all_prereleases else None
655-
),
656-
)
657-
)
658-
applicable_candidates = [
659-
# Again, converting to str to deal with debundling.
660-
c for c in all_candidates if str(c.version) in compatible_versions
661-
]
662-
663-
if applicable_candidates:
664-
best_candidate = max(applicable_candidates,
665-
key=self._candidate_sort_key)
666-
else:
667-
best_candidate = None
720+
candidates = self.find_candidates(req.name, req.specifier)
721+
best_candidate = candidates.get_best()
668722

723+
installed_version = None # type: Optional[_BaseVersion]
669724
if req.satisfied_by is not None:
670725
installed_version = parse_version(req.satisfied_by.version)
671-
else:
672-
installed_version = None
726+
727+
def _format_versions(cand_iter):
728+
# This repeated parse_version and str() conversion is needed to
729+
# handle different vendoring sources from pip and pkg_resources.
730+
# If we stop using the pkg_resources provided specifier.
731+
return ", ".join(sorted(
732+
{str(c.version) for c in cand_iter},
733+
key=parse_version,
734+
)) or "none"
673735

674736
if installed_version is None and best_candidate is None:
675737
logger.critical(
676738
'Could not find a version that satisfies the requirement %s '
677739
'(from versions: %s)',
678740
req,
679-
', '.join(
680-
sorted(
681-
{str(c.version) for c in all_candidates},
682-
key=parse_version,
683-
)
684-
)
741+
_format_versions(candidates.iter_all()),
685742
)
686743

687744
raise DistributionNotFound(
@@ -716,15 +773,14 @@ def find_requirement(self, req, upgrade):
716773
'Installed version (%s) is most up-to-date (past versions: '
717774
'%s)',
718775
installed_version,
719-
', '.join(sorted(compatible_versions, key=parse_version)) or
720-
"none",
776+
_format_versions(candidates.iter_applicable()),
721777
)
722778
raise BestVersionAlreadyInstalled
723779

724780
logger.debug(
725781
'Using version %s (newest of versions: %s)',
726782
best_candidate.version,
727-
', '.join(sorted(compatible_versions, key=parse_version))
783+
_format_versions(candidates.iter_applicable()),
728784
)
729785
return best_candidate.location
730786

src/pip/_internal/utils/outdated.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -129,12 +129,10 @@ def pip_version_check(session, options):
129129
trusted_hosts=options.trusted_hosts,
130130
session=session,
131131
)
132-
all_candidates = finder.find_all_candidates("pip")
133-
if not all_candidates:
132+
candidate = finder.find_candidates("pip").get_best()
133+
if candidate is None:
134134
return
135-
pypi_version = str(
136-
max(all_candidates, key=lambda c: c.version).version
137-
)
135+
pypi_version = str(candidate.version)
138136

139137
# save that we've performed a check
140138
state.save(pypi_version, current_time)

tests/unit/test_unit_outdated.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@
1212
from pip._internal.utils import outdated
1313

1414

15+
class MockFoundCandidates(object):
16+
def __init__(self, best):
17+
self._best = best
18+
19+
def get_best(self):
20+
return self._best
21+
22+
1523
class MockPackageFinder(object):
1624

1725
BASE_URL = 'https://pypi.org/simple/pip-{0}.tar.gz'
@@ -28,8 +36,8 @@ class MockPackageFinder(object):
2836
def __init__(self, *args, **kwargs):
2937
pass
3038

31-
def find_all_candidates(self, project_name):
32-
return self.INSTALLATION_CANDIDATES
39+
def find_candidates(self, project_name):
40+
return MockFoundCandidates(self.INSTALLATION_CANDIDATES[0])
3341

3442

3543
class MockDistribution(object):

0 commit comments

Comments
 (0)