Files
voculinux/tests/test_void_small_deps.py
T
xavierk 37cf549956
CI / security (push) Successful in 1m15s
CI / lint (push) Successful in 24s
CI / ai-review (push) Skipped
fix(void): carry python3-pysocks and constrain the audio and SOCKS floors (#39)
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.
2026-11-06 16:17:19 +00:00

307 lines
12 KiB
Python

"""Offline policy guards for the Void small-dependency templates (ticket #25).
Spec #16 (seam 2) closes the Void dependency gaps — the X11 keyboard input
library, and the audio binding and SOCKS module that sit below the project's
declared floors — as in-repo xbps templates in void-packages layout under the
packaging tree, each at or above its floor, and no verification-disabling
template options anywhere.
These tests pin the templates to the project's own lockfile (`uv.lock`,
`requirements/runtime.txt`, `pyproject.toml`) so a template that drifts
from the locked versions, relaxes the floor, disables dependency
verification, or names a package that does not exist in void-packages fails
offline — the same contract as the native/AppImage policy tests.
"""
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"
PINS = VOID / "tool_checksums.txt"
PYPROJECT = REPO_ROOT / "pyproject.toml"
RUNTIME = REPO_ROOT / "requirements" / "runtime.txt"
LOCK = REPO_ROOT / "uv.lock"
TEMPLATES = ("python3-pynput", "python3-PyAudio", "python3-pysocks")
#: uv.lock package name per template directory name.
LOCK_NAMES = {
"python3-pynput": "pynput",
"python3-PyAudio": "pyaudio",
"python3-pysocks": "pysocks",
}
#: Carried templates that replace a stock Void package sitting below the
#: project's floor: template -> (pip name, stock Void pkgver, the floor).
#: The stock pkgvers were read from void-packages master on 2026-11-06
#: (srcpkgs/python3-PyAudio/template 0.2.12_5, srcpkgs/python3-pysocks/template
#: 1.7.0_8). Stock below the floor is what makes the gap real.
GAP_PACKAGES = {
"python3-PyAudio": ("pyaudio", "0.2.12_5", (0, 2, 13)),
"python3-pysocks": ("pysocks", "1.7.0_8", (1, 7, 1)),
}
#: Void package names covering pynput's linux-marked install_requires
#: (evdev>=1.3, python-xlib>=0.17, six) as recorded in uv.lock.
PYNPUT_LINUX_DEPS = ("python3-evdev", "python3-six", "python3-xlib")
#: Every package name the templates may reference, verified to exist in
#: void-packages master on 2026-11-05: python3-{evdev,six,xlib} and
#: python3-setuptools templates fetched and read; portaudio-devel and
#: python3-devel confirmed as subpackages of the portaudio/python3
#: templates. Re-verify with `xbps-query -Rs <name>` on a Void system
#: (or the srcpkgs tree on GitHub) before extending this set.
VERIFIED_VOID_PACKAGES = frozenset(
{
"python3",
"python3-devel",
"python3-setuptools",
"python3-evdev",
"python3-six",
"python3-xlib",
"portaudio-devel",
}
)
#: Template options that switch off dependency verification. Ticket #25
#: resolves the audio version gap deliberately with an updated template,
#: never by hiding it behind one of these.
FORBIDDEN_VARS = ("noverifypydeps", "nopyprovides")
#: SPDX identifiers for the pinned versions (Void Manual: license must be
#: an SPDX expression). pynput 1.8.2 declares LGPLv3; PyAudio is MIT.
EXPECTED_LICENSES = {
"python3-pynput": "LGPL-3.0-or-later",
"python3-PyAudio": "MIT",
"python3-pysocks": "BSD-3-Clause",
}
#: Void Manual mandatory variables, plus the fetch inputs every template
#: here must declare so nothing arrives from an unpinned URL.
MANDATORY_VARS = (
"pkgname",
"version",
"revision",
"short_desc",
"maintainer",
"license",
"homepage",
"build_style",
"hostmakedepends",
"distfiles",
"checksum",
)
def _template_vars(name: str) -> dict:
"""Parse top-level VAR=value assignments from a void template file."""
text = (SRC / name / "template").read_text(encoding="utf-8")
variables = {}
in_function = False
for line in 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 _locked_sdist(name: str) -> tuple:
"""Return (version, sdist sha256) for a package in uv.lock."""
text = LOCK.read_text(encoding="utf-8")
block = re.search(
rf'^name = "{re.escape(name)}"\nversion = "([^"]+)"(.*?)(?=^\[\[package\]\]|\Z)',
text,
re.M | re.S,
)
assert block, f"{name} is missing from uv.lock"
sdist = re.search(r'sdist = \{[^}]*hash = "sha256:([0-9a-f]{64})"', block.group(2))
assert sdist, f"{name} has no hashed sdist in uv.lock"
return block.group(1), sdist.group(1)
def _exported_version(name: str) -> str:
"""Return the pinned version from the hash-pinned runtime export."""
match = re.search(
rf"^{re.escape(name)}==([^\s;\\]+)",
RUNTIME.read_text(encoding="utf-8"),
re.M,
)
assert match, f"{name} is not pinned in requirements/runtime.txt"
return match.group(1)
def _version_tuple(version: str) -> tuple:
"""Compare dotted versions without packaging (test-only helper)."""
return tuple(int(part) for part in version.split("."))
def _project_specifier(name: str) -> str:
"""Return the full dependency clause for a pip name in pyproject.toml."""
data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))
for dep in data["project"]["dependencies"]:
pip_name = re.match(r"^([A-Za-z0-9._-]+)", dep).group(1).lower().replace("_", "-")
if pip_name == name:
return dep
raise AssertionError(f"{name} is missing from pyproject.toml dependencies")
def _template_version(name: str) -> str:
"""Return the template version for a template directory name."""
return _template_vars(name)["version"]
def _void_pins() -> dict:
"""Parse packaging/void/tool_checksums.txt (name -> kind, value, source)."""
pins = {}
for line in PINS.read_text(encoding="utf-8").splitlines():
if not line.strip() or line.lstrip().startswith("#"):
continue
entry = line.split(None, 3)
assert len(entry) == 4, f"unparseable pin line: {line!r}"
pins[entry[0]] = (entry[1], entry[2], entry[3].strip())
return pins
def test_templates_live_in_void_packages_layout() -> None:
"""Every carried template sits where xbps-src expects it: srcpkgs/<pkgname>."""
for name in TEMPLATES:
template = SRC / name / "template"
assert template.is_file(), f"{template} is missing"
def test_pkgname_matches_template_directory() -> None:
"""Void requires pkgname to match srcpkgs/<pkgname>."""
for name in TEMPLATES:
assert _template_vars(name)["pkgname"] == name, name
def test_mandatory_void_variables_are_present() -> None:
"""The Manual's mandatory variables plus fetch inputs must be set."""
for name in TEMPLATES:
variables = _template_vars(name)
missing = [key for key in MANDATORY_VARS if not variables.get(key)]
assert not missing, f"{name} is missing: {missing}"
assert "python3-setuptools" in variables["hostmakedepends"].split(), name
pyaudio = _template_vars("python3-PyAudio")
assert "portaudio-devel" in pyaudio.get("makedepends", "").split()
def test_template_versions_match_lock_and_runtime_export() -> None:
"""Templates track the locked sdist versions, not newest upstream."""
for name in TEMPLATES:
version, _ = _locked_sdist(LOCK_NAMES[name])
assert _template_version(name) == version, name
assert _exported_version(LOCK_NAMES[name]) == version, name
def test_template_checksums_match_lock_sdist() -> None:
"""The template checksum is the hash uv already recorded for the sdist."""
for name in TEMPLATES:
_, digest = _locked_sdist(LOCK_NAMES[name])
assert _template_vars(name)["checksum"] == digest, name
@pytest.mark.parametrize("name", sorted(GAP_PACKAGES))
def test_carried_templates_satisfy_project_floors(name: str) -> None:
"""The updated template meets the declared floor it was built to close."""
pip_name, _, _ = GAP_PACKAGES[name]
specifier = _project_specifier(pip_name)
template = _version_tuple(_template_version(name))
for operator, floor in re.findall(r"(>=|==|~=|>)\s*([\d][\d.]*)", specifier):
if operator == ">":
assert template > _version_tuple(floor), specifier
else:
assert template >= _version_tuple(floor), specifier
@pytest.mark.parametrize("name", sorted(GAP_PACKAGES))
def test_stock_package_is_below_the_floor_and_the_floor_was_not_relaxed(name: str) -> None:
"""Stock Void sits below the floor, so the gap is real; the floor never moved."""
pip_name, stock, floor = GAP_PACKAGES[name]
stock_version = _version_tuple(stock.split("_")[0])
assert stock_version < floor, f"{name}: stock {stock} is not below the floor {floor}"
specifier = _project_specifier(pip_name)
lowers = [
_version_tuple(bound)
for operator, bound in re.findall(r"(>=|==|~=|>)\s*([\d][\d.]*)", specifier)
if operator in (">=", "==", "~=", ">")
]
assert lowers, f"{pip_name} clause carries no lower bound: {specifier}"
assert min(lowers) >= floor, f"floor relaxed to hide the gap: {specifier}"
def test_no_verification_disabling_options() -> None:
"""Dependency verification stays on; distfiles stay checksum-pinned."""
for name in TEMPLATES:
variables = _template_vars(name)
for key in FORBIDDEN_VARS:
assert key not in variables, f"{name} sets {key}"
assert re.fullmatch(r"[0-9a-f]{64}", variables["checksum"]), name
assert variables["distfiles"], name
for moving in ("/master", "/main", "/continuous"):
assert moving not in variables["distfiles"], f"{name}: {moving}"
def test_pynput_template_covers_linux_runtime_deps() -> None:
"""Every linux-marked install_requires of pynput resolves to a dep."""
depends = set(_template_vars("python3-pynput").get("depends", "").split())
missing = [dep for dep in PYNPUT_LINUX_DEPS if dep not in depends]
assert not missing, f"python3-pynput depends is missing: {missing}"
def test_template_dependencies_exist_in_void_packages() -> None:
"""Template dependency lists name only verified void-packages names."""
for name in TEMPLATES:
variables = _template_vars(name)
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"{name} references unverified packages: {unknown}"
def test_void_container_base_image_is_digest_pinned() -> None:
"""The build container is pinned by index digest, like the other gates."""
pins = _void_pins()
assert "base-image" in pins, "base-image pin is missing"
kind, value, source = pins["base-image"]
assert kind == "docker", f"base-image kind is {kind}"
assert "@sha256:" in value, "a tag is not a pin"
assert source, "the source tag the digest came from must be recorded"
def test_void_template_metadata_shape() -> None:
"""Maintainer, license, and description follow the Manual's shapes."""
for name in TEMPLATES:
variables = _template_vars(name)
assert re.fullmatch(
r".+ <[^@<>\s]+@[^@<>\s]+>", variables["maintainer"]
), f"{name}: bad maintainer"
assert "noreply" not in variables["maintainer"], name
assert variables["license"] == EXPECTED_LICENSES[name], name
assert len(variables["short_desc"]) <= 72, name
assert re.fullmatch(r"[0-9][A-Za-z0-9.]*", variables["version"]), name
assert variables["revision"].isdigit(), name