From c462ffb4b27ec519e92a85d9da256ecabb21fc52 Mon Sep 17 00:00:00 2001 From: soubarnak Date: Thu, 5 Nov 2026 23:09:16 +0530 Subject: [PATCH] feat(void): add xbps templates for pynput and updated PyAudio Ticket: https://git.bongbetic.com/xavierk/voculinux/issues/25 Two in-repo void-packages-layout templates under packaging/void/srcpkgs: python3-pynput 1.8.2 (new; Void does not ship it) and python3-PyAudio 0.2.14 (updated local template; Void ships 0.2.12, below the project's pyaudio>=0.2.13 floor). Versions and checksums track uv.lock and requirements/runtime.txt; pynput post_patch strips setup_requires so the build never fetches helpers over the network (setuptools-lint and twine are not in void-packages). No noverifypydeps/nopyprovides anywhere. Adds tests/test_void_small_deps.py (offline policy test): template layout and metadata shape, lock/export/floor pins, checksum pins, no verification-disabling options, linux dep coverage, verified-only dependency names, and the digest-pinned base image in packaging/void/tool_checksums.txt. Verified: xlint clean on both templates; both packages build with xbps-src in the pinned ghcr.io/void-linux/void-glibc-full container (podman) and install in a fresh container with pyaudio 0.2.14 importing against PortAudio V19.6.0. --- .../void/srcpkgs/python3-PyAudio/template | 19 ++ .../void/srcpkgs/python3-pynput/template | 20 ++ packaging/void/tool_checksums.txt | 16 + tests/test_void_small_deps.py | 283 ++++++++++++++++++ 4 files changed, 338 insertions(+) create mode 100644 packaging/void/srcpkgs/python3-PyAudio/template create mode 100644 packaging/void/srcpkgs/python3-pynput/template create mode 100644 packaging/void/tool_checksums.txt create mode 100644 tests/test_void_small_deps.py diff --git a/packaging/void/srcpkgs/python3-PyAudio/template b/packaging/void/srcpkgs/python3-PyAudio/template new file mode 100644 index 0000000..1ae0362 --- /dev/null +++ b/packaging/void/srcpkgs/python3-PyAudio/template @@ -0,0 +1,19 @@ +# Template file for 'python3-PyAudio' +pkgname=python3-PyAudio +version=0.2.14 +revision=1 +build_style=python3-module +hostmakedepends="python3-setuptools" +makedepends="python3-devel portaudio-devel" +depends="python3" +short_desc="Python3 Bindings for PortAudio, a cross-platform audio IO library" +maintainer="Jatin K Malik " +license="MIT" +homepage="https://people.csail.mit.edu/hubert/pyaudio/" +distfiles="${PYPI_SITE}/P/PyAudio/PyAudio-${version}.tar.gz" +checksum=78dfff3879b4994d1f4fc6485646a57755c6ee3c19647a491f790a0895bd2f87 + +post_install() { + sed -n '/Copyright/,/SOFTWARE\./p'< README.md > LICENSE + vlicense LICENSE +} diff --git a/packaging/void/srcpkgs/python3-pynput/template b/packaging/void/srcpkgs/python3-pynput/template new file mode 100644 index 0000000..2beaa1c --- /dev/null +++ b/packaging/void/srcpkgs/python3-pynput/template @@ -0,0 +1,20 @@ +# Template file for 'python3-pynput' +pkgname=python3-pynput +version=1.8.2 +revision=1 +build_style=python3-module +hostmakedepends="python3-setuptools" +depends="python3-evdev python3-six python3-xlib" +short_desc="Monitor and control user input devices" +maintainer="Jatin K Malik " +license="LGPL-3.0-or-later" +homepage="https://github.com/moses-palmer/pynput" +changelog="https://github.com/moses-palmer/pynput/raw/master/CHANGES.rst" +distfiles="${PYPI_SITE}/p/pynput/pynput-${version}.tar.gz" +checksum=f493c87157cd3861b4468f7f896857051762f44ed26f1b641e7cc5840a457087 + +post_patch() { + # setup_requires would make setuptools fetch helpers over the network; + # none of them are needed to build, and some are not in void-packages. + vsed -e "/setup_requires/d" -i setup.py +} diff --git a/packaging/void/tool_checksums.txt b/packaging/void/tool_checksums.txt new file mode 100644 index 0000000..2624a0b --- /dev/null +++ b/packaging/void/tool_checksums.txt @@ -0,0 +1,16 @@ +# Pinned build inputs for packaging/void (ticket #25: small Python dependency templates). +# +# Same contract as packaging/appimage/tool_checksums.txt and +# packaging/native/tool_checksums.txt: everything the template builds verify +# against is pinned here, so two builds of the same commit cannot differ. +# +# Columns: name, kind, value, source — parseable by awk. +# +# docker build inside image ; records the tag it came from +# +# To bump the base image: replace the digest in with the index digest +# from `podman manifest inspect ghcr.io/void-linux/void-glibc-full:latest` +# (use the index digest, not a per-architecture one — the same line has to +# work on x86_64 and aarch64). + +base-image docker ghcr.io/void-linux/void-glibc-full@sha256:e98f8cdde9e6ca85f8542fad5cdd06032a05dca3bf2b81757949bb24d67afa37 latest diff --git a/tests/test_void_small_deps.py b/tests/test_void_small_deps.py new file mode 100644 index 0000000..7963d2f --- /dev/null +++ b/tests/test_void_small_deps.py @@ -0,0 +1,283 @@ +"""Offline policy guards for the Void small-dependency templates (ticket #25). + +Spec #16 (seam 2) closes the two easy Void dependency gaps — the X11 +keyboard input library and the updated audio binding — as in-repo xbps +templates in void-packages layout under the packaging tree, with the audio +binding at or above the project's declared 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 + +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") + +#: uv.lock package name per template directory name. +LOCK_NAMES = {"python3-pynput": "pynput", "python3-PyAudio": "pyaudio"} + +#: 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 ` 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", +} + +#: 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: + """Both templates sit where xbps-src expects them: srcpkgs/.""" + 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/.""" + 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 + + +def test_audio_binding_satisfies_project_floor() -> None: + """The updated template meets the declared floor it was built to close.""" + specifier = _project_specifier("pyaudio") + template = _version_tuple(_template_version("python3-PyAudio")) + 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 + + +def test_audio_binding_floor_was_not_relaxed() -> None: + """Void's 0.2.12 stays below the floor: the floor itself never moved.""" + specifier = _project_specifier("pyaudio") + lowers = [ + _version_tuple(floor) + for operator, floor in re.findall(r"(>=|==|~=|>)\s*([\d][\d.]*)", specifier) + if operator in (">=", "==", "~=", ">") + ] + assert lowers, f"pyaudio clause carries no lower bound: {specifier}" + assert min(lowers) >= (0, 2, 13), 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