diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index 6a271d781..1e5d1591c 100644 --- a/mesonpy/__init__.py +++ b/mesonpy/__init__.py @@ -13,6 +13,7 @@ import collections import contextlib import functools +import importlib.machinery import io import itertools import json @@ -46,7 +47,7 @@ import mesonpy._tags import mesonpy._util -from mesonpy._compat import Collection, Iterator, Mapping, Path +from mesonpy._compat import Iterator, Path if typing.TYPE_CHECKING: # pragma: no cover @@ -102,9 +103,9 @@ def _init_colors() -> Dict[str, str]: _STYLES = _init_colors() # holds the color values, should be _COLORS or _NO_COLORS -_LINUX_NATIVE_MODULE_REGEX = re.compile(r'^(?P.+)\.(?P.+)\.so$') -_WINDOWS_NATIVE_MODULE_REGEX = re.compile(r'^(?P.+)\.(?P.+)\.pyd$') -_STABLE_ABI_TAG_REGEX = re.compile(r'^abi(?P[0-9]+)$') +_EXTENSION_SUFFIXES = frozenset(importlib.machinery.EXTENSION_SUFFIXES) +_EXTENSION_SUFFIX_REGEX = re.compile(r'^\.(?:(?P[^.]+)\.)?(?:so|pyd)$') +assert all(re.match(_EXTENSION_SUFFIX_REGEX, x) for x in _EXTENSION_SUFFIXES) def _showwarning( @@ -179,6 +180,11 @@ def _wheel_files(self) -> DefaultDict[str, List[Tuple[pathlib.Path, str]]]: def _has_internal_libs(self) -> bool: return bool(self._wheel_files['mesonpy-libs']) + @property + def _has_extension_modules(self) -> bool: + # Assume that all code installed in {platlib} is Python ABI dependent. + return bool(self._wheel_files['platlib']) + @property def basename(self) -> str: """Normalized wheel name and version (eg. meson_python-1.0.0).""" @@ -187,14 +193,25 @@ def basename(self) -> str: version=self._project.version, ) + @property + def tag(self) -> mesonpy._tags.Tag: + """Wheel tags.""" + if self.is_pure: + return mesonpy._tags.Tag('py3', 'none', 'any') + if not self._has_extension_modules: + # The wheel has platform dependent code (is not pure) but + # does not contain any extension module (does not + # distribute any file in {platlib}) thus use generic + # implementation and ABI tags. + return mesonpy._tags.Tag('py3', 'none', None) + return mesonpy._tags.Tag(None, self._stable_abi, None) + @property def name(self) -> str: - """Wheel name, this includes the basename and tags.""" - return '{basename}-{python_tag}-{abi_tag}-{platform_tag}'.format( + """Wheel name, this includes the basename and tag.""" + return '{basename}-{tag}'.format( basename=self.basename, - python_tag=self.python_tag, - abi_tag=self.abi_tag, - platform_tag=self.platform_tag, + tag=self.tag, ) @property @@ -226,10 +243,10 @@ def wheel(self) -> bytes: # noqa: F811 Wheel-Version: 1.0 Generator: meson Root-Is-Purelib: {is_purelib} - Tag: {tags} + Tag: {tag} ''').strip().format( is_purelib='true' if self.is_pure else 'false', - tags=f'{self.python_tag}-{self.abi_tag}-{self.platform_tag}', + tag=self.tag, ).encode() @property @@ -267,166 +284,40 @@ def _debian_python(self) -> bool: except ModuleNotFoundError: return False - @property - def python_tag(self) -> str: - selected_tag = self._select_abi_tag() - if selected_tag and selected_tag.python: - return selected_tag.python - return 'py3' - - @property - def abi_tag(self) -> str: - selected_tag = self._select_abi_tag() - if selected_tag: - return selected_tag.abi - return 'none' - @cached_property - def platform_tag(self) -> str: - if self.is_pure: - return 'any' - # XXX: Choose the sysconfig platform here and let something like auditwheel - # fix it later if there are system dependencies (eg. replace it with a manylinux tag) - platform_ = sysconfig.get_platform() - parts = platform_.split('-') - if parts[0] == 'macosx': - target = os.environ.get('MACOSX_DEPLOYMENT_TARGET') - if target: - print( - '{yellow}MACOSX_DEPLOYMENT_TARGET is set so we are setting the ' - 'platform tag to {target}{reset}'.format(target=target, **_STYLES) - ) - parts[1] = target - else: - # If no target macOS version is specified fallback to - # platform.mac_ver() instead of sysconfig.get_platform() as the - # latter specifies the target macOS version Python was built - # against. - parts[1] = platform.mac_ver()[0] - if parts[1] >= '11': - # Only pick up the major version, which changed from 10.X - # to X.0 from macOS 11 onwards. See - # https://github.com/mesonbuild/meson-python/issues/160 - parts[1] = parts[1].split('.')[0] - - if parts[1] in ('11', '12'): - # Workaround for bug where pypa/packaging does not consider macOS - # tags without minor versions valid. Some Python flavors (Homebrew - # for example) on macOS started to do this in version 11, and - # pypa/packaging should handle things correctly from version 13 and - # forward, so we will add a 0 minor version to MacOS 11 and 12. - # https://github.com/mesonbuild/meson-python/issues/91 - # https://github.com/pypa/packaging/issues/578 - parts[1] += '.0' - - platform_ = '-'.join(parts) - elif parts[0] == 'linux' and parts[1] == 'x86_64' and sys.maxsize == 0x7fffffff: - # 32-bit Python running on an x86_64 host - # https://github.com/mesonbuild/meson-python/issues/123 - parts[1] = 'i686' - platform_ = '-'.join(parts) - return platform_.replace('-', '_').replace('.', '_') - - def _calculate_file_abi_tag_heuristic_windows(self, filename: str) -> Optional[mesonpy._tags.Tag]: - """Try to calculate the Windows tag from the Python extension file name.""" - match = _WINDOWS_NATIVE_MODULE_REGEX.match(filename) - if not match: - return None - tag = match.group('tag') + def _stable_abi(self) -> Optional[str]: + """Determine stabe ABI compatibility. - try: - return mesonpy._tags.StableABITag(tag) - except ValueError: - return mesonpy._tags.InterpreterTag(tag) - - def _calculate_file_abi_tag_heuristic_posix(self, filename: str) -> Optional[mesonpy._tags.Tag]: - """Try to calculate the Posix tag from the Python extension file name.""" - # sysconfig is not guaranted to export SHLIB_SUFFIX but let's be - # preventive and check its value to make sure it matches our expectations - try: - extension = sysconfig.get_config_vars().get('SHLIB_SUFFIX', '.so') - if extension != '.so': - raise NotImplementedError( - f"We don't currently support the {extension} extension. " - 'Please report this to https://github.com/mesonbuild/mesonpy/issues ' - 'and include information about your operating system.' - ) - except KeyError: - warnings.warn( - 'sysconfig does not export SHLIB_SUFFIX, so we are unable to ' - 'perform the sanity check regarding the extension suffix. ' - 'Please report this to https://github.com/mesonbuild/mesonpy/issues ' - 'and include the output of `python -m sysconfig`.' - ) - match = _LINUX_NATIVE_MODULE_REGEX.match(filename) - if not match: # this file does not appear to be a native module - return None - tag = match.group('tag') + Examine all files installed in {platlib} that look like + extension modules (extension .pyd on Windows and .so on other + platforms) and, if they all share the same PEP 3149 filename + stable ABI tag, return it. - try: - return mesonpy._tags.StableABITag(tag) - except ValueError: - return mesonpy._tags.InterpreterTag(tag) - - def _calculate_file_abi_tag_heuristic(self, filename: str) -> Optional[mesonpy._tags.Tag]: - """Try to calculate the ABI tag from the Python extension file name.""" - if os.name == 'nt': - return self._calculate_file_abi_tag_heuristic_windows(filename) - # everything else *should* follow the POSIX way, at least to my knowledge - return self._calculate_file_abi_tag_heuristic_posix(filename) - - def _file_list_repr(self, files: Collection[str], prefix: str = '\t\t', max_count: int = 3) -> str: - if len(files) > max_count: - files = list(itertools.islice(files, max_count)) + [f'(... +{len(files)}))'] - return ''.join(f'{prefix}- {file}\n' for file in files) - - def _files_by_tag(self) -> Mapping[mesonpy._tags.Tag, Collection[str]]: - """Map files into ABI tags.""" - files_by_tag: Dict[mesonpy._tags.Tag, List[str]] = collections.defaultdict(list) - - for _, file in self._wheel_files['platlib']: - # if in platlib, calculate the ABI tag - tag = self._calculate_file_abi_tag_heuristic(file) - if tag: - files_by_tag[tag].append(file) - - return files_by_tag - - def _select_abi_tag(self) -> Optional[mesonpy._tags.Tag]: # noqa: C901 - """Given a list of ABI tags, selects the most specific one. - - Raises an error if there are incompatible tags. + All files that look like extension modules are verified to + have a file name compatibel with what is expected by the + Python interpreter. An exception is raised otherwise. + + Other files are ignored. """ - # Possibilities: - # - interpreter specific (cpython/pypy/etc, version) - # - stable abi (abiX) - tags = self._files_by_tag() - selected_tag = None - for tag, files in tags.items(): - # no selected tag yet, let's assign this one - if not selected_tag: - selected_tag = tag - # interpreter tag - elif isinstance(tag, mesonpy._tags.InterpreterTag): - if tag != selected_tag: - if isinstance(selected_tag, mesonpy._tags.InterpreterTag): - raise ValueError( - 'Found files with incompatible ABI tags:\n' - + self._file_list_repr(tags[selected_tag]) - + '\tand\n' - + self._file_list_repr(files) - ) - selected_tag = tag - # stable ABI - elif isinstance(tag, mesonpy._tags.StableABITag): - if isinstance(selected_tag, mesonpy._tags.StableABITag) and tag != selected_tag: + abis = [] + + for path, src in self._wheel_files['platlib']: + if os.name == 'nt' and path.suffix == '.pyd' or path.suffix == '.so': + match = re.match(r'^[^.]+(.*)$', path.name) + assert match is not None + suffix = match.group(1) + if suffix not in _EXTENSION_SUFFIXES: raise ValueError( - 'Found files with incompatible ABI tags:\n' - + self._file_list_repr(tags[selected_tag]) - + '\tand\n' - + self._file_list_repr(files) - ) - return selected_tag + f'Extension module {str(path)!r} not compatible with Python interpreter. ' + f'Filename suffix {suffix!r} not in {set(_EXTENSION_SUFFIXES)}.') + match = _EXTENSION_SUFFIX_REGEX.match(suffix) + assert match is not None + abis.append(match.group('abi')) + + stable = [x for x in abis if x and re.match(r'abi\d+', x)] + if len(stable) > 0 and len(stable) == len(abis) and all(x == stable[0] for x in stable[1:]): + return stable[0] + return None def _is_native(self, file: Union[str, pathlib.Path]) -> bool: """Check if file is a native file.""" diff --git a/mesonpy/_compat.py b/mesonpy/_compat.py index 9fab9054f..dc74de40b 100644 --- a/mesonpy/_compat.py +++ b/mesonpy/_compat.py @@ -10,11 +10,9 @@ if sys.version_info >= (3, 9): - from collections.abc import ( - Collection, Iterable, Iterator, Mapping, Sequence - ) + from collections.abc import Collection, Iterable, Iterator, Sequence else: - from typing import Collection, Iterable, Iterator, Mapping, Sequence + from typing import Collection, Iterable, Iterator, Sequence if sys.version_info >= (3, 8): @@ -41,7 +39,6 @@ def is_relative_to(path: pathlib.Path, other: Union[pathlib.Path, str]) -> bool: 'Iterable', 'Iterator', 'Literal', - 'Mapping', 'Path', 'Sequence', ] diff --git a/mesonpy/_tags.py b/mesonpy/_tags.py index af83892e0..81725e0d6 100644 --- a/mesonpy/_tags.py +++ b/mesonpy/_tags.py @@ -1,130 +1,151 @@ # SPDX-License-Identifier: MIT -# SPDX-FileCopyrightText: 2021 Quansight, LLC -# SPDX-FileCopyrightText: 2021 Filipe LaĆ­ns -import abc -import re - -from typing import Any, Optional - -from mesonpy._compat import Literal, Sequence - - -class Tag(abc.ABC): - @abc.abstractmethod - def __init__(self, value: str) -> None: ... - - @abc.abstractmethod - def __str__(self) -> str: ... - - @property - @abc.abstractmethod - def python(self) -> Optional[str]: - """Python tag.""" - - @property - @abc.abstractmethod - def abi(self) -> str: - """ABI tag.""" - - -class StableABITag(Tag): - _REGEX = re.compile(r'^abi(?P[0-9]+)$') - - def __init__(self, value: str) -> None: - match = self._REGEX.match(value) - if not match: - raise ValueError(f'Invalid PEP 3149 stable ABI tag, expecting pattern `{self._REGEX.pattern}`') - self._abi_number = int(match.group('abi_number')) - - @property - def abi_number(self) -> int: - return self._abi_number - - def __str__(self) -> str: - return f'abi{self.abi_number}' - - @property - def python(self) -> Literal[None]: - return None - - @property - def abi(self) -> str: - return f'abi{self.abi_number}' - - def __eq__(self, other: Any) -> bool: - return isinstance(other, self.__class__) and other.abi_number == self.abi_number - - def __hash__(self) -> int: - return hash(str(self)) - - -class InterpreterTag(Tag): - def __init__(self, value: str) -> None: - parts = value.split('-') - if len(parts) < 2: - raise ValueError( - 'Invalid PEP 3149 interpreter tag, expected at ' - f'least 2 parts but got {len(parts)}' - ) - - # On Windows, file extensions look like `.cp311-win_amd64.pyd`, so the - # implementation part (`cpython-`) is different from Linux. Handle that here: - if parts[0].startswith('cp3'): - parts.insert(0, 'cpython') - parts[1] = parts[1][2:] # strip 'cp' - - self._implementation = parts[0] - self._interpreter_version = parts[1] - self._additional_information = parts[2:] - - if self.implementation != 'cpython' and not self.implementation.startswith('pypy'): - raise NotImplementedError(f'Unknown Python implementation: {self.implementation}.') - - @property - def implementation(self) -> str: - return self._implementation - - @property - def interpreter_version(self) -> str: - return self._interpreter_version - - @property - def additional_information(self) -> Sequence[str]: - return tuple(self._additional_information) +import os +import platform +import sys +import sysconfig + +from typing import Optional, Union + + +# https://peps.python.org/pep-0425/#python-tag +INTERPRETERS = { + 'python': 'py', + 'cpython': 'cp', + 'pypy': 'pp', + 'ironpython': 'ip', + 'jython': 'jy', +} + + +_32_BIT_INTERPRETER = sys.maxsize <= 2**32 + + +def get_interpreter_tag() -> str: + name = sys.implementation.name + name = INTERPRETERS.get(name, name) + version = sys.version_info + return f'{name}{version[0]}{version[1]}' + + +def _get_config_var(name: str, default: Union[str, int, None] = None) -> Union[str, int, None]: + value = sysconfig.get_config_var(name) + if value is None: + return default + return value + + +def _get_cpython_abi() -> str: + version = sys.version_info + debug = pymalloc = '' + if _get_config_var('Py_DEBUG', hasattr(sys, 'gettotalrefcount')): + debug = 'd' + if version < (3, 8) and _get_config_var('WITH_PYMALLOC', True): + pymalloc = 'm' + return f'cp{version[0]}{version[1]}{debug}{pymalloc}' + + +def get_abi_tag() -> str: + # The best solution to obtain the Python ABI is to parse the + # $SOABI or $EXT_SUFFIX sysconfig variables as defined in PEP-314. + + # PyPy reports a $SOABI that does not agree with $EXT_SUFFIX. + # Using $EXT_SUFFIX will not break when PyPy will fix this. + # See https://foss.heptapod.net/pypy/pypy/-/issues/3816 and + # https://github.com/pypa/packaging/pull/607. + try: + empty, abi, ext = str(sysconfig.get_config_var('EXT_SUFFIX')).split('.') + except ValueError: + # CPython <= 3.8.7 on Windows does not implement PEP3149 and + # uses '.pyd' as $EXT_SUFFIX, which does not allow to extract + # the interpreter ABI. Check that the fallback is not hit for + # any other Python implementation. + if sys.implementation.name != 'cpython': + raise NotImplementedError + return _get_cpython_abi() + + # The packaging module initially based his understanding of the + # $SOABI variable on the inconsistent value reported by PyPy, and + # did not strip architecture information from it. Therefore the + # ABI tag for later Python implementations (all the ones not + # explicitly handled below) contains architecture information too. + # Unfortunately, fixing this now would break compatibility. + + if abi.startswith('cpython'): + abi = 'cp' + abi.split('-')[1] + elif abi.startswith('cp'): + abi = abi.split('-')[0] + elif abi.startswith('pypy'): + abi = '_'.join(abi.split('-')[:2]) + + return abi.replace('.', '_').replace('-', '_') + + +def _get_macosx_platform_tag() -> str: + ver, x, arch = platform.mac_ver() + + # Override the macOS version if one is provided via the + # MACOS_DEPLOYMENT_TARGET environment variable. + try: + version = tuple(map(int, os.environ.get('MACOS_DEPLOYMENT_TARGET', '').split('.')))[:2] + except ValueError: + version = tuple(map(int, ver.split('.')))[:2] + + # Python built with older macOS SDK on macOS 11, reports an + # unexising macOS 10.16 version instead of the real version. + # + # The packaging module introduced a workaround + # https://github.com/pypa/packaging/commit/67c4a2820c549070bbfc4bfbf5e2a250075048da + # + # This results in packaging versions up to 21.3 generating + # platform tags like "macosx_10_16_x86_64" and later versions + # generating "macosx_11_0_x86_64". Using latter would be more + # correct but prevents the resulting wheel from being installed on + # systems using packaging 21.3 or earlier (pip 22.3 or earlier). + # + # Fortunately packaging versions carrying the workaround still + # accepts "macosx_11_0_x86_64" as a compatible platform tag. We + # can therefore ignore the issue and generate the slightly + # incorrect tag. + + major, minor = version + + if major >= 11: + # For macOS reelases up to 10.15, the major version number is + # actually part of the OS name and the minor version is the + # actual OS release. Starting with macOS 11, the major + # version number is the OS release and the minor version is + # the patch level. Reset the patch level to zero. + minor = 0 + + if _32_BIT_INTERPRETER: + # 32-bit Python running on a 64-bit kernel. + if arch == 'ppc64': + arch = 'ppc' + if arch == 'x86_64': + arch = 'i386' + + return f'macosx_{major}_{minor}_{arch}' + + +def get_platform_tag() -> str: + platform = sysconfig.get_platform() + if platform.startswith('macosx'): + return _get_macosx_platform_tag() + if _32_BIT_INTERPRETER: + # 32-bit Python running on a 64-bit kernel. + if platform == 'linux-x86_64': + return 'linux_i686' + if platform == 'linux-aarch64': + return 'linux_armv7l' + return platform.replace('-', '_') + + +class Tag: + def __init__(self, interpreter: Optional[str] = None, abi: Optional[str] = None, platform: Optional[str] = None): + self.interpreter = interpreter or get_interpreter_tag() + self.abi = abi or get_abi_tag() + self.platform = platform or get_platform_tag() def __str__(self) -> str: - return '-'.join(( - self.implementation, - self.interpreter_version, - *self.additional_information, - )) - - @property - def python(self) -> str: - if self.implementation == 'cpython': - # The Python tag for CPython does not seem to include the flags suffixes. - return f'cp{self.interpreter_version}'.rstrip('dmu') - elif self.implementation.startswith('pypy'): - return f'pp{self.implementation[4:]}' - # XXX: FYI older PyPy version use the following model - # pp{self.implementation[4]}{self.interpreter_version[2:]} - raise ValueError(f'Unknown implementation: {self.implementation}') - - @property - def abi(self) -> str: - if self.implementation == 'cpython': - return f'cp{self.interpreter_version}' - elif self.implementation.startswith('pypy'): - return f'{self.implementation}_{self.interpreter_version}' - raise ValueError(f'Unknown implementation: {self.implementation}') - - def __eq__(self, other: Any) -> bool: - return ( - isinstance(other, self.__class__) - and other.implementation == self.implementation - and other.interpreter_version == self.interpreter_version - ) - - def __hash__(self) -> int: - return hash(str(self)) + return f'{self.interpreter}-{self.abi}-{self.platform}' diff --git a/tests/conftest.py b/tests/conftest.py index edac18edb..f101fef3d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ import os import os.path import pathlib +import re import shutil import tempfile @@ -15,6 +16,16 @@ import mesonpy +def adjust_packaging_platform_tag(platform: str) -> str: + # The packaging module generates overly specific platforms tags on + # Linux. The platforms tags on Linux evolved over time. + # meson-python uses more relaxed platform tags to maintain + # compatibility with old wheel installation tools. The relaxed + # platform tags match the ones generated by the wheel package. + # https://packaging.python.org/en/latest/specifications/platform-compatibility-tags/ + return re.sub(r'^(many|musl)linux(1|2010|2014|_\d+_\d+)_(.*)$', r'linux_\3', platform) + + package_dir = pathlib.Path(__file__).parent / 'packages' diff --git a/tests/test_tags.py b/tests/test_tags.py index 7b7fdd57a..07ba555ec 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -1,75 +1,100 @@ # SPDX-License-Identifier: MIT +import os +import pathlib +import platform import re -import sys +import sysconfig +from collections import defaultdict + +import packaging.tags import pytest +import mesonpy import mesonpy._tags +from .conftest import adjust_packaging_platform_tag + + +# Test against the wheel tag generated by packaging module. +tag = next(packaging.tags.sys_tags()) +ABI = tag.abi +INTERPRETER = tag.interpreter +PLATFORM = adjust_packaging_platform_tag(tag.platform) + +SUFFIX = sysconfig.get_config_var('EXT_SUFFIX') +ABI3SUFFIX = next((x for x in mesonpy._EXTENSION_SUFFIXES if '.abi3.' in x), None) + + +def test_wheel_tag(): + str(mesonpy._tags.Tag()) == f'{INTERPRETER}-{ABI}-{PLATFORM}' + str(mesonpy._tags.Tag(abi='abi3')) == f'{INTERPRETER}-abi3-{PLATFORM}' + + +@pytest.mark.skipif(platform.system() != 'Darwin', reason='macOS specific test') +def test_macos_platform_tag(monkeypatch): + for minor in range(9, 16): + monkeypatch.setenv('MACOS_DEPLOYMENT_TARGET', f'10.{minor}') + assert next(packaging.tags.mac_platforms((10, minor))) == mesonpy._tags.get_platform_tag() + for major in range(11, 20): + for minor in range(3): + monkeypatch.setenv('MACOS_DEPLOYMENT_TARGET', f'{major}.{minor}') + assert next(packaging.tags.mac_platforms((major, minor))) == mesonpy._tags.get_platform_tag() + + +def wheel_builder_test_factory(monkeypatch, content): + files = defaultdict(list) + files.update({key: [(pathlib.Path(x), os.path.join('build', x)) for x in value] for key, value in content.items()}) + monkeypatch.setattr(mesonpy._WheelBuilder, '_wheel_files', files) + return mesonpy._WheelBuilder(None, None, pathlib.Path(), pathlib.Path(), pathlib.Path(), pathlib.Path(), pathlib.Path()) + + +def test_tag_empty_wheel(monkeypatch): + builder = wheel_builder_test_factory(monkeypatch, {}) + assert str(builder.tag) == 'py3-none-any' + + +def test_tag_purelib_wheel(monkeypatch): + builder = wheel_builder_test_factory(monkeypatch, { + 'purelib': ['pure.py'], + }) + assert str(builder.tag) == 'py3-none-any' + + +def test_tag_platlib_wheel(monkeypatch): + builder = wheel_builder_test_factory(monkeypatch, { + 'platlib': [f'extension{SUFFIX}'], + }) + assert str(builder.tag) == f'{INTERPRETER}-{ABI}-{PLATFORM}' + + +@pytest.mark.skipif(not ABI3SUFFIX, reason='Stable ABI not supported by Python interpreter') +def test_tag_stable_abi(monkeypatch): + builder = wheel_builder_test_factory(monkeypatch, { + 'platlib': [f'extension{ABI3SUFFIX}'], + }) + assert str(builder.tag) == f'{INTERPRETER}-abi3-{PLATFORM}' + + +@pytest.mark.skipif(not ABI3SUFFIX, reason='Stable ABI not supported by Python interpreter') +def test_tag_mixed_abi(monkeypatch): + builder = wheel_builder_test_factory(monkeypatch, { + 'platlib': [f'extension{ABI3SUFFIX}', f'another{SUFFIX}'], + }) + assert str(builder.tag) == f'{INTERPRETER}-{ABI}-{PLATFORM}' + + +@pytest.mark.skipif(platform.system() != 'Darwin', reason='macOS specific test') +def test_tag_macos_build_target(monkeypatch): + monkeypatch.setenv('MACOS_BUILD_TARGET', '12.0') + builder = wheel_builder_test_factory(monkeypatch, { + 'platlib': [f'extension{SUFFIX}'], + }) + assert builder.tag.platform == re.sub(r'\d+\.\d+', '12.0', PLATFORM) -INTERPRETER_VERSION = f'{sys.version_info[0]}{sys.version_info[1]}' - - -@pytest.mark.parametrize( - ('value', 'number', 'abi', 'python'), - [ - ('abi3', 3, 'abi3', None), - ('abi4', 4, 'abi4', None), - ] -) -def test_stable_abi_tag(value, number, abi, python): - tag = mesonpy._tags.StableABITag(value) - assert str(tag) == value - assert tag.abi_number == number - assert tag.abi == abi - assert tag.python == python - assert tag == mesonpy._tags.StableABITag(value) - - -def test_stable_abi_tag_invalid(): - with pytest.raises(ValueError, match=re.escape( - r'Invalid PEP 3149 stable ABI tag, expecting pattern `^abi(?P[0-9]+)$`' - )): - mesonpy._tags.StableABITag('invalid') - - -@pytest.mark.parametrize( - ('value', 'implementation', 'version', 'additional', 'abi', 'python'), - [ - ('cpython-37-x86_64-linux-gnu', 'cpython', '37', ('x86_64', 'linux', 'gnu'), 'cp37', 'cp37'), - ('cpython-310-x86_64-linux-gnu', 'cpython', '310', ('x86_64', 'linux', 'gnu'), 'cp310', 'cp310'), - ('cpython-310', 'cpython', '310', (), 'cp310', 'cp310'), - ('cp311-win_amd64', 'cpython', '311', ('win_amd64', ), 'cp311', 'cp311'), - ('cpython-311-win_amd64', 'cpython', '311', ('win_amd64', ), 'cp311', 'cp311'), - ('cpython-310-special', 'cpython', '310', ('special',), 'cp310', 'cp310'), - ('cpython-310-x86_64-linux-gnu', 'cpython', '310', ('x86_64', 'linux', 'gnu'), 'cp310', 'cp310'), - ('pypy39-pp73-x86_64-linux-gnu', 'pypy39', 'pp73', ('x86_64', 'linux', 'gnu'), 'pypy39_pp73', 'pp39'), - ('pypy39-pp73-win_amd64', 'pypy39', 'pp73', ('win_amd64', ), 'pypy39_pp73', 'pp39'), - ('pypy38-pp73-darwin', 'pypy38', 'pp73', ('darwin', ), 'pypy38_pp73', 'pp38'), - ] -) -def test_interpreter_tag(value, implementation, version, additional, abi, python): - tag = mesonpy._tags.InterpreterTag(value) - if not value.startswith('cp311'): - # Avoid testing the workaround for the invalid Windows tag - assert str(tag) == value - - assert tag.implementation == implementation - assert tag.interpreter_version == version - assert tag.additional_information == additional - assert tag.abi == abi - assert tag.python == python - assert tag == mesonpy._tags.InterpreterTag(value) - - -@pytest.mark.parametrize( - ('value', 'msg'), - [ - ('', 'Invalid PEP 3149 interpreter tag, expected at least 2 parts but got 1'), - ('invalid', 'Invalid PEP 3149 interpreter tag, expected at least 2 parts but got 1'), - ] -) -def test_interpreter_tag_invalid(value, msg): - with pytest.raises(ValueError, match=msg): - mesonpy._tags.InterpreterTag(value) + monkeypatch.setenv('MACOS_BUILD_TARGET', '10.9') + builder = wheel_builder_test_factory(monkeypatch, { + 'platlib': [f'extension{SUFFIX}'], + }) + assert builder.tag.platform == re.sub(r'\d+\.\d+', '10.9', PLATFORM) diff --git a/tests/test_wheel.py b/tests/test_wheel.py index e0c7c667f..dcbec60e7 100644 --- a/tests/test_wheel.py +++ b/tests/test_wheel.py @@ -8,44 +8,23 @@ import sysconfig import textwrap +import packaging.tags import pytest import wheel.wheelfile -import mesonpy._elf +import mesonpy + +from .conftest import adjust_packaging_platform_tag EXT_SUFFIX = sysconfig.get_config_var('EXT_SUFFIX') INTERPRETER_VERSION = f'{sys.version_info[0]}{sys.version_info[1]}' - -if platform.python_implementation() == 'CPython': - INTERPRETER_TAG = f'cp{INTERPRETER_VERSION}' - PYTHON_TAG = INTERPRETER_TAG - # Py_UNICODE_SIZE has been a runtime option since Python 3.3, - # so the u suffix no longer exists - if sysconfig.get_config_var('Py_DEBUG'): - INTERPRETER_TAG += 'd' - # https://github.com/pypa/packaging/blob/5984e3b25f4fdee64aad20e98668c402f7ed5041/packaging/tags.py#L147-L150 - if sys.version_info < (3, 8): - pymalloc = sysconfig.get_config_var('WITH_PYMALLOC') - if pymalloc or pymalloc is None: # none is the default value, which is enable - INTERPRETER_TAG += 'm' -elif platform.python_implementation() == 'PyPy': - INTERPRETER_TAG = sysconfig.get_config_var('SOABI').replace('-', '_') - PYTHON_TAG = f'pp{INTERPRETER_VERSION}' -else: - raise NotImplementedError(f'Unknown implementation: {platform.python_implementation()}') - -platform_ = sysconfig.get_platform() -if platform.system() == 'Darwin': - parts = platform_.split('-') - parts[1] = platform.mac_ver()[0] - if parts[1] >= '11': - parts[1] = parts[1].split('.')[0] - if parts[1] in ('11', '12'): - parts[1] += '.0' - platform_ = '-'.join(parts) -PLATFORM_TAG = platform_.replace('-', '_').replace('.', '_') +# Test against the wheel tag generated by packaging module. +tag = next(packaging.tags.sys_tags()) +ABI = tag.abi +INTERPRETER = tag.interpreter +PLATFORM = adjust_packaging_platform_tag(tag.platform) if platform.system() == 'Linux': SHARED_LIB_EXT = 'so' @@ -72,7 +51,6 @@ def wheel_filename(artifact): win_py37 = os.name == 'nt' and sys.version_info < (3, 8) -@pytest.mark.skipif(win_py37, reason='An issue with missing file extension') def test_scipy_like(wheel_scipy_like): # This test is meant to exercise features commonly needed by a regular # Python package for scientific computing or data science: @@ -104,9 +82,9 @@ def test_scipy_like(wheel_scipy_like): assert wheel_contents(artifact) == expecting name = artifact.parsed_filename - assert name.group('pyver') == PYTHON_TAG - assert name.group('abi') == INTERPRETER_TAG - assert name.group('plat') == PLATFORM_TAG + assert name.group('pyver') == INTERPRETER + assert name.group('abi') == ABI + assert name.group('plat') == PLATFORM # Extra checks to doubly-ensure that there are no issues with erroneously # considering a package with an extension module as pure @@ -130,7 +108,6 @@ def test_contents(package_library, wheel_library): assert re.match(regex, name), f'`{name}` does not match `{regex}`' -@pytest.mark.skipif(win_py37, reason='An issue with missing file extension') def test_purelib_and_platlib(wheel_purelib_and_platlib): artifact = wheel.wheelfile.WheelFile(wheel_purelib_and_platlib) @@ -206,19 +183,18 @@ def test_executable_bit(wheel_executable_bit): assert not executable_bit, f'{info.filename} should not have the executable bit set!' -@pytest.mark.skipif(win_py37, reason='An issue with missing file extension') def test_detect_wheel_tag_module(wheel_purelib_and_platlib): name = wheel.wheelfile.WheelFile(wheel_purelib_and_platlib).parsed_filename - assert name.group('pyver') == PYTHON_TAG - assert name.group('abi') == INTERPRETER_TAG - assert name.group('plat') == PLATFORM_TAG + assert name.group('pyver') == INTERPRETER + assert name.group('abi') == ABI + assert name.group('plat') == PLATFORM def test_detect_wheel_tag_script(wheel_executable): name = wheel.wheelfile.WheelFile(wheel_executable).parsed_filename assert name.group('pyver') == 'py3' assert name.group('abi') == 'none' - assert name.group('plat') == PLATFORM_TAG + assert name.group('plat') == PLATFORM @pytest.mark.skipif(platform.system() != 'Linux', reason='Unsupported on this platform for now')