mirror of
https://github.com/python/cpython.git
synced 2025-09-26 10:19:53 +00:00
[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
14 changed files with 634 additions and 90 deletions
|
@ -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!!) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue