diff --git a/.claude/skills/forge-error-catalogue/references/failure-catalogue.md b/.claude/skills/forge-error-catalogue/references/failure-catalogue.md index 057de25c..99a85066 100644 --- a/.claude/skills/forge-error-catalogue/references/failure-catalogue.md +++ b/.claude/skills/forge-error-catalogue/references/failure-catalogue.md @@ -421,6 +421,26 @@ This forces forge to pip-install them before invoking `python -m build`. --- +### `BackendUnavailable: Cannot import 'setuptools.build_meta'` / `Backend 'setuptools.build_meta:__legacy__' is not available` + +**Cause:** the sdist is **setup.py-only** — no `pyproject.toml` `[build-system]` table at all +(ifaddr 0.2.0). PEP 517 then falls back to the legacy setuptools backend, but forge only +auto-seeds `build-system.requires` from pyproject — which doesn't exist — so setuptools is +absent from the build env and `python -m build --no-isolation` dies before doing anything. + +**Fix:** seed it explicitly: + +```yaml +requirements: + build: + - setuptools +``` + +Same lever as the `setuptools_scm` entry above; the tell that it's THIS variant is the +`:__legacy__` suffix in the error. + +--- + ### Rust: `error[E0463]: can't find crate for 'core'` **Cause:** the package pins a specific toolchain via `rust-toolchain.toml` (often @@ -1222,6 +1242,30 @@ Python-level branch — that one shows up as a hard crash with no traceback, thi --- +### iOS: a pure-Python ctypes lib silently returns NOTHING (empty list / zero results) — `platform.system() == "iOS"` misses a `"Darwin"` gate + +**Cause:** the flet iOS runtime reports `platform.system() == "iOS"` (PEP 730 semantics; the +iOS twin of the `sys.platform == "android"` entry above). Pure-Python libraries that select +**Darwin/BSD-specific ctypes struct layouts or codepaths** via `platform.system() == "Darwin"` +silently fall into their Linux branch on a Darwin ABI. Canonical case: **ifaddr** picks its +`sockaddr` ctypes layout this way — the BSD layout has a leading one-byte `sa_len` + +one-byte `sa_family`, the Linux one a two-byte `sa_family` — so on iOS every `sa_family` +read is garbage, no address ever matches AF_INET/AF_INET6, and `get_adapters()` returns an +**empty list with no exception**. Downstream, zeroconf's `Zeroconf()` then dies with +`RuntimeError: No interfaces to listen on…`. macOS desktop and the Android leg both pass, so +the recipe looks fine everywhere except on the iOS device/simulator run. + +**Fix:** patch the detection to include iOS — e.g. +`platform.system() in ("Darwin", "iOS", "iPadOS")` — and, when the offender is a *dependency* +rather than the recipe package, ship it as its own recipe: a platform-tagged +(`cp3X-cp3X-ios_*`/`android_*`) wheel of a pure-Python package **outranks PyPI's +`py3-none-any` at the same version**, so the fix delivers transparently to every consumer +(ifaddr recipe, `recipes/ifaddr/patches/ios-bsd-sockaddr.patch`). Grep candidates for +`platform.system()` before trusting an iOS leg; a lib that behaves right on macOS is NOT +thereby proven right on iOS. + +--- + ### `ModuleNotFoundError: No module named '.'` / `cannot import name '<_ext>' … (most likely due to a circular import)` for a NATIVE submodule (Android) **Cause:** the extension `.so` ships **untagged** — `ncnn/ncnn.so`, diff --git a/.claude/skills/new-mobile-recipe/SKILL.md b/.claude/skills/new-mobile-recipe/SKILL.md index 3034c73c..eea30e31 100644 --- a/.claude/skills/new-mobile-recipe/SKILL.md +++ b/.claude/skills/new-mobile-recipe/SKILL.md @@ -24,6 +24,12 @@ If any phase fails, fix forward in that phase before moving on. Don't skip ahead ## When NOT to use this skill - **Pure-Python packages** (no `.c`/`.cpp`/`.rs` source). PyPI's `py3-none-any` wheels already work on iOS and Android via serious-python's sitecustomize. No recipe needed. Confirm by checking PyPI: if the package only has a `py3-none-any.whl`, skip it. If it has `manylinux*` / `macosx_*` / `cp3X-cp3X-*` wheels, it's a recipe candidate. + **Exception — a pure-Python package that needs a MOBILE PATCH gets a recipe anyway**: forge's + platform-tagged wheel (`cp3X-cp3X-android_24_*`/`ios_13_0_*`) outranks PyPI's `py3-none-any` at + the same version, so the patched copy resolves transparently for every consumer. Precedent: + `recipes/ifaddr/` (ctypes `sockaddr` layout keyed on `platform.system() == "Darwin"`, which the + flet iOS runtime reports as `"iOS"` → empty `get_adapters()`; one-line patch, setup.py-only + sdist so `requirements.build: [setuptools]`). Same shadowing mechanics as pysodium. - **Pure-Python but SDIST-ONLY packages** (no wheel at all, but the sdist contains no native source — e.g. insightface's pure-Python releases). Still no recipe: the app opts in with `[tool.flet] source_packages = [""]` in its pyproject.toml, which makes `flet build` set `SERIOUS_PYTHON_ALLOW_SOURCE_DISTRIBUTIONS` and pip builds the sdist for the target. A recipe is only for packages that need *cross-compilation*. - **Bumping an existing `flet-lib*` recipe to a new upstream version** — use the sibling skill `native-recipe-bumps` instead. It encodes the version-conditional Jinja patterns specific to that workflow. - **Debugging a build failure in an existing recipe** — that's a different shape. This skill focuses on creation; for fixing, inspect `errors/-*.log` directly and cross-reference the `forge-error-catalogue` skill. @@ -71,6 +77,7 @@ Match the package to one of these shapes. Each maps to a template in `templates/ | C-ext consuming an existing flet-lib | Already-built `flet-libX` covers the C dep (libxml2, libcurl, libssl via openssl, etc.) | Adapt `templates/meta-with-patches.yaml` + add `requirements.host` | | Native library itself (flet-lib*), **static** | New C library a Python C-extension links at build time (libxml2, libcurl, libgeos…) | `templates/meta-flet-lib.yaml` + `templates/build-flet-lib.sh` | | Native library, **ctypes-loaded (shared)** | A pure-Python wrapper `dlopen`s the lib at runtime via `ctypes` (pyzbar→libzbar, python-magic→libmagic) | `templates/meta-flet-lib.yaml` + `templates/build-flet-lib-shared.sh`; see Pattern H | +| Cython-accelerated pure-Python (poetry-core build script) | `build-backend = "poetry.core.masonry.api"` + `[tool.poetry.build] script` that cythonizes the runtime `.py` files themselves (zeroconf; the Home-Assistant-ecosystem idiom). Forge's PEP 517 path handles poetry-core unchanged | No template — copy `recipes/zeroconf/` (branch `zeroconf`): `script_env REQUIRE_CYTHON: "1"` + a fail-loud patch (upstream swallows compile errors → silent pure-py wheel), test asserts the modules are real extensions | | C-ext that links a lib via a `*-config` tool | Compiled C-ext whose `setup.py` shells out to `pg_config`/`mysql_config`/… (psycopg2→libpq, mysqlclient→libmysqlclient) | A **static+PIC** `flet-lib*` (`build-flet-lib.sh` + `-fPIC`) shipping a config-shim, + consumer `script_env`/patch; see Pattern I | | CMake giant, **no sdist AND no setup.py/pyproject.toml** | Upstream's only wheel path is a host==target build script (onnxruntime's `ci_build/build.py`, TF's `build_pip_package_with_cmake.sh`) | No template — copy from `recipes/onnxruntime/` or `recipes/tflite-runtime/` (branches `machine/onnxruntime` / `machine/tflite-runtime`); see "PEP 517 shim" deep-dive below | | **Prebuilt-repackage + host_build chain** | Upstream publishes official prebuilt mobile archives of the native lib AND the consumer's own cmake links + re-ships the `.so` (flet-libonnxruntime→sherpa-onnx) | `build.sh` repackager + consumer `requirements.host_build`; copy from `recipes/flet-libonnxruntime/` + `recipes/sherpa-onnx/` (branch `machine/sherpa-onnx`); see "prebuilt-repackage" deep-dive below | diff --git a/recipes/ifaddr/meta.yaml b/recipes/ifaddr/meta.yaml new file mode 100644 index 00000000..d26b71d3 --- /dev/null +++ b/recipes/ifaddr/meta.yaml @@ -0,0 +1,15 @@ +package: + name: ifaddr + version: "0.2.0" + +build: + number: 1 + +requirements: + build: + # setup.py-only sdist (no [build-system] table) — seed the legacy + # setuptools backend into the build env. + - setuptools + +patches: + - ios-bsd-sockaddr.patch diff --git a/recipes/ifaddr/patches/ios-bsd-sockaddr.patch b/recipes/ifaddr/patches/ios-bsd-sockaddr.patch new file mode 100644 index 00000000..b4a5c58c --- /dev/null +++ b/recipes/ifaddr/patches/ios-bsd-sockaddr.patch @@ -0,0 +1,27 @@ +Treat iOS/iPadOS as BSD when selecting the sockaddr ctypes layout. + +ifaddr picks between two ctypes struct layouts at import time: the BSD one +(leading sa_len byte, one-byte sa_family — used on every Darwin-ABI system) +and the Linux/Windows one (two-byte sa_family). The switch tests +platform.system() == "Darwin", but Flet's iOS Python runtime reports "iOS" +(PEP 730 semantics), so ifaddr silently falls into the Linux layout on +iOS devices and simulators. Parsing Darwin-ABI getifaddrs results with the +Linux layout means sa_family never matches AF_INET/AF_INET6, no address +ever parses, and get_adapters() returns an EMPTY list — which in turn makes +zeroconf's Zeroconf() constructor fail with "RuntimeError: No interfaces to +listen on" (observed on the iOS simulator via the recipe-tester). + +The ABI is identical to macOS; only the platform string differs. Extend the +check to "iOS"/"iPadOS". No upstream fix exists as of 0.2.0 (2026-07). + +--- a/ifaddr/_shared.py 2022-06-15 23:08:21 ++++ b/ifaddr/_shared.py 2026-07-29 20:45:36 +@@ -118,7 +118,7 @@ + ) + + +-if platform.system() == "Darwin" or "BSD" in platform.system(): ++if platform.system() in ("Darwin", "iOS", "iPadOS") or "BSD" in platform.system(): + + # BSD derived systems use marginally different structures + # than either Linux or Windows. diff --git a/recipes/ifaddr/tests/test_ifaddr.py b/recipes/ifaddr/tests/test_ifaddr.py new file mode 100644 index 00000000..d2b16108 --- /dev/null +++ b/recipes/ifaddr/tests/test_ifaddr.py @@ -0,0 +1,25 @@ +def test_adapters_have_ips(): + """getifaddrs-backed enumeration finds at least one adapter carrying an IP + address — proves the sockaddr ctypes layout matches the platform ABI (the + iOS runtime reports platform.system() == "iOS", which upstream ifaddr + mis-classifies as Linux-layout, yielding zero adapters).""" + import ifaddr + + adapters = list(ifaddr.get_adapters()) + assert adapters, "ifaddr.get_adapters() returned no adapters" + ips = [ip for adapter in adapters for ip in adapter.ips] + assert ips, "no adapter has any IP address" + + +def test_loopback_visible(): + """The IPv4 loopback address is among the enumerated IPs — a concrete + parse-correctness check (a wrong struct layout can't produce 127.0.0.1).""" + import ifaddr + + all_v4 = [ + ip.ip + for adapter in ifaddr.get_adapters() + for ip in adapter.ips + if ip.is_IPv4 + ] + assert "127.0.0.1" in all_v4, all_v4 diff --git a/recipes/zeroconf/meta.yaml b/recipes/zeroconf/meta.yaml new file mode 100644 index 00000000..53e874a3 --- /dev/null +++ b/recipes/zeroconf/meta.yaml @@ -0,0 +1,18 @@ +package: + name: zeroconf + version: "0.150.0" + +build: + number: 1 + script_env: + # zeroconf's Cython extensions are optional-by-default (SKIP_CYTHON / + # swallowed build errors). Force them: a wheel without the 18 accelerator + # .so files would be a silent pure-Python fallback. + REQUIRE_CYTHON: "1" + +requirements: + build: + - cython + +patches: + - require-cython-fail-loud.patch diff --git a/recipes/zeroconf/patches/require-cython-fail-loud.patch b/recipes/zeroconf/patches/require-cython-fail-loud.patch new file mode 100644 index 00000000..29407cdd --- /dev/null +++ b/recipes/zeroconf/patches/require-cython-fail-loud.patch @@ -0,0 +1,23 @@ +Make REQUIRE_CYTHON actually fail the build on compile errors. + +zeroconf's Cython extensions are optional by design: build_ext.py guards the +cythonize() step with REQUIRE_CYTHON, but BuildExt.build_extensions() wraps the +actual C compilation in a bare try/except that only logs "Failed to build +cython extensions" — even when REQUIRE_CYTHON is set. Under a cross-compile, +any toolchain error at that stage would therefore produce a GREEN build that +silently ships a pure-Python wheel with zero .so files (the same silent- +fallback class as rapidfuzz's RAPIDFUZZ_BUILD_EXTENSION). + +The recipe sets REQUIRE_CYTHON=1 in build.script_env; this patch extends the +env var's contract to the compile stage so a broken cross-compile fails loudly +instead. Upstream behaviour without REQUIRE_CYTHON is unchanged. + +--- a/build_ext.py ++++ b/build_ext.py +@@ -51,6 +51,8 @@ + try: + super().build_extensions() + except Exception: ++ if os.environ.get("REQUIRE_CYTHON"): ++ raise + _LOGGER.info("Failed to build cython extensions") diff --git a/recipes/zeroconf/tests/test_zeroconf.py b/recipes/zeroconf/tests/test_zeroconf.py new file mode 100644 index 00000000..5e1a27ef --- /dev/null +++ b/recipes/zeroconf/tests/test_zeroconf.py @@ -0,0 +1,142 @@ +import socket +import threading + + +def test_cython_extensions_compiled(): + """The Cython accelerator modules are real compiled extensions, not the + pure-Python fallback (upstream's build swallows compile errors by design, + so a broken cross-compile would otherwise ship silently as pure Python). + zeroconf._services is a subpackage whose __init__ IS the native extension, + which also exercises serious-python's native-__init__ loading path.""" + import zeroconf._cache + import zeroconf._dns + import zeroconf._protocol.incoming + import zeroconf._protocol.outgoing + import zeroconf._services + import zeroconf._services.browser + import zeroconf._utils.time + + for mod in ( + zeroconf._dns, + zeroconf._cache, + zeroconf._protocol.incoming, + zeroconf._protocol.outgoing, + zeroconf._services, + zeroconf._services.browser, + zeroconf._utils.time, + ): + origin = mod.__spec__.origin + assert origin and not origin.endswith(".py"), ( + f"{mod.__name__} loaded from {origin!r} — pure-Python fallback, " + "the compiled extension is missing or was not used" + ) + + +def test_dns_wire_roundtrip(): + """DNS records survive serialization to mDNS wire format and back — + exercises the compiled outgoing/incoming protocol codecs without any + network access.""" + from zeroconf import const, current_time_millis + from zeroconf._dns import DNSPointer, DNSText + from zeroconf._protocol.incoming import DNSIncoming + from zeroconf._protocol.outgoing import DNSOutgoing + + type_ = "_forgetest._tcp.local." + name = "wire-roundtrip._forgetest._tcp.local." + now = current_time_millis() + + out = DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) + out.add_answer_at_time( + DNSPointer(type_, const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, name), + now, + ) + out.add_answer_at_time( + DNSText( + name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + b"\x09forge=yes", + ), + now, + ) + + packets = out.packets() + assert packets, "DNSOutgoing produced no packets" + + parsed = DNSIncoming(packets[0]) + assert parsed.valid, "round-tripped packet failed to parse" + answers = parsed.answers() + names = {answer.name for answer in answers} + assert type_ in names and name in names, names + texts = [a for a in answers if a.type == const._TYPE_TXT] + assert texts and texts[0].text == b"\x09forge=yes" + + +def test_ifaddr_enumerates_adapters(): + """zeroconf's interface-enumeration dependency (ifaddr, ctypes getifaddrs) + can list at least one adapter with an IP address on this platform.""" + import ifaddr + + adapters = ifaddr.get_adapters() + assert adapters, "ifaddr.get_adapters() returned no adapters" + assert any(adapter.ips for adapter in adapters), "no adapter has any IP address" + + +def test_socket_lifecycle(): + """A Zeroconf instance can be constructed (opens multicast sockets and sets + the mDNS socket options) and shut down cleanly.""" + from zeroconf import IPVersion, Zeroconf + + zc = Zeroconf(ip_version=IPVersion.V4Only) + try: + assert zc.started + finally: + zc.close() + + +def test_register_and_browse_loopback(): + """A service registered by one Zeroconf instance is discovered by a browser + on a second instance in the same process — a full register/announce/query/ + response cycle over real multicast sockets, no external network required + (mDNS multicast loops back on the local host).""" + from zeroconf import ( + IPVersion, + ServiceBrowser, + ServiceInfo, + ServiceStateChange, + Zeroconf, + ) + + type_ = "_forgetest._tcp.local." + svc_name = "zc-recipe-test._forgetest._tcp.local." + + found = threading.Event() + + def on_change(zeroconf, service_type, name, state_change, **kwargs): + if state_change is ServiceStateChange.Added and name == svc_name: + found.set() + + server = Zeroconf(ip_version=IPVersion.V4Only) + client = Zeroconf(ip_version=IPVersion.V4Only) + browser = None + try: + info = ServiceInfo( + type_, + svc_name, + addresses=[socket.inet_aton("127.0.0.1")], + port=8080, + properties={"from": "mobile-forge"}, + server="zc-recipe-test.local.", + ) + server.register_service(info) + browser = ServiceBrowser(client, type_, handlers=[on_change]) + assert found.wait(timeout=20), ( + "registered service was not discovered within 20s" + ) + finally: + if browser is not None: + browser.cancel() + server.unregister_all_services() + client.close() + server.close() diff --git a/setup.sh b/setup.sh index 380597d3..fbd590d1 100755 --- a/setup.sh +++ b/setup.sh @@ -34,7 +34,7 @@ if ! command -v uv &> /dev/null; then fi # Pinned flet-dev/python-build release to consume (date-keyed YYYYMMDD, PBS-style). -PYTHON_BUILD_RELEASE="${PYTHON_BUILD_RELEASE:-20260720}" +PYTHON_BUILD_RELEASE="${PYTHON_BUILD_RELEASE:-20260730}" # Resolve a full X.Y.Z from a bare X.Y minor using the pinned release's # manifest.json (downloaded + cached under downloads/). Echoes the full version;