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.
307 lines
12 KiB
Python
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
|