diff --git a/.claude/skills/forge-error-catalogue/references/failure-catalogue.md b/.claude/skills/forge-error-catalogue/references/failure-catalogue.md index 0ca4252..bcf86d9 100644 --- a/.claude/skills/forge-error-catalogue/references/failure-catalogue.md +++ b/.claude/skills/forge-error-catalogue/references/failure-catalogue.md @@ -1035,6 +1035,14 @@ uv run flet build apk # rebuild --- +### `ImportError: cannot import name '' from ''` for a native `.so` — Android, Flet ≥ 0.86 (serious_python 4.3.0) + +**Cause:** the traceback shows the package loading from `…/sitepackages.zip//__init__.pyc` and the missing name is a **native pybind/Cython extension** whose `.so` IS in the wheel. serious_python 4.3.0 (Flet 0.86) relocates native extension modules out of the stored `sitepackages.zip` into `jniLibs//lib.so` and makes them importable via a `sys.meta_path` finder that reads `.soref` markers — but ONLY for **ABI-tagged** names: `serious_python_android/android/build.gradle.kts` matches `extTag = /\.(cpython-[^/]+|abi3)\.so$/`, and anything else falls into the `rel.endsWith(".so") -> dep` branch (copied to jniLibs as a plain dependency lib, **no `.soref`**, never importable). A shim that stages a pybind module under a **plain** name (`_pywrap_….so`, `onnxruntime_pybind11_state.so`) trips exactly this. Normal Cython/pybind recipes are unaffected — their extensions already carry the target `EXT_SUFFIX` (android cp312 = `.cpython-312.so`). iOS is unaffected (different migration path); the same recipe passing pre-0.86 or on iOS is the tell. + +**Fix:** name the extension with the target `EXT_SUFFIX` so serious_python recognizes it (`.cpython-*.so` is also a valid CPython extension suffix, so the `from import ` still resolves). In the PEP 517 shim, stage it as `f"{sysconfig.get_config_var('EXT_SUFFIX')}"` instead of a plain `.so` (crossenv returns the target suffix; the recipe already sets `_PYTHON_SYSCONFIGDATA_NAME`). If the recipe's `setup.py` builds `package_data` by globbing hardcoded plain names (onnxruntime's `data = [… for x in libs if glob(… x)]`), also patch that to wildcard the pybind name and package the real basename, else the retagged `.so` is dropped from the wheel. Precedent: `machine/tflite-runtime` (`_pywrap_tensorflow_interpreter_wrapper.cpython-312.so`) and `machine/onnxruntime` (shim retag + setup.py glob patch). Verify: `unzip -l | grep '\.cpython-'`. + +--- + ### App launches but `import X` shows old version **Cause:** flet-cli has a hash-based cache (`build/.hash/`) that decides whether to re-run the pip install step. If you only updated wheels in `dist/` but pyproject deps didn't change, the cache thinks nothing has changed. @@ -1203,6 +1211,25 @@ If the wheel for the right tag isn't there, build it: `forge `. --- +### `No matching distribution found for ` at APK build — the recipe has `excluded_arches` + +**Cause:** the recipe declares `excluded_arches` (e.g. onnxruntime drops +`armeabi-v7a`), so no wheel is built for that ABI. But `flet build apk` defaults to +**all** Android ABIs (`arm64-v8a`, `x86_64`, `armeabi-v7a`), and pip fails to resolve +the package for the excluded ABI. The other-recipe tell: a recipe with all ABIs +(tflite) passes while the excluded-ABI recipe fails at the *same* step. Only bites +under the recipe-tester's app build (build wheels succeed on every leg). + +**Fix:** restrict the app's Android target to the built ABIs — +`[tool.flet.android] target_arch = ["arm64-v8a", "x86_64"]` (drop the excluded ones; +keep `x86_64` for the CI emulator). `tests/recipe-tester/stage_recipe.sh` now derives +this automatically from the wheels present in `dist-test`/`dist` and writes it into +the generated pyproject, so a recipe with `excluded_arches` no longer needs manual +handling in CI. For a real consumer app, the author sets `target_arch` themselves — +this is the mandatory `target_arch` note that belongs in the recipe's Consumer notes. + +--- + ### `flet build` pip-backtracks a pure-python dep to an ancient version → runtime crash (omegaconf 2.0.0 `UnsupportedValueType: PosixPath`) **Cause:** flet's mobile resolution requires **wheels**, and a pure-python dep in diff --git a/.claude/skills/new-mobile-recipe/SKILL.md b/.claude/skills/new-mobile-recipe/SKILL.md index 253b1a1..00d3ff2 100644 --- a/.claude/skills/new-mobile-recipe/SKILL.md +++ b/.claude/skills/new-mobile-recipe/SKILL.md @@ -89,6 +89,8 @@ The shim's load-bearing rules (each one bought with a failed build): - Prefer **`-D_BUILD_SHARED_LIB=OFF` → one statically-linked pybind module**: no ctypes/dylib gate, and it is exactly the wheel shape that works on BOTH platforms today (onnxruntime's Android design turned out to BE the iOS wheel shape; pywhispercpp/ncnn are the same shape). - CMake args ride in a recipe `script_env` var (`FORGE_CMAKE_ARGS`) that the shim `shlex.split`s. **Multi-token `-D` values cannot ride inside it** — give linker-flag strings their own env var and let the shim assemble the single `-D` argument (tflite's `FORGE_SHARED_LINKER_FLAGS: -Wl,-z,max-page-size=16384 -L{HOST_PYTHON_HOME}/lib -lpython{py_version_short}`). - **Trap — `setup.py` platform predicates:** under the crossenv `platform.system()` returns `"Android"` / `"iOS"`, which may match NO upstream branch → the libs list stays unset and the wheel silently ships **without the pybind `.so`** (onnxruntime extends upstream's predicate to `("Linux", "AIX", "Android", "iOS")`; the Darwin branch stays intact for real macOS builds). +- **Name the staged extension with the target `EXT_SUFFIX`, never a plain `.so`.** A shim that hand-stages the pybind module (`shutil.copy2(built, pkg / "_foo.so")`) must instead write `pkg / f"_foo{sysconfig.get_config_var('EXT_SUFFIX')}"` (android cp312 → `_foo.cpython-312.so`). Flet 0.86's serious_python only relocates ABI-tagged (`cpython-*`/`abi3`) extension modules into an importable jniLibs/`.soref` slot on Android; a plain `.so` is misfiled as a dependency lib and fails on-device with `cannot import name` (builds green, iOS fine — a nasty android-only surprise). `.cpython-*.so` is a valid CPython suffix so the import still resolves. If `setup.py` builds `package_data` by globbing hardcoded plain `.so` names, patch that too (wildcard the pybind name, package the real basename) or the retagged file is dropped. See the `forge-error-catalogue` entry "cannot import name … for a native `.so` — Android, Flet ≥ 0.86". + - **Cross-slice cleanup:** forge reuses the unpacked source tree across arch slices in one invocation, so if the shim stages into a dir it does NOT wipe each hook (e.g. an overlay `copytree(dirs_exist_ok=True)`), a prior slice's tagged `.so` lingers. Android is safe (all ABIs share `.cpython-312.so` → overwritten), but **iOS suffixes differ per slice** (`.cpython-312-iphoneos.so` vs `-iphonesimulator.so`) so the wheel ships BOTH and serious_python's `.so`→framework conversion fails on-device. Either `rmtree` the package dir before staging (tflite's shim) or, after retagging, delete every sibling `*.so` that isn't the current slice's target (onnxruntime's shim). **Real examples:** `machine/onnxruntime:recipes/onnxruntime/` and `machine/tflite-runtime:recipes/tflite-runtime/` — read via `git show :` if not on those branches. Both are ONE `mobile.patch` (description as text preamble above the first `---` header, per repo convention). diff --git a/recipes/onnxruntime/patches/mobile.patch b/recipes/onnxruntime/patches/mobile.patch index c46a72b..91bcbd0 100644 --- a/recipes/onnxruntime/patches/mobile.patch +++ b/recipes/onnxruntime/patches/mobile.patch @@ -8,7 +8,12 @@ following the sequence Termux proved for Android (their python-onnxruntime wrapping setuptools.build_meta that first runs the CMake build (source = ./cmake, binary dir = the source ROOT, args from FORGE_CMAKE_ARGS) so the staged python package lands in ./onnxruntime/capi/ where the root - setup.py expects it, then delegates wheel assembly to setuptools. + setup.py expects it, then delegates wheel assembly to setuptools. The + shim also retags the pybind module to the target EXT_SUFFIX (e.g. + onnxruntime_pybind11_state.cpython-312.so): serious_python's android + packager only relocates ABI-tagged (cpython-*/abi3) extension modules + into jniLibs with an importable .soref marker, so a plain '.so' is + misfiled as a dependency lib and never becomes importable on device. 2. cmake/CMakeLists.txt (Termux 0004): find Python COMPONENTS Development before Development.Module -- under a crossenv, the Module-only lookup @@ -17,13 +22,16 @@ following the sequence Termux proved for Android (their python-onnxruntime 3. mlas sqnbitgemm avx2 header (Termux 0006): include onnxruntime_config.h for the x86_64 lane. -4. setup.py: extend the packaged-libs predicate from ("Linux", "AIX") to - also match "Android"/"iOS" -- under the crossenv platform.system() - reports the TARGET OS, which matches no upstream branch, so `libs` - stays empty and the build goes green while the wheel silently ships - WITHOUT the pybind .so (Termux hand-installs it post-hoc instead; we - fix the predicate). The Darwin branch stays intact for real macOS - builds. +4. setup.py: two changes. (a) extend the packaged-libs predicate from + ("Linux", "AIX") to also match "Android"/"iOS" -- under the crossenv + platform.system() reports the TARGET OS, which matches no upstream + branch, so `libs` stays empty and the build goes green while the wheel + silently ships WITHOUT the pybind .so (Termux hand-installs it post-hoc + instead; we fix the predicate). The Darwin branch stays intact for real + macOS builds. (b) the package_data glob keys off the plain `libs` names, + so after the shim retags the pybind module (item 1) it would no longer + match -- wildcard the pybind name when globbing and package the real + basename, so the tagged .so is actually included. Deliberately NOT taken from Termux: their 0001/0002 (system abseil and protobuf -- we want the FetchContent vendored builds) and 0003 (Android @@ -36,7 +44,7 @@ protoc_mac_universal v21.12 on macOS hosts. diff -ruN a/_forge/forge_ort_backend.py b/_forge/forge_ort_backend.py --- a/_forge/forge_ort_backend.py 1970-01-01 01:00:00 +++ b/_forge/forge_ort_backend.py 2026-07-08 18:23:28 -@@ -0,0 +1,75 @@ +@@ -0,0 +1,96 @@ +"""mobile-forge PEP 517 shim for onnxruntime. + +Upstream produces wheels via ci_build/build.py: CMake builds the tree and @@ -55,10 +63,12 @@ diff -ruN a/_forge/forge_ort_backend.py b/_forge/forge_ort_backend.py + slice 1's architecture (the e_machine mismatch the first CI run caught). +""" + ++import glob +import os +import shlex +import shutil +import subprocess ++import sysconfig + +from setuptools import build_meta as _orig +from setuptools.build_meta import * # noqa: F401,F403 @@ -97,6 +107,25 @@ diff -ruN a/_forge/forge_ort_backend.py b/_forge/forge_ort_backend.py + # cheap, idempotent, and guarantees the CURRENT ABI wins. + shutil.copytree(os.path.join(bindir, "onnxruntime"), "onnxruntime", + dirs_exist_ok=True) ++ # Tag the pybind extension with the target EXT_SUFFIX (e.g. ++ # .cpython-312.so) so serious_python's android packager treats it as an ++ # importable extension module: its jniLibs/.soref meta_path finder only ++ # relocates cpython-*/abi3 names -- a plain '.so' is misfiled as a ++ # dependency lib and never becomes importable. ++ _capi = os.path.join("onnxruntime", "capi") ++ _ext = sysconfig.get_config_var("EXT_SUFFIX") or ".so" ++ _target = "onnxruntime_pybind11_state" + _ext ++ _plain = os.path.join(_capi, "onnxruntime_pybind11_state.so") ++ if os.path.exists(_plain): ++ os.replace(_plain, os.path.join(_capi, _target)) ++ # forge reuses the source tree across slices and copytree(dirs_exist_ok) ++ # never deletes, so a prior slice's tagged .so lingers -- on iOS the ++ # per-slice suffixes differ (iphoneos vs iphonesimulator) and the wheel ++ # would ship BOTH, breaking serious_python's .so->framework conversion. ++ # Drop every onnxruntime_pybind11_state*.so that isn't this slice's. ++ for _f in glob.glob(os.path.join(_capi, "onnxruntime_pybind11_state*.so")): ++ if os.path.basename(_f) != _target: ++ os.remove(_f) + + +def get_requires_for_build_wheel(config_settings=None): @@ -161,3 +190,17 @@ diff -ruN a/setup.py b/setup.py libs = [ "onnxruntime_pybind11_state.so", "libdnnl.so.2", +@@ -540,4 +540,12 @@ + ] + else: +- data = [path.join("capi", x) for x in libs if glob(path.join("onnxruntime", "capi", x))] ++ # mobile-forge: pybind module ships with the target EXT_SUFFIX (e.g. ++ # .cpython-312.so) so serious_python relocates it as an importable ++ # extension. Glob wildcards the plain pybind name and we package the ++ # real basename (package_data must name the on-disk file). ++ data = [] ++ for x in libs: ++ _pat = "onnxruntime_pybind11_state*.so" if x == "onnxruntime_pybind11_state.so" else x ++ for f in glob(path.join("onnxruntime", "capi", _pat)): ++ data.append(path.join("capi", path.basename(f))) + ext_modules = [] diff --git a/recipes/tflite-runtime/meta.yaml b/recipes/tflite-runtime/meta.yaml new file mode 100644 index 0000000..44cd1de --- /dev/null +++ b/recipes/tflite-runtime/meta.yaml @@ -0,0 +1,80 @@ +{% set version = "2.21.0" %} + +package: + name: tflite-runtime + version: '{{ version }}' + +build: + number: 1 + script_env: + _PYTHON_SYSCONFIGDATA_NAME: '{sysconfigdata_name}' + # Consumed by the patch-added PEP 517 shim (_forge/forge_tflite_backend.py): + # standalone host-flatc build + cross cmake of tensorflow/lite building + # ONLY the _pywrap_tensorflow_interpreter_wrapper pybind module, then + # upstream's exact 5-file python package staging. The wrapper target has + # no FindPython -- Python/numpy/pybind11 headers are plain -I injections + # (upstream's own mechanism); numpy+pybind11 resolve from the cross venv + # via {platlib}, no imports needed (target numpy is not host-runnable). + FORGE_WRAPPER_INCLUDES: >- + -I{HOST_PYTHON_HOME}/include/python{py_version_short} + -I{platlib}/numpy/_core/include + -I{platlib}/pybind11/include +# {% if sdk == 'android' %} + # Multi-token -D values can't ride inside FORGE_CMAKE_ARGS (the shim + # shlex-splits it), so linker flags get their own var. libpython is + # linked explicitly like every other forge pybind wheel: upstream + # relies on manylinux-style lazy resolution, Android wants DT_NEEDED. + FORGE_SHARED_LINKER_FLAGS: >- + -Wl,-z,max-page-size=16384 + -L{HOST_PYTHON_HOME}/lib -lpython{py_version_short} + # XNNPACK stays at its default (ON) except armeabi-v7a, where the shim + # follows upstream's own pip script convention (OFF for all 32-bit arm). + FORGE_CMAKE_ARGS: >- + -DCMAKE_BUILD_TYPE=Release + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 + -DCMAKE_TOOLCHAIN_FILE={NDK_ROOT}/build/cmake/android.toolchain.cmake + -DANDROID_ABI={ANDROID_ABI} + -DANDROID_NATIVE_API_LEVEL={ANDROID_API_LEVEL} + -DANDROID_STL=c++_shared +# {% else %} + # iOS: the wrapper target links no libpython (no FindPython anywhere), + # and apple's two-level namespace would fail the link on the undefined + # python symbols -- resolve them at dlopen instead. + FORGE_SHARED_LINKER_FLAGS: -Wl,-undefined,dynamic_lookup + FORGE_CMAKE_ARGS: >- + -DCMAKE_BUILD_TYPE=Release + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 + -DCMAKE_SYSTEM_NAME=iOS + -DCMAKE_OSX_SYSROOT={{ sdk }} + -DCMAKE_OSX_DEPLOYMENT_TARGET={{ sdk_version }} + -DCMAKE_OSX_ARCHITECTURES={{ arch }} + -DFLATBUFFERS_BUILD_FLATC=OFF + -DFLATBUFFERS_INSTALL=OFF +# {% endif %} + +source: + # tflite-runtime has no sdist (upstream wheels come from + # tensorflow/lite/tools/pip_package/build_pip_package_with_cmake.sh); + # build from the TF release tree. The lite/ CMake superbuild fetches its + # pinned deps (abseil/eigen/xnnpack/ruy/flatbuffers/...) at configure. + url: https://github.com/tensorflow/tensorflow/archive/refs/tags/v{{ version }}.tar.gz + +requirements: + build: + - setuptools + - wheel + - cmake + host: + # Build-time headers only (via FORGE_WRAPPER_INCLUDES {platlib} paths). + # numpy>=2 headers yield binaries compatible with numpy 1.x AND 2.x; + # numpy is also the wheel's one runtime dep (install_requires in the + # shim's setup.py, matching upstream setup_with_binary.py). + - numpy ^2.0.0 + - pybind11 +# {% if sdk == 'android' %} + # C++20 -> libc++_shared.so at runtime (iOS links the system libc++). + - flet-libcpp-shared >=27.2.12479018 +# {% endif %} + +patches: + - mobile.patch diff --git a/recipes/tflite-runtime/patches/mobile.patch b/recipes/tflite-runtime/patches/mobile.patch new file mode 100644 index 0000000..fa3be74 --- /dev/null +++ b/recipes/tflite-runtime/patches/mobile.patch @@ -0,0 +1,294 @@ +tflite-runtime has no sdist and no setup.py anywhere in the TF tree -- the +supported wheel path is tensorflow/lite/tools/pip_package/ +build_pip_package_with_cmake.sh (host==target oriented; its cmake dispatch +has no android case -- python-for-android patches one in). This patch makes +the TF release tree buildable by forge's standard PEP 517 flow by ADDING +three files (the TF 2.21 root ships neither pyproject.toml nor setup.py, so +nothing is replaced): + +1. pyproject.toml: points the build at the shim backend below. + +2. _forge/forge_tflite_backend.py: replicates the upstream script inside + PEP 517 hooks -- standalone host-flatc build (cached per flatbuffers tag; + cross-configuring tensorflow/lite FATALs without -DTFLITE_HOST_TOOLS_DIR), + cross cmake of tensorflow/lite building ONLY the + _pywrap_tensorflow_interpreter_wrapper pybind target (per-ABI build dirs: + forge reuses the source tree across arch slices -- the onnxruntime + e_machine lesson), then upstream's exact 5-file tflite_runtime/ staging. + XNNPACK stays ON for every ABI: interpreter_wrapper.cc references + TfLiteXNNPackDelegate* unconditionally at link time, so the python wheel + cannot build with XNNPACK off at all. The configure pins + -DTENSORFLOW_SOURCE_DIR to the unpacked root: without it the lite build + git-clones its own TF at v2.19.0 (a stale upstream default) and compiles + this tree's sources against those older headers. The wrapper module is + staged under the target EXT_SUFFIX (e.g. .cpython-312.so), not a plain + .so: serious_python's android packager only relocates ABI-tagged + (cpython-*/abi3) extension modules into jniLibs with an importable .soref + marker; a plain '.so' is misfiled as a dependency lib and never + becomes importable on device. + +3. setup.py: adapted from setup_with_binary.py (version parsed from the + staged __init__.py instead of env vars; same numpy install_requires, + has_ext_modules for proper cpXX tagging under the crossenv). + +diff -ruN a/_forge/forge_tflite_backend.py b/_forge/forge_tflite_backend.py +--- a/_forge/forge_tflite_backend.py 1970-01-01 01:00:00 ++++ b/_forge/forge_tflite_backend.py 2026-07-09 13:28:40 +@@ -0,0 +1,204 @@ ++"""mobile-forge PEP 517 shim for tflite-runtime. ++ ++TensorFlow ships no sdist or setup.py for tflite-runtime; upstream wheels ++come from tensorflow/lite/tools/pip_package/build_pip_package_with_cmake.sh. ++This backend replicates that script's steps under forge's standard PEP 517 ++flow (python -m build at the source root): ++ ++1. Host flatc: cross-configuring tensorflow/lite FATALs without a host ++ flatbuffers compiler (-DTFLITE_HOST_TOOLS_DIR). Upstream configures the ++ ENTIRE lite tree a second time for the host just to build flatc; instead, ++ build flatc standalone from the exact tag pinned in ++ tools/cmake/modules/flatbuffers.cmake, cached in the system temp dir ++ keyed by that tag (a host-arch tool, safe to share across ABI slices -- ++ unlike the per-arch cache that bit ckzg). ++ ++2. Cross cmake of tensorflow/lite (args from FORGE_CMAKE_ARGS plus per-ABI ++ extras) building ONLY the _pywrap_tensorflow_interpreter_wrapper pybind ++ target. Build dirs are keyed per-ABI because forge reuses the unpacked ++ source tree across arch slices within one invocation (the onnxruntime ++ e_machine lesson). The cmake step runs before EVERY hook, guarded by a ++ marker file (hooks are separate subprocesses). ++ ++3. Stage ./tflite_runtime/ exactly like the upstream script (interpreter.py, ++ metrics_interface.py, metrics_portable.py, a generated __init__.py, and ++ the built .so), fresh on every hook so the current ABI always wins, then ++ delegate to setuptools.build_meta against the forge-added setup.py. ++""" ++ ++import os ++import re ++import shlex ++import shutil ++import subprocess ++import sysconfig ++import tempfile ++import urllib.request ++from pathlib import Path ++ ++from setuptools import build_meta as _orig ++from setuptools.build_meta import * # noqa: F401,F403 ++ ++_LITE = Path("tensorflow") / "lite" ++ ++ ++def _tf_version() -> str: ++ text = (Path("tensorflow") / "tf_version.bzl").read_text() ++ return re.search(r'TF_VERSION = "([^"]+)"', text).group(1) ++ ++ ++def _flatbuffers_tag() -> str: ++ text = (_LITE / "tools/cmake/modules/flatbuffers.cmake").read_text() ++ return re.search(r"GIT_TAG\s+(\S+)", text).group(1) ++ ++ ++def _host_env() -> dict: ++ # Scrub the android cross toolchain out of the environment for the host ++ # flatc build (CC and friends point at NDK clang here). ++ env = dict(os.environ) ++ for k in ( ++ "CC", "CXX", "AR", "LD", "RANLIB", "STRIP", "READELF", "NM", ++ "CFLAGS", "CXXFLAGS", "CPPFLAGS", "LDFLAGS", ++ "_PYTHON_SYSCONFIGDATA_NAME", "PYTHONPATH", ++ ): ++ env.pop(k, None) ++ return env ++ ++ ++def _jobs() -> str: ++ return f"-j{os.cpu_count() or 4}" ++ ++ ++def _host_flatc() -> Path: ++ tag = _flatbuffers_tag() ++ root = Path(tempfile.gettempdir()) / f"forge-tflite-host-flatc-{tag}" ++ marker = root / ".done" ++ if not marker.exists(): ++ shutil.rmtree(root, ignore_errors=True) ++ (root / "src").mkdir(parents=True) ++ tarball = root / "flatbuffers.tar.gz" ++ urllib.request.urlretrieve( ++ f"https://github.com/google/flatbuffers/archive/refs/tags/{tag}.tar.gz", ++ tarball, ++ ) ++ subprocess.check_call( ++ ["tar", "-xzf", str(tarball), "-C", str(root / "src"), ++ "--strip-components", "1"] ++ ) ++ env = _host_env() ++ subprocess.check_call( ++ ["cmake", "-S", str(root / "src"), "-B", str(root / "build"), ++ "-DCMAKE_BUILD_TYPE=Release", "-DFLATBUFFERS_BUILD_TESTS=OFF"], ++ env=env, ++ ) ++ subprocess.check_call( ++ ["cmake", "--build", str(root / "build"), _jobs(), "-t", "flatc"], ++ env=env, ++ ) ++ (root / "bin").mkdir() ++ shutil.copy2(root / "build" / "flatc", root / "bin" / "flatc") ++ marker.write_text("ok\n") ++ return root ++ ++ ++def _cmake_build() -> None: ++ args = shlex.split(os.environ.get("FORGE_CMAKE_ARGS", "")) ++ if not args: ++ raise RuntimeError( ++ "FORGE_CMAKE_ARGS must carry the cross-compile CMake arguments" ++ ) ++ # Per-slice build dir key: ANDROID_ABI on android, sysroot + arch on ++ # iOS (device/sim x arm64/x86_64) -- the onnxruntime-iOS lesson. ++ abi = "default" ++ sysroot = arch = "" ++ for a in args: ++ if a.startswith("-DANDROID_ABI="): ++ abi = a.split("=", 1)[1] ++ elif a.startswith("-DCMAKE_OSX_SYSROOT="): ++ sysroot = a.split("=", 1)[1] ++ elif a.startswith("-DCMAKE_OSX_ARCHITECTURES="): ++ arch = a.split("=", 1)[1] ++ if sysroot or arch: ++ abi = f"{sysroot}-{arch}" ++ ++ ver = _tf_version() ++ major, minor, patch = ver.split(".")[:3] ++ includes = os.environ.get("FORGE_WRAPPER_INCLUDES", "").strip() ++ flags = ( ++ f"{includes} -DTF_MAJOR_VERSION={major} -DTF_MINOR_VERSION={minor}" ++ f" -DTF_PATCH_VERSION={patch} -DTF_VERSION_SUFFIX=''" ++ ) ++ extra = [f"-DCMAKE_C_FLAGS={flags}", f"-DCMAKE_CXX_FLAGS={flags}"] ++ linker = os.environ.get("FORGE_SHARED_LINKER_FLAGS", "").strip() ++ if linker: ++ extra.append(f"-DCMAKE_SHARED_LINKER_FLAGS={linker}") ++ # XNNPACK stays ON for every ABI (the default): interpreter_wrapper.cc ++ # calls TfLiteXNNPackDelegate* unconditionally at link time, so the ++ # python wheel cannot be built with TFLITE_ENABLE_XNNPACK=OFF at all -- ++ # upstream's own 32-bit-arm OFF convention predates that code and only ++ # works for the C++ library. p4a shipped XNNPACK-on-v7a at TF 2.8. ++ ++ bindir = Path(".forge_build") / abi ++ marker = bindir / ".done" ++ if not marker.exists(): ++ host_tools = _host_flatc() ++ # Without TENSORFLOW_SOURCE_DIR the lite build git-clones its own ++ # TF pinned at v2.19.0 (!) and compiles OUR sources against THOSE ++ # headers -- first slice died on a header that only exists in 2.21. ++ subprocess.check_call( ++ ["cmake", "-S", str(_LITE), "-B", str(bindir), *args, *extra, ++ f"-DTENSORFLOW_SOURCE_DIR={Path.cwd()}", ++ f"-DTFLITE_HOST_TOOLS_DIR={host_tools}"] ++ ) ++ subprocess.check_call( ++ ["cmake", "--build", str(bindir), _jobs(), "-t", ++ "_pywrap_tensorflow_interpreter_wrapper"] ++ ) ++ marker.write_text("ok\n") ++ ++ # Stage the python package fresh on every hook (current ABI wins). ++ pkg = Path("tflite_runtime") ++ shutil.rmtree(pkg, ignore_errors=True) ++ pkg.mkdir() ++ for src in ( ++ _LITE / "python" / "interpreter.py", ++ _LITE / "python" / "metrics" / "metrics_interface.py", ++ _LITE / "python" / "metrics" / "metrics_portable.py", ++ ): ++ shutil.copy2(src, pkg / src.name) ++ (pkg / "__init__.py").write_text( ++ f"__version__ = '{ver}'\n__git_version__ = 'v{ver}'\n" ++ ) ++ # CMake names the SHARED wrapper .so on android/linux but .dylib on ++ # apple; the python module must ship as .so either way (a dylib is ++ # perfectly loadable under the extension name -- the lightgbm trick). ++ so = bindir / "_pywrap_tensorflow_interpreter_wrapper.so" ++ if not so.exists(): ++ so = bindir / "lib_pywrap_tensorflow_interpreter_wrapper.dylib" ++ if not so.exists(): ++ so = bindir / "_pywrap_tensorflow_interpreter_wrapper.dylib" ++ # Stage under the target EXT_SUFFIX (e.g. .cpython-312.so), not a plain ++ # .so: serious_python's android packager only relocates ABI-tagged ++ # extension modules (cpython-*/abi3) into jniLibs with an importable ++ # .soref marker -- a plain '.so' is misfiled as a dependency lib ++ # and never becomes importable on device. The tag is a valid CPython ++ # EXTENSION_SUFFIX so the `from tflite_runtime import ...` still resolves. ++ ext = sysconfig.get_config_var("EXT_SUFFIX") or ".so" ++ shutil.copy2(so, pkg / f"_pywrap_tensorflow_interpreter_wrapper{ext}") ++ ++ ++def get_requires_for_build_wheel(config_settings=None): ++ _cmake_build() ++ return _orig.get_requires_for_build_wheel(config_settings) ++ ++ ++def prepare_metadata_for_build_wheel(metadata_directory, config_settings=None): ++ _cmake_build() ++ return _orig.prepare_metadata_for_build_wheel( ++ metadata_directory, config_settings ++ ) ++ ++ ++def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): ++ _cmake_build() ++ return _orig.build_wheel(wheel_directory, config_settings, metadata_directory) +diff -ruN a/pyproject.toml b/pyproject.toml +--- a/pyproject.toml 1970-01-01 01:00:00 ++++ b/pyproject.toml 2026-07-09 13:28:19 +@@ -0,0 +1,4 @@ ++[build-system] ++requires = ["setuptools>=61.0.0", "wheel"] ++build-backend = "forge_tflite_backend" ++backend-path = ["_forge"] +diff -ruN a/setup.py b/setup.py +--- a/setup.py 1970-01-01 01:00:00 ++++ b/setup.py 2026-07-09 13:28:19 +@@ -0,0 +1,41 @@ ++"""mobile-forge setuptools config for the tflite-runtime wheel. ++ ++Adapted from tensorflow/lite/tools/pip_package/setup_with_binary.py (which ++reads PROJECT_NAME/PACKAGE_VERSION from env vars set by upstream's build ++script). The PEP 517 shim (_forge/forge_tflite_backend.py) stages ++./tflite_runtime/ -- including the cross-compiled pybind module -- before ++setuptools runs. ++""" ++ ++import re ++ ++from setuptools import setup ++ ++with open("tflite_runtime/__init__.py") as f: ++ version = re.search(r"__version__ = '([^']+)'", f.read()).group(1) ++ ++DESCRIPTION = "TensorFlow Lite is for mobile and embedded devices." ++ ++setup( ++ name="tflite-runtime", ++ version=version, ++ description=DESCRIPTION, ++ long_description=DESCRIPTION, ++ url="https://www.tensorflow.org/lite/", ++ author="Google, LLC", ++ author_email="packages@tensorflow.org", ++ license="Apache 2.0", ++ include_package_data=True, ++ has_ext_modules=lambda: True, ++ keywords="tflite tensorflow tensor machine learning", ++ packages=["tflite_runtime"], ++ package_data={"tflite_runtime": ["*.so"]}, ++ install_requires=[ ++ "numpy >= 1.23.2", ++ ], ++ # The TF source root carries a Bazel `BUILD` file, which collides with ++ # setuptools' default `build/` dir on case-insensitive filesystems ++ # (macOS local builds): "could not create 'build/lib...': Not a ++ # directory". Build somewhere else. ++ options={"build": {"build_base": ".forge_pybuild"}}, ++) diff --git a/recipes/tflite-runtime/tests/dense_relu.tflite b/recipes/tflite-runtime/tests/dense_relu.tflite new file mode 100644 index 0000000..0c16092 Binary files /dev/null and b/recipes/tflite-runtime/tests/dense_relu.tflite differ diff --git a/recipes/tflite-runtime/tests/requirements.txt b/recipes/tflite-runtime/tests/requirements.txt new file mode 100644 index 0000000..24ce15a --- /dev/null +++ b/recipes/tflite-runtime/tests/requirements.txt @@ -0,0 +1 @@ +numpy diff --git a/recipes/tflite-runtime/tests/test_tflite_runtime.py b/recipes/tflite-runtime/tests/test_tflite_runtime.py new file mode 100644 index 0000000..f318715 --- /dev/null +++ b/recipes/tflite-runtime/tests/test_tflite_runtime.py @@ -0,0 +1,53 @@ +import os + +MODEL = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dense_relu.tflite") + + +def test_real_inference(): + """Real inference through the bundled 1KB dense+relu model (generated and + validated with desktop TF 2.21: fixed weights, seed 42). Runs everywhere.""" + import numpy as np + from tflite_runtime.interpreter import Interpreter + + interpreter = Interpreter(model_path=MODEL) + interpreter.allocate_tensors() + inp = interpreter.get_input_details()[0] + out = interpreter.get_output_details()[0] + + assert inp["shape"].tolist() == [1, 4] + assert out["shape"].tolist() == [1, 3] + + x = np.array([[1.0, 2.0, 3.0, 4.0]], dtype=np.float32) + interpreter.set_tensor(inp["index"], x) + interpreter.invoke() + y = interpreter.get_tensor(out["index"]) + + # Desktop-recorded expectation (XNNPACK delegate): relu clamps lanes 0-1. + expected = np.array([[0.0, 0.0, 2.817584753036499]], dtype=np.float32) + np.testing.assert_allclose(y, expected, rtol=1e-5, atol=1e-6) + + +def test_invoke_twice(): + """Tensor re-set + second invoke exercises the wrapper's buffer + handling; a zeros probe reduces the graph to relu(bias), whose value is + desktop-recorded.""" + import numpy as np + from tflite_runtime.interpreter import Interpreter + + interpreter = Interpreter(model_path=MODEL) + interpreter.allocate_tensors() + inp = interpreter.get_input_details()[0] + out = interpreter.get_output_details()[0] + + interpreter.set_tensor( + inp["index"], np.array([[1.0, 2.0, 3.0, 4.0]], dtype=np.float32) + ) + interpreter.invoke() + first = interpreter.get_tensor(out["index"]).copy() + assert first.max() > 0 + + interpreter.set_tensor(inp["index"], np.zeros((1, 4), dtype=np.float32)) + interpreter.invoke() + second = interpreter.get_tensor(out["index"]) + expected = np.array([[0.6648852825164795, 0.0, 0.0]], dtype=np.float32) + np.testing.assert_allclose(second, expected, rtol=1e-5, atol=1e-6) diff --git a/tests/recipe-tester/stage_recipe.sh b/tests/recipe-tester/stage_recipe.sh index 65d1b23..8d206db 100755 --- a/tests/recipe-tester/stage_recipe.sh +++ b/tests/recipe-tester/stage_recipe.sh @@ -101,6 +101,41 @@ while IFS= read -r tpl_line || [ -n "$tpl_line" ]; do fi done < "$TPL" +# 3. Restrict the Android target_arch to the ABIs we actually built a wheel +# for. A recipe with `excluded_arches` (e.g. onnxruntime drops +# armeabi-v7a) has no wheel for the excluded ABI, so `flet build apk`'s +# default all-ABI target fails pip resolution ("No matching distribution") +# for it. Derive target_arch from the wheels present in dist-test/dist and +# write it into the generated pyproject; a recipe with wheels for every +# ABI (or a pure-Python one with no per-ABI wheel) is left unrestricted. +ARCH_TOML=$(python3 - "$RECIPE" "$REPO_ROOT" <<'PY' +import sys, os, glob, re +recipe, repo = sys.argv[1], sys.argv[2] +norm = lambda s: re.sub(r'[-_.]+', '_', s).lower() +want = norm(recipe) +order = ["arm64-v8a", "x86_64", "armeabi-v7a"] +tag2arch = {"arm64_v8a": "arm64-v8a", "x86_64": "x86_64", "armeabi_v7a": "armeabi-v7a"} +found = set() +for d in ("dist-test", "dist"): + for whl in glob.glob(os.path.join(repo, d, "*.whl")): + b = os.path.basename(whl).lower() + if norm(b.split("-", 1)[0]) != want: + continue + m = re.search(r'android_\d+_(\w+)\.whl$', b) + if m and m.group(1) in tag2arch: + found.add(tag2arch[m.group(1)]) +# Only restrict when we have platform wheels for a proper, non-empty subset. +if found and found != set(order): + abis = [a for a in order if a in found] + print("[tool.flet.android]") + print("target_arch = [" + ", ".join('"%s"' % a for a in abis) + "]") +PY +) +if [ -n "$ARCH_TOML" ]; then + printf '\n%s\n' "$ARCH_TOML" >> "$OUT" + echo " android target_arch restricted (excluded ABIs have no wheel): $(printf '%s' "$ARCH_TOML" | tr '\n' ' ')" +fi + echo "Staged recipe '$RECIPE' (dep: $DEP)" echo " recipe_tests/:" ls -1 "$TEST_DIR" | sed 's/^/ /'