diff --git a/AGENTS.md b/AGENTS.md index 2388314..51602a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,7 @@ just lock # regenerate uv.lock, requirements/*.txt and the Flatpak dep just lock-check # fail if uv.lock is stale vs pyproject.toml just distro-packages # regenerate install.d/package_map.sh from YAML just distro-packages-check # fail if the generated package map is stale -just flatpak-deps # regenerate the Flatpak dep manifest from requirements/runtime.txt (needs PyPI) +just flatpak-deps # regenerate the Flatpak dep manifest from runtime + [vad] (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) @@ -132,7 +132,7 @@ Website: `web/AGENTS.md`, `web/PRODUCT.md`, `web/DESIGN.md`. Do not duplicate si | 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 | | Native packages (.deb / .rpm) | Thin packages built by nfpm in the pinned Debian 12 image (`packaging/native/tool_checksums.txt` pins the image and the nfpm releases). Python, GTK and PyGObject come from the distro; only `pywhispercpp` and `pynput` are vendored under `/usr/lib/vocalinux/vendor` (unpackaged on Debian/Fedora). `packaging/native/smoke-test.sh` installs the package per-distro and runs `vocalinux --version` plus an import smoke — `tests/test_native_packaging.py` keeps the deps lists, vendored set and CI matrix consistent offline | -| 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 | +| Flatpak deps | `packaging/flatpak/python3-dependencies.yaml` is generated from `requirements/vad.txt` (runtime dependencies plus the `[vad]` extra) 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`; `onnxruntime` is pinned to CPython wheels for each Flatpak architecture and the GNOME 50 Python version. 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 deccf2a..ebeadfb 100644 --- a/justfile +++ b/justfile @@ -237,7 +237,7 @@ distro-packages-check: _tooling flatpak-deps: _tooling uv run --no-sync python scripts/sync_flatpak_deps.py -# Fail if the Flatpak manifest is behind requirements/runtime.txt. +# Fail if the Flatpak manifest is behind requirements/vad.txt (runtime + [vad]). flatpak-deps-check: _tooling uv run --no-sync python scripts/sync_flatpak_deps.py --check diff --git a/packaging/flatpak/README.md b/packaging/flatpak/README.md index f826d47..a5ac688 100644 --- a/packaging/flatpak/README.md +++ b/packaging/flatpak/README.md @@ -42,9 +42,10 @@ contributors (below). ## Current Scope -The manifest ships the default `whisper_cpp` engine only. VOSK is omitted because -the PyPI package is wheel-only (no sdist), so a Flathub-ready VOSK build would -need to compile VOSK and its native deps from source. +The manifest ships the default `whisper_cpp` engine and Silero VAD +(`onnxruntime`). VOSK is omitted because the PyPI package is wheel-only (no +sdist), so a Flathub-ready VOSK build would need to compile VOSK and its native +deps from source. Global keyboard shortcuts use **evdev** (`/dev/input`) on Wayland and XWayland. On native X11, they use the X11 **pynput** listener through @@ -127,9 +128,10 @@ flatpak-builder --run build-dir packaging/flatpak/com.vocalinux.Vocalinux.yml \ ## Python Dependencies -`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: +`python3-dependencies.yaml` is generated from `requirements/vad.txt`, the +hash-pinned export `just lock` writes out of `uv.lock`. This includes the +runtime dependencies plus the `[vad]` extra. Refresh it whenever the dependency +set changes: ```bash just flatpak-deps # rewrite the manifest (needs PyPI) @@ -142,7 +144,9 @@ 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. +the sdist otherwise. `onnxruntime` has no sdist, so its CPython-ABI wheel is +pinned to GNOME 50's Python version and selected separately for each Flatpak +architecture. 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 diff --git a/packaging/flatpak/python3-dependencies.yaml b/packaging/flatpak/python3-dependencies.yaml index d78de19..9927dce 100644 --- a/packaging/flatpak/python3-dependencies.yaml +++ b/packaging/flatpak/python3-dependencies.yaml @@ -1,4 +1,4 @@ -# Generated by scripts/sync_flatpak_deps.py from requirements/runtime.txt, the +# Generated by scripts/sync_flatpak_deps.py from requirements/vad.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. @@ -144,6 +144,30 @@ modules: --prefix=${FLATPAK_DEST} "numpy" --no-build-isolation sources: - *id008 + - name: python3-onnxruntime + buildsystem: simple + build-commands: + - pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}" + --prefix=${FLATPAK_DEST} "onnxruntime" --no-build-isolation + sources: + - type: file + url: https://files.pythonhosted.org/packages/83/2c/d8eb945d2a372149df9705a8d5c8d7c6c46c987c5446dbcea9e1ea7f6556/onnxruntime-1.29.0-cp313-cp313-manylinux_2_28_x86_64.whl + sha256: e2128f31f449e922c62dbe5d8b6b7b079f0bcaf2d56a102fa203cb6e5bb5ab19 + only-arches: [x86_64] + - type: file + url: https://files.pythonhosted.org/packages/c9/17/b9ad04051a8c4f504852ce0e8e10f9a6b2f1a331eedcdcc503df776dd0ea/onnxruntime-1.29.0-cp313-cp313-manylinux_2_28_aarch64.whl + sha256: d67673c5367727860922c5262d724472f1b5539fb7ccf4c81a638f9b71719803 + only-arches: [aarch64] + - type: file + url: https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl + sha256: 7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4 + - type: file + url: https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl + sha256: d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + - type: file + url: https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl + sha256: 4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9 + - *id008 - name: python3-pyaudio buildsystem: simple build-commands: diff --git a/scripts/sync_flatpak_deps.py b/scripts/sync_flatpak_deps.py index 29afa9a..0119a2b 100755 --- a/scripts/sync_flatpak_deps.py +++ b/scripts/sync_flatpak_deps.py @@ -8,12 +8,16 @@ 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. +is never evaluated and nothing there could notice. It now reads +`requirements/vad.txt`, the runtime dependencies plus the `[vad]` extra, so +Silero VAD's onnxruntime ships too (#969). 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. +then the bytes in `uv.lock`, verified by hash rather than by convention. The +`onnxruntime` sources are selected as CPython-ABI wheels for each Flatpak +architecture because PyPI publishes no sdist for it. 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 @@ -41,15 +45,14 @@ from packaging.markers import Marker from packaging.utils import canonicalize_name REPO_ROOT = Path(__file__).resolve().parents[1] -EXPORT = REPO_ROOT / "requirements" / "runtime.txt" +EXPORT = REPO_ROOT / "requirements" / "vad.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. +#: The environment the Flatpak build resolves for. GNOME 50 ships Python 3.13. +#: numpy's lock entry splits at `python_full_version >= '3.12'`; onnxruntime's +#: CPython wheel tag also uses this Python version. Bump it when the manifest's +#: runtime-version moves. TARGET_ENV = { "sys_platform": "linux", "platform_system": "Linux", @@ -67,9 +70,16 @@ TARGET_ENV = { #: `just lock` builds with --no-emit-package pygobject for the same reason. RUNTIME_PROVIDED = {"pycairo"} +#: onnxruntime has no sdist on PyPI, and building it from source is out of reach +#: in the sandbox. These come as CPython-ABI wheels, one per Flatpak arch, tagged +#: for TARGET_ENV's python_version; a runtime Python bump makes pip --no-index +#: fail loudly at build time rather than silently. +BINARY_ONLY = {"onnxruntime"} +FLATPAK_ARCHES = ("x86_64", "aarch64") + #: 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. +#: in requirements/vad.txt and stay pinned here by hand. BUILD_BACKENDS = {"meson-python", "pyproject-metadata"} PYPI_JSON = "https://pypi.org/pypi/{name}/{version}/json" @@ -116,31 +126,53 @@ def pypi_artifacts(name: str, version: str) -> list[dict]: return json.load(response)["urls"] -def choose_artifact(name: str, version: str, digests: set[str]) -> tuple[str, str]: +def choose_artifact( + name: str, version: str, digests: set[str], arch: str | None = None +) -> 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. + Binary-only packages use an architecture-specific CPython wheel. For every + other package, prefer a universal wheel and fall back to the sdist so the + sandbox builds compiled dependencies against the SDK's interpreter. """ 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"] - candidates = wheels or sdists + if name in BINARY_ONLY: + if arch not in FLATPAK_ARCHES: + raise SystemExit( + f"{name} {version}: expected one of the Flatpak architectures " + f"{', '.join(FLATPAK_ARCHES)}, got {arch!r}" + ) + python_tag = TARGET_ENV["python_version"].replace(".", "") + wheel_pattern = re.compile( + rf"-cp{python_tag}-cp{python_tag}-manylinux_.+_{re.escape(arch)}\.whl$" + ) + candidates = [ + artifact + for artifact in artifacts + if artifact["packagetype"] == "bdist_wheel" + and wheel_pattern.search(artifact["filename"]) + ] + if not candidates: + raise SystemExit( + f"{name} {version} publishes no CPython cp{python_tag} manylinux " + f"wheel for {arch}; update the Flatpak Python runtime or package" + ) + else: + wheels = [ + artifact + for artifact in artifacts + if artifact["packagetype"] == "bdist_wheel" + and artifact["filename"].endswith("-none-any.whl") + ] + sdists = [artifact for artifact in artifacts if artifact["packagetype"] == "sdist"] + candidates = wheels or sdists if not candidates: raise SystemExit( f"{name} {version} publishes neither a universal wheel nor an sdist; " "the Flatpak cannot build it without an ABI-tagged wheel" ) - # A release can publish more than one universal wheel (PySocks 1.7.1 ships - # py27 and py3 builds); take the first whose digest uv actually locked. + # A release can publish more than one matching wheel; take the first whose + # digest uv actually locked. picked = next((a for a in candidates if a["digests"]["sha256"] in digests), None) if picked is None: first = candidates[0] @@ -189,9 +221,22 @@ def main() -> int: text = DEPS_YAML.read_text(encoding="utf-8") lines = text.splitlines(keepends=True) - in_manifest = {artifact_name(name) for _, name, _ in manifest_sources(text)} + sources = manifest_sources(text) + in_manifest = {artifact_name(name) for _, name, _ in sources} stale = sorted(in_manifest - set(locked) - BUILD_BACKENDS) missing = sorted(set(locked) - in_manifest - RUNTIME_PROVIDED) + binary_arches = {name: set() for name in BINARY_ONLY} + for _, filename, _ in sources: + name = artifact_name(filename) + if name in BINARY_ONLY: + arch_match = re.search(r"_(x86_64|aarch64)\.whl$", filename) + if arch_match: + binary_arches[name].add(arch_match.group(1)) + for name in BINARY_ONLY & set(locked): + missing.extend( + f"{name} ({arch})" for arch in FLATPAK_ARCHES if arch not in binary_arches[name] + ) + missing.sort() if stale: print( f"{DEPS_YAML.name} builds packages the lock does not resolve: " @@ -213,14 +258,20 @@ def main() -> int: return 1 changes: list[str] = [] - for index, filename, old_sha in manifest_sources(text): + for index, filename, old_sha in sources: 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) + arch = None + if name in BINARY_ONLY: + arch_match = re.search(r"_(x86_64|aarch64)\.whl$", filename) + if not arch_match: + raise SystemExit(f"{name}: cannot determine a Flatpak architecture from {filename}") + arch = arch_match.group(1) + url, sha = choose_artifact(name, version, digests, arch) if sha == old_sha: continue indent = re.match(r"^(\s*)", lines[index]).group(1) @@ -265,11 +316,18 @@ def main() -> int: ) if updated == text and manifest_updated == manifest_text: - print("Flatpak dependencies already match requirements/runtime.txt") + print( + "Flatpak dependencies already match requirements/vad.txt " + "(runtime dependencies plus the [vad] extra)" + ) return 0 if args.check: - print("Flatpak dependencies are behind requirements/runtime.txt:", file=sys.stderr) + print( + "Flatpak dependencies are behind requirements/vad.txt " + "(runtime dependencies plus the [vad] extra):", + file=sys.stderr, + ) for change in changes: print(change, file=sys.stderr) if manifest_updated != manifest_text: diff --git a/tests/test_flatpak_packaging.py b/tests/test_flatpak_packaging.py index 48d3763..7852fb5 100644 --- a/tests/test_flatpak_packaging.py +++ b/tests/test_flatpak_packaging.py @@ -24,6 +24,7 @@ from pathlib import Path import pytest from packaging.markers import Marker +from packaging.requirements import Requirement from packaging.specifiers import SpecifierSet from packaging.utils import canonicalize_name from packaging.version import Version @@ -43,7 +44,7 @@ PORTABLE_GGML_OPTIONS = ( REPO_ROOT = Path(__file__).resolve().parents[1] PYPROJECT = REPO_ROOT / "pyproject.toml" -EXPORT = REPO_ROOT / "requirements" / "runtime.txt" +EXPORT = REPO_ROOT / "requirements" / "vad.txt" FLATPAK_DIR = REPO_ROOT / "packaging" / "flatpak" DEPS_YAML = FLATPAK_DIR / "python3-dependencies.yaml" MANIFEST = FLATPAK_DIR / "com.vocalinux.Vocalinux.yml" @@ -70,6 +71,8 @@ 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"} +BINARY_ONLY = {"onnxruntime"} +FLATPAK_ARCHES = ("x86_64", "aarch64") def _locked() -> dict: @@ -95,7 +98,7 @@ def _locked() -> dict: return selected -def _flatpak_sources() -> list: +def _flatpak_sources() -> list[dict[str, str | None]]: """Every PyPI artifact the Flatpak downloads: name, version, digest, filename.""" lines = DEPS_YAML.read_text(encoding="utf-8").splitlines() sources = [] @@ -106,6 +109,11 @@ def _flatpak_sources() -> list: 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" + arch_match = ( + re.match(r"^\s*only-arches: \[([a-z0-9_]+)\]\s*$", lines[index + 2]) + if index + 2 < len(lines) + else None + ) if filename.endswith(".whl"): name, version = filename.split("-")[0], filename.split("-")[1] else: @@ -117,6 +125,7 @@ def _flatpak_sources() -> list: "version": version, "sha256": digest.group(1), "filename": filename, + "only_arches": arch_match.group(1) if arch_match else None, } ) assert sources, "no PyPI sources found; the parser and the file disagree" @@ -150,7 +159,7 @@ def test_every_source_is_the_version_the_lock_resolved(source: dict[str, str]) - ) assert source["version"] == locked["version"], ( f"{source['name']}: Flatpak builds {source['version']}," - f" requirements/runtime.txt pins {locked['version']}." + f" requirements/vad.txt pins {locked['version']}." " Run `just flatpak-deps`." ) @@ -164,11 +173,11 @@ def test_every_digest_is_one_uv_recorded(source: dict[str, str]) -> None: 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`." + " requirements/vad.txt. Run `just flatpak-deps`." ) -def test_the_flatpak_carries_every_runtime_dependency() -> None: +def test_the_flatpak_carries_every_exported_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) @@ -178,6 +187,49 @@ def test_the_flatpak_carries_every_runtime_dependency() -> None: ) +def test_every_vad_extra_package_is_built_by_the_flatpak() -> None: + """Keep optional VAD dependencies in the Flatpak export (#969).""" + with PYPROJECT.open("rb") as handle: + vad_dependencies = tomllib.load(handle)["project"]["optional-dependencies"]["vad"] + vad_packages = { + canonicalize_name(Requirement(dependency).name) for dependency in vad_dependencies + } + missing = sorted(vad_packages - set(_flatpak_versions())) + assert not missing, f"the Flatpak does not build [vad] packages: {', '.join(missing)}" + + +def test_the_silero_model_is_included_as_package_data() -> None: + """The Flatpak app install must ship the model alongside Silero VAD.""" + with PYPROJECT.open("rb") as handle: + package_data = tomllib.load(handle)["tool"]["setuptools"]["package-data"] + assert "data/*.onnx" in package_data["vocalinux.speech_recognition"] + assert ( + REPO_ROOT / "src" / "vocalinux" / "speech_recognition" / "data" / "silero_vad.onnx" + ).is_file() + + +def test_each_binary_only_package_has_one_wheel_per_flatpak_architecture() -> None: + """Each binary-only wheel is restricted to its matching Flatpak architecture.""" + sources = _flatpak_sources() + for name in BINARY_ONLY: + package_sources = [ + source + for source in sources + if source["name"] == name and source["filename"] is not None + ] + for arch in FLATPAK_ARCHES: + arch_sources = [ + source for source in package_sources if source["filename"].endswith(f"_{arch}.whl") + ] + assert ( + len(arch_sources) == 1 + ), f"{name} needs exactly one wheel for {arch}, found {len(arch_sources)}" + source = arch_sources[0] + assert ( + source["only_arches"] == arch + ), f"{source['filename']} must carry only-arches: [{arch}]" + + def test_the_flatpak_builds_nothing_the_lock_does_not_resolve() -> None: """#705 deleted pydub, lxml, tqdm and python-xlib from `pyproject.toml`. @@ -266,18 +318,24 @@ def test_the_manifest_env_agrees_with_the_module() -> None: @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. + """Keep universal wheels or sdists except binary-only runtime wheels. - 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. + Binary-only wheels must match the CPython ABI of GNOME 50. An unrelated ABI + tag would make a runtime Python bump fail during the offline build. """ 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" - ) + if source["name"] in BINARY_ONLY: + python_tag = TARGET_ENV["python_version"].replace(".", "") + assert re.search( + rf"-cp{python_tag}-cp{python_tag}-manylinux_.+_(x86_64|aarch64)\.whl$", + source["filename"], + ), f"{source['filename']} is not a wheel for the Flatpak's CPython runtime" + else: + 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 _on_event_block(event: str) -> str: diff --git a/tests/test_sync_flatpak_deps.py b/tests/test_sync_flatpak_deps.py new file mode 100644 index 0000000..d66d1a3 --- /dev/null +++ b/tests/test_sync_flatpak_deps.py @@ -0,0 +1,79 @@ +"""Unit tests for the Flatpak dependency artifact selector.""" + +import importlib.util +from pathlib import Path +from typing import Any + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "sync_flatpak_deps.py" +SPEC = importlib.util.spec_from_file_location("sync_flatpak_deps", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +SYNC: Any = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(SYNC) + + +def _wheel(filename: str, digest: str) -> dict[str, object]: + """Return a small PyPI wheel record for artifact selection tests.""" + return { + "packagetype": "bdist_wheel", + "filename": filename, + "digests": {"sha256": digest}, + "url": f"https://example.invalid/{filename}", + } + + +def _install_artifacts(monkeypatch: pytest.MonkeyPatch, artifacts: list[dict[str, object]]) -> None: + """Replace the PyPI lookup with deterministic artifact records.""" + + def fake_pypi_artifacts(name: str, version: str) -> list[dict[str, object]]: + return artifacts + + monkeypatch.setattr(SYNC, "pypi_artifacts", fake_pypi_artifacts) + + +def test_binary_only_selection_uses_matching_cpython_architecture_wheel( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Select the locked cp313 x86_64 wheel, not other ABI or arch variants.""" + artifacts = [ + _wheel("onnxruntime-1.29.0-cp313-cp313t-manylinux_2_28_x86_64.whl", "a" * 64), + _wheel("onnxruntime-1.29.0-cp312-cp312-manylinux_2_28_x86_64.whl", "b" * 64), + _wheel("onnxruntime-1.29.0-cp313-cp313-manylinux_2_28_aarch64.whl", "c" * 64), + _wheel("onnxruntime-1.29.0-cp313-cp313-manylinux_2_28_x86_64.whl", "d" * 64), + ] + _install_artifacts(monkeypatch, artifacts) + + selected = SYNC.choose_artifact( + "onnxruntime", + "1.29.0", + {"a" * 64, "b" * 64, "c" * 64, "d" * 64}, + arch="x86_64", + ) + + assert selected == (artifacts[-1]["url"], "d" * 64) + + +def test_binary_only_selection_fails_when_matching_wheel_digest_is_not_locked( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Do not select a compatible wheel unless uv recorded its digest.""" + wheel = _wheel("onnxruntime-1.29.0-cp313-cp313-manylinux_2_28_x86_64.whl", "e" * 64) + _install_artifacts(monkeypatch, [wheel]) + + with pytest.raises(SystemExit, match="not among the hashes uv locked"): + SYNC.choose_artifact("onnxruntime", "1.29.0", {"f" * 64}, arch="x86_64") + + +def test_non_binary_selection_prefers_universal_wheel( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Non-binary dependencies retain the universal-wheel preference.""" + source = _wheel("sample-1.0.tar.gz", "1" * 64) + source["packagetype"] = "sdist" + universal = _wheel("sample-1.0-py3-none-any.whl", "2" * 64) + _install_artifacts(monkeypatch, [source, universal]) + + selected = SYNC.choose_artifact("sample", "1.0", {"1" * 64, "2" * 64}) + + assert selected == (universal["url"], "2" * 64)