diff --git a/.claude/skills/forge-ci/SKILL.md b/.claude/skills/forge-ci/SKILL.md index 50a61ad..112df25 100644 --- a/.claude/skills/forge-ci/SKILL.md +++ b/.claude/skills/forge-ci/SKILL.md @@ -226,9 +226,10 @@ wire CI + local to the fix so recipes validate BEFORE the PR: builds also need `device_info_plus`/`connectivity_plus` pins, but keep those OUT of the committed template — add post-staging; CI's Xcode doesn't need them.) - The on-device iOS/APK test in CI still uses PUBLISHED sp, so it can't pass until the - release — dispatch wheel builds with `mobile_test_pythons=""` and verify the recipe - LOCALLY (git or path sp override + local flet-cli + `--python-version 3.12`; see the - local-recipe-testing skill). CI goes green only after the sp/python-build release. + release — dispatch wheel builds with `mobile_test_pythons=none` (see the footgun below) + and verify the recipe LOCALLY (git or path sp override + local flet-cli + + `--python-version 3.12`; see the local-recipe-testing skill). CI goes green only after + the sp/python-build release. ## Watching a run without babysitting @@ -237,3 +238,33 @@ runs take 30min–3h) and print the conclusion + failed-job list when it completes. One watcher per run; kill stale watchers when you supersede a run, and remember a killed multi-line script may still fire its remaining lines — put dispatches *before* long build loops in scripts, or in separate commands. + +## Deploying (publishing wheels to pypi.flet.dev) + +Publishing is gated in `build-wheels-version.yml`'s **Publish wheels** step. It fires when +either (a) `inputs.publish == true` (an explicit deploy dispatch), OR (b) the automatic path +— a **push to `main`** with an **empty `python_build_run_id`**. `publish_to_pypi dist/*.whl` +is **per matrix-job and 409-tolerant**: each (python × platform) job publishes its own wheels +at the end, already-published (version+build) wheels 409-skip, and one job failing never +blocks another's publish. + +- **Deploy via dispatch:** run on **origin (`flet-dev/mobile-forge`)** — that's where + `GEMFURY_TOKEN` lives; a `publish=true` run on a fork builds but the publish step has no + token. `gh workflow run build-wheels.yml -R flet-dev/mobile-forge --ref -f + packages=… -f publish=true -f mobile_test_pythons=none -f python_build_run_id= -f serious_python_ref=main`. (The `publish` boolean input is read from the + dispatched **ref**, so it works even before it's on `main`.) +- **Bump before republishing an already-published version.** A recipe published at + `-` will 409-skip on re-publish, so a forge/loader fix won't reach pypi.flet.dev + until the **build number is bumped** (pymongo/onnxruntime 1→2 for the fix_wheel fixes). +- **Only ONE genuine build-order coupling** across a deploy set is `host_build` (build-time + static-link) deps — e.g. `jq` needs `flet-libjq`; put it in `prebuild_recipes` (it's built + into dist/ AND published). Runtime deps (opaque→pysodium, ncnn→opencv) don't need ordering + when tests are off. Scan with `grep -A6 host_build: recipes/*/meta.yaml`. +- **Verify against pypi.flet.dev, not job conclusions.** A *cancelled* run's "success" jobs + are unreliable (early-cancel can leave gaps), and a per-job artifact-upload flake + (`Failed to CreateArtifact: … ENOTFOUND`) fails the job → skips its Publish. Check the + actual wheels — **with the filename normalized `-`→`_`** in the distribution name + (`opencv_python-…`, `scikit_learn-…`, `flet_libjq-…`; the project *URL* keeps the hyphen). + Allow a few minutes for Gemfury indexing after a Publish step reports `[success]`. Account + for single-platform recipes (`platforms: [ios]`, e.g. pyobjus → no android wheels expected). diff --git a/.claude/skills/forge-error-catalogue/references/failure-catalogue.md b/.claude/skills/forge-error-catalogue/references/failure-catalogue.md index 9ed4263..aba4c5a 100644 --- a/.claude/skills/forge-error-catalogue/references/failure-catalogue.md +++ b/.claude/skills/forge-error-catalogue/references/failure-catalogue.md @@ -1295,8 +1295,16 @@ if _sub is not None and getattr(_sub, "origin", None): return # else fall through to the stock loader (desktop / non-0.86) ``` -Pair with **`extract_packages: [cv2]`** in meta.yaml (the extra-submodule scan does -`os.listdir()` on the package dir, so cv2 must ship extracted, not zipped). On +The recipe also declares **`extract_packages: [cv2]`**, but that is **defensive, not +required for core cv2**: this loader loads the native `cv2` directly (no `config.py` +read, no dir scan), and the only path that touches the package dir — cv2's OPTIONAL +extra-submodule scan (`__collect_extra_submodules` → `os.listdir`, for `cv2.gapi` +etc.) — is wrapped in `try/except` and **fails silently** when zipped. So `import cv2` ++ image ops (`imdecode`, …) work fine from `sitepackages.zip` WITHOUT `extract_packages` +(verified: a consumer app with no EP classified on-device). Add `extract_packages: [cv2]` +only if you use one of those extra submodules. Consumer apps must set it themselves in +`[tool.flet.android] extract_packages` — the recipe's meta value only reaches the +recipe-tester (via `stage_recipe.sh`), not `pip install` consumers. On desktop `find_spec('cv2.cv2')` is None → stock loader runs unchanged. General tell: any package whose loader **re-imports its own native under a specific top-level name** — reproduce the exact name via a fresh `ExtensionFileLoader(name, origin)`, diff --git a/.github/workflows/build-wheels-version.yml b/.github/workflows/build-wheels-version.yml index 0b86d69..7dbd484 100644 --- a/.github/workflows/build-wheels-version.yml +++ b/.github/workflows/build-wheels-version.yml @@ -37,7 +37,8 @@ on: description: | Python versions (comma-separated) whose recipe mobile tests should actually run. Versions in `python_versions` that aren't in this list still build wheels but skip the - APK/iOS-simulator test stage. Use "ALL" to run tests for every entry in `python_versions`: + APK/iOS-simulator test stage. Use "ALL" to run tests for every entry in `python_versions`. + Empty (or any value matching no python, e.g. "none") disables all mobile tests: required: false type: string default: "3.12" @@ -104,7 +105,8 @@ on: description: | Python versions (comma-separated) whose recipe mobile tests should actually run. Versions in `python_versions` that aren't in this list still build wheels but skip the - APK/iOS-simulator test stage. Use "ALL" to run tests for every entry in `python_versions`: + APK/iOS-simulator test stage. Use "ALL" to run tests for every entry in `python_versions`. + Empty (or any value matching no python, e.g. "none") disables all mobile tests: required: false default: "3.12" python_build_run_id: @@ -429,7 +431,9 @@ jobs: fi # Gate on the caller-provided mobile_test_pythons list: only Python - # versions in that list actually run the APK/iOS-sim test step. + # versions in that list actually run the APK/iOS-sim test step. An empty + # list (or a non-matching sentinel like "none") matches no python, so + # every leg skips — i.e. mobile_test_pythons="" disables all mobile tests. if [[ "$MOBILE_TEST_PYTHONS" != "ALL" ]]; then allowed="" for v in $(echo "$MOBILE_TEST_PYTHONS" | tr ',' ' '); do @@ -523,7 +527,7 @@ jobs: cd tests/recipe-tester PIP_FIND_LINKS="$GITHUB_WORKSPACE/dist-test" \ - uvx --prerelease allow --with 'flet-cli>=0.86.0.dev0' --with 'flet>=0.86.0.dev0' flet build apk --arch x86_64 -vv --yes --python-version "$(echo "$INPUT_PYTHON_VERSION" | cut -d. -f1-2)" + uvx --prerelease allow --with 'flet-cli' --with 'flet' flet build apk --arch x86_64 -vv --yes --python-version "$(echo "$INPUT_PYTHON_VERSION" | cut -d. -f1-2)" - name: Test on Android emulator (API 28, x86_64) if: matrix.platform == 'android' && steps.detect-tests.outputs.has_tests == 'true' @@ -586,7 +590,7 @@ jobs: cd tests/recipe-tester PIP_FIND_LINKS="$GITHUB_WORKSPACE/dist-test" \ - uvx --prerelease allow --with 'flet-cli>=0.86.0.dev0' --with 'flet>=0.86.0.dev0' flet build ios-simulator -vv --yes --python-version "$(echo "$INPUT_PYTHON_VERSION" | cut -d. -f1-2)" + uvx --prerelease allow --with 'flet-cli' --with 'flet' flet build ios-simulator -vv --yes --python-version "$(echo "$INPUT_PYTHON_VERSION" | cut -d. -f1-2)" - name: Test on iOS Simulator if: matrix.platform == 'ios' && steps.detect-tests.outputs.has_tests == 'true' diff --git a/.github/workflows/build-wheels.yml b/.github/workflows/build-wheels.yml index 3ab92ea..f834583 100644 --- a/.github/workflows/build-wheels.yml +++ b/.github/workflows/build-wheels.yml @@ -30,7 +30,8 @@ on: description: | Python versions (comma-separated) whose recipe mobile tests should actually run. Versions in `python_versions` that aren't in this list still build wheels but skip the - APK/iOS-simulator test stage. Use "ALL" to run tests for every entry in `python_versions`: + APK/iOS-simulator test stage. Use "ALL" to run tests for every entry in `python_versions`. + Pass an empty string to build wheels WITHOUT any on-device tests (build-only deploy): required: false default: "3.12" python_build_run_id: @@ -199,7 +200,15 @@ jobs: archs: ${{ inputs.archs || 'android,iOS' }} packages: ${{ needs.detect.outputs.packages }} prebuild_recipes: ${{ inputs.prebuild_recipes || '' }} - mobile_test_pythons: ${{ inputs.mobile_test_pythons || '3.12' }} + # A non-empty value is honored verbatim (list of test legs, or "ALL"). push/PR + # carry no inputs, so those default to the 3.12 on-device test. On + # workflow_dispatch/workflow_call an EXPLICIT empty string disables all mobile + # tests — mapped to the `none` sentinel so it survives the reusable-workflow + # boundary and never matches a python in the child's gate (`none` works too). + mobile_test_pythons: >- + ${{ inputs.mobile_test_pythons != '' && inputs.mobile_test_pythons + || ((github.event_name == 'push' || github.event_name == 'pull_request') && '3.12') + || 'none' }} python_build_run_id: ${{ inputs.python_build_run_id || '' }} # No `|| default` here: an explicitly-passed empty string must survive so a # caller can disable the override and test against the published sp. diff --git a/recipes/pyclipper/meta.yaml b/recipes/pyclipper/meta.yaml new file mode 100644 index 0000000..8cdc747 --- /dev/null +++ b/recipes/pyclipper/meta.yaml @@ -0,0 +1,17 @@ +package: + name: pyclipper + version: "1.4.0" + +build: + number: 1 + script_env: + _PYTHON_SYSCONFIGDATA_NAME: '{sysconfigdata_name}' + +# {% if sdk == 'android' %} +requirements: + host: + # The extension is C++ (Cython language="c++" over the vendored Clipper + # sources) -> libc++_shared.so at runtime on Android (srsly/murmurhash + # wiring). + - flet-libcpp-shared >=27.2.12479018 +# {% endif %} diff --git a/recipes/pyclipper/tests/test_pyclipper.py b/recipes/pyclipper/tests/test_pyclipper.py new file mode 100644 index 0000000..b0be9f5 --- /dev/null +++ b/recipes/pyclipper/tests/test_pyclipper.py @@ -0,0 +1,51 @@ +def test_intersection(): + """pyclipper is a Cython C++ extension over the vendored Clipper library. + Clip a triangle against a square — the shape rapidocr uses it for + (text-box postprocessing) is exactly this kind of polygon math.""" + import pyclipper + + subj = [[180, 200], [260, 200], [260, 150], [180, 150]] # square + clip = [[190, 210], [240, 210], [240, 130], [190, 130]] # square + + pc = pyclipper.Pyclipper() + pc.AddPath(clip, pyclipper.PT_CLIP, True) + pc.AddPath(subj, pyclipper.PT_SUBJECT, True) + solution = pc.Execute(pyclipper.CT_INTERSECTION) + + assert len(solution) == 1 + # intersection of the two squares is the 50x50 box (190..240, 150..200) + assert sorted(map(tuple, solution[0])) == [ + (190, 150), + (190, 200), + (240, 150), + (240, 200), + ] + + +def test_offset(): + """PyclipperOffset (polygon dilation) — rapidocr's unclip step.""" + import pyclipper + + pco = pyclipper.PyclipperOffset() + pco.AddPath( + [[0, 0], [100, 0], [100, 100], [0, 100]], + pyclipper.JT_MITER, + pyclipper.ET_CLOSEDPOLYGON, + ) + solution = pco.Execute(10) + + assert len(solution) == 1 + xs = [p[0] for p in solution[0]] + ys = [p[1] for p in solution[0]] + assert min(xs) == -10 and max(xs) == 110 + assert min(ys) == -10 and max(ys) == 110 + + +def test_scale_helpers(): + """The float<->int fixed-point helpers used by every real caller.""" + import pyclipper + + path = [(0.5, 0.25), (1.5, 2.75)] + scaled = pyclipper.scale_to_clipper(path) + back = pyclipper.scale_from_clipper(scaled) + assert [tuple(p) for p in back] == [(0.5, 0.25), (1.5, 2.75)] diff --git a/tests/recipe-tester/pyproject.toml.tpl b/tests/recipe-tester/pyproject.toml.tpl index 2e233dd..7fe4aec 100644 --- a/tests/recipe-tester/pyproject.toml.tpl +++ b/tests/recipe-tester/pyproject.toml.tpl @@ -5,7 +5,7 @@ description = "Generic in-app pytest runner for mobile-forge recipe wheels." requires-python = ">=3.10" dependencies = [ - "flet>=0.86.0.dev0", + "flet >=0.86.0", "pytest", # `stage_recipe.sh` rewrites the line below to pin the recipe under test (e.g. `"numpy==2.2.2"`), # and replaces the token line after it with any test-only deps declared in @@ -16,7 +16,7 @@ dependencies = [ [dependency-groups] dev = [ - "flet[all]>=0.86.0.dev0", + "flet[all] >=0.86.0", ] [tool.flet]