diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 88b5bad8..579de065 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -39,7 +39,6 @@ repos:
- nox>=2025.2.9
- orjson
- packaging
- - pyelftools
- pygithub
- pytest
- rich
diff --git a/README.md b/README.md
index 1c88168a..9a98ad72 100644
--- a/README.md
+++ b/README.md
@@ -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`
`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 |
-
+
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 59654d70..a7d37162 100755
--- a/bin/generate_schema.py
+++ b/bin/generate_schema.py
@@ -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"]
diff --git a/bin/run_example_ci_configs.py b/bin/run_example_ci_configs.py
index fbc392f8..90287f5b 100755
--- a/bin/run_example_ci_configs.py
+++ b/bin/run_example_ci_configs.py
@@ -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)
diff --git a/cibuildwheel/options.py b/cibuildwheel/options.py
index 89b64681..20607e5c 100644
--- a/cibuildwheel/options.py
+++ b/cibuildwheel/options.py
@@ -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,
diff --git a/cibuildwheel/platforms/android.py b/cibuildwheel/platforms/android.py
index b8bd4bf5..59c3869f 100644
--- a/cibuildwheel/platforms/android.py
+++ b/cibuildwheel/platforms/android.py
@@ -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,
- )
diff --git a/cibuildwheel/resources/android/_cross_venv.py b/cibuildwheel/resources/android/_cross_venv.py
index 40dfaca5..0c50dcce 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 00000000..f91b55ba
--- /dev/null
+++ b/cibuildwheel/resources/android/fortran_shim.py
@@ -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()
diff --git a/cibuildwheel/resources/cibuildwheel.schema.json b/cibuildwheel/resources/cibuildwheel.schema.json
index 7d2522a6..707940d9 100644
--- a/cibuildwheel/resources/cibuildwheel.schema.json
+++ b/cibuildwheel/resources/cibuildwheel.schema.json
@@ -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"
},
diff --git a/cibuildwheel/resources/constraints-python310.txt b/cibuildwheel/resources/constraints-python310.txt
index 302967a6..8257343b 100644
--- a/cibuildwheel/resources/constraints-python310.txt
+++ b/cibuildwheel/resources/constraints-python310.txt
@@ -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
diff --git a/cibuildwheel/resources/constraints-python311.txt b/cibuildwheel/resources/constraints-python311.txt
index edb93120..b3e5128d 100644
--- a/cibuildwheel/resources/constraints-python311.txt
+++ b/cibuildwheel/resources/constraints-python311.txt
@@ -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
diff --git a/cibuildwheel/resources/constraints-python312.txt b/cibuildwheel/resources/constraints-python312.txt
index edb93120..b3e5128d 100644
--- a/cibuildwheel/resources/constraints-python312.txt
+++ b/cibuildwheel/resources/constraints-python312.txt
@@ -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
diff --git a/cibuildwheel/resources/constraints-python313.txt b/cibuildwheel/resources/constraints-python313.txt
index 319dfcf5..9acfd1b6 100644
--- a/cibuildwheel/resources/constraints-python313.txt
+++ b/cibuildwheel/resources/constraints-python313.txt
@@ -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
diff --git a/cibuildwheel/resources/constraints-python314.txt b/cibuildwheel/resources/constraints-python314.txt
index 319dfcf5..9acfd1b6 100644
--- a/cibuildwheel/resources/constraints-python314.txt
+++ b/cibuildwheel/resources/constraints-python314.txt
@@ -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
diff --git a/cibuildwheel/resources/constraints-python315.txt b/cibuildwheel/resources/constraints-python315.txt
index 319dfcf5..9acfd1b6 100644
--- a/cibuildwheel/resources/constraints-python315.txt
+++ b/cibuildwheel/resources/constraints-python315.txt
@@ -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
diff --git a/cibuildwheel/resources/constraints-python39.txt b/cibuildwheel/resources/constraints-python39.txt
index 835292fa..95ee8285 100644
--- a/cibuildwheel/resources/constraints-python39.txt
+++ b/cibuildwheel/resources/constraints-python39.txt
@@ -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
diff --git a/cibuildwheel/resources/constraints.in b/cibuildwheel/resources/constraints.in
index 72b0befd..3762c279 100644
--- a/cibuildwheel/resources/constraints.in
+++ b/cibuildwheel/resources/constraints.in
@@ -4,3 +4,8 @@ delocate
delvewheel
virtualenv
abi3audit
+
+# Android
+auditwheel
+patchelf
+pkgconf
diff --git a/cibuildwheel/resources/constraints.txt b/cibuildwheel/resources/constraints.txt
index 319dfcf5..9acfd1b6 100644
--- a/cibuildwheel/resources/constraints.txt
+++ b/cibuildwheel/resources/constraints.txt
@@ -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
diff --git a/cibuildwheel/resources/defaults.toml b/cibuildwheel/resources/defaults.toml
index e0937490..0a46e945 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 = ""
@@ -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]
diff --git a/cibuildwheel/util/helpers.py b/cibuildwheel/util/helpers.py
index 6eb79a95..a8a4df78 100644
--- a/cibuildwheel/util/helpers.py
+++ b/cibuildwheel/util/helpers.py
@@ -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)
diff --git a/docs/configuration.md b/docs/configuration.md
index 62bef786..ae05b420 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 e066fcdd..3011593b 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 07e62c02..ab492f07 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 271a9a8c..e6381009 100644
--- a/docs/options.md
+++ b/docs/options.md
@@ -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:
+ `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.
diff --git a/docs/platforms.md b/docs/platforms.md
index 33d82e21..89e17908 100644
--- a/docs/platforms.md
+++ b/docs/platforms.md
@@ -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
diff --git a/pyproject.toml b/pyproject.toml
index be2b1ca5..99aac2e2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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]
diff --git a/test/test_android.py b/test/test_android.py
index 2d52cb76..4037b004 100644
--- a/test/test_android.py
+++ b/test/test_android.py
@@ -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 ",
+ 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/meson.py b/test/test_projects/meson.py
index c3b14e17..2c5b2720 100644
--- a/test/test_projects/meson.py
+++ b/test/test_projects/meson.py
@@ -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,
}
)
diff --git a/test/test_projects/setuptools.py b/test/test_projects/setuptools.py
index 20f8bfb5..2f48039e 100644
--- a/test/test_projects/setuptools.py
+++ b/test/test_projects/setuptools.py
@@ -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", ""):
diff --git a/test/utils.py b/test/utils.py
index e274ed0d..f023c0c1 100644
--- a/test/utils.py
+++ b/test/utils.py
@@ -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":
diff --git a/unit_test/options_test.py b/unit_test/options_test.py
index 8c86026d..77fb29ae 100644
--- a/unit_test/options_test.py
+++ b/unit_test/options_test.py
@@ -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
diff --git a/unit_test/utils_test.py b/unit_test/utils_test.py
index 7a58ad49..85f38ebf 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")