#!/usr/bin/env python3 # desktop-assistant tool: Slackware ChangeLog entries not yet installed. # Copyright (C) 2026 Danilo M. # # This program is free software; you can redistribute it and/or modify it # under the terms of the GNU General Public License version 2 as published # by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General # Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. """updates.py [PACKAGE] [REPORT]: pending upgrades, comparing CHANGELOG (default the slackware64-current mirror on NFS) with the installed packages in PKGDIR (default /var/lib/pkgtools/packages). With PACKAGE, its pending ChangeLog notes. With REPORT true, writes a markdown recap to $XDG_RUNTIME_DIR/slackware-updates.md and opens it in MD_EDITOR (default xdg-open). --test runs the self-check. The last applied batch is the newest one holding an entry whose exact package name is installed; every newer batch is pending. """ import os import re import shlex import subprocess import sys import tempfile SEP = "+--------------------------+" ENTRY = re.compile(r"^(?:(\S+)/)?(\S+)-([^-\s]+)-([^-\s]+)-([^-\s]+)\.t[gx]z:\s+" r"(Upgraded|Rebuilt|Added|Removed)\.") def batches(text): """[(date, [(series, name, full, action, notes)])], newest first.""" out = [] for chunk in text.split(SEP): lines = chunk.strip("\n").splitlines() if not lines: continue entries = [] for line in lines[1:]: m = ENTRY.match(line) if m: series, name, ver, arch, build, action = m.groups() entries.append([series or "", name, f"{name}-{ver}-{arch}-{build}", action, []]) elif line.startswith((" ", "\t")) and entries: entries[-1][4].append(line.strip()) elif not line.startswith((" ", "\t")) and entries: entries.append(["", "", "", "", []]) # isolinux/, kernels/*: ends the notes out.append((lines[0].strip(), [e for e in entries if e[1]])) return out def pending(text, installed): """(newest date, last applied date or None, pending batch count, {name: info})""" have = {f.rsplit("-", 3)[0]: f for f in installed} full = set(installed) bs = batches(text) todo, applied, n = {}, None, 0 for date, entries in bs: if any(e[2] in full and e[3] != "Removed" for e in entries): # a removed one stays installed applied = date break n += 1 for series, name, fullname, action, notes in entries: if series.split("/")[0] in ("testing", "pasture"): continue ok = {"Upgraded": name in have, "Rebuilt": name in have, "Added": name not in have, "Removed": name in have}[action] if not ok: continue p = todo.setdefault(name, {"action": action, "new": fullname, "old": have.get(name), "security": False, "log": []}) p["security"] |= "(* Security fix *)" in notes p["log"].append((date, fullname, action, notes)) return (bs[0][0] if bs else "?"), applied, n, todo def ver(full): return full.rsplit("-", 3)[1] if full else "?" def kernel(todo): """'old -> new' when a pending kernel changes version, else None.""" for k in ("kernel-generic", "kernel-huge", "kernel-source"): p = todo.get(k) if p and p["old"] and ver(p["old"]) != ver(p["new"]): return f"{ver(p['old'])} -> {ver(p['new'])}" return None def by_action(todo): return {a: sorted(k for k, v in todo.items() if v["action"] == a) for a in ("Upgraded", "Rebuilt", "Added", "Removed")} def summary(newest, applied, n, todo): # Counts only, names just for the short lists. if not todo: return f"Up to date with the ChangeLog of {newest}." by = by_action(todo) sec = sorted(k for k, v in todo.items() if v["security"]) out = [f"ChangeLog of {newest}; last applied batch {applied or 'not found'}; " f"{n} pending batches.", f"Pending: {len(by['Upgraded'])} upgraded, {len(by['Rebuilt'])} rebuilt, " f"{len(by['Added'])} new, {len(by['Removed'])} removed.", f"Security fixes: {len(sec)}" + (f" ({', '.join(sec)})." if sec else ".")] k = kernel(todo) out.append(f"Kernel upgrade: {k}." if k else "No kernel upgrade.") for a, label in (("Added", "New"), ("Removed", "Removed")): if by[a]: out.append(f"{label}: " + ", ".join(by[a]) + ".") out.append("Offer a written recap (report = true); do not list every package.") return "\n".join(out) def report(newest, applied, n, todo): """The same recap in full, as markdown.""" by = by_action(todo) sec = sorted(k for k, v in todo.items() if v["security"]) k = kernel(todo) out = [f"# Pending Slackware updates", "", f"- ChangeLog: {newest}", f"- Last applied batch: {applied or 'not found'}", f"- Pending batches: {n}", f"- {len(by['Upgraded'])} upgraded, {len(by['Rebuilt'])} rebuilt, " f"{len(by['Added'])} new, {len(by['Removed'])} removed", f"- Security fixes: {len(sec)}", f"- Kernel: {k or 'no upgrade'}"] if sec: out += ["", "## Security fixes"] for name in sec: out += ["", f"### {name}", "", "```", detail(name, todo), "```"] for a, label in (("Added", "New"), ("Removed", "Removed"), ("Rebuilt", "Rebuilt")): if by[a]: out += ["", f"## {label}", ""] + [f"- {x}" for x in by[a]] if by["Upgraded"]: out += ["", "## Upgraded", "", "| Package | Installed | New |", "|---|---|---|"] out += [f"| {x} | {ver(todo[x]['old'])} | {ver(todo[x]['new'])} |" for x in by["Upgraded"]] notes = [x for x in sorted(todo) if x not in sec and any(l[3] for l in todo[x]["log"])] if notes: out += ["", "## Other notes"] for name in notes: out += ["", f"### {name}", "", "```", detail(name, todo), "```"] return "\n".join(out) + "\n" def open_report(r): # Private runtime dir (tmpfs, gone at reboot); the editor is detached so the # tool returns at once and a tool timeout never kills it. d = os.environ.get("XDG_RUNTIME_DIR") or tempfile.gettempdir() path = os.path.join(d, "slackware-updates.md") with open(path, "w") as f: f.write(report(*r)) editor = shlex.split(os.environ.get("MD_EDITOR") or "xdg-open") subprocess.Popen(editor + [path], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) return f"Written recap opened in {editor[0]}: {path}" def detail(name, todo): p = todo.get(name) if not p: return f"No pending ChangeLog entry for {name}." out = [f"{name}: installed {p['old'] or 'no'}."] for date, fullname, action, notes in p["log"]: out.append(f"{date}: {fullname} {action}.") out.extend(" " + l for l in notes) return "\n".join(out) def test(): log = f"""Sat Sep 26 20:00:00 UTC 2026 a/kbd-2.10.0-x86_64-4.txz: Rebuilt. Use setfont -R. n/php-8.5.11-x86_64-1.txz: Upgraded. l/newlib-1.0-x86_64-1.txz: Added. l/oldlib-2.0-x86_64-1.txz: Removed. testing/packages/newer-9.0-x86_64-1.txz: Added. x/notmine-1.0-x86_64-2.txz: Upgraded. isolinux/initrd.img: Rebuilt. {SEP} Fri Sep 25 20:00:00 UTC 2026 n/php-8.5.10-x86_64-2.txz: Rebuilt. Fixes a hole. (* Security fix *) {SEP} Thu Sep 24 20:00:00 UTC 2026 n/php-8.5.10-x86_64-1.txz: Upgraded. {SEP} Wed Sep 23 20:00:00 UTC 2026 n/php-8.5.9-x86_64-1.txz: Upgraded. (* Security fix *) """ inst = ["kbd-2.10.0-x86_64-2", "php-8.5.10-x86_64-1", "oldlib-2.0-x86_64-1"] newest, applied, n, todo = pending(log, inst) assert (newest, applied, n) == ("Sat Sep 26 20:00:00 UTC 2026", "Thu Sep 24 20:00:00 UTC 2026", 2) assert sorted(todo) == ["kbd", "newlib", "oldlib", "php"], todo assert todo["php"]["action"] == "Upgraded" and todo["php"]["security"] # older bump's fix counts assert not todo["kbd"]["security"] and todo["kbd"]["log"][0][3] == ["Use setfont -R."] s = summary(newest, applied, n, todo) assert "1 upgraded, 1 rebuilt, 1 new, 1 removed" in s and "Security fixes: 1 (php)." in s, s assert "No kernel upgrade." in s and "kbd" not in s, s m = report(newest, applied, n, todo) assert "| php | 8.5.10 | 8.5.11 |" in m and "Fixes a hole." in m and "Use setfont -R." in m, m todo2 = pending("d\na/kernel-generic-7.2.7-x86_64-1.txz: Upgraded.\n", ["kernel-generic-6.18.50-x86_64-1"])[3] assert kernel(todo2) == "6.18.50 -> 7.2.7" and kernel(todo) is None d = detail("php", todo) assert "Fixes a hole." in d and "installed php-8.5.10-x86_64-1" in d, d assert pending(log, ["php-8.5.11-x86_64-1", "kbd-2.10.0-x86_64-4"])[3] == {} with tempfile.TemporaryDirectory() as t: # PKGDIR listing path open(os.path.join(t, "php-8.5.10-x86_64-1"), "w").close() assert sorted(pending(log, os.listdir(t))[3]) == ["newlib", "php"] print("ok") if __name__ == "__main__": if sys.argv[1:] == ["--test"]: test() sys.exit() cl = os.environ.get("CHANGELOG") or "/mnt/nfs/Slackware/slackware64-current/ChangeLog.txt" pd = os.environ.get("PKGDIR") or "/var/lib/pkgtools/packages" try: with open(cl, encoding="utf-8", errors="replace") as f: text = f.read() except OSError as e: sys.exit(f"ChangeLog not reachable ({cl}): {e.strerror}") r = pending(text, os.listdir(pd)) a = sys.argv[1:] + ["", ""] name = a[0].strip() if a[1].lower() in ("true", "1", "yes"): print(open_report(r)) else: print(detail(name, r[3]) if name else summary(*r))