Files
voculinux/tests/test_void_app_template.py
T
xavierk 06b7b79664
CI / security (pull_request) Successful in 1m13s
CI / lint (pull_request) Successful in 23s
CI / ai-review (pull_request) Successful in 8s
fix(void): carry python3-pysocks and constrain the audio and SOCKS floors
Void's stock python3-PyAudio (0.2.12_5) and python3-pysocks (1.7.0_8) sit
below the project floors (pyaudio>=0.2.13, PySocks>=1.7.1). voculinux
depended on both by bare name, which an installed stock package already
satisfies, so a user who had the stock packages was never upgraded.

- add packaging/void/srcpkgs/python3-pysocks: PySocks 1.7.1 from the PyPI
  sdist that uv.lock pins (sha256 3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0)
- voculinux depends on python3-PyAudio>=0.2.14_1 and python3-pysocks>=1.7.1_1
- the install smoke installs the stock packages first, then voculinux, and
  fails unless each reads back as its carried pkgver
- tests: each constraint must name its carried template's current version
  and revision; the stock version must sit below the floor; the depends
  parser refuses malformed tokens instead of stripping them
- docs: "Version floors" in packaging/void/README.md; a pointer in
  docs/RELEASE_PROCESS.md

Refs #16
2026-11-06 21:32:33 +05:30

507 lines
22 KiB
Python

"""Offline policy guards for the Void application template (ticket #27).
Spec #16 ships Voculinux itself as a Void package: an in-repo xbps
template in void-packages layout under ``packaging/void`` builds the app
from the canonical, checksum-verified release source distribution, which the
release path builds from the tag and stages for xbps-src (see
``tests/test_void_release_build.py``),
depends on Void's own interpreter, GTK, desktop bindings and packaged
Python libraries plus the dependency templates from tickets #25 and #26,
and installs the renamed desktop entry and icons. Speech models are not
package payload, nothing is vendored, and no pip runs at build or
install time.
Independent sources of truth: ``src/vocalinux/version.py`` owns the
version, ``pyproject.toml`` owns the dependency ranges, ``MANIFEST.in``
plus the renamed ``voculinux.desktop`` own the install paths, and the
fork's release path (``releases/download/<tag>``) plus ``xbps-src``
conventions own the fetch shape. The template must agree with all of
them; the container smoke gate (ticket #28) runs the same release path.
"""
import re
import tomllib
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
VOID = REPO_ROOT / "packaging" / "void"
SRC = VOID / "srcpkgs"
TEMPLATE = SRC / "voculinux" / "template"
INSTALL_MSG = SRC / "voculinux" / "INSTALL.msg"
PINS = VOID / "tool_checksums.txt"
RELEASE_PATH = VOID / "build-release.sh"
CONTAINER_RELEASE = VOID / "container-release.sh"
VERSION_PY = REPO_ROOT / "src" / "vocalinux" / "version.py"
PYPROJECT = REPO_ROOT / "pyproject.toml"
DISTRO_MAP = REPO_ROOT / "scripts" / "distro-package-map.yaml"
DISTRO_DOC = REPO_ROOT / "docs" / "DISTRO_COMPATIBILITY.md"
#: pip name (lowercase, dashes) -> the Void binary package providing it.
#: pywhispercpp/pynput/PyAudio come from the ticket #25/#26 templates in
#: this same tree; the rest are Void's own packages.
PIP_TO_VOID = {
"pywhispercpp": "python3-pywhispercpp",
"pynput": "python3-pynput",
"evdev": "python3-evdev",
"requests": "python3-requests",
"pysocks": "python3-pysocks",
"numpy": "python3-numpy",
"pyaudio": "python3-PyAudio",
"pygobject": "python3-gobject",
"psutil": "python3-psutil",
}
#: Void packages beyond the direct pip mapping: pywhispercpp's own
#: install_requires (tqdm, platformdirs), pynput's X11 backend (xlib),
#: the directly imported cairo binding, and the desktop substrate.
EXTRA_VOID_DEPS = (
"python3-tqdm",
"python3-platformdirs",
"python3-xlib",
"python3-cairo",
"python3",
"gtk+3",
"libayatana-appindicator",
"gobject-introspection",
)
#: Command-line helpers the app runs at runtime, and the Void package each
#: comes from. The release gate needs them on a packaged install (ticket
#: #27; spec #16 "Void packaging"). ydotool is deliberately absent: it is not
#: packaged for Void, so its fallback rung does not exist there.
RUNTIME_HELPER_PACKAGES = {
"pipewire": "pw-record and pw-dump for PipeWire capture",
"wtype": "direct keysym typing, the first rung on niri",
"wl-clipboard": "wl-copy and wl-paste for the clipboard paths",
"xdotool": "X11 and XWayland injection",
"pulseaudio-utils": "pactl source listing",
}
#: The placeholder the committed template carries until the first fork release
#: has a recorded digest. Every release build replaces it in its build tree; it
#: is never a real digest, and it never reaches a published package.
PLACEHOLDER_CHECKSUM = "0" * 64
#: sha256 of the upstream 0.18.5 archive. The template must never pin it: the
#: package is the fork's own release, not a copy of upstream's.
UPSTREAM_0_18_5_SHA256 = "1def4d8f0f14f2df2f2fee64ca29e6865e3d82e551f7d465c2a78ec6d4d72b1d"
#: The fork's release host. Every distfile and the homepage live here.
FORK_RELEASE_HOST = "https://git.bongbetic.com/xavierk/voculinux"
#: Every package name the app template may reference, verified to exist in
#: void-packages master on 2026-11-06 via the srcpkgs/<name>/template
#: paths (python3-devel is a subpackage of the python3 template, as
#: recorded for ticket #25). Re-verify with `xbps-query -Rs <name>` on a
#: Void system before extending this set.
VERIFIED_VOID_PACKAGES = frozenset(
{
"python3",
"python3-devel",
"python3-setuptools",
"python3-gobject",
"python3-cairo",
"gtk+3",
"libayatana-appindicator",
"gobject-introspection",
"pipewire",
"wtype",
"wl-clipboard",
"xdotool",
"pulseaudio-utils",
"python3-numpy",
"python3-requests",
"python3-pysocks",
"python3-psutil",
"python3-evdev",
"python3-xlib",
"python3-tqdm",
"python3-platformdirs",
"python3-PyAudio",
"python3-pynput",
"python3-pywhispercpp",
}
)
#: Template options that switch off dependency verification. The app
#: resolves every dependency deliberately through the mapping above,
#: never by hiding it behind one of these.
FORBIDDEN_VARS = ("noverifypydeps", "nopyprovides")
#: Carried templates whose stock Void package sits below the project floor
#: (tests/test_void_small_deps.py, GAP_PACKAGES). voculinux must require each
#: one as name>=<carried version>_<revision>. A bare name is satisfied by the
#: stock package a user already has, so it would never be upgraded.
GAP_DEPENDS = ("python3-PyAudio", "python3-pysocks")
#: One xbps dependency token: a bare name, or name, comparator and
#: version_revision. The whole token must match; a malformed token such as
#: "python3-PyAudio>=" is refused, never stripped down to its name.
DEPENDS_TOKEN = re.compile(
r"(?P<name>[A-Za-z0-9+._-]+)(?:(?P<op>>=|<=|>|<)(?P<version>[0-9][A-Za-z0-9.]*_[0-9]+))?"
)
def _template_text() -> str:
"""Read the raw template file."""
assert TEMPLATE.is_file(), f"missing Void app template: {TEMPLATE}"
return TEMPLATE.read_text(encoding="utf-8")
def _template_vars() -> dict:
"""Parse top-level VAR=value assignments from the template file."""
variables = {}
in_function = False
for line in _template_text().splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if re.fullmatch(r"[A-Za-z0-9_+-]+\(\)\s*\{", stripped):
in_function = True
continue
if in_function:
if stripped == "}":
in_function = False
continue
key, separator, value = stripped.partition("=")
if not separator or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key):
continue
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
value = value[1:-1]
variables[key] = value
return variables
def _code_lines() -> str:
"""Template with comment lines stripped; behaviour lives in code."""
return "\n".join(
line for line in _template_text().splitlines() if not line.lstrip().startswith("#")
)
def _post_install_body() -> str:
"""Shell lines inside post_install(), without comments."""
lines = _template_text().splitlines()
start = next(i for i, line in enumerate(lines) if line.strip().startswith("post_install()"))
body = []
for line in lines[start + 1 :]:
if line.strip() == "}":
break
if line.strip() and not line.lstrip().startswith("#"):
body.append(line.strip())
return "\n".join(body)
def _app_version() -> str:
"""Version owned by src/vocalinux/version.py."""
match = re.search(r'^__version__ = "([^"]+)"', VERSION_PY.read_text(encoding="utf-8"), re.M)
assert match, "version.py no longer defines __version__"
return match.group(1)
def _project_dependencies() -> list:
"""Full dependency clauses from pyproject.toml."""
data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))
return list(data["project"]["dependencies"])
def _depends_tokens(value: str) -> list:
"""Validated tokens of an xbps depends value; a malformed token fails the test."""
tokens = []
for token in value.split():
if token.startswith("${"):
continue
assert DEPENDS_TOKEN.fullmatch(token), f"malformed dependency token: {token!r}"
tokens.append(token)
return tokens
def _depends_names(value: str) -> set:
"""Package names of an xbps depends value, with any version constraint removed."""
return {DEPENDS_TOKEN.fullmatch(token).group("name") for token in _depends_tokens(value)}
def _carried_pkgver(name: str) -> str:
"""The pkgver a carried template builds: its version and revision, as written."""
text = (SRC / name / "template").read_text(encoding="utf-8")
version = re.search(r"^version=(\S+)$", text, re.M)
revision = re.search(r"^revision=(\d+)$", text, re.M)
assert version and revision, f"{name} template lacks a version or revision"
return f"{version.group(1)}_{revision.group(1)}"
def test_app_template_lives_in_void_packages_layout() -> None:
"""The app template sits where xbps-src expects it: srcpkgs/<pkgname>."""
assert TEMPLATE.is_file(), f"{TEMPLATE} is missing"
assert INSTALL_MSG.is_file(), f"{INSTALL_MSG} is missing"
def test_pkgname_matches_template_directory() -> None:
"""Void requires pkgname to match srcpkgs/<pkgname>, using the new name."""
assert _template_vars()["pkgname"] == TEMPLATE.parent.name == "voculinux"
def test_mandatory_void_variables_are_present() -> None:
"""The Manual's mandatory variables plus fetch inputs must be set."""
variables = _template_vars()
for key in (
"pkgname",
"version",
"revision",
"short_desc",
"maintainer",
"license",
"homepage",
"build_style",
"hostmakedepends",
"makedepends",
"depends",
"distfiles",
"checksum",
):
assert variables.get(key), f"template is missing {key}"
# The release sdist ships no setup.py (setuptools pyproject-only), so
# xbps-src must build the PEP 517 wheel, not run setup.py directly.
assert variables["build_style"] == "python3-pep517"
assert "python3-setuptools" in variables["hostmakedepends"].split()
assert "python3-devel" in variables["makedepends"].split()
assert variables["revision"] == "1", "new templates start at revision 1"
def test_template_metadata_shape() -> None:
"""Maintainer, license, homepage and description follow the Manual."""
variables = _template_vars()
assert re.fullmatch(r".+ <[^@<>\s]+@[^@<>\s]+>", variables["maintainer"])
assert "noreply" not in variables["maintainer"]
assert variables["license"] == "AGPL-3.0-only"
assert variables["homepage"] == FORK_RELEASE_HOST
assert len(variables["short_desc"]) <= 72
assert re.fullmatch(r"[0-9][A-Za-z0-9.]*", variables["version"])
def test_template_version_matches_version_py() -> None:
"""The template builds the current source version, not a stale one."""
assert _template_vars()["version"] == _app_version()
def test_distfiles_sources_the_canonical_release_by_checksum() -> None:
"""One fork release-asset distfile, checksum-bound; never upstream or git."""
variables = _template_vars()
distfiles = variables["distfiles"]
assert distfiles == (
f"{FORK_RELEASE_HOST}/releases/download/${{_tag}}/"
f"{variables['pkgname']}-${{version}}.tar.gz"
), "distfiles must name the fork's release asset by its tag and xbps version"
assert "github.com" not in distfiles and "VocaHQ" not in distfiles, "no upstream archive"
for moving in ("/master", "/main", "/archive/", "git+"):
assert moving not in distfiles, f"distfiles must not track a moving ref: {moving}"
assert distfiles.rstrip().endswith(".tar.gz"), "the release payload is the sdist tarball"
assert variables["_tag"] == f"v{_app_version()}", "_tag is the tag, hyphen and all"
assert "v${version}" not in distfiles, "a pre-release tag keeps its hyphen in the URL"
assert "wrksrc" not in variables, "deprecated wrksrc override: xlint fails the template"
def test_checksum_is_a_recorded_digest_or_the_build_placeholder() -> None:
"""A 64-hex digest once recorded; until then the all-zero placeholder that
every release build replaces. Never the upstream digest, never empty."""
checksum = _template_vars()["checksum"]
assert checksum != UPSTREAM_0_18_5_SHA256, "the template must not pin the upstream archive"
assert re.fullmatch(r"[0-9a-f]{64}", checksum), f"not a sha256: {checksum!r}"
assert "PENDING" not in _template_text(), "no pending marker: the build pins the digest"
def test_every_project_dependency_is_covered() -> None:
"""pyproject.toml runtime deps plus substrate must all resolve to depends."""
depends = _depends_names(_template_vars()["depends"])
uncovered = []
for clause in _project_dependencies():
pip_name = re.match(r"^([A-Za-z0-9._-]+)", clause).group(1).lower().replace("_", "-")
void_name = PIP_TO_VOID.get(pip_name)
assert void_name, f"no Void mapping recorded for {pip_name}"
if void_name not in depends:
uncovered.append(f"{pip_name} -> {void_name}")
missing_extra = [name for name in EXTRA_VOID_DEPS if name not in depends]
assert not uncovered, f"deps with no Void coverage: {uncovered}"
assert not missing_extra, f"substrate missing from depends: {missing_extra}"
def test_template_dependencies_exist_in_void_packages() -> None:
"""Template dependency lists name only verified void-packages names."""
variables = _template_vars()
referenced = set()
for key in ("depends", "hostmakedepends", "makedepends", "checkdepends"):
referenced |= _depends_names(variables.get(key, ""))
unknown = sorted(referenced - VERIFIED_VOID_PACKAGES)
assert not unknown, f"template references unverified packages: {unknown}"
def test_depends_tokens_are_validated_not_stripped() -> None:
"""A constrained token is accepted only in full; a truncated one is malformed."""
assert DEPENDS_TOKEN.fullmatch("python3-PyAudio>=0.2.14_1")
assert DEPENDS_TOKEN.fullmatch("python3-pysocks")
for malformed in ("python3-PyAudio>=", "python3-PyAudio>=0.2.14", ">=0.2.14_1"):
assert not DEPENDS_TOKEN.fullmatch(malformed), malformed
with pytest.raises(AssertionError):
_depends_tokens(malformed)
def test_gap_packages_are_version_constrained_to_their_carried_templates() -> None:
"""voculinux requires name>=<carried pkgver>, never the bare name.
A bare name is satisfied by the stock package a user already has (below the
floor), so it would keep the stock package. The constraint must track the
carried template: bumping its version or revision without this line fails.
"""
tokens = _depends_tokens(_template_vars()["depends"])
for name in GAP_DEPENDS:
matching = [
token for token in tokens if DEPENDS_TOKEN.fullmatch(token).group("name") == name
]
expected = f"{name}>={_carried_pkgver(name)}"
assert matching == [expected], f"depends must name {expected}; found {matching}"
def test_no_stale_indicator_library_name() -> None:
"""The pre-rename libappindicator names do not exist in void-packages."""
variables = _template_vars()
for key in ("depends", "makedepends", "hostmakedepends"):
for token in variables.get(key, "").split():
assert token not in ("libappindicator", "libappindicator-gtk3"), (
f"{key} names {token}, which has no void-packages template; "
"the Ayatana fork (libayatana-appindicator) is the provider"
)
def test_no_pip_or_vendored_payload_at_build_or_install() -> None:
"""The build installs from the sdist; pip never runs, nothing is vendored."""
code = _code_lines().lower()
for forbidden in ("pip install", "pip3 install", "pip download", "requirements", ".whl"):
assert forbidden not in code, f"template runs pip or vendors wheels: {forbidden}"
assert "venv" not in code
body = _post_install_body().lower()
assert "pip" not in body, "post_install must not invoke pip"
def test_package_ships_no_speech_models() -> None:
"""Speech models download on first run; they are not package payload."""
code = _code_lines().lower()
assert "model" not in code, "models are not package payload"
def test_post_install_installs_the_fork_names_without_renaming() -> None:
"""The fork's sdist already carries the new names, so post_install installs
them as they are: no sed rewrite and no mv of upstream launchers."""
body = _post_install_body()
assert "vlicense LICENSE" in body
assert "vinstall voculinux.desktop 644 usr/share/applications" in body
assert "resources/icons/scalable" in body
assert "usr/share/icons/hicolor/scalable/apps" in body
assert "usr/share/voculinux/packaged" in body
for upstream_step in ("sed ", " mv ", "vocalinux"):
assert (
upstream_step not in body
), f"post_install still renames upstream names: {upstream_step}"
assert "usr/bin/vocalinux" not in _template_text(), "no upstream launcher is promoted"
def test_runtime_helpers_are_declared_and_ibus_is_not() -> None:
"""Capture, direct typing, the clipboard and X11 helpers come from xbps;
ibus is excluded because it is not an answer on niri (spec #16)."""
depends = set(_template_vars()["depends"].split())
for package, why in RUNTIME_HELPER_PACKAGES.items():
assert package in depends, f"depends must pull in {package} ({why})"
assert "ibus" not in depends, "ibus is excluded: it cannot inject on niri"
assert "ydotool" not in depends, "ydotool is not packaged for Void"
def test_build_fetches_nothing_beyond_the_sdist() -> None:
"""Only the homepage and the declared distfile may appear as URLs."""
text = _template_text()
assert text.count("https://") == 2, "homepage + distfiles are the only URLs a template needs"
assert "FetchContent" not in text and "ExternalProject" not in text
def test_no_verification_disabling_options() -> None:
"""Dependency verification stays on; distfiles stay checksum-bound."""
variables = _template_vars()
for key in FORBIDDEN_VARS:
assert key not in variables, f"template sets {key}"
assert "checksum" in variables, "the distfile must carry a checksum line"
def test_template_style_is_lint_clean() -> None:
"""Trailing whitespace and a 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_install_msg_covers_the_xbps_only_facts() -> None:
"""INSTALL.msg tells the user what xbps cannot express."""
assert INSTALL_MSG.is_file(), f"missing install message: {INSTALL_MSG}"
text = INSTALL_MSG.read_text(encoding="utf-8").lower()
assert "input" in text, "the evdev hotkey backend needs the input group"
assert "model" in text, "models download on first run"
for engine in ("vosk", "whisper", "parakeet", "faster-whisper", "onnxruntime"):
assert engine in text, f"optional-engine status must name {engine}"
assert "ydotool" in text, "the absent injection helper must be named"
assert "no permission" in text, "niri first run has no permission prompt"
assert "wtype" in text, "niri types direct through wtype"
assert "ibus" not in text, "IBus is excluded; the message must not point at it"
sentences = re.split(r"(?<=\.)\s+", " ".join(text.split()))
niri_sentences = [sentence for sentence in sentences if "niri" in sentence]
assert niri_sentences, "the message must describe the niri first run"
assert not any(
"portal" in sentence for sentence in niri_sentences
), "the portal is never recommended on niri"
def test_install_msg_line_length() -> None:
"""The Manual asks for at most 80 columns per line in INSTALL.msg."""
raw = INSTALL_MSG.read_bytes().decode("utf-8")
for lineno, line in enumerate(raw.splitlines(), 1):
assert len(line) <= 80, f"line {lineno} exceeds 80 columns"
assert raw.endswith("\n"), "INSTALL.msg must end with a newline"
def test_release_path_covers_the_app_package() -> None:
"""The install smoke runs the renamed binary and the app's payload checks."""
harness = CONTAINER_RELEASE.read_text(encoding="utf-8")
assert "voculinux --version" in harness, "the app smoke checks the entry point"
assert "usr/share/applications/voculinux.desktop" in harness
assert "usr/share/icons/hicolor/scalable/apps/voculinux.svg" in harness
assert "usr/share/voculinux/packaged" in harness
assert (
"no pre-rename" in harness or "pre-rename file names" in harness
), "the payload check must reject pre-rename file names in the package"
assert "models or wheels" in harness, "the payload check rejects models and wheels"
assert "import vocalinux.main" in harness, "the install smoke imports the app"
assert "pywhispercpp.model" in harness, "the install smoke imports the speech binding"
assert "PYNPUT_BACKEND=dummy" in harness and "import evdev" in harness
assert "build-release.sh" in RELEASE_PATH.read_text(encoding="utf-8")
def test_void_distro_inventory_matches_void_packages_reality() -> None:
"""The installer inventory names the Ayatana provider and marks ydotool absent."""
text = DISTRO_MAP.read_text(encoding="utf-8")
void_block = text.split(" void:", 1)[1].split("\n solus:", 1)[0]
code = "\n".join(line for line in void_block.splitlines() if not line.lstrip().startswith("#"))
assert "libayatana-appindicator" in code
assert "libappindicator-gtk3" not in code
assert "libappindicator " not in code.replace("libayatana-appindicator", "")
assert "absent" in void_block, "the ydotool absence must be marked in the inventory"
def test_void_install_docs_name_the_ayatana_provider() -> None:
"""The Void install snippet must install a package that exists."""
text = DISTRO_DOC.read_text(encoding="utf-8")
void_section = text.split("### Void Linux", 1)[1].split("###", 1)[0]
assert "libayatana-appindicator" in void_section
assert "libappindicator-gtk3" not in void_section
assert "libappindicator " not in void_section.replace("libayatana-appindicator", "")