This commit is contained in:
timrid 2026-05-04 05:34:29 +08:00 committed by GitHub
commit c34ed06c86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 145 additions and 8 deletions

View file

@ -0,0 +1 @@
The Android testbed can now copy files back from the emulator to the build machine.

View file

@ -161,6 +161,10 @@ ### Testing a third-party package
directory.
* `--site-packages`: the directory to copy into the testbed app to use as site
packages.
* `--pull`: if specified, the testbed app will pull the file or folder from the device
back to the build machine after the test run. This is useful for retrieving coverage
data, for example. Can be used multiple times.
* `--output-dir`: the directory on the build machine to which files will be pulled.
Extra arguments on the `android.py test` command line will be passed through to
Python use `--` to separate them from `android.py`'s own options. You must include

View file

@ -12,6 +12,7 @@
import subprocess
import sys
import sysconfig
import zipfile
from asyncio import wait_for
from contextlib import asynccontextmanager
from datetime import datetime, timezone
@ -635,6 +636,14 @@ def stop_app(serial):
run([adb, "-s", serial, "shell", "am", "force-stop", APP_ID], log=False)
def _extract_output_archives(output_dir):
"""Extract all zip archives written by PythonSuite.kt and delete them."""
for zip_path in Path(output_dir).glob("*.zip"):
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(output_dir)
zip_path.unlink()
async def gradle_task(context):
env = os.environ.copy()
if context.managed:
@ -669,10 +678,18 @@ async def gradle_task(context):
for name, value in [
("python.sitePackages", context.site_packages),
("python.cwd", context.cwd),
(
"python.outputDir",
context.output_dir
),
(
"android.testInstrumentationRunnerArguments.pythonArgs",
json.dumps(context.args),
),
(
"android.testInstrumentationRunnerArguments.pythonPull",
json.dumps(context.pull) if context.pull else None,
),
]
if value
]
@ -695,6 +712,8 @@ async def gradle_task(context):
status = await wait_for(process.wait(), timeout=1)
if status == 0:
if context.pull and context.output_dir:
_extract_output_archives(Path(context.output_dir))
exit(0)
else:
raise CalledProcessError(status, args)
@ -705,6 +724,9 @@ async def gradle_task(context):
async def run_testbed(context):
if context.pull and not context.output_dir:
sys.exit("--output-dir is required when --pull is used.")
setup_ci()
setup_sdk()
setup_testbed()
@ -975,6 +997,15 @@ def add_parser(*args, **kwargs):
test.add_argument(
"--cwd", metavar="DIR", type=abspath,
help="Directory to copy as the app's working directory.")
test.add_argument(
"--pull", metavar="PATH", action="append", default=[],
help="File or directory to copy from the app's working directory back "
"to the host after the test run. Paths are relative to --cwd on the "
"device. May be given multiple times.")
test.add_argument(
"--output-dir", metavar="DIR", type=abspath,
help="Local directory to write files pulled via --pull. "
"Required when --pull is used.")
test.add_argument(
"args", nargs="*", help=f"Python command-line arguments. "
f"Separate them from {SCRIPT_NAME}'s own arguments with `--`. "

View file

@ -180,12 +180,18 @@ android {
}
}
// 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") {
(localDevices.names + listOf("connected")).forEach { deviceName ->
val copyOutputTask = createCopyOutputTask(deviceName)
tasks.named("${deviceName}DebugAndroidTest") {
// If the previous test run succeeded and nothing has changed,
// Gradle thinks there's no need to run it again. Override that.
outputs.upToDateWhen { false }
// If python.outputDir is set, copy all files that are pulled
// from the emulator to the host by the UTP to the given output
// directory on the host.
copyOutputTask?.let { finalizedBy(it) }
}
}
}
@ -334,6 +340,43 @@ abstract class CreateEmulatorTask : DefaultTask() {
}
fun createCopyOutputTask(deviceName: String): TaskProvider<Copy>? {
val outputDir = findProperty("python.outputDir") as String?
if (outputDir.isNullOrEmpty()) return null
val additionalOutputPath = if (deviceName == "connected") {
"outputs/connected_android_test_additional_output"
} else {
"outputs/managed_device_android_test_additional_output"
}
// PythonSuite.kt packs all output files into a single zip archive,
// to avoid issues because the UTP copy skips dotfiles like ".coverage".
val archiveName = "org.python.testbed-output.zip"
return tasks.register<Copy>("${deviceName}CopyTestOutput") {
from(layout.buildDirectory.dir(additionalOutputPath))
// The subfolders of `connected_android_test_additional_output` contains
// names that are not equal to the serial of the device.
// The subfolders of `managed_device_android_test_additional_output` are
// also unpredictable, because e.g. the subfolder for the "maxVersion" emulator
// is named "minVersion".
// So we can't rely on the subfolder names and search for the archive in
// all subfolders. The archive should be in exactly one of the subfolders.
include("**/$archiveName")
into(outputDir)
// Flatten: drop any device-subfolder prefix, put the zip
// directly in outputDir.
eachFile { path = name }
includeEmptyDirs = false
// Each android.py invocation runs only one device at a time,
// so there should never be more than one archive. Fail loudly
// if that assumption is violated.
duplicatesStrategy = DuplicatesStrategy.FAIL
}
}
// Create some custom tasks to copy Python and its standard library from
// elsewhere in the repository.
androidComponents.onVariants { variant ->

View file

@ -1,29 +1,39 @@
package org.python.testbed
import android.content.Context
import android.os.Bundle
import androidx.test.annotation.UiThreadTest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.json.JSONArray
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
import java.io.File
@RunWith(AndroidJUnit4::class)
class PythonSuite {
@Test
@UiThreadTest
fun testPython() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val args = InstrumentationRegistry.getArguments()
val start = System.currentTimeMillis()
try {
val status = PythonTestRunner(
InstrumentationRegistry.getInstrumentation().targetContext
).run(
InstrumentationRegistry.getArguments().getString("pythonArgs")!!,
val status = PythonTestRunner(instrumentation.targetContext).run(
args.getString("pythonArgs")!!,
)
assertEquals(0, status)
} finally {
// Copy files requested via --pull to the directory that AGP/UTP
// injected as `additionalTestOutputDir`. AGP will pull everything
// written there back to the host before shutting down the emulator.
copyOutputFiles(instrumentation.targetContext, args)
// 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)
@ -32,4 +42,52 @@ class PythonSuite {
}
}
}
private fun copyOutputFiles(context: Context, args: Bundle) {
// A list of file paths (relative to the Python working directory) that should be
// copied back to the host after the test finishes.
val pullPathsJson = args.getString("pythonPull") ?: return
// The output directory is created by AGP/UTP and points to a location inside
// the emulator's filesystem. AGP/UTP will pull everything from there back
// to the host after the test finishes.
val outputDir = args.getString("additionalTestOutputDir") ?: return
// Pack all files into a single zip archive to avoid issues because the UTP copy
// skips dotfiles like ".coverage".
val archiveFile = File(outputDir, "org.python.testbed-output.zip")
val srcBase = File(context.filesDir, "python/cwd")
val paths = JSONArray(pullPathsJson)
java.util.zip.ZipOutputStream(archiveFile.outputStream().buffered()).use { zip ->
for (i in 0 until paths.length()) {
val src = File(srcBase, paths.getString(i))
if (!src.exists()) {
android.util.Log.w("python.stderr", "Pull path not found: $src\n")
continue
}
try {
addToZip(zip, src, src.name)
} catch (e: Exception) {
android.util.Log.e("python.stderr", "Failed to zip $src: $e\n")
}
}
}
android.util.Log.i("python.stdout", "Created output archive: $archiveFile\n")
}
private fun addToZip(
zip: java.util.zip.ZipOutputStream,
file: File,
entryName: String,
) {
if (file.isDirectory) {
for (child in file.listFiles() ?: emptyArray()) {
addToZip(zip, child, "$entryName/${child.name}")
}
} else {
zip.putNextEntry(java.util.zip.ZipEntry(entryName))
file.inputStream().use { it.copyTo(zip) }
zip.closeEntry()
}
}
}