Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 = list(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