#!/usr/bin/env bash # # Cross-compile pydantic-core for Android across all supported ABIs: # arm64-v8a (primary — modern TV hardware) # x86_64 (modern emulators) # x86 (legacy emulators) # armeabi-v7a (32-bit ARMv7 — older cheap TV boxes like X96 mini, MeCool) # # Outputs wheels into android/wheels/. Wheels are linked against the real # libpython3.11.so shipped by Chaquopy (stub .so does NOT work — see # memory/project_android_app.md for the incident notes). # # Prerequisites (on host): # - Rust + cargo (rustup) with targets: # aarch64/x86_64/i686/armv7a-linux-android(eabi) # - Android NDK (ANDROID_NDK_HOME, or installed at Sdk/ndk/*) # - Python 3.11 (matches Chaquopy's embedded version) # - maturin (pip install maturin) # # Rebuild cadence: whenever PYDANTIC_CORE_VERSION changes, or pydantic's # core dependency version changes. # # Usage: # ./build-pydantic-core.sh # build all 4 ABIs # ./build-pydantic-core.sh arm64 # build a single ABI # ./build-pydantic-core.sh arm64 x86_64 # build a subset # ./build-pydantic-core.sh armv7 # 32-bit ARM only # set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ANDROID_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" WHEELS_DIR="$ANDROID_DIR/wheels" BUILD_DIR="$ANDROID_DIR/.build-cache" PYDANTIC_CORE_VERSION="2.46.0" API_LEVEL=24 PY_VERSION="3.11" # Resolve a concrete python3.11 interpreter path (maturin needs it callable). # Windows Git Bash doesn't ship `python3.11` on PATH; fall back to `py -3.11`. if [ -z "${PYTHON_BIN:-}" ]; then # Prefer `py -3.11` on Windows (Git Bash's `python3.11` resolves to a # non-functional MSStore stub). Fall back to `python3.11` on Linux/macOS. if command -v py >/dev/null 2>&1 && py -3.11 -c '' 2>/dev/null; then PYTHON_BIN="$(py -3.11 -c 'import sys; print(sys.executable)' 2>/dev/null | tr -d '\r')" elif command -v python3.11 >/dev/null 2>&1 && python3.11 -c '' 2>/dev/null; then PYTHON_BIN="$(command -v python3.11)" fi fi [ -n "${PYTHON_BIN:-}" ] || { echo "ERROR: python3.11 not found; set PYTHON_BIN"; exit 1; } echo "Python: $PYTHON_BIN" mkdir -p "$WHEELS_DIR" # ── Find Android NDK ──────────────────────────────────────────────── if [ -z "${ANDROID_NDK_HOME:-}" ]; then for candidate in \ "$HOME/Library/Android/sdk/ndk"/* \ "$HOME/Android/Sdk/ndk"/* \ "${LOCALAPPDATA:-}/Android/Sdk/ndk"/* \ "/c/Users/$(whoami)/AppData/Local/Android/Sdk/ndk"/* \ "/usr/local/lib/android/sdk/ndk"/* \ "${ANDROID_SDK_ROOT:-}/ndk"/*; do if [ -d "$candidate" ]; then ANDROID_NDK_HOME="$candidate" break fi done fi [ -n "${ANDROID_NDK_HOME:-}" ] || { echo "ERROR: NDK not found; set ANDROID_NDK_HOME"; exit 1; } echo "NDK: $ANDROID_NDK_HOME" case "$(uname -s)" in Linux*) HOST_TAG="linux-x86_64" ;; Darwin*) HOST_TAG="darwin-x86_64" ;; MINGW*|MSYS*|CYGWIN*) HOST_TAG="windows-x86_64" ;; *) echo "Unsupported host OS"; exit 1 ;; esac TOOLCHAIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/$HOST_TAG" READELF="$TOOLCHAIN/bin/llvm-readelf" # ── Prepare source tree ───────────────────────────────────────────── SRC_DIR="$BUILD_DIR/pydantic_core-$PYDANTIC_CORE_VERSION" if [ ! -d "$SRC_DIR" ]; then echo "Downloading pydantic_core $PYDANTIC_CORE_VERSION sdist..." mkdir -p "$BUILD_DIR" SDIST="$BUILD_DIR/pydantic_core-$PYDANTIC_CORE_VERSION.tar.gz" [ -f "$SDIST" ] || curl -sSL --retry 3 -o "$SDIST" \ "https://files.pythonhosted.org/packages/source/p/pydantic_core/pydantic_core-$PYDANTIC_CORE_VERSION.tar.gz" tar -xzf "$SDIST" -C "$BUILD_DIR" fi # ── ABI table ─────────────────────────────────────────────────────── # Columns: short_name rust_target clang_prefix sysconfig_dir ABI_TABLE=( "arm64 aarch64-linux-android aarch64-linux-android${API_LEVEL} cross-sysconfig" "x86_64 x86_64-linux-android x86_64-linux-android${API_LEVEL} cross-sysconfig-x86_64" "x86 i686-linux-android i686-linux-android${API_LEVEL} cross-sysconfig-x86" "armv7 armv7-linux-androideabi armv7a-linux-androideabi${API_LEVEL} cross-sysconfig-armv7" ) declare -A ABI_TAG_MAP=( [arm64]="arm64_v8a" [x86_64]="x86_64" [x86]="x86" [armv7]="armeabi_v7a" ) # ── Select which ABIs to build ────────────────────────────────────── SELECTED=("$@") if [ ${#SELECTED[@]} -eq 0 ]; then SELECTED=(arm64 x86_64 x86 armv7) fi # ── Ensure rust targets are installed ─────────────────────────────── for name in "${SELECTED[@]}"; do for row in "${ABI_TABLE[@]}"; do read -r sname rtarget _ _ <<<"$row" if [ "$sname" = "$name" ]; then rustup target add "$rtarget" >/dev/null 2>&1 || true fi done done # ── Build loop ────────────────────────────────────────────────────── cd "$SRC_DIR" for name in "${SELECTED[@]}"; do MATCHED="" for row in "${ABI_TABLE[@]}"; do read -r sname rtarget cprefix sysdir <<<"$row" if [ "$sname" = "$name" ]; then MATCHED="1" break fi done [ -n "$MATCHED" ] || { echo "Unknown ABI: $name"; exit 1; } CROSS_LIB_DIR="$BUILD_DIR/$sysdir" [ -f "$CROSS_LIB_DIR/libpython3.11.so" ] || { echo "ERROR: missing $CROSS_LIB_DIR/libpython3.11.so (extract from a Chaquopy APK)" exit 1 } # On Windows hosts the clang wrapper is a .cmd batch file; cargo/rustc # can't exec it as a linker directly (os error 193). Use the .cmd path # explicitly so CreateProcess picks up cmd.exe as interpreter. CC_BIN="$TOOLCHAIN/bin/${cprefix}-clang" if [ "$HOST_TAG" = "windows-x86_64" ] && [ -f "${CC_BIN}.cmd" ]; then CC_BIN="${CC_BIN}.cmd" fi AR_BIN="$TOOLCHAIN/bin/llvm-ar" [ "$HOST_TAG" = "windows-x86_64" ] && [ -f "${AR_BIN}.exe" ] && AR_BIN="${AR_BIN}.exe" [ -f "$CC_BIN" ] || { echo "ERROR: $CC_BIN not found"; exit 1; } # Normalize rust target name for env var (cargo wants UPPER_WITH_UNDERSCORES) TARGET_ENV=$(echo "$rtarget" | tr 'a-z-' 'A-Z_') echo "" echo "════════════════════════════════════════════════════════════" echo " Building pydantic_core $PYDANTIC_CORE_VERSION for $name ($rtarget)" echo "════════════════════════════════════════════════════════════" env \ CARGO_TARGET_DIR="$SRC_DIR/target" \ "CC_${rtarget//-/_}=$CC_BIN" \ "AR_${rtarget//-/_}=$AR_BIN" \ "CARGO_TARGET_${TARGET_ENV}_LINKER=$CC_BIN" \ PYO3_CROSS=1 \ PYO3_CROSS_LIB_DIR="$CROSS_LIB_DIR" \ PYO3_CROSS_PYTHON_VERSION="$PY_VERSION" \ RUSTFLAGS="-C link-arg=-Wl,--no-as-needed -C link-arg=-lpython3.11 -C link-arg=-L$CROSS_LIB_DIR" \ maturin build \ --release \ --target "$rtarget" \ --interpreter "$PYTHON_BIN" \ --out "$WHEELS_DIR" \ --compatibility linux # maturin writes the wheel with the correct android__ platform # tag directly (the sysconfigdata provides MULTIARCH). Find + verify. ABI_TAG="${ABI_TAG_MAP[$name]}" OUT_WHL="$WHEELS_DIR/pydantic_core-$PYDANTIC_CORE_VERSION-cp311-cp311-android_${API_LEVEL}_${ABI_TAG}.whl" [ -f "$OUT_WHL" ] || { echo "ERROR: expected wheel not found: $OUT_WHL"; exit 1; } TMP=$(mktemp -d) unzip -qo "$OUT_WHL" -d "$TMP" "pydantic_core/*.so" SO=$(find "$TMP" -name "*.so" | head -1) if "$READELF" -d "$SO" | grep -q "libpython3.11.so"; then echo " NEEDED OK: libpython3.11.so present in $(basename "$OUT_WHL")" else echo " ERROR: libpython3.11.so NOT in NEEDED — wheel will crash at import" "$READELF" -d "$SO" | grep NEEDED rm -rf "$TMP" exit 1 fi rm -rf "$TMP" done echo "" echo "=== Build complete ===" ls -lh "$WHEELS_DIR"/pydantic_core-*.whl