feat(void): add xbps templates for pynput and updated PyAudio
Ticket: #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.
This commit is contained in:
@@ -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 <jatinkrmalik@gmail.com>"
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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 <jatinkrmalik@gmail.com>"
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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 <value>; <source> records the tag it came from
|
||||||
|
#
|
||||||
|
# To bump the base image: replace the digest in <value> 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
|
||||||
@@ -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 <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",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: 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/<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
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user