Skip to content

Commit 854e35f

Browse files
committed
ENH: determine wheel tags by Python interpreter introspection
The extension modules filename suffixes do not contain enough information to correctly determine the wheel tags. Instead introspect the Python interpreter to derive the wheel tags. This is the same approach used by other PEP517 backends, most notably wheel. The wheel contents only to determine whether the wheel contains python ABI dependent modules or other platform dependent code. The packaging module is the reference wheel tags derivation implementation and it is used (or vendored) by most python packages dealing with wheels. However, the API provided by packaging is cumbersome to use for our purposes and, with the goal of merging this code into Meson in the future, it is good to avoid an additional dependency. Therefore, the tags derivation code is reimplemented. Tests are added to verify that the tags produced by meson-python agree with the ones produced by packaging to ensure that the two implementations will not diverge. Fixes mesonbuild#142, fixes mesonbuild#189, fixes mesonbuild#190.
1 parent ef67e0e commit 854e35f

File tree

6 files changed

+318
-403
lines changed

6 files changed

+318
-403
lines changed

mesonpy/__init__.py

+57-166
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import collections
1414
import contextlib
1515
import functools
16+
import importlib.machinery
1617
import io
1718
import itertools
1819
import json
@@ -46,7 +47,7 @@
4647
import mesonpy._tags
4748
import mesonpy._util
4849

49-
from mesonpy._compat import Collection, Iterator, Mapping, Path
50+
from mesonpy._compat import Iterator, Path
5051

5152

5253
if typing.TYPE_CHECKING: # pragma: no cover
@@ -102,9 +103,9 @@ def _init_colors() -> Dict[str, str]:
102103
_STYLES = _init_colors() # holds the color values, should be _COLORS or _NO_COLORS
103104

104105

105-
_LINUX_NATIVE_MODULE_REGEX = re.compile(r'^(?P<name>.+)\.(?P<tag>.+)\.so$')
106-
_WINDOWS_NATIVE_MODULE_REGEX = re.compile(r'^(?P<name>.+)\.(?P<tag>.+)\.pyd$')
107-
_STABLE_ABI_TAG_REGEX = re.compile(r'^abi(?P<abi_number>[0-9]+)$')
106+
_EXTENSION_SUFFIXES = frozenset(importlib.machinery.EXTENSION_SUFFIXES)
107+
_EXTENSION_SUFFIX_REGEX = re.compile(r'^\.(?:(?P<abi>[^.]+)\.)?(?:so|pyd)$')
108+
assert all(re.match(_EXTENSION_SUFFIX_REGEX, x) for x in _EXTENSION_SUFFIXES)
108109

109110

110111
def _showwarning(
@@ -179,6 +180,11 @@ def _wheel_files(self) -> DefaultDict[str, List[Tuple[pathlib.Path, str]]]:
179180
def _has_internal_libs(self) -> bool:
180181
return bool(self._wheel_files['mesonpy-libs'])
181182

183+
@property
184+
def _has_extension_modules(self) -> bool:
185+
# Assume that all code installed in {platlib} is Python ABI dependent.
186+
return bool(self._wheel_files['platlib'])
187+
182188
@property
183189
def basename(self) -> str:
184190
"""Normalized wheel name and version (eg. meson_python-1.0.0)."""
@@ -187,14 +193,25 @@ def basename(self) -> str:
187193
version=self._project.version,
188194
)
189195

196+
@property
197+
def tag(self) -> mesonpy._tags.Tag:
198+
"""Wheel tags."""
199+
if self.is_pure:
200+
return mesonpy._tags.Tag('py3', 'none', 'any')
201+
if not self._has_extension_modules:
202+
# The wheel has platform dependent code (is not pure) but
203+
# does not contain any extension module (does not
204+
# distribute any file in {platlib}) thus use generic
205+
# implementation and ABI tags.
206+
return mesonpy._tags.Tag('py3', 'none', None)
207+
return mesonpy._tags.Tag(None, self._stable_abi, None)
208+
190209
@property
191210
def name(self) -> str:
192-
"""Wheel name, this includes the basename and tags."""
193-
return '{basename}-{python_tag}-{abi_tag}-{platform_tag}'.format(
211+
"""Wheel name, this includes the basename and tag."""
212+
return '{basename}-{tag}'.format(
194213
basename=self.basename,
195-
python_tag=self.python_tag,
196-
abi_tag=self.abi_tag,
197-
platform_tag=self.platform_tag,
214+
tag=self.tag,
198215
)
199216

200217
@property
@@ -226,10 +243,10 @@ def wheel(self) -> bytes: # noqa: F811
226243
Wheel-Version: 1.0
227244
Generator: meson
228245
Root-Is-Purelib: {is_purelib}
229-
Tag: {tags}
246+
Tag: {tag}
230247
''').strip().format(
231248
is_purelib='true' if self.is_pure else 'false',
232-
tags=f'{self.python_tag}-{self.abi_tag}-{self.platform_tag}',
249+
tag=self.tag,
233250
).encode()
234251

235252
@property
@@ -267,166 +284,40 @@ def _debian_python(self) -> bool:
267284
except ModuleNotFoundError:
268285
return False
269286

270-
@property
271-
def python_tag(self) -> str:
272-
selected_tag = self._select_abi_tag()
273-
if selected_tag and selected_tag.python:
274-
return selected_tag.python
275-
return 'py3'
276-
277-
@property
278-
def abi_tag(self) -> str:
279-
selected_tag = self._select_abi_tag()
280-
if selected_tag:
281-
return selected_tag.abi
282-
return 'none'
283-
284287
@cached_property
285-
def platform_tag(self) -> str:
286-
if self.is_pure:
287-
return 'any'
288-
# XXX: Choose the sysconfig platform here and let something like auditwheel
289-
# fix it later if there are system dependencies (eg. replace it with a manylinux tag)
290-
platform_ = sysconfig.get_platform()
291-
parts = platform_.split('-')
292-
if parts[0] == 'macosx':
293-
target = os.environ.get('MACOSX_DEPLOYMENT_TARGET')
294-
if target:
295-
print(
296-
'{yellow}MACOSX_DEPLOYMENT_TARGET is set so we are setting the '
297-
'platform tag to {target}{reset}'.format(target=target, **_STYLES)
298-
)
299-
parts[1] = target
300-
else:
301-
# If no target macOS version is specified fallback to
302-
# platform.mac_ver() instead of sysconfig.get_platform() as the
303-
# latter specifies the target macOS version Python was built
304-
# against.
305-
parts[1] = platform.mac_ver()[0]
306-
if parts[1] >= '11':
307-
# Only pick up the major version, which changed from 10.X
308-
# to X.0 from macOS 11 onwards. See
309-
# https://github.com/mesonbuild/meson-python/issues/160
310-
parts[1] = parts[1].split('.')[0]
311-
312-
if parts[1] in ('11', '12'):
313-
# Workaround for bug where pypa/packaging does not consider macOS
314-
# tags without minor versions valid. Some Python flavors (Homebrew
315-
# for example) on macOS started to do this in version 11, and
316-
# pypa/packaging should handle things correctly from version 13 and
317-
# forward, so we will add a 0 minor version to MacOS 11 and 12.
318-
# https://github.com/mesonbuild/meson-python/issues/91
319-
# https://github.com/pypa/packaging/issues/578
320-
parts[1] += '.0'
321-
322-
platform_ = '-'.join(parts)
323-
elif parts[0] == 'linux' and parts[1] == 'x86_64' and sys.maxsize == 0x7fffffff:
324-
# 32-bit Python running on an x86_64 host
325-
# https://github.com/mesonbuild/meson-python/issues/123
326-
parts[1] = 'i686'
327-
platform_ = '-'.join(parts)
328-
return platform_.replace('-', '_').replace('.', '_')
329-
330-
def _calculate_file_abi_tag_heuristic_windows(self, filename: str) -> Optional[mesonpy._tags.Tag]:
331-
"""Try to calculate the Windows tag from the Python extension file name."""
332-
match = _WINDOWS_NATIVE_MODULE_REGEX.match(filename)
333-
if not match:
334-
return None
335-
tag = match.group('tag')
288+
def _stable_abi(self) -> Optional[str]:
289+
"""Determine stabe ABI compatibility.
336290
337-
try:
338-
return mesonpy._tags.StableABITag(tag)
339-
except ValueError:
340-
return mesonpy._tags.InterpreterTag(tag)
341-
342-
def _calculate_file_abi_tag_heuristic_posix(self, filename: str) -> Optional[mesonpy._tags.Tag]:
343-
"""Try to calculate the Posix tag from the Python extension file name."""
344-
# sysconfig is not guaranted to export SHLIB_SUFFIX but let's be
345-
# preventive and check its value to make sure it matches our expectations
346-
try:
347-
extension = sysconfig.get_config_vars().get('SHLIB_SUFFIX', '.so')
348-
if extension != '.so':
349-
raise NotImplementedError(
350-
f"We don't currently support the {extension} extension. "
351-
'Please report this to https://github.com/mesonbuild/mesonpy/issues '
352-
'and include information about your operating system.'
353-
)
354-
except KeyError:
355-
warnings.warn(
356-
'sysconfig does not export SHLIB_SUFFIX, so we are unable to '
357-
'perform the sanity check regarding the extension suffix. '
358-
'Please report this to https://github.com/mesonbuild/mesonpy/issues '
359-
'and include the output of `python -m sysconfig`.'
360-
)
361-
match = _LINUX_NATIVE_MODULE_REGEX.match(filename)
362-
if not match: # this file does not appear to be a native module
363-
return None
364-
tag = match.group('tag')
291+
Examine all files installed in {platlib} that look like
292+
extension modules (extension .pyd on Windows and .so on other
293+
platforms) and, if they all share the same PEP 3149 filename
294+
stable ABI tag, return it.
365295
366-
try:
367-
return mesonpy._tags.StableABITag(tag)
368-
except ValueError:
369-
return mesonpy._tags.InterpreterTag(tag)
370-
371-
def _calculate_file_abi_tag_heuristic(self, filename: str) -> Optional[mesonpy._tags.Tag]:
372-
"""Try to calculate the ABI tag from the Python extension file name."""
373-
if os.name == 'nt':
374-
return self._calculate_file_abi_tag_heuristic_windows(filename)
375-
# everything else *should* follow the POSIX way, at least to my knowledge
376-
return self._calculate_file_abi_tag_heuristic_posix(filename)
377-
378-
def _file_list_repr(self, files: Collection[str], prefix: str = '\t\t', max_count: int = 3) -> str:
379-
if len(files) > max_count:
380-
files = list(itertools.islice(files, max_count)) + [f'(... +{len(files)}))']
381-
return ''.join(f'{prefix}- {file}\n' for file in files)
382-
383-
def _files_by_tag(self) -> Mapping[mesonpy._tags.Tag, Collection[str]]:
384-
"""Map files into ABI tags."""
385-
files_by_tag: Dict[mesonpy._tags.Tag, List[str]] = collections.defaultdict(list)
386-
387-
for _, file in self._wheel_files['platlib']:
388-
# if in platlib, calculate the ABI tag
389-
tag = self._calculate_file_abi_tag_heuristic(file)
390-
if tag:
391-
files_by_tag[tag].append(file)
392-
393-
return files_by_tag
394-
395-
def _select_abi_tag(self) -> Optional[mesonpy._tags.Tag]: # noqa: C901
396-
"""Given a list of ABI tags, selects the most specific one.
397-
398-
Raises an error if there are incompatible tags.
296+
All files that look like extension modules are verified to
297+
have a file name compatibel with what is expected by the
298+
Python interpreter. An exception is raised otherwise.
299+
300+
Other files are ignored.
399301
"""
400-
# Possibilities:
401-
# - interpreter specific (cpython/pypy/etc, version)
402-
# - stable abi (abiX)
403-
tags = self._files_by_tag()
404-
selected_tag = None
405-
for tag, files in tags.items():
406-
# no selected tag yet, let's assign this one
407-
if not selected_tag:
408-
selected_tag = tag
409-
# interpreter tag
410-
elif isinstance(tag, mesonpy._tags.InterpreterTag):
411-
if tag != selected_tag:
412-
if isinstance(selected_tag, mesonpy._tags.InterpreterTag):
413-
raise ValueError(
414-
'Found files with incompatible ABI tags:\n'
415-
+ self._file_list_repr(tags[selected_tag])
416-
+ '\tand\n'
417-
+ self._file_list_repr(files)
418-
)
419-
selected_tag = tag
420-
# stable ABI
421-
elif isinstance(tag, mesonpy._tags.StableABITag):
422-
if isinstance(selected_tag, mesonpy._tags.StableABITag) and tag != selected_tag:
302+
abis = []
303+
304+
for path, src in self._wheel_files['platlib']:
305+
if os.name == 'nt' and path.suffix == '.pyd' or path.suffix == '.so':
306+
match = re.match(r'^[^.]+(.*)$', path.name)
307+
assert match is not None
308+
suffix = match.group(1)
309+
if suffix not in _EXTENSION_SUFFIXES:
423310
raise ValueError(
424-
'Found files with incompatible ABI tags:\n'
425-
+ self._file_list_repr(tags[selected_tag])
426-
+ '\tand\n'
427-
+ self._file_list_repr(files)
428-
)
429-
return selected_tag
311+
f'Extension module {str(path)!r} not compatible with Python interpreter. '
312+
f'Filename suffix {suffix!r} not in {set(_EXTENSION_SUFFIXES)}.')
313+
match = _EXTENSION_SUFFIX_REGEX.match(suffix)
314+
assert match is not None
315+
abis.append(match.group('abi'))
316+
317+
stable = [x for x in abis if x and re.match(r'abi\d+', x)]
318+
if len(stable) > 0 and len(stable) == len(abis) and all(x == stable[0] for x in stable[1:]):
319+
return stable[0]
320+
return None
430321

431322
def _is_native(self, file: Union[str, pathlib.Path]) -> bool:
432323
"""Check if file is a native file."""

mesonpy/_compat.py

+2-5
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,9 @@
1010

1111

1212
if sys.version_info >= (3, 9):
13-
from collections.abc import (
14-
Collection, Iterable, Iterator, Mapping, Sequence
15-
)
13+
from collections.abc import Collection, Iterable, Iterator, Sequence
1614
else:
17-
from typing import Collection, Iterable, Iterator, Mapping, Sequence
15+
from typing import Collection, Iterable, Iterator, Sequence
1816

1917

2018
if sys.version_info >= (3, 8):
@@ -41,7 +39,6 @@ def is_relative_to(path: pathlib.Path, other: Union[pathlib.Path, str]) -> bool:
4139
'Iterable',
4240
'Iterator',
4341
'Literal',
44-
'Mapping',
4542
'Path',
4643
'Sequence',
4744
]

0 commit comments

Comments
 (0)