From 3c6bd2391e6622059b6a05252cc089bf966bcfb6 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 8 Jul 2026 17:33:14 +0200 Subject: [PATCH 01/14] 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 00000000..be0aacad --- /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 00000000..13a50ace --- /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 00000000..24ce15ab --- /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 00000000..8e32286c --- /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/14] 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 be0aacad..0a096248 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 13a50ace..5eaa74a3 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/14] 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 8e32286c..cb31e852 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/14] 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 0a096248..44cd1de9 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 cb31e852..f318715a 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/14] 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 5eaa74a3..fa3be749 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/14] 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 c46a72ba..45c806f2 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/14] 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 0ca4252d..6e65a3b3 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 253b1a15..880d2d12 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/14] 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 45c806f2..91bcbd05 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/14] 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 65d1b23d..8d206dbd 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/14] 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 6e65a3b3..bcf86d94 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 880d2d12..00d3ff2a 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). From f257d601edc5c30ecae9c86815420791d4d97d8c Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Sun, 12 Jul 2026 00:43:25 +0200 Subject: [PATCH 11/14] onnxruntime: also drop stale .so from setuptools build/lib (iOS lipo) The prior fix only cleaned the source onnxruntime/capi/, but setuptools' build/lib -- which the wheel is packed from -- persists across forge's reused source tree and build_py never deletes, so it kept accumulating a prior slice's tagged .so. On iOS that put both -iphoneos.so and -iphonesimulator.so in the iphonesimulator wheel; serious_python's xcframework conversion then fed both (same arch) to lipo -> 'have the same architectures (arm64) and can't be in the same fat output file' -> the framework was never built -> dlopen 'no such file' on device. Extend the cleanup to build/lib/onnxruntime/capi/ too. Verified: local 'forge --clean iOS onnxruntime' now yields one .so per slice wheel. --- recipes/onnxruntime/patches/mobile.patch | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/recipes/onnxruntime/patches/mobile.patch b/recipes/onnxruntime/patches/mobile.patch index 91bcbd05..a917d565 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,96 @@ +@@ -0,0 +1,100 @@ +"""mobile-forge PEP 517 shim for onnxruntime. + +Upstream produces wheels via ci_build/build.py: CMake builds the tree and @@ -118,14 +118,18 @@ diff -ruN a/_forge/forge_ort_backend.py b/_forge/forge_ort_backend.py + _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) ++ # forge reuses the source tree across slices, 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, in BOTH the ++ # source package dir AND setuptools' persistent build/lib (build_py ++ # copies but never deletes, so build/lib -- which the wheel is packed ++ # from -- accumulates across slices). ++ for _dir in (_capi, os.path.join("build", "lib", "onnxruntime", "capi")): ++ for _f in glob.glob(os.path.join(_dir, "onnxruntime_pybind11_state*.so")): ++ if os.path.basename(_f) != _target: ++ os.remove(_f) + + +def get_requires_for_build_wheel(config_settings=None): From 7449a19fe8ffea88f0cabba2e5cd4d59002051f0 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 15 Jul 2026 17:59:19 +0200 Subject: [PATCH 12/14] onnxruntime: drop superseded recipe-level .so staging (matches main) The three commits 6588541/6df782c/f257d60 retagged the pybind module to EXT_SUFFIX in the shim and cleaned stale per-slice .so from capi/ + build/lib. forge fix_wheel now does this generically on the built wheel (build.py:864+): ABI-tags bare extensions via PyInit_ (explicitly handling onnxruntime's PyInit_..._state@@VERS symbol versioning) and drops foreign-arch .so. The shim retag also self-inflicted the iOS lipo collision the later two commits then patched (per-slice EXT_SUFFIX on iOS); main avoids the retag on iOS entirely. Restore recipes/onnxruntime to main; onnxruntime build 2 is already green on all pythons/arches without any of this. --- recipes/onnxruntime/patches/mobile.patch | 65 ++++-------------------- 1 file changed, 9 insertions(+), 56 deletions(-) diff --git a/recipes/onnxruntime/patches/mobile.patch b/recipes/onnxruntime/patches/mobile.patch index a917d565..c46a72ba 100644 --- a/recipes/onnxruntime/patches/mobile.patch +++ b/recipes/onnxruntime/patches/mobile.patch @@ -8,12 +8,7 @@ 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. 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. + setup.py expects it, then delegates wheel assembly to setuptools. 2. cmake/CMakeLists.txt (Termux 0004): find Python COMPONENTS Development before Development.Module -- under a crossenv, the Module-only lookup @@ -22,16 +17,13 @@ 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: 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. +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. Deliberately NOT taken from Termux: their 0001/0002 (system abseil and protobuf -- we want the FetchContent vendored builds) and 0003 (Android @@ -44,7 +36,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,100 @@ +@@ -0,0 +1,75 @@ +"""mobile-forge PEP 517 shim for onnxruntime. + +Upstream produces wheels via ci_build/build.py: CMake builds the tree and @@ -63,12 +55,10 @@ 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 @@ -107,29 +97,6 @@ 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, 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, in BOTH the -+ # source package dir AND setuptools' persistent build/lib (build_py -+ # copies but never deletes, so build/lib -- which the wheel is packed -+ # from -- accumulates across slices). -+ for _dir in (_capi, os.path.join("build", "lib", "onnxruntime", "capi")): -+ for _f in glob.glob(os.path.join(_dir, "onnxruntime_pybind11_state*.so")): -+ if os.path.basename(_f) != _target: -+ os.remove(_f) + + +def get_requires_for_build_wheel(config_settings=None): @@ -194,17 +161,3 @@ 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 5cffdc2b64d660844525deb8dd6b9557c6dba727 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 15 Jul 2026 18:28:50 +0200 Subject: [PATCH 13/14] skills: reset .so-tagging docs to main (drop reverted recipe-level retag) Commits 0b56bf0 + 85ec924 documented the recipe-level pybind .so retag + package_data glob + per-slice cleanup in new-mobile-recipe SKILL.md (a bullet) and forge-error-catalogue (entry). That approach was superseded by forge fix_wheel (ABI-tags PyInit_* .so, drops foreign-arch .so) and the onnxruntime recipe patch doing it was reverted in 7449a19. main already documents the correct fix_wheel behaviour (catalogue 'NATIVE submodule' + foreign-arch entries), so restore both files to main rather than carry corrected-but-still- divergent docs on this branch. --- .../references/failure-catalogue.md | 27 ------------------- .claude/skills/new-mobile-recipe/SKILL.md | 2 -- 2 files changed, 29 deletions(-) diff --git a/.claude/skills/forge-error-catalogue/references/failure-catalogue.md b/.claude/skills/forge-error-catalogue/references/failure-catalogue.md index d0aff93d..aba4c5a1 100644 --- a/.claude/skills/forge-error-catalogue/references/failure-catalogue.md +++ b/.claude/skills/forge-error-catalogue/references/failure-catalogue.md @@ -1541,14 +1541,6 @@ 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. @@ -1741,25 +1733,6 @@ 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 d0781841..3034c73c 100644 --- a/.claude/skills/new-mobile-recipe/SKILL.md +++ b/.claude/skills/new-mobile-recipe/SKILL.md @@ -89,8 +89,6 @@ 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). From cadd476f74acaf041baaec560f9e823739f31920 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 15 Jul 2026 18:43:47 +0200 Subject: [PATCH 14/14] updates --- .claude/skills/local-recipe-testing/SKILL.md | 2 +- .../templates/test_template.py | 3 ++- recipes/tflite-runtime/meta.yaml | 7 ++++++ recipes/tflite-runtime/tests/requirements.txt | 1 - tests/recipe-tester/README.md | 24 ++++++++++--------- 5 files changed, 23 insertions(+), 14 deletions(-) delete mode 100644 recipes/tflite-runtime/tests/requirements.txt diff --git a/.claude/skills/local-recipe-testing/SKILL.md b/.claude/skills/local-recipe-testing/SKILL.md index e65f9384..bb9a18c7 100644 --- a/.claude/skills/local-recipe-testing/SKILL.md +++ b/.claude/skills/local-recipe-testing/SKILL.md @@ -147,7 +147,7 @@ for i in $(seq 1 30); do grep EXIT "$DATA/Library/Caches/console.log" 2>/dev/nul - **Big models (MBs): drop next to the test locally; the test discovers it by presence and skips otherwise.** Precedent sherpa-onnx `silero_vad.onnx` (2.2 MB): `if not os.path.exists(model): pytest.skip("silero_vad.onnx not bundled")`. CI (no asset) skips; your local loop runs REAL inference. `.gitignore` has `recipes/*/tests/*.onnx` so the asset can never be committed (that would silently flip the CI skip into a real run and embed MBs in git history) — extend the pattern for other formats. - **Tiny models (~KB): COMMIT them so CI runs real inference too.** Precedent tflite-runtime's 1 KB `dense_relu.tflite` (generated with desktop TF at a fixed seed, expected outputs asserted). -Test-only deps — packages the tests import that are NOT in the recipe's Requires-Dist (e.g. numpy) — go in the recipe's meta.yaml `test.requires` (a list of PEP 508 specs; this replaced the former `recipes//tests/requirements.txt`, and `stage_recipe.sh` now errors on a stale one). `stage_recipe.sh` injects them into the generated tester `pyproject.toml` `dependencies` (read via `read_meta_list.py`). Each must resolve for the MOBILE target: pure-Python from PyPI, or a recipe on pypi.flet.dev / seeded into `dist/`. +Test-only deps — packages the tests import that are NOT in the recipe's Requires-Dist (e.g. numpy) — go in the recipe's meta.yaml `test.requires` (a list of PEP 508 specs). Path-hungry packages (read bundled DATA via `__file__`) also need a top-level meta.yaml `extract_packages:` list on Android — see `forge-error-catalogue` § the `sitepackages.zip` class. diff --git a/.claude/skills/new-mobile-recipe/templates/test_template.py b/.claude/skills/new-mobile-recipe/templates/test_template.py index 8e29513a..a7b07382 100644 --- a/.claude/skills/new-mobile-recipe/templates/test_template.py +++ b/.claude/skills/new-mobile-recipe/templates/test_template.py @@ -15,7 +15,8 @@ - Keep tests deterministic and network-free (fixed seeds; no downloads; asset files only if tiny and shipped in the recipe's tests/ dir) - Test-only deps (e.g. numpy for a package that doesn't require it) go in - tests/requirements.txt — one PEP 508 spec per line + meta.yaml under `test.requires` (a list of PEP 508 specs) — NOT a + tests/requirements.txt file (that is no longer read) Replace with the import name (NOT the PyPI name — these can differ). """ diff --git a/recipes/tflite-runtime/meta.yaml b/recipes/tflite-runtime/meta.yaml index 44cd1de9..9bb476d7 100644 --- a/recipes/tflite-runtime/meta.yaml +++ b/recipes/tflite-runtime/meta.yaml @@ -78,3 +78,10 @@ requirements: patches: - mobile.patch + +test: + # The tests run real inference and import numpy, which the wheel itself + # requires — but the recipe-tester installs only flet + pytest + the recipe, + # so declare it here. (Replaces the former tests/requirements.txt.) + requires: + - numpy diff --git a/recipes/tflite-runtime/tests/requirements.txt b/recipes/tflite-runtime/tests/requirements.txt deleted file mode 100644 index 24ce15ab..00000000 --- a/recipes/tflite-runtime/tests/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -numpy diff --git a/tests/recipe-tester/README.md b/tests/recipe-tester/README.md index b5ca96f6..b9f13884 100644 --- a/tests/recipe-tester/README.md +++ b/tests/recipe-tester/README.md @@ -17,20 +17,22 @@ The app installs `flet + pytest + `, so the only third-party packages on the device are the recipe's own `Requires-Dist`. If a recipe's tests need something the recipe itself doesn't require (e.g. `numpy` for `safetensors`, whose numpy integration is an upstream extra), declare it in -an optional `recipes//tests/requirements.txt`: +the recipe's `meta.yaml` under `test.requires` (a list of PEP 508 specs): -``` -# one PEP 508 spec per line; blanks and #-comments are skipped -numpy +```yaml +test: + requires: + - numpy ``` -`stage_recipe.sh` injects those into the generated `pyproject.toml`. Each -dep must be installable for the *mobile* target — pure-Python from PyPI, or -a recipe already published on `pypi.flet.dev` (or seeded into `dist/` via -the workflow's `prebuild_recipes` input) — the same constraint a real app -faces. Keep it minimal: prefer `pytest.importorskip` for genuinely optional -integrations, and use this file only when skipping would hide the coverage -that matters on-device. +`stage_recipe.sh` reads that list (via `read_meta_list.py`) and injects it +into the generated `pyproject.toml` `dependencies`. Each dep must be +installable for the *mobile* target — pure-Python from PyPI, or a recipe +already published on `pypi.flet.dev` (or seeded into `dist/` via the +workflow's `prebuild_recipes` input) — the same constraint a real app faces. +Keep it minimal: prefer `pytest.importorskip` for genuinely optional +integrations, and use `test.requires` only when skipping would hide the +coverage that matters on-device. ## Local quick-start