diff --git a/README.md b/README.md index 2caeeaa76..ec78216e3 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,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`
`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 | @@ -170,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 | - + These options can be specified in a pyproject.toml file, or as environment variables, see [configuration docs](https://cibuildwheel.pypa.io/en/latest/configuration/). diff --git a/bin/generate_schema.py b/bin/generate_schema.py index 022ffe356..c6d0b3a4d 100755 --- a/bin/generate_schema.py +++ b/bin/generate_schema.py @@ -15,6 +15,8 @@ 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 @@ -195,6 +197,9 @@ 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 @@ -312,6 +317,7 @@ 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"} @@ -367,14 +373,15 @@ def as_object(d: dict[str, Any]) -> dict[str, Any]: "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"] diff --git a/cibuildwheel/options.py b/cibuildwheel/options.py index 972c7b2c6..b4293e341 100644 --- a/cibuildwheel/options.py +++ b/cibuildwheel/options.py @@ -113,6 +113,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 @@ -764,6 +765,14 @@ def _compute_build_options(self, identifier: str | None) -> BuildOptions: 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) @@ -908,6 +917,7 @@ def _compute_build_options(self, identifier: str | None) -> BuildOptions: 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, diff --git a/cibuildwheel/platforms/android.py b/cibuildwheel/platforms/android.py index 50b07884c..818426f46 100644 --- a/cibuildwheel/platforms/android.py +++ b/cibuildwheel/platforms/android.py @@ -1,15 +1,11 @@ -import csv -import hashlib import os import platform import re import shlex import shutil import subprocess -import sysconfig -from collections.abc import Iterable, Iterator, MutableMapping +import sys from dataclasses import dataclass -from os.path import relpath from pathlib import Path from pprint import pprint from runpy import run_path @@ -18,9 +14,8 @@ 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 @@ -28,6 +23,7 @@ from cibuildwheel.logger import log from cibuildwheel.options import BuildOptions, Options from cibuildwheel.selector import BuildSelector +from cibuildwheel.typing import PathOrStr from cibuildwheel.util import resources from cibuildwheel.util.cmd import call, shell from cibuildwheel.util.file import CIBW_CACHE_PATH, copy_test_sources, download, move_file @@ -138,6 +134,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: @@ -151,7 +148,7 @@ def build(options: Options, tmp_path: Path) -> None: built_wheel = build_wheel(state) repaired_wheel = repair_wheel(state, built_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: @@ -183,6 +180,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 @@ -196,13 +198,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( @@ -217,6 +213,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" @@ -231,14 +230,21 @@ def setup_env( raise errors.FatalError(msg) 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) + # TODO: use an official auditwheel version once + # https://github.com/pypa/auditwheel/pull/643 has been released, and add it to the + # constraints files. + tools = [ + "auditwheel @ git+https://github.com/pypa/auditwheel@main", + "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 @@ -329,8 +335,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 @@ -353,15 +358,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, ) ) @@ -400,6 +405,15 @@ 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", 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: @@ -408,11 +422,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 @@ -436,13 +446,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, ) @@ -487,15 +567,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 @@ -507,9 +582,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, @@ -518,14 +604,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"): @@ -533,119 +620,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( @@ -658,31 +638,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. @@ -752,13 +718,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, - ) diff --git a/cibuildwheel/resources/android/_cross_venv.py b/cibuildwheel/resources/android/_cross_venv.py index 40dfaca5f..0c50dcce2 100644 --- a/cibuildwheel/resources/android/_cross_venv.py +++ b/cibuildwheel/resources/android/_cross_venv.py @@ -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( diff --git a/cibuildwheel/resources/android/fortran_shim.py b/cibuildwheel/resources/android/fortran_shim.py new file mode 100644 index 000000000..b3e4978a3 --- /dev/null +++ b/cibuildwheel/resources/android/fortran_shim.py @@ -0,0 +1,110 @@ +# 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 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" + + +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"] + for path in ["/private", "/Users", "/tmp"]: + # 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. + 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() diff --git a/cibuildwheel/resources/cibuildwheel.schema.json b/cibuildwheel/resources/cibuildwheel.schema.json index a82518f4b..d8a686995 100644 --- a/cibuildwheel/resources/cibuildwheel.schema.json +++ b/cibuildwheel/resources/cibuildwheel.schema.json @@ -431,6 +431,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", @@ -644,6 +672,9 @@ "xbuild-tools": { "$ref": "#/$defs/inherit" }, + "xbuild-files": { + "$ref": "#/$defs/inherit" + }, "before-test": { "$ref": "#/$defs/inherit" }, @@ -766,6 +797,9 @@ "xbuild-tools": { "$ref": "#/properties/xbuild-tools" }, + "xbuild-files": { + "$ref": "#/properties/xbuild-files" + }, "pyodide-version": { "$ref": "#/properties/pyodide-version" }, @@ -884,6 +918,9 @@ "xbuild-tools": { "$ref": "#/properties/xbuild-tools" }, + "xbuild-files": { + "$ref": "#/properties/xbuild-files" + }, "pyodide-version": { "$ref": "#/properties/pyodide-version" }, @@ -960,6 +997,9 @@ "xbuild-tools": { "$ref": "#/properties/xbuild-tools" }, + "xbuild-files": { + "$ref": "#/properties/xbuild-files" + }, "pyodide-version": { "$ref": "#/properties/pyodide-version" }, @@ -1023,6 +1063,9 @@ "xbuild-tools": { "$ref": "#/properties/xbuild-tools" }, + "xbuild-files": { + "$ref": "#/properties/xbuild-files" + }, "pyodide-version": { "$ref": "#/properties/pyodide-version" }, @@ -1099,6 +1142,9 @@ "xbuild-tools": { "$ref": "#/properties/xbuild-tools" }, + "xbuild-files": { + "$ref": "#/properties/xbuild-files" + }, "pyodide-version": { "$ref": "#/properties/pyodide-version" }, @@ -1162,11 +1208,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" @@ -1225,6 +1287,9 @@ "xbuild-tools": { "$ref": "#/properties/xbuild-tools" }, + "xbuild-files": { + "$ref": "#/properties/xbuild-files" + }, "pyodide-version": { "$ref": "#/properties/pyodide-version" }, diff --git a/cibuildwheel/resources/constraints-python310.txt b/cibuildwheel/resources/constraints-python310.txt index 3f218e356..0908d4aca 100644 --- a/cibuildwheel/resources/constraints-python310.txt +++ b/cibuildwheel/resources/constraints-python310.txt @@ -20,8 +20,12 @@ packaging==26.0 # via # build # delocate +patchelf==0.17.2.4 + # via -r cibuildwheel/resources/constraints.in pip==26.0.1 # via -r cibuildwheel/resources/constraints.in +pkgconf==2.5.1.post1 + # via -r cibuildwheel/resources/constraints.in platformdirs==4.9.6 # via # python-discovery diff --git a/cibuildwheel/resources/constraints-python311.txt b/cibuildwheel/resources/constraints-python311.txt index 636dfbb8c..f86c0b5a1 100644 --- a/cibuildwheel/resources/constraints-python311.txt +++ b/cibuildwheel/resources/constraints-python311.txt @@ -18,8 +18,12 @@ packaging==26.0 # via # build # delocate +patchelf==0.17.2.4 + # via -r cibuildwheel/resources/constraints.in pip==26.0.1 # via -r cibuildwheel/resources/constraints.in +pkgconf==2.5.1.post1 + # via -r cibuildwheel/resources/constraints.in platformdirs==4.9.6 # via # python-discovery diff --git a/cibuildwheel/resources/constraints-python312.txt b/cibuildwheel/resources/constraints-python312.txt index 636dfbb8c..f86c0b5a1 100644 --- a/cibuildwheel/resources/constraints-python312.txt +++ b/cibuildwheel/resources/constraints-python312.txt @@ -18,8 +18,12 @@ packaging==26.0 # via # build # delocate +patchelf==0.17.2.4 + # via -r cibuildwheel/resources/constraints.in pip==26.0.1 # via -r cibuildwheel/resources/constraints.in +pkgconf==2.5.1.post1 + # via -r cibuildwheel/resources/constraints.in platformdirs==4.9.6 # via # python-discovery diff --git a/cibuildwheel/resources/constraints-python313.txt b/cibuildwheel/resources/constraints-python313.txt index 636dfbb8c..f86c0b5a1 100644 --- a/cibuildwheel/resources/constraints-python313.txt +++ b/cibuildwheel/resources/constraints-python313.txt @@ -18,8 +18,12 @@ packaging==26.0 # via # build # delocate +patchelf==0.17.2.4 + # via -r cibuildwheel/resources/constraints.in pip==26.0.1 # via -r cibuildwheel/resources/constraints.in +pkgconf==2.5.1.post1 + # via -r cibuildwheel/resources/constraints.in platformdirs==4.9.6 # via # python-discovery diff --git a/cibuildwheel/resources/constraints-python314.txt b/cibuildwheel/resources/constraints-python314.txt index 636dfbb8c..f86c0b5a1 100644 --- a/cibuildwheel/resources/constraints-python314.txt +++ b/cibuildwheel/resources/constraints-python314.txt @@ -18,8 +18,12 @@ packaging==26.0 # via # build # delocate +patchelf==0.17.2.4 + # via -r cibuildwheel/resources/constraints.in pip==26.0.1 # via -r cibuildwheel/resources/constraints.in +pkgconf==2.5.1.post1 + # via -r cibuildwheel/resources/constraints.in platformdirs==4.9.6 # via # python-discovery diff --git a/cibuildwheel/resources/constraints-python38.txt b/cibuildwheel/resources/constraints-python38.txt index 84c6ceae5..84004ce6d 100644 --- a/cibuildwheel/resources/constraints-python38.txt +++ b/cibuildwheel/resources/constraints-python38.txt @@ -13,15 +13,21 @@ filelock==3.16.1 # python-discovery # virtualenv importlib-metadata==8.5.0 - # via build + # via + # build + # pkgconf macholib==1.16.4 # via delocate packaging==26.0 # via # build # delocate +patchelf==0.17.2.4 + # via -r cibuildwheel/resources/constraints.in pip==25.0.1 # via -r cibuildwheel/resources/constraints.in +pkgconf==2.2.0.post0 + # via -r cibuildwheel/resources/constraints.in platformdirs==4.3.6 # via # python-discovery diff --git a/cibuildwheel/resources/constraints-python39.txt b/cibuildwheel/resources/constraints-python39.txt index 441105022..84aa2efee 100644 --- a/cibuildwheel/resources/constraints-python39.txt +++ b/cibuildwheel/resources/constraints-python39.txt @@ -13,15 +13,21 @@ filelock==3.19.1 # python-discovery # virtualenv importlib-metadata==8.7.1 - # via build + # via + # build + # pkgconf macholib==1.16.4 # via delocate packaging==26.0 # via # build # delocate +patchelf==0.17.2.4 + # via -r cibuildwheel/resources/constraints.in 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 diff --git a/cibuildwheel/resources/constraints.in b/cibuildwheel/resources/constraints.in index 50bfabb6e..562df62cf 100644 --- a/cibuildwheel/resources/constraints.in +++ b/cibuildwheel/resources/constraints.in @@ -1,4 +1,10 @@ pip build -delocate virtualenv + +# Android +patchelf +pkgconf + +# macOS +delocate diff --git a/cibuildwheel/resources/constraints.txt b/cibuildwheel/resources/constraints.txt index 636dfbb8c..f86c0b5a1 100644 --- a/cibuildwheel/resources/constraints.txt +++ b/cibuildwheel/resources/constraints.txt @@ -18,8 +18,12 @@ packaging==26.0 # via # build # delocate +patchelf==0.17.2.4 + # via -r cibuildwheel/resources/constraints.in pip==26.0.1 # via -r cibuildwheel/resources/constraints.in +pkgconf==2.5.1.post1 + # via -r cibuildwheel/resources/constraints.in platformdirs==4.9.6 # via # python-discovery diff --git a/cibuildwheel/resources/defaults.toml b/cibuildwheel/resources/defaults.toml index 78895bf9a..722dbcaa8 100644 --- a/cibuildwheel/resources/defaults.toml +++ b/cibuildwheel/resources/defaults.toml @@ -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 = "" @@ -50,6 +53,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}" @@ -60,6 +70,7 @@ repair-wheel-command = "delocate-wheel --require-archs {delocate_archs} -w {dest [tool.cibuildwheel.windows] [tool.cibuildwheel.android] +repair-wheel-command = "auditwheel repair --ldpaths {ldpaths} -w {dest_dir} {wheel}" [tool.cibuildwheel.ios] diff --git a/cibuildwheel/util/helpers.py b/cibuildwheel/util/helpers.py index 6e17cb2eb..7420f629e 100644 --- a/cibuildwheel/util/helpers.py +++ b/cibuildwheel/util/helpers.py @@ -104,7 +104,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 = "" @@ -121,7 +121,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) diff --git a/docs/configuration.md b/docs/configuration.md index c35c697fb..2506cdfd0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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. diff --git a/docs/data/how-it-works.png b/docs/data/how-it-works.png index bbbc2a301..5c6a1fcd5 100644 Binary files a/docs/data/how-it-works.png and b/docs/data/how-it-works.png differ diff --git a/docs/diagram.html b/docs/diagram.html index 8d9785e2f..8871d0595 100644 --- a/docs/diagram.html +++ b/docs/diagram.html @@ -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.' }, }, { diff --git a/docs/options.md b/docs/options.md index fd84d83c0..b81eeb454 100644 --- a/docs/options.md +++ b/docs/options.md @@ -902,6 +902,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, 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:
+ `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 @@ -909,8 +938,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 @@ -925,6 +953,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. cibuildwheel will set this to include any necessary locations in the NDK. To add your own locations, use the `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. diff --git a/docs/platforms.md b/docs/platforms.md index 44d8afbf5..a31961e72 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -222,13 +222,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 diff --git a/pyproject.toml b/pyproject.toml index d5c92213e..7cc3d272a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,8 +44,6 @@ 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", diff --git a/test/test_android.py b/test/test_android.py index 2d52cb761..e8a98cc58 100644 --- a/test/test_android.py +++ b/test/test_android.py @@ -13,7 +13,7 @@ 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,181 @@ 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_();", + ).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. + meson_build_path = tmp_path / "meson.build" + meson_build_path.write_text( + meson_build_path.read_text() + .replace("'c'", "'c', 'fortran'") + .replace("'spam.c'", "'spam.c', 'fortran.f90', link_language: 'fortran'") + ) + (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 ", + 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: """ diff --git a/test/test_projects/setuptools.py b/test/test_projects/setuptools.py index 30b33caf8..13375e981 100644 --- a/test/test_projects/setuptools.py +++ b/test/test_projects/setuptools.py @@ -9,9 +9,10 @@ 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", ""): diff --git a/test/utils.py b/test/utils.py index 7bf3d184f..61fb0dc21 100644 --- a/test/utils.py +++ b/test/utils.py @@ -172,6 +172,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, @@ -196,6 +197,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: @@ -221,6 +225,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, @@ -237,6 +242,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, @@ -391,11 +397,7 @@ def _expected_wheels( platform_tags.append(f"macosx_{min_macosx.replace('.', '_')}_universal2") elif platform == "android": - api_level = { - "cp313-cp313": 21, - "cp314-cp314": 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": diff --git a/unit_test/options_test.py b/unit_test/options_test.py index 0c42b1f9a..b5e61601f 100644 --- a/unit_test/options_test.py +++ b/unit_test/options_test.py @@ -697,3 +697,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 diff --git a/unit_test/utils_test.py b/unit_test/utils_test.py index 3ce67e273..7bc585dd8 100644 --- a/unit_test/utils_test.py +++ b/unit_test/utils_test.py @@ -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")