Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The Android testbed can now copy files back from the emulator to the build machine.
4 changes: 4 additions & 0 deletions Platforms/Android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions Platforms/Android/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
]
Expand All @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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 `--`. "
Expand Down
51 changes: 47 additions & 4 deletions Platforms/Android/testbed/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
}
}
Expand Down Expand Up @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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()
}
}
}
Loading