diff --git a/cibuildwheel/platforms/linux.py b/cibuildwheel/platforms/linux.py
index 818ad8f08..635392a16 100644
--- a/cibuildwheel/platforms/linux.py
+++ b/cibuildwheel/platforms/linux.py
@@ -1,5 +1,6 @@
import contextlib
import dataclasses
+import shutil
import subprocess
import sys
import textwrap
@@ -18,7 +19,7 @@
from ..util import resources
from ..util.file import copy_test_sources
from ..util.helpers import prepare_command, unwrap
-from ..util.packaging import find_compatible_wheel
+from ..util.packaging import find_compatible_wheel, is_abi3_wheel, run_abi3audit
if TYPE_CHECKING:
from ..typing import PathOrStr
@@ -359,6 +360,17 @@ def build_in_container(
if repaired_wheel.name in {wheel.name for wheel in built_wheels}:
raise errors.AlreadyBuiltWheelError(repaired_wheel.name)
+ if is_abi3_wheel(repaired_wheel.name):
+ local_abi3audit_dir = local_identifier_tmp_dir / "abi3audit"
+ local_abi3audit_dir.mkdir(parents=True, exist_ok=True)
+ container.copy_out(repaired_wheel_dir, local_abi3audit_dir)
+ local_wheel = local_abi3audit_dir / repaired_wheel.name
+ run_abi3audit(local_wheel)
+ try:
+ run_abi3audit(local_wheel)
+ finally:
+ shutil.rmtree(local_abi3audit_dir, ignore_errors=True)
+
if build_options.test_command and build_options.test_selector(config.identifier):
log.step("Testing wheel...")
diff --git a/cibuildwheel/platforms/macos.py b/cibuildwheel/platforms/macos.py
index 137c68964..fa5e28029 100644
--- a/cibuildwheel/platforms/macos.py
+++ b/cibuildwheel/platforms/macos.py
@@ -32,7 +32,7 @@
move_file,
)
from ..util.helpers import prepare_command, unwrap
-from ..util.packaging import find_compatible_wheel, get_pip_version
+from ..util.packaging import find_compatible_wheel, get_pip_version, run_abi3audit
from ..venv import constraint_flags, find_uv, virtualenv
@@ -564,6 +564,8 @@ def build(options: Options, tmp_path: Path) -> None:
if repaired_wheel.name in {wheel.name for wheel in built_wheels}:
raise errors.AlreadyBuiltWheelError(repaired_wheel.name)
+ run_abi3audit(repaired_wheel)
+
log.step_end()
if build_options.test_command and build_options.test_selector(config.identifier):
diff --git a/cibuildwheel/platforms/windows.py b/cibuildwheel/platforms/windows.py
index d13d925bf..b8e4ae549 100644
--- a/cibuildwheel/platforms/windows.py
+++ b/cibuildwheel/platforms/windows.py
@@ -23,7 +23,7 @@
from ..util.cmd import call, shell
from ..util.file import CIBW_CACHE_PATH, copy_test_sources, download, extract_zip, move_file
from ..util.helpers import prepare_command, unwrap
-from ..util.packaging import find_compatible_wheel, get_pip_version
+from ..util.packaging import find_compatible_wheel, get_pip_version, run_abi3audit
from ..venv import constraint_flags, find_uv, virtualenv
@@ -549,6 +549,8 @@ def build(options: Options, tmp_path: Path) -> None:
if repaired_wheel.name in {wheel.name for wheel in built_wheels}:
raise errors.AlreadyBuiltWheelError(repaired_wheel.name)
+ run_abi3audit(repaired_wheel)
+
test_selected = options.globals.test_selector(config.identifier)
if test_selected and config.arch == "ARM64" != platform_module.machine():
log.warning(
diff --git a/cibuildwheel/util/packaging.py b/cibuildwheel/util/packaging.py
index c3d8c21dd..7f419bc0f 100644
--- a/cibuildwheel/util/packaging.py
+++ b/cibuildwheel/util/packaging.py
@@ -1,4 +1,6 @@
import shlex
+import subprocess
+import sys
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from pathlib import Path, PurePath
@@ -6,6 +8,7 @@
from packaging.utils import parse_wheel_filename
+from ..logger import log
from . import resources
from .cmd import call
from .helpers import parse_key_value_string, unwrap
@@ -178,3 +181,24 @@ def find_compatible_wheel(wheels: Sequence[T], identifier: str) -> T | None:
return wheel
return None
+
+
+def is_abi3_wheel(wheel_name: str) -> bool:
+ """Check if a wheel uses the abi3 stable ABI based on its filename."""
+ _, _, _, tags = parse_wheel_filename(wheel_name)
+ return any(tag.abi == "abi3" for tag in tags)
+
+
+def run_abi3audit(wheel_path: Path) -> None:
+ """Run abi3audit on the given wheel if it is an abi3 wheel.
+
+ Raises subprocess.CalledProcessError if abi3audit reports violations.
+ """
+ if not is_abi3_wheel(wheel_path.name):
+ return
+
+ log.step("Running abi3audit...")
+ subprocess.run(
+ [sys.executable, "-m", "abi3audit", "--strict", "--report", str(wheel_path)],
+ check=True,
+ )
diff --git a/docs/faq.md b/docs/faq.md
index 949ee717f..8d9e41d3c 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -33,7 +33,7 @@ The CPython Limited API is a subset of the Python C Extension API that's declare
To create a package that builds ABI3 wheels, you'll need to configure your build backend to compile libraries correctly create wheels with the right tags. [Check this repo](https://github.com/joerick/python-abi3-package-sample) for an example of how to do this with setuptools.
-You could also consider running [abi3audit](https://github.com/trailofbits/abi3audit) against the produced wheels in order to check for abi3 violations or inconsistencies. You can run it alongside the default in your [repair-wheel-command](options.md#repair-wheel-command).
+cibuildwheel automatically runs [abi3audit](https://github.com/trailofbits/abi3audit) on any abi3 wheel after the repair step to check for stable ABI violations or inconsistencies. If abi3audit detects any issues, the build will fail with a detailed report.
### Packages with optional C extensions {: #optional-extensions}
diff --git a/docs/options.md b/docs/options.md
index 90f9015eb..0f2ea2517 100644
--- a/docs/options.md
+++ b/docs/options.md
@@ -964,24 +964,11 @@ Platform-specific environment variables are also available:
'python scripts/check_repaired_wheel.py -w {dest_dir} {wheel}',
]
- # Use abi3audit to catch issues with Limited API wheels
- [tool.cibuildwheel.linux]
- repair-wheel-command = [
- "auditwheel repair -w {dest_dir} {wheel}",
- "pipx run abi3audit --strict --report {wheel}",
- ]
- [tool.cibuildwheel.macos]
- repair-wheel-command = [
- "delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel}",
- "pipx run abi3audit --strict --report {wheel}",
- ]
- [tool.cibuildwheel.windows]
- repair-wheel-command = [
- "copy {wheel} {dest_dir}",
- "pipx run abi3audit --strict --report {wheel}",
- ]
```
+ !!! note
+ cibuildwheel automatically runs [abi3audit](https://github.com/trailofbits/abi3audit) on abi3 wheels after the repair step. You no longer need to add it to your repair command manually.
+
In configuration files, you can use an inline array, and the items will be joined with `&&`.
@@ -1003,16 +990,6 @@ Platform-specific environment variables are also available:
python scripts/repair_wheel.py -w {dest_dir} {wheel} &&
python scripts/check_repaired_wheel.py -w {dest_dir} {wheel}
- # Use abi3audit to catch issues with Limited API wheels
- CIBW_REPAIR_WHEEL_COMMAND_LINUX: >
- auditwheel repair -w {dest_dir} {wheel} &&
- pipx run abi3audit --strict --report {wheel}
- CIBW_REPAIR_WHEEL_COMMAND_MACOS: >
- delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel} &&
- pipx run abi3audit --strict --report {wheel}
- CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: >
- copy {wheel} {dest_dir} &&
- pipx run abi3audit --strict --report {wheel}
```
diff --git a/pyproject.toml b/pyproject.toml
index b252fdc77..e878c2379 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,6 +36,7 @@ classifiers = [
"Topic :: Software Development :: Build Tools",
]
dependencies = [
+ "abi3audit",
"bashlex!=0.13",
"bracex",
"build>=1.0.0",
diff --git a/test/test_abi3audit.py b/test/test_abi3audit.py
new file mode 100644
index 000000000..f4c35553f
--- /dev/null
+++ b/test/test_abi3audit.py
@@ -0,0 +1,135 @@
+import subprocess
+import textwrap
+
+import pytest
+
+from . import test_projects, utils
+
+pyproject_toml = r"""
+[build-system]
+requires = ["setuptools", "wheel"]
+build-backend = "setuptools.build_meta"
+"""
+
+limited_api_project = test_projects.new_c_project(
+ setup_py_add=textwrap.dedent(
+ r"""
+ import sysconfig
+
+ IS_CPYTHON = sys.implementation.name == "cpython"
+ Py_GIL_DISABLED = sysconfig.get_config_var("Py_GIL_DISABLED")
+ CAN_USE_ABI3 = IS_CPYTHON and not Py_GIL_DISABLED
+ setup_options = {}
+ extension_kwargs = {}
+ if CAN_USE_ABI3 and sys.version_info[:2] >= (3, 10):
+ extension_kwargs["define_macros"] = [("Py_LIMITED_API", "0x030A0000")]
+ extension_kwargs["py_limited_api"] = True
+ setup_options = {"bdist_wheel": {"py_limited_api": "cp310"}}
+ """
+ ),
+ setup_py_extension_args_add="**extension_kwargs",
+ setup_py_setup_args_add="options=setup_options",
+)
+
+limited_api_project.files["pyproject.toml"] = pyproject_toml
+
+# Project that claims abi3 but violates the stable ABI by calling
+# PyUnicode_AsUTF8 (not in stable ABI until 3.13) without defining
+# Py_LIMITED_API in the C code.
+violating_abi3_project = test_projects.new_c_project(
+ setup_py_add=textwrap.dedent(
+ r"""
+ import sysconfig
+
+ IS_CPYTHON = sys.implementation.name == "cpython"
+ Py_GIL_DISABLED = sysconfig.get_config_var("Py_GIL_DISABLED")
+ CAN_USE_ABI3 = IS_CPYTHON and not Py_GIL_DISABLED
+ setup_options = {}
+ extension_kwargs = {}
+ if CAN_USE_ABI3 and sys.version_info[:2] >= (3, 10):
+ # Intentionally NOT defining Py_LIMITED_API as a C macro,
+ # but still tagging the wheel as abi3.
+ extension_kwargs["py_limited_api"] = True
+ setup_options = {"bdist_wheel": {"py_limited_api": "cp310"}}
+ """
+ ),
+ spam_c_function_add=textwrap.dedent(
+ r"""
+ // Call a function not in the stable ABI until Python 3.13.
+ // Without Py_LIMITED_API defined, the compiler allows it.
+ PyObject *str_obj = PyUnicode_FromString(content);
+ const char *utf8 = PyUnicode_AsUTF8(str_obj);
+ (void)utf8;
+ Py_DECREF(str_obj);
+ """
+ ),
+ setup_py_extension_args_add="**extension_kwargs",
+ setup_py_setup_args_add="options=setup_options",
+)
+
+violating_abi3_project.files["pyproject.toml"] = pyproject_toml
+
+
+@utils.skip_if_pyodide("Pyodide doesn't build abi3 wheels, so abi3audit is not relevant")
+def test_abi3audit_runs_on_abi3_wheel(tmp_path, capfd):
+ """Test that abi3audit runs automatically on abi3 wheels."""
+ project_dir = tmp_path / "project"
+ limited_api_project.generate(project_dir)
+
+ actual_wheels = utils.cibuildwheel_run(
+ project_dir,
+ add_env={
+ # Let's only build one cpython version to keep the test fast.
+ "CIBW_BUILD": "cp310-*",
+ "CIBW_ARCHS": "native",
+ },
+ )
+
+ assert len(actual_wheels) >= 1
+
+ captured = capfd.readouterr()
+ assert "Running abi3audit" in captured.out
+
+
+@utils.skip_if_pyodide("Pyodide doesn't build abi3 wheels, so abi3audit is not relevant")
+def test_abi3audit_skipped_for_non_abi3_wheel(tmp_path, capfd):
+ """Test that abi3audit does not run for non-abi3 wheels."""
+ project_dir = tmp_path / "project"
+ basic_project = test_projects.new_c_project()
+ basic_project.generate(project_dir)
+
+ actual_wheels = utils.cibuildwheel_run(
+ project_dir,
+ add_env={
+ "CIBW_ARCHS": "native",
+ },
+ single_python=True,
+ )
+
+ assert len(actual_wheels) >= 1
+
+ captured = capfd.readouterr()
+ assert "Running abi3audit" not in captured.out
+
+
+@utils.skip_if_pyodide("Pyodide doesn't build abi3 wheels, so abi3audit is not relevant")
+def test_abi3audit_detects_violation(tmp_path, capfd):
+ """Test that abi3audit catches stable ABI violations and fails the build.
+
+ This project tags the wheel as cp310-abi3 but uses PyUnicode_AsUTF8,
+ which was not part of the stable ABI until Python 3.13.
+ """
+ project_dir = tmp_path / "project"
+ violating_abi3_project.generate(project_dir)
+
+ with pytest.raises(subprocess.CalledProcessError):
+ utils.cibuildwheel_run(
+ project_dir,
+ add_env={
+ "CIBW_BUILD": "cp310-*",
+ "CIBW_ARCHS": "native",
+ },
+ )
+
+ captured = capfd.readouterr()
+ assert "Running abi3audit" in captured.out
diff --git a/unit_test/abi3audit_test.py b/unit_test/abi3audit_test.py
new file mode 100644
index 000000000..b03c98c37
--- /dev/null
+++ b/unit_test/abi3audit_test.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+import subprocess
+import sys
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from cibuildwheel.util.packaging import is_abi3_wheel, run_abi3audit
+
+
+class TestIsAbi3Wheel:
+ def test_abi3_wheel(self):
+ assert is_abi3_wheel("foo-1.0-cp310-abi3-manylinux_2_28_x86_64.whl") is True
+
+ def test_abi3_wheel_macos(self):
+ assert is_abi3_wheel("foo-1.0-cp311-abi3-macosx_11_0_arm64.whl") is True
+
+ def test_abi3_wheel_windows(self):
+ assert is_abi3_wheel("foo-1.0-cp310-abi3-win_amd64.whl") is True
+
+ def test_cpython_wheel(self):
+ assert is_abi3_wheel("foo-1.0-cp310-cp310-manylinux_2_28_x86_64.whl") is False
+
+ def test_none_any_wheel(self):
+ assert is_abi3_wheel("foo-1.0-py3-none-any.whl") is False
+
+ def test_none_platform_wheel(self):
+ assert is_abi3_wheel("foo-1.0-cp310-none-win_amd64.whl") is False
+
+
+class TestRunAbi3audit:
+ def test_skips_non_abi3_wheel(self):
+ wheel = Path("/tmp/foo-1.0-cp310-cp310-manylinux_2_28_x86_64.whl")
+ with patch("cibuildwheel.util.packaging.subprocess.run") as mock_run:
+ run_abi3audit(wheel)
+ mock_run.assert_not_called()
+
+ def test_runs_on_abi3_wheel(self):
+ wheel = Path("/tmp/foo-1.0-cp310-abi3-manylinux_2_28_x86_64.whl")
+ with patch("cibuildwheel.util.packaging.subprocess.run") as mock_run:
+ run_abi3audit(wheel)
+ mock_run.assert_called_once_with(
+ [sys.executable, "-m", "abi3audit", "--strict", "--report", str(wheel)],
+ check=True,
+ )
+
+ def test_raises_on_failure(self):
+ wheel = Path("/tmp/foo-1.0-cp310-abi3-manylinux_2_28_x86_64.whl")
+ with (
+ patch(
+ "cibuildwheel.util.packaging.subprocess.run",
+ side_effect=subprocess.CalledProcessError(1, "abi3audit"),
+ ),
+ pytest.raises(subprocess.CalledProcessError),
+ ):
+ run_abi3audit(wheel)