Add a TargetPython class.

This commit is contained in:
Chris Jerdonek 2019-06-09 13:11:16 -07:00
parent 9c8b2ea759
commit b8d693c71e
7 changed files with 236 additions and 130 deletions

View File

@ -24,6 +24,7 @@ from pip._internal.exceptions import (
)
from pip._internal.index import PackageFinder
from pip._internal.locations import running_under_virtualenv
from pip._internal.models.target_python import TargetPython
from pip._internal.req.constructors import (
install_req_from_editable, install_req_from_line,
)
@ -344,6 +345,13 @@ class RequirementCommand(Command):
)
index_urls = []
target_python = TargetPython(
platform=platform,
py_version_info=py_version_info,
abi=abi,
implementation=implementation,
)
return PackageFinder.create(
find_links=options.find_links,
format_control=options.format_control,
@ -351,10 +359,7 @@ class RequirementCommand(Command):
trusted_hosts=options.trusted_hosts,
allow_all_prereleases=options.pre,
session=session,
platform=platform,
py_version_info=py_version_info,
abi=abi,
implementation=implementation,
target_python=target_python,
prefer_binary=options.prefer_binary,
ignore_requires_python=ignore_requires_python,
)

View File

@ -8,7 +8,6 @@ import mimetypes
import os
import posixpath
import re
import sys
from collections import namedtuple
from pip._vendor import html5lib, requests, six
@ -29,12 +28,12 @@ from pip._internal.models.candidate import InstallationCandidate
from pip._internal.models.format_control import FormatControl
from pip._internal.models.index import PyPI
from pip._internal.models.link import Link
from pip._internal.pep425tags import get_supported, version_info_to_nodot
from pip._internal.models.target_python import TargetPython
from pip._internal.utils.compat import ipaddress
from pip._internal.utils.logging import indent_log
from pip._internal.utils.misc import (
ARCHIVE_EXTENSIONS, SUPPORTED_EXTENSIONS, WHEEL_EXTENSION, normalize_path,
normalize_version_info, path_to_url, redact_password_from_url,
path_to_url, redact_password_from_url,
)
from pip._internal.utils.packaging import check_requires_python
from pip._internal.utils.typing import MYPY_CHECK_RUNNING
@ -48,7 +47,6 @@ if MYPY_CHECK_RUNNING:
)
from pip._vendor.packaging.version import _BaseVersion
from pip._vendor.requests import Response
from pip._internal.pep425tags import Pep425Tag
from pip._internal.req import InstallRequirement
from pip._internal.download import PipSession
@ -308,35 +306,29 @@ class CandidateEvaluator(object):
def __init__(
self,
valid_tags, # type: List[Pep425Tag]
target_python=None, # type: Optional[TargetPython]
prefer_binary=False, # type: bool
allow_all_prereleases=False, # type: bool
py_version_info=None, # type: Optional[Tuple[int, int, int]]
ignore_requires_python=None, # type: Optional[bool]
):
# type: (...) -> None
"""
: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 py_version_info: A 3-tuple of ints representing the Python
major-minor-micro version to use to check both the Python version
embedded in the filename and the package's "Requires-Python"
metadata. If None (the default), then `sys.version_info[:3]`
will be used.
:param ignore_requires_python: Whether to ignore incompatible
"Requires-Python" values in links. Defaults to False.
"""
if py_version_info is None:
py_version_info = sys.version_info[:3]
if target_python is None:
target_python = TargetPython()
if ignore_requires_python is None:
ignore_requires_python = False
py_version = '.'.join(map(str, py_version_info[:2]))
self._ignore_requires_python = ignore_requires_python
self._prefer_binary = prefer_binary
self._py_version = py_version
self._py_version_info = py_version_info
self._valid_tags = valid_tags
self._target_python = target_python
# We compile the regex here instead of as a class attribute so as
# not to impact pip start-up time. This is also okay because
@ -348,7 +340,8 @@ class CandidateEvaluator(object):
def _is_wheel_supported(self, wheel):
# type: (Wheel) -> bool
return wheel.supported(self._valid_tags)
valid_tags = self._target_python.get_tags()
return wheel.supported(valid_tags)
def evaluate_link(self, link, search):
# type: (Link, Search) -> Tuple[bool, Optional[str]]
@ -410,11 +403,11 @@ class CandidateEvaluator(object):
if match:
version = version[:match.start()]
py_version = match.group(1)
if py_version != self._py_version:
if py_version != self._target_python.py_version:
return (False, 'Python version is incorrect')
supports_python = _check_link_requires_python(
link, version_info=self._py_version_info,
link, version_info=self._target_python.py_version_info,
ignore_requires_python=self._ignore_requires_python,
)
if not supports_python:
@ -474,7 +467,8 @@ class CandidateEvaluator(object):
comparison operators, but then different sdist links
with the same version, would have to be considered equal
"""
support_num = len(self._valid_tags)
valid_tags = self._target_python.get_tags()
support_num = len(valid_tags)
build_tag = tuple() # type: BuildTag
binary_preference = 0
if candidate.location.is_wheel:
@ -487,7 +481,7 @@ class CandidateEvaluator(object):
)
if self._prefer_binary:
binary_preference = 1
pri = -(wheel.support_index_min(self._valid_tags))
pri = -(wheel.support_index_min(valid_tags))
if wheel.build_tag is not None:
match = re.match(r'^(\d+)(.*)$', wheel.build_tag)
build_tag_groups = match.groups()
@ -604,10 +598,7 @@ class PackageFinder(object):
trusted_hosts=None, # type: Optional[Iterable[str]]
session=None, # type: Optional[PipSession]
format_control=None, # type: Optional[FormatControl]
platform=None, # type: Optional[str]
py_version_info=None, # type: Optional[Tuple[int, ...]]
abi=None, # type: Optional[str]
implementation=None, # type: Optional[str]
target_python=None, # type: Optional[TargetPython]
prefer_binary=False, # type: bool
ignore_requires_python=None, # type: Optional[bool]
):
@ -620,19 +611,7 @@ class PackageFinder(object):
:param format_control: A FormatControl object or None. Used to control
the selection of source packages / binary packages when consulting
the index and links.
:param platform: A string or None. If None, searches for packages
that are supported by the current system. Otherwise, will find
packages that can be built on the platform passed in. These
packages will only be downloaded for distribution: they will
not be built locally.
:param py_version_info: An optional tuple of ints representing the
Python version information to use (e.g. `sys.version_info[:3]`).
This can have length 1, 2, or 3. This is used to construct the
value passed to pep425tags.py's get_supported() function.
:param abi: A string or None. This is passed directly
to pep425tags.py in the get_supported() method.
:param implementation: A string or None. This is passed directly
to pep425tags.py in the get_supported() method.
:param target_python: The target Python interpreter.
:param prefer_binary: Whether to prefer an old, but valid, binary
dist over a new source dist.
:param ignore_requires_python: Whether to ignore incompatible
@ -662,24 +641,9 @@ class PackageFinder(object):
for host in (trusted_hosts if trusted_hosts else [])
] # type: List[SecureOrigin]
if py_version_info:
versions = [version_info_to_nodot(py_version_info)]
else:
versions = None
py_version_info = normalize_version_info(py_version_info)
# The valid tags to check potential found wheel candidates against
valid_tags = get_supported(
versions=versions,
platform=platform,
abi=abi,
impl=implementation,
)
candidate_evaluator = CandidateEvaluator(
valid_tags=valid_tags, prefer_binary=prefer_binary,
target_python=target_python, prefer_binary=prefer_binary,
allow_all_prereleases=allow_all_prereleases,
py_version_info=py_version_info,
ignore_requires_python=ignore_requires_python,
)

View File

@ -0,0 +1,80 @@
import sys
from pip._internal.pep425tags import get_supported, version_info_to_nodot
from pip._internal.utils.misc import normalize_version_info
from pip._internal.utils.typing import MYPY_CHECK_RUNNING
if MYPY_CHECK_RUNNING:
from typing import Optional, Tuple
class TargetPython(object):
"""
Encapsulates the properties of a Python interpreter one is targeting
for a package install, download, etc.
"""
def __init__(
self,
platform=None, # type: Optional[str]
py_version_info=None, # type: Optional[Tuple[int, ...]]
abi=None, # type: Optional[str]
implementation=None, # type: Optional[str]
):
# type: (...) -> None
"""
:param platform: A string or None. If None, searches for packages
that are supported by the current system. Otherwise, will find
packages that can be built on the platform passed in. These
packages will only be downloaded for distribution: they will
not be built locally.
:param py_version_info: An optional tuple of ints representing the
Python version information to use (e.g. `sys.version_info[:3]`).
This can have length 1, 2, or 3 when provided.
:param abi: A string or None. This is passed to pep425tags.py's
get_supported() function as is.
:param implementation: A string or None. This is passed to
pep425tags.py's get_supported() function as is.
"""
# Store the given py_version_info for when we call get_supported().
self._given_py_version_info = py_version_info
if py_version_info is None:
py_version_info = sys.version_info[:3]
else:
py_version_info = normalize_version_info(py_version_info)
py_version = '.'.join(map(str, py_version_info[:2]))
self.abi = abi
self.implementation = implementation
self.platform = platform
self.py_version = py_version
self.py_version_info = py_version_info
# This is used to cache the return value of get_tags().
self._valid_tags = None
def get_tags(self):
"""
Return the supported tags to check wheel candidates against.
"""
if self._valid_tags is None:
# Pass versions=None if no py_version_info was given since
# versions=None uses special default logic.
py_version_info = self._given_py_version_info
if py_version_info is None:
versions = None
else:
versions = [version_info_to_nodot(py_version_info)]
tags = get_supported(
versions=versions,
platform=self.platform,
abi=self.abi,
impl=self.implementation,
)
self._valid_tags = tags
return self._valid_tags

View File

@ -22,6 +22,8 @@ SRC_DIR = Path(__file__).abspath.folder.folder.folder
pyversion = sys.version[:3]
pyversion_tuple = sys.version_info
CURRENT_PY_VERSION_INFO = sys.version_info[:3]
def assert_paths_equal(actual, expected):
os.path.normpath(actual) == os.path.normpath(expected)

View File

@ -14,6 +14,7 @@ from pip._internal.exceptions import (
from pip._internal.index import (
CandidateEvaluator, InstallationCandidate, Link, PackageFinder, Search,
)
from pip._internal.models.target_python import TargetPython
from pip._internal.req.constructors import install_req_from_line
@ -139,20 +140,16 @@ class TestWheel:
"""
Test not finding an unsupported wheel.
"""
monkeypatch.setattr(
pip._internal.pep425tags,
"get_supported",
lambda **kw: [("py1", "none", "any")],
)
req = install_req_from_line("simple.dist")
target_python = TargetPython()
# Make sure no tags will match.
target_python._valid_tags = []
finder = PackageFinder.create(
[data.find_links],
[],
session=PipSession(),
target_python=target_python,
)
valid_tags = pip._internal.pep425tags.get_supported()
finder.candidate_evaluator = CandidateEvaluator(valid_tags=valid_tags)
with pytest.raises(DistributionNotFound):
finder.find_requirement(req, True)
@ -246,7 +243,9 @@ class TestWheel:
('pyT', 'TEST', 'any'),
('pyT', 'none', 'any'),
]
evaluator = CandidateEvaluator(valid_tags=valid_tags)
target_python = TargetPython()
target_python._valid_tags = valid_tags
evaluator = CandidateEvaluator(target_python=target_python)
sort_key = evaluator._sort_key
results = sorted(links, key=sort_key, reverse=True)
results2 = sorted(reversed(links), key=sort_key, reverse=True)
@ -469,8 +468,7 @@ class TestCandidateEvaluator(object):
def setup(self):
self.search_name = 'pytest'
self.canonical_name = 'pytest'
valid_tags = pip._internal.pep425tags.get_supported()
self.evaluator = CandidateEvaluator(valid_tags=valid_tags)
self.evaluator = CandidateEvaluator()
@pytest.mark.parametrize('url, expected_version', [
('http:/yo/pytest-1.0.tar.gz', '1.0'),

View File

@ -1,9 +1,8 @@
import logging
import os.path
import sys
import pytest
from mock import Mock, patch
from mock import Mock
from pip._vendor import html5lib, requests
from pip._internal.download import PipSession
@ -12,8 +11,8 @@ from pip._internal.index import (
_check_link_requires_python, _clean_link, _determine_base_url,
_egg_info_matches, _find_name_version_sep, _get_html_page,
)
CURRENT_PY_VERSION_INFO = sys.version_info[:3]
from pip._internal.models.target_python import TargetPython
from tests.lib import CURRENT_PY_VERSION_INFO
@pytest.mark.parametrize('requires_python, expected', [
@ -84,33 +83,24 @@ def test_check_link_requires_python__invalid_requires(caplog):
class TestCandidateEvaluator:
@pytest.mark.parametrize('py_version_info, expected_py_version', [
((2, 7, 14), '2.7'),
((3, 6, 5), '3.6'),
# Check a minor version with two digits.
((3, 10, 1), '3.10'),
])
def test_init__py_version_info(self, py_version_info, expected_py_version):
def test_init__target_python(self):
"""
Test the py_version_info argument.
Test the target_python argument.
"""
evaluator = CandidateEvaluator([], py_version_info=py_version_info)
target_python = TargetPython(py_version_info=(3, 7, 3))
evaluator = CandidateEvaluator(target_python=target_python)
# The target_python attribute should be set as is.
assert evaluator._target_python is target_python
# The _py_version_info attribute should be set as is.
assert evaluator._py_version_info == py_version_info
assert evaluator._py_version == expected_py_version
def test_init__py_version_info_none(self):
def test_init__target_python_none(self):
"""
Test passing None for the py_version_info argument.
Test passing None for the target_python argument.
"""
evaluator = CandidateEvaluator([], py_version_info=None)
# Get the index of the second dot.
index = sys.version.find('.', 2)
current_major_minor = sys.version[:index] # e.g. "3.6"
assert evaluator._py_version_info == CURRENT_PY_VERSION_INFO
assert evaluator._py_version == current_major_minor
evaluator = CandidateEvaluator(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(
'py_version_info,ignore_requires_python,expected', [
@ -124,6 +114,11 @@ class TestCandidateEvaluator:
def test_evaluate_link(
self, py_version_info, ignore_requires_python, expected,
):
target_python = TargetPython(py_version_info=py_version_info)
evaluator = CandidateEvaluator(
target_python=target_python,
ignore_requires_python=ignore_requires_python,
)
link = Link(
'https://example.com/#egg=twine-1.12',
requires_python='== 3.6.5',
@ -131,10 +126,6 @@ class TestCandidateEvaluator:
search = Search(
supplied='twine', canonical='twine', formats=['source'],
)
evaluator = CandidateEvaluator(
[], py_version_info=py_version_info,
ignore_requires_python=ignore_requires_python,
)
actual = evaluator.evaluate_link(link, search=search)
assert actual == expected
@ -142,14 +133,14 @@ class TestCandidateEvaluator:
"""
Test an incompatible wheel.
"""
target_python = TargetPython(py_version_info=(3, 6, 4))
# Set the valid tags to an empty list to make sure nothing matches.
target_python._valid_tags = []
evaluator = CandidateEvaluator(target_python=target_python)
link = Link('https://example.com/sample-1.0-py2.py3-none-any.whl')
search = Search(
supplied='sample', canonical='sample', formats=['binary'],
)
# Pass an empty list for the valid tags to make sure nothing matches.
evaluator = CandidateEvaluator(
[], py_version_info=(3, 6, 4),
)
actual = evaluator.evaluate_link(link, search=search)
expected = (
False, "none of the wheel's tags match: py2-none-any, py3-none-any"
@ -159,37 +150,19 @@ class TestCandidateEvaluator:
class TestPackageFinder:
@pytest.mark.parametrize('py_version_info, expected', [
# Test tuples of varying lengths.
((), (None, (0, 0, 0))),
((2, ), (['2'], (2, 0, 0))),
((3, ), (['3'], (3, 0, 0))),
((3, 6,), (['36'], (3, 6, 0))),
((3, 6, 5), (['36'], (3, 6, 5))),
# Test a 2-digit minor version.
((3, 10), (['310'], (3, 10, 0))),
# Test passing None.
(None, (None, CURRENT_PY_VERSION_INFO)),
])
@patch('pip._internal.index.get_supported')
def test_create__py_version_info(
self, mock_get_supported, py_version_info, expected,
):
def test_create__target_python(self):
"""
Test that the py_version_info argument is handled correctly.
Test that target_python is passed to CandidateEvaluator as is.
"""
expected_versions, expected_evaluator_info = expected
target_python = TargetPython(py_version_info=(3, 7, 3))
finder = PackageFinder.create(
[], [], py_version_info=py_version_info, session=object(),
[], [], target_python=target_python, session=object(),
)
actual = mock_get_supported.call_args[1]['versions']
assert actual == expected_versions
# For candidate_evaluator, we only need to test _py_version_info
# since setting _py_version correctly is tested in
# TestCandidateEvaluator.
evaluator = finder.candidate_evaluator
assert evaluator._py_version_info == expected_evaluator_info
actual_target_python = evaluator._target_python
assert actual_target_python is target_python
assert actual_target_python.py_version_info == (3, 7, 3)
def test_sort_locations_file_expand_dir(data):

View File

@ -0,0 +1,84 @@
import sys
import pytest
from mock import patch
from pip._internal.models.target_python import TargetPython
from tests.lib import CURRENT_PY_VERSION_INFO
class TestTargetPython:
@pytest.mark.parametrize('py_version_info, expected', [
((), ((0, 0, 0), '0.0')),
((2, ), ((2, 0, 0), '2.0')),
((3, ), ((3, 0, 0), '3.0')),
((3, 7), ((3, 7, 0), '3.7')),
((3, 7, 3), ((3, 7, 3), '3.7')),
# Check a minor version with two digits.
((3, 10, 1), ((3, 10, 1), '3.10')),
])
def test_init__py_version_info(self, py_version_info, expected):
"""
Test passing the py_version_info argument.
"""
expected_py_version_info, expected_py_version = expected
target_python = TargetPython(py_version_info=py_version_info)
# The _given_py_version_info attribute should be set as is.
assert target_python._given_py_version_info == py_version_info
assert target_python.py_version_info == expected_py_version_info
assert target_python.py_version == expected_py_version
def test_init__py_version_info_none(self):
"""
Test passing py_version_info=None.
"""
# Get the index of the second dot.
index = sys.version.find('.', 2)
current_major_minor = sys.version[:index] # e.g. "3.6"
target_python = TargetPython(py_version_info=None)
assert target_python._given_py_version_info is None
assert target_python.py_version_info == CURRENT_PY_VERSION_INFO
assert target_python.py_version == current_major_minor
@pytest.mark.parametrize('py_version_info, expected_versions', [
((), ['']),
((2, ), ['2']),
((3, ), ['3']),
((3, 7), ['37']),
((3, 7, 3), ['37']),
# Check a minor version with two digits.
((3, 10, 1), ['310']),
# Check that versions=None is passed to get_tags().
(None, None),
])
@patch('pip._internal.models.target_python.get_supported')
def test_get_tags(
self, mock_get_supported, py_version_info, expected_versions,
):
mock_get_supported.return_value = ['tag-1', 'tag-2']
target_python = TargetPython(py_version_info=py_version_info)
actual = target_python.get_tags()
assert actual == ['tag-1', 'tag-2']
actual = mock_get_supported.call_args[1]['versions']
assert actual == expected_versions
# Check that the value was cached.
assert target_python._valid_tags == ['tag-1', 'tag-2']
def test_get_tags__uses_cached_value(self):
"""
Test that get_tags() uses the cached value.
"""
target_python = TargetPython(py_version_info=None)
target_python._valid_tags = ['tag-1', 'tag-2']
actual = target_python.get_tags()
assert actual == ['tag-1', 'tag-2']