Skip to content
Open
6 changes: 6 additions & 0 deletions newsfragments/5193.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Added support for building abi3t extensions and abi3.abi3t wheels on Python 3.15
and newer. See `PEP 803 <PEP803>`_ and the Python 3.15 `"What's New" entry
<whatsnew>`_ 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
55 changes: 42 additions & 13 deletions setuptools/command/bdist_wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -30,6 +31,8 @@

from distutils import log

flatten = chain.from_iterable


def safe_version(version: str) -> str:
"""
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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=",
Expand Down Expand Up @@ -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."
)

Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
24 changes: 20 additions & 4 deletions setuptools/tests/test_bdist_wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -408,6 +408,7 @@ def test_universal_deprecated(dummy_dist, monkeypatch, tmp_path):


EXTENSION_EXAMPLE = """\
#define Py_LIMITED_API 0x03020000
#include <Python.h>

static PyMethodDef methods[] = {
Expand Down Expand Up @@ -435,23 +436,31 @@ 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change looks correct and is a bug fix for the existing test, which didn't quite make sense before - I think given the test_limited_abi test name, it was intended to test the Limited API indeed.

)
],
)
"""


@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")
(source_dir / "extension.c").write_text(EXTENSION_EXAMPLE, encoding="utf-8")
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):
Expand Down Expand Up @@ -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)
Expand Down
29 changes: 23 additions & 6 deletions setuptools/tests/test_build_ext.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -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):
"""
Expand Down
Loading