feet: general approach to auditing wheels with abi3audit default (#2805)

* WIP - initial punt at audit command

* Add `abi3audit` as a dependency

* Add helper functions to check stable ABI wheels

* Run `abi3audit` for macOS and Windows wheels

* Copy out of container for repairing?

* Add some notes that `cibuildwheel` runs `abi3audit`

* Add basic unit tests

* Add a basic C extension with `Py_LIMITED_API`

* Add a test project that violates Stable ABI

* Fix linux test

* Skip abi3 wheel tests for Pyodide

* Patch the correct subprocess module

* wrap cleanup of abi3audit dir

* Write the docs for the new options

* Move to above testing in docs

* Implement audit-requires and audit-command

* Some cleanups after self-review

* Add default value

* fix type errors

* the key is `audit-command`, not `audit`

* Add a variety of tests for audit requires options

* Add `test_audit_requires` similar to `test_test_requires`

* Add some configurability-related audit tests

* Fix parsing error with options docs leaving out commands

* Better way to extract version (maybe helps Pyodide?)

* Fix a case of unbound `use_uv`

* Standardise: rename to `abi3_wheel`

* Fix audit command run message

* Simplify custom audit command a bit

* Remove unnecessary skip for Pyodide

* Pyodide should have no default audit command

* More accurate skip messages for Pyodide skips

* Wheels are audited after they are repaired

* Regenerate constraints to include `abi3audit`

* Fix typos

* Some attempts for Windows fixes

* Check `pyvenv.cfg` instead of directory existence

* Add validation for lack of wheel placeholders

* Try yet another Windows `uv` fix

* Regenerate diagram and re-trigger Azure CI

* Add missing `import sys` for abi3 C extension tests

* Remove audit-command at the global level

* Clarify `abi3audit` pinning a little bit

* Regen constraints

* Discard changes to cibuildwheel/resources/constraints-pyodide312.txt

* Discard changes to cibuildwheel/resources/constraints-pyodide313.txt

* try opt-in uv again

* fix issue on windows on Python 3.13 related to nested venvs

On win / python 3.13, virtualenv creates a venv where the 'home'
points back to the venv that sys.executable was running in, rather
than the root install. that seemingly leads to problems with package
resolution, where pip.exe couldn't find the pip python package.
this appears to fix it!

* Update constraints

* chore: revert python-discovery bump

Assisted-by: OpenCode:glm-5.1
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>

* fix: restore workaround for graalpy

Assisted-by: OpenCode:glm-5.1
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>

---------

Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
Co-authored-by: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com>
Co-authored-by: Henry Schreiner <henryfs@princeton.edu>
This commit is contained in:
Joe Rickerby
2026-05-14 07:41:14 -07:00
committed by GitHub
co-authored by Agriya Khetarpal Henry Schreiner
parent e04baff444
commit 79244d366c
37 changed files with 1425 additions and 112 deletions
+3 -1
View File
@@ -155,6 +155,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 |
@@ -169,7 +171,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 |
<!--[[[end]]] (sum: dbfwOkj/k/) -->
<!--[[[end]]] (sum: b7YIjCyCkf) -->
These options can be specified in a pyproject.toml file, or as environment variables, see [configuration docs](https://cibuildwheel.pypa.io/en/latest/configuration/).
+8
View File
@@ -38,6 +38,12 @@ additionalProperties: false
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
@@ -308,6 +314,8 @@ items:
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"}
+132
View File
@@ -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
+6
View File
@@ -103,3 +103,9 @@ class RepairStepProducedMultipleWheelsError(FatalError):
)
super().__init__(message)
self.return_code = 8
class AuditCommandFailedError(FatalError):
def __init__(self, message: str) -> None:
super().__init__(message)
self.return_code = 9
+13
View File
@@ -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
@@ -892,6 +894,15 @@ class Options:
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,
@@ -915,6 +926,8 @@ class Options:
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:
+2
View File
@@ -24,6 +24,7 @@ from filelock import FileLock
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,
@@ -154,6 +155,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)
+5 -2
View File
@@ -14,6 +14,7 @@ from filelock import FileLock
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,
@@ -546,10 +547,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")
+14
View File
@@ -1,5 +1,6 @@
import contextlib
import dataclasses
import shutil
import subprocess
import sys
import textwrap
@@ -10,6 +11,7 @@ from typing import TYPE_CHECKING, assert_never
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, prepare_config_settings
from cibuildwheel.logger import log
from cibuildwheel.oci_container import OCIContainer, OCIContainerEngineConfig, OCIPlatform
@@ -372,6 +374,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...")
+3
View File
@@ -17,6 +17,7 @@ from packaging.version import Version
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 (
@@ -576,6 +577,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()
testing_archs: list[Literal["x86_64", "arm64"]]
+3
View File
@@ -16,6 +16,7 @@ from filelock import FileLock
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, prepare_config_settings
from cibuildwheel.logger import log
@@ -463,6 +464,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...")
+3
View File
@@ -14,6 +14,7 @@ from filelock import FileLock
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,
@@ -585,6 +586,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(
@@ -26,6 +26,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": [
@@ -634,6 +664,12 @@
"type": "object",
"additionalProperties": false,
"properties": {
"audit-command": {
"$ref": "#/$defs/inherit"
},
"audit-requires": {
"$ref": "#/$defs/inherit"
},
"before-all": {
"$ref": "#/$defs/inherit"
},
@@ -681,6 +717,12 @@
}
}
},
"audit-command": {
"$ref": "#/properties/audit-command"
},
"audit-requires": {
"$ref": "#/properties/audit-requires"
},
"before-all": {
"$ref": "#/properties/before-all"
},
@@ -799,6 +841,12 @@
"type": "object",
"additionalProperties": false,
"properties": {
"audit-command": {
"$ref": "#/properties/audit-command"
},
"audit-requires": {
"$ref": "#/properties/audit-requires"
},
"archs": {
"$ref": "#/properties/archs"
},
@@ -929,6 +977,12 @@
"type": "object",
"additionalProperties": false,
"properties": {
"audit-command": {
"$ref": "#/properties/audit-command"
},
"audit-requires": {
"$ref": "#/properties/audit-requires"
},
"archs": {
"$ref": "#/properties/archs"
},
@@ -992,6 +1046,12 @@
"type": "object",
"additionalProperties": false,
"properties": {
"audit-command": {
"$ref": "#/properties/audit-command"
},
"audit-requires": {
"$ref": "#/properties/audit-requires"
},
"archs": {
"$ref": "#/properties/archs"
},
@@ -1068,6 +1128,12 @@
"type": "object",
"additionalProperties": false,
"properties": {
"audit-command": {
"$ref": "#/properties/audit-command"
},
"audit-requires": {
"$ref": "#/properties/audit-requires"
},
"archs": {
"$ref": "#/properties/archs"
},
@@ -1131,6 +1197,12 @@
"type": "object",
"additionalProperties": false,
"properties": {
"audit-command": {
"$ref": "#/properties/audit-command"
},
"audit-requires": {
"$ref": "#/properties/audit-requires"
},
"archs": {
"$ref": "#/properties/archs"
},
@@ -1194,6 +1266,12 @@
"type": "object",
"additionalProperties": false,
"properties": {
"audit-command": {
"$ref": "#/properties/audit-command"
},
"audit-requires": {
"$ref": "#/properties/audit-requires"
},
"archs": {
"$ref": "#/properties/archs"
},
@@ -23,7 +23,7 @@ filelock==3.29.0
# via
# python-discovery
# virtualenv
idna==3.14
idna==3.15
# via requests
leb128==1.0.9
# via auditwheel-emscripten
@@ -37,8 +37,7 @@ packaging==26.2
# build
# pyodide-build
# wheel
pip==26.1.1; implementation_name != "graalpy" or platform_system != "Windows"
pip==26.0.1; implementation_name == "graalpy" and platform_system == "Windows"
pip==26.1.1
# via -r .nox/update_constraints/tmp/constraints-pyodide.in
platformdirs==4.9.6
# via
@@ -63,9 +62,9 @@ pyodide-lock==0.1.3
# via pyodide-build
pyproject-hooks==1.2.0
# via build
python-discovery==1.3.0
python-discovery==1.3.1
# via virtualenv
requests==2.34.0
requests==2.34.1
# via pyodide-build
rich==15.0.0
# via
@@ -82,7 +81,7 @@ typing-inspection==0.4.2
# via pydantic
urllib3==2.7.0
# via requests
virtualenv==21.3.1
virtualenv==21.3.3
# via
# build
# pyodide-build
@@ -23,7 +23,7 @@ filelock==3.29.0
# via
# python-discovery
# virtualenv
idna==3.14
idna==3.15
# via requests
leb128==1.0.9
# via auditwheel-emscripten
@@ -62,9 +62,9 @@ pyodide-lock==0.1.3
# via pyodide-build
pyproject-hooks==1.2.0
# via build
python-discovery==1.3.0
python-discovery==1.3.1
# via virtualenv
requests==2.34.0
requests==2.34.1
# via pyodide-build
rich==15.0.0
# via
@@ -81,7 +81,7 @@ typing-inspection==0.4.2
# via pydantic
urllib3==2.7.0
# via requests
virtualenv==21.3.1
virtualenv==21.3.3
# via
# build
# pyodide-build
@@ -23,7 +23,7 @@ filelock==3.29.0
# via
# python-discovery
# virtualenv
idna==3.14
idna==3.15
# via requests
leb128==1.0.9
# via auditwheel-emscripten
@@ -62,9 +62,9 @@ pyodide-lock==0.1.3
# via pyodide-build
pyproject-hooks==1.2.0
# via build
python-discovery==1.3.0
python-discovery==1.3.1
# via virtualenv
requests==2.34.0
requests==2.34.1
# via pyodide-build
rich==15.0.0
# via
@@ -81,7 +81,7 @@ typing-inspection==0.4.2
# via pydantic
urllib3==2.7.0
# via requests
virtualenv==21.3.1
virtualenv==21.3.3
# via
# build
# pyodide-build
@@ -1,47 +1,96 @@
# 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.5.0
# via -r cibuildwheel/resources/constraints.in
cattrs==26.1.0
# via requests-cache
certifi==2026.4.22
# via requests
charset-normalizer==3.4.7
# via requests
delocate==0.13.0
# via -r cibuildwheel/resources/constraints.in
delvewheel==1.12.1
# via -r cibuildwheel/resources/constraints.in
distlib==0.4.0
# via virtualenv
exceptiongroup==1.3.1
# via cattrs
filelock==3.29.0
# via
# python-discovery
# virtualenv
idna==3.15
# via
# requests
# url-normalize
importlib-metadata==9.0.0
# via build
kaitaistruct==0.11
# via abi3audit
macholib==1.16.4
# via delocate
markdown-it-py==4.2.0
# via rich
mdurl==0.1.2
# via markdown-it-py
packaging==26.2
# via
# abi3audit
# build
# delocate
pefile==2024.8.26
# via delvewheel
pip==26.1.1; implementation_name != "graalpy" or platform_system != "Windows"
pip==26.0.1; implementation_name == "graalpy" and platform_system == "Windows"
# via
# abi3audit
# delvewheel
pip==26.1.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.3.0
python-discovery==1.3.1
# via virtualenv
requests==2.34.1
# via
# abi3audit
# requests-cache
requests-cache==1.3.2
# 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.3.1
url-normalize==3.0.0
# via requests-cache
urllib3==2.7.0
# via
# requests
# requests-cache
virtualenv==21.3.3
# via -r cibuildwheel/resources/constraints.in
zipp==3.23.1
# via importlib-metadata
@@ -1,9 +1,23 @@
# 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.5.0
# via -r cibuildwheel/resources/constraints.in
cattrs==26.1.0
# via requests-cache
certifi==2026.4.22
# via requests
charset-normalizer==3.4.7
# via requests
delocate==0.13.0
# via -r cibuildwheel/resources/constraints.in
delvewheel==1.12.1
@@ -14,26 +28,60 @@ filelock==3.29.0
# via
# python-discovery
# virtualenv
idna==3.15
# via
# requests
# url-normalize
kaitaistruct==0.11
# via abi3audit
macholib==1.16.4
# via delocate
markdown-it-py==4.2.0
# via rich
mdurl==0.1.2
# via markdown-it-py
packaging==26.2
# via
# abi3audit
# build
# delocate
pefile==2024.8.26
# via delvewheel
# via
# abi3audit
# delvewheel
pip==26.1.1; implementation_name != "graalpy" or platform_system != "Windows"
pip==26.0.1; implementation_name == "graalpy" and platform_system == "Windows"
# 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.3.0
python-discovery==1.3.1
# via virtualenv
requests==2.34.1
# via
# abi3audit
# requests-cache
requests-cache==1.3.2
# via abi3audit
rich==15.0.0
# via abi3audit
typing-extensions==4.15.0
# via delocate
virtualenv==21.3.1
# via
# cattrs
# delocate
url-normalize==3.0.0
# via requests-cache
urllib3==2.7.0
# via
# requests
# requests-cache
virtualenv==21.3.3
# via -r cibuildwheel/resources/constraints.in
@@ -1,9 +1,23 @@
# 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.5.0
# via -r cibuildwheel/resources/constraints.in
cattrs==26.1.0
# via requests-cache
certifi==2026.4.22
# via requests
charset-normalizer==3.4.7
# via requests
delocate==0.13.0
# via -r cibuildwheel/resources/constraints.in
delvewheel==1.12.1
@@ -14,26 +28,60 @@ filelock==3.29.0
# via
# python-discovery
# virtualenv
idna==3.15
# via
# requests
# url-normalize
kaitaistruct==0.11
# via abi3audit
macholib==1.16.4
# via delocate
markdown-it-py==4.2.0
# via rich
mdurl==0.1.2
# via markdown-it-py
packaging==26.2
# via
# abi3audit
# build
# delocate
pefile==2024.8.26
# via delvewheel
# via
# abi3audit
# delvewheel
pip==26.1.1; implementation_name != "graalpy" or platform_system != "Windows"
pip==26.0.1; implementation_name == "graalpy" and platform_system == "Windows"
# 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.3.0
python-discovery==1.3.1
# via virtualenv
requests==2.34.1
# via
# abi3audit
# requests-cache
requests-cache==1.3.2
# via abi3audit
rich==15.0.0
# via abi3audit
typing-extensions==4.15.0
# via delocate
virtualenv==21.3.1
# via
# cattrs
# delocate
url-normalize==3.0.0
# via requests-cache
urllib3==2.7.0
# via
# requests
# requests-cache
virtualenv==21.3.3
# via -r cibuildwheel/resources/constraints.in
@@ -1,9 +1,23 @@
# 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.5.0
# via -r cibuildwheel/resources/constraints.in
cattrs==26.1.0
# via requests-cache
certifi==2026.4.22
# via requests
charset-normalizer==3.4.7
# via requests
delocate==0.13.0
# via -r cibuildwheel/resources/constraints.in
delvewheel==1.12.1
@@ -14,26 +28,59 @@ filelock==3.29.0
# via
# python-discovery
# virtualenv
idna==3.15
# via
# requests
# url-normalize
kaitaistruct==0.11
# via abi3audit
macholib==1.16.4
# via delocate
markdown-it-py==4.2.0
# via rich
mdurl==0.1.2
# via markdown-it-py
packaging==26.2
# via
# abi3audit
# build
# delocate
pefile==2024.8.26
# via delvewheel
pip==26.1.1; implementation_name != "graalpy" or platform_system != "Windows"
pip==26.0.1; implementation_name == "graalpy" and platform_system == "Windows"
# via
# abi3audit
# delvewheel
pip==26.1.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.3.0
python-discovery==1.3.1
# via virtualenv
requests==2.34.1
# via
# abi3audit
# requests-cache
requests-cache==1.3.2
# via abi3audit
rich==15.0.0
# via abi3audit
typing-extensions==4.15.0
# via delocate
virtualenv==21.3.1
# via
# cattrs
# delocate
url-normalize==3.0.0
# via requests-cache
urllib3==2.7.0
# via
# requests
# requests-cache
virtualenv==21.3.3
# via -r cibuildwheel/resources/constraints.in
@@ -1,9 +1,23 @@
# 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.5.0
# via -r cibuildwheel/resources/constraints.in
cattrs==26.1.0
# via requests-cache
certifi==2026.4.22
# via requests
charset-normalizer==3.4.7
# via requests
delocate==0.13.0
# via -r cibuildwheel/resources/constraints.in
delvewheel==1.12.1
@@ -14,26 +28,59 @@ filelock==3.29.0
# via
# python-discovery
# virtualenv
idna==3.15
# via
# requests
# url-normalize
kaitaistruct==0.11
# via abi3audit
macholib==1.16.4
# via delocate
markdown-it-py==4.2.0
# via rich
mdurl==0.1.2
# via markdown-it-py
packaging==26.2
# via
# abi3audit
# build
# delocate
pefile==2024.8.26
# via delvewheel
pip==26.1.1; implementation_name != "graalpy" or platform_system != "Windows"
pip==26.0.1; implementation_name == "graalpy" and platform_system == "Windows"
# via
# abi3audit
# delvewheel
pip==26.1.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.3.0
python-discovery==1.3.1
# via virtualenv
requests==2.34.1
# via
# abi3audit
# requests-cache
requests-cache==1.3.2
# via abi3audit
rich==15.0.0
# via abi3audit
typing-extensions==4.15.0
# via delocate
virtualenv==21.3.1
# via
# cattrs
# delocate
url-normalize==3.0.0
# via requests-cache
urllib3==2.7.0
# via
# requests
# requests-cache
virtualenv==21.3.3
# via -r cibuildwheel/resources/constraints.in
@@ -1,9 +1,23 @@
# 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.5.0
# via -r cibuildwheel/resources/constraints.in
cattrs==26.1.0
# via requests-cache
certifi==2026.4.22
# via requests
charset-normalizer==3.4.7
# via requests
delocate==0.13.0
# via -r cibuildwheel/resources/constraints.in
delvewheel==1.12.1
@@ -14,26 +28,59 @@ filelock==3.29.0
# via
# python-discovery
# virtualenv
idna==3.15
# via
# requests
# url-normalize
kaitaistruct==0.11
# via abi3audit
macholib==1.16.4
# via delocate
markdown-it-py==4.2.0
# via rich
mdurl==0.1.2
# via markdown-it-py
packaging==26.2
# via
# abi3audit
# build
# delocate
pefile==2024.8.26
# via delvewheel
pip==26.1.1; implementation_name != "graalpy" or platform_system != "Windows"
pip==26.0.1; implementation_name == "graalpy" and platform_system == "Windows"
# via
# abi3audit
# delvewheel
pip==26.1.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.3.0
python-discovery==1.3.1
# via virtualenv
requests==2.34.1
# via
# abi3audit
# requests-cache
requests-cache==1.3.2
# via abi3audit
rich==15.0.0
# via abi3audit
typing-extensions==4.15.0
# via delocate
virtualenv==21.3.1
# via
# cattrs
# delocate
url-normalize==3.0.0
# via requests-cache
urllib3==2.7.0
# via
# requests
# requests-cache
virtualenv==21.3.3
# via -r cibuildwheel/resources/constraints.in
@@ -1,46 +1,96 @@
# 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.4
# via -r cibuildwheel/resources/constraints.in
cattrs==25.3.0
# via requests-cache
certifi==2026.4.22
# via requests
charset-normalizer==3.4.7
# via requests
delocate==0.13.0
# via -r cibuildwheel/resources/constraints.in
delvewheel==1.12.1
# 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.15
# via
# requests
# url-normalize
importlib-metadata==8.7.1
# via build
kaitaistruct==0.11
# via abi3audit
macholib==1.16.4
# via delocate
packaging==26.2
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 delvewheel
# via
# abi3audit
# delvewheel
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.3.0
python-discovery==1.3.1
# 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.3.1
url-normalize==2.2.1
# via requests-cache
urllib3==2.6.3
# via
# requests
# requests-cache
virtualenv==21.3.3
# via -r cibuildwheel/resources/constraints.in
zipp==3.23.1
# via importlib-metadata
+1
View File
@@ -3,3 +3,4 @@ build
delocate
delvewheel
virtualenv
abi3audit
+53 -6
View File
@@ -1,9 +1,23 @@
# 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.5.0
# via -r cibuildwheel/resources/constraints.in
cattrs==26.1.0
# via requests-cache
certifi==2026.4.22
# via requests
charset-normalizer==3.4.7
# via requests
delocate==0.13.0
# via -r cibuildwheel/resources/constraints.in
delvewheel==1.12.1
@@ -14,26 +28,59 @@ filelock==3.29.0
# via
# python-discovery
# virtualenv
idna==3.15
# via
# requests
# url-normalize
kaitaistruct==0.11
# via abi3audit
macholib==1.16.4
# via delocate
markdown-it-py==4.2.0
# via rich
mdurl==0.1.2
# via markdown-it-py
packaging==26.2
# via
# abi3audit
# build
# delocate
pefile==2024.8.26
# via delvewheel
pip==26.1.1; implementation_name != "graalpy" or platform_system != "Windows"
pip==26.0.1; implementation_name == "graalpy" and platform_system == "Windows"
# via
# abi3audit
# delvewheel
pip==26.1.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.3.0
python-discovery==1.3.1
# via virtualenv
requests==2.34.1
# via
# abi3audit
# requests-cache
requests-cache==1.3.2
# via abi3audit
rich==15.0.0
# via abi3audit
typing-extensions==4.15.0
# via delocate
virtualenv==21.3.1
# via
# cattrs
# delocate
url-normalize==3.0.0
# via requests-cache
urllib3==2.7.0
# via
# requests
# requests-cache
virtualenv==21.3.3
# via -r cibuildwheel/resources/constraints.in
+3
View File
@@ -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"
@@ -65,3 +67,4 @@ repair-wheel-command = "delvewheel repair -w {dest_dir} -v {wheel}"
[tool.cibuildwheel.ios]
[tool.cibuildwheel.pyodide]
audit-command = ""
+6
View File
@@ -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)
+15 -4
View File
@@ -166,10 +166,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",
@@ -185,6 +182,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):
Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 96 KiB

+16 -2
View File
@@ -38,7 +38,7 @@
</div>
<div class="grid-column-label"
style="grid-row: 2 / span 1;
grid-column: 9 / -3;
grid-column: 10 / -3;
margin-bottom: 0.5em;">
<div class="label">If tests are configured</div>
</div>
@@ -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;
+1 -1
View File
@@ -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}
+117 -46
View File
@@ -923,7 +923,6 @@ Default:
- on Linux: `'auditwheel repair -w {dest_dir} {wheel}'`
- on macOS: `'delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel}'`
- on Windows: `'delvewheel repair -w {dest_dir} -v {wheel}'`
- on Android: There is no default command, but cibuildwheel will add `libc++` to the
wheel if anything links against it. Setting a command will replace this behavior.
- on Pyodide: You can use `pyodide auditwheel repair --libdir /path/to/libraries --output-dir {dest_dir} {wheel}` command to repair the wheel.
@@ -948,35 +947,29 @@ The command is run in a shell, so you can run multiple commands like `cmd1 && cm
Platform-specific environment variables are also available:<br/>
`CIBW_REPAIR_WHEEL_COMMAND_MACOS` | `CIBW_REPAIR_WHEEL_COMMAND_WINDOWS` | `CIBW_REPAIR_WHEEL_COMMAND_LINUX` | `CIBW_REPAIR_WHEEL_COMMAND_ANDROID` | `CIBW_REPAIR_WHEEL_COMMAND_IOS` | `CIBW_REPAIR_WHEEL_COMMAND_PYODIDE`
!!! note "Windows: telling delvewheel where to find DLLs"
On Windows, delvewheel searches the directories on `PATH` for external DLL dependencies. If your DLLs are already discoverable via `PATH`, (say, installed by a package manager that adds itself and the relevant directories to `PATH`), the default repair command should be sufficient.
!!! tip
cibuildwheel doesn't yet ship a default repair command for Windows.
If your project links against DLLs in a custom location such as a [vcpkg](https://vcpkg.io/) or [Conan](https://conan.io/) install tree, or a manually built library directory, you may pass `--add-path` to tell delvewheel where to look. The flag can be used multiple times for more than one directory:
**If that's an issue for you, check out [delvewheel]** - a new package that aims to do the same as auditwheel or delocate for Windows.
```toml
[tool.cibuildwheel.windows]
repair-wheel-command = "delvewheel repair --add-path C:/vcpkg/installed/x64-windows/bin --add-path C:/mylibs/bin -w {dest_dir} -v {wheel}"
```
Because delvewheel is still relatively early-stage, cibuildwheel does not yet run it by default. However, we'd recommend giving it a try! See the examples below for usage.
You can also reference environment variables expanded by the shell at build time, for example if the path is set during `before-build`:
```yaml
CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: "delvewheel repair --add-path %VCPKG_INSTALLED_DIR%\\x64-windows\\bin -w {dest_dir} -v {wheel}"
```
[Delvewheel]: https://github.com/adang1345/delvewheel
#### Examples
!!! tab examples "pyproject.toml"
```toml
# Use delvewheel on windows
[tool.cibuildwheel.windows]
before-build = "pip install delvewheel"
repair-wheel-command = "delvewheel repair -w {dest_dir} {wheel}"
# Don't repair macOS wheels
[tool.cibuildwheel.macos]
repair-wheel-command = ""
# Don't repair Windows wheels
[tool.cibuildwheel.windows]
repair-wheel-command = ""
# Pass the `--lib-sdir .` flag to auditwheel on Linux
[tool.cibuildwheel.linux]
repair-wheel-command = "auditwheel repair --lib-sdir . -w {dest_dir} {wheel}"
@@ -988,36 +981,24 @@ Platform-specific environment variables are also available:<br/>
'python scripts/check_repaired_wheel.py -w {dest_dir} {wheel}',
]
# Use abi3audit to catch issues with Limited API wheels
[tool.cibuildwheel.linux]
repair-wheel-command = [
"auditwheel repair -w {dest_dir} {wheel}",
"pipx run abi3audit --strict --report {wheel}",
]
[tool.cibuildwheel.macos]
repair-wheel-command = [
"delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel}",
"pipx run abi3audit --strict --report {wheel}",
]
[tool.cibuildwheel.windows]
repair-wheel-command = [
"delvewheel repair -w {dest_dir} -v {wheel}",
"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 `&&`.
!!! tab examples "Environment variables"
```yaml
# Use delvewheel on windows
CIBW_BEFORE_BUILD_WINDOWS: "pip install delvewheel"
CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: "delvewheel repair -w {dest_dir} {wheel}"
# Don't repair macOS wheels
CIBW_REPAIR_WHEEL_COMMAND_MACOS: ""
# Don't repair Windows wheels
CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: ""
# Pass the `--lib-sdir .` flag to auditwheel on Linux
CIBW_REPAIR_WHEEL_COMMAND_LINUX: "auditwheel repair --lib-sdir . -w {dest_dir} {wheel}"
@@ -1026,16 +1007,6 @@ Platform-specific environment variables are also available:<br/>
python scripts/repair_wheel.py -w {dest_dir} {wheel} &&
python scripts/check_repaired_wheel.py -w {dest_dir} {wheel}
# Use abi3audit to catch issues with Limited API wheels
CIBW_REPAIR_WHEEL_COMMAND_LINUX: >
auditwheel repair -w {dest_dir} {wheel} &&
pipx run abi3audit --strict --report {wheel}
CIBW_REPAIR_WHEEL_COMMAND_MACOS: >
delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel} &&
pipx run abi3audit --strict --report {wheel}
CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: >
delvewheel repair -w {dest_dir} -v {wheel} &&
pipx run abi3audit --strict --report {wheel}
```
@@ -1255,6 +1226,10 @@ Platform-specific environment variables are also available:<br/>
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"
@@ -1349,6 +1324,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}
+215
View File
@@ -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,
)
+1
View File
@@ -14,6 +14,7 @@ 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"
+139
View File
@@ -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)
+24
View File
@@ -301,6 +301,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(
+159
View File
@@ -541,3 +541,162 @@ before-all = ["override2"]
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"
+21 -1
View File
@@ -15,7 +15,7 @@ from cibuildwheel.util.helpers import (
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