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)