Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
11 changes: 10 additions & 1 deletion cibuildwheel/platforms/linux.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import contextlib
import dataclasses
import shutil
import subprocess
import sys
import textwrap
Expand All @@ -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
Expand Down Expand Up @@ -359,6 +360,14 @@ 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)
shutil.rmtree(local_abi3audit_dir)

if build_options.test_command and build_options.test_selector(config.identifier):
log.step("Testing wheel...")

Expand Down
4 changes: 3 additions & 1 deletion cibuildwheel/platforms/macos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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):
Expand Down
4 changes: 3 additions & 1 deletion cibuildwheel/platforms/windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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(
Expand Down
24 changes: 24 additions & 0 deletions cibuildwheel/util/packaging.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import shlex
import subprocess
import sys
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from pathlib import Path, PurePath
from typing import Any, Literal, Self, TypeVar

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
Expand Down Expand Up @@ -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,
)
2 changes: 1 addition & 1 deletion docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
29 changes: 3 additions & 26 deletions docs/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -964,24 +964,11 @@ Platform-specific environment variables are also available:<br/>
'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 `&&`.


Expand All @@ -1003,16 +990,6 @@ Platform-specific environment variables are also available:<br/>
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}
```


Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ classifiers = [
"Topic :: Software Development :: Build Tools",
]
dependencies = [
"abi3audit",
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

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

Consider adding a minimum version constraint for abi3audit to ensure the --strict and --report flags are available. For example, abi3audit>=0.0.8 would ensure compatibility with the flags used in the code. This would prevent issues if someone has an older version of abi3audit installed in their environment.

Suggested change
"abi3audit",
"abi3audit>=0.0.8",

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

https://github.com/pypa/abi3audit/releases/tag/v0.0.26 lists Python 3.10 as the minimum supported version. There's no proper CHANGELOG for older versions (such as v0.0.8, as suggested here), so I'm not sure when the --strict flag was added.

But this begs the question: what's the best approach for us here? We wouldn't want to run different versions of abi3audit without the user knowing, right?

"bashlex!=0.13",
"bracex",
"build>=1.0.0",
Expand Down
135 changes: 135 additions & 0 deletions test/test_abi3audit.py
Original file line number Diff line number Diff line change
@@ -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
58 changes: 58 additions & 0 deletions unit_test/abi3audit_test.py
Original file line number Diff line number Diff line change
@@ -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("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)
Loading