From 19f78a6278de03f3700b71573d1b65d128c098f9 Mon Sep 17 00:00:00 2001 From: timrid <6593626+timrid@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:00:29 +0200 Subject: [PATCH 1/2] Enhance Android test framework: add --pull and --output-dir options for file management --- Platforms/Android/README.md | 4 ++ Platforms/Android/__main__.py | 31 +++++++++ .../Android/testbed/app/build.gradle.kts | 51 ++++++++++++-- .../java/org/python/testbed/PythonSuite.kt | 66 +++++++++++++++++-- 4 files changed, 144 insertions(+), 8 deletions(-) diff --git a/Platforms/Android/README.md b/Platforms/Android/README.md index d6f95c365c63a0..3c859c3a514cd9 100644 --- a/Platforms/Android/README.md +++ b/Platforms/Android/README.md @@ -161,6 +161,10 @@ configuring the execution of a third-party test suite: 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 diff --git a/Platforms/Android/__main__.py b/Platforms/Android/__main__.py index 315632ea12c07d..05ec24a43dc566 100755 --- a/Platforms/Android/__main__.py +++ b/Platforms/Android/__main__.py @@ -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 @@ -629,6 +630,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: @@ -663,10 +672,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 ] @@ -689,6 +706,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) @@ -699,6 +718,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() @@ -969,6 +991,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 `--`. " diff --git a/Platforms/Android/testbed/app/build.gradle.kts b/Platforms/Android/testbed/app/build.gradle.kts index e51398fce81e26..00e9c688abff65 100644 --- a/Platforms/Android/testbed/app/build.gradle.kts +++ b/Platforms/Android/testbed/app/build.gradle.kts @@ -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? { + 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("${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 -> diff --git a/Platforms/Android/testbed/app/src/androidTest/java/org/python/testbed/PythonSuite.kt b/Platforms/Android/testbed/app/src/androidTest/java/org/python/testbed/PythonSuite.kt index e57243566f91dc..902344b844f6a8 100644 --- a/Platforms/Android/testbed/app/src/androidTest/java/org/python/testbed/PythonSuite.kt +++ b/Platforms/Android/testbed/app/src/androidTest/java/org/python/testbed/PythonSuite.kt @@ -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() + } + } } From 231c3a82b3892240008ab7ff11a3d63962fed328 Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Sat, 2 May 2026 10:19:14 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=9C=F0=9F=A4=96=20Added=20by=20blu?= =?UTF-8?q?rb=5Fit.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../next/Tests/2026-05-02-10-19-13.gh-issue-149113.wwDoqp.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Tests/2026-05-02-10-19-13.gh-issue-149113.wwDoqp.rst diff --git a/Misc/NEWS.d/next/Tests/2026-05-02-10-19-13.gh-issue-149113.wwDoqp.rst b/Misc/NEWS.d/next/Tests/2026-05-02-10-19-13.gh-issue-149113.wwDoqp.rst new file mode 100644 index 00000000000000..23fdcc31a398d0 --- /dev/null +++ b/Misc/NEWS.d/next/Tests/2026-05-02-10-19-13.gh-issue-149113.wwDoqp.rst @@ -0,0 +1 @@ +The Android testbed can now copy files back from the emulator to the build machine.