| #!/usr/bin/env python3 |
| |
| import asyncio |
| import argparse |
| from glob import glob |
| import os |
| import re |
| import shlex |
| import shutil |
| import signal |
| import subprocess |
| import sys |
| import sysconfig |
| from asyncio import wait_for |
| from contextlib import asynccontextmanager |
| from os.path import basename, relpath |
| from pathlib import Path |
| from subprocess import CalledProcessError |
| from tempfile import TemporaryDirectory |
| |
| |
| SCRIPT_NAME = Path(__file__).name |
| CHECKOUT = Path(__file__).resolve().parent.parent |
| ANDROID_DIR = CHECKOUT / "Android" |
| TESTBED_DIR = ANDROID_DIR / "testbed" |
| CROSS_BUILD_DIR = CHECKOUT / "cross-build" |
| |
| APP_ID = "org.python.testbed" |
| DECODE_ARGS = ("UTF-8", "backslashreplace") |
| |
| |
| try: |
| android_home = Path(os.environ['ANDROID_HOME']) |
| except KeyError: |
| sys.exit("The ANDROID_HOME environment variable is required.") |
| |
| adb = Path( |
| f"{android_home}/platform-tools/adb" |
| + (".exe" if os.name == "nt" else "") |
| ) |
| |
| gradlew = Path( |
| f"{TESTBED_DIR}/gradlew" |
| + (".bat" if os.name == "nt" else "") |
| ) |
| |
| logcat_started = False |
| |
| |
| def delete_glob(pattern): |
| # Path.glob doesn't accept non-relative patterns. |
| for path in glob(str(pattern)): |
| path = Path(path) |
| print(f"Deleting {path} ...") |
| if path.is_dir() and not path.is_symlink(): |
| shutil.rmtree(path) |
| else: |
| path.unlink() |
| |
| |
| def subdir(name, *, clean=None): |
| path = CROSS_BUILD_DIR / name |
| if clean: |
| delete_glob(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, env=None, log=True, **kwargs): |
| kwargs.setdefault("check", True) |
| if env is None: |
| env = os.environ.copy() |
| original_env = env.copy() |
| |
| if host: |
| env_script = ANDROID_DIR / "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 == original_env: |
| raise ValueError(f"Found no variables in {env_script.name} output:\n" |
| + env_output) |
| |
| if log: |
| print(">", " ".join(map(str, command))) |
| return subprocess.run(command, env=env, **kwargs) |
| |
| |
| 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-2", "libffi-3.4.4-3", "openssl-3.0.15-4", |
| "sqlite-3.45.3-3", "xz-5.4.6-1"]: |
| 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): |
| # The CFLAGS and LDFLAGS set in android-env include the prefix dir, so |
| # delete any previous Python installation to prevent it being used during |
| # the build. |
| host_dir = subdir(context.host) |
| prefix_dir = host_dir / "prefix" |
| delete_glob(f"{prefix_dir}/include/python*") |
| delete_glob(f"{prefix_dir}/lib/libpython*") |
| delete_glob(f"{prefix_dir}/lib/python*") |
| |
| os.chdir(host_dir / "build") |
| run(["make", "-j", str(os.cpu_count())], host=context.host) |
| run(["make", "install", f"prefix={prefix_dir}"], 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_glob(CROSS_BUILD_DIR) |
| |
| |
| def setup_sdk(): |
| sdkmanager = android_home / ( |
| "cmdline-tools/latest/bin/sdkmanager" |
| + (".bat" if os.name == "nt" else "") |
| ) |
| |
| # Gradle will fail if it needs to install an SDK package whose license |
| # hasn't been accepted, so pre-accept all licenses. |
| if not all((android_home / "licenses" / path).exists() for path in [ |
| "android-sdk-arm-dbt-license", "android-sdk-license" |
| ]): |
| run([sdkmanager, "--licenses"], text=True, input="y\n" * 100) |
| |
| # Gradle may install this automatically, but we can't rely on that because |
| # we need to run adb within the logcat task. |
| if not adb.exists(): |
| run([sdkmanager, "platform-tools"]) |
| |
| |
| # 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(): |
| if all((TESTBED_DIR / path).exists() for path in [ |
| "gradlew", "gradlew.bat", "gradle/wrapper/gradle-wrapper.jar", |
| ]): |
| return |
| |
| ver_long = "8.7.0" |
| ver_short = ver_long.removesuffix(".0") |
| |
| 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: |
| bin_zip = download( |
| f"https://services.gradle.org/distributions/gradle-{ver_short}-bin.zip", |
| temp_dir) |
| outer_jar = f"gradle-{ver_short}/lib/plugins/gradle-wrapper-{ver_short}.jar" |
| run(["unzip", "-d", temp_dir, bin_zip, outer_jar]) |
| run(["unzip", "-o", "-d", f"{TESTBED_DIR}/gradle/wrapper", |
| f"{temp_dir}/{outer_jar}", "gradle-wrapper.jar"]) |
| |
| |
| # run_testbed will build the app automatically, but it's useful to have this as |
| # a separate command to allow running the app outside of this script. |
| def build_testbed(context): |
| setup_sdk() |
| setup_testbed() |
| run( |
| [gradlew, "--console", "plain", "packageDebug", "packageDebugAndroidTest"], |
| cwd=TESTBED_DIR, |
| ) |
| |
| |
| # Work around a bug involving sys.exit and TaskGroups |
| # (https://github.com/python/cpython/issues/101515). |
| def exit(*args): |
| raise MySystemExit(*args) |
| |
| |
| class MySystemExit(Exception): |
| pass |
| |
| |
| # The `test` subcommand runs all subprocesses through this context manager so |
| # that no matter what happens, they can always be cancelled from another task, |
| # and they will always be cleaned up on exit. |
| @asynccontextmanager |
| async def async_process(*args, **kwargs): |
| process = await asyncio.create_subprocess_exec(*args, **kwargs) |
| try: |
| yield process |
| finally: |
| if process.returncode is None: |
| # Allow a reasonably long time for Gradle to clean itself up, |
| # because we don't want stale emulators left behind. |
| timeout = 10 |
| process.terminate() |
| try: |
| await wait_for(process.wait(), timeout) |
| except TimeoutError: |
| print( |
| f"Command {args} did not terminate after {timeout} seconds " |
| f" - sending SIGKILL" |
| ) |
| process.kill() |
| |
| # Even after killing the process we must still wait for it, |
| # otherwise we'll get the warning "Exception ignored in __del__". |
| await wait_for(process.wait(), timeout=1) |
| |
| |
| async def async_check_output(*args, **kwargs): |
| async with async_process( |
| *args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs |
| ) as process: |
| stdout, stderr = await process.communicate() |
| if process.returncode == 0: |
| return stdout.decode(*DECODE_ARGS) |
| else: |
| raise CalledProcessError( |
| process.returncode, args, |
| stdout.decode(*DECODE_ARGS), stderr.decode(*DECODE_ARGS) |
| ) |
| |
| |
| # Return a list of the serial numbers of connected devices. Emulators will have |
| # serials of the form "emulator-5678". |
| async def list_devices(): |
| serials = [] |
| header_found = False |
| |
| lines = (await async_check_output(adb, "devices")).splitlines() |
| for line in lines: |
| # Ignore blank lines, and all lines before the header. |
| line = line.strip() |
| if line == "List of devices attached": |
| header_found = True |
| elif header_found and line: |
| try: |
| serial, status = line.split() |
| except ValueError: |
| raise ValueError(f"failed to parse {line!r}") |
| if status == "device": |
| serials.append(serial) |
| |
| if not header_found: |
| raise ValueError(f"failed to parse {lines}") |
| return serials |
| |
| |
| async def find_device(context, initial_devices): |
| if context.managed: |
| print("Waiting for managed device - this may take several minutes") |
| while True: |
| new_devices = set(await list_devices()).difference(initial_devices) |
| if len(new_devices) == 0: |
| await asyncio.sleep(1) |
| elif len(new_devices) == 1: |
| serial = new_devices.pop() |
| print(f"Serial: {serial}") |
| return serial |
| else: |
| exit(f"Found more than one new device: {new_devices}") |
| else: |
| return context.connected |
| |
| |
| # An older version of this script in #121595 filtered the logs by UID instead. |
| # But logcat can't filter by UID until API level 31. If we ever switch back to |
| # filtering by UID, we'll also have to filter by time so we only show messages |
| # produced after the initial call to `stop_app`. |
| # |
| # We're more likely to miss the PID because it's shorter-lived, so there's a |
| # workaround in PythonSuite.kt to stop it being *too* short-lived. |
| async def find_pid(serial): |
| print("Waiting for app to start - this may take several minutes") |
| shown_error = False |
| while True: |
| try: |
| # `pidof` requires API level 24 or higher. The level 23 emulator |
| # includes it, but it doesn't work (it returns all processes). |
| pid = (await async_check_output( |
| adb, "-s", serial, "shell", "pidof", "-s", APP_ID |
| )).strip() |
| except CalledProcessError as e: |
| # If the app isn't running yet, pidof gives no output. So if there |
| # is output, there must have been some other error. However, this |
| # sometimes happens transiently, especially when running a managed |
| # emulator for the first time, so don't make it fatal. |
| if (e.stdout or e.stderr) and not shown_error: |
| print_called_process_error(e) |
| print("This may be transient, so continuing to wait") |
| shown_error = True |
| else: |
| # Some older devices (e.g. Nexus 4) return zero even when no process |
| # was found, so check whether we actually got any output. |
| if pid: |
| print(f"PID: {pid}") |
| return pid |
| |
| # Loop fairly rapidly to avoid missing a short-lived process. |
| await asyncio.sleep(0.2) |
| |
| |
| async def logcat_task(context, initial_devices): |
| # Gradle may need to do some large downloads of libraries and emulator |
| # images. This will happen during find_device in --managed mode, or find_pid |
| # in --connected mode. |
| startup_timeout = 600 |
| serial = await wait_for(find_device(context, initial_devices), startup_timeout) |
| pid = await wait_for(find_pid(serial), startup_timeout) |
| |
| # `--pid` requires API level 24 or higher. |
| args = [adb, "-s", serial, "logcat", "--pid", pid, "--format", "tag"] |
| hidden_output = [] |
| async with async_process( |
| *args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, |
| ) as process: |
| while line := (await process.stdout.readline()).decode(*DECODE_ARGS): |
| if match := re.fullmatch(r"([A-Z])/(.*)", line, re.DOTALL): |
| level, message = match.groups() |
| else: |
| # If the regex doesn't match, this is probably the second or |
| # subsequent line of a multi-line message. Python won't produce |
| # such messages, but other components might. |
| level, message = None, line |
| |
| # Exclude high-volume messages which are rarely useful. |
| if context.verbose < 2 and "from python test_syslog" in message: |
| continue |
| |
| # Put high-level messages on stderr so they're highlighted in the |
| # buildbot logs. This will include Python's own stderr. |
| stream = ( |
| sys.stderr |
| if level in ["W", "E", "F"] # WARNING, ERROR, FATAL (aka ASSERT) |
| else sys.stdout |
| ) |
| |
| # To simplify automated processing of the output, e.g. a buildbot |
| # posting a failure notice on a GitHub PR, we strip the level and |
| # tag indicators from Python's stdout and stderr. |
| for prefix in ["python.stdout: ", "python.stderr: "]: |
| if message.startswith(prefix): |
| global logcat_started |
| logcat_started = True |
| stream.write(message.removeprefix(prefix)) |
| break |
| else: |
| if context.verbose: |
| # Non-Python messages add a lot of noise, but they may |
| # sometimes help explain a failure. |
| stream.write(line) |
| else: |
| hidden_output.append(line) |
| |
| # If the device disconnects while logcat is running, which always |
| # happens in --managed mode, some versions of adb return non-zero. |
| # Distinguish this from a logcat startup error by checking whether we've |
| # received a message from Python yet. |
| status = await wait_for(process.wait(), timeout=1) |
| if status != 0 and not logcat_started: |
| raise CalledProcessError(status, args, "".join(hidden_output)) |
| |
| |
| def stop_app(serial): |
| run([adb, "-s", serial, "shell", "am", "force-stop", APP_ID], log=False) |
| |
| |
| async def gradle_task(context): |
| env = os.environ.copy() |
| if context.managed: |
| task_prefix = context.managed |
| else: |
| task_prefix = "connected" |
| env["ANDROID_SERIAL"] = context.connected |
| |
| args = [ |
| gradlew, "--console", "plain", f"{task_prefix}DebugAndroidTest", |
| "-Pandroid.testInstrumentationRunnerArguments.pythonArgs=" |
| + shlex.join(context.args), |
| ] |
| hidden_output = [] |
| try: |
| async with async_process( |
| *args, cwd=TESTBED_DIR, env=env, |
| stdout=subprocess.PIPE, stderr=subprocess.STDOUT, |
| ) as process: |
| while line := (await process.stdout.readline()).decode(*DECODE_ARGS): |
| # Gradle may take several minutes to install SDK packages, so |
| # it's worth showing those messages even in non-verbose mode. |
| if context.verbose or line.startswith('Preparing "Install'): |
| sys.stdout.write(line) |
| else: |
| hidden_output.append(line) |
| |
| status = await wait_for(process.wait(), timeout=1) |
| if status == 0: |
| exit(0) |
| else: |
| raise CalledProcessError(status, args) |
| finally: |
| # If logcat never started, then something has gone badly wrong, so the |
| # user probably wants to see the Gradle output even in non-verbose mode. |
| if hidden_output and not logcat_started: |
| sys.stdout.write("".join(hidden_output)) |
| |
| # Gradle does not stop the tests when interrupted. |
| if context.connected: |
| stop_app(context.connected) |
| |
| |
| async def run_testbed(context): |
| setup_sdk() |
| setup_testbed() |
| |
| if context.managed: |
| # In this mode, Gradle will create a device with an unpredictable name. |
| # So we save a list of the running devices before starting Gradle, and |
| # find_device then waits for a new device to appear. |
| initial_devices = await list_devices() |
| else: |
| # In case the previous shutdown was unclean, make sure the app isn't |
| # running, otherwise we might show logs from a previous run. This is |
| # unnecessary in --managed mode, because Gradle creates a new emulator |
| # every time. |
| stop_app(context.connected) |
| initial_devices = None |
| |
| try: |
| async with asyncio.TaskGroup() as tg: |
| tg.create_task(logcat_task(context, initial_devices)) |
| tg.create_task(gradle_task(context)) |
| except* MySystemExit as e: |
| raise SystemExit(*e.exceptions[0].args) from None |
| except* CalledProcessError as e: |
| # Extract it from the ExceptionGroup so it can be handled by `main`. |
| raise e.exceptions[0] |
| |
| |
| # Handle SIGTERM the same way as SIGINT. This ensures that if we're terminated |
| # by the buildbot worker, we'll make an attempt to clean up our subprocesses. |
| def install_signal_handler(): |
| def signal_handler(*args): |
| os.kill(os.getpid(), signal.SIGINT) |
| |
| signal.signal(signal.SIGTERM, signal_handler) |
| |
| |
| def parse_args(): |
| 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") |
| |
| 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`") |
| |
| subcommands.add_parser( |
| "build-testbed", help="Build the testbed app") |
| test = subcommands.add_parser( |
| "test", help="Run the test suite") |
| test.add_argument( |
| "-v", "--verbose", action="count", default=0, |
| help="Show Gradle output, and non-Python logcat messages. " |
| "Use twice to include high-volume messages which are rarely useful.") |
| device_group = test.add_mutually_exclusive_group(required=True) |
| device_group.add_argument( |
| "--connected", metavar="SERIAL", help="Run on a connected device. " |
| "Connect it yourself, then get its serial from `adb devices`.") |
| device_group.add_argument( |
| "--managed", metavar="NAME", help="Run on a Gradle-managed device. " |
| "These are defined in `managedDevices` in testbed/app/build.gradle.kts.") |
| test.add_argument( |
| "args", nargs="*", help=f"Arguments for `python -m test`. " |
| f"Separate them from {SCRIPT_NAME}'s own arguments with `--`.") |
| |
| return parser.parse_args() |
| |
| |
| def main(): |
| install_signal_handler() |
| |
| # Under the buildbot, stdout is not a TTY, but we must still flush after |
| # every line to make sure our output appears in the correct order relative |
| # to the output of our subprocesses. |
| for stream in [sys.stdout, sys.stderr]: |
| stream.reconfigure(line_buffering=True) |
| |
| context = 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, |
| "build-testbed": build_testbed, |
| "test": run_testbed} |
| |
| try: |
| result = dispatch[context.subcommand](context) |
| if asyncio.iscoroutine(result): |
| asyncio.run(result) |
| except CalledProcessError as e: |
| print_called_process_error(e) |
| sys.exit(1) |
| |
| |
| def print_called_process_error(e): |
| for stream_name in ["stdout", "stderr"]: |
| content = getattr(e, stream_name) |
| stream = getattr(sys, stream_name) |
| if content: |
| stream.write(content) |
| if not content.endswith("\n"): |
| stream.write("\n") |
| |
| # Format the command so it can be copied into a shell. shlex uses single |
| # quotes, so we surround the whole command with double quotes. |
| args_joined = ( |
| e.cmd if isinstance(e.cmd, str) |
| else " ".join(shlex.quote(str(arg)) for arg in e.cmd) |
| ) |
| print( |
| f'Command "{args_joined}" returned exit status {e.returncode}' |
| ) |
| |
| |
| if __name__ == "__main__": |
| main() |