From 1fac26ff0776caa68dc5ff592122b2f54e9319ed Mon Sep 17 00:00:00 2001 From: Sergey Date: Sat, 12 Sep 2026 21:41:06 +0200 Subject: [PATCH] fix(packaging): derive the Flatpak and AUR dependency sets from the lock (#819) * fix(packaging): derive the Flatpak and AUR dependency sets from the lock * test(packaging): add return annotations on new packaging tests Greptile asked for -> None on the new tests, matching AGENTS.md. * test(packaging): type parametrized Flatpak source arguments Greptile asked for type hints on the source parameter, matching AGENTS.md. --------- Co-authored-by: jatinkrmalik --- .github/workflows/flatpak.yml | 8 + AGENTS.md | 7 +- justfile | 15 + packaging/aur/build-test.sh | 2 +- packaging/aur/vocalinux/PKGBUILD | 8 +- packaging/flatpak/README.md | 37 ++- packaging/flatpak/com.vocalinux.Vocalinux.yml | 2 +- packaging/flatpak/python3-dependencies.yaml | 81 +++-- scripts/sync_flatpak_deps.py | 294 ++++++++++++++++++ tests/test_aur_packaging.py | 147 +++++++++ tests/test_flatpak_packaging.py | 253 +++++++++++++++ 11 files changed, 796 insertions(+), 58 deletions(-) create mode 100755 scripts/sync_flatpak_deps.py create mode 100644 tests/test_flatpak_packaging.py diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml index 806dc11..568e382 100644 --- a/.github/workflows/flatpak.yml +++ b/.github/workflows/flatpak.yml @@ -7,6 +7,10 @@ on: - 'packaging/flatpak/**' - 'src/**' - 'pyproject.toml' + # The dependency manifest is generated from the hash-pinned export, so a + # lock refresh moves what this build ships. Without this, `just lock` + # could change every version in it and start no Flatpak job. + - 'requirements/**' - '.github/workflows/flatpak.yml' pull_request: branches: [ main, master ] @@ -14,6 +18,10 @@ on: - 'packaging/flatpak/**' - 'src/**' - 'pyproject.toml' + # The dependency manifest is generated from the hash-pinned export, so a + # lock refresh moves what this build ships. Without this, `just lock` + # could change every version in it and start no Flatpak job. + - 'requirements/**' - '.github/workflows/flatpak.yml' workflow_dispatch: diff --git a/AGENTS.md b/AGENTS.md index 78fe909..4cedd5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,8 +80,10 @@ just test # pytest -v just test-cov # pytest --cov=src --cov-report=html just deps # sync .venv with dev+vad extras and the lint group just deps-all # also whisper/vosk/docs; later recipes use --no-sync so they keep it -just lock # regenerate uv.lock + requirements/*.txt +just lock # regenerate uv.lock, requirements/*.txt and the Flatpak dep manifest just lock-check # fail if uv.lock is stale vs pyproject.toml +just flatpak-deps # regenerate the Flatpak dep manifest from requirements/runtime.txt (needs PyPI) +just flatpak-deps-check # fail if that manifest is behind the export just model-checksums # refresh pinned model digests after adding a model just appimage # build the AppImage in its pinned base image (needs docker) just appimage-boot fedora:42 # boot that AppImage in a distro container @@ -106,7 +108,7 @@ Website: `web/AGENTS.md`, `web/PRODUCT.md`, `web/DESIGN.md`. Do not duplicate si ## Dependencies (uv) -`uv.lock` is authoritative. `just lock` regenerates it and the hash-pinned `requirements/*.txt` exports. The AppImage build installs from `runtime`/`vad` plus `appimage.txt`; `install.sh` still resolves at install time (phase 2 of #701). **Do not edit `requirements/*.txt` by hand.** Change `pyproject.toml` (or `requirements/whisper.in` for the Whisper engine, `requirements/appimage*.in` for the AppImage), run `just lock`, and commit the lock plus the exports with the manifest change. +`uv.lock` is authoritative. `just lock` regenerates it, the hash-pinned `requirements/*.txt` exports, and `packaging/flatpak/python3-dependencies.yaml`. The AppImage build installs from `runtime`/`vad` plus `appimage.txt`; `install.sh` still resolves at install time (phase 2 of #701). **Do not edit `requirements/*.txt` by hand**, nor the url/sha256 pairs in `packaging/flatpak/python3-dependencies.yaml`. Change `pyproject.toml` (or `requirements/whisper.in` for the Whisper engine, `requirements/appimage*.in` for the AppImage), run `just lock`, and commit the lock plus the exports with the manifest change. | Constraint | Rule | |---|---| @@ -118,6 +120,7 @@ Website: `web/AGENTS.md`, `web/PRODUCT.md`, `web/DESIGN.md`. Do not duplicate si | AppImage PyGObject | Pinned separately in `requirements/appimage.in`, and below 3.52: the AppImage bundles its own interpreter and builds PyGObject against the base image's girepository-1.0, while uv.lock's 3.56 needs girepository-2.0 (glib 2.80+) | | AppImage build inputs | Base image, tooling, interpreter, shaderc and the Vulkan headers are pinned in `packaging/appimage/tool_checksums.txt`. Build with `just appimage` (docker) — building on the host ships the host's glibc, which is what kept the AppImage off Debian 12 | | AppImage boot matrix | `packaging/appimage/boot-test.sh` runs the finished AppImage in distro containers, and the matrix in `unified-pipeline.yml` must keep distros both older and newer than the build image: the old ones prove the glibc floor, the new ones catch a bundle that breaks the host binaries it spawns. A distro added there needs its package recipe in the script — `tests/test_appimage_packaging.py` checks both | +| Flatpak deps | `packaging/flatpak/python3-dependencies.yaml` is generated from `requirements/runtime.txt` by `scripts/sync_flatpak_deps.py` (`just flatpak-deps`, which `just lock` runs itself). It resolves each artifact by the digest uv already recorded, so the Flatpak downloads the bytes in `uv.lock`. The project installs there with `pip3 install --no-deps`, so nothing inside the build evaluates `pyproject.toml` — `tests/test_flatpak_packaging.py` checks the invariant offline | | Speech models | Verified against digests pinned in `src/vocalinux/utils/model_checksums.txt` before install, by both `install.sh` and the runtime downloaders. **Fails closed** — an unpinned model is refused. Regenerate with `just model-checksums` (never by hand); `tests/test_model_checksums.py` fails if it falls behind. whisper.cpp URLs use a pinned Hugging Face commit, never `main` | Optional extras: `vosk`, `whisper`, `vad`, `dev`. diff --git a/justfile b/justfile index a92f431..bd69c55 100644 --- a/justfile +++ b/justfile @@ -168,11 +168,26 @@ lock: -o requirements/appimage.txt uv pip compile requirements/appimage-tools.in --universal --no-deps --generate-hashes \ -o requirements/appimage-tools.txt + # The Flatpak is a fourth copy of the dependency set, and it drifted ten + # packages behind before anything compared it to source. Regenerate it here + # so a lock refresh cannot leave it behind again. + just flatpak-deps # Fail if uv.lock is stale relative to pyproject.toml lock-check: uv lock --check +# The export pins names, versions and digests; this looks up the URL that serves +# those bytes, so it needs PyPI. tests/test_flatpak_packaging.py checks the same +# invariant offline, which is what CI gates on. +# Point the Flatpak's dependency manifest at what uv.lock resolved. +flatpak-deps: _tooling + uv run --no-sync python scripts/sync_flatpak_deps.py + +# Fail if the Flatpak manifest is behind requirements/runtime.txt. +flatpak-deps-check: _tooling + uv run --no-sync python scripts/sync_flatpak_deps.py --check + # Refresh the pinned model digests. whisper.cpp digests come from Hugging Face # `lfs` metadata and cost no bandwidth; VOSK is pinned by the bytes we fetch, so # every zip not already in the manifest is downloaded. A first run, or any run diff --git a/packaging/aur/build-test.sh b/packaging/aur/build-test.sh index 902cc46..f7dc800 100644 --- a/packaging/aur/build-test.sh +++ b/packaging/aur/build-test.sh @@ -174,7 +174,7 @@ pacman -U --noconfirm "$BUILD"/*.pkg.tar.* >/dev/null # The console script proves the wheel's entry points landed; the imports walk # every depends entry the package declares (gi/Gtk/AppIndicator, IBus, cairo, -# pyaudio, numpy, requests, tqdm, psutil, lxml, pydub, evdev, xlib, pynput). +# pyaudio, numpy, requests, tqdm, psutil, evdev, xlib, pynput). vocalinux --version python - <<'PY' || fail "the installed package does not import" import vocalinux.main diff --git a/packaging/aur/vocalinux/PKGBUILD b/packaging/aur/vocalinux/PKGBUILD index 7588826..456b262 100644 --- a/packaging/aur/vocalinux/PKGBUILD +++ b/packaging/aur/vocalinux/PKGBUILD @@ -26,8 +26,6 @@ depends=( 'python-requests' 'python-tqdm' 'python-psutil' - 'python-lxml' - 'python-pydub' 'python-evdev' 'python-xlib' 'hicolor-icon-theme' @@ -45,6 +43,12 @@ optdepends=( 'python-pywhispercpp-cuda: NVIDIA GPU whisper.cpp backend' 'python-pywhispercpp-rocm: AMD GPU whisper.cpp backend' 'python-onnxruntime: Silero VAD' + # One line per selectable engine. Without it the engine is offered in + # Settings and fails at import, which is how vosk and whisper shipped too. + 'python-vosk: Vosk engine' + 'python-openai-whisper: OpenAI Whisper engine' + 'python-sherpa-onnx: Parakeet TDT 0.6B engine' + 'python-faster-whisper: faster-whisper engine' 'xdotool: X11 injection fallback' 'xclip: X11 clipboard tools (copy/paste injection fallbacks)' 'wl-clipboard: Wayland clipboard (wl-copy/wl-paste) injection fallbacks' diff --git a/packaging/flatpak/README.md b/packaging/flatpak/README.md index 8e87a9f..e48b54c 100644 --- a/packaging/flatpak/README.md +++ b/packaging/flatpak/README.md @@ -74,19 +74,38 @@ flatpak-builder --run build-dir packaging/flatpak/com.vocalinux.Vocalinux.yml \ ## Python Dependencies -`python3-dependencies.yaml` is generated with `flatpak-pip-generator` (offline -builds). Regenerate when `pyproject.toml` deps change: +`python3-dependencies.yaml` is generated from `requirements/runtime.txt`, the +hash-pinned export `just lock` writes out of `uv.lock`. Refresh it whenever the +dependency set changes: ```bash -pipx run flatpak-pip-generator \ - --runtime org.gnome.Sdk//50 \ - --yaml \ - --ignore-pkg 'vosk>=0.3.45' "PyGObject; sys_platform == 'linux'" \ - --output packaging/flatpak/python3-dependencies \ - pywhispercpp pydub pynput evdev requests tqdm numpy pyaudio python-xlib psutil lxml \ - meson-python pyproject-metadata +just flatpak-deps # rewrite the manifest (needs PyPI) +just flatpak-deps-check # report drift, change nothing ``` +`just lock` runs the first of those itself, so a normal dependency change needs +no extra step. + +The script resolves each package's URL by looking up the digest uv already +recorded, so the bytes this build downloads are the bytes in `uv.lock`, not +merely the same version number. It takes a universal wheel where one exists and +the sdist otherwise; an ABI-tagged wheel would pin the build to one SDK Python. + +This replaced a `flatpak-pip-generator` invocation whose package list was typed +out on the command line. Nothing regenerated it and no test compared it to +anything, so it drifted: by 2026-09-10 ten of its fifteen shared packages were +behind the lock, `pydub` and `lxml` were still built four months after #705 +deleted them for having no imports, and pywhispercpp sat at 1.4.1 under the +project's own `>=1.5.0`. The project is installed here with +`pip3 install --no-deps`, so nothing inside the build could notice. +`tests/test_flatpak_packaging.py` is what notices now, and it runs offline. + +Two things in that file are hand-written and the script preserves them, because +no generator produces them: pywhispercpp's Vulkan build, which injects a version +into `setup.py` (the sdist ships no `version.txt`, `setup()` takes no `version=` +argument, and setuptools-scm has no git tree to read in the sandbox), and the +symlinking of whisper.cpp's shared libraries onto the loader path. + `python3-build-dependencies.yaml` is a small hand-maintained helper so `--no-build-isolation` builds can import `mesonpy` before NumPy is built. diff --git a/packaging/flatpak/com.vocalinux.Vocalinux.yml b/packaging/flatpak/com.vocalinux.Vocalinux.yml index a5f34f8..c8b20ec 100644 --- a/packaging/flatpak/com.vocalinux.Vocalinux.yml +++ b/packaging/flatpak/com.vocalinux.Vocalinux.yml @@ -7,7 +7,7 @@ command: vocalinux build-options: env: NO_REPAIR: "1" - PYWHISPERCPP_VERSION: "1.4.1" + PYWHISPERCPP_VERSION: "1.5.0" finish-args: # Download speech-recognition models on first run. diff --git a/packaging/flatpak/python3-dependencies.yaml b/packaging/flatpak/python3-dependencies.yaml index 9af234f..ac34ce5 100644 --- a/packaging/flatpak/python3-dependencies.yaml +++ b/packaging/flatpak/python3-dependencies.yaml @@ -1,4 +1,12 @@ -# Generated with flatpak-pip-generator --runtime org.gnome.Sdk//50 --yaml --ignore-pkg vosk>=0.3.45 PyGObject; sys_platform == 'linux' --output /tmp/vocalinux-flatpak-gen3/python3-dependencies pywhispercpp pydub pynput evdev requests tqdm numpy pyaudio python-xlib psutil lxml meson-python pyproject-metadata +# Generated by scripts/sync_flatpak_deps.py from requirements/runtime.txt, the +# hash-pinned export `just lock` writes out of uv.lock. Run `just flatpak-deps` +# after any dependency change; `just flatpak-deps-check` and +# tests/test_flatpak_packaging.py fail when this file falls behind. +# +# Do not hand-edit the url/sha256 pairs. Every other line is hand-written and +# the script preserves it: pywhispercpp needs a Vulkan build, a version +# injected into setup.py, and whisper.cpp's shared libraries symlinked onto +# the loader path, none of which a generator produces. name: python3-dependencies buildsystem: simple build-commands: [] @@ -10,10 +18,15 @@ modules: # the offline Flatpak build, so inject the version explicitly. --no-same-owner # stops GNU tar from trying to restore the archive's uid inside the build # sandbox (where chown to an arbitrary uid is not permitted). - - tar --no-same-owner -xzf pywhispercpp-1.4.1.tar.gz - - python3 -c "from pathlib import Path; p = Path('pywhispercpp-1.4.1/setup.py'); s = p.read_text(); p.write_text(s.replace('setup(\\n name=\"pywhispercpp\",', 'setup(\\n name=\"pywhispercpp\",\\n version=\"1.4.1\",'))" - - tar --owner=0 --group=0 -czf pywhispercpp-1.4.1.tar.gz pywhispercpp-1.4.1 - - PYWHISPERCPP_VERSION=1.4.1 NO_REPAIR=1 GGML_VULKAN=1 pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}" + - tar --no-same-owner -xzf pywhispercpp-1.5.0.tar.gz + # The build runs under --no-build-isolation, where setuptools-scm is absent, + # so a replace that matched nothing would not fail -- it would produce a wheel + # carrying version 0.0.0. recognition_manager.py reads that metadata and + # degrades GPU context_params below the floor in pyproject.toml, silently. + # Assert the shape instead. + - python3 -c "from pathlib import Path; p = Path('pywhispercpp-1.5.0/setup.py'); s = p.read_text(); n = 'setup(\\n name=\"pywhispercpp\",'; assert n in s, 'pywhispercpp setup.py no longer matches the injection point'; p.write_text(s.replace(n, n + '\\n version=\"1.5.0\",'))" + - tar --owner=0 --group=0 -czf pywhispercpp-1.5.0.tar.gz pywhispercpp-1.5.0 + - PYWHISPERCPP_VERSION=1.5.0 NO_REPAIR=1 GGML_VULKAN=1 pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}" --prefix=${FLATPAK_DEST} "pywhispercpp" --no-build-isolation # whisper.cpp installs its shared libraries next to the extension module. # Symlink them onto the default loader path so the extension resolves them @@ -29,47 +42,38 @@ modules: sources: - &id002 type: file - url: https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl - sha256: 3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a + url: https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl + sha256: 62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 - &id003 type: file - url: https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl - sha256: 3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d + url: https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl + sha256: 6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6 - &id004 type: file - url: https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl - sha256: 892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3 + url: https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl + sha256: 815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4 - &id008 type: file - url: https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz - sha256: 2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0 + url: https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz + sha256: d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860 - type: file - url: https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl - sha256: e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917 + url: https://files.pythonhosted.org/packages/19/a9/c34aebedd3a4c9afe5101b1b8713710b3fec18087c8a36c35d2f909861bd/platformdirs-4.11.3-py3-none-any.whl + sha256: 5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7 - type: file - url: https://files.pythonhosted.org/packages/f6/7d/fe4b2b190d6cdcf0d5e4ca2b946d73bc449a586606a24d25ff50ffec5a5c/pywhispercpp-1.4.1.tar.gz - sha256: 520ce9275bb6fc81e0daa2e08144a80ee006b117a18058a77860c5b1c9c122f4 + url: https://files.pythonhosted.org/packages/1c/43/8778f0685a4d32d74efdfa78fc5f745ca9998d96f8121dbf7793db2da5d3/pywhispercpp-1.5.0.tar.gz + sha256: 5aa54c73ec96617d97aa519dbbf817e44255eddd9bd52ee1243be61028dbd018 - &id005 type: file - url: https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl - sha256: 4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a + url: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl + sha256: 2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 - &id007 type: file - url: https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl - sha256: ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf + url: https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl + sha256: 7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953 - &id006 type: file - url: https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl - sha256: bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4 - - name: python3-pydub - buildsystem: simple - build-commands: - - pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}" - --prefix=${FLATPAK_DEST} "pydub" --no-build-isolation - sources: - - type: file - url: https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl - sha256: 65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6 + url: https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl + sha256: 9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 - name: python3-pynput buildsystem: simple build-commands: @@ -81,8 +85,8 @@ modules: url: https://files.pythonhosted.org/packages/a5/f5/397b61091120a9ca5001041dd7bf76c385b3bfd67a0e5bcb74b852bd22a4/evdev-1.9.3.tar.gz sha256: 2c140e01ac8437758fa23fe5c871397412461f42d421aa20241dc8fe8cfccbc9 - type: file - url: https://files.pythonhosted.org/packages/59/4f/ac3fa906ae8a375a536b12794128c5efacade9eaa917a35dfd27ce0c7400/pynput-1.8.1-py2.py3-none-any.whl - sha256: 42dfcf27404459ca16ca889c8fb8ffe42a9fe54f722fd1a3e130728e59e768d2 + url: https://files.pythonhosted.org/packages/6d/98/bbeb760852adb27f166ce1617f0e51aabb15f21b1e60ea703f2aed3c78ac/pynput-1.8.2-py2.py3-none-any.whl + sha256: 8cc38cf13a6ab2749cb375678be8a0fd705d7ce49c8001ff5db4007a723bbef1 - &id009 type: file url: https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl @@ -149,15 +153,6 @@ modules: - type: file url: https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz sha256: 0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372 - - name: python3-lxml - buildsystem: simple - build-commands: - - pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}" - --prefix=${FLATPAK_DEST} "lxml" --no-build-isolation - sources: - - type: file - url: https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz - sha256: bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13 - name: python3-meson-python buildsystem: simple build-commands: diff --git a/scripts/sync_flatpak_deps.py b/scripts/sync_flatpak_deps.py new file mode 100755 index 0000000..4dd88e1 --- /dev/null +++ b/scripts/sync_flatpak_deps.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +"""Point the Flatpak's dependency manifest at the versions uv.lock resolved. + +`packaging/flatpak/python3-dependencies.yaml` was produced once by +flatpak-pip-generator, from a package list typed out by hand on the command +line recorded in its first line, and then hand-edited. Nothing regenerated it +afterwards and no test compared it to anything, so it drifted: by 2026-09-10 ten +of its fifteen shared packages were behind `requirements/runtime.txt`, including +pywhispercpp, which sat at 1.4.1 under the project's own `>=1.5.0` floor. The +Flatpak installs the project with `pip3 install --no-deps`, so that constraint +is never evaluated and nothing there could notice. + +This script closes the loop the other way round: it reads the hash-pinned export +that `just lock` produces and rewrites every source in the manifest to the +artifact whose digest uv already recorded. The bytes the Flatpak downloads are +then the bytes in `uv.lock`, verified by hash rather than by convention. + +It edits in place rather than regenerating, because two things in that file are +hand-written and no generator produces them: pywhispercpp's Vulkan build with +its version injection, and the symlinking of whisper.cpp's shared libraries onto +the loader path. Keeping the structure keeps the diff to versions and digests. + +Usage: + python scripts/sync_flatpak_deps.py # rewrite the manifest + python scripts/sync_flatpak_deps.py --check # report drift, change nothing + +`tests/test_flatpak_packaging.py` asserts the same invariant offline, so CI +catches drift without reaching PyPI. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import urllib.request +from pathlib import Path + +from packaging.markers import Marker +from packaging.utils import canonicalize_name + +REPO_ROOT = Path(__file__).resolve().parents[1] +EXPORT = REPO_ROOT / "requirements" / "runtime.txt" +DEPS_YAML = REPO_ROOT / "packaging" / "flatpak" / "python3-dependencies.yaml" +MANIFEST = REPO_ROOT / "packaging" / "flatpak" / "com.vocalinux.Vocalinux.yml" + +#: The environment the Flatpak build resolves for. `flatpak remote-info flathub +#: org.gnome.Sdk//50 --show-metadata` reports a freedesktop 25.08 base, which +#: ships Python 3.13. Only numpy reads this today: the lock splits it at +#: `python_full_version >= '3.12'`. Everything else is marked on sys_platform +#: alone. Bump it when the manifest's runtime-version moves. +TARGET_ENV = { + "sys_platform": "linux", + "platform_system": "Linux", + "platform_machine": "x86_64", + "os_name": "posix", + "python_version": "3.13", + "python_full_version": "3.13.0", + "implementation_name": "cpython", + "platform_python_implementation": "CPython", + "extra": "", +} + +#: org.gnome.Platform//50 ships these; a second copy under /app would shadow the +#: runtime's own GI stack. PyGObject is already absent from the export, which +#: `just lock` builds with --no-emit-package pygobject for the same reason. +RUNTIME_PROVIDED = {"pycairo"} + +#: Build backends for the sdists the manifest builds offline under +#: --no-build-isolation. They are not runtime dependencies, so they never appear +#: in requirements/runtime.txt and stay pinned here by hand. +BUILD_BACKENDS = {"meson-python", "pyproject-metadata"} + +PYPI_JSON = "https://pypi.org/pypi/{name}/{version}/json" + + +def parse_export(path: Path) -> dict[str, tuple[str, set[str]]]: + """Map every package the Flatpak target selects to its version and digests. + + uv writes one `name==version [; marker]` line followed by indented + `--hash=sha256:` lines, continued with backslashes. A package can appear + more than once under mutually exclusive markers, which is how numpy is + split across Python versions, so the marker decides rather than the order. + """ + selected: dict[str, tuple[str, set[str]]] = {} + current: str | None = None + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip().rstrip("\\").strip() + if not line or line.startswith("#"): + continue + head = re.match(r"^([A-Za-z0-9._-]+)==([^ ;]+)\s*(?:;\s*(.*))?$", line) + if head: + name, version, marker = head.group(1), head.group(2), head.group(3) + current = None + if marker and not Marker(marker).evaluate(TARGET_ENV): + continue + key = canonicalize_name(name) + if key in selected: + raise SystemExit( + f"{path.name}: {key} resolves twice for the Flatpak target " + f"({selected[key][0]} and {version}); the markers overlap" + ) + selected[key] = (version, set()) + current = key + continue + digest = re.match(r"^--hash=sha256:([0-9a-f]{64})$", line) + if digest and current: + selected[current][1].add(digest.group(1)) + return selected + + +def pypi_artifacts(name: str, version: str) -> list[dict]: + url = PYPI_JSON.format(name=name, version=version) + with urllib.request.urlopen(url, timeout=30) as response: # noqa: S310 + return json.load(response)["urls"] + + +def choose_artifact(name: str, version: str, digests: set[str]) -> tuple[str, str]: + """The one file the Flatpak should download, as (url, sha256). + + Prefer a universal wheel, fall back to the sdist. That is the rule the + original generator followed and it holds for every package here: the pure + Python ones ship `py3-none-any`, and the compiled ones (numpy, evdev, + PyAudio, psutil, pywhispercpp) ship no universal wheel, so the sandbox + builds them from source against the SDK's own interpreter. Never an + ABI-tagged wheel, whose cp tag would pin us to one SDK Python. + """ + artifacts = pypi_artifacts(name, version) + wheels = [ + a + for a in artifacts + if a["packagetype"] == "bdist_wheel" and a["filename"].endswith("-none-any.whl") + ] + sdists = [a for a in artifacts if a["packagetype"] == "sdist"] + chosen = wheels or sdists + if not chosen: + raise SystemExit( + f"{name} {version} publishes neither a universal wheel nor an sdist; " + "the Flatpak cannot build it without an ABI-tagged wheel" + ) + picked = chosen[0] + sha = picked["digests"]["sha256"] + if sha not in digests: + raise SystemExit( + f"{name} {version}: PyPI serves {picked['filename']} with digest " + f"{sha}, which is not among the hashes uv locked. Re-run `just lock`." + ) + return picked["url"], sha + + +def artifact_name(filename: str) -> str: + if filename.endswith(".whl"): + return canonicalize_name(filename.split("-")[0]) + base = re.sub(r"\.(tar\.gz|zip|tar\.bz2)$", "", filename) + return canonicalize_name(base.rpartition("-")[0]) + + +def manifest_sources(text: str) -> list[tuple[int, str, str]]: + """Every PyPI source in the manifest, as (line index, filename, sha256).""" + lines = text.splitlines() + found = [] + for index, line in enumerate(lines): + url = re.match(r"^\s*url: (https://files\.pythonhosted\.org/\S+/([^/\s]+))$", line) + if not url: + continue + digest = re.match(r"^\s*sha256: ([0-9a-f]{64})\s*$", lines[index + 1]) + if not digest: + raise SystemExit( + f"{DEPS_YAML.name}:{index + 2}: a PyPI source with no sha256 beneath it" + ) + found.append((index, url.group(2), digest.group(1))) + return found + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", + action="store_true", + help="report what would change and exit non-zero, without writing", + ) + args = parser.parse_args() + + locked = parse_export(EXPORT) + text = DEPS_YAML.read_text(encoding="utf-8") + lines = text.splitlines(keepends=True) + + in_manifest = {artifact_name(name) for _, name, _ in manifest_sources(text)} + stale = sorted(in_manifest - set(locked) - BUILD_BACKENDS) + missing = sorted(set(locked) - in_manifest - RUNTIME_PROVIDED) + if stale: + print( + f"{DEPS_YAML.name} builds packages the lock does not resolve: " + f"{', '.join(stale)}\n" + "Delete their modules and sources by hand: whether a package is a " + "build backend the sandbox needs or a leftover this script cannot " + "tell.", + file=sys.stderr, + ) + if missing: + print( + f"{DEPS_YAML.name} is missing packages the lock resolves: " + f"{', '.join(missing)}\n" + "The Flatpak installs with --no-deps, so a missing one fails at " + "runtime rather than at build time. Add a module for each.", + file=sys.stderr, + ) + if stale or missing: + return 1 + + changes: list[str] = [] + for index, filename, old_sha in manifest_sources(text): + name = artifact_name(filename) + if name in BUILD_BACKENDS: + # Absent from the lock by definition: nothing imports them at + # runtime, they only let the sandbox build the sdists offline. + continue + version, digests = locked[name] + url, sha = choose_artifact(name, version, digests) + if sha == old_sha: + continue + indent = re.match(r"^(\s*)", lines[index]).group(1) + lines[index] = f"{indent}url: {url}\n" + lines[index + 1] = f"{indent}sha256: {sha}\n" + changes.append(f" {name}: {filename} -> {url.rsplit('/', 1)[-1]}") + + updated = "".join(lines) + + # pywhispercpp's version is spelled out in its build commands: the sdist + # carries no version.txt and setup() takes no version= argument, so the + # module rewrites setup.py before building. setuptools-scm would otherwise + # read a git tree that does not exist in the sandbox. + pywhispercpp_version = locked["pywhispercpp"][0] + updated, injected = re.subn( + # The version appears as a tarball name, a directory name and a + # literal inside the setup.py rewrite. Refuse to match rather than + # match short: a partial match would leave the tail of the old version + # glued to the new one and rename the directory tar just wrote. + r"pywhispercpp-\d+(?:\.\d+)*(?![\d.]\d)", + f"pywhispercpp-{pywhispercpp_version}", + updated, + ) + updated = re.sub( + r"PYWHISPERCPP_VERSION=\d+(?:\.\d+)*", + f"PYWHISPERCPP_VERSION={pywhispercpp_version}", + updated, + ) + updated = re.sub( + r'(version=\\")\d+(?:\.\d+)*(\\")', + rf"\g<1>{pywhispercpp_version}\g<2>", + updated, + ) + if injected and updated != text and not changes: + changes.append(f" pywhispercpp: build commands -> {pywhispercpp_version}") + + manifest_text = MANIFEST.read_text(encoding="utf-8") + manifest_updated = re.sub( + r'(PYWHISPERCPP_VERSION: ")\d+(?:\.\d+)*(")', + rf"\g<1>{pywhispercpp_version}\g<2>", + manifest_text, + ) + + if updated == text and manifest_updated == manifest_text: + print("Flatpak dependencies already match requirements/runtime.txt") + return 0 + + if args.check: + print("Flatpak dependencies are behind requirements/runtime.txt:", file=sys.stderr) + for change in changes: + print(change, file=sys.stderr) + if manifest_updated != manifest_text: + print( + f" {MANIFEST.name}: PYWHISPERCPP_VERSION -> {pywhispercpp_version}", + file=sys.stderr, + ) + print("Run `just flatpak-deps` to update.", file=sys.stderr) + return 1 + + DEPS_YAML.write_text(updated, encoding="utf-8") + MANIFEST.write_text(manifest_updated, encoding="utf-8") + print(f"Updated {DEPS_YAML.relative_to(REPO_ROOT)}:") + for change in changes: + print(change) + if manifest_updated != manifest_text: + print( + f"Updated {MANIFEST.relative_to(REPO_ROOT)}: " + f"PYWHISPERCPP_VERSION={pywhispercpp_version}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_aur_packaging.py b/tests/test_aur_packaging.py index 8e16560..04faf14 100644 --- a/tests/test_aur_packaging.py +++ b/tests/test_aur_packaging.py @@ -6,6 +6,7 @@ first place, because no paths filter matched packaging/aur/**. """ import re +import tomllib from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] @@ -172,3 +173,149 @@ def test_the_pkgbuild_builds_without_isolation_on_distro_setuptools(): changes what the gate proves, not a slip to make in passing.""" pkgbuild = PKGBUILD.read_text(encoding="utf-8") assert "--no-isolation" in pkgbuild + + +#: Arch names in depends=() that are not Python distributions from our lock: +#: the GI stack and the C libraries behind it. `python-gobject` and +#: `python-cairo` are Python packages, but they come from Arch rather than from +#: the export -- `just lock` builds it with --no-emit-package pygobject, and +#: PyGObject cannot be pip-installed on a user machine anyway. +NOT_FROM_THE_LOCK = { + "python", + "python-gobject", + "python-cairo", + "gtk3", + "libayatana-appindicator", + "ibus", + "gobject-introspection", + "portaudio", + "hicolor-icon-theme", +} + +#: Arch name -> PyPI name, where stripping `python-` does not get there. +ARCH_TO_PYPI = { + "python-pywhispercpp": "pywhispercpp", + "python-pyaudio": "pyaudio", + # Arch keeps the distribution name, which already starts with `python-`. + "python-xlib": "python-xlib", +} + + +def _locked_distributions() -> set: + """PyPI names in the hash-pinned runtime export, canonicalized. + + Read the resolved set, not `pyproject.toml`'s direct one. #705 deleted + tqdm and python-xlib from the direct list, but pywhispercpp still needs the + first and pynput the second, so both are still installed on every machine + and the PKGBUILD is right to name them. Comparing against the direct list + would call for deleting two dependencies Arch users need. + """ + names = set() + for line in RUNTIME_EXPORT.read_text(encoding="utf-8").splitlines(): + match = re.match(r"^([A-Za-z0-9._-]+)==", line) + if match: + names.add(match.group(1).lower().replace("_", "-")) + assert names, "requirements/runtime.txt parsed to nothing" + return names + + +def _direct_dependencies() -> set: + """`[project].dependencies` from pyproject.toml, canonicalized. + + PyGObject is dropped: Arch ships it as python-gobject, which is already in + NOT_FROM_THE_LOCK, and it is absent from the export for the same reason. + """ + with (REPO_ROOT / "pyproject.toml").open("rb") as handle: + specs = tomllib.load(handle)["project"]["dependencies"] + names = { + re.split(r"[<>=!~\[; ]", spec, maxsplit=1)[0].strip().lower().replace("_", "-") + for spec in specs + } + return names - {"pygobject"} + + +def _pypi_name(arch_name: str) -> str: + """PyPI distribution behind an Arch package name, minus any version bound. + + depends=() entries may carry a constraint (`python>=3.11`); pacman treats + the name and the bound separately and so must this. + """ + bare = re.split(r"[<>=]", arch_name, maxsplit=1)[0] + if bare in ARCH_TO_PYPI: + return ARCH_TO_PYPI[bare] + return bare.removeprefix("python-").lower().replace("_", "-") + + +def test_depends_declares_no_python_package_nothing_depends_on() -> None: + """#705 deleted pydub, lxml, tqdm and python-xlib from `pyproject.toml` + and left all four here, so AUR users installed four packages for nothing. + + Two of them came back through the dependency chain and belong here; pydub + and lxml had no path back and stayed for four months. #772's build gate + could not see it: `makepkg` verifies that every depends entry *resolves*, + never that anything needs it, and the depends test above compares the + gate's stub to this list rather than either to source. + """ + orphans = sorted( + name + for name in _aur_depends() + if re.split(r"[<>=]", name, maxsplit=1)[0] not in NOT_FROM_THE_LOCK + and _pypi_name(name) not in _locked_distributions() + ) + assert not orphans, ( + f"depends=() names packages the lock does not resolve: {', '.join(orphans)}." + " Drop them, or add the dependency to pyproject.toml and re-run `just lock`." + ) + + +def test_depends_covers_every_direct_dependency() -> None: + """The other direction, and against the direct list rather than the + resolved one. + + pacman resolves transitive dependencies itself, so certifi, idna, six and + urllib3 have no business in depends=(); naming them would pin us to + requests' internals. What must be here is what `src/` imports directly. A + dependency added to `pyproject.toml` and not here builds fine, because + `python -m build` never reads depends=(), and then fails at import on a + user's machine. + """ + declared = {_pypi_name(name) for name in _aur_depends()} + missing = sorted( + dist + for dist in _direct_dependencies() + if dist not in declared and f"python-{dist}" not in NOT_FROM_THE_LOCK + ) + assert ( + not missing + ), f"pyproject.toml requires these and depends=() omits them: {', '.join(missing)}" + + +def test_every_selectable_engine_reaches_optdepends() -> None: + """Each optional extra is an engine a user can pick in Settings. One with + no optdepends line is an engine the AUR install cannot run and does not + say so. #802 and #543 both landed without one.""" + with (REPO_ROOT / "pyproject.toml").open("rb") as handle: + extras = tomllib.load(handle)["project"]["optional-dependencies"] + #: Extras that are tooling, not engines. Everything else a user can pick + #: in Settings, and a new engine extra must land here without this test + #: being edited: an allowlist would let it slip past silently, which is + #: exactly how #802 and #543 shipped. + not_engines = {"dev", "docs"} + lines = PKGBUILD.read_text(encoding="utf-8").splitlines() + start = next(i for i, line in enumerate(lines) if line.startswith("optdepends=(")) + end = next(i for i in range(start + 1, len(lines)) if lines[i].strip() == ")") + optdepends = "\n".join(lines[start + 1 : end]) + + missing = [] + for extra in sorted(set(extras) - not_engines): + for spec in extras[extra]: + dist = re.split(r"[<>=!~\[; ]", spec, maxsplit=1)[0].strip().lower() + if dist in {"torch", "torchaudio"}: + continue # pulled in by python-openai-whisper, not named alone + arch_name = f"python-{dist.replace('_', '-')}" + if arch_name not in optdepends: + missing.append(f"{extra} -> {arch_name}") + assert not missing, ( + "these optional engines have no optdepends line, so an AUR user who" + f" selects them gets an ImportError: {', '.join(missing)}" + ) diff --git a/tests/test_flatpak_packaging.py b/tests/test_flatpak_packaging.py new file mode 100644 index 0000000..cf30c27 --- /dev/null +++ b/tests/test_flatpak_packaging.py @@ -0,0 +1,253 @@ +"""Tie the Flatpak's dependency set back to the lock that everything else uses. + +`packaging/flatpak/python3-dependencies.yaml` was generated once from a package +list typed on a command line, then hand-edited, then never compared to anything. +By 2026-09-10 ten of its fifteen shared packages were behind +`requirements/runtime.txt` and two more (pydub, lxml) were still built after +#705 deleted them for having no imports in `src/`. + +The one that mattered was pywhispercpp 1.4.1, under the project's own +`>=1.5.0`. Nothing could catch it: the Flatpak installs the project with +`pip3 install --no-deps`, so the constraint in `pyproject.toml` is never +evaluated, and `recognition_manager.py` degrades GPU `context_params` below +1.5.0, so Flatpak users lost that quietly rather than loudly. + +`packaging/appimage/build.sh` has done this check for the AppImage since #743. +These are the Flatpak's, and they run offline: `scripts/sync_flatpak_deps.py` +needs PyPI to look a URL up, but everything asserted here is already in the +repo. +""" + +import re +import tomllib +from pathlib import Path + +import pytest +from packaging.markers import Marker +from packaging.specifiers import SpecifierSet +from packaging.utils import canonicalize_name +from packaging.version import Version + +REPO_ROOT = Path(__file__).resolve().parents[1] +PYPROJECT = REPO_ROOT / "pyproject.toml" +EXPORT = REPO_ROOT / "requirements" / "runtime.txt" +FLATPAK_DIR = REPO_ROOT / "packaging" / "flatpak" +DEPS_YAML = FLATPAK_DIR / "python3-dependencies.yaml" +MANIFEST = FLATPAK_DIR / "com.vocalinux.Vocalinux.yml" +WORKFLOW = REPO_ROOT / ".github" / "workflows" / "flatpak.yml" + +#: Kept in step with scripts/sync_flatpak_deps.py, which resolves the same +#: markers when it picks a version. GNOME 50 is a freedesktop 25.08 base. +TARGET_ENV = { + "sys_platform": "linux", + "platform_system": "Linux", + "platform_machine": "x86_64", + "os_name": "posix", + "python_version": "3.13", + "python_full_version": "3.13.0", + "implementation_name": "cpython", + "platform_python_implementation": "CPython", + "extra": "", +} + +#: org.gnome.Platform//50 ships these. PyGObject is already absent from the +#: export, which `just lock` builds with --no-emit-package pygobject. +RUNTIME_PROVIDED = {"pycairo"} + +#: Build backends for the sdists the sandbox compiles under +#: --no-build-isolation. Never imported at runtime, so never in the export. +BUILD_BACKENDS = {"meson-python", "pyproject-metadata"} + + +def _locked() -> dict: + """Every package the export selects for the Flatpak target: version, digests.""" + selected = {} + current = None + for raw in EXPORT.read_text(encoding="utf-8").splitlines(): + line = raw.strip().rstrip("\\").strip() + if not line or line.startswith("#"): + continue + head = re.match(r"^([A-Za-z0-9._-]+)==([^ ;]+)\s*(?:;\s*(.*))?$", line) + if head: + name, version, marker = head.groups() + current = None + if marker and not Marker(marker).evaluate(TARGET_ENV): + continue + current = canonicalize_name(name) + selected[current] = {"version": version, "hashes": set()} + continue + digest = re.match(r"^--hash=sha256:([0-9a-f]{64})$", line) + if digest and current: + selected[current]["hashes"].add(digest.group(1)) + return selected + + +def _flatpak_sources() -> list: + """Every PyPI artifact the Flatpak downloads: name, version, digest, filename.""" + lines = DEPS_YAML.read_text(encoding="utf-8").splitlines() + sources = [] + for index, line in enumerate(lines): + url = re.match(r"^\s*url: https://files\.pythonhosted\.org/\S+/([^/\s]+)$", line) + if not url: + continue + filename = url.group(1) + digest = re.match(r"^\s*sha256: ([0-9a-f]{64})\s*$", lines[index + 1]) + assert digest, f"{DEPS_YAML.name}:{index + 2}: PyPI source with no sha256 under it" + if filename.endswith(".whl"): + name, version = filename.split("-")[0], filename.split("-")[1] + else: + base = re.sub(r"\.(tar\.gz|zip|tar\.bz2)$", "", filename) + name, _, version = base.rpartition("-") + sources.append( + { + "name": canonicalize_name(name), + "version": version, + "sha256": digest.group(1), + "filename": filename, + } + ) + assert sources, "no PyPI sources found; the parser and the file disagree" + return sources + + +def _flatpak_versions() -> dict: + return {source["name"]: source["version"] for source in _flatpak_sources()} + + +def test_the_project_is_installed_without_deps() -> None: + """The reason every other test here has to exist. + + `--no-deps` is correct (the runtime provides PyGObject and the modules + below provide the rest), but it means pip never reads `pyproject.toml`'s + constraints. Nothing inside a Flatpak build can notice a dependency that + violates them, so the check has to live out here. + """ + assert "pip3 install --no-deps" in MANIFEST.read_text(encoding="utf-8") + + +@pytest.mark.parametrize("source", _flatpak_sources(), ids=lambda s: s["name"]) +def test_every_source_is_the_version_the_lock_resolved(source: dict[str, str]) -> None: + """A version here that uv did not resolve is a package nothing tested.""" + if source["name"] in BUILD_BACKENDS: + pytest.skip("build backend; absent from the runtime export by design") + locked = _locked().get(source["name"]) + assert locked, ( + f"{source['name']} is built by the Flatpak but the lock does not" + " resolve it for Linux. Drop its module, or add it to pyproject.toml." + ) + assert source["version"] == locked["version"], ( + f"{source['name']}: Flatpak builds {source['version']}," + f" requirements/runtime.txt pins {locked['version']}." + " Run `just flatpak-deps`." + ) + + +@pytest.mark.parametrize("source", _flatpak_sources(), ids=lambda s: s["name"]) +def test_every_digest_is_one_uv_recorded(source: dict[str, str]) -> None: + """The bytes the Flatpak fetches are the bytes in uv.lock, not merely the + same version number. A digest uv never saw is an artifact nothing pinned.""" + if source["name"] in BUILD_BACKENDS: + pytest.skip("build backend; absent from the runtime export by design") + hashes = _locked()[source["name"]]["hashes"] + assert source["sha256"] in hashes, ( + f"{source['filename']} carries a digest absent from" + " requirements/runtime.txt. Run `just flatpak-deps`." + ) + + +def test_the_flatpak_carries_every_runtime_dependency() -> None: + """A missing one fails at first launch, not at build time, because the + project is installed with --no-deps and nothing resolves the gap.""" + missing = sorted(set(_locked()) - set(_flatpak_versions()) - RUNTIME_PROVIDED) + assert not missing, ( + "the lock resolves these for Linux and the Flatpak does not build them:" + f" {', '.join(missing)}" + ) + + +def test_the_flatpak_builds_nothing_the_lock_does_not_resolve() -> None: + """#705 deleted pydub, lxml, tqdm and python-xlib from `pyproject.toml`. + + Two of those four came back through the dependency chain: pywhispercpp + needs tqdm and pynput needs python-xlib, so the lock still resolves them + and the Flatpak must still build them. pydub and lxml are the ones with no + path back, and they stayed here for four months. Compare against the + resolved set rather than the direct one, or this test deletes two packages + the Flatpak needs. + """ + extra = sorted(set(_flatpak_versions()) - set(_locked()) - BUILD_BACKENDS) + assert not extra, ( + f"the Flatpak builds packages nothing depends on: {', '.join(extra)}." + " Delete their modules from python3-dependencies.yaml." + ) + + +def test_pywhispercpp_satisfies_the_projects_own_floor() -> None: + """The finding this file was written for. Stated as the constraint rather + than a literal, so bumping the floor cannot leave the check behind it.""" + with PYPROJECT.open("rb") as handle: + dependencies = tomllib.load(handle)["project"]["dependencies"] + spec = next(d for d in dependencies if canonicalize_name(d.split(">")[0]) == "pywhispercpp") + constraint = SpecifierSet(spec[len("pywhispercpp") :]) + shipped = _flatpak_versions()["pywhispercpp"] + assert Version(shipped) in constraint, ( + f"the Flatpak ships pywhispercpp {shipped}, outside the project's own" + f" {constraint}. recognition_manager.py degrades GPU context_params" + " below 1.5.0, so this is silent for the user." + ) + + +def test_the_version_injection_names_the_version_being_built() -> None: + """pywhispercpp's sdist carries no version.txt and its setup() takes no + version= argument, so the module rewrites setup.py before building and + spells the version out four times. All four have to agree with the tarball + actually downloaded, or the build renames a directory tar just wrote. + """ + module = re.search( + r"^ - name: python3-pywhispercpp$(.*?)(?=^ - name: |\Z)", + DEPS_YAML.read_text(encoding="utf-8"), + re.M | re.S, + ) + assert module, "the pywhispercpp module is gone; this test needs rewriting" + spelled = set(re.findall(r"pywhispercpp-(\d+(?:\.\d+)+)(?![\d.]\d)", module.group(1))) + spelled |= set(re.findall(r"PYWHISPERCPP_VERSION=(\d+(?:\.\d+)+)", module.group(1))) + spelled |= set(re.findall(r'version=\\"(\d+(?:\.\d+)+)\\"', module.group(1))) + assert spelled == {_flatpak_versions()["pywhispercpp"]}, ( + f"the pywhispercpp module spells out {sorted(spelled)}; the tarball it" + f" downloads is {_flatpak_versions()['pywhispercpp']}" + ) + + +def test_the_manifest_env_agrees_with_the_module() -> None: + """build-options.env sets PYWHISPERCPP_VERSION for the whole build. Left + behind, it tells the build one version while the module builds another.""" + declared = re.search(r'PYWHISPERCPP_VERSION: "([^"]+)"', MANIFEST.read_text(encoding="utf-8")) + assert declared, "the manifest no longer sets PYWHISPERCPP_VERSION" + assert declared.group(1) == _flatpak_versions()["pywhispercpp"] + + +@pytest.mark.parametrize("source", _flatpak_sources(), ids=lambda s: s["name"]) +def test_no_source_is_pinned_to_one_python_abi(source: dict[str, str]) -> None: + """Universal wheel or sdist, never a cp-tagged wheel. + + The SDK's Python minor version is not ours to choose and moves with the + GNOME runtime. An abi3 or cp313 wheel builds today and stops resolving the + day the runtime bumps, with nothing in this repo changed. + """ + if not source["filename"].endswith(".whl"): + return + assert source["filename"].endswith("-none-any.whl"), ( + f"{source['filename']} is pinned to one interpreter ABI; take the sdist" + " and let the sandbox build it against the SDK's own Python" + ) + + +def test_a_lock_refresh_reaches_the_flatpak_build() -> None: + """The manifest is generated from requirements/, so a change there changes + what the Flatpak ships. Without the path filter, `just lock` could move + every version in it and start no Flatpak job.""" + text = WORKFLOW.read_text(encoding="utf-8") + assert text.count("'requirements/**'") >= 2, ( + "flatpak.yml does not watch requirements/**, so a lock refresh changes" + " what the Flatpak builds and runs no build" + )