384 lines
13 KiB
Python
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))
|