#!/usr/bin/env python3 import argparse import os import re import shutil import subprocess import sys import sysconfig from os.path import basename, relpath from pathlib import Path from tempfile import TemporaryDirectory SCRIPT_NAME = Path(__file__).name CHECKOUT = Path(__file__).resolve().parent.parent CROSS_BUILD_DIR = CHECKOUT / "cross-build" def delete_if_exists(path): if path.exists(): print(f"Deleting {path} ...") shutil.rmtree(path) def subdir(name, *, clean=None): path = CROSS_BUILD_DIR / name if clean: delete_if_exists(path) if not path.exists(): if clean is None: sys.exit( f"{path} does not exist. Create it by running the appropriate " f"`configure` subcommand of {SCRIPT_NAME}.") else: path.mkdir(parents=True) return path def run(command, *, host=None, **kwargs): env = os.environ.copy() if host: env_script = CHECKOUT / "Android/android-env.sh" env_output = subprocess.run( f"set -eu; " f"HOST={host}; " f"PREFIX={subdir(host)}/prefix; " f". {env_script}; " f"export", check=True, shell=True, text=True, stdout=subprocess.PIPE ).stdout for line in env_output.splitlines(): # We don't require every line to match, as there may be some other # output from installing the NDK. if match := re.search( "^(declare -x |export )?(\\w+)=['\"]?(.*?)['\"]?$", line ): key, value = match[2], match[3] if env.get(key) != value: print(line) env[key] = value if env == os.environ: raise ValueError(f"Found no variables in {env_script.name} output:\n" + env_output) print(">", " ".join(map(str, command))) try: subprocess.run(command, check=True, env=env, **kwargs) except subprocess.CalledProcessError as e: sys.exit(e) def build_python_path(): """The path to the build Python binary.""" build_dir = subdir("build") binary = build_dir / "python" if not binary.is_file(): binary = binary.with_suffix(".exe") if not binary.is_file(): raise FileNotFoundError("Unable to find `python(.exe)` in " f"{build_dir}") return binary def configure_build_python(context): os.chdir(subdir("build", clean=context.clean)) command = [relpath(CHECKOUT / "configure")] if context.args: command.extend(context.args) run(command) def make_build_python(context): os.chdir(subdir("build")) run(["make", "-j", str(os.cpu_count())]) def unpack_deps(host): deps_url = "https://github.com/beeware/cpython-android-source-deps/releases/download" for name_ver in ["bzip2-1.0.8-1", "libffi-3.4.4-2", "openssl-3.0.13-1", "sqlite-3.45.1-0", "xz-5.4.6-0"]: filename = f"{name_ver}-{host}.tar.gz" download(f"{deps_url}/{name_ver}/{filename}") run(["tar", "-xf", filename]) os.remove(filename) def download(url, target_dir="."): out_path = f"{target_dir}/{basename(url)}" run(["curl", "-Lf", "-o", out_path, url]) return out_path def configure_host_python(context): host_dir = subdir(context.host, clean=context.clean) prefix_dir = host_dir / "prefix" if not prefix_dir.exists(): prefix_dir.mkdir() os.chdir(prefix_dir) unpack_deps(context.host) build_dir = host_dir / "build" build_dir.mkdir(exist_ok=True) os.chdir(build_dir) command = [ # Basic cross-compiling configuration relpath(CHECKOUT / "configure"), f"--host={context.host}", f"--build={sysconfig.get_config_var('BUILD_GNU_TYPE')}", f"--with-build-python={build_python_path()}", "--without-ensurepip", # Android always uses a shared libpython. "--enable-shared", "--without-static-libpython", # Dependent libraries. The others are found using pkg-config: see # android-env.sh. f"--with-openssl={prefix_dir}", ] if context.args: command.extend(context.args) run(command, host=context.host) def make_host_python(context): host_dir = subdir(context.host) os.chdir(host_dir / "build") run(["make", "-j", str(os.cpu_count())], host=context.host) run(["make", "install", f"prefix={host_dir}/prefix"], host=context.host) def build_all(context): steps = [configure_build_python, make_build_python, configure_host_python, make_host_python] for step in steps: step(context) def clean_all(context): delete_if_exists(CROSS_BUILD_DIR) # To avoid distributing compiled artifacts without corresponding source code, # the Gradle wrapper is not included in the CPython repository. Instead, we # extract it from the Gradle release. def setup_testbed(context): ver_long = "8.7.0" ver_short = ver_long.removesuffix(".0") testbed_dir = CHECKOUT / "Android/testbed" for filename in ["gradlew", "gradlew.bat"]: out_path = download( f"https://raw.githubusercontent.com/gradle/gradle/v{ver_long}/{filename}", testbed_dir) os.chmod(out_path, 0o755) with TemporaryDirectory(prefix=SCRIPT_NAME) as temp_dir: os.chdir(temp_dir) bin_zip = download( f"https://services.gradle.org/distributions/gradle-{ver_short}-bin.zip") outer_jar = f"gradle-{ver_short}/lib/plugins/gradle-wrapper-{ver_short}.jar" run(["unzip", bin_zip, outer_jar]) run(["unzip", "-o", "-d", f"{testbed_dir}/gradle/wrapper", outer_jar, "gradle-wrapper.jar"]) def main(): parser = argparse.ArgumentParser() subcommands = parser.add_subparsers(dest="subcommand") build = subcommands.add_parser("build", help="Build everything") configure_build = subcommands.add_parser("configure-build", help="Run `configure` for the " "build Python") make_build = subcommands.add_parser("make-build", help="Run `make` for the build Python") configure_host = subcommands.add_parser("configure-host", help="Run `configure` for Android") make_host = subcommands.add_parser("make-host", help="Run `make` for Android") subcommands.add_parser( "clean", help="Delete the cross-build directory") subcommands.add_parser( "setup-testbed", help="Download the testbed Gradle wrapper") for subcommand in build, configure_build, configure_host: subcommand.add_argument( "--clean", action="store_true", default=False, dest="clean", help="Delete any relevant directories before building") for subcommand in build, configure_host, make_host: subcommand.add_argument( "host", metavar="HOST", choices=["aarch64-linux-android", "x86_64-linux-android"], help="Host triplet: choices=[%(choices)s]") for subcommand in build, configure_build, configure_host: subcommand.add_argument("args", nargs="*", help="Extra arguments to pass to `configure`") context = parser.parse_args() dispatch = {"configure-build": configure_build_python, "make-build": make_build_python, "configure-host": configure_host_python, "make-host": make_host_python, "build": build_all, "clean": clean_all, "setup-testbed": setup_testbed} dispatch[context.subcommand](context) if __name__ == "__main__": main()