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/classify.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""pkgtool.classify — 卸载风险分类:全项目唯一判定入口。
|
|
2
|
+
|
|
3
|
+
只有 APP 类允许删除,其余一律拒绝(remove.py 会再校验一次)。
|
|
4
|
+
|
|
5
|
+
deb 的四层证据,判不准就保守归 SYSTEM:
|
|
6
|
+
1. dpkg priority/section(来自 apt 索引):required/important → BASE;
|
|
7
|
+
section=libs/libdevel → LIBRARY;kernel/base → SYSTEM
|
|
8
|
+
2. 命名规则:linux-*/xserver-*/firmware-*/nvidia-* → SYSTEM;
|
|
9
|
+
lib*/-dev/-dbg → LIBRARY
|
|
10
|
+
3. 文件布局:有 .desktop 或装进 /opt → APP(自带软件);完全无可执行文件 → LIBRARY
|
|
11
|
+
4. 安装意图:DPKG_LOCAL/APT_LOCAL/UNKNOWN 通道(= 用户自己拿本地 .deb 装的)
|
|
12
|
+
→ APP
|
|
13
|
+
|
|
14
|
+
原实现有两处问题在这里一并修掉:
|
|
15
|
+
· /opt 规则是死代码:top_dirs 存的是单层目录名("opt"),判定却写
|
|
16
|
+
startswith("opt/"),永远不成立,文档里承诺的"装进 /opt 算 app"从未生效。
|
|
17
|
+
· 文档写了 nvidia-* → system,代码里没有;补上(更保守,不会放开删除权限)。
|
|
18
|
+
分类只在 inventory 层调用一次,覆盖所有后端。原实现 deb 在后端分类、
|
|
19
|
+
snap/flatpak 在 Web UI 分类,两套入口导致规则漂移。
|
|
20
|
+
"""
|
|
21
|
+
import re
|
|
22
|
+
|
|
23
|
+
from .apt import lists as apt_lists
|
|
24
|
+
from .base import Channel, OriginKind, PkgClass
|
|
25
|
+
|
|
26
|
+
# 删掉就会让系统起不来的包,无论其他证据如何都不给删
|
|
27
|
+
CRITICAL = frozenset({
|
|
28
|
+
"dpkg", "apt", "bash", "dash", "coreutils", "systemd", "init", "base-files",
|
|
29
|
+
"base-passwd", "util-linux", "login", "passwd", "tar", "gzip", "sed", "grep",
|
|
30
|
+
"findutils", "e2fsprogs", "kmod", "mount", "perl-base", "libc-bin", "debconf",
|
|
31
|
+
"apt-utils", "policy-rc.d", "init-system-helpers",
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
_SYSTEM_PREFIXES = ("linux-", "xserver-", "firmware-", "nvidia-")
|
|
35
|
+
_LIBRARY_SUFFIXES = ("-dev", "-dbg", "-dbgsym")
|
|
36
|
+
_LIB_NAME_RE = re.compile(r"^lib[a-z0-9]+.*\d")
|
|
37
|
+
_SNAP_BASE_RE = re.compile(r"^(snapd|bare|core\d*|gnome-|gtk-common-themes|mesa-)")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def classify(rec, index=None):
|
|
41
|
+
"""→ (PkgClass, reason)。index 是 apt 仓库索引(提供 section/priority),可为 None。"""
|
|
42
|
+
if rec.is_loose_file:
|
|
43
|
+
return PkgClass.FILE, "磁盘上的包文件,不是已安装记录"
|
|
44
|
+
t = rec.pkg_type
|
|
45
|
+
if t == "deb":
|
|
46
|
+
return _deb(rec, index)
|
|
47
|
+
if t == "snap":
|
|
48
|
+
return _snap(rec)
|
|
49
|
+
if t.startswith("flatpak"):
|
|
50
|
+
return _flatpak(rec)
|
|
51
|
+
if t == "linyap":
|
|
52
|
+
return _linyap(rec)
|
|
53
|
+
if t == "appimage":
|
|
54
|
+
return PkgClass.APP, "便携应用(用户自行安置)"
|
|
55
|
+
return PkgClass.SYSTEM, f"未知包类型 {t},保守拒绝"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _deb(rec, index):
|
|
59
|
+
name = rec.name
|
|
60
|
+
meta = index.meta(name) if isinstance(index, apt_lists.RepoIndex) else {}
|
|
61
|
+
section = meta.get("section", "")
|
|
62
|
+
priority = meta.get("priority", "")
|
|
63
|
+
top_dirs = rec.extra.get("top_dirs") or []
|
|
64
|
+
has_desktop = bool(rec.extra.get("desktop_id") or rec.extra.get("desktop_files"))
|
|
65
|
+
exes = rec.executables
|
|
66
|
+
mark = rec.extra.get("apt_mark", "")
|
|
67
|
+
|
|
68
|
+
if name in CRITICAL or priority in ("required", "important"):
|
|
69
|
+
return PkgClass.BASE, f"基础包(priority={priority or 'critical-list'})"
|
|
70
|
+
if name.startswith(_SYSTEM_PREFIXES) or "firmware" in name \
|
|
71
|
+
or section in ("kernel", "base"):
|
|
72
|
+
return PkgClass.SYSTEM, "内核/固件/显卡驱动/X 服务"
|
|
73
|
+
if has_desktop:
|
|
74
|
+
return PkgClass.APP, "GUI 应用(带 .desktop)"
|
|
75
|
+
if "opt" in top_dirs:
|
|
76
|
+
return PkgClass.APP, "自带软件(装进 /opt)"
|
|
77
|
+
if rec.channel in (Channel.DPKG_LOCAL, Channel.APT_LOCAL, Channel.UNKNOWN):
|
|
78
|
+
# APT_LOCAL 也算:`apt install ./x.deb` 和 `dpkg -i x.deb` 一样,
|
|
79
|
+
# 都是用户自己下载本地包装进来的,只是走了不同的安装器。
|
|
80
|
+
# 但得有应用证据(桌面入口/可执行文件/装进 /opt)才给 APP:
|
|
81
|
+
# cuda-repo、cuda-keyring 这类仓库/密钥/元包也是本地 .deb,标成
|
|
82
|
+
# "软件"会误导用户以为可以启动甚至卸载(卸掉会连带弄坏 apt 源)。
|
|
83
|
+
if has_desktop or exes or "opt" in top_dirs:
|
|
84
|
+
return PkgClass.APP, "用户自行安装(本地 .deb)"
|
|
85
|
+
return PkgClass.LIBRARY, "本地 .deb 但无应用入口(仓库/密钥/元包类载荷)"
|
|
86
|
+
if section in ("libs", "libdevel"):
|
|
87
|
+
return PkgClass.LIBRARY, f"section={section}"
|
|
88
|
+
if _LIB_NAME_RE.match(name) or name.endswith(_LIBRARY_SUFFIXES):
|
|
89
|
+
return PkgClass.LIBRARY, "命名规则(lib*/-dev/-dbg)"
|
|
90
|
+
if not exes:
|
|
91
|
+
return PkgClass.LIBRARY, "无可执行文件(库/数据/配置)"
|
|
92
|
+
if mark == "manual":
|
|
93
|
+
return PkgClass.APP, "用户主动安装(manual 标记)"
|
|
94
|
+
return PkgClass.SYSTEM, "系统组件(保守默认)"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _snap(rec):
|
|
98
|
+
if _SNAP_BASE_RE.match(rec.name):
|
|
99
|
+
return PkgClass.BASE, "snap 基础运行时"
|
|
100
|
+
return PkgClass.APP, "snap 应用"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _flatpak(rec):
|
|
104
|
+
if rec.pkg_type.endswith("-runtime") or rec.extra.get("kind") == "runtime":
|
|
105
|
+
return PkgClass.LIBRARY, "flatpak runtime(运行库)"
|
|
106
|
+
return PkgClass.APP, "flatpak 应用"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _linyap(rec):
|
|
110
|
+
# base 层的 kind 也是 "runtime",不看名字只看 kind
|
|
111
|
+
if rec.extra.get("kind") in ("runtime", "base"):
|
|
112
|
+
return PkgClass.LIBRARY, "玲珑运行时/基础环境(被应用引用,不单独卸载)"
|
|
113
|
+
return PkgClass.APP, "玲珑应用"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def is_removable(rec):
|
|
117
|
+
return rec.pkg_class is PkgClass.APP
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# 默认视图的披露层级:0=仅用户可用的软件(APP),1=+库/数据/散落文件,
|
|
121
|
+
# 2=+系统/基础组件(全部)。层级看分类结论,不看 apt_mark——预装的
|
|
122
|
+
# GUI 应用也是"用户可用的软件",应该出现在默认视图里。
|
|
123
|
+
VISIBILITY = {PkgClass.APP: 0, PkgClass.FILE: 1, PkgClass.LIBRARY: 1,
|
|
124
|
+
PkgClass.SYSTEM: 2, PkgClass.BASE: 2}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def visibility_level(rec):
|
|
128
|
+
"""→ 该记录在分级披露里所属的层级(默认视图只展示 0 层)。"""
|
|
129
|
+
if rec.pkg_class is None:
|
|
130
|
+
return 2 # 未分类的保守处理:深层才显示
|
|
131
|
+
return VISIBILITY.get(rec.pkg_class, 2)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def is_user_installed(rec):
|
|
135
|
+
""""仅本地自装"过滤:用户自己动手装进来的东西。"""
|
|
136
|
+
if rec.is_loose_file:
|
|
137
|
+
return False
|
|
138
|
+
t = rec.pkg_type
|
|
139
|
+
if t == "deb":
|
|
140
|
+
return (rec.origin_kind is OriginKind.LOCAL
|
|
141
|
+
or rec.channel in (Channel.DPKG_LOCAL, Channel.APT_LOCAL))
|
|
142
|
+
if t == "appimage":
|
|
143
|
+
return True # 已安置的便携应用(散落文件上面已排除)
|
|
144
|
+
if t == "linyap":
|
|
145
|
+
return rec.extra.get("kind", "app") == "app" # 运行时不算
|
|
146
|
+
if t.endswith("-runtime"):
|
|
147
|
+
return False
|
|
148
|
+
return rec.origin_kind is OriginKind.LOCAL
|
pkgtool/clean.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""pkgtool.clean — 跨格式磁盘回收:扫描可清理目标 + 执行删除。
|
|
2
|
+
|
|
3
|
+
六类目标。能用官方清理命令的一律用官方命令——它们知道自己该删什么、不会
|
|
4
|
+
破坏自己的元数据;只有"磁盘上的散落包文件"和"用户缓存目录"才由
|
|
5
|
+
本工具直接删:
|
|
6
|
+
|
|
7
|
+
loose 散落 .deb / .AppImage 直接删文件(主目录内不需要 root)
|
|
8
|
+
apt-cache /var/cache/apt/archives apt-get clean
|
|
9
|
+
snap-rev 非激活的旧修订 snap remove <name> --revision <rev>
|
|
10
|
+
flatpak-unused 没有应用引用的运行时 flatpak uninstall --unused -y
|
|
11
|
+
linyap-unused 玲珑未引用的 base/runtime ll-cli prune(卸载应用后会残留)
|
|
12
|
+
user-cache ~/.cache 等用户缓存 直接删目录(XDG 规范:可再生的
|
|
13
|
+
非必要数据,逐个顶层目录列出)
|
|
14
|
+
conda-cache <conda 根>/pkgs conda clean -a -y
|
|
15
|
+
|
|
16
|
+
user-cache 的安全性依据:XDG 规范把 cache 目录定义为"可随时再生的非必要
|
|
17
|
+
数据",应用的会话、凭据、配置都在 ~/.config / ~/.local,删缓存碰不到;
|
|
18
|
+
flatpak 沙盒应用的同位缓存(~/.var/app/*/cache)同理。小于
|
|
19
|
+
cache_min_mb 的条目不列,避免几百条 KB 级噪音淹没列表。
|
|
20
|
+
|
|
21
|
+
conda 的 pkgs 目录大量用硬链接(解包后的文件被链接进各环境),按文件体积
|
|
22
|
+
累加会高估可回收空间(实测 1.45 GB vs du 的 1.1 GB),所以只统计压缩包部分。
|
|
23
|
+
"""
|
|
24
|
+
import glob
|
|
25
|
+
import os
|
|
26
|
+
import shutil
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from datetime import datetime
|
|
29
|
+
from urllib.parse import quote
|
|
30
|
+
|
|
31
|
+
from .apt import actions
|
|
32
|
+
from .base import delete_paths, file_size_mb, is_safe_name, is_under_home
|
|
33
|
+
from .config import CFG
|
|
34
|
+
from .labels import loose_state, size_text
|
|
35
|
+
|
|
36
|
+
KINDS = ("loose", "apt-cache", "snap-rev", "flatpak-unused", "linyap-unused",
|
|
37
|
+
"user-cache", "conda-cache")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Target:
|
|
42
|
+
"""一个可清理目标。paths 与 argv 二选一:直接删文件,或执行官方清理命令。"""
|
|
43
|
+
kind: str
|
|
44
|
+
label: str
|
|
45
|
+
detail: str
|
|
46
|
+
size_mb: float = 0.0
|
|
47
|
+
size_known: bool = True
|
|
48
|
+
paths: list = field(default_factory=list)
|
|
49
|
+
argv: list = field(default_factory=list)
|
|
50
|
+
privileged: bool = False
|
|
51
|
+
note: str = ""
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def size_text(self):
|
|
55
|
+
return size_text(self.size_mb, self.size_known)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------- 各类目标的扫描 ----------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def loose_target(rec, cfg=CFG):
|
|
62
|
+
"""一条散落文件记录 → 清理目标;文件已不在原位置时返回 None。
|
|
63
|
+
磁盘回收视图与散落包文件视图共用,保证两处删除行为一致。"""
|
|
64
|
+
path = rec.extra.get("loose")
|
|
65
|
+
if not path or not os.path.exists(path):
|
|
66
|
+
return None
|
|
67
|
+
return Target(
|
|
68
|
+
kind="loose", label=f"{rec.name} {rec.version}", detail=path,
|
|
69
|
+
size_mb=rec.size_mb or file_size_mb(path), paths=[path],
|
|
70
|
+
privileged=not is_under_home(path, cfg), note=loose_state(rec))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _loose(inv, cfg):
|
|
74
|
+
"""磁盘上散落的 .deb / .AppImage。"""
|
|
75
|
+
out = []
|
|
76
|
+
for r in (inv.records if inv else []):
|
|
77
|
+
if not r.is_loose_file:
|
|
78
|
+
continue
|
|
79
|
+
t = loose_target(r, cfg)
|
|
80
|
+
if t:
|
|
81
|
+
out.append(t)
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _apt_cache(inv, cfg):
|
|
86
|
+
"""apt 下载缓存。用 apt-get clean 而不是自己 rm:它还会清 partial/ 与索引临时文件。"""
|
|
87
|
+
d = cfg.apt_archives_dir
|
|
88
|
+
files = glob.glob(os.path.join(d, "*.deb")) + glob.glob(os.path.join(d, "*.ddeb"))
|
|
89
|
+
if not files:
|
|
90
|
+
return []
|
|
91
|
+
total = round(sum(file_size_mb(f) for f in files), 1)
|
|
92
|
+
return [Target(kind="apt-cache", label=f"{len(files)} 个 .deb/.ddeb",
|
|
93
|
+
detail=d,
|
|
94
|
+
size_mb=total, argv=["apt-get", "clean"], privileged=True,
|
|
95
|
+
note="只删已下载的包文件,需要时会重新下载")]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _snap_revisions(inv, cfg):
|
|
99
|
+
"""snapd 保留的旧修订(非当前激活的那些)。"""
|
|
100
|
+
from .backends.snap import SnapBackend
|
|
101
|
+
if not SnapBackend.available(cfg):
|
|
102
|
+
return []
|
|
103
|
+
be = SnapBackend(cfg)
|
|
104
|
+
out = []
|
|
105
|
+
for name, revs in sorted(be.stored_revisions().items()):
|
|
106
|
+
if not is_safe_name(name):
|
|
107
|
+
continue
|
|
108
|
+
active = be.active_revision(name, revs)
|
|
109
|
+
for rev in sorted(revs, key=int):
|
|
110
|
+
if rev == active:
|
|
111
|
+
continue
|
|
112
|
+
path = revs[rev]
|
|
113
|
+
out.append(Target(
|
|
114
|
+
kind="snap-rev", label=f"{name} rev{rev}", detail=path,
|
|
115
|
+
size_mb=file_size_mb(path),
|
|
116
|
+
argv=["snap", "remove", name, "--revision", rev],
|
|
117
|
+
privileged=True, note=f"当前激活 rev{active}"))
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _flatpak_unused(inv, cfg):
|
|
122
|
+
"""没有任何应用引用的 flatpak 运行时。体积只有执行后才知道。"""
|
|
123
|
+
from .backends.flatpak import FlatpakBackend
|
|
124
|
+
if not FlatpakBackend.available(cfg) or not shutil.which("flatpak"):
|
|
125
|
+
return []
|
|
126
|
+
return [Target(kind="flatpak-unused", label="",
|
|
127
|
+
detail="flatpak uninstall --unused:卸载没有任何应用引用的运行时",
|
|
128
|
+
size_known=False, argv=["flatpak", "uninstall", "--unused", "-y"],
|
|
129
|
+
privileged=True, note="体积要执行后才知道")]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _linyap_unused(inv, cfg):
|
|
133
|
+
"""玲珑卸载应用后残留的未引用 base/runtime(ll-cli prune 专清这个)。
|
|
134
|
+
体积只有执行后才知道。"""
|
|
135
|
+
if not shutil.which("ll-cli"):
|
|
136
|
+
return []
|
|
137
|
+
return [Target(kind="linyap-unused", label="",
|
|
138
|
+
detail="ll-cli prune:移除未被任何应用引用的基础环境/运行时",
|
|
139
|
+
size_known=False, argv=["ll-cli", "prune"],
|
|
140
|
+
privileged=True, note="体积要执行后才知道")]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _user_cache(inv, cfg):
|
|
144
|
+
"""~/.cache 与 flatpak 沙盒应用缓存(~/.var/app/*/cache)下的顶层目录。
|
|
145
|
+
每个目录单独一条,用户可以只挑不要的删;小于 cfg.cache_min_mb 的不列。"""
|
|
146
|
+
roots = [cfg.xdg_cache_home]
|
|
147
|
+
var_app = cfg.flatpak_sandbox_home
|
|
148
|
+
if os.path.isdir(var_app):
|
|
149
|
+
for app in sorted(os.listdir(var_app)):
|
|
150
|
+
p = os.path.join(var_app, app, "cache")
|
|
151
|
+
if os.path.isdir(p):
|
|
152
|
+
roots.append(p)
|
|
153
|
+
out = []
|
|
154
|
+
for root in roots:
|
|
155
|
+
try:
|
|
156
|
+
entries = os.listdir(root)
|
|
157
|
+
except OSError:
|
|
158
|
+
continue
|
|
159
|
+
for e in sorted(entries):
|
|
160
|
+
path = os.path.join(root, e)
|
|
161
|
+
size = file_size_mb(path)
|
|
162
|
+
if size < cfg.cache_min_mb:
|
|
163
|
+
continue
|
|
164
|
+
out.append(Target(kind="user-cache", label=e, detail=path,
|
|
165
|
+
size_mb=size, paths=[path],
|
|
166
|
+
note="可再生缓存,会话与配置不受影响"))
|
|
167
|
+
return out
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _conda_cache(inv, cfg):
|
|
171
|
+
"""conda 包缓存。用 conda clean -a:它知道哪些包还被环境引用着。
|
|
172
|
+
conda 根目录由 config.conda_roots 定位——这里只是找缓存目录,
|
|
173
|
+
不做任何环境探测(环境管理已整体移除)。"""
|
|
174
|
+
out = []
|
|
175
|
+
for dist in cfg.conda_roots:
|
|
176
|
+
pkgs = os.path.join(dist, cfg.conda_pkgs_subdir)
|
|
177
|
+
if not os.path.isdir(pkgs):
|
|
178
|
+
continue
|
|
179
|
+
# 只统计压缩包:解包后的目录被硬链接进各环境,删了也不释放空间
|
|
180
|
+
tarballs = (glob.glob(os.path.join(pkgs, "*.conda"))
|
|
181
|
+
+ glob.glob(os.path.join(pkgs, "*.tar.bz2")))
|
|
182
|
+
size = round(sum(file_size_mb(t) for t in tarballs), 1)
|
|
183
|
+
if not size:
|
|
184
|
+
continue
|
|
185
|
+
binary = os.path.join(dist, "bin", "conda")
|
|
186
|
+
out.append(Target(
|
|
187
|
+
kind="conda-cache",
|
|
188
|
+
label=f"{os.path.basename(dist)} · {len(tarballs)} 个压缩包",
|
|
189
|
+
detail=pkgs, size_mb=size,
|
|
190
|
+
argv=[binary, "clean", "-a", "-y"] if os.path.isfile(binary) else [],
|
|
191
|
+
paths=[] if os.path.isfile(binary) else tarballs,
|
|
192
|
+
note="conda clean -a:清索引缓存、锁文件与未使用的包"))
|
|
193
|
+
return out
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
COLLECTORS = (("loose", _loose), ("apt-cache", _apt_cache),
|
|
197
|
+
("snap-rev", _snap_revisions), ("flatpak-unused", _flatpak_unused),
|
|
198
|
+
("linyap-unused", _linyap_unused),
|
|
199
|
+
("user-cache", _user_cache), ("conda-cache", _conda_cache))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def collect_targets(cfg=CFG, kinds=None, inv=None, min_size_mb=0.0):
|
|
203
|
+
"""扫描全部(或指定类别的)可清理目标,按体积从大到小排。"""
|
|
204
|
+
kinds = tuple(kinds) if kinds else KINDS
|
|
205
|
+
if inv is None and "loose" in kinds:
|
|
206
|
+
from . import inventory # 延迟导入:只有需要散落文件时才做全量采集
|
|
207
|
+
inv = inventory.collect(cfg)
|
|
208
|
+
out = []
|
|
209
|
+
for kind, fn in COLLECTORS:
|
|
210
|
+
if kind not in kinds:
|
|
211
|
+
continue
|
|
212
|
+
try:
|
|
213
|
+
out.extend(fn(inv, cfg))
|
|
214
|
+
except Exception: # noqa: BLE001 一类扫不出来不该挡住其他类
|
|
215
|
+
continue
|
|
216
|
+
if min_size_mb > 0:
|
|
217
|
+
# 显式给了体积下限就严格执行:体积未知的目标(flatpak 无用运行时)也一并
|
|
218
|
+
# 排除。否则 "--min-size 500" 会连一个不知道多大的东西也删,不符合直觉。
|
|
219
|
+
out = [t for t in out if t.size_known and t.size_mb >= min_size_mb]
|
|
220
|
+
out.sort(key=lambda t: -t.size_mb)
|
|
221
|
+
return out
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def total_mb(targets):
|
|
225
|
+
return round(sum(t.size_mb for t in targets if t.size_known), 1)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------- 删除 ----------
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def delete(target, trash=False, on_line=None, cfg=CFG):
|
|
232
|
+
"""执行一个目标 → actions.Result。
|
|
233
|
+
trash=True 时对普通权限的文件类目标移到回收站(可还原);
|
|
234
|
+
特权目标和官方命令类目标不受影响,照常执行。"""
|
|
235
|
+
if target.argv:
|
|
236
|
+
if target.privileged:
|
|
237
|
+
return actions.run_privileged(target.argv, timeout=cfg.timeout_remove,
|
|
238
|
+
on_line=on_line, cfg=cfg)
|
|
239
|
+
return actions.run_plain(target.argv, timeout=cfg.timeout_remove, cfg=cfg)
|
|
240
|
+
if not target.paths:
|
|
241
|
+
return actions.Result(ok=False, error="目标没有任何可删除的内容")
|
|
242
|
+
if target.privileged:
|
|
243
|
+
# root 拥有的文件进不了用户回收站,只能真删
|
|
244
|
+
return actions.run_privileged(["rm", "-rf", "--", *target.paths],
|
|
245
|
+
timeout=cfg.timeout_remove, on_line=on_line,
|
|
246
|
+
cfg=cfg)
|
|
247
|
+
if trash:
|
|
248
|
+
return to_trash(target.paths, cfg)
|
|
249
|
+
removed, failed = delete_paths(target.paths)
|
|
250
|
+
return actions.Result(ok=not failed, file=",".join(removed),
|
|
251
|
+
error="; ".join(failed), command=["rm", "-rf", *target.paths])
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def to_trash(paths, cfg=CFG):
|
|
255
|
+
"""移到 freedesktop 回收站(~/.local/share/Trash),可从文件管理器还原。
|
|
256
|
+
跨文件系统时 shutil.move 会退化成复制+删除,大文件会慢。"""
|
|
257
|
+
files_dir = os.path.join(cfg.trash_dir, "files")
|
|
258
|
+
info_dir = os.path.join(cfg.trash_dir, "info")
|
|
259
|
+
try:
|
|
260
|
+
os.makedirs(files_dir, exist_ok=True)
|
|
261
|
+
os.makedirs(info_dir, exist_ok=True)
|
|
262
|
+
except OSError as e:
|
|
263
|
+
return actions.Result(ok=False, error=f"无法创建回收站目录:{e}")
|
|
264
|
+
moved, failed = [], []
|
|
265
|
+
for p in paths:
|
|
266
|
+
base = os.path.basename(p.rstrip(os.sep)) or "unnamed"
|
|
267
|
+
dest, n = os.path.join(files_dir, base), 1
|
|
268
|
+
while os.path.exists(dest):
|
|
269
|
+
dest = os.path.join(files_dir, f"{base}.{n}")
|
|
270
|
+
n += 1
|
|
271
|
+
try:
|
|
272
|
+
shutil.move(p, dest)
|
|
273
|
+
except OSError as e:
|
|
274
|
+
failed.append(f"{p}: {e}")
|
|
275
|
+
continue
|
|
276
|
+
try:
|
|
277
|
+
with open(os.path.join(info_dir, os.path.basename(dest) + ".trashinfo"),
|
|
278
|
+
"w", encoding="utf-8") as fh:
|
|
279
|
+
fh.write("[Trash Info]\n")
|
|
280
|
+
fh.write(f"Path={quote(os.path.abspath(p))}\n")
|
|
281
|
+
fh.write("DeletionDate="
|
|
282
|
+
+ datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + "\n")
|
|
283
|
+
except OSError:
|
|
284
|
+
pass # 文件已移走,元数据写失败不影响回收
|
|
285
|
+
moved.append(p)
|
|
286
|
+
return actions.Result(ok=not failed, file=",".join(moved),
|
|
287
|
+
error="; ".join(failed),
|
|
288
|
+
command=["gio", "trash", *paths])
|