diff --git a/README.md b/README.md index 2caeeaa76..31e046fb3 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,8 @@ The following diagram summarises the steps that cibuildwheel takes on each platf | | [`container-engine`](https://cibuildwheel.pypa.io/en/stable/options/#container-engine) | Specify the container engine to use when building Linux wheels | | | [`dependency-versions`](https://cibuildwheel.pypa.io/en/stable/options/#dependency-versions) | Control the versions of the tools cibuildwheel uses | | | [`pyodide-version`](https://cibuildwheel.pypa.io/en/stable/options/#pyodide-version) | Specify the Pyodide version to use for `pyodide` platform builds | +| **Auditing** | [`audit-requires`](https://cibuildwheel.pypa.io/en/stable/options/#audit-requires) | Install Python dependencies for the audit step | +| | [`audit-command`](https://cibuildwheel.pypa.io/en/stable/options/#audit-command) | Use a tool to check wheels before the end of the run | | **Testing** | [`test-command`](https://cibuildwheel.pypa.io/en/stable/options/#test-command) | The command to test each built wheel | | | [`before-test`](https://cibuildwheel.pypa.io/en/stable/options/#before-test) | Execute a shell command before testing each wheel | | | [`test-sources`](https://cibuildwheel.pypa.io/en/stable/options/#test-sources) | Paths that are copied into the working directory of the tests | @@ -170,7 +172,7 @@ The following diagram summarises the steps that cibuildwheel takes on each platf | | [`build-verbosity`](https://cibuildwheel.pypa.io/en/stable/options/#build-verbosity) | Increase/decrease the output of the build | - + These options can be specified in a pyproject.toml file, or as environment variables, see [configuration docs](https://cibuildwheel.pypa.io/en/latest/configuration/). diff --git a/bin/generate_schema.py b/bin/generate_schema.py index 022ffe356..52e44109a 100755 --- a/bin/generate_schema.py +++ b/bin/generate_schema.py @@ -39,6 +39,12 @@ description: cibuildwheel's settings. type: object properties: + audit-command: + description: Execute a shell command to audit each wheel after it is repaired. Use {wheel} for each wheel path, or {abi3_wheel} to only audit abi3 wheels. + type: string_array + audit-requires: + description: Install Python dependencies for the audit step. + type: string_array archs: description: Change the architectures built on your machine by default. type: string_array @@ -309,6 +315,8 @@ type: object additionalProperties: false properties: + audit-command: {"$ref": "#/$defs/inherit"} + audit-requires: {"$ref": "#/$defs/inherit"} before-all: {"$ref": "#/$defs/inherit"} before-build: {"$ref": "#/$defs/inherit"} xbuild-tools: {"$ref": "#/$defs/inherit"} diff --git a/cibuildwheel/audit.py b/cibuildwheel/audit.py new file mode 100644 index 000000000..f91b01558 --- /dev/null +++ b/cibuildwheel/audit.py @@ -0,0 +1,132 @@ +import subprocess +import sys +from pathlib import Path + +from cibuildwheel import errors +from cibuildwheel.logger import log +from cibuildwheel.options import BuildOptions +from cibuildwheel.util.cmd import call, shell +from cibuildwheel.util.helpers import prepare_command +from cibuildwheel.util.packaging import is_abi3_wheel +from cibuildwheel.venv import activate_virtualenv, find_uv, virtualenv + + +def run_audit( + *, + tmp_dir: Path, + build_options: BuildOptions, + wheel: Path, +) -> None: + """ + Run the audit commands on a single wheel. + + Creates a virtualenv (or reuses an existing one) and installs any + audit requirements, then runs each audit command template against + the wheel. Commands containing {abi3_wheel} are skipped for + non-abi3 wheels. + """ + + if not needs_audit(build_options.audit_command, wheel.name): + return + + log.step("Auditing wheel...") + + use_uv = build_options.build_frontend.name in {"build[uv]", "uv"} + version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + dependency_constraint = build_options.dependency_constraints.get_for_python_version( + version=version, tmp_dir=tmp_dir + ) + + # Use the base interpreter, not the venv python, to avoid nested-venv + # issues where pip can't be found (seen on Windows + Python 3.13). + host_python = Path(getattr(sys, "_base_executable", sys.executable)) + + audit_venv_dir = tmp_dir / "audit_venv" + if not (audit_venv_dir / "pyvenv.cfg").exists(): + env = virtualenv( + version, + host_python, + audit_venv_dir, + dependency_constraint=dependency_constraint, + use_uv=use_uv, + ) + else: + env = activate_virtualenv(audit_venv_dir) + + # install audit requirements. This is run every time in case the user has + # defined overrides. + audit_requires = build_options.audit_requires + if audit_requires: + print(f"Installing audit dependencies: {', '.join(audit_requires)}") + + pip: list[str] + if use_uv: + uv_path = find_uv() + assert uv_path is not None + pip = [str(uv_path), "pip"] + else: + pip = ["pip"] + # we pin if the audit-requires is left as the default "abi3audit" + should_pin = audit_requires == ["abi3audit"] and dependency_constraint + + call( + *pip, + "install", + *(["--constraint", str(dependency_constraint)] if should_pin else []), + *audit_requires, + env=env, + ) + + audit_command = build_options.audit_command + + for command_template in audit_command: + if "{abi3_wheel}" in command_template and "{wheel}" in command_template: + msg = ( + f"Invalid audit command {command_template!r}: cannot contain both {{abi3_wheel}} " + "and {{wheel}} placeholders" + ) + raise errors.ConfigurationError(msg) + + if "{abi3_wheel}" in command_template and not is_abi3_wheel(wheel.name): + continue + + prepared_command = prepare_command( + command_template, + abi3_wheel=wheel, + wheel=wheel, + project=".", + package=build_options.package_dir, + ) + + print(f"Running audit command: {prepared_command}") + try: + shell(prepared_command, env=env) + except subprocess.CalledProcessError as e: + print(f"Audit command failed with exit code {e.returncode}") + msg = f"Audit command failed: {prepared_command}" + raise errors.AuditCommandFailedError(msg) from e + + +def needs_audit(audit_commands: list[str], wheel_name: str) -> bool: + saw_abi3_placeholder = False + for audit_command in audit_commands: + if "{abi3_wheel}" not in audit_command and "{wheel}" not in audit_command: + msg = ( + f"Invalid audit command {audit_command!r}: must contain either " + "{{abi3_wheel}} or {{wheel}} placeholder" + ) + raise errors.ConfigurationError(msg) + + if "{abi3_wheel}" in audit_command: + saw_abi3_placeholder = True + if is_abi3_wheel(wheel_name): + return True + elif "{wheel}" in audit_command: + return True + + if saw_abi3_placeholder: + print("No audit required for this wheel, as it is not abi3") + else: + print("No audit configured") + + return False diff --git a/cibuildwheel/errors.py b/cibuildwheel/errors.py index 9f0a71f15..c03b0cd0c 100644 --- a/cibuildwheel/errors.py +++ b/cibuildwheel/errors.py @@ -103,3 +103,9 @@ def __init__(self, wheels: list[str]) -> None: ) super().__init__(message) self.return_code = 8 + + +class AuditCommandFailedError(FatalError): + def __init__(self, message: str) -> None: + super().__init__(message) + self.return_code = 9 diff --git a/cibuildwheel/options.py b/cibuildwheel/options.py index 972c7b2c6..ea57b51ce 100644 --- a/cibuildwheel/options.py +++ b/cibuildwheel/options.py @@ -125,6 +125,8 @@ class BuildOptions: test_groups: list[str] test_environment: ParsedEnvironment test_runtime: TestRuntimeConfig + audit_requires: list[str] + audit_command: list[str] build_verbosity: int build_frontend: BuildFrontendConfig config_settings: str @@ -894,6 +896,15 @@ def _compute_build_options(self, identifier: str | None) -> BuildOptions: pyodide_version = self.reader.get("pyodide-version", env_plat=False) + audit_command_str = self.reader.get( + "audit-command", option_format=ListFormat(sep=" && ") + ) + audit_command = audit_command_str.split(" && ") if audit_command_str else [] + + audit_requires = self.reader.get( + "audit-requires", option_format=ListFormat(sep=" ") + ).split() + return BuildOptions( globals=self.globals, test_command=test_command, @@ -917,6 +928,8 @@ def _compute_build_options(self, identifier: str | None) -> BuildOptions: config_settings=config_settings, container_engine=container_engine, pyodide_version=pyodide_version or None, + audit_command=audit_command, + audit_requires=audit_requires, ) def check_for_invalid_configuration(self, identifiers: Iterable[str]) -> None: diff --git a/cibuildwheel/platforms/android.py b/cibuildwheel/platforms/android.py index 50b07884c..7913c5556 100644 --- a/cibuildwheel/platforms/android.py +++ b/cibuildwheel/platforms/android.py @@ -24,6 +24,7 @@ from cibuildwheel import errors, platforms # pylint: disable=cyclic-import from cibuildwheel.architecture import Architecture, arch_synonym +from cibuildwheel.audit import run_audit from cibuildwheel.frontend import get_build_frontend_extra_flags, parse_config_settings from cibuildwheel.logger import log from cibuildwheel.options import BuildOptions, Options @@ -150,6 +151,7 @@ def build(options: Options, tmp_path: Path) -> None: before_build(state) built_wheel = build_wheel(state) repaired_wheel = repair_wheel(state, built_wheel) + run_audit(tmp_dir=tmp_path, build_options=build_options, wheel=repaired_wheel) test_wheel(state, repaired_wheel, build_frontend=build_options.build_frontend.name) diff --git a/cibuildwheel/platforms/ios.py b/cibuildwheel/platforms/ios.py index 9e2474483..54092b065 100644 --- a/cibuildwheel/platforms/ios.py +++ b/cibuildwheel/platforms/ios.py @@ -14,6 +14,7 @@ from cibuildwheel import errors from cibuildwheel.architecture import Architecture +from cibuildwheel.audit import run_audit from cibuildwheel.environment import ParsedEnvironment from cibuildwheel.frontend import BuildFrontendName, get_build_frontend_extra_flags from cibuildwheel.logger import log @@ -539,10 +540,12 @@ 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) - test_wheel = repaired_wheel - log.step_end() + run_audit(tmp_dir=tmp_path, build_options=build_options, wheel=repaired_wheel) + + test_wheel = repaired_wheel + if build_options.test_command and build_options.test_selector(config.identifier): if not config.is_simulator: log.step("Skipping tests on non-simulator SDK") diff --git a/cibuildwheel/platforms/linux.py b/cibuildwheel/platforms/linux.py index d5d9366b7..6dbde2046 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 @@ -10,6 +11,7 @@ from cibuildwheel import errors from cibuildwheel.architecture import Architecture +from cibuildwheel.audit import needs_audit, run_audit from cibuildwheel.frontend import get_build_frontend_extra_flags from cibuildwheel.logger import log from cibuildwheel.oci_container import OCIContainer, OCIContainerEngineConfig, OCIPlatform @@ -359,6 +361,18 @@ def build_in_container( if repaired_wheel.name in {wheel.name for wheel in built_wheels}: raise errors.AlreadyBuiltWheelError(repaired_wheel.name) + log.step_end() + + if needs_audit(build_options.audit_command, repaired_wheel.name): + local_abi3audit_dir = local_identifier_tmp_dir / "audit" + local_abi3audit_dir.mkdir(parents=True, exist_ok=True) + try: + container.copy_out(repaired_wheel_dir, local_abi3audit_dir) + local_wheel = local_abi3audit_dir / repaired_wheel.name + run_audit(tmp_dir=local_tmp_dir, build_options=build_options, wheel=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 37c3d799c..f47b3a821 100644 --- a/cibuildwheel/platforms/macos.py +++ b/cibuildwheel/platforms/macos.py @@ -17,6 +17,7 @@ from cibuildwheel import errors from cibuildwheel.architecture import Architecture +from cibuildwheel.audit import run_audit from cibuildwheel.ci import detect_ci_provider from cibuildwheel.environment import ParsedEnvironment from cibuildwheel.frontend import BuildFrontendName, get_build_frontend_extra_flags @@ -569,6 +570,8 @@ def build(options: Options, tmp_path: Path) -> None: log.step_end() + run_audit(tmp_dir=tmp_path, build_options=build_options, wheel=repaired_wheel) + if build_options.test_command and build_options.test_selector(config.identifier): machine_arch = platform.machine() python_arch = call( diff --git a/cibuildwheel/platforms/pyodide.py b/cibuildwheel/platforms/pyodide.py index e0560b4a0..1cb52673b 100644 --- a/cibuildwheel/platforms/pyodide.py +++ b/cibuildwheel/platforms/pyodide.py @@ -16,6 +16,7 @@ from cibuildwheel import errors from cibuildwheel.architecture import Architecture +from cibuildwheel.audit import run_audit from cibuildwheel.environment import ParsedEnvironment from cibuildwheel.frontend import get_build_frontend_extra_flags from cibuildwheel.logger import log @@ -461,6 +462,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_audit(tmp_dir=tmp_path, build_options=build_options, wheel=repaired_wheel) + if build_options.test_command and build_options.test_selector(config.identifier): log.step("Testing wheel...") diff --git a/cibuildwheel/platforms/windows.py b/cibuildwheel/platforms/windows.py index 3bb9e86f7..fdbd3cf82 100644 --- a/cibuildwheel/platforms/windows.py +++ b/cibuildwheel/platforms/windows.py @@ -14,6 +14,7 @@ from cibuildwheel import errors from cibuildwheel.architecture import Architecture +from cibuildwheel.audit import run_audit from cibuildwheel.environment import ParsedEnvironment from cibuildwheel.frontend import BuildFrontendName, get_build_frontend_extra_flags from cibuildwheel.logger import log @@ -559,6 +560,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_audit(tmp_dir=tmp_path, build_options=build_options, wheel=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/resources/cibuildwheel.schema.json b/cibuildwheel/resources/cibuildwheel.schema.json index a82518f4b..022faf129 100644 --- a/cibuildwheel/resources/cibuildwheel.schema.json +++ b/cibuildwheel/resources/cibuildwheel.schema.json @@ -27,6 +27,36 @@ "description": "cibuildwheel's settings.", "type": "object", "properties": { + "audit-command": { + "description": "Execute a shell command to audit each wheel after it is repaired. 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_COMMAND" + }, + "audit-requires": { + "description": "Install Python dependencies for the audit step.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "title": "CIBW_AUDIT_REQUIRES" + }, "archs": { "description": "Change the architectures built on your machine by default.", "oneOf": [ @@ -635,6 +665,12 @@ "type": "object", "additionalProperties": false, "properties": { + "audit-command": { + "$ref": "#/$defs/inherit" + }, + "audit-requires": { + "$ref": "#/$defs/inherit" + }, "before-all": { "$ref": "#/$defs/inherit" }, @@ -682,6 +718,12 @@ } } }, + "audit-command": { + "$ref": "#/properties/audit-command" + }, + "audit-requires": { + "$ref": "#/properties/audit-requires" + }, "before-all": { "$ref": "#/properties/before-all" }, @@ -800,6 +842,12 @@ "type": "object", "additionalProperties": false, "properties": { + "audit-command": { + "$ref": "#/properties/audit-command" + }, + "audit-requires": { + "$ref": "#/properties/audit-requires" + }, "archs": { "$ref": "#/properties/archs" }, @@ -930,6 +978,12 @@ "type": "object", "additionalProperties": false, "properties": { + "audit-command": { + "$ref": "#/properties/audit-command" + }, + "audit-requires": { + "$ref": "#/properties/audit-requires" + }, "archs": { "$ref": "#/properties/archs" }, @@ -993,6 +1047,12 @@ "type": "object", "additionalProperties": false, "properties": { + "audit-command": { + "$ref": "#/properties/audit-command" + }, + "audit-requires": { + "$ref": "#/properties/audit-requires" + }, "archs": { "$ref": "#/properties/archs" }, @@ -1069,6 +1129,12 @@ "type": "object", "additionalProperties": false, "properties": { + "audit-command": { + "$ref": "#/properties/audit-command" + }, + "audit-requires": { + "$ref": "#/properties/audit-requires" + }, "archs": { "$ref": "#/properties/archs" }, @@ -1132,6 +1198,12 @@ "type": "object", "additionalProperties": false, "properties": { + "audit-command": { + "$ref": "#/properties/audit-command" + }, + "audit-requires": { + "$ref": "#/properties/audit-requires" + }, "archs": { "$ref": "#/properties/archs" }, @@ -1195,6 +1267,12 @@ "type": "object", "additionalProperties": false, "properties": { + "audit-command": { + "$ref": "#/properties/audit-command" + }, + "audit-requires": { + "$ref": "#/properties/audit-requires" + }, "archs": { "$ref": "#/properties/archs" }, diff --git a/cibuildwheel/resources/constraints-python310.txt b/cibuildwheel/resources/constraints-python310.txt index 3f218e356..1c6c3fe05 100644 --- a/cibuildwheel/resources/constraints-python310.txt +++ b/cibuildwheel/resources/constraints-python310.txt @@ -1,42 +1,92 @@ # This file was autogenerated by uv via the following command: # nox -s update_constraints +abi3audit==0.0.26 + # via -r cibuildwheel/resources/constraints.in +abi3info==2025.11.29 + # via abi3audit altgraph==0.17.5 # via macholib +attrs==26.1.0 + # via + # cattrs + # requests-cache build==1.4.3 # via -r cibuildwheel/resources/constraints.in +cattrs==26.1.0 + # via requests-cache +certifi==2026.2.25 + # via requests +charset-normalizer==3.4.7 + # via requests delocate==0.13.0 # via -r cibuildwheel/resources/constraints.in distlib==0.4.0 # via virtualenv -filelock==3.25.2 +exceptiongroup==1.3.1 + # via cattrs +filelock==3.28.0 # via # python-discovery # virtualenv +idna==3.11 + # via + # requests + # url-normalize importlib-metadata==9.0.0 # via build +kaitaistruct==0.11 + # via abi3audit macholib==1.16.4 # via delocate -packaging==26.0 +markdown-it-py==4.0.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +packaging==26.1 # via + # abi3audit # build # delocate +pefile==2024.8.26 + # via abi3audit pip==26.0.1 # via -r cibuildwheel/resources/constraints.in platformdirs==4.9.6 # via # python-discovery + # requests-cache # virtualenv +pyelftools==0.32 + # via abi3audit +pygments==2.20.0 + # via rich pyproject-hooks==1.2.0 # via build python-discovery==1.2.2 # via virtualenv +requests==2.33.1 + # via + # abi3audit + # requests-cache +requests-cache==1.3.1 + # via abi3audit +rich==15.0.0 + # via abi3audit tomli==2.4.1 # via build typing-extensions==4.15.0 # via + # cattrs # delocate + # exceptiongroup # virtualenv -virtualenv==21.2.1 +url-normalize==2.2.1 + # via requests-cache +urllib3==2.6.3 + # via + # requests + # requests-cache +virtualenv==21.2.4 # via -r cibuildwheel/resources/constraints.in -zipp==3.23.0 +zipp==3.23.1 # via importlib-metadata diff --git a/cibuildwheel/resources/constraints-python311.txt b/cibuildwheel/resources/constraints-python311.txt index 636dfbb8c..f9b23d07b 100644 --- a/cibuildwheel/resources/constraints-python311.txt +++ b/cibuildwheel/resources/constraints-python311.txt @@ -1,34 +1,82 @@ # This file was autogenerated by uv via the following command: # nox -s update_constraints +abi3audit==0.0.26 + # via -r cibuildwheel/resources/constraints.in +abi3info==2025.11.29 + # via abi3audit altgraph==0.17.5 # via macholib +attrs==26.1.0 + # via + # cattrs + # requests-cache build==1.4.3 # via -r cibuildwheel/resources/constraints.in +cattrs==26.1.0 + # via requests-cache +certifi==2026.2.25 + # via requests +charset-normalizer==3.4.7 + # via requests delocate==0.13.0 # via -r cibuildwheel/resources/constraints.in distlib==0.4.0 # via virtualenv -filelock==3.25.2 +filelock==3.28.0 # via # python-discovery # virtualenv +idna==3.11 + # via + # requests + # url-normalize +kaitaistruct==0.11 + # via abi3audit macholib==1.16.4 # via delocate -packaging==26.0 +markdown-it-py==4.0.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +packaging==26.1 # via + # abi3audit # build # delocate +pefile==2024.8.26 + # via abi3audit pip==26.0.1 # via -r cibuildwheel/resources/constraints.in platformdirs==4.9.6 # via # python-discovery + # requests-cache # virtualenv +pyelftools==0.32 + # via abi3audit +pygments==2.20.0 + # via rich pyproject-hooks==1.2.0 # via build python-discovery==1.2.2 # via virtualenv +requests==2.33.1 + # via + # abi3audit + # requests-cache +requests-cache==1.3.1 + # via abi3audit +rich==15.0.0 + # via abi3audit typing-extensions==4.15.0 - # via delocate -virtualenv==21.2.1 + # via + # cattrs + # delocate +url-normalize==2.2.1 + # via requests-cache +urllib3==2.6.3 + # via + # requests + # requests-cache +virtualenv==21.2.4 # via -r cibuildwheel/resources/constraints.in diff --git a/cibuildwheel/resources/constraints-python312.txt b/cibuildwheel/resources/constraints-python312.txt index 636dfbb8c..f9b23d07b 100644 --- a/cibuildwheel/resources/constraints-python312.txt +++ b/cibuildwheel/resources/constraints-python312.txt @@ -1,34 +1,82 @@ # This file was autogenerated by uv via the following command: # nox -s update_constraints +abi3audit==0.0.26 + # via -r cibuildwheel/resources/constraints.in +abi3info==2025.11.29 + # via abi3audit altgraph==0.17.5 # via macholib +attrs==26.1.0 + # via + # cattrs + # requests-cache build==1.4.3 # via -r cibuildwheel/resources/constraints.in +cattrs==26.1.0 + # via requests-cache +certifi==2026.2.25 + # via requests +charset-normalizer==3.4.7 + # via requests delocate==0.13.0 # via -r cibuildwheel/resources/constraints.in distlib==0.4.0 # via virtualenv -filelock==3.25.2 +filelock==3.28.0 # via # python-discovery # virtualenv +idna==3.11 + # via + # requests + # url-normalize +kaitaistruct==0.11 + # via abi3audit macholib==1.16.4 # via delocate -packaging==26.0 +markdown-it-py==4.0.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +packaging==26.1 # via + # abi3audit # build # delocate +pefile==2024.8.26 + # via abi3audit pip==26.0.1 # via -r cibuildwheel/resources/constraints.in platformdirs==4.9.6 # via # python-discovery + # requests-cache # virtualenv +pyelftools==0.32 + # via abi3audit +pygments==2.20.0 + # via rich pyproject-hooks==1.2.0 # via build python-discovery==1.2.2 # via virtualenv +requests==2.33.1 + # via + # abi3audit + # requests-cache +requests-cache==1.3.1 + # via abi3audit +rich==15.0.0 + # via abi3audit typing-extensions==4.15.0 - # via delocate -virtualenv==21.2.1 + # via + # cattrs + # delocate +url-normalize==2.2.1 + # via requests-cache +urllib3==2.6.3 + # via + # requests + # requests-cache +virtualenv==21.2.4 # via -r cibuildwheel/resources/constraints.in diff --git a/cibuildwheel/resources/constraints-python313.txt b/cibuildwheel/resources/constraints-python313.txt index 636dfbb8c..f9b23d07b 100644 --- a/cibuildwheel/resources/constraints-python313.txt +++ b/cibuildwheel/resources/constraints-python313.txt @@ -1,34 +1,82 @@ # This file was autogenerated by uv via the following command: # nox -s update_constraints +abi3audit==0.0.26 + # via -r cibuildwheel/resources/constraints.in +abi3info==2025.11.29 + # via abi3audit altgraph==0.17.5 # via macholib +attrs==26.1.0 + # via + # cattrs + # requests-cache build==1.4.3 # via -r cibuildwheel/resources/constraints.in +cattrs==26.1.0 + # via requests-cache +certifi==2026.2.25 + # via requests +charset-normalizer==3.4.7 + # via requests delocate==0.13.0 # via -r cibuildwheel/resources/constraints.in distlib==0.4.0 # via virtualenv -filelock==3.25.2 +filelock==3.28.0 # via # python-discovery # virtualenv +idna==3.11 + # via + # requests + # url-normalize +kaitaistruct==0.11 + # via abi3audit macholib==1.16.4 # via delocate -packaging==26.0 +markdown-it-py==4.0.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +packaging==26.1 # via + # abi3audit # build # delocate +pefile==2024.8.26 + # via abi3audit pip==26.0.1 # via -r cibuildwheel/resources/constraints.in platformdirs==4.9.6 # via # python-discovery + # requests-cache # virtualenv +pyelftools==0.32 + # via abi3audit +pygments==2.20.0 + # via rich pyproject-hooks==1.2.0 # via build python-discovery==1.2.2 # via virtualenv +requests==2.33.1 + # via + # abi3audit + # requests-cache +requests-cache==1.3.1 + # via abi3audit +rich==15.0.0 + # via abi3audit typing-extensions==4.15.0 - # via delocate -virtualenv==21.2.1 + # via + # cattrs + # delocate +url-normalize==2.2.1 + # via requests-cache +urllib3==2.6.3 + # via + # requests + # requests-cache +virtualenv==21.2.4 # via -r cibuildwheel/resources/constraints.in diff --git a/cibuildwheel/resources/constraints-python314.txt b/cibuildwheel/resources/constraints-python314.txt index 636dfbb8c..f9b23d07b 100644 --- a/cibuildwheel/resources/constraints-python314.txt +++ b/cibuildwheel/resources/constraints-python314.txt @@ -1,34 +1,82 @@ # This file was autogenerated by uv via the following command: # nox -s update_constraints +abi3audit==0.0.26 + # via -r cibuildwheel/resources/constraints.in +abi3info==2025.11.29 + # via abi3audit altgraph==0.17.5 # via macholib +attrs==26.1.0 + # via + # cattrs + # requests-cache build==1.4.3 # via -r cibuildwheel/resources/constraints.in +cattrs==26.1.0 + # via requests-cache +certifi==2026.2.25 + # via requests +charset-normalizer==3.4.7 + # via requests delocate==0.13.0 # via -r cibuildwheel/resources/constraints.in distlib==0.4.0 # via virtualenv -filelock==3.25.2 +filelock==3.28.0 # via # python-discovery # virtualenv +idna==3.11 + # via + # requests + # url-normalize +kaitaistruct==0.11 + # via abi3audit macholib==1.16.4 # via delocate -packaging==26.0 +markdown-it-py==4.0.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +packaging==26.1 # via + # abi3audit # build # delocate +pefile==2024.8.26 + # via abi3audit pip==26.0.1 # via -r cibuildwheel/resources/constraints.in platformdirs==4.9.6 # via # python-discovery + # requests-cache # virtualenv +pyelftools==0.32 + # via abi3audit +pygments==2.20.0 + # via rich pyproject-hooks==1.2.0 # via build python-discovery==1.2.2 # via virtualenv +requests==2.33.1 + # via + # abi3audit + # requests-cache +requests-cache==1.3.1 + # via abi3audit +rich==15.0.0 + # via abi3audit typing-extensions==4.15.0 - # via delocate -virtualenv==21.2.1 + # via + # cattrs + # delocate +url-normalize==2.2.1 + # via requests-cache +urllib3==2.6.3 + # via + # requests + # requests-cache +virtualenv==21.2.4 # via -r cibuildwheel/resources/constraints.in diff --git a/cibuildwheel/resources/constraints-python38.txt b/cibuildwheel/resources/constraints-python38.txt index 84c6ceae5..bd18367f9 100644 --- a/cibuildwheel/resources/constraints-python38.txt +++ b/cibuildwheel/resources/constraints-python38.txt @@ -1,42 +1,93 @@ # This file was autogenerated by uv via the following command: # nox -s update_constraints +abi3audit==0.0.17 + # via -r cibuildwheel/resources/constraints.in +abi3info==2025.4.29 + # via abi3audit altgraph==0.17.5 # via macholib +attrs==25.3.0 + # via + # cattrs + # requests-cache build==1.2.2.post1 # via -r cibuildwheel/resources/constraints.in +cattrs==24.1.3 + # via requests-cache +certifi==2026.2.25 + # via requests +charset-normalizer==3.4.7 + # via requests delocate==0.12.0 # via -r cibuildwheel/resources/constraints.in distlib==0.4.0 # via virtualenv +exceptiongroup==1.3.1 + # via cattrs filelock==3.16.1 # via # python-discovery # virtualenv +idna==3.11 + # via + # requests + # url-normalize importlib-metadata==8.5.0 # via build +kaitaistruct==0.11 + # via abi3audit macholib==1.16.4 # via delocate -packaging==26.0 +markdown-it-py==3.0.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +packaging==24.2 # via + # abi3audit # build # delocate +pefile==2024.8.26 + # via abi3audit pip==25.0.1 # via -r cibuildwheel/resources/constraints.in platformdirs==4.3.6 # via # python-discovery + # requests-cache # virtualenv +pyelftools==0.32 + # via abi3audit +pygments==2.19.2 + # via rich pyproject-hooks==1.2.0 # via build python-discovery==1.2.2 # via virtualenv +requests==2.32.4 + # via + # abi3audit + # requests-cache +requests-cache==1.2.1 + # via abi3audit +rich==13.8.1 + # via abi3audit tomli==2.4.1 # via build typing-extensions==4.13.2 # via + # cattrs # delocate + # exceptiongroup + # rich # virtualenv -virtualenv==21.2.1 +url-normalize==2.2.1 + # via requests-cache +urllib3==2.2.3 + # via + # requests + # requests-cache +virtualenv==21.2.4 # via -r cibuildwheel/resources/constraints.in zipp==3.20.2 # via importlib-metadata diff --git a/cibuildwheel/resources/constraints-python39.txt b/cibuildwheel/resources/constraints-python39.txt index 441105022..0838442a5 100644 --- a/cibuildwheel/resources/constraints-python39.txt +++ b/cibuildwheel/resources/constraints-python39.txt @@ -1,42 +1,92 @@ # This file was autogenerated by uv via the following command: # nox -s update_constraints +abi3audit==0.0.25 + # via -r cibuildwheel/resources/constraints.in +abi3info==2025.4.29 + # via abi3audit altgraph==0.17.5 # via macholib +attrs==26.1.0 + # via + # cattrs + # requests-cache build==1.4.3 # via -r cibuildwheel/resources/constraints.in +cattrs==25.3.0 + # via requests-cache +certifi==2026.2.25 + # via requests +charset-normalizer==3.4.7 + # via requests delocate==0.13.0 # via -r cibuildwheel/resources/constraints.in distlib==0.4.0 # via virtualenv +exceptiongroup==1.3.1 + # via cattrs filelock==3.19.1 # via # python-discovery # virtualenv +idna==3.11 + # via + # requests + # url-normalize importlib-metadata==8.7.1 # via build +kaitaistruct==0.11 + # via abi3audit macholib==1.16.4 # via delocate -packaging==26.0 +markdown-it-py==3.0.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +packaging==25.0 # via + # abi3audit # build # delocate +pefile==2024.8.26 + # via abi3audit pip==26.0.1 # via -r cibuildwheel/resources/constraints.in platformdirs==4.4.0 # via # python-discovery + # requests-cache # virtualenv +pyelftools==0.32 + # via abi3audit +pygments==2.20.0 + # via rich pyproject-hooks==1.2.0 # via build python-discovery==1.2.2 # via virtualenv +requests==2.32.5 + # via + # abi3audit + # requests-cache +requests-cache==1.2.1 + # via abi3audit +rich==14.2.0 + # via abi3audit tomli==2.4.1 # via build typing-extensions==4.15.0 # via + # cattrs # delocate + # exceptiongroup # virtualenv -virtualenv==21.2.1 +url-normalize==2.2.1 + # via requests-cache +urllib3==2.6.3 + # via + # requests + # requests-cache +virtualenv==21.2.4 # via -r cibuildwheel/resources/constraints.in -zipp==3.23.0 +zipp==3.23.1 # via importlib-metadata diff --git a/cibuildwheel/resources/constraints.in b/cibuildwheel/resources/constraints.in index 50bfabb6e..1f7841f01 100644 --- a/cibuildwheel/resources/constraints.in +++ b/cibuildwheel/resources/constraints.in @@ -2,3 +2,4 @@ pip build delocate virtualenv +abi3audit diff --git a/cibuildwheel/resources/constraints.txt b/cibuildwheel/resources/constraints.txt index 636dfbb8c..f9b23d07b 100644 --- a/cibuildwheel/resources/constraints.txt +++ b/cibuildwheel/resources/constraints.txt @@ -1,34 +1,82 @@ # This file was autogenerated by uv via the following command: # nox -s update_constraints +abi3audit==0.0.26 + # via -r cibuildwheel/resources/constraints.in +abi3info==2025.11.29 + # via abi3audit altgraph==0.17.5 # via macholib +attrs==26.1.0 + # via + # cattrs + # requests-cache build==1.4.3 # via -r cibuildwheel/resources/constraints.in +cattrs==26.1.0 + # via requests-cache +certifi==2026.2.25 + # via requests +charset-normalizer==3.4.7 + # via requests delocate==0.13.0 # via -r cibuildwheel/resources/constraints.in distlib==0.4.0 # via virtualenv -filelock==3.25.2 +filelock==3.28.0 # via # python-discovery # virtualenv +idna==3.11 + # via + # requests + # url-normalize +kaitaistruct==0.11 + # via abi3audit macholib==1.16.4 # via delocate -packaging==26.0 +markdown-it-py==4.0.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +packaging==26.1 # via + # abi3audit # build # delocate +pefile==2024.8.26 + # via abi3audit pip==26.0.1 # via -r cibuildwheel/resources/constraints.in platformdirs==4.9.6 # via # python-discovery + # requests-cache # virtualenv +pyelftools==0.32 + # via abi3audit +pygments==2.20.0 + # via rich pyproject-hooks==1.2.0 # via build python-discovery==1.2.2 # via virtualenv +requests==2.33.1 + # via + # abi3audit + # requests-cache +requests-cache==1.3.1 + # via abi3audit +rich==15.0.0 + # via abi3audit typing-extensions==4.15.0 - # via delocate -virtualenv==21.2.1 + # via + # cattrs + # delocate +url-normalize==2.2.1 + # via requests-cache +urllib3==2.6.3 + # via + # requests + # requests-cache +virtualenv==21.2.4 # via -r cibuildwheel/resources/constraints.in diff --git a/cibuildwheel/resources/defaults.toml b/cibuildwheel/resources/defaults.toml index 78895bf9a..ab7d31905 100644 --- a/cibuildwheel/resources/defaults.toml +++ b/cibuildwheel/resources/defaults.toml @@ -5,6 +5,8 @@ test-skip = "" enable = [] archs = ["auto"] +audit-requires = ["abi3audit"] +audit-command = "abi3audit --strict --report {abi3_wheel}" build-frontend = "default" config-settings = {} dependency-versions = "pinned" @@ -64,3 +66,4 @@ repair-wheel-command = "delocate-wheel --require-archs {delocate_archs} -w {dest [tool.cibuildwheel.ios] [tool.cibuildwheel.pyodide] +audit-command = "" diff --git a/cibuildwheel/util/packaging.py b/cibuildwheel/util/packaging.py index 82e3494c4..1dc71eaf0 100644 --- a/cibuildwheel/util/packaging.py +++ b/cibuildwheel/util/packaging.py @@ -177,3 +177,9 @@ 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) diff --git a/cibuildwheel/venv.py b/cibuildwheel/venv.py index dd4fe90aa..5e68da9b8 100644 --- a/cibuildwheel/venv.py +++ b/cibuildwheel/venv.py @@ -139,10 +139,7 @@ def virtualenv( python, venv_path, ) - paths = [str(venv_path), str(venv_path / "Scripts")] if _IS_WIN else [str(venv_path / "bin")] - venv_env = os.environ.copy() if env is None else env.copy() - venv_env["PATH"] = os.pathsep.join([*paths, venv_env["PATH"]]) - venv_env["VIRTUAL_ENV"] = str(venv_path) + venv_env = activate_virtualenv(venv_path, env=env) if not use_uv and pip_version == "embed": call( "python", @@ -158,6 +155,20 @@ def virtualenv( return venv_env +def activate_virtualenv( + venv_path: Path, + env: dict[str, str] | None = None, +) -> dict[str, str]: + """ + Return a copy of the environment with the virtualenv at `venv_path` activated. + """ + paths = [str(venv_path), str(venv_path / "Scripts")] if _IS_WIN else [str(venv_path / "bin")] + venv_env = os.environ.copy() if env is None else env.copy() + venv_env["PATH"] = os.pathsep.join([*paths, venv_env["PATH"]]) + venv_env["VIRTUAL_ENV"] = str(venv_path) + return venv_env + + def find_uv() -> Path | None: # Prefer uv in our environment with contextlib.suppress(ImportError, FileNotFoundError): diff --git a/docs/data/how-it-works.png b/docs/data/how-it-works.png index bbbc2a301..e066fcdd7 100644 Binary files a/docs/data/how-it-works.png and b/docs/data/how-it-works.png differ diff --git a/docs/diagram.html b/docs/diagram.html index 8d9785e2f..07e62c028 100644 --- a/docs/diagram.html +++ b/docs/diagram.html @@ -38,7 +38,7 @@
If tests are configured
@@ -200,6 +200,20 @@ }, }, ], + [ + { + env: "CIBW_AUDIT_COMMAND", + href: 'options/#audit-command', + label: 'audit wheel', + platforms: ['linux', 'macos', 'windows'], + style: 'dot', + tooltip: { + title: 'CIBW_AUDIT_COMMAND', + tag: 'Optional step', + description: 'Runs a shell command to check each built wheel. By default this runs abi3audit if produced wheels are abi3.' + }, + } + ], [ { href: 'options/#before-test', @@ -481,7 +495,7 @@ grid-column: 3 / -2; } .grid-outline.testVenv { - grid-column: 9 / span 3; + grid-column: 10 / span 3; } .grid-outline .outline { position: absolute; diff --git a/docs/faq.md b/docs/faq.md index db574127e..15b276ab6 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 fd84d83c0..eef50791e 100644 --- a/docs/options.md +++ b/docs/options.md @@ -967,24 +967,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 `&&`. @@ -1006,16 +993,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} ``` @@ -1235,6 +1212,10 @@ Platform-specific environment variables are also available:
dependency versions on Linux, use the [`manylinux-*` / `musllinux-*`](#linux-image) options. + There is one exception to this rule - when `audit-requires` is left as the + default `["abi3audit"]`, the `abi3audit` version is governed by this option, + because audits take place outside of the build container. + #### Examples !!! tab examples "pyproject.toml" @@ -1329,6 +1310,102 @@ The available Pyodide versions are determined by the version of `pyodide-build` ``` +## Auditing + +### `audit-requires` {: #audit-requires toml env-var } + +> Install Python dependencies for the audit step + +Default: `abi3audit` + +Space-separated list of package dependencies required for the audit command. +These are installed into an isolated environment before running the +[`audit-command`](#audit-command). + +If no audit command is specified, or no audit is required (i.e. your project builds non-abi3 wheels and the command refers only to abi3 wheels), then the audit environment won't be created and this option is ignored. + +If you leave this as the default, the versions of abi3audit and libraries are pinned according to [`dependency-versions`](#dependency-versions), even on Linux. + +#### Examples + +!!! tab examples "pyproject.toml" + + ```toml + # Install twine for wheel metadata checks + [tool.cibuildwheel] + audit-requires = "twine" + + # Install specific versions of audit dependencies + [tool.cibuildwheel] + audit-requires = ["twine==6.1.0", "abi3audit==0.0.17"] + ``` + + In configuration files, you can use an array, and the items will be joined with a space. + +!!! tab examples "Environment variables" + + ```yaml + # Install twine for wheel metadata checks + CIBW_AUDIT_REQUIRES: twine + + # Install specific versions of audit dependencies + CIBW_AUDIT_REQUIRES: twine==6.1.0 abi3audit==0.0.17 + ``` + +### `audit-command` {: #audit-command toml env-var } + +> Use a tool to check wheels before the end of the run + +Default: `abi3audit --strict --report {abi3_wheel}` + +Run shell commands to verify your wheels once they are built. Multiple commands can be passed, they should be separated with ` && `. In each command, you must use one of the following placeholders: + +- `{abi3_wheel}`: if your build produces an [ABI3 wheel](https://docs.python.org/3/c-api/stable.html#limited-c-api), as determined by the presence of an ABI3 tag in the filename, the command is run and this placeholder is substituted for the wheel path. +- `{wheel}`: inserts the wheel path for all wheels that were built. + +#### Examples + +!!! tab examples "pyproject.toml" + + ```toml + # Run a custom audit tool on all wheels + [tool.cibuildwheel] + audit-command = "my-audit-tool --check {wheel}" + + # Run multiple audit commands, one for abi3 wheels only and one for all wheels + [tool.cibuildwheel] + audit-command = [ + "./my-audit-tool --check-abi3 {abi3_wheel}", + "./my-audit-tool --check {wheel}", + ] + + # Use twine check to validate wheel metadata + [tool.cibuildwheel] + audit-requires = ["twine"] + audit-command = "twine check {wheel}" + + # Add an additional audit command using overrides, keeping the default abi3audit check + [[tool.cibuildwheel.overrides]] + select = "*" + inherit.audit-command = "append" + audit-command = "twine check {wheel}" + ``` + +!!! tab examples "Environment variables" + + ```yaml + # Run a custom audit tool on all wheels + CIBW_AUDIT_COMMAND: "my-audit-tool --check {wheel}" + + # Run multiple audit commands + CIBW_AUDIT_COMMAND: "./my-audit-tool --check-abi3 {abi3_wheel} && ./my-audit-tool --check {wheel}" + + # Use twine check to validate wheel metadata + CIBW_AUDIT_REQUIRES: "twine" + CIBW_AUDIT_COMMAND: "twine check {wheel}" + ``` + + ## Testing ### `test-command` {: #test-command env-var toml} diff --git a/test/test_abi3audit.py b/test/test_abi3audit.py new file mode 100644 index 000000000..adacf25c5 --- /dev/null +++ b/test/test_abi3audit.py @@ -0,0 +1,215 @@ +import subprocess +import textwrap +from pathlib import Path + +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 sys + 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 sys + 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("abi3audit is disabled on Pyodide (wasm shared objects are not supported)") +def test_abi3audit_runs_on_abi3_wheel(tmp_path: Path, capfd: pytest.CaptureFixture[str]) -> None: + """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 audit command: abi3audit" in captured.out + + +def test_abi3audit_skipped_for_non_abi3_wheel( + tmp_path: Path, capfd: pytest.CaptureFixture[str] +) -> None: + """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 audit command: abi3audit" not in captured.out + + +@utils.skip_if_pyodide("abi3audit is disabled on Pyodide (wasm shared objects are not supported)") +def test_abi3audit_detects_violation(tmp_path: Path, capfd: pytest.CaptureFixture[str]) -> None: + """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 audit command: abi3audit" in captured.out + + +def test_custom_audit_command(tmp_path: Path, capfd: pytest.CaptureFixture[str]) -> None: + project_dir = tmp_path / "project" + test_projects.new_c_project().generate(project_dir) + + actual_wheels = utils.cibuildwheel_run( + project_dir, + add_env={ + "CIBW_AUDIT_COMMAND": "echo custom-audit-ok {wheel}", + "CIBW_AUDIT_REQUIRES": "", + "CIBW_ARCHS": "native", + }, + single_python=True, + ) + + assert len(actual_wheels) >= 1 + captured = capfd.readouterr() + assert "Auditing wheel" in captured.out + assert "custom-audit-ok" in captured.out + + +def test_custom_audit_requires(tmp_path: Path, capfd: pytest.CaptureFixture[str]) -> None: + project_dir = tmp_path / "project" + test_projects.new_c_project().generate(project_dir) + + actual_wheels = utils.cibuildwheel_run( + project_dir, + add_env={ + "CIBW_AUDIT_REQUIRES": "pycowsay", + "CIBW_AUDIT_COMMAND": "pycowsay moo {wheel}", + "CIBW_ARCHS": "native", + }, + single_python=True, + ) + + assert len(actual_wheels) >= 1 + captured = capfd.readouterr() + assert "Installing audit dependencies: pycowsay" in captured.out + assert "moo" in captured.out + + +def test_empty_audit_command_disables_audit( + tmp_path: Path, capfd: pytest.CaptureFixture[str] +) -> None: + project_dir = tmp_path / "project" + test_projects.new_c_project().generate(project_dir) + + actual_wheels = utils.cibuildwheel_run( + project_dir, + add_env={ + "CIBW_AUDIT_COMMAND": "", + "CIBW_ARCHS": "native", + }, + single_python=True, + ) + + assert len(actual_wheels) >= 1 + captured = capfd.readouterr() + assert "Auditing wheel" not in captured.out + + +def test_custom_audit_command_failure(tmp_path: Path) -> None: + project_dir = tmp_path / "project" + test_projects.new_c_project().generate(project_dir) + + with pytest.raises(subprocess.CalledProcessError): + utils.cibuildwheel_run( + project_dir, + add_env={ + "CIBW_AUDIT_COMMAND": 'python -c "import sys; sys.exit(1)" {wheel}', + "CIBW_AUDIT_REQUIRES": "", + "CIBW_ARCHS": "native", + }, + single_python=True, + ) diff --git a/test/test_abi_variants.py b/test/test_abi_variants.py index 25fc9ca5d..b8f180ce0 100644 --- a/test/test_abi_variants.py +++ b/test/test_abi_variants.py @@ -14,6 +14,7 @@ limited_api_project = test_projects.new_c_project( setup_py_add=textwrap.dedent( r""" + import sys import sysconfig IS_CPYTHON = sys.implementation.name == "cpython" diff --git a/unit_test/audit_test.py b/unit_test/audit_test.py new file mode 100644 index 000000000..49495d0ce --- /dev/null +++ b/unit_test/audit_test.py @@ -0,0 +1,139 @@ +import contextlib +import subprocess +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest + +from cibuildwheel import errors +from cibuildwheel.audit import needs_audit, run_audit + + +def mock_virtualenv() -> contextlib.AbstractContextManager[Mock]: + return patch( + "cibuildwheel.audit.virtualenv", + return_value={ + "PATH": "/bin", + "VIRTUAL_ENV": "/tmp/v", + }, + ) + + +class TestNeedsAudit: + def test_empty_commands(self) -> None: + assert needs_audit([], "example-1.0.0-cp310-cp310-manylinux_2_17_x86_64.whl") is False + + def test_wheel_placeholder_matches_any_wheel(self) -> None: + assert needs_audit( + ["my-tool {wheel}"], "example-1.0.0-cp310-cp310-manylinux_2_17_x86_64.whl" + ) + + def test_abi3_placeholder_skips_non_abi3(self) -> None: + assert ( + needs_audit( + ["abi3audit {abi3_wheel}"], "example-1.0.0-cp310-cp310-manylinux_2_17_x86_64.whl" + ) + is False + ) + + def test_abi3_placeholder_matches_abi3(self) -> None: + assert needs_audit( + ["abi3audit {abi3_wheel}"], "example-1.0.0-cp38-abi3-manylinux_2_17_x86_64.whl" + ) + + def test_mixed_commands_matches_if_any_applies(self) -> None: + commands = ["abi3audit {abi3_wheel}", "twine check {wheel}"] + # non-abi3 wheel still needs audit because of the {wheel} command + assert needs_audit(commands, "example-1.0.0-cp310-cp310-manylinux_2_17_x86_64.whl") + + def test_command_without_placeholder_raises(self) -> None: + with pytest.raises(errors.ConfigurationError, match="must contain either"): + needs_audit(["my-tool"], "example-1.0.0-cp310-cp310-manylinux_2_17_x86_64.whl") + + +class TestRunAudit: + @pytest.fixture + def mock_build_options(self) -> Mock: + opts = Mock() + opts.audit_command = [] + opts.audit_requires = [] + opts.package_dir = Path("/fake/package") + opts.build_frontend.name = "build" + opts.dependency_constraints.get_for_python_version.return_value = None + return opts + + def test_no_commands_does_nothing(self, tmp_path: Path, mock_build_options: Mock) -> None: + wheel = tmp_path / "example-1.0.0-cp310-cp310-manylinux_2_17_x86_64.whl" + mock_build_options.audit_command = [] + + with patch("cibuildwheel.audit.shell") as mock_shell: + run_audit(tmp_dir=tmp_path, build_options=mock_build_options, wheel=wheel) + mock_shell.assert_not_called() + + def test_runs_wheel_command(self, tmp_path: Path, mock_build_options: Mock) -> None: + wheel = tmp_path / "example-1.0.0-cp310-cp310-manylinux_2_17_x86_64.whl" + mock_build_options.audit_command = ["my-tool {wheel}"] + + with mock_virtualenv(), patch("cibuildwheel.audit.shell") as mock_shell: + run_audit(tmp_dir=tmp_path, build_options=mock_build_options, wheel=wheel) + mock_shell.assert_called_once() + cmd = mock_shell.call_args[0][0] + assert str(wheel) in cmd + + def test_abi3_command_skipped_for_non_abi3( + self, tmp_path: Path, mock_build_options: Mock + ) -> None: + wheel = tmp_path / "example-1.0.0-cp310-cp310-manylinux_2_17_x86_64.whl" + mock_build_options.audit_command = ["abi3audit {abi3_wheel}"] + + with patch("cibuildwheel.audit.shell") as mock_shell: + run_audit(tmp_dir=tmp_path, build_options=mock_build_options, wheel=wheel) + mock_shell.assert_not_called() + + def test_abi3_command_runs_for_abi3(self, tmp_path: Path, mock_build_options: Mock) -> None: + wheel = tmp_path / "example-1.0.0-cp38-abi3-manylinux_2_17_x86_64.whl" + mock_build_options.audit_command = ["abi3audit {abi3_wheel}"] + + with ( + mock_virtualenv(), + patch("cibuildwheel.audit.shell") as mock_shell, + ): + run_audit(tmp_dir=tmp_path, build_options=mock_build_options, wheel=wheel) + mock_shell.assert_called_once() + cmd = mock_shell.call_args[0][0] + assert str(wheel) in cmd + + def test_raises_on_command_failure(self, tmp_path: Path, mock_build_options: Mock) -> None: + wheel = tmp_path / "example-1.0.0-cp310-cp310-manylinux_2_17_x86_64.whl" + mock_build_options.audit_command = ["failing-tool {wheel}"] + + with ( + mock_virtualenv(), + patch( + "cibuildwheel.audit.shell", + side_effect=subprocess.CalledProcessError(1, "failing-tool"), + ), + pytest.raises(errors.AuditCommandFailedError), + ): + run_audit(tmp_dir=tmp_path, build_options=mock_build_options, wheel=wheel) + + def test_multiple_commands_all_run(self, tmp_path: Path, mock_build_options: Mock) -> None: + wheel = tmp_path / "example-1.0.0-cp310-cp310-manylinux_2_17_x86_64.whl" + mock_build_options.audit_command = ["tool-a {wheel}", "tool-b {wheel}"] + + with ( + mock_virtualenv(), + patch("cibuildwheel.audit.shell") as mock_shell, + ): + run_audit(tmp_dir=tmp_path, build_options=mock_build_options, wheel=wheel) + assert mock_shell.call_count == 2 + + def test_both_placeholders_raises(self, tmp_path: Path, mock_build_options: Mock) -> None: + wheel = tmp_path / "example-1.0.0-cp38-abi3-manylinux_2_17_x86_64.whl" + mock_build_options.audit_command = ["my-tool {wheel} {abi3_wheel}"] + + with ( + mock_virtualenv(), + pytest.raises(errors.ConfigurationError, match="cannot contain both"), + ): + run_audit(tmp_dir=tmp_path, build_options=mock_build_options, wheel=wheel) diff --git a/unit_test/main_tests/main_options_test.py b/unit_test/main_tests/main_options_test.py index 8e3794d6b..ed6a4abf2 100644 --- a/unit_test/main_tests/main_options_test.py +++ b/unit_test/main_tests/main_options_test.py @@ -370,6 +370,30 @@ def test_test_requires( assert build_options.test_requires == (test_requires or "").split() +@pytest.mark.parametrize("audit_requires", [None, "abi3audit", "abi3audit custom-audit-tool"]) +@pytest.mark.parametrize("platform_specific", [False, True]) +def test_audit_requires( + audit_requires: str | None, + platform_specific: bool, + platform: str, + intercepted_build_args: "ArgsInterceptor", + monkeypatch: pytest.MonkeyPatch, +) -> None: + if audit_requires is not None: + if platform_specific: + monkeypatch.setenv("CIBW_AUDIT_REQUIRES_" + platform.upper(), audit_requires) + monkeypatch.setenv("CIBW_AUDIT_REQUIRES", "overwritten") + else: + monkeypatch.setenv("CIBW_AUDIT_REQUIRES", audit_requires) + + main() + + build_options = intercepted_build_args.args[0].build_options(identifier=None) + + expected = (audit_requires or "abi3audit").split() + assert build_options.audit_requires == expected + + @pytest.mark.parametrize("test_extras", [None, "extras"]) @pytest.mark.parametrize("platform_specific", [False, True]) def test_test_extras( diff --git a/unit_test/options_toml_test.py b/unit_test/options_toml_test.py index 429dba767..0689be824 100644 --- a/unit_test/options_toml_test.py +++ b/unit_test/options_toml_test.py @@ -541,3 +541,162 @@ def test_overrides_inherit(tmp_path: Path) -> None: options_reader.get("config-settings", option_format=ShlexTableFormat()) == "key1=value1 key2=override2 empty='' key3=value3" ) + + +def test_audit_command_option(tmp_path: Path, platform: PlatformName) -> None: + pyproject_toml: Path = tmp_path / "pyproject.toml" + pyproject_toml.write_text( + """ +[tool.cibuildwheel] +audit-command = "abi3audit {abi3_wheel}" +""" + ) + + options_reader = OptionsReader(pyproject_toml, platform=platform, env={}) + assert ( + options_reader.get("audit-command", option_format=ListFormat(" && ")) + == "abi3audit {abi3_wheel}" + ) + + +def test_audit_command_option_list(tmp_path: Path, platform: PlatformName) -> None: + pyproject_toml: Path = tmp_path / "pyproject.toml" + pyproject_toml.write_text( + """ +[tool.cibuildwheel] +audit-command = ["first command", "second command"] +""" + ) + + options_reader = OptionsReader(pyproject_toml, platform=platform, env={}) + assert ( + options_reader.get("audit-command", option_format=ListFormat(" && ")) + == "first command && second command" + ) + + +def test_audit_command_option_env(tmp_path: Path, platform: PlatformName) -> None: + pyproject_toml: Path = tmp_path / "pyproject.toml" + pyproject_toml.write_text( + """ +[tool.cibuildwheel] +""" + ) + + options_reader = OptionsReader( + pyproject_toml, platform=platform, env={"CIBW_AUDIT_COMMAND": "my-audit-tool {wheel}"} + ) + assert ( + options_reader.get("audit-command", option_format=ListFormat(" && ")) + == "my-audit-tool {wheel}" + ) + + +def test_audit_requires_option(tmp_path: Path, platform: PlatformName) -> None: + pyproject_toml: Path = tmp_path / "pyproject.toml" + pyproject_toml.write_text( + """ +[tool.cibuildwheel] +audit-requires = "abi3audit" +""" + ) + + options_reader = OptionsReader(pyproject_toml, platform=platform, env={}) + assert options_reader.get("audit-requires", option_format=ListFormat(" ")) == "abi3audit" + + +def test_audit_requires_option_list(tmp_path: Path, platform: PlatformName) -> None: + pyproject_toml: Path = tmp_path / "pyproject.toml" + pyproject_toml.write_text( + """ +[tool.cibuildwheel] +audit-requires = ["abi3audit", "twine"] +""" + ) + + options_reader = OptionsReader(pyproject_toml, platform=platform, env={}) + assert options_reader.get("audit-requires", option_format=ListFormat(" ")) == "abi3audit twine" + + +def test_audit_requires_option_env(tmp_path: Path, platform: PlatformName) -> None: + pyproject_toml: Path = tmp_path / "pyproject.toml" + pyproject_toml.write_text( + """ +[tool.cibuildwheel] +""" + ) + + options_reader = OptionsReader( + pyproject_toml, platform=platform, env={"CIBW_AUDIT_REQUIRES": "custom-audit-tool"} + ) + assert ( + options_reader.get("audit-requires", option_format=ListFormat(" ")) == "custom-audit-tool" + ) + + +def test_audit_requires_option_env_override(tmp_path: Path, platform: PlatformName) -> None: + pyproject_toml: Path = tmp_path / "pyproject.toml" + pyproject_toml.write_text( + """ +[tool.cibuildwheel] +audit-requires = "abi3audit" +""" + ) + + options_reader = OptionsReader( + pyproject_toml, platform=platform, env={"CIBW_AUDIT_REQUIRES": "custom-audit-tool"} + ) + assert ( + options_reader.get("audit-requires", option_format=ListFormat(" ")) == "custom-audit-tool" + ) + + +def test_audit_requires_platform_specific(tmp_path: Path) -> None: + pyproject_toml: Path = tmp_path / "pyproject.toml" + pyproject_toml.write_text( + """ +[tool.cibuildwheel] +audit-requires = "abi3audit" + +[tool.cibuildwheel.linux] +audit-requires = ["abi3audit", "check-wheel-contents"] # whatever + +[tool.cibuildwheel.macos] +audit-requires = ["check-wheel-contents", "pydistcheck"] # whatever +""" + ) + + linux_reader = OptionsReader(pyproject_toml, platform="linux", env={}) + assert ( + linux_reader.get("audit-requires", option_format=ListFormat(" ")) + == "abi3audit check-wheel-contents" + ) + + macos_reader = OptionsReader(pyproject_toml, platform="macos", env={}) + assert ( + macos_reader.get("audit-requires", option_format=ListFormat(" ")) + == "check-wheel-contents pydistcheck" + ) + + windows_reader = OptionsReader(pyproject_toml, platform="windows", env={}) + assert windows_reader.get("audit-requires", option_format=ListFormat(" ")) == "abi3audit" + + +def test_audit_requires_platform_env_override(tmp_path: Path) -> None: + pyproject_toml: Path = tmp_path / "pyproject.toml" + pyproject_toml.write_text( + """ +[tool.cibuildwheel] +audit-requires = "abi3audit" +""" + ) + + options_reader = OptionsReader( + pyproject_toml, + platform="linux", + env={ + "CIBW_AUDIT_REQUIRES": "some-fallback-tool", + "CIBW_AUDIT_REQUIRES_LINUX": "linux-audit-tool", + }, + ) + assert options_reader.get("audit-requires", option_format=ListFormat(" ")) == "linux-audit-tool" diff --git a/unit_test/utils_test.py b/unit_test/utils_test.py index 3ce67e273..7a58ad498 100644 --- a/unit_test/utils_test.py +++ b/unit_test/utils_test.py @@ -15,7 +15,7 @@ unwrap, unwrap_preserving_paragraphs, ) -from cibuildwheel.util.packaging import find_compatible_wheel +from cibuildwheel.util.packaging import find_compatible_wheel, is_abi3_wheel def test_format_safe() -> None: @@ -401,3 +401,23 @@ def test_unwrap_preserving_paragraphs() -> None: """) == "paragraph one\n\nparagraph two" ) + + +class TestIsAbi3Wheel: + def test_abi3_wheel(self) -> None: + assert is_abi3_wheel("foo-1.0-cp310-abi3-manylinux_2_28_x86_64.whl") is True + + def test_abi3_wheel_macos(self) -> None: + assert is_abi3_wheel("foo-1.0-cp311-abi3-macosx_11_0_arm64.whl") is True + + def test_abi3_wheel_windows(self) -> None: + assert is_abi3_wheel("foo-1.0-cp310-abi3-win_amd64.whl") is True + + def test_cpython_wheel(self) -> None: + assert is_abi3_wheel("foo-1.0-cp310-cp310-manylinux_2_28_x86_64.whl") is False + + def test_none_any_wheel(self) -> None: + assert is_abi3_wheel("foo-1.0-py3-none-any.whl") is False + + def test_none_platform_wheel(self) -> None: + assert is_abi3_wheel("foo-1.0-cp310-none-win_amd64.whl") is False