1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
|
#!/usr/bin/env python3
# desktop-assistant tool: Slackware ChangeLog entries not yet installed.
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
#
# 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):
# Spoken: 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 read package lists aloud.")
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))
|