Skip to content

Android: don't link libpython into dart_bridge.abi3.so #28

Android: don't link libpython into dart_bridge.abi3.so

Android: don't link libpython into dart_bridge.abi3.so #28

name: Test bridge (no publish, no slow platform tests)
# Throwaway workflow that exercises just the new serious_python_bridge work —
# the cibuildwheel matrix, the Android NDK cross-build, and the macOS bridge
# example integration test — without triggering the slow flet_example
# platform matrix or the publish/release steps in ci.yml. Delete before
# merging to main.
on:
push:
branches: [dart-bridge]
workflow_dispatch:
env:
# Mirrors ci.yml's workflow-level env: serious_python_darwin's
# sync_site_packages.sh and the Linux CMakeLists.txt both read this to know
# where package_command.dart staged the bundled site-packages.
SERIOUS_PYTHON_SITE_PACKAGES: "${{ github.workspace }}/site-packages"
jobs:
test_wheel_build:
name: cibuildwheel (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, ubuntu-24.04-arm, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build wheels
uses: pypa/cibuildwheel@v2.21.3
with:
package-dir: src/serious_python_bridge/python
# Windows ships two CPython variants (Release pythonXY.dll and Debug
# pythonXY_d.dll). The cibuildwheel-built abi3 .pyd is Release-CRT; a
# Debug Flutter app loads Debug Python and tries to import
# dart_bridge_d.cp<XY>-win_amd64.pyd (CPython appends _d to EXT_SUFFIX
# when built in Debug mode). Compile a Debug-CRT variant of the same
# dart_bridge_shim.c and pack it alongside the Release .pyd in each
# wheel. Released wheels then work in both `flutter build` (Release)
# and `fvm flutter run` (Debug) without a serious_python_windows split.
- name: Build Debug-variant dart_bridge.pyd and inject into Windows wheels
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
# Use 3.12 headers — abi3 (Py_LIMITED_API=0x030c0000) means one .pyd
# built against the minimum supported version works for every
# 3.12+ runtime.
$pyver = '3.12'
$pyverNodot = '312'
$pywinZip = "python-windows-for-dart-$pyver.zip"
$pywinUrl = "https://github.com/flet-dev/python-build/releases/download/v$pyver/$pywinZip"
Write-Host "Downloading $pywinUrl"
Invoke-WebRequest -Uri $pywinUrl -OutFile $pywinZip
Expand-Archive -Path $pywinZip -DestinationPath "pywin-$pyver" -Force
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
$vsPath = & $vswhere -latest -property installationPath
$vcvars = "$vsPath\VC\Auxiliary\Build\vcvars64.bat"
Write-Host "vcvars: $vcvars"
$shimSrc = (Resolve-Path 'src/serious_python_bridge/native/dart_bridge_shim.c').Path
$includeDir = (Resolve-Path "pywin-$pyver/include").Path
$libDir = (Resolve-Path "pywin-$pyver/libs").Path
$outDir = "$PWD\debug_build"
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
$outPyd = "$outDir\dart_bridge_d.pyd"
# /LD = build DLL. /MDd = link Debug CRT (vcruntime140d/msvcp140d).
# /D_DEBUG = trigger pyconfig.h's Debug branch + match Debug CRT.
# /DPy_LIMITED_API = mirror the Release wheel's abi3 contract.
# Link the Debug abi3 stub python3_d.lib (forwards to python3_d.dll
# which serious_python_windows ships in Debug Flutter builds).
# /LIBPATH puts python-windows-for-dart's libs/ folder on the
# linker search path so pyconfig.h's auto-link `#pragma comment(
# lib, "pythonXY_d.lib")` resolves (it asks for python312_d.lib by
# name; that file ships in the same libs/ folder).
$clCmd = "cl /nologo /LD /MDd /D_DEBUG /DPy_LIMITED_API=0x030c0000 /I `"$includeDir`" `"$shimSrc`" /link /LIBPATH:`"$libDir`" /OUT:`"$outPyd`" `"$libDir\python3_d.lib`""
Write-Host "Running: $clCmd"
cmd /c "`"$vcvars`" >NUL && $clCmd"
if (-not (Test-Path $outPyd)) {
throw "Debug .pyd not produced: $outPyd"
}
Write-Host "Built: $outPyd"
Get-Item $outPyd | Format-List FullName, Length
# Inject the Debug .pyd into each Windows wheel.
python -m pip install --quiet wheel
foreach ($whl in Get-ChildItem "wheelhouse\dart_bridge-*-cp312-abi3-win_amd64.whl") {
$unpackDir = Join-Path $env:RUNNER_TEMP ("unpack_" + [IO.Path]::GetFileNameWithoutExtension($whl.Name))
if (Test-Path $unpackDir) { Remove-Item -Recurse -Force $unpackDir }
python -m wheel unpack -d $unpackDir $whl.FullName
$unpackedRoot = Get-ChildItem $unpackDir -Directory | Select-Object -First 1
Write-Host "Unpacked tree:"
Get-ChildItem -Recurse -Path $unpackedRoot.FullName | ForEach-Object {
Write-Host " $($_.FullName.Substring($unpackedRoot.FullName.Length))"
}
# Find any existing dart_bridge*.pyd to know where to place the Debug
# sibling. setuptools may name it dart_bridge.pyd, dart_bridge.abi3.pyd,
# dart_bridge.cp312-win_amd64.pyd, etc., depending on the build setup.
$existing = Get-ChildItem -Recurse -Path $unpackedRoot.FullName -Filter 'dart_bridge*.pyd' | Select-Object -First 1
if (-not $existing) { throw "No dart_bridge*.pyd found inside $($whl.FullName)" }
Write-Host "Found Release pyd: $($existing.Name)"
$debugDest = Join-Path $existing.DirectoryName "dart_bridge_d.pyd"
Copy-Item $outPyd $debugDest
Write-Host "Injected: $debugDest"
Remove-Item $whl.FullName
python -m wheel pack -d wheelhouse $unpackedRoot.FullName
}
- name: List built wheels
shell: bash
run: |
ls -la wheelhouse/
echo
echo "Wheel filenames (expect cp312-abi3-<plat>):"
ls wheelhouse/*.whl
if [ "${{ matrix.os }}" = "windows-latest" ]; then
echo
echo "Wheel contents (verify Debug + Release .pyd both present):"
for w in wheelhouse/*.whl; do
echo "--- $w ---"
python -m zipfile -l "$w" | grep -E "dart_bridge.*\.pyd" || true
done
fi
- name: Upload wheel artifacts
uses: actions/upload-artifact@v4
with:
name: bridge-wheels-${{ matrix.os }}
path: wheelhouse/*.whl
if-no-files-found: error
test_android_build:
name: Android cross-build (${{ matrix.abi }})
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
abi: [arm64-v8a, armeabi-v7a, x86_64]
env:
PYTHON_VERSION: "3.12"
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download python-android-mobile-forge tarball
run: |
set -euo pipefail
VER="$PYTHON_VERSION"
ABI="${{ matrix.abi }}"
ARCHIVE="python-android-mobile-forge-${VER}.tar.gz"
curl -fL -o "$ARCHIVE" \
"https://github.com/flet-dev/python-build/releases/download/v${VER}/${ARCHIVE}"
mkdir -p pydist
tar -xzf "$ARCHIVE" -C pydist \
"install/android/${ABI}/python-${VER}.13/include/" \
"install/android/${ABI}/python-${VER}.13/lib/"
echo "Python.h candidates:"
find pydist -name "Python.h"
echo "libpython candidates:"
find pydist -name "libpython*.so"
- name: Cross-compile dart_bridge.abi3.so
run: |
set -euxo pipefail
VER="$PYTHON_VERSION"
ABI="${{ matrix.abi }}"
case "$ABI" in
arm64-v8a) TARGET=aarch64-linux-android ;;
armeabi-v7a) TARGET=armv7a-linux-androideabi ;;
x86_64) TARGET=x86_64-linux-android ;;
*) echo "unsupported ABI: $ABI" >&2 ; exit 1 ;;
esac
API=21
NDK="${ANDROID_NDK_HOME:-${ANDROID_NDK_LATEST_HOME}}"
TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/linux-x86_64"
CC="$TOOLCHAIN/bin/${TARGET}${API}-clang"
test -x "$CC"
INCLUDE_DIR=$(find pydist -path "*/python-${VER}.13/include/python${VER}" | head -n1)
LIBPYTHON=$(find pydist -path "*/python-${VER}.13/lib/libpython${VER}.so" | head -n1)
test -n "$INCLUDE_DIR" -a -n "$LIBPYTHON"
OUT="dart_bridge.abi3-android-${ABI}.so"
# Compile dart_bridge_shim.c (the Python-callable module) — same
# source used by the Linux/Windows wheels. Symbols from the core
# (dart_bridge_global_enqueue_handler_func, dart_bridge_post_to_dart)
# are resolved at PyInit time via dlsym/dlopen against
# libflet_bridge.so, which the Flutter plugin builds + bundles
# separately. Linking dart_bridge.c directly into this .so would
# produce a second, isolated copy of the global handler cell —
# Dart's side and Python's side would never see the same value.
# -ldl gives dart_bridge_shim.c its dlsym/dlopen.
#
# Intentionally NOT linking $LIBPYTHON: hardcoding it would emit
# DT_NEEDED for the version-specific libpython3.X.so used at build
# time (we build once with 3.12), and Android's linker would then
# fail to load this .so under 3.13/3.14 Python (which ship
# libpython3.13.so / libpython3.14.so but not libpython3.12.so).
# `-shared` allows undefined Python symbols; they resolve at import
# time because dlopen flags propagate from the already-loaded
# libpython into the new module's symbol lookup.
# `-Wl,--allow-shlib-undefined` makes the linker tolerate the
# unresolved Python C API references during the link itself.
$CC -shared -fPIC -fvisibility=hidden \
-DPy_LIMITED_API=0x030c0000 \
-I"$INCLUDE_DIR" \
-I"src/serious_python_bridge/native" \
src/serious_python_bridge/native/dart_bridge_shim.c \
-ldl \
-Wl,--allow-shlib-undefined \
-Wl,-z,max-page-size=16384 \
-o "$OUT"
- name: Inspect output
run: |
ls -lh dart_bridge.abi3-android-*.so
file dart_bridge.abi3-android-*.so
- name: Upload .so artifact
uses: actions/upload-artifact@v4
with:
name: bridge-android-${{ matrix.abi }}
path: dart_bridge.abi3-android-*.so
if-no-files-found: error
test_bridge_example_macos:
name: Bridge example macOS round-trip (Python ${{ matrix.python_version }})
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
python_version: ['3.12', '3.13', '3.14']
env:
SERIOUS_PYTHON_VERSION: ${{ matrix.python_version }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Flutter
uses: kuhnroyal/flutter-fvm-config-action/setup@v3
with:
path: '.fvmrc'
cache: true
- name: Package + run integration test
working-directory: "src/serious_python_bridge/example"
run: |
dart run serious_python:main package app/src --platform Darwin --python-version ${{ matrix.python_version }}
flutter test integration_test -d macos
test_bridge_example_ios:
name: Bridge example iOS round-trip (Python ${{ matrix.python_version }})
runs-on: macos-latest
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
python_version: ['3.12', '3.13', '3.14']
env:
SERIOUS_PYTHON_VERSION: ${{ matrix.python_version }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Flutter
uses: kuhnroyal/flutter-fvm-config-action/setup@v3
with:
path: '.fvmrc'
cache: true
- name: Setup iOS Simulator
id: simulator
uses: futureware-tech/simulator-action@v4
with:
model: 'iPhone 16 Pro Max'
os: "iOS"
os_version: "^18.6"
shutdown_after_job: true
wait_for_boot: true
- name: Package + run integration test
working-directory: "src/serious_python_bridge/example"
run: |
# certifi is a placeholder requirement: serious_python_darwin's
# sync_site_packages.sh only populates dist_ios/site-xcframeworks
# (which bundle-python-frameworks-ios.sh then requires at build
# time) when iOS-specific site-packages subdirs exist. Empty
# --requirements skips that branch and the build fails.
dart run serious_python:main package app/src --platform iOS --python-version ${{ matrix.python_version }} --requirements certifi
flutter test integration_test --device-id ${{ steps.simulator.outputs.udid }}
test_bridge_example_linux:
name: Bridge example Linux ${{ matrix.title }} round-trip (Python ${{ matrix.python_version }})
runs-on: ${{ matrix.runner }}
needs: test_wheel_build
strategy:
fail-fast: false
matrix:
python_version: ['3.12', '3.13', '3.14']
arch: [arm64, amd64]
include:
- arch: arm64
runner: ubuntu-24.04-arm
title: ARM64
wheel_artifact: bridge-wheels-ubuntu-24.04-arm
- arch: amd64
runner: ubuntu-24.04
title: AMD64
wheel_artifact: bridge-wheels-ubuntu-24.04
env:
SERIOUS_PYTHON_VERSION: ${{ matrix.python_version }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup uv
uses: astral-sh/setup-uv@v6
- name: Get Flutter version from .fvmrc
uses: kuhnroyal/flutter-fvm-config-action/config@v3
id: fvm-config-action
with:
path: '.fvmrc'
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ steps.fvm-config-action.outputs.FLUTTER_VERSION }}
channel: ${{ matrix.arch == 'arm64' && 'master' || 'stable' }}
cache: true
- name: Install Linux desktop build deps
run: |
sudo apt-get update --allow-releaseinfo-change
sudo apt-get install -y xvfb libgtk-3-dev
if [ "${{ matrix.arch }}" = "amd64" ]; then
sudo apt-get install -y \
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \
libgstreamer-plugins-bad1.0-dev gstreamer1.0-plugins-base \
gstreamer1.0-plugins-good gstreamer1.0-plugins-bad \
gstreamer1.0-plugins-ugly gstreamer1.0-libav
else
sudo apt-get install -y \
clang ninja-build gstreamer1.0-plugins-bad \
gstreamer1.0-plugins-ugly gstreamer1.0-libav
fi
- name: Download dart_bridge wheel artifact
uses: actions/download-artifact@v4
with:
name: ${{ matrix.wheel_artifact }}
path: ${{ runner.temp }}/dart_bridge_wheels
- name: Pick matching wheel for this arch
id: wheel
run: |
set -euo pipefail
WHL=$(ls "${{ runner.temp }}/dart_bridge_wheels"/dart_bridge-*manylinux*.whl | head -n1)
test -n "$WHL"
echo "path=$WHL" >> "$GITHUB_OUTPUT"
echo "Picked: $WHL"
- name: Package + run integration test
working-directory: src/serious_python_bridge/example
run: |
flutter pub get
dart run serious_python:main package app/src \
--platform Linux \
--python-version ${{ matrix.python_version }} \
--requirements ${{ steps.wheel.outputs.path }}
xvfb-run flutter test integration_test -d linux
test_bridge_example_android:
name: Bridge example Android round-trip (Python ${{ matrix.python_version }})
runs-on: ubuntu-latest
needs: test_android_build
strategy:
fail-fast: false
matrix:
# x86_64 matches the emulator architecture below; only build/install
# for that ABI to keep CI fast.
python_version: ['3.12', '3.13', '3.14']
env:
SERIOUS_PYTHON_VERSION: ${{ matrix.python_version }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Flutter
uses: kuhnroyal/flutter-fvm-config-action/setup@v3
with:
path: '.fvmrc'
cache: true
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Gradle cache
uses: gradle/actions/setup-gradle@v3
- name: AVD cache
uses: actions/cache@v4
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-bridge
- name: Download Android bridge .so for x86_64
uses: actions/download-artifact@v4
with:
name: bridge-android-x86_64
path: ${{ runner.temp }}/dart_bridge_android
- name: Inject dart_bridge.abi3.so into bundled site-packages
run: |
# serious_python_android's gradle takes everything from
# SERIOUS_PYTHON_SITE_PACKAGES/<abi>/ and zips it as
# libpythonsitepackages.so, which ends up in the APK. Drop our
# cross-compiled dart_bridge.abi3.so there so Python finds it at
# runtime. The .so name has to be the canonical 'dart_bridge.abi3.so'
# for CPython's import to match.
set -euxo pipefail
ABI=x86_64
DEST="$SERIOUS_PYTHON_SITE_PACKAGES/$ABI"
mkdir -p "$DEST"
cp "${{ runner.temp }}/dart_bridge_android/dart_bridge.abi3-android-$ABI.so" \
"$DEST/dart_bridge.abi3.so"
ls -lh "$DEST"
- name: Setup Android Emulator + Run tests
uses: reactivecircus/android-emulator-runner@v2
env:
EMULATOR_PORT: 5554
with:
avd-name: android_emulator
api-level: 33
target: google_atd
arch: x86_64
profile: pixel_5
sdcard-path-or-size: 128M
ram-size: 2048M
disk-size: 4096M
emulator-port: ${{ env.EMULATOR_PORT }}
disable-animations: true
emulator-options: -no-window -noaudio -no-boot-anim -wipe-data -cache-size 1000 -partition-size 8192
pre-emulator-launch-script: |
sdkmanager --list_installed
script: |
cd src/serious_python_bridge/example && dart run serious_python:main package app/src --platform Android --python-version ${{ matrix.python_version }}
cd src/serious_python_bridge/example && flutter test integration_test --device-id emulator-${{ env.EMULATOR_PORT }}
- name: Diagnostics on failure
if: failure()
shell: bash
run: |
set +e
REPO="$GITHUB_WORKSPACE"
ABI=x86_64
echo "=== serious_python_android/android/src/main/jniLibs/$ABI/ (post-extract source) ==="
ls -la "$REPO/src/serious_python_android/android/src/main/jniLibs/$ABI/" 2>/dev/null || echo "(not found)"
echo
echo "=== serious_python_bridge/android/src/main/jniLibs/$ABI/ ==="
ls -la "$REPO/src/serious_python_bridge/android/src/main/jniLibs/$ABI/" 2>/dev/null || echo "(not found)"
echo
echo "=== example/build/app/intermediates/merged_native_libs/.../$ABI/ ==="
find "$REPO/src/serious_python_bridge/example/build/app/intermediates/merged_native_libs" -type f 2>/dev/null
echo
echo "=== example/build/app/outputs/apk/debug/*.apk ==="
APK=$(find "$REPO/src/serious_python_bridge/example/build" -name "*-debug.apk" 2>/dev/null | head -n1)
echo "APK: $APK"
if [ -n "$APK" ]; then
echo "Native libs inside APK:"
unzip -l "$APK" | grep -E "lib/$ABI/" || echo "(no lib/$ABI/ entries)"
fi
echo
echo "=== installed app native lib dir (adb) ==="
adb shell run-as com.flet.serious_python_bridge_example ls -la /data/data/com.flet.serious_python_bridge_example/lib/ 2>/dev/null || true
adb shell pm path com.flet.serious_python_bridge_example 2>/dev/null || true
test_bridge_example_windows:
name: Bridge example Windows round-trip (Python ${{ matrix.python_version }})
runs-on: windows-latest
needs: test_wheel_build
strategy:
fail-fast: false
matrix:
python_version: ['3.12', '3.13', '3.14']
env:
SERIOUS_PYTHON_VERSION: ${{ matrix.python_version }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Flutter
uses: kuhnroyal/flutter-fvm-config-action/setup@v3
with:
path: '.fvmrc'
cache: true
- name: Download dart_bridge wheel artifact
uses: actions/download-artifact@v4
with:
name: bridge-wheels-windows-latest
path: ${{ runner.temp }}\dart_bridge_wheels
- name: Pick matching wheel
id: wheel
shell: bash
run: |
set -euo pipefail
WHL=$(ls "$RUNNER_TEMP/dart_bridge_wheels"/dart_bridge-*-win_amd64.whl | head -n1)
test -n "$WHL"
echo "path=$WHL" >> "$GITHUB_OUTPUT"
echo "Picked: $WHL"
- name: Package + run integration test
working-directory: "src/serious_python_bridge/example"
run: |
dart run serious_python:main package app/src --platform Windows --python-version ${{ matrix.python_version }} --requirements ${{ steps.wheel.outputs.path }}
flutter test integration_test -d windows
- name: Diagnostics on failure
if: failure()
shell: bash
working-directory: "src/serious_python_bridge/example"
run: |
DBG_DIR=build/windows/x64/runner/Debug
echo "=== runner/Debug dir ==="
ls -la $DBG_DIR/ || true
echo
echo "=== runner/Debug/site-packages ==="
ls -la $DBG_DIR/site-packages/ || true
echo
echo "=== shim log next to .exe ==="
cat $DBG_DIR/dart_bridge_shim.log 2>/dev/null || echo "(log not found)"
echo
echo "=== direct import test (catches the actual ImportError) ==="
# Try to import dart_bridge using the bundled Python so we can see
# the actual error. Python search paths the bundle uses.
PY_BUNDLED=$(find $DBG_DIR -maxdepth 1 -name "python.exe" -o -name "python3*.exe" 2>/dev/null | head -n1)
if [ -z "$PY_BUNDLED" ]; then
echo "(no python.exe in $DBG_DIR — trying system python)"
PY_BUNDLED=python
fi
(cd $DBG_DIR && PYTHONPATH="site-packages;." "$PY_BUNDLED" -c "import sys; print('sys.path:', sys.path); import dart_bridge; print('imported OK:', dart_bridge)") 2>&1 || true
echo
echo "=== look for stray flet_bridge.dll / dart_bridge.pyd ==="
find build -name "flet_bridge.dll" -o -name "dart_bridge*.pyd" || true