summaryrefslogtreecommitdiffstats
path: root/Android/testbed/app
diff options
context:
space:
mode:
authorMalcolm Smith <smith@chaquo.com>2024-08-16 05:00:29 (GMT)
committerGitHub <noreply@github.com>2024-08-16 05:00:29 (GMT)
commitf84cce6f2588c6437d69a30856d7c4ba00b70ae0 (patch)
treee1a99f5e59aa7588c9d548217bfd8fdd47384ccd /Android/testbed/app
parente913d2c87f1ae4e7a4aef5ba78368ef31d060767 (diff)
downloadcpython-f84cce6f2588c6437d69a30856d7c4ba00b70ae0.zip
cpython-f84cce6f2588c6437d69a30856d7c4ba00b70ae0.tar.gz
cpython-f84cce6f2588c6437d69a30856d7c4ba00b70ae0.tar.bz2
gh-116622: Add Android test script (#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.
Diffstat (limited to 'Android/testbed/app')
-rw-r--r--Android/testbed/app/build.gradle.kts85
-rw-r--r--Android/testbed/app/src/androidTest/java/org/python/testbed/PythonSuite.kt35
-rw-r--r--Android/testbed/app/src/main/c/main_activity.c12
-rw-r--r--Android/testbed/app/src/main/java/org/python/testbed/MainActivity.kt36
-rw-r--r--Android/testbed/app/src/main/python/main.py11
5 files changed, 142 insertions, 37 deletions
diff --git a/Android/testbed/app/build.gradle.kts b/Android/testbed/app/build.gradle.kts
index 7320b21..7e0bef5 100644
--- a/Android/testbed/app/build.gradle.kts
+++ b/Android/testbed/app/build.gradle.kts
@@ -1,17 +1,18 @@
import com.android.build.api.variant.*
+import kotlin.math.max
plugins {
id("com.android.application")
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 ABIS = mapOf(
"arm64-v8a" to "aarch64-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()) {
throw GradleException(
"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) {
val match = """#define PY_VERSION\s+"(\d+\.\d+)""".toRegex().find(line)
if (match != null) {
@@ -29,6 +30,16 @@ val PYTHON_VERSION = File("$PYTHON_DIR/Include/patchlevel.h").useLines {
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 {
namespace = "org.python.testbed"
@@ -45,6 +56,8 @@ android {
externalNativeBuild.cmake.arguments(
"-DPYTHON_CROSS_DIR=$PYTHON_CROSS_DIR",
"-DPYTHON_VERSION=$PYTHON_VERSION")
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
externalNativeBuild.cmake {
@@ -62,41 +75,81 @@ android {
kotlinOptions {
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 {
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
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
// elsewhere in the repository.
androidComponents.onVariants { variant ->
+ val pyPlusVer = "python$PYTHON_VERSION"
generateTask(variant, variant.sources.assets!!) {
into("python") {
- for (triplet in ABIS.values) {
- for (subDir in listOf("include", "lib")) {
- into(subDir) {
- from("$PYTHON_CROSS_DIR/$triplet/prefix/$subDir")
- include("python$PYTHON_VERSION/**")
- duplicatesStrategy = DuplicatesStrategy.EXCLUDE
- }
+ into("include/$pyPlusVer") {
+ for (triplet in ABIS.values) {
+ from("$PYTHON_CROSS_DIR/$triplet/prefix/include/$pyPlusVer")
}
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
- into("lib/python$PYTHON_VERSION") {
- // Uncomment this to pick up edits from the source directory
- // without having to rerun `make install`.
- // from("$PYTHON_DIR/Lib")
- // duplicatesStrategy = DuplicatesStrategy.INCLUDE
+
+ into("lib/$pyPlusVer") {
+ // To aid debugging, the source directory takes priority.
+ from("$PYTHON_DIR/Lib")
+
+ // 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") {
from("$projectDir/src/main/python")
}
+
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+ exclude("**/__pycache__")
}
}
- exclude("**/__pycache__")
}
generateTask(variant, variant.sources.jniLibs!!) {
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
new file mode 100644
index 0000000..0e888ab
--- /dev/null
+++ b/Android/testbed/app/src/androidTest/java/org/python/testbed/PythonSuite.kt
@@ -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)
+ }
+ }
+ }
+}
diff --git a/Android/testbed/app/src/main/c/main_activity.c b/Android/testbed/app/src/main/c/main_activity.c
index 73aba41..5347090 100644
--- a/Android/testbed/app/src/main/c/main_activity.c
+++ b/Android/testbed/app/src/main/c/main_activity.c
@@ -84,7 +84,7 @@ static char *redirect_stream(StreamInfo *si) {
return 0;
}
-JNIEXPORT void JNICALL Java_org_python_testbed_MainActivity_redirectStdioToLogcat(
+JNIEXPORT void JNICALL Java_org_python_testbed_PythonTestRunner_redirectStdioToLogcat(
JNIEnv *env, jobject obj
) {
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 : "");
}
-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
) {
PyConfig config;
@@ -125,13 +125,13 @@ JNIEXPORT void JNICALL Java_org_python_testbed_MainActivity_runPython(
status = set_config_string(env, &config, &config.home, home);
if (PyStatus_Exception(status)) {
throw_status(env, status);
- return;
+ return 1;
}
status = set_config_string(env, &config, &config.run_module, runModule);
if (PyStatus_Exception(status)) {
throw_status(env, status);
- return;
+ return 1;
}
// 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);
if (PyStatus_Exception(status)) {
throw_status(env, status);
- return;
+ return 1;
}
- Py_RunMain();
+ return Py_RunMain();
}
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 5a590d5..c4bf6cb 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
@@ -1,38 +1,56 @@
package org.python.testbed
+import android.content.Context
import android.os.*
import android.system.Os
import android.widget.TextView
import androidx.appcompat.app.*
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() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
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,
// 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()
System.loadLibrary("main_activity")
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 {
- val pythonHome = File(filesDir, "python")
+ val pythonHome = File(context.filesDir, "python")
if (pythonHome.exists() && !pythonHome.deleteRecursively()) {
throw RuntimeException("Failed to delete $pythonHome")
}
- extractAssetDir("python", filesDir)
+ extractAssetDir("python", context.filesDir)
return pythonHome
}
private fun extractAssetDir(path: String, targetDir: File) {
- val names = assets.list(path)
+ val names = context.assets.list(path)
?: throw RuntimeException("Failed to list $path")
val targetSubdir = File(targetDir, path)
if (!targetSubdir.mkdirs()) {
@@ -43,7 +61,7 @@ class MainActivity : AppCompatActivity() {
val subPath = "$path/$name"
val input: InputStream
try {
- input = assets.open(subPath)
+ input = context.assets.open(subPath)
} catch (e: FileNotFoundException) {
extractAssetDir(subPath, targetDir)
continue
@@ -57,5 +75,5 @@ class MainActivity : AppCompatActivity() {
}
private external fun redirectStdioToLogcat()
- private external fun runPython(home: String, runModule: String)
-} \ No newline at end of file
+ private external fun runPython(home: String, runModule: String) : Int
+}
diff --git a/Android/testbed/app/src/main/python/main.py b/Android/testbed/app/src/main/python/main.py
index a1b6def..c7314b5 100644
--- a/Android/testbed/app/src/main/python/main.py
+++ b/Android/testbed/app/src/main/python/main.py
@@ -1,4 +1,6 @@
+import os
import runpy
+import shlex
import signal
import sys
@@ -8,10 +10,7 @@ import sys
# profile save"), so disabling it should not weaken the tests.
signal.pthread_sigmask(signal.SIG_UNBLOCK, [signal.SIGUSR1])
-# To run specific tests, or pass any other arguments to the test suite, edit
-# this command line.
-sys.argv[1:] = [
- "--use", "all,-cpu",
- "--verbose3",
-]
+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")