Skip to content

Commit 2520eed

Browse files
authored
gh-116622: Add Android testbed (GH-117878)
Add code and config for a minimal Android app, and instructions to build and run it. Improve Android build instructions in general. Add a tool subcommand to download the Gradle wrapper (with its binary blob). Android studio must be downloaded manually (due to the license).
1 parent 21336aa commit 2520eed

19 files changed

+570
-10
lines changed

.github/CODEOWNERS

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,18 @@ Lib/test/support/interpreters/ @ericsnowcurrently
243243
Modules/_xx*interp*module.c @ericsnowcurrently
244244
Lib/test/test_interpreters/ @ericsnowcurrently
245245

246+
# Android
247+
**/*Android* @mhsmith
248+
**/*android* @mhsmith
249+
250+
# iOS (but not termios)
251+
**/iOS* @freakboy3742
252+
**/ios* @freakboy3742
253+
**/*_iOS* @freakboy3742
254+
**/*_ios* @freakboy3742
255+
**/*-iOS* @freakboy3742
256+
**/*-ios* @freakboy3742
257+
246258
# WebAssembly
247259
/Tools/wasm/ @brettcannon
248260

Android/README.md

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,25 @@ you don't already have the SDK, here's how to install it:
2222
`android-sdk/cmdline-tools/latest`.
2323
* `export ANDROID_HOME=/path/to/android-sdk`
2424

25+
The `android.py` script also requires the following commands to be on the `PATH`:
26+
27+
* `curl`
28+
* `java`
29+
* `tar`
30+
* `unzip`
31+
2532

2633
## Building
2734

28-
Building for Android requires doing a cross-build where you have a "build"
29-
Python to help produce an Android build of CPython. This procedure has been
30-
tested on Linux and macOS.
35+
Python can be built for Android on any POSIX platform supported by the Android
36+
development tools, which currently means Linux or macOS. This involves doing a
37+
cross-build where you use a "build" Python (for your development machine) to
38+
help produce a "host" Python for Android.
39+
40+
First, make sure you have all the usual tools and libraries needed to build
41+
Python for your development machine. The only Android tool you need to install
42+
is the command line tools package above: the build script will download the
43+
rest.
3144

3245
The easiest way to do a build is to use the `android.py` script. You can either
3346
have it perform the entire build process from start to finish in one step, or
@@ -43,9 +56,10 @@ The discrete steps for building via `android.py` are:
4356
./android.py make-host HOST
4457
```
4558

46-
To see the possible values of HOST, run `./android.py configure-host --help`.
59+
`HOST` identifies which architecture to build. To see the possible values, run
60+
`./android.py configure-host --help`.
4761

48-
Or to do it all in a single command, run:
62+
To do all steps in a single command, run:
4963

5064
```sh
5165
./android.py build HOST
@@ -62,3 +76,22 @@ call. For example, if you want a pydebug build that also caches the results from
6276
```sh
6377
./android.py build HOST -- -C --with-pydebug
6478
```
79+
80+
81+
## Testing
82+
83+
To run the Python test suite on Android:
84+
85+
* Install Android Studio, if you don't already have it.
86+
* Follow the instructions in the previous section to build all supported
87+
architectures.
88+
* Run `./android.py setup-testbed` to download the Gradle wrapper.
89+
* Open the `testbed` directory in Android Studio.
90+
* In the *Device Manager* dock, connect a device or start an emulator.
91+
Then select it from the drop-down list in the toolbar.
92+
* Click the "Run" button in the toolbar.
93+
* The testbed app displays nothing on screen while running. To see its output,
94+
open the [Logcat window](https://developer.android.com/studio/debug/logcat).
95+
96+
To run specific tests, or pass any other arguments to the test suite, edit the
97+
command line in testbed/app/src/main/python/main.py.

Android/android.py

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
import subprocess
88
import sys
99
import sysconfig
10-
from os.path import relpath
10+
from os.path import basename, relpath
1111
from pathlib import Path
12+
from tempfile import TemporaryDirectory
1213

1314
SCRIPT_NAME = Path(__file__).name
1415
CHECKOUT = Path(__file__).resolve().parent.parent
@@ -102,11 +103,17 @@ def unpack_deps(host):
102103
for name_ver in ["bzip2-1.0.8-1", "libffi-3.4.4-2", "openssl-3.0.13-1",
103104
"sqlite-3.45.1-0", "xz-5.4.6-0"]:
104105
filename = f"{name_ver}-{host}.tar.gz"
105-
run(["wget", f"{deps_url}/{name_ver}/{filename}"])
106+
download(f"{deps_url}/{name_ver}/{filename}")
106107
run(["tar", "-xf", filename])
107108
os.remove(filename)
108109

109110

111+
def download(url, target_dir="."):
112+
out_path = f"{target_dir}/{basename(url)}"
113+
run(["curl", "-Lf", "-o", out_path, url])
114+
return out_path
115+
116+
110117
def configure_host_python(context):
111118
host_dir = subdir(context.host, clean=context.clean)
112119

@@ -160,6 +167,30 @@ def clean_all(context):
160167
delete_if_exists(CROSS_BUILD_DIR)
161168

162169

170+
# To avoid distributing compiled artifacts without corresponding source code,
171+
# the Gradle wrapper is not included in the CPython repository. Instead, we
172+
# extract it from the Gradle release.
173+
def setup_testbed(context):
174+
ver_long = "8.7.0"
175+
ver_short = ver_long.removesuffix(".0")
176+
testbed_dir = CHECKOUT / "Android/testbed"
177+
178+
for filename in ["gradlew", "gradlew.bat"]:
179+
out_path = download(
180+
f"https://raw.githubusercontent.com/gradle/gradle/v{ver_long}/{filename}",
181+
testbed_dir)
182+
os.chmod(out_path, 0o755)
183+
184+
with TemporaryDirectory(prefix=SCRIPT_NAME) as temp_dir:
185+
os.chdir(temp_dir)
186+
bin_zip = download(
187+
f"https://services.gradle.org/distributions/gradle-{ver_short}-bin.zip")
188+
outer_jar = f"gradle-{ver_short}/lib/plugins/gradle-wrapper-{ver_short}.jar"
189+
run(["unzip", bin_zip, outer_jar])
190+
run(["unzip", "-o", "-d", f"{testbed_dir}/gradle/wrapper", outer_jar,
191+
"gradle-wrapper.jar"])
192+
193+
163194
def main():
164195
parser = argparse.ArgumentParser()
165196
subcommands = parser.add_subparsers(dest="subcommand")
@@ -173,8 +204,11 @@ def main():
173204
help="Run `configure` for Android")
174205
make_host = subcommands.add_parser("make-host",
175206
help="Run `make` for Android")
176-
clean = subcommands.add_parser("clean", help="Delete files and directories "
177-
"created by this script")
207+
subcommands.add_parser(
208+
"clean", help="Delete the cross-build directory")
209+
subcommands.add_parser(
210+
"setup-testbed", help="Download the testbed Gradle wrapper")
211+
178212
for subcommand in build, configure_build, configure_host:
179213
subcommand.add_argument(
180214
"--clean", action="store_true", default=False, dest="clean",
@@ -194,7 +228,8 @@ def main():
194228
"configure-host": configure_host_python,
195229
"make-host": make_host_python,
196230
"build": build_all,
197-
"clean": clean_all}
231+
"clean": clean_all,
232+
"setup-testbed": setup_testbed}
198233
dispatch[context.subcommand](context)
199234

200235

Android/testbed/.gitignore

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# The Gradle wrapper should be downloaded by running `../android.py setup-testbed`.
2+
/gradlew
3+
/gradlew.bat
4+
/gradle/wrapper/gradle-wrapper.jar
5+
6+
*.iml
7+
.gradle
8+
/local.properties
9+
/.idea/caches
10+
/.idea/deploymentTargetDropdown.xml
11+
/.idea/libraries
12+
/.idea/modules.xml
13+
/.idea/workspace.xml
14+
/.idea/navEditor.xml
15+
/.idea/assetWizardSettings.xml
16+
.DS_Store
17+
/build
18+
/captures
19+
.externalNativeBuild
20+
.cxx
21+
local.properties

Android/testbed/app/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/build

Android/testbed/app/build.gradle.kts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import com.android.build.api.variant.*
2+
3+
plugins {
4+
id("com.android.application")
5+
id("org.jetbrains.kotlin.android")
6+
}
7+
8+
val PYTHON_DIR = File(projectDir, "../../..").canonicalPath
9+
val PYTHON_CROSS_DIR = "$PYTHON_DIR/cross-build"
10+
val ABIS = mapOf(
11+
"arm64-v8a" to "aarch64-linux-android",
12+
"x86_64" to "x86_64-linux-android",
13+
)
14+
15+
val PYTHON_VERSION = File("$PYTHON_DIR/Include/patchlevel.h").useLines {
16+
for (line in it) {
17+
val match = """#define PY_VERSION\s+"(\d+\.\d+)""".toRegex().find(line)
18+
if (match != null) {
19+
return@useLines match.groupValues[1]
20+
}
21+
}
22+
throw GradleException("Failed to find Python version")
23+
}
24+
25+
26+
android {
27+
namespace = "org.python.testbed"
28+
compileSdk = 34
29+
30+
defaultConfig {
31+
applicationId = "org.python.testbed"
32+
minSdk = 21
33+
targetSdk = 34
34+
versionCode = 1
35+
versionName = "1.0"
36+
37+
ndk.abiFilters.addAll(ABIS.keys)
38+
externalNativeBuild.cmake.arguments(
39+
"-DPYTHON_CROSS_DIR=$PYTHON_CROSS_DIR",
40+
"-DPYTHON_VERSION=$PYTHON_VERSION")
41+
}
42+
43+
externalNativeBuild.cmake {
44+
path("src/main/c/CMakeLists.txt")
45+
}
46+
47+
// Set this property to something non-empty, otherwise it'll use the default
48+
// list, which ignores asset directories beginning with an underscore.
49+
aaptOptions.ignoreAssetsPattern = ".git"
50+
51+
compileOptions {
52+
sourceCompatibility = JavaVersion.VERSION_1_8
53+
targetCompatibility = JavaVersion.VERSION_1_8
54+
}
55+
kotlinOptions {
56+
jvmTarget = "1.8"
57+
}
58+
}
59+
60+
dependencies {
61+
implementation("androidx.appcompat:appcompat:1.6.1")
62+
implementation("com.google.android.material:material:1.11.0")
63+
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
64+
}
65+
66+
67+
// Create some custom tasks to copy Python and its standard library from
68+
// elsewhere in the repository.
69+
androidComponents.onVariants { variant ->
70+
generateTask(variant, variant.sources.assets!!) {
71+
into("python") {
72+
for (triplet in ABIS.values) {
73+
for (subDir in listOf("include", "lib")) {
74+
into(subDir) {
75+
from("$PYTHON_CROSS_DIR/$triplet/prefix/$subDir")
76+
include("python$PYTHON_VERSION/**")
77+
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
78+
}
79+
}
80+
}
81+
into("lib/python$PYTHON_VERSION") {
82+
// Uncomment this to pick up edits from the source directory
83+
// without having to rerun `make install`.
84+
// from("$PYTHON_DIR/Lib")
85+
// duplicatesStrategy = DuplicatesStrategy.INCLUDE
86+
87+
into("site-packages") {
88+
from("$projectDir/src/main/python")
89+
}
90+
}
91+
}
92+
exclude("**/__pycache__")
93+
}
94+
95+
generateTask(variant, variant.sources.jniLibs!!) {
96+
for ((abi, triplet) in ABIS.entries) {
97+
into(abi) {
98+
from("$PYTHON_CROSS_DIR/$triplet/prefix/lib")
99+
include("libpython*.*.so")
100+
include("lib*_python.so")
101+
}
102+
}
103+
}
104+
}
105+
106+
107+
fun generateTask(
108+
variant: ApplicationVariant, directories: SourceDirectories,
109+
configure: GenerateTask.() -> Unit
110+
) {
111+
val taskName = "generate" +
112+
listOf(variant.name, "Python", directories.name)
113+
.map { it.replaceFirstChar(Char::uppercase) }
114+
.joinToString("")
115+
116+
directories.addGeneratedSourceDirectory(
117+
tasks.register<GenerateTask>(taskName) {
118+
into(outputDir)
119+
configure()
120+
},
121+
GenerateTask::outputDir)
122+
}
123+
124+
125+
// addGeneratedSourceDirectory requires the task to have a DirectoryProperty.
126+
abstract class GenerateTask: Sync() {
127+
@get:OutputDirectory
128+
abstract val outputDir: DirectoryProperty
129+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3+
4+
<uses-permission android:name="android.permission.INTERNET"/>
5+
6+
<application
7+
android:icon="@drawable/ic_launcher"
8+
android:label="@string/app_name"
9+
android:theme="@style/Theme.Material3.Light.NoActionBar">
10+
<activity
11+
android:name=".MainActivity"
12+
android:exported="true">
13+
<intent-filter>
14+
<action android:name="android.intent.action.MAIN" />
15+
<category android:name="android.intent.category.LAUNCHER" />
16+
</intent-filter>
17+
</activity>
18+
</application>
19+
20+
</manifest>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
cmake_minimum_required(VERSION 3.4.1)
2+
project(testbed)
3+
4+
set(PREFIX_DIR ${PYTHON_CROSS_DIR}/${CMAKE_LIBRARY_ARCHITECTURE}/prefix)
5+
include_directories(${PREFIX_DIR}/include/python${PYTHON_VERSION})
6+
link_directories(${PREFIX_DIR}/lib)
7+
link_libraries(log python${PYTHON_VERSION})
8+
9+
add_library(main_activity SHARED main_activity.c)

0 commit comments

Comments
 (0)