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 <jatinkrmalik@gmail.com>
This commit is contained in:
Sergey
2026-09-12 12:41:06 -07:00
committed by GitHub
co-authored by jatinkrmalik
parent 4d8c58f536
commit 1fac26ff07
11 changed files with 796 additions and 58 deletions
+8
View File
@@ -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:
+5 -2
View File
@@ -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`.
+15
View File
@@ -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
+1 -1
View File
@@ -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
+6 -2
View File
@@ -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'
+28 -9
View File
@@ -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.
@@ -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.
+38 -43
View File
@@ -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:
+294
View File
@@ -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())
+147
View File
@@ -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)}"
)
+253
View File
@@ -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"
)