[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:
Miss Islington (bot) 2024-08-16 10:36:46 +02:00 committed by GitHub
parent 0dd89a7f40
commit cf6d14b966
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 634 additions and 90 deletions

View file

@ -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!!) {

View file

@ -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)
}
}
}
}

View file

@ -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();
}

View file

@ -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)
}
private external fun runPython(home: String, runModule: String) : Int
}

View file

@ -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")

View file

@ -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.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
}
}

View file

@ -20,4 +20,9 @@ kotlin.code.style=official
# 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,
# 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

View file

@ -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.2-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists