voculinux now depends on python3-PyAudio and python3-pysocks at the carried versions, so a stock Void package below the project floors is upgraded rather than silently kept. The container smoke installs the stock packages first and asserts the upgrade.
243 lines
11 KiB
Python
243 lines
11 KiB
Python
"""Offline policy tests for the Void template of the whisper.cpp binding (#26).
|
|
|
|
Spec #16 packages four carried templates in void-packages layout under
|
|
``packaging/void``; this ticket owns the hard one, ``python3-pywhispercpp``,
|
|
built from the pinned PyPI source distribution with the Vulkan backend,
|
|
shared libraries, and portable CPU flags. Ticket #28 builds the container
|
|
smoke gate on top; everything asserted here runs offline from sources
|
|
already in the repo.
|
|
|
|
Independent sources of truth: ``install.sh`` pins the binding version
|
|
(``PYWHISPERCPP_VERSION``), ``uv.lock`` pins the sdist URL and sha256, and
|
|
the sdist's own ``setup.py``/``PKG-INFO`` name the build and runtime
|
|
requirements. The template must agree with all three.
|
|
"""
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
VOID_DIR = REPO_ROOT / "packaging" / "void"
|
|
TEMPLATE = VOID_DIR / "srcpkgs" / "python3-pywhispercpp" / "template"
|
|
PINS = VOID_DIR / "tool_checksums.txt"
|
|
BUILD_SH = VOID_DIR / "build-release.sh"
|
|
INSTALL_SH = REPO_ROOT / "install.sh"
|
|
UV_LOCK = REPO_ROOT / "uv.lock"
|
|
|
|
#: ggml SIMD options pinned off for the plain x86-64 baseline (#957).
|
|
#: GGML_NATIVE=OFF alone is not portable: while ggml's native default is on,
|
|
#: every GGML_* SIMD option stays on and still compiles -mavx2 -mfma.
|
|
#: Mirrors PORTABLE_GGML_OPTIONS in tests/test_flatpak_packaging.py and the
|
|
#: CMAKE_ARGS line in packaging/flatpak/python3-dependencies.yaml.
|
|
PORTABLE_GGML_OPTIONS = (
|
|
"GGML_NATIVE",
|
|
"GGML_SSE42",
|
|
"GGML_AVX",
|
|
"GGML_AVX2",
|
|
"GGML_BMI2",
|
|
"GGML_FMA",
|
|
"GGML_F16C",
|
|
)
|
|
|
|
#: install_requires of pywhispercpp's setup.py, mapped to the Void binary
|
|
#: package names. Each name was verified to exist as srcpkgs/<name>/template
|
|
#: in current void-packages (2026-11-05); the container build re-verifies via
|
|
#: xbps-query, so a removal upstream fails there rather than silently here.
|
|
VOID_RUNTIME_DEPS = (
|
|
"python3-numpy",
|
|
"python3-requests",
|
|
"python3-tqdm",
|
|
"python3-platformdirs",
|
|
)
|
|
|
|
|
|
def _template() -> str:
|
|
assert TEMPLATE.is_file(), f"missing Void template: {TEMPLATE}"
|
|
return TEMPLATE.read_text(encoding="utf-8")
|
|
|
|
|
|
def _pinned_version() -> str:
|
|
text = INSTALL_SH.read_text(encoding="utf-8")
|
|
match = re.search(r'^PYWHISPERCPP_VERSION="([^"]+)"', text, re.M)
|
|
assert match, "install.sh no longer pins PYWHISPERCPP_VERSION"
|
|
return match.group(1)
|
|
|
|
|
|
def _sdist_url_and_hash() -> tuple[str, str]:
|
|
text = UV_LOCK.read_text(encoding="utf-8")
|
|
match = re.search(
|
|
r'sdist = \{ url = "(https://files\.pythonhosted\.org/[^"]*?'
|
|
r"/pywhispercpp-([0-9.]+)\.tar\.gz)\","
|
|
r' hash = "sha256:([0-9a-f]{64})"',
|
|
text,
|
|
)
|
|
assert match, "uv.lock no longer pins the pywhispercpp sdist URL and hash"
|
|
return match.group(1), match.group(3)
|
|
|
|
|
|
def test_template_names_the_package_after_its_directory() -> None:
|
|
"""pkgname must match srcpkgs/<pkgname>, per the Void manual."""
|
|
text = _template()
|
|
match = re.search(r"^pkgname=(\S+)", text, re.M)
|
|
assert match, "template sets no pkgname"
|
|
assert match.group(1) == TEMPLATE.parent.name == "python3-pywhispercpp"
|
|
|
|
|
|
def test_template_carries_the_mandatory_void_variables() -> None:
|
|
"""Mandatory vars: version, revision, short_desc, maintainer, license."""
|
|
text = _template()
|
|
assert re.search(r"^version=[0-9][0-9.]*$", text, re.M), "unpinned or missing version"
|
|
assert re.search(r"^revision=1$", text, re.M), "new templates start at revision 1"
|
|
match = re.search(r'^short_desc="([^"]+)"', text, re.M)
|
|
assert match, "missing short_desc"
|
|
assert len(match.group(1)) <= 72, f"short_desc exceeds 72 chars: {match.group(1)!r}"
|
|
assert re.search(
|
|
r'^maintainer="?[^"]*<\S+@\S+>"?$', text, re.M
|
|
), "maintainer is not 'name <email>'"
|
|
assert re.search(r'^license="?MIT"?$', text, re.M), "pywhispercpp is MIT licensed"
|
|
assert re.search(r'^homepage="?https://github\.com/absadiki/pywhispercpp"?$', text, re.M)
|
|
|
|
|
|
def test_template_version_matches_the_installer_and_lockfile_pins() -> None:
|
|
"""The template builds the pinned binding, not newest upstream."""
|
|
text = _template()
|
|
pinned = _pinned_version()
|
|
match = re.search(r"^version=(\S+)", text, re.M)
|
|
assert match and match.group(1) == pinned, (
|
|
f"template builds {match.group(1) if match else 'nothing'}, " f"install.sh pins {pinned}"
|
|
)
|
|
|
|
|
|
def test_distfiles_and_checksum_match_the_locked_sdist() -> None:
|
|
"""One PyPI source distribution, checksum-verified against uv.lock."""
|
|
text = _template()
|
|
url, sha = _sdist_url_and_hash()
|
|
assert (
|
|
"${PYPI_SITE}/p/pywhispercpp/pywhispercpp-${version}.tar.gz" in text
|
|
), "distfiles must use the ${PYPI_SITE} mirror helper with ${version}"
|
|
match = re.search(r"^checksum=([0-9a-f]{64})$", text, re.M)
|
|
assert match, "checksum must be a bare sha256 hex digest"
|
|
assert match.group(1) == sha, "template checksum is behind uv.lock's sdist hash"
|
|
assert url.endswith(f"pywhispercpp-{_pinned_version()}.tar.gz")
|
|
|
|
|
|
def test_build_style_is_python3_module() -> None:
|
|
"""setup.py exists, so the module style applies (per the Void manual)."""
|
|
assert re.search(r"^build_style=python3-module$", _template(), re.M)
|
|
|
|
|
|
def test_portable_cpu_flags_pin_every_ggml_simd_option_off() -> None:
|
|
"""No host-native tuning: the plain x86-64 baseline, like the Flatpak."""
|
|
text = _template()
|
|
for flag in PORTABLE_GGML_OPTIONS:
|
|
assert f"-D{flag}=OFF" in text, (
|
|
f"template does not pin {flag} off; the package inherits the "
|
|
"builder's instruction set and SIGILLs on older CPUs (#938, #957)"
|
|
)
|
|
|
|
|
|
def _code_lines() -> str:
|
|
"""Template with comment lines stripped; flags live in code, not prose."""
|
|
return "\n".join(line for line in _template().splitlines() if not line.lstrip().startswith("#"))
|
|
|
|
|
|
def test_no_native_tuning_or_cuda_anywhere() -> None:
|
|
"""Void has no CUDA toolkit package; portable means portable."""
|
|
text = _code_lines()
|
|
assert "-march=native" not in text and "-mcpu=native" not in text
|
|
assert "GGML_NATIVE=ON" not in text
|
|
assert "cuda" not in text.lower(), "no CUDA build: Void ships no CUDA toolkit"
|
|
|
|
|
|
def test_vulkan_is_a_default_on_build_option() -> None:
|
|
"""Source builders rebuild CPU-only with -o ~vulkan."""
|
|
text = _template()
|
|
match = re.search(r"^build_options=(.*)$", text, re.M)
|
|
assert match and "vulkan" in match.group(1).replace('"', "").split()
|
|
match = re.search(r"^build_options_default=(.*)$", text, re.M)
|
|
assert match and "vulkan" in match.group(1).replace('"', "").split(), "Vulkan is on by default"
|
|
# The common options description already defines desc_option_vulkan;
|
|
# a local redefinition would drift from it.
|
|
assert "desc_option_vulkan" not in text
|
|
assert "GGML_VULKAN=1" in text, "the Vulkan backend is enabled via GGML_VULKAN=1"
|
|
assert "build_option_vulkan" in text, "GGML_VULKAN must be gated on the option"
|
|
|
|
|
|
def test_vulkan_deps_are_conditional_and_runtime_names_exist() -> None:
|
|
"""Vulkan-only deps vanish from CPU-only builds; runtime names are real."""
|
|
text = _template()
|
|
assert "vopt_if vulkan" in text, "conditional Vulkan deps use vopt_if"
|
|
assert "vulkan-loader" in text, "libvulkan comes from vulkan-loader"
|
|
assert "Vulkan-Headers" in text or "vulkan-loader-devel" in text
|
|
assert "shaderc" in text, "ggml-vulkan requires glslc at build time"
|
|
depends = re.search(r"^depends=(.*)$", text, re.M)
|
|
assert depends, "template declares no runtime depends"
|
|
for dep in VOID_RUNTIME_DEPS:
|
|
assert dep in depends.group(1), f"{dep} missing from depends"
|
|
|
|
|
|
def test_build_time_deps_cover_setup_py() -> None:
|
|
"""setup.py imports bdist_wheel at top level and shells out to cmake."""
|
|
text = _template()
|
|
host = re.search(r"^hostmakedepends=(.*)$", text, re.M)
|
|
assert host, "template declares no hostmakedepends"
|
|
for dep in ("cmake", "pkg-config", "python3-setuptools", "python3-wheel"):
|
|
assert dep in host.group(1), f"{dep} missing from hostmakedepends"
|
|
make = re.search(r"^makedepends=(.*)$", text, re.M)
|
|
assert make and "python3-devel" in make.group(1), "compiled extension needs python3-devel"
|
|
|
|
|
|
def test_version_is_injected_for_the_offline_build() -> None:
|
|
"""The sdist carries no version.txt; setup.py reads PYWHISPERCPP_VERSION."""
|
|
assert "PYWHISPERCPP_VERSION" in _template()
|
|
|
|
|
|
def test_build_fetches_nothing_beyond_the_sdist() -> None:
|
|
"""Only declared distfiles may come from the network."""
|
|
text = _template()
|
|
assert text.count("https://") == 1, "homepage is the only URL a template may name"
|
|
assert "FetchContent" not in text and "ExternalProject" not in text
|
|
assert "make_check=no" in text, "the sdist ships no tests to check"
|
|
|
|
|
|
def test_template_style_is_lint_clean() -> None:
|
|
"""Trailing whitespace and missing final newline fail xlint."""
|
|
raw = TEMPLATE.read_bytes().decode("utf-8")
|
|
for lineno, line in enumerate(raw.splitlines(), 1):
|
|
assert line == line.rstrip(), f"trailing whitespace on line {lineno}"
|
|
assert "\t" not in line or line.startswith("\t"), f"stray tab on line {lineno}"
|
|
assert raw.endswith("\n"), "template must end with a newline"
|
|
|
|
|
|
def test_tool_checksums_pin_the_void_glibc_image() -> None:
|
|
"""The container gate builds in one pinned image, like native/appimage."""
|
|
assert PINS.is_file(), f"missing pins file: {PINS}"
|
|
pins = {}
|
|
for line in PINS.read_text(encoding="utf-8").splitlines():
|
|
if not line.strip() or line.lstrip().startswith("#"):
|
|
continue
|
|
name, kind, value, _source = line.split(None, 3)
|
|
pins[name] = (kind, value)
|
|
assert "base-image" in pins, "no pinned base image"
|
|
kind, value = pins["base-image"]
|
|
assert kind == "docker", "base-image pin kind matches native/appimage convention"
|
|
assert value.startswith(
|
|
"ghcr.io/void-linux/void-glibc-full@sha256:"
|
|
), f"base image must be the pinned Void glibc image, got {value}"
|
|
assert len(value.rsplit(":", 1)[1]) == 64, "image digest must be a full sha256"
|
|
|
|
|
|
def test_build_script_uses_podman_and_the_pinned_image() -> None:
|
|
"""Container work uses podman, never Docker; the image comes from the pins."""
|
|
assert BUILD_SH.is_file(), f"missing build script: {BUILD_SH}"
|
|
text = BUILD_SH.read_text(encoding="utf-8")
|
|
assert "podman" in text, "the build harness must use podman"
|
|
scrubbed = text.lower().replace("never docker", "")
|
|
assert "docker" not in scrubbed, "never Docker"
|
|
assert "tool_checksums.txt" in text, "the image digest comes from the pins file"
|
|
# The container flow lives in a mounted file, not an inline sh -c string:
|
|
# single quotes inside sh -c silently regroup the command.
|
|
name = "container-release.sh"
|
|
assert (VOID_DIR / name).is_file(), f"missing container script: {name}"
|
|
assert name in text, f"{BUILD_SH.name} must mount {name}"
|