feat: Android improvements needed for building NumPy and related packages (#2695)

* Use auditwheel on Android

* Add auditwheel command to defaults

* pkgconfig fixes

* Pre-import ctypes before monkey-patching in _cross_venv

* Set PKG_CONFIG and PKG_CONFIG_RELOCATE_PATHS variables

* Initial attempt at using mzakharo/android-gfortran

* Switch to using termux/ndk-toolchain-clang-with-flang

* Move PKG_CONFIG variables from build_env to android_env

* Simplify flang installation

* Add cross build files for NumPy

* Add ldpaths entry for libomp

* Add `--rm` to docker command line

* Make Rust and Fortran shims consistent

* Update documentation

* Default to API level 24 on all Python versions

* Clarify comments

* Cleanups

* Fix tests:
* Set up Android env after installing pkgconf
* Add tests for successfully using an older API level
* Previous commit's auditwheel failure is fixed in the auditwheel PR

* Update how-it-works diagram

* Add tests for repair errors

* Add more repair tests

* Add test for Meson and Fortran

* Add test for cross build files

* Improve test_api_level error message

* Add xbuild-files option

* use pypa/auditwheel@main

* Remove dependencies which are no longer needed

* Fix README

* Update to auditwheel 6.7.0

* Fix compatibility with pkgconf 2.5.1.post2

* Documentation clarifications

Co-authored-by: Joe Rickerby <joerick@mac.com>

* Fortran shim improvements

* Add Jinja variables to new_meson_project

* Update run_example_ci_configs for changed new_meson_project signature

* Add missing dependency to run_example_ci_configs

---------

Co-authored-by: mayeut <mayeut@users.noreply.github.com>
Co-authored-by: Joe Rickerby <joerick@mac.com>
This commit is contained in:
Malcolm Smith
2026-05-29 00:27:28 -04:00
committed by GitHub
co-authored by Joe Rickerby mayeut
parent e3e7cc9e07
commit 1cf64d0d3c
32 changed files with 775 additions and 246 deletions
-1
View File
@@ -39,7 +39,6 @@ repos:
- nox>=2025.2.9
- orjson
- packaging
- pyelftools
- pygithub
- pytest
- rich
+2 -1
View File
@@ -152,6 +152,7 @@ The following diagram summarises the steps that cibuildwheel takes on each platf
| | [`before-all`](https://cibuildwheel.pypa.io/en/stable/options/#before-all) | Execute a shell command on the build system before any wheels are built. |
| | [`before-build`](https://cibuildwheel.pypa.io/en/stable/options/#before-build) | Execute a shell command preparing each wheel's build |
| | [`xbuild-tools`](https://cibuildwheel.pypa.io/en/stable/options/#xbuild-tools) | Binaries on the path that should be included in an isolated cross-build environment. |
| | [`xbuild-files`](https://cibuildwheel.pypa.io/en/stable/options/#xbuild-files) | Platform-specific files in the build environment |
| | [`repair-wheel-command`](https://cibuildwheel.pypa.io/en/stable/options/#repair-wheel-command) | Execute a shell command to repair each built wheel |
| | [`manylinux-*-image`<br>`musllinux-*-image`](https://cibuildwheel.pypa.io/en/stable/options/#linux-image) | Specify manylinux / musllinux container images |
| | [`container-engine`](https://cibuildwheel.pypa.io/en/stable/options/#container-engine) | Specify the container engine to use when building Linux wheels |
@@ -173,7 +174,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: b7YIjCyCkf) -->
<!--[[[end]]] (sum: Of/28Z7Nut) -->
These options can be specified in a pyproject.toml file, or as environment variables, see [configuration docs](https://cibuildwheel.pypa.io/en/latest/configuration/).
+15 -8
View File
@@ -15,6 +15,8 @@ parser = argparse.ArgumentParser(allow_abbrev=False)
parser.add_argument("--schemastore", action="store_true", help="Generate schema_store version")
args = parser.parse_args()
# The defaults in the schema are used by external tools for validation and IDE support. They
# should match the values in defaults.toml, which are used by cibuildwheel itself.
starter = """
$schema: http://json-schema.org/draft-07/schema#
$id: https://github.com/pypa/cibuildwheel/blob/main/cibuildwheel/resources/cibuildwheel.schema.json
@@ -200,6 +202,9 @@ properties:
xbuild-tools:
description: Binaries on the path that should be included in an isolated cross-build environment
type: string_array
xbuild-files:
description: Platform-specific files in the build environment
type: string_table_array
pyodide-version:
type: string
description: Specify the version of Pyodide to use
@@ -319,6 +324,7 @@ items:
before-all: {"$ref": "#/$defs/inherit"}
before-build: {"$ref": "#/$defs/inherit"}
xbuild-tools: {"$ref": "#/$defs/inherit"}
xbuild-files: {"$ref": "#/$defs/inherit"}
before-test: {"$ref": "#/$defs/inherit"}
config-settings: {"$ref": "#/$defs/inherit"}
container-engine: {"$ref": "#/$defs/inherit"}
@@ -374,14 +380,15 @@ oses = {
"ios": as_object(not_linux),
}
oses["linux"]["properties"]["repair-wheel-command"] = {
**schema["properties"]["repair-wheel-command"],
"default": "auditwheel repair -w {dest_dir} {wheel}",
}
oses["macos"]["properties"]["repair-wheel-command"] = {
**schema["properties"]["repair-wheel-command"],
"default": "delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel}",
}
for os_name, command in [
("linux", "auditwheel repair -w {dest_dir} {wheel}"),
("macos", "delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel}"),
("android", "auditwheel repair --ldpaths {ldpaths} -w {dest_dir} {wheel}"),
]:
oses[os_name]["properties"]["repair-wheel-command"] = {
**schema["properties"]["repair-wheel-command"],
"default": command,
}
del oses["linux"]["properties"]["dependency-versions"]
+7 -3
View File
@@ -3,6 +3,7 @@
# /// script
# dependencies = [
# "click",
# "jinja2",
# ]
# ///
@@ -39,13 +40,16 @@ def generate_project(path: Path, build_backend: BuildBackend) -> None:
sys.path.insert(0, "")
match build_backend:
case "meson":
from test.test_projects.meson import new_meson_project as new_project # noqa: PLC0415
from test.test_projects.meson import new_meson_project # noqa: PLC0415
project = new_meson_project()
case "setuptools":
from test.test_projects.setuptools import new_c_project as new_project # noqa: PLC0415
from test.test_projects.setuptools import new_c_project # noqa: PLC0415
project = new_c_project()
case _:
typing.assert_never(build_backend)
project = new_project()
project.generate(path)
+10
View File
@@ -120,6 +120,7 @@ class BuildOptions:
before_all: str
before_build: str | None
xbuild_tools: list[str] | None
xbuild_files: dict[str, list[str]]
repair_command: str
manylinux_images: dict[str, str] | None
musllinux_images: dict[str, str] | None
@@ -772,6 +773,14 @@ class Options:
if xbuild_tools == ["\u0000"]:
xbuild_tools = None
xbuild_files = parse_key_value_string(
self.reader.get(
"xbuild-files",
option_format=ShlexTableFormat(sep="; ", pair_sep=":", allow_merge=False),
),
kw_arg_names=["*"],
)
test_sources = shlex.split(
self.reader.get(
"test-sources", option_format=ListFormat(sep=" ", quote=shlex.quote)
@@ -925,6 +934,7 @@ class Options:
before_all=before_all,
build_verbosity=build_verbosity,
xbuild_tools=xbuild_tools,
xbuild_files=xbuild_files,
repair_command=repair_command,
environment=environment,
dependency_constraints=dependency_constraints,
+130 -180
View File
@@ -1,16 +1,13 @@
from __future__ import annotations
import csv
import hashlib
import os
import platform
import re
import shlex
import shutil
import subprocess
import sysconfig
import sys
from dataclasses import dataclass
from os.path import relpath
from pathlib import Path
from pprint import pprint
from runpy import run_path
@@ -19,9 +16,8 @@ from typing import Any
from build import ProjectBuilder
from build.env import IsolatedEnv
from elftools.common.exceptions import ELFError
from elftools.elf.elffile import ELFFile
from filelock import FileLock
from packaging.utils import canonicalize_name
from cibuildwheel import errors, platforms # pylint: disable=cyclic-import
from cibuildwheel.architecture import Architecture, arch_synonym
@@ -42,10 +38,9 @@ from cibuildwheel.venv import constraint_flags, find_uv, virtualenv
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator, MutableMapping
from cibuildwheel.options import BuildOptions, Options
from cibuildwheel.selector import BuildSelector
from cibuildwheel.typing import PathOrStr
RESOURCES_ANDROID = resources.PATH / "android"
ANDROID_TRIPLET = {
@@ -148,6 +143,7 @@ def build(options: Options, tmp_path: Path) -> None:
state = BuildState(
config, build_options, build_path, python_dir, build_env, android_env
)
setup_xbuild_files(state)
compatible_wheel = find_compatible_wheel(built_wheels, config.identifier)
if compatible_wheel:
@@ -162,7 +158,7 @@ def build(options: Options, tmp_path: Path) -> None:
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)
test_wheel(state, repaired_wheel)
output_wheel: Path | None = None
if compatible_wheel is None:
@@ -194,6 +190,11 @@ def setup_target_python(config: PythonConfiguration, build_path: Path) -> Path:
# updated to Python versions that include the fix.
call("patch", "-p1", "-i", RESOURCES_ANDROID / "android.patch", cwd=python_dir)
# Work around https://github.com/python/cpython/issues/138800. This can be removed
# once we've updated to Python versions that include the fix.
pc_path = python_dir / f"prefix/lib/pkgconfig/python-{config.version}.pc"
pc_path.write_text(pc_path.read_text().replace("$(BLDLIBRARY)", f"-lpython{config.version}"))
return python_dir
@@ -207,13 +208,7 @@ def setup_env(
* android_env, which uses the environment while simulating running on Android.
"""
log.step("Setting up build environment...")
build_frontend = build_options.build_frontend.name
use_uv = build_frontend in {"build[uv]", "uv"}
uv_path = find_uv()
if use_uv and uv_path is None:
msg = "uv not found"
raise AssertionError(msg)
pip = ["pip"] if not use_uv else [str(uv_path), "pip"]
use_uv, pip = find_pip(build_options)
# Create virtual environment
python_exe = create_python_build_standalone_environment(
@@ -228,6 +223,9 @@ def setup_env(
)
create_cmake_toolchain(config, build_path, python_dir, build_env)
# See platforms.md for the reason why we use this default API level.
build_env.setdefault("ANDROID_API_LEVEL", "24")
# Apply custom environment variables, and check environment is still valid
build_env = build_options.environment.as_dictionary(build_env)
build_env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1"
@@ -245,14 +243,14 @@ def setup_env(
else:
call(command, "--version", env=build_env)
# Construct an altered environment which simulates running on Android.
android_env = setup_android_env(config, python_dir, venv_dir, build_env)
# Install build tools
if build_frontend not in {"build", "build[uv]", "uv"}:
msg = "Android requires the build frontend to be 'build' or 'uv'"
raise errors.FatalError(msg)
call(*pip, "install", "build", *constraint_flags(dependency_constraint), env=build_env)
tools = ["auditwheel", "patchelf", "pkgconf"]
if build_options.build_frontend.name in {"build", "build[uv]"}:
tools.append("build")
call(*pip, "install", *tools, *constraint_flags(dependency_constraint), env=build_env)
# Construct an altered environment which simulates running on Android.
android_env = setup_android_env(config, python_dir, build_env)
# Build-time requirements must be queried within android_env, because
# `get_requires_for_build` can run arbitrary code in setup.py scripts, which may be
@@ -343,8 +341,7 @@ def localized_vars(
final = final.replace(orig_prefix, str(prefix))
if key == "ANDROID_API_LEVEL":
if api_level := build_env.get(key):
final = int(api_level)
final = int(build_env[key])
# Build systems vary in whether FLAGS variables are read from sysconfig, and if so,
# whether they're replaced by environment variables or combined with them. Even
@@ -367,15 +364,15 @@ def localized_vars(
def setup_android_env(
config: PythonConfiguration, python_dir: Path, venv_dir: Path, build_env: dict[str, str]
config: PythonConfiguration, python_dir: Path, build_env: dict[str, str]
) -> dict[str, str]:
site_packages = next(venv_dir.glob("lib/python*/site-packages"))
site_packages = find_site_packages(build_env)
for suffix in ["pth", "py"]:
shutil.copy(RESOURCES_ANDROID / f"_cross_venv.{suffix}", site_packages)
sysconfigdata_path = Path(
shutil.copy(
next(python_dir.glob("prefix/lib/python*/_sysconfigdata_*.py")),
glob1(python_dir, "prefix/lib/python*/_sysconfigdata_*.py"),
site_packages,
)
)
@@ -414,6 +411,17 @@ def setup_android_env(
# Cargo target linker needs to be specified after CC is set
setup_rust(config, python_dir, android_env)
# Create shims which install additional build tools on first use.
setup_fortran(android_env)
# `android.py env` returns PKG_CONFIG="pkg-config --define-prefix", but some build
# systems can't handle arguments in that variable. Since we have a known version
# of pkgconf, it's safe to use PKG_CONFIG_RELOCATE_PATHS instead.
android_env["PKG_CONFIG"] = call(
"which", "pkgconf-pypi", env=build_env, capture_stdout=True
).strip()
android_env["PKG_CONFIG_RELOCATE_PATHS"] = "1"
# Format the environment so it can be pasted into a shell when debugging.
for key, value in sorted(android_env.items()):
if os.environ.get(key) != value:
@@ -422,11 +430,7 @@ def setup_android_env(
return android_env
def setup_rust(
config: PythonConfiguration,
python_dir: Path,
env: MutableMapping[str, str],
) -> None:
def setup_rust(config: PythonConfiguration, python_dir: Path, env: dict[str, str]) -> None:
cargo_target = android_triplet(config.identifier)
# CARGO_BUILD_TARGET is the variable used by Cargo and setuptools_rust
@@ -450,13 +454,83 @@ def setup_rust(
shim_path.chmod(0o755)
def setup_fortran(env: dict[str, str]) -> None:
# In case there's any autodetection based on the executable name, use the same name
# as the real executable (see fortran_shim.run_flang)
shim_in = RESOURCES_ANDROID / "fortran_shim.py"
shim_out = Path(env["VIRTUAL_ENV"]) / "bin/flang-new"
# The hashbang line runs the shim in cibuildwheel's own virtual environment, so it
# has access to utility functions for downloading and caching files.
shim_out.write_text(f"#!{sys.executable}\n\n" + shim_in.read_text())
shim_out.chmod(0o755)
env["FC"] = str(shim_out)
def setup_xbuild_files(state: BuildState) -> None:
_, pip = find_pip(state.options)
xbf_dir = state.build_path / "xbuild_files"
xbf_dir.mkdir()
for requirement in call(*pip, "freeze", env=state.build_env, capture_stdout=True).splitlines():
name, _, _ = requirement.strip().partition("==")
xbuild_files = state.options.xbuild_files.get(canonicalize_name(name), [])
if xbuild_files:
log.step(f"Installing xbuild-files for {name}...")
pip_install_android(state, xbf_dir, "--no-deps", requirement)
for xbf in xbuild_files:
if (xbf_dir / xbf).exists():
shutil.copy(
xbf_dir / xbf,
find_site_packages(state.build_env) / xbf,
)
else:
log.warning(f"{xbf_dir / xbf} does not exist")
def pip_install_android(state: BuildState, target: Path, *args: PathOrStr) -> None:
use_uv, pip = find_pip(state.options)
call(
*pip,
"install",
"--only-binary=:all:",
*(["--python-platform", android_triplet(state.config.identifier)] if use_uv else []),
"--target",
target,
*args,
env=state.android_env,
)
def find_site_packages(env: dict[str, str]) -> Path:
return glob1(Path(env["VIRTUAL_ENV"]), "lib/python*/site-packages")
def glob1(base: Path, pattern: str) -> Path:
results = list(base.glob(pattern))
if len(results) != 1:
msg = f"{base} contains {len(results)} paths matching '{pattern}'; expected 1"
raise errors.FatalError(msg)
return results[0]
def find_pip(build_options: BuildOptions) -> tuple[bool, list[str]]:
use_uv = build_options.build_frontend.name in {"build[uv]", "uv"}
uv_path = find_uv()
if use_uv and uv_path is None:
msg = "uv not found"
raise AssertionError(msg)
pip = ["pip"] if not use_uv else [str(uv_path), "pip"]
return use_uv, pip
def before_build(state: BuildState) -> None:
if state.options.before_build:
log.step("Running before_build...")
shell_prepared(
state.options.before_build,
build_options=state.options,
env=state.build_env,
env=state.android_env,
)
@@ -507,15 +581,10 @@ def build_wheel(state: BuildState) -> Path:
env=state.android_env,
)
case x:
msg = f"Invalid build backend {x!r}"
raise AssertionError(msg)
built_wheels = list(built_wheel_dir.glob("*.whl"))
if len(built_wheels) != 1:
msg = f"{built_wheel_dir} contains {len(built_wheels)} wheels; expected 1"
raise errors.FatalError(msg)
built_wheel = built_wheels[0]
msg = f"Android requires the build frontend to be 'build' or 'uv', not {x!r}"
raise errors.FatalError(msg)
built_wheel = glob1(built_wheel_dir, "*.whl")
if built_wheel.name.endswith("none-any.whl"):
raise errors.NonPlatformWheelError()
return built_wheel
@@ -527,9 +596,20 @@ def repair_wheel(state: BuildState, built_wheel: Path) -> Path:
repaired_wheel_dir.mkdir()
if state.options.repair_command:
# Tell auditwheel the locations of compiler libraries.
toolchain = Path(state.android_env["CC"]).parent.parent
triplet = android_triplet(state.config.identifier)
ldpaths = ":".join(
str(glob1(toolchain, pattern))
for pattern in [
f"lib/clang/*/lib/linux/{triplet.split('-')[0]}", # libomp
f"sysroot/usr/lib/{triplet}", # libc++_shared
]
)
shell(
prepare_command(
state.options.repair_command,
ldpaths=ldpaths,
wheel=built_wheel,
dest_dir=repaired_wheel_dir,
package=state.options.package_dir,
@@ -538,14 +618,15 @@ def repair_wheel(state: BuildState, built_wheel: Path) -> Path:
env=state.build_env,
)
else:
repair_default(state.android_env, built_wheel, repaired_wheel_dir)
shutil.move(built_wheel, repaired_wheel_dir)
repaired_wheels = list(repaired_wheel_dir.glob("*.whl"))
if len(repaired_wheels) == 0:
raise errors.RepairStepProducedNoWheelError()
if len(repaired_wheels) != 1:
msg = f"{repaired_wheel_dir} contains {len(repaired_wheels)} wheels; expected 1"
raise errors.FatalError(msg)
raise errors.RepairStepProducedMultipleWheelsError(
[rw.name for rw in repaired_wheels],
)
repaired_wheel = repaired_wheels[0]
if repaired_wheel.name.endswith("none-any.whl"):
@@ -553,119 +634,12 @@ def repair_wheel(state: BuildState, built_wheel: Path) -> Path:
return repaired_wheel
def repair_default(
android_env: dict[str, str], built_wheel: Path, repaired_wheel_dir: Path
) -> None:
"""
Adds libc++ to the wheel if anything links against it. In the future this should be
moved to auditwheel and generalized to support more libraries.
"""
if (match := re.search(r"^(.+?)-", built_wheel.name)) is None:
msg = f"Failed to parse wheel filename: {built_wheel.name}"
raise errors.FatalError(msg)
wheel_name = match[1]
unpacked_dir = repaired_wheel_dir / "unpacked"
unpacked_dir.mkdir()
shutil.unpack_archive(built_wheel, unpacked_dir, format="zip")
# Some build systems are inconsistent about name normalization, so don't assume the
# dist-info name is identical to the wheel name.
record_paths = list(unpacked_dir.glob("*.dist-info/RECORD"))
if len(record_paths) != 1:
msg = f"{built_wheel.name} contains {len(record_paths)} dist-info/RECORD files; expected 1"
raise errors.FatalError(msg)
old_soname = "libc++_shared.so"
paths_to_patch = []
for path, elffile in elf_file_filter(
unpacked_dir / filename
for filename, *_ in csv.reader(record_paths[0].read_text().splitlines())
):
if (dynamic := elffile.get_section_by_name(".dynamic")) and any( # type: ignore[no-untyped-call]
tag.entry.d_tag == "DT_NEEDED" and tag.needed == old_soname
for tag in dynamic.iter_tags()
):
paths_to_patch.append(path)
if not paths_to_patch:
shutil.copyfile(built_wheel, repaired_wheel_dir / built_wheel.name)
else:
# Android doesn't support DT_RPATH, but supports DT_RUNPATH since API level 24
# (https://github.com/aosp-mirror/platform_bionic/blob/master/android-changes-for-ndk-developers.md).
if int(sysconfig_print('get_config_vars()["ANDROID_API_LEVEL"]', android_env)) < 24:
msg = f"Adding {old_soname} requires ANDROID_API_LEVEL to be at least 24"
raise errors.FatalError(msg)
toolchain = Path(android_env["CC"]).parent.parent
src_path = toolchain / f"sysroot/usr/lib/{android_env['CIBW_HOST_TRIPLET']}/{old_soname}"
# Use the same library location as auditwheel would.
libs_dir = unpacked_dir / (wheel_name + ".libs")
libs_dir.mkdir()
new_soname = soname_with_hash(src_path)
dst_path = libs_dir / new_soname
shutil.copyfile(src_path, dst_path)
call(which("patchelf"), "--set-soname", new_soname, dst_path)
for path in paths_to_patch:
call(which("patchelf"), "--replace-needed", old_soname, new_soname, path)
call(
which("patchelf"),
"--set-rpath",
f"${{ORIGIN}}/{relpath(libs_dir, path.parent)}",
path,
)
call(which("wheel"), "pack", unpacked_dir, "-d", repaired_wheel_dir)
# If cibuildwheel was called without activating its environment, its scripts directory
# will not be on the PATH.
def which(cmd: str) -> str:
scripts_dir = sysconfig.get_path("scripts")
result = shutil.which(cmd, path=scripts_dir + os.pathsep + os.environ["PATH"])
if result is None:
msg = f"Couldn't find {cmd!r} in {scripts_dir} or on the PATH"
raise errors.FatalError(msg)
return result
def elf_file_filter(paths: Iterable[Path]) -> Iterator[tuple[Path, ELFFile]]:
"""Filter through an iterator of filenames and load up only ELF files"""
for path in paths:
if not path.name.endswith(".py"):
try:
with open(path, "rb") as f:
candidate = ELFFile(f) # type: ignore[no-untyped-call]
yield path, candidate
except ELFError:
pass # Not an ELF file
def soname_with_hash(src_path: Path) -> str:
"""Return the same library filename as auditwheel would"""
shorthash = hashlib.sha256(src_path.read_bytes()).hexdigest()[:8]
src_name = src_path.name
base, ext = src_name.split(".", 1)
if not base.endswith(f"-{shorthash}"):
return f"{base}-{shorthash}.{ext}"
else:
return src_name
def test_wheel(state: BuildState, wheel: Path, *, build_frontend: str) -> None:
def test_wheel(state: BuildState, wheel: Path) -> None:
test_command = state.options.test_command
if not (test_command and state.options.test_selector(state.config.identifier)):
return
log.step("Testing wheel...")
use_uv = build_frontend in {"build[uv]", "uv"}
uv_path = find_uv()
if use_uv and uv_path is None:
msg = "uv not found"
raise AssertionError(msg)
pip = ["pip"] if not use_uv else [str(uv_path), "pip"]
native_arch = arch_synonym(platform.machine(), platforms.native_platform(), "android")
if state.config.arch != native_arch:
log.warning(
@@ -678,31 +652,17 @@ def test_wheel(state: BuildState, wheel: Path, *, build_frontend: str) -> None:
shell_prepared(
state.options.before_test,
build_options=state.options,
env=state.build_env,
env=state.android_env,
)
platform_args = (
["--python-platform", android_triplet(state.config.identifier)]
if use_uv
else [
"--platform",
sysconfig_print("get_platform()", state.android_env).replace("-", "_"),
]
)
# Install the wheel and test-requires.
site_packages_dir = state.build_path / "site-packages"
site_packages_dir.mkdir()
call(
*pip,
"install",
"--only-binary=:all:",
*platform_args,
"--target",
pip_install_android(
state,
site_packages_dir,
f"{wheel}{state.options.test_extras}",
*state.options.test_requires,
env=state.android_env,
)
# Copy test-sources.
@@ -772,13 +732,3 @@ def test_wheel(state: BuildState, wheel: Path, *, build_frontend: str) -> None:
*test_args,
env=state.build_env,
)
def sysconfig_print(method_call: str, env: dict[str, str]) -> str:
return call(
"python",
"-c",
f'import sysconfig; print(sysconfig.{method_call}, end="")',
env=env,
capture_stdout=True,
)
@@ -14,6 +14,9 @@ def initialize() -> None:
if not (host_triplet := os.environ.get("CIBW_HOST_TRIPLET")):
return
# Pre-import any modules which would fail to import after the monkey-patching.
import ctypes # noqa: F401, PLC0415 - uses get_config_var("LDLIBRARY")
# os ######################################################################
def cross_os_uname() -> os.uname_result:
return os.uname_result(
@@ -0,0 +1,121 @@
# This file intentionally has no hashbang line in the source: cibuildwheel will add it
# above this comment when the file is deployed.
import os
import re
import shutil
import sys
from pathlib import Path
from tempfile import gettempdir
from filelock import FileLock
from cibuildwheel.util.file import CIBW_CACHE_PATH, download
# In the future we might pick a different Flang release depending on the NDK version,
# but so far all Python versions use the same NDK version, so there's no need.
RELEASE_URL = "https://github.com/termux/ndk-toolchain-clang-with-flang/releases/download"
RELEASE_VERSION = "r27c"
ARCHS = ["aarch64", "x86_64"]
# The compiler is built for Linux x86_64, so we use Docker on macOS.
DOCKER_IMAGE = "debian:trixie-slim"
def main() -> None:
cache_dir = CIBW_CACHE_PATH / f"flang-android-{RELEASE_VERSION}"
with FileLock(f"{cache_dir}.lock"):
if not cache_dir.exists():
download_flang(cache_dir)
run_flang(cache_dir)
def download_flang(cache_dir: Path) -> None:
tmp_dir = Path(f"{cache_dir}.tmp")
if tmp_dir.exists():
shutil.rmtree(tmp_dir)
tmp_dir.mkdir(parents=True)
for archive_name in [f"package-flang-{arch}.tar.bz2" for arch in ARCHS] + [
"package-flang-host.tar.bz2",
"package-install.tar.bz2",
]:
archive_path = tmp_dir / archive_name
download(f"{RELEASE_URL}/{RELEASE_VERSION}/{archive_name}", archive_path)
shutil.unpack_archive(archive_path, tmp_dir)
archive_path.unlink()
# Merge the extracted trees together, along with the necessary parts of the NDK. Based on
# https://github.com/kivy/python-for-android/blob/develop/pythonforandroid/recipes/fortran/__init__.py.
flang_toolchain = tmp_dir / "toolchain"
(tmp_dir / "out/install/linux-x86/clang-dev").rename(flang_toolchain)
ndk_toolchain = Path(os.environ["CC"]).parents[1]
if (clang_ver_flang := clang_ver(flang_toolchain)) != (
clang_ver_ndk := clang_ver(ndk_toolchain)
):
msg = f"Flang uses Clang {clang_ver_flang}, but NDK uses Clang {clang_ver_ndk}"
raise ValueError(msg)
clang_lib_path = f"lib/clang/{clang_ver_ndk}/lib"
shutil.rmtree(flang_toolchain / clang_lib_path)
for src, dst in [
(f"{tmp_dir}/build-{arch}-install", f"sysroot/usr/lib/{arch}-linux-android")
for arch in ARCHS
] + [
(f"{tmp_dir}/build-host-install", ""),
(f"{ndk_toolchain}/{clang_lib_path}", clang_lib_path),
(f"{ndk_toolchain}/sysroot", "sysroot"),
]:
shutil.copytree(src, flang_toolchain / dst, symlinks=True, dirs_exist_ok=True)
flang_toolchain.rename(cache_dir)
shutil.rmtree(tmp_dir)
def clang_ver(toolchain: Path) -> str:
versions = [p.name for p in (toolchain / "lib/clang").iterdir()]
assert len(versions) == 1
return versions[0]
def run_flang(cache_dir: Path) -> None:
match = re.fullmatch(r".+/(.+)-clang", os.environ["CC"])
assert match is not None
target = match[1]
# In a future Flang version the executable name will change to "flang"
# (https://blog.llvm.org/posts/2025-03-11-flang-new/).
flang_args = [f"{cache_dir}/bin/flang-new", f"--target={target}", *sys.argv[1:]]
if sys.platform == "linux":
args = flang_args
elif sys.platform == "darwin":
args = ["docker", "run", "--rm", "--platform", "linux/amd64"]
# Docker on macOS only allows certain directories to be mounted as volumes
# by default, but they include all the locations we're likely to need.
for path in [
# Location of CIBW_CACHE_PATH (containing the Fortran compiler), and
# probably the project directory.
Path.home(),
# Temporary directories created by build backends and cibuildwheel itself.
# This is controlled by the TMPDIR environment variable, which is set to
# an unpredictable subdirectory of /var/folders. /var is itself a symlink
# to /private/var.
Path(gettempdir()),
Path(gettempdir()).resolve(),
]:
args += ["-v", f"{path}:{path}"]
args += ["--workdir", str(Path.cwd())]
args += ["--entrypoint", flang_args[0], DOCKER_IMAGE, *flang_args[1:]]
else:
msg = f"unknown platform: {sys.platform}"
raise ValueError(msg)
os.execvp(args[0], args)
if __name__ == "__main__":
main()
@@ -460,6 +460,34 @@
],
"title": "CIBW_XBUILD_TOOLS"
},
"xbuild-files": {
"description": "Platform-specific files in the build environment",
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"additionalProperties": false,
"patternProperties": {
".+": {
"oneOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
}
}
}
],
"title": "CIBW_XBUILD_FILES"
},
"pyodide-version": {
"type": "string",
"description": "Specify the version of Pyodide to use",
@@ -679,6 +707,9 @@
"xbuild-tools": {
"$ref": "#/$defs/inherit"
},
"xbuild-files": {
"$ref": "#/$defs/inherit"
},
"before-test": {
"$ref": "#/$defs/inherit"
},
@@ -807,6 +838,9 @@
"xbuild-tools": {
"$ref": "#/properties/xbuild-tools"
},
"xbuild-files": {
"$ref": "#/properties/xbuild-files"
},
"pyodide-version": {
"$ref": "#/properties/pyodide-version"
},
@@ -931,6 +965,9 @@
"xbuild-tools": {
"$ref": "#/properties/xbuild-tools"
},
"xbuild-files": {
"$ref": "#/properties/xbuild-files"
},
"pyodide-version": {
"$ref": "#/properties/pyodide-version"
},
@@ -1013,6 +1050,9 @@
"xbuild-tools": {
"$ref": "#/properties/xbuild-tools"
},
"xbuild-files": {
"$ref": "#/properties/xbuild-files"
},
"pyodide-version": {
"$ref": "#/properties/pyodide-version"
},
@@ -1082,6 +1122,9 @@
"xbuild-tools": {
"$ref": "#/properties/xbuild-tools"
},
"xbuild-files": {
"$ref": "#/properties/xbuild-files"
},
"pyodide-version": {
"$ref": "#/properties/pyodide-version"
},
@@ -1164,6 +1207,9 @@
"xbuild-tools": {
"$ref": "#/properties/xbuild-tools"
},
"xbuild-files": {
"$ref": "#/properties/xbuild-files"
},
"pyodide-version": {
"$ref": "#/properties/pyodide-version"
},
@@ -1233,11 +1279,27 @@
"xbuild-tools": {
"$ref": "#/properties/xbuild-tools"
},
"xbuild-files": {
"$ref": "#/properties/xbuild-files"
},
"pyodide-version": {
"$ref": "#/properties/pyodide-version"
},
"repair-wheel-command": {
"$ref": "#/properties/repair-wheel-command"
"description": "Execute a shell command to repair each built wheel.",
"oneOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
],
"title": "CIBW_REPAIR_WHEEL_COMMAND",
"default": "auditwheel repair --ldpaths {ldpaths} -w {dest_dir} {wheel}"
},
"test-command": {
"$ref": "#/properties/test-command"
@@ -1302,6 +1364,9 @@
"xbuild-tools": {
"$ref": "#/properties/xbuild-tools"
},
"xbuild-files": {
"$ref": "#/properties/xbuild-files"
},
"pyodide-version": {
"$ref": "#/properties/pyodide-version"
},
@@ -10,6 +10,8 @@ attrs==26.1.0
# via
# cattrs
# requests-cache
auditwheel==6.7.0
# via -r cibuildwheel/resources/constraints.in
build==1.5.0
# via -r cibuildwheel/resources/constraints.in
cattrs==26.1.0
@@ -47,21 +49,28 @@ mdurl==0.1.2
packaging==26.2
# via
# abi3audit
# auditwheel
# build
# delocate
patchelf==0.17.2.4
# via -r cibuildwheel/resources/constraints.in
pefile==2024.8.26
# via
# abi3audit
# delvewheel
pip==26.1.1
# via -r cibuildwheel/resources/constraints.in
pkgconf==2.5.1.post2
# via -r cibuildwheel/resources/constraints.in
platformdirs==4.9.6
# via
# python-discovery
# requests-cache
# virtualenv
pyelftools==0.32
# via abi3audit
# via
# abi3audit
# auditwheel
pygments==2.20.0
# via rich
pyproject-hooks==1.2.0
@@ -10,6 +10,8 @@ attrs==26.1.0
# via
# cattrs
# requests-cache
auditwheel==6.7.0
# via -r cibuildwheel/resources/constraints.in
build==1.5.0
# via -r cibuildwheel/resources/constraints.in
cattrs==26.1.0
@@ -43,8 +45,11 @@ mdurl==0.1.2
packaging==26.2
# via
# abi3audit
# auditwheel
# build
# delocate
patchelf==0.17.2.4
# via -r cibuildwheel/resources/constraints.in
pefile==2024.8.26
# via
# abi3audit
@@ -52,13 +57,17 @@ pefile==2024.8.26
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
pkgconf==2.5.1.post2
# via -r cibuildwheel/resources/constraints.in
platformdirs==4.9.6
# via
# python-discovery
# requests-cache
# virtualenv
pyelftools==0.32
# via abi3audit
# via
# abi3audit
# auditwheel
pygments==2.20.0
# via rich
pyproject-hooks==1.2.0
@@ -10,6 +10,8 @@ attrs==26.1.0
# via
# cattrs
# requests-cache
auditwheel==6.7.0
# via -r cibuildwheel/resources/constraints.in
build==1.5.0
# via -r cibuildwheel/resources/constraints.in
cattrs==26.1.0
@@ -43,8 +45,11 @@ mdurl==0.1.2
packaging==26.2
# via
# abi3audit
# auditwheel
# build
# delocate
patchelf==0.17.2.4
# via -r cibuildwheel/resources/constraints.in
pefile==2024.8.26
# via
# abi3audit
@@ -52,13 +57,17 @@ pefile==2024.8.26
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
pkgconf==2.5.1.post2
# via -r cibuildwheel/resources/constraints.in
platformdirs==4.9.6
# via
# python-discovery
# requests-cache
# virtualenv
pyelftools==0.32
# via abi3audit
# via
# abi3audit
# auditwheel
pygments==2.20.0
# via rich
pyproject-hooks==1.2.0
@@ -10,6 +10,8 @@ attrs==26.1.0
# via
# cattrs
# requests-cache
auditwheel==6.7.0
# via -r cibuildwheel/resources/constraints.in
build==1.5.0
# via -r cibuildwheel/resources/constraints.in
cattrs==26.1.0
@@ -43,21 +45,28 @@ mdurl==0.1.2
packaging==26.2
# via
# abi3audit
# auditwheel
# build
# delocate
patchelf==0.17.2.4
# via -r cibuildwheel/resources/constraints.in
pefile==2024.8.26
# via
# abi3audit
# delvewheel
pip==26.1.1
# via -r cibuildwheel/resources/constraints.in
pkgconf==2.5.1.post2
# via -r cibuildwheel/resources/constraints.in
platformdirs==4.9.6
# via
# python-discovery
# requests-cache
# virtualenv
pyelftools==0.32
# via abi3audit
# via
# abi3audit
# auditwheel
pygments==2.20.0
# via rich
pyproject-hooks==1.2.0
@@ -10,6 +10,8 @@ attrs==26.1.0
# via
# cattrs
# requests-cache
auditwheel==6.7.0
# via -r cibuildwheel/resources/constraints.in
build==1.5.0
# via -r cibuildwheel/resources/constraints.in
cattrs==26.1.0
@@ -43,21 +45,28 @@ mdurl==0.1.2
packaging==26.2
# via
# abi3audit
# auditwheel
# build
# delocate
patchelf==0.17.2.4
# via -r cibuildwheel/resources/constraints.in
pefile==2024.8.26
# via
# abi3audit
# delvewheel
pip==26.1.1
# via -r cibuildwheel/resources/constraints.in
pkgconf==2.5.1.post2
# via -r cibuildwheel/resources/constraints.in
platformdirs==4.9.6
# via
# python-discovery
# requests-cache
# virtualenv
pyelftools==0.32
# via abi3audit
# via
# abi3audit
# auditwheel
pygments==2.20.0
# via rich
pyproject-hooks==1.2.0
@@ -10,6 +10,8 @@ attrs==26.1.0
# via
# cattrs
# requests-cache
auditwheel==6.7.0
# via -r cibuildwheel/resources/constraints.in
build==1.5.0
# via -r cibuildwheel/resources/constraints.in
cattrs==26.1.0
@@ -43,21 +45,28 @@ mdurl==0.1.2
packaging==26.2
# via
# abi3audit
# auditwheel
# build
# delocate
patchelf==0.17.2.4
# via -r cibuildwheel/resources/constraints.in
pefile==2024.8.26
# via
# abi3audit
# delvewheel
pip==26.1.1
# via -r cibuildwheel/resources/constraints.in
pkgconf==2.5.1.post2
# via -r cibuildwheel/resources/constraints.in
platformdirs==4.9.6
# via
# python-discovery
# requests-cache
# virtualenv
pyelftools==0.32
# via abi3audit
# via
# abi3audit
# auditwheel
pygments==2.20.0
# via rich
pyproject-hooks==1.2.0
@@ -10,6 +10,8 @@ attrs==26.1.0
# via
# cattrs
# requests-cache
auditwheel==6.4.2
# via -r cibuildwheel/resources/constraints.in
build==1.4.4
# via -r cibuildwheel/resources/constraints.in
cattrs==25.3.0
@@ -35,7 +37,9 @@ idna==3.16
# requests
# url-normalize
importlib-metadata==8.7.1
# via build
# via
# build
# pkgconf
kaitaistruct==0.11
# via abi3audit
macholib==1.16.4
@@ -47,21 +51,28 @@ mdurl==0.1.2
packaging==25.0
# via
# abi3audit
# auditwheel
# build
# delocate
patchelf==0.17.2.4
# via -r cibuildwheel/resources/constraints.in
pefile==2024.8.26
# via
# abi3audit
# delvewheel
pip==26.0.1
# via -r cibuildwheel/resources/constraints.in
pkgconf==2.4.3.post2
# via -r cibuildwheel/resources/constraints.in
platformdirs==4.4.0
# via
# python-discovery
# requests-cache
# virtualenv
pyelftools==0.32
# via abi3audit
# via
# abi3audit
# auditwheel
pygments==2.20.0
# via rich
pyproject-hooks==1.2.0
+5
View File
@@ -4,3 +4,8 @@ delocate
delvewheel
virtualenv
abi3audit
# Android
auditwheel
patchelf
pkgconf
+10 -1
View File
@@ -10,6 +10,8 @@ attrs==26.1.0
# via
# cattrs
# requests-cache
auditwheel==6.7.0
# via -r cibuildwheel/resources/constraints.in
build==1.5.0
# via -r cibuildwheel/resources/constraints.in
cattrs==26.1.0
@@ -43,21 +45,28 @@ mdurl==0.1.2
packaging==26.2
# via
# abi3audit
# auditwheel
# build
# delocate
patchelf==0.17.2.4
# via -r cibuildwheel/resources/constraints.in
pefile==2024.8.26
# via
# abi3audit
# delvewheel
pip==26.1.1
# via -r cibuildwheel/resources/constraints.in
pkgconf==2.5.1.post2
# via -r cibuildwheel/resources/constraints.in
platformdirs==4.9.6
# via
# python-discovery
# requests-cache
# virtualenv
pyelftools==0.32
# via abi3audit
# via
# abi3audit
# auditwheel
pygments==2.20.0
# via rich
pyproject-hooks==1.2.0
+11
View File
@@ -1,3 +1,6 @@
# These are the defaults used by cibuildwheel itself. They should match the values in
# generate_schema.py, which are used by external tools for validation and IDE support.
[tool.cibuildwheel]
build = "*"
skip = ""
@@ -52,6 +55,13 @@ musllinux-s390x-image = "musllinux_1_2"
musllinux-armv7l-image = "musllinux_1_2"
musllinux-riscv64-image = "musllinux_1_2"
[tool.cibuildwheel.xbuild-files]
numpy = [
"numpy/_core/include/numpy/_numpyconfig.h",
"numpy/_core/include/numpy/numpyconfig.h",
"numpy/_core/lib/libnpymath.a",
"numpy/random/lib/libnpyrandom.a",
]
[tool.cibuildwheel.linux]
repair-wheel-command = "auditwheel repair -w {dest_dir} {wheel}"
@@ -63,6 +73,7 @@ repair-wheel-command = "delocate-wheel --require-archs {delocate_archs} -w {dest
repair-wheel-command = "delvewheel repair -w {dest_dir} -v {wheel}"
[tool.cibuildwheel.android]
repair-wheel-command = "auditwheel repair --ldpaths {ldpaths} -w {dest_dir} {wheel}"
[tool.cibuildwheel.ios]
+2 -2
View File
@@ -109,7 +109,7 @@ def parse_key_value_string(
if kw_arg_names is None:
kw_arg_names = []
all_field_names = [*positional_arg_names, *kw_arg_names]
all_field_names = None if ("*" in kw_arg_names) else [*positional_arg_names, *kw_arg_names]
shlexer = shlex.shlex(key_value_string, posix=True, punctuation_chars=";")
shlexer.commenters = ""
@@ -126,7 +126,7 @@ def parse_key_value_string(
# check to see if the option name is specified
field_name, sep, first_value = field[0].partition(":")
if sep:
if field_name not in all_field_names:
if (all_field_names is not None) and (field_name not in all_field_names):
msg = f"Failed to parse {key_value_string!r}. Unknown field name {field_name!r}"
raise ValueError(msg)
+2 -2
View File
@@ -58,8 +58,8 @@ cibuildwheel to run tests, add the following YAML to your CI config file:
You can configure cibuildwheel with a config file, such as `pyproject.toml`.
Options have the same names as the environment variable overrides, but are
placed in `[tool.cibuildwheel]` and are lower case, with dashes, following
common [TOML](https://toml.io) practice. Anything placed in subsections `linux`, `windows`,
`macos`, or `pyodide` will only affect those platforms. Lists can be used
common [TOML](https://toml.io) practice. Anything placed in subsections
named after a platform will only affect those platforms. Lists can be used
instead of strings for items that are naturally a list. Multiline strings also
work just like in the environment variables. Environment variables will take
precedence if defined.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

+2 -2
View File
@@ -179,14 +179,14 @@
},
},
{
label: 'Repair wheel',
label: 'Repair using auditwheel',
href: 'options/#repair-wheel-command',
platforms: ['android'],
style: 'block',
tooltip: {
title: 'CIBW_REPAIR_WHEEL_COMMAND',
tag: 'Customisable step',
description: 'By default, bundles libc++ into the wheel if any shared library links against it, using patchelf. Can be overridden with a custom repair command.'
description: 'Bundle shared libraries by running auditwheel on each built wheel.'
},
},
{
+31 -2
View File
@@ -916,6 +916,35 @@ Platform-specific environment variables are also available on platforms that use
```
### `xbuild-files` {: #xbuild-files env-var toml}
> Platform-specific files in the build environment
When cross-compiling a package for Android, any dependencies in its [`build-system.requires`](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/) are installed for the build platform. However, some dependencies contain platform-specific files such as headers and static libraries, which must correspond to the target platform.
This option maps a [normalized](https://packaging.python.org/en/latest/specifications/name-normalization/#name-normalization) package name to a list of paths within that package. If the package is present in the build environment, then a matching version will be downloaded for the target platform, and used to overwrite the given paths within the build environment.
The default value of this option includes [paths from popular packages](configuration.md#configuration-file).
Platform-specific environment variables are also available:<br/>
`CIBW_XBUILD_FILES_ANDROID`
#### Examples
!!! tab examples "pyproject.toml"
```toml
[tool.cibuildwheel.xbuild-files]
package1 = ["some/header.h", "some/library.a"]
package2 = ["other/header.h"]
```
!!! tab examples "Environment variables"
```yaml
CIBW_XBUILD_FILES: "package1: some/header.h some/library.a; package2: other/header.h"
```
### `repair-wheel-command` {: #repair-wheel-command env-var toml}
> Execute a shell command to repair each built wheel
@@ -923,8 +952,7 @@ Default:
- on Linux: `'auditwheel repair -w {dest_dir} {wheel}'`
- on macOS: `'delocate-wheel --require-archs {delocate_archs} -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 Android: `'auditwheel repair --ldpaths {ldpaths} -w {dest_dir} {wheel}'`
- on Pyodide: You can use `pyodide auditwheel repair --libdir /path/to/libraries --output-dir {dest_dir} {wheel}` command to repair the wheel.
Unlike other platforms, this command is not set by default as you need to explicitly
specify the library directory. You might not want to use the libraries in the system
@@ -939,6 +967,7 @@ The following placeholders must be used inside the command and will be replaced
- `{wheel}` for the absolute path to the built wheel
- `{dest_dir}` for the absolute path of the directory where to create the repaired wheel
- `{delocate_archs}` (macOS only) comma-separated list of architectures in the wheel.
- `{ldpaths}` (Android only) colon-separated list of directories to search for external libraries, set by cibuildwheel to include any necessary locations in the NDK. You can add more directories by appending them with a colon separator after the placeholder, or by setting the `AUDITWHEEL_LD_LIBRARY_PATH` environment variable.
You can use the `{package}` or `{project}` placeholders in your `repair-wheel-command` to refer to the package being built or the project root, respectively.
+3 -7
View File
@@ -219,13 +219,9 @@ It also requires the following commands to be on the `PATH`:
Android builds will honor the `ANDROID_API_LEVEL` environment variable to set the
minimum supported [API level](https://developer.android.com/tools/releases/platforms)
for generated wheels. This will default to the minimum API level of the selected Python
version.
If the [`repair-wheel-command`](options.md#repair-wheel-command) adds any libraries to
the wheel, then `ANDROID_API_LEVEL` must be at least 24. This is already the default
when building for Python 3.14 and later, but you may need to set it when building for
Python 3.13.
for generated wheels. This defaults to 24, which is supported by [99% of active
devices](https://dl.google.com/android/studio/metadata/distributions.json), and is the first
version to support RUNPATH, which auditwheel needs in order to graft external libraries.
### Build frontend support
-4
View File
@@ -45,11 +45,7 @@ dependencies = [
"filelock",
"humanize",
"packaging>=20.9",
# patchelf is used for Android
"patchelf; (sys_platform == 'linux' or sys_platform == 'darwin') and (platform_machine == 'x86_64' or platform_machine == 'arm64' or platform_machine == 'aarch64')",
"platformdirs",
"pyelftools>=0.29",
"wheel>=0.33.6",
]
[project.optional-dependencies]
+197 -17
View File
@@ -13,7 +13,7 @@ from zipfile import ZipFile
import pytest
from .test_projects import new_c_project
from .test_projects import new_c_project, new_meson_project
from .utils import cibuildwheel_run, expected_wheels
pytestmark = pytest.mark.android
@@ -125,7 +125,7 @@ def test_frontend_good(tmp_path: Path, build_frontend_env: dict[str, str]) -> No
tmp_path,
add_env={**cp313_env, **build_frontend_env, "CIBW_TEST_COMMAND": "python -m site"},
)
assert wheels == [f"spam-0.1.0-cp313-cp313-android_21_{native_arch.android_abi}.whl"]
assert wheels == [f"spam-0.1.0-cp313-cp313-android_24_{native_arch.android_abi}.whl"]
@pytest.mark.parametrize("frontend", ["pip"])
@@ -136,7 +136,10 @@ def test_frontend_bad(frontend: str, tmp_path: Path, capfd: pytest.CaptureFixtur
tmp_path,
add_env={**cp313_env, "CIBW_BUILD_FRONTEND": frontend},
)
assert "Android requires the build frontend to be 'build'" in capfd.readouterr().err
assert (
f"Android requires the build frontend to be 'build' or 'uv', not '{frontend}'"
in capfd.readouterr().err
)
@needs_emulator
@@ -162,7 +165,7 @@ def test_archs(tmp_path: Path, capfd: pytest.CaptureFixture[str]) -> None:
),
},
)
assert wheels == [f"spam-0.1.0-cp313-cp313-android_21_{arch.android_abi}.whl" for arch in archs]
assert wheels == [f"spam-0.1.0-cp313-cp313-android_24_{arch.android_abi}.whl" for arch in archs]
stdout, stderr = capfd.readouterr()
lines = (line for line in stdout.splitlines() if line.startswith("Hello from"))
@@ -435,13 +438,25 @@ def test_verbosity(tmp_path: Path, capfd: pytest.CaptureFixture[str]) -> None:
@needs_emulator
def test_api_level(tmp_path: Path, capfd: pytest.CaptureFixture[str]) -> None:
project = new_c_project()
project = new_c_project(
# Check that the the compiler options are set correctly.
spam_c_top_level_add=dedent(
"""\
#if __ANDROID_API__ != 33
#error Unexpected API level; the following syntax error will show the actual value:
__ANDROID_API__
#endif
"""
)
)
project.files["pyproject.toml"] = dedent(
"""\
[build-system]
requires = ["setuptools"]
[tool.cibuildwheel]
# Test setting API level in pyproject.toml (test_libcxx covers setting
# it in the outer environment.)
android.environment.ANDROID_API_LEVEL = "33"
android.environment.PIP_EXTRA_INDEX_URL = "https://chaquo.com/pypi-13.1"
"""
@@ -486,17 +501,21 @@ def test_libcxx(tmp_path: Path, capfd: pytest.CaptureFixture[str]) -> None:
"PATH": non_venv_path,
}
# Including external libraries requires API level 24.
# Including external libraries requires API level 24. This is enforced by auditwheel.
cp313_android_21_env = {
**cp313_test_env,
# Test setting API level in the outer environment (test_api_level covers setting
# it in pyproject.toml.)
"ANDROID_API_LEVEL": "21",
}
with pytest.raises(CalledProcessError):
cibuildwheel_run(project_dir, add_env=cp313_test_env, output_dir=output_dir)
assert "libc++_shared.so requires ANDROID_API_LEVEL to be at least 24" in capfd.readouterr().err
wheels = cibuildwheel_run(
project_dir,
add_env={**cp313_test_env, "ANDROID_API_LEVEL": "24"},
output_dir=output_dir,
cibuildwheel_run(project_dir, add_env=cp313_android_21_env, output_dir=output_dir)
assert (
"Grafting libraries with RUNPATH requires API level 24 or higher" in capfd.readouterr().err
)
assert len(wheels) == 1
wheels = cibuildwheel_run(project_dir, add_env=cp313_test_env, output_dir=output_dir)
assert wheels == [f"spam-0.1.0-cp313-cp313-android_24_{native_arch.android_abi}.whl"]
names = ZipFile(output_dir / wheels[0]).namelist()
libcxx_names = [
name for name in names if re.fullmatch(r"spam\.libs/libc\+\+_shared-[0-9a-f]{8}\.so", name)
@@ -504,16 +523,177 @@ def test_libcxx(tmp_path: Path, capfd: pytest.CaptureFixture[str]) -> None:
assert len(libcxx_names) == 1
assert "ham: 1, spam: 0" in capfd.readouterr().out
# A C package should not include libc++.
# A C package should not include libc++, and can therefore use an older API level.
rmtree(project_dir)
rmtree(output_dir)
new_c_project().generate(project_dir)
wheels = cibuildwheel_run(project_dir, add_env=cp313_env, output_dir=output_dir)
assert len(wheels) == 1
wheels = cibuildwheel_run(project_dir, add_env=cp313_android_21_env, output_dir=output_dir)
assert wheels == [f"spam-0.1.0-cp313-cp313-android_21_{native_arch.android_abi}.whl"]
for name in ZipFile(output_dir / wheels[0]).namelist():
assert ".libs" not in name
@needs_emulator
def test_repair_none(tmp_path: Path, capfd: pytest.CaptureFixture[str]) -> None:
new_c_project(setup_py_extension_args_add="language='c++'").generate(tmp_path)
with pytest.raises(CalledProcessError):
cibuildwheel_run(
tmp_path,
add_env={
**cp313_env,
"CIBW_REPAIR_WHEEL_COMMAND": "",
"CIBW_TEST_COMMAND": "python -c 'import spam'",
},
)
assert 'dlopen failed: library "libc++_shared.so" not found' in capfd.readouterr().err
def test_repair_ldpaths(tmp_path: Path) -> None:
new_c_project().generate(tmp_path)
repair_path = tmp_path / "repair.py"
repair_path.write_text(
dedent(
"""\
#!/usr/bin/env python
import shutil
import sys
from pathlib import Path
assert len(sys.argv) == 4, sys.argv
ldpaths = list(map(Path, sys.argv[1].split(":")))
dest_dir = sys.argv[2]
wheel = sys.argv[3]
for name in ["libc++_shared.so", "libomp.so"]:
assert any((lp / name).exists() for lp in ldpaths), (name, ldpaths)
shutil.copy(wheel, dest_dir)
"""
)
)
repair_path.chmod(0o755)
wheels = cibuildwheel_run(
tmp_path,
add_env={
**cp313_env,
"CIBW_REPAIR_WHEEL_COMMAND": f"{repair_path} {{ldpaths}} {{dest_dir}} {{wheel}}",
},
)
assert wheels == [f"spam-0.1.0-cp313-cp313-android_24_{native_arch.android_abi}.whl"]
@pytest.mark.parametrize(
("script", "error"),
[
("", "did not produce a wheel"),
("touch $dest_dir/one.whl $dest_dir/two.whl", "produced multiple wheels"),
("touch $dest_dir/one-0.0.1-py3-none-any.whl", "pure Python wheel was generated"),
],
)
def test_repair_error(
script: str, error: str, tmp_path: Path, capfd: pytest.CaptureFixture[str]
) -> None:
new_c_project().generate(tmp_path)
repair_path = tmp_path / "repair.sh"
repair_path.write_text(
dedent(
f"""\
#!/bin/sh
dest_dir=$1
{script}
"""
)
)
repair_path.chmod(0o755)
with pytest.raises(CalledProcessError):
cibuildwheel_run(
tmp_path,
add_env={**cp313_env, "CIBW_REPAIR_WHEEL_COMMAND": f"{repair_path} {{dest_dir}}"},
)
assert error in capfd.readouterr().err
# This also tests integration with pkgconf, because Meson uses it to find Python.
@needs_emulator
def test_meson(tmp_path: Path, capfd: pytest.CaptureFixture[str]) -> None:
# Alter spam.filter to return the value of a Fortran function.
new_meson_project(
spam_c_top_level_add="int fortran_func_();",
spam_c_function_add="sts = fortran_func_();",
project_args_add="'fortran',",
extension_args_add="'fortran.f90', link_language: 'fortran',",
).generate(tmp_path)
# TODO: remove once meson-python has a release with Android support.
pyproject_path = tmp_path / "pyproject.toml"
pyproject_path.write_text(
pyproject_path.read_text().replace(
"meson-python", "meson-python @ git+https://github.com/mesonbuild/meson-python@main"
)
)
# Add Fortran code.
(tmp_path / "fortran.f90").write_text(
dedent(
"""\
integer*4 function fortran_func()
fortran_func = 42
end
"""
)
)
script = 'import spam; print(f"result: {spam.filter("")}")'
cibuildwheel_run(
tmp_path,
add_env={**cp313_env, "CIBW_TEST_COMMAND": f"python -c '{script}'"},
)
assert "result: 42" in capfd.readouterr().out
@needs_emulator
def test_xbuild_files(tmp_path: Path, capfd: pytest.CaptureFixture[str]) -> None:
# Verify that we've replaced the correct files by compiling against a non-trivial
# function from libnpymath.a.
new_c_project(
setup_py_add=dedent(
"""\
import numpy as np
np_include = np.get_include()
np_lib = f"{np_include}/../lib"
libraries.append("npymath")
"""
),
setup_py_extension_args_add="include_dirs=[np_include], library_dirs=[np_lib]",
spam_c_top_level_add="#include <numpy/halffloat.h>",
spam_c_function_add="sts = npy_float_to_half(42);",
).generate(tmp_path)
(tmp_path / "pyproject.toml").write_text(
dedent(
"""\
[build-system]
requires = ["setuptools", "numpy==2.3.2"]
"""
)
)
script = 'import spam; print(f"result: {spam.filter(""):#x}")'
cibuildwheel_run(
tmp_path,
add_env={
**cp313_env,
# TODO: remove this once there are official Android NumPy wheels on PyPI.
"PIP_EXTRA_INDEX_URL": "https://chaquo.com/pypi-test",
"CIBW_ARCHS": "all", # Include both native and non-native archs.
"CIBW_TEST_COMMAND": f"python -c '{script}'",
},
)
assert "result: 0x5140" in capfd.readouterr().out
@needs_emulator
def test_setuptools_rust(tmp_path: Path, capfd: pytest.CaptureFixture[str]) -> None:
"""
+6
View File
@@ -22,6 +22,7 @@ archs = ["auto64"]
MESON_BUILD_TEMPLATE = r"""
project('spam', 'c',
{{ project_args_add | indent(2) }}
version: '0.1.0',
default_options: ['warning_level=2'],
)
@@ -30,6 +31,7 @@ py = import('python').find_installation(pure: false)
py.extension_module('spam',
'spam.c',
{{ extension_args_add | indent(2) }}
install: true,
)
"""
@@ -39,6 +41,8 @@ def new_meson_project(
*,
spam_c_top_level_add: str = "",
spam_c_function_add: str = "",
project_args_add: str = "",
extension_args_add: str = "",
) -> TestProject:
project = TestProject()
@@ -54,6 +58,8 @@ def new_meson_project(
{
"spam_c_top_level_add": spam_c_top_level_add,
"spam_c_function_add": spam_c_function_add,
"project_args_add": project_args_add,
"extension_args_add": extension_args_add,
}
)
+2 -1
View File
@@ -82,9 +82,10 @@ import sys
from setuptools import setup, Extension
libraries = []
{{ setup_py_add }}
libraries = []
# Emscripten fails if you pass -lc...
# See: https://github.com/emscripten-core/emscripten/issues/16680
if sys.platform.startswith('linux') and "emscripten" not in os.environ.get("_PYTHON_HOST_PLATFORM", ""):
+7 -6
View File
@@ -177,6 +177,7 @@ def expected_wheels(
musllinux_versions: list[str] | None = None,
macosx_deployment_target: str | None = None,
iphoneos_deployment_target: str | None = None,
android_api_level: int | None = None,
machine_arch: str | None = None,
platform: str | None = None,
python_abi_tags: list[str] | None = None,
@@ -201,6 +202,9 @@ def expected_wheels(
if iphoneos_deployment_target is None:
iphoneos_deployment_target = os.environ.get("IPHONEOS_DEPLOYMENT_TARGET", "13.0")
if android_api_level is None:
android_api_level = int(os.environ.get("ANDROID_API_LEVEL", "24"))
architectures = [machine_arch]
if not single_arch:
if platform == "linux" and full_auto:
@@ -226,6 +230,7 @@ def expected_wheels(
musllinux_versions=musllinux_versions,
macosx_deployment_target=macosx_deployment_target,
iphoneos_deployment_target=iphoneos_deployment_target,
android_api_level=android_api_level,
platform=platform,
python_abi_tags=python_abi_tags,
include_universal2=include_universal2,
@@ -242,6 +247,7 @@ def _expected_wheels(
musllinux_versions: list[str] | None,
macosx_deployment_target: str,
iphoneos_deployment_target: str,
android_api_level: int,
platform: str,
python_abi_tags: list[str] | None,
include_universal2: bool,
@@ -387,12 +393,7 @@ def _expected_wheels(
platform_tags.append(f"macosx_{min_macosx.replace('.', '_')}_universal2")
elif platform == "android":
api_level = {
"cp313-cp313": 21,
"cp314-cp314": 24,
"cp315-cp315": 24,
}[python_abi_tag]
platform_tags = [f"android_{api_level}_{machine_arch}"]
platform_tags = [f"android_{android_api_level}_{machine_arch}"]
elif platform == "ios":
if machine_arch == "x86_64":
+48
View File
@@ -667,3 +667,51 @@ def test_xbuild_tools_handling(tmp_path: Path, definition: str, expected: list[s
local = options.build_options("cp313-ios_13_0_arm64_iphoneos")
assert local.xbuild_tools == expected
DEFAULT_XBUILD_FILES = {
"numpy": [
"numpy/_core/include/numpy/_numpyconfig.h",
"numpy/_core/include/numpy/numpyconfig.h",
"numpy/_core/lib/libnpymath.a",
"numpy/random/lib/libnpyrandom.a",
]
}
@pytest.mark.parametrize(
("definition", "expected"),
[
("", DEFAULT_XBUILD_FILES),
('xbuild-files = ""', {}),
('xbuild-files = "package1: file1"', {"package1": ["file1"]}),
(
"xbuild-files = \"package1: 'file1 with space'; package2: file2a file2b\"",
{"package1": ["file1 with space"], "package2": ["file2a", "file2b"]},
),
],
)
def test_xbuild_files(tmp_path: Path, definition: str, expected: dict[str, list[str]]) -> None:
args = CommandLineArguments.defaults()
args.package_dir = tmp_path
pyproject_toml: Path = tmp_path / "pyproject.toml"
pyproject_toml.write_text(
textwrap.dedent(
f"""\
[tool.cibuildwheel]
{definition}
"""
)
)
options = Options(platform="ios", command_line_arguments=args, env={})
assert options.build_options(None).xbuild_files == expected
pyproject_toml.write_text(
"[tool.cibuildwheel.xbuild-files]\n"
+ "\n".join(f"{package} = {files}" for package, files in expected.items())
)
options = Options(platform="ios", command_line_arguments=args, env={})
assert options.build_options(None).xbuild_files == expected
+22
View File
@@ -212,6 +212,28 @@ def test_parse_key_value_string() -> None:
}
def test_parse_key_value_string_unknown_name() -> None:
# Unknown fields are not allowed by default.
with pytest.raises(ValueError, match=r"Failed to parse 'key: value'. Unknown field name 'key'"):
parse_key_value_string("key: value")
# Unknown fields can be enabled by passing "*".
assert parse_key_value_string(
"key: value",
kw_arg_names=["*"],
) == {
"key": ["value"],
}
assert parse_key_value_string(
"key1: value1a value1b; key2: value2",
kw_arg_names=["*"],
) == {
"key1": ["value1a", "value1b"],
"key2": ["value2"],
}
def test_flexible_version_comparisons() -> None:
assert FlexibleVersion("2.0") == FlexibleVersion("2")
assert FlexibleVersion("2.0") < FlexibleVersion("2.1")