Skip to content

forge: ship a licence with every build.sh wheel - #116

Merged
ndonkoHenri merged 12 commits into
mainfrom
wheel-licenses
Aug 24, 2026
Merged

forge: ship a licence with every build.sh wheel#116
ndonkoHenri merged 12 commits into
mainfrom
wheel-licenses

Conversation

@ndonkoHenri

Copy link
Copy Markdown

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 §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. It is inside the Jinja-rendered meta.yaml like every other key, so a recipe shipping different code per platform can gate it on sdk.
  • Metadata-Version goes 1.2 → 2.4, the floor for PEP 639's License-Expression and License-File.
  • 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.
  • Finding no licence is a hard error, not a warning — a warning here sits unread in a thousand-line log on a job that exits 0, which is how this went unnoticed for years. The error names the directories searched, the patterns tried and the fix. The likeliest way to meet it is a version bump where upstream renamed or relocated its notice, which is exactly when someone should be stopped. A recipe with genuinely nothing to ship opts out explicitly with 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-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.
  • flet-libomp gets a per-SDK expression, since it ships different code on each platform: Apache-2.0 WITH LLVM-exception on Android (LLVM's libomp.so, from the NDK) and BSD-3-Clause on iOS (the serial stub build.sh generates, which is this repo's own code). AND would be wrong — no single wheel contains both.
  • 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.

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), and flet-libxslt (Expat plus a name-in-advertising clause, so neither MIT nor X11 is both faithful and conventional, and upstream declares nothing). Each carries a comment in its meta.yaml saying 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.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.

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 real forge android:arm64-v8a flet-libiconv build that still bundles COPYING.LIB and 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.py predate this change and are left alone.

`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.
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
ndonkoHenri merged commit 09b46b7 into main Aug 24, 2026
62 of 63 checks passed
@ndonkoHenri
ndonkoHenri deleted the wheel-licenses branch August 24, 2026 20:11
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]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant