diff --git a/.github/workflows/build-wheels-version.yml b/.github/workflows/build-wheels-version.yml index 7dbd4842..6c4da06c 100644 --- a/.github/workflows/build-wheels-version.yml +++ b/.github/workflows/build-wheels-version.yml @@ -133,7 +133,7 @@ permissions: jobs: setup: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} has_jobs: ${{ steps.set-matrix.outputs.has_jobs }} @@ -180,7 +180,7 @@ jobs: for pkg in $build_packages; do pkg_name="${pkg%%:*}" if [[ "$arch" == "android" ]]; then - runner="ubuntu-latest" + runner="ubuntu-24.04" platform="android" rust_targets="aarch64-linux-android,arm-linux-androideabi,x86_64-linux-android" else @@ -495,10 +495,11 @@ jobs: for w in "$GITHUB_WORKSPACE"/dist/*.whl; do [[ -e "$w" ]] || continue base=$(basename "$w") - # name-version[-buildtag]-cpXY-cpXY-plat.whl - # → name-version-9999-cpXY-cpXY-plat.whl + # name-version[-buildtag]-{cpXY-cpXY|cpXY-abi3|py3-none}-plat.whl + # → name-version-9999-...-plat.whl. py3-none covers the build.sh + # flet-lib* wheels a prebuild_recipes chain puts here. new=$(printf '%s\n' "$base" \ - | sed -E 's/^([^-]+-[^-]+)(-[0-9]+)?-(cp[0-9]+-cp[0-9]+)-/\1-9999-\3-/') + | sed -E 's/^([^-]+-[^-]+)(-[0-9]+)?-((cp[0-9]+|py3)-(cp[0-9]+|abi[0-9]+|none))-/\1-9999-\3-/') cp "$w" "$GITHUB_WORKSPACE/dist-test/$new" done @@ -562,7 +563,7 @@ jobs: [[ -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-/') + | sed -E 's/^([^-]+-[^-]+)(-[0-9]+)?-((cp[0-9]+|py3)-(cp[0-9]+|abi[0-9]+|none))-/\1-9999-\3-/') cp "$w" "$GITHUB_WORKSPACE/dist-test/$new" done diff --git a/.github/workflows/build-wheels.yml b/.github/workflows/build-wheels.yml index f8345833..23df609e 100644 --- a/.github/workflows/build-wheels.yml +++ b/.github/workflows/build-wheels.yml @@ -111,7 +111,7 @@ permissions: jobs: detect: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 outputs: pythons_json: ${{ steps.detect-pythons.outputs.pythons_json }} canonical_python: ${{ steps.detect-pythons.outputs.canonical_python }} diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index e9ba32c0..c8ffd438 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -9,7 +9,7 @@ permissions: {} jobs: zizmor: name: Run zizmor - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: security-events: write contents: read diff --git a/recipes/cffi/meta.yaml b/recipes/cffi/meta.yaml index 92269e12..09714175 100644 --- a/recipes/cffi/meta.yaml +++ b/recipes/cffi/meta.yaml @@ -1,6 +1,6 @@ package: name: cffi - version: "2.0.0" + version: "2.1.1" build: number: 1 @@ -10,4 +10,5 @@ requirements: - libffi patches: - - mobile.patch \ No newline at end of file + - mobile.patch + - ios-ffi-closure-alloc.patch diff --git a/recipes/cffi/patches/ios-ffi-closure-alloc.patch b/recipes/cffi/patches/ios-ffi-closure-alloc.patch new file mode 100644 index 00000000..dc9dfa31 --- /dev/null +++ b/recipes/cffi/patches/ios-ffi-closure-alloc.patch @@ -0,0 +1,54 @@ +Make ffi.callback() and variadic calls work on iOS (python-cffi/cffi#265). + +cffi uses libffi's ffi_closure_alloc()/ffi_prep_closure_loc()/ffi_prep_cif_var() +on Apple only when ffi.h defines FFI_AVAILABLE_APPLE, which only the macOS SDK's +ffi.h does. iOS links the support tree's upstream libffi, so every +ffi.callback() fell back to malloc_closure.h's mmap(PROT_WRITE|PROT_EXEC), +which iOS refuses ("MemoryError: Cannot allocate write+execute memory"), and +variadic calls went through ffi_prep_cif(), which passes garbage on arm64, +where Apple puts varargs on the stack. The fallback's ffi_prep_closure() is +unusable on arm64 even with W+X allowed: that libffi uses trampoline tables. + +Same defines as upstream #265 (90e8cc434a, merged 2026-07-26, in no release +as of 2.1.1), narrowed to TARGET_OS_IPHONE rather than upstream's hunk +verbatim, because forge applies patches with a non-interactive `patch -p1`: +on a release that already carries #265 the verbatim hunk is taken as +"previously applied", answered "Assume -R? [y]", and silently REVERTED with +exit 0 (measured on the 2.1.1 tree with #265 applied). This variant lands +there as a redundant, dead branch instead. Android is not __APPLE__ and forge +builds no macOS, so behaviour on every forge target equals upstream's. Drop +this patch on a cffi release that contains #265. + +--- a/src/c/_cffi_backend.c ++++ b/src/c/_cffi_backend.c +@@ -99,6 +99,10 @@ + * This is also used on macOS, provided we are executing on macOS 10.15 or + * above. It's a mess because it needs runtime checks in that case. + */ ++#ifdef __APPLE__ ++# include ++#endif ++ + #ifdef __NetBSD__ + + # define CFFI_CHECK_FFI_CLOSURE_ALLOC 1 +@@ -117,6 +121,19 @@ + # define CFFI_CHECK_FFI_PREP_CIF_VAR __builtin_available(macos 10.15, ios 13, watchos 6, tvos 13, *) + # define CFFI_CHECK_FFI_PREP_CIF_VAR_MAYBE 1 + ++#elif defined(__APPLE__) && TARGET_OS_IPHONE ++ ++/* iOS links upstream libffi, whose ffi.h lacks FFI_AVAILABLE_APPLE (only the ++ * macOS SDK's has it). Falling to malloc_closure.h means a W+X mmap iOS ++ * refuses, and ffi_prep_cif() for varargs, which arm64 Apple passes on the ++ * stack. The static libffi always has all three. python-cffi/cffi#265. */ ++# define CFFI_CHECK_FFI_CLOSURE_ALLOC 1 ++# define CFFI_CHECK_FFI_CLOSURE_ALLOC_MAYBE 1 ++# define CFFI_CHECK_FFI_PREP_CLOSURE_LOC 1 ++# define CFFI_CHECK_FFI_PREP_CLOSURE_LOC_MAYBE 1 ++# define CFFI_CHECK_FFI_PREP_CIF_VAR 1 ++# define CFFI_CHECK_FFI_PREP_CIF_VAR_MAYBE 1 ++ + #elif defined(__EMSCRIPTEN__) + + # define CFFI_CHECK_FFI_CLOSURE_ALLOC 1 diff --git a/recipes/cffi/patches/mobile.patch b/recipes/cffi/patches/mobile.patch index b55f9d93..fa3144a6 100644 --- a/recipes/cffi/patches/mobile.patch +++ b/recipes/cffi/patches/mobile.patch @@ -1,8 +1,19 @@ -diff --git a/setup.py b/setup.py -index f9cabaa..c592203 100644 +Cross-compile cffi against the target's libffi, not the build machine's. + +setup.py: drop the /usr/include/ffi and /usr/include/libffi defaults and the +pkg-config probe, which would point a cross build at the host's libffi +headers; forge's cross environment supplies the target paths. + +malloc_closure.h: guard the Linux-only PaX getline() scan by +__ANDROID_API__ >= 18, as Chaquopy does (bionic gained getline() at API 18). +A no-op at forge's API 24. + +Regenerated against 2.1.1, where the setup.py hunk moved by 6 lines. + +diff -ruN a/setup.py b/setup.py --- a/setup.py +++ b/setup.py -@@ -9,8 +9,7 @@ import setuptools +@@ -9,8 +9,7 @@ sources = ['src/c/_cffi_backend.c'] libraries = ['ffi'] @@ -12,7 +23,7 @@ index f9cabaa..c592203 100644 define_macros = [('FFI_BUILDING', '1')] # for linking with libffi static library library_dirs = [] extra_compile_args = [] -@@ -144,7 +143,7 @@ if sys.platform == "win32" and uses_msvc(): +@@ -150,7 +149,7 @@ sources.extend(os.path.join(COMPILE_LIBFFI, filename) for filename in _filenames) else: @@ -21,11 +32,10 @@ index f9cabaa..c592203 100644 ask_supports_thread() ask_supports_sync_synchronize() -diff --git a/src/c/malloc_closure.h b/src/c/malloc_closure.h -index bebb93d..f82dbe4 100644 +diff -ruN a/src/c/malloc_closure.h b/src/c/malloc_closure.h --- a/src/c/malloc_closure.h +++ b/src/c/malloc_closure.h -@@ -42,6 +42,8 @@ emutramp_enabled_check (void) +@@ -42,6 +42,8 @@ return 0; ret = 0; @@ -34,7 +44,7 @@ index bebb93d..f82dbe4 100644 while (getline (&buf, &len, f) != -1) if (!strncmp (buf, "PaX:", 4)) { -@@ -51,6 +53,8 @@ emutramp_enabled_check (void) +@@ -51,6 +53,8 @@ break; } free (buf); @@ -42,4 +52,4 @@ index bebb93d..f82dbe4 100644 + fclose (f); return ret; - } \ No newline at end of file + } diff --git a/recipes/cffi/tests/test_cffi.py b/recipes/cffi/tests/test_cffi.py index 51a80945..3c00a817 100644 --- a/recipes/cffi/tests/test_cffi.py +++ b/recipes/cffi/tests/test_cffi.py @@ -1,7 +1,101 @@ -def test_basic(): - from cffi import FFI +import gc +import sys +import threading +from typing import Any +from cffi import FFI + +CDEF = """ + size_t strlen(char *str); + void qsort(void *base, size_t nmemb, size_t size, + int (*compar)(const void *, const void *)); + typedef unsigned long pthread_t; + int pthread_create(pthread_t *thread, void *attr, + void *(*start)(void *), void *arg); + int pthread_join(pthread_t thread, void **retval); + int snprintf(char *str, size_t size, const char *format, ...); +""" + + +def _libc() -> tuple[FFI, Any]: + """ABI-mode handle on the C library: bionic's public libc.so on Android, the + process's own symbols (libSystem) on iOS.""" ffi = FFI() - ffi.cdef("size_t strlen(char *str);") - lib = ffi.dlopen(None) + ffi.cdef(CDEF) + # Not sys.platform: Android's Python 3.12 still reports "linux". + android = hasattr(sys, "getandroidapilevel") + return ffi, ffi.dlopen("libc.so" if android else None) + + +def test_basic() -> None: + """ABI-mode call into libc through a dlopen'd handle.""" + ffi, lib = _libc() assert lib.strlen(ffi.new("char[]", b"hello world")) == 11 + + +def test_callback_called_from_c() -> None: + """C calls a Python ffi.callback() (qsort's comparator). On iOS this needs + libffi's ffi_closure_alloc(): cffi's own allocator asks for W+X pages.""" + ffi, lib = _libc() + + @ffi.callback("int(const void *, const void *)") + def cmp(a: Any, b: Any) -> int: + x, y = ffi.cast("int *", a)[0], ffi.cast("int *", b)[0] + return (x > y) - (x < y) + + arr = ffi.new("int[]", [5, 3, 9, 1, 7, 2]) + lib.qsort(arr, 6, ffi.sizeof("int"), cmp) + assert list(arr) == [1, 2, 3, 5, 7, 9] + + +def test_callback_from_native_thread() -> None: + """A callback entered on a thread Python did not create, as PortAudio and + libsndfile's virtual I/O do.""" + ffi, lib = _libc() + seen: list[tuple[int, int]] = [] + + @ffi.callback("void *(void *)") + def start(arg: Any) -> Any: + seen.append((threading.get_ident(), int(ffi.cast("intptr_t", arg)))) + return ffi.NULL + + tid = ffi.new("pthread_t *") + assert lib.pthread_create(tid, ffi.NULL, start, ffi.cast("void *", 1234)) == 0 + assert lib.pthread_join(tid[0], ffi.NULL) == 0 + assert len(seen) == 1 + assert seen[0][1] == 1234 + assert seen[0][0] != threading.get_ident() + + +def test_many_callbacks_freed_and_reallocated() -> None: + """3000 live closures, freed, then 3000 more. On arm64 iOS a libffi trampoline + table holds 1024, so this grows past one table, releases them and starts again.""" + ffi, _ = _libc() + cbs = [ + ffi.callback("int(int)", (lambda k: lambda x: x + k)(k)) for k in range(3000) + ] + assert [cbs[k](1) for k in (0, 1023, 1024, 2999)] == [1, 1024, 1025, 3000] + del cbs + gc.collect() + again = [ffi.callback("int(int)", lambda x: -x) for _ in range(3000)] + assert again[0](5) == -5 + assert again[-1](7) == -7 + + +# Last on purpose: a backend without ffi_prep_cif_var() can crash here, not just fail. +def test_variadic_call() -> None: + """snprintf with a string, an int and a double in the variadic part. arm64 + Apple passes varargs on the stack, so this is garbage or a crash unless cffi + prepares the call with ffi_prep_cif_var().""" + ffi, lib = _libc() + buf = ffi.new("char[64]") + n = lib.snprintf( + buf, + 64, + b"[%s] [%d] [%.2f]", + ffi.new("char[]", b"hi"), + ffi.cast("int", 42), + ffi.cast("double", 3.5), + ) + assert ffi.string(buf) == b"[hi] [42] [3.50]" + assert n == 16 diff --git a/recipes/soundfile/README.md b/recipes/soundfile/README.md index bd042bb9..e4849ef1 100644 --- a/recipes/soundfile/README.md +++ b/recipes/soundfile/README.md @@ -61,27 +61,26 @@ file, so it is how you check a samplerate or duration before deciding to decode. ### Reading audio the user picked [`FilePicker`](https://flet.dev/docs/controls/filepicker/) hands back a path on both -platforms, and `sf.read()` takes it directly. **A real path is the only form that works -everywhere** — see the iOS note below. +platforms, and `sf.read()` takes it directly. -Where you hold bytes instead (a download, a file read out of `assets/`), `soundfile` does -accept any file object, and on Android that works: +Where you hold bytes instead (a download, a file read out of `assets/`), pass any binary +file object: ```python import io -data, sr = sf.read(io.BytesIO(raw_bytes)) # Android and desktop only +data, sr = sf.read(io.BytesIO(raw_bytes)) ``` -**On iOS this raises `MemoryError`,** and no version of the package will fix it. That path -uses libsndfile's virtual I/O, which means handing the C library a `ffi.callback()`; cffi -writes that trampoline into memory at runtime, and iOS refuses write+execute pages to an -app without the JIT entitlement. The message names the cause: +A file object goes through libsndfile's virtual I/O, which calls back into Python through +`ffi.callback()`. That works on both platforms with **cffi 2.1.1-1 or later from +pypi.flet.dev**, which pip picks over PyPI's own iOS wheel of the same version. Apps built +against an older cffi raised this on iOS for every file object: MemoryError: Cannot allocate write+execute memory for ffi.callback(). -Write the bytes to a file and read that instead — it costs one write, needs no -per-platform branch, and is the recommended form on both platforms: +A real path is still the simplest form, with no callbacks at all. If the bytes are going to +disk anyway, read them back by path: ```python import os @@ -92,9 +91,6 @@ with open(path, "wb") as f: data, sr = sf.read(path) ``` -Only the *file-object* form is affected. Paths, and integer file descriptors (which go -through `sf_open_fd`), use no callbacks and work normally on iOS. - ### Storage `soundfile` reads and writes nothing of its own — no config, no cache, no env vars. Files you @@ -142,9 +138,10 @@ device, which is not something you can assume for every package. being killed by the OS. Pass `dtype="float32"` unless you specifically need the precision, and prefer `blocks()` over reading whole files. -- **iOS cannot read audio from a file object.** Repeating it here because it is the one - thing that behaves differently between the two platforms, and it fails at the point of - use rather than at import. `io.BytesIO` in, `MemoryError` out; write a file first. +- **`MemoryError: Cannot allocate write+execute memory` on iOS means an old cffi.** File + objects need cffi 2.1.1-1 or later; with PyPI's 2.1.1 or our 2.0.0-1 the app imports + fine and fails at the first file object. Check which cffi the build resolved rather + than looking at soundfile. - **Opus only encodes at 48 kHz.** libsndfile will not resample for you; `sf.write(..., format="OGG", subtype="OPUS")` at any other rate raises. Resample first — [`soxr`](../soxr) @@ -218,6 +215,10 @@ download six wheels whose contents already live inside `libsndfile.so`. `py2.py3-none-any` for an unknown `sys.platform`. forge's `fix_wheel` overwrites the tag, which is what makes our wheel outrank PyPI's `any` wheel at the same version. Do not "fix" the tag in a patch. +- **File objects on iOS depend on the cffi recipe's `ios-ffi-closure-alloc.patch`** + (python-cffi/cffi#265, in no cffi release as of 2.1.1). If PyPI ships a newer cffi with + iOS wheels before pypi.flet.dev does, apps resolve it, and `test_read_from_file_object` + goes red on iOS unless that release carries #265. - **FLAC 1.5.0 needs `-DCMAKE_SYSTEM_VERSION` on Android** and `-DENABLE_MULTITHREADING=OFF`; both have comments in `recipes/flet-libflac/build.sh` saying what breaks without them. @@ -241,10 +242,10 @@ download six wheels whose contents already live inside `libsndfile.so`. ### Coverage gaps The device tests cover loading the library, WAV round trips in three subtypes, every -container and codec, virtual I/O from `BytesIO` (Android) and its `MemoryError` on iOS, -the write-a-file workaround, block reads with seeking, and the format registry. They do not -cover: writing to `FLET_APP_STORAGE_*` (a path question, not a -libsndfile one), any real recorded audio file, `sf.info()`, the `RAW` format's manual -`samplerate`/`channels`/`subtype` arguments, or multi-threaded decoding. The security -backports are compile-verified only — there are no regression tests for the five fixes, since -reproducing them needs the malformed inputs from upstream's fuzz corpus. +container and codec, virtual I/O from `BytesIO` on both platforms, bytes spilled to a path, +block reads with seeking, and the format registry. They do not cover: writing to +`FLET_APP_STORAGE_*` (a path question, not a libsndfile one), any real recorded audio +file, `sf.info()`, the `RAW` format's manual `samplerate`/`channels`/`subtype` arguments, +or multi-threaded decoding. The security backports are compile-verified only — there are +no regression tests for the five fixes, since reproducing them needs the malformed inputs +from upstream's fuzz corpus. diff --git a/recipes/soundfile/examples/codec-roundtrip/README.md b/recipes/soundfile/examples/codec-roundtrip/README.md index e297fbb7..d31abd3f 100644 --- a/recipes/soundfile/examples/codec-roundtrip/README.md +++ b/recipes/soundfile/examples/codec-roundtrip/README.md @@ -20,10 +20,10 @@ What it demonstrates: quantisation, not codec loss — a distinction worth seeing once. - **Writing into Flet's storage.** Each round trip writes a real file under [`FLET_APP_STORAGE_TEMP`](https://flet.dev/docs/reference/environment-variables#flet_app_storage_temp) - and deletes it again. Files rather than `io.BytesIO` deliberately: a file object routes - through libsndfile's virtual I/O, which needs a cffi callback, which needs write+execute - memory that iOS refuses — so the file-object form works on Android and raises - `MemoryError` on iOS. A path works on both. + and deletes it again. Files rather than `io.BytesIO` deliberately: a path needs no + callbacks, so it works with any cffi. A file object routes through libsndfile's virtual + I/O, which calls back into Python through `ffi.callback()`; that works on both platforms + too, but on iOS only with cffi 2.1.1-1 or later from pypi.flet.dev. - **Compute off the UI thread.** The sweep runs in [`page.run_thread(...)`](https://flet.dev/docs/controls/page/#flet.Page.run_thread) with a spinner up, ending in the explicit diff --git a/recipes/soundfile/examples/codec-roundtrip/src/audio_codecs.py b/recipes/soundfile/examples/codec-roundtrip/src/audio_codecs.py index 83ef6fa5..1b01038f 100644 --- a/recipes/soundfile/examples/codec-roundtrip/src/audio_codecs.py +++ b/recipes/soundfile/examples/codec-roundtrip/src/audio_codecs.py @@ -41,8 +41,8 @@ def roundtrip(label, fmt, subtype, rate): audible-codec-sized for the lossy ones. Returns an `error` string instead if the format is not compiled in. - Files, not io.BytesIO: a file object would route through libsndfile's virtual I/O, - which needs a cffi callback, which needs write+execute memory that iOS refuses. + Files, not io.BytesIO: a path needs no virtual I/O callbacks, so it works with any + cffi. A file object works too, but on iOS only with cffi 2.1.1-1 or later. """ source = signal(rate) # FLET_APP_STORAGE_TEMP on device; tempfile's default elsewhere. diff --git a/recipes/soundfile/tests/test_soundfile.py b/recipes/soundfile/tests/test_soundfile.py index bdf42a8a..b293713c 100644 --- a/recipes/soundfile/tests/test_soundfile.py +++ b/recipes/soundfile/tests/test_soundfile.py @@ -1,5 +1,5 @@ import io -import sys +from pathlib import Path import numpy as np import pytest @@ -8,20 +8,22 @@ SR = 16000 -def _tone(seconds=0.25, freq=440.0, sample_rate=SR): +def _tone( + seconds: float = 0.25, freq: float = 440.0, sample_rate: int = SR +) -> np.ndarray: """Deterministic mono float64 sine — no RNG, no assets, no network.""" t = np.arange(int(sample_rate * seconds)) / sample_rate return np.sin(2.0 * np.pi * freq * t) -def test_libsndfile_loaded(): +def test_libsndfile_loaded() -> None: """The cffi loader found the flet-libsndfile .so and bound the C API — this is the single claim the mobile patch exists to make true.""" assert sf.__libsndfile_version__ assert sf.SoundFile is not None -def test_wav_roundtrip(tmp_path): +def test_wav_roundtrip(tmp_path: Path) -> None: """A float64 signal written as 32-bit float WAV reads back bit-identical, with the samplerate and channel count preserved.""" path = str(tmp_path / "tone.wav") @@ -34,7 +36,7 @@ def test_wav_roundtrip(tmp_path): np.testing.assert_array_equal(y, x) -def test_stereo_and_int_subtypes(tmp_path): +def test_stereo_and_int_subtypes(tmp_path: Path) -> None: """Two-channel PCM_16 and PCM_24 round-trip within their quantisation step — covers the integer conversion paths, not just the float passthrough.""" x = np.stack([_tone(), 0.5 * _tone(freq=880.0)], axis=1) @@ -62,7 +64,9 @@ def test_stereo_and_int_subtypes(tmp_path): ("MP3", "mp3", "MPEG_LAYER_III", True), ], ) -def test_container_roundtrip(tmp_path, fmt, ext, subtype, lossy): +def test_container_roundtrip( + tmp_path: Path, fmt: str, ext: str, subtype: str, lossy: bool +) -> None: """Every container this wheel's libsndfile is built for encodes and decodes on device — including the ones that only work because flet-libflac, flet-libogg, flet-libvorbis, flet-libopus, flet-libmpg123 and flet-libmp3lame are linked in. @@ -85,11 +89,11 @@ def test_container_roundtrip(tmp_path, fmt, ext, subtype, lossy): assert np.max(np.abs(y - x)) <= 2**-15 -@pytest.mark.skipif(sys.platform == "ios", reason="no W+X memory for ffi.callback()") -def test_read_from_file_object(): +def test_read_from_file_object() -> None: """sf.read() works on an in-memory binary stream via libsndfile's virtual I/O — the path an app takes for audio fetched over the network, where there may be - no real file to open. Android only; see the iOS counterpart below.""" + no real file to open. Virtual I/O hands libsndfile cffi callbacks, so on iOS + this needs cffi >= 2.1.1-1 (libffi's closure allocator, not W+X pages).""" buf = io.BytesIO() x = _tone() sf.write(buf, x, SR, format="WAV", subtype="DOUBLE") @@ -100,30 +104,13 @@ def test_read_from_file_object(): np.testing.assert_array_equal(y, x) -@pytest.mark.skipif(sys.platform != "ios", reason="iOS-only limitation") -def test_file_object_is_unavailable_on_ios(): - """Passing a file object on iOS raises MemoryError, and this is permanent: virtual - I/O hands libsndfile a `ffi.callback()`, cffi writes that trampoline at runtime, and - iOS refuses write+execute pages to an app without the JIT entitlement. Asserted - rather than skipped so the README's "write it to a file first" guidance is checked, - and so a future cffi or OS change that lifts it does not go unnoticed.""" - buf = io.BytesIO() - with pytest.raises(MemoryError, match="write.execute"): - sf.write(buf, _tone(), SR, format="WAV", subtype="DOUBLE") - - -def test_path_roundtrip_is_the_ios_workaround(tmp_path): - """The replacement for the file-object path: spill the bytes to a real file and read - it back. Works on every platform, so an app needs no per-platform branch.""" - raw = io.BytesIO() +def test_bytes_spilled_to_a_path(tmp_path: Path) -> None: + """The README's simplest form for bytes in hand: write them to a real file and + read that by path. No virtual I/O anywhere, so this works with any cffi.""" x = _tone() - try: - sf.write(raw, x, SR, format="WAV", subtype="DOUBLE") - encoded = raw.getvalue() - except MemoryError: # iOS: produce the same bytes without virtual I/O - staged = str(tmp_path / "staged.wav") - sf.write(staged, x, SR, subtype="DOUBLE") - encoded = open(staged, "rb").read() + staged = tmp_path / "staged.wav" + sf.write(str(staged), x, SR, subtype="DOUBLE") + encoded = staged.read_bytes() path = tmp_path / "from_bytes.wav" path.write_bytes(encoded) @@ -132,7 +119,7 @@ def test_path_roundtrip_is_the_ios_workaround(tmp_path): np.testing.assert_array_equal(y, x) -def test_blockwise_read(tmp_path): +def test_blockwise_read(tmp_path: Path) -> None: """SoundFile seeking and block reads reassemble the whole signal — the memory- bounded path for files too large to hold in RAM on a phone.""" path = str(tmp_path / "tone.wav") @@ -151,7 +138,7 @@ def test_blockwise_read(tmp_path): np.testing.assert_array_equal(f.read(100), x[SR // 2 : SR // 2 + 100]) -def test_available_formats_advertises_the_linked_codecs(): +def test_available_formats_advertises_the_linked_codecs() -> None: """libsndfile's own format registry lists the codec containers, proving they were compiled in rather than merely present at link time.""" formats = sf.available_formats()