From 619e1dc98929aeea9106e412c2b5e2a3a1f90b3a Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Wed, 4 Feb 2026 09:26:35 +0000 Subject: [PATCH] WIP - initial punt at audit command --- cibuildwheel/__main__.py | 13 ++ cibuildwheel/audit.py | 89 ++++++++++++ cibuildwheel/options.py | 4 + .../resources/cibuildwheel.schema.json | 15 ++ cibuildwheel/resources/defaults.toml | 1 + unit_test/audit_test.py | 134 ++++++++++++++++++ unit_test/options_toml_test.py | 43 ++++++ 7 files changed, 299 insertions(+) create mode 100644 cibuildwheel/audit.py create mode 100644 unit_test/audit_test.py diff --git a/cibuildwheel/__main__.py b/cibuildwheel/__main__.py index 72e2434b..bec7ae05 100644 --- a/cibuildwheel/__main__.py +++ b/cibuildwheel/__main__.py @@ -384,10 +384,23 @@ def build_in_directory(args: CommandLineArguments) -> None: output_dir.mkdir(parents=True, exist_ok=True) + # Snapshot wheels before build to detect newly built ones + wheels_before = {p.name for p in output_dir.glob("*.whl")} + tmp_path = Path(mkdtemp(prefix="cibw-run-")).resolve(strict=True) try: with log.print_summary(options=options): platform_module.build(options, tmp_path) + + # Run audit step after all builds complete + if options.globals.audit_command: + from cibuildwheel.audit import run_audit + + run_audit( + audit_command=options.globals.audit_command, + output_dir=output_dir, + wheels_before=wheels_before, + ) finally: # avoid https://github.com/python/cpython/issues/86962 by performing # cleanup manually diff --git a/cibuildwheel/audit.py b/cibuildwheel/audit.py new file mode 100644 index 00000000..65f9cbd2 --- /dev/null +++ b/cibuildwheel/audit.py @@ -0,0 +1,89 @@ +""" +Audit step for wheels built by cibuildwheel. + +This module provides functionality to run audit commands (like abi3audit) +on built wheels after all platform builds are complete. +""" + +import os +import subprocess +from pathlib import Path + +from packaging.utils import parse_wheel_filename + +from .logger import log +from .util.helpers import format_safe + + +def is_abi3_wheel(wheel_path: Path) -> bool: + """Check if a wheel is an abi3 wheel by parsing its filename.""" + _, _, _, tags = parse_wheel_filename(wheel_path.name) + return any(t.abi == "abi3" for t in tags) + + +def run_audit( + audit_command: str, + output_dir: Path, + wheels_before: set[str], +) -> None: + """ + Run the audit command on wheels built in this run. + + The audit command supports the following placeholders: + - {wheel}: expands to each wheel path, runs the command once per wheel + - {abi3_wheel}: same as {wheel}, but only for abi3 wheels + + If the command contains {abi3_wheel} but no abi3 wheels were produced, + the audit step is skipped. + + Args: + audit_command: The command template to run + output_dir: Directory where wheels were output + wheels_before: Set of wheel filenames that existed before the build + """ + if not audit_command: + return + + # Find wheels built in this run (new wheels that weren't there before) + all_wheels = sorted(output_dir.glob("*.whl")) + just_built = [w for w in all_wheels if w.name not in wheels_before] + + if not just_built: + return + + # Determine if we're auditing abi3 wheels only + abi3_only = "{abi3_wheel}" in audit_command + + # Filter wheels if needed + if abi3_only: + wheels_to_audit = [w for w in just_built if is_abi3_wheel(w)] + if not wheels_to_audit: + log.step("Skipping audit step (no abi3 wheels produced)") + return + else: + wheels_to_audit = just_built + + log.step("Running audit...") + + for wheel in wheels_to_audit: + # Prepare command with placeholders + prepared = format_safe( + audit_command, + wheel=wheel, + abi3_wheel=wheel, + ) + + log.step(f" Auditing {wheel.name}...") + env = os.environ.copy() + + try: + subprocess.run( + prepared, + shell=True, + check=True, + env=env, + cwd=output_dir, + ) + except subprocess.CalledProcessError as e: + log.error(f"Audit command failed for {wheel.name}") + raise SystemExit(e.returncode) from e diff --git a/cibuildwheel/options.py b/cibuildwheel/options.py index af946a88..133f8cc7 100644 --- a/cibuildwheel/options.py +++ b/cibuildwheel/options.py @@ -90,6 +90,7 @@ class GlobalOptions: test_selector: TestSelector architectures: set[Architecture] allow_empty: bool + audit_command: str @dataclasses.dataclass(frozen=True) @@ -695,6 +696,8 @@ class Options: ) test_selector = TestSelector(skip_config=test_skip) + audit_command = self.reader.get("audit", option_format=ListFormat(sep=" && ")) + return GlobalOptions( package_dir=package_dir, output_dir=output_dir, @@ -702,6 +705,7 @@ class Options: test_selector=test_selector, architectures=architectures, allow_empty=allow_empty, + audit_command=audit_command, ) def _check_pinned_image(self, value: str, pinned_images: Mapping[str, str]) -> None: diff --git a/cibuildwheel/resources/cibuildwheel.schema.json b/cibuildwheel/resources/cibuildwheel.schema.json index c5a27ea8..c543ae42 100644 --- a/cibuildwheel/resources/cibuildwheel.schema.json +++ b/cibuildwheel/resources/cibuildwheel.schema.json @@ -27,6 +27,21 @@ "description": "cibuildwheel's settings.", "type": "object", "properties": { + "audit": { + "description": "Execute a shell command to audit each wheel after all builds complete. Use {wheel} for each wheel path, or {abi3_wheel} to only audit abi3 wheels.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "title": "CIBW_AUDIT" + }, "archs": { "description": "Change the architectures built on your machine by default.", "oneOf": [ diff --git a/cibuildwheel/resources/defaults.toml b/cibuildwheel/resources/defaults.toml index 78895bf9..a58952d8 100644 --- a/cibuildwheel/resources/defaults.toml +++ b/cibuildwheel/resources/defaults.toml @@ -5,6 +5,7 @@ test-skip = "" enable = [] archs = ["auto"] +audit = "" build-frontend = "default" config-settings = {} dependency-versions = "pinned" diff --git a/unit_test/audit_test.py b/unit_test/audit_test.py new file mode 100644 index 00000000..bf1ef5c6 --- /dev/null +++ b/unit_test/audit_test.py @@ -0,0 +1,134 @@ +from pathlib import Path + +import pytest + +from cibuildwheel.audit import is_abi3_wheel, run_audit + + +class TestIsAbi3Wheel: + def test_abi3_wheel(self) -> None: + assert is_abi3_wheel(Path("example-1.0.0-cp38-abi3-manylinux_2_17_x86_64.whl")) + + def test_abi3_wheel_macos(self) -> None: + assert is_abi3_wheel(Path("example-1.0.0-cp39-abi3-macosx_10_9_x86_64.whl")) + + def test_abi3_wheel_windows(self) -> None: + assert is_abi3_wheel(Path("example-1.0.0-cp310-abi3-win_amd64.whl")) + + def test_non_abi3_wheel(self) -> None: + assert not is_abi3_wheel(Path("example-1.0.0-cp310-cp310-manylinux_2_17_x86_64.whl")) + + def test_pure_python_wheel(self) -> None: + assert not is_abi3_wheel(Path("example-1.0.0-py3-none-any.whl")) + + +class TestRunAudit: + def test_empty_command_does_nothing(self, tmp_path: Path) -> None: + # Create a wheel file + wheel_path = tmp_path / "example-1.0.0-cp38-abi3-manylinux_2_17_x86_64.whl" + wheel_path.touch() + + # Should not raise and should be a no-op + run_audit( + audit_command="", + output_dir=tmp_path, + wheels_before=set(), + ) + + def test_audit_runs_on_new_wheels(self, tmp_path: Path) -> None: + # Create a wheel file (simulating a build) + wheel_path = tmp_path / "example-1.0.0-cp310-cp310-manylinux_2_17_x86_64.whl" + wheel_path.touch() + + # Create a marker file to verify command ran + marker = tmp_path / "audit_ran.txt" + + run_audit( + audit_command=f"touch {marker}", + output_dir=tmp_path, + wheels_before=set(), + ) + + assert marker.exists() + + def test_audit_skips_old_wheels(self, tmp_path: Path) -> None: + # Create a wheel file + wheel_path = tmp_path / "example-1.0.0-cp310-cp310-manylinux_2_17_x86_64.whl" + wheel_path.touch() + + # Create a marker file to verify command ran + marker = tmp_path / "audit_ran.txt" + + # Pre-existing wheel should be skipped + run_audit( + audit_command=f"touch {marker}", + output_dir=tmp_path, + wheels_before={wheel_path.name}, + ) + + assert not marker.exists() + + def test_abi3_only_mode_skips_non_abi3(self, tmp_path: Path) -> None: + # Create a non-abi3 wheel + wheel_path = tmp_path / "example-1.0.0-cp310-cp310-manylinux_2_17_x86_64.whl" + wheel_path.touch() + + # Create a marker file to verify command ran + marker = tmp_path / "audit_ran.txt" + + run_audit( + audit_command=f"echo {{abi3_wheel}} && touch {marker}", + output_dir=tmp_path, + wheels_before=set(), + ) + + # Should not run because no abi3 wheels + assert not marker.exists() + + def test_abi3_only_mode_runs_on_abi3(self, tmp_path: Path) -> None: + # Create an abi3 wheel + wheel_path = tmp_path / "example-1.0.0-cp38-abi3-manylinux_2_17_x86_64.whl" + wheel_path.touch() + + # Create a marker file to verify command ran + marker = tmp_path / "audit_ran.txt" + + run_audit( + audit_command=f"echo {{abi3_wheel}} && touch {marker}", + output_dir=tmp_path, + wheels_before=set(), + ) + + assert marker.exists() + + def test_wheel_placeholder_expanded(self, tmp_path: Path) -> None: + # Create a wheel file + wheel_path = tmp_path / "example-1.0.0-cp310-cp310-manylinux_2_17_x86_64.whl" + wheel_path.touch() + + # Write wheel path to a file to verify expansion + output_file = tmp_path / "wheel_path.txt" + + run_audit( + audit_command=f"echo {{wheel}} > {output_file}", + output_dir=tmp_path, + wheels_before=set(), + ) + + assert output_file.exists() + content = output_file.read_text().strip() + assert content == str(wheel_path) + + def test_audit_fails_on_error(self, tmp_path: Path) -> None: + # Create a wheel file + wheel_path = tmp_path / "example-1.0.0-cp310-cp310-manylinux_2_17_x86_64.whl" + wheel_path.touch() + + with pytest.raises(SystemExit) as exc_info: + run_audit( + audit_command="exit 1", + output_dir=tmp_path, + wheels_before=set(), + ) + + assert exc_info.value.code == 1 diff --git a/unit_test/options_toml_test.py b/unit_test/options_toml_test.py index 0fb19f63..255cec2c 100644 --- a/unit_test/options_toml_test.py +++ b/unit_test/options_toml_test.py @@ -541,3 +541,46 @@ before-all = ["override2"] options_reader.get("config-settings", option_format=ShlexTableFormat()) == "key1=value1 key2=override2 empty='' key3=value3" ) + + +def test_audit_option(tmp_path, platform): + pyproject_toml: Path = tmp_path / "pyproject.toml" + pyproject_toml.write_text( + """ +[tool.cibuildwheel] +audit = "abi3audit {abi3_wheel}" +""" + ) + + options_reader = OptionsReader(pyproject_toml, platform=platform, env={}) + assert options_reader.get("audit", option_format=ListFormat(" && ")) == "abi3audit {abi3_wheel}" + + +def test_audit_option_list(tmp_path, platform): + pyproject_toml: Path = tmp_path / "pyproject.toml" + pyproject_toml.write_text( + """ +[tool.cibuildwheel] +audit = ["first command", "second command"] +""" + ) + + options_reader = OptionsReader(pyproject_toml, platform=platform, env={}) + assert ( + options_reader.get("audit", option_format=ListFormat(" && ")) + == "first command && second command" + ) + + +def test_audit_option_env(tmp_path, platform): + pyproject_toml: Path = tmp_path / "pyproject.toml" + pyproject_toml.write_text( + """ +[tool.cibuildwheel] +""" + ) + + options_reader = OptionsReader( + pyproject_toml, platform=platform, env={"CIBW_AUDIT": "my-audit-tool {wheel}"} + ) + assert options_reader.get("audit", option_format=ListFormat(" && ")) == "my-audit-tool {wheel}"