Merge feature/27 into integration/spec16 (ticket #27: Void xbps app template)

This commit is contained in:
soubarnak
2026-11-06 03:04:11 +05:30
13 changed files with 685 additions and 14 deletions
+93
View File
@@ -0,0 +1,93 @@
"""Unit guards for packaged-install detection and its update presentation.
Ticket #27 decides the update-checker behaviour for packaged installs:
background checks stay available as a signal, but every user-visible
update affordance presents the package-manager path instead of release
downloads. The xbps template installs the marker this module reads.
"""
from pathlib import Path
from vocalinux.utils.packaging import (
PACKAGED_MARKER_PATH,
PACKAGED_UPDATE_HINT,
is_packaged_install,
update_menu_label,
update_menu_tooltip,
update_notification_body,
updates_description,
)
def test_default_marker_lives_under_the_renamed_share_dir() -> None:
"""The template installs usr/share/voculinux/packaged; the code reads it."""
assert PACKAGED_MARKER_PATH == Path("/usr/share/voculinux/packaged")
def test_unpackaged_install_by_default(tmp_path: Path) -> None:
"""No marker in the test environment means a non-packaged install."""
assert not is_packaged_install(marker=tmp_path / "packaged")
def test_packaged_install_when_the_marker_exists(tmp_path: Path) -> None:
"""The xbps template drops the marker file at install time."""
marker = tmp_path / "packaged"
marker.touch()
assert is_packaged_install(marker=marker)
def test_packaged_hint_names_the_package_manager_path() -> None:
"""The presented path is xbps, not a release download."""
assert "package manager" in PACKAGED_UPDATE_HINT
assert "xbps-install -Syu voculinux" in PACKAGED_UPDATE_HINT
def test_unpackaged_menu_label_is_unchanged() -> None:
"""Source installs keep the release-download affordance they always had."""
assert update_menu_label("v1.2.3", packaged=False) == "Update Available (v1.2.3)…"
def test_packaged_menu_label_presents_the_package_manager() -> None:
"""Packaged installs must not misdirect users to release downloads."""
label = update_menu_label("v1.2.3", packaged=True)
assert "v1.2.3" in label
assert "package manager" in label
def test_unpackaged_menu_tooltip_is_unchanged() -> None:
"""Source installs keep pointing at release notes and download links."""
tooltip = update_menu_tooltip(packaged=False)
assert "release notes" in tooltip and "download links" in tooltip
def test_packaged_menu_tooltip_names_xbps() -> None:
"""The packaged tooltip tells the user where the update comes from."""
assert "xbps" in update_menu_tooltip(packaged=True)
def test_packaged_notification_body_presents_the_package_manager() -> None:
"""The desktop notification must not offer a download path either."""
body = update_notification_body("v1.2.3", packaged=True)
assert "v1.2.3" in body
assert "package manager" in body
def test_unpackaged_notification_body_keeps_release_notes() -> None:
"""Source installs keep the existing notification wording."""
body = update_notification_body("v1.2.3", packaged=False)
assert "v1.2.3" in body
assert "release notes" in body
def test_unpackaged_updates_description_is_unchanged() -> None:
"""The About page keeps its channel wording for source installs."""
description = updates_description("stable", packaged=False)
assert "stable" in description
assert "package manager" not in description
def test_packaged_updates_description_presents_the_package_manager() -> None:
"""The About page tells packaged users to update through xbps."""
description = updates_description("stable", packaged=True)
assert "stable" in description
assert "package manager" in description
+7
View File
@@ -122,6 +122,13 @@ def _allowed(path: str, line: str) -> bool:
if 'PKG_NAME="vocalinux"' in line:
# Installer probes the retained import package, not the distribution.
return True
if path == "packaging/void/srcpkgs/voculinux/template" and "vocalinux-${version}" in line:
# Pre-rename release asset root (ticket #27); dropped at the first
# post-rename release bump.
return True
if path == "tests/test_void_app_template.py" and '"vocalinux"' in line:
# The app-template policy test asserts the old name is gone.
return True
if '== "vocalinux"' in line or '.startswith("vocalinux' in line:
# Retained module identity comparisons in test isolation helpers.
return True
+379
View File
@@ -0,0 +1,379 @@
"""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,
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
GitHub release publish path (``releases/download``) plus ``xbps-src``
conventions own the fetch shape. The template must agree with all of
them; the container smoke gate (ticket #28) builds on top.
"""
import re
import tomllib
from pathlib import Path
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"
BUILD_SH = VOID / "build.sh"
CONTAINER_MAIN = VOID / "container-main.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",
"ibus",
"gobject-introspection",
)
#: 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",
"ibus",
"gobject-introspection",
"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")
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 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}"
assert variables["build_style"] == "python3-module"
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"] == "https://github.com/VocaHQ/vocalinux"
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 release-asset distfile, checksum-pinned; never a git snapshot."""
variables = _template_vars()
distfiles = variables["distfiles"]
version = variables["version"]
assert (
"https://github.com/VocaHQ/vocalinux/releases/download/v${version}/" in distfiles
), "distfiles must use the canonical release distribution for this version"
assert distfiles.rstrip().endswith(".tar.gz"), "the release payload is the sdist tarball"
for moving in ("/master", "/main", "/archive/", "git+"):
assert moving not in distfiles, f"distfiles must not track a moving ref: {moving}"
assert re.fullmatch(r"[0-9a-f]{64}", variables["checksum"]), "checksum must be sha256"
# The unpacked top-level directory must be where the template builds:
# wrksrc defaults to pkgname-version, so a distfile rooted under the
# pre-rename archive name needs an explicit wrksrc override.
root = Path(distfiles.rsplit("/", 1)[1].replace("${version}", version))
assert root.suffixes[-2:] == [".tar", ".gz"]
expected_wrksrc = variables.get("wrksrc", f"{variables['pkgname']}-${{version}}")
assert expected_wrksrc.replace("${version}", version) == root.name[: -len(".tar.gz")]
def test_every_project_dependency_is_covered() -> None:
"""pyproject.toml runtime deps plus substrate must all resolve to depends."""
depends = set(_template_vars()["depends"].split())
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"):
for token in variables.get(key, "").split():
if token.startswith("${"):
continue
referenced.add(token)
unknown = sorted(referenced - VERIFIED_VOID_PACKAGES)
assert not unknown, f"template references unverified packages: {unknown}"
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_uses_the_renamed_paths() -> None:
"""Desktop entry, icons, license and the packaged-install marker install."""
body = _post_install_body()
assert "vlicense LICENSE" in body
assert "voculinux.desktop" in body and "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
assert "vocalinux" not in body, "post_install must use only the renamed install paths"
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-pinned."""
variables = _template_vars()
for key in FORBIDDEN_VARS:
assert key not in variables, f"template sets {key}"
assert re.fullmatch(r"[0-9a-f]{64}", variables["checksum"])
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"
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_container_harness_covers_the_app_package() -> None:
"""build.sh stays podman-only; container-main smokes the installed app."""
build = BUILD_SH.read_text(encoding="utf-8")
assert "podman" in build, "the build harness must use podman"
assert "docker" not in build.lower().replace("never docker", "")
harness = CONTAINER_MAIN.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 "python3-pywhispercpp" in harness or "PKGNAME" in harness
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", "")