from __future__ import annotations
import argparse
import re
import sys
import tempfile
from pathlib import Path
TEMPLATE_VERSION = 1
SCALARS = ("pkgname", "pkgver", "pkgrel", "pkgdesc", "url")
ARRAYS = ("arch", "license", "depends", "makedepends", "options", "source", "sha256sums")
class ParseError(Exception):
def _strip_quotes(value: str) -> str:
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
return value[1:-1]
return value
def _split_array(body: str) -> list[str]:
out: list[str] = []
token = ""
quote = None
for ch in body:
if quote:
if ch == quote:
quote = None
else:
token += ch
elif ch in "'\"":
quote = ch
elif ch.isspace():
if token:
out.append(token)
token = ""
else:
token += ch
if quote:
raise ParseError(f"unterminated {quote} in array: {body!r}")
if token:
out.append(token)
return out
def parse_pkgbuild(text: str) -> dict[str, list[str]]:
fields: dict[str, list[str]] = {}
lines = text.splitlines()
i = 0
while i < len(lines):
raw = lines[i]
line = raw.strip()
i += 1
if not line or line.startswith("#"):
continue
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*\s*\(\)\s*\{", line):
break m = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$", line)
if not m:
continue
name, rest = m.group(1), m.group(2)
if name not in SCALARS and name not in ARRAYS:
continue
if rest.startswith("("):
body = rest
while body.count("(") > body.count(")"):
if i >= len(lines):
raise ParseError(f"unterminated array for {name}")
body += "\n" + lines[i]
i += 1
inner = body[body.index("(") + 1 : body.rindex(")")]
fields[name] = _split_array(inner)
else:
fields[name] = [_strip_quotes(rest)]
missing = [f for f in ("pkgname", "pkgver", "pkgrel") if f not in fields]
if missing:
raise ParseError(f"PKGBUILD is missing required field(s): {', '.join(missing)}")
return fields
def expand(value: str, fields: dict[str, list[str]]) -> str:
scalars = {k: v[0] for k, v in fields.items() if k in SCALARS}
def repl(m: re.Match[str]) -> str:
name = m.group(1) or m.group(2)
if name not in scalars:
raise ParseError(f"cannot expand ${name} — not a scalar field in this PKGBUILD")
return scalars[name]
return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)", repl, value)
def pkgbuild_fields(text: str) -> set[tuple[str, str]]:
parsed = parse_pkgbuild(text)
out: set[tuple[str, str]] = set()
for key, values in parsed.items():
for v in values:
out.add((key, expand(v, parsed)))
return out
def srcinfo_fields(text: str) -> set[tuple[str, str]]:
out: set[tuple[str, str]] = set()
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#") or line.startswith("pkgbase"):
continue
if "=" not in line:
continue
key, value = (s.strip() for s in line.split("=", 1))
out.add((key, value))
return out
def compare(pkgbuild_text: str, srcinfo_text: str) -> list[str]:
pkg = pkgbuild_fields(pkgbuild_text)
src = srcinfo_fields(srcinfo_text)
problems = []
for key, value in sorted(pkg - src):
problems.append(f"PKGBUILD has {key} = {value!r}, .SRCINFO does not")
for key, value in sorted(src - pkg):
problems.append(f".SRCINFO has {key} = {value!r}, PKGBUILD does not")
return problems
_GOOD_PKGBUILD = """\
# Maintainer: someone <nobody@example.com>
pkgname=retch
pkgver=0.7.0
pkgrel=1
pkgdesc="A fast, feature-rich system information fetcher written in Rust"
arch=('x86_64' 'aarch64')
url="https://github.com/l1a/retch"
license=('GPL3')
depends=('gcc-libs' 'glibc')
makedepends=('cargo')
options=('!lto')
source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('3d1079e594091341136a272904e3526c69d9764be50dff3e3e96913d001f7691')
build() {
cd "$pkgname-$pkgver"
DATE=$(date +"%B %Y")
cargo build --release --frozen
}
"""
_GOOD_SRCINFO = """\
pkgbase = retch
\tpkgdesc = A fast, feature-rich system information fetcher written in Rust
\tpkgver = 0.7.0
\tpkgrel = 1
\turl = https://github.com/l1a/retch
\tarch = x86_64
\tarch = aarch64
\tlicense = GPL3
\tmakedepends = cargo
\tdepends = gcc-libs
\tdepends = glibc
\toptions = !lto
\tsource = retch-0.7.0.tar.gz::https://github.com/l1a/retch/archive/refs/tags/v0.7.0.tar.gz
\tsha256sums = 3d1079e594091341136a272904e3526c69d9764be50dff3e3e96913d001f7691
pkgname = retch
"""
def _self_test() -> int:
failures = []
def check(name: str, cond: bool, detail: str = "") -> None:
if not cond:
failures.append(f"{name}: {detail}")
check("matching pair", compare(_GOOD_PKGBUILD, _GOOD_SRCINFO) == [],
f"got {compare(_GOOD_PKGBUILD, _GOOD_SRCINFO)}")
pkg = pkgbuild_fields(_GOOD_PKGBUILD)
check("source expanded",
("source", "retch-0.7.0.tar.gz::https://github.com/l1a/retch/archive/refs/tags/v0.7.0.tar.gz") in pkg,
f"expanded source missing from {sorted(v for k, v in pkg if k == 'source')}")
stale = _GOOD_SRCINFO.replace("pkgver = 0.7.0", "pkgver = 0.6.23")
check("stale pkgver detected", len(compare(_GOOD_PKGBUILD, stale)) >= 2,
f"got {compare(_GOOD_PKGBUILD, stale)}")
badsum = _GOOD_SRCINFO.replace(
"3d1079e594091341136a272904e3526c69d9764be50dff3e3e96913d001f7691",
"bf51f58b97adf6dda271eb197bea2cecac7e0fad0475f355962bb0a485f1a8bd")
check("stale sha256 detected", len(compare(_GOOD_PKGBUILD, badsum)) >= 2,
f"got {compare(_GOOD_PKGBUILD, badsum)}")
extra = _GOOD_SRCINFO.replace("\tmakedepends = cargo",
"\tmakedepends = cargo\n\tmakedepends = mandown")
check("extra makedepend detected",
any("mandown" in p for p in compare(_GOOD_PKGBUILD, extra)),
f"got {compare(_GOOD_PKGBUILD, extra)}")
check("function bodies ignored",
not any(k == "DATE" for k, _ in pkgbuild_fields(_GOOD_PKGBUILD)),
"DATE from build() was parsed as metadata")
check("pkgdesc intact",
("pkgdesc", "A fast, feature-rich system information fetcher written in Rust") in pkg,
"pkgdesc was split or mangled")
try:
expand("$nope/x", parse_pkgbuild(_GOOD_PKGBUILD))
check("unknown var raises", False, "expand() silently accepted $nope")
except ParseError:
pass
try:
parse_pkgbuild("pkgname=retch\n")
check("missing pkgver raises", False, "parse_pkgbuild accepted a PKGBUILD with no pkgver")
except ParseError:
pass
if failures:
for f in failures:
print(f" FAIL {f}", file=sys.stderr)
print(f"aur_check.py self-test FAILED ({len(failures)})", file=sys.stderr)
return 1
print(f"aur_check.py self-test passed (template v{TEMPLATE_VERSION})")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--self-test", action="store_true", help="run built-in tests and exit")
ap.add_argument("--dir", default=None, help="directory holding PKGBUILD and .SRCINFO")
args = ap.parse_args()
if args.self_test:
return _self_test()
base = Path(args.dir) if args.dir else Path(__file__).resolve().parent.parent / "packaging" / "aur"
pkgbuild, srcinfo = base / "PKGBUILD", base / ".SRCINFO"
for f in (pkgbuild, srcinfo):
if not f.is_file():
print(f"error: {f} not found", file=sys.stderr)
return 1
try:
problems = compare(pkgbuild.read_text(encoding="utf-8"),
srcinfo.read_text(encoding="utf-8"))
except ParseError as e:
print(f"error: {e}", file=sys.stderr)
return 1
if problems:
print(f"error: {pkgbuild} and {srcinfo} disagree:", file=sys.stderr)
for p in problems:
print(f" {p}", file=sys.stderr)
print("\nRegenerate with: just aur-srcinfo", file=sys.stderr)
return 1
ver = dict(compare_ver := [(k, v) for k, v in pkgbuild_fields(pkgbuild.read_text(encoding="utf-8")) if k == "pkgver"])
print(f"packaging/aur: PKGBUILD and .SRCINFO agree (pkgver {ver.get('pkgver', '?')})")
return 0
if __name__ == "__main__":
sys.exit(main())