From c7acbfc7d9146a4440f45f3efd4246935c2ffd19 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 20 Oct 2026 12:50:18 +0200 Subject: sbb-remote 0.7.0: phantom deps list, suggestions, fix-current The list moves to phantom_deps_file (/etc/sb-builder/phantom-deps, mkhint's format), edited with phantom-add/phantom-remove. phantom-list also suggests every REQUIRES name no package of the repo has, with who needs it: SBo drops a package when it enters the official tree. The fix-current job (and fix-current-now, inline for sbshadow) is mkhint -F: listed deps merged into DELREQUIRES, .bak first, add only. manifest_packages is neovim alone: openvino lives in the overlay now. Co-Authored-By: Claude Opus 5.5 --- remote/config.example.toml | 3 +- remote/sbb-remote | 104 +++++++++++++++++++++++++++++++++++++++++++-- sb_builder/__init__.py | 2 +- tests/test_remote.py | 65 ++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+), 6 deletions(-) diff --git a/remote/config.example.toml b/remote/config.example.toml index a77ace9..0fbe801 100644 --- a/remote/config.example.toml +++ b/remote/config.example.toml @@ -12,4 +12,5 @@ hintdir = "/etc/slackrepo/SBo-danix/hintfiles" default_hintdir = "" # searched after hintdir when set genreprc = "/root/.genreprc" # REPOSOWNERGPG names the signing key slackrepo_logdir = "/var/log/slackrepo/SBo-danix" -manifest_packages = ["neovim", "openvino"] # bundled deps: hint-update refuses them (phase 4b) +manifest_packages = ["neovim"] # bundled deps: hint-update refuses them (phase 4b) +phantom_deps_file = "/etc/sb-builder/phantom-deps" # deps -current ships; one per line, # comments diff --git a/remote/sbb-remote b/remote/sbb-remote index 5183b77..c37b44c 100755 --- a/remote/sbb-remote +++ b/remote/sbb-remote @@ -40,7 +40,7 @@ import time import tomllib import urllib.request -VERSION = "0.6.0" +VERSION = "0.7.0" DEFAULTS = { "state_dir": "/var/lib/sb-builder", "repo_dir": "/var/lib/sbopkg/SBo-danix", @@ -53,7 +53,8 @@ DEFAULTS = { "default_hintdir": "", "genreprc": "/root/.genreprc", "slackrepo_logdir": "/var/log/slackrepo/SBo-danix", - "manifest_packages": ["neovim", "openvino"], # bundled deps: left to mkhint until phase 4b + "manifest_packages": ["neovim"], # bundled deps: left to mkhint until phase 4b + "phantom_deps_file": "/etc/sb-builder/phantom-deps", # deps -current ships: DELREQUIREd } FLAGS = ("--yes", "--no") JOB_ID = re.compile(r"^\d{8}-\d{6}-[a-z]+(-[a-z]+)*(-\d+)?$") @@ -196,14 +197,18 @@ def hint_args(cfg, kind, args): def _self_job(kind): """A job kind run by this file itself (_hint), argv-free like the others.""" def build(cfg, args): - hint_args(cfg, kind, args) + if kind == "fix-current": + if args: + raise Error("fix-current takes no arguments") + else: + hint_args(cfg, kind, args) return [sys.executable, os.path.abspath(__file__), "_hint", kind, *args], {} return build KINDS = {"sbpull": kind_sbpull, "sbshadow": kind_sbshadow, "slackrepo": kind_slackrepo, "publish": kind_publish, - **{k: _self_job(k) for k in ("hint-update", "hint-new")}} + **{k: _self_job(k) for k in ("hint-update", "hint-new", "fix-current")}} def signs(kind, args): @@ -757,8 +762,95 @@ def _save(target, text): _write(target, text) +# ── phantom deps: what -current ships, DELREQUIREd from the SBo packages ───── + +def phantom_deps(cfg): + """The listed names, file order; a missing file is an empty list.""" + text = _read(cfg["phantom_deps_file"]) or "" + return list(dict.fromkeys(n for n in (ln.split("#")[0].strip() for ln in text.splitlines()) if n)) + + +def _set_phantom(cfg, names, add): + names = list(names) + if not names or not all(PKGNAME.match(n) for n in names): + raise Error("expected package names") + text = _read(cfg["phantom_deps_file"]) or "" + have = phantom_deps(cfg) + if add: + text += "".join(f"{n}\n" for n in dict.fromkeys(names) if n not in have) + else: # drop the names' lines, comments and the rest kept as written + text = "".join(ln for ln in text.splitlines(keepends=True) + if ln.split("#")[0].strip() not in names) + _write(cfg["phantom_deps_file"], text) + return {"deps": phantom_deps(cfg)} + + +def cmd_phantom_add(cfg, *names): + return _set_phantom(cfg, names, True) + + +def cmd_phantom_remove(cfg, *names): + return _set_phantom(cfg, names, False) + + +def _requires(cfg, pkgs): + """{pkg: REQUIRES words without %README%}.""" + out = {} + for pkg, path in pkgs.items(): + info = _read(os.path.join(cfg["repo_dir"], path, pkg + ".info")) or "" + out[pkg] = [r for r in hint_tokens(info, "REQUIRES") if r != "%README%"] + return out + + +def cmd_phantom_list(cfg): + """The list, and suggestions: REQUIRES names no package of the repo has (SBo + drops a package when it enters the official tree), with who needs them.""" + pkgs = _pkgmap(cfg) + listed = set(phantom_deps(cfg)) + needed = {} + for pkg, reqs in sorted(_requires(cfg, pkgs).items()): + for r in reqs: + if r not in pkgs and r not in listed: + needed.setdefault(r, []).append(pkg) + return {"deps": phantom_deps(cfg), + "suggestions": [{"name": n, "needed_by": needed[n]} for n in sorted(needed)]} + + +def merge_delrequires(text, deps): + """text with deps added to DELREQUIRES, or None when all are there already.""" + have = hint_tokens(text, "DELREQUIRES") + add = [d for d in dict.fromkeys(deps) if d not in have] + return hint_set(text, "DELREQUIRES", " ".join(have + add)) if add else None + + +def cmd_fix_current_now(cfg): + """mkhint -F: listed deps into the DELREQUIRES of whoever requires them (add only).""" + ok, failed = [], [] + listed = phantom_deps(cfg) + pkgs = _pkgmap(cfg) + for pkg, reqs in sorted(_requires(cfg, pkgs).items()): + deps = [d for d in listed if d in reqs] + if not deps: + continue + try: + _, target = _hint_target(cfg, pkg, pkgs) + text = merge_delrequires(_read(target) or "", deps) + if text is not None: + _save(target, text) + print(f"{pkg}: DELREQUIRES {' '.join(deps)}", flush=True) + ok.append(pkg) + except (Error, OSError) as e: + print(f"{pkg}: failed: {e}", flush=True) + failed.append([pkg, str(e)]) + print("SBB-RESULT " + json.dumps({"ok": ok, "failed": failed}), flush=True) + if failed: + raise Error(f"{len(failed)} failed") + + def cmd_hint(cfg, kind=None, *args): """Body of the hint jobs: log lines, then SBB-RESULT {"ok": [...], "failed": [[pkg, why]]}.""" + if kind == "fix-current": + return cmd_fix_current_now(cfg) if kind not in ("hint-update", "hint-new"): raise Error(f"unknown hint job: {kind}") ok, failed = [], [] @@ -900,6 +992,10 @@ COMMANDS = { "hint-revert": cmd_hint_revert, "hint-delete": cmd_hint_delete, "job-result": cmd_job_result, + "phantom-list": cmd_phantom_list, + "phantom-add": cmd_phantom_add, + "phantom-remove": cmd_phantom_remove, + "fix-current-now": cmd_fix_current_now, "_run": cmd_run, "_hint": cmd_hint, } diff --git a/sb_builder/__init__.py b/sb_builder/__init__.py index f9bea08..f4a1f98 100644 --- a/sb_builder/__init__.py +++ b/sb_builder/__init__.py @@ -16,4 +16,4 @@ """sb-builder: Qt6 GUI driving the SlackBuild tooling on a build VM.""" -VERSION = "0.6.0" # must match remote/sbb-remote VERSION +VERSION = "0.7.0" # must match remote/sbb-remote VERSION diff --git a/tests/test_remote.py b/tests/test_remote.py index 54c5614..57d492c 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -434,6 +434,71 @@ class HintJobsTest(Base): self.assertEqual(self.read(self.hint), 'VERSION="2.0"\nDOWNLOAD="no"\n') +class PhantomTest(Base): + def setUp(self): + super().setUp() + self.cfg["phantom_deps_file"] = os.path.join(self.tmp, "phantom-deps") + with open(self.cfg["phantom_deps_file"], "w") as f: + f.write("# deps needed on stable but not on -current\nrust-opt\n") + self.make_pkg("desktop/marco", 'VERSION="1"\nREQUIRES="zenity libfoo"\n') + self.make_pkg("games/steam", 'VERSION="1"\nREQUIRES="zenity"\n', + hint='DELREQUIRES="rust-opt"\n') + self.make_pkg("libs/libfoo", 'VERSION="1"\nREQUIRES="%README% rust-opt"\n') + + def read(self, path): + with open(path) as f: + return f.read() + + def test_list_and_suggestions(self): + self.assertEqual(sbb.cmd_phantom_list(self.cfg), { + "deps": ["rust-opt"], + "suggestions": [{"name": "zenity", "needed_by": ["marco", "steam"]}]}) + os.remove(self.cfg["phantom_deps_file"]) + self.assertEqual(sbb.cmd_phantom_list(self.cfg)["deps"], []) # missing file: empty list + + def test_add_remove_keep_comments(self): + self.assertEqual(sbb.cmd_phantom_add(self.cfg, "zenity", "rust-opt", "zenity"), + {"deps": ["rust-opt", "zenity"]}) + self.assertEqual(sbb.cmd_phantom_list(self.cfg)["suggestions"], []) + self.assertEqual(sbb.cmd_phantom_remove(self.cfg, "rust-opt"), {"deps": ["zenity"]}) + self.assertEqual(self.read(self.cfg["phantom_deps_file"]), + "# deps needed on stable but not on -current\nzenity\n") + for bad in ([], ["../x"], ["a b"]): + with self.subTest(bad=bad), self.assertRaises(sbb.Error): + sbb.cmd_phantom_add(self.cfg, *bad) + + def test_fix_current(self): + sbb.cmd_phantom_add(self.cfg, "zenity") + out = io.StringIO() + with contextlib.redirect_stdout(out): + sbb.cmd_fix_current_now(self.cfg) + lines = out.getvalue().splitlines() + self.assertEqual(json.loads(lines[-1].split(" ", 1)[1]), + {"ok": ["libfoo", "marco", "steam"], "failed": []}) + hints = self.cfg["hintdir"] + self.assertEqual(self.read(os.path.join(hints, "marco.hint")), 'DELREQUIRES="zenity"\n') + self.assertFalse(os.path.exists(os.path.join(hints, "marco.hint.bak"))) # new: revert deletes + self.assertEqual(self.read(os.path.join(hints, "steam.hint")), 'DELREQUIRES="rust-opt zenity"\n') + self.assertEqual(self.read(os.path.join(hints, "steam.hint.bak")), 'DELREQUIRES="rust-opt"\n') + self.assertIn("marco: DELREQUIRES zenity", lines) + out = io.StringIO() + with contextlib.redirect_stdout(out): + sbb.cmd_fix_current_now(self.cfg) # second run: nothing to do, no .bak churn + self.assertIn('{"ok": [], "failed": []}', out.getvalue()) + self.assertEqual(self.read(os.path.join(hints, "steam.hint.bak")), 'DELREQUIRES="rust-opt"\n') + + def test_merge_delrequires(self): + self.assertEqual(sbb.merge_delrequires('DELREQUIRES="a"\n', ["b", "a"]), 'DELREQUIRES="a b"\n') + self.assertIsNone(sbb.merge_delrequires('DELREQUIRES="a b"\n', ["b"])) + self.assertEqual(sbb.merge_delrequires("", ["b", "b"]), 'DELREQUIRES="b"\n') + + def test_job_kind(self): + me = [sys.executable, os.path.abspath(sbb.__file__), "_hint"] + self.assertEqual(sbb.KINDS["fix-current"](self.cfg, []), (me + ["fix-current"], {})) + with self.assertRaisesRegex(sbb.Error, "no arguments"): + sbb.KINDS["fix-current"](self.cfg, ["x"]) + + class MainTest(Base): def test_unknown_command(self): rc, data = self.run_main("nope") -- cgit v1.2.3