pkgmate 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pkgmate-0.0.1.dist-info/METADATA +10 -0
- pkgmate-0.0.1.dist-info/RECORD +36 -0
- pkgmate-0.0.1.dist-info/WHEEL +5 -0
- pkgmate-0.0.1.dist-info/entry_points.txt +2 -0
- pkgmate-0.0.1.dist-info/licenses/LICENSE +674 -0
- pkgmate-0.0.1.dist-info/top_level.txt +1 -0
- pkgtool/__init__.py +26 -0
- pkgtool/__main__.py +7 -0
- pkgtool/app.py +841 -0
- pkgtool/apt/__init__.py +9 -0
- pkgtool/apt/actions.py +193 -0
- pkgtool/apt/deps.py +94 -0
- pkgtool/apt/dpkg.py +153 -0
- pkgtool/apt/lists.py +336 -0
- pkgtool/apt/logs.py +83 -0
- pkgtool/apt/version.py +92 -0
- pkgtool/backends/__init__.py +30 -0
- pkgtool/backends/appimage.py +145 -0
- pkgtool/backends/deb.py +167 -0
- pkgtool/backends/flatpak.py +149 -0
- pkgtool/backends/linyap.py +110 -0
- pkgtool/backends/snap.py +106 -0
- pkgtool/base.py +238 -0
- pkgtool/catalog.py +345 -0
- pkgtool/classify.py +148 -0
- pkgtool/clean.py +288 -0
- pkgtool/cli.py +500 -0
- pkgtool/compress.py +56 -0
- pkgtool/config.py +236 -0
- pkgtool/inventory.py +167 -0
- pkgtool/labels.py +210 -0
- pkgtool/launch.py +156 -0
- pkgtool/remove.py +301 -0
- pkgtool/report.py +339 -0
- pkgtool/tui.py +473 -0
- pkgtool/upgrade.py +46 -0
pkgtool/apt/lists.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""pkgtool.apt.lists — /var/lib/apt/lists 的唯一解析器。
|
|
2
|
+
|
|
3
|
+
合并原先两套并存的解析:deb_backend.build_source_index()(来源判定用)和
|
|
4
|
+
apt_repo.load_index()(搜索/升级检测用)。二者读的是同一批 Packages 文件,
|
|
5
|
+
每次采集要解压解析两遍,且字段各取一半、语义还不完全一致。
|
|
6
|
+
|
|
7
|
+
一次遍历同时产出:
|
|
8
|
+
· (包名, 版本) → 源标签集合 来源判定:包是否来自某个已配置源
|
|
9
|
+
· 包名 → 全部版本条目 搜索、版本列表、升级候选
|
|
10
|
+
· 包名 → section / priority 卸载分类(classify.py)用
|
|
11
|
+
源标签 = Release 文件的 Label(缺失时用 Origin,再缺失用文件名前缀)
|
|
12
|
+
+ 文件名里解析出的 suite / component,形如 "Ubuntu noble/main"。
|
|
13
|
+
|
|
14
|
+
无需 root:lists 目录全局可读。
|
|
15
|
+
"""
|
|
16
|
+
import glob
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import asdict, dataclass
|
|
22
|
+
from email.utils import parsedate_to_datetime
|
|
23
|
+
from functools import cmp_to_key
|
|
24
|
+
|
|
25
|
+
from ..base import matches_tokens, query_tokens
|
|
26
|
+
from ..compress import open_text, read_text, strip_compressed_suffix
|
|
27
|
+
from ..config import CFG
|
|
28
|
+
from .version import newest, ver_cmp, ver_gt
|
|
29
|
+
|
|
30
|
+
# Packages 记录里需要留存的字段(其余忽略)
|
|
31
|
+
_FIELDS = frozenset(("Package", "Version", "Architecture", "Maintainer", "Size",
|
|
32
|
+
"Filename", "Description", "Section", "Priority"))
|
|
33
|
+
_PGP_TAIL = "-----BEGIN PGP SIGNATURE-----"
|
|
34
|
+
_PGP_HEAD = "-----BEGIN PGP SIGNED MESSAGE-----"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(slots=True)
|
|
38
|
+
class RepoEntry:
|
|
39
|
+
"""某源里某个包的一个版本。"""
|
|
40
|
+
version: str
|
|
41
|
+
repo: str
|
|
42
|
+
suite: str
|
|
43
|
+
component: str
|
|
44
|
+
arch: str
|
|
45
|
+
maintainer: str
|
|
46
|
+
size_kb: int
|
|
47
|
+
filename: str
|
|
48
|
+
date: str
|
|
49
|
+
desc: str
|
|
50
|
+
section: str
|
|
51
|
+
priority: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(slots=True)
|
|
55
|
+
class _Release:
|
|
56
|
+
label: str
|
|
57
|
+
suite: str
|
|
58
|
+
date: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _iter_records(lines):
|
|
62
|
+
"""流式解析 Debian 控制文件:空行分隔记录;续行(空格/制表符开头)跳过,
|
|
63
|
+
因此 Description 只留首行摘要,不把多行正文吃进内存。"""
|
|
64
|
+
cur = {}
|
|
65
|
+
for line in lines:
|
|
66
|
+
line = line.rstrip("\n")
|
|
67
|
+
if not line:
|
|
68
|
+
if cur:
|
|
69
|
+
yield cur
|
|
70
|
+
cur = {}
|
|
71
|
+
continue
|
|
72
|
+
if line[0] in " \t":
|
|
73
|
+
continue
|
|
74
|
+
key, sep, value = line.partition(": ")
|
|
75
|
+
if sep and key in _FIELDS and key not in cur:
|
|
76
|
+
cur[key] = value.strip()
|
|
77
|
+
if cur:
|
|
78
|
+
yield cur
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _release_body(path):
|
|
82
|
+
"""InRelease 的字段在 PGP 头之后的第一个空行后;Release 文件直接就是字段。"""
|
|
83
|
+
text = read_text(path)
|
|
84
|
+
if text is None:
|
|
85
|
+
return ""
|
|
86
|
+
if text.startswith(_PGP_HEAD):
|
|
87
|
+
_head, _sep, body = text.partition("\n\n")
|
|
88
|
+
text = body
|
|
89
|
+
end = text.find(_PGP_TAIL)
|
|
90
|
+
return text if end < 0 else text[:end]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parse_releases(lists_dir):
|
|
94
|
+
"""→ {文件名前缀: _Release}。"""
|
|
95
|
+
out = {}
|
|
96
|
+
for path in glob.glob(os.path.join(lists_dir, "*")):
|
|
97
|
+
base = os.path.basename(path)
|
|
98
|
+
if base.endswith(".gpg"):
|
|
99
|
+
continue
|
|
100
|
+
prefix = None
|
|
101
|
+
for suffix in ("_InRelease", "_Release"):
|
|
102
|
+
if base.endswith(suffix):
|
|
103
|
+
prefix = base[: -len(suffix)]
|
|
104
|
+
break
|
|
105
|
+
if prefix is None:
|
|
106
|
+
continue
|
|
107
|
+
label = origin = suite = date = ""
|
|
108
|
+
for line in _release_body(path).splitlines():
|
|
109
|
+
key, sep, value = line.partition(": ")
|
|
110
|
+
if not sep:
|
|
111
|
+
continue
|
|
112
|
+
value = value.strip()
|
|
113
|
+
if key == "Label" and not label:
|
|
114
|
+
label = value
|
|
115
|
+
elif key == "Origin" and not origin:
|
|
116
|
+
origin = value
|
|
117
|
+
elif key == "Suite" and not suite:
|
|
118
|
+
suite = value
|
|
119
|
+
elif key == "Date" and not date:
|
|
120
|
+
try:
|
|
121
|
+
date = parsedate_to_datetime(value).strftime("%Y-%m-%d")
|
|
122
|
+
except (TypeError, ValueError):
|
|
123
|
+
date = value
|
|
124
|
+
# Label 优先于 Origin(apt 自己也是这么显示的):Release 里 Origin 常写在
|
|
125
|
+
# Label 前面,按出现顺序取会把 "NVIDIA CUDA" 丢成 "NVIDIA"。
|
|
126
|
+
out[prefix] = _Release(label or origin or prefix, suite, date)
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _suite_component(basename, cfg):
|
|
131
|
+
"""从 Packages 文件名解析 suite/component:
|
|
132
|
+
规范仓库 <prefix>_dists_<suite>_<component>_binary-<arch>_Packages[.gz]
|
|
133
|
+
扁平仓库(sources.list 里写 `deb <url> ./`)文件名里没有 _dists_,
|
|
134
|
+
硬套位置会把 URL 路径段当成 suite——NVIDIA 的 CUDA 源曾被解析成
|
|
135
|
+
suite="x86%5f64",所以这种情况直接返回空,只留 Release 里的 Label。"""
|
|
136
|
+
core = strip_compressed_suffix(basename).split("_binary-")[0]
|
|
137
|
+
if "_dists_" not in core:
|
|
138
|
+
return "", ""
|
|
139
|
+
toks = core.split("_")
|
|
140
|
+
comp = toks[-1] if toks[-1] in cfg.known_components else ""
|
|
141
|
+
suite = ""
|
|
142
|
+
if len(toks) >= 2 and (comp or toks[-2] not in ("dists", "deb")):
|
|
143
|
+
suite = toks[-2]
|
|
144
|
+
return suite, comp
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _package_files(lists_dir, cfg):
|
|
148
|
+
"""→ [(路径, 文件名前缀, suite, component)],已按架构过滤。"""
|
|
149
|
+
skip = re.compile(cfg.index_skip_arch_re)
|
|
150
|
+
out = []
|
|
151
|
+
for path in sorted(set(glob.glob(os.path.join(lists_dir, "*_Packages"))
|
|
152
|
+
+ glob.glob(os.path.join(lists_dir, "*_Packages.*")))):
|
|
153
|
+
base = os.path.basename(path)
|
|
154
|
+
if base.endswith((".gpg", ".tmp")) or skip.search(base):
|
|
155
|
+
continue
|
|
156
|
+
core = strip_compressed_suffix(base)
|
|
157
|
+
if not core.endswith("_Packages"):
|
|
158
|
+
continue
|
|
159
|
+
suite, comp = _suite_component(base, cfg)
|
|
160
|
+
out.append((path, core[: -len("_Packages")], suite, comp))
|
|
161
|
+
return out
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _match_release(prefix, releases):
|
|
165
|
+
"""Packages 文件名比 Release 多 component/arch 段,取最长前缀匹配。"""
|
|
166
|
+
best = None
|
|
167
|
+
for r in releases:
|
|
168
|
+
if prefix == r or prefix.startswith(r + "_"):
|
|
169
|
+
if best is None or len(r) > len(best):
|
|
170
|
+
best = r
|
|
171
|
+
return releases.get(best)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class RepoIndex:
|
|
175
|
+
"""已解析的仓库索引,构造后只读。"""
|
|
176
|
+
|
|
177
|
+
def __init__(self, entries):
|
|
178
|
+
self._entries = entries # name → [RepoEntry]
|
|
179
|
+
self._top = {} # name → 最新版本的 RepoEntry(惰性缓存)
|
|
180
|
+
|
|
181
|
+
def __len__(self):
|
|
182
|
+
return len(self._entries)
|
|
183
|
+
|
|
184
|
+
def __contains__(self, name):
|
|
185
|
+
return name in self._entries
|
|
186
|
+
|
|
187
|
+
def names(self):
|
|
188
|
+
return self._entries.keys()
|
|
189
|
+
|
|
190
|
+
def entries(self, name):
|
|
191
|
+
return self._entries.get(name, [])
|
|
192
|
+
|
|
193
|
+
def top(self, name):
|
|
194
|
+
"""最新版本的条目(同版本多源时取先出现的);无此包返回 None。"""
|
|
195
|
+
if name not in self._top:
|
|
196
|
+
es = self._entries.get(name) or []
|
|
197
|
+
if not es:
|
|
198
|
+
self._top[name] = None
|
|
199
|
+
else:
|
|
200
|
+
best = newest(e.version for e in es)
|
|
201
|
+
self._top[name] = next(e for e in es if e.version == best)
|
|
202
|
+
return self._top[name]
|
|
203
|
+
|
|
204
|
+
def candidate(self, name):
|
|
205
|
+
"""仓库里的最高版本;包不在任何源则返回 ""。"""
|
|
206
|
+
e = self.top(name)
|
|
207
|
+
return e.version if e else ""
|
|
208
|
+
|
|
209
|
+
def versions(self, name):
|
|
210
|
+
"""去重后的版本列表,按 Debian 版本序从高到低。"""
|
|
211
|
+
vs = {e.version for e in self.entries(name)}
|
|
212
|
+
return sorted(vs, key=cmp_to_key(ver_cmp), reverse=True)
|
|
213
|
+
|
|
214
|
+
def repos(self, name, version=None):
|
|
215
|
+
"""命中的源标签:给了 version 就按 (名,版本) 精确匹配,否则按包名。"""
|
|
216
|
+
es = self.entries(name)
|
|
217
|
+
if version is not None:
|
|
218
|
+
es = [e for e in es if e.version == version]
|
|
219
|
+
out = []
|
|
220
|
+
for e in es:
|
|
221
|
+
if e.repo not in out:
|
|
222
|
+
out.append(e.repo)
|
|
223
|
+
return out
|
|
224
|
+
|
|
225
|
+
def meta(self, name):
|
|
226
|
+
"""→ {section, priority}(取最新版本条目),供卸载分类用。"""
|
|
227
|
+
e = self.top(name)
|
|
228
|
+
return {"section": e.section, "priority": e.priority} if e else {}
|
|
229
|
+
|
|
230
|
+
def search(self, q, limit=30):
|
|
231
|
+
"""按包名/描述搜索。
|
|
232
|
+
排名:完全匹配 > 前缀 > 名字含 > 描述含 > 分词全命中。最后一级用分词,
|
|
233
|
+
让 "zen browser" 这类多词查询也能命中名字里用下划线/连字符的包。
|
|
234
|
+
先全量扫描再排序——原实现边扫边在 limit*3 处 break,结果取决于 dict
|
|
235
|
+
插入顺序,会漏掉更优匹配。"""
|
|
236
|
+
q = (q or "").lower().strip()
|
|
237
|
+
if not q:
|
|
238
|
+
return []
|
|
239
|
+
tokens = query_tokens(q)
|
|
240
|
+
hits = []
|
|
241
|
+
for name in self._entries:
|
|
242
|
+
nl = name.lower()
|
|
243
|
+
e = self.top(name)
|
|
244
|
+
desc = ((e.desc if e else "") or "").lower()
|
|
245
|
+
if nl == q:
|
|
246
|
+
rank = 0
|
|
247
|
+
elif nl.startswith(q):
|
|
248
|
+
rank = 1
|
|
249
|
+
elif q in nl:
|
|
250
|
+
rank = 2
|
|
251
|
+
elif q in desc:
|
|
252
|
+
rank = 3
|
|
253
|
+
elif matches_tokens(f"{name} {desc}", tokens):
|
|
254
|
+
rank = 4
|
|
255
|
+
else:
|
|
256
|
+
continue
|
|
257
|
+
hits.append((rank, name))
|
|
258
|
+
hits.sort()
|
|
259
|
+
out = []
|
|
260
|
+
for _rank, name in hits[:limit]:
|
|
261
|
+
e = self.top(name)
|
|
262
|
+
out.append({"name": name, "version": e.version, "repo": e.repo,
|
|
263
|
+
"suite": e.suite, "component": e.component,
|
|
264
|
+
"maintainer": e.maintainer, "size_kb": e.size_kb,
|
|
265
|
+
"date": e.date, "desc": e.desc, "section": e.section})
|
|
266
|
+
return out
|
|
267
|
+
|
|
268
|
+
def info(self, name):
|
|
269
|
+
"""单包详情:全部版本(每源每版本一条)。"""
|
|
270
|
+
es = self.entries(name)
|
|
271
|
+
if not es:
|
|
272
|
+
return None
|
|
273
|
+
# RepoEntry 是 slots dataclass,vars() 会抛 TypeError,得用 asdict
|
|
274
|
+
return {"name": name, "candidate": self.candidate(name),
|
|
275
|
+
"versions": [asdict(e) for e in es]}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
_CACHE = {"t": 0.0, "mtime": None, "idx": None}
|
|
279
|
+
_LOCK = threading.Lock()
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _lists_mtime(lists_dir):
|
|
283
|
+
"""目录 mtime:apt update 会重写列表文件,比单纯 TTL 更准。"""
|
|
284
|
+
try:
|
|
285
|
+
return os.path.getmtime(lists_dir)
|
|
286
|
+
except OSError:
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def load_index(cfg=CFG, force=False):
|
|
291
|
+
"""解析并缓存仓库索引;force=True 忽略缓存。"""
|
|
292
|
+
with _LOCK:
|
|
293
|
+
now = time.time()
|
|
294
|
+
mtime = _lists_mtime(cfg.apt_lists_dir)
|
|
295
|
+
if (_CACHE["idx"] is not None and not force
|
|
296
|
+
and now - _CACHE["t"] < cfg.index_cache_ttl
|
|
297
|
+
and _CACHE["mtime"] == mtime):
|
|
298
|
+
return _CACHE["idx"]
|
|
299
|
+
idx = _build(cfg)
|
|
300
|
+
_CACHE.update(t=now, mtime=mtime, idx=idx)
|
|
301
|
+
return idx
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _build(cfg):
|
|
305
|
+
releases = _parse_releases(cfg.apt_lists_dir)
|
|
306
|
+
entries = {}
|
|
307
|
+
for path, prefix, suite, comp in _package_files(cfg.apt_lists_dir, cfg):
|
|
308
|
+
rel = _match_release(prefix, releases)
|
|
309
|
+
label = rel.label if rel else prefix
|
|
310
|
+
loc = "/".join(x for x in (suite or (rel.suite if rel else ""), comp) if x)
|
|
311
|
+
tag = f"{label} {loc}".strip()
|
|
312
|
+
date = rel.date if rel else ""
|
|
313
|
+
try:
|
|
314
|
+
with open_text(path) as fh:
|
|
315
|
+
for rec in _iter_records(fh):
|
|
316
|
+
name, ver = rec.get("Package"), rec.get("Version")
|
|
317
|
+
arch = rec.get("Architecture", "")
|
|
318
|
+
if not name or not ver or arch not in cfg.index_arches:
|
|
319
|
+
continue
|
|
320
|
+
size = rec.get("Size", "")
|
|
321
|
+
entries.setdefault(name, []).append(RepoEntry(
|
|
322
|
+
version=ver, repo=tag, suite=suite, component=comp,
|
|
323
|
+
arch=arch, maintainer=rec.get("Maintainer", "?"),
|
|
324
|
+
size_kb=int(size) // 1024 if size.isdigit() else 0,
|
|
325
|
+
filename=rec.get("Filename", ""), date=date,
|
|
326
|
+
desc=rec.get("Description", "")[:160],
|
|
327
|
+
section=rec.get("Section", ""),
|
|
328
|
+
priority=rec.get("Priority", "")))
|
|
329
|
+
except OSError:
|
|
330
|
+
continue
|
|
331
|
+
return RepoIndex(entries)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def is_upgrade(candidate, installed):
|
|
335
|
+
"""仓库候选版本是否高于已安装版本。"""
|
|
336
|
+
return bool(candidate) and ver_gt(candidate, installed)
|
pkgtool/apt/logs.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""pkgtool.apt.logs — dpkg.log / apt history.log 时间线解析。
|
|
2
|
+
|
|
3
|
+
安装通道判定(base.Channel)的证据来源。合并原先两套 apt history 解析:
|
|
4
|
+
deb_backend.parse_apt_history()(返回 {名: [(时间,版本)]})和
|
|
5
|
+
update_channels.apt_history_names()(返回名字集合,且用 split(", ") 拆条目,
|
|
6
|
+
遇到版本括号内的逗号就拆错——靠正则补救)。这里统一成一个解析器。
|
|
7
|
+
|
|
8
|
+
数据来源可信度:
|
|
9
|
+
/var/log/dpkg.log* 所有 dpkg 操作的时间线,含 dpkg -i,最接近事实
|
|
10
|
+
/var/log/apt/history.log* 仅 apt 系工具的操作记录(apt install ./x.deb 也在里面)
|
|
11
|
+
两者都会轮转,查无记录时只能标 UNKNOWN,不能当成"没装过"。
|
|
12
|
+
"""
|
|
13
|
+
import re
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from datetime import datetime, timedelta
|
|
16
|
+
|
|
17
|
+
from ..compress import iter_lines
|
|
18
|
+
from ..config import CFG
|
|
19
|
+
|
|
20
|
+
TS_FMT = "%Y-%m-%d %H:%M:%S"
|
|
21
|
+
|
|
22
|
+
# dpkg.log: "2024-01-01 12:00:00 install bash:amd64 <none> 5.2-2ubuntu1"
|
|
23
|
+
# 第 3 字段是安装前版本(新装时为 <none>),第 4 字段才是装上的版本。
|
|
24
|
+
# 原实现取第 3 字段当版本号,拿到的一律是 "<none>"(好在只用了时间戳)。
|
|
25
|
+
_DPKG_INSTALL_RE = re.compile(
|
|
26
|
+
r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) install (\S+) (\S+)(?: (\S+))?")
|
|
27
|
+
|
|
28
|
+
# apt history: "Install: libfoo:amd64 (1.2-3, automatic), bar:amd64 (4.5)"
|
|
29
|
+
# 架构段可有可无,括号内可能带 ", automatic",所以不能用逗号拆条目。
|
|
30
|
+
_APT_ENTRY_RE = re.compile(r"([\w.+\-~]+)(?::[\w\-]+)?\s*\(([^)]*)\)")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_ts(text):
|
|
34
|
+
"""→ datetime;解析不了返回 None(不抛)。"""
|
|
35
|
+
try:
|
|
36
|
+
return datetime.strptime(" ".join((text or "").split()), TS_FMT)
|
|
37
|
+
except (ValueError, TypeError):
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def dpkg_installs(cfg=CFG):
|
|
42
|
+
"""→ ({包名: (首次 install 时间, 版本)}, 全局最早事件时间)。
|
|
43
|
+
只取每个包的第一次 install 事件;earliest 用于推算系统"出生时间"。"""
|
|
44
|
+
first = {}
|
|
45
|
+
earliest = None
|
|
46
|
+
for line in iter_lines([cfg.dpkg_log_glob]):
|
|
47
|
+
m = _DPKG_INSTALL_RE.match(line)
|
|
48
|
+
if not m:
|
|
49
|
+
continue
|
|
50
|
+
ts, pkg, _old, new = m.groups()
|
|
51
|
+
pkg = pkg.split(":")[0]
|
|
52
|
+
if earliest is None or ts < earliest:
|
|
53
|
+
earliest = ts
|
|
54
|
+
if pkg not in first:
|
|
55
|
+
first[pkg] = (ts, new or "")
|
|
56
|
+
return first, earliest
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def apt_history(cfg=CFG):
|
|
60
|
+
"""→ {包名: [(时间, 版本字符串), ...]},来自 Install/Upgrade 行。
|
|
61
|
+
括号里的 ", automatic" 是 apt 的自动安装标记不是版本的一部分,
|
|
62
|
+
剥掉(Upgrade 行的 "旧版, 新版" 双版本则保留)。"""
|
|
63
|
+
out = defaultdict(list)
|
|
64
|
+
cur_ts = ""
|
|
65
|
+
for line in iter_lines([cfg.apt_history_glob]):
|
|
66
|
+
if line.startswith("Start-Date:"):
|
|
67
|
+
cur_ts = " ".join(line.split(":", 1)[1].split())
|
|
68
|
+
elif line.startswith(("Install:", "Upgrade:")):
|
|
69
|
+
for name, ver in _APT_ENTRY_RE.findall(line):
|
|
70
|
+
parts = [p.strip() for p in ver.split(",")]
|
|
71
|
+
if parts and parts[-1] == "automatic":
|
|
72
|
+
parts = parts[:-1]
|
|
73
|
+
out[name].append((cur_ts, ", ".join(parts)))
|
|
74
|
+
return dict(out)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def birth_cutoff(earliest_ts, cfg=CFG):
|
|
78
|
+
"""系统"出生时间"上界 = 日志最早事件 + birth_margin_hours。
|
|
79
|
+
在此窗口内装入且无 apt 记录的包,判为镜像自带。日志缺失时返回 None。"""
|
|
80
|
+
start = parse_ts(earliest_ts)
|
|
81
|
+
if start is None:
|
|
82
|
+
return None
|
|
83
|
+
return start + timedelta(hours=cfg.birth_margin_hours)
|
pkgtool/apt/version.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""pkgtool.apt.version — Debian 版本号比较(dpkg verrev 算法,纯标准库实现)。
|
|
2
|
+
|
|
3
|
+
版本格式:[epoch:]upstream_version[-debian_revision]
|
|
4
|
+
比较规则(dpkg/version.c):
|
|
5
|
+
· epoch 按整数比,缺省为 0
|
|
6
|
+
· upstream 与 revision 各自交替比较「非数字段」和「数字段」:
|
|
7
|
+
非数字段逐字符按 order() 排序,数字段整体按数值(前导零无意义)
|
|
8
|
+
· order(): '~' < 字符串结束 < 数字 < 字母 < 其他符号
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _order(ch):
|
|
13
|
+
"""dpkg 的字符序。ch=None 表示字符串已结束。"""
|
|
14
|
+
if ch == "~":
|
|
15
|
+
return -1
|
|
16
|
+
if ch is None:
|
|
17
|
+
return 0
|
|
18
|
+
if ch.isdigit():
|
|
19
|
+
return 0
|
|
20
|
+
if ch.isalpha():
|
|
21
|
+
return ord(ch)
|
|
22
|
+
return ord(ch) + 256
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _nondigit(s, i):
|
|
26
|
+
j = i
|
|
27
|
+
while j < len(s) and not s[j].isdigit():
|
|
28
|
+
j += 1
|
|
29
|
+
return s[i:j], j
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _digit(s, i):
|
|
33
|
+
j = i
|
|
34
|
+
while j < len(s) and s[j].isdigit():
|
|
35
|
+
j += 1
|
|
36
|
+
return s[i:j], j
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _cmp_part(a, b):
|
|
40
|
+
"""交替比较非数字段与数字段,直到两边都走完。"""
|
|
41
|
+
i = j = 0
|
|
42
|
+
while i < len(a) or j < len(b):
|
|
43
|
+
sa, i = _nondigit(a, i)
|
|
44
|
+
sb, j = _nondigit(b, j)
|
|
45
|
+
for k in range(max(len(sa), len(sb))):
|
|
46
|
+
ca = sa[k] if k < len(sa) else None
|
|
47
|
+
cb = sb[k] if k < len(sb) else None
|
|
48
|
+
oa, ob = _order(ca), _order(cb)
|
|
49
|
+
if oa != ob:
|
|
50
|
+
return -1 if oa < ob else 1
|
|
51
|
+
da, i = _digit(a, i)
|
|
52
|
+
db, j = _digit(b, j)
|
|
53
|
+
na = int(da) if da else 0
|
|
54
|
+
nb = int(db) if db else 0
|
|
55
|
+
if na != nb:
|
|
56
|
+
return -1 if na < nb else 1
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _split(v):
|
|
61
|
+
"""→ (epoch, upstream, revision)。"""
|
|
62
|
+
v = (v or "").strip()
|
|
63
|
+
epoch = 0
|
|
64
|
+
if ":" in v:
|
|
65
|
+
head, v = v.split(":", 1)
|
|
66
|
+
epoch = int(head) if head.isdigit() else 0
|
|
67
|
+
up, sep, rev = v.rpartition("-") # 最后一个连字符之后才是 debian revision
|
|
68
|
+
if not sep:
|
|
69
|
+
up, rev = v, ""
|
|
70
|
+
return epoch, up, rev
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def ver_cmp(a, b):
|
|
74
|
+
"""Debian 版本比较 → -1 / 0 / 1。"""
|
|
75
|
+
ea, ua, ra = _split(a)
|
|
76
|
+
eb, ub, rb = _split(b)
|
|
77
|
+
if ea != eb:
|
|
78
|
+
return -1 if ea < eb else 1
|
|
79
|
+
return _cmp_part(ua, ub) or _cmp_part(ra, rb)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def ver_gt(a, b):
|
|
83
|
+
return ver_cmp(a, b) > 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def newest(versions):
|
|
87
|
+
"""一组版本字符串里最高的那个;空序列返回 ""。"""
|
|
88
|
+
best = ""
|
|
89
|
+
for v in versions:
|
|
90
|
+
if not best or ver_gt(v, best):
|
|
91
|
+
best = v
|
|
92
|
+
return best
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""pkgtool.backends — 各包格式采集器的注册表。
|
|
2
|
+
|
|
3
|
+
新增一种格式 = 新建 <fmt>.py 实现 Backend 子类 + 在 BACKENDS 里加一行,
|
|
4
|
+
其余模块(inventory / classify / report / cli / app)都不需要改。
|
|
5
|
+
"""
|
|
6
|
+
from ..base import Backend
|
|
7
|
+
from ..config import CFG
|
|
8
|
+
from .appimage import AppImageBackend
|
|
9
|
+
from .deb import DebBackend
|
|
10
|
+
from .flatpak import FlatpakBackend
|
|
11
|
+
from .linyap import LinyapBackend
|
|
12
|
+
from .snap import SnapBackend
|
|
13
|
+
|
|
14
|
+
BACKENDS = (DebBackend, SnapBackend, FlatpakBackend, LinyapBackend,
|
|
15
|
+
AppImageBackend)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def available_backends(cfg=CFG):
|
|
19
|
+
"""本机存在对应包管理体系的后端类(available() 不得抛异常)。"""
|
|
20
|
+
return [b for b in BACKENDS if b.available(cfg)]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def discover(cfg=CFG):
|
|
24
|
+
"""→ 本机可用后端的实例列表。"""
|
|
25
|
+
return [b(cfg) for b in available_backends(cfg)]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
__all__ = ["BACKENDS", "Backend", "available_backends", "discover",
|
|
29
|
+
"DebBackend", "SnapBackend", "FlatpakBackend", "LinyapBackend",
|
|
30
|
+
"AppImageBackend"]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""pkgtool.backends.appimage — AppImage 嗅探(纯被动,不执行目标文件)。
|
|
2
|
+
|
|
3
|
+
原理:AppImage v1/v2 = ELF 运行时 + 追加的 squashfs 负载。
|
|
4
|
+
1. 扫描用户区域找 *.AppImage(主目录/下载、~/Applications、/opt…)
|
|
5
|
+
2. 在文件里找 squashfs 超块(魔数 hsqs + block_size 是 4096~1MB 的 2 次幂
|
|
6
|
+
+ inode 数合理),避开 ELF 运行时内嵌 unsquashfs 代码里的字符串误报
|
|
7
|
+
3. `unsquashfs -o <off> -l` 列出负载(只读目录树,不解压不执行):
|
|
8
|
+
取 .desktop 文件名作显示名、usr/bin/* 作可执行文件
|
|
9
|
+
4. 版本号从文件名解析(CC-Switch-v3.20.4-Linux-x86_64 → 3.20.4)
|
|
10
|
+
位置语义:~/Applications、/opt、/usr/local/bin 下 = 已安置的便携应用;
|
|
11
|
+
下载等散落区域 = 未安装的包文件(extra["loose"],与 deb 散落文件同构)。
|
|
12
|
+
"""
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import struct
|
|
17
|
+
import subprocess
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
from ..base import Backend, OriginKind, PackageRecord, file_size_mb, scan_file_areas
|
|
21
|
+
from ..config import CFG
|
|
22
|
+
|
|
23
|
+
# 核心版本号 + 可选预发布后缀(beta/rc…),避免吞掉 -Linux-x86_64 这类架构尾巴
|
|
24
|
+
_VERSION_RE = re.compile(
|
|
25
|
+
r"v?(\d+(?:\.\d+)+[A-Za-z0-9.+~]*"
|
|
26
|
+
r"(?:-(?:beta|alpha|rc|dev|pre|nightly)[A-Za-z0-9.]*)?)")
|
|
27
|
+
_USR_BIN_RE = re.compile(r"(?:^|/)usr/bin/([^/]+)$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _squashfs_offset(path, limit):
|
|
31
|
+
"""找 squashfs 超块偏移;找不到返回 None。"""
|
|
32
|
+
try:
|
|
33
|
+
with open(path, "rb") as fh:
|
|
34
|
+
data = fh.read(limit)
|
|
35
|
+
except OSError:
|
|
36
|
+
return None
|
|
37
|
+
i = 0
|
|
38
|
+
while True:
|
|
39
|
+
i = data.find(b"hsqs", i)
|
|
40
|
+
if i < 0 or i + 16 > len(data):
|
|
41
|
+
return None
|
|
42
|
+
# 超块布局: magic(4) + inode_count(4) + creation_time(4) + block_size(4)
|
|
43
|
+
inodes, _mtime, blk = struct.unpack("<III", data[i + 4:i + 16])
|
|
44
|
+
if (blk and (blk & (blk - 1)) == 0 and 4096 <= blk <= 1 << 20
|
|
45
|
+
and 0 < inodes < 10 ** 6):
|
|
46
|
+
return i
|
|
47
|
+
i += 1
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _payload_listing(path, offset, cfg):
|
|
51
|
+
"""unsquashfs -l → (显示名, [usr/bin 可执行])。失败返回 ("", [])。"""
|
|
52
|
+
if not shutil.which("unsquashfs"):
|
|
53
|
+
return "", []
|
|
54
|
+
try:
|
|
55
|
+
p = subprocess.run(["unsquashfs", "-q", "-o", str(offset), "-l", path],
|
|
56
|
+
capture_output=True, text=True, timeout=cfg.timeout_query)
|
|
57
|
+
except (OSError, subprocess.SubprocessError):
|
|
58
|
+
return "", []
|
|
59
|
+
display, bins = "", []
|
|
60
|
+
for line in p.stdout.splitlines():
|
|
61
|
+
line = line.strip()
|
|
62
|
+
if not line or "/" not in line:
|
|
63
|
+
continue
|
|
64
|
+
base = line.rsplit("/", 1)[-1]
|
|
65
|
+
if not display and base.endswith(".desktop"):
|
|
66
|
+
display = base[: -len(".desktop")]
|
|
67
|
+
m = _USR_BIN_RE.search(line)
|
|
68
|
+
if m and m.group(1) not in cfg.appimage_xdg_wrappers:
|
|
69
|
+
bins.append(m.group(1))
|
|
70
|
+
return display, bins[:cfg.appimage_max_exes]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _version_from_name(stem):
|
|
74
|
+
found = _VERSION_RE.findall(stem)
|
|
75
|
+
return found[-1] if found else ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AppImageBackend(Backend):
|
|
79
|
+
pkg_type = "appimage"
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def available(cls, cfg=CFG):
|
|
83
|
+
home = cfg.home
|
|
84
|
+
cands = [os.path.join(home, d) for d in
|
|
85
|
+
cfg.appimage_app_dirs + cfg.appimage_download_dirs]
|
|
86
|
+
cands += list(cfg.appimage_system_dirs)
|
|
87
|
+
return any(os.path.isdir(d) for d in cands)
|
|
88
|
+
|
|
89
|
+
def _settled_dirs(self):
|
|
90
|
+
"""已安置目录:这些位置下的 .AppImage 算便携应用,其余算散落文件。"""
|
|
91
|
+
home = self.cfg.home
|
|
92
|
+
return [os.path.join(home, d) for d in self.cfg.appimage_app_dirs] \
|
|
93
|
+
+ list(self.cfg.appimage_system_dirs)
|
|
94
|
+
|
|
95
|
+
def _record(self, path, loose):
|
|
96
|
+
cfg = self.cfg
|
|
97
|
+
fn = os.path.basename(path)
|
|
98
|
+
stem = fn.rsplit(".", 1)[0] if "." in fn else fn
|
|
99
|
+
try:
|
|
100
|
+
mtime = time.strftime("%Y-%m-%d", time.localtime(os.path.getmtime(path)))
|
|
101
|
+
except OSError:
|
|
102
|
+
mtime = ""
|
|
103
|
+
|
|
104
|
+
name, exes = stem, []
|
|
105
|
+
off = _squashfs_offset(path, cfg.appimage_scan_limit)
|
|
106
|
+
if off is not None:
|
|
107
|
+
display, exes = _payload_listing(path, off, cfg)
|
|
108
|
+
if display:
|
|
109
|
+
name = display
|
|
110
|
+
extra = {"found_at": path, "mtime": mtime}
|
|
111
|
+
if loose:
|
|
112
|
+
extra.update(loose=path, state="uninstalled",
|
|
113
|
+
found_in=os.path.basename(os.path.dirname(path)))
|
|
114
|
+
return PackageRecord(
|
|
115
|
+
pkg_type=self.pkg_type, name=name,
|
|
116
|
+
version=_version_from_name(stem) or "?",
|
|
117
|
+
# name 取自 .desktop 显示名,两个不同文件可能同名;用文件名做变体,
|
|
118
|
+
# 否则 /opt 里已安置的和下载目录里散落的那份会互相顶掉
|
|
119
|
+
variant=fn,
|
|
120
|
+
origin_kind=OriginKind.FILE if loose else OriginKind.LOCAL,
|
|
121
|
+
size_mb=file_size_mb(path),
|
|
122
|
+
install_path=path, executables=exes, extra=extra)
|
|
123
|
+
|
|
124
|
+
def collect(self):
|
|
125
|
+
records, seen = [], set()
|
|
126
|
+
|
|
127
|
+
def add(path, loose):
|
|
128
|
+
try:
|
|
129
|
+
rp = os.path.realpath(path)
|
|
130
|
+
except OSError:
|
|
131
|
+
return
|
|
132
|
+
if rp in seen:
|
|
133
|
+
return
|
|
134
|
+
seen.add(rp)
|
|
135
|
+
records.append(self._record(path, loose))
|
|
136
|
+
|
|
137
|
+
for d in self._settled_dirs():
|
|
138
|
+
if not os.path.isdir(d):
|
|
139
|
+
continue
|
|
140
|
+
for f in sorted(os.listdir(d)):
|
|
141
|
+
if f.lower().endswith(".appimage"):
|
|
142
|
+
add(os.path.join(d, f), loose=False)
|
|
143
|
+
for p in scan_file_areas((".appimage",), cfg=self.cfg):
|
|
144
|
+
add(p, loose=True)
|
|
145
|
+
return records
|