universalisos/tools/uos-pkg/uos-pkg.py

384 lines
13 KiB
Python

#!/usr/bin/env python3
"""uos-pkg — UniversalisOS package tool (macro engine slice).
Clean-room reimplementation of the PikeOS RPM 4.2 packaging stack
(docs/parity/RPM_PARITY_SPEC.md §5). This first slice implements the macro
engine (§5.5) — the heart of spec compatibility — plus `eval`/`showrc` so the
engine can be differentially tested against the mirrored rpm 4.2 binary.
Macro syntax supported (rpm 4.2 semantics):
%name simple expansion (name = [A-Za-z0-9_]+, also %{name})
%{name} braced expansion
%{builtin:args} builtin with argument (u2p, basename, dirname, suffix,
expand, unexpand, getenv, echo, warn, error, nil, ...)
%name(args) parameterized macro invocation (%1..%9, %*, %#)
%(...) shell expansion
%{?cond:then} conditional (also %{!?cond:else}, %{?cond})
%{-o:...} %{-o*} option forms (treated as undefined unless defined)
%% literal percent
%define/%global/%undefine (in loaded macro files)
"""
import os
import re
import subprocess
import sys
UOS_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
MIRROR = os.path.join(UOS_ROOT, "pikeos-mirror")
PIKEOS_PREFIX = "/opt/pikeos-D5.0" # compiled-in prefix of the mirrored stack
def default_macrofiles(target="x86_amd64-linux", host="lin64"):
"""Mirror rpmrc's `macrofiles:` list, retargeted to the on-disk mirror.
Original (rpmrc:355):
<prefix>/rpm/lib/rpm/macros : <prefix>/config/rpm/hosts/<host> :
<prefix>/config/rpm/targets/%{_target} : <prefix>/config/rpm/common :
<prefix>/rpm/lib/rpm/%{_target}/macros : <prefix>/config/rpm/macros.* :
<prefix>/config/rpm/macros : <prefix>/config/rpm/%{_target}/macros :
~/.rpmmacros
"""
def m(rel):
return os.path.join(MIRROR, rel)
return [
m("rpm/lib/rpm/macros"),
m(f"config/rpm/hosts/{host}"),
m(f"config/rpm/targets/{target}"),
m("config/rpm/common"),
m(f"rpm/lib/rpm/{target}/macros"),
m("config/rpm/macros.specspo"),
m("config/rpm/macros.prelink"),
m("config/rpm/macros.solve"),
m("config/rpm/macros.up2date"),
m("config/rpm/macros"),
m(f"config/rpm/{target}/macros"),
os.path.expanduser("~/.rpmmacros"),
]
class MacroError(Exception):
pass
# rpm 4.2 %{builtin:arg} set (verified against the mirrored binary: dirname,
# suffix-with-dot, getenv, nil are NOT builtins in 4.2 and echo literally).
RPM42_BUILTINS = frozenset({
"u2p", "basename", "suffix", "expand", "unexpand", "echo", "warn",
"error", "trace", "dump", "verbose", "getconfdir", "S", "P", "F",
})
class MacroEngine:
"""rpm-4.2-style macro processor."""
def __init__(self):
self.macros = {} # name -> body
self.param_macros = {} # name -> body (defined with %define, callable with args)
self.rc_config = {} # rpmrc arch-config families
self.depth = 0
self.max_depth = 64
# ---------- definition handling ----------
def define(self, name, body, param=False):
self.macros[name] = body
if param:
self.param_macros[name] = body
def undefine(self, name):
self.macros.pop(name, None)
self.param_macros.pop(name, None)
def is_defined(self, name):
return name in self.macros
# ---------- file loading ----------
def load_rpmrc(self, path, build_arch):
"""Parse rpmrc arch-conditional config (rpm 4.2 subset).
Handles: `optflags: <arch> <flags>` (defines %optflags for the build
arch), `arch_canon`/`arch_compat`/`buildarch_compat` (arch resolution),
and the `macrofiles:` list. Values land in the lowest-priority context
(set only if not already defined by a compiled-in default... rpm 4.2
actually lets macro FILES override these, so we set them BEFORE file
loads — see new_engine()).
"""
try:
with open(path, errors="replace") as f:
lines = f.readlines()
except OSError:
return
for ln in lines:
ln = ln.rstrip("\n")
m = re.match(r"^optflags:\s+(\S+)\s+(.*)$", ln)
if m and m.group(1) == build_arch:
self.define("optflags", m.group(2).strip())
m = re.match(r"^(arch_canon|arch_compat|buildarch_compat|buildarchtranslate|os_canon|os_compat):\s+(.*)$", ln)
if m:
self.rc_config.setdefault(m.group(1), []).append(m.group(2))
def load_file(self, path):
"""Load a macro file: %define/%global lines and bare 'name body' lines."""
try:
with open(path, errors="replace") as f:
lines = f.readlines()
except OSError:
return
# join continuations
logical = []
buf = ""
for ln in lines:
ln = ln.rstrip("\n")
if buf:
buf += ln
else:
buf = ln
if buf.endswith("\\"):
buf = buf[:-1]
continue
logical.append(buf)
buf = ""
if buf:
logical.append(buf)
for ln in logical:
self._load_line(ln)
def _load_line(self, ln):
s = ln.strip()
if not s or s.startswith("#"):
return
m = re.match(r"%(define|global)\s+(\w+)\s*(.*)", s)
if m:
self.define(m.group(2), m.group(3), param=True)
return
m = re.match(r"%undefine\s+(\w+)", s)
if m:
self.undefine(m.group(1))
return
# macro-file entry: "%name<ws>body" (canonical rpm style) or bare
# "name<ws>body" (tolerated). Names may start with '_' or a letter.
m = re.match(r"^%?([A-Za-z_][A-Za-z0-9_]*)\s+(.*)$", ln)
if m:
self.define(m.group(1), m.group(2).rstrip())
def load_files(self, paths):
for p in paths:
self.load_file(p)
# ---------- expansion ----------
def expand(self, text):
if self.depth > self.max_depth:
raise MacroError("macro expansion too deep (recursive?)")
self.depth += 1
try:
return self._expand(text)
finally:
self.depth -= 1
def _expand(self, text):
out = []
i, n = 0, len(text)
while i < n:
c = text[i]
if c != "%":
out.append(c)
i += 1
continue
# c == '%'
if i + 1 < n and text[i + 1] == "%":
out.append("%")
i += 2
continue
if i + 1 < n and text[i + 1] == "(":
j = self._match_paren(text, i + 1)
cmd = text[i + 2:j - 1]
out.append(self._shell(cmd))
i = j
continue
if i + 1 < n and text[i + 1] == "{":
j = text.find("}", i + 2)
if j == -1:
out.append(c)
i += 1
continue
out.append(self._expand_braced(text[i + 2:j]))
i = j + 1
continue
# %name or %name(args)
m = re.match(r"%([A-Za-z_][A-Za-z0-9_]*)", text[i:])
if not m:
out.append(c)
i += 1
continue
name = m.group(1)
j = i + len(name) + 1
args = None
if j < n and text[j] == "(":
k = self._match_paren(text, j)
args = text[j + 1:k - 1]
j = k
out.append(self._expand_named(name, args))
i = j
return "".join(out)
@staticmethod
def _match_paren(text, open_idx):
"""text[open_idx] == '(' ; return index just past the matching ')'."""
depth = 0
for k in range(open_idx, len(text)):
if text[k] == "(":
depth += 1
elif text[k] == ")":
depth -= 1
if depth == 0:
return k + 1
raise MacroError("unbalanced parenthesis in macro")
def _expand_named(self, name, args):
if name not in self.macros:
return "%" + name if args is None else f"%{name}({args})"
body = self.macros[name]
if args is not None:
# parameterized: %1..%9, %*, %#, %0
argv = args.split()
body = body.replace("%*", args)
body = body.replace("%#", str(len(argv)))
body = body.replace("%0", name)
for idx in range(1, 10):
body = body.replace(f"%{idx}", argv[idx - 1] if idx <= len(argv) else "")
return self.expand(body)
def _expand_braced(self, inner):
# conditionals: {?c:t} {!?c:t} {?c} — the no-colon form yields the
# macro's value when defined (rpm 4.2 behavior)
m = re.match(r"^(!?)\?([A-Za-z_][A-Za-z0-9_]*)(?::(.*))?$", inner, re.S)
if m:
neg, cond, then = m.group(1), m.group(2), m.group(3)
defined = self.is_defined(cond)
if neg:
defined = not defined
if then is not None:
return self.expand(then) if defined else ""
return self.expand(self.macros[cond]) if (defined and cond in self.macros) else ""
# option forms {-o:...} {-o*} — undefined unless a macro named -o exists
if inner.startswith("-"):
return ""
# builtin:args — only the rpm 4.2 builtin set; anything else is echoed
if ":" in inner:
name, arg = inner.split(":", 1)
if name in RPM42_BUILTINS:
return self._builtin(name, arg)
return "%{" + inner + "}"
if inner in self.macros:
return self._expand_named(inner, None)
# undefined braced macro: rpm 4.2 echoes it back verbatim
return "%{" + inner + "}"
def _builtin(self, name, arg):
if name == "u2p":
# host path conversion: identity on linux hosts
return arg
if name == "basename":
return os.path.basename(arg)
if name == "suffix":
# rpm 4.2: extension WITHOUT the dot
b = os.path.basename(arg)
return b[b.rfind(".") + 1:] if "." in b else ""
if name == "expand":
# expand, then re-expand the result (%% -> % -> expand again)
return self.expand(self.expand(arg))
if name == "unexpand":
return arg
if name == "echo":
sys.stderr.write(arg + "\n")
return ""
if name == "warn":
sys.stderr.write("warning: " + arg + "\n")
return ""
if name == "error":
raise MacroError(arg)
if name == "verbose":
return ""
if name in ("trace", "dump", "getconfdir", "S", "P", "F"):
return ""
# not a builtin: maybe a macro with a colon in its name
full = f"{name}:{arg}"
if full in self.macros:
return self.expand(self.macros[full])
return "%{" + full + "}"
@staticmethod
def _shell(cmd):
try:
r = subprocess.run(cmd, shell=True, capture_output=True, text=True)
return r.stdout.rstrip("\n")
except OSError:
return ""
# rpm 4.2 compiled-in defaults (observed via the mirrored binary's `-E` with an
# empty rcfile). Lowest priority: macro files override these.
COMPILED_IN_DEFAULTS = {
"_topdir": PIKEOS_PREFIX + "/rpm/usr/src/redhat",
"_var": PIKEOS_PREFIX + "/rpm/var",
"_dbpath": PIKEOS_PREFIX + "/rpm/var/lib/rpm",
"_tmppath": "/tmp",
"_usr": PIKEOS_PREFIX + "/rpm/usr",
"_rpmfilename": "%%{ARCH}/%%{NAME}-%%{VERSION}-%%{RELEASE}.%%{ARCH}.rpm",
}
def new_engine(macrofiles=None, target="x86_amd64-linux", host="lin64",
build_arch="x86_64", rcfile=None):
eng = MacroEngine()
# 1) compiled-in defaults (lowest priority)
for k, v in COMPILED_IN_DEFAULTS.items():
eng.define(k, v)
# 2) seed %{_target} so the macrofiles list's %{_target} interpolation works
eng.define("_target", target)
# 3) macro files in rpmrc order
eng.load_files(macrofiles or default_macrofiles(target, host))
# 4) rpmrc arch-config LAST: in rpm 4.2 the rc config (optflags etc.) wins
# over macro-file values
eng.load_rpmrc(rcfile or os.path.join(MIRROR, "rpm/lib/rpm/rpmrc"), build_arch)
return eng
USAGE = """uos-pkg — UniversalisOS package tool (macro engine slice)
usage:
uos-pkg.py eval <expr> expand a macro expression (rpm -E parity)
uos-pkg.py showrc dump all loaded macros
uos-pkg.py version
"""
def main(argv):
if len(argv) < 2:
print(USAGE)
return 2
cmd = argv[1]
if cmd == "version":
print("uos-pkg version 5.0 (RPM 4.2 parity, macro-engine slice)")
return 0
if cmd == "eval":
if len(argv) < 3:
print("usage: uos-pkg.py eval <expr>", file=sys.stderr)
return 2
eng = new_engine()
try:
print(eng.expand(argv[2]))
except MacroError as e:
print(f"error: {e}", file=sys.stderr)
return 1
return 0
if cmd == "showrc":
eng = new_engine()
for k in sorted(eng.macros):
print(f"{k}\t{eng.macros[k]}")
return 0
print(USAGE)
return 2
if __name__ == "__main__":
sys.exit(main(sys.argv))