Skip to content

Commit 83e2bc0

Browse files
dnicolodirgommers
authored andcommitted
ENH: fix support for Python limited API / stable ABI wheels
Meson gained support for building Python extension modules targeting the Python limited API, therefore it is time for meson-python to properly support tagging the build wheels as targeting the stable ABI. Unfortunately there isn't a reliable and cross-platform way to detect when extension modules are build for the limited API. Therefore, we need to add an explicit "limited-api" configuration option in the [tool.meson-python] section in pyproject.toml.
1 parent 46d4b35 commit 83e2bc0

File tree

8 files changed

+171
-40
lines changed

8 files changed

+171
-40
lines changed

docs/reference/pyproject-settings.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,19 @@ This page lists the configuration settings supported by
1313
:ref:`how-to-guides-meson-args` guide for for information on how to
1414
use them and examples.
1515

16+
.. option:: tool.meson-python.limited-api
17+
18+
A boolean indicating whether the extension modules contained in the
19+
Python package target the `Python limited API`__. Extension
20+
modules can be compiled for the Python limited API specifying the
21+
``limited_api`` argument to the |extension_module()|__ function
22+
in the Meson Python module. When this setting is set to true, the
23+
value ``abi3`` is used for the Python wheel filename ABI tag.
24+
25+
This setting is automatically reverted to false when the
26+
``-Dpython.allow_limited_api=false`` option is passed to ``meson
27+
setup``.
28+
1629
.. option:: tool.meson-python.args.dist
1730

1831
Extra arguments to be passed to the ``meson dist`` command.
@@ -28,3 +41,9 @@ use them and examples.
2841
.. option:: tool.meson-python.args.install
2942

3043
Extra arguments to be passed to the ``meson install`` command.
44+
45+
46+
__ https://docs.python.org/3/c-api/stable.html?highlight=limited%20api#stable-application-binary-interface
47+
__ https://mesonbuild.com/Python-module.html#extension_module
48+
49+
.. |extension_module()| replace:: ``extension_module()``

mesonpy/__init__.py

Lines changed: 33 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,8 @@ def _init_colors() -> Dict[str, str]:
128128

129129

130130
_SUFFIXES = importlib.machinery.all_suffixes()
131-
_EXTENSION_SUFFIXES = importlib.machinery.EXTENSION_SUFFIXES
132-
_EXTENSION_SUFFIX_REGEX = re.compile(r'^\.(?:(?P<abi>[^.]+)\.)?(?:so|pyd|dll)$')
133-
assert all(re.match(_EXTENSION_SUFFIX_REGEX, x) for x in _EXTENSION_SUFFIXES)
131+
_EXTENSION_SUFFIX_REGEX = re.compile(r'^[^.]+\.(?:(?P<abi>[^.]+)\.)?(?:so|pyd|dll)$')
132+
assert all(re.match(_EXTENSION_SUFFIX_REGEX, f'foo{x}') for x in importlib.machinery.EXTENSION_SUFFIXES)
134133

135134

136135
# Map Meson installation path placeholders to wheel installation paths.
@@ -344,34 +343,20 @@ def entrypoints_txt(self) -> bytes:
344343

345344
@cached_property
346345
def _stable_abi(self) -> Optional[str]:
347-
"""Determine stabe ABI compatibility.
348-
349-
Examine all files installed in {platlib} that look like
350-
extension modules (extension .pyd on Windows, .dll on Cygwin,
351-
and .so on other platforms) and, if they all share the same
352-
PEP 3149 filename stable ABI tag, return it.
353-
354-
Other files are ignored.
355-
356-
"""
357-
soext = sorted(_EXTENSION_SUFFIXES, key=len)[0]
358-
abis = []
359-
360-
for path, _ in self._wheel_files['platlib']:
361-
# NOTE: When searching for shared objects files, we assume the host
362-
# and build machines have the same soext, even though that we might
363-
# be cross compiling.
364-
if path.suffix == soext:
365-
match = re.match(r'^[^.]+(.*)$', path.name)
366-
assert match is not None
367-
suffix = match.group(1)
368-
match = _EXTENSION_SUFFIX_REGEX.match(suffix)
346+
if self._project._limited_api:
347+
# Verify stabe ABI compatibility: examine files installed
348+
# in {platlib} that look like extension modules, and raise
349+
# an exception if any of them has a Python version
350+
# specific extension filename suffix ABI tag.
351+
for path, _ in self._wheel_files['platlib']:
352+
match = _EXTENSION_SUFFIX_REGEX.match(path.name)
369353
if match:
370-
abis.append(match.group('abi'))
371-
372-
stable = [x for x in abis if x and re.match(r'abi\d+', x)]
373-
if len(stable) > 0 and len(stable) == len(abis) and all(x == stable[0] for x in stable[1:]):
374-
return stable[0]
354+
abi = match.group('abi')
355+
if abi is not None and abi != 'abi3':
356+
raise BuildError(
357+
f'The package declares compatibility with Python limited API but extension '
358+
f'module {os.fspath(path)!r} is tagged for a specific Python version.')
359+
return 'abi3'
375360
return None
376361

377362
@property
@@ -576,10 +561,16 @@ def _strings(value: Any, name: str) -> List[str]:
576561
raise ConfigError(f'Configuration entry "{name}" must be a list of strings')
577562
return value
578563

564+
def _bool(value: Any, name: str) -> bool:
565+
if not isinstance(value, bool):
566+
raise ConfigError(f'Configuration entry "{name}" must be a boolean')
567+
return value
568+
579569
scheme = _table({
570+
'limited-api': _bool,
580571
'args': _table({
581572
name: _strings for name in _MESON_ARGS_KEYS
582-
})
573+
}),
583574
})
584575

585576
table = pyproject.get('tool', {}).get('meson-python', {})
@@ -632,7 +623,7 @@ class Project():
632623
]
633624
_metadata: pyproject_metadata.StandardMetadata
634625

635-
def __init__(
626+
def __init__( # noqa: C901
636627
self,
637628
source_dir: Path,
638629
working_dir: Path,
@@ -648,6 +639,7 @@ def __init__(
648639
self._meson_native_file = self._build_dir / 'meson-python-native-file.ini'
649640
self._meson_cross_file = self._build_dir / 'meson-python-cross-file.ini'
650641
self._meson_args: MesonArgs = collections.defaultdict(list)
642+
self._limited_api = False
651643

652644
_check_meson_version()
653645

@@ -730,6 +722,15 @@ def __init__(
730722
if 'version' in self._metadata.dynamic:
731723
self._metadata.version = packaging.version.Version(self._meson_version)
732724

725+
# limited API
726+
self._limited_api = pyproject_config.get('limited-api', False)
727+
if self._limited_api:
728+
# check whether limited API is disabled for the Meson project
729+
options = self._info('intro-buildoptions')
730+
value = next((option['value'] for option in options if option['name'] == 'python.allow_limited_api'), None)
731+
if not value:
732+
self._limited_api = False
733+
733734
def _run(self, cmd: Sequence[str]) -> None:
734735
"""Invoke a subprocess."""
735736
# Flush the line to ensure that the log line with the executed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# SPDX-FileCopyrightText: 2023 The meson-python developers
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
project('limited-api', 'c', version: '1.0.0')
6+
7+
py = import('python').find_installation(pure: false)
8+
9+
py.extension_module(
10+
'module',
11+
'module.c',
12+
limited_api: '3.7',
13+
install: true,
14+
)
15+
16+
if get_option('extra')
17+
py.extension_module(
18+
'extra',
19+
'module.c',
20+
install: true,
21+
)
22+
endif
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# SPDX-FileCopyrightText: 2023 The meson-python developers
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
option('extra', type: 'boolean', value: false)

tests/packages/limited-api/module.c

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// SPDX-FileCopyrightText: 2023 The meson-python developers
2+
//
3+
// SPDX-License-Identifier: MIT
4+
5+
#include <Python.h>
6+
7+
static PyObject* add(PyObject *self, PyObject *args) {
8+
int a, b;
9+
10+
if (!PyArg_ParseTuple(args, "ii", &a, &b))
11+
return NULL;
12+
13+
return PyLong_FromLong(a + b);
14+
}
15+
16+
static PyMethodDef methods[] = {
17+
{"add", add, METH_VARARGS, NULL},
18+
{NULL, NULL, 0, NULL},
19+
};
20+
21+
static struct PyModuleDef module = {
22+
PyModuleDef_HEAD_INIT,
23+
"plat",
24+
NULL,
25+
-1,
26+
methods,
27+
};
28+
29+
PyMODINIT_FUNC PyInit_module(void) {
30+
return PyModule_Create(&module);
31+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# SPDX-FileCopyrightText: 2023 The meson-python developers
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
[build-system]
6+
build-backend = 'mesonpy'
7+
requires = ['meson-python']
8+
9+
[tool.meson-python]
10+
limited-api = true

tests/test_tags.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
#
33
# SPDX-License-Identifier: MIT
44

5+
import importlib.machinery
56
import os
67
import pathlib
78
import platform
9+
import sys
810
import sysconfig
911

1012
from collections import defaultdict
@@ -24,8 +26,17 @@
2426
INTERPRETER = tag.interpreter
2527
PLATFORM = adjust_packaging_platform_tag(tag.platform)
2628

29+
30+
def get_abi3_suffix():
31+
for suffix in importlib.machinery.EXTENSION_SUFFIXES:
32+
if '.abi3' in suffix: # Unix
33+
return suffix
34+
elif suffix == '.pyd': # Windows
35+
return suffix
36+
37+
2738
SUFFIX = sysconfig.get_config_var('EXT_SUFFIX')
28-
ABI3SUFFIX = next((x for x in mesonpy._EXTENSION_SUFFIXES if '.abi3.' in x), None)
39+
ABI3SUFFIX = get_abi3_suffix()
2940

3041

3142
def test_wheel_tag():
@@ -52,11 +63,13 @@ def test_python_host_platform(monkeypatch):
5263
assert mesonpy._tags.get_platform_tag().endswith('x86_64')
5364

5465

55-
def wheel_builder_test_factory(monkeypatch, content):
66+
def wheel_builder_test_factory(monkeypatch, content, limited_api=False):
5667
files = defaultdict(list)
5768
files.update({key: [(pathlib.Path(x), os.path.join('build', x)) for x in value] for key, value in content.items()})
5869
monkeypatch.setattr(mesonpy._WheelBuilder, '_wheel_files', files)
59-
return mesonpy._WheelBuilder(None, None, pathlib.Path(), pathlib.Path(), pathlib.Path())
70+
class Project:
71+
_limited_api = limited_api
72+
return mesonpy._WheelBuilder(Project(), None, pathlib.Path(), pathlib.Path(), pathlib.Path())
6073

6174

6275
def test_tag_empty_wheel(monkeypatch):
@@ -78,17 +91,18 @@ def test_tag_platlib_wheel(monkeypatch):
7891
assert str(builder.tag) == f'{INTERPRETER}-{ABI}-{PLATFORM}'
7992

8093

81-
@pytest.mark.skipif(not ABI3SUFFIX, reason='Stable ABI not supported by Python interpreter')
8294
def test_tag_stable_abi(monkeypatch):
8395
builder = wheel_builder_test_factory(monkeypatch, {
8496
'platlib': [f'extension{ABI3SUFFIX}'],
85-
})
97+
}, True)
8698
assert str(builder.tag) == f'{INTERPRETER}-abi3-{PLATFORM}'
8799

88100

89-
@pytest.mark.skipif(not ABI3SUFFIX, reason='Stable ABI not supported by Python interpreter')
101+
@pytest.mark.skipif(sys.version_info < (3, 8) and platform.system() == 'Windows',
102+
reason='Extension modules filename suffix without ABI tags')
90103
def test_tag_mixed_abi(monkeypatch):
91104
builder = wheel_builder_test_factory(monkeypatch, {
92105
'platlib': [f'extension{ABI3SUFFIX}', f'another{SUFFIX}'],
93-
})
94-
assert str(builder.tag) == f'{INTERPRETER}-{ABI}-{PLATFORM}'
106+
}, True)
107+
with pytest.raises(mesonpy.BuildError, match='The package declares compatibility with Python limited API but '):
108+
assert str(builder.tag) == f'{INTERPRETER}-abi3-{PLATFORM}'

tests/test_wheel.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,3 +282,32 @@ def test_skip_subprojects(package_subproject, tmp_path, arg):
282282
'subproject-1.0.0.dist-info/WHEEL',
283283
'subproject.py',
284284
}
285+
286+
287+
# Requires Meson 1.3.0, see https://github.com/mesonbuild/meson/pull/11745.
288+
@pytest.mark.skipif(MESON_VERSION < (1, 2, 99), reason='Meson version too old')
289+
def test_limited_api(wheel_limited_api):
290+
artifact = wheel.wheelfile.WheelFile(wheel_limited_api)
291+
name = artifact.parsed_filename
292+
assert name.group('pyver') == INTERPRETER
293+
assert name.group('abi') == 'abi3'
294+
assert name.group('plat') == PLATFORM
295+
296+
297+
# Requires Meson 1.3.0, see https://github.com/mesonbuild/meson/pull/11745.
298+
@pytest.mark.skipif(MESON_VERSION < (1, 2, 99), reason='Meson version too old')
299+
def test_limited_api_bad(package_limited_api, tmp_path):
300+
with pytest.raises(mesonpy.BuildError, match='The package declares compatibility with Python limited API but '):
301+
with mesonpy.Project.with_temp_working_dir(meson_args={'setup': ['-Dextra=true']}) as project:
302+
project.wheel(tmp_path)
303+
304+
305+
# Requires Meson 1.3.0, see https://github.com/mesonbuild/meson/pull/11745.
306+
@pytest.mark.skipif(MESON_VERSION < (1, 2, 99), reason='Meson version too old')
307+
def test_limited_api_disabled(package_limited_api, tmp_path):
308+
filename = mesonpy.build_wheel(tmp_path, {'setup-args': ['-Dpython.allow_limited_api=false']})
309+
artifact = wheel.wheelfile.WheelFile(tmp_path / filename)
310+
name = artifact.parsed_filename
311+
assert name.group('pyver') == INTERPRETER
312+
assert name.group('abi') == ABI
313+
assert name.group('plat') == PLATFORM

0 commit comments

Comments
 (0)