gh-131531: android.py enhancements to support cibuildwheel (#132870)
Modifies the environment handling and execution arguments of the Android management script to support the compilation of third-party binaries, and the use of the testbed to invoke third-party test code. Co-authored-by: Adam Turner <9087854+AA-Turner@users.noreply.github.com> Co-authored-by: Russell Keith-Magee <russell@keith-magee.com>
This commit is contained in:
parent
6b77af257c
commit
2e1544fd2b
@ -156,6 +156,10 @@ repository's `Lib` directory will be picked up immediately. Changes in C files,
|
|||||||
and architecture-specific files such as sysconfigdata, will not take effect
|
and architecture-specific files such as sysconfigdata, will not take effect
|
||||||
until you re-run `android.py make-host` or `build`.
|
until you re-run `android.py make-host` or `build`.
|
||||||
|
|
||||||
|
The testbed app can also be used to test third-party packages. For more details,
|
||||||
|
run `android.py test --help`, paying attention to the options `--site-packages`,
|
||||||
|
`--cwd`, `-c` and `-m`.
|
||||||
|
|
||||||
|
|
||||||
## Using in your own app
|
## Using in your own app
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
: "${HOST:?}" # GNU target triplet
|
: "${HOST:?}" # GNU target triplet
|
||||||
|
|
||||||
# You may also override the following:
|
# You may also override the following:
|
||||||
: "${api_level:=24}" # Minimum Android API level the build will run on
|
: "${ANDROID_API_LEVEL:=24}" # Minimum Android API level the build will run on
|
||||||
: "${PREFIX:-}" # Path in which to find required libraries
|
: "${PREFIX:-}" # Path in which to find required libraries
|
||||||
|
|
||||||
|
|
||||||
@ -24,7 +24,7 @@ fail() {
|
|||||||
# * https://android.googlesource.com/platform/ndk/+/ndk-rXX-release/docs/BuildSystemMaintainers.md
|
# * https://android.googlesource.com/platform/ndk/+/ndk-rXX-release/docs/BuildSystemMaintainers.md
|
||||||
# where XX is the NDK version. Do a diff against the version you're upgrading from, e.g.:
|
# where XX is the NDK version. Do a diff against the version you're upgrading from, e.g.:
|
||||||
# https://android.googlesource.com/platform/ndk/+/ndk-r25-release..ndk-r26-release/docs/BuildSystemMaintainers.md
|
# https://android.googlesource.com/platform/ndk/+/ndk-r25-release..ndk-r26-release/docs/BuildSystemMaintainers.md
|
||||||
ndk_version=27.1.12297006
|
ndk_version=27.2.12479018
|
||||||
|
|
||||||
ndk=$ANDROID_HOME/ndk/$ndk_version
|
ndk=$ANDROID_HOME/ndk/$ndk_version
|
||||||
if ! [ -e "$ndk" ]; then
|
if ! [ -e "$ndk" ]; then
|
||||||
@ -43,7 +43,7 @@ fi
|
|||||||
toolchain=$(echo "$ndk"/toolchains/llvm/prebuilt/*)
|
toolchain=$(echo "$ndk"/toolchains/llvm/prebuilt/*)
|
||||||
export AR="$toolchain/bin/llvm-ar"
|
export AR="$toolchain/bin/llvm-ar"
|
||||||
export AS="$toolchain/bin/llvm-as"
|
export AS="$toolchain/bin/llvm-as"
|
||||||
export CC="$toolchain/bin/${clang_triplet}${api_level}-clang"
|
export CC="$toolchain/bin/${clang_triplet}${ANDROID_API_LEVEL}-clang"
|
||||||
export CXX="${CC}++"
|
export CXX="${CC}++"
|
||||||
export LD="$toolchain/bin/ld"
|
export LD="$toolchain/bin/ld"
|
||||||
export NM="$toolchain/bin/llvm-nm"
|
export NM="$toolchain/bin/llvm-nm"
|
||||||
|
@ -14,7 +14,7 @@ from asyncio import wait_for
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from glob import glob
|
from glob import glob
|
||||||
from os.path import basename, relpath
|
from os.path import abspath, basename, relpath
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from subprocess import CalledProcessError
|
from subprocess import CalledProcessError
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
@ -22,9 +22,13 @@ from tempfile import TemporaryDirectory
|
|||||||
|
|
||||||
SCRIPT_NAME = Path(__file__).name
|
SCRIPT_NAME = Path(__file__).name
|
||||||
ANDROID_DIR = Path(__file__).resolve().parent
|
ANDROID_DIR = Path(__file__).resolve().parent
|
||||||
CHECKOUT = ANDROID_DIR.parent
|
PYTHON_DIR = ANDROID_DIR.parent
|
||||||
|
in_source_tree = (
|
||||||
|
ANDROID_DIR.name == "Android" and (PYTHON_DIR / "pyconfig.h.in").exists()
|
||||||
|
)
|
||||||
|
|
||||||
TESTBED_DIR = ANDROID_DIR / "testbed"
|
TESTBED_DIR = ANDROID_DIR / "testbed"
|
||||||
CROSS_BUILD_DIR = CHECKOUT / "cross-build"
|
CROSS_BUILD_DIR = PYTHON_DIR / "cross-build"
|
||||||
|
|
||||||
HOSTS = ["aarch64-linux-android", "x86_64-linux-android"]
|
HOSTS = ["aarch64-linux-android", "x86_64-linux-android"]
|
||||||
APP_ID = "org.python.testbed"
|
APP_ID = "org.python.testbed"
|
||||||
@ -76,39 +80,68 @@ def run(command, *, host=None, env=None, log=True, **kwargs):
|
|||||||
kwargs.setdefault("check", True)
|
kwargs.setdefault("check", True)
|
||||||
if env is None:
|
if env is None:
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
original_env = env.copy()
|
|
||||||
|
|
||||||
if host:
|
if host:
|
||||||
env_script = ANDROID_DIR / "android-env.sh"
|
host_env = android_env(host)
|
||||||
env_output = subprocess.run(
|
print_env(host_env)
|
||||||
f"set -eu; "
|
env.update(host_env)
|
||||||
f"HOST={host}; "
|
|
||||||
f"PREFIX={subdir(host)}/prefix; "
|
|
||||||
f". {env_script}; "
|
|
||||||
f"export",
|
|
||||||
check=True, shell=True, text=True, stdout=subprocess.PIPE
|
|
||||||
).stdout
|
|
||||||
|
|
||||||
for line in env_output.splitlines():
|
|
||||||
# We don't require every line to match, as there may be some other
|
|
||||||
# output from installing the NDK.
|
|
||||||
if match := re.search(
|
|
||||||
"^(declare -x |export )?(\\w+)=['\"]?(.*?)['\"]?$", line
|
|
||||||
):
|
|
||||||
key, value = match[2], match[3]
|
|
||||||
if env.get(key) != value:
|
|
||||||
print(line)
|
|
||||||
env[key] = value
|
|
||||||
|
|
||||||
if env == original_env:
|
|
||||||
raise ValueError(f"Found no variables in {env_script.name} output:\n"
|
|
||||||
+ env_output)
|
|
||||||
|
|
||||||
if log:
|
if log:
|
||||||
print(">", " ".join(map(str, command)))
|
print(">", join_command(command))
|
||||||
return subprocess.run(command, env=env, **kwargs)
|
return subprocess.run(command, env=env, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# Format a command so it can be copied into a shell. Like shlex.join, but also
|
||||||
|
# accepts arguments which are Paths, or a single string/Path outside of a list.
|
||||||
|
def join_command(args):
|
||||||
|
if isinstance(args, (str, Path)):
|
||||||
|
return str(args)
|
||||||
|
else:
|
||||||
|
return shlex.join(map(str, args))
|
||||||
|
|
||||||
|
|
||||||
|
# Format the environment so it can be pasted into a shell.
|
||||||
|
def print_env(env):
|
||||||
|
for key, value in sorted(env.items()):
|
||||||
|
print(f"export {key}={shlex.quote(value)}")
|
||||||
|
|
||||||
|
|
||||||
|
def android_env(host):
|
||||||
|
if host:
|
||||||
|
prefix = subdir(host) / "prefix"
|
||||||
|
else:
|
||||||
|
prefix = ANDROID_DIR / "prefix"
|
||||||
|
sysconfig_files = prefix.glob("lib/python*/_sysconfigdata__android_*.py")
|
||||||
|
sysconfig_filename = next(sysconfig_files).name
|
||||||
|
host = re.fullmatch(r"_sysconfigdata__android_(.+).py", sysconfig_filename)[1]
|
||||||
|
|
||||||
|
env_script = ANDROID_DIR / "android-env.sh"
|
||||||
|
env_output = subprocess.run(
|
||||||
|
f"set -eu; "
|
||||||
|
f"export HOST={host}; "
|
||||||
|
f"PREFIX={prefix}; "
|
||||||
|
f". {env_script}; "
|
||||||
|
f"export",
|
||||||
|
check=True, shell=True, capture_output=True, encoding='utf-8',
|
||||||
|
).stdout
|
||||||
|
|
||||||
|
env = {}
|
||||||
|
for line in env_output.splitlines():
|
||||||
|
# We don't require every line to match, as there may be some other
|
||||||
|
# output from installing the NDK.
|
||||||
|
if match := re.search(
|
||||||
|
"^(declare -x |export )?(\\w+)=['\"]?(.*?)['\"]?$", line
|
||||||
|
):
|
||||||
|
key, value = match[2], match[3]
|
||||||
|
if os.environ.get(key) != value:
|
||||||
|
env[key] = value
|
||||||
|
|
||||||
|
if not env:
|
||||||
|
raise ValueError(f"Found no variables in {env_script.name} output:\n"
|
||||||
|
+ env_output)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
def build_python_path():
|
def build_python_path():
|
||||||
"""The path to the build Python binary."""
|
"""The path to the build Python binary."""
|
||||||
build_dir = subdir("build")
|
build_dir = subdir("build")
|
||||||
@ -127,7 +160,7 @@ def configure_build_python(context):
|
|||||||
clean("build")
|
clean("build")
|
||||||
os.chdir(subdir("build", create=True))
|
os.chdir(subdir("build", create=True))
|
||||||
|
|
||||||
command = [relpath(CHECKOUT / "configure")]
|
command = [relpath(PYTHON_DIR / "configure")]
|
||||||
if context.args:
|
if context.args:
|
||||||
command.extend(context.args)
|
command.extend(context.args)
|
||||||
run(command)
|
run(command)
|
||||||
@ -139,12 +172,13 @@ def make_build_python(context):
|
|||||||
|
|
||||||
|
|
||||||
def unpack_deps(host, prefix_dir):
|
def unpack_deps(host, prefix_dir):
|
||||||
|
os.chdir(prefix_dir)
|
||||||
deps_url = "https://github.com/beeware/cpython-android-source-deps/releases/download"
|
deps_url = "https://github.com/beeware/cpython-android-source-deps/releases/download"
|
||||||
for name_ver in ["bzip2-1.0.8-2", "libffi-3.4.4-3", "openssl-3.0.15-4",
|
for name_ver in ["bzip2-1.0.8-3", "libffi-3.4.4-3", "openssl-3.0.15-4",
|
||||||
"sqlite-3.49.1-0", "xz-5.4.6-1"]:
|
"sqlite-3.49.1-0", "xz-5.4.6-1"]:
|
||||||
filename = f"{name_ver}-{host}.tar.gz"
|
filename = f"{name_ver}-{host}.tar.gz"
|
||||||
download(f"{deps_url}/{name_ver}/{filename}")
|
download(f"{deps_url}/{name_ver}/{filename}")
|
||||||
shutil.unpack_archive(filename, prefix_dir)
|
shutil.unpack_archive(filename)
|
||||||
os.remove(filename)
|
os.remove(filename)
|
||||||
|
|
||||||
|
|
||||||
@ -167,7 +201,7 @@ def configure_host_python(context):
|
|||||||
os.chdir(host_dir)
|
os.chdir(host_dir)
|
||||||
command = [
|
command = [
|
||||||
# Basic cross-compiling configuration
|
# Basic cross-compiling configuration
|
||||||
relpath(CHECKOUT / "configure"),
|
relpath(PYTHON_DIR / "configure"),
|
||||||
f"--host={context.host}",
|
f"--host={context.host}",
|
||||||
f"--build={sysconfig.get_config_var('BUILD_GNU_TYPE')}",
|
f"--build={sysconfig.get_config_var('BUILD_GNU_TYPE')}",
|
||||||
f"--with-build-python={build_python_path()}",
|
f"--with-build-python={build_python_path()}",
|
||||||
@ -196,9 +230,12 @@ def make_host_python(context):
|
|||||||
for pattern in ("include/python*", "lib/libpython*", "lib/python*"):
|
for pattern in ("include/python*", "lib/libpython*", "lib/python*"):
|
||||||
delete_glob(f"{prefix_dir}/{pattern}")
|
delete_glob(f"{prefix_dir}/{pattern}")
|
||||||
|
|
||||||
|
# The Android environment variables were already captured in the Makefile by
|
||||||
|
# `configure`, and passing them again when running `make` may cause some
|
||||||
|
# flags to be duplicated. So we don't use the `host` argument here.
|
||||||
os.chdir(host_dir)
|
os.chdir(host_dir)
|
||||||
run(["make", "-j", str(os.cpu_count())], host=context.host)
|
run(["make", "-j", str(os.cpu_count())])
|
||||||
run(["make", "install", f"prefix={prefix_dir}"], host=context.host)
|
run(["make", "install", f"prefix={prefix_dir}"])
|
||||||
|
|
||||||
|
|
||||||
def build_all(context):
|
def build_all(context):
|
||||||
@ -228,7 +265,12 @@ def setup_sdk():
|
|||||||
if not all((android_home / "licenses" / path).exists() for path in [
|
if not all((android_home / "licenses" / path).exists() for path in [
|
||||||
"android-sdk-arm-dbt-license", "android-sdk-license"
|
"android-sdk-arm-dbt-license", "android-sdk-license"
|
||||||
]):
|
]):
|
||||||
run([sdkmanager, "--licenses"], text=True, input="y\n" * 100)
|
run(
|
||||||
|
[sdkmanager, "--licenses"],
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
input="y\n" * 100,
|
||||||
|
)
|
||||||
|
|
||||||
# Gradle may install this automatically, but we can't rely on that because
|
# Gradle may install this automatically, but we can't rely on that because
|
||||||
# we need to run adb within the logcat task.
|
# we need to run adb within the logcat task.
|
||||||
@ -474,24 +516,49 @@ async def gradle_task(context):
|
|||||||
task_prefix = "connected"
|
task_prefix = "connected"
|
||||||
env["ANDROID_SERIAL"] = context.connected
|
env["ANDROID_SERIAL"] = context.connected
|
||||||
|
|
||||||
|
hidden_output = []
|
||||||
|
|
||||||
|
def log(line):
|
||||||
|
# Gradle may take several minutes to install SDK packages, so it's worth
|
||||||
|
# showing those messages even in non-verbose mode.
|
||||||
|
if context.verbose or line.startswith('Preparing "Install'):
|
||||||
|
sys.stdout.write(line)
|
||||||
|
else:
|
||||||
|
hidden_output.append(line)
|
||||||
|
|
||||||
|
if context.command:
|
||||||
|
mode = "-c"
|
||||||
|
module = context.command
|
||||||
|
else:
|
||||||
|
mode = "-m"
|
||||||
|
module = context.module or "test"
|
||||||
|
|
||||||
args = [
|
args = [
|
||||||
gradlew, "--console", "plain", f"{task_prefix}DebugAndroidTest",
|
gradlew, "--console", "plain", f"{task_prefix}DebugAndroidTest",
|
||||||
"-Pandroid.testInstrumentationRunnerArguments.pythonArgs="
|
] + [
|
||||||
+ shlex.join(context.args),
|
# Build-time properties
|
||||||
|
f"-Ppython.{name}={value}"
|
||||||
|
for name, value in [
|
||||||
|
("sitePackages", context.site_packages), ("cwd", context.cwd)
|
||||||
|
] if value
|
||||||
|
] + [
|
||||||
|
# Runtime properties
|
||||||
|
f"-Pandroid.testInstrumentationRunnerArguments.python{name}={value}"
|
||||||
|
for name, value in [
|
||||||
|
("Mode", mode), ("Module", module), ("Args", join_command(context.args))
|
||||||
|
] if value
|
||||||
]
|
]
|
||||||
hidden_output = []
|
if context.verbose >= 2:
|
||||||
|
args.append("--info")
|
||||||
|
log("> " + join_command(args))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with async_process(
|
async with async_process(
|
||||||
*args, cwd=TESTBED_DIR, env=env,
|
*args, cwd=TESTBED_DIR, env=env,
|
||||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
) as process:
|
) as process:
|
||||||
while line := (await process.stdout.readline()).decode(*DECODE_ARGS):
|
while line := (await process.stdout.readline()).decode(*DECODE_ARGS):
|
||||||
# Gradle may take several minutes to install SDK packages, so
|
log(line)
|
||||||
# it's worth showing those messages even in non-verbose mode.
|
|
||||||
if context.verbose or line.startswith('Preparing "Install'):
|
|
||||||
sys.stdout.write(line)
|
|
||||||
else:
|
|
||||||
hidden_output.append(line)
|
|
||||||
|
|
||||||
status = await wait_for(process.wait(), timeout=1)
|
status = await wait_for(process.wait(), timeout=1)
|
||||||
if status == 0:
|
if status == 0:
|
||||||
@ -604,6 +671,10 @@ def package(context):
|
|||||||
print(f"Wrote {package_path}")
|
print(f"Wrote {package_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def env(context):
|
||||||
|
print_env(android_env(getattr(context, "host", None)))
|
||||||
|
|
||||||
|
|
||||||
# Handle SIGTERM the same way as SIGINT. This ensures that if we're terminated
|
# Handle SIGTERM the same way as SIGINT. This ensures that if we're terminated
|
||||||
# by the buildbot worker, we'll make an attempt to clean up our subprocesses.
|
# by the buildbot worker, we'll make an attempt to clean up our subprocesses.
|
||||||
def install_signal_handler():
|
def install_signal_handler():
|
||||||
@ -615,36 +686,41 @@ def install_signal_handler():
|
|||||||
|
|
||||||
def parse_args():
|
def parse_args():
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
subcommands = parser.add_subparsers(dest="subcommand")
|
subcommands = parser.add_subparsers(dest="subcommand", required=True)
|
||||||
|
|
||||||
# Subcommands
|
# Subcommands
|
||||||
build = subcommands.add_parser("build", help="Build everything")
|
build = subcommands.add_parser(
|
||||||
configure_build = subcommands.add_parser("configure-build",
|
"build", help="Run configure-build, make-build, configure-host and "
|
||||||
help="Run `configure` for the "
|
"make-host")
|
||||||
"build Python")
|
configure_build = subcommands.add_parser(
|
||||||
make_build = subcommands.add_parser("make-build",
|
"configure-build", help="Run `configure` for the build Python")
|
||||||
help="Run `make` for the build Python")
|
|
||||||
configure_host = subcommands.add_parser("configure-host",
|
|
||||||
help="Run `configure` for Android")
|
|
||||||
make_host = subcommands.add_parser("make-host",
|
|
||||||
help="Run `make` for Android")
|
|
||||||
subcommands.add_parser(
|
subcommands.add_parser(
|
||||||
"clean", help="Delete all build and prefix directories")
|
"make-build", help="Run `make` for the build Python")
|
||||||
subcommands.add_parser(
|
configure_host = subcommands.add_parser(
|
||||||
"build-testbed", help="Build the testbed app")
|
"configure-host", help="Run `configure` for Android")
|
||||||
test = subcommands.add_parser(
|
make_host = subcommands.add_parser(
|
||||||
"test", help="Run the test suite")
|
"make-host", help="Run `make` for Android")
|
||||||
|
|
||||||
|
subcommands.add_parser("clean", help="Delete all build directories")
|
||||||
|
subcommands.add_parser("build-testbed", help="Build the testbed app")
|
||||||
|
test = subcommands.add_parser("test", help="Run the testbed app")
|
||||||
package = subcommands.add_parser("package", help="Make a release package")
|
package = subcommands.add_parser("package", help="Make a release package")
|
||||||
|
env = subcommands.add_parser("env", help="Print environment variables")
|
||||||
|
|
||||||
# Common arguments
|
# Common arguments
|
||||||
for subcommand in build, configure_build, configure_host:
|
for subcommand in build, configure_build, configure_host:
|
||||||
subcommand.add_argument(
|
subcommand.add_argument(
|
||||||
"--clean", action="store_true", default=False, dest="clean",
|
"--clean", action="store_true", default=False, dest="clean",
|
||||||
help="Delete the relevant build and prefix directories first")
|
help="Delete the relevant build directories first")
|
||||||
for subcommand in [build, configure_host, make_host, package]:
|
|
||||||
|
host_commands = [build, configure_host, make_host, package]
|
||||||
|
if in_source_tree:
|
||||||
|
host_commands.append(env)
|
||||||
|
for subcommand in host_commands:
|
||||||
subcommand.add_argument(
|
subcommand.add_argument(
|
||||||
"host", metavar="HOST", choices=HOSTS,
|
"host", metavar="HOST", choices=HOSTS,
|
||||||
help="Host triplet: choices=[%(choices)s]")
|
help="Host triplet: choices=[%(choices)s]")
|
||||||
|
|
||||||
for subcommand in build, configure_build, configure_host:
|
for subcommand in build, configure_build, configure_host:
|
||||||
subcommand.add_argument("args", nargs="*",
|
subcommand.add_argument("args", nargs="*",
|
||||||
help="Extra arguments to pass to `configure`")
|
help="Extra arguments to pass to `configure`")
|
||||||
@ -654,6 +730,7 @@ def parse_args():
|
|||||||
"-v", "--verbose", action="count", default=0,
|
"-v", "--verbose", action="count", default=0,
|
||||||
help="Show Gradle output, and non-Python logcat messages. "
|
help="Show Gradle output, and non-Python logcat messages. "
|
||||||
"Use twice to include high-volume messages which are rarely useful.")
|
"Use twice to include high-volume messages which are rarely useful.")
|
||||||
|
|
||||||
device_group = test.add_mutually_exclusive_group(required=True)
|
device_group = test.add_mutually_exclusive_group(required=True)
|
||||||
device_group.add_argument(
|
device_group.add_argument(
|
||||||
"--connected", metavar="SERIAL", help="Run on a connected device. "
|
"--connected", metavar="SERIAL", help="Run on a connected device. "
|
||||||
@ -661,8 +738,24 @@ def parse_args():
|
|||||||
device_group.add_argument(
|
device_group.add_argument(
|
||||||
"--managed", metavar="NAME", help="Run on a Gradle-managed device. "
|
"--managed", metavar="NAME", help="Run on a Gradle-managed device. "
|
||||||
"These are defined in `managedDevices` in testbed/app/build.gradle.kts.")
|
"These are defined in `managedDevices` in testbed/app/build.gradle.kts.")
|
||||||
|
|
||||||
test.add_argument(
|
test.add_argument(
|
||||||
"args", nargs="*", help=f"Arguments for `python -m test`. "
|
"--site-packages", metavar="DIR", type=abspath,
|
||||||
|
help="Directory to copy as the app's site-packages.")
|
||||||
|
test.add_argument(
|
||||||
|
"--cwd", metavar="DIR", type=abspath,
|
||||||
|
help="Directory to copy as the app's working directory.")
|
||||||
|
|
||||||
|
mode_group = test.add_mutually_exclusive_group()
|
||||||
|
mode_group.add_argument(
|
||||||
|
"-c", dest="command", help="Execute the given Python code.")
|
||||||
|
mode_group.add_argument(
|
||||||
|
"-m", dest="module", help="Execute the module with the given name.")
|
||||||
|
test.epilog = (
|
||||||
|
"If neither -c nor -m are passed, the default is '-m test', which will "
|
||||||
|
"run Python's own test suite.")
|
||||||
|
test.add_argument(
|
||||||
|
"args", nargs="*", help=f"Arguments to add to sys.argv. "
|
||||||
f"Separate them from {SCRIPT_NAME}'s own arguments with `--`.")
|
f"Separate them from {SCRIPT_NAME}'s own arguments with `--`.")
|
||||||
|
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
@ -688,6 +781,7 @@ def main():
|
|||||||
"build-testbed": build_testbed,
|
"build-testbed": build_testbed,
|
||||||
"test": run_testbed,
|
"test": run_testbed,
|
||||||
"package": package,
|
"package": package,
|
||||||
|
"env": env,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -708,14 +802,9 @@ def print_called_process_error(e):
|
|||||||
if not content.endswith("\n"):
|
if not content.endswith("\n"):
|
||||||
stream.write("\n")
|
stream.write("\n")
|
||||||
|
|
||||||
# Format the command so it can be copied into a shell. shlex uses single
|
# shlex uses single quotes, so we surround the command with double quotes.
|
||||||
# quotes, so we surround the whole command with double quotes.
|
|
||||||
args_joined = (
|
|
||||||
e.cmd if isinstance(e.cmd, str)
|
|
||||||
else " ".join(shlex.quote(str(arg)) for arg in e.cmd)
|
|
||||||
)
|
|
||||||
print(
|
print(
|
||||||
f'Command "{args_joined}" returned exit status {e.returncode}'
|
f'Command "{join_command(e.cmd)}" returned exit status {e.returncode}'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -85,7 +85,7 @@ android {
|
|||||||
|
|
||||||
minSdk = androidEnvFile.useLines {
|
minSdk = androidEnvFile.useLines {
|
||||||
for (line in it) {
|
for (line in it) {
|
||||||
"""api_level:=(\d+)""".toRegex().find(line)?.let {
|
"""ANDROID_API_LEVEL:=(\d+)""".toRegex().find(line)?.let {
|
||||||
return@useLines it.groupValues[1].toInt()
|
return@useLines it.groupValues[1].toInt()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -205,11 +205,29 @@ androidComponents.onVariants { variant ->
|
|||||||
|
|
||||||
into("site-packages") {
|
into("site-packages") {
|
||||||
from("$projectDir/src/main/python")
|
from("$projectDir/src/main/python")
|
||||||
|
|
||||||
|
val sitePackages = findProperty("python.sitePackages") as String?
|
||||||
|
if (!sitePackages.isNullOrEmpty()) {
|
||||||
|
if (!file(sitePackages).exists()) {
|
||||||
|
throw GradleException("$sitePackages does not exist")
|
||||||
|
}
|
||||||
|
from(sitePackages)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||||
exclude("**/__pycache__")
|
exclude("**/__pycache__")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
into("cwd") {
|
||||||
|
val cwd = findProperty("python.cwd") as String?
|
||||||
|
if (!cwd.isNullOrEmpty()) {
|
||||||
|
if (!file(cwd).exists()) {
|
||||||
|
throw GradleException("$cwd does not exist")
|
||||||
|
}
|
||||||
|
from(cwd)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,11 +17,11 @@ class PythonSuite {
|
|||||||
fun testPython() {
|
fun testPython() {
|
||||||
val start = System.currentTimeMillis()
|
val start = System.currentTimeMillis()
|
||||||
try {
|
try {
|
||||||
val context =
|
val status = PythonTestRunner(
|
||||||
InstrumentationRegistry.getInstrumentation().targetContext
|
InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
val args =
|
).run(
|
||||||
InstrumentationRegistry.getArguments().getString("pythonArgs", "")
|
InstrumentationRegistry.getArguments()
|
||||||
val status = PythonTestRunner(context).run(args)
|
)
|
||||||
assertEquals(0, status)
|
assertEquals(0, status)
|
||||||
} finally {
|
} finally {
|
||||||
// Make sure the process lives long enough for the test script to
|
// Make sure the process lives long enough for the test script to
|
||||||
|
@ -15,17 +15,29 @@ class MainActivity : AppCompatActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.activity_main)
|
setContentView(R.layout.activity_main)
|
||||||
val status = PythonTestRunner(this).run("-W -uall")
|
val status = PythonTestRunner(this).run("-m", "test", "-W -uall")
|
||||||
findViewById<TextView>(R.id.tvHello).text = "Exit status $status"
|
findViewById<TextView>(R.id.tvHello).text = "Exit status $status"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class PythonTestRunner(val context: Context) {
|
class PythonTestRunner(val context: Context) {
|
||||||
/** @param args Extra arguments for `python -m test`.
|
fun run(instrumentationArgs: Bundle) = run(
|
||||||
* @return The Python exit status: zero if the tests passed, nonzero if
|
instrumentationArgs.getString("pythonMode")!!,
|
||||||
* they failed. */
|
instrumentationArgs.getString("pythonModule")!!,
|
||||||
fun run(args: String = "") : Int {
|
instrumentationArgs.getString("pythonArgs") ?: "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Run Python.
|
||||||
|
*
|
||||||
|
* @param mode Either "-c" or "-m".
|
||||||
|
* @param module Python statements for "-c" mode, or a module name for
|
||||||
|
* "-m" mode.
|
||||||
|
* @param args Arguments to add to sys.argv. Will be parsed by `shlex.split`.
|
||||||
|
* @return The Python exit status: zero on success, nonzero on failure. */
|
||||||
|
fun run(mode: String, module: String, args: String) : Int {
|
||||||
|
Os.setenv("PYTHON_MODE", mode, true)
|
||||||
|
Os.setenv("PYTHON_MODULE", module, true)
|
||||||
Os.setenv("PYTHON_ARGS", args, true)
|
Os.setenv("PYTHON_ARGS", args, true)
|
||||||
|
|
||||||
// Python needs this variable to help it find the temporary directory,
|
// Python needs this variable to help it find the temporary directory,
|
||||||
@ -36,8 +48,9 @@ class PythonTestRunner(val context: Context) {
|
|||||||
System.loadLibrary("main_activity")
|
System.loadLibrary("main_activity")
|
||||||
redirectStdioToLogcat()
|
redirectStdioToLogcat()
|
||||||
|
|
||||||
// The main module is in src/main/python/main.py.
|
// The main module is in src/main/python. We don't simply call it
|
||||||
return runPython(pythonHome.toString(), "main")
|
// "main", as that could clash with third-party test code.
|
||||||
|
return runPython(pythonHome.toString(), "android_testbed_main")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractAssets() : File {
|
private fun extractAssets() : File {
|
||||||
|
@ -26,7 +26,23 @@ import sys
|
|||||||
# test_signals in test_threadsignals.py.
|
# test_signals in test_threadsignals.py.
|
||||||
signal.pthread_sigmask(signal.SIG_UNBLOCK, [signal.SIGUSR1])
|
signal.pthread_sigmask(signal.SIG_UNBLOCK, [signal.SIGUSR1])
|
||||||
|
|
||||||
|
mode = os.environ["PYTHON_MODE"]
|
||||||
|
module = os.environ["PYTHON_MODULE"]
|
||||||
sys.argv[1:] = shlex.split(os.environ["PYTHON_ARGS"])
|
sys.argv[1:] = shlex.split(os.environ["PYTHON_ARGS"])
|
||||||
|
|
||||||
# The test module will call sys.exit to indicate whether the tests passed.
|
cwd = f"{sys.prefix}/cwd"
|
||||||
runpy.run_module("test")
|
if not os.path.exists(cwd):
|
||||||
|
# Empty directories are lost in the asset packing/unpacking process.
|
||||||
|
os.mkdir(cwd)
|
||||||
|
os.chdir(cwd)
|
||||||
|
|
||||||
|
if mode == "-c":
|
||||||
|
# In -c mode, sys.path starts with an empty string, which means whatever the current
|
||||||
|
# working directory is at the moment of each import.
|
||||||
|
sys.path.insert(0, "")
|
||||||
|
exec(module, {})
|
||||||
|
elif mode == "-m":
|
||||||
|
sys.path.insert(0, os.getcwd())
|
||||||
|
runpy.run_module(module, run_name="__main__", alter_sys=True)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"unknown mode: {mode}")
|
@ -1,5 +1,5 @@
|
|||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application") version "8.6.1" apply false
|
id("com.android.application") version "8.10.0" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
#Mon Feb 19 20:29:06 GMT 2024
|
#Mon Feb 19 20:29:06 GMT 2024
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
@ -63,3 +63,12 @@ link to the relevant file.
|
|||||||
* Add code to your app to :source:`start Python in embedded mode
|
* Add code to your app to :source:`start Python in embedded mode
|
||||||
<Android/testbed/app/src/main/c/main_activity.c>`. This will need to be C code
|
<Android/testbed/app/src/main/c/main_activity.c>`. This will need to be C code
|
||||||
called via JNI.
|
called via JNI.
|
||||||
|
|
||||||
|
Building a Python package for Android
|
||||||
|
-------------------------------------
|
||||||
|
|
||||||
|
Python packages can be built for Android as wheels and released on PyPI. The
|
||||||
|
recommended tool for doing this is `cibuildwheel
|
||||||
|
<https://cibuildwheel.pypa.io/en/stable/platforms/#android>`__, which automates
|
||||||
|
all the details of setting up a cross-compilation environment, building the
|
||||||
|
wheel, and testing it on an emulator.
|
||||||
|
Loading…
x
Reference in New Issue
Block a user