1#!/usr/bin/env python3 2 3import asyncio 4import argparse 5from glob import glob 6import os 7import re 8import shlex 9import shutil 10import signal 11import subprocess 12import sys 13import sysconfig 14from asyncio import wait_for 15from contextlib import asynccontextmanager 16from os.path import basename, relpath 17from pathlib import Path 18from subprocess import CalledProcessError 19from tempfile import TemporaryDirectory 20 21 22SCRIPT_NAME = Path(__file__).name 23CHECKOUT = Path(__file__).resolve().parent.parent 24ANDROID_DIR = CHECKOUT / "Android" 25TESTBED_DIR = ANDROID_DIR / "testbed" 26CROSS_BUILD_DIR = CHECKOUT / "cross-build" 27 28APP_ID = "org.python.testbed" 29DECODE_ARGS = ("UTF-8", "backslashreplace") 30 31 32try: 33 android_home = Path(os.environ['ANDROID_HOME']) 34except KeyError: 35 sys.exit("The ANDROID_HOME environment variable is required.") 36 37adb = Path( 38 f"{android_home}/platform-tools/adb" 39 + (".exe" if os.name == "nt" else "") 40) 41 42gradlew = Path( 43 f"{TESTBED_DIR}/gradlew" 44 + (".bat" if os.name == "nt" else "") 45) 46 47logcat_started = False 48 49 50def delete_glob(pattern): 51 # Path.glob doesn't accept non-relative patterns. 52 for path in glob(str(pattern)): 53 path = Path(path) 54 print(f"Deleting {path} ...") 55 if path.is_dir() and not path.is_symlink(): 56 shutil.rmtree(path) 57 else: 58 path.unlink() 59 60 61def subdir(name, *, clean=None): 62 path = CROSS_BUILD_DIR / name 63 if clean: 64 delete_glob(path) 65 if not path.exists(): 66 if clean is None: 67 sys.exit( 68 f"{path} does not exist. Create it by running the appropriate " 69 f"`configure` subcommand of {SCRIPT_NAME}.") 70 else: 71 path.mkdir(parents=True) 72 return path 73 74 75def run(command, *, host=None, env=None, log=True, **kwargs): 76 kwargs.setdefault("check", True) 77 if env is None: 78 env = os.environ.copy() 79 original_env = env.copy() 80 81 if host: 82 env_script = ANDROID_DIR / "android-env.sh" 83 env_output = subprocess.run( 84 f"set -eu; " 85 f"HOST={host}; " 86 f"PREFIX={subdir(host)}/prefix; " 87 f". {env_script}; " 88 f"export", 89 check=True, shell=True, text=True, stdout=subprocess.PIPE 90 ).stdout 91 92 for line in env_output.splitlines(): 93 # We don't require every line to match, as there may be some other 94 # output from installing the NDK. 95 if match := re.search( 96 "^(declare -x |export )?(\\w+)=['\"]?(.*?)['\"]?$", line 97 ): 98 key, value = match[2], match[3] 99 if env.get(key) != value: 100 print(line) 101 env[key] = value 102 103 if env == original_env: 104 raise ValueError(f"Found no variables in {env_script.name} output:\n" 105 + env_output) 106 107 if log: 108 print(">", " ".join(map(str, command))) 109 return subprocess.run(command, env=env, **kwargs) 110 111 112def build_python_path(): 113 """The path to the build Python binary.""" 114 build_dir = subdir("build") 115 binary = build_dir / "python" 116 if not binary.is_file(): 117 binary = binary.with_suffix(".exe") 118 if not binary.is_file(): 119 raise FileNotFoundError("Unable to find `python(.exe)` in " 120 f"{build_dir}") 121 122 return binary 123 124 125def configure_build_python(context): 126 os.chdir(subdir("build", clean=context.clean)) 127 128 command = [relpath(CHECKOUT / "configure")] 129 if context.args: 130 command.extend(context.args) 131 run(command) 132 133 134def make_build_python(context): 135 os.chdir(subdir("build")) 136 run(["make", "-j", str(os.cpu_count())]) 137 138 139def unpack_deps(host): 140 deps_url = "https://github.com/beeware/cpython-android-source-deps/releases/download" 141 for name_ver in ["bzip2-1.0.8-2", "libffi-3.4.4-3", "openssl-3.0.15-4", 142 "sqlite-3.45.3-3", "xz-5.4.6-1"]: 143 filename = f"{name_ver}-{host}.tar.gz" 144 download(f"{deps_url}/{name_ver}/{filename}") 145 run(["tar", "-xf", filename]) 146 os.remove(filename) 147 148 149def download(url, target_dir="."): 150 out_path = f"{target_dir}/{basename(url)}" 151 run(["curl", "-Lf", "-o", out_path, url]) 152 return out_path 153 154 155def configure_host_python(context): 156 host_dir = subdir(context.host, clean=context.clean) 157 158 prefix_dir = host_dir / "prefix" 159 if not prefix_dir.exists(): 160 prefix_dir.mkdir() 161 os.chdir(prefix_dir) 162 unpack_deps(context.host) 163 164 build_dir = host_dir / "build" 165 build_dir.mkdir(exist_ok=True) 166 os.chdir(build_dir) 167 168 command = [ 169 # Basic cross-compiling configuration 170 relpath(CHECKOUT / "configure"), 171 f"--host={context.host}", 172 f"--build={sysconfig.get_config_var('BUILD_GNU_TYPE')}", 173 f"--with-build-python={build_python_path()}", 174 "--without-ensurepip", 175 176 # Android always uses a shared libpython. 177 "--enable-shared", 178 "--without-static-libpython", 179 180 # Dependent libraries. The others are found using pkg-config: see 181 # android-env.sh. 182 f"--with-openssl={prefix_dir}", 183 ] 184 185 if context.args: 186 command.extend(context.args) 187 run(command, host=context.host) 188 189 190def make_host_python(context): 191 # The CFLAGS and LDFLAGS set in android-env include the prefix dir, so 192 # delete any previous Python installation to prevent it being used during 193 # the build. 194 host_dir = subdir(context.host) 195 prefix_dir = host_dir / "prefix" 196 delete_glob(f"{prefix_dir}/include/python*") 197 delete_glob(f"{prefix_dir}/lib/libpython*") 198 delete_glob(f"{prefix_dir}/lib/python*") 199 200 os.chdir(host_dir / "build") 201 run(["make", "-j", str(os.cpu_count())], host=context.host) 202 run(["make", "install", f"prefix={prefix_dir}"], host=context.host) 203 204 205def build_all(context): 206 steps = [configure_build_python, make_build_python, configure_host_python, 207 make_host_python] 208 for step in steps: 209 step(context) 210 211 212def clean_all(context): 213 delete_glob(CROSS_BUILD_DIR) 214 215 216def setup_sdk(): 217 sdkmanager = android_home / ( 218 "cmdline-tools/latest/bin/sdkmanager" 219 + (".bat" if os.name == "nt" else "") 220 ) 221 222 # Gradle will fail if it needs to install an SDK package whose license 223 # hasn't been accepted, so pre-accept all licenses. 224 if not all((android_home / "licenses" / path).exists() for path in [ 225 "android-sdk-arm-dbt-license", "android-sdk-license" 226 ]): 227 run([sdkmanager, "--licenses"], text=True, input="y\n" * 100) 228 229 # Gradle may install this automatically, but we can't rely on that because 230 # we need to run adb within the logcat task. 231 if not adb.exists(): 232 run([sdkmanager, "platform-tools"]) 233 234 235# To avoid distributing compiled artifacts without corresponding source code, 236# the Gradle wrapper is not included in the CPython repository. Instead, we 237# extract it from the Gradle release. 238def setup_testbed(): 239 if all((TESTBED_DIR / path).exists() for path in [ 240 "gradlew", "gradlew.bat", "gradle/wrapper/gradle-wrapper.jar", 241 ]): 242 return 243 244 ver_long = "8.7.0" 245 ver_short = ver_long.removesuffix(".0") 246 247 for filename in ["gradlew", "gradlew.bat"]: 248 out_path = download( 249 f"https://raw.githubusercontent.com/gradle/gradle/v{ver_long}/{filename}", 250 TESTBED_DIR) 251 os.chmod(out_path, 0o755) 252 253 with TemporaryDirectory(prefix=SCRIPT_NAME) as temp_dir: 254 bin_zip = download( 255 f"https://services.gradle.org/distributions/gradle-{ver_short}-bin.zip", 256 temp_dir) 257 outer_jar = f"gradle-{ver_short}/lib/plugins/gradle-wrapper-{ver_short}.jar" 258 run(["unzip", "-d", temp_dir, bin_zip, outer_jar]) 259 run(["unzip", "-o", "-d", f"{TESTBED_DIR}/gradle/wrapper", 260 f"{temp_dir}/{outer_jar}", "gradle-wrapper.jar"]) 261 262 263# run_testbed will build the app automatically, but it's useful to have this as 264# a separate command to allow running the app outside of this script. 265def build_testbed(context): 266 setup_sdk() 267 setup_testbed() 268 run( 269 [gradlew, "--console", "plain", "packageDebug", "packageDebugAndroidTest"], 270 cwd=TESTBED_DIR, 271 ) 272 273 274# Work around a bug involving sys.exit and TaskGroups 275# (https://github.com/python/cpython/issues/101515). 276def exit(*args): 277 raise MySystemExit(*args) 278 279 280class MySystemExit(Exception): 281 pass 282 283 284# The `test` subcommand runs all subprocesses through this context manager so 285# that no matter what happens, they can always be cancelled from another task, 286# and they will always be cleaned up on exit. 287@asynccontextmanager 288async def async_process(*args, **kwargs): 289 process = await asyncio.create_subprocess_exec(*args, **kwargs) 290 try: 291 yield process 292 finally: 293 if process.returncode is None: 294 # Allow a reasonably long time for Gradle to clean itself up, 295 # because we don't want stale emulators left behind. 296 timeout = 10 297 process.terminate() 298 try: 299 await wait_for(process.wait(), timeout) 300 except TimeoutError: 301 print( 302 f"Command {args} did not terminate after {timeout} seconds " 303 f" - sending SIGKILL" 304 ) 305 process.kill() 306 307 # Even after killing the process we must still wait for it, 308 # otherwise we'll get the warning "Exception ignored in __del__". 309 await wait_for(process.wait(), timeout=1) 310 311 312async def async_check_output(*args, **kwargs): 313 async with async_process( 314 *args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs 315 ) as process: 316 stdout, stderr = await process.communicate() 317 if process.returncode == 0: 318 return stdout.decode(*DECODE_ARGS) 319 else: 320 raise CalledProcessError( 321 process.returncode, args, 322 stdout.decode(*DECODE_ARGS), stderr.decode(*DECODE_ARGS) 323 ) 324 325 326# Return a list of the serial numbers of connected devices. Emulators will have 327# serials of the form "emulator-5678". 328async def list_devices(): 329 serials = [] 330 header_found = False 331 332 lines = (await async_check_output(adb, "devices")).splitlines() 333 for line in lines: 334 # Ignore blank lines, and all lines before the header. 335 line = line.strip() 336 if line == "List of devices attached": 337 header_found = True 338 elif header_found and line: 339 try: 340 serial, status = line.split() 341 except ValueError: 342 raise ValueError(f"failed to parse {line!r}") 343 if status == "device": 344 serials.append(serial) 345 346 if not header_found: 347 raise ValueError(f"failed to parse {lines}") 348 return serials 349 350 351async def find_device(context, initial_devices): 352 if context.managed: 353 print("Waiting for managed device - this may take several minutes") 354 while True: 355 new_devices = set(await list_devices()).difference(initial_devices) 356 if len(new_devices) == 0: 357 await asyncio.sleep(1) 358 elif len(new_devices) == 1: 359 serial = new_devices.pop() 360 print(f"Serial: {serial}") 361 return serial 362 else: 363 exit(f"Found more than one new device: {new_devices}") 364 else: 365 return context.connected 366 367 368# An older version of this script in #121595 filtered the logs by UID instead. 369# But logcat can't filter by UID until API level 31. If we ever switch back to 370# filtering by UID, we'll also have to filter by time so we only show messages 371# produced after the initial call to `stop_app`. 372# 373# We're more likely to miss the PID because it's shorter-lived, so there's a 374# workaround in PythonSuite.kt to stop it being *too* short-lived. 375async def find_pid(serial): 376 print("Waiting for app to start - this may take several minutes") 377 shown_error = False 378 while True: 379 try: 380 # `pidof` requires API level 24 or higher. The level 23 emulator 381 # includes it, but it doesn't work (it returns all processes). 382 pid = (await async_check_output( 383 adb, "-s", serial, "shell", "pidof", "-s", APP_ID 384 )).strip() 385 except CalledProcessError as e: 386 # If the app isn't running yet, pidof gives no output. So if there 387 # is output, there must have been some other error. However, this 388 # sometimes happens transiently, especially when running a managed 389 # emulator for the first time, so don't make it fatal. 390 if (e.stdout or e.stderr) and not shown_error: 391 print_called_process_error(e) 392 print("This may be transient, so continuing to wait") 393 shown_error = True 394 else: 395 # Some older devices (e.g. Nexus 4) return zero even when no process 396 # was found, so check whether we actually got any output. 397 if pid: 398 print(f"PID: {pid}") 399 return pid 400 401 # Loop fairly rapidly to avoid missing a short-lived process. 402 await asyncio.sleep(0.2) 403 404 405async def logcat_task(context, initial_devices): 406 # Gradle may need to do some large downloads of libraries and emulator 407 # images. This will happen during find_device in --managed mode, or find_pid 408 # in --connected mode. 409 startup_timeout = 600 410 serial = await wait_for(find_device(context, initial_devices), startup_timeout) 411 pid = await wait_for(find_pid(serial), startup_timeout) 412 413 # `--pid` requires API level 24 or higher. 414 args = [adb, "-s", serial, "logcat", "--pid", pid, "--format", "tag"] 415 hidden_output = [] 416 async with async_process( 417 *args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, 418 ) as process: 419 while line := (await process.stdout.readline()).decode(*DECODE_ARGS): 420 if match := re.fullmatch(r"([A-Z])/(.*)", line, re.DOTALL): 421 level, message = match.groups() 422 else: 423 # If the regex doesn't match, this is probably the second or 424 # subsequent line of a multi-line message. Python won't produce 425 # such messages, but other components might. 426 level, message = None, line 427 428 # Exclude high-volume messages which are rarely useful. 429 if context.verbose < 2 and "from python test_syslog" in message: 430 continue 431 432 # Put high-level messages on stderr so they're highlighted in the 433 # buildbot logs. This will include Python's own stderr. 434 stream = ( 435 sys.stderr 436 if level in ["W", "E", "F"] # WARNING, ERROR, FATAL (aka ASSERT) 437 else sys.stdout 438 ) 439 440 # To simplify automated processing of the output, e.g. a buildbot 441 # posting a failure notice on a GitHub PR, we strip the level and 442 # tag indicators from Python's stdout and stderr. 443 for prefix in ["python.stdout: ", "python.stderr: "]: 444 if message.startswith(prefix): 445 global logcat_started 446 logcat_started = True 447 stream.write(message.removeprefix(prefix)) 448 break 449 else: 450 if context.verbose: 451 # Non-Python messages add a lot of noise, but they may 452 # sometimes help explain a failure. 453 stream.write(line) 454 else: 455 hidden_output.append(line) 456 457 # If the device disconnects while logcat is running, which always 458 # happens in --managed mode, some versions of adb return non-zero. 459 # Distinguish this from a logcat startup error by checking whether we've 460 # received a message from Python yet. 461 status = await wait_for(process.wait(), timeout=1) 462 if status != 0 and not logcat_started: 463 raise CalledProcessError(status, args, "".join(hidden_output)) 464 465 466def stop_app(serial): 467 run([adb, "-s", serial, "shell", "am", "force-stop", APP_ID], log=False) 468 469 470async def gradle_task(context): 471 env = os.environ.copy() 472 if context.managed: 473 task_prefix = context.managed 474 else: 475 task_prefix = "connected" 476 env["ANDROID_SERIAL"] = context.connected 477 478 args = [ 479 gradlew, "--console", "plain", f"{task_prefix}DebugAndroidTest", 480 "-Pandroid.testInstrumentationRunnerArguments.pythonArgs=" 481 + shlex.join(context.args), 482 ] 483 hidden_output = [] 484 try: 485 async with async_process( 486 *args, cwd=TESTBED_DIR, env=env, 487 stdout=subprocess.PIPE, stderr=subprocess.STDOUT, 488 ) as process: 489 while line := (await process.stdout.readline()).decode(*DECODE_ARGS): 490 # Gradle may take several minutes to install SDK packages, so 491 # it's worth showing those messages even in non-verbose mode. 492 if context.verbose or line.startswith('Preparing "Install'): 493 sys.stdout.write(line) 494 else: 495 hidden_output.append(line) 496 497 status = await wait_for(process.wait(), timeout=1) 498 if status == 0: 499 exit(0) 500 else: 501 raise CalledProcessError(status, args) 502 finally: 503 # If logcat never started, then something has gone badly wrong, so the 504 # user probably wants to see the Gradle output even in non-verbose mode. 505 if hidden_output and not logcat_started: 506 sys.stdout.write("".join(hidden_output)) 507 508 # Gradle does not stop the tests when interrupted. 509 if context.connected: 510 stop_app(context.connected) 511 512 513async def run_testbed(context): 514 setup_sdk() 515 setup_testbed() 516 517 if context.managed: 518 # In this mode, Gradle will create a device with an unpredictable name. 519 # So we save a list of the running devices before starting Gradle, and 520 # find_device then waits for a new device to appear. 521 initial_devices = await list_devices() 522 else: 523 # In case the previous shutdown was unclean, make sure the app isn't 524 # running, otherwise we might show logs from a previous run. This is 525 # unnecessary in --managed mode, because Gradle creates a new emulator 526 # every time. 527 stop_app(context.connected) 528 initial_devices = None 529 530 try: 531 async with asyncio.TaskGroup() as tg: 532 tg.create_task(logcat_task(context, initial_devices)) 533 tg.create_task(gradle_task(context)) 534 except* MySystemExit as e: 535 raise SystemExit(*e.exceptions[0].args) from None 536 except* CalledProcessError as e: 537 # Extract it from the ExceptionGroup so it can be handled by `main`. 538 raise e.exceptions[0] 539 540 541# Handle SIGTERM the same way as SIGINT. This ensures that if we're terminated 542# by the buildbot worker, we'll make an attempt to clean up our subprocesses. 543def install_signal_handler(): 544 def signal_handler(*args): 545 os.kill(os.getpid(), signal.SIGINT) 546 547 signal.signal(signal.SIGTERM, signal_handler) 548 549 550def parse_args(): 551 parser = argparse.ArgumentParser() 552 subcommands = parser.add_subparsers(dest="subcommand") 553 build = subcommands.add_parser("build", help="Build everything") 554 configure_build = subcommands.add_parser("configure-build", 555 help="Run `configure` for the " 556 "build Python") 557 make_build = subcommands.add_parser("make-build", 558 help="Run `make` for the build Python") 559 configure_host = subcommands.add_parser("configure-host", 560 help="Run `configure` for Android") 561 make_host = subcommands.add_parser("make-host", 562 help="Run `make` for Android") 563 subcommands.add_parser( 564 "clean", help="Delete the cross-build directory") 565 566 for subcommand in build, configure_build, configure_host: 567 subcommand.add_argument( 568 "--clean", action="store_true", default=False, dest="clean", 569 help="Delete any relevant directories before building") 570 for subcommand in build, configure_host, make_host: 571 subcommand.add_argument( 572 "host", metavar="HOST", 573 choices=["aarch64-linux-android", "x86_64-linux-android"], 574 help="Host triplet: choices=[%(choices)s]") 575 for subcommand in build, configure_build, configure_host: 576 subcommand.add_argument("args", nargs="*", 577 help="Extra arguments to pass to `configure`") 578 579 subcommands.add_parser( 580 "build-testbed", help="Build the testbed app") 581 test = subcommands.add_parser( 582 "test", help="Run the test suite") 583 test.add_argument( 584 "-v", "--verbose", action="count", default=0, 585 help="Show Gradle output, and non-Python logcat messages. " 586 "Use twice to include high-volume messages which are rarely useful.") 587 device_group = test.add_mutually_exclusive_group(required=True) 588 device_group.add_argument( 589 "--connected", metavar="SERIAL", help="Run on a connected device. " 590 "Connect it yourself, then get its serial from `adb devices`.") 591 device_group.add_argument( 592 "--managed", metavar="NAME", help="Run on a Gradle-managed device. " 593 "These are defined in `managedDevices` in testbed/app/build.gradle.kts.") 594 test.add_argument( 595 "args", nargs="*", help=f"Arguments for `python -m test`. " 596 f"Separate them from {SCRIPT_NAME}'s own arguments with `--`.") 597 598 return parser.parse_args() 599 600 601def main(): 602 install_signal_handler() 603 604 # Under the buildbot, stdout is not a TTY, but we must still flush after 605 # every line to make sure our output appears in the correct order relative 606 # to the output of our subprocesses. 607 for stream in [sys.stdout, sys.stderr]: 608 stream.reconfigure(line_buffering=True) 609 610 context = parse_args() 611 dispatch = {"configure-build": configure_build_python, 612 "make-build": make_build_python, 613 "configure-host": configure_host_python, 614 "make-host": make_host_python, 615 "build": build_all, 616 "clean": clean_all, 617 "build-testbed": build_testbed, 618 "test": run_testbed} 619 620 try: 621 result = dispatch[context.subcommand](context) 622 if asyncio.iscoroutine(result): 623 asyncio.run(result) 624 except CalledProcessError as e: 625 print_called_process_error(e) 626 sys.exit(1) 627 628 629def print_called_process_error(e): 630 for stream_name in ["stdout", "stderr"]: 631 content = getattr(e, stream_name) 632 stream = getattr(sys, stream_name) 633 if content: 634 stream.write(content) 635 if not content.endswith("\n"): 636 stream.write("\n") 637 638 # Format the command so it can be copied into a shell. shlex uses single 639 # quotes, so we surround the whole command with double quotes. 640 args_joined = ( 641 e.cmd if isinstance(e.cmd, str) 642 else " ".join(shlex.quote(str(arg)) for arg in e.cmd) 643 ) 644 print( 645 f'Command "{args_joined}" returned exit status {e.returncode}' 646 ) 647 648 649if __name__ == "__main__": 650 main() 651