aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/Android
diff options
context:
space:
mode:
Diffstat (limited to 'Android')
-rw-r--r--Android/README.md4
-rw-r--r--Android/android-env.sh6
-rwxr-xr-xAndroid/android.py231
-rw-r--r--Android/testbed/app/build.gradle.kts20
-rw-r--r--Android/testbed/app/src/androidTest/java/org/python/testbed/PythonSuite.kt8
-rw-r--r--Android/testbed/app/src/main/java/org/python/testbed/MainActivity.kt27
-rw-r--r--Android/testbed/app/src/main/python/android_testbed_main.py (renamed from Android/testbed/app/src/main/python/main.py)20
-rw-r--r--Android/testbed/build.gradle.kts2
-rw-r--r--Android/testbed/gradle/wrapper/gradle-wrapper.properties2
9 files changed, 230 insertions, 90 deletions
diff --git a/Android/README.md b/Android/README.md
index 6cabd6ba5d6..c42eb627006 100644
--- a/Android/README.md
+++ b/Android/README.md
@@ -156,6 +156,10 @@ 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`.
+The testbed app can also be used to test third-party packages. For more details,
+run `android.py test --help`, paying attention to the options `--site-packages`,
+`--cwd`, `-c` and `-m`.
+
## Using in your own app
diff --git a/Android/android-env.sh b/Android/android-env.sh
index bab4130c9e9..7b381a013cf 100644
--- a/Android/android-env.sh
+++ b/Android/android-env.sh
@@ -3,7 +3,7 @@
: "${HOST:?}" # GNU target triplet
# You may also override the following:
-: "${api_level:=24}" # Minimum Android API level the build will run on
+: "${ANDROID_API_LEVEL:=24}" # Minimum Android API level the build will run on
: "${PREFIX:-}" # Path in which to find required libraries
@@ -24,7 +24,7 @@ fail() {
# * https://android.googlesource.com/platform/ndk/+/ndk-rXX-release/docs/BuildSystemMaintainers.md
# where XX is the NDK version. Do a diff against the version you're upgrading from, e.g.:
# https://android.googlesource.com/platform/ndk/+/ndk-r25-release..ndk-r26-release/docs/BuildSystemMaintainers.md
-ndk_version=27.1.12297006
+ndk_version=27.2.12479018
ndk=$ANDROID_HOME/ndk/$ndk_version
if ! [ -e "$ndk" ]; then
@@ -43,7 +43,7 @@ fi
toolchain=$(echo "$ndk"/toolchains/llvm/prebuilt/*)
export AR="$toolchain/bin/llvm-ar"
export AS="$toolchain/bin/llvm-as"
-export CC="$toolchain/bin/${clang_triplet}${api_level}-clang"
+export CC="$toolchain/bin/${clang_triplet}${ANDROID_API_LEVEL}-clang"
export CXX="${CC}++"
export LD="$toolchain/bin/ld"
export NM="$toolchain/bin/llvm-nm"
diff --git a/Android/android.py b/Android/android.py
index 3f48b42aa17..551168fc4b2 100755
--- a/Android/android.py
+++ b/Android/android.py
@@ -14,7 +14,7 @@ from asyncio import wait_for
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from glob import glob
-from os.path import basename, relpath
+from os.path import abspath, basename, relpath
from pathlib import Path
from subprocess import CalledProcessError
from tempfile import TemporaryDirectory
@@ -22,9 +22,13 @@ from tempfile import TemporaryDirectory
SCRIPT_NAME = Path(__file__).name
ANDROID_DIR = Path(__file__).resolve().parent
-CHECKOUT = ANDROID_DIR.parent
+PYTHON_DIR = ANDROID_DIR.parent
+in_source_tree = (
+ ANDROID_DIR.name == "Android" and (PYTHON_DIR / "pyconfig.h.in").exists()
+)
+
TESTBED_DIR = ANDROID_DIR / "testbed"
-CROSS_BUILD_DIR = CHECKOUT / "cross-build"
+CROSS_BUILD_DIR = PYTHON_DIR / "cross-build"
HOSTS = ["aarch64-linux-android", "x86_64-linux-android"]
APP_ID = "org.python.testbed"
@@ -76,39 +80,68 @@ def run(command, *, host=None, env=None, log=True, **kwargs):
kwargs.setdefault("check", True)
if env is None:
env = os.environ.copy()
- original_env = env.copy()
if host:
- env_script = ANDROID_DIR / "android-env.sh"
- env_output = subprocess.run(
- f"set -eu; "
- f"HOST={host}; "
- f"PREFIX={subdir(host)}/prefix; "
- f". {env_script}; "
- f"export",
- check=True, shell=True, text=True, stdout=subprocess.PIPE
- ).stdout
-
- for line in env_output.splitlines():
- # We don't require every line to match, as there may be some other
- # output from installing the NDK.
- if match := re.search(
- "^(declare -x |export )?(\\w+)=['\"]?(.*?)['\"]?$", line
- ):
- key, value = match[2], match[3]
- if env.get(key) != value:
- print(line)
- env[key] = value
-
- if env == original_env:
- raise ValueError(f"Found no variables in {env_script.name} output:\n"
- + env_output)
+ host_env = android_env(host)
+ print_env(host_env)
+ env.update(host_env)
if log:
- print(">", " ".join(map(str, command)))
+ print(">", join_command(command))
return subprocess.run(command, env=env, **kwargs)
+# Format a command so it can be copied into a shell. Like shlex.join, but also
+# accepts arguments which are Paths, or a single string/Path outside of a list.
+def join_command(args):
+ if isinstance(args, (str, Path)):
+ return str(args)
+ else:
+ return shlex.join(map(str, args))
+
+
+# Format the environment so it can be pasted into a shell.
+def print_env(env):
+ for key, value in sorted(env.items()):
+ print(f"export {key}={shlex.quote(value)}")
+
+
+def android_env(host):
+ if host:
+ prefix = subdir(host) / "prefix"
+ else:
+ prefix = ANDROID_DIR / "prefix"
+ sysconfig_files = prefix.glob("lib/python*/_sysconfigdata__android_*.py")
+ sysconfig_filename = next(sysconfig_files).name
+ host = re.fullmatch(r"_sysconfigdata__android_(.+).py", sysconfig_filename)[1]
+
+ env_script = ANDROID_DIR / "android-env.sh"
+ env_output = subprocess.run(
+ f"set -eu; "
+ f"export HOST={host}; "
+ f"PREFIX={prefix}; "
+ f". {env_script}; "
+ f"export",
+ check=True, shell=True, capture_output=True, encoding='utf-8',
+ ).stdout
+
+ env = {}
+ for line in env_output.splitlines():
+ # We don't require every line to match, as there may be some other
+ # output from installing the NDK.
+ if match := re.search(
+ "^(declare -x |export )?(\\w+)=['\"]?(.*?)['\"]?$", line
+ ):
+ key, value = match[2], match[3]
+ if os.environ.get(key) != value:
+ env[key] = value
+
+ if not env:
+ raise ValueError(f"Found no variables in {env_script.name} output:\n"
+ + env_output)
+ return env
+
+
def build_python_path():
"""The path to the build Python binary."""
build_dir = subdir("build")
@@ -127,7 +160,7 @@ def configure_build_python(context):
clean("build")
os.chdir(subdir("build", create=True))
- command = [relpath(CHECKOUT / "configure")]
+ command = [relpath(PYTHON_DIR / "configure")]
if context.args:
command.extend(context.args)
run(command)
@@ -139,12 +172,13 @@ def make_build_python(context):
def unpack_deps(host, prefix_dir):
+ os.chdir(prefix_dir)
deps_url = "https://github.com/beeware/cpython-android-source-deps/releases/download"
- for name_ver in ["bzip2-1.0.8-2", "libffi-3.4.4-3", "openssl-3.0.15-4",
+ for name_ver in ["bzip2-1.0.8-3", "libffi-3.4.4-3", "openssl-3.0.15-4",
"sqlite-3.49.1-0", "xz-5.4.6-1"]:
filename = f"{name_ver}-{host}.tar.gz"
download(f"{deps_url}/{name_ver}/{filename}")
- shutil.unpack_archive(filename, prefix_dir)
+ shutil.unpack_archive(filename)
os.remove(filename)
@@ -167,7 +201,7 @@ def configure_host_python(context):
os.chdir(host_dir)
command = [
# Basic cross-compiling configuration
- relpath(CHECKOUT / "configure"),
+ relpath(PYTHON_DIR / "configure"),
f"--host={context.host}",
f"--build={sysconfig.get_config_var('BUILD_GNU_TYPE')}",
f"--with-build-python={build_python_path()}",
@@ -196,9 +230,12 @@ def make_host_python(context):
for pattern in ("include/python*", "lib/libpython*", "lib/python*"):
delete_glob(f"{prefix_dir}/{pattern}")
+ # The Android environment variables were already captured in the Makefile by
+ # `configure`, and passing them again when running `make` may cause some
+ # flags to be duplicated. So we don't use the `host` argument here.
os.chdir(host_dir)
- run(["make", "-j", str(os.cpu_count())], host=context.host)
- run(["make", "install", f"prefix={prefix_dir}"], host=context.host)
+ run(["make", "-j", str(os.cpu_count())])
+ run(["make", "install", f"prefix={prefix_dir}"])
def build_all(context):
@@ -228,7 +265,12 @@ def setup_sdk():
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)
+ run(
+ [sdkmanager, "--licenses"],
+ text=True,
+ capture_output=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.
@@ -474,24 +516,49 @@ async def gradle_task(context):
task_prefix = "connected"
env["ANDROID_SERIAL"] = context.connected
+ hidden_output = []
+
+ def log(line):
+ # 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)
+
+ if context.command:
+ mode = "-c"
+ module = context.command
+ else:
+ mode = "-m"
+ module = context.module or "test"
+
args = [
gradlew, "--console", "plain", f"{task_prefix}DebugAndroidTest",
- "-Pandroid.testInstrumentationRunnerArguments.pythonArgs="
- + shlex.join(context.args),
+ ] + [
+ # Build-time properties
+ f"-Ppython.{name}={value}"
+ for name, value in [
+ ("sitePackages", context.site_packages), ("cwd", context.cwd)
+ ] if value
+ ] + [
+ # Runtime properties
+ f"-Pandroid.testInstrumentationRunnerArguments.python{name}={value}"
+ for name, value in [
+ ("Mode", mode), ("Module", module), ("Args", join_command(context.args))
+ ] if value
]
- hidden_output = []
+ if context.verbose >= 2:
+ args.append("--info")
+ log("> " + join_command(args))
+
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)
+ log(line)
status = await wait_for(process.wait(), timeout=1)
if status == 0:
@@ -604,6 +671,10 @@ def package(context):
print(f"Wrote {package_path}")
+def env(context):
+ print_env(android_env(getattr(context, "host", None)))
+
+
# 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():
@@ -615,36 +686,41 @@ def install_signal_handler():
def parse_args():
parser = argparse.ArgumentParser()
- subcommands = parser.add_subparsers(dest="subcommand")
+ subcommands = parser.add_subparsers(dest="subcommand", required=True)
# Subcommands
- build = subcommands.add_parser("build", help="Build everything")
- configure_build = subcommands.add_parser("configure-build",
- help="Run `configure` for the "
- "build Python")
- make_build = subcommands.add_parser("make-build",
- help="Run `make` for the build Python")
- configure_host = subcommands.add_parser("configure-host",
- help="Run `configure` for Android")
- make_host = subcommands.add_parser("make-host",
- help="Run `make` for Android")
+ build = subcommands.add_parser(
+ "build", help="Run configure-build, make-build, configure-host and "
+ "make-host")
+ configure_build = subcommands.add_parser(
+ "configure-build", help="Run `configure` for the build Python")
subcommands.add_parser(
- "clean", help="Delete all build and prefix directories")
- subcommands.add_parser(
- "build-testbed", help="Build the testbed app")
- test = subcommands.add_parser(
- "test", help="Run the test suite")
+ "make-build", help="Run `make` for the build Python")
+ configure_host = subcommands.add_parser(
+ "configure-host", help="Run `configure` for Android")
+ make_host = subcommands.add_parser(
+ "make-host", help="Run `make` for Android")
+
+ subcommands.add_parser("clean", help="Delete all build directories")
+ subcommands.add_parser("build-testbed", help="Build the testbed app")
+ test = subcommands.add_parser("test", help="Run the testbed app")
package = subcommands.add_parser("package", help="Make a release package")
+ env = subcommands.add_parser("env", help="Print environment variables")
# Common arguments
for subcommand in build, configure_build, configure_host:
subcommand.add_argument(
"--clean", action="store_true", default=False, dest="clean",
- help="Delete the relevant build and prefix directories first")
- for subcommand in [build, configure_host, make_host, package]:
+ help="Delete the relevant build directories first")
+
+ host_commands = [build, configure_host, make_host, package]
+ if in_source_tree:
+ host_commands.append(env)
+ for subcommand in host_commands:
subcommand.add_argument(
"host", metavar="HOST", choices=HOSTS,
help="Host triplet: choices=[%(choices)s]")
+
for subcommand in build, configure_build, configure_host:
subcommand.add_argument("args", nargs="*",
help="Extra arguments to pass to `configure`")
@@ -654,6 +730,7 @@ def parse_args():
"-v", "--verbose", action="count", default=0,
help="Show Gradle output, and non-Python logcat messages. "
"Use twice to include high-volume messages which are rarely useful.")
+
device_group = test.add_mutually_exclusive_group(required=True)
device_group.add_argument(
"--connected", metavar="SERIAL", help="Run on a connected device. "
@@ -661,8 +738,24 @@ def parse_args():
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(
+ "--site-packages", metavar="DIR", type=abspath,
+ help="Directory to copy as the app's site-packages.")
test.add_argument(
- "args", nargs="*", help=f"Arguments for `python -m test`. "
+ "--cwd", metavar="DIR", type=abspath,
+ help="Directory to copy as the app's working directory.")
+
+ mode_group = test.add_mutually_exclusive_group()
+ mode_group.add_argument(
+ "-c", dest="command", help="Execute the given Python code.")
+ mode_group.add_argument(
+ "-m", dest="module", help="Execute the module with the given name.")
+ test.epilog = (
+ "If neither -c nor -m are passed, the default is '-m test', which will "
+ "run Python's own test suite.")
+ test.add_argument(
+ "args", nargs="*", help=f"Arguments to add to sys.argv. "
f"Separate them from {SCRIPT_NAME}'s own arguments with `--`.")
return parser.parse_args()
@@ -688,6 +781,7 @@ def main():
"build-testbed": build_testbed,
"test": run_testbed,
"package": package,
+ "env": env,
}
try:
@@ -708,14 +802,9 @@ def print_called_process_error(e):
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)
- )
+ # shlex uses single quotes, so we surround the command with double quotes.
print(
- f'Command "{args_joined}" returned exit status {e.returncode}'
+ f'Command "{join_command(e.cmd)}" returned exit status {e.returncode}'
)
diff --git a/Android/testbed/app/build.gradle.kts b/Android/testbed/app/build.gradle.kts
index c627cb1b0e0..92cffd61f86 100644
--- a/Android/testbed/app/build.gradle.kts
+++ b/Android/testbed/app/build.gradle.kts
@@ -85,7 +85,7 @@ android {
minSdk = androidEnvFile.useLines {
for (line in it) {
- """api_level:=(\d+)""".toRegex().find(line)?.let {
+ """ANDROID_API_LEVEL:=(\d+)""".toRegex().find(line)?.let {
return@useLines it.groupValues[1].toInt()
}
}
@@ -205,11 +205,29 @@ androidComponents.onVariants { variant ->
into("site-packages") {
from("$projectDir/src/main/python")
+
+ val sitePackages = findProperty("python.sitePackages") as String?
+ if (!sitePackages.isNullOrEmpty()) {
+ if (!file(sitePackages).exists()) {
+ throw GradleException("$sitePackages does not exist")
+ }
+ from(sitePackages)
+ }
}
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
exclude("**/__pycache__")
}
+
+ into("cwd") {
+ val cwd = findProperty("python.cwd") as String?
+ if (!cwd.isNullOrEmpty()) {
+ if (!file(cwd).exists()) {
+ throw GradleException("$cwd does not exist")
+ }
+ from(cwd)
+ }
+ }
}
}
diff --git a/Android/testbed/app/src/androidTest/java/org/python/testbed/PythonSuite.kt b/Android/testbed/app/src/androidTest/java/org/python/testbed/PythonSuite.kt
index 0e888ab71d8..94be52dd2dc 100644
--- a/Android/testbed/app/src/androidTest/java/org/python/testbed/PythonSuite.kt
+++ b/Android/testbed/app/src/androidTest/java/org/python/testbed/PythonSuite.kt
@@ -17,11 +17,11 @@ class PythonSuite {
fun testPython() {
val start = System.currentTimeMillis()
try {
- val context =
+ val status = PythonTestRunner(
InstrumentationRegistry.getInstrumentation().targetContext
- val args =
- InstrumentationRegistry.getArguments().getString("pythonArgs", "")
- val status = PythonTestRunner(context).run(args)
+ ).run(
+ InstrumentationRegistry.getArguments()
+ )
assertEquals(0, status)
} finally {
// Make sure the process lives long enough for the test script to
diff --git a/Android/testbed/app/src/main/java/org/python/testbed/MainActivity.kt b/Android/testbed/app/src/main/java/org/python/testbed/MainActivity.kt
index c4bf6cbe83d..ef28948486f 100644
--- a/Android/testbed/app/src/main/java/org/python/testbed/MainActivity.kt
+++ b/Android/testbed/app/src/main/java/org/python/testbed/MainActivity.kt
@@ -15,17 +15,29 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
- val status = PythonTestRunner(this).run("-W -uall")
+ val status = PythonTestRunner(this).run("-m", "test", "-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 {
+ fun run(instrumentationArgs: Bundle) = run(
+ instrumentationArgs.getString("pythonMode")!!,
+ instrumentationArgs.getString("pythonModule")!!,
+ instrumentationArgs.getString("pythonArgs") ?: "",
+ )
+
+ /** Run Python.
+ *
+ * @param mode Either "-c" or "-m".
+ * @param module Python statements for "-c" mode, or a module name for
+ * "-m" mode.
+ * @param args Arguments to add to sys.argv. Will be parsed by `shlex.split`.
+ * @return The Python exit status: zero on success, nonzero on failure. */
+ fun run(mode: String, module: String, args: String) : Int {
+ Os.setenv("PYTHON_MODE", mode, true)
+ Os.setenv("PYTHON_MODULE", module, true)
Os.setenv("PYTHON_ARGS", args, true)
// Python needs this variable to help it find the temporary directory,
@@ -36,8 +48,9 @@ class PythonTestRunner(val context: Context) {
System.loadLibrary("main_activity")
redirectStdioToLogcat()
- // The main module is in src/main/python/main.py.
- return runPython(pythonHome.toString(), "main")
+ // The main module is in src/main/python. We don't simply call it
+ // "main", as that could clash with third-party test code.
+ return runPython(pythonHome.toString(), "android_testbed_main")
}
private fun extractAssets() : File {
diff --git a/Android/testbed/app/src/main/python/main.py b/Android/testbed/app/src/main/python/android_testbed_main.py
index d6941b14412..31b8e5343a8 100644
--- a/Android/testbed/app/src/main/python/main.py
+++ b/Android/testbed/app/src/main/python/android_testbed_main.py
@@ -26,7 +26,23 @@ import sys
# test_signals in test_threadsignals.py.
signal.pthread_sigmask(signal.SIG_UNBLOCK, [signal.SIGUSR1])
+mode = os.environ["PYTHON_MODE"]
+module = os.environ["PYTHON_MODULE"]
sys.argv[1:] = shlex.split(os.environ["PYTHON_ARGS"])
-# The test module will call sys.exit to indicate whether the tests passed.
-runpy.run_module("test")
+cwd = f"{sys.prefix}/cwd"
+if not os.path.exists(cwd):
+ # Empty directories are lost in the asset packing/unpacking process.
+ os.mkdir(cwd)
+os.chdir(cwd)
+
+if mode == "-c":
+ # In -c mode, sys.path starts with an empty string, which means whatever the current
+ # working directory is at the moment of each import.
+ sys.path.insert(0, "")
+ exec(module, {})
+elif mode == "-m":
+ sys.path.insert(0, os.getcwd())
+ runpy.run_module(module, run_name="__main__", alter_sys=True)
+else:
+ raise ValueError(f"unknown mode: {mode}")
diff --git a/Android/testbed/build.gradle.kts b/Android/testbed/build.gradle.kts
index 4d1d6f87594..451517b3f1a 100644
--- a/Android/testbed/build.gradle.kts
+++ b/Android/testbed/build.gradle.kts
@@ -1,5 +1,5 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
- id("com.android.application") version "8.6.1" apply false
+ id("com.android.application") version "8.10.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
}
diff --git a/Android/testbed/gradle/wrapper/gradle-wrapper.properties b/Android/testbed/gradle/wrapper/gradle-wrapper.properties
index 36529c89642..5d42fbae084 100644
--- a/Android/testbed/gradle/wrapper/gradle-wrapper.properties
+++ b/Android/testbed/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
#Mon Feb 19 20:29:06 GMT 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists