WIP - initial punt at audit command

This commit is contained in:
Joe Rickerby
2026-02-04 09:26:35 +00:00
parent e8646fb903
commit 619e1dc989
7 changed files with 299 additions and 0 deletions
+13
View File
@@ -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
+89
View File
@@ -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
+4
View File
@@ -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:
@@ -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": [
+1
View File
@@ -5,6 +5,7 @@ test-skip = ""
enable = []
archs = ["auto"]
audit = ""
build-frontend = "default"
config-settings = {}
dependency-versions = "pinned"
+134
View File
@@ -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
+43
View File
@@ -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}"