From 8dbf88dff7ffa3a2a81d450549a117f181d073c1 Mon Sep 17 00:00:00 2001 From: Chris Jerdonek Date: Thu, 6 Jun 2019 01:17:18 -0700 Subject: [PATCH] Update pip-download to respect --python-version. --- news/5369.bugfix | 2 + src/pip/_internal/commands/download.py | 1 + src/pip/_internal/index.py | 22 ++++++---- src/pip/_internal/legacy_resolve.py | 12 +++-- src/pip/_internal/utils/packaging.py | 11 +++-- tests/functional/test_download.py | 60 ++++++++++++++++++++++++- tests/unit/test_index.py | 61 ++++++++++++++++---------- 7 files changed, 126 insertions(+), 43 deletions(-) create mode 100644 news/5369.bugfix diff --git a/news/5369.bugfix b/news/5369.bugfix new file mode 100644 index 00000000000..e17f88289d8 --- /dev/null +++ b/news/5369.bugfix @@ -0,0 +1,2 @@ +Update ``pip download`` to respect the given ``--python-version`` when checking +``"Requires-Python"``. diff --git a/src/pip/_internal/commands/download.py b/src/pip/_internal/commands/download.py index b2c9ce49e0d..abdd1c2d01b 100644 --- a/src/pip/_internal/commands/download.py +++ b/src/pip/_internal/commands/download.py @@ -152,6 +152,7 @@ def run(self, options, args): upgrade_strategy="to-satisfy-only", force_reinstall=False, ignore_dependencies=options.ignore_dependencies, + py_version_info=options.python_version, ignore_requires_python=False, ignore_installed=True, isolated=options.isolated_mode, diff --git a/src/pip/_internal/index.py b/src/pip/_internal/index.py index d6e30a483f6..16ace1a45e3 100644 --- a/src/pip/_internal/index.py +++ b/src/pip/_internal/index.py @@ -34,7 +34,7 @@ from pip._internal.utils.logging import indent_log from pip._internal.utils.misc import ( ARCHIVE_EXTENSIONS, SUPPORTED_EXTENSIONS, WHEEL_EXTENSION, normalize_path, - path_to_url, redact_password_from_url, + normalize_version_info, 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 @@ -258,7 +258,7 @@ def _get_html_page(link, session=None): def _check_link_requires_python( link, # type: Link - version_info, # type: Tuple[int, ...] + version_info, # type: Tuple[int, int, int] ignore_requires_python=False, # type: bool ): # type: (...) -> bool @@ -266,8 +266,8 @@ def _check_link_requires_python( Return whether the given Python version is compatible with a link's "Requires-Python" value. - :param version_info: The Python version to use to check, as a 3-tuple - of ints (major-minor-micro). + :param version_info: A 3-tuple of ints representing the Python + major-minor-micro version to check. :param ignore_requires_python: Whether to ignore the "Requires-Python" value if the given Python version isn't compatible. """ @@ -311,16 +311,17 @@ def __init__( valid_tags, # type: List[Pep425Tag] prefer_binary=False, # type: bool allow_all_prereleases=False, # type: bool - py_version_info=None, # type: Optional[Tuple[int, ...]] + py_version_info=None, # type: Optional[Tuple[int, int, int]] ignore_requires_python=None, # type: Optional[bool] ): # type: (...) -> None """ :param allow_all_prereleases: Whether to allow all pre-releases. - :param py_version_info: The Python version, as a 3-tuple of ints - representing a major-minor-micro version, to use to check both - the Python version embedded in the filename and the package's - "Requires-Python" metadata. Defaults to `sys.version_info[:3]`. + :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. """ @@ -666,6 +667,8 @@ def create( 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, @@ -676,6 +679,7 @@ def create( candidate_evaluator = CandidateEvaluator( valid_tags=valid_tags, 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/legacy_resolve.py b/src/pip/_internal/legacy_resolve.py index a6714cb1fc2..1cc8531d137 100644 --- a/src/pip/_internal/legacy_resolve.py +++ b/src/pip/_internal/legacy_resolve.py @@ -23,7 +23,9 @@ ) from pip._internal.req.constructors import install_req_from_req_string from pip._internal.utils.logging import indent_log -from pip._internal.utils.misc import dist_in_usersite, ensure_dir +from pip._internal.utils.misc import ( + dist_in_usersite, ensure_dir, normalize_version_info, +) from pip._internal.utils.packaging import ( check_requires_python, get_requires_python, ) @@ -46,7 +48,7 @@ def _check_dist_requires_python( dist, # type: pkg_resources.Distribution - version_info, # type: Tuple[int, ...] + version_info, # type: Tuple[int, int, int] ignore_requires_python=False, # type: bool ): # type: (...) -> None @@ -54,8 +56,8 @@ def _check_dist_requires_python( Check whether the given Python version is compatible with a distribution's "Requires-Python" value. - :param version_info: The Python version to use to check, as a 3-tuple - of ints (major-minor-micro). + :param version_info: A 3-tuple of ints representing the Python + major-minor-micro version to check. :param ignore_requires_python: Whether to ignore the "Requires-Python" value if the given Python version isn't compatible. @@ -121,6 +123,8 @@ def __init__( if py_version_info is None: py_version_info = sys.version_info[:3] + else: + py_version_info = normalize_version_info(py_version_info) self._py_version_info = py_version_info diff --git a/src/pip/_internal/utils/packaging.py b/src/pip/_internal/utils/packaging.py index 2d80920dfb9..68aa86edbf0 100644 --- a/src/pip/_internal/utils/packaging.py +++ b/src/pip/_internal/utils/packaging.py @@ -22,16 +22,15 @@ def check_requires_python(requires_python, version_info): # type: (Optional[str], Tuple[int, ...]) -> bool """ - Check if the given Python version matches a `requires_python` specifier. + Check if the given Python version matches a "Requires-Python" specifier. - :param version_info: A 3-tuple of ints representing the Python + :param version_info: A 3-tuple of ints representing a Python major-minor-micro version to check (e.g. `sys.version_info[:3]`). - Returns `True` if the version of python in use matches the requirement. - Returns `False` if the version of python in use does not matches the - requirement. + :return: `True` if the given Python version satisfies the requirement. + Otherwise, return `False`. - Raises an InvalidSpecifier if `requires_python` have an invalid format. + :raises InvalidSpecifier: If `requires_python` has an invalid format. """ if requires_python is None: # The package provides no information diff --git a/tests/functional/test_download.py b/tests/functional/test_download.py index e318aa004ca..b216450dbc1 100644 --- a/tests/functional/test_download.py +++ b/tests/functional/test_download.py @@ -1,3 +1,4 @@ +import os.path import textwrap import pytest @@ -388,7 +389,7 @@ def test_explicit_platform_only(self, data, script): ) -def test_download_specify_python_version(script, data): +def test_download__python_version(script, data): """ Test using "pip download --python-version" to download a .whl archive supported for a specific interpreter @@ -477,6 +478,63 @@ def test_download_specify_python_version(script, data): ) +def make_wheel_with_python_requires(script, package_name, python_requires): + """ + Create a wheel using the given python_requires. + + :return: the path to the wheel file. + """ + package_dir = script.scratch_path / package_name + package_dir.mkdir() + + text = textwrap.dedent("""\ + from setuptools import setup + setup(name='{}', + python_requires='{}', + version='1.0') + """).format(package_name, python_requires) + package_dir.join('setup.py').write(text) + script.run( + 'python', 'setup.py', 'bdist_wheel', '--universal', cwd=package_dir, + ) + + file_name = '{}-1.0-py2.py3-none-any.whl'.format(package_name) + return package_dir / 'dist' / file_name + + +def test_download__python_version_used_for_python_requires( + script, data, with_wheel, +): + """ + Test that --python-version is used for the Requires-Python check. + """ + wheel_path = make_wheel_with_python_requires( + script, 'mypackage', python_requires='==3.2', + ) + wheel_dir = os.path.dirname(wheel_path) + + def make_args(python_version): + return [ + 'download', '--no-index', '--find-links', wheel_dir, + '--only-binary=:all:', + '--dest', '.', + '--python-version', python_version, + 'mypackage==1.0', + ] + + args = make_args('33') + result = script.pip(*args, expect_error=True) + expected_err = ( + "ERROR: Package 'mypackage' requires a different Python: " + "3.3.0 not in '==3.2'" + ) + assert expected_err in result.stderr, 'stderr: {}'.format(result.stderr) + + # Now try with a --python-version that satisfies the Requires-Python. + args = make_args('32') + script.pip(*args) # no exception + + def test_download_specify_abi(script, data): """ Test using "pip download --abi" to download a .whl archive diff --git a/tests/unit/test_index.py b/tests/unit/test_index.py index 513826d611e..1d6c4804054 100644 --- a/tests/unit/test_index.py +++ b/tests/unit/test_index.py @@ -13,6 +13,8 @@ _egg_info_matches, _find_name_version_sep, _get_html_page, ) +CURRENT_PY_VERSION_INFO = sys.version_info[:3] + @pytest.mark.parametrize('requires_python, expected', [ ('== 3.6.4', False), @@ -82,27 +84,33 @@ def test_check_link_requires_python__invalid_requires(caplog): class TestCandidateEvaluator: - @pytest.mark.parametrize("version_info, expected", [ + @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(self, version_info, expected): + def test_init__py_version_info(self, py_version_info, expected_py_version): """ - Test the _py_version attribute. + Test the py_version_info argument. """ - evaluator = CandidateEvaluator([], py_version_info=version_info) - assert evaluator._py_version == expected + evaluator = CandidateEvaluator([], py_version_info=py_version_info) + + # 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_default(self): + def test_init__py_version_info_none(self): """ - Test the _py_version attribute's default value. + Test passing None for the py_version_info argument. """ - evaluator = CandidateEvaluator([]) + evaluator = CandidateEvaluator([], py_version_info=None) # Get the index of the second dot. index = sys.version.find('.', 2) - assert evaluator._py_version == sys.version[:index] + 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 @pytest.mark.parametrize( 'py_version_info,ignore_requires_python,expected', [ @@ -151,30 +159,37 @@ def test_evaluate_link__incompatible_wheel(self): class TestPackageFinder: - @pytest.mark.parametrize('version_info, expected', [ - ((2,), ['2']), - ((3,), ['3']), - ((3, 6,), ['36']), - # Test a tuple of length 3. - ((3, 6, 5), ['36']), + @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']), - # Test falsey values. - (None, None), - ((), None), + ((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, version_info, expected, + self, mock_get_supported, py_version_info, expected, ): """ Test that the py_version_info argument is handled correctly. """ - PackageFinder.create( - [], [], py_version_info=version_info, session=object(), + expected_versions, expected_evaluator_info = expected + finder = PackageFinder.create( + [], [], py_version_info=py_version_info, session=object(), ) actual = mock_get_supported.call_args[1]['versions'] - assert actual == expected + 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 def test_sort_locations_file_expand_dir(data):