mirror of https://github.com/python/cpython
[3.13] gh-116622: Add Android test script (GH-121595) (#123061)
gh-116622: Add Android test script (GH-121595)
Adds a script for running the test suite on Android emulator devices. Starting
with a fresh install of the Android Commandline tools; the script manages
installing other requirements, starting the emulator (if required), and
retrieving results from that emulator.
(cherry picked from commit f84cce6f25
)
Co-authored-by: Malcolm Smith <smith@chaquo.com>
This commit is contained in:
parent
0dd89a7f40
commit
cf6d14b966
|
@ -25,7 +25,7 @@ you don't already have the SDK, here's how to install it:
|
||||||
The `android.py` script also requires the following commands to be on the `PATH`:
|
The `android.py` script also requires the following commands to be on the `PATH`:
|
||||||
|
|
||||||
* `curl`
|
* `curl`
|
||||||
* `java`
|
* `java` (or set the `JAVA_HOME` environment variable)
|
||||||
* `tar`
|
* `tar`
|
||||||
* `unzip`
|
* `unzip`
|
||||||
|
|
||||||
|
@ -80,18 +80,54 @@ call. For example, if you want a pydebug build that also caches the results from
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
To run the Python test suite on Android:
|
The tests can be run on Linux, macOS, or Windows, although on Windows you'll
|
||||||
|
have to build the `cross-build/HOST` subdirectory on one of the other platforms
|
||||||
|
and copy it over.
|
||||||
|
|
||||||
* Install Android Studio, if you don't already have it.
|
The test suite can usually be run on a device with 2 GB of RAM, though for some
|
||||||
* Follow the instructions in the previous section to build all supported
|
configurations or test orders you may need to increase this. As of Android
|
||||||
architectures.
|
Studio Koala, 2 GB is the default for all emulators, although the user interface
|
||||||
* Run `./android.py setup-testbed` to download the Gradle wrapper.
|
may indicate otherwise. The effective setting is `hw.ramSize` in
|
||||||
* Open the `testbed` directory in Android Studio.
|
~/.android/avd/*.avd/hardware-qemu.ini, whereas Android Studio displays the
|
||||||
* In the *Device Manager* dock, connect a device or start an emulator.
|
value from config.ini. Changing the value in Android Studio will update both of
|
||||||
Then select it from the drop-down list in the toolbar.
|
these files.
|
||||||
* Click the "Run" button in the toolbar.
|
|
||||||
* The testbed app displays nothing on screen while running. To see its output,
|
|
||||||
open the [Logcat window](https://developer.android.com/studio/debug/logcat).
|
|
||||||
|
|
||||||
To run specific tests, or pass any other arguments to the test suite, edit the
|
Before running the test suite, follow the instructions in the previous section
|
||||||
command line in testbed/app/src/main/python/main.py.
|
to build the architecture you want to test. Then run the test script in one of
|
||||||
|
the following modes:
|
||||||
|
|
||||||
|
* In `--connected` mode, it runs on a device or emulator you have already
|
||||||
|
connected to the build machine. List the available devices with
|
||||||
|
`$ANDROID_HOME/platform-tools/adb devices -l`, then pass a device ID to the
|
||||||
|
script like this:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./android.py test --connected emulator-5554
|
||||||
|
```
|
||||||
|
|
||||||
|
* In `--managed` mode, it uses a temporary headless emulator defined in the
|
||||||
|
`managedDevices` section of testbed/app/build.gradle.kts. This mode is slower,
|
||||||
|
but more reproducible.
|
||||||
|
|
||||||
|
We currently define two devices: `minVersion` and `maxVersion`, corresponding
|
||||||
|
to our minimum and maximum supported Android versions. For example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./android.py test --managed maxVersion
|
||||||
|
```
|
||||||
|
|
||||||
|
By default, the only messages the script will show are Python's own stdout and
|
||||||
|
stderr. Add the `-v` option to also show Gradle output, and non-Python logcat
|
||||||
|
messages.
|
||||||
|
|
||||||
|
Any other arguments on the `android.py test` command line will be passed through
|
||||||
|
to `python -m test` – use `--` to separate them from android.py's own options.
|
||||||
|
See the [Python Developer's
|
||||||
|
Guide](https://devguide.python.org/testing/run-write-tests/) for common options
|
||||||
|
– most of them will work on Android, except for those that involve subprocesses,
|
||||||
|
such as `-j`.
|
||||||
|
|
||||||
|
Every time you run `android.py test`, changes in pure-Python files in the
|
||||||
|
repository's `Lib` directory will be picked up immediately. Changes in C files,
|
||||||
|
and architecture-specific files such as sysconfigdata, will not take effect
|
||||||
|
until you re-run `android.py make-host` or `build`.
|
||||||
|
|
|
@ -28,7 +28,7 @@ ndk_version=26.2.11394342
|
||||||
|
|
||||||
ndk=$ANDROID_HOME/ndk/$ndk_version
|
ndk=$ANDROID_HOME/ndk/$ndk_version
|
||||||
if ! [ -e $ndk ]; then
|
if ! [ -e $ndk ]; then
|
||||||
log "Installing NDK: this may take several minutes"
|
log "Installing NDK - this may take several minutes"
|
||||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;$ndk_version"
|
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;$ndk_version"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
@ -1,21 +1,51 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import argparse
|
import argparse
|
||||||
from glob import glob
|
from glob import glob
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import sysconfig
|
import sysconfig
|
||||||
|
from asyncio import wait_for
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from os.path import basename, relpath
|
from os.path import basename, relpath
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from subprocess import CalledProcessError
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
|
||||||
SCRIPT_NAME = Path(__file__).name
|
SCRIPT_NAME = Path(__file__).name
|
||||||
CHECKOUT = Path(__file__).resolve().parent.parent
|
CHECKOUT = Path(__file__).resolve().parent.parent
|
||||||
|
ANDROID_DIR = CHECKOUT / "Android"
|
||||||
|
TESTBED_DIR = ANDROID_DIR / "testbed"
|
||||||
CROSS_BUILD_DIR = CHECKOUT / "cross-build"
|
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):
|
def delete_glob(pattern):
|
||||||
# Path.glob doesn't accept non-relative patterns.
|
# Path.glob doesn't accept non-relative patterns.
|
||||||
|
@ -42,10 +72,14 @@ def subdir(name, *, clean=None):
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def run(command, *, host=None, **kwargs):
|
def run(command, *, host=None, env=None, log=True, **kwargs):
|
||||||
env = os.environ.copy()
|
kwargs.setdefault("check", True)
|
||||||
|
if env is None:
|
||||||
|
env = os.environ.copy()
|
||||||
|
original_env = env.copy()
|
||||||
|
|
||||||
if host:
|
if host:
|
||||||
env_script = CHECKOUT / "Android/android-env.sh"
|
env_script = ANDROID_DIR / "android-env.sh"
|
||||||
env_output = subprocess.run(
|
env_output = subprocess.run(
|
||||||
f"set -eu; "
|
f"set -eu; "
|
||||||
f"HOST={host}; "
|
f"HOST={host}; "
|
||||||
|
@ -66,15 +100,13 @@ def run(command, *, host=None, **kwargs):
|
||||||
print(line)
|
print(line)
|
||||||
env[key] = value
|
env[key] = value
|
||||||
|
|
||||||
if env == os.environ:
|
if env == original_env:
|
||||||
raise ValueError(f"Found no variables in {env_script.name} output:\n"
|
raise ValueError(f"Found no variables in {env_script.name} output:\n"
|
||||||
+ env_output)
|
+ env_output)
|
||||||
|
|
||||||
print(">", " ".join(map(str, command)))
|
if log:
|
||||||
try:
|
print(">", " ".join(map(str, command)))
|
||||||
subprocess.run(command, check=True, env=env, **kwargs)
|
return subprocess.run(command, env=env, **kwargs)
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
sys.exit(e)
|
|
||||||
|
|
||||||
|
|
||||||
def build_python_path():
|
def build_python_path():
|
||||||
|
@ -180,31 +212,334 @@ def clean_all(context):
|
||||||
delete_glob(CROSS_BUILD_DIR)
|
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,
|
# To avoid distributing compiled artifacts without corresponding source code,
|
||||||
# the Gradle wrapper is not included in the CPython repository. Instead, we
|
# the Gradle wrapper is not included in the CPython repository. Instead, we
|
||||||
# extract it from the Gradle release.
|
# extract it from the Gradle release.
|
||||||
def setup_testbed(context):
|
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_long = "8.7.0"
|
||||||
ver_short = ver_long.removesuffix(".0")
|
ver_short = ver_long.removesuffix(".0")
|
||||||
testbed_dir = CHECKOUT / "Android/testbed"
|
|
||||||
|
|
||||||
for filename in ["gradlew", "gradlew.bat"]:
|
for filename in ["gradlew", "gradlew.bat"]:
|
||||||
out_path = download(
|
out_path = download(
|
||||||
f"https://raw.githubusercontent.com/gradle/gradle/v{ver_long}/{filename}",
|
f"https://raw.githubusercontent.com/gradle/gradle/v{ver_long}/{filename}",
|
||||||
testbed_dir)
|
TESTBED_DIR)
|
||||||
os.chmod(out_path, 0o755)
|
os.chmod(out_path, 0o755)
|
||||||
|
|
||||||
with TemporaryDirectory(prefix=SCRIPT_NAME) as temp_dir:
|
with TemporaryDirectory(prefix=SCRIPT_NAME) as temp_dir:
|
||||||
os.chdir(temp_dir)
|
|
||||||
bin_zip = download(
|
bin_zip = download(
|
||||||
f"https://services.gradle.org/distributions/gradle-{ver_short}-bin.zip")
|
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"
|
outer_jar = f"gradle-{ver_short}/lib/plugins/gradle-wrapper-{ver_short}.jar"
|
||||||
run(["unzip", bin_zip, outer_jar])
|
run(["unzip", "-d", temp_dir, bin_zip, outer_jar])
|
||||||
run(["unzip", "-o", "-d", f"{testbed_dir}/gradle/wrapper", outer_jar,
|
run(["unzip", "-o", "-d", f"{TESTBED_DIR}/gradle/wrapper",
|
||||||
"gradle-wrapper.jar"])
|
f"{temp_dir}/{outer_jar}", "gradle-wrapper.jar"])
|
||||||
|
|
||||||
|
|
||||||
def main():
|
# run_testbed will build the app automatically, but it hides the Gradle output
|
||||||
|
# by default, so it's useful to have this as a separate command for the buildbot.
|
||||||
|
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:
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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 ["E", "F"] # ERROR and 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()
|
parser = argparse.ArgumentParser()
|
||||||
subcommands = parser.add_subparsers(dest="subcommand")
|
subcommands = parser.add_subparsers(dest="subcommand")
|
||||||
build = subcommands.add_parser("build", help="Build everything")
|
build = subcommands.add_parser("build", help="Build everything")
|
||||||
|
@ -219,8 +554,6 @@ def main():
|
||||||
help="Run `make` for Android")
|
help="Run `make` for Android")
|
||||||
subcommands.add_parser(
|
subcommands.add_parser(
|
||||||
"clean", help="Delete the cross-build directory")
|
"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:
|
for subcommand in build, configure_build, configure_host:
|
||||||
subcommand.add_argument(
|
subcommand.add_argument(
|
||||||
|
@ -235,15 +568,66 @@ def main():
|
||||||
subcommand.add_argument("args", nargs="*",
|
subcommand.add_argument("args", nargs="*",
|
||||||
help="Extra arguments to pass to `configure`")
|
help="Extra arguments to pass to `configure`")
|
||||||
|
|
||||||
context = parser.parse_args()
|
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="store_true",
|
||||||
|
help="Show Gradle output, and non-Python logcat messages")
|
||||||
|
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()
|
||||||
|
context = parse_args()
|
||||||
dispatch = {"configure-build": configure_build_python,
|
dispatch = {"configure-build": configure_build_python,
|
||||||
"make-build": make_build_python,
|
"make-build": make_build_python,
|
||||||
"configure-host": configure_host_python,
|
"configure-host": configure_host_python,
|
||||||
"make-host": make_host_python,
|
"make-host": make_host_python,
|
||||||
"build": build_all,
|
"build": build_all,
|
||||||
"clean": clean_all,
|
"clean": clean_all,
|
||||||
"setup-testbed": setup_testbed}
|
"build-testbed": build_testbed,
|
||||||
dispatch[context.subcommand](context)
|
"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__":
|
if __name__ == "__main__":
|
||||||
|
|
|
@ -1,17 +1,18 @@
|
||||||
import com.android.build.api.variant.*
|
import com.android.build.api.variant.*
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
}
|
}
|
||||||
|
|
||||||
val PYTHON_DIR = File(projectDir, "../../..").canonicalPath
|
val PYTHON_DIR = file("../../..").canonicalPath
|
||||||
val PYTHON_CROSS_DIR = "$PYTHON_DIR/cross-build"
|
val PYTHON_CROSS_DIR = "$PYTHON_DIR/cross-build"
|
||||||
|
|
||||||
val ABIS = mapOf(
|
val ABIS = mapOf(
|
||||||
"arm64-v8a" to "aarch64-linux-android",
|
"arm64-v8a" to "aarch64-linux-android",
|
||||||
"x86_64" to "x86_64-linux-android",
|
"x86_64" to "x86_64-linux-android",
|
||||||
).filter { File("$PYTHON_CROSS_DIR/${it.value}").exists() }
|
).filter { file("$PYTHON_CROSS_DIR/${it.value}").exists() }
|
||||||
if (ABIS.isEmpty()) {
|
if (ABIS.isEmpty()) {
|
||||||
throw GradleException(
|
throw GradleException(
|
||||||
"No Android ABIs found in $PYTHON_CROSS_DIR: see Android/README.md " +
|
"No Android ABIs found in $PYTHON_CROSS_DIR: see Android/README.md " +
|
||||||
|
@ -19,7 +20,7 @@ if (ABIS.isEmpty()) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val PYTHON_VERSION = File("$PYTHON_DIR/Include/patchlevel.h").useLines {
|
val PYTHON_VERSION = file("$PYTHON_DIR/Include/patchlevel.h").useLines {
|
||||||
for (line in it) {
|
for (line in it) {
|
||||||
val match = """#define PY_VERSION\s+"(\d+\.\d+)""".toRegex().find(line)
|
val match = """#define PY_VERSION\s+"(\d+\.\d+)""".toRegex().find(line)
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
|
@ -29,6 +30,16 @@ val PYTHON_VERSION = File("$PYTHON_DIR/Include/patchlevel.h").useLines {
|
||||||
throw GradleException("Failed to find Python version")
|
throw GradleException("Failed to find Python version")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
android.ndkVersion = file("../../android-env.sh").useLines {
|
||||||
|
for (line in it) {
|
||||||
|
val match = """ndk_version=(\S+)""".toRegex().find(line)
|
||||||
|
if (match != null) {
|
||||||
|
return@useLines match.groupValues[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw GradleException("Failed to find NDK version")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "org.python.testbed"
|
namespace = "org.python.testbed"
|
||||||
|
@ -45,6 +56,8 @@ android {
|
||||||
externalNativeBuild.cmake.arguments(
|
externalNativeBuild.cmake.arguments(
|
||||||
"-DPYTHON_CROSS_DIR=$PYTHON_CROSS_DIR",
|
"-DPYTHON_CROSS_DIR=$PYTHON_CROSS_DIR",
|
||||||
"-DPYTHON_VERSION=$PYTHON_VERSION")
|
"-DPYTHON_VERSION=$PYTHON_VERSION")
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
externalNativeBuild.cmake {
|
externalNativeBuild.cmake {
|
||||||
|
@ -62,41 +75,81 @@ android {
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "1.8"
|
jvmTarget = "1.8"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
managedDevices {
|
||||||
|
localDevices {
|
||||||
|
create("minVersion") {
|
||||||
|
device = "Small Phone"
|
||||||
|
|
||||||
|
// Managed devices have a minimum API level of 27.
|
||||||
|
apiLevel = max(27, defaultConfig.minSdk!!)
|
||||||
|
|
||||||
|
// ATD devices are smaller and faster, but have a minimum
|
||||||
|
// API level of 30.
|
||||||
|
systemImageSource = if (apiLevel >= 30) "aosp-atd" else "aosp"
|
||||||
|
}
|
||||||
|
|
||||||
|
create("maxVersion") {
|
||||||
|
device = "Small Phone"
|
||||||
|
apiLevel = defaultConfig.targetSdk!!
|
||||||
|
systemImageSource = "aosp-atd"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the previous test run succeeded and nothing has changed,
|
||||||
|
// Gradle thinks there's no need to run it again. Override that.
|
||||||
|
afterEvaluate {
|
||||||
|
(localDevices.names + listOf("connected")).forEach {
|
||||||
|
tasks.named("${it}DebugAndroidTest") {
|
||||||
|
outputs.upToDateWhen { false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||||
implementation("com.google.android.material:material:1.11.0")
|
implementation("com.google.android.material:material:1.11.0")
|
||||||
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
||||||
|
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||||
|
androidTestImplementation("androidx.test:rules:1.5.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Create some custom tasks to copy Python and its standard library from
|
// Create some custom tasks to copy Python and its standard library from
|
||||||
// elsewhere in the repository.
|
// elsewhere in the repository.
|
||||||
androidComponents.onVariants { variant ->
|
androidComponents.onVariants { variant ->
|
||||||
|
val pyPlusVer = "python$PYTHON_VERSION"
|
||||||
generateTask(variant, variant.sources.assets!!) {
|
generateTask(variant, variant.sources.assets!!) {
|
||||||
into("python") {
|
into("python") {
|
||||||
for (triplet in ABIS.values) {
|
into("include/$pyPlusVer") {
|
||||||
for (subDir in listOf("include", "lib")) {
|
for (triplet in ABIS.values) {
|
||||||
into(subDir) {
|
from("$PYTHON_CROSS_DIR/$triplet/prefix/include/$pyPlusVer")
|
||||||
from("$PYTHON_CROSS_DIR/$triplet/prefix/$subDir")
|
|
||||||
include("python$PYTHON_VERSION/**")
|
|
||||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||||
}
|
}
|
||||||
into("lib/python$PYTHON_VERSION") {
|
|
||||||
// Uncomment this to pick up edits from the source directory
|
into("lib/$pyPlusVer") {
|
||||||
// without having to rerun `make install`.
|
// To aid debugging, the source directory takes priority.
|
||||||
// from("$PYTHON_DIR/Lib")
|
from("$PYTHON_DIR/Lib")
|
||||||
// duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
|
||||||
|
// The cross-build directory provides ABI-specific files such as
|
||||||
|
// sysconfigdata.
|
||||||
|
for (triplet in ABIS.values) {
|
||||||
|
from("$PYTHON_CROSS_DIR/$triplet/prefix/lib/$pyPlusVer")
|
||||||
|
}
|
||||||
|
|
||||||
into("site-packages") {
|
into("site-packages") {
|
||||||
from("$projectDir/src/main/python")
|
from("$projectDir/src/main/python")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||||
|
exclude("**/__pycache__")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
exclude("**/__pycache__")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
generateTask(variant, variant.sources.jniLibs!!) {
|
generateTask(variant, variant.sources.jniLibs!!) {
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
package org.python.testbed
|
||||||
|
|
||||||
|
import androidx.test.annotation.UiThreadTest
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class PythonSuite {
|
||||||
|
@Test
|
||||||
|
@UiThreadTest
|
||||||
|
fun testPython() {
|
||||||
|
val start = System.currentTimeMillis()
|
||||||
|
try {
|
||||||
|
val context =
|
||||||
|
InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
val args =
|
||||||
|
InstrumentationRegistry.getArguments().getString("pythonArgs", "")
|
||||||
|
val status = PythonTestRunner(context).run(args)
|
||||||
|
assertEquals(0, status)
|
||||||
|
} finally {
|
||||||
|
// Make sure the process lives long enough for the test script to
|
||||||
|
// detect it (see `find_pid` in android.py).
|
||||||
|
val delay = 2000 - (System.currentTimeMillis() - start)
|
||||||
|
if (delay > 0) {
|
||||||
|
Thread.sleep(delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -84,7 +84,7 @@ static char *redirect_stream(StreamInfo *si) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
JNIEXPORT void JNICALL Java_org_python_testbed_MainActivity_redirectStdioToLogcat(
|
JNIEXPORT void JNICALL Java_org_python_testbed_PythonTestRunner_redirectStdioToLogcat(
|
||||||
JNIEnv *env, jobject obj
|
JNIEnv *env, jobject obj
|
||||||
) {
|
) {
|
||||||
for (StreamInfo *si = STREAMS; si->file; si++) {
|
for (StreamInfo *si = STREAMS; si->file; si++) {
|
||||||
|
@ -115,7 +115,7 @@ static void throw_status(JNIEnv *env, PyStatus status) {
|
||||||
throw_runtime_exception(env, status.err_msg ? status.err_msg : "");
|
throw_runtime_exception(env, status.err_msg ? status.err_msg : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
JNIEXPORT void JNICALL Java_org_python_testbed_MainActivity_runPython(
|
JNIEXPORT int JNICALL Java_org_python_testbed_PythonTestRunner_runPython(
|
||||||
JNIEnv *env, jobject obj, jstring home, jstring runModule
|
JNIEnv *env, jobject obj, jstring home, jstring runModule
|
||||||
) {
|
) {
|
||||||
PyConfig config;
|
PyConfig config;
|
||||||
|
@ -125,13 +125,13 @@ JNIEXPORT void JNICALL Java_org_python_testbed_MainActivity_runPython(
|
||||||
status = set_config_string(env, &config, &config.home, home);
|
status = set_config_string(env, &config, &config.home, home);
|
||||||
if (PyStatus_Exception(status)) {
|
if (PyStatus_Exception(status)) {
|
||||||
throw_status(env, status);
|
throw_status(env, status);
|
||||||
return;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
status = set_config_string(env, &config, &config.run_module, runModule);
|
status = set_config_string(env, &config, &config.run_module, runModule);
|
||||||
if (PyStatus_Exception(status)) {
|
if (PyStatus_Exception(status)) {
|
||||||
throw_status(env, status);
|
throw_status(env, status);
|
||||||
return;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some tests generate SIGPIPE and SIGXFSZ, which should be ignored.
|
// Some tests generate SIGPIPE and SIGXFSZ, which should be ignored.
|
||||||
|
@ -140,8 +140,8 @@ JNIEXPORT void JNICALL Java_org_python_testbed_MainActivity_runPython(
|
||||||
status = Py_InitializeFromConfig(&config);
|
status = Py_InitializeFromConfig(&config);
|
||||||
if (PyStatus_Exception(status)) {
|
if (PyStatus_Exception(status)) {
|
||||||
throw_status(env, status);
|
throw_status(env, status);
|
||||||
return;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
Py_RunMain();
|
return Py_RunMain();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,38 +1,56 @@
|
||||||
package org.python.testbed
|
package org.python.testbed
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.os.*
|
import android.os.*
|
||||||
import android.system.Os
|
import android.system.Os
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.appcompat.app.*
|
import androidx.appcompat.app.*
|
||||||
import java.io.*
|
import java.io.*
|
||||||
|
|
||||||
|
|
||||||
|
// Launching the tests from an activity is OK for a quick check, but for
|
||||||
|
// anything more complicated it'll be more convenient to use `android.py test`
|
||||||
|
// to launch the tests via PythonSuite.
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.activity_main)
|
setContentView(R.layout.activity_main)
|
||||||
|
val status = PythonTestRunner(this).run("-W -uall")
|
||||||
|
findViewById<TextView>(R.id.tvHello).text = "Exit status $status"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PythonTestRunner(val context: Context) {
|
||||||
|
/** @param args Extra arguments for `python -m test`.
|
||||||
|
* @return The Python exit status: zero if the tests passed, nonzero if
|
||||||
|
* they failed. */
|
||||||
|
fun run(args: String = "") : Int {
|
||||||
|
Os.setenv("PYTHON_ARGS", args, true)
|
||||||
|
|
||||||
// Python needs this variable to help it find the temporary directory,
|
// Python needs this variable to help it find the temporary directory,
|
||||||
// but Android only sets it on API level 33 and later.
|
// but Android only sets it on API level 33 and later.
|
||||||
Os.setenv("TMPDIR", cacheDir.toString(), false)
|
Os.setenv("TMPDIR", context.cacheDir.toString(), false)
|
||||||
|
|
||||||
val pythonHome = extractAssets()
|
val pythonHome = extractAssets()
|
||||||
System.loadLibrary("main_activity")
|
System.loadLibrary("main_activity")
|
||||||
redirectStdioToLogcat()
|
redirectStdioToLogcat()
|
||||||
runPython(pythonHome.toString(), "main")
|
|
||||||
findViewById<TextView>(R.id.tvHello).text = "Python complete"
|
// The main module is in src/main/python/main.py.
|
||||||
|
return runPython(pythonHome.toString(), "main")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractAssets() : File {
|
private fun extractAssets() : File {
|
||||||
val pythonHome = File(filesDir, "python")
|
val pythonHome = File(context.filesDir, "python")
|
||||||
if (pythonHome.exists() && !pythonHome.deleteRecursively()) {
|
if (pythonHome.exists() && !pythonHome.deleteRecursively()) {
|
||||||
throw RuntimeException("Failed to delete $pythonHome")
|
throw RuntimeException("Failed to delete $pythonHome")
|
||||||
}
|
}
|
||||||
extractAssetDir("python", filesDir)
|
extractAssetDir("python", context.filesDir)
|
||||||
return pythonHome
|
return pythonHome
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractAssetDir(path: String, targetDir: File) {
|
private fun extractAssetDir(path: String, targetDir: File) {
|
||||||
val names = assets.list(path)
|
val names = context.assets.list(path)
|
||||||
?: throw RuntimeException("Failed to list $path")
|
?: throw RuntimeException("Failed to list $path")
|
||||||
val targetSubdir = File(targetDir, path)
|
val targetSubdir = File(targetDir, path)
|
||||||
if (!targetSubdir.mkdirs()) {
|
if (!targetSubdir.mkdirs()) {
|
||||||
|
@ -43,7 +61,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
val subPath = "$path/$name"
|
val subPath = "$path/$name"
|
||||||
val input: InputStream
|
val input: InputStream
|
||||||
try {
|
try {
|
||||||
input = assets.open(subPath)
|
input = context.assets.open(subPath)
|
||||||
} catch (e: FileNotFoundException) {
|
} catch (e: FileNotFoundException) {
|
||||||
extractAssetDir(subPath, targetDir)
|
extractAssetDir(subPath, targetDir)
|
||||||
continue
|
continue
|
||||||
|
@ -57,5 +75,5 @@ class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private external fun redirectStdioToLogcat()
|
private external fun redirectStdioToLogcat()
|
||||||
private external fun runPython(home: String, runModule: String)
|
private external fun runPython(home: String, runModule: String) : Int
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
|
import os
|
||||||
import runpy
|
import runpy
|
||||||
|
import shlex
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
@ -8,10 +10,7 @@ import sys
|
||||||
# profile save"), so disabling it should not weaken the tests.
|
# profile save"), so disabling it should not weaken the tests.
|
||||||
signal.pthread_sigmask(signal.SIG_UNBLOCK, [signal.SIGUSR1])
|
signal.pthread_sigmask(signal.SIG_UNBLOCK, [signal.SIGUSR1])
|
||||||
|
|
||||||
# To run specific tests, or pass any other arguments to the test suite, edit
|
sys.argv[1:] = shlex.split(os.environ["PYTHON_ARGS"])
|
||||||
# this command line.
|
|
||||||
sys.argv[1:] = [
|
# The test module will call sys.exit to indicate whether the tests passed.
|
||||||
"--use", "all,-cpu",
|
|
||||||
"--verbose3",
|
|
||||||
]
|
|
||||||
runpy.run_module("test")
|
runpy.run_module("test")
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application") version "8.2.2" apply false
|
id("com.android.application") version "8.4.2" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,4 +20,9 @@ kotlin.code.style=official
|
||||||
# Enables namespacing of each library's R class so that its R class includes only the
|
# Enables namespacing of each library's R class so that its R class includes only the
|
||||||
# resources declared in the library itself and none from the library's dependencies,
|
# resources declared in the library itself and none from the library's dependencies,
|
||||||
# thereby reducing the size of the R class for that library
|
# thereby reducing the size of the R class for that library
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
|
|
||||||
|
# By default, the app will be uninstalled after the tests finish (apparently
|
||||||
|
# after 10 seconds in case of an unclean shutdown). We disable this, because
|
||||||
|
# when using android.py it can conflict with the installation of the next run.
|
||||||
|
android.injected.androidTest.leaveApksInstalledAfterRun=true
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
#Mon Feb 19 20:29:06 GMT 2024
|
#Mon Feb 19 20:29:06 GMT 2024
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|
|
@ -31,15 +31,17 @@ def init_streams(android_log_write, stdout_prio, stderr_prio):
|
||||||
logcat = Logcat(android_log_write)
|
logcat = Logcat(android_log_write)
|
||||||
|
|
||||||
sys.stdout = TextLogStream(
|
sys.stdout = TextLogStream(
|
||||||
stdout_prio, "python.stdout", errors=sys.stdout.errors)
|
stdout_prio, "python.stdout", sys.stdout.fileno(),
|
||||||
|
errors=sys.stdout.errors)
|
||||||
sys.stderr = TextLogStream(
|
sys.stderr = TextLogStream(
|
||||||
stderr_prio, "python.stderr", errors=sys.stderr.errors)
|
stderr_prio, "python.stderr", sys.stderr.fileno(),
|
||||||
|
errors=sys.stderr.errors)
|
||||||
|
|
||||||
|
|
||||||
class TextLogStream(io.TextIOWrapper):
|
class TextLogStream(io.TextIOWrapper):
|
||||||
def __init__(self, prio, tag, **kwargs):
|
def __init__(self, prio, tag, fileno=None, **kwargs):
|
||||||
kwargs.setdefault("encoding", "UTF-8")
|
kwargs.setdefault("encoding", "UTF-8")
|
||||||
super().__init__(BinaryLogStream(prio, tag), **kwargs)
|
super().__init__(BinaryLogStream(prio, tag, fileno), **kwargs)
|
||||||
self._lock = RLock()
|
self._lock = RLock()
|
||||||
self._pending_bytes = []
|
self._pending_bytes = []
|
||||||
self._pending_bytes_count = 0
|
self._pending_bytes_count = 0
|
||||||
|
@ -98,9 +100,10 @@ class TextLogStream(io.TextIOWrapper):
|
||||||
|
|
||||||
|
|
||||||
class BinaryLogStream(io.RawIOBase):
|
class BinaryLogStream(io.RawIOBase):
|
||||||
def __init__(self, prio, tag):
|
def __init__(self, prio, tag, fileno=None):
|
||||||
self.prio = prio
|
self.prio = prio
|
||||||
self.tag = tag
|
self.tag = tag
|
||||||
|
self._fileno = fileno
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<BinaryLogStream {self.tag!r}>"
|
return f"<BinaryLogStream {self.tag!r}>"
|
||||||
|
@ -122,6 +125,12 @@ class BinaryLogStream(io.RawIOBase):
|
||||||
logcat.write(self.prio, self.tag, b)
|
logcat.write(self.prio, self.tag, b)
|
||||||
return len(b)
|
return len(b)
|
||||||
|
|
||||||
|
# This is needed by the test suite --timeout option, which uses faulthandler.
|
||||||
|
def fileno(self):
|
||||||
|
if self._fileno is None:
|
||||||
|
raise io.UnsupportedOperation("fileno")
|
||||||
|
return self._fileno
|
||||||
|
|
||||||
|
|
||||||
# When a large volume of data is written to logcat at once, e.g. when a test
|
# When a large volume of data is written to logcat at once, e.g. when a test
|
||||||
# module fails in --verbose3 mode, there's a risk of overflowing logcat's own
|
# module fails in --verbose3 mode, there's a risk of overflowing logcat's own
|
||||||
|
|
|
@ -421,9 +421,7 @@ def _parse_args(args, **kwargs):
|
||||||
# Continuous Integration (CI): common options for fast/slow CI modes
|
# Continuous Integration (CI): common options for fast/slow CI modes
|
||||||
if ns.slow_ci or ns.fast_ci:
|
if ns.slow_ci or ns.fast_ci:
|
||||||
# Similar to options:
|
# Similar to options:
|
||||||
#
|
# -j0 --randomize --fail-env-changed --rerun --slowest --verbose3
|
||||||
# -j0 --randomize --fail-env-changed --fail-rerun --rerun
|
|
||||||
# --slowest --verbose3
|
|
||||||
if ns.use_mp is None:
|
if ns.use_mp is None:
|
||||||
ns.use_mp = 0
|
ns.use_mp = 0
|
||||||
ns.randomize = True
|
ns.randomize = True
|
||||||
|
|
|
@ -19,6 +19,9 @@ if sys.platform != "android":
|
||||||
|
|
||||||
api_level = platform.android_ver().api_level
|
api_level = platform.android_ver().api_level
|
||||||
|
|
||||||
|
# (name, level, fileno)
|
||||||
|
STREAM_INFO = [("stdout", "I", 1), ("stderr", "W", 2)]
|
||||||
|
|
||||||
|
|
||||||
# Test redirection of stdout and stderr to the Android log.
|
# Test redirection of stdout and stderr to the Android log.
|
||||||
@unittest.skipIf(
|
@unittest.skipIf(
|
||||||
|
@ -94,19 +97,21 @@ class TestAndroidOutput(unittest.TestCase):
|
||||||
stack = ExitStack()
|
stack = ExitStack()
|
||||||
stack.enter_context(self.subTest(stream_name))
|
stack.enter_context(self.subTest(stream_name))
|
||||||
stream = getattr(sys, stream_name)
|
stream = getattr(sys, stream_name)
|
||||||
|
native_stream = getattr(sys, f"__{stream_name}__")
|
||||||
if isinstance(stream, io.StringIO):
|
if isinstance(stream, io.StringIO):
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch(
|
patch(
|
||||||
f"sys.{stream_name}",
|
f"sys.{stream_name}",
|
||||||
TextLogStream(
|
TextLogStream(
|
||||||
prio, f"python.{stream_name}", errors="backslashreplace"
|
prio, f"python.{stream_name}", native_stream.fileno(),
|
||||||
|
errors="backslashreplace"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return stack
|
return stack
|
||||||
|
|
||||||
def test_str(self):
|
def test_str(self):
|
||||||
for stream_name, level in [("stdout", "I"), ("stderr", "W")]:
|
for stream_name, level, fileno in STREAM_INFO:
|
||||||
with self.stream_context(stream_name, level):
|
with self.stream_context(stream_name, level):
|
||||||
stream = getattr(sys, stream_name)
|
stream = getattr(sys, stream_name)
|
||||||
tag = f"python.{stream_name}"
|
tag = f"python.{stream_name}"
|
||||||
|
@ -114,6 +119,7 @@ class TestAndroidOutput(unittest.TestCase):
|
||||||
|
|
||||||
self.assertIs(stream.writable(), True)
|
self.assertIs(stream.writable(), True)
|
||||||
self.assertIs(stream.readable(), False)
|
self.assertIs(stream.readable(), False)
|
||||||
|
self.assertEqual(stream.fileno(), fileno)
|
||||||
self.assertEqual("UTF-8", stream.encoding)
|
self.assertEqual("UTF-8", stream.encoding)
|
||||||
self.assertIs(stream.line_buffering, True)
|
self.assertIs(stream.line_buffering, True)
|
||||||
self.assertIs(stream.write_through, False)
|
self.assertIs(stream.write_through, False)
|
||||||
|
@ -257,13 +263,14 @@ class TestAndroidOutput(unittest.TestCase):
|
||||||
write("\n", [s * 51]) # 0 bytes in, 510 bytes out
|
write("\n", [s * 51]) # 0 bytes in, 510 bytes out
|
||||||
|
|
||||||
def test_bytes(self):
|
def test_bytes(self):
|
||||||
for stream_name, level in [("stdout", "I"), ("stderr", "W")]:
|
for stream_name, level, fileno in STREAM_INFO:
|
||||||
with self.stream_context(stream_name, level):
|
with self.stream_context(stream_name, level):
|
||||||
stream = getattr(sys, stream_name).buffer
|
stream = getattr(sys, stream_name).buffer
|
||||||
tag = f"python.{stream_name}"
|
tag = f"python.{stream_name}"
|
||||||
self.assertEqual(f"<BinaryLogStream '{tag}'>", repr(stream))
|
self.assertEqual(f"<BinaryLogStream '{tag}'>", repr(stream))
|
||||||
self.assertIs(stream.writable(), True)
|
self.assertIs(stream.writable(), True)
|
||||||
self.assertIs(stream.readable(), False)
|
self.assertIs(stream.readable(), False)
|
||||||
|
self.assertEqual(stream.fileno(), fileno)
|
||||||
|
|
||||||
def write(b, lines=None, *, write_len=None):
|
def write(b, lines=None, *, write_len=None):
|
||||||
if write_len is None:
|
if write_len is None:
|
||||||
|
|
Loading…
Reference in New Issue