Skip to content

Commit d52302d

Browse files
committed
Merge remote-tracking branch 'origin/main' into xgboost
# Conflicts: # .claude/skills/local-recipe-testing/SKILL.md
2 parents 9e4afda + 88290ff commit d52302d

15 files changed

Lines changed: 621 additions & 27 deletions

File tree

‎.claude/skills/forge-ci/SKILL.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ Then the two log sources:
176176

177177
```bash
178178
# Job log (build phase, staging, packaging errors):
179-
gh api repos/<fork>/actions/jobs/<job-id>/logs > job.log
179+
gh api --allow-escape-sequences repos/<fork>/actions/jobs/<job-id>/logs > job.log
180180
grep -nE "error:|CMake Error|No matching distribution|FAILED" job.log
181181

182182
# On-device test output (the actual pytest run) — it is an ARTIFACT, usually
@@ -185,6 +185,13 @@ gh run download <run-id> --repo <fork> -D artifacts/
185185
cat artifacts/test-py3.12-<platform>-<pkg>-*/console.log
186186
```
187187

188+
Without `--allow-escape-sequences`, `gh` may refuse (*"the response contains terminal escape
189+
sequences"*), write nothing and exit 1 — and a grep over the empty file is a silent false
190+
negative. console.log does not print the interpreter, so after a `mobile_test_pythons=ALL`
191+
run confirm each leg from its job log: the interpreter, then the wheel pip actually
192+
installed (flet's `-vv` output wraps lines, so join them first):
193+
`grep -oE -- '--python-version [0-9.]+' job.log; sed -E 's/^[^ ]+ +//; s/ +$//' job.log | tr -d '\n' | grep -oE 'Processing */[^ ]*dist-test/<pkg>-[A-Za-z0-9_.+-]*\.whl'`.
194+
188195
A missing console.log artifact for a failed 3.12 job means the job died
189196
*before* the device test — almost always at recipe-tester packaging
190197
(resolution: see Chains) rather than on the device.

‎.claude/skills/local-recipe-testing/SKILL.md‎

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,24 @@ DATA=$(xcrun simctl get_app_container "$UDID" com.flet.recipe-tester data)
9292
for i in $(seq 1 30); do grep EXIT "$DATA/Library/Caches/console.log" 2>/dev/null && break; sleep 5; done
9393
```
9494

95+
## Shortcut: the consumer example pass on CI's wheels
96+
97+
Once a CI run is green, its `wheels-py3.X-<platform>-<pkg>-*` artifacts are the exact
98+
wheels the device tests used. Flatten them into one find-links dir and the example app
99+
builds with **no local forge env at all** — no support tarballs, no NDK, no `forge` run:
100+
101+
```bash
102+
gh run download <run-id> --repo <fork> -p 'wheels-*' -D /tmp/ci_wheels
103+
mkdir /tmp/ci_wheels/findlinks && cp /tmp/ci_wheels/wheels-*/*.whl /tmp/ci_wheels/findlinks/
104+
cd recipes/<pkg>/examples/<example>
105+
PIP_FIND_LINKS=/tmp/ci_wheels/findlinks uv run flet build apk # or ios-simulator
106+
```
107+
108+
The example builds for 3.14 (gotcha #2), so the run must include the 3.14 leg. To read the
109+
app's own output without racing a screenshot against the update, the Flet patch log
110+
carries every control value: `xcrun simctl spawn "$UDID" log show --last 2m --style compact
111+
--predicate 'process == "<app-name>"'` (a release APK logs no patches; use screenshots there).
112+
95113
### forge slice syntax (quick reference)
96114

97115
`android:arm64-v8a` | `android:x86_64` | `android:armeabi-v7a` | `iphonesimulator:arm64` | `iphonesimulator:x86_64` | `iphoneos:arm64` — the first token is the **SDK**, not the OS. `forge iOS:arm64` dies with a raw `KeyError: 'iOS'` (only the bare-platform forms `forge android` / `forge iOS` take the OS name, and those build every arch).
@@ -115,6 +133,12 @@ for i in $(seq 1 30); do grep EXIT "$DATA/Library/Caches/console.log" 2>/dev/nul
115133
sed -i '' 's/hw.ramSize=.*/hw.ramSize=6144/' "$cfg" 2>/dev/null || echo 'hw.ramSize=6144' >> "$cfg"
116134
```
117135
(`aosp_atd` is rootable but headless — it can't run a Flet GUI app, so don't use it here.)
136+
If the emulator dies with *"Cannot find AVD system path. Please define ANDROID_SDK_ROOT"*
137+
(or, with that variable set, *"Broken AVD system path"*), the AVD survived but its system
138+
image did not (`system-images/` empty) — exporting the variable only changes the message.
139+
Reinstall the image with the SDK's own sdkmanager; a Homebrew `sdkmanager` first on `PATH`
140+
installs into its own root, where the emulator never looks:
141+
`"$SDK/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$SDK" "system-images;android-34;google_apis;arm64-v8a"`.
118142

119143
5. **Give the emulator RAM + disk.** A heavy `.so` (polars ~130 MB) + Python + the Flutter engine OOM-kills the app on a default AVD (`lowmemorykiller: Kill 'com.flet.recipe_tester'`). 6 GB RAM avoids it. The ~100–235 MB APK install needs a big `/data` (6 GB partition); if you hit `INSTALL_FAILED_INSUFFICIENT_STORAGE`, free space (uninstall old apps) or use a fresh AVD.
120144

@@ -126,7 +150,7 @@ for i in $(seq 1 30); do grep EXIT "$DATA/Library/Caches/console.log" 2>/dev/nul
126150

127151
9. **Android `console.log` lives in the app's CACHE dir — `/data/data/com.flet.recipe_tester/cache/console.log` — NOT under `files/flet/app/`** (that's the app code; `python_site_packages` is a SIBLING under `files/flet/`). Polling the wrong dir looks like "the app never wrote a result" and cost ~10 min during the sherpa-onnx validation. Root is still required to read it (gotcha #4): `adb root` then `adb shell cat …`, or `adb shell su 0 cat …` on a google_apis image.
128152

129-
10. **`flet build ios-simulator` resolves the `iphoneos` (device) wheel AS WELL as both simulator ones.** It configures pip for `iphoneos.arm64` + `iphonesimulator.arm64` + `iphonesimulator.x86_64` and needs a wheel for EACH — a partial local matrix fails with `No matching distribution found`. Build all three iOS slices first (for the recipe AND every `flet-lib*` host dep). CI never hits this because it dumps all of `dist/*.whl` into its find-links dir. (`flet build apk` needs only the one `--arch` slice — the asymmetry is iOS-only.) Long-standing gotcha; re-hit during the onnxruntime iOS spike. **Worse: serious_python's PER-ARCH native staging (`build/site-packages/<iosarch>/`) resolves those slice wheels from the INDEX directly and does NOT honor `PIP_FIND_LINKS`/dist-test locally** — so a hand-patched `-9999` wheel in your find-links dir is used for the initial pip install but the staged PYTHON code (e.g. `cv2/__init__.py`, whichever slice it picks — often `iphoneos.arm64`) still comes from the published wheel. Net: you cannot validate a *hand-patched loader* on a local `ios-simulator` build; use a real `forge` build of all slices, or verify in CI (where the freshly-built slice wheels ARE used — this is why coolprop iOS passed in CI but a hand-patched opencv wouldn't locally).
153+
10. **`flet build ios-simulator` resolves the `iphoneos` (device) wheel AS WELL as both simulator ones.** It configures pip for `iphoneos.arm64` + `iphonesimulator.arm64` + `iphonesimulator.x86_64` and needs a wheel for EACH — a partial local matrix fails with `No matching distribution found`. Build all three iOS slices first (for the recipe AND every `flet-lib*` host dep). CI never hits this because it dumps all of `dist/*.whl` into its find-links dir. (`flet build apk` needs only the one `--arch` slice — the asymmetry is iOS-only.) Long-standing gotcha; re-hit during the onnxruntime iOS spike. **Worse: a hand-patched wheel can still lose in serious_python's PER-ARCH native staging (`build/site-packages/<iosarch>/`).** That pip run does read the inherited `PIP_FIND_LINKS` (flet 0.86.5 logs "Looking in links: …", and an unpublished package such as lz4 resolves from it), but during the opencv work a hand-patched `-9999` wheel in the find-links dir was used for the initial pip install while the staged PYTHON code (e.g. `cv2/__init__.py`, whichever slice it picks — often `iphoneos.arm64`) still comes from the published wheel. Net: you cannot validate a *hand-patched loader* on a local `ios-simulator` build; use a real `forge` build of all slices, or verify in CI (where the freshly-built slice wheels ARE used — this is why coolprop iOS passed in CI but a hand-patched opencv wouldn't locally).
130154

131155
10a. **Old local Xcode can't compile newer Flutter plugins** — e.g. Xcode 16.4 dies on `device_info_plus` 12.4.0 with `ARC Semantic Issue: No visible @interface for 'NSProcessInfo' declares the selector 'isiOSAppOnVision'` (a visionOS selector added in a newer SDK). This is a LOCAL toolchain gap, not your recipe (CI's Xcode 26.5 is fine). Pin the offending plugins older in the generated app pyproject before `flet build ios-simulator`:
132156
```toml
@@ -136,17 +160,28 @@ for i in $(seq 1 30); do grep EXIT "$DATA/Library/Caches/console.log" 2>/dev/nul
136160
```
137161
(`stage_recipe.sh` regenerates the pyproject, so append this AFTER staging.)
138162

139-
10b. **New local Xcode rejects flet's iOS 13.0 deployment target** — Xcode 27 with flet 1.0.3: `Target Integrity (Xcode): The iOS Simulator deployment target 'IPHONEOS_DEPLOYMENT_TARGET' is set to 13.0, but the range of supported deployment target versions is 15.0 to 27.0.x`. The site-packages staging has already finished at that point, so finish the build by hand in `<app>/build/flutter`:
140-
- `ios/Podfile`: `platform :ios, '15.0'`, and inside `post_install`'s `installer.pods_project.targets.each` loop add `target.build_configurations.each { |config| config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' }`;
141-
- `ios/Runner.xcodeproj/project.pbxproj`: every `IPHONEOS_DEPLOYMENT_TARGET = 13.0;` → `15.0;`;
142-
- rerun Flutter with the environment `flet build` would have passed (`_flutter_path_env` in flet-cli's `build_base.py`):
163+
10b. **New local Xcode rejects flet's iOS deployment target.** Xcode 27 makes
164+
`IPHONEOS_DEPLOYMENT_TARGET = 13.0` (flet's template from 0.86.5 through 1.0.3, still on
165+
`main` as of 2026-10-03) a hard error: *"set to 13.0, but the range of supported deployment target
166+
versions is 15.0 to 27.0.x"*. Plain `flet build` output hides it behind "Failed to build
167+
iOS app" and a doctor dump; `-vv` shows the `error:` lines. Not your recipe, and CI's
168+
Xcode 26 is unaffected. Workaround — flet has already staged everything, so patch the
169+
generated project and re-run only the flutter step with the same env:
143170
```bash
144-
SERIOUS_PYTHON_VERSION=3.14 SERIOUS_PYTHON_SITE_PACKAGES=$APP/build/site-packages \
145-
SERIOUS_PYTHON_APP=$APP/build/python-app SERIOUS_PYTHON_BUNDLE_ID=com.flet.<app-name> \
146-
SP_NATIVE_SET=$(cat $APP/build/.serious_python_spm_key) \
147-
~/fvm/default/bin/flutter build ios --simulator --build-name 1.0.0
171+
cd build/flutter/ios
172+
sed -i '' "s/platform :ios, '13.0'/platform :ios, '15.0'/" Podfile
173+
# in Podfile's post_install target loop, before GCC_PREPROCESSOR_DEFINITIONS:
174+
# config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
175+
sed -i '' 's/IPHONEOS_DEPLOYMENT_TARGET = 13.0;/IPHONEOS_DEPLOYMENT_TARGET = 15.0;/' Runner.xcodeproj/project.pbxproj
176+
cd .. && SERIOUS_PYTHON_APP=$PWD/../python-app SERIOUS_PYTHON_SITE_PACKAGES=$PWD/../site-packages \
177+
SERIOUS_PYTHON_VERSION=3.14 SERIOUS_PYTHON_BUNDLE_ID=com.flet.<app-name> \
178+
SP_NATIVE_SET=$(cat ../.serious_python_spm_key) \
179+
flutter build ios --simulator --build-name 1.0.0 # VERSION = the Python flet built for
180+
# → build/flutter/build/ios/iphonesimulator/<app>.app
148181
```
149-
The `.app` lands in `build/flutter/build/ios/iphonesimulator/`, not `build/ios-simulator/`. CI's older Xcode needs none of this. Used for the xgboost example, 2026-10-04.
182+
A fresh `flet build` regenerates the template, so repeat the patch after every one.
183+
The env is flet-cli's `_flutter_path_env` (`build_base.py`); without
184+
`SERIOUS_PYTHON_BUNDLE_ID` the embedded frameworks get `org.python.*` identifiers.
150185

151186
11. **Two booted simulators make `simctl booted` ambiguous.** With more than one sim booted, `simctl install booted …` targets one device and your subsequent `get_app_container booted …` may query the OTHER — the app "isn't installed" / the container is empty despite a successful install. Use the explicit `$UDID` for every simctl call (as the loop above does); never rely on `booted` unless you've verified exactly one device is booted (`xcrun simctl list devices | grep -c Booted`).
152187

‎.claude/skills/new-mobile-recipe/SKILL.md‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,18 @@ you can `export NDK_HOME=~/Library/Android/sdk/ndk/<version>` and use that — f
236236
the differences between r27 / r27d / r28 are immaterial. CI builds against r27d; your local r27
237237
should produce equivalent wheels.
238238

239+
### Trap: a truncated support tarball
240+
241+
`download_support` in `setup.sh` returns early once the extracted
242+
`downloads/support/python-<plat>-mobile-forge-<ver>/support/` exists, otherwise skips the
243+
download whenever the tarball exists, and never checks `tar`'s exit status. A shell killed
244+
mid-`curl` leaves a partial tarball behind (2026-10-01: an iOS 3.14.6 tarball at 34 MB of
245+
296 MB): `tar` reports `truncated gzip input`, and depending on how far it got, setup either
246+
fails its support-path check or keeps a half-extracted tree on every later run. Compare a
247+
cached tarball with the release (`curl -sIL <url> | grep -i content-length` against
248+
`stat -f%z`), and delete both the tarball and its extracted `downloads/support/…` directory
249+
before re-running. On a slow link, fetch with parallel `curl -r` ranges and concatenate.
250+
239251
### Note: Android sysconfigdata CI paths — self-healing, nothing to fix
240252

241253
Historically the `python-android-mobile-forge-3.12.tar.gz` broke macOS local dev: CI-runner
@@ -299,6 +311,9 @@ Open `tests/test_<name>.py` and replace the placeholder with a real smoke test:
299311
- **Every test function has a docstring** — one line saying what behavior it proves.
300312
- Tests must be **network-free and deterministic** (fixed seeds, committed tiny assets) —
301313
they run on an emulator with no guarantees about connectivity.
314+
- **Type every parameter where Python allows it** — tests, fixtures (`tmp_path: Path`),
315+
helpers, and the example apps' functions and nested handlers alike. Only lambdas are
316+
exempt (no annotation syntax); domain functions also get a return type.
302317

303318
For ML/inference recipes, raise the bar from import-only to real compute:
304319

@@ -545,6 +560,29 @@ what's committed vs generated).
545560

546561
`>>>>>>>>>> EXIT 0 <<<<<<<<<<` in console.log on both platforms = ready to ship.
547562

563+
### Audit the consumer README and example before the PR
564+
565+
Every sentence in `recipes/<name>/README.md` and the example is a claim a bump can break,
566+
and plausible ones are often wrong. An adversarial claim audit of lz4 (one skeptic per
567+
claim group, each told to refute with source quotes and runs against the sdist, PyPI's
568+
desktop wheel and the reference tools) turned up defects that a green CI could not:
569+
570+
- **Upstream constants can overstate the library.** python-lz4 exports
571+
`COMPRESSIONLEVEL_MAX = 16`, but the vendored liblz4 clamps HC at 12, so levels 13–16
572+
produce byte-identical output. Check a "max" against the vendored C, not the binding.
573+
- **An example with several buttons and `page.run_thread` races itself.** The pool runs
574+
workers concurrently, so a second tap mid-run overlapped the first on a shared file and
575+
failed it every time. Disabling the controls in the handler is **not enough**: on an iOS
576+
simulator two taps reached Python 11 ms apart, before the `disabled` patch reached
577+
Flutter, and the race still fired. Guard the handler itself —
578+
`if not busy.acquire(blocking=False): return`, release at the end of the worker — and
579+
disable the controls for the visual cue (`recipes/lz4/examples/log-archive`).
580+
- **"Stateful object, one per thread" understates it** when the binding mutates the native
581+
context with the GIL released: sharing an `LZ4FrameCompressor` crashed the interpreter.
582+
- **Prose that says "streams" needs code that streams** — one `write()` of the whole
583+
buffer is not it, and neither is a test that never calls `write()` twice.
584+
- **Run flake8 over the example** (`--config .flake8`); E741 slipped through review.
585+
548586
### Wheel hygiene checklist (before commit)
549587

550588
Unzip the wheel(s) from `dist/` and inspect every native binary:

‎recipes/av/README.md‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,13 +133,16 @@ control for playing the result.
133133
PyAV releases the GIL around demuxing, decoding and encoding, so this work genuinely runs in
134134
parallel with the UI. Hand it to
135135
[`page.run_thread(...)`](https://flet.dev/docs/controls/page/#flet.Page.run_thread) — a
136-
half-second decode on the event-handler thread is a visibly frozen app. `run_thread` swallows
137-
exceptions and does not carry an automatic update with it, so catch your own failures and
138-
finish the handler with an explicit
136+
half-second decode on the event-handler thread is a visibly frozen app. `run_thread` reports a
137+
failure only in the log and does not carry an automatic update with it, so catch your own
138+
failures and finish the worker with an explicit
139139
[`page.update()`](https://flet.dev/docs/controls/page/#flet.Page.update).
140140

141141
A single container is not safe to use from two threads at once. Give each thread its own
142-
`av.open(...)`, or serialise access with a `threading.Lock`.
142+
`av.open(...)`, or serialise access with a `threading.Lock`. Two runs writing one output
143+
file need a lock even with separate containers, and `run_thread` uses a pool, so two quick
144+
taps on a button run concurrently. Take it in the click handler: disabling the button alone
145+
does not stop a tap that is already in flight.
143146

144147
### What this build can encode
145148

‎recipes/av/examples/clip-roundtrip/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ What it demonstrates:
2121
- **Work off the UI thread** — the encode runs in
2222
[`page.run_thread(...)`](https://flet.dev/docs/controls/page/#flet.Page.run_thread), which
2323
needs an explicit [`page.update()`](https://flet.dev/docs/controls/page/#flet.Page.update)
24-
at the end.
24+
at the end. The handler also takes a non-blocking lock: every run rewrites the same clip,
25+
and disabling the button does not stop a second tap that is already in flight.
2526

2627
## Try it
2728

‎recipes/av/examples/clip-roundtrip/src/clip.py‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
"""Writes a short MP4 with a video and an audio stream, reads it back to describe
2-
what actually landed in the file, and pulls stills out of it as JPEG bytes an
3-
`ft.Image` can display.
4-
"""
1+
"""Writes a short MP4 with a video and an audio stream, reads it back to describe what
2+
actually landed in the file, and pulls stills out of it as JPEG bytes an `ft.Image` can
3+
display."""
54

65
import io
76
import math
@@ -57,7 +56,7 @@ def _video_frame(index: int):
5756
return frame
5857

5958

60-
def _audio_frames(stream):
59+
def _audio_frames(stream: av.AudioStream):
6160
"""A 440 Hz tone, resampled into whatever layout the AAC encoder asked for."""
6261
resampler = av.AudioResampler(
6362
format=stream.format, layout=stream.layout, rate=SAMPLE_RATE
@@ -129,7 +128,7 @@ def probe(path: str) -> list[tuple[str, str]]:
129128
return rows
130129

131130

132-
def _jpeg(frame, width: int) -> bytes:
131+
def _jpeg(frame: av.VideoFrame, width: int) -> bytes:
133132
"""Re-encode one decoded frame as a JPEG, scaled to `width`."""
134133
height = width * frame.height // frame.width
135134
scaled = frame.reformat(width=width, height=height, format="yuvj420p")

0 commit comments

Comments
 (0)