Skip to content

gh-131531: android.py enhancements to support cibuildwheel #132870

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
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
8 changes: 5 additions & 3 deletions Android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ it:
`android-sdk/cmdline-tools/latest`.
* `export ANDROID_HOME=/path/to/android-sdk`

The `android.py` script also requires the following commands to be on the `PATH`:
The `android.py` script will automatically use the SDK's `sdkmanager` to install
any packages it needs.

The script also requires the following commands to be on the `PATH`:

* `curl`
* `java` (or set the `JAVA_HOME` environment variable)
* `tar`


## Building
Expand Down Expand Up @@ -97,7 +99,7 @@ similar to the `Android` directory of the CPython source tree.
The Python test suite can be run on Linux, macOS, or Windows:

* On Linux, the emulator needs access to the KVM virtualization interface, and
a DISPLAY environment variable pointing at an X server.
a DISPLAY environment variable pointing at an X server. Xvfb is acceptable.

The test suite can usually be run on a device with 2 GB of RAM, but this is
borderline, so you may need to increase it to 4 GB. As of Android
Expand Down
4 changes: 2 additions & 2 deletions Android/android-env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
: "${HOST:?}" # GNU target triplet

# 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


Expand Down Expand Up @@ -43,7 +43,7 @@ fi
toolchain=$(echo "$ndk"/toolchains/llvm/prebuilt/*)
export AR="$toolchain/bin/llvm-ar"
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 LD="$toolchain/bin/ld"
export NM="$toolchain/bin/llvm-nm"
Expand Down
116 changes: 72 additions & 44 deletions Android/android.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,13 @@

SCRIPT_NAME = Path(__file__).name
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"
CROSS_BUILD_DIR = CHECKOUT / "cross-build"
CROSS_BUILD_DIR = PYTHON_DIR / "cross-build"

HOSTS = ["aarch64-linux-android", "x86_64-linux-android"]
APP_ID = "org.python.testbed"
Expand Down Expand Up @@ -74,41 +78,61 @@ def subdir(*parts, create=False):

def run(command, *, host=None, env=None, log=True, **kwargs):
kwargs.setdefault("check", True)

if env is None:
env = os.environ.copy()
original_env = env.copy()

if host:
env_script = ANDROID_DIR / "android-env.sh"
env_output = subprocess.run(
f"set -eu; "
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)
env.update(android_env(host))

if log:
print(">", " ".join(map(str, command)))
return subprocess.run(command, env=env, **kwargs)


def print_env(context):
android_env(getattr(context, "host", None))


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
Comment on lines +122 to +124
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems simpler, unless there's a need to minimise the size of 'env'? You could even use a dictcomp.

Suggested change
key, value = match[2], match[3]
if os.environ.get(key) != value:
env[key] = value
key, value = match[2], match[3]
env[key] = value

Copy link
Member Author

@mhsmith mhsmith Apr 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is run by the android.py env command, which cibuildwheel uses to determine the environment variables it needs when building for Android. It outputs all variables changed by the android-env.sh script, which looks something like this:

% ./Android/android.py env aarch64-linux-android
export AR=/Users/msmith/Library/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-ar
export AS=/Users/msmith/Library/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-as
export CC=/Users/msmith/Library/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android24-clang
export CFLAGS='-D__BIONIC_NO_PAGE_SIZE_MACRO -I/Users/msmith/git/python/cpython/cross-build/aarch64-linux-android/prefix/include'
export CPU_COUNT=10
export CXX=/Users/msmith/Library/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android24-clang++
export CXXFLAGS='-D__BIONIC_NO_PAGE_SIZE_MACRO -I/Users/msmith/git/python/cpython/cross-build/aarch64-linux-android/prefix/include'
export HOST=aarch64-linux-android
export LD=/Users/msmith/Library/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/darwin-x86_64/bin/ld
export LDFLAGS='-Wl,--build-id=sha1 -Wl,--no-rosegment -Wl,-z,max-page-size=16384 -Wl,--no-undefined -lm -L/Users/msmith/git/python/cpython/cross-build/aarch64-linux-android/prefix/lib'
export NM=/Users/msmith/Library/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-nm
export PKG_CONFIG='pkg-config --define-prefix'
export PKG_CONFIG_LIBDIR=/Users/msmith/git/python/cpython/cross-build/aarch64-linux-android/prefix/lib/pkgconfig
export RANLIB=/Users/msmith/Library/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-ranlib
export READELF=/Users/msmith/Library/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-readelf
export SHLVL=2
export STRIP=/Users/msmith/Library/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-strip

If this included every single environment variable, it would be a lot less convenient to use it to set variables manually when debugging build problems. It would also be a lot more difficult to verify its correctness.


if not env:
raise ValueError(f"Found no variables in {env_script.name} output:\n"
+ env_output)

# Format the environment so it can be pasted into a shell.
for key, value in sorted(env.items()):
print(f"export {key}={shlex.quote(value)}")
return env


def build_python_path():
"""The path to the build Python binary."""
build_dir = subdir("build")
Expand All @@ -127,7 +151,7 @@ def configure_build_python(context):
clean("build")
os.chdir(subdir("build", create=True))

command = [relpath(CHECKOUT / "configure")]
command = [relpath(PYTHON_DIR / "configure")]
if context.args:
command.extend(context.args)
run(command)
Expand All @@ -138,19 +162,19 @@ def make_build_python(context):
run(["make", "-j", str(os.cpu_count())])


def unpack_deps(host):
def unpack_deps(host, prefix_dir):
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",
"sqlite-3.49.1-0", "xz-5.4.6-1"]:
filename = f"{name_ver}-{host}.tar.gz"
download(f"{deps_url}/{name_ver}/{filename}")
run(["tar", "-xf", filename])
shutil.unpack_archive(filename, prefix_dir)
os.remove(filename)


def download(url, target_dir="."):
out_path = f"{target_dir}/{basename(url)}"
run(["curl", "-Lf", "-o", out_path, url])
run(["curl", "-Lf", "--retry", "5", "--retry-all-errors", "-o", out_path, url])
return out_path


Expand All @@ -162,13 +186,12 @@ def configure_host_python(context):
prefix_dir = host_dir / "prefix"
if not prefix_dir.exists():
prefix_dir.mkdir()
os.chdir(prefix_dir)
unpack_deps(context.host)
unpack_deps(context.host, prefix_dir)

os.chdir(host_dir)
command = [
# Basic cross-compiling configuration
relpath(CHECKOUT / "configure"),
relpath(PYTHON_DIR / "configure"),
f"--host={context.host}",
f"--build={sysconfig.get_config_var('BUILD_GNU_TYPE')}",
f"--with-build-python={build_python_path()}",
Expand Down Expand Up @@ -241,16 +264,15 @@ def setup_sdk():
# the Gradle wrapper is not included in the CPython repository. Instead, we
# extract it from the Gradle GitHub repository.
def setup_testbed():
# The Gradle version used for the build is specified in
# testbed/gradle/wrapper/gradle-wrapper.properties. This wrapper version
# doesn't need to match, as any version of the wrapper can download any
# version of Gradle.
version = "8.9.0"
paths = ["gradlew", "gradlew.bat", "gradle/wrapper/gradle-wrapper.jar"]

if all((TESTBED_DIR / path).exists() for path in paths):
return

# The wrapper version isn't important, as any version of the wrapper can
# download any version of Gradle. The Gradle version actually used for the
# build is specified in testbed/gradle/wrapper/gradle-wrapper.properties.
version = "8.9.0"

for path in paths:
out_path = TESTBED_DIR / path
out_path.parent.mkdir(exist_ok=True)
Expand Down Expand Up @@ -624,8 +646,7 @@ def parse_args():
configure_build = subcommands.add_parser("configure-build",
help="Run `configure` for the "
"build Python")
make_build = subcommands.add_parser("make-build",
help="Run `make` for the build Python")
subcommands.add_parser("make-build", 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",
Expand All @@ -637,16 +658,22 @@ def parse_args():
test = subcommands.add_parser(
"test", help="Run the test suite")
package = subcommands.add_parser("package", help="Make a release package")
env = subcommands.add_parser("env", help="Print environment variables")

# Common arguments
for subcommand in build, configure_build, configure_host:
subcommand.add_argument(
"--clean", action="store_true", default=False, dest="clean",
help="Delete the relevant build and prefix 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(
"host", metavar="HOST", choices=HOSTS,
help="Host triplet: choices=[%(choices)s]")

for subcommand in build, configure_build, configure_host:
subcommand.add_argument("args", nargs="*",
help="Extra arguments to pass to `configure`")
Expand Down Expand Up @@ -690,6 +717,7 @@ def main():
"build-testbed": build_testbed,
"test": run_testbed,
"package": package,
"env": print_env,
}

try:
Expand Down
2 changes: 1 addition & 1 deletion Android/testbed/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ android {

minSdk = androidEnvFile.useLines {
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()
}
}
Expand Down
Loading