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>
This commit is contained in:
co-authored by
Joe Rickerby
Henry Schreiner
parent
e2e24882d8
commit
0f487ee2cb
@@ -0,0 +1,644 @@
|
||||
import csv
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
from collections.abc import Iterable, Iterator
|
||||
from dataclasses import dataclass
|
||||
from os.path import relpath
|
||||
from pathlib import Path
|
||||
from pprint import pprint
|
||||
from runpy import run_path
|
||||
from textwrap import dedent
|
||||
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 .. import errors, platforms
|
||||
from ..architecture import Architecture, arch_synonym
|
||||
from ..frontend import get_build_frontend_extra_flags, parse_config_settings
|
||||
from ..logger import log
|
||||
from ..options import BuildOptions, Options
|
||||
from ..selector import BuildSelector
|
||||
from ..util import resources
|
||||
from ..util.cmd import call, shell
|
||||
from ..util.file import CIBW_CACHE_PATH, copy_test_sources, download, move_file
|
||||
from ..util.helpers import prepare_command
|
||||
from ..util.packaging import find_compatible_wheel
|
||||
from ..util.python_build_standalone import create_python_build_standalone_environment
|
||||
from ..venv import constraint_flags, virtualenv
|
||||
|
||||
|
||||
def android_triplet(identifier: str) -> str:
|
||||
return {
|
||||
"arm64_v8a": "aarch64-linux-android",
|
||||
"x86_64": "x86_64-linux-android",
|
||||
}[parse_identifier(identifier)[1]]
|
||||
|
||||
|
||||
def parse_identifier(identifier: str) -> tuple[str, str]:
|
||||
match = re.fullmatch(r"cp(\d)(\d+)-android_(.+)", identifier)
|
||||
if not match:
|
||||
msg = f"invalid Android identifier: '{identifier}'"
|
||||
raise ValueError(msg)
|
||||
major, minor, arch = match.groups()
|
||||
return (f"{major}.{minor}", arch)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PythonConfiguration:
|
||||
version: str
|
||||
identifier: str
|
||||
url: str
|
||||
|
||||
@property
|
||||
def arch(self) -> str:
|
||||
return parse_identifier(self.identifier)[1]
|
||||
|
||||
|
||||
def all_python_configurations() -> list[PythonConfiguration]:
|
||||
return [PythonConfiguration(**item) for item in resources.read_python_configs("android")]
|
||||
|
||||
|
||||
def get_python_configurations(
|
||||
build_selector: BuildSelector, architectures: set[Architecture]
|
||||
) -> list[PythonConfiguration]:
|
||||
return [
|
||||
c
|
||||
for c in all_python_configurations()
|
||||
if c.arch in architectures and build_selector(c.identifier)
|
||||
]
|
||||
|
||||
|
||||
def shell_prepared(command: str, *, build_options: BuildOptions, env: dict[str, str]) -> None:
|
||||
shell(
|
||||
prepare_command(command, project=".", package=build_options.package_dir),
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def before_all(options: Options, python_configurations: list[PythonConfiguration]) -> None:
|
||||
before_all_options = options.build_options(python_configurations[0].identifier)
|
||||
if before_all_options.before_all:
|
||||
log.step("Running before_all...")
|
||||
shell_prepared(
|
||||
before_all_options.before_all,
|
||||
build_options=before_all_options,
|
||||
env=before_all_options.environment.as_dictionary(os.environ),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BuildState:
|
||||
config: PythonConfiguration
|
||||
options: BuildOptions
|
||||
build_path: Path
|
||||
python_dir: Path
|
||||
build_env: dict[str, str]
|
||||
android_env: dict[str, str]
|
||||
|
||||
|
||||
def build(options: Options, tmp_path: Path) -> None:
|
||||
if "ANDROID_HOME" not in os.environ:
|
||||
msg = (
|
||||
"ANDROID_HOME environment variable is not set. For instructions, see "
|
||||
"https://cibuildwheel.pypa.io/en/stable/platforms/#android"
|
||||
)
|
||||
raise errors.FatalError(msg)
|
||||
|
||||
configs = get_python_configurations(
|
||||
options.globals.build_selector, options.globals.architectures
|
||||
)
|
||||
if not configs:
|
||||
return
|
||||
|
||||
try:
|
||||
before_all(options, configs)
|
||||
|
||||
built_wheels: list[Path] = []
|
||||
for config in configs:
|
||||
log.build_start(config.identifier)
|
||||
build_options = options.build_options(config.identifier)
|
||||
build_path = tmp_path / config.identifier
|
||||
build_path.mkdir()
|
||||
python_dir = setup_target_python(config, build_path)
|
||||
build_env, android_env = setup_env(config, build_options, build_path, python_dir)
|
||||
state = BuildState(
|
||||
config, build_options, build_path, python_dir, build_env, android_env
|
||||
)
|
||||
|
||||
compatible_wheel = find_compatible_wheel(built_wheels, config.identifier)
|
||||
if compatible_wheel:
|
||||
print(
|
||||
f"\nFound previously built wheel {compatible_wheel.name} that is "
|
||||
f"compatible with {config.identifier}. Skipping build step..."
|
||||
)
|
||||
repaired_wheel = compatible_wheel
|
||||
else:
|
||||
before_build(state)
|
||||
built_wheel = build_wheel(state)
|
||||
repaired_wheel = repair_wheel(state, built_wheel)
|
||||
|
||||
test_wheel(state, repaired_wheel)
|
||||
|
||||
output_wheel: Path | None = None
|
||||
if compatible_wheel is None:
|
||||
output_wheel = move_file(
|
||||
repaired_wheel, build_options.output_dir / repaired_wheel.name
|
||||
)
|
||||
built_wheels.append(output_wheel)
|
||||
|
||||
shutil.rmtree(build_path)
|
||||
log.build_end(output_wheel)
|
||||
|
||||
except subprocess.CalledProcessError as error:
|
||||
msg = f"Command {error.cmd} failed with code {error.returncode}. {error.stdout or ''}"
|
||||
raise errors.FatalError(msg) from error
|
||||
|
||||
|
||||
def setup_target_python(config: PythonConfiguration, build_path: Path) -> Path:
|
||||
log.step("Installing target Python...")
|
||||
python_tgz = CIBW_CACHE_PATH / config.url.rpartition("/")[-1]
|
||||
with FileLock(f"{python_tgz}.lock"):
|
||||
if not python_tgz.exists():
|
||||
download(config.url, python_tgz)
|
||||
|
||||
python_dir = build_path / "python"
|
||||
python_dir.mkdir()
|
||||
shutil.unpack_archive(python_tgz, python_dir)
|
||||
return python_dir
|
||||
|
||||
|
||||
def setup_env(
|
||||
config: PythonConfiguration, build_options: BuildOptions, build_path: Path, python_dir: Path
|
||||
) -> tuple[dict[str, str], dict[str, str]]:
|
||||
"""
|
||||
Returns two environment dicts, both pointing at the same virtual environment:
|
||||
|
||||
* build_env, which uses the environment normally.
|
||||
* android_env, which uses the environment while simulating running on Android.
|
||||
"""
|
||||
log.step("Setting up build environment...")
|
||||
|
||||
# Create virtual environment
|
||||
python_exe = create_python_build_standalone_environment(
|
||||
config.version, build_path, CIBW_CACHE_PATH
|
||||
)
|
||||
venv_dir = build_path / "venv"
|
||||
dependency_constraint = build_options.dependency_constraints.get_for_python_version(
|
||||
version=config.version, tmp_dir=build_path
|
||||
)
|
||||
build_env = virtualenv(
|
||||
config.version, python_exe, venv_dir, dependency_constraint, use_uv=False
|
||||
)
|
||||
create_cmake_toolchain(config, build_path, python_dir, build_env)
|
||||
|
||||
# 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"
|
||||
for command in ["python", "pip"]:
|
||||
which = call("which", command, env=build_env, capture_stdout=True).strip()
|
||||
if which != f"{venv_dir}/bin/{command}":
|
||||
msg = (
|
||||
f"{command} available on PATH doesn't match our installed instance. If you "
|
||||
f"have modified PATH, ensure that you don't overwrite cibuildwheel's entry "
|
||||
f"or insert {command} above it."
|
||||
)
|
||||
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
|
||||
build_frontend = build_options.build_frontend
|
||||
if build_frontend.name != "build":
|
||||
msg = "Android requires the build frontend to be 'build'"
|
||||
raise errors.FatalError(msg)
|
||||
call("pip", "install", "build", *constraint_flags(dependency_constraint), env=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
|
||||
# affected by the target platform. However, the requirements must be installed
|
||||
# within build_env, because they're going to run on the build machine.
|
||||
#
|
||||
# The `build` CLI doesn't support this combination, so we use its API to query the
|
||||
# requirements, and then install them ourselves with pip. We'll later run `build` in
|
||||
# the same environment, passing the `--no-isolation` option.
|
||||
class AndroidEnv(IsolatedEnv):
|
||||
@property
|
||||
def python_executable(self) -> str:
|
||||
return f"{venv_dir}/bin/python"
|
||||
|
||||
def make_extra_environ(self) -> dict[str, str]:
|
||||
return android_env
|
||||
|
||||
pb = ProjectBuilder.from_isolated_env(AndroidEnv(), build_options.package_dir)
|
||||
if pb.build_system_requires:
|
||||
call("pip", "install", *pb.build_system_requires, env=build_env)
|
||||
|
||||
requires_for_build = pb.get_requires_for_build(
|
||||
"wheel", parse_config_settings(build_options.config_settings)
|
||||
)
|
||||
if requires_for_build:
|
||||
call("pip", "install", *requires_for_build, env=build_env)
|
||||
|
||||
return build_env, android_env
|
||||
|
||||
|
||||
def create_cmake_toolchain(
|
||||
config: PythonConfiguration, build_path: Path, python_dir: Path, build_env: dict[str, str]
|
||||
) -> None:
|
||||
toolchain_path = build_path / "toolchain.cmake"
|
||||
build_env["CMAKE_TOOLCHAIN_FILE"] = str(toolchain_path)
|
||||
with open(toolchain_path, "w", encoding="UTF-8") as toolchain_file:
|
||||
print(
|
||||
dedent(
|
||||
f"""\
|
||||
# To support as many build systems as possible, we use environment
|
||||
# variables as the single source of truth for compiler flags and paths,
|
||||
# so they don't need to be specified here.
|
||||
|
||||
set(CMAKE_SYSTEM_NAME Android)
|
||||
set(CMAKE_SYSTEM_PROCESSOR {android_triplet(config.identifier).split("-")[0]})
|
||||
|
||||
# Inhibit all of CMake's own NDK handling code.
|
||||
set(CMAKE_SYSTEM_VERSION 1)
|
||||
|
||||
# Tell CMake where to look for headers and libraries.
|
||||
list(INSERT CMAKE_FIND_ROOT_PATH 0 {python_dir}/prefix)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
|
||||
"""
|
||||
),
|
||||
file=toolchain_file,
|
||||
)
|
||||
|
||||
|
||||
def localize_sysconfigdata(
|
||||
python_dir: Path, build_env: dict[str, str], sysconfigdata_path: Path
|
||||
) -> dict[str, Any]:
|
||||
sysconfigdata: dict[str, Any] = run_path(str(sysconfigdata_path))["build_time_vars"]
|
||||
with sysconfigdata_path.open("w", encoding="UTF-8") as f:
|
||||
f.write("# Generated by cibuildwheel\n")
|
||||
f.write("build_time_vars = ")
|
||||
sysconfigdata = localized_vars(build_env, sysconfigdata, python_dir / "prefix")
|
||||
pprint(sysconfigdata, stream=f, compact=True)
|
||||
return sysconfigdata
|
||||
|
||||
|
||||
def localized_vars(
|
||||
build_env: dict[str, str], orig_vars: dict[str, Any], prefix: Path
|
||||
) -> dict[str, Any]:
|
||||
orig_prefix = orig_vars["prefix"]
|
||||
localized_vars_ = {}
|
||||
for key, value in orig_vars.items():
|
||||
# The host's sysconfigdata will include references to build-time paths.
|
||||
# Update these to refer to the current prefix.
|
||||
final = value
|
||||
if isinstance(final, str):
|
||||
final = final.replace(orig_prefix, str(prefix))
|
||||
|
||||
if key == "ANDROID_API_LEVEL":
|
||||
if api_level := build_env.get(key):
|
||||
final = int(api_level)
|
||||
|
||||
# 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
|
||||
# setuptools has changed its behavior here
|
||||
# (https://github.com/pypa/setuptools/issues/4836).
|
||||
#
|
||||
# Ensure consistency by clearing the sysconfig variables and letting the environment
|
||||
# variables take effect alone. This will also work for any non-Python build systems
|
||||
# which the build script may call.
|
||||
elif key in ["CFLAGS", "CXXFLAGS", "LDFLAGS"]:
|
||||
final = ""
|
||||
|
||||
# These variables contain an embedded copy of LDFLAGS.
|
||||
elif key in ["LDSHARED", "LDCXXSHARED"]:
|
||||
final = final.removesuffix(" " + orig_vars["LDFLAGS"])
|
||||
|
||||
localized_vars_[key] = final
|
||||
|
||||
return localized_vars_
|
||||
|
||||
|
||||
def setup_android_env(
|
||||
config: PythonConfiguration, python_dir: Path, venv_dir: Path, build_env: dict[str, str]
|
||||
) -> dict[str, str]:
|
||||
site_packages = next(venv_dir.glob("lib/python*/site-packages"))
|
||||
for suffix in ["pth", "py"]:
|
||||
shutil.copy(resources.PATH / f"_cross_venv.{suffix}", site_packages)
|
||||
|
||||
sysconfigdata_path = Path(
|
||||
shutil.copy(
|
||||
next(python_dir.glob("prefix/lib/python*/_sysconfigdata_*.py")),
|
||||
site_packages,
|
||||
)
|
||||
)
|
||||
sysconfigdata = localize_sysconfigdata(python_dir, build_env, sysconfigdata_path)
|
||||
|
||||
# Activate the code in _cross_venv.py.
|
||||
android_env = build_env.copy()
|
||||
android_env["CIBW_HOST_TRIPLET"] = android_triplet(config.identifier)
|
||||
|
||||
# Get the environment variables needed to build for Android (CC, CFLAGS, etc). These are
|
||||
# generated by https://github.com/python/cpython/blob/main/Android/android-env.sh.
|
||||
env_output = call(python_dir / "android.py", "env", env=build_env, capture_stdout=True)
|
||||
|
||||
# shlex.split should produce a sequence alternating between:
|
||||
# * the word "export"
|
||||
# * a key=value string, without quotes
|
||||
for i, token in enumerate(shlex.split(env_output)):
|
||||
if i % 2 == 0:
|
||||
assert token == "export", token
|
||||
else:
|
||||
key, sep, value = token.partition("=")
|
||||
assert sep == "=", token
|
||||
android_env[key] = value
|
||||
|
||||
# localized_vars cleared the CFLAGS and CXXFLAGS in the sysconfigdata, but most
|
||||
# packages take their optimization flags from these variables. Pass these flags via
|
||||
# environment variables instead.
|
||||
#
|
||||
# We don't enable debug information, because it significantly increases binary size,
|
||||
# and most Android app developers don't have the NDK installed, so they would have no
|
||||
# way to strip it.
|
||||
opt = " ".join(word for word in sysconfigdata["OPT"].split() if not word.startswith("-g"))
|
||||
for key in ["CFLAGS", "CXXFLAGS"]:
|
||||
android_env[key] += " " + opt
|
||||
|
||||
# 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:
|
||||
print(f"export {key}={shlex.quote(value)}")
|
||||
|
||||
return android_env
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def build_wheel(state: BuildState) -> Path:
|
||||
log.step("Building wheel...")
|
||||
built_wheel_dir = state.build_path / "built_wheel"
|
||||
call(
|
||||
"python",
|
||||
"-m",
|
||||
"build",
|
||||
state.options.package_dir,
|
||||
"--wheel",
|
||||
"--no-isolation",
|
||||
"--skip-dependency-check",
|
||||
f"--outdir={built_wheel_dir}",
|
||||
*get_build_frontend_extra_flags(
|
||||
state.options.build_frontend,
|
||||
state.options.build_verbosity,
|
||||
state.options.config_settings,
|
||||
),
|
||||
env=state.android_env,
|
||||
)
|
||||
|
||||
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]
|
||||
|
||||
if built_wheel.name.endswith("none-any.whl"):
|
||||
raise errors.NonPlatformWheelError()
|
||||
return built_wheel
|
||||
|
||||
|
||||
def repair_wheel(state: BuildState, built_wheel: Path) -> Path:
|
||||
log.step("Repairing wheel...")
|
||||
repaired_wheel_dir = state.build_path / "repaired_wheel"
|
||||
repaired_wheel_dir.mkdir()
|
||||
|
||||
if state.options.repair_command:
|
||||
shell(
|
||||
prepare_command(
|
||||
state.options.repair_command, wheel=built_wheel, dest_dir=repaired_wheel_dir
|
||||
),
|
||||
env=state.build_env,
|
||||
)
|
||||
else:
|
||||
repair_default(state.android_env, 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)
|
||||
repaired_wheel = repaired_wheels[0]
|
||||
|
||||
if repaired_wheel.name.endswith("none-any.whl"):
|
||||
raise errors.NonPlatformWheelError()
|
||||
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("patchelf", "--set-soname", new_soname, dst_path)
|
||||
|
||||
for path in paths_to_patch:
|
||||
call("patchelf", "--replace-needed", old_soname, new_soname, path)
|
||||
call(
|
||||
"patchelf",
|
||||
"--set-rpath",
|
||||
f"${{ORIGIN}}/{relpath(libs_dir, path.parent)}",
|
||||
path,
|
||||
)
|
||||
call("wheel", "pack", unpacked_dir, "-d", repaired_wheel_dir)
|
||||
|
||||
|
||||
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) -> None:
|
||||
test_command = state.options.test_command
|
||||
if not (test_command and state.options.test_selector(state.config.identifier)):
|
||||
return
|
||||
|
||||
log.step("Testing wheel...")
|
||||
native_arch = arch_synonym(platform.machine(), platforms.native_platform(), "android")
|
||||
if state.config.arch != native_arch:
|
||||
log.warning(
|
||||
f"Skipping tests for {state.config.arch}, as the build machine only "
|
||||
f"supports {native_arch}"
|
||||
)
|
||||
return
|
||||
|
||||
if state.options.before_test:
|
||||
shell_prepared(
|
||||
state.options.before_test,
|
||||
build_options=state.options,
|
||||
env=state.build_env,
|
||||
)
|
||||
|
||||
# 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",
|
||||
sysconfig_print("get_platform()", state.android_env).replace("-", "_"),
|
||||
"--target",
|
||||
site_packages_dir,
|
||||
f"{wheel}{state.options.test_extras}",
|
||||
*state.options.test_requires,
|
||||
env=state.build_env,
|
||||
)
|
||||
|
||||
# Copy test-sources.
|
||||
cwd_dir = state.build_path / "cwd"
|
||||
cwd_dir.mkdir()
|
||||
if state.options.test_sources:
|
||||
copy_test_sources(state.options.test_sources, Path.cwd(), cwd_dir)
|
||||
else:
|
||||
(cwd_dir / "test_fail.py").write_text(
|
||||
resources.TEST_FAIL_CWD_FILE.read_text(),
|
||||
)
|
||||
|
||||
# Android doesn't support placeholders in the test command.
|
||||
if any(("{" + placeholder + "}") in test_command for placeholder in ["project", "package"]):
|
||||
msg = (
|
||||
f"Test command {test_command!r} with a "
|
||||
"'{project}' or '{package}' placeholder is not supported on Android, "
|
||||
"because the source directory is not visible on the emulator."
|
||||
)
|
||||
raise errors.FatalError(msg)
|
||||
|
||||
# Parse test-command.
|
||||
test_args = shlex.split(test_command)
|
||||
if test_args[:2] in [["python", "-c"], ["python", "-m"]]:
|
||||
test_args[:3] = [test_args[1], test_args[2], "--"]
|
||||
elif test_args[0] in ["pytest"]:
|
||||
# We transform some commands into the `python -m` form, but this is deprecated.
|
||||
msg = (
|
||||
f"Test command {test_command!r} is not supported on Android. "
|
||||
"cibuildwheel will try to execute it as if it started with 'python -m'. "
|
||||
"If this works, all you need to do is add that to your test command."
|
||||
)
|
||||
log.warning(msg)
|
||||
test_args[:1] = ["-m", test_args[0], "--"]
|
||||
else:
|
||||
msg = (
|
||||
f"Test command {test_command!r} is not supported on Android. "
|
||||
f"Supported commands are 'python -m' and 'python -c'."
|
||||
)
|
||||
raise errors.FatalError(msg)
|
||||
|
||||
# Run the test app.
|
||||
call(
|
||||
state.python_dir / "android.py",
|
||||
"test",
|
||||
"--managed",
|
||||
"maxVersion",
|
||||
"--site-packages",
|
||||
site_packages_dir,
|
||||
"--cwd",
|
||||
cwd_dir,
|
||||
*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,
|
||||
)
|
||||
Reference in New Issue
Block a user