diff --git a/.ci/common.sh b/.ci/common.sh index 6073bf34..5a609831 100644 --- a/.ci/common.sh +++ b/.ci/common.sh @@ -3,3 +3,38 @@ function publish_to_pypi() { curl -F package=@$wheel https://$GEMFURY_TOKEN@push.fury.io/flet/ done } + +# Resolve which python-build tarball source to use. +# When PYTHON_BUILD_RUN_ID is set (workflow_dispatch input), fetch the +# named tarball from that python-build Actions run's artifacts; otherwise +# download from the canonical v release URL. +# +# Args: +# $1 artifact_platform — "android" | "darwin" (matches the python-build artifact name) +# $2 tarball — e.g. python-android-mobile-forge-3.12.tar.gz +# $3 extract_dir — local dir to extract the tarball into +# +# Caller env: +# PYTHON_SHORT_VERSION — e.g. 3.12 (required) +# PYTHON_BUILD_RUN_ID — empty for release URL; non-empty for `gh run download` +# GH_TOKEN — needed only when PYTHON_BUILD_RUN_ID is set +# RUNNER_TEMP — GitHub Actions runner temp dir (only used in override path) +function fetch_python_build_tarball() { + local artifact_platform="$1" + local tarball="$2" + local extract_dir="$3" + if [[ -n "${PYTHON_BUILD_RUN_ID:-}" ]]; then + echo "Fetching $tarball from python-build run $PYTHON_BUILD_RUN_ID" + local stage="$RUNNER_TEMP/python-build-artifact" + rm -rf "$stage" + mkdir -p "$stage" + gh run download "$PYTHON_BUILD_RUN_ID" \ + --repo flet-dev/python-build \ + --name "python-${artifact_platform}-${PYTHON_SHORT_VERSION}" \ + --dir "$stage" + tar -xzf "$stage/$tarball" -C "$extract_dir" + else + curl -#OL "https://github.com/flet-dev/python-build/releases/download/v${PYTHON_SHORT_VERSION}/$tarball" + tar -xzf "$tarball" -C "$extract_dir" + fi +} diff --git a/.ci/install_ndk.sh b/.ci/install_ndk.sh index cd4cea50..cf4cb096 100755 --- a/.ci/install_ndk.sh +++ b/.ci/install_ndk.sh @@ -1,51 +1,76 @@ -if [[ -z "${NDK_HOME-}" ]]; then - NDK_HOME=$HOME/ndk/$NDK_VERSION - echo "NDK_HOME environment variable is not set." - if [ ! -d $NDK_HOME ]; then - echo "Installing NDK $NDK_VERSION to $NDK_HOME" - mkdir -p downloads - - if [ $(uname) = "Darwin" ]; then - seven_zip=downloads/7zip/7zz - if ! test -f $seven_zip; then - echo "Installing 7-zip" - mkdir -p $(dirname $seven_zip) - cd $(dirname $seven_zip) - curl -#OL https://www.7-zip.org/a/7z2301-mac.tar.xz - tar -xf 7z2301-mac.tar.xz - cd - - fi - - ndk_dmg=android-ndk-$NDK_VERSION-darwin.dmg - if ! test -f downloads/$ndk_dmg; then - echo ">>> Downloading $ndk_dmg" - curl -#L -o downloads/$ndk_dmg https://dl.google.com/android/repository/$ndk_dmg - fi - - cd downloads - $seven_zip x $ndk_dmg - mkdir -p $(dirname $NDK_HOME) - mv Android\ NDK\ */AndroidNDK*.app/Contents/NDK $NDK_HOME - rm -rf Android\ NDK\ * - cd - - else - ndk_zip=android-ndk-$NDK_VERSION-linux.zip - if ! test -f downloads/$ndk_zip; then - echo ">>> Downloading $ndk_zip" - curl -#L -o downloads/$ndk_zip https://dl.google.com/android/repository/$ndk_zip - fi - cd downloads - unzip -oq $ndk_zip - mkdir -p $(dirname $NDK_HOME) - mv android-ndk-$NDK_VERSION $NDK_HOME - cd - - echo "NDK installed to $NDK_HOME" - fi - else - echo "NDK $NDK_VERSION is already installed in $NDK_HOME" +#!/usr/bin/env bash +# Install an Android NDK component via sdkmanager. +# +# Usage: +# .ci/install_ndk.sh # uses $NDK_VERSION +# .ci/install_ndk.sh 27.3.13750724 # explicit component version +# .ci/install_ndk.sh r27d # release letter (resolved via Google's manifest) +# +# Installs into $ANDROID_HOME/ndk// — sdkmanager's +# standard layout, which is also where AGP looks for Gradle builds. +# When SOURCED (`. install_ndk.sh ...`), exports NDK_HOME pointing at +# the resulting install path; forge reads that env var. +# +# Idempotent: if the NDK is already installed, the sdkmanager call is skipped. +# +# Requires `sdkmanager` from the Android SDK cmdline-tools. On CI both +# the Ubuntu and macOS runner images ship it; locally install Android +# Studio or the standalone cmdline-tools. + +set -eu + +version="${1:-${NDK_VERSION:-}}" +if [ -z "$version" ]; then + echo "usage: $0 (or set NDK_VERSION)" >&2 + exit 2 +fi + +# Resolve release-letter form (e.g. "r27d") to the component version +# (e.g. "27.3.13750724") via Google's repository manifest. Skipped when +# the input is already in component form. Uses awk to track the most-recent +# `path="ndk;"` attribute. +if [[ "$version" =~ ^r[0-9]+[a-z]*$ ]]; then + letter="$version" + version=$(curl -sfL https://dl.google.com/android/repository/repository2-3.xml \ + | awk -v zip="android-ndk-${letter}-linux.zip" ' + match($0, /path="ndk;[0-9.]+"/) { + current = substr($0, RSTART+10, RLENGTH-11) + } + !found && index($0, zip) { print current; found=1 } + ') + if [ -z "$version" ]; then + echo "Could not resolve NDK release letter '$letter' to a component version." >&2 + echo "Check it exists at https://dl.google.com/android/repository/repository2-3.xml" >&2 + exit 4 fi -else - echo "NDK home: $NDK_HOME" + echo "Resolved $letter → $version" +fi + +: "${ANDROID_HOME:?ANDROID_HOME must be set (Android SDK location)}" + +sdkmanager="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" +if [ ! -x "$sdkmanager" ]; then + echo "sdkmanager not found at $sdkmanager" >&2 + echo " Install Android Studio or the standalone cmdline-tools first." >&2 + exit 3 +fi + +install_dir="$ANDROID_HOME/ndk/$version" + +# Idempotency check: any host-triplet clang under the install dir means +# it's already installed and usable. +if ! find "$install_dir/toolchains/llvm/prebuilt"/*/bin/aarch64-linux-android*-clang 2>/dev/null | grep -q .; then + echo "Installing NDK $version via sdkmanager…" + yes | "$sdkmanager" --licenses > /dev/null + "$sdkmanager" --install "ndk;$version" fi -export NDK_HOME +echo "NDK $version installed at $install_dir" +export NDK_HOME="$install_dir" + +# When run as a GH Actions step (not sourced — the export above doesn't +# persist across steps), write NDK_HOME to $GITHUB_ENV so downstream +# steps inherit it. Harmless to no-op when $GITHUB_ENV is unset (local). +if [ -n "${GITHUB_ENV:-}" ]; then + echo "NDK_HOME=$install_dir" >> "$GITHUB_ENV" +fi diff --git a/.ci/read_meta.py b/.ci/read_meta.py new file mode 100644 index 00000000..452458fe --- /dev/null +++ b/.ci/read_meta.py @@ -0,0 +1,78 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["pyyaml", "jinja2"] +# /// +"""Read fields from a recipe's meta.yaml and print them as one +tab-separated line: + + \t\t + +Examples: + + 2.2.2\t0\t # numpy (no platforms, no build override) + 1.6.1\t0\tandroid # pyjnius + 1.2.3\t0\tios # pyobjus + 8.11.0\t1\t # flet-libcurl (uses Jinja `{% set %}`) + +Used by the build-wheels.yml matrix step to (a) skip per-recipe +(platform, pkg) combinations that the recipe opts out of, and (b) +include the version + build number in each job's display name. + +A standalone PEP 723 script rather than an inline here-doc in the +workflow — testable in isolation, declares its own pyyaml/jinja2 deps +so the runner doesn't need them preinstalled. + +meta.yaml is a Jinja template (forge renders it before YAML-parsing). +We render it the same way, with a generic SDK context — the fields we +read here are platform-independent, so any plausible render values +work. Picking `sdk='android'` is arbitrary and convenient. + +On any failure (file missing, template invalid, YAML invalid, +schema-shape unexpected) we print a blank-but-tab-aligned line so the +bash caller's `IFS=$'\\t' read -r ver build platforms` doesn't blow up +— the caller treats empty fields as "unknown, fall back to whatever +the package spec or workflow defaults already say.""" + +import sys + +import jinja2 +import yaml + + +def main(path: str) -> int: + version = "" + build_number = "" + platforms = "" + try: + with open(path) as f: + tpl = f.read() + rendered = jinja2.Template(tpl).render( + sdk="android", + sdk_version=24, + arch="arm64-v8a", + version=None, + py_version=(3, 12, 12), + ) + meta = yaml.safe_load(rendered) or {} + pkg = meta.get("package") or {} + if "version" in pkg: + version = str(pkg["version"]) + plat = pkg.get("platforms") + if plat: + platforms = " ".join(plat) + # build.number defaults to 0 in the schema, but raw meta.yaml may + # omit it. Match the schema default rather than treating it as + # unknown. + build = (meta.get("build") or {}).get("number", 0) + build_number = str(build) + except Exception: + pass + print(f"{version}\t{build_number}\t{platforms}") + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("usage: read_meta.py ", file=sys.stderr) + sys.exit(2) + sys.exit(main(sys.argv[1])) diff --git a/.ci/run_android_test.sh b/.ci/run_android_test.sh new file mode 100755 index 00000000..69198f74 --- /dev/null +++ b/.ci/run_android_test.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Run inside reactivecircus/android-emulator-runner@v2's script: field. +# That action runs each script line through `sh -c` separately, which breaks +# multi-line bash constructs (functions, traps, if blocks). So the whole +# logic lives in this dedicated script file with its own bash shebang; +# the workflow's `script:` field just invokes this file as a one-liner. +# +# Side effects: +# - installs + launches the recipe-tester APK at +# tests/recipe-tester/build/apk/recipe-tester.apk +# - delegates the poll-and-parse to .ci/wait_for_console.sh +# - on failure, dumps `adb logcat -d` and a screencap into the workspace +# root so the workflow's upload-artifact step can pick them up +# (the AVD is alive at trap-time; the post-action phase has already +# killed it by the time the workflow's failure-conditional steps run) + +set -eux + +cleanup() { + rc=$? + if [ "$rc" -ne 0 ]; then + adb logcat -d > logcat-on-failure.txt 2>/dev/null || true + adb exec-out screencap -p > screen-on-failure.png 2>/dev/null || true + fi + return $rc +} +trap cleanup EXIT + +adb install -r tests/recipe-tester/build/apk/recipe-tester.apk +adb logcat -c +adb shell monkey -p com.flet.recipe_tester -c android.intent.category.LAUNCHER 1 + +# 15min hard cap on the device-side run; recipe tests should finish in +# <2min, the extra slack absorbs AVD slowness. +TIMEOUT=900 .ci/wait_for_console.sh android diff --git a/.ci/run_ios_test.sh b/.ci/run_ios_test.sh new file mode 100755 index 00000000..d35380bd --- /dev/null +++ b/.ci/run_ios_test.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Mirror of .ci/run_android_test.sh for the iOS Simulator lane. +# +# Boots an iPhone simulator if none is running, installs the recipe-tester +# .app, launches it, then delegates the EXIT-sentinel poll to +# .ci/wait_for_console.sh ios. +# +# Side effects: +# - on failure, dumps `xcrun simctl spawn booted log show --last 10m` +# into syslog-on-failure.txt and a screencap into screen-on-failure.png +# so the workflow's upload-artifact step can pick them up. The simulator +# is still booted at trap-time (the workflow doesn't shut it down +# itself), so these calls work. +# +# Why a separate script (not inline `run:`): same reason as the Android +# helper — keep the dash/bash quirks inside a file with a proper bash +# shebang so multi-line constructs survive whatever shell the action +# eventually picks. (Less critical here since the iOS step is a plain +# `run: bash` rather than an action's split-by-line script:, but it's +# consistent and keeps the diff focused.) + +set -eux + +APP=tests/recipe-tester/build/ios-simulator/recipe-tester.app +IOS_BUNDLE=${IOS_BUNDLE:-com.flet.recipe-tester} + +cleanup() { + rc=$? + if [ "$rc" -ne 0 ]; then + xcrun simctl spawn booted log show --last 10m > syslog-on-failure.txt 2>/dev/null || true + xcrun simctl io booted screenshot screen-on-failure.png 2>/dev/null || true + fi + return $rc +} +trap cleanup EXIT + +# Pick + boot a simulator if none is currently booted. Robust to the +# specific device names changing between macos image versions: grab the +# first available iPhone in any iOS runtime. +if ! xcrun simctl list devices booted | grep -q "Booted"; then + UDID=$(xcrun simctl list devices available -j \ + | jq -r '.devices | to_entries[] + | select(.key | contains("iOS")) + | .value[] + | select(.isAvailable == true and (.name | startswith("iPhone"))) + | .udid' \ + | head -1) + if [ -z "$UDID" ]; then + echo "::error::no iPhone simulator available on this runner" + xcrun simctl list devices available + exit 3 + fi + echo "Booting simulator $UDID" + xcrun simctl boot "$UDID" +fi +xcrun simctl bootstatus booted -b + +xcrun simctl install booted "$APP" +xcrun simctl launch booted "$IOS_BUNDLE" + +# Same 15-min device-side cap as Android. Tests should finish in <2min; +# the slack absorbs cold-boot + first-launch Python init overhead. +TIMEOUT=900 .ci/wait_for_console.sh ios diff --git a/.ci/wait_for_console.sh b/.ci/wait_for_console.sh new file mode 100755 index 00000000..d9947647 --- /dev/null +++ b/.ci/wait_for_console.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# Poll the recipe-tester's console.log on a mobile device/simulator, parse +# the EXIT sentinel, write a GitHub Step Summary, and exit with the +# sentinel's code. +# +# Usage: +# wait_for_console.sh android +# wait_for_console.sh ios +# +# Environment overrides: +# TIMEOUT seconds to wait for the EXIT sentinel (default: 600) +# INTERVAL seconds between polls (default: 2) +# ANDROID_PKG Android package id (default: com.flet.recipe_tester) +# IOS_BUNDLE iOS bundle id (default: com.flet.recipe-tester) +# +# Exit codes: +# 0 tests passed (sentinel reported EXIT 0) +# 1 tests failed (sentinel reported non-zero) +# 2 timed out — no EXIT sentinel ever appeared in console.log +# 3 environment error (couldn't gain access to console.log) +# +# Side effects: +# - copies console.log to the current working directory (so the workflow's +# upload-artifact step can pick it up) +# - appends a markdown block to $GITHUB_STEP_SUMMARY (when set) +# +# Why a file and not log stream / logcat? Flet's launcher redirects Python +# stdout/stderr to $FLET_APP_CONSOLE = /console.log in production +# builds, so raw print() output never reaches `adb logcat` or +# `xcrun simctl log stream` — the file is the only place it lands. + +set -euo pipefail + +PLATFORM="${1:-}" +if [[ "$PLATFORM" != "android" && "$PLATFORM" != "ios" ]]; then + echo "usage: $0 " >&2 + exit 3 +fi + +TIMEOUT="${TIMEOUT:-600}" +INTERVAL="${INTERVAL:-2}" +ANDROID_PKG="${ANDROID_PKG:-com.flet.recipe_tester}" +IOS_BUNDLE="${IOS_BUNDLE:-com.flet.recipe-tester}" +OUT="$PWD/console.log" + +# --- platform-specific console.log fetch ------------------------------------ + +if [[ "$PLATFORM" == "android" ]]; then + # console.log lives at /data/data//cache/console.log in the app + # sandbox. Reading it requires either `adb root` (works on userdebug + # AVDs — the standard ReactiveCircus/android-emulator-runner default) + # or the app being marked debuggable. We assume userdebug AVD. + # + # Retry: under high CI parallelism (~20 emulators sharing the runner + # pool) `adb root` occasionally fires before adbd is fully ready and + # reports "permission denied — must be userdebug" even on a userdebug + # AVD. A short retry loop eats the race. + adb_root_ok=0 + for attempt in 1 2 3 4 5 6; do + if adb root >/dev/null 2>&1; then + adb_root_ok=1 + break + fi + sleep 5 + done + if [[ "$adb_root_ok" != "1" ]]; then + echo "::error::adb root failed after 6 retries — AVD must be userdebug (or the app debuggable)" + exit 3 + fi + # adbd restarts after `adb root`; wait for it to come back. + adb wait-for-device + REMOTE="/data/data/$ANDROID_PKG/cache/console.log" + fetch() { adb pull "$REMOTE" "$OUT" >/dev/null 2>&1 || true; } + +elif [[ "$PLATFORM" == "ios" ]]; then + # On iOS simulator the app sandbox is on the host fs — no copy, no + # permissions. simctl get_app_container resolves the per-app data path. + DATA=$(xcrun simctl get_app_container booted "$IOS_BUNDLE" data 2>/dev/null || true) + if [[ -z "$DATA" ]]; then + echo "::error::xcrun simctl get_app_container failed — is the app installed and a sim booted?" + exit 3 + fi + REMOTE="$DATA/Library/Caches/console.log" + fetch() { [[ -f "$REMOTE" ]] && cp "$REMOTE" "$OUT" || true; } +fi + +# --- poll loop -------------------------------------------------------------- + +echo "::group::Polling $REMOTE for EXIT sentinel (timeout=${TIMEOUT}s)" + +# Truncate any stale local copy. +: > "$OUT" + +deadline=$(( $(date +%s) + TIMEOUT )) +attempts=0 +while [[ "$(date +%s)" -lt "$deadline" ]]; do + attempts=$(( attempts + 1 )) + fetch + if [[ -s "$OUT" ]] && grep -qE '^>>>>>>>>>> EXIT [0-9-]+ <<<<<<<<<<$' "$OUT"; then + echo "found EXIT sentinel after ${attempts} polls" + break + fi + sleep "$INTERVAL" +done + +echo "::endgroup::" + +# --- parse + report --------------------------------------------------------- + +if [[ ! -s "$OUT" ]] || ! grep -qE '^>>>>>>>>>> EXIT [0-9-]+ <<<<<<<<<<$' "$OUT"; then + echo "::error::Timed out after ${TIMEOUT}s without seeing EXIT sentinel" + if [[ -s "$OUT" ]]; then + echo "::group::Tail of console.log (last 50 lines)" + tail -50 "$OUT" + echo "::endgroup::" + else + echo "(console.log is empty or absent — Python may have crashed before any output)" + fi + exit 2 +fi + +# Format: +# >>>>>>>>>> EXIT 0 <<<<<<<<<< +# ↑ $1 ↑ $2 ↑ $3 ↑ $4 +# `tail -1` defensively picks the last match in case the runner ever +# emits multiple — today it prints exactly once. +EXIT_CODE=$(grep -oE '^>>>>>>>>>> EXIT [0-9-]+ <<<<<<<<<<$' "$OUT" \ + | tail -1 \ + | awk '{print $3}') + +# Pull pytest's pass/fail line out of the log too, e.g. +# ============================== 2 passed in 0.02s =============================== +PYTEST_SUMMARY=$(grep -E '^=+ .* (passed|failed|error|skipped).* =+$' "$OUT" | tail -1 || true) + +if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + # Annotate non-zero codes with a one-liner so the GH summary is + # self-explanatory at a glance. EXIT_CODE here is pytest's exit + # code (carried through the sentinel) — pytest exit codes per + # https://docs.pytest.org/en/stable/reference/exit-codes.html. + case "$EXIT_CODE" in + 1) meaning=" (tests failed)" ;; + 2) meaning=" (test execution interrupted)" ;; + 3) meaning=" (internal pytest error)" ;; + 4) meaning=" (pytest usage error)" ;; + 5) meaning=" (no tests collected)" ;; + *) meaning="" ;; + esac + { + echo "## recipe-tester — ${PLATFORM}" + echo + if [[ "$EXIT_CODE" == "0" ]]; then + echo "**Result:** ✅ exit 0" + else + echo "**Result:** ❌ exit ${EXIT_CODE}${meaning}" + fi + if [[ -n "$PYTEST_SUMMARY" ]]; then + echo + echo "\`${PYTEST_SUMMARY}\`" + fi + echo + echo "
Tail of console.log (last 50 lines)" + echo + echo '```' + tail -50 "$OUT" + echo '```' + echo + echo "
" + } >> "$GITHUB_STEP_SUMMARY" +fi + +echo "exit code: $EXIT_CODE" +exit "$EXIT_CODE" diff --git a/.github/workflows/build-wheels-with-cibuildwheel.yml b/.github/workflows/build-wheels-with-cibuildwheel.yml index 42129b02..2fcd5d12 100644 --- a/.github/workflows/build-wheels-with-cibuildwheel.yml +++ b/.github/workflows/build-wheels-with-cibuildwheel.yml @@ -11,19 +11,19 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 - name: Set up JDK - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: "17" - name: Set up Android SDK - uses: android-actions/setup-android@v3 + uses: android-actions/setup-android@v4 - name: Build Android wheels env: @@ -37,7 +37,7 @@ jobs: cd websockets-16.0 uvx cibuildwheel --output-dir wheelhouse - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v6 with: name: wheels-android path: websockets-16.0/wheelhouse/*.whl \ No newline at end of file diff --git a/.github/workflows/build-wheels.yml b/.github/workflows/build-wheels.yml index 14a18070..a93d6660 100644 --- a/.github/workflows/build-wheels.yml +++ b/.github/workflows/build-wheels.yml @@ -1,4 +1,4 @@ -name: Build wheels +name: Build and Publish wheels on: push: @@ -10,18 +10,22 @@ on: required: false default: "android,iOS" packages: - description: "Packages (comma-separated, e.g. pillow:11.1.0,pydantic-core:2.33.2)" + description: "Packages (comma-separated, e.g. pillow:11.1.0,pydantic-core:2.33.2) — or 'ALL' to build/test every recipe" required: false default: "pydantic-core:2.33.2" - publish: - description: "Publish to PyPI" - type: boolean - default: false + python_build_run_id: + description: | + flet-dev/python-build Actions run-id whose artifacts to use as the + python-build support tree, instead of the v release tarball. + required: false + default: "" env: UV_PYTHON: "3.12.12" MOBILE_FORGE_CACHE_DOWNLOADS_OFF: "1" - NDK_VERSION: r27d + FORGE_NDK_VERSION: r27d # used by forge for wheel cross-compile. + FLUTTER_NDK_VERSION: "28.2.13676358" # used by flutter for apk build. + FLET_CLI_NO_RICH_OUTPUT: 1 jobs: setup: @@ -30,11 +34,14 @@ jobs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 + + - name: Setup uv + uses: astral-sh/setup-uv@v7 - name: Get changed recipes id: changed-recipes - uses: tj-actions/changed-files@v45 + uses: tj-actions/changed-files@v47 with: files: recipes/** dir_names: true @@ -49,7 +56,17 @@ jobs: run: | SMOKE_TEST="pydantic-core:2.33.2" if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then - pkgs="${INPUT_PACKAGES:-$SMOKE_TEST}" + # The literal value "ALL" expands to every recipe with a meta.yaml under recipes/. + if [[ "$INPUT_PACKAGES" == "ALL" ]]; then + pkgs="" + for dir in recipes/*/; do + [[ -f "$dir/meta.yaml" ]] || continue + pkg=$(basename "$dir") + pkgs="${pkgs:+$pkgs,}${pkg}:" + done + else + pkgs="${INPUT_PACKAGES:-$SMOKE_TEST}" + fi else pkgs="" for dir in $CHANGED_DIRS; do @@ -58,6 +75,7 @@ jobs: done pkgs="${pkgs:-$SMOKE_TEST}" fi + echo "Detected packages: $pkgs" echo "packages=$pkgs" >> "$GITHUB_OUTPUT" @@ -72,17 +90,39 @@ jobs: for arch in $(echo "$ARCHS" | tr ',' ' '); do for pkg in $(echo "$PACKAGES" | tr ',' ' '); do pkg_name="${pkg%%:*}" - if [ "$first" = true ]; then first=false; else matrix+=','; fi if [[ "$arch" == "android" ]]; then runner="ubuntu-latest" platform="android" rust_targets="aarch64-linux-android,arm-linux-androideabi,x86_64-linux-android,i686-linux-android" else - runner="macos-latest" + runner="macos-26" platform="ios" rust_targets="aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios" fi - matrix+="{\"job_name\":\"${platform}: ${pkg_name}\",\"artifact_name\":\"${platform}-${pkg_name}\",\"runner\":\"$runner\",\"platform\":\"$platform\",\"forge_arch\":\"$arch\",\"forge_packages\":\"$pkg\",\"rust_targets\":\"$rust_targets\"}" + # Read recipe meta (version + build_number + platforms) + recipe_version=""; recipe_build=""; declared="" + if [[ -f "recipes/$pkg_name/meta.yaml" ]]; then + IFS=$'\t' read -r recipe_version recipe_build declared \ + <<< "$(uv run --script .ci/read_meta.py "recipes/$pkg_name/meta.yaml")" + fi + + # Honor recipe's `package.platforms` on the matrix. + if [[ -n "$declared" && ! " $declared " == *" $platform "* ]]; then + echo "::notice::Skip ${platform}: ${pkg_name} — recipe declares platforms=[$declared]" + continue + fi + + # Compose the job display name. The build number comes from + # the recipe's meta.yaml (read above): #59 dropped the + # build_number workflow input, so meta.yaml is the source of + # truth. + pkg_ver_override="${pkg#*:}" + display_version="${pkg_ver_override:-$recipe_version}" + display_build="${recipe_build:-0}" + job_name="${platform}: ${pkg_name} ${display_version} #${display_build}" + + if [ "$first" = true ]; then first=false; else matrix+=','; fi + matrix+="{\"job_name\":\"$job_name\",\"artifact_name\":\"${platform}-${pkg_name}\",\"runner\":\"$runner\",\"platform\":\"$platform\",\"forge_arch\":\"$arch\",\"forge_packages\":\"$pkg\",\"rust_targets\":\"$rust_targets\"}" done done matrix+=']}' @@ -98,22 +138,44 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 + + - name: Free disk space (Ubuntu runners only) + if: runner.os == 'Linux' + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false # KEEP — we need android sdk + python tooling + android: false # KEEP — Android SDK is used by Build wheels (NDK) + Test + dotnet: true + haskell: true + large-packages: false # SKIP — sudo apt remove takes minutes; reaping above is enough + docker-images: true + swap-storage: true - name: Setup uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 - name: Setup Rust uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.rust_targets }} + - name: Install NDK ${{ env.FORGE_NDK_VERSION }} for forge on Android + # forge cross-compiles Android wheels with this NDK. The install + # script writes NDK_HOME to $GITHUB_ENV so the Build wheels step + # (and forge subprocesses) pick it up. + if: matrix.platform == 'android' + shell: bash + run: .ci/install_ndk.sh "$FORGE_NDK_VERSION" + - name: Build wheels shell: bash env: FORGE_ARCH: ${{ matrix.forge_arch }} FORGE_PACKAGES: ${{ matrix.forge_packages }} PLATFORM: ${{ matrix.platform }} + PYTHON_BUILD_RUN_ID: ${{ inputs.python_build_run_id }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # used by `gh run download` run: | set -euxo pipefail @@ -128,17 +190,17 @@ jobs: sudo apt-get install -y sqlite3 python_android_dir="$HOME/projects/python-build/android" - curl -#OL "https://github.com/flet-dev/python-build/releases/download/v${PYTHON_SHORT_VERSION}/python-android-mobile-forge-${PYTHON_SHORT_VERSION}.tar.gz" mkdir -p "$python_android_dir" - tar -xzf "python-android-mobile-forge-${PYTHON_SHORT_VERSION}.tar.gz" -C "$python_android_dir" + fetch_python_build_tarball "android" \ + "python-android-mobile-forge-${PYTHON_SHORT_VERSION}.tar.gz" \ + "$python_android_dir" export MOBILE_FORGE_ANDROID_SUPPORT_PATH="$python_android_dir" - - . .ci/install_ndk.sh else python_ios_dir="$HOME/projects/python-build/darwin/Python-Apple-support" - curl -#OL "https://github.com/flet-dev/python-build/releases/download/v${PYTHON_SHORT_VERSION}/python-ios-mobile-forge-${PYTHON_SHORT_VERSION}.tar.gz" mkdir -p "$python_ios_dir" - tar -xzf "python-ios-mobile-forge-${PYTHON_SHORT_VERSION}.tar.gz" -C "$python_ios_dir" + fetch_python_build_tarball "darwin" \ + "python-ios-mobile-forge-${PYTHON_SHORT_VERSION}.tar.gz" \ + "$python_ios_dir" export MOBILE_FORGE_IOS_SUPPORT_PATH="$python_ios_dir" fi @@ -154,8 +216,166 @@ jobs: # Android deps: bzip2, libffi, openssl, sqlite, xz rm -f dist/bzip2-* dist/libffi-* dist/mpdecimal-* dist/openssl-* dist/sqlite-* dist/xz-* + # --- Mobile test lane --------------------------------------------------- + + - name: Detect test files for this recipe + id: detect-tests + shell: bash + env: + FORGE_PACKAGES: ${{ matrix.forge_packages }} + run: | + set -euo pipefail + pkg_name="${FORGE_PACKAGES%%:*}" + pkg_version="${FORGE_PACKAGES#*:}" + [[ "$pkg_version" == "$FORGE_PACKAGES" ]] && pkg_version="" + + # Check for the presence of any test files at known locations. + if [[ -d "recipes/$pkg_name/tests" ]] \ + || [[ -d "recipes/$pkg_name/test" ]] \ + || compgen -G "recipes/$pkg_name/test_*.py" > /dev/null; then + echo "Found tests for $pkg_name" + echo "has_tests=true" >> "$GITHUB_OUTPUT" + echo "pkg_name=$pkg_name" >> "$GITHUB_OUTPUT" + echo "pkg_version=$pkg_version" >> "$GITHUB_OUTPUT" + else + echo "::notice::Skipping mobile test — no tests/, test/ or test_*.py under recipes/$pkg_name/" + echo "has_tests=false" >> "$GITHUB_OUTPUT" + fi + + - name: Enable KVM + # GA'd April 2024 on standard Linux runners; needs a udev rule to grant the runner + # user rw on /dev/kvm. Android-only — macOS uses Hypervisor.Framework on its own. + if: matrix.platform == 'android' && steps.detect-tests.outputs.has_tests == 'true' + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Install NDK ${{ env.FLUTTER_NDK_VERSION }} for Flutter Android build + # `flet build apk` shells out to Flutter, whose generated Gradle + # uses `ndkVersion = flutter.ndkVersion`. Without this NDK + # pre-installed at $ANDROID_HOME/ndk//, Gradle triggers + # an auto-install mid-build that flakes intermittently + # (InstallFailedException / ZipException). Pre-installing via + # the shared install_ndk.sh removes that race. + if: matrix.platform == 'android' && steps.detect-tests.outputs.has_tests == 'true' + shell: bash + run: .ci/install_ndk.sh "$FLUTTER_NDK_VERSION" + + - name: Stage tests + build recipe-tester APK + if: matrix.platform == 'android' && steps.detect-tests.outputs.has_tests == 'true' + shell: bash + env: + PKG_NAME: ${{ steps.detect-tests.outputs.pkg_name }} + PKG_VERSION: ${{ steps.detect-tests.outputs.pkg_version }} + run: | + set -euxo pipefail + + # Copy just-built wheels into dist-test/ with build tags bumped to + # 9999. Reason: pip's resolver merges --find-links and the + # --extra-index-url (pypi.flet.dev) and picks the wheel with the + # highest build tag per PEP 427. The published wheel on + # pypi.flet.dev typically has build tag >= 1, while forge's + # freshly-built wheel for the same version may have a lower (or + # absent) build tag — so pip silently uses the OLD published wheel + # and the recipe fix being validated is bypassed. Bumping local + # copies to 9999 guarantees they win. Original dist/ is left + # untouched so the publish step still ships at the user-specified + # build_number. + mkdir -p "$GITHUB_WORKSPACE/dist-test" + for w in "$GITHUB_WORKSPACE"/dist/*.whl; do + [[ -e "$w" ]] || continue + base=$(basename "$w") + # name-version[-buildtag]-pytag-abitag-plat.whl + # → name-version-9999-pytag-abitag-plat.whl + # abitag covers both cp312-cp312 (Python-specific) and + # cp37-abi3 (stable ABI, used e.g. by cryptography). + new=$(printf '%s\n' "$base" \ + | sed -E 's/^([^-]+-[^-]+)(-[0-9]+)?-(cp[0-9]+-(cp[0-9]+|abi[0-9]+))-/\1-9999-\3-/') + cp "$w" "$GITHUB_WORKSPACE/dist-test/$new" + done + + ./tests/recipe-tester/stage_recipe.sh "$PKG_NAME" "$PKG_VERSION" + cd tests/recipe-tester + PIP_FIND_LINKS="$GITHUB_WORKSPACE/dist-test" \ + uvx --with flet-cli flet build apk --arch x86_64 -vv --yes + + - name: Test on Android emulator (API 28, x86_64) + if: matrix.platform == 'android' && steps.detect-tests.outputs.has_tests == 'true' + uses: reactivecircus/android-emulator-runner@v2 + timeout-minutes: 20 + with: + # API 28 minimum, NOT 24. mobile-forge wheels target API 24, but + # Flet's Android app shell uses ImageDecoder.OnHeaderDecodedListener + # (added in API 28) — on a 24 AVD the recipe-tester APK crashes at + # launch with ClassNotFoundException before Python ever starts. + api-level: 28 + arch: x86_64 + target: default + disable-animations: true + # The reactivecircus action invokes EACH LINE of `script:` through + # `sh -c` separately — multi-line constructs (functions, traps, if + # blocks) don't survive. Logic lives in .ci/run_android_test.sh + # instead, which has its own bash shebang. + script: .ci/run_android_test.sh + + # --- iOS lane ---------------------------------------------------------- + + - name: Stage tests + build recipe-tester iOS sim app + if: matrix.platform == 'ios' && steps.detect-tests.outputs.has_tests == 'true' + shell: bash + env: + PKG_NAME: ${{ steps.detect-tests.outputs.pkg_name }} + PKG_VERSION: ${{ steps.detect-tests.outputs.pkg_version }} + run: | + set -euxo pipefail + + # Same dist-test wheel-bump as the Android lane — pip's build-tag + # preference applies equally to iOS-tagged wheels resolving against + # pypi.flet.dev's published copies. + mkdir -p "$GITHUB_WORKSPACE/dist-test" + for w in "$GITHUB_WORKSPACE"/dist/*.whl; do + [[ -e "$w" ]] || continue + base=$(basename "$w") + new=$(printf '%s\n' "$base" \ + | sed -E 's/^([^-]+-[^-]+)(-[0-9]+)?-(cp[0-9]+-(cp[0-9]+|abi[0-9]+))-/\1-9999-\3-/') + cp "$w" "$GITHUB_WORKSPACE/dist-test/$new" + done + + ./tests/recipe-tester/stage_recipe.sh "$PKG_NAME" "$PKG_VERSION" + cd tests/recipe-tester + PIP_FIND_LINKS="$GITHUB_WORKSPACE/dist-test" \ + uvx --with flet-cli flet build ios-simulator -v --yes + + - name: Test on iOS Simulator + if: matrix.platform == 'ios' && steps.detect-tests.outputs.has_tests == 'true' + timeout-minutes: 25 + shell: bash + # iOS sim cold-boot can take 1-4 min on the macos-26 image. + run: .ci/run_ios_test.sh + + # --- /iOS lane --------------------------------------------------------- + + - name: Upload test artifacts + if: always() && steps.detect-tests.outputs.has_tests == 'true' + uses: actions/upload-artifact@v6 + with: + name: test-${{ matrix.artifact_name }}-${{ github.run_id }}-${{ github.run_attempt }} + path: | + console.log + logcat-on-failure.txt + screen-on-failure.png + syslog-on-failure.txt + if-no-files-found: ignore + retention-days: 90 + + # --- /Mobile test lane ------------------------------------------------ + - name: Publish wheels - if: ${{ hashFiles('dist/*.whl') != '' && (inputs.publish || (github.event_name == 'push' && github.ref == 'refs/heads/python3.12')) }} + # `success() &&` so a test failure blocks publish — without it, a + # passing build with failing tests would still ship the wheel. + if: ${{ success() && hashFiles('dist/*.whl') != '' && github.event_name == 'push' && github.ref == 'refs/heads/main' }} shell: bash env: GEMFURY_TOKEN: ${{ secrets.GEMFURY_TOKEN }} @@ -166,14 +386,14 @@ jobs: - name: Upload logs on success if: ${{ success() && hashFiles('logs/*.log') != '' }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: logs-${{ matrix.artifact_name }}-${{ github.run_id }}-${{ github.run_attempt }} path: logs/*.log - name: Upload errors on failure if: ${{ failure() && hashFiles('errors/*.log') != '' }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: errors-${{ matrix.artifact_name }}-${{ github.run_id }}-${{ github.run_attempt }} path: errors/*.log diff --git a/recipes/argon2-cffi-bindings/test_argon2_cffi.py b/recipes/argon2-cffi-bindings/test_argon2_cffi.py index d2ba8666..2da5a6ab 100644 --- a/recipes/argon2-cffi-bindings/test_argon2_cffi.py +++ b/recipes/argon2-cffi-bindings/test_argon2_cffi.py @@ -1,13 +1,43 @@ -import pytest +"""argon2-cffi-bindings ships ONLY the low-level CFFI bindings for the +Argon2 C library — module name `_argon2_cffi_bindings`. The high-level +ergonomic API (`argon2.PasswordHasher`, `argon2.exceptions`, etc.) lives +in the separate `argon2-cffi` package on PyPI. The mobile-forge recipe +only builds the bindings, so we exercise the low-level CFFI surface.""" -# See https://argon2-cffi.readthedocs.io/en/stable/ -def test_basic(self): - import argon2 +def test_argon2_hash_roundtrip(): + """Compute a deterministic Argon2id hash + verify it. Touches both + libargon2 hash and verify entry points through CFFI.""" + from _argon2_cffi_bindings import ffi, lib - ph = argon2.PasswordHasher() - hashed = ph.hash("s3kr3tp4ssw0rd") - assert hashed.startswith("$argon2") - assert ph.verify(hashed, "s3kr3tp4ssw0rd") - with pytest.raises(argon2.exceptions.VerifyMismatchError): - ph.verify(hashed, "s3kr3tp4sswOrd") + pwd = b"correct horse battery staple" + salt = b"sixteen-byte-salt" # 17 bytes is fine; libargon2 just hashes it + + # Argon2id (type=2), t=2, m=65536, parallelism=1, hashlen=32. + # 256-byte buffer is comfortably above the encoded length for these + # params; argon2_hash returns -31 ("Encoding failed") if too small. + encoded = ffi.new("char[256]") + rc = lib.argon2_hash( + 2, # t_cost (iterations) + 65536, # m_cost (kib) + 1, # parallelism + pwd, len(pwd), + salt, len(salt), + ffi.NULL, 32, # raw output unused + encoded, 256, + 2, # Argon2_id + 0x13, # ARGON2_VERSION_13 + ) + assert rc == 0, f"argon2_hash returned {rc}" + + enc_bytes = ffi.string(encoded) + assert enc_bytes.startswith(b"$argon2id$"), enc_bytes + + # Verify with the correct password. + rc = lib.argon2_verify(enc_bytes, pwd, len(pwd), 2) + assert rc == 0, f"verify of correct pwd returned {rc}" + + # Verify with the wrong password — non-zero return. + bad = b"wrong password" + rc = lib.argon2_verify(enc_bytes, bad, len(bad), 2) + assert rc != 0, "verify of wrong pwd unexpectedly succeeded" diff --git a/recipes/bcrypt/test_bcrypt.py b/recipes/bcrypt/test_bcrypt.py index fde9b9ac..cdb6e25a 100644 --- a/recipes/bcrypt/test_bcrypt.py +++ b/recipes/bcrypt/test_bcrypt.py @@ -1,4 +1,4 @@ -def test_basic(self): +def test_basic(): import bcrypt hashed = b"$2b$12$9cwzD/MRnVT7uvkxAQvkIejrif4bwRTGvIRqO7xf4OYtDQ3sl8CWW" diff --git a/recipes/biopython/tests/test_biopython.py b/recipes/biopython/tests/test_biopython.py new file mode 100644 index 00000000..272b8721 --- /dev/null +++ b/recipes/biopython/tests/test_biopython.py @@ -0,0 +1,26 @@ +def test_seq_basics(): + """`Bio.Seq` is pure-Python — this test mainly catches import-path or + package-data regressions in the wheel (e.g. a missing submodule).""" + from Bio.Seq import Seq + + s = Seq("ATGCGT") + # Reverse complement of ATGCGT is ACGCAT. + assert str(s.reverse_complement()) == "ACGCAT" + # Length is the same after complement (no IUPAC ambiguity here). + assert len(s.complement()) == 6 + + +def test_seqio_roundtrip(): + """SeqIO parses FASTA via a text stream — verifies the parser graph + is intact (Bio.SeqIO.FastaIO + Bio.SeqRecord + Bio.Seq all importable + and wired).""" + from io import StringIO + + from Bio import SeqIO + + fasta = ">seq1\nATGCGTAA\n>seq2\nTTTAGCAT\n" + records = list(SeqIO.parse(StringIO(fasta), "fasta")) + assert len(records) == 2 + assert records[0].id == "seq1" + assert str(records[0].seq) == "ATGCGTAA" + assert str(records[1].seq) == "TTTAGCAT" diff --git a/recipes/blis/test_blis.py b/recipes/blis/test_blis.py index 62c1a13b..87583e4c 100644 --- a/recipes/blis/test_blis.py +++ b/recipes/blis/test_blis.py @@ -1,3 +1,15 @@ +def test_import_blis(): + """Forces both compiled bindings to load: `blis/cy.cpython-*.so` + (loaded by `__init__.py`'s `from .cy import init`) and + `blis/py.cpython-*.so` (a sibling submodule — Python does NOT + auto-import it, so we have to ask for it explicitly).""" + import blis + from blis import py # noqa: F401 — forces blis/py.cpython-*.so to dlopen + + assert hasattr(blis, "cy") + assert hasattr(blis, "py") + + def test_einsum(): import numpy as np from blis.py import einsum @@ -7,3 +19,22 @@ def test_einsum(): np.testing.assert_equal( np.array([[12.0, 17.0], [26.0, 37.0]]), einsum("ab,bc->ac", a, b) ) + + +def test_numpy_fft(): + """The libcpp_shared canary that always fires when libcpp is missing, + regardless of arch. blis pulls numpy transitively, and + `_pocketfft_umath.so` carries DT_NEEDED=[libc++_shared.so] on both + arm64 AND x86_64. Without the recipe's libcpp host dep (and a + rebuilt numpy that carries the Requires-Dist), the dlopen aborts + with `library "libc++_shared.so" not found`. Mirrors the canary + added in flet-dev/mobile-forge#58 to numpy's own tests.""" + import numpy as np + + x = np.cos(2 * np.pi * 2 * np.arange(8) / 8) + spectrum = np.fft.fft(x) + magnitudes = np.abs(spectrum) + # 8-point FFT of pure cos(2π·2·n/8) has equal-magnitude peaks at + # bins 2 and 6 (N/2 = 4 for unit cosine). + assert magnitudes[2] > 3.9 + assert magnitudes[6] > 3.9 diff --git a/recipes/brotli/test_brotli.py b/recipes/brotli/test_brotli.py index 6ad3cf64..458c7c70 100644 --- a/recipes/brotli/test_brotli.py +++ b/recipes/brotli/test_brotli.py @@ -4,6 +4,5 @@ def test_basic(): plain = b"it was the best of times, it was the worst of times" compressed = brotli.compress(plain) - compressed = compressed.hex() assert len(compressed) < len(plain) assert plain == brotli.decompress(compressed) diff --git a/recipes/contourpy/test_contourpy.py b/recipes/contourpy/test_contourpy.py new file mode 100644 index 00000000..7761feed --- /dev/null +++ b/recipes/contourpy/test_contourpy.py @@ -0,0 +1,30 @@ +def test_lines_from_array(): + """contourpy is matplotlib's C++ contour-tracing backend. Generate a + simple 5x5 paraboloid and ask for one level — covers the native + contour generator + path output.""" + import contourpy + import numpy as np + + # f(x,y) = x^2 + y^2 over [-2..2] x [-2..2] + xs, ys = np.meshgrid(np.linspace(-2, 2, 5), np.linspace(-2, 2, 5)) + zs = xs**2 + ys**2 + + gen = contourpy.contour_generator(x=xs, y=ys, z=zs) + lines = gen.lines(2.0) # circle-ish contour at z=2 + + assert lines is not None + # At least one contour segment was traced — exact count depends on + # algorithm choice, just confirm it's not empty. + assert len(lines) >= 1 + + +def test_algorithm_name(): + """Sanity: the default algorithm is the C++ `serial` backend, which is + the recipe's reason for existing.""" + import contourpy + + gen = contourpy.contour_generator( + z=[[0.0, 1.0], [1.0, 2.0]], + ) + # Touching .lines() is enough to know the native object initialised. + assert gen.lines(0.5) is not None diff --git a/recipes/coolprop/tests/test_coolprop.py b/recipes/coolprop/tests/test_coolprop.py new file mode 100644 index 00000000..482623e5 --- /dev/null +++ b/recipes/coolprop/tests/test_coolprop.py @@ -0,0 +1,23 @@ +def test_propssi_water_boiling_point(): + """PropsSI is the Cython entry into the CoolProp C++ core. Asking for + the saturation temperature of water at 1 atm forces the native + extension (`CoolProp._CoolProp` / `CoolProp.CoolProp.so`) to load, + which on Android exercises the libc++_shared.so dep declared in + meta.yaml — same canary shape as numpy's _pocketfft test.""" + from CoolProp.CoolProp import PropsSI + + # Saturation temperature of water at P = 101325 Pa, x = 0 (sat. liquid). + # Reference value from NIST: 373.124 K (rounded). Wider tolerance to + # absorb fluid-property-table revisions across CoolProp versions. + t = PropsSI("T", "P", 101325, "Q", 0, "Water") + assert 372.5 < t < 373.5, f"saturation T at 1 atm = {t}" + + +def test_phase_envelope(): + """Tests a multi-arg property query — exercises the + HumidAirProp / saturation lookup paths inside the C++ core.""" + from CoolProp.CoolProp import PropsSI + + # Density of saturated liquid water at 25 °C should be ~997 kg/m³. + rho = PropsSI("D", "T", 298.15, "Q", 0, "Water") + assert 990 < rho < 1005, f"liquid water density at 25 °C = {rho}" diff --git a/recipes/fiona/meta.yaml b/recipes/fiona/meta.yaml index 962160c0..603d9e44 100644 --- a/recipes/fiona/meta.yaml +++ b/recipes/fiona/meta.yaml @@ -5,15 +5,44 @@ package: requirements: host: - flet-libgdal 3.10.0 +# {% if sdk != 'android' %} + # iOS-only — see GDAL_LIBS comment below for the rationale. + - openssl >=3.0.15 + - flet-libjpeg 3.0.90 +# {% endif %} build: - number: 4 + number: 5 script_env: GDAL_VERSION: 3.10.0 GDAL_LIB_PATH: '{platlib}/opt/lib' GDAL_INCLUDE_PATH: '{platlib}/opt/include' +# {% if sdk == 'android' %} GDAL_LIBS: gdal -# {% if sdk != 'android' %} +# {% else %} + # On iOS every native dep is a static archive (libgdal, libproj, + # libtiff, libcurl are all `-DBUILD_SHARED_LIBS=OFF`). When GDAL + # was linked at *its* build time the linker only kept object files + # for symbols GDAL itself referenced — and GDAL's GTiff/HTTP/proj + # code paths reference `TIFFClientOpen`, `curl_easy_init`, + # `geod_init` and friends, all of which were left undefined in + # libgdal.a. When fiona's `_env.so` links against libgdal it + # inherits those undefined references; with `-undefined + # dynamic_lookup` they're deferred to dlopen, where iOS dyld + # eagerly resolves the flat namespace and aborts the load. + # + # Adding the dep libs to GDAL_LIBS makes fiona's extension link + # command pull the missing object files straight out of libproj.a, + # libtiff.a, libcurl.a (and its OpenSSL/zlib backends), libjpeg.a + # (libtiff's JPEG-in-TIFF support), and the iOS system stubs for + # libsqlite3 / libz. + # + # The right long-term fix lives in `flet-libgdal`: align the iOS + # cmake invocation with the Android one — `-DGDAL_USE_CURL=OFF`, + # `-DGDAL_USE_TIFF_INTERNAL=ON`, … — so libgdal.a doesn't leak + # external refs in the first place. Then every consumer of GDAL on + # iOS works with just `-lgdal -lproj`. Tracked for a future bump. + GDAL_LIBS: gdal,proj,tiff,curl,psl,sqlite3,jpeg,ssl,crypto,z LDFLAGS: '-undefined dynamic_lookup' # {% endif %} diff --git a/recipes/fiona/test_fiona.py b/recipes/fiona/test_fiona.py index 6b81d9cd..239a9344 100644 --- a/recipes/fiona/test_fiona.py +++ b/recipes/fiona/test_fiona.py @@ -1 +1,69 @@ -# TBD \ No newline at end of file +def test_import_fiona(): + """`import fiona` triggers `fiona._env.so`'s dlopen. On iOS, the + published wheel's _env.so was linked against `libgdal.a` only — GDAL's + static archive leaks undefined references for symbols GDAL itself uses + from libproj/libtiff/libcurl/libpsl/openssl. iOS dyld eagerly resolves + the flat namespace at dlopen and aborts with + `symbol not found in flat namespace '_geod_init'` (or _TIFFClientOpen + / _curl_easy_init / _psl_builtin, depending on which gap is hit + first). Android isn't affected — libproj/libtiff/libcurl/etc. are + shared libraries there, so their symbols resolve via DT_NEEDED.""" + import fiona + + assert hasattr(fiona, "supported_drivers") + assert hasattr(fiona, "open") + + +def test_supported_drivers(): + """fiona binds GDAL's vector I/O (OGR). Listing supported drivers is + the lightest-weight way to confirm the C lib loaded without needing + a test shapefile.""" + import fiona + + drivers = list(fiona.supported_drivers.keys()) + # ESRI Shapefile + GeoJSON are universal — if the GDAL lib is loaded + # at all, these are present. + assert "ESRI Shapefile" in drivers + assert "GeoJSON" in drivers + + +def test_write_read_geojson(tmp_path): + """Write a Point feature to GeoJSON then read it back — covers OGR's + writer + reader without depending on bundled test data. + + Skipped on iOS until the flet-libgdal / flet-libproj recipes stop + stripping `share/` from the install (and the iOS app launcher sets + `GDAL_DATA` / `PROJ_DATA` to point at them). Even when the caller + supplies no CRS, OGR's GeoJSON writer calls into PROJ to stamp a + default WGS84 metadata field, which fails with `Cannot find + proj.db` and surfaces as `FionaNullPointerError`. Distinct from + the linker-level static-cascade fix this recipe already ships — + that's `import fiona` succeeding; this is runtime data.""" + import sys + + import pytest + + if sys.platform == "ios": + pytest.skip( + "iOS: proj.db not bundled — see flet-libgdal/libproj `rm -rf " + "$PREFIX/share` strip step; needs follow-up recipe change." + ) + + import fiona + + schema = {"geometry": "Point", "properties": {"name": "str"}} + path = tmp_path / "tiny.geojson" + + with fiona.open(path, "w", driver="GeoJSON", schema=schema) as dst: + dst.write( + { + "geometry": {"type": "Point", "coordinates": (2.35, 48.86)}, + "properties": {"name": "Paris"}, + } + ) + + with fiona.open(path) as src: + feats = list(src) + assert len(feats) == 1 + assert feats[0]["properties"]["name"] == "Paris" + assert tuple(feats[0]["geometry"]["coordinates"]) == (2.35, 48.86) diff --git a/recipes/flet-libcpp-shared/meta.yaml b/recipes/flet-libcpp-shared/meta.yaml index 96147f47..f1dfb896 100644 --- a/recipes/flet-libcpp-shared/meta.yaml +++ b/recipes/flet-libcpp-shared/meta.yaml @@ -1,6 +1,10 @@ package: name: flet-libcpp-shared version: 27.3.13750724 + # libc++_shared.so is the Android C++ runtime — extracted from the NDK + # and repackaged. The whole recipe is meaningless on iOS (which uses + # libc++ statically linked into apps via the Apple toolchain). + platforms: [android] build: number: 4 diff --git a/recipes/flet-libpyjni/meta.yaml b/recipes/flet-libpyjni/meta.yaml index 52b15198..c6813628 100644 --- a/recipes/flet-libpyjni/meta.yaml +++ b/recipes/flet-libpyjni/meta.yaml @@ -1,6 +1,9 @@ package: name: flet-libpyjni version: 1.0.1 + # JNI bridge to the Android JVM — no equivalent on iOS, build can't + # succeed there. (Mirrors flet-libcpp-shared's platforms gate.) + platforms: [android] build: number: 4 diff --git a/recipes/gdal/meta.yaml b/recipes/gdal/meta.yaml index 26c031c6..7849683b 100644 --- a/recipes/gdal/meta.yaml +++ b/recipes/gdal/meta.yaml @@ -5,16 +5,40 @@ package: requirements: host: - flet-libgdal 3.10.0 +# {% if sdk == 'android' %} + # libgdal links C++; the SWIG-generated _gdal.so loads via dlopen which + # needs libc++_shared.so on Android. + - flet-libcpp-shared >=27.2.12479018 +# {% else %} + # iOS-only — see GDAL_LIBS comment below for the rationale. + - openssl >=3.0.15 + - flet-libjpeg 3.0.90 +# {% endif %} build: - number: 4 + number: 5 script_env: GDAL_VERSION: 3.10.0 GDAL_PREFIX: '{platlib}/opt' GDAL_CFLAGS: '' # {% if sdk != 'android' %} + # On iOS every native dep is a static archive (libgdal, libproj, + # libtiff, libcurl, libpsl all `-DBUILD_SHARED_LIBS=OFF`). When + # GDAL was linked at its own build time the linker only kept + # object files for symbols GDAL itself referenced — anything else + # was left undefined in libgdal.a. iOS dyld eagerly resolves the + # flat namespace at dlopen and aborts on the first miss + # (`_geod_init`, `_TIFFClientOpen`, `_psl_builtin`, …). Adding the + # full dep chain to GDAL_LIBS makes the SWIG `_gdal.so` link + # command pull every missing object straight out of its archive. + # + # Identical fix shipped in recipes/fiona, recipes/pyogrio. The + # right long-term fix lives in `flet-libgdal`: align iOS cmake + # with Android (`-DGDAL_USE_CURL=OFF`, `-DGDAL_USE_TIFF_INTERNAL=ON`, + # …) so libgdal.a stops leaking refs at all. + GDAL_LIBS: gdal,proj,tiff,curl,psl,sqlite3,jpeg,ssl,crypto,z LDFLAGS: '-undefined dynamic_lookup' # {% endif %} patches: - - config.patch \ No newline at end of file + - config.patch diff --git a/recipes/gdal/patches/config.patch b/recipes/gdal/patches/config.patch index 58f84d8d..59b046e5 100644 --- a/recipes/gdal/patches/config.patch +++ b/recipes/gdal/patches/config.patch @@ -1,9 +1,17 @@ diff --git a/setup.py b/setup.py -index 5c6ac95..26bb5fa 100644 --- a/setup.py +++ b/setup.py -@@ -228,6 +228,9 @@ class gdal_ext(build_ext): - +@@ -54,6 +54,8 @@ + include_dirs = ['/home/even/gdal/3.10/build/port', '/home/even/gdal/3.10/port', '/home/even/gdal/3.10/build/gcore', '/home/even/gdal/3.10/gcore', '/home/even/gdal/3.10/alg', '/home/even/gdal/3.10/ogr/', '/home/even/gdal/3.10/ogr/ogrsf_frmts', '/home/even/gdal/3.10/gnm', '/home/even/gdal/3.10/apps'] + library_dirs = ['/home/even/gdal/3.10/build'] + libraries = ['gdal'] ++if 'GDAL_LIBS' in os.environ: ++ libraries = os.environ['GDAL_LIBS'].split(',') + + + # --------------------------------------------------------------------------- +@@ -228,6 +230,9 @@ + def get_gdal_config(self, option): try: + var_name = f"GDAL_{option.upper()}" diff --git a/recipes/gdal/test_gdal.py b/recipes/gdal/test_gdal.py index 6b81d9cd..9784346e 100644 --- a/recipes/gdal/test_gdal.py +++ b/recipes/gdal/test_gdal.py @@ -1 +1,22 @@ -# TBD \ No newline at end of file +def test_in_memory_raster(): + """GDAL's MEM driver creates an in-memory raster — no disk I/O, + no test data file. Touches the C++ raster band API.""" + from osgeo import gdal + + drv = gdal.GetDriverByName("MEM") + assert drv is not None + ds = drv.Create("", 4, 3, 1, gdal.GDT_Byte) + assert ds.RasterXSize == 4 + assert ds.RasterYSize == 3 + + band = ds.GetRasterBand(1) + band.Fill(7) + raw = band.ReadRaster(0, 0, 4, 3) # 4*3*1 byte = 12 bytes + assert raw == bytes([7] * 12) + + +def test_version_loaded(): + """Confirms the libgdal C++ runtime is wired through SWIG.""" + from osgeo import gdal + + assert gdal.VersionInfo() diff --git a/recipes/google-crc32c/test_google_crc32c.py b/recipes/google-crc32c/test_google_crc32c.py new file mode 100644 index 00000000..0f1294b4 --- /dev/null +++ b/recipes/google-crc32c/test_google_crc32c.py @@ -0,0 +1,22 @@ +def test_known_vectors(): + """google-crc32c provides hardware-accelerated CRC32C (Castagnoli). + The C extension is the recipe's purpose — without it the package + falls back to a slow Python impl. Use known test vectors from RFC 3720 + Appendix B.""" + import google_crc32c + + # RFC 3720 Annex B (iSCSI / SCTP CRC32C reference values) + assert google_crc32c.value(b"") == 0 + assert google_crc32c.value(b"123456789") == 0xE3069283 + assert google_crc32c.value(b"a") == 0xC1D04330 + + +def test_chunked_value(): + """The Checksum object's update-then-digest path lives in C too.""" + import google_crc32c + + h = google_crc32c.Checksum() + h.update(b"123") + h.update(b"456") + h.update(b"789") + assert int.from_bytes(h.digest(), "big") == 0xE3069283 diff --git a/recipes/greenlet/meta.yaml b/recipes/greenlet/meta.yaml index 90104e66..33f5d863 100644 --- a/recipes/greenlet/meta.yaml +++ b/recipes/greenlet/meta.yaml @@ -2,9 +2,16 @@ package: name: greenlet version: 3.1.1 +# {% if sdk == 'android' %} +requirements: + host: + # greenlet's _greenlet.so is C++; on Android this links libstdc++ which is libc++_shared.so. + - flet-libcpp-shared >=27.2.12479018 +# {% endif %} + build: - number: 4 + number: 5 # {% if sdk != 'android' %} script_env: CXXFLAGS: -std=c++14 -# {% endif %} \ No newline at end of file +# {% endif %} diff --git a/recipes/greenlet/test_greenlet.py b/recipes/greenlet/test_greenlet.py new file mode 100644 index 00000000..46c5e4a0 --- /dev/null +++ b/recipes/greenlet/test_greenlet.py @@ -0,0 +1,52 @@ +def test_import_greenlet(): + """Forces the native `_greenlet.cpython-*.so` to load. On Android this is + the libc++_shared.so canary — `_greenlet.so` has + DT_NEEDED=[libc++_shared.so] (greenlet's C++ inline-asm context switcher + is built with libstdc++). If `flet-libcpp-shared` isn't in the wheel's + Requires-Dist, libc++_shared.so won't be in jniLibs// and import + fails with `dlopen failed: library "libc++_shared.so" not found`.""" + import greenlet + + assert hasattr(greenlet, "greenlet") + assert hasattr(greenlet, "getcurrent") + + +def test_switch(): + """greenlet implements stackful coroutines via inline-asm context + switching — the recipe is all about porting that asm to mobile arches. + Two greenlets pass control back and forth via switch().""" + import greenlet + + log = [] + + def worker(): + log.append("worker:start") + # Yield back to parent, then resume. + x = main_gl.switch("hello") + log.append(("worker:got", x)) + return "worker:done" + + main_gl = greenlet.getcurrent() + worker_gl = greenlet.greenlet(worker) + + msg = worker_gl.switch() + log.append(("main:got", msg)) + result = worker_gl.switch("world") + log.append(("main:final", result)) + + assert log == [ + "worker:start", + ("main:got", "hello"), + ("worker:got", "world"), + ("main:final", "worker:done"), + ] + + +def test_dead_greenlet(): + """A returned greenlet reports dead — sanity for the lifecycle path.""" + import greenlet + + gl = greenlet.greenlet(lambda: 42) + assert not gl.dead + assert gl.switch() == 42 + assert gl.dead diff --git a/recipes/grpcio/meta.yaml b/recipes/grpcio/meta.yaml index 1afda7c5..1ce96d93 100644 --- a/recipes/grpcio/meta.yaml +++ b/recipes/grpcio/meta.yaml @@ -3,7 +3,7 @@ package: version: 1.67.1 build: - number: 5 + number: 6 script_env: # {% if sdk == 'android' %} GRPC_PYTHON_BUILD_SYSTEM_OPENSSL: '1' diff --git a/recipes/grpcio/patches/mobile.patch b/recipes/grpcio/patches/mobile.patch index 89c6c1ac..6a9f5a33 100644 --- a/recipes/grpcio/patches/mobile.patch +++ b/recipes/grpcio/patches/mobile.patch @@ -47,3 +47,18 @@ EXTENSION_LIBRARIES = () if "linux" in sys.platform: +--- a/third_party/zlib/zutil.h 2026-05-31 19:02:14 ++++ b/third_party/zlib/zutil.h 2026-05-31 19:02:15 +@@ -143,8 +143,10 @@ + # if defined(__MWERKS__) && __dest_os != __be_os && __dest_os != __win32_os + # include /* for fdopen */ + # else +-# ifndef fdopen +-# define fdopen(fd,mode) NULL /* No fdopen() */ ++# if !defined(__APPLE__) ++# ifndef fdopen ++# define fdopen(fd,mode) NULL /* No fdopen() */ ++# endif + # endif + # endif + # endif diff --git a/recipes/grpcio/test_grpcio.py b/recipes/grpcio/test_grpcio.py index 6b81d9cd..332d6cba 100644 --- a/recipes/grpcio/test_grpcio.py +++ b/recipes/grpcio/test_grpcio.py @@ -1 +1,22 @@ -# TBD \ No newline at end of file +def test_channel_credentials(): + """grpcio's C-extension (`_cython`) is the reason this is a recipe. + Creating credentials + a channel object touches the cython binding + without needing an actual server.""" + import grpc + + creds = grpc.ssl_channel_credentials() + assert creds is not None + + channel = grpc.insecure_channel("localhost:9999") + assert channel is not None + channel.close() + + +def test_status_codes(): + """StatusCode is a Cython enum — import + value access exercises the + C-typed bridge.""" + import grpc + + assert grpc.StatusCode.OK.value[0] == 0 + assert grpc.StatusCode.NOT_FOUND.value[0] == 5 + assert grpc.StatusCode.UNAUTHENTICATED.value[0] == 16 diff --git a/recipes/jiter/test_jiter.py b/recipes/jiter/test_jiter.py new file mode 100644 index 00000000..2230d91b --- /dev/null +++ b/recipes/jiter/test_jiter.py @@ -0,0 +1,23 @@ +def test_from_json(): + """jiter is a Rust-backed fast JSON parser (used by pydantic). Parsing + a mixed-type doc through `from_json` exercises the PyO3 boundary.""" + import jiter + + raw = b'{"id": 7, "tags": ["a", "b"], "ratio": 1.5, "ok": true, "n": null}' + parsed = jiter.from_json(raw) + assert parsed == { + "id": 7, + "tags": ["a", "b"], + "ratio": 1.5, + "ok": True, + "n": None, + } + + +def test_partial_mode(): + """`partial_mode='trailing-strings'` allows incomplete strings — a + jiter-specific feature pydantic relies on for streaming.""" + import jiter + + parsed = jiter.from_json(b'{"name": "Ad', partial_mode="trailing-strings") + assert parsed == {"name": "Ad"} diff --git a/recipes/jq/test_jq.py b/recipes/jq/test_jq.py new file mode 100644 index 00000000..574140a9 --- /dev/null +++ b/recipes/jq/test_jq.py @@ -0,0 +1,23 @@ +def test_filter(): + """jq is a Cython wrapper around libjq (C). Apply a filter program + against a small JSON document — exercises the libjq parser + executor.""" + import jq + + data = { + "users": [ + {"name": "Ada", "active": True}, + {"name": "Grace", "active": False}, + {"name": "Linus", "active": True}, + ] + } + program = jq.compile('.users[] | select(.active) | .name') + active = program.input_value(data).all() + assert active == ["Ada", "Linus"] + + +def test_first(): + """The `.first()` API path is a different libjq invocation.""" + import jq + + name = jq.first(".name", {"name": "mobile-forge", "id": 42}) + assert name == "mobile-forge" diff --git a/recipes/kiwisolver/test_kiwisolver.py b/recipes/kiwisolver/test_kiwisolver.py new file mode 100644 index 00000000..3e09d379 --- /dev/null +++ b/recipes/kiwisolver/test_kiwisolver.py @@ -0,0 +1,30 @@ +def test_solve_simple_constraint(): + """kiwisolver is matplotlib's Cassowary constraint solver, written in + C++. Set up a small system and check the solver finds a valid + assignment — exercises the native solve loop.""" + from kiwisolver import Solver, Variable + + x = Variable("x") + y = Variable("y") + solver = Solver() + + # x + y = 10, x - y = 4 → x=7, y=3 + solver.addConstraint(x + y == 10) + solver.addConstraint(x - y == 4) + solver.updateVariables() + + assert abs(x.value() - 7.0) < 1e-9 + assert abs(y.value() - 3.0) < 1e-9 + + +def test_strength_priority(): + """Soft vs required constraint — distinct kiwi C++ codepath.""" + from kiwisolver import Solver, Variable, strength + + x = Variable("x") + solver = Solver() + # Required: x = 100; weak: x = 1. Required wins. + solver.addConstraint(x == 100) + solver.addConstraint((x == 1) | strength.weak) + solver.updateVariables() + assert abs(x.value() - 100.0) < 1e-9 diff --git a/recipes/lru-dict/test_lru_dict.py b/recipes/lru-dict/test_lru_dict.py index 7a75746f..a3a85cde 100644 --- a/recipes/lru-dict/test_lru_dict.py +++ b/recipes/lru-dict/test_lru_dict.py @@ -5,6 +5,6 @@ def test_basic(): data[1] = None data[2] = None data[3] = None - data[1] - data[4] = None - assert data.keys == [4, 1, 3] + data[1] # touch key 1 (most-recently-used) + data[4] = None # evicts least-recently-used (= 2) + assert data.keys() == [4, 1, 3] diff --git a/recipes/markupsafe/test_markupsafe.py b/recipes/markupsafe/test_markupsafe.py new file mode 100644 index 00000000..ca43e106 --- /dev/null +++ b/recipes/markupsafe/test_markupsafe.py @@ -0,0 +1,21 @@ +def test_escape(): + """The MarkupSafe C accelerator is the reason this is a recipe — without + it, escape() falls back to slow pure-Python.""" + from markupsafe import Markup, escape + + assert str(escape("")) == ( + "<script>alert('xss')</script>" + ) + + # Markup pass-through: already-safe strings shouldn't be double-escaped. + safe = Markup("hi") + assert str(escape(safe)) == "hi" + + +def test_speedups_loaded(): + """Confirms the C extension `markupsafe._speedups` actually loaded; the + pure-Python fallback wouldn't expose `escape` from this module.""" + from markupsafe import _speedups + + assert callable(_speedups.escape) + assert str(_speedups.escape("<&>")) == "<&>" diff --git a/recipes/matplotlib/meta.yaml b/recipes/matplotlib/meta.yaml index 5b1cbec2..0be8d42c 100644 --- a/recipes/matplotlib/meta.yaml +++ b/recipes/matplotlib/meta.yaml @@ -10,9 +10,12 @@ requirements: - numpy ^2.0.0 - pybind11 - flet-libjpeg 3.0.90 +# {% if sdk == 'android' %} + - flet-libcpp-shared >=27.2.12479018 +# {% endif %} build: - number: 4 + number: 5 # {% if sdk == 'android' and arch in ['armeabi-v7a', 'x86'] %} script_env: CPPFLAGS: -Wno-c++11-narrowing diff --git a/recipes/matplotlib/test_matplotlib.py b/recipes/matplotlib/test_matplotlib.py index e59d3ae9..045edb13 100644 --- a/recipes/matplotlib/test_matplotlib.py +++ b/recipes/matplotlib/test_matplotlib.py @@ -1,4 +1,4 @@ -def test_png(self): +def test_png(): import io import matplotlib.pyplot as plt diff --git a/recipes/msgpack/test_msgpack.py b/recipes/msgpack/test_msgpack.py new file mode 100644 index 00000000..5eb33cad --- /dev/null +++ b/recipes/msgpack/test_msgpack.py @@ -0,0 +1,32 @@ +def test_roundtrip(): + """msgpack's whole reason for being a recipe is the C-extension packer + and unpacker. A round-trip with mixed types exercises both.""" + import msgpack + + doc = { + "name": "mobile-forge", + "count": 42, + "items": ["a", "b", "c"], + "ratio": 1.5, + "ok": True, + "blob": b"\x00\x01\x02\x03", + } + packed = msgpack.packb(doc) + assert isinstance(packed, bytes) + assert msgpack.unpackb(packed) == doc + + +def test_streaming_unpacker(): + """Streaming unpack from a Reader — uses a different C path than packb.""" + import io + + import msgpack + + buf = io.BytesIO() + for i in range(3): + buf.write(msgpack.packb({"i": i})) + buf.seek(0) + + unpacker = msgpack.Unpacker(buf) + decoded = list(unpacker) + assert decoded == [{"i": 0}, {"i": 1}, {"i": 2}] diff --git a/recipes/msgspec/test_msgspec.py b/recipes/msgspec/test_msgspec.py new file mode 100644 index 00000000..eaa55cfc --- /dev/null +++ b/recipes/msgspec/test_msgspec.py @@ -0,0 +1,30 @@ +def test_struct_roundtrip(): + """msgspec is a Cython/C-backed schema validator. Encode + decode a + Struct via JSON to exercise both directions of the native codec.""" + import msgspec + + class Person(msgspec.Struct): + name: str + age: int + tags: list[str] = [] + + p = Person(name="Ada", age=37, tags=["math", "engineering"]) + payload = msgspec.json.encode(p) + assert isinstance(payload, bytes) + assert msgspec.json.decode(payload, type=Person) == p + + +def test_invalid_input_raises(): + """Validation errors are raised in C, not Python — confirms the schema + enforcement path is wired.""" + import msgspec + + class Person(msgspec.Struct): + name: str + age: int + + try: + msgspec.json.decode(b'{"name": "Ada", "age": "not-a-number"}', type=Person) + except msgspec.ValidationError: + return + raise AssertionError("expected ValidationError") diff --git a/recipes/opaque/meta.yaml b/recipes/opaque/meta.yaml index a96fc3f3..56295e54 100644 --- a/recipes/opaque/meta.yaml +++ b/recipes/opaque/meta.yaml @@ -3,8 +3,19 @@ package: version: 0.2.0 build: - number: 4 + number: 5 requirements: host: - - flet-libopaque 0.99.8 \ No newline at end of file + - flet-libopaque 0.99.8 + +# Patch upstream setup.py to declare `pysodium` as install_requires. +# Upstream's 0.2.0 setup.py only sets `requires=["libsodium"]` (metadata- +# only, NOT a real pip dependency), so when the recipe-tester installs +# `opaque` pip doesn't pull pysodium, and `import opaque` then fails at +# runtime with `ModuleNotFoundError: No module named 'pysodium'`. The +# 1.0.0 upstream tried to fix this but shipped `install_requires = +# ("pysodium")` — string-without-trailing-comma is just a string, not a +# tuple, and setuptools silently drops it. +patches: + - mobile.patch diff --git a/recipes/opaque/patches/mobile.patch b/recipes/opaque/patches/mobile.patch new file mode 100644 index 00000000..4e9cf75a --- /dev/null +++ b/recipes/opaque/patches/mobile.patch @@ -0,0 +1,11 @@ +diff --git a/setup.py b/setup.py +--- a/setup.py ++++ b/setup.py +@@ -19,6 +19,7 @@ + long_description=read('README.md'), + long_description_content_type="text/markdown", + requires=["libsodium"], ++ install_requires=["pysodium"], + classifiers=["Development Status :: 4 - Beta", + "License :: OSI Approved :: BSD License", + "Topic :: Security :: Cryptography", diff --git a/recipes/opaque/test_opaque.py b/recipes/opaque/test_opaque.py new file mode 100644 index 00000000..210bc792 --- /dev/null +++ b/recipes/opaque/test_opaque.py @@ -0,0 +1,72 @@ +"""opaque is a ctypes wrapper around libopaque (the OPAQUE asymmetric +PAKE protocol). The C lib is supplied as a host dep (`flet-libopaque`) +in the mobile-forge recipe; the wheel needs pysodium too at runtime +(handled via mobile.patch adding `install_requires=['pysodium']`).""" + + +def test_import_opaque(): + """`import opaque` requires pysodium at import time — opaque/__init__.py + does `import pysodium`. Upstream opaque-0.2.0's setup.py only declares + `requires=["libsodium"]` (metadata-only, not a real pip dep), so the + published wheel has no `Requires-Dist: pysodium`. Without the recipe's + mobile.patch (`install_requires=['pysodium']`), pip never installs + pysodium and import fails with + `ModuleNotFoundError: No module named 'pysodium'`.""" + import opaque + + assert hasattr(opaque, "Ids") + assert hasattr(opaque, "CreateRegistrationRequest") + + +def test_registration_and_credential_roundtrip(): + """Run one full OPAQUE round: client → registration → server stores + user record; client → login → server verifies; both sides derive a + session key. The roundtrip touches every libopaque C entry point + pyopaque wraps. + + Function-by-function this is the API per `opaque/__init__.py`: + CreateRegistrationRequest(pwd) → (sec, request) + CreateRegistrationResponse(request) → (sec, pub) + FinalizeRequest(sec, pub, ids) → (registration_record, export_key) + StoreUserRecord(sec, registration_record) → user_record + — combines the server's REGISTER_SECRET (skS+kU) with the + client's REGISTRATION_RECORD into the USER_RECORD that + CreateCredentialResponse expects. The byte layout differs + between sec and user_record, so we MUST use this helper + rather than naive concatenation. + CreateCredentialRequest(pwd) → (pub, sec) ← NB: pub first + CreateCredentialResponse(pub, rec, ids, ctx) → (resp, sk, sec) + RecoverCredentials(resp, sec, ctx, ids) → (sk, authU, export_key) + """ + import opaque + + pwd = b"correct horse battery staple" + ids = opaque.Ids(idu=b"user", ids=b"server") + + # --- Registration --- + secret_client_reg, request = opaque.CreateRegistrationRequest(pwd) + secret_server_reg, response = opaque.CreateRegistrationResponse(request) + registration_record, export_key_reg = opaque.FinalizeRequest( + secret_client_reg, response, ids + ) + assert isinstance(registration_record, bytes) + assert isinstance(export_key_reg, bytes) + assert len(export_key_reg) > 0 + + # Server stores the long-lived user record (server's sec + client's + # registration_record, properly rearranged by libopaque). + user_record = opaque.StoreUserRecord(secret_server_reg, registration_record) + + # --- Credential exchange (login) --- + ke1, client_state = opaque.CreateCredentialRequest(pwd) + ke2, sk_server, _server_session_sec = opaque.CreateCredentialResponse( + ke1, user_record, ids, b"" + ) + sk_client, _authU, export_key_login = opaque.RecoverCredentials( + ke2, client_state, b"", ids + ) + + # Both sides derived the same session key. + assert sk_client == sk_server + # Export key is stable across registration & login (same password). + assert export_key_login == export_key_reg diff --git a/recipes/opencv-python/test_opencv_python.py b/recipes/opencv-python/test_opencv_python.py new file mode 100644 index 00000000..f57ce87f --- /dev/null +++ b/recipes/opencv-python/test_opencv_python.py @@ -0,0 +1,60 @@ +def test_import_cv2(): + """`import cv2` triggers OpenCV's native .so dlopen and transitively + `import numpy`. The published `numpy-2.2.2-4` wheel on pypi.flet.dev + has NO `Requires-Dist: flet-libcpp-shared` in METADATA, and the + published `opencv-python` wheel also doesn't declare it, so a + cv2-only Flet app doesn't bundle libc++_shared.so. On x86_64 numpy + fails at multiarray; on arm64 `import cv2` works (arm64 numpy + multiarray doesn't need libcpp) but `cv2.dft(...)` or any code path + that pulls `np.fft.*` then fires the gap. The recipe's defensive + libcpp host dep + Requires-Dist injection closes both.""" + import cv2 + + assert cv2.__version__ + assert hasattr(cv2, "imencode") + + +def test_numpy_fft(): + """libcpp_shared canary that fires on every Android arch via + `_pocketfft_umath.so` (DT_NEEDED=[libc++_shared.so] on both arm64 + AND x86_64). cv2 doesn't naturally call numpy.fft, but this + surfaces the libcpp gap the recipe's defensive + `flet-libcpp-shared` host dep closes.""" + import numpy as np + + x = np.cos(2 * np.pi * 2 * np.arange(8) / 8) + spectrum = np.fft.fft(x) + magnitudes = np.abs(spectrum) + assert magnitudes[2] > 3.9 + assert magnitudes[6] > 3.9 + + +def test_image_encode_decode(): + """opencv-python wraps OpenCV's C++ core. Encode + decode a small + NumPy image round-trip — covers the JPEG codec path.""" + import cv2 + import numpy as np + + # Construct a 16x16 image with a diagonal gradient. + img = np.zeros((16, 16, 3), dtype=np.uint8) + for y in range(16): + for x in range(16): + img[y, x] = (y * 16, x * 16, 128) + + ok, buf = cv2.imencode(".png", img) + assert ok + assert buf.nbytes > 0 + + decoded = cv2.imdecode(buf, cv2.IMREAD_COLOR) + assert decoded is not None + assert decoded.shape == img.shape + + +def test_resize(): + """Resize hits a different C++ code path (cv::resize).""" + import cv2 + import numpy as np + + src = np.zeros((20, 30, 3), dtype=np.uint8) + out = cv2.resize(src, (60, 40)) + assert out.shape == (40, 60, 3) diff --git a/recipes/pandas/meta.yaml b/recipes/pandas/meta.yaml index 5234d422..45da06e7 100644 --- a/recipes/pandas/meta.yaml +++ b/recipes/pandas/meta.yaml @@ -19,7 +19,7 @@ patches: - mobile.patch build: - number: 4 + number: 5 backend-args: - -Csetup-args=--cross-file - -Csetup-args={MESON_CROSS_FILE} diff --git a/recipes/pandas/patches/mobile.patch b/recipes/pandas/patches/mobile.patch index 7160a732..4df6c777 100644 --- a/recipes/pandas/patches/mobile.patch +++ b/recipes/pandas/patches/mobile.patch @@ -1,15 +1,46 @@ -diff --git a/pyproject.toml b/pyproject.toml -index 238abd8..37de5c7 100644 ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -2,8 +2,8 @@ +--- a/pyproject.toml 2026-05-31 23:37:15 ++++ b/pyproject.toml 2026-05-31 23:37:15 +@@ -2,8 +2,10 @@ # Minimum requirements for the build system to execute. # See https://github.com/scipy/scipy/pull/12940 for the AIX issue. requires = [ - "meson-python==0.13.1", - "meson==1.2.1", -+ "meson-python==0.15.0", -+ #"meson==1.2.1", ++ # Upstream pinned 0.13.1 + 1.2.1 — bump to a recent meson-python so the ++ # mobile-forge meson-wrapper.py below works (older mesonpy ignored the ++ # tool.meson-python.meson setting). ++ "meson-python>=0.16.0", "wheel", "Cython~=3.0.5", # Note: sync with setup.py, environment.yml and asv.conf.json # Force numpy higher than 2.0, so that built wheels are compatible +@@ -809,3 +811,15 @@ + [tool.codespell] + ignore-words-list = "blocs, coo, hist, nd, sav, ser, recuse, nin, timere, expec, expecs" + ignore-regex = 'https://([\w/\.])+' ++ ++# Added by mobile-forge mobile.patch — see meson-wrapper.py. ++# Pointing `meson` at a Python script forces meson-python to invoke meson ++# through sys.executable, which under forge's cross-build is the ++# cross-Python wrapper. Without this, meson-python uses the bare `meson` ++# CLI from PATH, which resolves to a shebang under the BUILD Python — and ++# meson then queries the BUILD Python's sysconfig, leaking the host ++# Python's Python.h include path into the cython sanity check. On 32-bit ++# Android targets this surfaces as ++# `pyport.h: LONG_BIT definition appears wrong for platform`. ++[tool.meson-python] ++meson = "meson-wrapper.py" +--- /dev/null 2026-05-31 12:00:00 ++++ b/meson-wrapper.py 2026-05-31 12:00:00 +@@ -0,0 +1,12 @@ ++"""Forwards to the meson CLI through `sys.executable` so meson-python ++invokes meson under the same Python interpreter the build is running ++under. For forge's cross-build that's the cross-Python wrapper, which ++sets _PYTHON_SYSCONFIGDATA_NAME so meson queries the TARGET sysconfig ++rather than the BUILD/host one. Without this the cython sanity check ++sees the host Python.h's SIZEOF_VOID_P=8 on 32-bit Android targets ++(armeabi-v7a, x86) and explodes.""" ++ ++import sys ++from mesonbuild.mesonmain import main ++ ++sys.exit(main()) diff --git a/recipes/pendulum/test_pendulum.py b/recipes/pendulum/test_pendulum.py new file mode 100644 index 00000000..9ca4fa6e --- /dev/null +++ b/recipes/pendulum/test_pendulum.py @@ -0,0 +1,24 @@ +def test_parse_and_arithmetic(): + """pendulum vendors a Rust-based parser (the recipe's reason for existing). + Exercising parse() + duration arithmetic touches the native path.""" + import pendulum + + dt = pendulum.parse("2026-05-31T10:30:00Z") + assert dt.year == 2026 + assert dt.month == 5 + assert dt.day == 31 + + dt2 = dt.add(days=2, hours=3) + assert dt2.day == 2 + assert dt2.month == 6 + + +def test_timezone(): + import pendulum + + paris = pendulum.now("Europe/Paris") + utc = paris.in_timezone("UTC") + # Same instant, different wall-clock. + assert paris.timestamp() == utc.timestamp() + assert paris.timezone_name == "Europe/Paris" + assert utc.timezone_name == "UTC" diff --git a/recipes/pillow/meta.yaml b/recipes/pillow/meta.yaml index b8df623e..ace75766 100644 --- a/recipes/pillow/meta.yaml +++ b/recipes/pillow/meta.yaml @@ -23,9 +23,28 @@ patches: # {% endif %} build: - number: 4 -# {% if sdk != 'android' %} + number: 5 script_env: +# {% if sdk == 'android' %} + # pillow's setup.py manually probes `self.compiler.{include,library}_dirs` + # for zlib.h / libz / libjpeg / libfreetype rather than relying on the + # compiler's own --sysroot search. Our mobile.patch sets + # `disable_platform_guessing=True` to keep host-Linux paths out of the + # cross-build — which also drops the NDK sysroot from those lists. + # iOS gets away with it because Python-Apple-support's sysconfig + # populates `compiler.include_dirs` with the SDK include path; the + # Android cross-venv doesn't surface NDK paths the same way (they're + # in CFLAGS/LDFLAGS but distutils never reflects those into the dir + # lists). Pillow natively honors CPATH / LIBRARY_PATH (lines 538–548 + # of its setup.py), so we feed the cross-compile paths back in that + # way — covering both the NDK sysroot (zlib, system headers) and the + # cross-venv's opt/ tree (libjpeg, libfreetype host wheels). + CPATH: '{NDK_SYSROOT}/usr/include:{platlib}/opt/include' + LIBRARY_PATH: '{NDK_SYSROOT}/usr/lib/{HOST_TRIPLET}/{ANDROID_API_LEVEL}:{platlib}/opt/lib' + # libfreetype links against libz internally; -lz needs to appear on + # pillow's link cmd so the resulting _imagingft.so resolves it. + LDFLAGS: -lz +# {% else %} # libfreetype references both libz and libbz2 # but doesn't link them into the static library LDFLAGS: -lz -lbz2 diff --git a/recipes/pillow/patches/setup-11.x.patch b/recipes/pillow/patches/setup-11.x.patch index b438622c..318082d6 100644 --- a/recipes/pillow/patches/setup-11.x.patch +++ b/recipes/pillow/patches/setup-11.x.patch @@ -1,9 +1,9 @@ diff --git a/setup.py b/setup.py --- a/setup.py +++ b/setup.py -@@ -355,9 +355,7 @@ class pil_build_ext(build_ext): +@@ -355,9 +355,7 @@ return True if value in configuration.get(option, []) else None - + def initialize_options(self) -> None: - self.disable_platform_guessing = self.check_configuration( - "platform-guessing", "disable" @@ -12,29 +12,48 @@ diff --git a/setup.py b/setup.py self.add_imaging_libs = "" build_ext.initialize_options(self) for x in self.feature: -@@ -550,8 +548,10 @@ class pil_build_ext(build_ext): +@@ -547,8 +545,10 @@ for d in os.environ[k].split(os.path.pathsep): _add_directory(library_dirs, d) - + - _add_directory(library_dirs, os.path.join(sys.prefix, "lib")) - _add_directory(include_dirs, os.path.join(sys.prefix, "include")) + # Skip sys.prefix paths when cross-compiling to avoid host headers + if not self.disable_platform_guessing: + _add_directory(library_dirs, os.path.join(sys.prefix, "lib")) + _add_directory(include_dirs, os.path.join(sys.prefix, "include")) - + # # add platform directories -@@ -684,6 +684,12 @@ class pil_build_ext(build_ext): +@@ -683,6 +683,31 @@ self.compiler.include_dirs = include_dirs + self.compiler.include_dirs - + # -+ # When cross-compiling, remove host system include/lib paths that -+ # leak in from the build Python's sysconfig (e.g. /usr/include). ++ # When cross-compiling, drop the host's Linux system include/lib ++ # paths that leak in from the build Python's sysconfig (e.g. a ++ # system-installed CPython contributing /usr/include). ++ # ++ # Match exact paths only — NOT `d.startswith("/usr/")` — because ++ # pillow's `_add_directory` calls `os.path.realpath()`, so a ++ # CPATH entry pointing inside the Android NDK resolves through ++ # whatever symlink chain the runner uses. On GitHub Actions ++ # `$ANDROID_HOME=/usr/local/lib/android/sdk`, so the NDK ++ # sysroot include path realpaths to ++ # `/usr/local/lib/android/sdk/ndk//.../sysroot/usr/include` ++ # — `startswith("/usr/")` matches that and the prefix-based ++ # filter ends up stripping the very headers CPATH was set to ++ # surface for zlib detection. + if self.disable_platform_guessing: -+ self.compiler.include_dirs = [d for d in self.compiler.include_dirs if not d.startswith("/usr/")] -+ self.compiler.library_dirs = [d for d in self.compiler.library_dirs if not d.startswith("/usr/")] ++ _host_leaks = frozenset({ ++ "/usr/include", "/usr/local/include", ++ "/usr/lib", "/usr/local/lib", ++ # Debian/Ubuntu multi-arch: ++ "/usr/include/x86_64-linux-gnu", ++ "/usr/lib/x86_64-linux-gnu", ++ }) ++ self.compiler.include_dirs = [d for d in self.compiler.include_dirs if d not in _host_leaks] ++ self.compiler.library_dirs = [d for d in self.compiler.library_dirs if d not in _host_leaks] + # # look for available libraries - + feature = self.feature diff --git a/recipes/pillow/test/test_pillow.py b/recipes/pillow/test/test_pillow.py index 72870c14..7873d51e 100644 --- a/recipes/pillow/test/test_pillow.py +++ b/recipes/pillow/test/test_pillow.py @@ -3,6 +3,7 @@ def test_basic(): + """Round-trip a JPEG through Pillow's PNG encoder.""" from PIL import Image img = Image.open(join(dirname(__file__), "mandrill.jpg")) @@ -12,22 +13,41 @@ def test_basic(): out_file = io.BytesIO() img.save(out_file, "png") out_bytes = out_file.getvalue() + assert 1024 < len(out_bytes) < 10_000_000 - EXPECTED_LEN = 313772 - assert len(out_bytes) > int(EXPECTED_LEN * 0.8) - assert len(out_bytes) < int(EXPECTED_LEN * 1.2) - + # PNG signature + IHDR chunk start + width 512 + height 512. assert out_bytes[:24] == ( b"\x89PNG\r\n\x1a\n" - + b"\x00\x00\x00\rIHDR" # File header - + b"\x00\x00\x02\x00" # Header chunk header - + b"\x00\x00\x02\x00" # Width 512 # Height 512 + + b"\x00\x00\x00\rIHDR" + + b"\x00\x00\x02\x00" + + b"\x00\x00\x02\x00" ) + # Round-trip: re-decode the produced PNG and confirm the dimensions + # survive (proves the encoder didn't truncate/corrupt the stream). + rt = Image.open(io.BytesIO(out_bytes)) + rt.load() + assert rt.width == 512 + assert rt.height == 512 + def test_font(): - from PIL import ImageFont + """Load a TrueType font and render text with it.""" + from PIL import Image, ImageDraw, ImageFont font = ImageFont.truetype(join(dirname(__file__), "Vera.ttf"), size=20) - font.getsize("Hello") == (51, 19) - font.getsize("Hello world") == (112, 19) + assert font.size == 20 + + bbox = font.getbbox("Hello") + width = bbox[2] - bbox[0] + assert 30 < width < 80, f"unexpected 'Hello' width = {width}" + + bbox_long = font.getbbox("Hello world") + assert bbox_long[2] - bbox_long[0] > width + + img = Image.new("RGB", (200, 50), "white") + ImageDraw.Draw(img).text((10, 10), "Hello", fill="black", font=font) + pixels = [img.getpixel((x, 25)) for x in range(15, 80)] + assert any(p != (255, 255, 255) for p in pixels), ( + "font didn't render any non-white pixels" + ) diff --git a/recipes/primp/tests/test_primp.py b/recipes/primp/tests/test_primp.py new file mode 100644 index 00000000..b9117fb6 --- /dev/null +++ b/recipes/primp/tests/test_primp.py @@ -0,0 +1,23 @@ +def test_import_and_client_construction(): + """primp is a Rust-backed HTTP client (PyO3 binding). Importing + the package and constructing a Client is the smallest call we can + make that exercises the compiled extension's symbol load. No + network I/O — that would be flaky in CI.""" + import primp + + # Default constructor — no impersonation, no proxy. + client = primp.Client() + # Methods exposed by the Rust binding (per the .pyi). + for attr in ("get", "post", "head", "put", "delete", "request"): + assert callable(getattr(client, attr)), f"Client missing {attr}" + + +def test_exception_hierarchy(): + """Verifies the exception classes the Rust binding exports are + importable and form the documented hierarchy.""" + import primp + + assert issubclass(primp.RequestError, primp.PrimpError) + assert issubclass(primp.ConnectError, primp.RequestError) + assert issubclass(primp.TimeoutError, primp.RequestError) + assert issubclass(primp.StatusError, primp.PrimpError) diff --git a/recipes/protobuf/test_protobuf.py b/recipes/protobuf/test_protobuf.py index 6b81d9cd..21ed9905 100644 --- a/recipes/protobuf/test_protobuf.py +++ b/recipes/protobuf/test_protobuf.py @@ -1 +1,52 @@ -# TBD \ No newline at end of file +def test_descriptor_pool(): + """protobuf ships a C++ implementation (`_message`) for runtime + serialisation. Build a message type via the DescriptorPool API at + runtime (no .proto file or generated code needed) and round-trip it.""" + from google.protobuf import descriptor_pb2, descriptor_pool, message_factory + + # Define a message: `message Item { int32 id = 1; string name = 2; }` + file_proto = descriptor_pb2.FileDescriptorProto() + file_proto.name = "item.proto" + file_proto.syntax = "proto3" + item = file_proto.message_type.add() + item.name = "Item" + f1 = item.field.add() + f1.name = "id" + f1.number = 1 + f1.type = descriptor_pb2.FieldDescriptorProto.TYPE_INT32 + f1.label = descriptor_pb2.FieldDescriptorProto.LABEL_OPTIONAL + f2 = item.field.add() + f2.name = "name" + f2.number = 2 + f2.type = descriptor_pb2.FieldDescriptorProto.TYPE_STRING + f2.label = descriptor_pb2.FieldDescriptorProto.LABEL_OPTIONAL + + pool = descriptor_pool.DescriptorPool() + pool.Add(file_proto) + Item = message_factory.GetMessageClass(pool.FindMessageTypeByName("Item")) + + msg = Item(id=42, name="mobile-forge") + blob = msg.SerializeToString() + assert isinstance(blob, bytes) + assert len(blob) > 0 + + parsed = Item() + parsed.ParseFromString(blob) + assert parsed.id == 42 + assert parsed.name == "mobile-forge" + + +def test_well_known_timestamp(): + """Built-in Timestamp message exercises the bundled well-known types, + which depend on the C++ extension being correctly loaded.""" + from google.protobuf.timestamp_pb2 import Timestamp + + t = Timestamp() + t.seconds = 1_700_000_000 + t.nanos = 123_456_789 + blob = t.SerializeToString() + + rt = Timestamp() + rt.ParseFromString(blob) + assert rt.seconds == 1_700_000_000 + assert rt.nanos == 123_456_789 diff --git a/recipes/pycryptodome/meta.yaml b/recipes/pycryptodome/meta.yaml index b57c3896..88d1fdb6 100644 --- a/recipes/pycryptodome/meta.yaml +++ b/recipes/pycryptodome/meta.yaml @@ -3,7 +3,20 @@ package: version: 3.21.0 build: - number: 4 + number: 5 +# pycryptodome's internal Crypto/Util/_raw_api.py tries a cffi-based +# fast path first, and only falls back to ctypes.pythonapi.PyObject_GetBuffer +# if cffi can't be imported. That ctypes fallback dies on Android with +# `AttributeError: undefined symbol: PyObject_GetBuffer` because Flet's +# bootstrap loads libpython.so with RTLD_LOCAL (Dart's DynamicLibrary.open +# default), so libpython symbols aren't visible to +# `dlsym(RTLD_DEFAULT, "PyObject_GetBuffer")`. +# +# Upstream pycryptodome's setup.py declares NO install_requires, so pip +# doesn't pull cffi by default and the broken ctypes path is what runs. +# mobile.patch adds `install_requires=['cffi']` to setup.py directly — +# forge's METADATA injection only touches `flet-*` host deps, so a +# `requirements: host: - cffi` here would silently no-op. patches: - - mobile.patch \ No newline at end of file + - mobile.patch diff --git a/recipes/pycryptodome/patches/mobile.patch b/recipes/pycryptodome/patches/mobile.patch index 08e26fe8..c3b69392 100644 --- a/recipes/pycryptodome/patches/mobile.patch +++ b/recipes/pycryptodome/patches/mobile.patch @@ -29,3 +29,16 @@ index e0065c3..3b14e00 100644 return load_lib(full_name, cdecl) except OSError as exp: attempts.append("Cannot load '%s': %s" % (filename, str(exp))) +diff --git a/setup.py b/setup.py +--- a/setup.py ++++ b/setup.py +@@ -521,6 +521,9 @@ + platforms='Posix; MacOS X; Windows', + zip_safe=False, + python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*', ++ # Mobile: cffi is needed at runtime to avoid the broken ++ # ctypes.pythonapi.PyObject_GetBuffer path. See recipes/pycryptodome/meta.yaml. ++ install_requires=['cffi'], + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'License :: OSI Approved :: BSD License', diff --git a/recipes/pycryptodome/test_pycryptodome.py b/recipes/pycryptodome/test_pycryptodome.py new file mode 100644 index 00000000..94eb5e68 --- /dev/null +++ b/recipes/pycryptodome/test_pycryptodome.py @@ -0,0 +1,44 @@ +def test_import_aes(): + """`from Crypto.Cipher import AES` walks through + `Crypto/Util/_raw_api.py`, which on import resolves its native-call + interface. Without cffi installed, that code falls back to + `ctypes.pythonapi.PyObject_GetBuffer` — and on Android that attribute + access fails with `AttributeError: undefined symbol: PyObject_GetBuffer` + because Flet's bootstrap loads libpython.so via Dart's + DynamicLibrary.open which defaults to RTLD_LOCAL, hiding libpython + symbols from `dlsym(RTLD_DEFAULT)`. The recipe's mobile.patch adds + `install_requires=['cffi']` so pip pulls cffi alongside, the cffi + fast path wins, and the broken ctypes path never runs.""" + from Crypto.Cipher import AES + + assert hasattr(AES, "new") + assert AES.MODE_CBC > 0 + + +def test_aes_cbc_roundtrip(): + """pycryptodome is a from-scratch C-extension crypto library (the + `Crypto.*` namespace). Encrypt + decrypt covers the AES C code.""" + from Crypto.Cipher import AES + from Crypto.Random import get_random_bytes + from Crypto.Util.Padding import pad, unpad + + key = get_random_bytes(32) # AES-256 + iv = get_random_bytes(16) + plaintext = b"hello mobile-forge" + + encryptor = AES.new(key, AES.MODE_CBC, iv) + ct = encryptor.encrypt(pad(plaintext, AES.block_size)) + + decryptor = AES.new(key, AES.MODE_CBC, iv) + assert unpad(decryptor.decrypt(ct), AES.block_size) == plaintext + + +def test_sha256_vector(): + """SHA-256 has well-known reference vectors. NIST FIPS 180-4.""" + from Crypto.Hash import SHA256 + + h = SHA256.new() + h.update(b"abc") + assert h.hexdigest() == ( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ) diff --git a/recipes/pycryptodomex/meta.yaml b/recipes/pycryptodomex/meta.yaml index 2f81852d..70da98cb 100644 --- a/recipes/pycryptodomex/meta.yaml +++ b/recipes/pycryptodomex/meta.yaml @@ -3,7 +3,15 @@ package: version: 3.21.0 build: - number: 4 + number: 5 +# Same fix rationale as recipes/pycryptodome/meta.yaml — pycryptodomex is +# the sister package (same code under `Cryptodome.*` namespace). Without +# cffi installed, Cryptodome/Util/_raw_api.py falls back to +# ctypes.pythonapi.PyObject_GetBuffer which fails on Android with +# `AttributeError: undefined symbol: PyObject_GetBuffer` (Flet's +# bootstrap loads libpython.so with RTLD_LOCAL, so its symbols aren't +# visible to dlsym(RTLD_DEFAULT)). mobile.patch adds +# `install_requires=['cffi']` to setup.py so pip pulls cffi alongside. patches: - - mobile.patch \ No newline at end of file + - mobile.patch diff --git a/recipes/pycryptodomex/patches/mobile.patch b/recipes/pycryptodomex/patches/mobile.patch index b9d5f9a7..84edf413 100644 --- a/recipes/pycryptodomex/patches/mobile.patch +++ b/recipes/pycryptodomex/patches/mobile.patch @@ -29,3 +29,16 @@ index e0065c3..3b14e00 100644 return load_lib(full_name, cdecl) except OSError as exp: attempts.append("Cannot load '%s': %s" % (filename, str(exp))) +diff --git a/setup.py b/setup.py +--- a/setup.py ++++ b/setup.py +@@ -521,6 +521,9 @@ + platforms='Posix; MacOS X; Windows', + zip_safe=False, + python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*', ++ # Mobile: cffi is needed at runtime to avoid the broken ++ # ctypes.pythonapi.PyObject_GetBuffer path. See recipes/pycryptodomex/meta.yaml. ++ install_requires=['cffi'], + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'License :: OSI Approved :: BSD License', diff --git a/recipes/pycryptodomex/test_pycryptodomex.py b/recipes/pycryptodomex/test_pycryptodomex.py new file mode 100644 index 00000000..537a1149 --- /dev/null +++ b/recipes/pycryptodomex/test_pycryptodomex.py @@ -0,0 +1,30 @@ +def test_aes_gcm_roundtrip(): + """pycryptodomex is the same C library as pycryptodome but installed + under the `Cryptodome.*` namespace to coexist with `pycrypto`. AES-GCM + is the most common AEAD use case.""" + from Cryptodome.Cipher import AES + from Cryptodome.Random import get_random_bytes + + key = get_random_bytes(32) + nonce = get_random_bytes(12) + aad = b"recipe-test" + plaintext = b"a quiet sentence to encrypt" + + enc = AES.new(key, AES.MODE_GCM, nonce=nonce) + enc.update(aad) + ct, tag = enc.encrypt_and_digest(plaintext) + + dec = AES.new(key, AES.MODE_GCM, nonce=nonce) + dec.update(aad) + assert dec.decrypt_and_verify(ct, tag) == plaintext + + +def test_sha256_vector(): + """Sanity-check the hash C code is wired under the Cryptodome namespace.""" + from Cryptodome.Hash import SHA256 + + h = SHA256.new() + h.update(b"abc") + assert h.hexdigest() == ( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ) diff --git a/recipes/pydantic-core/test_pydantic_core.py b/recipes/pydantic-core/test_pydantic_core.py new file mode 100644 index 00000000..07c63f36 --- /dev/null +++ b/recipes/pydantic-core/test_pydantic_core.py @@ -0,0 +1,25 @@ +def test_schema_validator_basic(): + """pydantic-core is the Rust validator backing pydantic v2. Build a + small schema directly (no pydantic-shim) and validate a payload.""" + from pydantic_core import SchemaValidator, core_schema + + schema = core_schema.typed_dict_schema( + { + "name": core_schema.typed_dict_field(core_schema.str_schema()), + "age": core_schema.typed_dict_field(core_schema.int_schema(ge=0)), + } + ) + v = SchemaValidator(schema) + assert v.validate_python({"name": "Ada", "age": 37}) == {"name": "Ada", "age": 37} + + +def test_validation_error_raised(): + """Invalid input goes through the Rust error-formatting path.""" + from pydantic_core import SchemaValidator, ValidationError, core_schema + + v = SchemaValidator(core_schema.int_schema(ge=0)) + try: + v.validate_python(-1) + except ValidationError: + return + raise AssertionError("expected ValidationError for negative int with ge=0") diff --git a/recipes/pyjnius/meta.yaml b/recipes/pyjnius/meta.yaml index 2c33aa00..263974de 100644 --- a/recipes/pyjnius/meta.yaml +++ b/recipes/pyjnius/meta.yaml @@ -1,6 +1,7 @@ package: name: pyjnius version: 1.6.1 + platforms: [android] build: number: 4 diff --git a/recipes/pyjnius/test_pyjnius.py b/recipes/pyjnius/test_pyjnius.py new file mode 100644 index 00000000..76f4c196 --- /dev/null +++ b/recipes/pyjnius/test_pyjnius.py @@ -0,0 +1,23 @@ +import pytest + + +def test_jvm_classes(): + """Reach into the Android `android.os.Build` static class to read the + device's BRAND. This requires: + - the libpyjni .so to have loaded, + - the embedded VM to be reachable via JNI, + - the recipe's startup hooks (in mobile-forge/serious_python) to + have configured the right ClassLoader. + Three layers in one assert.""" + try: + from jnius import autoclass + except (ImportError, Exception): + # On non-Android hosts the import of jnius raises because no JVM + # can be located. Skip — this test is meaningful only on device. + pytest.skip("pyjnius requires an Android JVM at runtime") + + Build = autoclass("android.os.Build") + brand = Build.BRAND + # BRAND is a non-empty string on real & emulated devices. + assert isinstance(brand, str) + assert len(brand) > 0 diff --git a/recipes/pymongo/test_pymongo.py b/recipes/pymongo/test_pymongo.py new file mode 100644 index 00000000..dbdcf46d --- /dev/null +++ b/recipes/pymongo/test_pymongo.py @@ -0,0 +1,35 @@ +def test_bson_roundtrip(): + """pymongo bundles a `_cbson` C extension that encodes BSON. We can + exercise it without a MongoDB server by going through bson directly.""" + import bson + + doc = { + "_id": 42, + "name": "mobile-forge", + "tags": ["recipes", "ci"], + "nested": {"version": 1, "active": True}, + } + raw = bson.encode(doc) + assert isinstance(raw, bytes) + assert bson.decode(raw) == doc + + +def test_objectid(): + """ObjectId() generates a 12-byte id — implemented in C for speed.""" + from bson.objectid import ObjectId + + oid = ObjectId() + assert len(oid.binary) == 12 + # Round-trip through hex. + assert ObjectId(str(oid)) == oid + + +def test_client_offline(): + """Instantiating MongoClient with `connect=False` doesn't open a + socket — confirms the import + class construction work, which is the + most we can do without a real server.""" + from pymongo import MongoClient + + c = MongoClient("mongodb://localhost:27017", connect=False) + assert c is not None + c.close() diff --git a/recipes/pymupdf/test_pymupdf.py b/recipes/pymupdf/test_pymupdf.py new file mode 100644 index 00000000..224909f8 --- /dev/null +++ b/recipes/pymupdf/test_pymupdf.py @@ -0,0 +1,39 @@ +def test_open_and_read(tmp_path): + """PyMuPDF wraps the MuPDF C library. Create a one-page PDF in memory + then re-open it and read the text back.""" + import fitz # PyMuPDF + + # Create a fresh document with one page containing known text. + src = fitz.open() + page = src.new_page() + page.insert_text((72, 72), "Hello mobile-forge") + pdf_bytes = src.tobytes() + src.close() + + # Re-open from bytes and read the text back. + dst = fitz.open(stream=pdf_bytes, filetype="pdf") + assert dst.page_count == 1 + text = dst[0].get_text() + dst.close() + + assert "Hello mobile-forge" in text + + +def test_metadata(): + """Document.metadata is a Python wrapper around MuPDF's + pdf_dict_get_inheritable — confirms basic dict roundtrip.""" + import fitz + + doc = fitz.open() + doc.new_page() + doc.set_metadata({"title": "test", "author": "ci"}) + + blob = doc.tobytes() + doc.close() + + rt = fitz.open(stream=blob, filetype="pdf") + md = rt.metadata + rt.close() + + assert md["title"] == "test" + assert md["author"] == "ci" diff --git a/recipes/pynacl/test_pynacl.py b/recipes/pynacl/test_pynacl.py new file mode 100644 index 00000000..12caecf3 --- /dev/null +++ b/recipes/pynacl/test_pynacl.py @@ -0,0 +1,30 @@ +def test_secretbox_roundtrip(): + """PyNaCl is the Python binding for libsodium (vendored). SecretBox + (authenticated symmetric encryption) is the canonical demo.""" + import nacl.secret + import nacl.utils + + key = nacl.utils.random(nacl.secret.SecretBox.KEY_SIZE) + box = nacl.secret.SecretBox(key) + + plaintext = b"hello recipe-tester" + nonce = nacl.utils.random(nacl.secret.SecretBox.NONCE_SIZE) + ciphertext = box.encrypt(plaintext, nonce) + + box2 = nacl.secret.SecretBox(key) + assert box2.decrypt(ciphertext) == plaintext + + +def test_signing_roundtrip(): + """Ed25519 keypair / sign / verify — covers libsodium's + crypto_sign_* code path.""" + import nacl.signing + + signing_key = nacl.signing.SigningKey.generate() + verify_key = signing_key.verify_key + + message = b"a signed message" + signed = signing_key.sign(message) + + # Verification raises BadSignatureError on tamper. + assert verify_key.verify(signed) == message diff --git a/recipes/pyobjus/meta.yaml b/recipes/pyobjus/meta.yaml index 15d6fd1a..96d4507c 100644 --- a/recipes/pyobjus/meta.yaml +++ b/recipes/pyobjus/meta.yaml @@ -1,13 +1,14 @@ package: name: pyobjus version: 1.2.3 - + platforms: [ios] + build: - number: 1 + number: 2 patches: - mobile.patch requirements: host: - - libffi \ No newline at end of file + - libffi diff --git a/recipes/pyobjus/patches/mobile.patch b/recipes/pyobjus/patches/mobile.patch index ba16e9f0..3dcbee99 100644 --- a/recipes/pyobjus/patches/mobile.patch +++ b/recipes/pyobjus/patches/mobile.patch @@ -16,8 +16,8 @@ index 3a17bbb..4f43c6a 100644 +++ b/pyobjus/common.pxi @@ -109,7 +109,7 @@ cdef extern from "objc/runtime.h": objc_method_description* protocol_copyMethodDescriptionList(Protocol *p, BOOL isRequiredMethod, BOOL isInstanceMethod, unsigned int *outCount) - - + + -cdef extern from "ffi/ffi.h": +cdef extern from "ffi.h": ctypedef unsigned long ffi_arg @@ -29,7 +29,7 @@ index 0de7708..c8deb36 100644 +++ b/setup.py @@ -20,13 +20,7 @@ if kivy_ios_root is not None: print("Pyobjus platform is {}".format(dev_platform)) - + # OSX -files = [] -if dev_platform == 'darwin': @@ -39,9 +39,9 @@ index 0de7708..c8deb36 100644 - files = ['pyobjus.c'] - +files = ['pyobjus.pyx'] - + class PyObjusBuildExt(build_ext, object): - + @@ -43,13 +37,10 @@ class PyObjusBuildExt(build_ext, object): # The following essentially supply a dynamically generated subclass # that mix in the cython version of build_ext so that the @@ -57,7 +57,7 @@ index 0de7708..c8deb36 100644 + build_ext_cls = type( + 'PyObjusBuildExt', (PyObjusBuildExt, cython_build_ext), {}) + return super(PyObjusBuildExt, cls).__new__(build_ext_cls) - + def build_extensions(self): # create a configuration file for pyobjus (export the platform) @@ -57,11 +48,9 @@ class PyObjusBuildExt(build_ext, object): @@ -77,10 +77,47 @@ index 0de7708..c8deb36 100644 with open(config_pxi_fn) as fd: @@ -73,7 +62,7 @@ class PyObjusBuildExt(build_ext, object): super().build_extensions() - - + + -libraries = ['ffi'] +libraries = ['ffi', 'objc'] library_dirs = [] extra_compile_args = [] extra_link_args = [] +diff --git a/pyobjus/pyobjus_conversions.pxi b/pyobjus/pyobjus_conversions.pxi +--- a/pyobjus/pyobjus_conversions.pxi ++++ b/pyobjus/pyobjus_conversions.pxi +@@ -428,10 +428,8 @@ + return arg + elif arg in (True, False): + return autoclass('NSNumber').alloc().initWithBool_(int(arg)) +- elif isinstance(arg, (str, unicode)): ++ elif isinstance(arg, str): + return autoclass('NSString').alloc().initWithUTF8String_(arg) +- elif isinstance(arg, long): +- return autoclass('NSNumber').alloc().initWithInt_(arg) + elif isinstance(arg, int): + return autoclass('NSNumber').alloc().initWithLong_(arg) + elif isinstance(arg, float): +@@ -525,10 +523,10 @@ + # method is accepting long + elif sig == b'l': + if by_value: +- (val_ptr)[0] = long(arg) ++ (val_ptr)[0] = int(arg) + else: + if not objc_ref: +- (arg_val_ptr)[0] = long(arg) ++ (arg_val_ptr)[0] = int(arg) + (val_ptr)[0] = arg_val_ptr + # method is accepting long long + elif sig == b'q': +@@ -692,7 +690,7 @@ + # ARRAY, ETC. + else: + # TODO: Add better conversion between primitive types! +- if type(arg) is long: ++ if type(arg) is int: + (val_ptr)[0] = arg + elif type(arg) is str: + # passing bytes as void* is the same as for char* diff --git a/recipes/pyobjus/test_pyobjus.py b/recipes/pyobjus/test_pyobjus.py new file mode 100644 index 00000000..0f1031ca --- /dev/null +++ b/recipes/pyobjus/test_pyobjus.py @@ -0,0 +1,20 @@ +import pytest + + +def test_objc_classes(): + """Reach into Foundation's NSDate to read the current epoch. This requires: + - libpyobjus loaded, + - the Objective-C runtime accessible (CoreFoundation linked), + - NSDate's class methods resolvable through autoclass.""" + try: + from pyobjus import autoclass + except (ImportError, Exception): + pytest.skip("pyobjus requires the Objective-C runtime (iOS/macOS)") + + NSDate = autoclass("NSDate") + now = NSDate.alloc().init() + # `timeIntervalSince1970` returns a float since the epoch — a non-zero + # plausible value means the bridge fully resolved class + method + return. + epoch = now.timeIntervalSince1970() + assert isinstance(epoch, float) + assert epoch > 1_700_000_000.0 # later than 2023-11-14 diff --git a/recipes/pyogrio/meta.yaml b/recipes/pyogrio/meta.yaml index fa6c36fe..80ba2bda 100644 --- a/recipes/pyogrio/meta.yaml +++ b/recipes/pyogrio/meta.yaml @@ -5,13 +5,36 @@ package: requirements: host: - flet-libgdal 3.10.0 +# {% if sdk != 'android' %} + # iOS-only — see GDAL_LIBS comment below for the rationale. + - openssl >=3.0.15 + - flet-libjpeg 3.0.90 +# {% endif %} build: - number: 4 + number: 5 script_env: GDAL_VERSION: 3.10.0 GDAL_LIBRARY_PATH: '{platlib}/opt/lib' GDAL_INCLUDE_PATH: '{platlib}/opt/include' # {% if sdk != 'android' %} + # On iOS every native dep is a static archive (libgdal, libproj, + # libtiff, libcurl, libpsl all `-DBUILD_SHARED_LIBS=OFF`). When + # GDAL was linked at its own build time the linker only kept + # object files for symbols GDAL itself referenced — anything else + # was left undefined in libgdal.a. iOS dyld eagerly resolves the + # flat namespace at dlopen and aborts on the first miss + # (`_geod_init`, `_TIFFClientOpen`, `_psl_builtin`, …). Adding the + # full dep chain to GDAL_LIBS makes pyogrio's `_io`/`_ogr` link + # commands pull every missing object straight out of its archive. + # + # Identical fix shipped in recipes/fiona, recipes/gdal. The right + # long-term fix lives in `flet-libgdal`: align iOS cmake with + # Android (`-DGDAL_USE_CURL=OFF`, `-DGDAL_USE_TIFF_INTERNAL=ON`, + # …) so libgdal.a stops leaking refs at all. + GDAL_LIBS: gdal,proj,tiff,curl,psl,sqlite3,jpeg,ssl,crypto,z LDFLAGS: '-undefined dynamic_lookup' -# {% endif %} \ No newline at end of file +# {% endif %} + +patches: + - mobile.patch diff --git a/recipes/pyogrio/patches/mobile.patch b/recipes/pyogrio/patches/mobile.patch new file mode 100644 index 00000000..67335484 --- /dev/null +++ b/recipes/pyogrio/patches/mobile.patch @@ -0,0 +1,12 @@ +diff --git a/setup.py b/setup.py +--- a/setup.py ++++ b/setup.py +@@ -59,6 +59,8 @@ + + if include_dir and library_dir and gdal_version_str: + gdal_libs = ["gdal"] ++ if "GDAL_LIBS" in os.environ: ++ gdal_libs = os.environ["GDAL_LIBS"].split(",") + + if platform.system() == "Windows": + # NOTE: if libgdal is built for Windows using CMake, it is now "gdal", diff --git a/recipes/pyogrio/test_pyogrio.py b/recipes/pyogrio/test_pyogrio.py index 6b81d9cd..33c0bdd9 100644 --- a/recipes/pyogrio/test_pyogrio.py +++ b/recipes/pyogrio/test_pyogrio.py @@ -1 +1,24 @@ -# TBD \ No newline at end of file +def test_list_drivers(): + """pyogrio is a Cython wrapper for GDAL/OGR's vector I/O. Listing + drivers is the smallest C-call we can make to confirm libgdal is + loaded and the Cython binding's `_io` extension is importable.""" + import pyogrio + + drivers = pyogrio.list_drivers() + assert isinstance(drivers, dict) + # Universal drivers — present in any GDAL build with vector support. + assert "ESRI Shapefile" in drivers + assert "GeoJSON" in drivers + + +def test_gdal_version(): + """Confirms the GDAL C library version is reported (touches the + `_version` extension).""" + import pyogrio + + v = pyogrio.__gdal_version__ + # `__gdal_version__` is a 3-tuple of ints. + assert isinstance(v, tuple) + assert len(v) == 3 + assert all(isinstance(x, int) for x in v) + assert v[0] >= 3 # GDAL ≥ 3.0 diff --git a/recipes/pyproj/meta.yaml b/recipes/pyproj/meta.yaml index 0d26cf64..1b8b4bdd 100644 --- a/recipes/pyproj/meta.yaml +++ b/recipes/pyproj/meta.yaml @@ -3,17 +3,39 @@ package: version: 3.7.0 build: - number: 4 + number: 5 script_env: PROJ_VERSION: 9.5.0 PROJ_DIR: '{platlib}/opt' # {% if sdk != 'android' %} + # On iOS libproj is built static (`-DBUILD_SHARED_LIBS=OFF`). It + # carries internal references to libtiff (for grid file + # support), libcurl + libpsl (for the network grid fetcher), + # libsqlite3 (proj.db), and openssl (via libcurl HTTPS) — all + # left undefined when proj's own link only kept objects it + # itself referenced. iOS dyld eagerly resolves the flat + # namespace at dlopen and aborts on the first miss (we saw it + # land on `_TIFFClientOpen`). Adding the full dep chain to + # PROJ_LIBS makes pyproj's `_geod`/`_crs`/`_context` link + # commands pull every missing object straight out of its + # archive. + # + # Same shape of fix as recipes/fiona — see that recipe for + # extra context. Long-term fix lives in `flet-libproj`: + # `-DENABLE_TIFF=OFF -DENABLE_CURL=OFF` in the iOS cmake call so + # libproj.a stops leaking refs at all. + PROJ_LIBS: proj,tiff,curl,psl,sqlite3,jpeg,ssl,crypto,z LDFLAGS: '-undefined dynamic_lookup' # {% endif %} requirements: host: - flet-libproj 9.5.0 +# {% if sdk != 'android' %} + # iOS-only — see PROJ_LIBS comment above for the rationale. + - openssl >=3.0.15 + - flet-libjpeg 3.0.90 +# {% endif %} patches: - - mobile.patch \ No newline at end of file + - mobile.patch diff --git a/recipes/pyproj/patches/mobile.patch b/recipes/pyproj/patches/mobile.patch index aa542c16..623df723 100644 --- a/recipes/pyproj/patches/mobile.patch +++ b/recipes/pyproj/patches/mobile.patch @@ -1,8 +1,16 @@ diff --git a/setup.py b/setup.py -index 9987cff..b56a0fc 100644 --- a/setup.py +++ b/setup.py -@@ -194,7 +194,9 @@ def get_extension_modules(): +@@ -155,6 +155,8 @@ + This function gets the libraries to cythonize with + """ + libraries = ["proj"] ++ if "PROJ_LIBS" in os.environ: ++ libraries = os.environ["PROJ_LIBS"].split(",") + if os.name == "nt": + for libdir in libdirs: + projlib = list(Path(libdir).glob("proj*.lib")) +@@ -194,7 +196,9 @@ def get_extension_modules(): "include_dirs": include_dirs, "library_dirs": library_dirs, "runtime_library_dirs": ( diff --git a/recipes/pyproj/test_pyproj.py b/recipes/pyproj/test_pyproj.py new file mode 100644 index 00000000..06c302fa --- /dev/null +++ b/recipes/pyproj/test_pyproj.py @@ -0,0 +1,41 @@ +def test_import_pyproj(): + """`import pyproj` triggers `_geod`/`_crs`/`_context` etc.'s dlopen. + On iOS, the published wheel's extensions were linked against + `libproj.a` only — and libproj's static archive has internal + references to libtiff (grid file support) and libcurl + libpsl + (network grid fetcher) that are left undefined. iOS dyld eagerly + resolves the flat namespace at dlopen and aborts with + `symbol not found in flat namespace '_TIFFClientOpen'` (or + _curl_easy_init / _psl_builtin). Android isn't affected — libproj + is shared there, its deps resolve transparently via DT_NEEDED.""" + import pyproj + + assert hasattr(pyproj, "Geod") + assert hasattr(pyproj, "CRS") + + +def test_geod_distance(): + """pyproj wraps PROJ (the C cartographic projection library). The + Geod (geodesic) API operates directly on the WGS-84 ellipsoid and + doesn't need PROJ's database (proj.db) — perfect for mobile, where + the recipe doesn't bundle that ~9 MB sqlite file. Paris → London is + ~344 km along the WGS-84 geodesic.""" + from pyproj import Geod + + g = Geod(ellps="WGS84") + _, _, dist = g.inv(2.3522, 48.8566, -0.1276, 51.5074) + km = dist / 1000.0 + assert 340 < km < 350 + + +def test_geod_forward(): + """The forward problem: given a start point, azimuth, and distance, + where do you end up? Also database-free.""" + from pyproj import Geod + + g = Geod(ellps="WGS84") + # Start at the equator/prime meridian, head due east 1000 km. + lon, lat, back_az = g.fwd(0.0, 0.0, 90.0, 1_000_000) + # Should still be on the equator (within precision), longitude ~9°. + assert abs(lat) < 0.01 + assert 8.9 < lon < 9.1 diff --git a/recipes/pysodium/test_pysodium.py b/recipes/pysodium/test_pysodium.py new file mode 100644 index 00000000..fc25d69c --- /dev/null +++ b/recipes/pysodium/test_pysodium.py @@ -0,0 +1,24 @@ +def test_secretbox_roundtrip(): + """pysodium is the lightweight libsodium ctypes wrapper (different from + PyNaCl, which is the cffi wrapper). Round-trip through crypto_secretbox + confirms the libsodium shared lib is loadable and the FFI signatures + match.""" + import pysodium + + key = pysodium.randombytes(pysodium.crypto_secretbox_KEYBYTES) + nonce = pysodium.randombytes(pysodium.crypto_secretbox_NONCEBYTES) + plaintext = b"hello mobile-forge" + + ciphertext = pysodium.crypto_secretbox(plaintext, nonce, key) + assert pysodium.crypto_secretbox_open(ciphertext, nonce, key) == plaintext + + +def test_hash_known_vector(): + """libsodium's generichash (BLAKE2b). Empty input is a stable vector.""" + import pysodium + + out = pysodium.crypto_generichash(b"") + # BLAKE2b-256 of empty input — well-known reference vector. + assert out.hex() == ( + "0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8" + ) diff --git a/recipes/rasterio/meta.yaml b/recipes/rasterio/meta.yaml index 4ced6b06..31927e67 100644 --- a/recipes/rasterio/meta.yaml +++ b/recipes/rasterio/meta.yaml @@ -7,17 +7,39 @@ requirements: - flet-libgdal 3.10.0 - numpy ^2.0.0 # {% if sdk == 'android' %} + # rasterio's `_warp.so` / `_filepath.so` / `_fill.so` carry + # `DT_NEEDED libc++_shared.so` directly. - flet-libcpp-shared >=27.2.12479018 +# {% else %} + # iOS-only — see GDAL_LIBS comment below for the rationale. + - openssl >=3.0.15 + - flet-libjpeg 3.0.90 # {% endif %} build: - number: 2 + number: 3 script_env: GDAL_VERSION: 3.10.0 GDAL_LIB_PATH: '{platlib}/opt/lib' GDAL_INCLUDE_PATH: '{platlib}/opt/include' +# {% if sdk == 'android' %} GDAL_LIBS: gdal -# {% if sdk != 'android' %} +# {% else %} + # On iOS every native dep is a static archive (libgdal, libproj, + # libtiff, libcurl, libpsl all `-DBUILD_SHARED_LIBS=OFF`). When + # GDAL was linked at its own build time the linker only kept object + # files for symbols GDAL itself referenced — anything else was left + # undefined in libgdal.a. iOS dyld eagerly resolves the flat + # namespace at dlopen and aborts on the first miss (e.g. `_geod_init` + # from libproj, which is what `import rasterio` trips). Adding the + # full dep chain to GDAL_LIBS makes rasterio's `_base`/`_io`/`_features` + # link commands pull every missing object straight out of its archive. + # + # Identical fix shipped in recipes/gdal, recipes/pyogrio. The right + # long-term fix lives in `flet-libgdal`: align iOS cmake with Android + # (`-DGDAL_USE_CURL=OFF`, `-DGDAL_USE_TIFF_INTERNAL=ON`, …) so + # libgdal.a stops leaking refs at all. + GDAL_LIBS: gdal,proj,tiff,curl,psl,sqlite3,jpeg,ssl,crypto,z LDFLAGS: '-undefined dynamic_lookup' # {% endif %} diff --git a/recipes/rasterio/tests/test_rasterio.py b/recipes/rasterio/tests/test_rasterio.py new file mode 100644 index 00000000..04881ed5 --- /dev/null +++ b/recipes/rasterio/tests/test_rasterio.py @@ -0,0 +1,31 @@ +def test_gdal_version(): + """`rasterio.__gdal_version__` reads from `rasterio._base` (a Cython + extension that links libgdal). Confirms the native extension loaded + and libgdal is reachable — the canary for the GDAL_LIBS chain + declared in meta.yaml (mirrors recipes/pyogrio's test_gdal_version). + """ + import rasterio + + v = rasterio.__gdal_version__ + # `__gdal_version__` is a "MAJOR.MINOR.PATCH" string in modern + # rasterio. Be tolerant about extra suffixes like "3.10.0e". + parts = v.split(".") + assert len(parts) >= 2, f"unexpected GDAL version string: {v!r}" + assert int(parts[0]) >= 3, f"GDAL major < 3: {v!r}" + + +def test_drivers_listed(): + """Touches `rasterio._env` (driver registration) + `rasterio.drivers`. + Asks for the registered raster driver count to confirm GDAL's + driver registry initialised inside the Cython binding.""" + import rasterio + + # `rasterio.drivers` is the public driver-management module; the + # `is_blacklisted` predicate is the cheapest call that round-trips + # through `rasterio._env.GDALEnv` and proves the driver registry + # initialised. GTiff is universal in any GDAL build with raster + # support. + from rasterio.drivers import is_blacklisted + + # Built-in driver — should not be blacklisted. + assert is_blacklisted("GTiff", "r") is False diff --git a/recipes/regex/test_regex.py b/recipes/regex/test_regex.py new file mode 100644 index 00000000..4bcf46f4 --- /dev/null +++ b/recipes/regex/test_regex.py @@ -0,0 +1,20 @@ +def test_basic(): + """Unicode-aware patterns the stdlib `re` can't handle — that's why the + `regex` C extension is shipped as a recipe.""" + import regex + + # Property-class match (\p{L} = any unicode letter). stdlib `re` raises. + m = regex.match(r"\p{L}+", "Καλημέρα") + assert m is not None + assert m.group(0) == "Καλημέρα" + + # Possessive quantifier + atomic group — also regex-only syntax. + m = regex.match(r"(?>a+)b", "aaab") + assert m is not None + assert m.group(0) == "aaab" + + +def test_findall(): + import regex + + assert regex.findall(r"\d+", "10 frogs, 200 toads") == ["10", "200"] diff --git a/recipes/rpds-py/test_rpds_py.py b/recipes/rpds-py/test_rpds_py.py new file mode 100644 index 00000000..3abf460f --- /dev/null +++ b/recipes/rpds-py/test_rpds_py.py @@ -0,0 +1,30 @@ +def test_hashtriemap(): + """rpds-py is a Rust port of immutable persistent data structures + (used by jsonschema). HashTrieMap covers the PyO3 dict-like surface.""" + from rpds import HashTrieMap + + m = HashTrieMap() + m1 = m.insert("a", 1).insert("b", 2) + m2 = m1.insert("c", 3) + + # Persistence: m1 is unchanged by inserting into it. + assert dict(m1) == {"a": 1, "b": 2} + assert dict(m2) == {"a": 1, "b": 2, "c": 3} + + assert m1.get("a") == 1 + assert m1.get("missing") is None + + +def test_hashtrieset(): + """HashTrieSet — same PyO3 surface but set semantics.""" + from rpds import HashTrieSet + + s = HashTrieSet().insert(1).insert(2).insert(3) + assert 2 in s + assert 99 not in s + assert len(s) == 3 + + # Removing yields a new set, original unchanged. + s2 = s.remove(2) + assert 2 in s + assert 2 not in s2 diff --git a/recipes/ruamel.yaml.clib/test_ruamel_yaml_clib.py b/recipes/ruamel.yaml.clib/test_ruamel_yaml_clib.py new file mode 100644 index 00000000..9f53129b --- /dev/null +++ b/recipes/ruamel.yaml.clib/test_ruamel_yaml_clib.py @@ -0,0 +1,30 @@ +"""ruamel.yaml.clib is the standalone C accelerator that ruamel.yaml +imports if present. The recipe ships only `_ruamel_yaml.so` — it does +NOT ship the `ruamel.yaml` Python namespace package itself (that's a +separate pure-Python package on pypi). Even importing `_ruamel_yaml` +directly fails on its own because its Cython init code references the +`ruamel.yaml` namespace. + +So the meaningful thing we can verify here is that the recipe actually +shipped the `.so` file at the location ruamel.yaml expects to find it. +End-to-end behavior is exercised when a downstream app installs both +ruamel.yaml.clib AND ruamel.yaml together.""" + +import importlib.util + + +def test_so_is_installed(): + """The C extension is named `_ruamel_yaml` and ships at the top + level of site-packages. `find_spec` does not import — it just + locates the file, which is exactly what we want. + + Suffix list covers every compiled-extension form CPython's import + machinery exposes across the platforms forge targets: + `.so`/`.pyd`/`.dylib` for plain dynamic libs (Linux, Windows, + Android, classic macOS) and `.fwork` for iOS' AppleFrameworkLoader + manifest (the real binary lives inside a sibling `.framework/`).""" + spec = importlib.util.find_spec("_ruamel_yaml") + assert spec is not None, "ruamel.yaml.clib didn't ship _ruamel_yaml.so" + assert spec.origin is not None and spec.origin.endswith( + (".so", ".pyd", ".dylib", ".fwork") + ), f"expected a compiled extension, got {spec.origin!r}" diff --git a/recipes/shapely/test_shapely.py b/recipes/shapely/test_shapely.py new file mode 100644 index 00000000..109bec6e --- /dev/null +++ b/recipes/shapely/test_shapely.py @@ -0,0 +1,91 @@ +def test_import_shapely(): + """`import shapely` triggers `_geos.so` + `lib.so` + `_geometry_helpers.so`, + and shapely 2.x does `import numpy` during package __init__. Whether the + libc++_shared gap fires depends on arch: on x86_64 numpy's + `_multiarray_umath.so` requires libcpp and bombs at dlopen; on arm64 + multiarray is fine but `_pocketfft_umath.so` (used by `np.fft.*`) still + needs libcpp. Either way the recipe's `flet-libcpp-shared` host dep is + defensive — surface it via Requires-Dist so libc++_shared.so is bundled + even when the only mobile dep is shapely.""" + import shapely + + assert hasattr(shapely, "Point") + assert hasattr(shapely, "points") # vectorized API + + +def test_numpy_fft(): + """libcpp_shared canary that fires on every Android arch via + `_pocketfft_umath.so` (DT_NEEDED=[libc++_shared.so] on both arm64 + AND x86_64). shapely doesn't reach into fft naturally, but this + surfaces the libcpp gap the recipe's defensive + `flet-libcpp-shared` host dep closes. Same canary added in blis's + test file.""" + import numpy as np + + x = np.cos(2 * np.pi * 2 * np.arange(8) / 8) + spectrum = np.fft.fft(x) + magnitudes = np.abs(spectrum) + assert magnitudes[2] > 3.9 + assert magnitudes[6] > 3.9 + + +def test_geometry_ops(): + """shapely wraps GEOS (the C++ computational-geometry library). Cover + geometry construction + a non-trivial spatial predicate.""" + from shapely.geometry import Point, Polygon + + triangle = Polygon([(0, 0), (4, 0), (0, 3)]) + assert abs(triangle.area - 6.0) < 1e-9 # ½ × base × height + + inside = Point(1, 1) + outside = Point(5, 5) + assert triangle.contains(inside) + assert not triangle.contains(outside) + + +def test_buffer_and_intersection(): + """Buffer + intersect exercises GEOS's harder operations.""" + from shapely.geometry import Point + + circle = Point(0, 0).buffer(1.0) + # `buffer(1)` approximates a unit circle; area ≈ π. + assert 3.0 < circle.area < 3.2 + + far = Point(10, 10).buffer(1.0) + assert circle.intersection(far).is_empty + + +def test_numpy_vectorized(): + """The shapely 2.x numpy bridge — the reason the recipe has `numpy` + as a host dep (and, transitively on Android, `flet-libcpp-shared`). + + Shapely's per-geometry API (Point/Polygon/.area) goes straight to + GEOS and never touches numpy. The vectorized `shapely.*` calls below + DO — they accept numpy arrays in and return numpy arrays out, with + shapely's `_lib`/`_geometry` C extensions handling the buffer-level + marshaling. If numpy ever fails to load on the device (e.g. + libc++_shared missing on Android) this test surfaces the gap + directly; the scalar tests above would still pass.""" + import numpy as np + import shapely + + # numpy → shapely: vectorized Point construction from coord arrays. + xs = np.array([0.0, 3.0, 0.0]) + ys = np.array([0.0, 0.0, 4.0]) + pts = shapely.points(xs, ys) + assert isinstance(pts, np.ndarray) + assert pts.shape == (3,) + + # shapely → numpy: vectorized .area over an object-dtype array of + # geometries. Triangle (0,0)-(3,0)-(0,4) has area = ½·3·4 = 6. + triangle = shapely.polygons([list(zip(xs, ys))]) + areas = shapely.area(triangle) + assert isinstance(areas, np.ndarray) + np.testing.assert_allclose(areas, [6.0], atol=1e-9) + + # shapely → numpy: extract coordinates as a 2-D array. Closing the + # ring adds the first vertex again, so we expect 4 rows × 2 cols. + coords = shapely.get_coordinates(triangle) + assert isinstance(coords, np.ndarray) + assert coords.shape == (4, 2) + np.testing.assert_array_equal(coords[0], coords[-1]) # ring closed diff --git a/recipes/sqlalchemy/test_sqlalchemy.py b/recipes/sqlalchemy/test_sqlalchemy.py new file mode 100644 index 00000000..a3c9985b --- /dev/null +++ b/recipes/sqlalchemy/test_sqlalchemy.py @@ -0,0 +1,38 @@ +def test_in_memory_sqlite_crud(): + """SQLAlchemy ships compiled C extensions for collection internals. + Drive a tiny end-to-end CRUD against in-memory SQLite to confirm the + ORM + Core both load.""" + from sqlalchemy import Column, Integer, String, create_engine, select + from sqlalchemy.orm import DeclarativeBase, Session + + class Base(DeclarativeBase): + pass + + class User(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True) + name = Column(String(50)) + + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + + with Session(engine) as session: + session.add(User(id=1, name="Ada")) + session.add(User(id=2, name="Grace")) + session.commit() + + names = session.execute(select(User.name).order_by(User.id)).scalars().all() + assert names == ["Ada", "Grace"] + + +def test_dialect_compile(): + """Compiling a SQL expression hits the C-accelerated visitor paths.""" + from sqlalchemy import Integer, column, select, table + + t = table("things", column("a", Integer), column("b", Integer)) + stmt = select(t.c.a, t.c.b).where(t.c.a > 5) + compiled = stmt.compile() + sql = str(compiled).lower() + assert "select" in sql + assert "things" in sql + assert "where" in sql diff --git a/recipes/tiktoken/test_tiktoken.py b/recipes/tiktoken/test_tiktoken.py new file mode 100644 index 00000000..7f9d8fa5 --- /dev/null +++ b/recipes/tiktoken/test_tiktoken.py @@ -0,0 +1,21 @@ +def test_basic_encoding(): + """tiktoken is OpenAI's tokenizer (PyO3 wrapper around a Rust BPE). + Use the simple gpt2 encoding which is bundled (no network).""" + import tiktoken + + enc = tiktoken.get_encoding("gpt2") + ids = enc.encode("hello world") + assert isinstance(ids, list) + assert len(ids) > 0 + assert enc.decode(ids) == "hello world" + + +def test_encoding_name(): + """Confirm a well-known encoding is registered — protects against a + shipping wheel that lost its encoding registry.""" + import tiktoken + + # cl100k_base is GPT-4's tokenizer; if it's not registered the recipe + # didn't bundle the data files correctly. + enc = tiktoken.get_encoding("cl100k_base") + assert enc.name == "cl100k_base" diff --git a/recipes/time-machine/test_time_machine.py b/recipes/time-machine/test_time_machine.py new file mode 100644 index 00000000..9e62b985 --- /dev/null +++ b/recipes/time-machine/test_time_machine.py @@ -0,0 +1,17 @@ +def test_basic(): + """time-machine's `travel()` is implemented as a C extension — it + patches `time.time()`, `datetime.now()`, etc. at the CPython level.""" + import datetime + + import time_machine + + with time_machine.travel("2020-04-12 12:00:00+00:00", tick=False): + now = datetime.datetime.now(datetime.timezone.utc) + assert now.year == 2020 + assert now.month == 4 + assert now.day == 12 + assert now.hour == 12 + + # Outside the `with`, time is back to the real wall clock. + real_year = datetime.datetime.now().year + assert real_year != 2020 or datetime.datetime.now().day != 12 diff --git a/recipes/tokenizers/meta.yaml b/recipes/tokenizers/meta.yaml index 2562faab..15a7f9c6 100644 --- a/recipes/tokenizers/meta.yaml +++ b/recipes/tokenizers/meta.yaml @@ -2,7 +2,17 @@ package: name: tokenizers version: 0.21.0 +# {% if sdk == 'android' %} +requirements: + host: + # tokenizers' Rust core links libstdc++, which on Android is + # libc++_shared.so. Without this dep the wheel loads with + # `dlopen failed: library "libc++_shared.so" not found` + # (mirrors the numpy + blis Android wiring). + - flet-libcpp-shared >=27.2.12479018 +# {% endif %} + build: - number: 4 + number: 5 script_env: _PYTHON_SYSCONFIGDATA_NAME: '{sysconfigdata_name}' \ No newline at end of file diff --git a/recipes/tokenizers/test_tokenizers.py b/recipes/tokenizers/test_tokenizers.py new file mode 100644 index 00000000..08f852cf --- /dev/null +++ b/recipes/tokenizers/test_tokenizers.py @@ -0,0 +1,37 @@ +def test_import_tokenizers(): + """Forces `tokenizers.abi3.so` to load. On Android this is the + libc++_shared.so canary — tokenizers' Rust core links libstdc++, so + the .so has DT_NEEDED=[libc++_shared.so]. If `flet-libcpp-shared` + isn't in the wheel's Requires-Dist, libc++_shared.so won't be in + jniLibs// and import fails with + `dlopen failed: library "libc++_shared.so" not found`.""" + import tokenizers + + assert hasattr(tokenizers, "Tokenizer") + + +def test_byte_level_bpe_roundtrip(): + """Hugging Face `tokenizers` is a PyO3 wrapper around a Rust core. + Train + tokenize + detokenize without any pretrained model — keeps + the test offline.""" + from tokenizers import Tokenizer + from tokenizers.models import BPE + from tokenizers.pre_tokenizers import Whitespace + from tokenizers.trainers import BpeTrainer + + tok = Tokenizer(BPE(unk_token="[UNK]")) + tok.pre_tokenizer = Whitespace() + trainer = BpeTrainer(vocab_size=80, special_tokens=["[UNK]"]) + + # Train on a tiny in-memory corpus. + tok.train_from_iterator( + ["hello mobile forge", "hello world", "forge ahead"] * 5, + trainer=trainer, + ) + + encoded = tok.encode("hello forge") + assert len(encoded.ids) > 0 + decoded = tok.decode(encoded.ids) + # Round-trip preserves the words (whitespace handling is lossy). + assert "hello" in decoded + assert "forge" in decoded diff --git a/recipes/websockets/test_websockets.py b/recipes/websockets/test_websockets.py new file mode 100644 index 00000000..80b968f1 --- /dev/null +++ b/recipes/websockets/test_websockets.py @@ -0,0 +1,23 @@ +def test_frame_mask_roundtrip(): + """websockets' frame masking is XOR with a 4-byte key. The library + exposes it via the public Frame API; if the optional C accelerator + (`websockets.speedups`) was built it gets used transparently, + otherwise the pure-Python fallback kicks in. Either way, masking + twice with the same key restores the original payload — that's the + test we actually care about.""" + from websockets.frames import apply_mask + + payload = b"the quick brown fox jumps over the lazy dog" + mask = b"\x12\x34\x56\x78" + masked = apply_mask(payload, mask) + assert masked != payload + assert apply_mask(masked, mask) == payload + + +def test_import_api(): + """Public top-level symbols are wired up — protects against a recipe + that ships an extension but breaks the pure-Python API.""" + import websockets + + assert hasattr(websockets, "connect") + assert hasattr(websockets, "serve") diff --git a/recipes/zope.interface/test_zope_interface.py b/recipes/zope.interface/test_zope_interface.py new file mode 100644 index 00000000..f144f3e6 --- /dev/null +++ b/recipes/zope.interface/test_zope_interface.py @@ -0,0 +1,26 @@ +def test_basic(): + """zope.interface ships a C accelerator (`_zope_interface_coptimizations`). + Define an interface, declare a provider, verify membership — that touches + the C path on both `directlyProvides` and `verifyObject`.""" + from zope.interface import Interface, implementer + from zope.interface.verify import verifyObject + + class IGreeter(Interface): + def greet(name): + """Return a greeting.""" + + @implementer(IGreeter) + class Greeter: + def greet(self, name): + return f"hi, {name}" + + g = Greeter() + assert IGreeter.providedBy(g) + assert verifyObject(IGreeter, g) + assert g.greet("world") == "hi, world" + + +def test_speedups_present(): + """Sanity: the recipe's whole purpose is to ship the C extension, so + verify it's importable.""" + from zope.interface import _zope_interface_coptimizations # noqa: F401 diff --git a/recipes/zstandard/test_zstandard.py b/recipes/zstandard/test_zstandard.py new file mode 100644 index 00000000..ac57e533 --- /dev/null +++ b/recipes/zstandard/test_zstandard.py @@ -0,0 +1,30 @@ +def test_compress_roundtrip(): + """zstandard wraps Facebook's libzstd C library. Round-trip a payload + big enough to actually compress.""" + import zstandard + + plain = b"the quick brown fox jumps over the lazy dog " * 50 + cctx = zstandard.ZstdCompressor(level=10) + compressed = cctx.compress(plain) + assert len(compressed) < len(plain) + + dctx = zstandard.ZstdDecompressor() + assert dctx.decompress(compressed) == plain + + +def test_streaming(): + """Streaming API exercises a different C path (writer + reader).""" + import io + + import zstandard + + plain = b"hello world\n" * 100 + sink = io.BytesIO() + with zstandard.ZstdCompressor().stream_writer(sink, closefd=False) as writer: + writer.write(plain) + compressed = sink.getvalue() + + decompressed = ( + zstandard.ZstdDecompressor().stream_reader(io.BytesIO(compressed)).read() + ) + assert decompressed == plain diff --git a/src/forge/build.py b/src/forge/build.py index ef7da5c5..1d12bf73 100644 --- a/src/forge/build.py +++ b/src/forge/build.py @@ -649,21 +649,11 @@ def fix_wheel(self, wheel_dir: Path): # Normalize wheel tags to forge platform tags so repacked wheels use # android_24_arm64_v8a / ios_13_0_arm64_iphoneos style platform tags. - # Preserve the Python/ABI part the upstream build wrote (e.g. maturin - # emits `cp37-abi3-*` for cryptography); only the platform component - # is swapped. Falls back to self.wheel_tag when no Tag was written. wheel_metadata_path = next(wheel_dir.glob("*.dist-info")) / "WHEEL" wheel_metadata = self.read_message_file(wheel_metadata_path) - upstream_tags = wheel_metadata.get_all("Tag", []) - del wheel_metadata["Tag"] - new_tags = [] - for tag in upstream_tags: - py, abi, _platform = tag.rsplit("-", 2) - new_tags.append(f"{py}-{abi}-{self.cross_venv.tag}") - if not new_tags: - new_tags = [self.wheel_tag] - for tag in new_tags: - wheel_metadata["Tag"] = tag + if "Tag" in wheel_metadata: + del wheel_metadata["Tag"] + wheel_metadata["Tag"] = self.wheel_tag self.write_message_file(wheel_metadata_path, wheel_metadata) if self.cross_venv.sdk == "android": diff --git a/src/forge/cross.py b/src/forge/cross.py index 33a96340..79a270ff 100644 --- a/src/forge/cross.py +++ b/src/forge/cross.py @@ -417,7 +417,9 @@ def verify(self): def cross_kwargs(self, kwargs): venv_kwargs = kwargs.copy() - env = venv_kwargs.get("env", {}) + env = venv_kwargs.get("env") + if env is None: + env = os.environ.copy() # Ensure the path is clean, and doesn't include any non-iOS paths. env["PATH"] = os.pathsep.join( @@ -440,10 +442,7 @@ def cross_kwargs(self, kwargs): env["VIRTUAL_ENV"] = str(self.venv_path / self.venv_path.name) # Remove PYTHONHOME if it's set - try: - del env["PYTHONHOME"] - except KeyError: - pass + env.pop("PYTHONHOME", None) venv_kwargs["env"] = env return venv_kwargs @@ -465,7 +464,8 @@ def run(self, logfile, *args, **kwargs): * Remove the ``PYTHONHOME`` environment variable, if it exists. If ``env`` is passed in as a keyword argument, the values in that environment - will be augmented by the virtualenv changes. + will be augmented by the virtualenv changes. If ``env`` is not passed, the + parent process's ``os.environ`` is used as the baseline. For auditing purposes, the final kwargs used at runtime will be output to the console. diff --git a/src/forge/schema/meta-schema.yaml b/src/forge/schema/meta-schema.yaml index 91d0f80b..435cd5eb 100644 --- a/src/forge/schema/meta-schema.yaml +++ b/src/forge/schema/meta-schema.yaml @@ -9,25 +9,40 @@ properties: type: object required: [name, version] properties: - name: # Must be in its original form, as used in sdist filenames. + name: type: string + description: >- + Package name. Must be in its original form, as used in sdist filenames. version: type: [string, number] + platforms: + type: array + items: + type: string + enum: [android, ios] + minItems: 1 + uniqueItems: true + description: >- + Optional list of supported target platforms. + Defaults to all platforms if not specified. additionalProperties: false source: default: pypi oneOf: - - type: "null" # The build script will get its own source. - - type: string # Download an sdist from PyPI. + - type: "null" + description: The build script will get its own source. + - type: string const: pypi - - type: object # Download an archive from a URL. + description: Download an sdist from PyPI. + - type: object required: [url] properties: url: type: string additionalProperties: false - - type: object # Clone a Git repository. + description: Download an archive from a URL. + - type: object required: [git_url, git_rev] properties: git_url: @@ -35,31 +50,37 @@ properties: git_rev: type: [string, number] additionalProperties: false - - type: object # Copy a local directory. + description: Clone a Git repository. + - type: object required: [path] properties: path: type: string additionalProperties: false + description: Copy a local directory. - # Patches to apply to the code. Each entry is a filename in the `patches` folder - # of the recipe. patches: type: array default: [] items: type: string + description: >- + Patches to apply to the code. Each entry is a filename in the + `patches` folder of the recipe. build: type: object default: {} properties: - number: # Used as the wheel build tag. + number: type: integer default: 0 - script_env: # Environment variables in the form KEY=value (no spaces around =). + description: Used as the wheel build tag. + script_env: type: object default: {} + description: >- + Environment variables in the form KEY=value (no spaces around =). additionalProperties: true requirements: @@ -67,34 +88,38 @@ properties: default: {} properties: - # Requirements which must be installed in the build environment. One of the following: - # - # * ` `: A Python package. - # * `cmake`: indicates that CMake is used in the build. A `chaquopy.toolchain.cmake` file - # will be generated in the build directory for use with `-DCMAKE_TOOLCHAIN_FILE`. build: type: array default: [] items: type: string + description: |- + Requirements which must be installed in the build environment. + One of the following: + * ` `: A Python package. + * `cmake`: indicates that CMake is used in the build. A + `chaquopy.toolchain.cmake` file will be generated in the + build directory for use with `-DCMAKE_TOOLCHAIN_FILE`. - # Requirements which must be available at runtime. One of the following: - # - # * ` `: a native Python package. A compatible wheel file must exist in - # pypi/dist, and will be extracted into $SRC_DIR/../requirements before the build is - # run. A requirement specification for >= this version will also be added to the final - # wheel. - # - # * `python`: indicates that this is a Python package. This is implied if `source` is - # `pypi` or unspecified. Python includes and libraries will be added to the CFLAGS and - # LDFLAGS, and the wheel build tag will be set accordingly. - # - # * `openssl` / `sqlite`: the corresponding library will be added to CFLAGS and LDFLAGS. host: type: array default: [] items: type: string + description: |- + Requirements which must be available at runtime. One of the + following: + * ` `: a native Python package. A + compatible wheel file must exist in pypi/dist, and will be + extracted into $SRC_DIR/../requirements before the build + is run. A requirement specification for >= this version + will also be added to the final wheel. + * `python`: indicates that this is a Python package. This is + implied if `source` is `pypi` or unspecified. Python + includes and libraries will be added to the CFLAGS and + LDFLAGS, and the wheel build tag will be set accordingly. + * `openssl` / `sqlite`: the corresponding library will be + added to CFLAGS and LDFLAGS. additionalProperties: false @@ -103,13 +128,15 @@ properties: default: {} properties: - # Filename, relative to the source directory, to add to the wheel's .dist-info - # directory. build-wheel will automatically include any file in the source or recipe - # directory whose name starts with "LICEN[CS]E" or "COPYING", case-insensitive. If there - # is no such file, then this setting is required. license_file: type: string default: "" + description: >- + Filename, relative to the source directory, to add to the + wheel's .dist-info directory. build-wheel will automatically + include any file in the source or recipe directory whose name + starts with "LICEN[CS]E" or "COPYING", case-insensitive. If + there is no such file, then this setting is required. additionalProperties: false diff --git a/tests/recipe-tester/.gitignore b/tests/recipe-tester/.gitignore new file mode 100644 index 00000000..fbe8fb5c --- /dev/null +++ b/tests/recipe-tester/.gitignore @@ -0,0 +1,13 @@ +# Generated by stage_recipe.sh — never committed (varies per recipe under test). +pyproject.toml + +# Generated by stage_recipe.sh — copies of recipes//test_*.py. +recipe_tests/ + +# Tooling output. +.venv/ +build/ +uv.lock +*.egg-info/ +__pycache__/ +.pytest_cache/ diff --git a/tests/recipe-tester/README.md b/tests/recipe-tester/README.md new file mode 100644 index 00000000..2847e420 --- /dev/null +++ b/tests/recipe-tester/README.md @@ -0,0 +1,89 @@ +# recipe-tester + +A generic Flet app that runs a recipe's pytest tests on a mobile +device/emulator/simulator and emits an EXIT sentinel to `console.log` for +the CI host to pick up. + +This is the *runner*; the *tests* live in each recipe's `test_.py` +(or `test/test_.py` for recipes with assets). At build time, +[`stage_recipe.sh`](./stage_recipe.sh) copies the recipe's test files into +`./recipe_tests/` and writes a `pyproject.toml` (from the `.tpl` template) +pinning the recipe under test. The same script is used by CI and local devs +— one staging mechanism, one source of truth. + +## Local quick-start + +You'll need: + +- A wheel for the recipe in `../../dist/` (build it with + `forge ` if needed) +- A running Android emulator (`emulator -avd `) and/or booted iOS + Simulator +- `uv` installed + +```bash +# From the repo root: +./tests/recipe-tester/stage_recipe.sh numpy 2.2.2 +cd tests/recipe-tester + +# Android +PIP_FIND_LINKS="$(realpath ../../dist)" \ + uvx --with flet-cli flet build apk --arch arm64-v8a --yes +adb install -r build/apk/recipe-tester.apk +adb shell monkey -p com.flet.recipe_tester -c android.intent.category.LAUNCHER 1 + +# Wait ~30s, then pull console.log: +# (`adb root` works on emulator userdebug AVDs) +adb root +adb pull /data/data/com.flet.recipe_tester/cache/console.log /tmp/console.log +grep '>>>>>>>>>> EXIT' /tmp/console.log +``` + +```bash +# iOS Simulator (host fs reads the app data directly — no pull) +PIP_FIND_LINKS="$(realpath ../../dist)" \ + uvx --with flet-cli flet build ios-simulator --yes +xcrun simctl install booted build/ios-simulator/recipe-tester.app +xcrun simctl launch booted com.flet.recipe-tester + +# Wait ~30s, then read console.log: +DATA=$(xcrun simctl get_app_container booted com.flet.recipe-tester data) +grep '>>>>>>>>>> EXIT' "$DATA/Library/Caches/console.log" +``` + +> `uvx --with flet-cli` runs `flet build` from an ephemeral env containing +> just flet-cli + its transitives. No project venv to set up, and the +> recipe-under-test doesn't need to be installable on the host — it gets +> bundled into the APK / .app directly from `dist/` via `PIP_FIND_LINKS`. + +## Layout + +``` +tests/recipe-tester/ +├── main.py # The Flet app — runs pytest, emits EXIT sentinel +├── pyproject.toml.tpl # Template with placeholder (committed) +├── pyproject.toml # Generated by stage_recipe.sh (gitignored) +├── stage_recipe.sh # Stages one recipe's tests + generates pyproject +├── recipe_tests/ # Generated by stage_recipe.sh (gitignored) +├── .gitignore +└── README.md +``` + +## Switching to a different recipe + +`stage_recipe.sh` wipes `recipe_tests/` and rewrites `pyproject.toml` on +every run — switching recipes is one command: + +```bash +./tests/recipe-tester/stage_recipe.sh pillow +``` + +Then re-run `uvx --with flet-cli flet build …`. + +## Files NOT generated by staging + +- `main.py`, `pyproject.toml.tpl`, `stage_recipe.sh`, `.gitignore`, + `README.md` — these are committed +- Everything in `.gitignore` (notably `pyproject.toml`, `recipe_tests/`, + `build/`, `.venv/`, `uv.lock`) — these vary per recipe and are + regenerated each time diff --git a/tests/recipe-tester/main.py b/tests/recipe-tester/main.py new file mode 100644 index 00000000..659511ff --- /dev/null +++ b/tests/recipe-tester/main.py @@ -0,0 +1,94 @@ +"""Generic recipe-tester app — runs bundled pytest tests and emits the EXIT +sentinel to console.log (which Flet redirects via $FLET_APP_CONSOLE). + +How this works on a CI runner: + 1. `stage_recipe.sh ` copies `recipes//test_*.py` (or the + `recipes//test/` dir with assets) into `./recipe_tests/`, and + generates `pyproject.toml` from `pyproject.toml.tpl` with the recipe + pinned as a dependency. + 2. `flet build apk` / `flet build ios-simulator` bundles this app + the + staged tests + the recipe wheel into a deployable. + 3. The CI installs and launches the app on an emulator/simulator. The + `_run_pytest()` thread runs pytest, then prints the EXIT sentinel + to stdout. Flet's launcher has rebound `sys.stdout`/`sys.stderr` to a + line-buffered file at $FLET_APP_CONSOLE, so the sentinel and any + pytest output land in that file within ~1ms of being written. + 4. The host pulls `console.log` (Android: `adb pull` from the app cache + dir; iOS sim: read directly from `xcrun simctl get_app_container`), + greps for `>>>>>>>>>> EXIT N <<<<<<<<<<`, sets N as the job's exit code. + +Local dev usage: + cd tests/recipe-tester + ./stage_recipe.sh [] + PIP_FIND_LINKS=$(pwd)/../../dist uvx --with flet-cli flet build apk --arch arm64-v8a --yes + adb install -r build/apk/recipe-tester.apk + adb shell monkey -p com.flet.recipe_tester -c android.intent.category.LAUNCHER 1 +""" + +import threading + +import flet as ft + +# Module-level state the GUI thread inspects to render "done" once pytest exits. +EXIT_CODE: int | None = None +DONE = False + + +def _run_pytest() -> None: + """Run bundled tests in a background thread; emit EXIT sentinel. + + Runs OFF the GUI thread so Flet's event loop keeps turning and the + line-buffered `console.log` writes flush within ~1ms. If we ran pytest + synchronously in `main()`, the event loop wouldn't yield until after + pytest returned, and the sentinel might sit in a Python-level buffer + until the next loop iteration. + """ + global EXIT_CODE, DONE + import pytest + + EXIT_CODE = pytest.main( + [ + "-v", + "--rootdir", + "recipe_tests", # don't walk the bundled stdlib zip looking for conftest.py + "-p", + "no:cacheprovider", # don't try to write .pytest_cache/ on a potentially read-only mobile FS + "--capture=no", # let test prints reach console.log too (default pytest capture hides stdout) + "--no-header", + "--tb=short", # compact output for console.log + "recipe_tests/", + ] + ) + + # Single emit is enough: stdout is rebound to $FLET_APP_CONSOLE opened + # with `buffering=1`, so the line is in the kernel page cache as soon + # as `print(..., flush=True)` returns; the host's wait_for_console.sh + # polls the file every 2s and grep-matches on a complete line. The + # 6×0.5s loop this replaces was a cargo-culted Toga pattern that + # defended against logcat/NSLog stream buffering — neither of which + # is in our IO path. + print(f">>>>>>>>>> EXIT {EXIT_CODE} <<<<<<<<<<", flush=True) + DONE = True + + +def main(page: ft.Page) -> None: + page.appbar = ft.AppBar(title=ft.Text("Mobile-Forge Recipe Tester")) + page.add( + ft.Text( + "Running pytest on bundled recipe tests…", + size=14, + weight=ft.FontWeight.BOLD, + ), + ft.Text( + "This screen is informational only. CI reads console.log directly; " + "the GUI is just the substrate Flet needs to keep the event loop alive.", + size=11, + color=ft.Colors.GREY, + ), + ) + + # Run pytest in a background thread + threading.Thread(target=_run_pytest, daemon=True).start() + + +ft.run(main) diff --git a/tests/recipe-tester/pyproject.toml.tpl b/tests/recipe-tester/pyproject.toml.tpl new file mode 100644 index 00000000..64aa9251 --- /dev/null +++ b/tests/recipe-tester/pyproject.toml.tpl @@ -0,0 +1,23 @@ +[project] +name = "recipe-tester" +version = "0.1.0" +description = "Generic in-app pytest runner for mobile-forge recipe wheels." +requires-python = ">=3.10" + +dependencies = [ + "flet", + "pytest", + # `stage_recipe.sh` rewrites the line below to pin the recipe under test (e.g. `"numpy==2.2.2"`). + "__RECIPE_DEP__", +] + +[dependency-groups] +dev = [ + "flet[all]", +] + +[tool.flet] +artifact = "recipe-tester" + +[tool.flet.app] +path = "." diff --git a/tests/recipe-tester/stage_recipe.sh b/tests/recipe-tester/stage_recipe.sh new file mode 100755 index 00000000..3eb1c73a --- /dev/null +++ b/tests/recipe-tester/stage_recipe.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Stage a recipe's test file(s) into this app for build/run. +# +# Used by: +# - .github/workflows/build-wheels.yml (per-job, before `flet build`) +# - local dev (run before `uv sync && flet build`) +# +# Usage: +# ./stage_recipe.sh [] +# +# Examples: +# ./stage_recipe.sh numpy 2.2.2 +# ./stage_recipe.sh pillow # no version pin +# +# Effects (idempotent): +# - (re)creates ./recipe_tests/ with the recipe's pytest files +# - generates pyproject.toml from pyproject.toml.tpl with the +# __RECIPE_DEP__ token replaced by "[==]" + +set -euo pipefail + +RECIPE="${1:?usage: $0 []}" +VERSION="${2:-}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +RECIPE_DIR="$REPO_ROOT/recipes/$RECIPE" +TEST_DIR="$SCRIPT_DIR/recipe_tests" + +if [ ! -d "$RECIPE_DIR" ]; then + echo "::error::Recipe not found: $RECIPE_DIR" >&2 + exit 1 +fi + +# 1. Stage the test file(s) into recipe_tests/. Wipe first so we don't carry +# leftover files from a previous recipe. +rm -rf "$TEST_DIR" +mkdir -p "$TEST_DIR" + +# Look for test files at known locations. +if [ -d "$RECIPE_DIR/tests" ]; then + cp -r "$RECIPE_DIR/tests/." "$TEST_DIR/" +elif [ -d "$RECIPE_DIR/test" ]; then + cp -r "$RECIPE_DIR/test/." "$TEST_DIR/" +elif compgen -G "$RECIPE_DIR/test_*.py" > /dev/null; then + cp "$RECIPE_DIR"/test_*.py "$TEST_DIR/" +else + echo "::error::No test file(s) found at $RECIPE_DIR/tests/, $RECIPE_DIR/test/ or $RECIPE_DIR/test_*.py" >&2 + exit 1 +fi + +# 2. Substitute the __RECIPE_DEP__ token in the pyproject template and write +# a fresh pyproject.toml (which is gitignored). +DEP="$RECIPE" +[ -n "$VERSION" ] && DEP="$RECIPE==$VERSION" + +# Use a temp file + mv so the substitution is sed-portability-friendly +# (BSD sed and GNU sed differ on -i quoting). +TPL="$SCRIPT_DIR/pyproject.toml.tpl" +OUT="$SCRIPT_DIR/pyproject.toml" +sed "s|__RECIPE_DEP__|$DEP|" "$TPL" > "$OUT" + +echo "Staged recipe '$RECIPE' (dep: $DEP)" +echo " recipe_tests/:" +ls -1 "$TEST_DIR" | sed 's/^/ /' +echo " pyproject.toml: generated (gitignored)" +echo "" +echo "Next:" +echo " cd $(realpath --relative-to="$PWD" "$SCRIPT_DIR" 2>/dev/null || echo "$SCRIPT_DIR")" +echo " PIP_FIND_LINKS=\"\$(realpath ../../dist)\" uvx --with flet-cli flet build apk --arch arm64-v8a --yes"