Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 '<pkg>.<ext>'` / `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`,
Expand Down
7 changes: 7 additions & 0 deletions .claude/skills/new-mobile-recipe/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ["<name>"]` 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/<pkg>-*.log` directly and cross-reference the `forge-error-catalogue` skill.
Expand Down Expand Up @@ -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 |
Expand Down
15 changes: 15 additions & 0 deletions recipes/ifaddr/meta.yaml
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions recipes/ifaddr/patches/ios-bsd-sockaddr.patch
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions recipes/ifaddr/tests/test_ifaddr.py
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions recipes/zeroconf/meta.yaml
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions recipes/zeroconf/patches/require-cython-fail-loud.patch
Original file line number Diff line number Diff line change
@@ -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")
142 changes: 142 additions & 0 deletions recipes/zeroconf/tests/test_zeroconf.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading