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 new file mode 100644 index 00000000..9bb476d7 --- /dev/null +++ b/recipes/tflite-runtime/meta.yaml @@ -0,0 +1,87 @@ +{% set version = "2.21.0" %} + +package: + name: tflite-runtime + version: '{{ version }}' + +build: + number: 1 + script_env: + _PYTHON_SYSCONFIGDATA_NAME: '{sysconfigdata_name}' + # Consumed by the patch-added PEP 517 shim (_forge/forge_tflite_backend.py): + # standalone host-flatc build + cross cmake of tensorflow/lite building + # ONLY the _pywrap_tensorflow_interpreter_wrapper pybind module, then + # upstream's exact 5-file python package staging. The wrapper target has + # no FindPython -- Python/numpy/pybind11 headers are plain -I injections + # (upstream's own mechanism); numpy+pybind11 resolve from the cross venv + # via {platlib}, no imports needed (target numpy is not host-runnable). + FORGE_WRAPPER_INCLUDES: >- + -I{HOST_PYTHON_HOME}/include/python{py_version_short} + -I{platlib}/numpy/_core/include + -I{platlib}/pybind11/include +# {% if sdk == 'android' %} + # Multi-token -D values can't ride inside FORGE_CMAKE_ARGS (the shim + # shlex-splits it), so linker flags get their own var. libpython is + # linked explicitly like every other forge pybind wheel: upstream + # relies on manylinux-style lazy resolution, Android wants DT_NEEDED. + FORGE_SHARED_LINKER_FLAGS: >- + -Wl,-z,max-page-size=16384 + -L{HOST_PYTHON_HOME}/lib -lpython{py_version_short} + # XNNPACK stays at its default (ON) except armeabi-v7a, where the shim + # follows upstream's own pip script convention (OFF for all 32-bit arm). + FORGE_CMAKE_ARGS: >- + -DCMAKE_BUILD_TYPE=Release + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 + -DCMAKE_TOOLCHAIN_FILE={NDK_ROOT}/build/cmake/android.toolchain.cmake + -DANDROID_ABI={ANDROID_ABI} + -DANDROID_NATIVE_API_LEVEL={ANDROID_API_LEVEL} + -DANDROID_STL=c++_shared +# {% else %} + # iOS: the wrapper target links no libpython (no FindPython anywhere), + # and apple's two-level namespace would fail the link on the undefined + # python symbols -- resolve them at dlopen instead. + FORGE_SHARED_LINKER_FLAGS: -Wl,-undefined,dynamic_lookup + FORGE_CMAKE_ARGS: >- + -DCMAKE_BUILD_TYPE=Release + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 + -DCMAKE_SYSTEM_NAME=iOS + -DCMAKE_OSX_SYSROOT={{ sdk }} + -DCMAKE_OSX_DEPLOYMENT_TARGET={{ sdk_version }} + -DCMAKE_OSX_ARCHITECTURES={{ arch }} + -DFLATBUFFERS_BUILD_FLATC=OFF + -DFLATBUFFERS_INSTALL=OFF +# {% endif %} + +source: + # tflite-runtime has no sdist (upstream wheels come from + # tensorflow/lite/tools/pip_package/build_pip_package_with_cmake.sh); + # build from the TF release tree. The lite/ CMake superbuild fetches its + # pinned deps (abseil/eigen/xnnpack/ruy/flatbuffers/...) at configure. + url: https://github.com/tensorflow/tensorflow/archive/refs/tags/v{{ version }}.tar.gz + +requirements: + build: + - setuptools + - wheel + - cmake + host: + # Build-time headers only (via FORGE_WRAPPER_INCLUDES {platlib} paths). + # numpy>=2 headers yield binaries compatible with numpy 1.x AND 2.x; + # numpy is also the wheel's one runtime dep (install_requires in the + # shim's setup.py, matching upstream setup_with_binary.py). + - numpy ^2.0.0 + - pybind11 +# {% if sdk == 'android' %} + # C++20 -> libc++_shared.so at runtime (iOS links the system libc++). + - flet-libcpp-shared >=27.2.12479018 +# {% endif %} + +patches: + - mobile.patch + +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/patches/mobile.patch b/recipes/tflite-runtime/patches/mobile.patch new file mode 100644 index 00000000..fa3be749 --- /dev/null +++ b/recipes/tflite-runtime/patches/mobile.patch @@ -0,0 +1,294 @@ +tflite-runtime has no sdist and no setup.py anywhere in the TF tree -- the +supported wheel path is tensorflow/lite/tools/pip_package/ +build_pip_package_with_cmake.sh (host==target oriented; its cmake dispatch +has no android case -- python-for-android patches one in). This patch makes +the TF release tree buildable by forge's standard PEP 517 flow by ADDING +three files (the TF 2.21 root ships neither pyproject.toml nor setup.py, so +nothing is replaced): + +1. pyproject.toml: points the build at the shim backend below. + +2. _forge/forge_tflite_backend.py: replicates the upstream script inside + PEP 517 hooks -- standalone host-flatc build (cached per flatbuffers tag; + cross-configuring tensorflow/lite FATALs without -DTFLITE_HOST_TOOLS_DIR), + cross cmake of tensorflow/lite building ONLY the + _pywrap_tensorflow_interpreter_wrapper pybind target (per-ABI build dirs: + forge reuses the source tree across arch slices -- the onnxruntime + e_machine lesson), then upstream's exact 5-file tflite_runtime/ staging. + XNNPACK stays ON for every ABI: interpreter_wrapper.cc references + TfLiteXNNPackDelegate* unconditionally at link time, so the python wheel + cannot build with XNNPACK off at all. The configure pins + -DTENSORFLOW_SOURCE_DIR to the unpacked root: without it the lite build + git-clones its own TF at v2.19.0 (a stale upstream default) and compiles + this tree's sources against those older headers. The wrapper module is + staged under the target EXT_SUFFIX (e.g. .cpython-312.so), not a plain + .so: serious_python's android packager only relocates ABI-tagged + (cpython-*/abi3) extension modules into jniLibs with an importable .soref + marker; a plain '.so' is misfiled as a dependency lib and never + becomes importable on device. + +3. setup.py: adapted from setup_with_binary.py (version parsed from the + staged __init__.py instead of env vars; same numpy install_requires, + has_ext_modules for proper cpXX tagging under the crossenv). + +diff -ruN a/_forge/forge_tflite_backend.py b/_forge/forge_tflite_backend.py +--- a/_forge/forge_tflite_backend.py 1970-01-01 01:00:00 ++++ b/_forge/forge_tflite_backend.py 2026-07-09 13:28:40 +@@ -0,0 +1,204 @@ ++"""mobile-forge PEP 517 shim for tflite-runtime. ++ ++TensorFlow ships no sdist or setup.py for tflite-runtime; upstream wheels ++come from tensorflow/lite/tools/pip_package/build_pip_package_with_cmake.sh. ++This backend replicates that script's steps under forge's standard PEP 517 ++flow (python -m build at the source root): ++ ++1. Host flatc: cross-configuring tensorflow/lite FATALs without a host ++ flatbuffers compiler (-DTFLITE_HOST_TOOLS_DIR). Upstream configures the ++ ENTIRE lite tree a second time for the host just to build flatc; instead, ++ build flatc standalone from the exact tag pinned in ++ tools/cmake/modules/flatbuffers.cmake, cached in the system temp dir ++ keyed by that tag (a host-arch tool, safe to share across ABI slices -- ++ unlike the per-arch cache that bit ckzg). ++ ++2. Cross cmake of tensorflow/lite (args from FORGE_CMAKE_ARGS plus per-ABI ++ extras) building ONLY the _pywrap_tensorflow_interpreter_wrapper pybind ++ target. Build dirs are keyed per-ABI because forge reuses the unpacked ++ source tree across arch slices within one invocation (the onnxruntime ++ e_machine lesson). The cmake step runs before EVERY hook, guarded by a ++ marker file (hooks are separate subprocesses). ++ ++3. Stage ./tflite_runtime/ exactly like the upstream script (interpreter.py, ++ metrics_interface.py, metrics_portable.py, a generated __init__.py, and ++ the built .so), fresh on every hook so the current ABI always wins, then ++ delegate to setuptools.build_meta against the forge-added setup.py. ++""" ++ ++import os ++import re ++import shlex ++import shutil ++import subprocess ++import sysconfig ++import tempfile ++import urllib.request ++from pathlib import Path ++ ++from setuptools import build_meta as _orig ++from setuptools.build_meta import * # noqa: F401,F403 ++ ++_LITE = Path("tensorflow") / "lite" ++ ++ ++def _tf_version() -> str: ++ text = (Path("tensorflow") / "tf_version.bzl").read_text() ++ return re.search(r'TF_VERSION = "([^"]+)"', text).group(1) ++ ++ ++def _flatbuffers_tag() -> str: ++ text = (_LITE / "tools/cmake/modules/flatbuffers.cmake").read_text() ++ return re.search(r"GIT_TAG\s+(\S+)", text).group(1) ++ ++ ++def _host_env() -> dict: ++ # Scrub the android cross toolchain out of the environment for the host ++ # flatc build (CC and friends point at NDK clang here). ++ env = dict(os.environ) ++ for k in ( ++ "CC", "CXX", "AR", "LD", "RANLIB", "STRIP", "READELF", "NM", ++ "CFLAGS", "CXXFLAGS", "CPPFLAGS", "LDFLAGS", ++ "_PYTHON_SYSCONFIGDATA_NAME", "PYTHONPATH", ++ ): ++ env.pop(k, None) ++ return env ++ ++ ++def _jobs() -> str: ++ return f"-j{os.cpu_count() or 4}" ++ ++ ++def _host_flatc() -> Path: ++ tag = _flatbuffers_tag() ++ root = Path(tempfile.gettempdir()) / f"forge-tflite-host-flatc-{tag}" ++ marker = root / ".done" ++ if not marker.exists(): ++ shutil.rmtree(root, ignore_errors=True) ++ (root / "src").mkdir(parents=True) ++ tarball = root / "flatbuffers.tar.gz" ++ urllib.request.urlretrieve( ++ f"https://github.com/google/flatbuffers/archive/refs/tags/{tag}.tar.gz", ++ tarball, ++ ) ++ subprocess.check_call( ++ ["tar", "-xzf", str(tarball), "-C", str(root / "src"), ++ "--strip-components", "1"] ++ ) ++ env = _host_env() ++ subprocess.check_call( ++ ["cmake", "-S", str(root / "src"), "-B", str(root / "build"), ++ "-DCMAKE_BUILD_TYPE=Release", "-DFLATBUFFERS_BUILD_TESTS=OFF"], ++ env=env, ++ ) ++ subprocess.check_call( ++ ["cmake", "--build", str(root / "build"), _jobs(), "-t", "flatc"], ++ env=env, ++ ) ++ (root / "bin").mkdir() ++ shutil.copy2(root / "build" / "flatc", root / "bin" / "flatc") ++ marker.write_text("ok\n") ++ return root ++ ++ ++def _cmake_build() -> None: ++ args = shlex.split(os.environ.get("FORGE_CMAKE_ARGS", "")) ++ if not args: ++ raise RuntimeError( ++ "FORGE_CMAKE_ARGS must carry the cross-compile CMake arguments" ++ ) ++ # Per-slice build dir key: ANDROID_ABI on android, sysroot + arch on ++ # iOS (device/sim x arm64/x86_64) -- the onnxruntime-iOS lesson. ++ abi = "default" ++ sysroot = arch = "" ++ for a in args: ++ if a.startswith("-DANDROID_ABI="): ++ abi = a.split("=", 1)[1] ++ elif a.startswith("-DCMAKE_OSX_SYSROOT="): ++ sysroot = a.split("=", 1)[1] ++ elif a.startswith("-DCMAKE_OSX_ARCHITECTURES="): ++ arch = a.split("=", 1)[1] ++ if sysroot or arch: ++ abi = f"{sysroot}-{arch}" ++ ++ ver = _tf_version() ++ major, minor, patch = ver.split(".")[:3] ++ includes = os.environ.get("FORGE_WRAPPER_INCLUDES", "").strip() ++ flags = ( ++ f"{includes} -DTF_MAJOR_VERSION={major} -DTF_MINOR_VERSION={minor}" ++ f" -DTF_PATCH_VERSION={patch} -DTF_VERSION_SUFFIX=''" ++ ) ++ extra = [f"-DCMAKE_C_FLAGS={flags}", f"-DCMAKE_CXX_FLAGS={flags}"] ++ linker = os.environ.get("FORGE_SHARED_LINKER_FLAGS", "").strip() ++ if linker: ++ extra.append(f"-DCMAKE_SHARED_LINKER_FLAGS={linker}") ++ # XNNPACK stays ON for every ABI (the default): interpreter_wrapper.cc ++ # calls TfLiteXNNPackDelegate* unconditionally at link time, so the ++ # python wheel cannot be built with TFLITE_ENABLE_XNNPACK=OFF at all -- ++ # upstream's own 32-bit-arm OFF convention predates that code and only ++ # works for the C++ library. p4a shipped XNNPACK-on-v7a at TF 2.8. ++ ++ bindir = Path(".forge_build") / abi ++ marker = bindir / ".done" ++ if not marker.exists(): ++ host_tools = _host_flatc() ++ # Without TENSORFLOW_SOURCE_DIR the lite build git-clones its own ++ # TF pinned at v2.19.0 (!) and compiles OUR sources against THOSE ++ # headers -- first slice died on a header that only exists in 2.21. ++ subprocess.check_call( ++ ["cmake", "-S", str(_LITE), "-B", str(bindir), *args, *extra, ++ f"-DTENSORFLOW_SOURCE_DIR={Path.cwd()}", ++ f"-DTFLITE_HOST_TOOLS_DIR={host_tools}"] ++ ) ++ subprocess.check_call( ++ ["cmake", "--build", str(bindir), _jobs(), "-t", ++ "_pywrap_tensorflow_interpreter_wrapper"] ++ ) ++ marker.write_text("ok\n") ++ ++ # Stage the python package fresh on every hook (current ABI wins). ++ pkg = Path("tflite_runtime") ++ shutil.rmtree(pkg, ignore_errors=True) ++ pkg.mkdir() ++ for src in ( ++ _LITE / "python" / "interpreter.py", ++ _LITE / "python" / "metrics" / "metrics_interface.py", ++ _LITE / "python" / "metrics" / "metrics_portable.py", ++ ): ++ shutil.copy2(src, pkg / src.name) ++ (pkg / "__init__.py").write_text( ++ f"__version__ = '{ver}'\n__git_version__ = 'v{ver}'\n" ++ ) ++ # CMake names the SHARED wrapper .so on android/linux but .dylib on ++ # apple; the python module must ship as .so either way (a dylib is ++ # perfectly loadable under the extension name -- the lightgbm trick). ++ so = bindir / "_pywrap_tensorflow_interpreter_wrapper.so" ++ if not so.exists(): ++ so = bindir / "lib_pywrap_tensorflow_interpreter_wrapper.dylib" ++ if not so.exists(): ++ so = bindir / "_pywrap_tensorflow_interpreter_wrapper.dylib" ++ # Stage under the target EXT_SUFFIX (e.g. .cpython-312.so), not a plain ++ # .so: serious_python's android packager only relocates ABI-tagged ++ # extension modules (cpython-*/abi3) into jniLibs with an importable ++ # .soref marker -- a plain '.so' is misfiled as a dependency lib ++ # and never becomes importable on device. The tag is a valid CPython ++ # EXTENSION_SUFFIX so the `from tflite_runtime import ...` still resolves. ++ ext = sysconfig.get_config_var("EXT_SUFFIX") or ".so" ++ shutil.copy2(so, pkg / f"_pywrap_tensorflow_interpreter_wrapper{ext}") ++ ++ ++def get_requires_for_build_wheel(config_settings=None): ++ _cmake_build() ++ return _orig.get_requires_for_build_wheel(config_settings) ++ ++ ++def prepare_metadata_for_build_wheel(metadata_directory, config_settings=None): ++ _cmake_build() ++ return _orig.prepare_metadata_for_build_wheel( ++ metadata_directory, config_settings ++ ) ++ ++ ++def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): ++ _cmake_build() ++ return _orig.build_wheel(wheel_directory, config_settings, metadata_directory) +diff -ruN a/pyproject.toml b/pyproject.toml +--- a/pyproject.toml 1970-01-01 01:00:00 ++++ b/pyproject.toml 2026-07-09 13:28:19 +@@ -0,0 +1,4 @@ ++[build-system] ++requires = ["setuptools>=61.0.0", "wheel"] ++build-backend = "forge_tflite_backend" ++backend-path = ["_forge"] +diff -ruN a/setup.py b/setup.py +--- a/setup.py 1970-01-01 01:00:00 ++++ b/setup.py 2026-07-09 13:28:19 +@@ -0,0 +1,41 @@ ++"""mobile-forge setuptools config for the tflite-runtime wheel. ++ ++Adapted from tensorflow/lite/tools/pip_package/setup_with_binary.py (which ++reads PROJECT_NAME/PACKAGE_VERSION from env vars set by upstream's build ++script). The PEP 517 shim (_forge/forge_tflite_backend.py) stages ++./tflite_runtime/ -- including the cross-compiled pybind module -- before ++setuptools runs. ++""" ++ ++import re ++ ++from setuptools import setup ++ ++with open("tflite_runtime/__init__.py") as f: ++ version = re.search(r"__version__ = '([^']+)'", f.read()).group(1) ++ ++DESCRIPTION = "TensorFlow Lite is for mobile and embedded devices." ++ ++setup( ++ name="tflite-runtime", ++ version=version, ++ description=DESCRIPTION, ++ long_description=DESCRIPTION, ++ url="https://www.tensorflow.org/lite/", ++ author="Google, LLC", ++ author_email="packages@tensorflow.org", ++ license="Apache 2.0", ++ include_package_data=True, ++ has_ext_modules=lambda: True, ++ keywords="tflite tensorflow tensor machine learning", ++ packages=["tflite_runtime"], ++ package_data={"tflite_runtime": ["*.so"]}, ++ install_requires=[ ++ "numpy >= 1.23.2", ++ ], ++ # The TF source root carries a Bazel `BUILD` file, which collides with ++ # setuptools' default `build/` dir on case-insensitive filesystems ++ # (macOS local builds): "could not create 'build/lib...': Not a ++ # directory". Build somewhere else. ++ options={"build": {"build_base": ".forge_pybuild"}}, ++) diff --git a/recipes/tflite-runtime/tests/dense_relu.tflite b/recipes/tflite-runtime/tests/dense_relu.tflite new file mode 100644 index 00000000..0c16092c Binary files /dev/null and b/recipes/tflite-runtime/tests/dense_relu.tflite differ 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..f318715a --- /dev/null +++ b/recipes/tflite-runtime/tests/test_tflite_runtime.py @@ -0,0 +1,53 @@ +import os + +MODEL = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dense_relu.tflite") + + +def test_real_inference(): + """Real inference through the bundled 1KB dense+relu model (generated and + validated with desktop TF 2.21: fixed weights, seed 42). Runs everywhere.""" + import numpy as np + from tflite_runtime.interpreter import Interpreter + + interpreter = Interpreter(model_path=MODEL) + interpreter.allocate_tensors() + inp = interpreter.get_input_details()[0] + out = interpreter.get_output_details()[0] + + assert inp["shape"].tolist() == [1, 4] + assert out["shape"].tolist() == [1, 3] + + x = np.array([[1.0, 2.0, 3.0, 4.0]], dtype=np.float32) + interpreter.set_tensor(inp["index"], x) + interpreter.invoke() + y = interpreter.get_tensor(out["index"]) + + # Desktop-recorded expectation (XNNPACK delegate): relu clamps lanes 0-1. + expected = np.array([[0.0, 0.0, 2.817584753036499]], dtype=np.float32) + np.testing.assert_allclose(y, expected, rtol=1e-5, atol=1e-6) + + +def test_invoke_twice(): + """Tensor re-set + second invoke exercises the wrapper's buffer + handling; a zeros probe reduces the graph to relu(bias), whose value is + desktop-recorded.""" + import numpy as np + from tflite_runtime.interpreter import Interpreter + + interpreter = Interpreter(model_path=MODEL) + interpreter.allocate_tensors() + inp = interpreter.get_input_details()[0] + out = interpreter.get_output_details()[0] + + interpreter.set_tensor( + inp["index"], np.array([[1.0, 2.0, 3.0, 4.0]], dtype=np.float32) + ) + interpreter.invoke() + first = interpreter.get_tensor(out["index"]).copy() + assert first.max() > 0 + + interpreter.set_tensor(inp["index"], np.zeros((1, 4), dtype=np.float32)) + interpreter.invoke() + second = interpreter.get_tensor(out["index"]) + expected = np.array([[0.6648852825164795, 0.0, 0.0]], dtype=np.float32) + np.testing.assert_allclose(second, expected, rtol=1e-5, atol=1e-6) diff --git a/tests/recipe-tester/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