• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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