Add LinkEvaluator class.

This commit is contained in:
Chris Jerdonek 2019-06-30 12:14:53 -07:00
parent a8510bc5e6
commit 979c8405d2
4 changed files with 316 additions and 185 deletions

View File

@ -7,7 +7,6 @@ import logging
import mimetypes import mimetypes
import os import os
import re import re
from collections import namedtuple
from pip._vendor import html5lib, requests, six from pip._vendor import html5lib, requests, six
from pip._vendor.distlib.compat import unescape from pip._vendor.distlib.compat import unescape
@ -41,8 +40,8 @@ from pip._internal.wheel import Wheel
if MYPY_CHECK_RUNNING: if MYPY_CHECK_RUNNING:
from logging import Logger from logging import Logger
from typing import ( from typing import (
Any, Callable, Iterable, Iterator, List, MutableMapping, Optional, Any, Callable, FrozenSet, Iterable, Iterator, List, MutableMapping,
Sequence, Set, Text, Tuple, Union, Optional, Sequence, Set, Text, Tuple, Union,
) )
import xml.etree.ElementTree import xml.etree.ElementTree
from pip._vendor.packaging.version import _BaseVersion from pip._vendor.packaging.version import _BaseVersion
@ -50,6 +49,7 @@ if MYPY_CHECK_RUNNING:
from pip._internal.models.search_scope import SearchScope from pip._internal.models.search_scope import SearchScope
from pip._internal.req import InstallRequirement from pip._internal.req import InstallRequirement
from pip._internal.download import PipSession from pip._internal.download import PipSession
from pip._internal.pep425tags import Pep425Tag
BuildTag = Tuple[Any, ...] # either empty tuple or Tuple[int, str] BuildTag = Tuple[Any, ...] # either empty tuple or Tuple[int, str]
CandidateSortingKey = ( CandidateSortingKey = (
@ -301,62 +301,58 @@ def _check_link_requires_python(
return True return True
class CandidateEvaluator(object): class LinkEvaluator(object):
""" """
Responsible for filtering and sorting candidates for installation based Responsible for evaluating links for a particular project.
on what tags are valid.
""" """
_py_version_re = re.compile(r'-py([123]\.?[0-9]?)$')
# Don't include an allow_yanked default value to make sure each call # Don't include an allow_yanked default value to make sure each call
# site considers whether yanked releases are allowed. This also causes # site considers whether yanked releases are allowed. This also causes
# that decision to be made explicit in the calling code, which helps # that decision to be made explicit in the calling code, which helps
# people when reading the code. # people when reading the code.
def __init__( def __init__(
self, self,
allow_yanked, # type: bool project_name, # type: str
target_python=None, # type: Optional[TargetPython] canonical_name, # type: str
prefer_binary=False, # type: bool formats, # type: FrozenSet
allow_all_prereleases=False, # type: bool target_python, # type: TargetPython
allow_yanked, # type: bool
ignore_requires_python=None, # type: Optional[bool] ignore_requires_python=None, # type: Optional[bool]
): ):
# type: (...) -> None # type: (...) -> None
""" """
:param project_name: The user supplied package name.
:param canonical_name: The canonical package name.
:param formats: The formats allowed for this package. Should be a set
with 'binary' or 'source' or both in it.
:param target_python: The target Python interpreter to use when
evaluating link compatibility. This is used, for example, to
check wheel compatibility, as well as when checking the Python
version, e.g. the Python version embedded in a link filename
(or egg fragment) and against an HTML link's optional PEP 503
"data-requires-python" attribute.
:param allow_yanked: Whether files marked as yanked (in the sense :param allow_yanked: Whether files marked as yanked (in the sense
of PEP 592) are permitted to be candidates for install. of PEP 592) are permitted to be candidates for install.
:param target_python: The target Python interpreter to use to check
both the Python version embedded in the filename and the package's
"Requires-Python" metadata. If None (the default), then a
TargetPython object will be constructed from the running Python.
:param allow_all_prereleases: Whether to allow all pre-releases.
:param ignore_requires_python: Whether to ignore incompatible :param ignore_requires_python: Whether to ignore incompatible
"Requires-Python" values in links. Defaults to False. PEP 503 "data-requires-python" values in HTML links. Defaults
to False.
""" """
if target_python is None:
target_python = TargetPython()
if ignore_requires_python is None: if ignore_requires_python is None:
ignore_requires_python = False ignore_requires_python = False
self._allow_yanked = allow_yanked self._allow_yanked = allow_yanked
self._canonical_name = canonical_name
self._ignore_requires_python = ignore_requires_python self._ignore_requires_python = ignore_requires_python
self._prefer_binary = prefer_binary self._formats = formats
self._target_python = target_python self._target_python = target_python
# We compile the regex here instead of as a class attribute so as self.project_name = project_name
# not to impact pip start-up time. This is also okay because
# CandidateEvaluator is generally instantiated only once per pip
# invocation (when PackageFinder is instantiated).
self._py_version_re = re.compile(r'-py([123]\.?[0-9]?)$')
self.allow_all_prereleases = allow_all_prereleases def evaluate_link(self, link):
# type: (Link) -> Tuple[bool, Optional[Text]]
def _is_wheel_supported(self, wheel):
# type: (Wheel) -> bool
valid_tags = self._target_python.get_tags()
return wheel.supported(valid_tags)
def evaluate_link(self, link, search):
# type: (Link, Search) -> Tuple[bool, Optional[Text]]
""" """
Determine whether a link is a candidate for installation. Determine whether a link is a candidate for installation.
@ -382,8 +378,8 @@ class CandidateEvaluator(object):
return (False, 'not a file') return (False, 'not a file')
if ext not in SUPPORTED_EXTENSIONS: if ext not in SUPPORTED_EXTENSIONS:
return (False, 'unsupported archive format: %s' % ext) return (False, 'unsupported archive format: %s' % ext)
if "binary" not in search.formats and ext == WHEEL_EXTENSION: if "binary" not in self._formats and ext == WHEEL_EXTENSION:
reason = 'No binaries permitted for %s' % search.supplied reason = 'No binaries permitted for %s' % self.project_name
return (False, reason) return (False, reason)
if "macosx10" in link.path and ext == '.zip': if "macosx10" in link.path and ext == '.zip':
return (False, 'macosx10 one') return (False, 'macosx10 one')
@ -392,11 +388,12 @@ class CandidateEvaluator(object):
wheel = Wheel(link.filename) wheel = Wheel(link.filename)
except InvalidWheelFilename: except InvalidWheelFilename:
return (False, 'invalid wheel filename') return (False, 'invalid wheel filename')
if canonicalize_name(wheel.name) != search.canonical: if canonicalize_name(wheel.name) != self._canonical_name:
reason = 'wrong project name (not %s)' % search.supplied reason = 'wrong project name (not %s)' % self.project_name
return (False, reason) return (False, reason)
if not self._is_wheel_supported(wheel): supported_tags = self._target_python.get_tags()
if not wheel.supported(supported_tags):
# Include the wheel's tags in the reason string to # Include the wheel's tags in the reason string to
# simplify troubleshooting compatibility issues. # simplify troubleshooting compatibility issues.
file_tags = wheel.get_formatted_file_tags() file_tags = wheel.get_formatted_file_tags()
@ -409,16 +406,18 @@ class CandidateEvaluator(object):
version = wheel.version version = wheel.version
# This should be up by the search.ok_binary check, but see issue 2700. # This should be up by the self.ok_binary check, but see issue 2700.
if "source" not in search.formats and ext != WHEEL_EXTENSION: if "source" not in self._formats and ext != WHEEL_EXTENSION:
return (False, 'No sources permitted for %s' % search.supplied) return (False, 'No sources permitted for %s' % self.project_name)
if not version: if not version:
version = _extract_version_from_fragment( version = _extract_version_from_fragment(
egg_info, search.canonical, egg_info, self._canonical_name,
) )
if not version: if not version:
return (False, 'Missing project version for %s' % search.supplied) return (
False, 'Missing project version for %s' % self.project_name,
)
match = self._py_version_re.search(version) match = self._py_version_re.search(version)
if match: if match:
@ -440,6 +439,36 @@ class CandidateEvaluator(object):
return (True, version) return (True, version)
class CandidateEvaluator(object):
"""
Responsible for filtering and sorting candidates for installation based
on what tags are valid.
"""
def __init__(
self,
supported_tags=None, # type: Optional[List[Pep425Tag]]
prefer_binary=False, # type: bool
allow_all_prereleases=False, # type: bool
):
# type: (...) -> None
"""
:param supported_tags: The PEP 425 tags supported by the target
Python in order of preference (most preferred first). If None,
then the list will be generated from the running Python.
:param allow_all_prereleases: Whether to allow all pre-releases.
"""
if supported_tags is None:
target_python = TargetPython()
supported_tags = target_python.get_tags()
self._prefer_binary = prefer_binary
self._supported_tags = supported_tags
self.allow_all_prereleases = allow_all_prereleases
def make_found_candidates( def make_found_candidates(
self, self,
candidates, # type: List[InstallationCandidate] candidates, # type: List[InstallationCandidate]
@ -490,7 +519,7 @@ class CandidateEvaluator(object):
If not finding wheels, they are sorted by version only. If not finding wheels, they are sorted by version only.
If finding wheels, then the sort order is by version, then: If finding wheels, then the sort order is by version, then:
1. existing installs 1. existing installs
2. wheels ordered via Wheel.support_index_min(self._valid_tags) 2. wheels ordered via Wheel.support_index_min(self._supported_tags)
3. source archives 3. source archives
If prefer_binary was set, then all wheels are sorted above sources. If prefer_binary was set, then all wheels are sorted above sources.
@ -498,7 +527,7 @@ class CandidateEvaluator(object):
comparison operators, but then different sdist links comparison operators, but then different sdist links
with the same version, would have to be considered equal with the same version, would have to be considered equal
""" """
valid_tags = self._target_python.get_tags() valid_tags = self._supported_tags
support_num = len(valid_tags) support_num = len(valid_tags)
build_tag = tuple() # type: BuildTag build_tag = tuple() # type: BuildTag
binary_preference = 0 binary_preference = 0
@ -506,7 +535,7 @@ class CandidateEvaluator(object):
if link.is_wheel: if link.is_wheel:
# can raise InvalidWheelFilename # can raise InvalidWheelFilename
wheel = Wheel(link.filename) wheel = Wheel(link.filename)
if not self._is_wheel_supported(wheel): if not wheel.supported(valid_tags):
raise UnsupportedWheel( raise UnsupportedWheel(
"%s is not a supported wheel for this platform. It " "%s is not a supported wheel for this platform. It "
"can't be sorted." % wheel.filename "can't be sorted." % wheel.filename
@ -616,8 +645,11 @@ class PackageFinder(object):
candidate_evaluator, # type: CandidateEvaluator candidate_evaluator, # type: CandidateEvaluator
search_scope, # type: SearchScope search_scope, # type: SearchScope
session, # type: PipSession session, # type: PipSession
target_python, # type: TargetPython
allow_yanked, # type: bool
format_control=None, # type: Optional[FormatControl] format_control=None, # type: Optional[FormatControl]
trusted_hosts=None, # type: Optional[List[str]] trusted_hosts=None, # type: Optional[List[str]]
ignore_requires_python=None, # type: Optional[bool]
): ):
# type: (...) -> None # type: (...) -> None
""" """
@ -635,6 +667,10 @@ class PackageFinder(object):
format_control = format_control or FormatControl(set(), set()) format_control = format_control or FormatControl(set(), set())
self._allow_yanked = allow_yanked
self._ignore_requires_python = ignore_requires_python
self._target_python = target_python
self.candidate_evaluator = candidate_evaluator self.candidate_evaluator = candidate_evaluator
self.search_scope = search_scope self.search_scope = search_scope
self.session = session self.session = session
@ -665,28 +701,34 @@ class PackageFinder(object):
:param trusted_hosts: Domains not to emit warnings for when not using :param trusted_hosts: Domains not to emit warnings for when not using
HTTPS. HTTPS.
:param session: The Session to use to make requests. :param session: The Session to use to make requests.
:param target_python: The target Python interpreter. :param target_python: The target Python interpreter to use when
checking compatibility. If None (the default), a TargetPython
object will be constructed from the running Python.
""" """
if session is None: if session is None:
raise TypeError( raise TypeError(
"PackageFinder.create() missing 1 required keyword argument: " "PackageFinder.create() missing 1 required keyword argument: "
"'session'" "'session'"
) )
if target_python is None:
target_python = TargetPython()
supported_tags = target_python.get_tags()
candidate_evaluator = CandidateEvaluator( candidate_evaluator = CandidateEvaluator(
allow_yanked=selection_prefs.allow_yanked, supported_tags=supported_tags,
target_python=target_python,
prefer_binary=selection_prefs.prefer_binary, prefer_binary=selection_prefs.prefer_binary,
allow_all_prereleases=selection_prefs.allow_all_prereleases, allow_all_prereleases=selection_prefs.allow_all_prereleases,
ignore_requires_python=selection_prefs.ignore_requires_python,
) )
return cls( return cls(
candidate_evaluator=candidate_evaluator, candidate_evaluator=candidate_evaluator,
search_scope=search_scope, search_scope=search_scope,
session=session, session=session,
target_python=target_python,
allow_yanked=selection_prefs.allow_yanked,
format_control=selection_prefs.format_control, format_control=selection_prefs.format_control,
trusted_hosts=trusted_hosts, trusted_hosts=trusted_hosts,
ignore_requires_python=selection_prefs.ignore_requires_python,
) )
@property @property
@ -865,6 +907,20 @@ class PackageFinder(object):
return False return False
def make_link_evaluator(self, project_name):
# type: (str) -> LinkEvaluator
canonical_name = canonicalize_name(project_name)
formats = self.format_control.get_allowed_formats(canonical_name)
return LinkEvaluator(
project_name=project_name,
canonical_name=canonical_name,
formats=formats,
target_python=self._target_python,
allow_yanked=self._allow_yanked,
ignore_requires_python=self._ignore_requires_python,
)
def find_all_candidates(self, project_name): def find_all_candidates(self, project_name):
# type: (str) -> List[InstallationCandidate] # type: (str) -> List[InstallationCandidate]
"""Find all available InstallationCandidate for project_name """Find all available InstallationCandidate for project_name
@ -872,7 +928,7 @@ class PackageFinder(object):
This checks index_urls and find_links. This checks index_urls and find_links.
All versions found are returned as an InstallationCandidate list. All versions found are returned as an InstallationCandidate list.
See CandidateEvaluator.evaluate_link() for details on which files See LinkEvaluator.evaluate_link() for details on which files
are accepted. are accepted.
""" """
search_scope = self.search_scope search_scope = self.search_scope
@ -903,13 +959,11 @@ class PackageFinder(object):
for location in url_locations: for location in url_locations:
logger.debug('* %s', location) logger.debug('* %s', location)
canonical_name = canonicalize_name(project_name) link_evaluator = self.make_link_evaluator(project_name)
formats = self.format_control.get_allowed_formats(canonical_name)
search = Search(project_name, canonical_name, formats)
find_links_versions = self._package_versions( find_links_versions = self._package_versions(
link_evaluator,
# We trust every directly linked archive in find_links # We trust every directly linked archive in find_links
(Link(url, '-f') for url in self.find_links), (Link(url, '-f') for url in self.find_links),
search
) )
page_versions = [] page_versions = []
@ -917,10 +971,10 @@ class PackageFinder(object):
logger.debug('Analyzing links from page %s', page.url) logger.debug('Analyzing links from page %s', page.url)
with indent_log(): with indent_log():
page_versions.extend( page_versions.extend(
self._package_versions(page.iter_links(), search) self._package_versions(link_evaluator, page.iter_links())
) )
file_versions = self._package_versions(file_locations, search) file_versions = self._package_versions(link_evaluator, file_locations)
if file_versions: if file_versions:
file_versions.sort(reverse=True) file_versions.sort(reverse=True)
logger.debug( logger.debug(
@ -1075,35 +1129,31 @@ class PackageFinder(object):
logger.debug(u'Skipping link: %s: %s', reason, link) logger.debug(u'Skipping link: %s: %s', reason, link)
self._logged_links.add(link) self._logged_links.add(link)
def get_install_candidate(self, link, search): def get_install_candidate(self, link_evaluator, link):
# type: (Link, Search) -> Optional[InstallationCandidate] # type: (LinkEvaluator, Link) -> Optional[InstallationCandidate]
""" """
If the link is a candidate for install, convert it to an If the link is a candidate for install, convert it to an
InstallationCandidate and return it. Otherwise, return None. InstallationCandidate and return it. Otherwise, return None.
""" """
is_candidate, result = ( is_candidate, result = link_evaluator.evaluate_link(link)
self.candidate_evaluator.evaluate_link(link, search=search)
)
if not is_candidate: if not is_candidate:
if result: if result:
self._log_skipped_link(link, reason=result) self._log_skipped_link(link, reason=result)
return None return None
return InstallationCandidate( return InstallationCandidate(
project=link_evaluator.project_name,
location=link,
# Convert the Text result to str since InstallationCandidate # Convert the Text result to str since InstallationCandidate
# accepts str. # accepts str.
search.supplied, location=link, version=str(result), version=str(result),
) )
def _package_versions( def _package_versions(self, link_evaluator, links):
self, # type: (LinkEvaluator, Iterable[Link]) -> List[InstallationCandidate]
links, # type: Iterable[Link]
search # type: Search
):
# type: (...) -> List[InstallationCandidate]
result = [] result = []
for link in self._sort_links(links): for link in self._sort_links(links):
candidate = self.get_install_candidate(link, search=search) candidate = self.get_install_candidate(link_evaluator, link)
if candidate is not None: if candidate is not None:
result.append(candidate) result.append(candidate)
return result return result
@ -1272,13 +1322,3 @@ class HTMLPage(object):
if link is None: if link is None:
continue continue
yield link yield link
Search = namedtuple('Search', 'supplied canonical formats')
"""Capture key aspects of a search.
:attribute supplied: The user supplied package.
:attribute canonical: The canonical package name.
:attribute formats: The formats allowed for this package. Should be a set
with 'binary' or 'source' or both in it.
"""

View File

@ -82,7 +82,9 @@ class TargetPython(object):
def get_tags(self): def get_tags(self):
# type: () -> List[Pep425Tag] # type: () -> List[Pep425Tag]
""" """
Return the supported tags to check wheel candidates against. Return the supported PEP 425 tags to check wheel candidates against.
The tags are returned in order of preference (most preferred first).
""" """
if self._valid_tags is None: if self._valid_tags is None:
# Pass versions=None if no py_version_info was given since # Pass versions=None if no py_version_info was given since

View File

@ -3,7 +3,7 @@ import sys
import pytest import pytest
from mock import Mock, patch from mock import Mock, patch
from pkg_resources import Distribution, parse_version from pkg_resources import parse_version
import pip._internal.pep425tags import pip._internal.pep425tags
import pip._internal.wheel import pip._internal.wheel
@ -11,7 +11,7 @@ from pip._internal.exceptions import (
BestVersionAlreadyInstalled, DistributionNotFound, BestVersionAlreadyInstalled, DistributionNotFound,
) )
from pip._internal.index import ( from pip._internal.index import (
CandidateEvaluator, InstallationCandidate, Link, Search, CandidateEvaluator, InstallationCandidate, Link, LinkEvaluator,
) )
from pip._internal.models.target_python import TargetPython from pip._internal.models.target_python import TargetPython
from pip._internal.req.constructors import install_req_from_line from pip._internal.req.constructors import install_req_from_line
@ -216,12 +216,7 @@ class TestWheel:
('pyT', 'TEST', 'any'), ('pyT', 'TEST', 'any'),
('pyT', 'none', 'any'), ('pyT', 'none', 'any'),
] ]
target_python = TargetPython() evaluator = CandidateEvaluator(supported_tags=valid_tags)
target_python._valid_tags = valid_tags
evaluator = CandidateEvaluator(
allow_yanked=True,
target_python=target_python,
)
sort_key = evaluator._sort_key sort_key = evaluator._sort_key
results = sorted(links, key=sort_key, reverse=True) results = sorted(links, key=sort_key, reverse=True)
results2 = sorted(reversed(links), key=sort_key, reverse=True) results2 = sorted(reversed(links), key=sort_key, reverse=True)
@ -419,17 +414,17 @@ def test_finder_installs_pre_releases_with_version_spec():
assert link.url == "https://foo/bar-2.0b1.tar.gz" assert link.url == "https://foo/bar-2.0b1.tar.gz"
class TestCandidateEvaluator(object): class TestLinkEvaluator(object):
# patch this for travis which has distribute in its base env for now def make_test_link_evaluator(self, formats):
@patch( target_python = TargetPython()
'pip._internal.wheel.pkg_resources.get_distribution', return LinkEvaluator(
lambda x: Distribution(project_name='setuptools', version='0.9') project_name='pytest',
) canonical_name='pytest',
def setup(self): formats=formats,
self.search_name = 'pytest' target_python=target_python,
self.canonical_name = 'pytest' allow_yanked=True,
self.evaluator = CandidateEvaluator(allow_yanked=True) )
@pytest.mark.parametrize('url, expected_version', [ @pytest.mark.parametrize('url, expected_version', [
('http:/yo/pytest-1.0.tar.gz', '1.0'), ('http:/yo/pytest-1.0.tar.gz', '1.0'),
@ -438,12 +433,8 @@ class TestCandidateEvaluator(object):
def test_evaluate_link__match(self, url, expected_version): def test_evaluate_link__match(self, url, expected_version):
"""Test that 'pytest' archives match for 'pytest'""" """Test that 'pytest' archives match for 'pytest'"""
link = Link(url) link = Link(url)
search = Search( evaluator = self.make_test_link_evaluator(formats=['source', 'binary'])
supplied=self.search_name, actual = evaluator.evaluate_link(link)
canonical=self.canonical_name,
formats=['source', 'binary'],
)
actual = self.evaluator.evaluate_link(link, search)
assert actual == (True, expected_version) assert actual == (True, expected_version)
@pytest.mark.parametrize('url, expected_msg', [ @pytest.mark.parametrize('url, expected_msg', [
@ -457,12 +448,8 @@ class TestCandidateEvaluator(object):
def test_evaluate_link__substring_fails(self, url, expected_msg): def test_evaluate_link__substring_fails(self, url, expected_msg):
"""Test that 'pytest<something> archives won't match for 'pytest'.""" """Test that 'pytest<something> archives won't match for 'pytest'."""
link = Link(url) link = Link(url)
search = Search( evaluator = self.make_test_link_evaluator(formats=['source', 'binary'])
supplied=self.search_name, actual = evaluator.evaluate_link(link)
canonical=self.canonical_name,
formats=['source', 'binary'],
)
actual = self.evaluator.evaluate_link(link, search)
assert actual == (False, expected_msg) assert actual == (False, expected_msg)

View File

@ -7,9 +7,10 @@ from pip._vendor import html5lib, requests
from pip._internal.download import PipSession from pip._internal.download import PipSession
from pip._internal.index import ( from pip._internal.index import (
CandidateEvaluator, HTMLPage, Link, PackageFinder, Search, CandidateEvaluator, FormatControl, HTMLPage, Link, LinkEvaluator,
_check_link_requires_python, _clean_link, _determine_base_url, PackageFinder, _check_link_requires_python, _clean_link,
_extract_version_from_fragment, _find_name_version_sep, _get_html_page, _determine_base_url, _extract_version_from_fragment,
_find_name_version_sep, _get_html_page,
) )
from pip._internal.models.candidate import InstallationCandidate from pip._internal.models.candidate import InstallationCandidate
from pip._internal.models.search_scope import SearchScope from pip._internal.models.search_scope import SearchScope
@ -84,32 +85,7 @@ def test_check_link_requires_python__invalid_requires(caplog):
check_caplog(caplog, 'DEBUG', expected_message) check_caplog(caplog, 'DEBUG', expected_message)
class TestCandidateEvaluator: class TestLinkEvaluator:
def test_init__target_python(self):
"""
Test the target_python argument.
"""
target_python = TargetPython(py_version_info=(3, 7, 3))
evaluator = CandidateEvaluator(
allow_yanked=True,
target_python=target_python,
)
# The target_python attribute should be set as is.
assert evaluator._target_python is target_python
def test_init__target_python_none(self):
"""
Test passing None for the target_python argument.
"""
evaluator = CandidateEvaluator(
allow_yanked=True,
target_python=None,
)
# Spot-check the default TargetPython object.
actual_target_python = evaluator._target_python
assert actual_target_python._given_py_version_info is None
assert actual_target_python.py_version_info == CURRENT_PY_VERSION_INFO
@pytest.mark.parametrize( @pytest.mark.parametrize(
'py_version_info,ignore_requires_python,expected', [ 'py_version_info,ignore_requires_python,expected', [
@ -124,19 +100,19 @@ class TestCandidateEvaluator:
self, py_version_info, ignore_requires_python, expected, self, py_version_info, ignore_requires_python, expected,
): ):
target_python = TargetPython(py_version_info=py_version_info) target_python = TargetPython(py_version_info=py_version_info)
evaluator = CandidateEvaluator( evaluator = LinkEvaluator(
allow_yanked=True, project_name='twine',
canonical_name='twine',
formats={'source'},
target_python=target_python, target_python=target_python,
allow_yanked=True,
ignore_requires_python=ignore_requires_python, ignore_requires_python=ignore_requires_python,
) )
link = Link( link = Link(
'https://example.com/#egg=twine-1.12', 'https://example.com/#egg=twine-1.12',
requires_python='== 3.6.5', requires_python='== 3.6.5',
) )
search = Search( actual = evaluator.evaluate_link(link)
supplied='twine', canonical='twine', formats=['source'],
)
actual = evaluator.evaluate_link(link, search=search)
assert actual == expected assert actual == expected
@pytest.mark.parametrize('yanked_reason, allow_yanked, expected', [ @pytest.mark.parametrize('yanked_reason, allow_yanked, expected', [
@ -155,15 +131,19 @@ class TestCandidateEvaluator:
def test_evaluate_link__allow_yanked( def test_evaluate_link__allow_yanked(
self, yanked_reason, allow_yanked, expected, self, yanked_reason, allow_yanked, expected,
): ):
evaluator = CandidateEvaluator(allow_yanked=allow_yanked) target_python = TargetPython(py_version_info=(3, 6, 4))
evaluator = LinkEvaluator(
project_name='twine',
canonical_name='twine',
formats={'source'},
target_python=target_python,
allow_yanked=allow_yanked,
)
link = Link( link = Link(
'https://example.com/#egg=twine-1.12', 'https://example.com/#egg=twine-1.12',
yanked_reason=yanked_reason, yanked_reason=yanked_reason,
) )
search = Search( actual = evaluator.evaluate_link(link)
supplied='twine', canonical='twine', formats=['source'],
)
actual = evaluator.evaluate_link(link, search=search)
assert actual == expected assert actual == expected
def test_evaluate_link__incompatible_wheel(self): def test_evaluate_link__incompatible_wheel(self):
@ -173,20 +153,23 @@ class TestCandidateEvaluator:
target_python = TargetPython(py_version_info=(3, 6, 4)) target_python = TargetPython(py_version_info=(3, 6, 4))
# Set the valid tags to an empty list to make sure nothing matches. # Set the valid tags to an empty list to make sure nothing matches.
target_python._valid_tags = [] target_python._valid_tags = []
evaluator = CandidateEvaluator( evaluator = LinkEvaluator(
allow_yanked=True, project_name='sample',
canonical_name='sample',
formats={'binary'},
target_python=target_python, target_python=target_python,
allow_yanked=True,
) )
link = Link('https://example.com/sample-1.0-py2.py3-none-any.whl') link = Link('https://example.com/sample-1.0-py2.py3-none-any.whl')
search = Search( actual = evaluator.evaluate_link(link)
supplied='sample', canonical='sample', formats=['binary'],
)
actual = evaluator.evaluate_link(link, search=search)
expected = ( expected = (
False, "none of the wheel's tags match: py2-none-any, py3-none-any" False, "none of the wheel's tags match: py2-none-any, py3-none-any"
) )
assert actual == expected assert actual == expected
class TestCandidateEvaluator:
@pytest.mark.parametrize('yanked_reason, expected', [ @pytest.mark.parametrize('yanked_reason, expected', [
# Test a non-yanked file. # Test a non-yanked file.
(None, 0), (None, 0),
@ -201,7 +184,7 @@ class TestCandidateEvaluator:
link = Link(url, yanked_reason=yanked_reason) link = Link(url, yanked_reason=yanked_reason)
candidate = InstallationCandidate('mypackage', '1.0', link) candidate = InstallationCandidate('mypackage', '1.0', link)
evaluator = CandidateEvaluator(allow_yanked=True) evaluator = CandidateEvaluator()
sort_value = evaluator._sort_key(candidate) sort_value = evaluator._sort_key(candidate)
# Yanked / non-yanked is reflected in the first element of the tuple. # Yanked / non-yanked is reflected in the first element of the tuple.
actual = sort_value[0] actual = sort_value[0]
@ -218,7 +201,7 @@ class TestCandidateEvaluator:
""" """
Test passing an empty list. Test passing an empty list.
""" """
evaluator = CandidateEvaluator(allow_yanked=True) evaluator = CandidateEvaluator()
actual = evaluator.get_best_candidate([]) actual = evaluator.get_best_candidate([])
assert actual is None assert actual is None
@ -233,7 +216,7 @@ class TestCandidateEvaluator:
self.make_mock_candidate('2.0', yanked_reason='bad metadata #2'), self.make_mock_candidate('2.0', yanked_reason='bad metadata #2'),
] ]
expected_best = candidates[1] expected_best = candidates[1]
evaluator = CandidateEvaluator(allow_yanked=True) evaluator = CandidateEvaluator()
actual = evaluator.get_best_candidate(candidates) actual = evaluator.get_best_candidate(candidates)
assert actual is expected_best assert actual is expected_best
assert str(actual.version) == '3.0' assert str(actual.version) == '3.0'
@ -264,7 +247,7 @@ class TestCandidateEvaluator:
candidates = [ candidates = [
self.make_mock_candidate('1.0', yanked_reason=yanked_reason), self.make_mock_candidate('1.0', yanked_reason=yanked_reason),
] ]
evaluator = CandidateEvaluator(allow_yanked=True) evaluator = CandidateEvaluator()
actual = evaluator.get_best_candidate(candidates) actual = evaluator.get_best_candidate(candidates)
assert str(actual.version) == '1.0' assert str(actual.version) == '1.0'
@ -291,7 +274,7 @@ class TestCandidateEvaluator:
self.make_mock_candidate('1.0'), self.make_mock_candidate('1.0'),
] ]
expected_best = candidates[1] expected_best = candidates[1]
evaluator = CandidateEvaluator(allow_yanked=True) evaluator = CandidateEvaluator()
actual = evaluator.get_best_candidate(candidates) actual = evaluator.get_best_candidate(candidates)
assert actual is expected_best assert actual is expected_best
assert str(actual.version) == '2.0' assert str(actual.version) == '2.0'
@ -302,43 +285,116 @@ class TestCandidateEvaluator:
class TestPackageFinder: class TestPackageFinder:
@pytest.mark.parametrize('allow_yanked', [False, True]) @pytest.mark.parametrize('allow_all_prereleases, prefer_binary', [
def test_create__allow_yanked(self, allow_yanked): (False, False),
(False, True),
(True, False),
(True, True),
])
def test_create__candidate_evaluator(
self, allow_all_prereleases, prefer_binary,
):
""" """
Test that allow_yanked is passed to CandidateEvaluator. Test that the candidate_evaluator attribute is set correctly.
""" """
search_scope = SearchScope([], [])
selection_prefs = SelectionPreferences(
allow_yanked=allow_yanked,
)
finder = PackageFinder.create(
search_scope=search_scope,
selection_prefs=selection_prefs,
session=object(),
)
evaluator = finder.candidate_evaluator
assert evaluator._allow_yanked == allow_yanked
def test_create__target_python(self):
"""
Test that target_python is passed to CandidateEvaluator as is.
"""
search_scope = SearchScope([], [])
selection_prefs = SelectionPreferences( selection_prefs = SelectionPreferences(
allow_yanked=True, allow_yanked=True,
allow_all_prereleases=allow_all_prereleases,
prefer_binary=prefer_binary,
) )
target_python = TargetPython(py_version_info=(3, 7, 3)) target_python = TargetPython(py_version_info=(3, 7, 3))
target_python._valid_tags = ['tag1', 'tag2']
finder = PackageFinder.create( finder = PackageFinder.create(
search_scope=search_scope, search_scope=SearchScope([], []),
selection_prefs=selection_prefs, selection_prefs=selection_prefs,
session=object(), session=PipSession(),
target_python=target_python, target_python=target_python,
) )
evaluator = finder.candidate_evaluator evaluator = finder.candidate_evaluator
actual_target_python = evaluator._target_python assert evaluator.allow_all_prereleases == allow_all_prereleases
assert evaluator._prefer_binary == prefer_binary
assert evaluator._supported_tags == ['tag1', 'tag2']
def test_create__target_python(self):
"""
Test that the _target_python attribute is set correctly.
"""
target_python = TargetPython(py_version_info=(3, 7, 3))
finder = PackageFinder.create(
search_scope=SearchScope([], []),
selection_prefs=SelectionPreferences(allow_yanked=True),
session=PipSession(),
target_python=target_python,
)
actual_target_python = finder._target_python
# The target_python attribute should be set as is.
assert actual_target_python is target_python assert actual_target_python is target_python
# Check that the attributes weren't reset.
assert actual_target_python.py_version_info == (3, 7, 3) assert actual_target_python.py_version_info == (3, 7, 3)
def test_create__target_python_none(self):
"""
Test passing target_python=None.
"""
finder = PackageFinder.create(
search_scope=SearchScope([], []),
selection_prefs=SelectionPreferences(allow_yanked=True),
session=PipSession(),
target_python=None,
)
# Spot-check the default TargetPython object.
actual_target_python = finder._target_python
assert actual_target_python._given_py_version_info is None
assert actual_target_python.py_version_info == CURRENT_PY_VERSION_INFO
@pytest.mark.parametrize('allow_yanked', [False, True])
def test_create__allow_yanked(self, allow_yanked):
"""
Test that the _allow_yanked attribute is set correctly.
"""
selection_prefs = SelectionPreferences(allow_yanked=allow_yanked)
finder = PackageFinder.create(
search_scope=SearchScope([], []),
selection_prefs=selection_prefs,
session=PipSession(),
)
assert finder._allow_yanked == allow_yanked
@pytest.mark.parametrize('ignore_requires_python', [False, True])
def test_create__ignore_requires_python(self, ignore_requires_python):
"""
Test that the _ignore_requires_python attribute is set correctly.
"""
selection_prefs = SelectionPreferences(
allow_yanked=True,
ignore_requires_python=ignore_requires_python,
)
finder = PackageFinder.create(
search_scope=SearchScope([], []),
selection_prefs=selection_prefs,
session=PipSession(),
)
assert finder._ignore_requires_python == ignore_requires_python
def test_create__format_control(self):
"""
Test that the format_control attribute is set correctly.
"""
format_control = FormatControl(set(), {':all:'})
selection_prefs = SelectionPreferences(
allow_yanked=True,
format_control=format_control,
)
finder = PackageFinder.create(
search_scope=SearchScope([], []),
selection_prefs=selection_prefs,
session=PipSession(),
)
actual_format_control = finder.format_control
assert actual_format_control is format_control
# Check that the attributes weren't reset.
assert actual_format_control.only_binary == {':all:'}
def test_add_trusted_host(self): def test_add_trusted_host(self):
# Leave a gap to test how the ordering is affected. # Leave a gap to test how the ordering is affected.
trusted_hosts = ['host1', 'host3'] trusted_hosts = ['host1', 'host3']
@ -429,6 +485,52 @@ class TestPackageFinder:
# Spot-check that SECURE_ORIGINS is included. # Spot-check that SECURE_ORIGINS is included.
assert actual[0] == ('https', '*', '*') assert actual[0] == ('https', '*', '*')
@pytest.mark.parametrize(
'allow_yanked, ignore_requires_python, only_binary, expected_formats',
[
(False, False, {}, frozenset({'binary', 'source'})),
# Test allow_yanked=True.
(True, False, {}, frozenset({'binary', 'source'})),
# Test ignore_requires_python=True.
(False, True, {}, frozenset({'binary', 'source'})),
# Test a non-trivial only_binary.
(False, False, {'twine'}, frozenset({'binary'})),
]
)
def test_make_link_evaluator(
self, allow_yanked, ignore_requires_python, only_binary,
expected_formats,
):
# Create a test TargetPython that we can check for.
target_python = TargetPython(py_version_info=(3, 7))
format_control = FormatControl(set(), only_binary)
finder = PackageFinder(
candidate_evaluator=CandidateEvaluator(),
search_scope=SearchScope([], []),
session=PipSession(),
target_python=target_python,
allow_yanked=allow_yanked,
format_control=format_control,
ignore_requires_python=ignore_requires_python,
)
# Pass a project_name that will be different from canonical_name.
link_evaluator = finder.make_link_evaluator('Twine')
assert link_evaluator.project_name == 'Twine'
assert link_evaluator._canonical_name == 'twine'
assert link_evaluator._allow_yanked == allow_yanked
assert link_evaluator._ignore_requires_python == ignore_requires_python
assert link_evaluator._formats == expected_formats
# Test the _target_python attribute.
actual_target_python = link_evaluator._target_python
# The target_python attribute should be set as is.
assert actual_target_python is target_python
# For good measure, check that the attributes weren't reset.
assert actual_target_python._given_py_version_info == (3, 7)
assert actual_target_python.py_version_info == (3, 7, 0)
def test_sort_locations_file_expand_dir(data): def test_sort_locations_file_expand_dir(data):
""" """