diff --git a/newsfragments/5193.feature.rst b/newsfragments/5193.feature.rst new file mode 100644 index 0000000000..bec9a7613e --- /dev/null +++ b/newsfragments/5193.feature.rst @@ -0,0 +1,6 @@ +Added support for building abi3t extensions and abi3.abi3t wheels on Python 3.15 +and newer. See `PEP 803 `_ and the Python 3.15 `"What's New" entry +`_ for more details. + +.. _PEP803: https://peps.python.org/pep-0803/ +.. _whatsnew: https://docs.python.org/3.15/whatsnew/3.15.html#pep-803-abi3t-stable-abi-for-free-threaded-builds diff --git a/setuptools/command/bdist_wheel.py b/setuptools/command/bdist_wheel.py index 3bdfa0b35a..ebde40532a 100644 --- a/setuptools/command/bdist_wheel.py +++ b/setuptools/command/bdist_wheel.py @@ -16,6 +16,7 @@ from collections.abc import Iterable, Sequence from email.generator import BytesGenerator from glob import iglob +from itertools import chain from typing import Literal, cast from zipfile import ZIP_DEFLATED, ZIP_STORED @@ -30,6 +31,8 @@ from distutils import log +flatten = chain.from_iterable + def safe_version(version: str) -> str: """ @@ -132,6 +135,18 @@ def safer_version(version: str) -> str: return safe_version(version).replace("-", "_") +def stable_abi_tag(impl_name, impl_version): + abi_tag = None + if impl_name == "cp" and impl_version[0] == '3': + if sysconfig.get_config_var("Py_GIL_DISABLED"): + # per PEP 803 these are possible on older Python versions + # but in practice these builds need cp315 or newer + abi_tag = "abi3.abi3t" + else: + abi_tag = "abi3" + return abi_tag + + class bdist_wheel(Command): description = "create a wheel distribution" @@ -192,7 +207,7 @@ class bdist_wheel(Command): ( "py-limited-api=", None, - "Python tag (cp32|cp33|cpNN) for abi3 wheel tag [default: false]", + "Python tag (cp32|cp33|cpNN) for abi3 or abi3t ABI [default: false]", ), ( "dist-info-dir=", @@ -281,11 +296,11 @@ def _validate_py_limited_api(self) -> None: if not re.match(PY_LIMITED_API_PATTERN, self.py_limited_api): raise ValueError(f"py-limited-api must match '{PY_LIMITED_API_PATTERN}'") - if sysconfig.get_config_var("Py_GIL_DISABLED"): + if sysconfig.get_config_var("Py_GIL_DISABLED") and sys.version_info < (3, 15): raise ValueError( f"`py_limited_api={self.py_limited_api!r}` not supported. " - "`Py_LIMITED_API` is currently incompatible with " - "`Py_GIL_DISABLED`. " + "`Py_LIMITED_API` is incompatible with `Py_GIL_DISABLED` " + "on Python 3.14 and older. " "See https://github.com/python/cpython/issues/111506." ) @@ -300,6 +315,18 @@ def wheel_dist_name(self) -> str: components.append(self.build_number) return "-".join(components) + @property + def abi_tag(self) -> str: + impl_name = tags.interpreter_name() + impl_ver = tags.interpreter_version() + tag = None + if self.py_limited_api: + tag = stable_abi_tag(impl_name, impl_ver) + if tag is None: + # not a stable ABI build, use version-specific ABI tag + tag = str(get_abi_tag()).lower() + return tag + def get_tag(self) -> tuple[str, str, str]: # bdist sets self.plat_name if unset, we should only use it for purepy # wheels if the user supplied it. @@ -342,18 +369,20 @@ def get_tag(self) -> tuple[str, str, str]: impl_name = tags.interpreter_name() impl_ver = tags.interpreter_version() impl = impl_name + impl_ver - # We don't work on CPython 3.1, 3.0. - if self.py_limited_api and (impl_name + impl_ver).startswith("cp3"): + abi_tag = self.abi_tag + if "abi3" in abi_tag: + assert self.py_limited_api is not False impl = self.py_limited_api - abi_tag = "abi3" - else: - abi_tag = str(get_abi_tag()).lower() tag = (impl, abi_tag, plat_name) + possible_tags = tags.parse_tag("-".join(tag)) # issue gh-374: allow overriding plat_name - supported_tags = [ - (t.interpreter, t.abi, plat_name) for t in tags.sys_tags() - ] - assert tag in supported_tags, ( + sys_tags = ( + "-".join((t.interpreter, t.abi, plat_name)) for t in tags.sys_tags() + ) + supported_tags = flatten(tags.parse_tag(t) for t in sys_tags) + # abi_tag can contain multiple (e.g. "abi3.abi3t") tags + # only one of them will be supported + assert any(t in supported_tags for t in possible_tags), ( f"would build wheel with unsupported tag {tag}" ) return tag diff --git a/setuptools/tests/test_bdist_wheel.py b/setuptools/tests/test_bdist_wheel.py index 68cc0c4d36..664e7d4bb9 100644 --- a/setuptools/tests/test_bdist_wheel.py +++ b/setuptools/tests/test_bdist_wheel.py @@ -18,7 +18,7 @@ from packaging import tags import setuptools -from setuptools.command.bdist_wheel import bdist_wheel, get_abi_tag +from setuptools.command.bdist_wheel import bdist_wheel, get_abi_tag, stable_abi_tag from setuptools.dist import Distribution from setuptools.warnings import SetuptoolsDeprecationWarning @@ -408,6 +408,7 @@ def test_universal_deprecated(dummy_dist, monkeypatch, tmp_path): EXTENSION_EXAMPLE = """\ +#define Py_LIMITED_API 0x03020000 #include static PyMethodDef methods[] = { @@ -435,7 +436,13 @@ def test_universal_deprecated(dummy_dist, monkeypatch, tmp_path): name="extension.dist", version="0.1", description="A testing distribution \N{SNOWMAN}", - ext_modules=[Extension(name="extension", sources=["extension.c"])], + ext_modules=[ + Extension( + name="extension", + sources=["extension.c"], + py_limited_api=True + ) + ], ) """ @@ -443,7 +450,7 @@ def test_universal_deprecated(dummy_dist, monkeypatch, tmp_path): @pytest.mark.filterwarnings( "once:Config variable '.*' is unset.*, Python ABI tag may be incorrect" ) -def test_limited_abi(monkeypatch, tmp_path, tmp_path_factory): +def test_limited_api(monkeypatch, tmp_path, tmp_path_factory): """Test that building a binary wheel with the limited ABI works.""" source_dir = tmp_path_factory.mktemp("extension_dist") (source_dir / "setup.py").write_text(EXTENSION_SETUPPY, encoding="utf-8") @@ -451,7 +458,9 @@ def test_limited_abi(monkeypatch, tmp_path, tmp_path_factory): build_dir = tmp_path.joinpath("build") dist_dir = tmp_path.joinpath("dist") monkeypatch.chdir(source_dir) - bdist_wheel_cmd(bdist_dir=str(build_dir), dist_dir=str(dist_dir)).run() + bdist_wheel_cmd( + bdist_dir=str(build_dir), dist_dir=str(dist_dir), py_limited_api="cp32" + ).run() def test_build_from_readonly_tree(dummy_dist, monkeypatch, tmp_path): @@ -538,6 +547,13 @@ def test_get_abi_tag_fallback(monkeypatch): assert get_abi_tag() == "unknown_python_310" +def test_stable_abi_tag(monkeypatch): + monkeypatch.setattr(sysconfig, "get_config_var", lambda x: 0) + assert stable_abi_tag("cp", "315") == "abi3" + monkeypatch.setattr(sysconfig, "get_config_var", lambda x: '1') + assert stable_abi_tag("cp", "315") == "abi3.abi3t" + + def test_platform_with_space(dummy_dist, monkeypatch): """Ensure building on platforms with a space in the name succeed.""" monkeypatch.chdir(dummy_dist) diff --git a/setuptools/tests/test_build_ext.py b/setuptools/tests/test_build_ext.py index c7b60ac32f..29c5e5e05d 100644 --- a/setuptools/tests/test_build_ext.py +++ b/setuptools/tests/test_build_ext.py @@ -7,6 +7,7 @@ import pytest from jaraco import path +from setuptools.command import build_ext as build_ext_mod from setuptools.command.build_ext import build_ext, get_abi3_suffix from setuptools.dist import Distribution from setuptools.errors import CompileError @@ -19,6 +20,10 @@ from distutils.sysconfig import get_config_var IS_PYPY = '__pypy__' in sys.builtin_module_names +# from a Mac running Python 3.14 +ABI3_EXT_SUFFIXES = ['.cpython-314-darwin.so', '.abi3.so', '.so'] +# from a Mac running Python 3.15t +ABI3T_EXT_SUFFIXES = ['.cpython-315t-darwin.so', '.abi3t.so', '.so'] class TestBuildExt: @@ -35,11 +40,7 @@ def test_get_ext_filename(self): wanted = orig.build_ext.get_ext_filename(cmd, 'foo') assert res == wanted - def test_abi3_filename(self): - """ - Filename needs to be loadable by several versions - of Python 3 if 'is_abi3' is truthy on Extension() - """ + def check_stable_abi(self, abi_name): print(get_abi3_suffix()) extension = Extension('spam.eggs', ['eggs.c'], py_limited_api=True) @@ -54,7 +55,23 @@ def test_abi3_filename(self): elif sys.platform == 'win32': assert res.endswith('eggs.pyd') else: - assert 'abi3' in res + assert abi_name in res + + @pytest.mark.parametrize( + ('extension_name', 'suffixes'), + [ + ("abi3", ABI3_EXT_SUFFIXES), + ("abi3t", ABI3T_EXT_SUFFIXES), + ], + ) + def test_stable_abi_filename(self, monkeypatch, extension_name, suffixes): + """ + Test that extension filename is correct if 'py_limited_abi' is + truthy on Extension() + """ + if sys.platform != 'win32': + monkeypatch.setattr(build_ext_mod, "EXTENSION_SUFFIXES", suffixes) + self.check_stable_abi(extension_name) def test_ext_suffix_override(self): """