diff --git a/news/7629.bugfix b/news/7629.bugfix new file mode 100644 index 00000000000..439b7ef7e7a --- /dev/null +++ b/news/7629.bugfix @@ -0,0 +1,6 @@ +Restore compatibility with legacy PyPy-specific interpreter wheel tags that use +the interpreter implementation version in the filename instead of the nominal +Python language version (for example, the expected PyPy-specific interpreter +tag is ``pp36`` for versions of PyPy that target Python 3.6 compatibility, but +older versions of pip instead looked for tags like ``pp372``, incorporating the +first two digits of the implementation version). diff --git a/src/pip/_internal/models/wheel.py b/src/pip/_internal/models/wheel.py index f1e3f44c598..fbbfcf60dff 100644 --- a/src/pip/_internal/models/wheel.py +++ b/src/pip/_internal/models/wheel.py @@ -2,10 +2,12 @@ name that have meaning. """ import re +from collections import OrderedDict from pip._vendor.packaging.tags import Tag from pip._internal.exceptions import InvalidWheelFilename +from pip._internal.utils.deprecation import deprecated from pip._internal.utils.typing import MYPY_CHECK_RUNNING if MYPY_CHECK_RUNNING: @@ -42,6 +44,28 @@ def __init__(self, filename): self.abis = wheel_info.group('abi').split('.') self.plats = wheel_info.group('plat').split('.') + # Adjust for any legacy custom PyPy tags found in pyversions + missing_tags = _infer_missing_pypy_version_tags(self.pyversions) + if missing_tags: + self.pyversions.extend(missing_tags) + + deprecated( + reason=( + "{} includes legacy PyPy version tags without the " + "corresponding standard tags ({})." + ).format(self.filename, missing_tags), + replacement=( + "for maintainers: use v0.34.0 or later of the wheel " + "project to create a correctly tagged {0} PyPy wheel. " + "For users: contact the maintainers of {0} to let " + "them know to regenerate their PyPy wheel(s)".format( + self.name + ) + ), + gone_in="21.0", + issue=7629, + ) + # All the tag combinations from this file self.file_tags = { Tag(x, y, z) for x in self.pyversions @@ -76,3 +100,71 @@ def supported(self, tags): :param tags: the PEP 425 tags to check the wheel against. """ return not self.file_tags.isdisjoint(tags) + + +def _is_legacy_pypy_tag(pyversion_tag): + # type: (str) -> bool + """Returns True if the given tag looks like a legacy custom PyPy tag + + :param pyversion_tag: pyversion tags to be checked + """ + return ( + len(pyversion_tag) == 5 and + pyversion_tag.startswith('pp') and + pyversion_tag[2:].isdigit() + ) + + +# Note: the listed thresholds are the first non-alpha PyPy version that +# *doesn't* report the given Python version in sys.version_info. This +# means that PyPy 7.0.0 is handled as a Python 3.5 compatible release. +_PYPY3_COMPATIBILITY_TAG_THRESHOLDS = OrderedDict(( + ('pp32', (5, 2)), + ('pp33', (5, 7)), + ('pp35', (7, 1)), + ('pp36', (8, 0)) + # The legacy custom PyPy wheel tags are not supported on PyPy 8.0.0+ +)) + + +def _infer_missing_pypy_version_tags(pyversions): + # type: (List[str]) -> List[str] + """Infer standard PyPy tags for any legacy PyPy tags, avoiding duplicates + + Returns an ordered list of the missing tags (which may be empty) + + :param pyversions: the list of pyversion tags to be checked + """ + # Several wheel versions prior to 0.34.0 produced non-standard tags + # for PyPy wheel archives. For backwards compatibility, we translate + # those legacy custom tags to standard tags for PyPy versions prior to + # PyPy 8.0.0. + legacy_pypy_tags = [tag for tag in pyversions if _is_legacy_pypy_tag(tag)] + if not legacy_pypy_tags: + return [] # Nothing to do + + standard_tags = set() + py3_tag_thresholds = _PYPY3_COMPATIBILITY_TAG_THRESHOLDS.items() + for tag in legacy_pypy_tags: + py_major, pypy_major, pypy_minor = map(int, tag[2:]) + if py_major == 2: + standard_tags.add('pp27') + continue + if py_major > 3: + continue + pypy_version = (pypy_major, pypy_minor) + for standard_tag, version_limit in py3_tag_thresholds: + if pypy_version < version_limit: + standard_tags.add(standard_tag) + break + + if not standard_tags: + return [] # Nothing to do + + existing_tags = set(pyversions) + missing_tags = standard_tags - existing_tags + if not missing_tags: + return [] # Nothing to do + + # Order the tags to get consistent warning output + return sorted(missing_tags) diff --git a/tests/functional/test_download.py b/tests/functional/test_download.py index 05b97ab3aec..cb9de57459c 100644 --- a/tests/functional/test_download.py +++ b/tests/functional/test_download.py @@ -1,5 +1,6 @@ import os.path import shutil +import sys import textwrap from hashlib import sha256 @@ -11,6 +12,14 @@ from tests.lib.path import Path from tests.lib.server import file_response +from pip._vendor.packaging.tags import ( # isort:skip + _cpython_abis, # For platform specific download testing + _generic_abi, # For platform specific download testing + _generic_platforms, # For platform specific download testing + interpreter_name, # For platform specific download testing + interpreter_version # For platform specific download testing +) + def fake_wheel(data, wheel_path): wheel_name = os.path.basename(wheel_path) @@ -61,6 +70,83 @@ def test_download_wheel(script, data): assert script.site_packages / 'piptestpackage' not in result.files_created +def test_download_platform_specific_wheel(script, data): + """ + Test using "pip download" to download a platform specific *.whl archive + """ + + def _get_abi_tag(): + # If we're on CPython, use CPython-specific ABI tag (as 'SOABI' + # contains platform information that isn't part of the wheel ABI tag) + if interpreter_name() == 'cp': + # Unlike the generic ABI tags, CPython ABI tags are always defined + return _cpython_abis(sys.version_info)[0] + # Otherwise use the first generic wheel ABI tag + try: + return next(_generic_abi()) + except StopIteration: + pass + raise RuntimeError("Failed to determine an ABI tag for this platform") + + interp = interpreter_name() + interpreter_version() + abi = _get_abi_tag() + platform = next(_generic_platforms()) + platform_wheel = 'fake-1.0-{}-{}-{}.whl'.format(interp, abi, platform) + fake_wheel(data, platform_wheel) + + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + 'fake', + ) + assert Path('scratch') / platform_wheel in result.files_created + + +@pytest.mark.parametrize("legacy_pypy_tag,standard_pypy_tag", [ + ("pp224", "pp27"), + ("pp273", "pp27"), + ("pp324", "pp32"), + ("pp352", "pp33"), + ("pp355", "pp33"), + ("pp357", "pp35"), + ("pp358", "pp35"), + ("pp359", "pp35"), + ("pp360", "pp35"), + ("pp370", "pp35"), + ("pp371", "pp36"), + ("pp372", "pp36"), + ("pp373", "pp36"), + ("pp379", "pp36"), +]) +def test_download_pypy_version_specific_wheel( + legacy_pypy_tag, standard_pypy_tag, script, data): + """ + Test using "pip download" to download a PyPy version specific *.whl archive + """ + # This can run on any platform, as the custom logic runs when the + # the PyPy specific wheel filename is processed, and doesn't depend on + # any details of the running interpreter. + interp = legacy_pypy_tag + abi = 'fake_abi' + platform_ = 'fake_platform' + pypy_wheel = 'fake-1.0-{}-{}-{}.whl'.format(interp, abi, platform_) + fake_wheel(data, pypy_wheel) + + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--implementation', standard_pypy_tag[:2], + '--python-version', standard_pypy_tag[2:], + '--abi', abi, + '--platform', platform_, + 'fake', + allow_stderr_warning=True + ) + assert Path('scratch') / pypy_wheel in result.files_created + + @pytest.mark.network def test_single_download_from_requirements_file(script): """ @@ -608,8 +694,8 @@ def test_download_specify_abi(script, data): def test_download_specify_implementation(script, data): """ - Test using "pip download --abi" to download a .whl archive - supported for a specific abi + Test using "pip download --implementation" to download a .whl archive + supported for a specific implementation """ fake_wheel(data, 'fake-1.0-py2.py3-none-any.whl') result = script.pip(