From 3c6bd2391e6622059b6a05252cc089bf966bcfb6 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 8 Jul 2026 17:33:14 +0200 Subject: [PATCH 01/10] recipe: tflite-runtime 2.21.0 (the .tflite ecosystem on Android) No sdist has ever existed (upstream wheels come from tensorflow/lite/tools/pip_package/build_pip_package_with_cmake.sh, whose cmake dispatch has no android case; python-for-android patches one in on a TF 2.8 pin). Build from the TF v2.21.0 tag - the legacy CMake pip path is still intact there and tf.lite has not yet moved out to LiteRT. mobile.patch ADDS three files (TF root ships neither pyproject.toml nor setup.py, nothing replaced): a PEP 517 shim (host-flatc build + cross cmake of tensorflow/lite building ONLY the pybind wrapper target + upstream's exact 5-file tflite_runtime/ staging) and an adapted setup_with_binary.py. The wrapper has no FindPython: Python/numpy/pybind11 headers ride -I injections ({platlib} paths, no target-numpy imports); libpython is linked explicitly (Android wants the DT_NEEDED; upstream relies on manylinux lazy resolution). Three fights, all captured in the patch/shim comments: - TENSORFLOW_SOURCE_DIR must be pinned to the unpacked root: unset, the lite build git-clones ITS OWN TensorFlow at v2.19.0 and compiles this tree's sources against those stale headers (first slice died on a header that only exists in 2.21). - TF's Bazel `BUILD` file collides with setuptools' `build/` dir on case-insensitive filesystems (macOS local builds) -> build_base moved. - XNNPACK cannot be turned off for the python wheel on ANY ABI: interpreter_wrapper.cc references TfLiteXNNPackDelegate* at link time. Upstream's own 32-bit-arm OFF convention only works for the C++ lib; XNNPACK-on-v7a builds fine with NDK r27 (p4a shipped it at TF 2.8). Host flatc: standalone build of the exact flatbuffers tag TF pins (parsed from tools/cmake/modules/flatbuffers.cmake), cached in the temp dir per tag - host-arch tool, safe to share across ABI slices, and much cheaper than upstream's second full lite configure. Local matrix 3/3 (arm64-v8a, x86_64 - the p4a-era x86_64 failure does not reproduce on 2.21 - and armeabi-v7a). Wheels 1.9-2.8MB, upstream's exact package layout, 16KB-aligned, minimal DT_NEEDED. On-device arm64 emulator: 4/4 PASSED incl. REAL inference through XNNPACK on a 1KB committed dense+relu model (desktop-recorded expectations matched exactly; tests/requirements.txt pulls numpy - the wheel's one runtime dep). tf.lite.Interpreter deprecation warning is upstream cosmetics; ai-edge-litert (the successor) is Bazel-only and stays infeasible. --- recipes/tflite-runtime/meta.yaml | 63 +++++ recipes/tflite-runtime/patches/mobile.patch | 265 ++++++++++++++++++ .../tflite-runtime/tests/dense_relu.tflite | Bin 0 -> 1032 bytes recipes/tflite-runtime/tests/requirements.txt | 1 + .../tests/test_tflite_runtime.py | 66 +++++ 5 files changed, 395 insertions(+) create mode 100644 recipes/tflite-runtime/meta.yaml create mode 100644 recipes/tflite-runtime/patches/mobile.patch create mode 100644 recipes/tflite-runtime/tests/dense_relu.tflite create mode 100644 recipes/tflite-runtime/tests/requirements.txt create mode 100644 recipes/tflite-runtime/tests/test_tflite_runtime.py diff --git a/recipes/tflite-runtime/meta.yaml b/recipes/tflite-runtime/meta.yaml new file mode 100644 index 0000000..be0aaca --- /dev/null +++ b/recipes/tflite-runtime/meta.yaml @@ -0,0 +1,63 @@ +package: + name: tflite-runtime + version: "2.21.0" + # Android-first like onnxruntime/sherpa-onnx; a TFLite iOS wheel is a + # separate spike (Metal delegate, different packaging). + platforms: [android] + +source: + # tflite-runtime has never had an 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/v2.21.0.tar.gz + +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 + # 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 + +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 + # C++20 -> libc++_shared.so at runtime. + - flet-libcpp-shared >=27.2.12479018 + +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..13a50ac --- /dev/null +++ b/recipes/tflite-runtime/patches/mobile.patch @@ -0,0 +1,265 @@ +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. + +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-08 17:26:49 +@@ -0,0 +1,180 @@ ++"""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 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" ++ ) ++ abi = next( ++ (a.split("=", 1)[1] for a in args if a.startswith("-DANDROID_ABI=")), ++ "default", ++ ) ++ ++ 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" ++ ) ++ so = bindir / "_pywrap_tensorflow_interpreter_wrapper.so" ++ shutil.copy2(so, pkg / so.name) ++ ++ ++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-08 16:48:14 +@@ -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-08 17:08: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 0000000000000000000000000000000000000000..0c16092ce949254f8af29228d2c18f019b0a4e91 GIT binary patch literal 1032 zcmZ8gO=}ZT6ur@y#2Ra)wRBM=N>?sITJ6S4VvLoxB#oq@hzR58Ol4p)iRnxT=psMB zuZ4(<{)}#QT!;%7B7(Tlo#;kEaZ>~x&w0rVH7_}H?>qDEz3-m)W=2FBmnx?xB`Y&B zEt8UwG`N(+;R`sCd%$PlM+}dBB0Kv<@E1-qfXJANV$2w3jAz_A;0Ul~cDj~xOEb(( z-LqX47YDx`1OfBVPXR5@ZF{bkha0VV^EU|ip0#=a%F7vm+(*PGhW-R>xwf{kR9|1I zt!dSzM(JXyQ9|Cd>V^3a>{;8i9IfLyuGKWPuG!hLT8<>~KE*=73z%)-G2jBMcNO($ zuLFa=x$p5PKe<^zB%!!)Z)DPuwsED;&u5kU&TjS*`)5zg%k}fQ`MDevcr~oWoO8Ja zQqkD`69iv|@2T~-g;T}VQbQqp+9tj%&HN0j5#bZToB<+}_+zM&h{#?z0b`_o{B%-lA?8 zaHIXa9*JQ->azo$=Umm@st2LSGavUPGNm*fD8tMgNw8|f7*DX*Ss<={BE0(}-Ebq=ix-kC$EqR)h literal 0 HcmV?d00001 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..8e32286 --- /dev/null +++ b/recipes/tflite-runtime/tests/test_tflite_runtime.py @@ -0,0 +1,66 @@ +import os + +MODEL = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dense_relu.tflite") + + +def test_import_and_version(): + import tflite_runtime + + assert tflite_runtime.__version__.startswith("2.21") + + +def test_interpreter_surface(): + """The Interpreter class and its wrapper module load (the pybind .so is + the whole recipe).""" + from tflite_runtime.interpreter import Interpreter, load_delegate # noqa: F401 + + assert Interpreter is not None + + +def test_real_inference(): + """Real inference through the bundled 1KB dense+relu model (committed + test asset, generated and validated with desktop TF 2.21: fixed weights, + seed 42). Runs everywhere -- CI emulator included.""" + 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) From 128293959e76c97987005dc7a9288043b956d1be Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 9 Jul 2026 13:43:30 +0200 Subject: [PATCH 02/10] Support iOS builds for tflite-runtime in PEP 517 shim; improve ABI resolution logic and add platform-specific linker flag adjustments. --- recipes/tflite-runtime/meta.yaml | 37 +++++++++++++++------ recipes/tflite-runtime/patches/mobile.patch | 34 ++++++++++++++----- 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/recipes/tflite-runtime/meta.yaml b/recipes/tflite-runtime/meta.yaml index be0aaca..0a09624 100644 --- a/recipes/tflite-runtime/meta.yaml +++ b/recipes/tflite-runtime/meta.yaml @@ -1,16 +1,6 @@ package: name: tflite-runtime version: "2.21.0" - # Android-first like onnxruntime/sherpa-onnx; a TFLite iOS wheel is a - # separate spike (Metal delegate, different packaging). - platforms: [android] - -source: - # tflite-runtime has never had an 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/v2.21.0.tar.gz build: number: 1 @@ -27,6 +17,7 @@ build: -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 @@ -43,6 +34,28 @@ build: -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 never had an 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/v2.21.0.tar.gz requirements: build: @@ -56,8 +69,10 @@ requirements: # shim's setup.py, matching upstream setup_with_binary.py). - numpy ^2.0.0 - pybind11 - # C++20 -> libc++_shared.so at runtime. +# {% 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 index 13a50ac..5eaa74a 100644 --- a/recipes/tflite-runtime/patches/mobile.patch +++ b/recipes/tflite-runtime/patches/mobile.patch @@ -28,8 +28,8 @@ nothing is replaced): 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-08 17:26:49 -@@ -0,0 +1,180 @@ ++++ b/_forge/forge_tflite_backend.py 2026-07-09 13:28:40 +@@ -0,0 +1,196 @@ +"""mobile-forge PEP 517 shim for tflite-runtime. + +TensorFlow ships no sdist or setup.py for tflite-runtime; upstream wheels @@ -138,10 +138,19 @@ diff -ruN a/_forge/forge_tflite_backend.py b/_forge/forge_tflite_backend.py + raise RuntimeError( + "FORGE_CMAKE_ARGS must carry the cross-compile CMake arguments" + ) -+ abi = next( -+ (a.split("=", 1)[1] for a in args if a.startswith("-DANDROID_ABI=")), -+ "default", -+ ) ++ # 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] @@ -191,8 +200,15 @@ diff -ruN a/_forge/forge_tflite_backend.py b/_forge/forge_tflite_backend.py + (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" -+ shutil.copy2(so, pkg / so.name) ++ if not so.exists(): ++ so = bindir / "lib_pywrap_tensorflow_interpreter_wrapper.dylib" ++ if not so.exists(): ++ so = bindir / "_pywrap_tensorflow_interpreter_wrapper.dylib" ++ shutil.copy2(so, pkg / "_pywrap_tensorflow_interpreter_wrapper.so") + + +def get_requires_for_build_wheel(config_settings=None): @@ -212,7 +228,7 @@ diff -ruN a/_forge/forge_tflite_backend.py b/_forge/forge_tflite_backend.py + 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-08 16:48:14 ++++ b/pyproject.toml 2026-07-09 13:28:19 @@ -0,0 +1,4 @@ +[build-system] +requires = ["setuptools>=61.0.0", "wheel"] @@ -220,7 +236,7 @@ diff -ruN a/pyproject.toml b/pyproject.toml +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-08 17:08:19 ++++ b/setup.py 2026-07-09 13:28:19 @@ -0,0 +1,41 @@ +"""mobile-forge setuptools config for the tflite-runtime wheel. + From 7f23e39bf1da3bf3e49d03d29f972ec9d5df35c9 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 9 Jul 2026 13:59:12 +0200 Subject: [PATCH 03/10] tflite-runtime: extend to iOS (device + both simulators) - runtime platform parity Same playbook as the onnxruntime-iOS extension: jinja lane split in FORGE_CMAKE_ARGS (apple toolchain args) and per-slice apple keying in the shim (sysroot+arch), plus three iOS-specific pieces: - FORGE_SHARED_LINKER_FLAGS = -Wl,-undefined,dynamic_lookup: the wrapper target has no FindPython to link libpython, and apple's two-level namespace fails the link on undefined python symbols (android keeps its explicit libpython + 16KB flags). - -DFLATBUFFERS_BUILD_FLATC=OFF / -DFLATBUFFERS_INSTALL=OFF on iOS: the FetchContent'd flatbuffers installs a TARGET flatc, which iOS's forced MACOSX_BUNDLE mode rejects ("no BUNDLE DESTINATION") - and codegen uses the cached HOST flatc anyway. - The SHARED wrapper builds as .dylib on apple; the shim stages it under the .so extension name (a dylib is loadable as a python ext - the lightgbm precedent). XNNPACK cross-compiles to iOS cleanly (default ON, all slices). All 3 iOS slices green; on-simulator 4/4 incl. REAL XNNPACK inference matching desktop expectations to 1e-6. Tests: version-assert test removed per the repo test conventions. meta reorganized by user; android lane unchanged and re-verified by content (NDK marker + host entries per lane). --- recipes/tflite-runtime/tests/test_tflite_runtime.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/recipes/tflite-runtime/tests/test_tflite_runtime.py b/recipes/tflite-runtime/tests/test_tflite_runtime.py index 8e32286..cb31e85 100644 --- a/recipes/tflite-runtime/tests/test_tflite_runtime.py +++ b/recipes/tflite-runtime/tests/test_tflite_runtime.py @@ -3,12 +3,6 @@ MODEL = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dense_relu.tflite") -def test_import_and_version(): - import tflite_runtime - - assert tflite_runtime.__version__.startswith("2.21") - - def test_interpreter_surface(): """The Interpreter class and its wrapper module load (the pybind .so is the whole recipe).""" From db4a23c037278b0a9fc05d03aec95f7c06652ab0 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Sat, 11 Jul 2026 16:41:07 +0200 Subject: [PATCH 04/10] Make tflite-runtime recipe version configurable via Jinja `version` variable --- recipes/tflite-runtime/meta.yaml | 8 +++++--- .../tflite-runtime/tests/test_tflite_runtime.py | 17 +++++------------ 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/recipes/tflite-runtime/meta.yaml b/recipes/tflite-runtime/meta.yaml index 0a09624..44cd1de 100644 --- a/recipes/tflite-runtime/meta.yaml +++ b/recipes/tflite-runtime/meta.yaml @@ -1,6 +1,8 @@ +{% set version = "2.21.0" %} + package: name: tflite-runtime - version: "2.21.0" + version: '{{ version }}' build: number: 1 @@ -51,11 +53,11 @@ build: # {% endif %} source: - # tflite-runtime has never had an sdist (upstream wheels come from + # 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/v2.21.0.tar.gz + url: https://github.com/tensorflow/tensorflow/archive/refs/tags/v{{ version }}.tar.gz requirements: build: diff --git a/recipes/tflite-runtime/tests/test_tflite_runtime.py b/recipes/tflite-runtime/tests/test_tflite_runtime.py index cb31e85..f318715 100644 --- a/recipes/tflite-runtime/tests/test_tflite_runtime.py +++ b/recipes/tflite-runtime/tests/test_tflite_runtime.py @@ -3,18 +3,9 @@ MODEL = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dense_relu.tflite") -def test_interpreter_surface(): - """The Interpreter class and its wrapper module load (the pybind .so is - the whole recipe).""" - from tflite_runtime.interpreter import Interpreter, load_delegate # noqa: F401 - - assert Interpreter is not None - - def test_real_inference(): - """Real inference through the bundled 1KB dense+relu model (committed - test asset, generated and validated with desktop TF 2.21: fixed weights, - seed 42). Runs everywhere -- CI emulator included.""" + """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 @@ -48,7 +39,9 @@ def test_invoke_twice(): 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.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 From e051d9355ac8e797f9047a202d980f945202b19f Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Sat, 11 Jul 2026 18:23:44 +0200 Subject: [PATCH 05/10] tflite-runtime: stage the pybind .so with the target EXT_SUFFIX Flet 0.86's serious_python (4.3.0) only relocates ABI-tagged (cpython-*/abi3) extension modules out of sitepackages.zip 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 _pywrap_tensorflow_interpreter_wrapper' (build green, iOS fine). Stage the wrapper as _pywrap_tensorflow_interpreter_wrapper (e.g. .cpython-312.so) instead of a plain .so -- a valid CPython extension suffix, so the import still resolves. Verified: the rebuilt arm64 wheel now ships _pywrap_tensorflow_interpreter_wrapper.cpython-312.so. --- recipes/tflite-runtime/patches/mobile.patch | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/recipes/tflite-runtime/patches/mobile.patch b/recipes/tflite-runtime/patches/mobile.patch index 5eaa74a..fa3be74 100644 --- a/recipes/tflite-runtime/patches/mobile.patch +++ b/recipes/tflite-runtime/patches/mobile.patch @@ -20,7 +20,12 @@ nothing is replaced): 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. + 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, @@ -29,7 +34,7 @@ nothing is replaced): 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,196 @@ +@@ -0,0 +1,204 @@ +"""mobile-forge PEP 517 shim for tflite-runtime. + +TensorFlow ships no sdist or setup.py for tflite-runtime; upstream wheels @@ -63,6 +68,7 @@ diff -ruN a/_forge/forge_tflite_backend.py b/_forge/forge_tflite_backend.py +import shlex +import shutil +import subprocess ++import sysconfig +import tempfile +import urllib.request +from pathlib import Path @@ -208,7 +214,14 @@ diff -ruN a/_forge/forge_tflite_backend.py b/_forge/forge_tflite_backend.py + so = bindir / "lib_pywrap_tensorflow_interpreter_wrapper.dylib" + if not so.exists(): + so = bindir / "_pywrap_tensorflow_interpreter_wrapper.dylib" -+ shutil.copy2(so, pkg / "_pywrap_tensorflow_interpreter_wrapper.so") ++ # 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): From 65885410a733112596a5f9b51b38210e894b6412 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Sat, 11 Jul 2026 18:28:38 +0200 Subject: [PATCH 06/10] onnxruntime: stage the pybind .so with the target EXT_SUFFIX Flet 0.86's serious_python (4.3.0) only relocates ABI-tagged (cpython-*/abi3) extension modules out of sitepackages.zip 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 onnxruntime_pybind11_state' (build green, iOS fine). Two changes: (1) the PEP 517 shim retags the built onnxruntime_pybind11_state.so to the target EXT_SUFFIX (e.g. .cpython-312.so) after staging; (2) setup.py builds package_data by globbing the hardcoded plain 'libs' names, so the retagged file would be dropped -- wildcard the pybind name and package the real basename. Verified: forge-rebuilt arm64 wheel ships onnxruntime/capi/onnxruntime_pybind11_state.cpython-312.so (present, 22MB). --- recipes/onnxruntime/patches/mobile.patch | 52 ++++++++++++++++++++---- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/recipes/onnxruntime/patches/mobile.patch b/recipes/onnxruntime/patches/mobile.patch index c46a72b..45c806f 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,87 @@ +"""mobile-forge PEP 517 shim for onnxruntime. + +Upstream produces wheels via ci_build/build.py: CMake builds the tree and @@ -59,6 +67,7 @@ diff -ruN a/_forge/forge_ort_backend.py b/_forge/forge_ort_backend.py +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 +106,17 @@ 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. Idempotent: copytree ++ # re-lays the plain .so each hook, we retag it in place. ++ _capi = os.path.join("onnxruntime", "capi") ++ _plain = os.path.join(_capi, "onnxruntime_pybind11_state.so") ++ if os.path.exists(_plain): ++ _ext = sysconfig.get_config_var("EXT_SUFFIX") or ".so" ++ os.replace(_plain, os.path.join(_capi, "onnxruntime_pybind11_state" + _ext)) + + +def get_requires_for_build_wheel(config_settings=None): @@ -161,3 +181,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 = [] From 0b56bf04b227e803cfbe4fa1b0cf706a386d0e7a Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Sat, 11 Jul 2026 18:28:50 +0200 Subject: [PATCH 07/10] skills: document the Flet 0.86 untagged-.so-in-package trap serious_python 4.3.0 only relocates ABI-tagged (cpython-*/abi3) extension modules into an importable jniLibs/.soref slot on Android; a plain '.so' bundled inside a package is misfiled as a dependency lib and fails on-device with 'cannot import name'. Add a forge-error-catalogue entry (error -> cause -> EXT_SUFFIX fix, incl. the setup.py-glob gotcha) and a new-mobile-recipe PEP 517-shim bullet (always stage extensions with EXT_SUFFIX). Precedents: machine/tflite-runtime, machine/onnxruntime-ext-suffix. --- .../forge-error-catalogue/references/failure-catalogue.md | 8 ++++++++ .claude/skills/new-mobile-recipe/SKILL.md | 1 + 2 files changed, 9 insertions(+) diff --git a/.claude/skills/forge-error-catalogue/references/failure-catalogue.md b/.claude/skills/forge-error-catalogue/references/failure-catalogue.md index 0ca4252..6e65a3b 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. diff --git a/.claude/skills/new-mobile-recipe/SKILL.md b/.claude/skills/new-mobile-recipe/SKILL.md index 253b1a1..880d2d1 100644 --- a/.claude/skills/new-mobile-recipe/SKILL.md +++ b/.claude/skills/new-mobile-recipe/SKILL.md @@ -89,6 +89,7 @@ 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". **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). From 6df782ccb62264ea948f8c49981ac0e0ac7a90b5 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Sat, 11 Jul 2026 21:33:51 +0200 Subject: [PATCH 08/10] onnxruntime: drop stale per-slice pybind .so (fixes iOS) The EXT_SUFFIX retag + onnxruntime's copytree(dirs_exist_ok, which never deletes) + forge reusing the source tree across slices meant a prior slice's tagged .so lingered in onnxruntime/capi/. On iOS the per-slice suffixes differ (.cpython-312-iphoneos.so vs -iphonesimulator.so), so the wheel shipped BOTH and serious_python's .so->framework conversion failed on-device (dlopen: onnxruntime.capi.onnxruntime_pybind11_state.framework not found). Delete every onnxruntime_pybind11_state*.so that isn't the current slice's target after retagging. (Android is unaffected -- all its ABIs share .cpython-312.so; tflite is unaffected -- its shim rmtrees the package dir each hook.) --- recipes/onnxruntime/patches/mobile.patch | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/recipes/onnxruntime/patches/mobile.patch b/recipes/onnxruntime/patches/mobile.patch index 45c806f..91bcbd0 100644 --- a/recipes/onnxruntime/patches/mobile.patch +++ b/recipes/onnxruntime/patches/mobile.patch @@ -44,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,87 @@ +@@ -0,0 +1,96 @@ +"""mobile-forge PEP 517 shim for onnxruntime. + +Upstream produces wheels via ci_build/build.py: CMake builds the tree and @@ -63,6 +63,7 @@ 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 @@ -110,13 +111,21 @@ diff -ruN a/_forge/forge_ort_backend.py b/_forge/forge_ort_backend.py + # .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. Idempotent: copytree -+ # re-lays the plain .so each hook, we retag it in place. ++ # 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): -+ _ext = sysconfig.get_config_var("EXT_SUFFIX") or ".so" -+ os.replace(_plain, os.path.join(_capi, "onnxruntime_pybind11_state" + _ext)) ++ 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): From bf81c5bd6f861277dedda92fb300724e5c6b144f Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Sat, 11 Jul 2026 21:33:51 +0200 Subject: [PATCH 09/10] recipe-tester: restrict Android target_arch to built ABIs A recipe with excluded_arches (onnxruntime drops armeabi-v7a) has no wheel for the excluded ABI, but the recipe-tester's 'flet build apk' defaults to all 3 Android ABIs -> pip 'No matching distribution for ' on the excluded ABI, breaking the app build. stage_recipe.sh now derives [tool.flet.android] target_arch from the wheels actually present in dist-test/dist and writes it into the generated pyproject; recipes with all ABIs (or pure-Python, no per-ABI wheel) stay unrestricted. --- tests/recipe-tester/stage_recipe.sh | 35 +++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) 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/^/ /' From 85ec9245964d08c61786808258a078435c88ab29 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Sat, 11 Jul 2026 21:35:03 +0200 Subject: [PATCH 10/10] skills: document per-slice .so cleanup + excluded-ABI app build - new-mobile-recipe PEP517 bullet: forge reuses the source tree across slices, so a shim that overlays (not wipes) must delete stale per-slice .so -- iOS suffixes differ (iphoneos vs iphonesimulator) and both would ship, breaking framework conversion. - forge-error-catalogue: 'No matching distribution for ' at APK build when a recipe has excluded_arches; stage_recipe.sh now auto-derives target_arch, and the mandatory target_arch note for Consumer notes. --- .../references/failure-catalogue.md | 19 +++++++++++++++++++ .claude/skills/new-mobile-recipe/SKILL.md | 1 + 2 files changed, 20 insertions(+) diff --git a/.claude/skills/forge-error-catalogue/references/failure-catalogue.md b/.claude/skills/forge-error-catalogue/references/failure-catalogue.md index 6e65a3b..bcf86d9 100644 --- a/.claude/skills/forge-error-catalogue/references/failure-catalogue.md +++ b/.claude/skills/forge-error-catalogue/references/failure-catalogue.md @@ -1211,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 880d2d1..00d3ff2 100644 --- a/.claude/skills/new-mobile-recipe/SKILL.md +++ b/.claude/skills/new-mobile-recipe/SKILL.md @@ -90,6 +90,7 @@ The shim's load-bearing rules (each one bought with a failed build): - 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).