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/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""pkgtool.apt — deb/apt 的数据源与写操作。
|
|
2
|
+
|
|
3
|
+
子模块:
|
|
4
|
+
version Debian 版本号比较(dpkg verrev,无 python-debian 依赖)
|
|
5
|
+
lists /var/lib/apt/lists 的唯一解析器(仓库索引 + 源标签 + section/priority)
|
|
6
|
+
logs dpkg.log / apt history.log 时间线(安装通道判定的证据来源)
|
|
7
|
+
dpkg /var/lib/dpkg 状态:status、.list 清单、extended_states、apt-mark
|
|
8
|
+
actions 写操作:apt-get download / 特权执行
|
|
9
|
+
"""
|
pkgtool/apt/actions.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""pkgtool.apt.actions — 需要特权的写操作。
|
|
2
|
+
|
|
3
|
+
统一提权入口:原实现有三份互不复用的特权执行路径(Web UI 的 sudo -S apt 升级、
|
|
4
|
+
sudo -S flatpak 升级、safety.py 的 pkexec bash -c),这里收敛成一个
|
|
5
|
+
run_privileged(),remove.py 与各后端的升级动作都走它。
|
|
6
|
+
|
|
7
|
+
安全约束:
|
|
8
|
+
· 密码只经 stdin 传给 sudo,不进 argv(argv 在 /proc/<pid>/cmdline 全局可见)
|
|
9
|
+
· 一律用 argv 列表调用,不经过 shell
|
|
10
|
+
· 包名/版本先过 base.is_safe_name 白名单,拒绝前导 '-' 造成的参数注入
|
|
11
|
+
"""
|
|
12
|
+
import glob
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import signal
|
|
16
|
+
import subprocess
|
|
17
|
+
import tempfile
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
from ..base import is_safe_name
|
|
21
|
+
from ..config import CFG
|
|
22
|
+
|
|
23
|
+
_SUDO_NOPASS = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Result:
|
|
28
|
+
"""一次写操作的结果。command 是不含密码的可复现命令(供失败时提示手动执行)。"""
|
|
29
|
+
ok: bool
|
|
30
|
+
returncode: int = 0
|
|
31
|
+
output: str = ""
|
|
32
|
+
error: str = ""
|
|
33
|
+
file: str = ""
|
|
34
|
+
command: list = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def command_text(self):
|
|
38
|
+
return " ".join(shutil.quote(c) for c in self.command)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_root():
|
|
42
|
+
return os.geteuid() == 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def sudo_nopass():
|
|
46
|
+
"""是否有免密 sudo(结果缓存)。没有时特权命令会在终端上交互提示。"""
|
|
47
|
+
global _SUDO_NOPASS
|
|
48
|
+
if _SUDO_NOPASS is None:
|
|
49
|
+
if is_root():
|
|
50
|
+
_SUDO_NOPASS = True
|
|
51
|
+
return True
|
|
52
|
+
try:
|
|
53
|
+
_SUDO_NOPASS = subprocess.run(
|
|
54
|
+
["sudo", "-n", "true"], capture_output=True, timeout=10).returncode == 0
|
|
55
|
+
except (OSError, subprocess.SubprocessError):
|
|
56
|
+
_SUDO_NOPASS = False
|
|
57
|
+
return _SUDO_NOPASS
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def privileged_argv(argv, password=None):
|
|
61
|
+
"""套上提权前缀。已是 root → 原样;给了密码 → sudo -S;否则裸 sudo 走终端提示。"""
|
|
62
|
+
if is_root():
|
|
63
|
+
return list(argv)
|
|
64
|
+
if password:
|
|
65
|
+
return ["sudo", "-S", "-p", "", *argv]
|
|
66
|
+
return ["sudo", *argv]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _kill(p, own_session):
|
|
70
|
+
"""中止子进程。
|
|
71
|
+
own_session=True 时子进程自成一个会话,可以 killpg 干净地带走整棵树;
|
|
72
|
+
False 时它和我们同组,killpg 会把自己一起杀掉,只能 terminate 它本身
|
|
73
|
+
(apt-get 收到 SIGTERM 会自行收拾 dpkg)。"""
|
|
74
|
+
try:
|
|
75
|
+
if own_session:
|
|
76
|
+
os.killpg(os.getpgid(p.pid), signal.SIGTERM)
|
|
77
|
+
else:
|
|
78
|
+
p.terminate()
|
|
79
|
+
p.wait(timeout=5)
|
|
80
|
+
except (OSError, subprocess.SubprocessError):
|
|
81
|
+
try:
|
|
82
|
+
if own_session:
|
|
83
|
+
os.killpg(os.getpgid(p.pid), signal.SIGKILL)
|
|
84
|
+
else:
|
|
85
|
+
p.kill()
|
|
86
|
+
except OSError:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _error_summary(rc, lines):
|
|
91
|
+
"""失败原因:取输出的最后一行非空内容。
|
|
92
|
+
只报"退出码 1"等于没报——sudo 认证失败、apt 依赖冲突这些真正的原因
|
|
93
|
+
都在输出里,用户看到的却是一个光秃秃的数字。"""
|
|
94
|
+
for line in reversed(lines):
|
|
95
|
+
line = line.strip()
|
|
96
|
+
if line:
|
|
97
|
+
return f"{line[:200]}(退出码 {rc})"
|
|
98
|
+
return f"退出码 {rc}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def run_privileged(argv, password=None, timeout=None, on_line=None, cfg=CFG):
|
|
102
|
+
"""执行特权命令并逐行回显。Ctrl-C 中止(替代原 Web UI 的 cancel 接口)。
|
|
103
|
+
|
|
104
|
+
只有"密码经 stdin 喂进去"这种非交互场景才新建会话(那样能 killpg 整棵树)。
|
|
105
|
+
交互式必须留在当前会话里:start_new_session 会 setsid() 切断控制终端,
|
|
106
|
+
sudo 就再也打不开 /dev/tty 读密码,直接报
|
|
107
|
+
"A terminal is required to authenticate"——密码提示根本出不来。"""
|
|
108
|
+
cmd = privileged_argv(argv, password)
|
|
109
|
+
shown = list(argv) if is_root() else ["sudo", *argv] # 可复现命令,永不含密码
|
|
110
|
+
own_session = bool(password)
|
|
111
|
+
lines = []
|
|
112
|
+
try:
|
|
113
|
+
p = subprocess.Popen(cmd, stdin=subprocess.PIPE if password else None,
|
|
114
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
115
|
+
text=True, start_new_session=own_session)
|
|
116
|
+
except OSError as e:
|
|
117
|
+
return Result(ok=False, returncode=-1, error=f"{type(e).__name__}: {e}",
|
|
118
|
+
command=shown)
|
|
119
|
+
try:
|
|
120
|
+
if password:
|
|
121
|
+
try:
|
|
122
|
+
p.stdin.write(password + "\n")
|
|
123
|
+
p.stdin.close()
|
|
124
|
+
except OSError:
|
|
125
|
+
pass # sudo 可能已因缓存凭据直接放行
|
|
126
|
+
for line in p.stdout:
|
|
127
|
+
line = line.rstrip("\n")
|
|
128
|
+
lines.append(line)
|
|
129
|
+
if on_line:
|
|
130
|
+
on_line(line)
|
|
131
|
+
rc = p.wait(timeout=timeout)
|
|
132
|
+
except KeyboardInterrupt:
|
|
133
|
+
_kill(p, own_session)
|
|
134
|
+
return Result(ok=False, returncode=-2, output="\n".join(lines),
|
|
135
|
+
error="已中止(Ctrl-C)", command=shown)
|
|
136
|
+
except subprocess.TimeoutExpired:
|
|
137
|
+
_kill(p, own_session)
|
|
138
|
+
return Result(ok=False, returncode=-3, output="\n".join(lines),
|
|
139
|
+
error=f"超时({timeout}s)", command=shown)
|
|
140
|
+
except OSError as e:
|
|
141
|
+
_kill(p, own_session)
|
|
142
|
+
return Result(ok=False, returncode=-1, output="\n".join(lines),
|
|
143
|
+
error=f"{type(e).__name__}: {e}", command=shown)
|
|
144
|
+
return Result(ok=rc == 0, returncode=rc,
|
|
145
|
+
output="\n".join(lines)[-cfg.output_tail_chars:],
|
|
146
|
+
error="" if rc == 0 else _error_summary(rc, lines),
|
|
147
|
+
command=shown)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def run_plain(argv, cwd=None, timeout=None, cfg=CFG):
|
|
151
|
+
"""非特权命令(apt-get download / dpkg-deb 一类)。"""
|
|
152
|
+
try:
|
|
153
|
+
p = subprocess.run(argv, cwd=cwd, capture_output=True, text=True,
|
|
154
|
+
timeout=timeout or cfg.timeout_query)
|
|
155
|
+
except subprocess.TimeoutExpired:
|
|
156
|
+
return Result(ok=False, returncode=-3, error=f"超时({timeout}s)", command=argv)
|
|
157
|
+
except OSError as e:
|
|
158
|
+
return Result(ok=False, returncode=-1, error=f"{type(e).__name__}: {e}", command=argv)
|
|
159
|
+
return Result(ok=p.returncode == 0, returncode=p.returncode,
|
|
160
|
+
output=((p.stdout or "") + (p.stderr or "")).strip()[-cfg.output_tail_chars:],
|
|
161
|
+
error=(p.stderr or "").strip()[-500:], command=argv)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def download(name, version="", dest_dir=None, cfg=CFG):
|
|
165
|
+
"""apt-get download(非 root 可用)→ .deb 落到 dest_dir(默认 ~/Downloads)。"""
|
|
166
|
+
if not is_safe_name(name) or (version and not is_safe_name(version)):
|
|
167
|
+
return Result(ok=False, error=f"包名或版本非法:{name}={version}")
|
|
168
|
+
spec = f"{name}={version}" if version else name
|
|
169
|
+
dest_dir = dest_dir or cfg.download_dir
|
|
170
|
+
tmp = tempfile.mkdtemp(prefix="pkgtool_dl_")
|
|
171
|
+
try:
|
|
172
|
+
r = run_plain(["apt-get", "download", spec], cwd=tmp,
|
|
173
|
+
timeout=cfg.timeout_download, cfg=cfg)
|
|
174
|
+
debs = glob.glob(os.path.join(tmp, "*.deb"))
|
|
175
|
+
if not r.ok or not debs:
|
|
176
|
+
tail = [ln for ln in (r.output or r.error).splitlines() if ln.strip()]
|
|
177
|
+
return Result(ok=False, error=(tail[-1] if tail else "下载失败")[:300],
|
|
178
|
+
command=r.command)
|
|
179
|
+
os.makedirs(dest_dir, exist_ok=True)
|
|
180
|
+
dest = os.path.join(dest_dir, os.path.basename(debs[0]))
|
|
181
|
+
shutil.move(debs[0], dest)
|
|
182
|
+
return Result(ok=True, file=dest, command=r.command)
|
|
183
|
+
finally:
|
|
184
|
+
shutil.rmtree(tmp, ignore_errors=True) # 原实现把临时目录留在 /tmp 不清
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def upgrade(name, version="", password=None, on_line=None, cfg=CFG):
|
|
188
|
+
"""apt-get install -y 到指定版本(version 为空则装候选版本)。"""
|
|
189
|
+
if not is_safe_name(name) or (version and not is_safe_name(version)):
|
|
190
|
+
return Result(ok=False, error=f"包名或版本非法:{name}={version}")
|
|
191
|
+
spec = f"{name}={version}" if version else name
|
|
192
|
+
return run_privileged(["apt-get", "install", "-y", spec], password=password,
|
|
193
|
+
timeout=cfg.timeout_upgrade, on_line=on_line, cfg=cfg)
|
pkgtool/apt/deps.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""pkgtool.apt.deps — 已安装包的依赖图:独占依赖闭包与体积核算。
|
|
2
|
+
|
|
3
|
+
「独占依赖」= 只被这一个包硬依赖(Depends / Pre-Depends)、且 apt 标记为
|
|
4
|
+
auto 的包,取传递闭包。回答的问题是"这个软件拖了多少只属于它自己的东西"。
|
|
5
|
+
|
|
6
|
+
它和 `apt-get autoremove` 的答案不是一回事,两者不要混用:
|
|
7
|
+
· 独占依赖 这个包**用到**的、没人共用的东西 → 体积核算用
|
|
8
|
+
· autoremove 结果 删掉这个包后**连带孤立**的全部自动包 → 卸载预览用
|
|
9
|
+
后者含反向依赖级联,数字通常大得多:node-css-loader 的独占依赖是 0,
|
|
10
|
+
但删它会让 npm(依赖它)连带 396 个自动包一起走。卸载预览走 remove.py
|
|
11
|
+
的 apt dry-run,不用这里的数字。
|
|
12
|
+
|
|
13
|
+
依赖串解析要处理:逗号分隔的多项、竖线分隔的可选项、版本约束括号、
|
|
14
|
+
:arch 后缀。虚拟包(如 default-mta)不在已安装集合里,自然被丢弃。
|
|
15
|
+
"""
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
_DEP_NAME_RE = re.compile(r"([A-Za-z0-9][A-Za-z0-9.+\-]*)")
|
|
19
|
+
_KB_PER_MB = 1024.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def dep_names(raw, known):
|
|
23
|
+
"""依赖字段 → 已安装包名集合。raw 可以是多个字段拼接的串。"""
|
|
24
|
+
out = set()
|
|
25
|
+
for group in (raw or "").split(","):
|
|
26
|
+
for alt in group.split("|"):
|
|
27
|
+
m = _DEP_NAME_RE.match(alt.strip())
|
|
28
|
+
if not m:
|
|
29
|
+
continue
|
|
30
|
+
name = m.group(1)
|
|
31
|
+
if name in known:
|
|
32
|
+
out.add(name)
|
|
33
|
+
return out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DepGraph:
|
|
37
|
+
"""硬依赖图。构造一次,多次查询(exclusive 结果带缓存)。"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, entries, manual=frozenset()):
|
|
40
|
+
self._size = {n: e.installed_size_kb for n, e in entries.items()}
|
|
41
|
+
self._deps = {n: dep_names(f"{e.depends}, {e.pre_depends}", entries)
|
|
42
|
+
for n, e in entries.items()}
|
|
43
|
+
self._manual = set(manual)
|
|
44
|
+
refcount = {}
|
|
45
|
+
for deps in self._deps.values():
|
|
46
|
+
for d in deps:
|
|
47
|
+
refcount[d] = refcount.get(d, 0) + 1
|
|
48
|
+
self._refcount = refcount
|
|
49
|
+
self._cache = {}
|
|
50
|
+
|
|
51
|
+
def __len__(self):
|
|
52
|
+
return len(self._deps)
|
|
53
|
+
|
|
54
|
+
def size_mb(self, name):
|
|
55
|
+
"""包自身已安装体积(来自 status 的 Installed-Size,不遍历文件树)。"""
|
|
56
|
+
return round(self._size.get(name, 0) / _KB_PER_MB, 1)
|
|
57
|
+
|
|
58
|
+
def is_shared(self, name):
|
|
59
|
+
"""被两个以上的包依赖 = 共用,删掉任何一个都不会释放它。"""
|
|
60
|
+
return self._refcount.get(name, 0) > 1
|
|
61
|
+
|
|
62
|
+
def _children(self, name):
|
|
63
|
+
"""name 的依赖中,只被 name 一个包依赖、且 apt 未标记为 manual 的那些。
|
|
64
|
+
manual 标记的包 apt 不会自动清理,算进来会虚高"能释放多少"。"""
|
|
65
|
+
out = []
|
|
66
|
+
for d in self._deps.get(name, ()):
|
|
67
|
+
if d == name or d in self._manual:
|
|
68
|
+
continue
|
|
69
|
+
if self._refcount.get(d, 0) == 1:
|
|
70
|
+
out.append(d)
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
def exclusive(self, name):
|
|
74
|
+
"""独占依赖的传递闭包(带环保护:A↔B 互相独占时不会死循环)。"""
|
|
75
|
+
if name in self._cache:
|
|
76
|
+
return self._cache[name]
|
|
77
|
+
seen = set()
|
|
78
|
+
stack = [name]
|
|
79
|
+
while stack:
|
|
80
|
+
cur = stack.pop()
|
|
81
|
+
for d in self._children(cur):
|
|
82
|
+
if d == name or d in seen:
|
|
83
|
+
continue
|
|
84
|
+
seen.add(d)
|
|
85
|
+
stack.append(d)
|
|
86
|
+
self._cache[name] = seen
|
|
87
|
+
return seen
|
|
88
|
+
|
|
89
|
+
def total_mb(self, name):
|
|
90
|
+
"""→ (自身体积, 独占依赖体积, 合计)。"""
|
|
91
|
+
deps = self.exclusive(name)
|
|
92
|
+
own = self.size_mb(name)
|
|
93
|
+
extra = round(sum(self._size.get(d, 0) for d in deps) / _KB_PER_MB, 1)
|
|
94
|
+
return own, extra, round(own + extra, 1)
|
pkgtool/apt/dpkg.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""pkgtool.apt.dpkg — /var/lib/dpkg 与 apt 标记状态的读取。
|
|
2
|
+
|
|
3
|
+
extended_states 是 auto/manual 标记的权威存储,兼容两种格式/位置:
|
|
4
|
+
apt 3.x: /var/lib/apt/extended_states stanza 格式(Package: / Auto-Installed:)
|
|
5
|
+
dpkg 旧版: /var/lib/dpkg/extended_states 行格式 "pkg:arch auto"
|
|
6
|
+
注意新版 dpkg(1.23+) 的 status 第三字段已不再区分 user/installed,不能再用它
|
|
7
|
+
判断 manual/auto,所以要与 apt-mark 交叉核对,不一致时标记 conflict。
|
|
8
|
+
"""
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from ..config import CFG
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class StatusEntry:
|
|
18
|
+
"""/var/lib/dpkg/status 里一个包的全部所需字段。
|
|
19
|
+
体积与依赖都在这里一次读出来,避免为算大小再去遍历文件树、
|
|
20
|
+
为算依赖图再解析一遍 status。"""
|
|
21
|
+
name: str
|
|
22
|
+
version: str = ""
|
|
23
|
+
status: str = ""
|
|
24
|
+
installed_size_kb: int = 0
|
|
25
|
+
depends: str = ""
|
|
26
|
+
pre_depends: str = ""
|
|
27
|
+
recommends: str = ""
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def size_mb(self):
|
|
31
|
+
return round(self.installed_size_kb / 1024.0, 1)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_STATUS_FIELDS = {
|
|
35
|
+
"Package": "name", "Version": "version", "Status": "status",
|
|
36
|
+
"Installed-Size": "installed_size_kb", "Depends": "depends",
|
|
37
|
+
"Pre-Depends": "pre_depends", "Recommends": "recommends",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def installed(cfg=CFG):
|
|
42
|
+
"""解析 dpkg status → {包名: StatusEntry}。
|
|
43
|
+
多架构同名包(gcc:amd64 / gcc:i386)按名字归并,后者覆盖前者。"""
|
|
44
|
+
pkgs = {}
|
|
45
|
+
cur = {}
|
|
46
|
+
|
|
47
|
+
def flush():
|
|
48
|
+
name = cur.get("name")
|
|
49
|
+
if name:
|
|
50
|
+
size = cur.get("installed_size_kb", "")
|
|
51
|
+
pkgs[name] = StatusEntry(
|
|
52
|
+
name=name, version=cur.get("version", ""),
|
|
53
|
+
status=cur.get("status", ""),
|
|
54
|
+
installed_size_kb=int(size) if str(size).isdigit() else 0,
|
|
55
|
+
depends=cur.get("depends", ""),
|
|
56
|
+
pre_depends=cur.get("pre_depends", ""),
|
|
57
|
+
recommends=cur.get("recommends", ""))
|
|
58
|
+
cur.clear()
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
fh = open(cfg.dpkg_status, errors="replace")
|
|
62
|
+
except OSError:
|
|
63
|
+
return pkgs
|
|
64
|
+
with fh:
|
|
65
|
+
for line in fh:
|
|
66
|
+
if line == "\n":
|
|
67
|
+
flush()
|
|
68
|
+
continue
|
|
69
|
+
if line[0] in " \t": # 续行(Description 正文等)
|
|
70
|
+
continue
|
|
71
|
+
key, sep, value = line.partition(": ")
|
|
72
|
+
if sep:
|
|
73
|
+
field = _STATUS_FIELDS.get(key)
|
|
74
|
+
if field and field not in cur:
|
|
75
|
+
cur[field] = value.strip()
|
|
76
|
+
flush()
|
|
77
|
+
return pkgs
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def package_files(pkg, cfg=CFG):
|
|
81
|
+
"""从 <pkg>.list 读文件清单(比逐个 dpkg -L 快得多)→ (files, dirs)。"""
|
|
82
|
+
path = os.path.join(cfg.dpkg_info_dir, pkg + ".list")
|
|
83
|
+
files, dirs = [], []
|
|
84
|
+
if not os.path.isfile(path):
|
|
85
|
+
return files, dirs
|
|
86
|
+
try:
|
|
87
|
+
with open(path, errors="replace") as fh:
|
|
88
|
+
for line in fh:
|
|
89
|
+
p = line.rstrip("\n")
|
|
90
|
+
if not p or p == "/":
|
|
91
|
+
continue
|
|
92
|
+
(dirs if p.endswith("/") else files).append(p)
|
|
93
|
+
except OSError:
|
|
94
|
+
return files, dirs
|
|
95
|
+
return files, dirs
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def apt_marks(cfg=CFG):
|
|
99
|
+
"""→ (manual 集合, auto 集合)。apt-mark 不可用时返回两个空集。"""
|
|
100
|
+
def run(sub):
|
|
101
|
+
try:
|
|
102
|
+
out = subprocess.run(["apt-mark", sub], capture_output=True,
|
|
103
|
+
text=True, timeout=cfg.timeout_query).stdout
|
|
104
|
+
return set(out.split())
|
|
105
|
+
except (OSError, subprocess.SubprocessError):
|
|
106
|
+
return set()
|
|
107
|
+
return run("showmanual"), run("showauto")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def extended_states(cfg=CFG):
|
|
111
|
+
"""解析 extended_states → ({包名: 是否 auto}, 命中的文件路径)。
|
|
112
|
+
只记录 auto 的包;未出现的包视为 manual。文件都不存在时返回 ({}, None)。"""
|
|
113
|
+
for path in cfg.ext_states_paths:
|
|
114
|
+
if not os.path.isfile(path):
|
|
115
|
+
continue
|
|
116
|
+
try:
|
|
117
|
+
with open(path, errors="replace") as fh:
|
|
118
|
+
lines = [ln.strip() for ln in fh if ln.strip()]
|
|
119
|
+
except OSError:
|
|
120
|
+
continue
|
|
121
|
+
autos = {}
|
|
122
|
+
if any(ln.startswith("Package: ") for ln in lines): # stanza 格式
|
|
123
|
+
name = None
|
|
124
|
+
for ln in lines:
|
|
125
|
+
if ln.startswith("Package: "):
|
|
126
|
+
name = ln.split(":", 1)[1].strip().split(":")[0]
|
|
127
|
+
elif ln.startswith("Auto-Installed: ") and name is not None:
|
|
128
|
+
autos[name] = ln.split(":", 1)[1].strip() == "1"
|
|
129
|
+
name = None
|
|
130
|
+
else: # 行格式
|
|
131
|
+
for ln in lines:
|
|
132
|
+
parts = ln.split()
|
|
133
|
+
if len(parts) >= 2 and ":" in parts[0] and parts[1] in ("auto", "manual"):
|
|
134
|
+
autos[parts[0].split(":")[0]] = parts[1] == "auto"
|
|
135
|
+
return autos, path
|
|
136
|
+
return {}, None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def deb_file_info(path, *fields, cfg=CFG):
|
|
140
|
+
"""dpkg-deb -f 读散落 .deb 的元数据 → {字段: 值};读不出包名返回 None。
|
|
141
|
+
合并原先 scan_deb_files 与 _deb_file_info 两份同构实现。"""
|
|
142
|
+
want = fields or ("Package", "Version")
|
|
143
|
+
try:
|
|
144
|
+
p = subprocess.run(["dpkg-deb", "-f", path, *want],
|
|
145
|
+
capture_output=True, text=True, timeout=cfg.timeout_query)
|
|
146
|
+
except (OSError, subprocess.SubprocessError):
|
|
147
|
+
return None
|
|
148
|
+
out = {}
|
|
149
|
+
for line in p.stdout.splitlines():
|
|
150
|
+
key, sep, value = line.partition(": ")
|
|
151
|
+
if sep:
|
|
152
|
+
out[key.strip()] = value.strip()
|
|
153
|
+
return out if out.get("Package") else None
|