forge: ship a licence with every build.sh wheel - #116
Merged
Conversation
`flet-lib*` wheels have been going out with **no licence information at all** — no
`License` field, and no licence file inside the wheel. Checked against what is published
today:
flet-libgeos (GEOS, LGPL-2.1) METADATA licence fields: none
flet-libiconv (GNU libiconv, LGPL) licence files in wheel: none
Python packages are unaffected: their build backend copies upstream's metadata and licence
files into the wheel and forge preserves both (verified against the published `zeroconf`
wheel, which carries `License-Expression` and its `COPYING`). The gap is only the build.sh
path, where `make_wheel` synthesises METADATA by hand — `Metadata-Version`, `Name`,
`Version`, an empty `Summary` and an empty `Download-URL`, and nothing else.
Every licence in this tree requires its notice to accompany the binary. That is the least
ambiguous obligation any of them carries and the cheapest to satisfy, and we were not
satisfying it.
## The `about` key was documented but dead
`meta.yaml` already had `about.license_file`, whose own schema description promises that
"build-wheel will automatically include any file in the source or recipe directory whose
name starts with LICEN[CS]E or COPYING". Nothing in `src/forge/` read `about` at all.
`flet-libfreetype` has been setting `license_file: docs/FTL.TXT` to no effect since it was
written. This makes the documented behaviour real.
## What now happens
- Any top-level `LICEN[CS]E*` / `COPYING*` / `COPYRIGHT*` / `NOTICE*` in the **source** or
the **recipe** directory is copied into `.dist-info/licenses/` and listed as
`License-File`. A source file shadows a recipe file of the same name, so a recipe can
carry a fallback without overriding upstream.
- `COPYRIGHT` is matched because for `libpq`, `libxml2` and `libxslt` it *is* the licence
grant and the only such file shipped — each was previously ending up with nothing.
- `NOTICE` is matched because Apache-2.0 section 4(d) requires redistributing it, and an
ASF archive keeps its attributions there rather than in LICENSE (arrow).
- `about.license_file` takes a path **or a list**, and replaces discovery entirely — for
the two cases discovery cannot get right: excluding a notice that covers something the
wheel does not contain, and including one below the top level.
- New `about.license` holds an SPDX expression, emitted as `License-Expression`.
- `Metadata-Version` goes 1.2 -> 2.4, the floor for PEP 639's `License-Expression` and
`License-File`.
- `write_message_file` accepts a list value, emitting the header once per item, since
`License-File` is legitimately repeated.
- Relative paths are preserved rather than flattened to a basename: PEP 639 records
`License-File` that way, and flattening would silently drop one of two same-named
notices (jq ships a `COPYING` for itself and another for the oniguruma it bundles).
- Setting `about` on a **Python** recipe now warns rather than being silently ignored,
since that path's metadata comes from the package's own backend.
## Verified
Real builds, not reasoning: `flet-libiconv`, `flet-libgeos`, `flet-libxml2`,
`flet-libxslt`, `flet-libpq`, `flet-libfreetype`, `flet-libjq`, `flet-libopenblas`,
`flet-libpsl`, `flet-libcpp-shared` and `flet-libomp` (both platforms) all produce wheels
carrying the expected notices and metadata.
`Metadata-Version: 2.4` is the one compatibility risk, so it was tested rather than
assumed: strict `packaging` 26.3 validates the result, and **pip and uv both install it**
with the licence files landing in site-packages. forge's own METADATA handling uses the
email parser and a regex, not `packaging.metadata`, so the older `packaging` in the build
venv is not in the path.
No new lint: the two flake8 findings in `build.py` predate this change and are left alone.
Fills in `about` for the native-library recipes now that forge acts on it. 23 recipes get an SPDX `license`; four deliberately get none and say why in a comment, so the absence does not read as an oversight and get filled in from memory later. Every expression was read out of the archive we actually ship from, corroborated against the upstream project's own declaration, then adversarially re-checked. **A wrong identifier in shipped metadata is worse than none**, so the rule throughout was to abstain rather than guess. The licence *file* ships either way — the expression is only the machine-readable convenience on top. ## Cases where the obvious answer is wrong - **`flet-libiconv` pins `license_file: COPYING.LIB`.** Its top-level `COPYING` is **GPL-3.0**, covering the `iconv` program and docs that build.sh deletes; only `COPYING.LIB` (LGPL-2.1) applies to the library the wheel contains. Auto-discovery would have bundled both, putting GPL-3.0 text in an LGPL-only wheel — a worse outcome than shipping nothing. - **`flet-libfreetds` is `LGPL-2.0-or-later`, not 2.1.** `COPYING_LIB.txt` is the 1991 *Library* GPL. GitHub's licence API reports GPL-2.0 for that repo because licensee picks the first `COPYING*`; that is the whole-repo answer, and it is wrong for this wheel — the plain-GPL notices cover `src/pool` and `src/apps`, which the configure flags exclude. - **`flet-libpng` is lowercase `libpng-2.0`.** Capital-L `Libpng` is the version-1 licence and applies only up to 1.6.35; the pinned 1.6.43 carries the 2.0 text, and the archive's own CMakeLists has the matching SPDX tag. - **`flet-libfreetype` keeps `LICENSE.TXT` first**, ahead of both arm texts: it is the file that explains the dual licence. The parentheses in `(FTL OR GPL-2.0-or-later) AND MIT` are load-bearing — SPDX binds AND tighter than OR. - Several expressions are compound because vendored code is genuinely in the binary: jq bundles oniguruma, libpsl compiles the MPL-2.0 PSL in and links Chromium's BSD-3 DAFSA sources, liboprf installs Apache-2.0 karamel headers, libtiff's `tif_lzw.c` carries a second Berkeley grant that 4.7.0's `LICENSE.md` omits (upstream added it afterwards). ## Abstained `flet-libgeos` (headers name no LGPL version, so section 13 makes any FSF version choosable — broader than `-or-later`, and not `-only`), `flet-libgdal` (core MIT, but the cross build forces internal libtiff/libgeotiff/libjpeg/zlib/json-c/qhull/LercLib that LICENSE.TXT does not enumerate), `flet-libxslt` (Expat plus a name-in-advertising clause, so neither `MIT` nor `X11` is both faithful and conventional, and upstream declares nothing), and `flet-libomp` (ships different code per platform — LLVM's `libomp.so` on Android, this repo's own serial stub on iOS — so no single expression is honest, and `AND` would be wrong too since no wheel contains both). ## Also here `flet-libcpp-shared` and `flet-libomp` have no upstream archive, so their build.sh now stages a notice into the source root. Both take it from the same NDK the binary is copied from, so the two cannot drift. `flet-libomp` additionally carries a recipe-level `LICENSE` covering its iOS stub; the Android leg's staged NDK notice shadows it by name, which is how one recipe ships the right notice on each platform.
`about` is inside the Jinja-rendered meta.yaml like every other key, so a recipe that
ships different code per platform can just gate the licence on `sdk` — which is the
right answer here rather than leaving it unset:
android -> Apache-2.0 WITH LLVM-exception (LLVM's libomp.so, from the NDK)
ios -> BSD-3-Clause (the serial stub build.sh generates,
which is this repo's own code)
Verified on real builds of both platforms: the wheels now carry those two expressions,
alongside the notices that already split correctly (130424 B of LLVM NOTICE on Android,
1528 B of this repo's LICENSE on iOS).
The other three abstentions stand — theirs are not per-platform ambiguities. libgeos is
about which LGPL version a recipient may choose, libgdal about which internal drivers the
cross build forces in, libxslt about MIT vs X11 for an Expat-plus-advertising-clause text;
Jinja cannot resolve any of those.
Brings in the merged soxr recipe (#115) so this branch sits on current main. Clean merge, no conflicts — soxr touches only new files under recipes/soxr/, and this branch touches src/forge plus the flet-lib* recipes. [skip ci] — the merge introduces nothing this branch's CI would exercise. It adds only recipes/soxr/, which is a Python-package recipe: it has no build.sh, so the synthesised- METADATA path changed here never runs for it, and it sets no `about:`, so the new "about is only honoured for build.sh recipes" warning cannot fire either. Without this marker the push would detect soxr as changed and spend a full matrix rebuilding a recipe that is already green on main.
Without this the whole branch is a no-op in production. `publish_to_pypi` is
409-tolerant: a wheel already published at `<version>-<build>` is skipped, so
re-publishing these recipes unchanged would leave pypi.flet.dev serving the same
licence-less wheels it serves today.
23 of the 27 native-library recipes sit at exactly their published build number —
checked against the index rather than assumed:
flet-libarrow 24.0.0 recipe 1 == published 1
flet-libiconv 1.17 recipe 1 == published 1
flet-libxml2 2.15.3 recipe 10 == published 10
...
Each is bumped to `published + 1`. The other four — `flet-libcurl`, `flet-liboprf`,
`flet-libproj`, `flet-libtiff` — have nothing published at their current version, so
their existing build number ships correctly and is left alone.
Nothing but `number:` lines changed; the bump was derived from the index, so re-running
it is a no-op rather than a second increment.
Consequence worth stating: this forces every consumer to re-resolve these libraries on
their next build. That is the point — it is how the licence-carrying wheels replace the
ones currently being served.
…[skip ci]
The iOS "pillow reports codecs no forge wheel has" mystery was not a pillow
defect, not an iOS packaging quirk, and not the recipe-tester running fake
code. The test app was testing a different package.
Chain, from CI run 32532320970:
FORGE_PACKAGES: pillow: -> pkg_version=""
Staged recipe 'pillow' (dep: pillow) <- no pin
app site-packages: pillow-12.3.0.dist-info
recipe built: pillow 12.2.0
`packages="pillow:"` is the documented dispatch format and yields an EMPTY
version, so stage_recipe.sh pinned nothing. The app resolves against PyPI as
well as pypi.flet.dev, and PyPI now ships official iOS wheels for pillow —
cp313 and cp314 only, no Android. 12.3.0 beat the freshly built 12.2.0 on
version, and its _imaging carries jpeg2k_encoder and libtiff_encoder, which is
exactly the {jpg, jpg_2000, libtiff, zlib} the test reported, webp absent and
all.
That also explains the shape of the failure that made it look like a platform
bug: iOS 3.13 and 3.14 failed (PyPI has iOS wheels for those two Pythons), iOS
3.12 passed (no cp312 iOS wheel), and Android never failed at all (no Android
wheels on PyPI).
The dist-test/ build-tag bump to 9999 cannot prevent this: a build tag only
breaks ties at the same version, and this is a version difference.
So when no version is passed, take it from the wheel staged for the test rather
than from meta.yaml — meta.yaml can be Jinja-conditional (cryptography builds a
different version per Python) while the wheel on disk is what the device runs.
When no wheel is found the script now says so loudly instead of quietly
resolving whatever is newest.
This was silent false coverage for any recipe whose upstream has started
publishing mobile wheels, and that set is growing.
Keeps the recipe's actual content at the top; the note explaining why there is no `about.license` reads fine at the end. No content change.
Turns the missing-licence warning into a hard error, with an explicit in-recipe opt-out.
A warning here was never going to hold the invariant. It lands in a build log thousands
of lines long, on a job that exits 0 — which is exactly how every `flet-lib*` wheel came
to ship with no notice at all while CI stayed green for years, and how the `about` key
came to be documented but unimplemented. "We tried" is not the same guarantee as "it
holds", and this is the one obligation every licence in the tree shares.
The realistic way someone meets this is a **version bump** where upstream renames or
relocates its notice — `LICENSE` moving into `LICENSES/`, or becoming `COPYING.md`. That
is not a spurious block: it is the one moment where the alternative is silently shipping
a violation, and the fix is one line. Three variants of exactly that turned up while
writing this change (libpq/libxml2/libxslt keep theirs in `COPYRIGHT`, arrow in `NOTICE`,
libiconv ships two where only one applies), so it is a live failure mode, not a
hypothetical.
So the error has to earn the interruption. It names the directories searched, the name
patterns tried, and both fixes:
flet-libfoo: no licence file found, so this wheel would ship the library's object
code with no notice.
searched (top level only):
<build dir>
<recipe dir>
for names starting with: LICENSE / LICENCE / COPYING / COPYRIGHT / NOTICE (any case)
Fix by pointing at the real file, ...
about:
license_file: path/to/LICENSE # or a list of paths
A recipe with genuinely nothing to ship says so explicitly instead:
about:
license_file: [] # deliberately none -- <reason>
An explicit `license_file` naming a file that isn't there now raises too, rather than
warning and shipping nothing — that is a typo or a moved file, not a preference.
The opt-out is an **empty list**, deliberately written in the recipe rather than passed
as an environment variable, so it shows up in review next to the comment justifying it.
Note the distinction the code has to be careful about: an unset `license_file` is the
empty *string* the schema defaults to and still means "discover for me"; only an empty
*list* opts out. Both are falsy, so the list check comes first.
No recipe needs the opt-out today — all 27 pass — so this is free to turn on now.
Verified against a stub builder across all branches: discovery (LICENSE, COPYRIGHT,
source-shadows-recipe, recipe fallback), explicit sub-path, explicit list keeping two
same-named notices apart, both error paths, the empty-list opt-out, and unset-still-
discovers. Then a real `forge android:arm64-v8a flet-libiconv` build, which still bundles
`COPYING.LIB` and now tags the wheel `-2-`.
[skip ci] — a push here would rebuild only flet-libgdal (a comment move); the change
that needs validating is the full 27-recipe matrix, which wants an explicit dispatch.
…s built [skip ci]" This reverts commit 193f898.
Only `new-mobile-recipe` covered any of this; the two skills someone actually reaches for when it bites had nothing. **forge-error-catalogue** — a new entry for the hard failure this branch introduces. That error is designed to be actionable, and the catalogue is where people grep when a build stops, so it not being there defeated the point. Records the cause, that the likely trigger is a version bump rather than a new recipe, the `license_file` fix in both its forms, the exclude case (libiconv's GPL `COPYING` vs its LGPL `COPYING.LIB`), and the unset-vs-empty-list distinction that decides whether discovery runs at all. **native-recipe-bumps** — a bump is the likeliest moment for a licence to go wrong, in two ways that behave very differently. The FILE moving is loud: the build now fails and the error names the fix. The EXPRESSION changing is silent, because `about.license` is a string nobody re-reads — `libpng` is `Libpng` up to 1.6.35 and `libpng-2.0` after; `libtiff` 4.7.0's LICENSE.md omits the `tif_lzw.c` Berkeley grant that 4.7.1 adds. Both are real, both were found the hard way, and only the second can ship wrong. Also cross-links the publish trap from where licences are actually edited. `forge-ci` already documented "bump before republishing" — the knowledge was captured and I simply didn't apply it, which cost a commit to notice. A pointer from the licensing section is the cheap fix, since editing metadata needs no version change and so gives no other cue. No new facts invented here: everything is from this branch's work.
…skip ci] Staged as `LICENSE` originally because the matcher was `licen[cs]e|copying|copyright` at the time and would not have found a `NOTICE`. `NOTICE` was added to it afterwards, for Apache-2.0 section 4(d) and arrow, so the rename now buys nothing here — and it costs the filename a compliance reviewer actually looks for. flet-libomp still stages the same NDK file as `LICENSE`, and must: dedup is by filename, source directory first, so that name is what shadows the recipe-level `LICENSE` covering its iOS stub. Staging it as `NOTICE` there would collect both and put this repo's BSD-3-Clause into an Android wheel that contains none of its code. The asymmetry between two otherwise-parallel recipes is deliberate, and now says so in both files. Verified: `forge android:arm64-v8a flet-libcpp-shared` bundles `dist-info/licenses/NOTICE` (130,424 B) with `License-File: NOTICE` and the unchanged `License-Expression: Apache-2.0 WITH LLVM-exception`.
ndonkoHenri
added a commit
to ndonkoHenri/mobile-forge
that referenced
this pull request
Aug 25, 2026
Adds recipes for [av](https://pyav.basswood.io) 18.1.0 — FFmpeg's libraries behind a Python API: containers, streams, packets, codecs and frames as objects, with the pixel and sample data reachable in place — and the [FFmpeg](https://ffmpeg.org) 8.1.2 it links. Requested in [flet#6730](flet-dev/flet#6730). - [Docs](recipes/av/README.md) - [Example](recipes/av/examples/clip-roundtrip) An app cannot ship an `ffmpeg` binary or spawn a subprocess on either platform, so PyAV is the only route to media processing in Python on a phone. The wheel carries its own FFmpeg, which also means what an app can decode stops depending on the OS underneath it. ## Recipe shape A two-recipe chain, `flet-libarrow` -> `pyarrow` in shape: `flet-libffmpeg` builds the seven `libav*` libraries **shared**, and `av` compiles PyAV's 49 Cython extension modules against them. Shared is forced, not chosen — PyAV's `setup.py` rejects a static FFmpeg outright ("Building PyAV against static FFmpeg libraries is not supported"), and 49 modules each static-linking a 13 MB `libavcodec` is not a wheel anyone would ship. Delivery is the proven one: unversioned `lib<name>.so` with a matching SONAME into the APK's jniLibs on Android (FFmpeg's own `android` target already emits exactly that), and unversioned `lib<name>.dylib` with an `@rpath` install-id into per-slice `*.framework` bundles on iOS, plus an `__init__` preload shim. Two things were needed on top: - **`-Wl,-headerpad_max_install_names` on the iOS lanes of both recipes.** serious_python rewrites each `@rpath/libavformat.dylib` to `@rpath/opt.lib.libavformat.framework/opt.lib.libavformat`, 30 bytes longer, and `install_name_tool` cannot grow a load command with no padding. CMake passes the flag on Apple platforms itself, which is why pyarrow never hit this; setuptools and FFmpeg's `configure` do not. **The failure is silent:** `reconcile_framework_install_names` treats it as fatal and aborts the sync, but `flet build` still reports success — and since the plugin's `dist_ios` lives in the shared pub cache, the `.app` then ships whatever the last successful build of any other project left there. Observed as an app carrying another recipe's packages and no `av` at all. - **Relocatable `.pc` files.** `setup.py` finds FFmpeg *only* through `pkg-config`, and forge already puts `<site-packages>/opt/lib/pkgconfig` on `PKG_CONFIG_LIBDIR` — so once `prefix`/`libdir`/`includedir` are rewritten to `${pcfiledir}`-relative form the consumer needs no `script_env` for it at all. FFmpeg writes all three out as absolute staging paths. `--disable-autodetect` (plus an explicit `--enable-zlib`) is what makes the feature set identical on a laptop and in CI, and keeps the two platforms symmetric: no VideoToolbox on iOS without MediaCodec on Android, no `iconv` on one side only. ## Licensing No `--enable-gpl`, no `--enable-nonfree`, no `--enable-version3` — and two on-device tests assert that rather than leaving it to prose: every library must report LGPL, and no GPL/nonfree codec may appear in `codecs_available`. `about` declares **`LGPL-2.1-or-later AND IJG`**, not plain LGPL. libavcodec's `jrevdct.c`, `jfdctfst.c` and `jfdctint_template.c` are Independent JPEG Group code and are genuinely compiled in (`jrevdct.o`, `jfdctfst.o` and `jfdctint.o` are all in the link), and while FFmpeg's `LICENSE.md` concludes "in combination the LGPL v2.1+ applies", the IJG grant adds a condition that survives the combination and lands on whoever ships the binary: an app distributing only executables must state in its documentation that it is "based in part on the work of the Independent JPEG Group". The recipe README tells consumers so in a bullet of its own; the expression is what makes it discoverable from the wheel. Verified to canonicalise under strict `packaging`. The IJG grant lives in a header comment, not a file, so `build.sh` **extracts** it into `COPYING.IJG` from the source being built and fails the build if it is not there — a copy checked into the recipe would drift silently on the next bump. `license_file` is pinned to `COPYING.LGPLv2.1`, `LICENSE.md` and that extract; discovery would otherwise bundle `COPYING.GPLv2`/`GPLv3`/`LGPLv3`, notices for code this build does not contain. `av` needs no `about` — it is a Python recipe, so its own backend carries `License-Expression: BSD-3-Clause` and `LICENSE.txt` through, as flet-dev#116 describes. ## Validation - 12/12 wheels green: both recipes x arm64-v8a, armeabi-v7a, x86_64, iphoneos.arm64, iphonesimulator.arm64, iphonesimulator.x86_64. - On-device 12/12 passed, EXIT 0, locally on an Android arm64 emulator and the iOS Simulator, and again in CI (run 32852067992, 12/12 jobs green across 3.12/3.13/3.14 × both platforms) on the x86_64 emulator and simulator. - The `clip-roundtrip` example builds and runs on both: writes a 3 s MP4 with video and audio streams into app storage, reopens it, and puts four seeked frames on screen. ## Changes - `recipes/flet-libffmpeg/` — `meta.yaml` + `build.sh`, incl. the IJG notice extraction. - `recipes/av/` — `meta.yaml`, one patch, 12 on-device tests, `README.md`, and the example. - `.claude/skills/` — the headerpad failure class (catalogue + both sibling skills), the relocatable-`.pc` convention, and the `flet --version` / bundled-site-packages checks that would have caught the silent iOS build in one line. ## Consumer notes PyAV gives a Flet app the whole FFmpeg surface for reading and rewriting media: probing, trimming, remuxing, extracting stills and audio, filtering, resampling. Decoding is broad (H.264, HEVC, AV1, VP8/9, AAC, MP3, Opus, FLAC…); encoding is narrower because the modern video encoders are all GPL or unbuilt external libraries, and there is no `https` protocol — both documented, with the workarounds, in [the recipe README](recipes/av/README.md). [skip ci]
ndonkoHenri
added a commit
to ndonkoHenri/mobile-forge
that referenced
this pull request
Aug 25, 2026
Adds recipes for [av](https://pyav.basswood.io) 18.1.0 — FFmpeg's libraries behind a Python API: containers, streams, packets, codecs and frames as objects, with the pixel and sample data reachable in place — and the [FFmpeg](https://ffmpeg.org) 8.1.2 it links. Requested in [flet#6730](flet-dev/flet#6730). - [Docs](recipes/av/README.md) - [Example](recipes/av/examples/clip-roundtrip) An app cannot ship an `ffmpeg` binary or spawn a subprocess on either platform, so PyAV is the only route to media processing in Python on a phone. The wheel carries its own FFmpeg, which also means what an app can decode stops depending on the OS underneath it. ## Recipe shape A two-recipe chain, `flet-libarrow` -> `pyarrow` in shape: `flet-libffmpeg` builds the seven `libav*` libraries **shared**, and `av` compiles PyAV's 49 Cython extension modules against them. Shared is forced, not chosen — PyAV's `setup.py` rejects a static FFmpeg outright ("Building PyAV against static FFmpeg libraries is not supported"), and 49 modules each static-linking a 13 MB `libavcodec` is not a wheel anyone would ship. Delivery is the proven one: unversioned `lib<name>.so` with a matching SONAME into the APK's jniLibs on Android (FFmpeg's own `android` target already emits exactly that), and unversioned `lib<name>.dylib` with an `@rpath` install-id into per-slice `*.framework` bundles on iOS, plus an `__init__` preload shim. Two things were needed on top: - **`-Wl,-headerpad_max_install_names` on the iOS lanes of both recipes.** serious_python rewrites each `@rpath/libavformat.dylib` to `@rpath/opt.lib.libavformat.framework/opt.lib.libavformat`, 30 bytes longer, and `install_name_tool` cannot grow a load command with no padding. CMake passes the flag on Apple platforms itself, which is why pyarrow never hit this; setuptools and FFmpeg's `configure` do not. **The failure is silent:** `reconcile_framework_install_names` treats it as fatal and aborts the sync, but `flet build` still reports success — and since the plugin's `dist_ios` lives in the shared pub cache, the `.app` then ships whatever the last successful build of any other project left there. Observed as an app carrying another recipe's packages and no `av` at all. - **Relocatable `.pc` files.** `setup.py` finds FFmpeg *only* through `pkg-config`, and forge already puts `<site-packages>/opt/lib/pkgconfig` on `PKG_CONFIG_LIBDIR` — so once `prefix`/`libdir`/`includedir` are rewritten to `${pcfiledir}`-relative form the consumer needs no `script_env` for it at all. FFmpeg writes all three out as absolute staging paths. `--disable-autodetect` (plus an explicit `--enable-zlib`) is what makes the feature set identical on a laptop and in CI, and keeps the two platforms symmetric: no VideoToolbox on iOS without MediaCodec on Android, no `iconv` on one side only. ## Licensing No `--enable-gpl`, no `--enable-nonfree`, no `--enable-version3` — and two on-device tests assert that rather than leaving it to prose: every library must report LGPL, and no GPL/nonfree codec may appear in `codecs_available`. `about` declares **`LGPL-2.1-or-later AND IJG`**, not plain LGPL. libavcodec's `jrevdct.c`, `jfdctfst.c` and `jfdctint_template.c` are Independent JPEG Group code and are genuinely compiled in (`jrevdct.o`, `jfdctfst.o` and `jfdctint.o` are all in the link), and while FFmpeg's `LICENSE.md` concludes "in combination the LGPL v2.1+ applies", the IJG grant adds a condition that survives the combination and lands on whoever ships the binary: an app distributing only executables must state in its documentation that it is "based in part on the work of the Independent JPEG Group". The recipe README tells consumers so in a bullet of its own; the expression is what makes it discoverable from the wheel. Verified to canonicalise under strict `packaging`. The IJG grant lives in a header comment, not a file, so `build.sh` **extracts** it into `COPYING.IJG` from the source being built and fails the build if it is not there — a copy checked into the recipe would drift silently on the next bump. `license_file` is pinned to `COPYING.LGPLv2.1`, `LICENSE.md` and that extract; discovery would otherwise bundle `COPYING.GPLv2`/`GPLv3`/`LGPLv3`, notices for code this build does not contain. `av` needs no `about` — it is a Python recipe, so its own backend carries `License-Expression: BSD-3-Clause` and `LICENSE.txt` through, as flet-dev#116 describes. ## Validation - 12/12 wheels green: both recipes x arm64-v8a, armeabi-v7a, x86_64, iphoneos.arm64, iphonesimulator.arm64, iphonesimulator.x86_64. - On-device 12/12 passed, EXIT 0, locally on an Android arm64 emulator and the iOS Simulator. - CI **12/12 jobs green** across 3.12 / 3.13 / 3.14 × android / iOS, twice: run 32852067992 for the recipes and run 32855217090 once the licence work landed, the latter on this exact tree. On-device `12 passed` / `EXIT 0` on the x86_64 emulator and simulator in both. - The `clip-roundtrip` example builds and runs on both: writes a 3 s MP4 with video and audio streams into app storage, reopens it, and puts four seeked frames on screen. ## Changes - `recipes/flet-libffmpeg/` — `meta.yaml` + `build.sh`, incl. the IJG notice extraction. - `recipes/av/` — `meta.yaml`, one patch, 12 on-device tests, `README.md`, and the example. - `.claude/skills/` — the headerpad failure class (catalogue + both sibling skills), the relocatable-`.pc` convention, and the `flet --version` / bundled-site-packages checks that would have caught the silent iOS build in one line. ## Consumer notes PyAV gives a Flet app the whole FFmpeg surface for reading and rewriting media: probing, trimming, remuxing, extracting stills and audio, filtering, resampling. Decoding is broad (H.264, HEVC, AV1, VP8/9, AAC, MP3, Opus, FLAC…); encoding is narrower because the modern video encoders are all GPL or unbuilt external libraries, and there is no `https` protocol — both documented, with the workarounds, in [the recipe README](recipes/av/README.md). [skip ci]
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
flet-lib*wheels have been going out with no licence information at all — noLicensefield, and no licence file inside the wheel. Checked against what is published today:Python packages are unaffected: their build backend copies upstream's metadata and licence files into the wheel and forge preserves both (verified against the published
zeroconfwheel, which carriesLicense-Expressionand itsCOPYING). The gap is only the build.sh path, wheremake_wheelsynthesises METADATA by hand —Metadata-Version,Name,Version, an emptySummaryand an emptyDownload-URL, and nothing else.Every licence in this tree requires its notice to accompany the binary. That is the least ambiguous obligation any of them carries and the cheapest to satisfy, and we were not satisfying it.
The
aboutkey was documented but deadmeta.yamlalready hadabout.license_file, whose own schema description promises that "build-wheel will automatically include any file in the source or recipe directory whose name starts with LICEN[CS]E or COPYING". Nothing insrc/forge/readaboutat all.flet-libfreetypehas been settinglicense_file: docs/FTL.TXTto no effect since it was written. This makes the documented behaviour real.What now happens
LICEN[CS]E*/COPYING*/COPYRIGHT*/NOTICE*in the source or the recipe directory is copied into.dist-info/licenses/and listed asLicense-File. A source file shadows a recipe file of the same name, so a recipe can carry a fallback without overriding upstream.COPYRIGHTis matched because forlibpq,libxml2andlibxsltit is the licence grant and the only such file shipped — each was previously ending up with nothing.NOTICEis matched because Apache-2.0 §4(d) requires redistributing it, and an ASF archive keeps its attributions there rather than in LICENSE (arrow).about.license_filetakes a path or a list, and replaces discovery entirely — for the two cases discovery cannot get right: excluding a notice that covers something the wheel does not contain, and including one below the top level.about.licenseholds an SPDX expression, emitted asLicense-Expression. It is inside the Jinja-rendered meta.yaml like every other key, so a recipe shipping different code per platform can gate it onsdk.Metadata-Versiongoes 1.2 → 2.4, the floor for PEP 639'sLicense-ExpressionandLicense-File.License-Filethat way, and flattening would silently drop one of two same-named notices (jq ships aCOPYINGfor itself and another for the oniguruma it bundles).abouton a Python recipe now warns rather than being silently ignored.license_file: [], in the recipe where a reviewer sees it; no recipe needs the opt-out today.Cases where the obvious answer is wrong
flet-libiconvpinslicense_file: COPYING.LIB. Its top-levelCOPYINGis GPL-3.0, covering theiconvprogram and docs that build.sh deletes; onlyCOPYING.LIB(LGPL-2.1) applies to the library the wheel contains. Auto-discovery would have bundled both, putting GPL-3.0 text in an LGPL-only wheel — a worse outcome than shipping nothing.flet-libfreetdsisLGPL-2.0-or-later, not 2.1.COPYING_LIB.txtis the 1991 Library GPL. GitHub's licence API reports GPL-2.0 for that repo because licensee picks the firstCOPYING*; that is the whole-repo answer, and it is wrong for this wheel — the plain-GPL notices coversrc/poolandsrc/apps, which the configure flags exclude.flet-libpngis lowercaselibpng-2.0. Capital-LLibpngis the version-1 licence and applies only up to 1.6.35.flet-libompgets a per-SDK expression, since it ships different code on each platform:Apache-2.0 WITH LLVM-exceptionon Android (LLVM'slibomp.so, from the NDK) andBSD-3-Clauseon iOS (the serial stub build.sh generates, which is this repo's own code).ANDwould be wrong — no single wheel contains both.tif_lzw.ccarries a second Berkeley grant that 4.7.0'sLICENSE.mdomits.Deliberately not set
flet-libgeos(headers name no LGPL version, so §13 makes any FSF version choosable — broader than-or-later, and not-only),flet-libgdal(core MIT, but the cross build forces internal libtiff/libgeotiff/libjpeg/zlib/json-c/qhull/LercLib that LICENSE.TXT does not enumerate), andflet-libxslt(Expat plus a name-in-advertising clause, so neitherMITnorX11is both faithful and conventional, and upstream declares nothing). Each carries a comment in itsmeta.yamlsaying what is ambiguous, so the absence does not read as an oversight. A wrong identifier in shipped metadata is worse than none; the licence file ships for all three regardless.Every expression was read out of the archive we actually ship from, corroborated against upstream's own declaration, then adversarially re-checked.
Verification
flet-libomp's per-platform split is confirmed by hash in the CI artifacts: 130,424 B of LLVM notice on all three Android ABIs, 1,528 B of this repo's licence on all three iOS slices.Metadata-Version: 2.4is the one compatibility risk, so it was tested rather than assumed: strictpackaging26.3 validates the result, and pip and uv both install it with the licence files landing in site-packages. forge's own METADATA handling uses the email parser and a regex, notpackaging.metadata, so the olderpackagingin the build venv is not in the path.The hard-error change landed after those runs and is verified locally instead: all nine branches of
collect_license_files()against a stub (discovery, source-shadows-recipe, sub-paths, two same-named notices, both error paths, the empty-list opt-out, and unset-still-discovers), then a realforge android:arm64-v8a flet-libiconvbuild that still bundlesCOPYING.LIBand now tags the wheel-2-. It can only fire when no licence is found, and all 27 were proven to find one in the runs above. CI on this PR covers it for real.No new lint: the two flake8 findings in
build.pypredate this change and are left alone.