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

* Use auditwheel on Android

* Add auditwheel command to defaults

* pkgconfig fixes

* Pre-import ctypes before monkey-patching in _cross_venv

* Set PKG_CONFIG and PKG_CONFIG_RELOCATE_PATHS variables

* Initial attempt at using mzakharo/android-gfortran

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

* Move PKG_CONFIG variables from build_env to android_env

* Simplify flang installation

* Add cross build files for NumPy

* Add ldpaths entry for libomp

* Add `--rm` to docker command line

* Make Rust and Fortran shims consistent

* Update documentation

* Default to API level 24 on all Python versions

* Clarify comments

* Cleanups

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

* Update how-it-works diagram

* Add tests for repair errors

* Add more repair tests

* Add test for Meson and Fortran

* Add test for cross build files

* Improve test_api_level error message

* Add xbuild-files option

* use pypa/auditwheel@main

* Remove dependencies which are no longer needed

* Fix README

* Update to auditwheel 6.7.0

* Fix compatibility with pkgconf 2.5.1.post2

* Documentation clarifications

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

* Fortran shim improvements

* Add Jinja variables to new_meson_project

* Update run_example_ci_configs for changed new_meson_project signature

* Add missing dependency to run_example_ci_configs

---------

Co-authored-by: mayeut <mayeut@users.noreply.github.com>
Co-authored-by: Joe Rickerby <joerick@mac.com>
This commit is contained in:
Malcolm Smith
2026-05-29 00:27:28 -04:00
committed by GitHub
co-authored by Joe Rickerby mayeut
parent e3e7cc9e07
commit 1cf64d0d3c
32 changed files with 775 additions and 246 deletions
+197 -17
View File
@@ -13,7 +13,7 @@ from zipfile import ZipFile
import pytest
from .test_projects import new_c_project
from .test_projects import new_c_project, new_meson_project
from .utils import cibuildwheel_run, expected_wheels
pytestmark = pytest.mark.android
@@ -125,7 +125,7 @@ def test_frontend_good(tmp_path: Path, build_frontend_env: dict[str, str]) -> No
tmp_path,
add_env={**cp313_env, **build_frontend_env, "CIBW_TEST_COMMAND": "python -m site"},
)
assert wheels == [f"spam-0.1.0-cp313-cp313-android_21_{native_arch.android_abi}.whl"]
assert wheels == [f"spam-0.1.0-cp313-cp313-android_24_{native_arch.android_abi}.whl"]
@pytest.mark.parametrize("frontend", ["pip"])
@@ -136,7 +136,10 @@ def test_frontend_bad(frontend: str, tmp_path: Path, capfd: pytest.CaptureFixtur
tmp_path,
add_env={**cp313_env, "CIBW_BUILD_FRONTEND": frontend},
)
assert "Android requires the build frontend to be 'build'" in capfd.readouterr().err
assert (
f"Android requires the build frontend to be 'build' or 'uv', not '{frontend}'"
in capfd.readouterr().err
)
@needs_emulator
@@ -162,7 +165,7 @@ def test_archs(tmp_path: Path, capfd: pytest.CaptureFixture[str]) -> None:
),
},
)
assert wheels == [f"spam-0.1.0-cp313-cp313-android_21_{arch.android_abi}.whl" for arch in archs]
assert wheels == [f"spam-0.1.0-cp313-cp313-android_24_{arch.android_abi}.whl" for arch in archs]
stdout, stderr = capfd.readouterr()
lines = (line for line in stdout.splitlines() if line.startswith("Hello from"))
@@ -435,13 +438,25 @@ def test_verbosity(tmp_path: Path, capfd: pytest.CaptureFixture[str]) -> None:
@needs_emulator
def test_api_level(tmp_path: Path, capfd: pytest.CaptureFixture[str]) -> None:
project = new_c_project()
project = new_c_project(
# Check that the the compiler options are set correctly.
spam_c_top_level_add=dedent(
"""\
#if __ANDROID_API__ != 33
#error Unexpected API level; the following syntax error will show the actual value:
__ANDROID_API__
#endif
"""
)
)
project.files["pyproject.toml"] = dedent(
"""\
[build-system]
requires = ["setuptools"]
[tool.cibuildwheel]
# Test setting API level in pyproject.toml (test_libcxx covers setting
# it in the outer environment.)
android.environment.ANDROID_API_LEVEL = "33"
android.environment.PIP_EXTRA_INDEX_URL = "https://chaquo.com/pypi-13.1"
"""
@@ -486,17 +501,21 @@ def test_libcxx(tmp_path: Path, capfd: pytest.CaptureFixture[str]) -> None:
"PATH": non_venv_path,
}
# Including external libraries requires API level 24.
# Including external libraries requires API level 24. This is enforced by auditwheel.
cp313_android_21_env = {
**cp313_test_env,
# Test setting API level in the outer environment (test_api_level covers setting
# it in pyproject.toml.)
"ANDROID_API_LEVEL": "21",
}
with pytest.raises(CalledProcessError):
cibuildwheel_run(project_dir, add_env=cp313_test_env, output_dir=output_dir)
assert "libc++_shared.so requires ANDROID_API_LEVEL to be at least 24" in capfd.readouterr().err
wheels = cibuildwheel_run(
project_dir,
add_env={**cp313_test_env, "ANDROID_API_LEVEL": "24"},
output_dir=output_dir,
cibuildwheel_run(project_dir, add_env=cp313_android_21_env, output_dir=output_dir)
assert (
"Grafting libraries with RUNPATH requires API level 24 or higher" in capfd.readouterr().err
)
assert len(wheels) == 1
wheels = cibuildwheel_run(project_dir, add_env=cp313_test_env, output_dir=output_dir)
assert wheels == [f"spam-0.1.0-cp313-cp313-android_24_{native_arch.android_abi}.whl"]
names = ZipFile(output_dir / wheels[0]).namelist()
libcxx_names = [
name for name in names if re.fullmatch(r"spam\.libs/libc\+\+_shared-[0-9a-f]{8}\.so", name)
@@ -504,16 +523,177 @@ def test_libcxx(tmp_path: Path, capfd: pytest.CaptureFixture[str]) -> None:
assert len(libcxx_names) == 1
assert "ham: 1, spam: 0" in capfd.readouterr().out
# A C package should not include libc++.
# A C package should not include libc++, and can therefore use an older API level.
rmtree(project_dir)
rmtree(output_dir)
new_c_project().generate(project_dir)
wheels = cibuildwheel_run(project_dir, add_env=cp313_env, output_dir=output_dir)
assert len(wheels) == 1
wheels = cibuildwheel_run(project_dir, add_env=cp313_android_21_env, output_dir=output_dir)
assert wheels == [f"spam-0.1.0-cp313-cp313-android_21_{native_arch.android_abi}.whl"]
for name in ZipFile(output_dir / wheels[0]).namelist():
assert ".libs" not in name
@needs_emulator
def test_repair_none(tmp_path: Path, capfd: pytest.CaptureFixture[str]) -> None:
new_c_project(setup_py_extension_args_add="language='c++'").generate(tmp_path)
with pytest.raises(CalledProcessError):
cibuildwheel_run(
tmp_path,
add_env={
**cp313_env,
"CIBW_REPAIR_WHEEL_COMMAND": "",
"CIBW_TEST_COMMAND": "python -c 'import spam'",
},
)
assert 'dlopen failed: library "libc++_shared.so" not found' in capfd.readouterr().err
def test_repair_ldpaths(tmp_path: Path) -> None:
new_c_project().generate(tmp_path)
repair_path = tmp_path / "repair.py"
repair_path.write_text(
dedent(
"""\
#!/usr/bin/env python
import shutil
import sys
from pathlib import Path
assert len(sys.argv) == 4, sys.argv
ldpaths = list(map(Path, sys.argv[1].split(":")))
dest_dir = sys.argv[2]
wheel = sys.argv[3]
for name in ["libc++_shared.so", "libomp.so"]:
assert any((lp / name).exists() for lp in ldpaths), (name, ldpaths)
shutil.copy(wheel, dest_dir)
"""
)
)
repair_path.chmod(0o755)
wheels = cibuildwheel_run(
tmp_path,
add_env={
**cp313_env,
"CIBW_REPAIR_WHEEL_COMMAND": f"{repair_path} {{ldpaths}} {{dest_dir}} {{wheel}}",
},
)
assert wheels == [f"spam-0.1.0-cp313-cp313-android_24_{native_arch.android_abi}.whl"]
@pytest.mark.parametrize(
("script", "error"),
[
("", "did not produce a wheel"),
("touch $dest_dir/one.whl $dest_dir/two.whl", "produced multiple wheels"),
("touch $dest_dir/one-0.0.1-py3-none-any.whl", "pure Python wheel was generated"),
],
)
def test_repair_error(
script: str, error: str, tmp_path: Path, capfd: pytest.CaptureFixture[str]
) -> None:
new_c_project().generate(tmp_path)
repair_path = tmp_path / "repair.sh"
repair_path.write_text(
dedent(
f"""\
#!/bin/sh
dest_dir=$1
{script}
"""
)
)
repair_path.chmod(0o755)
with pytest.raises(CalledProcessError):
cibuildwheel_run(
tmp_path,
add_env={**cp313_env, "CIBW_REPAIR_WHEEL_COMMAND": f"{repair_path} {{dest_dir}}"},
)
assert error in capfd.readouterr().err
# This also tests integration with pkgconf, because Meson uses it to find Python.
@needs_emulator
def test_meson(tmp_path: Path, capfd: pytest.CaptureFixture[str]) -> None:
# Alter spam.filter to return the value of a Fortran function.
new_meson_project(
spam_c_top_level_add="int fortran_func_();",
spam_c_function_add="sts = fortran_func_();",
project_args_add="'fortran',",
extension_args_add="'fortran.f90', link_language: 'fortran',",
).generate(tmp_path)
# TODO: remove once meson-python has a release with Android support.
pyproject_path = tmp_path / "pyproject.toml"
pyproject_path.write_text(
pyproject_path.read_text().replace(
"meson-python", "meson-python @ git+https://github.com/mesonbuild/meson-python@main"
)
)
# Add Fortran code.
(tmp_path / "fortran.f90").write_text(
dedent(
"""\
integer*4 function fortran_func()
fortran_func = 42
end
"""
)
)
script = 'import spam; print(f"result: {spam.filter("")}")'
cibuildwheel_run(
tmp_path,
add_env={**cp313_env, "CIBW_TEST_COMMAND": f"python -c '{script}'"},
)
assert "result: 42" in capfd.readouterr().out
@needs_emulator
def test_xbuild_files(tmp_path: Path, capfd: pytest.CaptureFixture[str]) -> None:
# Verify that we've replaced the correct files by compiling against a non-trivial
# function from libnpymath.a.
new_c_project(
setup_py_add=dedent(
"""\
import numpy as np
np_include = np.get_include()
np_lib = f"{np_include}/../lib"
libraries.append("npymath")
"""
),
setup_py_extension_args_add="include_dirs=[np_include], library_dirs=[np_lib]",
spam_c_top_level_add="#include <numpy/halffloat.h>",
spam_c_function_add="sts = npy_float_to_half(42);",
).generate(tmp_path)
(tmp_path / "pyproject.toml").write_text(
dedent(
"""\
[build-system]
requires = ["setuptools", "numpy==2.3.2"]
"""
)
)
script = 'import spam; print(f"result: {spam.filter(""):#x}")'
cibuildwheel_run(
tmp_path,
add_env={
**cp313_env,
# TODO: remove this once there are official Android NumPy wheels on PyPI.
"PIP_EXTRA_INDEX_URL": "https://chaquo.com/pypi-test",
"CIBW_ARCHS": "all", # Include both native and non-native archs.
"CIBW_TEST_COMMAND": f"python -c '{script}'",
},
)
assert "result: 0x5140" in capfd.readouterr().out
@needs_emulator
def test_setuptools_rust(tmp_path: Path, capfd: pytest.CaptureFixture[str]) -> None:
"""