fix(snap): build from locked python inputs and verify artifacts (#1041)
* fix(snap): build and verify payload from locked python inputs * docs(snap): document locked inputs and artifact verification * test(clipboard): isolate restore workers between tests * test(packaging): tighten isolation and clarify snap audit guarantees * fix(ci): run snap recipe tests on snap-only changes A change to snap/snapcraft.yaml or snap/local/smoke-test.sh only matched the snap filter, so the Python tests that guard the recipe (including the portable whisper.cpp flags from #957) and the smoke script were skipped. Match all of snap/ in the python filter, as scripts/ already is. * docs(snap): use a version placeholder in the smoke-test example --------- Co-authored-by: Jatin <jatinkrmalik@gmail.com>
This commit is contained in:
@@ -1028,7 +1028,8 @@ jobs:
|
||||
# Secret: SNAPCRAFT_STORE_CREDENTIALS (snapcraft export-login).
|
||||
build-snap:
|
||||
name: Build Snap
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -1039,6 +1040,11 @@ jobs:
|
||||
id: build
|
||||
uses: snapcore/action-build@3bdaa03e1ba6bf59a65f84a751d943d549a54e79 # v1
|
||||
|
||||
- name: Verify the installed snap
|
||||
env:
|
||||
SNAP_FILE: ${{ steps.build.outputs.snap }}
|
||||
run: bash snap/local/smoke-test.sh "$SNAP_FILE"
|
||||
|
||||
- name: Upload the snap
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
|
||||
@@ -25,6 +25,7 @@ jobs:
|
||||
python: ${{ steps.filter.outputs.python }}
|
||||
aur: ${{ steps.filter.outputs.aur }}
|
||||
native: ${{ steps.filter.outputs.native }}
|
||||
snap: ${{ steps.filter.outputs.snap }}
|
||||
deploy_website: ${{ steps.deploy_check.outputs.deploy }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -57,6 +58,10 @@ jobs:
|
||||
# lint job: a scripts-only change that skipped this filter would
|
||||
# skip both the checker and the tests that validate it.
|
||||
- 'scripts/**'
|
||||
# Same for snap/: tests/test_snap_*.py check the recipe, the
|
||||
# portable whisper.cpp flags (#957) and smoke-test.sh, and the
|
||||
# lint job checks snap/local/*.py.
|
||||
- 'snap/**'
|
||||
- '.pre-commit-config.yaml'
|
||||
- '.github/workflows/unified-pipeline.yml'
|
||||
- '.github/workflows/release.yml'
|
||||
@@ -78,6 +83,13 @@ jobs:
|
||||
native:
|
||||
- 'packaging/native/**'
|
||||
- '.github/workflows/unified-pipeline.yml'
|
||||
snap:
|
||||
- 'snap/**'
|
||||
- 'MANIFEST.in'
|
||||
- 'README.md'
|
||||
- 'LICENSE'
|
||||
- '.github/workflows/release.yml'
|
||||
- '.github/workflows/unified-pipeline.yml'
|
||||
- name: Check if website should deploy
|
||||
id: deploy_check
|
||||
env:
|
||||
@@ -157,15 +169,15 @@ jobs:
|
||||
# tool versions are the ones `just lint` uses locally.
|
||||
- name: Lint with flake8 (critical errors)
|
||||
run: |
|
||||
uv run --locked --only-group lint flake8 src/ tests/ --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
uv run --locked --only-group lint flake8 src/ tests/ snap/local/ --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
|
||||
- name: Check formatting with black
|
||||
run: |
|
||||
uv run --locked --only-group lint black --check --diff src/ tests/
|
||||
uv run --locked --only-group lint black --check --diff src/ tests/ snap/local/
|
||||
|
||||
- name: Check import sorting with isort
|
||||
run: |
|
||||
uv run --locked --only-group lint isort --check-only --diff --profile black src/ tests/
|
||||
uv run --locked --only-group lint isort --check-only --diff --profile black src/ tests/ snap/local/
|
||||
|
||||
- name: Type check (mypy)
|
||||
run: |
|
||||
@@ -501,6 +513,33 @@ jobs:
|
||||
"${{ matrix.distro }}" \
|
||||
bash /pk/smoke-test.sh
|
||||
|
||||
# Build the same recipe as release.yml and inspect the installed artifact.
|
||||
# No Store credentials or publication are needed, including on fork PRs.
|
||||
snap-build-test:
|
||||
name: 📦 Build and install Snap (gate)
|
||||
runs-on: ubuntu-24.04
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.snap == 'true'
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Build snap
|
||||
id: build
|
||||
uses: snapcore/action-build@3bdaa03e1ba6bf59a65f84a751d943d549a54e79 # v1
|
||||
- name: Verify the installed snap
|
||||
env:
|
||||
SNAP_FILE: ${{ steps.build.outputs.snap }}
|
||||
run: bash snap/local/smoke-test.sh "$SNAP_FILE"
|
||||
- name: Upload the verified snap
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: vocalinux-snap-amd64
|
||||
path: ${{ steps.build.outputs.snap }}
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
web-build:
|
||||
name: 🌐 Build Website
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -137,6 +137,7 @@ Use **uv 0.13.3** for `just lock` and `just export-check`, matching CI. Other ve
|
||||
| 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 |
|
||||
| Snap Python inputs | `snap/local/build.py` uses `requirements/installer-build.txt` in an isolated, build-only venv and installs `requirements/runtime.txt` with hashes, no dependency resolution or build isolation. The project installs without dependencies or index access; never strip PyGObject from `pyproject.toml`. GI comes from the GNOME extension. Keep portable CPU flags and force the pinned pywhispercpp sdist without a wheel cache. PR/release CI installs the real artifact via `snap/local/smoke-test.sh` before publication. Native Ubuntu/GNOME/Snapcraft inputs remain channel-controlled, not byte-for-byte reproducible — see `docs/SNAP.md` |
|
||||
| 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` |
|
||||
|
||||
@@ -241,7 +242,7 @@ Site-only work: `web/AGENTS.md` (commands, layout map). Product claims: `web/PRO
|
||||
|
||||
## Runtime (any Linux dev machine)
|
||||
|
||||
- Create runtime/dev venvs with `--system-site-packages`. Build-only venvs for `scripts/build_reproducible_wheel.py` and `just aur-gate` preparation are the exception: exclude host packages to keep the locked release tools isolated; never prune the developer environment to run a packaging gate
|
||||
- Create runtime/dev venvs with `--system-site-packages`. Build-only venvs for `scripts/build_reproducible_wheel.py`, `just aur-gate` preparation and `snap/local/build.py` are the exception: exclude host packages to keep the locked tools isolated; the Snap runtime gets GI from its GNOME command chain. Never prune the developer environment to run a packaging gate
|
||||
- Kill the app by PID, never `pkill -f vocalinux` (matches editors, shells, and cwd paths)
|
||||
- Stale instance: `$XDG_DATA_HOME/vocalinux/instance.lock` (default `~/.local/share/vocalinux/instance.lock`)
|
||||
- Default dictation shortcut: hold Right Alt (push-to-talk). Existing `~/.config/vocalinux/config.json` wins
|
||||
|
||||
@@ -170,6 +170,9 @@ Desktop shortcuts only toggle dictation, so they cannot support push-to-talk.
|
||||
|
||||
Listing: [snapcraft.io/vocalinux](https://snapcraft.io/vocalinux) (issue [#48](https://github.com/VocaHQ/vocalinux/issues/48)). Recipe: `snap/snapcraft.yaml`. Tagged `v*` releases attach `vocalinux_<version>_amd64.snap` to the GitHub Release and try Snap Store `edge` and `candidate` when credentials are set. `stable` is promoted from `candidate` after QA.
|
||||
|
||||
Build inputs, the PR/release artifact gate, and its verification limits are
|
||||
documented in [SNAP.md](SNAP.md).
|
||||
|
||||
Store install, once Canonical lists the revision:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -451,7 +451,9 @@ After pushing the tag, the GitHub Actions workflow will automatically:
|
||||
`FLATPAK_GPG_PRIVATE_KEY` + `FLATPAK_REPO_TOKEN` are configured — see
|
||||
docs/FLATPAK_REMOTE.md)
|
||||
8. Deploy the website to vocalinux.com
|
||||
9. Build the amd64 snap, attach it to the GitHub Release, and try Snap Store
|
||||
9. Build the amd64 snap from locked Python exports and run the installed-artifact
|
||||
gate described in [SNAP.md](SNAP.md), then attach it to the GitHub Release and
|
||||
try Snap Store
|
||||
`edge`/`candidate`. Store human review (for example a super-privileged
|
||||
`allow-installation` plug) must not block the GitHub `.snap`. `stable` does not auto-release (#783):
|
||||
after candidate QA, dispatch `snap-promote.yml`, which releases the
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Snap build inputs and verification
|
||||
|
||||
The supported Snap build is **amd64, core24 (Ubuntu 24.04 / Python 3.12),
|
||||
strict confinement, GNOME extension**. whisper.cpp is built for the portable
|
||||
CPU baseline, not Vulkan/CUDA. Optional local engines are not bundled.
|
||||
User installation and interface connections are documented in
|
||||
[INSTALL.md](INSTALL.md#snap-ubuntu-snap-store).
|
||||
|
||||
## Python inputs
|
||||
|
||||
`uv.lock` owns Python versions. The Snap reuses these generated exports:
|
||||
|
||||
- `requirements/installer-build.txt`: the complete hash-pinned build toolchain,
|
||||
installed wheel-only into a temporary, isolated build virtualenv;
|
||||
- `requirements/runtime.txt`: the runtime pins, platform markers and SHA-256
|
||||
hashes, already excluding PyGObject.
|
||||
|
||||
`snap/local/build.py` replaces the Python plugin's implicit resolution. A distro
|
||||
`ensurepip` seeds the build environment offline; its first network operation
|
||||
installs the locked toolchain with `--require-hashes --no-deps`. Runtime
|
||||
dependencies use the same flags plus `--no-build-isolation`. Native builds find
|
||||
the locked CMake/Ninja on PATH. pywhispercpp always builds from its pinned sdist,
|
||||
without a wheel cache, with the portable `GGML_*` CPU options in the recipe.
|
||||
|
||||
The project installs with `--no-deps --no-build-isolation --no-index`. Neither
|
||||
`pyproject.toml` nor its dependency metadata is patched. PyGObject/GTK come from
|
||||
the GNOME platform, not pip. The packaged virtualenv contains no pip or build
|
||||
toolchain. Its launchers are relocatable and prioritize the locked Python
|
||||
packages over incidental copies in the GNOME platform.
|
||||
|
||||
The build audits build-tool and installed runtime versions against the active
|
||||
export pins and records expected runtime versions and both export digests in
|
||||
`usr/share/vocalinux/python-inputs.json` inside the snap. Missing or wrong-version
|
||||
build tools fail the build. For the packaged runtime, extra and duplicate
|
||||
Python distributions are rejected as well.
|
||||
|
||||
Regenerate exports with the repository's `just lock` workflow, not by editing
|
||||
them or adding independent pins to `snapcraft.yaml`. `just export-check` checks
|
||||
them against `uv.lock` in CI.
|
||||
|
||||
## Build and artifact gate
|
||||
|
||||
On a Linux machine with Snapcraft and LXD or Multipass:
|
||||
|
||||
```bash
|
||||
snapcraft pack
|
||||
```
|
||||
|
||||
On a **disposable Ubuntu host with snapd**, inspect the resulting artifact:
|
||||
|
||||
```bash
|
||||
bash snap/local/smoke-test.sh ./vocalinux_<version>_amd64.snap
|
||||
```
|
||||
|
||||
Replace `<version>` with the version that was built. The script refuses to overwrite an
|
||||
existing Vocalinux snap. It installs the supplied file with `--dangerous`,
|
||||
runs the actual application launcher with `--version`, then enters the GNOME
|
||||
command-chain environment to audit Python versions and import the native,
|
||||
GTK, recognition and text-injection modules. It leaves the installation in
|
||||
place for inspection; use a disposable host, not your normal Vocalinux install.
|
||||
|
||||
`unified-pipeline.yml` builds and runs this gate on relevant Python/Snap PRs and
|
||||
main pushes, including recipe-only changes. `release.yml` runs it before the
|
||||
snap artifact can be attached or sent to the Store. Both use the same pinned
|
||||
builder action and Ubuntu 24.04 runner. PR checks need no Store credentials and
|
||||
never publish. Historical-tag backfills retain the tagged recipe's behavior.
|
||||
|
||||
## Deliberate limits
|
||||
|
||||
- This pins Python downloads and build tools, **not every native input**.
|
||||
Ubuntu packages are controlled by the core24/Ubuntu archive policy; the
|
||||
GNOME SDK/platform, Snapcraft and base snaps follow their upstream channels.
|
||||
Their exact revisions and native package versions remain visible in the
|
||||
Snapcraft build records. They are not an immutable snapshot owned by our
|
||||
Python lock.
|
||||
- This is not a claim of byte-for-byte reproducible `.snap` archives. There is
|
||||
no independent Snap rebuild comparison, and native inputs can change.
|
||||
- The smoke imports real modules under confinement but does not download a
|
||||
model, run inference, capture audio, test shortcuts, or type into an app.
|
||||
Candidate desktop QA and promotion to stable remain separate operations;
|
||||
this gate does not trigger `snap-promote.yml`.
|
||||
@@ -77,18 +77,18 @@ test-cov: _tooling
|
||||
# Run linters (flake8, black, isort)
|
||||
lint: _tooling
|
||||
@echo "Running flake8..."
|
||||
uv run --no-sync flake8 src/ tests/ --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
uv run --no-sync flake8 src/ tests/ snap/local/ --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
@echo "Checking black formatting..."
|
||||
uv run --no-sync black --check --diff src/ tests/
|
||||
uv run --no-sync black --check --diff src/ tests/ snap/local/
|
||||
@echo "Checking isort..."
|
||||
uv run --no-sync isort --check-only --diff --profile black src/ tests/
|
||||
uv run --no-sync isort --check-only --diff --profile black src/ tests/ snap/local/
|
||||
|
||||
# Auto-format code (black + isort)
|
||||
format: _tooling
|
||||
@echo "Formatting with black..."
|
||||
uv run --no-sync black src/ tests/
|
||||
uv run --no-sync black src/ tests/ snap/local/
|
||||
@echo "Sorting imports with isort..."
|
||||
uv run --no-sync isort --profile black src/ tests/
|
||||
uv run --no-sync isort --profile black src/ tests/ snap/local/
|
||||
|
||||
# Run type checking (mypy)
|
||||
typecheck: _tooling
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install locked Python inputs without changing the project's dependency metadata.
|
||||
|
||||
Snapcraft's default Python plugin resolves the project and its build environment
|
||||
again. Use a separate, hash-pinned build virtualenv instead, and install only the
|
||||
runtime export and the unchanged project into the snap's relocatable virtualenv.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import venv
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run(argv: list[str], source: Path, env: dict[str, str]) -> None:
|
||||
"""Run a build command, preserving its output and failure status."""
|
||||
print(f"+ {' '.join(argv)}", flush=True)
|
||||
subprocess.run(argv, cwd=source, env=env, check=True)
|
||||
|
||||
|
||||
def relocate_scripts(install: Path, tooling: Path) -> None:
|
||||
"""Remove temporary interpreter paths, including pip's long-shebang shim."""
|
||||
prefix = str(tooling).encode()
|
||||
for script in (install / "bin").iterdir():
|
||||
if script.is_symlink() or not script.is_file():
|
||||
continue
|
||||
lines = script.read_bytes().splitlines(keepends=True)
|
||||
if lines and lines[0].startswith(b"#!" + prefix + b"/"):
|
||||
script.write_bytes(b"#!/usr/bin/env python3\n" + b"".join(lines[1:]))
|
||||
elif (
|
||||
len(lines) >= 3
|
||||
and lines[0] == b"#!/bin/sh\n"
|
||||
and lines[1].startswith(b"'''exec' ")
|
||||
and prefix in lines[1]
|
||||
and lines[2] == b"' '''\n"
|
||||
):
|
||||
script.write_bytes(b"#!/usr/bin/env python3\n" + b"".join(lines[3:]))
|
||||
|
||||
# GNOME's command chain adds platform Python paths. Our locked packages
|
||||
# must win over incidental copies in that platform, while GI stays there.
|
||||
python_version = f"python{sys.version_info.major}.{sys.version_info.minor}"
|
||||
launcher = (
|
||||
"#!/usr/bin/env python3\n"
|
||||
"import sys\n"
|
||||
"from pathlib import Path\n"
|
||||
f"sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "
|
||||
f"'lib/{python_version}/site-packages'))\n"
|
||||
"from vocalinux.main import main\n"
|
||||
"if __name__ == '__main__':\n"
|
||||
" sys.exit(main())\n"
|
||||
)
|
||||
for name in ("vocalinux", "vocalinux-gui"):
|
||||
path = install / "bin" / name
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise RuntimeError(f"Missing installed entry point: {path}")
|
||||
path.write_text(launcher, encoding="utf-8")
|
||||
|
||||
|
||||
def build(source: Path, install: Path, work: Path) -> None:
|
||||
"""Build using committed exports; never stage the build tools or a new GI."""
|
||||
metadata = source / "pyproject.toml"
|
||||
original_metadata = metadata.read_bytes()
|
||||
for export in ("installer-build.txt", "runtime.txt"):
|
||||
if not (source / "requirements" / export).is_file():
|
||||
raise RuntimeError(f"Missing locked export: requirements/{export}")
|
||||
|
||||
env = {
|
||||
key: value
|
||||
for key, value in os.environ.items()
|
||||
if not key.startswith("PIP_") and key not in {"PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV"}
|
||||
}
|
||||
env["PIP_CONFIG_FILE"] = os.devnull
|
||||
env["PYTHONNOUSERSITE"] = "1"
|
||||
work.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="snap-python-", dir=work) as temporary:
|
||||
tooling = Path(temporary)
|
||||
# Both environments are build outputs, not the contributor's dev venv.
|
||||
# ensurepip seeds pip from the distro; the first network operation
|
||||
# replaces it with the complete hash-pinned installer toolchain.
|
||||
venv.EnvBuilder(with_pip=True, symlinks=True).create(tooling)
|
||||
python = str(tooling / "bin" / "python3")
|
||||
env["PATH"] = f"{tooling / 'bin'}{os.pathsep}{env['PATH']}"
|
||||
pip = [
|
||||
python,
|
||||
"-m",
|
||||
"pip",
|
||||
"--isolated",
|
||||
"--disable-pip-version-check",
|
||||
"install",
|
||||
"--no-cache-dir",
|
||||
]
|
||||
run(
|
||||
pip
|
||||
+ [
|
||||
"--require-hashes",
|
||||
"--no-deps",
|
||||
"--only-binary=:all:",
|
||||
"--upgrade",
|
||||
"-r",
|
||||
str(source / "requirements/installer-build.txt"),
|
||||
],
|
||||
source,
|
||||
env,
|
||||
)
|
||||
venv.EnvBuilder(with_pip=False, symlinks=True).create(install)
|
||||
target = ["--prefix", str(install), "--ignore-installed", "--no-compile"]
|
||||
run(
|
||||
pip
|
||||
+ target
|
||||
+ [
|
||||
"--require-hashes",
|
||||
"--no-deps",
|
||||
"--no-build-isolation",
|
||||
# A cached upstream wheel can require AVX2/FMA (#957).
|
||||
"--no-binary=pywhispercpp",
|
||||
"-r",
|
||||
str(source / "requirements/runtime.txt"),
|
||||
],
|
||||
source,
|
||||
env,
|
||||
)
|
||||
run(
|
||||
pip + target + ["--no-deps", "--no-build-isolation", "--no-index", str(source)],
|
||||
source,
|
||||
env,
|
||||
)
|
||||
relocate_scripts(install, tooling)
|
||||
run(
|
||||
[
|
||||
python,
|
||||
str(source / "snap/local/check_runtime.py"),
|
||||
"--root",
|
||||
str(install),
|
||||
"--record",
|
||||
str(source),
|
||||
],
|
||||
source,
|
||||
env,
|
||||
)
|
||||
if metadata.read_bytes() != original_metadata:
|
||||
raise RuntimeError("The Snap build modified pyproject.toml")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Build the Python payload inside Snapcraft's core24 environment."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--source", type=Path, required=True)
|
||||
parser.add_argument("--install", type=Path, required=True)
|
||||
parser.add_argument("--work", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
if sys.platform != "linux" or sys.version_info[:2] != (3, 12):
|
||||
parser.error("Build this core24 snap with Linux's system Python 3.12")
|
||||
try:
|
||||
build(args.source.resolve(), args.install.resolve(), args.work.resolve())
|
||||
except (OSError, RuntimeError, subprocess.CalledProcessError) as error:
|
||||
parser.exit(1, f"Snap Python build failed: {error}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Record locked Python versions at build time and audit the installed Snap.
|
||||
|
||||
Only recording uses packaging, from the locked build toolchain. The packaged
|
||||
audit needs only the standard library and the runtime it is checking.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib
|
||||
import importlib.metadata
|
||||
import json
|
||||
import re
|
||||
import runpy
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
MANIFEST = Path("usr/share/vocalinux/python-inputs.json")
|
||||
|
||||
|
||||
def normalize(name: str) -> str:
|
||||
"""Normalize a Python distribution name."""
|
||||
return re.sub(r"[-_.]+", "-", name).lower()
|
||||
|
||||
|
||||
def locked_versions(export: Path) -> dict[str, str]:
|
||||
"""Evaluate the complete export for the build interpreter, rejecting GI."""
|
||||
from packaging.requirements import Requirement
|
||||
|
||||
expected = {}
|
||||
blocks = re.split(r"(?=^[A-Za-z0-9_.-]+==)", export.read_text(), flags=re.MULTILINE)[1:]
|
||||
if not blocks:
|
||||
raise RuntimeError(f"No pinned requirements in {export}")
|
||||
for block in blocks:
|
||||
requirement = Requirement(re.split(r"\s+--hash=|\\", block, maxsplit=1)[0].strip())
|
||||
if not re.search(r"--hash=sha256:[0-9a-f]{64}(?:\s|$)", block):
|
||||
raise RuntimeError(f"Missing SHA-256 for {requirement.name}")
|
||||
name = normalize(requirement.name)
|
||||
if name == "pygobject":
|
||||
raise RuntimeError("GI must come from the GNOME extension, not pip")
|
||||
if requirement.marker and not requirement.marker.evaluate():
|
||||
continue
|
||||
pins = list(requirement.specifier)
|
||||
if len(pins) != 1 or pins[0].operator != "==" or "*" in pins[0].version:
|
||||
raise RuntimeError(f"Not an exact pin: {requirement}")
|
||||
if name in expected:
|
||||
raise RuntimeError(f"Multiple active pins for {name}")
|
||||
expected[name] = pins[0].version
|
||||
if not expected:
|
||||
raise RuntimeError(f"No active requirements in {export}")
|
||||
return expected
|
||||
|
||||
|
||||
def audit(root: Path, expected: dict[str, str]) -> Path:
|
||||
"""Check every bundled distribution, including missing/extra/build packages."""
|
||||
site = root / f"lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages"
|
||||
actual = {}
|
||||
for distribution in importlib.metadata.distributions(path=[str(site)]):
|
||||
name = normalize(distribution.metadata["Name"])
|
||||
if name in actual:
|
||||
raise RuntimeError(f"Duplicate installed distribution: {name}")
|
||||
actual[name] = distribution.version
|
||||
if actual != expected:
|
||||
differences = {
|
||||
name: {"expected": expected.get(name), "installed": actual.get(name)}
|
||||
for name in sorted(expected.keys() | actual.keys())
|
||||
if expected.get(name) != actual.get(name)
|
||||
}
|
||||
raise RuntimeError(f"Snap Python versions differ from the lock: {differences}")
|
||||
return site
|
||||
|
||||
|
||||
def record(root: Path, source: Path) -> None:
|
||||
"""Retain the expected versions and original export digests in the payload."""
|
||||
for name, expected_version in locked_versions(
|
||||
source / "requirements/installer-build.txt"
|
||||
).items():
|
||||
if importlib.metadata.version(name) != expected_version:
|
||||
raise RuntimeError(f"Build tool {name} differs from its locked version")
|
||||
expected = locked_versions(source / "requirements/runtime.txt")
|
||||
expected["vocalinux"] = runpy.run_path(str(source / "src/vocalinux/version.py"))["__version__"]
|
||||
audit(root, expected)
|
||||
manifest = {
|
||||
"python": list(sys.version_info[:2]),
|
||||
"packages": expected,
|
||||
"exports": {
|
||||
name: hashlib.sha256((source / "requirements" / name).read_bytes()).hexdigest()
|
||||
for name in ("runtime.txt", "installer-build.txt")
|
||||
},
|
||||
}
|
||||
destination = root / MANIFEST
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def smoke(root: Path) -> None:
|
||||
"""Audit a packaged runtime and import native/UI modules without a model."""
|
||||
manifest = json.loads((root / MANIFEST).read_text())
|
||||
if manifest["python"] != list(sys.version_info[:2]):
|
||||
raise RuntimeError("The Snap is running with a different Python ABI")
|
||||
site = audit(root, manifest["packages"])
|
||||
sys.path.insert(0, str(site))
|
||||
for name in ("numpy", "requests", "psutil", "pyaudio", "cairo", "evdev", "vocalinux"):
|
||||
module = importlib.import_module(name)
|
||||
if not module.__file__ or not Path(module.__file__).resolve().is_relative_to(
|
||||
site.resolve()
|
||||
):
|
||||
raise RuntimeError(f"{name} is shadowed by a platform/host package")
|
||||
from vocalinux.utils.pywhispercpp_loader import preload_shared_libraries
|
||||
|
||||
preload_shared_libraries()
|
||||
for name in (
|
||||
"pywhispercpp.model",
|
||||
"vocalinux.main",
|
||||
"vocalinux.ui.tray_indicator",
|
||||
"vocalinux.speech_recognition.recognition_manager",
|
||||
"vocalinux.text_injection.text_injector",
|
||||
):
|
||||
importlib.import_module(name)
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk # noqa: F401
|
||||
|
||||
if Path(gi.__file__).resolve().is_relative_to(site.resolve()):
|
||||
raise RuntimeError("The Snap bundled GI instead of using the GNOME platform")
|
||||
print("PASS: locked Snap Python versions, native modules and GNOME GTK import")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Record build inputs or check the installed, confined artifact."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, required=True)
|
||||
parser.add_argument("--record", type=Path)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.record:
|
||||
record(args.root, args.record)
|
||||
else:
|
||||
smoke(args.root)
|
||||
except (OSError, ValueError, KeyError, RuntimeError, ImportError) as error:
|
||||
parser.exit(1, f"Snap runtime verification failed: {error}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install the actual artifact on a disposable Ubuntu host with snapd. This is
|
||||
# not desktop QA: no microphone, model download, hotkey or typing is exercised.
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
|
||||
echo "Usage: bash snap/local/smoke-test.sh PATH.snap" >&2
|
||||
exit 1
|
||||
fi
|
||||
if snap list vocalinux >/dev/null 2>&1; then
|
||||
echo "Refusing to replace an existing Vocalinux snap; use a disposable host" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sudo snap install --dangerous "$1"
|
||||
snap run vocalinux --version
|
||||
# --shell does not run the application's command chain. Explicitly enter the
|
||||
# same GNOME desktop-launch environment before checking the installed payload.
|
||||
PYNPUT_BACKEND=dummy snap run --shell vocalinux -c '
|
||||
exec "$SNAP/snap/command-chain/desktop-launch" "$SNAP/bin/python3" \
|
||||
"$SNAP/usr/lib/vocalinux/check_runtime.py" --root "$SNAP"
|
||||
'
|
||||
+12
-13
@@ -6,7 +6,7 @@
|
||||
#
|
||||
# Build (from repo root, with snapcraft + LXD or Multipass):
|
||||
# snapcraft pack
|
||||
# See docs/INSTALL.md for pack and store upload steps.
|
||||
# See docs/SNAP.md for controlled inputs, the build gate and verification limits.
|
||||
|
||||
name: vocalinux
|
||||
base: core24 # Ubuntu 24.04 / Python 3.12 (project requires-python >=3.11)
|
||||
@@ -20,7 +20,7 @@ description: |
|
||||
|
||||
Speech recognition runs on your machine after you download a model. No Voca account. No cloud transcription on the default path.
|
||||
|
||||
This snap ships the whisper.cpp engine plus VOSK. The optional OpenAI Whisper / PyTorch stack is not bundled (too large for the snap).
|
||||
This snap ships the whisper.cpp engine. Optional local speech engines are not bundled.
|
||||
|
||||
Features:
|
||||
- System tray control and GTK settings
|
||||
@@ -104,28 +104,27 @@ layout:
|
||||
|
||||
parts:
|
||||
vocalinux:
|
||||
plugin: python
|
||||
# The Python plugin would resolve dependencies and build tools again.
|
||||
plugin: nil
|
||||
source: .
|
||||
source-type: local
|
||||
build-environment:
|
||||
- PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
||||
# pywhispercpp's PyPI wheel ships a libggml-cpu compiled for AVX2+FMA,
|
||||
# which dies with SIGILL at model load on pre-Haswell CPUs (#957), so
|
||||
# the pinned sdist is built instead of installing that wheel.
|
||||
# GGML_NATIVE=OFF alone is not the portable baseline either: while
|
||||
# ggml's native default is on it turns every GGML_* SIMD option on, so
|
||||
# each one is pinned off for the plain x86-64 baseline.
|
||||
- PIP_NO_BINARY: pywhispercpp
|
||||
- CMAKE_ARGS: "-DGGML_NATIVE=OFF -DGGML_SSE42=OFF -DGGML_AVX=OFF -DGGML_AVX2=OFF -DGGML_BMI2=OFF -DGGML_FMA=OFF -DGGML_F16C=OFF"
|
||||
build-packages:
|
||||
- build-essential
|
||||
- cmake
|
||||
- git
|
||||
- pkg-config
|
||||
- portaudio19-dev
|
||||
- libgirepository1.0-dev
|
||||
- libcairo2-dev
|
||||
- python3-dev
|
||||
- python3-venv
|
||||
- libdbus-1-dev
|
||||
stage-packages:
|
||||
# Only extras not provided by the gnome-46-2404 platform content snap.
|
||||
@@ -141,18 +140,18 @@ parts:
|
||||
- xsel
|
||||
- xclip
|
||||
- wl-clipboard
|
||||
python-packages:
|
||||
- pip
|
||||
- setuptools
|
||||
- wheel
|
||||
override-pull: |
|
||||
craftctl default
|
||||
version="$(python3 -c "import pathlib, re; t = pathlib.Path('${CRAFT_PART_SRC}/src/vocalinux/version.py').read_text(); m = re.search(r'__version__\\s*=\\s*[\\'\"]([^\\'\"]+)', t); assert m, 'version not found'; print(m.group(1))")"
|
||||
craftctl set version="$version"
|
||||
# PyGObject/GI provided by gnome extension.
|
||||
sed -i '/^[[:space:]]*"PyGObject/d' "${CRAFT_PART_SRC}/pyproject.toml"
|
||||
override-build: |
|
||||
craftctl default
|
||||
# The runtime export already excludes PyGObject; GI comes from the gnome
|
||||
# extension. Keep pyproject.toml and installed metadata unchanged.
|
||||
/usr/bin/python3 "${CRAFT_PART_SRC}/snap/local/build.py" \
|
||||
--source "${CRAFT_PART_BUILD}" --install "${CRAFT_PART_INSTALL}" \
|
||||
--work "${CRAFT_PART_BUILD}/.snap-python-build"
|
||||
install -Dm644 "${CRAFT_PART_SRC}/snap/local/check_runtime.py" \
|
||||
"${CRAFT_PART_INSTALL}/usr/lib/vocalinux/check_runtime.py"
|
||||
install -Dm644 "${CRAFT_PROJECT_DIR}/snap/gui/vocalinux.desktop" \
|
||||
"${CRAFT_PART_INSTALL}/usr/share/applications/vocalinux.desktop"
|
||||
install -Dm644 "${CRAFT_PROJECT_DIR}/resources/icons/scalable/vocalinux.svg" \
|
||||
|
||||
@@ -10,6 +10,8 @@ import subprocess
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -207,6 +209,25 @@ class TestReadClipboard(unittest.TestCase):
|
||||
self.assertEqual(result, "x11 clipboard")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _joined_restore_workers() -> Iterator[None]:
|
||||
"""Finish real restore workers before the enclosing clipboard mocks exit."""
|
||||
started: list[threading.Thread] = []
|
||||
original_start = threading.Thread.start
|
||||
|
||||
def capture_start(worker: threading.Thread) -> None:
|
||||
original_start(worker)
|
||||
started.append(worker)
|
||||
|
||||
with patch.object(threading.Thread, "start", capture_start):
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for worker in started:
|
||||
worker.join(timeout=3)
|
||||
assert not any(worker.is_alive() for worker in started), "restore worker leaked"
|
||||
|
||||
|
||||
class TestClipboardRestoreAfterInjection(unittest.TestCase):
|
||||
"""Behavioural tests for the save/restore logic in _inject_via_clipboard_paste."""
|
||||
|
||||
@@ -234,8 +255,8 @@ class TestClipboardRestoreAfterInjection(unittest.TestCase):
|
||||
"vocalinux.text_injection.text_injector.subprocess.run",
|
||||
return_value=MagicMock(returncode=0),
|
||||
):
|
||||
result = obj._inject_via_clipboard_paste("injected text")
|
||||
time.sleep(0.5) # wait for the 300ms restore thread
|
||||
with _joined_restore_workers():
|
||||
result = obj._inject_via_clipboard_paste("injected text")
|
||||
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(copy_calls[0], "injected text")
|
||||
@@ -267,8 +288,8 @@ class TestClipboardRestoreAfterInjection(unittest.TestCase):
|
||||
"vocalinux.text_injection.text_injector.subprocess.run",
|
||||
return_value=MagicMock(returncode=0),
|
||||
):
|
||||
obj._inject_via_clipboard_paste("text")
|
||||
time.sleep(0.5)
|
||||
with _joined_restore_workers():
|
||||
obj._inject_via_clipboard_paste("text")
|
||||
|
||||
self.assertEqual(call_order[0], "read", "read must happen before any copy")
|
||||
self.assertIn("copy:text", call_order)
|
||||
@@ -365,7 +386,7 @@ class TestClipboardRestoreAfterInjection(unittest.TestCase):
|
||||
# First copy: injection; second copy: immediate restore after paste failure
|
||||
self.assertEqual(copy_calls, ["text", "prev"])
|
||||
|
||||
def test_restore_thread_is_a_daemon(self):
|
||||
def test_restore_thread_is_a_daemon(self) -> None:
|
||||
"""The restore thread is a daemon so it never blocks interpreter shutdown."""
|
||||
obj = _make_injector()
|
||||
obj.wayland_tool = "ydotool"
|
||||
@@ -390,6 +411,11 @@ class TestClipboardRestoreAfterInjection(unittest.TestCase):
|
||||
):
|
||||
with patch.object(threading.Thread, "start", capture_start):
|
||||
obj._inject_via_clipboard_paste("text")
|
||||
# Keep the clipboard mocks installed until the
|
||||
# real worker exits, not merely until start().
|
||||
for worker in started:
|
||||
worker.join(timeout=3)
|
||||
self.assertFalse(worker.is_alive(), "restore worker leaked")
|
||||
|
||||
self.assertEqual(len(started), 1, "exactly one restore thread should start")
|
||||
self.assertTrue(started[0].daemon, "restore thread must be a daemon")
|
||||
|
||||
@@ -30,6 +30,7 @@ def _make_injector() -> Any:
|
||||
obj._clipboard_timeout = 0.35
|
||||
obj._clipboard_restore_generation = 0
|
||||
obj._clipboard_restore_target = None
|
||||
obj._should_copy_to_clipboard = MagicMock(return_value=False)
|
||||
return obj
|
||||
|
||||
|
||||
@@ -212,7 +213,11 @@ class TestYdotoolTerminalPasteCommand(unittest.TestCase):
|
||||
|
||||
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
||||
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
||||
def test_clipboard_paste_uses_wtype_argv_when_chosen(self, mock_run, mock_which):
|
||||
@patch("vocalinux.text_injection.text_injector.threading.Thread")
|
||||
def test_clipboard_paste_uses_wtype_argv_when_chosen(
|
||||
self, mock_thread: MagicMock, mock_run: MagicMock, mock_which: MagicMock
|
||||
) -> None:
|
||||
"""Check wtype arguments without launching an unrelated restore worker."""
|
||||
mock_which.side_effect = lambda cmd: cmd in ("wl-copy", "wtype", "ydotool")
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
injector = _make_injector()
|
||||
@@ -222,6 +227,9 @@ class TestYdotoolTerminalPasteCommand(unittest.TestCase):
|
||||
with patch.object(injector, "_read_clipboard", return_value="old"):
|
||||
with patch.object(injector, "_should_use_terminal_paste", return_value=False):
|
||||
self.assertTrue(injector._inject_via_clipboard_paste("hello"))
|
||||
# Restore behaviour is covered separately; never let a delayed worker
|
||||
# outlive these clipboard/subprocess patches and reach the next test.
|
||||
mock_thread.return_value.start.assert_called_once_with()
|
||||
paste_cmds = [c.args[0] for c in mock_run.call_args_list if c.args]
|
||||
self.assertTrue(
|
||||
any(c == ["wtype", "-M", "ctrl", "v"] for c in paste_cmds),
|
||||
@@ -244,7 +252,11 @@ class TestYdotoolTerminalPasteCommand(unittest.TestCase):
|
||||
|
||||
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
||||
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
||||
def test_clipboard_paste_sends_terminal_chord(self, mock_run, mock_which):
|
||||
@patch("vocalinux.text_injection.text_injector.threading.Thread")
|
||||
def test_clipboard_paste_sends_terminal_chord(
|
||||
self, mock_thread: MagicMock, mock_run: MagicMock, mock_which: MagicMock
|
||||
) -> None:
|
||||
"""Check the terminal chord without launching an unrelated restore worker."""
|
||||
mock_which.side_effect = lambda cmd: cmd in ("wl-copy", "ydotool")
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
injector = _make_injector()
|
||||
@@ -258,8 +270,10 @@ class TestYdotoolTerminalPasteCommand(unittest.TestCase):
|
||||
) as mock_cmd:
|
||||
self.assertTrue(injector._inject_via_clipboard_paste("hello"))
|
||||
mock_cmd.assert_called_once_with(terminal=True)
|
||||
mock_thread.return_value.start.assert_called_once_with()
|
||||
|
||||
def test_clipboard_paste_survives_mocked_subprocess_run(self):
|
||||
@patch("vocalinux.text_injection.text_injector.threading.Thread")
|
||||
def test_clipboard_paste_survives_mocked_subprocess_run(self, mock_thread: MagicMock) -> None:
|
||||
"""Patching text_injector.subprocess.run also shadows stdlib subprocess.
|
||||
|
||||
Existing clipboard-restore tests do that. Window detection must fail
|
||||
@@ -279,3 +293,4 @@ class TestYdotoolTerminalPasteCommand(unittest.TestCase):
|
||||
return_value=MagicMock(returncode=0),
|
||||
):
|
||||
self.assertTrue(injector._inject_via_clipboard_paste("hello"))
|
||||
mock_thread.return_value.start.assert_called_once_with()
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
"""Exercise locked Snap build orchestration, audit failures and offline hashes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import venv
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def load_helper(name: str) -> ModuleType:
|
||||
"""Load packaging helpers independently of the app and the machine's GTK."""
|
||||
spec = importlib.util.spec_from_file_location(f"snap_{name}", ROOT / f"snap/local/{name}.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
BUILD = load_helper("build")
|
||||
CHECK = load_helper("check_runtime")
|
||||
# Legacy recognition tests replace sys.modules['tempfile'] during collection.
|
||||
# Bind a private real stdlib copy without changing their global module state.
|
||||
_TEMPFILE_SPEC = importlib.util.spec_from_file_location(
|
||||
"snap_stdlib_tempfile", Path(os.__file__).with_name("tempfile.py")
|
||||
)
|
||||
_TEMPFILE = importlib.util.module_from_spec(_TEMPFILE_SPEC)
|
||||
_TEMPFILE_SPEC.loader.exec_module(_TEMPFILE)
|
||||
BUILD.tempfile = _TEMPFILE
|
||||
|
||||
|
||||
def export_pin(name: str, version: str = "1.0", marker: str = "") -> str:
|
||||
"""Make a syntactically hash-pinned requirement, optionally with a marker."""
|
||||
condition = f" ; {marker}" if marker else ""
|
||||
return f"{name}=={version}{condition} \\\n --hash=sha256:{'a' * 64}\n"
|
||||
|
||||
|
||||
def distribution(root: Path, name: str, version: str) -> Path:
|
||||
"""Write real dist-info metadata for the standard-library audit."""
|
||||
site = root / f"lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages"
|
||||
info = site / f"{name}-{version}.dist-info"
|
||||
info.mkdir(parents=True, exist_ok=True)
|
||||
(info / "METADATA").write_text(f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n")
|
||||
return site
|
||||
|
||||
|
||||
def test_locked_versions_evaluates_markers_and_normalizes_names(tmp_path: Path) -> None:
|
||||
"""The other interpreter's numpy pin must not overwrite the active one."""
|
||||
export = tmp_path / "runtime.txt"
|
||||
export.write_text(
|
||||
export_pin("Some_Package", "2.0")
|
||||
+ export_pin("numpy", "2.4", 'python_version < "3.12"')
|
||||
+ export_pin("numpy", "2.5", 'python_version >= "3.12"')
|
||||
)
|
||||
assert CHECK.locked_versions(export) == {
|
||||
"some-package": "2.0",
|
||||
"numpy": "2.5" if sys.version_info >= (3, 12) else "2.4",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "message"),
|
||||
[
|
||||
("# empty export\n", "No pinned requirements"),
|
||||
("demo==1.0\n", "Missing SHA-256"),
|
||||
(export_pin("PyGObject"), "GI must come from"),
|
||||
(export_pin("demo") + export_pin("demo", "2.0"), "Multiple active pins"),
|
||||
(export_pin("demo", marker='python_version < "2"'), "No active requirements"),
|
||||
],
|
||||
)
|
||||
def test_locked_versions_fails_closed(tmp_path: Path, text: str, message: str) -> None:
|
||||
"""Reject exports that cannot define an unambiguous locked runtime."""
|
||||
export = tmp_path / "runtime.txt"
|
||||
export.write_text(text)
|
||||
with pytest.raises(RuntimeError, match=message):
|
||||
CHECK.locked_versions(export)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["missing", "extra", "version", "duplicate"])
|
||||
def test_runtime_audit_rejects_drift(tmp_path: Path, change: str) -> None:
|
||||
"""Reject missing, extra, duplicate and wrong-version runtime distributions."""
|
||||
distribution(tmp_path, "demo", "1.0")
|
||||
expected = {"demo": "1.0"}
|
||||
if change == "missing":
|
||||
expected["absent"] = "2.0"
|
||||
elif change == "extra":
|
||||
distribution(tmp_path, "setuptools", "84.0")
|
||||
elif change == "version":
|
||||
expected["demo"] = "2.0"
|
||||
else:
|
||||
distribution(tmp_path, "demo", "2.0")
|
||||
with pytest.raises(RuntimeError, match="Duplicate|differ from the lock"):
|
||||
CHECK.audit(tmp_path, expected)
|
||||
|
||||
|
||||
def test_record_audits_payload_and_preserves_export_digests(tmp_path: Path) -> None:
|
||||
"""Record audited runtime pins and the exact input export digests."""
|
||||
source = tmp_path / "source"
|
||||
(source / "requirements").mkdir(parents=True)
|
||||
(source / "requirements/runtime.txt").write_text(export_pin("demo"))
|
||||
(source / "requirements/installer-build.txt").write_text(
|
||||
export_pin("packaging", importlib.metadata.version("packaging"))
|
||||
)
|
||||
(source / "src/vocalinux").mkdir(parents=True)
|
||||
(source / "src/vocalinux/version.py").write_text('__version__ = "0.18.5"\n')
|
||||
root = tmp_path / "install"
|
||||
distribution(root, "demo", "1.0")
|
||||
distribution(root, "vocalinux", "0.18.5")
|
||||
CHECK.record(root, source)
|
||||
manifest = json.loads((root / CHECK.MANIFEST).read_text())
|
||||
assert manifest["packages"] == {"demo": "1.0", "vocalinux": "0.18.5"}
|
||||
assert manifest["python"] == list(sys.version_info[:2])
|
||||
for name, digest in manifest["exports"].items():
|
||||
assert digest == hashlib.sha256((source / "requirements" / name).read_bytes()).hexdigest()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "message"),
|
||||
[
|
||||
(export_pin("packaging", "0.0"), "Build tool packaging"),
|
||||
("# empty build export\n", "No pinned requirements"),
|
||||
],
|
||||
)
|
||||
def test_record_rejects_unlocked_build_tools(tmp_path: Path, text: str, message: str) -> None:
|
||||
"""Refuse incorrect build-tool versions or an empty toolchain export."""
|
||||
source = tmp_path / "source"
|
||||
(source / "requirements").mkdir(parents=True)
|
||||
(source / "requirements/installer-build.txt").write_text(text)
|
||||
with pytest.raises(RuntimeError, match=message):
|
||||
CHECK.record(tmp_path / "install", source)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("long_shebang", [False, True])
|
||||
def test_launchers_are_relocatable_and_keep_locked_packages_first(
|
||||
tmp_path: Path, long_shebang: bool
|
||||
) -> None:
|
||||
"""Run relocated launchers against locked packages despite platform copies."""
|
||||
install = tmp_path / "install"
|
||||
binary = install / "bin"
|
||||
binary.mkdir(parents=True)
|
||||
tooling = tmp_path / "tooling"
|
||||
python = tooling / "bin/python3"
|
||||
header = (
|
||||
f"#!/bin/sh\n'''exec' {python} \"$0\" \"$@\"\n' '''\n" if long_shebang else f"#!{python}\n"
|
||||
)
|
||||
for name in ("vocalinux", "vocalinux-gui", "pwcpp"):
|
||||
path = binary / name
|
||||
path.write_text(header + "print('entry point')\n")
|
||||
path.chmod(0o755)
|
||||
(binary / "python3").symlink_to(sys.executable)
|
||||
site = distribution(install, "vocalinux", "0.18.5")
|
||||
package = site / "vocalinux"
|
||||
package.mkdir()
|
||||
(package / "__init__.py").write_text("")
|
||||
(package / "main.py").write_text("def main():\n print('locked runtime')\n")
|
||||
# Simulate a platform with an incidental conflicting copy.
|
||||
platform = tmp_path / "platform/vocalinux"
|
||||
platform.mkdir(parents=True)
|
||||
(platform / "__init__.py").write_text("raise RuntimeError('wrong runtime')\n")
|
||||
BUILD.relocate_scripts(install, tooling)
|
||||
for name in ("vocalinux", "vocalinux-gui", "pwcpp"):
|
||||
text = (binary / name).read_text()
|
||||
assert text.startswith("#!/usr/bin/env python3\n")
|
||||
assert str(tooling) not in text
|
||||
assert os.access(binary / name, os.X_OK)
|
||||
result = subprocess.run(
|
||||
[str(binary / "vocalinux")],
|
||||
env={
|
||||
**os.environ,
|
||||
"PATH": f"{binary}{os.pathsep}{os.environ['PATH']}",
|
||||
"PYTHONPATH": str(platform.parent),
|
||||
},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "locked runtime"
|
||||
|
||||
|
||||
def test_build_orchestration_uses_hashes_and_separates_tooling(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Stub expensive builds, not the command construction or source protection."""
|
||||
source = tmp_path / "source"
|
||||
(source / "requirements").mkdir(parents=True)
|
||||
metadata = source / "pyproject.toml"
|
||||
metadata.write_text('[project]\ndependencies = ["PyGObject"]\n')
|
||||
for name in ("installer-build.txt", "runtime.txt"):
|
||||
(source / "requirements" / name).write_text(export_pin("demo"))
|
||||
install = tmp_path / "install"
|
||||
work = tmp_path / "work"
|
||||
environments = []
|
||||
calls = []
|
||||
|
||||
def create(builder: venv.EnvBuilder, path: Path) -> None:
|
||||
(path / "bin").mkdir(parents=True, exist_ok=True)
|
||||
environments.append((path, builder.with_pip, builder.system_site_packages))
|
||||
|
||||
def run(argv: list[str], cwd: Path, env: dict[str, str]) -> None:
|
||||
calls.append((argv, cwd, env.copy()))
|
||||
if str(source) == argv[-1]:
|
||||
for name in ("vocalinux", "vocalinux-gui"):
|
||||
(install / "bin" / name).write_text(f"#!{argv[0]}\n")
|
||||
|
||||
monkeypatch.setattr(BUILD.venv.EnvBuilder, "create", create)
|
||||
monkeypatch.setattr(BUILD, "run", run)
|
||||
monkeypatch.setenv("PIP_INDEX_URL", "https://untrusted.invalid")
|
||||
monkeypatch.setenv("PYTHONPATH", "/unlocked-platform")
|
||||
monkeypatch.setenv("CMAKE_ARGS", "-DGGML_NATIVE=OFF")
|
||||
before = metadata.read_bytes()
|
||||
BUILD.build(source, install, work)
|
||||
assert metadata.read_bytes() == before
|
||||
assert len(calls) == 4
|
||||
tools, runtime, project, audit = [call[0] for call in calls]
|
||||
for argv in (tools, runtime):
|
||||
assert {
|
||||
"--isolated",
|
||||
"--disable-pip-version-check",
|
||||
"--no-cache-dir",
|
||||
"--require-hashes",
|
||||
"--no-deps",
|
||||
} <= set(argv)
|
||||
assert "--only-binary=:all:" in tools
|
||||
assert tools[-1] == str(source / "requirements/installer-build.txt")
|
||||
assert {"--no-build-isolation", "--ignore-installed", "--no-binary=pywhispercpp"} <= set(
|
||||
runtime
|
||||
)
|
||||
assert runtime[-1] == str(source / "requirements/runtime.txt")
|
||||
assert runtime[runtime.index("--prefix") + 1] == str(install)
|
||||
assert {"--no-deps", "--no-build-isolation", "--no-index"} <= set(project)
|
||||
assert project[-1] == str(source)
|
||||
assert "--record" in audit and str(install) in audit
|
||||
assert environments[0][1:] == (True, False)
|
||||
assert environments[1] == (install, False, False)
|
||||
assert not environments[0][0].exists()
|
||||
for _, cwd, env in calls:
|
||||
assert cwd == source
|
||||
assert "PIP_INDEX_URL" not in env and "PYTHONPATH" not in env
|
||||
assert env["PIP_CONFIG_FILE"] == os.devnull
|
||||
assert env["CMAKE_ARGS"] == "-DGGML_NATIVE=OFF"
|
||||
assert env["PATH"].split(os.pathsep)[0] == str(environments[0][0] / "bin")
|
||||
|
||||
|
||||
def test_ci_builds_recipe_only_changes_and_checks_before_publication() -> None:
|
||||
"""Require equivalent PR/release artifact gates before uploading the Snap."""
|
||||
ci = yaml.safe_load((ROOT / ".github/workflows/unified-pipeline.yml").read_text())
|
||||
jobs = ci["jobs"]
|
||||
changes = jobs["changes"]
|
||||
filters = yaml.safe_load(
|
||||
next(
|
||||
step["with"]["filters"]
|
||||
for step in changes["steps"]
|
||||
if "filters" in step.get("with", {})
|
||||
)
|
||||
)
|
||||
assert "snap/**" in filters["snap"]
|
||||
# A recipe- or smoke-script-only change must still run the tests guarding them.
|
||||
assert "snap/**" in filters["python"]
|
||||
assert changes["outputs"]["snap"] == "${{ steps.filter.outputs.snap }}"
|
||||
pr = jobs["snap-build-test"]
|
||||
assert "outputs.snap == 'true'" in pr["if"]
|
||||
assert "outputs.python == 'true'" in pr["if"]
|
||||
assert pr["permissions"] == {"contents": "read"}
|
||||
release = yaml.safe_load((ROOT / ".github/workflows/release.yml").read_text())
|
||||
production = release["jobs"]["build-snap"]
|
||||
builders = []
|
||||
for job in (pr, production):
|
||||
steps = job["steps"]
|
||||
build = next(
|
||||
index
|
||||
for index, step in enumerate(steps)
|
||||
if step.get("uses", "").startswith("snapcore/action-build@")
|
||||
)
|
||||
smoke = next(
|
||||
index
|
||||
for index, step in enumerate(steps)
|
||||
if "snap/local/smoke-test.sh" in step.get("run", "")
|
||||
)
|
||||
upload = next(
|
||||
index
|
||||
for index, step in enumerate(steps)
|
||||
if step.get("uses", "").startswith("actions/upload-artifact@")
|
||||
)
|
||||
assert build < smoke < upload
|
||||
assert job["runs-on"] == "ubuntu-24.04"
|
||||
assert job["timeout-minutes"] >= 30
|
||||
assert "continue-on-error" not in job
|
||||
assert "continue-on-error" not in steps[smoke]
|
||||
assert "secrets." not in json.dumps(job)
|
||||
builders.append(steps[build]["uses"])
|
||||
assert builders[0] == builders[1]
|
||||
|
||||
|
||||
@pytest.mark.timeout(30)
|
||||
def test_runtime_pip_flags_reject_corrupt_artifact_offline(tmp_path: Path) -> None:
|
||||
"""Exercise pip's real hash check with the same runtime install flags."""
|
||||
environment = tmp_path / "tooling"
|
||||
venv.EnvBuilder(with_pip=True).create(environment)
|
||||
wheel = tmp_path / "demo-1.0-py3-none-any.whl"
|
||||
real_zipfile = getattr(sys, "_vocalinux_real_zipfile", zipfile)
|
||||
with real_zipfile.ZipFile(wheel, "w") as archive:
|
||||
archive.writestr("demo.py", "VALUE = 1\n")
|
||||
archive.writestr(
|
||||
"demo-1.0.dist-info/METADATA", "Metadata-Version: 2.1\nName: demo\nVersion: 1.0\n"
|
||||
)
|
||||
archive.writestr(
|
||||
"demo-1.0.dist-info/WHEEL",
|
||||
"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
|
||||
)
|
||||
archive.writestr("demo-1.0.dist-info/RECORD", "")
|
||||
digest = hashlib.sha256(wheel.read_bytes()).hexdigest()
|
||||
export = tmp_path / "runtime.txt"
|
||||
export.write_text(f"demo==1.0 --hash=sha256:{digest}\n")
|
||||
python = str(environment / "bin/python3")
|
||||
command = [
|
||||
python,
|
||||
"-m",
|
||||
"pip",
|
||||
"--isolated",
|
||||
"install",
|
||||
"--no-cache-dir",
|
||||
"--require-hashes",
|
||||
"--no-deps",
|
||||
"--no-build-isolation",
|
||||
"--no-binary=pywhispercpp",
|
||||
"--ignore-installed",
|
||||
"--no-compile",
|
||||
"--prefix",
|
||||
str(tmp_path / "install"),
|
||||
"--no-index",
|
||||
"--find-links",
|
||||
str(tmp_path),
|
||||
"-r",
|
||||
str(export),
|
||||
]
|
||||
env = {**os.environ, "PIP_CONFIG_FILE": os.devnull}
|
||||
result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=10)
|
||||
assert result.returncode == 0, result.stderr
|
||||
CHECK.audit(tmp_path / "install", {"demo": "1.0"})
|
||||
with wheel.open("ab") as stream:
|
||||
stream.write(b"corrupted after the hash was pinned")
|
||||
result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=10)
|
||||
assert result.returncode != 0
|
||||
assert "DO NOT MATCH THE HASHES" in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.timeout(60)
|
||||
def test_full_python_payload_build_is_offline_and_keeps_project_metadata(tmp_path: Path) -> None:
|
||||
"""Run the real build pipeline with tiny local wheels and a PEP 517 fixture."""
|
||||
import packaging
|
||||
|
||||
source = tmp_path / "source"
|
||||
(source / "requirements").mkdir(parents=True)
|
||||
(source / "snap/local").mkdir(parents=True)
|
||||
shutil.copy2(ROOT / "snap/local/check_runtime.py", source / "snap/local/check_runtime.py")
|
||||
(source / "src/vocalinux").mkdir(parents=True)
|
||||
(source / "src/vocalinux/version.py").write_text('__version__ = "0.18.5"\n')
|
||||
wheelhouse = tmp_path / "wheels"
|
||||
wheelhouse.mkdir()
|
||||
real_zipfile = getattr(sys, "_vocalinux_real_zipfile", zipfile)
|
||||
|
||||
def wheel(name: str, version: str, files: dict[str, str]) -> Path:
|
||||
path = wheelhouse / f"{name}-{version}-py3-none-any.whl"
|
||||
with real_zipfile.ZipFile(path, "w") as archive:
|
||||
for filename, text in files.items():
|
||||
archive.writestr(filename, text)
|
||||
info = f"{name}-{version}.dist-info"
|
||||
archive.writestr(
|
||||
f"{info}/METADATA", f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n"
|
||||
)
|
||||
archive.writestr(
|
||||
f"{info}/WHEEL", "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n"
|
||||
)
|
||||
archive.writestr(f"{info}/RECORD", "")
|
||||
return path
|
||||
|
||||
# The build-only packaging library is real, but every byte is served by
|
||||
# the local wheelhouse. No test index, shared pip cache or network is used.
|
||||
library = Path(packaging.__file__).parent
|
||||
tools = wheel(
|
||||
"packaging",
|
||||
packaging.__version__,
|
||||
{
|
||||
f"packaging/{path.relative_to(library)}": path.read_text()
|
||||
for path in library.rglob("*.py")
|
||||
},
|
||||
)
|
||||
runtime = wheel("demo", "1.0", {"demo.py": "VALUE = 1\n"})
|
||||
app = wheel(
|
||||
"vocalinux",
|
||||
"0.18.5",
|
||||
{
|
||||
"vocalinux/__init__.py": "",
|
||||
"vocalinux/main.py": "def main():\n print('fixture app')\n",
|
||||
"vocalinux-0.18.5.dist-info/entry_points.txt": "[console_scripts]\nvocalinux = vocalinux.main:main\n"
|
||||
"[gui_scripts]\nvocalinux-gui = vocalinux.main:main\n",
|
||||
},
|
||||
)
|
||||
for filename, name, version, artifact in (
|
||||
("installer-build.txt", "packaging", packaging.__version__, tools),
|
||||
("runtime.txt", "demo", "1.0", runtime),
|
||||
):
|
||||
digest = hashlib.sha256(artifact.read_bytes()).hexdigest()
|
||||
(source / "requirements" / filename).write_text(
|
||||
f"--no-index\n--find-links {wheelhouse}\n{name}=={version} --hash=sha256:{digest}\n"
|
||||
)
|
||||
# An entirely stdlib backend lets the fixture build without downloading
|
||||
# setuptools. The real project uses the locked setuptools toolchain.
|
||||
metadata = source / "pyproject.toml"
|
||||
metadata.write_text(
|
||||
'[build-system]\nrequires = []\nbuild-backend = "backend"\nbackend-path = ["."]\n'
|
||||
'[project]\nname = "vocalinux"\nversion = "0.18.5"\ndependencies = ["PyGObject"]\n'
|
||||
)
|
||||
(source / "backend.py").write_text(
|
||||
"import shutil\nfrom pathlib import Path\n"
|
||||
"def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):\n"
|
||||
f" source = Path({str(app)!r})\n"
|
||||
" shutil.copy2(source, Path(wheel_directory) / source.name)\n"
|
||||
" return source.name\n"
|
||||
)
|
||||
before = metadata.read_bytes()
|
||||
install = tmp_path / "install"
|
||||
BUILD.build(source, install, tmp_path / "work")
|
||||
assert metadata.read_bytes() == before
|
||||
CHECK.audit(install, {"demo": "1.0", "vocalinux": "0.18.5"})
|
||||
assert not list((tmp_path / "work").iterdir())
|
||||
assert not (install / "bin/pip").exists()
|
||||
result = subprocess.run(
|
||||
[str(install / "bin/python3"), str(install / "bin/vocalinux")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "fixture app"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("existing", [False, True])
|
||||
def test_artifact_smoke_refuses_user_install_and_checks_the_supplied_file(
|
||||
tmp_path: Path, existing: bool
|
||||
) -> None:
|
||||
"""Execute the real shell gate; fake only snapd/sudo, never touch a host app."""
|
||||
tools = tmp_path / "tools"
|
||||
tools.mkdir()
|
||||
log = tmp_path / "calls.jsonl"
|
||||
for name in ("snap", "sudo"):
|
||||
path = tools / name
|
||||
path.write_text(
|
||||
f"#!{sys.executable}\n"
|
||||
"import json, os, sys\n"
|
||||
"from pathlib import Path\n"
|
||||
"with open(os.environ['SNAP_TEST_LOG'], 'a') as stream:\n"
|
||||
" stream.write(json.dumps([Path(sys.argv[0]).name, sys.argv[1:], "
|
||||
"os.environ.get('PYNPUT_BACKEND')]) + '\\n')\n"
|
||||
"if sys.argv[1:3] == ['list', 'vocalinux']:\n"
|
||||
" sys.exit(0 if os.environ['SNAP_TEST_EXISTING'] == '1' else 1)\n"
|
||||
)
|
||||
path.chmod(0o755)
|
||||
artifact = tmp_path / "selected.snap"
|
||||
artifact.write_bytes(b"artifact stand-in")
|
||||
result = subprocess.run(
|
||||
["bash", str(ROOT / "snap/local/smoke-test.sh"), str(artifact)],
|
||||
env={
|
||||
**os.environ,
|
||||
"PATH": f"{tools}{os.pathsep}{os.environ['PATH']}",
|
||||
"SNAP_TEST_LOG": str(log),
|
||||
"SNAP_TEST_EXISTING": "1" if existing else "0",
|
||||
},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
calls = [json.loads(line) for line in log.read_text().splitlines()]
|
||||
if existing:
|
||||
assert result.returncode != 0
|
||||
assert "Refusing to replace" in result.stderr
|
||||
assert len(calls) == 1
|
||||
else:
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert calls[1][:2] == ["sudo", ["snap", "install", "--dangerous", str(artifact)]]
|
||||
assert calls[2][:2] == ["snap", ["run", "vocalinux", "--version"]]
|
||||
assert calls[3][1][:4] == ["run", "--shell", "vocalinux", "-c"]
|
||||
assert '"$SNAP/snap/command-chain/desktop-launch"' in calls[3][1][4]
|
||||
assert '"$SNAP/usr/lib/vocalinux/check_runtime.py"' in calls[3][1][4]
|
||||
assert calls[3][2] == "dummy"
|
||||
@@ -84,14 +84,16 @@ def test_snap_docs_explain_typing_without_uinput_plug() -> None:
|
||||
assert "portal" in lowered
|
||||
|
||||
|
||||
def test_snap_strips_pygobject_and_uses_gnome_gi() -> None:
|
||||
"""Pip must not build PyGObject; GI comes from the gnome extension."""
|
||||
def test_snap_preserves_metadata_and_uses_gnome_gi() -> None:
|
||||
"""GI comes from GNOME without rewriting project dependencies."""
|
||||
text = SNAPCRAFT_YAML.read_text(encoding="utf-8")
|
||||
doc = yaml.safe_load(text)
|
||||
override_pull = doc["parts"]["vocalinux"]["override-pull"]
|
||||
assert "PyGObject" in override_pull
|
||||
assert "pyproject.toml" in override_pull
|
||||
assert "gnome extension" in override_pull
|
||||
part = doc["parts"]["vocalinux"]
|
||||
assert part["plugin"] == "nil"
|
||||
assert "python-packages" not in part
|
||||
assert "sed -i" not in part["override-pull"]
|
||||
assert "snap/local/build.py" in part["override-build"]
|
||||
assert "craftctl default" not in part["override-build"]
|
||||
stage = doc["parts"]["vocalinux"].get("stage-packages") or []
|
||||
assert "python3-gi" not in stage
|
||||
assert "python3-gi-cairo" not in stage
|
||||
@@ -222,9 +224,9 @@ def test_snap_builds_pywhispercpp_at_the_portable_baseline() -> None:
|
||||
env: dict = {}
|
||||
for entry in doc["parts"]["vocalinux"].get("build-environment") or []:
|
||||
env.update(entry)
|
||||
assert (
|
||||
env.get("PIP_NO_BINARY") == "pywhispercpp"
|
||||
), "without --no-binary the snap installs the AVX2+FMA wheel (#957)"
|
||||
build = (REPO_ROOT / "snap/local/build.py").read_text()
|
||||
assert '"--no-binary=pywhispercpp"' in build
|
||||
assert '"--no-cache-dir"' in build
|
||||
cmake_args = env.get("CMAKE_ARGS") or ""
|
||||
for flag in PORTABLE_GGML_OPTIONS:
|
||||
assert (
|
||||
|
||||
Reference in New Issue
Block a user