myspace-cli 1.5.0__py3-none-any.whl → 1.6.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.
- {myspace_cli-1.5.0.dist-info → myspace_cli-1.6.1.dist-info}/METADATA +4 -1
- myspace_cli-1.6.1.dist-info/RECORD +9 -0
- myspace_cli-1.6.1.dist-info/entry_points.txt +2 -0
- space_cli/__init__.py +10 -0
- space_cli/index_store.py +93 -0
- space_cli/space_analyzer.py +419 -0
- space_cli/spacecli_class.py +811 -0
- myspace_cli-1.5.0.dist-info/RECORD +0 -6
- myspace_cli-1.5.0.dist-info/entry_points.txt +0 -2
- space_cli.py +0 -293
- {myspace_cli-1.5.0.dist-info → myspace_cli-1.6.1.dist-info}/WHEEL +0 -0
- {myspace_cli-1.5.0.dist-info → myspace_cli-1.6.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,811 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
"""
|
3
|
+
SpaceCli - Mac OS 磁盘空间分析工具
|
4
|
+
用于检测磁盘空间健康度并列出占用空间最大的目录
|
5
|
+
"""
|
6
|
+
|
7
|
+
import os
|
8
|
+
import sys
|
9
|
+
import argparse
|
10
|
+
import subprocess
|
11
|
+
import shutil
|
12
|
+
from pathlib import Path
|
13
|
+
from typing import List, Tuple, Dict
|
14
|
+
import json
|
15
|
+
import time
|
16
|
+
from datetime import datetime, timedelta
|
17
|
+
import heapq
|
18
|
+
|
19
|
+
from .space_analyzer import SpaceAnalyzer
|
20
|
+
from .index_store import IndexStore
|
21
|
+
|
22
|
+
|
23
|
+
|
24
|
+
class SpaceCli:
|
25
|
+
"""SpaceCli 主类"""
|
26
|
+
|
27
|
+
def __init__(self):
|
28
|
+
self.analyzer = SpaceAnalyzer()
|
29
|
+
self.index = IndexStore()
|
30
|
+
# 应用分析缓存存放于 ~/.cache/spacecli/apps.json
|
31
|
+
home = str(Path.home())
|
32
|
+
app_cache_dir = os.path.join(home, ".cache", "spacecli")
|
33
|
+
os.makedirs(app_cache_dir, exist_ok=True)
|
34
|
+
self.app_index = IndexStore(index_file=os.path.join(app_cache_dir, "apps.json"))
|
35
|
+
self.ignore_dir_list = [
|
36
|
+
"/System", # 系统目录
|
37
|
+
"/Volumes", # 外部挂载卷
|
38
|
+
"/private", # 私有目录
|
39
|
+
".Trash", # 垃圾桶
|
40
|
+
".localized", # 本地化目录
|
41
|
+
]
|
42
|
+
|
43
|
+
# —— 应用删除相关 ——
|
44
|
+
def _candidate_app_paths(self, app_name: str) -> List[str]:
|
45
|
+
"""根据应用名推导可能占用空间的相关目录/文件路径列表。"""
|
46
|
+
home = str(Path.home())
|
47
|
+
candidates: List[str] = []
|
48
|
+
possible_bases = [
|
49
|
+
("/Applications", f"{app_name}.app"),
|
50
|
+
(os.path.join(home, "Applications"), f"{app_name}.app"),
|
51
|
+
("/Library/Application Support", app_name),
|
52
|
+
(os.path.join(home, "Library", "Application Support"), app_name),
|
53
|
+
("/Library/Caches", app_name),
|
54
|
+
(os.path.join(home, "Library", "Caches"), app_name),
|
55
|
+
("/Library/Logs", app_name),
|
56
|
+
(os.path.join(home, "Library", "Logs"), app_name),
|
57
|
+
(os.path.join(home, "Library", "Containers"), app_name),
|
58
|
+
]
|
59
|
+
# 直接拼接命中
|
60
|
+
for base, tail in possible_bases:
|
61
|
+
path = os.path.join(base, tail)
|
62
|
+
if os.path.exists(path):
|
63
|
+
candidates.append(path)
|
64
|
+
# 模糊扫描:包含应用名的目录
|
65
|
+
scan_dirs = [
|
66
|
+
"/Applications",
|
67
|
+
os.path.join(home, "Applications"),
|
68
|
+
"/Library/Application Support",
|
69
|
+
os.path.join(home, "Library", "Application Support"),
|
70
|
+
"/Library/Caches",
|
71
|
+
os.path.join(home, "Library", "Caches"),
|
72
|
+
"/Library/Logs",
|
73
|
+
os.path.join(home, "Library", "Logs"),
|
74
|
+
os.path.join(home, "Library", "Containers"),
|
75
|
+
]
|
76
|
+
app_lower = app_name.lower()
|
77
|
+
for base in scan_dirs:
|
78
|
+
if not os.path.exists(base):
|
79
|
+
continue
|
80
|
+
try:
|
81
|
+
for item in os.listdir(base):
|
82
|
+
item_path = os.path.join(base, item)
|
83
|
+
# 只收集目录或 .app 包
|
84
|
+
if not os.path.isdir(item_path):
|
85
|
+
continue
|
86
|
+
name_lower = item.lower()
|
87
|
+
if app_lower in name_lower:
|
88
|
+
candidates.append(item_path)
|
89
|
+
except (PermissionError, OSError):
|
90
|
+
continue
|
91
|
+
# 去重并按路径长度降序(先删更深层,避免空目录残留)
|
92
|
+
uniq: List[str] = []
|
93
|
+
seen = set()
|
94
|
+
for p in sorted(set(candidates), key=lambda x: len(x), reverse=True):
|
95
|
+
if p not in seen:
|
96
|
+
uniq.append(p)
|
97
|
+
seen.add(p)
|
98
|
+
return uniq
|
99
|
+
|
100
|
+
def _delete_paths_and_sum(self, paths: List[str]) -> Tuple[int, List[Tuple[str, str]]]:
|
101
|
+
"""删除给定路径列表,返回释放的总字节数与失败列表(路径, 原因)。"""
|
102
|
+
total_freed = 0
|
103
|
+
failures: List[Tuple[str, str]] = []
|
104
|
+
|
105
|
+
def _try_fix_permissions(path: str) -> None:
|
106
|
+
"""尝试修复权限与不可变标记以便删除。"""
|
107
|
+
try:
|
108
|
+
# 去除不可变标记(普通用户能去除的场景)
|
109
|
+
subprocess.run(["chflags", "-R", "nouchg", path], capture_output=True)
|
110
|
+
except Exception:
|
111
|
+
pass
|
112
|
+
try:
|
113
|
+
os.chmod(path, 0o777)
|
114
|
+
except Exception:
|
115
|
+
pass
|
116
|
+
|
117
|
+
def _onerror(func, path, exc_info):
|
118
|
+
# 当 rmtree 无法删除时,尝试修复权限并重试一次
|
119
|
+
_try_fix_permissions(path)
|
120
|
+
try:
|
121
|
+
func(path)
|
122
|
+
except Exception:
|
123
|
+
# 让上层捕获
|
124
|
+
raise
|
125
|
+
for p in paths:
|
126
|
+
try:
|
127
|
+
size_before = 0
|
128
|
+
try:
|
129
|
+
if os.path.isdir(p):
|
130
|
+
size_before = self.analyzer.get_directory_size(p)
|
131
|
+
elif os.path.isfile(p):
|
132
|
+
size_before = os.path.getsize(p)
|
133
|
+
except Exception:
|
134
|
+
size_before = 0
|
135
|
+
if os.path.isdir(p) and not os.path.islink(p):
|
136
|
+
try:
|
137
|
+
shutil.rmtree(p, ignore_errors=False, onerror=_onerror)
|
138
|
+
except Exception:
|
139
|
+
# 目录删除失败,降级为逐项尝试删除(尽量清理可删部分)
|
140
|
+
for dirpath, dirnames, filenames in os.walk(p, topdown=False):
|
141
|
+
for name in filenames:
|
142
|
+
fpath = os.path.join(dirpath, name)
|
143
|
+
try:
|
144
|
+
_try_fix_permissions(fpath)
|
145
|
+
os.remove(fpath)
|
146
|
+
except Exception:
|
147
|
+
continue
|
148
|
+
for name in dirnames:
|
149
|
+
dpath = os.path.join(dirpath, name)
|
150
|
+
try:
|
151
|
+
_try_fix_permissions(dpath)
|
152
|
+
os.rmdir(dpath)
|
153
|
+
except Exception:
|
154
|
+
continue
|
155
|
+
# 最后尝试删除顶层目录
|
156
|
+
_try_fix_permissions(p)
|
157
|
+
os.rmdir(p)
|
158
|
+
else:
|
159
|
+
os.remove(p)
|
160
|
+
total_freed += size_before
|
161
|
+
except Exception as e:
|
162
|
+
failures.append((p, str(e)))
|
163
|
+
return total_freed, failures
|
164
|
+
|
165
|
+
def _offer_app_delete(self, apps: List[Tuple[str, int]]) -> None:
|
166
|
+
"""在已打印的应用列表后,提供按序号一键删除功能。"""
|
167
|
+
if not sys.stdin.isatty() or getattr(self.args, 'no_prompt', False):
|
168
|
+
return
|
169
|
+
try:
|
170
|
+
ans = input("是否要一键删除某个应用?输入序号或回车跳过: ").strip()
|
171
|
+
except EOFError:
|
172
|
+
ans = ""
|
173
|
+
if not ans:
|
174
|
+
return
|
175
|
+
try:
|
176
|
+
idx = int(ans)
|
177
|
+
except ValueError:
|
178
|
+
print("❌ 无效的输入(应为数字序号)")
|
179
|
+
return
|
180
|
+
if idx < 1 or idx > len(apps):
|
181
|
+
print("❌ 序号超出范围")
|
182
|
+
return
|
183
|
+
app_name, app_size = apps[idx - 1]
|
184
|
+
size_str = self.analyzer.format_bytes(app_size)
|
185
|
+
try:
|
186
|
+
confirm = input(f"确认删除应用及相关缓存: {app_name} (约 {size_str})?[y/N]: ").strip().lower()
|
187
|
+
except EOFError:
|
188
|
+
confirm = ""
|
189
|
+
if confirm not in ("y", "yes"):
|
190
|
+
print("已取消删除")
|
191
|
+
return
|
192
|
+
related_paths = self._candidate_app_paths(app_name)
|
193
|
+
if not related_paths:
|
194
|
+
print("未找到可删除的相关目录/文件")
|
195
|
+
return
|
196
|
+
print("将尝试删除以下路径:")
|
197
|
+
for p in related_paths:
|
198
|
+
print(f" - {p}")
|
199
|
+
try:
|
200
|
+
confirm2 = input("再次确认删除以上路径?[y/N]: ").strip().lower()
|
201
|
+
except EOFError:
|
202
|
+
confirm2 = ""
|
203
|
+
if confirm2 not in ("y", "yes"):
|
204
|
+
print("已取消删除")
|
205
|
+
return
|
206
|
+
freed, failures = self._delete_paths_and_sum(related_paths)
|
207
|
+
print(f"✅ 删除完成,预计释放空间: {self.analyzer.format_bytes(freed)}")
|
208
|
+
if failures:
|
209
|
+
print("以下路径删除失败,可能需要手动或管理员权限:")
|
210
|
+
for p, reason in failures:
|
211
|
+
print(f" - {p} -> {reason}")
|
212
|
+
# 常见提示:Operation not permitted(SIP/容器元数据等)
|
213
|
+
if any("Operation not permitted" in r for _, r in failures):
|
214
|
+
print("提示:部分系统受保护或容器元数据文件无法删除。可尝试:")
|
215
|
+
print(" - 先退出相关应用(如 Docker)再重试")
|
216
|
+
print(" - 给予当前终端“完全磁盘访问权限”(系统设置 → 隐私与安全性)")
|
217
|
+
print(" - 仅删除用户目录下缓存,保留系统级容器元数据")
|
218
|
+
|
219
|
+
# 通用渲染:目录与应用(减少重复)
|
220
|
+
def _render_dirs(self, entries: List[Tuple[str, int]], total_bytes: int) -> None:
|
221
|
+
for i, (dir_path, size) in enumerate(entries, 1):
|
222
|
+
size_str = self.analyzer.format_bytes(size)
|
223
|
+
percentage = (size / total_bytes) * 100 if total_bytes else 0
|
224
|
+
# 1G 以上红色,否则绿色
|
225
|
+
color = "\033[31m" if size >= 1024**3 else "\033[32m"
|
226
|
+
print(f"{i:2d}. \033[36m{dir_path}\033[0m -- 大小: {color}{size_str}\033[0m (\033[33m{percentage:.2f}%\033[0m)")
|
227
|
+
|
228
|
+
def _render_apps(self, entries: List[Tuple[str, int]], disk_total: int) -> None:
|
229
|
+
for i, (app, size) in enumerate(entries, 1):
|
230
|
+
size_str = self.analyzer.format_bytes(size)
|
231
|
+
pct = (size / disk_total) * 100 if disk_total else 0
|
232
|
+
suggestion = "建议卸载或清理缓存" if size >= 5 * 1024**3 else "可保留,定期清理缓存"
|
233
|
+
# 3G 以上红色,否则绿色
|
234
|
+
color = "\033[31m" if size >= 3 * 1024**3 else "\033[32m"
|
235
|
+
print(f"{i:2d}. \033[36m{app}\033[0m -- 占用: {color}{size_str}\033[0m ({pct:.2f}%) — {suggestion}")
|
236
|
+
|
237
|
+
def analyze_app_directories(self, top_n: int = 20,
|
238
|
+
index: IndexStore = None,
|
239
|
+
use_index: bool = True,
|
240
|
+
reindex: bool = False,
|
241
|
+
index_ttl_hours: int = 24,
|
242
|
+
prompt: bool = True) -> List[Tuple[str, int]]:
|
243
|
+
"""分析应用安装与数据目录占用,按应用归并估算大小(支持缓存)"""
|
244
|
+
|
245
|
+
# 命中命名缓存
|
246
|
+
cache_name = "apps_aggregate"
|
247
|
+
if use_index and index and not reindex and index.is_fresh_named(cache_name, index_ttl_hours):
|
248
|
+
cached = index.get_named(cache_name)
|
249
|
+
if cached and cached.get("entries"):
|
250
|
+
if prompt and sys.stdin.isatty():
|
251
|
+
try:
|
252
|
+
ans = input("检测到最近应用分析索引,是否使用缓存结果?[Y/n]: ").strip().lower()
|
253
|
+
if ans in ("", "y", "yes"):
|
254
|
+
return [(e["name"], int(e["size"])) for e in cached["entries"]][:top_n]
|
255
|
+
except EOFError:
|
256
|
+
pass
|
257
|
+
else:
|
258
|
+
return [(e["name"], int(e["size"])) for e in cached["entries"]][:top_n]
|
259
|
+
# 关注目录
|
260
|
+
home = str(Path.home())
|
261
|
+
target_dirs = [
|
262
|
+
"/Applications",
|
263
|
+
os.path.join(home, "Applications"),
|
264
|
+
"/Library/Application Support",
|
265
|
+
"/Library/Caches",
|
266
|
+
"/Library/Logs",
|
267
|
+
os.path.join(home, "Library", "Application Support"),
|
268
|
+
os.path.join(home, "Library", "Caches"),
|
269
|
+
os.path.join(home, "Library", "Logs"),
|
270
|
+
os.path.join(home, "Library", "Containers"),
|
271
|
+
]
|
272
|
+
|
273
|
+
def app_key_from_path(p: str) -> str:
|
274
|
+
# 优先用.app 名称,其次用顶级目录名
|
275
|
+
parts = Path(p).parts
|
276
|
+
for i in range(len(parts)-1, -1, -1):
|
277
|
+
if parts[i].endswith('.app'):
|
278
|
+
return parts[i].replace('.app', '')
|
279
|
+
# 否则返回倒数第二级或最后一级作为应用键
|
280
|
+
return parts[-1] if parts else p
|
281
|
+
|
282
|
+
app_size_map: Dict[str, int] = {}
|
283
|
+
scanned_dirs: List[str] = []
|
284
|
+
|
285
|
+
for base in target_dirs:
|
286
|
+
if not os.path.exists(base):
|
287
|
+
continue
|
288
|
+
try:
|
289
|
+
for item in os.listdir(base):
|
290
|
+
item_path = os.path.join(base, item)
|
291
|
+
if not os.path.isdir(item_path):
|
292
|
+
continue
|
293
|
+
key = app_key_from_path(item_path)
|
294
|
+
# 进度提示:当前应用相关目录(单行覆盖)
|
295
|
+
item_path = item_path[:100]
|
296
|
+
sys.stdout.write(f"\r\033[K-> 正在读取: \033[36m{item_path}\033[0m")
|
297
|
+
sys.stdout.flush()
|
298
|
+
size = self.analyzer.get_directory_size(item_path)
|
299
|
+
scanned_dirs.append(item_path)
|
300
|
+
app_size_map[key] = app_size_map.get(key, 0) + size
|
301
|
+
except (PermissionError, OSError):
|
302
|
+
continue
|
303
|
+
# 结束时换行
|
304
|
+
sys.stdout.write("\n")
|
305
|
+
sys.stdout.flush()
|
306
|
+
|
307
|
+
# 转为排序列表
|
308
|
+
result = sorted(app_size_map.items(), key=lambda x: x[1], reverse=True)
|
309
|
+
# 写入命名缓存
|
310
|
+
if index:
|
311
|
+
try:
|
312
|
+
index.set_named(cache_name, result)
|
313
|
+
except Exception:
|
314
|
+
pass
|
315
|
+
return result[:top_n]
|
316
|
+
|
317
|
+
def print_disk_health(self, path: str = "/"):
|
318
|
+
"""打印磁盘健康状态"""
|
319
|
+
print("=" * 60)
|
320
|
+
print("🔍 磁盘空间健康度分析")
|
321
|
+
print("=" * 60)
|
322
|
+
|
323
|
+
usage_info = self.analyzer.get_disk_usage(path)
|
324
|
+
if not usage_info:
|
325
|
+
print("❌ 无法获取磁盘使用情况")
|
326
|
+
return
|
327
|
+
|
328
|
+
status, message = self.analyzer.get_disk_health_status(usage_info)
|
329
|
+
|
330
|
+
# 状态图标
|
331
|
+
status_icon = {
|
332
|
+
"良好": "✅",
|
333
|
+
"警告": "⚠️",
|
334
|
+
"严重": "🚨"
|
335
|
+
}.get(status, "❓")
|
336
|
+
|
337
|
+
print(f"磁盘路径: \033[36m{usage_info['path']}\033[0m")
|
338
|
+
print(f"总容量: \033[36m{self.analyzer.format_bytes(usage_info['total'])}\033[0m")
|
339
|
+
print(f"已使用: \033[36m{self.analyzer.format_bytes(usage_info['used'])}\033[0m")
|
340
|
+
print(f"可用空间: \033[36m{self.analyzer.format_bytes(usage_info['free'])}\033[0m")
|
341
|
+
print(f"使用率: \033[36m{usage_info['usage_percent']:.1f}%\033[0m")
|
342
|
+
print(f"健康状态: {status_icon} \033[36m{status}\033[0m")
|
343
|
+
print(f"建议: \033[36m{message}\033[0m")
|
344
|
+
print()
|
345
|
+
|
346
|
+
def print_largest_directories(self, path: str = "/Library", top_n: int = 20):
|
347
|
+
"""打印占用空间最大的目录"""
|
348
|
+
print("=" * 60)
|
349
|
+
print("📊 占用空间最大的目录")
|
350
|
+
print("=" * 60)
|
351
|
+
|
352
|
+
# 若有缓存:直接显示缓存,然后再询问是否重新分析
|
353
|
+
if self.args.use_index:
|
354
|
+
cached = self.index.get(path)
|
355
|
+
if cached and cached.get("entries"):
|
356
|
+
cached_entries = [(e["path"], int(e["size"])) for e in cached["entries"]][:top_n]
|
357
|
+
total_info = self.analyzer.get_disk_usage(path)
|
358
|
+
total_bytes = total_info['total'] if total_info else 1
|
359
|
+
print(f"(来自索引) 显示前 {min(len(cached_entries), top_n)} 个最大的目录:\n")
|
360
|
+
self._render_dirs(cached_entries, total_bytes)
|
361
|
+
if sys.stdin.isatty() and not self.args.no_prompt:
|
362
|
+
try:
|
363
|
+
ans = input("是否重新分析以刷新索引?[y/N]: ").strip().lower()
|
364
|
+
except EOFError:
|
365
|
+
ans = ""
|
366
|
+
if ans not in ("y", "yes"):
|
367
|
+
# 提供下探分析选项
|
368
|
+
self._offer_drill_down_analysis(cached_entries, path)
|
369
|
+
return
|
370
|
+
else:
|
371
|
+
return
|
372
|
+
|
373
|
+
directories = self.analyzer.analyze_largest_directories(
|
374
|
+
path,
|
375
|
+
top_n=top_n,
|
376
|
+
index=self.index,
|
377
|
+
use_index=self.args.use_index,
|
378
|
+
reindex=True, # 走到这里表示要刷新
|
379
|
+
index_ttl_hours=self.args.index_ttl,
|
380
|
+
prompt=False,
|
381
|
+
)
|
382
|
+
if not directories:
|
383
|
+
print("❌ 无法分析目录大小")
|
384
|
+
return
|
385
|
+
total_info = self.analyzer.get_disk_usage(path)
|
386
|
+
total_bytes = total_info['total'] if total_info else 1
|
387
|
+
print("\n已重新分析,最新结果:\n")
|
388
|
+
self._render_dirs(directories, total_bytes)
|
389
|
+
|
390
|
+
# 提供下探分析选项
|
391
|
+
self._offer_drill_down_analysis(directories, path)
|
392
|
+
|
393
|
+
def _offer_drill_down_analysis(self, directories: List[Tuple[str, int]], current_path: str) -> None:
|
394
|
+
"""提供交互式下探分析选项"""
|
395
|
+
if not sys.stdin.isatty() or getattr(self.args, 'no_prompt', False):
|
396
|
+
return
|
397
|
+
|
398
|
+
print("\n" + "=" * 60)
|
399
|
+
print("🔍 下探分析选项")
|
400
|
+
print("=" * 60)
|
401
|
+
print("选择序号进行深度分析,选择0返回上一级,直接回车退出:")
|
402
|
+
|
403
|
+
try:
|
404
|
+
choice = input("请输入选择 [回车=退出]: ").strip()
|
405
|
+
except EOFError:
|
406
|
+
return
|
407
|
+
|
408
|
+
if not choice:
|
409
|
+
return
|
410
|
+
|
411
|
+
try:
|
412
|
+
idx = int(choice)
|
413
|
+
except ValueError:
|
414
|
+
print("❌ 无效的输入(应为数字序号)")
|
415
|
+
return
|
416
|
+
|
417
|
+
if idx == 0:
|
418
|
+
# 返回上一级
|
419
|
+
parent_path = os.path.dirname(current_path.rstrip('/'))
|
420
|
+
if parent_path != current_path and parent_path != '/':
|
421
|
+
print(f"\n🔄 返回上一级: {parent_path}")
|
422
|
+
self.print_largest_directories(parent_path, self.args.top_n)
|
423
|
+
else:
|
424
|
+
print("❌ 已在根目录,无法返回上一级")
|
425
|
+
return
|
426
|
+
|
427
|
+
if idx < 1 or idx > len(directories):
|
428
|
+
print("❌ 序号超出范围")
|
429
|
+
return
|
430
|
+
|
431
|
+
selected_path, selected_size = directories[idx - 1]
|
432
|
+
size_str = self.analyzer.format_bytes(selected_size)
|
433
|
+
|
434
|
+
print(f"\n🔍 正在分析: {selected_path} ({size_str})")
|
435
|
+
print("=" * 60)
|
436
|
+
|
437
|
+
# 递归调用下探分析
|
438
|
+
self.print_largest_directories(selected_path, self.args.top_n)
|
439
|
+
|
440
|
+
def print_app_analysis(self, top_n: int = 20):
|
441
|
+
"""打印应用目录占用分析,并给出卸载建议"""
|
442
|
+
print("=" * 60)
|
443
|
+
print("🧩 应用目录空间分析与卸载建议")
|
444
|
+
print("=" * 60)
|
445
|
+
|
446
|
+
# 先显示缓存,再决定是否刷新
|
447
|
+
if self.args.use_index:
|
448
|
+
cached = self.app_index.get_named("apps_aggregate")
|
449
|
+
if cached and cached.get("entries"):
|
450
|
+
cached_entries = [(e["name"], int(e["size"])) for e in cached["entries"]][:top_n]
|
451
|
+
total = self.analyzer.get_disk_usage("/")
|
452
|
+
disk_total = total['total'] if total else 1
|
453
|
+
print(f"(来自索引) 显示前 {min(len(cached_entries), top_n)} 个空间占用最高的应用:\n")
|
454
|
+
self._render_apps(cached_entries, disk_total)
|
455
|
+
# 提供一键删除
|
456
|
+
self._offer_app_delete(cached_entries)
|
457
|
+
if sys.stdin.isatty() and not self.args.no_prompt:
|
458
|
+
try:
|
459
|
+
ans = input("是否重新分析应用以刷新索引?[y/N]: ").strip().lower()
|
460
|
+
except EOFError:
|
461
|
+
ans = ""
|
462
|
+
if ans not in ("y", "yes"):
|
463
|
+
return
|
464
|
+
else:
|
465
|
+
return
|
466
|
+
|
467
|
+
apps = self.analyze_app_directories(
|
468
|
+
top_n=top_n,
|
469
|
+
index=self.app_index,
|
470
|
+
use_index=self.args.use_index,
|
471
|
+
reindex=True,
|
472
|
+
index_ttl_hours=self.args.index_ttl,
|
473
|
+
prompt=False,
|
474
|
+
)
|
475
|
+
if not apps:
|
476
|
+
print("❌ 未发现可分析的应用目录")
|
477
|
+
return
|
478
|
+
total = self.analyzer.get_disk_usage("/")
|
479
|
+
disk_total = total['total'] if total else 1
|
480
|
+
print("\n已重新分析,最新应用占用结果:\n")
|
481
|
+
self._render_apps(apps, disk_total)
|
482
|
+
# 提供一键删除
|
483
|
+
self._offer_app_delete(apps)
|
484
|
+
|
485
|
+
def print_home_deep_analysis(self, top_n: int = 20):
|
486
|
+
"""对用户目录的 Library / Downloads / Documents 分别下探分析"""
|
487
|
+
home = str(Path.home())
|
488
|
+
targets = [
|
489
|
+
("Library", os.path.join(home, "Library")),
|
490
|
+
("Downloads", os.path.join(home, "Downloads")),
|
491
|
+
("Documents", os.path.join(home, "Documents")),
|
492
|
+
]
|
493
|
+
|
494
|
+
for label, target in targets:
|
495
|
+
if not os.path.exists(target):
|
496
|
+
continue
|
497
|
+
print("=" * 60)
|
498
|
+
print(f"🏠 用户目录下探 - {label}")
|
499
|
+
print("=" * 60)
|
500
|
+
directories = self.analyzer.analyze_largest_directories(
|
501
|
+
target,
|
502
|
+
top_n=top_n,
|
503
|
+
index=self.index,
|
504
|
+
use_index=self.args.use_index,
|
505
|
+
reindex=self.args.reindex,
|
506
|
+
index_ttl_hours=self.args.index_ttl,
|
507
|
+
prompt=not self.args.no_prompt,
|
508
|
+
)
|
509
|
+
if not directories:
|
510
|
+
print("❌ 无法分析目录大小")
|
511
|
+
continue
|
512
|
+
total_info = self.analyzer.get_disk_usage("/")
|
513
|
+
total_bytes = total_info['total'] if total_info else 1
|
514
|
+
print(f"显示前 {min(len(directories), top_n)} 个最大的目录:\n")
|
515
|
+
for i, (dir_path, size) in enumerate(directories, 1):
|
516
|
+
size_str = self.analyzer.format_bytes(size)
|
517
|
+
percentage = (size / total_bytes) * 100
|
518
|
+
color = "\033[31m" if size >= 1024**3 else "\033[32m"
|
519
|
+
print(f"{i:2d}. \033[36m{dir_path}\033[0m -- 大小: {color}{size_str}\033[0m (\033[33m{percentage:.2f}%\033[0m)")
|
520
|
+
#print()
|
521
|
+
|
522
|
+
def print_big_files(self, path: str, top_n: int = 50, min_size_bytes: int = 0):
|
523
|
+
"""打印大文件列表"""
|
524
|
+
print("=" * 60)
|
525
|
+
print("🗄️ 大文件分析")
|
526
|
+
print("=" * 60)
|
527
|
+
files = self.analyzer.analyze_largest_files(path, top_n=top_n, min_size_bytes=min_size_bytes)
|
528
|
+
if not files:
|
529
|
+
print("❌ 未找到符合条件的大文件")
|
530
|
+
return
|
531
|
+
total = self.analyzer.get_disk_usage("/")
|
532
|
+
disk_total = total['total'] if total else 1
|
533
|
+
for i, (file_path, size) in enumerate(files, 1):
|
534
|
+
|
535
|
+
size_str = self.analyzer.format_bytes(size)
|
536
|
+
pct = (size / disk_total) * 100
|
537
|
+
color = "\033[31m" if size >= 5 * 1024**3 else ("\033[33m" if size >= 1024**3 else "\033[32m")
|
538
|
+
print(f"{i:2d}. \033[36m{file_path}\033[0m -- 大小: {color}{size_str}\033[0m (\033[33m{pct:.2f}%\033[0m)")
|
539
|
+
print()
|
540
|
+
|
541
|
+
def print_system_info(self):
|
542
|
+
"""打印系统信息"""
|
543
|
+
print("=" * 60)
|
544
|
+
print("💻 系统信息")
|
545
|
+
print("=" * 60)
|
546
|
+
|
547
|
+
system_info = self.analyzer.get_system_info()
|
548
|
+
|
549
|
+
for key, value in system_info.items():
|
550
|
+
print(f"{key}: \033[36m{value}\033[0m")
|
551
|
+
print()
|
552
|
+
|
553
|
+
def print_memory_cleanup(self):
|
554
|
+
"""打印内存释放优化结果"""
|
555
|
+
print("=" * 60)
|
556
|
+
print("🧹 内存释放优化")
|
557
|
+
print("=" * 60)
|
558
|
+
|
559
|
+
cleanup_results = self.analyzer.memory_cleanup()
|
560
|
+
|
561
|
+
print(f"✅ 垃圾回收释放对象: {cleanup_results['purged_memory']} 个")
|
562
|
+
print(f"✅ 交换空间释放: {'成功' if cleanup_results['freed_swap'] else '跳过'}")
|
563
|
+
|
564
|
+
if cleanup_results['cleared_caches']:
|
565
|
+
print("\n📋 已清理的缓存:")
|
566
|
+
for cache in cleanup_results['cleared_caches']:
|
567
|
+
print(f" - {cache}")
|
568
|
+
|
569
|
+
if cleanup_results['errors']:
|
570
|
+
print("\n⚠️ 清理过程中的警告:")
|
571
|
+
for error in cleanup_results['errors']:
|
572
|
+
print(f" - {error}")
|
573
|
+
|
574
|
+
print("\n💡 提示:")
|
575
|
+
print(" - 某些操作需要管理员权限,如遇权限错误属正常现象")
|
576
|
+
print(" - 建议定期执行内存清理以保持系统性能")
|
577
|
+
print(" - 重启系统可获得最佳内存释放效果")
|
578
|
+
print()
|
579
|
+
|
580
|
+
def export_report(self, output_file: str, path: str = "/"):
|
581
|
+
"""导出分析报告到JSON文件"""
|
582
|
+
print(f"正在生成报告并保存到: {output_file}")
|
583
|
+
|
584
|
+
usage_info = self.analyzer.get_disk_usage(path)
|
585
|
+
status, message = self.analyzer.get_disk_health_status(usage_info)
|
586
|
+
directories = self.analyzer.analyze_largest_directories(path)
|
587
|
+
system_info = self.analyzer.get_system_info()
|
588
|
+
# 可选:大文件分析
|
589
|
+
largest_files = []
|
590
|
+
try:
|
591
|
+
if getattr(self, 'args', None) and getattr(self.args, 'big_files', False):
|
592
|
+
files = self.analyzer.analyze_largest_files(
|
593
|
+
path,
|
594
|
+
top_n=getattr(self.args, 'big_files_top', 20),
|
595
|
+
min_size_bytes=getattr(self.args, 'big_files_min_bytes', 0),
|
596
|
+
)
|
597
|
+
largest_files = [
|
598
|
+
{
|
599
|
+
"path": file_path,
|
600
|
+
"size_bytes": size,
|
601
|
+
"size_formatted": self.analyzer.format_bytes(size),
|
602
|
+
}
|
603
|
+
for file_path, size in files
|
604
|
+
]
|
605
|
+
except Exception:
|
606
|
+
largest_files = []
|
607
|
+
|
608
|
+
report = {
|
609
|
+
"timestamp": subprocess.run(['date'], capture_output=True, text=True).stdout.strip(),
|
610
|
+
"system_info": system_info,
|
611
|
+
"disk_usage": usage_info,
|
612
|
+
"health_status": {
|
613
|
+
"status": status,
|
614
|
+
"message": message
|
615
|
+
},
|
616
|
+
"largest_directories": [
|
617
|
+
{
|
618
|
+
"path": dir_path,
|
619
|
+
"size_bytes": size,
|
620
|
+
"size_formatted": self.analyzer.format_bytes(size)
|
621
|
+
}
|
622
|
+
for dir_path, size in directories
|
623
|
+
],
|
624
|
+
"largest_files": largest_files
|
625
|
+
}
|
626
|
+
|
627
|
+
try:
|
628
|
+
with open(output_file, 'w', encoding='utf-8') as f:
|
629
|
+
json.dump(report, f, ensure_ascii=False, indent=2)
|
630
|
+
print(f"✅ 报告已保存到: {output_file}")
|
631
|
+
except Exception as e:
|
632
|
+
print(f"❌ 保存报告失败: {e}")
|
633
|
+
|
634
|
+
|
635
|
+
def main():
|
636
|
+
"""包内 CLI 入口:参数解析 + 交互菜单,执行完返回菜单"""
|
637
|
+
import argparse
|
638
|
+
from pathlib import Path
|
639
|
+
import os
|
640
|
+
import sys
|
641
|
+
|
642
|
+
parser = argparse.ArgumentParser(
|
643
|
+
description="SpaceCli - Mac OS 磁盘空间分析工具",
|
644
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
645
|
+
epilog="""
|
646
|
+
示例用法:
|
647
|
+
space-cli # 菜单 + 默认执行
|
648
|
+
space-cli -p /Users # 分析用户目录
|
649
|
+
space-cli -n 10 # 显示前10个最大目录
|
650
|
+
space-cli --export report.json # 导出报告
|
651
|
+
space-cli --health-only # 只显示健康状态
|
652
|
+
"""
|
653
|
+
)
|
654
|
+
|
655
|
+
parser.add_argument('-p', '--path', default='/', help='要分析的路径 (默认: /)')
|
656
|
+
parser.add_argument('--home', action='store_true', help='将分析路径设置为当前用户目录($HOME)')
|
657
|
+
parser.add_argument('-n', '--top-n', type=int, default=20, help='显示前N个最大的目录 (默认: 20)')
|
658
|
+
parser.add_argument('--health-only', action='store_true', help='只显示磁盘健康状态')
|
659
|
+
parser.add_argument('--directories-only', action='store_true', help='只显示目录分析')
|
660
|
+
|
661
|
+
# 索引相关
|
662
|
+
parser.add_argument('--use-index', dest='use_index', action='store_true', help='使用已存在的索引缓存(若存在)')
|
663
|
+
parser.add_argument('--no-index', dest='use_index', action='store_false', help='不使用索引缓存')
|
664
|
+
parser.set_defaults(use_index=True)
|
665
|
+
parser.add_argument('--reindex', action='store_true', help='强制重建索引')
|
666
|
+
parser.add_argument('--index-ttl', type=int, default=24, help='索引缓存有效期(小时),默认24小时')
|
667
|
+
parser.add_argument('--no-prompt', action='store_true', help='非交互模式:不提示是否使用缓存')
|
668
|
+
|
669
|
+
# 应用分析
|
670
|
+
parser.add_argument('--apps', action='store_true', help='显示应用目录空间分析与卸载建议')
|
671
|
+
|
672
|
+
# 大文件分析
|
673
|
+
parser.add_argument('--big-files', action='store_true', help='显示大文件分析结果')
|
674
|
+
parser.add_argument('--big-files-top', type=int, default=20, help='大文件分析显示前N个(默认20)')
|
675
|
+
parser.add_argument('--big-files-min', type=str, default='0', help='只显示大于该阈值的文件,支持K/M/G/T,如 500M、2G,默认0')
|
676
|
+
|
677
|
+
parser.add_argument('--export', metavar='FILE', help='导出分析报告到JSON文件')
|
678
|
+
parser.add_argument('--version', action='version', version='SpaceCli 1.6.0')
|
679
|
+
|
680
|
+
args = parser.parse_args()
|
681
|
+
|
682
|
+
# 解析 --big-files-min 阈值字符串到字节
|
683
|
+
def parse_size(s: str) -> int:
|
684
|
+
s = (s or '0').strip().upper()
|
685
|
+
if s.endswith('K'):
|
686
|
+
return int(float(s[:-1]) * 1024)
|
687
|
+
if s.endswith('M'):
|
688
|
+
return int(float(s[:-1]) * 1024**2)
|
689
|
+
if s.endswith('G'):
|
690
|
+
return int(float(s[:-1]) * 1024**3)
|
691
|
+
if s.endswith('T'):
|
692
|
+
return int(float(s[:-1]) * 1024**4)
|
693
|
+
try:
|
694
|
+
return int(float(s))
|
695
|
+
except ValueError:
|
696
|
+
return 0
|
697
|
+
args.big_files_min_bytes = parse_size(getattr(args, 'big_files_min', '0'))
|
698
|
+
|
699
|
+
# 单次执行
|
700
|
+
def run_once(run_args, interactive: bool = False):
|
701
|
+
if getattr(run_args, 'home', False):
|
702
|
+
run_args.path = str(Path.home())
|
703
|
+
if not os.path.exists(run_args.path):
|
704
|
+
print(f"❌ 错误: 路径 '{run_args.path}' 不存在")
|
705
|
+
if interactive:
|
706
|
+
return
|
707
|
+
sys.exit(1)
|
708
|
+
space_cli = SpaceCli()
|
709
|
+
space_cli.args = run_args
|
710
|
+
try:
|
711
|
+
space_cli.print_system_info()
|
712
|
+
if run_args.health_only:
|
713
|
+
space_cli.print_disk_health(run_args.path)
|
714
|
+
if run_args.directories_only or run_args.path != '/':
|
715
|
+
space_cli.print_largest_directories(run_args.path, run_args.top_n)
|
716
|
+
if os.path.abspath(run_args.path) == os.path.abspath(str(Path.home())):
|
717
|
+
space_cli.print_home_deep_analysis(run_args.top_n)
|
718
|
+
if run_args.apps:
|
719
|
+
space_cli.print_app_analysis(run_args.top_n)
|
720
|
+
if run_args.big_files:
|
721
|
+
space_cli.print_big_files(run_args.path, top_n=run_args.big_files_top, min_size_bytes=run_args.big_files_min_bytes)
|
722
|
+
if getattr(run_args, 'memory_cleanup', False):
|
723
|
+
space_cli.print_memory_cleanup()
|
724
|
+
if run_args.export:
|
725
|
+
space_cli.export_report(run_args.export, run_args.path)
|
726
|
+
print("=" * 60)
|
727
|
+
print("✅ 分析完成!")
|
728
|
+
print("=" * 60)
|
729
|
+
except KeyboardInterrupt:
|
730
|
+
print("\n\n❌ 用户中断操作")
|
731
|
+
if interactive:
|
732
|
+
return
|
733
|
+
sys.exit(1)
|
734
|
+
except Exception as e:
|
735
|
+
print(f"\n❌ 发生错误: {e}")
|
736
|
+
if interactive:
|
737
|
+
return
|
738
|
+
sys.exit(1)
|
739
|
+
|
740
|
+
# 交互式菜单:当未传入任何参数时触发(默认执行全部),执行后返回菜单
|
741
|
+
if len(sys.argv) == 1:
|
742
|
+
while True:
|
743
|
+
print("=" * 60)
|
744
|
+
print("🧭 SpaceCli 菜单(直接回车 = 执行全部项目)")
|
745
|
+
print("=" * 60)
|
746
|
+
home_path = str(Path.home())
|
747
|
+
# 动态获取磁盘与内存占用率
|
748
|
+
try:
|
749
|
+
analyzer_for_menu = SpaceAnalyzer()
|
750
|
+
disk_info = analyzer_for_menu.get_disk_usage('/')
|
751
|
+
disk_usage_display = f"{disk_info['usage_percent']:.1f}%" if disk_info else "未知"
|
752
|
+
sysinfo = analyzer_for_menu.get_system_info()
|
753
|
+
mem_usage_display = sysinfo.get("内存使用率", "未知")
|
754
|
+
except Exception:
|
755
|
+
disk_usage_display = "未知"
|
756
|
+
mem_usage_display = "未知"
|
757
|
+
|
758
|
+
print("1) \033[36m执行主要项目(系统信息 + 健康 + 应用)\033[0m")
|
759
|
+
print(f"2) \033[36m当前用户目录分析(路径: {home_path})\033[0m")
|
760
|
+
print("3) \033[36m仅显示系统信息\033[0m")
|
761
|
+
print(f"4) \033[36m仅显示磁盘健康状态\033[0m — 当前磁盘占用: \033[33m{disk_usage_display}\033[0m")
|
762
|
+
print("5) \033[36m交互式目录空间分析\033[0m")
|
763
|
+
print("6) \033[36m仅分析程序应用目录空间\033[0m")
|
764
|
+
print("7) \033[36m仅进行大文件分析(比较耗时,可随时终止)\033[0m")
|
765
|
+
print(f"8) \033[36m内存释放优化\033[0m — 当前内存使用率: \033[33m{mem_usage_display}\033[0m")
|
766
|
+
print("0) \033[36m退出\033[0m")
|
767
|
+
try:
|
768
|
+
choice = input("请选择 [回车=1]: ").strip()
|
769
|
+
except EOFError:
|
770
|
+
choice = ""
|
771
|
+
|
772
|
+
# 重置
|
773
|
+
args.health_only = False
|
774
|
+
args.directories_only = False
|
775
|
+
args.apps = False
|
776
|
+
args.big_files = False
|
777
|
+
args.memory_cleanup = False
|
778
|
+
args.path = '/'
|
779
|
+
|
780
|
+
if choice == "0":
|
781
|
+
sys.exit(0)
|
782
|
+
elif choice == "2":
|
783
|
+
args.path = home_path
|
784
|
+
args.directories_only = True
|
785
|
+
elif choice == "3":
|
786
|
+
pass
|
787
|
+
elif choice == "4":
|
788
|
+
args.health_only = True
|
789
|
+
elif choice == "5":
|
790
|
+
args.directories_only = True
|
791
|
+
elif choice == "6":
|
792
|
+
args.apps = True
|
793
|
+
elif choice == "7":
|
794
|
+
args.big_files = True
|
795
|
+
elif choice == "8":
|
796
|
+
args.memory_cleanup = True
|
797
|
+
else:
|
798
|
+
args.health_only = True
|
799
|
+
args.apps = True
|
800
|
+
|
801
|
+
run_once(args, interactive=True)
|
802
|
+
|
803
|
+
try:
|
804
|
+
back = input("按回车返回菜单,输入 q 退出: ").strip().lower()
|
805
|
+
except EOFError:
|
806
|
+
back = ""
|
807
|
+
if back == 'q':
|
808
|
+
sys.exit(0)
|
809
|
+
else:
|
810
|
+
# 非交互:按参数执行一次
|
811
|
+
run_once(args, interactive=False)
|