diff --git a/src/pip/_internal/cli/base_command.py b/src/pip/_internal/cli/base_command.py index 959b82a1c..31e5f6afe 100644 --- a/src/pip/_internal/cli/base_command.py +++ b/src/pip/_internal/cli/base_command.py @@ -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, ) diff --git a/src/pip/_internal/index.py b/src/pip/_internal/index.py index 16ace1a45..b67ce51f8 100644 --- a/src/pip/_internal/index.py +++ b/src/pip/_internal/index.py @@ -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, ) diff --git a/src/pip/_internal/models/target_python.py b/src/pip/_internal/models/target_python.py new file mode 100644 index 000000000..e10efb8ea --- /dev/null +++ b/src/pip/_internal/models/target_python.py @@ -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 diff --git a/tests/lib/__init__.py b/tests/lib/__init__.py index b57f49d7b..5d5f2dc99 100644 --- a/tests/lib/__init__.py +++ b/tests/lib/__init__.py @@ -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) diff --git a/tests/unit/test_finder.py b/tests/unit/test_finder.py index 3512b93a8..077babc26 100644 --- a/tests/unit/test_finder.py +++ b/tests/unit/test_finder.py @@ -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'), diff --git a/tests/unit/test_index.py b/tests/unit/test_index.py index 1d6c48040..faadce30f 100644 --- a/tests/unit/test_index.py +++ b/tests/unit/test_index.py @@ -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): diff --git a/tests/unit/test_target_python.py b/tests/unit/test_target_python.py new file mode 100644 index 000000000..2230b12b7 --- /dev/null +++ b/tests/unit/test_target_python.py @@ -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']