Files
cibuildwheel/test/test_android.py
T
0f487ee2cb feat: add support for building Android wheels (#2349)
* Add Android to resource files

* Add Android to miscellaneous places

* Add Android documentation

* Docs cleanups

* Add Android platform module; implement top-level structure and target Python installation

* Implement setup_env and build_wheel

* lru-dict build working

* Alter prefix in sysconfigdata file; fix various issues with FLAGS variables

* Implement Android testing

* Add type annotations to _cross_venv

* Revert Python 3.8 to pip 25.0.1

* Make test-sources required on Android

* Add Android integration tests

* Test cleanups

* Add test of all available Python versions

* Update test-sources and test-command behavior to match iOS

* Documentation cleanups

* Replace Builder class with a set of global functions

* Rename "env" to "build_env"

* Remove Chaquopy repository from default pip command line

* Move native_platform to platforms module

* Fix parse_config_settings

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

* Add unit tests for parse_config_settings and arch_synonym

* Make `shell_prepared` arguments keyword-only, and add tests for the commands that use it

* Replace `importlib.util.spec_from_file_location` with `runpy.run_path`

* Use python-build-standalone

* Update Android Python

* Enable KVM in Linux CI

* Move KVM code to test_android.py

* Use Java 17 on Azure

* Install emulator if necessary before running -accel-check

* Free up additional disk space on Linux runners

* Add sudo

* Skip emulator tests on CI platforms that don't support it

* Download Android Python from Maven Central

* Free up more disk space on Linux runners

* fix: minor fixups

Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>

* Set sysconfig._BASE_PREFIX to support sysconfig.get_path("include")

* Get ANDROID_API_LEVEL from the build environment, not cibuildwheel's own environment

* Correct relative path of test-sources

* Pass a CMake toolchain file to the build

* Add "repair" step which adds libc++ to the wheel when necessary

* Add missing needs_emulator decorator

* Provide useful error message if ANDROID_HOME is not set

* Remove use of HOST environment variable

* Update to Python 3.15.5

* Fix PyLint warnings, clarify comment

* Group common arguments into a dataclass

* Handle environment variables containing newlines

* Discourage the use of `pytest` test commands without `python -m`

* Use single quotes in user-visible messages

* Improve testing documentation

* Pass wheel filename to `log.build_end`

* In GitHub Actions example, skip Android tests on macOS

* Correct relative paths in `patchelf --set-rpath`

* Clarify `test-sources` docs

* Update to Python 3.13.5+20250722.214220

---------

Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>
Co-authored-by: Joe Rickerby <joerick@mac.com>
Co-authored-by: Henry Schreiner <henryschreineriii@gmail.com>
2025-07-23 17:18:33 -04:00

384 lines
12 KiB
Python

import os
import platform
import re
from dataclasses import dataclass
from shutil import rmtree
from subprocess import CalledProcessError
from textwrap import dedent
from zipfile import ZipFile
import pytest
from .test_projects import new_c_project
from .utils import cibuildwheel_run, expected_wheels
CIBW_PLATFORM = os.environ.get("CIBW_PLATFORM", "android")
if CIBW_PLATFORM != "android":
pytest.skip(f"{CIBW_PLATFORM=}", allow_module_level=True)
if (platform.system(), platform.machine()) not in [
("Linux", "x86_64"),
("Darwin", "arm64"),
("Darwin", "x86_64"),
]:
pytest.skip(
f"cibuildwheel does not support building Android wheels on "
f"{platform.system()} {platform.machine()}",
allow_module_level=True,
)
# Detect CI services which have the Android SDK pre-installed.
ci_supports_build = any(
key in os.environ
for key in [
"GITHUB_ACTIONS",
"TF_BUILD", # Azure Pipelines
]
)
if "ANDROID_HOME" not in os.environ:
msg = "ANDROID_HOME environment variable is not set"
if ci_supports_build:
pytest.fail(msg)
else:
pytest.skip(msg, allow_module_level=True)
# Many CI services don't support running the Android emulator: see platforms.md.
ci_supports_emulator = "GITHUB_ACTIONS" in os.environ and platform.system() == "Linux"
def needs_emulator(test):
# All copies of the testbed app run on the same emulator with the same
# application ID, so these tests must be run serially.
test = pytest.mark.serial(test)
if ci_supports_build and not ci_supports_emulator:
test = pytest.mark.skip("This CI platform doesn't support the emulator")(test)
return test
@dataclass
class Architecture:
linux_machine: str
macos_machine: str
android_abi: str
archs = [
Architecture("aarch64", "arm64", "arm64_v8a"),
Architecture("x86_64", "x86_64", "x86_64"),
]
native_arch = next(
arch for arch in archs if platform.machine() in [arch.linux_machine, arch.macos_machine]
)
cp313_env = {
"CIBW_PLATFORM": "android",
"CIBW_BUILD": "cp313-*",
}
def test_android_home(tmp_path, capfd):
new_c_project().generate(tmp_path)
env = os.environ.copy()
del env["ANDROID_HOME"]
with pytest.raises(CalledProcessError):
cibuildwheel_run(tmp_path, env={**env, **cp313_env})
assert "ANDROID_HOME environment variable is not set" in capfd.readouterr().err
def test_frontend_good(tmp_path):
new_c_project().generate(tmp_path)
wheels = cibuildwheel_run(
tmp_path,
add_env={**cp313_env, "CIBW_BUILD_FRONTEND": "build"},
)
assert wheels == [f"spam-0.1.0-cp313-cp313-android_21_{native_arch.android_abi}.whl"]
@pytest.mark.parametrize("frontend", ["build[uv]", "pip"])
def test_frontend_bad(frontend, tmp_path, capfd):
new_c_project().generate(tmp_path)
with pytest.raises(CalledProcessError):
cibuildwheel_run(
tmp_path,
add_env={**cp313_env, "CIBW_BUILD_FRONTEND": frontend},
)
assert "Android requires the build frontend to be 'build'" in capfd.readouterr().err
def test_expected_wheels(tmp_path):
new_c_project().generate(tmp_path)
wheels = cibuildwheel_run(tmp_path, add_env={"CIBW_PLATFORM": "android"})
assert wheels == expected_wheels(
"spam", "0.1.0", platform="android", machine_arch=native_arch.android_abi
)
@needs_emulator
def test_archs(tmp_path, capfd):
new_c_project().generate(tmp_path)
# Build all architectures while checking the handling of the `before` commands.
command_pattern = 'echo "Hello from {0}, package={{package}}, python=$(which python)"'
output_pattern = (
f"Hello from {{0}}, package={tmp_path}, python=/.+/cp313-android_{{1}}/venv/bin/python"
)
wheels = cibuildwheel_run(
tmp_path,
add_env={
**cp313_env,
"CIBW_ARCHS": "all",
"CIBW_BEFORE_ALL": "echo 'Hello from before_all'",
"CIBW_BEFORE_BUILD": command_pattern.format("before_build"),
"CIBW_BEFORE_TEST": command_pattern.format("before_test"),
"CIBW_TEST_COMMAND": (
"python -c 'import platform; print(f\"Hello from {platform.machine()}\")'"
),
},
)
assert wheels == [f"spam-0.1.0-cp313-cp313-android_21_{arch.android_abi}.whl" for arch in archs]
stdout, stderr = capfd.readouterr()
lines = (line for line in stdout.splitlines() if line.startswith("Hello from"))
assert next(lines) == "Hello from before_all"
# All architectures should be built, but only the native architecture should run tests.
for arch in archs:
abi = arch.android_abi
assert re.fullmatch(output_pattern.format("before_build", abi), next(lines))
if arch == native_arch:
assert re.fullmatch(output_pattern.format("before_test", abi), next(lines))
assert next(lines) == f"Hello from {arch.linux_machine}"
else:
assert (
f"Skipping tests for {arch.android_abi}, as the build machine "
f"only supports {native_arch.android_abi}"
) in stderr
try:
line = next(lines)
except StopIteration:
pass
else:
pytest.fail(f"Unexpected line: {line!r}")
def test_build_requires(tmp_path, capfd):
# Build-time requirements should be installed for the build platform, not for Android. Prove
# this by installing some non-pure-Python requirements and using them in setup.py.
#
# setup_requires is installed via ProjectBuilder.get_requires_for_build.
project = new_c_project(
setup_py_setup_args_add="setup_requires=['cmake==3.31.4']",
setup_py_add=dedent(
"""\
if "egg_info" not in sys.argv:
import subprocess
subprocess.run(["cmake", "--version"], check=True)
from bitarray import bitarray
print(f"{bitarray('10110').count()=}")
"""
),
)
# [build_system] requires is installed via ProjectBuilder.build_system_requires.
project.files["pyproject.toml"] = dedent(
"""\
[build-system]
requires = ["setuptools", "wheel", "bitarray==3.3.2"]
"""
)
project.generate(tmp_path)
cibuildwheel_run(tmp_path, add_env={**cp313_env})
# Test for a specific version to minimize the chance that we ran a system cmake.
stdout = capfd.readouterr().out
assert "cmake version 3.31.4" in stdout
assert "bitarray('10110').count()=3" in stdout
@pytest.fixture
def spam_env(tmp_path):
project = new_c_project()
project.files["test_spam.py"] = dedent(
"""\
import spam
def test_spam():
assert spam.filter("ham")
assert not spam.filter("spam")
print("Spam test passed")
"""
)
project.generate(tmp_path)
return {
**cp313_env,
"CIBW_TEST_SOURCES": "test_spam.py",
"CIBW_TEST_REQUIRES": "pytest==8.3.5",
}
@needs_emulator
@pytest.mark.parametrize(
("command", "expected_output"),
[
("python -c 'import test_spam; test_spam.test_spam()'", "Spam test passed"),
("python -m pytest test_spam.py", "=== 1 passed in "),
("pytest test_spam.py", "=== 1 passed in "),
],
)
def test_test_command_good(command, expected_output, tmp_path, spam_env, capfd):
cibuildwheel_run(tmp_path, add_env={**spam_env, "CIBW_TEST_COMMAND": command})
stdout, stderr = capfd.readouterr()
assert expected_output in stdout
if not command.startswith("python"):
assert (
f"Test command {command!r} is not supported on Android. cibuildwheel "
"will try to execute it as if it started with 'python -m'."
) in stderr
@needs_emulator
@pytest.mark.parametrize(
("command", "expected_output"),
[
# Build-time failure: unrecognized command
(
"./test_spam.py",
"Test command './test_spam.py' is not supported on Android. "
"Supported commands are 'python -m' and 'python -c'.",
),
# Build-time failure: unrecognized placeholder
(
"pytest {project}",
"Test command 'pytest {project}' with a '{project}' or '{package}' "
"placeholder is not supported on Android",
),
(
"pytest {package}",
"Test command 'pytest {package}' with a '{project}' or '{package}' "
"placeholder is not supported on Android",
),
# Runtime failure
("pytest test_ham.py", "not found: test_ham.py"),
],
)
def test_test_command_bad(command, expected_output, tmp_path, spam_env, capfd):
with pytest.raises(CalledProcessError):
cibuildwheel_run(tmp_path, add_env={**spam_env, "CIBW_TEST_COMMAND": command})
assert expected_output in capfd.readouterr().err
@needs_emulator
def test_package_subdir(tmp_path, spam_env, capfd):
spam_paths = list(tmp_path.iterdir())
package_dir = tmp_path / "package"
package_dir.mkdir()
for path in spam_paths:
path.rename(package_dir / path.name)
test_filename = "package/" + spam_env["CIBW_TEST_SOURCES"]
cibuildwheel_run(
tmp_path,
package_dir,
add_env={
**spam_env,
"CIBW_TEST_SOURCES": test_filename,
"CIBW_TEST_COMMAND": f"python -m pytest {test_filename}",
},
)
assert "=== 1 passed in " in capfd.readouterr().out
@needs_emulator
def test_no_test_sources(tmp_path, capfd):
new_c_project().generate(tmp_path)
with pytest.raises(CalledProcessError):
cibuildwheel_run(
tmp_path,
add_env={**cp313_env, "CIBW_TEST_COMMAND": "python -m unittest discover"},
)
assert (
"On this platform, you must copy your test files to the testbed app by "
"setting the `test-sources` option"
) in capfd.readouterr().err
@needs_emulator
def test_api_level(tmp_path, capfd):
project = new_c_project()
project.files["pyproject.toml"] = dedent(
"""\
[build-system]
requires = ["setuptools"]
[tool.cibuildwheel]
android.environment.ANDROID_API_LEVEL = "33"
android.environment.PIP_EXTRA_INDEX_URL = "https://chaquo.com/pypi-13.1"
"""
)
project.generate(tmp_path)
wheels = cibuildwheel_run(
tmp_path,
add_env={
**cp313_env,
# Verify that Android dependencies can be installed from the Chaquopy repository, and
# that wheels tagged with an older version of Android (in this case 24) are still
# accepted.
"CIBW_TEST_REQUIRES": "bitarray==3.0.0",
"CIBW_TEST_COMMAND": (
"python -c 'from bitarray import bitarray; print(~bitarray(\"01100\"))'"
),
},
)
assert wheels == [f"spam-0.1.0-cp313-cp313-android_33_{native_arch.android_abi}.whl"]
assert "bitarray('10011')" in capfd.readouterr().out
@needs_emulator
def test_libcxx(tmp_path, capfd):
project_dir = tmp_path / "project"
output_dir = tmp_path / "output"
# A C++ package should include libc++, and the extension module should be able to
# find it using DT_RUNPATH.
new_c_project(setup_py_extension_args_add="language='c++'").generate(project_dir)
script = 'import spam; print(", ".join(f"{s}: {spam.filter(s)}" for s in ["ham", "spam"]))'
cp313_test_env = {**cp313_env, "CIBW_TEST_COMMAND": f"python -c '{script}'"}
# Including external libraries requires API level 24.
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,
)
assert len(wheels) == 1
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)
]
assert len(libcxx_names) == 1
assert "ham: 1, spam: 0" in capfd.readouterr().out
# A C package should not include libc++.
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
for name in ZipFile(output_dir / wheels[0]).namelist():
assert ".libs" not in name