myspace-cli 1.3.0__py3-none-any.whl → 1.5.0__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.3.0.dist-info → myspace_cli-1.5.0.dist-info}/METADATA +53 -27
- myspace_cli-1.5.0.dist-info/RECORD +6 -0
- space_cli.py +136 -910
- myspace_cli-1.3.0.dist-info/RECORD +0 -6
- {myspace_cli-1.3.0.dist-info → myspace_cli-1.5.0.dist-info}/WHEEL +0 -0
- {myspace_cli-1.3.0.dist-info → myspace_cli-1.5.0.dist-info}/entry_points.txt +0 -0
- {myspace_cli-1.3.0.dist-info → myspace_cli-1.5.0.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: myspace-cli
|
3
|
-
Version: 1.
|
3
|
+
Version: 1.5.0
|
4
4
|
Summary: A macOS disk space analysis CLI: health, index, app usage, big files.
|
5
5
|
Author-email: Your Name <you@example.com>
|
6
6
|
License: MIT
|
@@ -17,28 +17,28 @@ Description-Content-Type: text/markdown
|
|
17
17
|
Provides-Extra: mcp
|
18
18
|
Requires-Dist: mcp<2,>=1; extra == "mcp"
|
19
19
|
|
20
|
-
# space-cli -
|
20
|
+
# space-cli - macOS 磁盘空间优化工具
|
21
21
|
|
22
22
|
很多人的Mac电脑都会出现磁盘空间不够用,付费软件太贵或者难以使用。
|
23
23
|
|
24
|
-
space-cli是一个开源的
|
24
|
+
space-cli是一个开源的macOS命令行小工具,用于分析磁盘空间健康度并找出占用空间大的目录,可选择针对单个应用进行一键清理。
|
25
25
|
|
26
26
|
本软件采用**最严安全原则**,所有分析操作采用只读模式,未经允许不会尝试改写和破坏用户电脑的任何数据,也不会上传任何数据到外网,严格保护用户的隐私。
|
27
27
|
|
28
28
|
## 功能特性
|
29
29
|
|
30
30
|
- 🔍 **磁盘健康度检测** - 评估磁盘空间使用情况,提供健康状态建议
|
31
|
-
- 📊
|
32
|
-
- 💻
|
31
|
+
- 📊 **交互式目录分析** - 递归分析目录大小,支持选择序号进行深度下探分析
|
32
|
+
- 💻 **详细系统信息** - 显示CPU、内存、GPU、硬盘等完整硬件信息
|
33
33
|
- 📄 **报告导出** - 将分析结果导出为JSON格式报告
|
34
|
-
- ⚡
|
34
|
+
- ⚡ **高性能优化** - 优先使用 `du -sk` 命令,失败时回退到 `os.scandir` 高效遍历
|
35
35
|
- 🎯 **灵活配置** - 支持自定义分析路径和显示数量
|
36
|
-
- 🗂️
|
36
|
+
- 🗂️ **智能索引缓存** - 目录大小结果本地索引缓存(`~/.spacecli/index.json`),支持TTL与重建提示
|
37
37
|
- 🧩 **应用分析** - 汇总 `Applications`、`Library`、`Caches`、`Logs` 等路径估算应用占用,给出卸载建议
|
38
38
|
- 🗑️ **一键删除应用** - 在应用分析列表中输入序号即可一键删除所选应用及其缓存(含二次确认)
|
39
|
-
|
40
|
-
|
41
|
-
|
39
|
+
- 🏠 **用户目录深度分析** - 针对 `~/Library`、`~/Downloads`、`~/Documents` 分别下探并展示Top N目录
|
40
|
+
- 🗄️ **大文件分析** - 扫描并列出指定路径下最大的文件,支持数量和最小体积阈值
|
41
|
+
- ⏱️ **支持MCP调用** - 支持你自己的AI Agent无缝调用磁盘空间信息
|
42
42
|
|
43
43
|
## 安装
|
44
44
|
|
@@ -51,10 +51,16 @@ python3 -m pip install --upgrade myspace-cli
|
|
51
51
|
pip install myspace-cli
|
52
52
|
|
53
53
|
# 安装完成后直接使用
|
54
|
-
space-cli
|
54
|
+
# 请注意命令行的启动文件名(space-cli)和pip包的名字(myspace-cli)不一样
|
55
|
+
# 建议直接运行,可以看到使用菜单
|
56
|
+
space-cli
|
55
57
|
|
56
58
|
# 或以模块方式
|
57
|
-
python3 -m space_cli
|
59
|
+
python3 -m space_cli
|
60
|
+
|
61
|
+
# 如果要使用高级的功能,请使用更复杂的命令行参数,可以运行help
|
62
|
+
space-cli --help
|
63
|
+
|
58
64
|
```
|
59
65
|
|
60
66
|
### 方法2:直接使用
|
@@ -89,7 +95,7 @@ space-cli
|
|
89
95
|
### 基本用法
|
90
96
|
|
91
97
|
```bash
|
92
|
-
#
|
98
|
+
# 分析根目录(默认)- 支持交互式下探分析
|
93
99
|
python3 space_cli.py
|
94
100
|
|
95
101
|
# 分析指定路径
|
@@ -100,6 +106,9 @@ python3 space_cli.py -n 10
|
|
100
106
|
|
101
107
|
# 快捷分析当前用户目录(含用户目录深度分析)
|
102
108
|
python3 space_cli.py --home
|
109
|
+
|
110
|
+
# 交互式目录空间分析(支持选择序号下探,选择0返回上一级)
|
111
|
+
python3 space_cli.py --directories-only
|
103
112
|
```
|
104
113
|
|
105
114
|
### 高级用法
|
@@ -182,21 +191,30 @@ python3 space_cli.py --big-files --export report.json
|
|
182
191
|
建议: 磁盘空间不足,建议清理一些文件
|
183
192
|
```
|
184
193
|
|
185
|
-
###
|
194
|
+
### 交互式目录分析
|
186
195
|
```
|
187
196
|
============================================================
|
188
197
|
📊 占用空间最大的目录
|
189
198
|
============================================================
|
190
199
|
显示前 20 个最大的目录:
|
191
200
|
|
192
|
-
1. /Applications
|
193
|
-
大小:
|
201
|
+
1. /Applications -- 大小: 15.2 GB (3.04%)
|
202
|
+
2. /Users/username/Library -- 大小: 8.5 GB (1.70%)
|
203
|
+
3. /System -- 大小: 6.8 GB (1.36%)
|
194
204
|
|
195
|
-
|
196
|
-
|
205
|
+
============================================================
|
206
|
+
🔍 下探分析选项
|
207
|
+
============================================================
|
208
|
+
选择序号进行深度分析,选择0返回上一级,直接回车退出:
|
209
|
+
请输入选择 [回车=退出]: 1
|
197
210
|
|
198
|
-
|
199
|
-
|
211
|
+
🔍 正在分析: /Applications (15.2 GB)
|
212
|
+
============================================================
|
213
|
+
📊 占用空间最大的目录
|
214
|
+
============================================================
|
215
|
+
1. /Applications/Xcode.app -- 大小: 8.2 GB (1.64%)
|
216
|
+
2. /Applications/Docker.app -- 大小: 3.1 GB (0.62%)
|
217
|
+
3. /Applications/Visual Studio Code.app -- 大小: 1.8 GB (0.36%)
|
200
218
|
```
|
201
219
|
|
202
220
|
### 大文件分析
|
@@ -257,12 +275,13 @@ python3 mcp_server.py
|
|
257
275
|
|
258
276
|
## 性能优化
|
259
277
|
|
260
|
-
-
|
261
|
-
-
|
262
|
-
-
|
263
|
-
-
|
264
|
-
|
265
|
-
|
278
|
+
- **优先使用 `du -sk` 命令**:在 macOS 上使用原生 `du` 命令快速获取目录大小
|
279
|
+
- **智能回退机制**:当 `du` 命令失败时,自动回退到基于 `os.scandir` 的高效遍历
|
280
|
+
- **跳过系统目录**:自动忽略 `/System`、`/Volumes`、`/private` 等系统目录
|
281
|
+
- **跳过无法访问的文件**:自动处理权限错误和符号链接
|
282
|
+
- **支持中断操作**:使用 Ctrl+C 随时中断分析
|
283
|
+
- **内存优化遍历**:使用栈式迭代替代递归,避免深度目录的栈溢出
|
284
|
+
- **单行滚动进度**:避免输出刷屏,使用 ANSI 清行(\r\033[K)避免长行残留
|
266
285
|
|
267
286
|
## 故障排除
|
268
287
|
|
@@ -321,7 +340,14 @@ MIT License
|
|
321
340
|
- 单行滚动进度显示
|
322
341
|
|
323
342
|
### v1.2.0
|
324
|
-
-
|
343
|
+
- 应用分析支持"按序号一键删除应用",并显示将删除的路径清单与预计释放空间
|
325
344
|
- 删除过程增加权限修复与降级清理策略(chflags nouchg / chmod 0777 / 逐项清理)
|
326
345
|
- 针对 "Operation not permitted" 增加友好提示(SIP、完全磁盘访问、退出相关应用)
|
327
346
|
- 单行覆盖输出加入 ANSI 清行,避免长行残留
|
347
|
+
|
348
|
+
### v1.3.0
|
349
|
+
- **性能大幅优化**:优先使用 `du -sk` 命令获取目录大小,失败时回退到 `os.scandir` 高效遍历
|
350
|
+
- **交互式下探分析**:支持选择序号进行深度目录分析,选择0返回上一级
|
351
|
+
- **增强系统信息**:显示 CPU、内存、GPU、硬盘等完整硬件信息
|
352
|
+
- **智能目录过滤**:自动忽略系统目录(`/System`、`/Volumes`、`/private`)
|
353
|
+
- **优化用户体验**:改进菜单选项,支持交互式目录空间分析
|
@@ -0,0 +1,6 @@
|
|
1
|
+
space_cli.py,sha256=smLtarqGtTKHcIMi7usckw8M9IcM2hlQDXXY8zvM3x4,9487
|
2
|
+
myspace_cli-1.5.0.dist-info/METADATA,sha256=QCG4_N2rvebz_sC2ui4RFCG6hRhY1I9j_AvKd38lVLc,12665
|
3
|
+
myspace_cli-1.5.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
4
|
+
myspace_cli-1.5.0.dist-info/entry_points.txt,sha256=sIVEmPf6W8aNa7KiqOwAI6JrsgHlQciO5xH2G29tKPQ,45
|
5
|
+
myspace_cli-1.5.0.dist-info/top_level.txt,sha256=lnUfbQX9h-9ZjecMVCOWucS2kx69FwTndlrb48S20Sc,10
|
6
|
+
myspace_cli-1.5.0.dist-info/RECORD,,
|
space_cli.py
CHANGED
@@ -1,826 +1,19 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
2
|
# -*- coding: utf-8 -*-
|
3
3
|
"""
|
4
|
-
SpaceCli -
|
5
|
-
|
4
|
+
SpaceCli - macOS 磁盘空间分析工具
|
5
|
+
模块化版本的主入口文件
|
6
6
|
"""
|
7
7
|
|
8
8
|
import os
|
9
9
|
import sys
|
10
10
|
import argparse
|
11
|
-
import subprocess
|
12
|
-
import shutil
|
13
11
|
from pathlib import Path
|
14
|
-
from typing import List, Tuple, Dict
|
15
|
-
import json
|
16
|
-
import time
|
17
|
-
from datetime import datetime, timedelta
|
18
|
-
import heapq
|
19
12
|
|
13
|
+
# 添加 src 目录到 Python 路径
|
14
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
20
15
|
|
21
|
-
|
22
|
-
"""简单的目录大小索引缓存管理器"""
|
23
|
-
|
24
|
-
def __init__(self, index_file: str = None):
|
25
|
-
home = str(Path.home())
|
26
|
-
cache_dir = os.path.join(home, ".spacecli")
|
27
|
-
os.makedirs(cache_dir, exist_ok=True)
|
28
|
-
self.index_file = index_file or os.path.join(cache_dir, "index.json")
|
29
|
-
self._data: Dict = {}
|
30
|
-
self._loaded = False
|
31
|
-
|
32
|
-
def load(self) -> None:
|
33
|
-
if self._loaded:
|
34
|
-
return
|
35
|
-
if os.path.exists(self.index_file):
|
36
|
-
try:
|
37
|
-
with open(self.index_file, "r", encoding="utf-8") as f:
|
38
|
-
self._data = json.load(f)
|
39
|
-
except Exception:
|
40
|
-
self._data = {}
|
41
|
-
self._loaded = True
|
42
|
-
|
43
|
-
def save(self) -> None:
|
44
|
-
try:
|
45
|
-
with open(self.index_file, "w", encoding="utf-8") as f:
|
46
|
-
json.dump(self._data, f, ensure_ascii=False, indent=2)
|
47
|
-
except Exception:
|
48
|
-
pass
|
49
|
-
|
50
|
-
def _key(self, root_path: str) -> str:
|
51
|
-
return os.path.abspath(root_path)
|
52
|
-
|
53
|
-
def get(self, root_path: str) -> Dict:
|
54
|
-
self.load()
|
55
|
-
return self._data.get(self._key(root_path))
|
56
|
-
|
57
|
-
def set(self, root_path: str, entries: List[Tuple[str, int]]) -> None:
|
58
|
-
self.load()
|
59
|
-
now_iso = datetime.utcnow().isoformat()
|
60
|
-
self._data[self._key(root_path)] = {
|
61
|
-
"updated_at": now_iso,
|
62
|
-
"entries": [{"path": p, "size": s} for p, s in entries],
|
63
|
-
}
|
64
|
-
self.save()
|
65
|
-
|
66
|
-
def is_fresh(self, root_path: str, ttl_hours: int) -> bool:
|
67
|
-
self.load()
|
68
|
-
rec = self._data.get(self._key(root_path))
|
69
|
-
if not rec:
|
70
|
-
return False
|
71
|
-
try:
|
72
|
-
updated_at = datetime.fromisoformat(rec.get("updated_at"))
|
73
|
-
return datetime.utcnow() - updated_at <= timedelta(hours=ttl_hours)
|
74
|
-
except Exception:
|
75
|
-
return False
|
76
|
-
|
77
|
-
# 命名缓存(非路径键),适合应用分析等聚合结果
|
78
|
-
def get_named(self, name: str) -> Dict:
|
79
|
-
self.load()
|
80
|
-
return self._data.get(name)
|
81
|
-
|
82
|
-
def set_named(self, name: str, entries: List[Tuple[str, int]]) -> None:
|
83
|
-
self.load()
|
84
|
-
now_iso = datetime.utcnow().isoformat()
|
85
|
-
self._data[name] = {
|
86
|
-
"updated_at": now_iso,
|
87
|
-
"entries": [{"name": p, "size": s} for p, s in entries],
|
88
|
-
}
|
89
|
-
self.save()
|
90
|
-
|
91
|
-
def is_fresh_named(self, name: str, ttl_hours: int) -> bool:
|
92
|
-
self.load()
|
93
|
-
rec = self._data.get(name)
|
94
|
-
if not rec:
|
95
|
-
return False
|
96
|
-
try:
|
97
|
-
updated_at = datetime.fromisoformat(rec.get("updated_at"))
|
98
|
-
return datetime.utcnow() - updated_at <= timedelta(hours=ttl_hours)
|
99
|
-
except Exception:
|
100
|
-
return False
|
101
|
-
|
102
|
-
|
103
|
-
class SpaceAnalyzer:
|
104
|
-
"""磁盘空间分析器"""
|
105
|
-
|
106
|
-
def __init__(self):
|
107
|
-
self.warning_threshold = 80 # 警告阈值百分比
|
108
|
-
self.critical_threshold = 90 # 严重阈值百分比
|
109
|
-
|
110
|
-
def get_disk_usage(self, path: str = "/") -> Dict:
|
111
|
-
"""获取磁盘使用情况"""
|
112
|
-
try:
|
113
|
-
statvfs = os.statvfs(path)
|
114
|
-
|
115
|
-
# 计算磁盘空间信息
|
116
|
-
total_bytes = statvfs.f_frsize * statvfs.f_blocks
|
117
|
-
free_bytes = statvfs.f_frsize * statvfs.f_bavail
|
118
|
-
used_bytes = total_bytes - free_bytes
|
119
|
-
|
120
|
-
# 计算百分比
|
121
|
-
usage_percent = (used_bytes / total_bytes) * 100
|
122
|
-
|
123
|
-
return {
|
124
|
-
'total': total_bytes,
|
125
|
-
'used': used_bytes,
|
126
|
-
'free': free_bytes,
|
127
|
-
'usage_percent': usage_percent,
|
128
|
-
'path': path
|
129
|
-
}
|
130
|
-
except Exception as e:
|
131
|
-
print(f"错误:无法获取磁盘使用情况 - {e}")
|
132
|
-
return None
|
133
|
-
|
134
|
-
def get_disk_health_status(self, usage_info: Dict) -> Tuple[str, str]:
|
135
|
-
"""评估磁盘健康状态"""
|
136
|
-
if not usage_info:
|
137
|
-
return "未知", "无法获取磁盘信息"
|
138
|
-
|
139
|
-
usage_percent = usage_info['usage_percent']
|
140
|
-
|
141
|
-
if usage_percent >= self.critical_threshold:
|
142
|
-
return "严重", "磁盘空间严重不足!请立即清理磁盘空间"
|
143
|
-
elif usage_percent >= self.warning_threshold:
|
144
|
-
return "警告", "磁盘空间不足,建议清理一些文件"
|
145
|
-
else:
|
146
|
-
return "良好", "磁盘空间充足"
|
147
|
-
|
148
|
-
def format_bytes(self, bytes_value: int) -> str:
|
149
|
-
"""格式化字节数为人类可读格式"""
|
150
|
-
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
151
|
-
if bytes_value < 1024.0:
|
152
|
-
return f"{bytes_value:.1f} {unit}"
|
153
|
-
bytes_value /= 1024.0
|
154
|
-
return f"{bytes_value:.1f} PB"
|
155
|
-
|
156
|
-
def get_directory_size(self, path: str) -> int:
|
157
|
-
"""递归计算目录大小"""
|
158
|
-
total_size = 0
|
159
|
-
try:
|
160
|
-
for dirpath, dirnames, filenames in os.walk(path):
|
161
|
-
for filename in filenames:
|
162
|
-
filepath = os.path.join(dirpath, filename)
|
163
|
-
try:
|
164
|
-
total_size += os.path.getsize(filepath)
|
165
|
-
except (OSError, FileNotFoundError):
|
166
|
-
# 跳过无法访问的文件
|
167
|
-
continue
|
168
|
-
except (OSError, PermissionError):
|
169
|
-
# 跳过无法访问的目录
|
170
|
-
pass
|
171
|
-
return total_size
|
172
|
-
|
173
|
-
def analyze_largest_files(self, root_path: str = "/", top_n: int = 50,
|
174
|
-
min_size_bytes: int = 0) -> List[Tuple[str, int]]:
|
175
|
-
"""扫描并返回体积最大的文件列表"""
|
176
|
-
print("正在扫描大文件,这可能需要一些时间...")
|
177
|
-
heap: List[Tuple[int, str]] = [] # 最小堆 (size, path)
|
178
|
-
scanned = 0
|
179
|
-
try:
|
180
|
-
for dirpath, dirnames, filenames in os.walk(root_path):
|
181
|
-
# 进度提示:单行覆盖当前目录
|
182
|
-
dirpath_display = dirpath[-80:] # 截取最后50个字符
|
183
|
-
if dirpath_display == "":
|
184
|
-
dirpath_display = dirpath
|
185
|
-
sys.stdout.write(f"\r\033[K-> 正在读取: \033[36m{dirpath_display}\033[0m")
|
186
|
-
sys.stdout.flush()
|
187
|
-
for filename in filenames:
|
188
|
-
filepath = os.path.join(dirpath, filename)
|
189
|
-
try:
|
190
|
-
size = os.path.getsize(filepath)
|
191
|
-
except (OSError, FileNotFoundError, PermissionError):
|
192
|
-
continue
|
193
|
-
if size < min_size_bytes:
|
194
|
-
continue
|
195
|
-
if len(heap) < top_n:
|
196
|
-
heapq.heappush(heap, (size, filepath))
|
197
|
-
else:
|
198
|
-
if size > heap[0][0]:
|
199
|
-
heapq.heapreplace(heap, (size, filepath))
|
200
|
-
scanned += 1
|
201
|
-
if scanned % 500 == 0:
|
202
|
-
dirpath_display = dirpath[-80:] # 截取最后50个字符
|
203
|
-
if dirpath_display == "":
|
204
|
-
dirpath_display = dirpath
|
205
|
-
# 间隔性进度输出(单行覆盖)
|
206
|
-
sys.stdout.write(f"\r\033[K-> 正在读取: \033[36m{dirpath_display}\033[0m 已扫描文件数: \033[32m{scanned}\033[0m")
|
207
|
-
sys.stdout.flush()
|
208
|
-
except KeyboardInterrupt:
|
209
|
-
print("\n用户中断扫描,返回当前结果...")
|
210
|
-
except Exception as e:
|
211
|
-
print(f"扫描时出错: {e}")
|
212
|
-
finally:
|
213
|
-
sys.stdout.write("\n")
|
214
|
-
sys.stdout.flush()
|
215
|
-
# 转换为按体积降序列表
|
216
|
-
result = sorted([(p, s) for s, p in heap], key=lambda x: x[1], reverse=False)
|
217
|
-
result.sort(key=lambda x: x[1])
|
218
|
-
result = sorted([(p, s) for s, p in heap], key=lambda x: x[1])
|
219
|
-
# 正确:按 size 降序
|
220
|
-
result = sorted([(p, s) for s, p in heap], key=lambda x: x[1], reverse=True)
|
221
|
-
# 以上为了避免编辑器误合并,最终以最后一行排序为准
|
222
|
-
return result
|
223
|
-
|
224
|
-
def analyze_largest_directories(self, root_path: str = "/", max_depth: int = 2, top_n: int = 20,
|
225
|
-
index: IndexStore = None, use_index: bool = True,
|
226
|
-
reindex: bool = False, index_ttl_hours: int = 24,
|
227
|
-
prompt: bool = True) -> List[Tuple[str, int]]:
|
228
|
-
"""分析占用空间最大的目录(支持索引缓存)"""
|
229
|
-
# 索引命中
|
230
|
-
if use_index and index and not reindex and index.is_fresh(root_path, index_ttl_hours):
|
231
|
-
cached = index.get(root_path)
|
232
|
-
if cached and cached.get("entries"):
|
233
|
-
if prompt and sys.stdin.isatty():
|
234
|
-
try:
|
235
|
-
ans = input("检测到最近索引,是否使用缓存结果而不重新索引?[Y/n]: ").strip().lower()
|
236
|
-
if ans in ("", "y", "yes"):
|
237
|
-
return [(e["path"], int(e["size"])) for e in cached["entries"]][:top_n]
|
238
|
-
except EOFError:
|
239
|
-
pass
|
240
|
-
else:
|
241
|
-
return [(e["path"], int(e["size"])) for e in cached["entries"]][:top_n]
|
242
|
-
|
243
|
-
print("正在分析目录大小,这可能需要一些时间...")
|
244
|
-
|
245
|
-
directory_sizes = []
|
246
|
-
|
247
|
-
try:
|
248
|
-
# 获取根目录下的直接子目录
|
249
|
-
for item in os.listdir(root_path):
|
250
|
-
item_path = os.path.join(root_path, item)
|
251
|
-
|
252
|
-
# 跳过隐藏文件和系统文件
|
253
|
-
if item.startswith('.') and item not in ['.Trash', '.localized']:
|
254
|
-
continue
|
255
|
-
|
256
|
-
if os.path.isdir(item_path):
|
257
|
-
try:
|
258
|
-
# 进度提示:当前正在读取的目录(单行覆盖)
|
259
|
-
sys.stdout.write(f"\r\033[K-> 正在读取: \033[36m{item_path}\033[0m")
|
260
|
-
sys.stdout.flush()
|
261
|
-
size = self.get_directory_size(item_path)
|
262
|
-
directory_sizes.append((item_path, size))
|
263
|
-
#print(f"已分析: {item_path} ({self.format_bytes(size)})")
|
264
|
-
print(f" ({self.format_bytes(size)})\033[0m")
|
265
|
-
except (OSError, PermissionError):
|
266
|
-
print(f"跳过无法访问的目录: {item_path}")
|
267
|
-
continue
|
268
|
-
# 结束时换行,避免后续输出粘连在同一行
|
269
|
-
sys.stdout.write("\n")
|
270
|
-
sys.stdout.flush()
|
271
|
-
|
272
|
-
# 按大小排序
|
273
|
-
directory_sizes.sort(key=lambda x: x[1], reverse=True)
|
274
|
-
# 写入索引
|
275
|
-
if index:
|
276
|
-
try:
|
277
|
-
index.set(root_path, directory_sizes)
|
278
|
-
except Exception:
|
279
|
-
pass
|
280
|
-
return directory_sizes[:top_n]
|
281
|
-
|
282
|
-
except Exception as e:
|
283
|
-
print(f"分析目录时出错: {e}")
|
284
|
-
return []
|
285
|
-
|
286
|
-
def get_system_info(self) -> Dict:
|
287
|
-
"""获取系统信息"""
|
288
|
-
try:
|
289
|
-
# 获取系统版本
|
290
|
-
result = subprocess.run(['sw_vers'], capture_output=True, text=True)
|
291
|
-
system_info = {}
|
292
|
-
for line in result.stdout.split('\n'):
|
293
|
-
if ':' in line:
|
294
|
-
key, value = line.split(':', 1)
|
295
|
-
system_info[key.strip()] = value.strip()
|
296
|
-
|
297
|
-
return system_info
|
298
|
-
except Exception:
|
299
|
-
return {"ProductName": "macOS", "ProductVersion": "未知"}
|
300
|
-
|
301
|
-
|
302
|
-
class SpaceCli:
|
303
|
-
"""SpaceCli 主类"""
|
304
|
-
|
305
|
-
def __init__(self):
|
306
|
-
self.analyzer = SpaceAnalyzer()
|
307
|
-
self.index = IndexStore()
|
308
|
-
# 应用分析缓存存放于 ~/.cache/spacecli/apps.json
|
309
|
-
home = str(Path.home())
|
310
|
-
app_cache_dir = os.path.join(home, ".cache", "spacecli")
|
311
|
-
os.makedirs(app_cache_dir, exist_ok=True)
|
312
|
-
self.app_index = IndexStore(index_file=os.path.join(app_cache_dir, "apps.json"))
|
313
|
-
|
314
|
-
# —— 应用删除相关 ——
|
315
|
-
def _candidate_app_paths(self, app_name: str) -> List[str]:
|
316
|
-
"""根据应用名推导可能占用空间的相关目录/文件路径列表。"""
|
317
|
-
home = str(Path.home())
|
318
|
-
candidates: List[str] = []
|
319
|
-
possible_bases = [
|
320
|
-
("/Applications", f"{app_name}.app"),
|
321
|
-
(os.path.join(home, "Applications"), f"{app_name}.app"),
|
322
|
-
("/Library/Application Support", app_name),
|
323
|
-
(os.path.join(home, "Library", "Application Support"), app_name),
|
324
|
-
("/Library/Caches", app_name),
|
325
|
-
(os.path.join(home, "Library", "Caches"), app_name),
|
326
|
-
("/Library/Logs", app_name),
|
327
|
-
(os.path.join(home, "Library", "Logs"), app_name),
|
328
|
-
(os.path.join(home, "Library", "Containers"), app_name),
|
329
|
-
]
|
330
|
-
# 直接拼接命中
|
331
|
-
for base, tail in possible_bases:
|
332
|
-
path = os.path.join(base, tail)
|
333
|
-
if os.path.exists(path):
|
334
|
-
candidates.append(path)
|
335
|
-
# 模糊扫描:包含应用名的目录
|
336
|
-
scan_dirs = [
|
337
|
-
"/Applications",
|
338
|
-
os.path.join(home, "Applications"),
|
339
|
-
"/Library/Application Support",
|
340
|
-
os.path.join(home, "Library", "Application Support"),
|
341
|
-
"/Library/Caches",
|
342
|
-
os.path.join(home, "Library", "Caches"),
|
343
|
-
"/Library/Logs",
|
344
|
-
os.path.join(home, "Library", "Logs"),
|
345
|
-
os.path.join(home, "Library", "Containers"),
|
346
|
-
]
|
347
|
-
app_lower = app_name.lower()
|
348
|
-
for base in scan_dirs:
|
349
|
-
if not os.path.exists(base):
|
350
|
-
continue
|
351
|
-
try:
|
352
|
-
for item in os.listdir(base):
|
353
|
-
item_path = os.path.join(base, item)
|
354
|
-
# 只收集目录或 .app 包
|
355
|
-
if not os.path.isdir(item_path):
|
356
|
-
continue
|
357
|
-
name_lower = item.lower()
|
358
|
-
if app_lower in name_lower:
|
359
|
-
candidates.append(item_path)
|
360
|
-
except (PermissionError, OSError):
|
361
|
-
continue
|
362
|
-
# 去重并按路径长度降序(先删更深层,避免空目录残留)
|
363
|
-
uniq: List[str] = []
|
364
|
-
seen = set()
|
365
|
-
for p in sorted(set(candidates), key=lambda x: len(x), reverse=True):
|
366
|
-
if p not in seen:
|
367
|
-
uniq.append(p)
|
368
|
-
seen.add(p)
|
369
|
-
return uniq
|
370
|
-
|
371
|
-
def _delete_paths_and_sum(self, paths: List[str]) -> Tuple[int, List[Tuple[str, str]]]:
|
372
|
-
"""删除给定路径列表,返回释放的总字节数与失败列表(路径, 原因)。"""
|
373
|
-
total_freed = 0
|
374
|
-
failures: List[Tuple[str, str]] = []
|
375
|
-
|
376
|
-
def _try_fix_permissions(path: str) -> None:
|
377
|
-
"""尝试修复权限与不可变标记以便删除。"""
|
378
|
-
try:
|
379
|
-
# 去除不可变标记(普通用户能去除的场景)
|
380
|
-
subprocess.run(["chflags", "-R", "nouchg", path], capture_output=True)
|
381
|
-
except Exception:
|
382
|
-
pass
|
383
|
-
try:
|
384
|
-
os.chmod(path, 0o777)
|
385
|
-
except Exception:
|
386
|
-
pass
|
387
|
-
|
388
|
-
def _onerror(func, path, exc_info):
|
389
|
-
# 当 rmtree 无法删除时,尝试修复权限并重试一次
|
390
|
-
_try_fix_permissions(path)
|
391
|
-
try:
|
392
|
-
func(path)
|
393
|
-
except Exception:
|
394
|
-
# 让上层捕获
|
395
|
-
raise
|
396
|
-
for p in paths:
|
397
|
-
try:
|
398
|
-
size_before = 0
|
399
|
-
try:
|
400
|
-
if os.path.isdir(p):
|
401
|
-
size_before = self.analyzer.get_directory_size(p)
|
402
|
-
elif os.path.isfile(p):
|
403
|
-
size_before = os.path.getsize(p)
|
404
|
-
except Exception:
|
405
|
-
size_before = 0
|
406
|
-
if os.path.isdir(p) and not os.path.islink(p):
|
407
|
-
try:
|
408
|
-
shutil.rmtree(p, ignore_errors=False, onerror=_onerror)
|
409
|
-
except Exception:
|
410
|
-
# 目录删除失败,降级为逐项尝试删除(尽量清理可删部分)
|
411
|
-
for dirpath, dirnames, filenames in os.walk(p, topdown=False):
|
412
|
-
for name in filenames:
|
413
|
-
fpath = os.path.join(dirpath, name)
|
414
|
-
try:
|
415
|
-
_try_fix_permissions(fpath)
|
416
|
-
os.remove(fpath)
|
417
|
-
except Exception:
|
418
|
-
continue
|
419
|
-
for name in dirnames:
|
420
|
-
dpath = os.path.join(dirpath, name)
|
421
|
-
try:
|
422
|
-
_try_fix_permissions(dpath)
|
423
|
-
os.rmdir(dpath)
|
424
|
-
except Exception:
|
425
|
-
continue
|
426
|
-
# 最后尝试删除顶层目录
|
427
|
-
_try_fix_permissions(p)
|
428
|
-
os.rmdir(p)
|
429
|
-
else:
|
430
|
-
os.remove(p)
|
431
|
-
total_freed += size_before
|
432
|
-
except Exception as e:
|
433
|
-
failures.append((p, str(e)))
|
434
|
-
return total_freed, failures
|
435
|
-
|
436
|
-
def _offer_app_delete(self, apps: List[Tuple[str, int]]) -> None:
|
437
|
-
"""在已打印的应用列表后,提供按序号一键删除功能。"""
|
438
|
-
if not sys.stdin.isatty() or getattr(self.args, 'no_prompt', False):
|
439
|
-
return
|
440
|
-
try:
|
441
|
-
ans = input("是否要一键删除某个应用?输入序号或回车跳过: ").strip()
|
442
|
-
except EOFError:
|
443
|
-
ans = ""
|
444
|
-
if not ans:
|
445
|
-
return
|
446
|
-
try:
|
447
|
-
idx = int(ans)
|
448
|
-
except ValueError:
|
449
|
-
print("❌ 无效的输入(应为数字序号)")
|
450
|
-
return
|
451
|
-
if idx < 1 or idx > len(apps):
|
452
|
-
print("❌ 序号超出范围")
|
453
|
-
return
|
454
|
-
app_name, app_size = apps[idx - 1]
|
455
|
-
size_str = self.analyzer.format_bytes(app_size)
|
456
|
-
try:
|
457
|
-
confirm = input(f"确认删除应用及相关缓存: {app_name} (约 {size_str})?[y/N]: ").strip().lower()
|
458
|
-
except EOFError:
|
459
|
-
confirm = ""
|
460
|
-
if confirm not in ("y", "yes"):
|
461
|
-
print("已取消删除")
|
462
|
-
return
|
463
|
-
related_paths = self._candidate_app_paths(app_name)
|
464
|
-
if not related_paths:
|
465
|
-
print("未找到可删除的相关目录/文件")
|
466
|
-
return
|
467
|
-
print("将尝试删除以下路径:")
|
468
|
-
for p in related_paths:
|
469
|
-
print(f" - {p}")
|
470
|
-
try:
|
471
|
-
confirm2 = input("再次确认删除以上路径?[y/N]: ").strip().lower()
|
472
|
-
except EOFError:
|
473
|
-
confirm2 = ""
|
474
|
-
if confirm2 not in ("y", "yes"):
|
475
|
-
print("已取消删除")
|
476
|
-
return
|
477
|
-
freed, failures = self._delete_paths_and_sum(related_paths)
|
478
|
-
print(f"✅ 删除完成,预计释放空间: {self.analyzer.format_bytes(freed)}")
|
479
|
-
if failures:
|
480
|
-
print("以下路径删除失败,可能需要手动或管理员权限:")
|
481
|
-
for p, reason in failures:
|
482
|
-
print(f" - {p} -> {reason}")
|
483
|
-
# 常见提示:Operation not permitted(SIP/容器元数据等)
|
484
|
-
if any("Operation not permitted" in r for _, r in failures):
|
485
|
-
print("提示:部分系统受保护或容器元数据文件无法删除。可尝试:")
|
486
|
-
print(" - 先退出相关应用(如 Docker)再重试")
|
487
|
-
print(" - 给予当前终端“完全磁盘访问权限”(系统设置 → 隐私与安全性)")
|
488
|
-
print(" - 仅删除用户目录下缓存,保留系统级容器元数据")
|
489
|
-
|
490
|
-
# 通用渲染:目录与应用(减少重复)
|
491
|
-
def _render_dirs(self, entries: List[Tuple[str, int]], total_bytes: int) -> None:
|
492
|
-
for i, (dir_path, size) in enumerate(entries, 1):
|
493
|
-
size_str = self.analyzer.format_bytes(size)
|
494
|
-
percentage = (size / total_bytes) * 100 if total_bytes else 0
|
495
|
-
# 1G 以上红色,否则绿色
|
496
|
-
color = "\033[31m" if size >= 1024**3 else "\033[32m"
|
497
|
-
print(f"{i:2d}. \033[36m{dir_path}\033[0m -- 大小: {color}{size_str}\033[0m (\033[33m{percentage:.2f}%\033[0m)")
|
498
|
-
|
499
|
-
def _render_apps(self, entries: List[Tuple[str, int]], disk_total: int) -> None:
|
500
|
-
for i, (app, size) in enumerate(entries, 1):
|
501
|
-
size_str = self.analyzer.format_bytes(size)
|
502
|
-
pct = (size / disk_total) * 100 if disk_total else 0
|
503
|
-
suggestion = "建议卸载或清理缓存" if size >= 5 * 1024**3 else "可保留,定期清理缓存"
|
504
|
-
# 3G 以上红色,否则绿色
|
505
|
-
color = "\033[31m" if size >= 3 * 1024**3 else "\033[32m"
|
506
|
-
print(f"{i:2d}. \033[36m{app}\033[0m -- 占用: {color}{size_str}\033[0m ({pct:.2f}%) — {suggestion}")
|
507
|
-
|
508
|
-
def analyze_app_directories(self, top_n: int = 20,
|
509
|
-
index: IndexStore = None,
|
510
|
-
use_index: bool = True,
|
511
|
-
reindex: bool = False,
|
512
|
-
index_ttl_hours: int = 24,
|
513
|
-
prompt: bool = True) -> List[Tuple[str, int]]:
|
514
|
-
"""分析应用安装与数据目录占用,按应用归并估算大小(支持缓存)"""
|
515
|
-
|
516
|
-
# 命中命名缓存
|
517
|
-
cache_name = "apps_aggregate"
|
518
|
-
if use_index and index and not reindex and index.is_fresh_named(cache_name, index_ttl_hours):
|
519
|
-
cached = index.get_named(cache_name)
|
520
|
-
if cached and cached.get("entries"):
|
521
|
-
if prompt and sys.stdin.isatty():
|
522
|
-
try:
|
523
|
-
ans = input("检测到最近应用分析索引,是否使用缓存结果?[Y/n]: ").strip().lower()
|
524
|
-
if ans in ("", "y", "yes"):
|
525
|
-
return [(e["name"], int(e["size"])) for e in cached["entries"]][:top_n]
|
526
|
-
except EOFError:
|
527
|
-
pass
|
528
|
-
else:
|
529
|
-
return [(e["name"], int(e["size"])) for e in cached["entries"]][:top_n]
|
530
|
-
# 关注目录
|
531
|
-
home = str(Path.home())
|
532
|
-
target_dirs = [
|
533
|
-
"/Applications",
|
534
|
-
os.path.join(home, "Applications"),
|
535
|
-
"/Library/Application Support",
|
536
|
-
"/Library/Caches",
|
537
|
-
"/Library/Logs",
|
538
|
-
os.path.join(home, "Library", "Application Support"),
|
539
|
-
os.path.join(home, "Library", "Caches"),
|
540
|
-
os.path.join(home, "Library", "Logs"),
|
541
|
-
os.path.join(home, "Library", "Containers"),
|
542
|
-
]
|
543
|
-
|
544
|
-
def app_key_from_path(p: str) -> str:
|
545
|
-
# 优先用.app 名称,其次用顶级目录名
|
546
|
-
parts = Path(p).parts
|
547
|
-
for i in range(len(parts)-1, -1, -1):
|
548
|
-
if parts[i].endswith('.app'):
|
549
|
-
return parts[i].replace('.app', '')
|
550
|
-
# 否则返回倒数第二级或最后一级作为应用键
|
551
|
-
return parts[-1] if parts else p
|
552
|
-
|
553
|
-
app_size_map: Dict[str, int] = {}
|
554
|
-
scanned_dirs: List[str] = []
|
555
|
-
|
556
|
-
for base in target_dirs:
|
557
|
-
if not os.path.exists(base):
|
558
|
-
continue
|
559
|
-
try:
|
560
|
-
for item in os.listdir(base):
|
561
|
-
item_path = os.path.join(base, item)
|
562
|
-
if not os.path.isdir(item_path):
|
563
|
-
continue
|
564
|
-
key = app_key_from_path(item_path)
|
565
|
-
# 进度提示:当前应用相关目录(单行覆盖)
|
566
|
-
item_path = item_path[:100]
|
567
|
-
sys.stdout.write(f"\r\033[K-> 正在读取: \033[36m{item_path}\033[0m")
|
568
|
-
sys.stdout.flush()
|
569
|
-
size = self.analyzer.get_directory_size(item_path)
|
570
|
-
scanned_dirs.append(item_path)
|
571
|
-
app_size_map[key] = app_size_map.get(key, 0) + size
|
572
|
-
except (PermissionError, OSError):
|
573
|
-
continue
|
574
|
-
# 结束时换行
|
575
|
-
sys.stdout.write("\n")
|
576
|
-
sys.stdout.flush()
|
577
|
-
|
578
|
-
# 转为排序列表
|
579
|
-
result = sorted(app_size_map.items(), key=lambda x: x[1], reverse=True)
|
580
|
-
# 写入命名缓存
|
581
|
-
if index:
|
582
|
-
try:
|
583
|
-
index.set_named(cache_name, result)
|
584
|
-
except Exception:
|
585
|
-
pass
|
586
|
-
return result[:top_n]
|
587
|
-
|
588
|
-
def print_disk_health(self, path: str = "/"):
|
589
|
-
"""打印磁盘健康状态"""
|
590
|
-
print("=" * 60)
|
591
|
-
print("🔍 磁盘空间健康度分析")
|
592
|
-
print("=" * 60)
|
593
|
-
|
594
|
-
usage_info = self.analyzer.get_disk_usage(path)
|
595
|
-
if not usage_info:
|
596
|
-
print("❌ 无法获取磁盘使用情况")
|
597
|
-
return
|
598
|
-
|
599
|
-
status, message = self.analyzer.get_disk_health_status(usage_info)
|
600
|
-
|
601
|
-
# 状态图标
|
602
|
-
status_icon = {
|
603
|
-
"良好": "✅",
|
604
|
-
"警告": "⚠️",
|
605
|
-
"严重": "🚨"
|
606
|
-
}.get(status, "❓")
|
607
|
-
|
608
|
-
print(f"磁盘路径: \033[36m{usage_info['path']}\033[0m")
|
609
|
-
print(f"总容量: \033[36m{self.analyzer.format_bytes(usage_info['total'])}\033[0m")
|
610
|
-
print(f"已使用: \033[36m{self.analyzer.format_bytes(usage_info['used'])}\033[0m")
|
611
|
-
print(f"可用空间: \033[36m{self.analyzer.format_bytes(usage_info['free'])}\033[0m")
|
612
|
-
print(f"使用率: \033[36m{usage_info['usage_percent']:.1f}%\033[0m")
|
613
|
-
print(f"健康状态: {status_icon} \033[36m{status}\033[0m")
|
614
|
-
print(f"建议: \033[36m{message}\033[0m")
|
615
|
-
print()
|
616
|
-
|
617
|
-
def print_largest_directories(self, path: str = "/", top_n: int = 20):
|
618
|
-
"""打印占用空间最大的目录"""
|
619
|
-
print("=" * 60)
|
620
|
-
print("📊 占用空间最大的目录")
|
621
|
-
print("=" * 60)
|
622
|
-
|
623
|
-
# 若有缓存:直接显示缓存,然后再询问是否重新分析
|
624
|
-
if self.args.use_index:
|
625
|
-
cached = self.index.get(path)
|
626
|
-
if cached and cached.get("entries"):
|
627
|
-
cached_entries = [(e["path"], int(e["size"])) for e in cached["entries"]][:top_n]
|
628
|
-
total_info = self.analyzer.get_disk_usage(path)
|
629
|
-
total_bytes = total_info['total'] if total_info else 1
|
630
|
-
print(f"(来自索引) 显示前 {min(len(cached_entries), top_n)} 个最大的目录:\n")
|
631
|
-
self._render_dirs(cached_entries, total_bytes)
|
632
|
-
if sys.stdin.isatty() and not self.args.no_prompt:
|
633
|
-
try:
|
634
|
-
ans = input("是否重新分析以刷新索引?[y/N]: ").strip().lower()
|
635
|
-
except EOFError:
|
636
|
-
ans = ""
|
637
|
-
if ans not in ("y", "yes"):
|
638
|
-
return
|
639
|
-
else:
|
640
|
-
return
|
641
|
-
|
642
|
-
directories = self.analyzer.analyze_largest_directories(
|
643
|
-
path,
|
644
|
-
top_n=top_n,
|
645
|
-
index=self.index,
|
646
|
-
use_index=self.args.use_index,
|
647
|
-
reindex=True, # 走到这里表示要刷新
|
648
|
-
index_ttl_hours=self.args.index_ttl,
|
649
|
-
prompt=False,
|
650
|
-
)
|
651
|
-
if not directories:
|
652
|
-
print("❌ 无法分析目录大小")
|
653
|
-
return
|
654
|
-
total_info = self.analyzer.get_disk_usage(path)
|
655
|
-
total_bytes = total_info['total'] if total_info else 1
|
656
|
-
print("\n已重新分析,最新结果:\n")
|
657
|
-
self._render_dirs(directories, total_bytes)
|
658
|
-
|
659
|
-
def print_app_analysis(self, top_n: int = 20):
|
660
|
-
"""打印应用目录占用分析,并给出卸载建议"""
|
661
|
-
print("=" * 60)
|
662
|
-
print("🧩 应用目录空间分析与卸载建议")
|
663
|
-
print("=" * 60)
|
664
|
-
|
665
|
-
# 先显示缓存,再决定是否刷新
|
666
|
-
if self.args.use_index:
|
667
|
-
cached = self.app_index.get_named("apps_aggregate")
|
668
|
-
if cached and cached.get("entries"):
|
669
|
-
cached_entries = [(e["name"], int(e["size"])) for e in cached["entries"]][:top_n]
|
670
|
-
total = self.analyzer.get_disk_usage("/")
|
671
|
-
disk_total = total['total'] if total else 1
|
672
|
-
print(f"(来自索引) 显示前 {min(len(cached_entries), top_n)} 个空间占用最高的应用:\n")
|
673
|
-
self._render_apps(cached_entries, disk_total)
|
674
|
-
# 提供一键删除
|
675
|
-
self._offer_app_delete(cached_entries)
|
676
|
-
if sys.stdin.isatty() and not self.args.no_prompt:
|
677
|
-
try:
|
678
|
-
ans = input("是否重新分析应用以刷新索引?[y/N]: ").strip().lower()
|
679
|
-
except EOFError:
|
680
|
-
ans = ""
|
681
|
-
if ans not in ("y", "yes"):
|
682
|
-
return
|
683
|
-
else:
|
684
|
-
return
|
685
|
-
|
686
|
-
apps = self.analyze_app_directories(
|
687
|
-
top_n=top_n,
|
688
|
-
index=self.app_index,
|
689
|
-
use_index=self.args.use_index,
|
690
|
-
reindex=True,
|
691
|
-
index_ttl_hours=self.args.index_ttl,
|
692
|
-
prompt=False,
|
693
|
-
)
|
694
|
-
if not apps:
|
695
|
-
print("❌ 未发现可分析的应用目录")
|
696
|
-
return
|
697
|
-
total = self.analyzer.get_disk_usage("/")
|
698
|
-
disk_total = total['total'] if total else 1
|
699
|
-
print("\n已重新分析,最新应用占用结果:\n")
|
700
|
-
self._render_apps(apps, disk_total)
|
701
|
-
# 提供一键删除
|
702
|
-
self._offer_app_delete(apps)
|
703
|
-
|
704
|
-
def print_home_deep_analysis(self, top_n: int = 20):
|
705
|
-
"""对用户目录的 Library / Downloads / Documents 分别下探分析"""
|
706
|
-
home = str(Path.home())
|
707
|
-
targets = [
|
708
|
-
("Library", os.path.join(home, "Library")),
|
709
|
-
("Downloads", os.path.join(home, "Downloads")),
|
710
|
-
("Documents", os.path.join(home, "Documents")),
|
711
|
-
]
|
712
|
-
|
713
|
-
for label, target in targets:
|
714
|
-
if not os.path.exists(target):
|
715
|
-
continue
|
716
|
-
print("=" * 60)
|
717
|
-
print(f"🏠 用户目录下探 - {label}")
|
718
|
-
print("=" * 60)
|
719
|
-
directories = self.analyzer.analyze_largest_directories(
|
720
|
-
target,
|
721
|
-
top_n=top_n,
|
722
|
-
index=self.index,
|
723
|
-
use_index=self.args.use_index,
|
724
|
-
reindex=self.args.reindex,
|
725
|
-
index_ttl_hours=self.args.index_ttl,
|
726
|
-
prompt=not self.args.no_prompt,
|
727
|
-
)
|
728
|
-
if not directories:
|
729
|
-
print("❌ 无法分析目录大小")
|
730
|
-
continue
|
731
|
-
total_info = self.analyzer.get_disk_usage("/")
|
732
|
-
total_bytes = total_info['total'] if total_info else 1
|
733
|
-
print(f"显示前 {min(len(directories), top_n)} 个最大的目录:\n")
|
734
|
-
for i, (dir_path, size) in enumerate(directories, 1):
|
735
|
-
size_str = self.analyzer.format_bytes(size)
|
736
|
-
percentage = (size / total_bytes) * 100
|
737
|
-
color = "\033[31m" if size >= 1024**3 else "\033[32m"
|
738
|
-
print(f"{i:2d}. \033[36m{dir_path}\033[0m -- 大小: {color}{size_str}\033[0m (\033[33m{percentage:.2f}%\033[0m)")
|
739
|
-
#print()
|
740
|
-
|
741
|
-
def print_big_files(self, path: str, top_n: int = 50, min_size_bytes: int = 0):
|
742
|
-
"""打印大文件列表"""
|
743
|
-
print("=" * 60)
|
744
|
-
print("🗄️ 大文件分析")
|
745
|
-
print("=" * 60)
|
746
|
-
files = self.analyzer.analyze_largest_files(path, top_n=top_n, min_size_bytes=min_size_bytes)
|
747
|
-
if not files:
|
748
|
-
print("❌ 未找到符合条件的大文件")
|
749
|
-
return
|
750
|
-
total = self.analyzer.get_disk_usage("/")
|
751
|
-
disk_total = total['total'] if total else 1
|
752
|
-
for i, (file_path, size) in enumerate(files, 1):
|
753
|
-
size_str = self.analyzer.format_bytes(size)
|
754
|
-
pct = (size / disk_total) * 100
|
755
|
-
color = "\033[31m" if size >= 5 * 1024**3 else ("\033[33m" if size >= 1024**3 else "\033[32m")
|
756
|
-
print(f"{i:2d}. \033[36m{file_path}\033[0m -- 大小: {color}{size_str}\033[0m (\033[33m{pct:.2f}%\033[0m)")
|
757
|
-
print()
|
758
|
-
|
759
|
-
def print_system_info(self):
|
760
|
-
"""打印系统信息"""
|
761
|
-
print("=" * 60)
|
762
|
-
print("💻 系统信息")
|
763
|
-
print("=" * 60)
|
764
|
-
|
765
|
-
system_info = self.analyzer.get_system_info()
|
766
|
-
|
767
|
-
for key, value in system_info.items():
|
768
|
-
print(f"{key}: {value}")
|
769
|
-
print()
|
770
|
-
|
771
|
-
def export_report(self, output_file: str, path: str = "/"):
|
772
|
-
"""导出分析报告到JSON文件"""
|
773
|
-
print(f"正在生成报告并保存到: {output_file}")
|
774
|
-
|
775
|
-
usage_info = self.analyzer.get_disk_usage(path)
|
776
|
-
status, message = self.analyzer.get_disk_health_status(usage_info)
|
777
|
-
directories = self.analyzer.analyze_largest_directories(path)
|
778
|
-
system_info = self.analyzer.get_system_info()
|
779
|
-
# 可选:大文件分析
|
780
|
-
largest_files = []
|
781
|
-
try:
|
782
|
-
if getattr(self, 'args', None) and getattr(self.args, 'big_files', False):
|
783
|
-
files = self.analyzer.analyze_largest_files(
|
784
|
-
path,
|
785
|
-
top_n=getattr(self.args, 'big_files_top', 20),
|
786
|
-
min_size_bytes=getattr(self.args, 'big_files_min_bytes', 0),
|
787
|
-
)
|
788
|
-
largest_files = [
|
789
|
-
{
|
790
|
-
"path": file_path,
|
791
|
-
"size_bytes": size,
|
792
|
-
"size_formatted": self.analyzer.format_bytes(size),
|
793
|
-
}
|
794
|
-
for file_path, size in files
|
795
|
-
]
|
796
|
-
except Exception:
|
797
|
-
largest_files = []
|
798
|
-
|
799
|
-
report = {
|
800
|
-
"timestamp": subprocess.run(['date'], capture_output=True, text=True).stdout.strip(),
|
801
|
-
"system_info": system_info,
|
802
|
-
"disk_usage": usage_info,
|
803
|
-
"health_status": {
|
804
|
-
"status": status,
|
805
|
-
"message": message
|
806
|
-
},
|
807
|
-
"largest_directories": [
|
808
|
-
{
|
809
|
-
"path": dir_path,
|
810
|
-
"size_bytes": size,
|
811
|
-
"size_formatted": self.analyzer.format_bytes(size)
|
812
|
-
}
|
813
|
-
for dir_path, size in directories
|
814
|
-
],
|
815
|
-
"largest_files": largest_files
|
816
|
-
}
|
817
|
-
|
818
|
-
try:
|
819
|
-
with open(output_file, 'w', encoding='utf-8') as f:
|
820
|
-
json.dump(report, f, ensure_ascii=False, indent=2)
|
821
|
-
print(f"✅ 报告已保存到: {output_file}")
|
822
|
-
except Exception as e:
|
823
|
-
print(f"❌ 保存报告失败: {e}")
|
16
|
+
from space_cli import SpaceAnalyzer, SpaceCli, IndexStore
|
824
17
|
|
825
18
|
|
826
19
|
def main():
|
@@ -936,7 +129,7 @@ def main():
|
|
936
129
|
parser.add_argument(
|
937
130
|
'--version',
|
938
131
|
action='version',
|
939
|
-
version='SpaceCli 1.
|
132
|
+
version='SpaceCli 1.5.0'
|
940
133
|
)
|
941
134
|
|
942
135
|
args = parser.parse_args()
|
@@ -958,109 +151,142 @@ def main():
|
|
958
151
|
return 0
|
959
152
|
args.big_files_min_bytes = parse_size(getattr(args, 'big_files_min', '0'))
|
960
153
|
|
961
|
-
#
|
962
|
-
|
963
|
-
|
964
|
-
|
965
|
-
|
966
|
-
|
967
|
-
|
968
|
-
|
969
|
-
|
970
|
-
|
971
|
-
|
972
|
-
|
973
|
-
|
974
|
-
|
154
|
+
# 将主要执行流程提取为函数,便于交互模式复用
|
155
|
+
def run_once(run_args, interactive: bool = False):
|
156
|
+
# --home 优先设置路径
|
157
|
+
if getattr(run_args, 'home', False):
|
158
|
+
run_args.path = str(Path.home())
|
159
|
+
|
160
|
+
# 检查路径是否存在
|
161
|
+
if not os.path.exists(run_args.path):
|
162
|
+
print(f"❌ 错误: 路径 '{run_args.path}' 不存在")
|
163
|
+
if interactive:
|
164
|
+
return
|
165
|
+
sys.exit(1)
|
166
|
+
|
167
|
+
# 创建SpaceCli实例
|
168
|
+
space_cli = SpaceCli()
|
169
|
+
# 让 SpaceCli 实例可访问参数(用于索引与提示控制)
|
170
|
+
space_cli.args = run_args
|
171
|
+
|
975
172
|
try:
|
976
|
-
|
977
|
-
|
978
|
-
|
173
|
+
# 显示系统信息
|
174
|
+
space_cli.print_system_info()
|
175
|
+
|
176
|
+
# 显示磁盘健康状态
|
177
|
+
if run_args.health_only:
|
178
|
+
space_cli.print_disk_health(run_args.path)
|
179
|
+
|
180
|
+
# 显示目录分析
|
181
|
+
if run_args.directories_only or run_args.path !='/':
|
182
|
+
space_cli.print_largest_directories(run_args.path, run_args.top_n)
|
183
|
+
# 若分析路径为当前用户目录,做深度分析
|
184
|
+
if os.path.abspath(run_args.path) == os.path.abspath(str(Path.home())):
|
185
|
+
space_cli.print_home_deep_analysis(run_args.top_n)
|
186
|
+
|
187
|
+
# 应用目录分析
|
188
|
+
if run_args.apps:
|
189
|
+
space_cli.print_app_analysis(run_args.top_n)
|
190
|
+
|
191
|
+
# 大文件分析
|
192
|
+
if run_args.big_files:
|
193
|
+
space_cli.print_big_files(run_args.path, top_n=run_args.big_files_top, min_size_bytes=run_args.big_files_min_bytes)
|
194
|
+
|
195
|
+
# 内存释放优化
|
196
|
+
if getattr(run_args, 'memory_cleanup', False):
|
197
|
+
space_cli.print_memory_cleanup()
|
198
|
+
|
199
|
+
# 导出报告
|
200
|
+
if run_args.export:
|
201
|
+
space_cli.export_report(run_args.export, run_args.path)
|
202
|
+
|
203
|
+
print("=" * 60)
|
204
|
+
print("✅ 分析完成!")
|
205
|
+
print("=" * 60)
|
206
|
+
|
207
|
+
except KeyboardInterrupt:
|
208
|
+
print("\n\n❌ 用户中断操作")
|
209
|
+
if interactive:
|
210
|
+
return
|
211
|
+
sys.exit(1)
|
212
|
+
except Exception as e:
|
213
|
+
print(f"\n❌ 发生错误: {e}")
|
214
|
+
if interactive:
|
215
|
+
return
|
216
|
+
sys.exit(1)
|
217
|
+
|
218
|
+
# 交互式菜单:当未传入任何参数时触发(默认执行全部),执行完后返回菜单
|
219
|
+
if len(sys.argv) == 1:
|
220
|
+
while True:
|
221
|
+
print("=" * 60)
|
222
|
+
print("🧭 SpaceCli 菜单(直接回车 = 执行全部项目)")
|
223
|
+
print("=" * 60)
|
224
|
+
home_path = str(Path.home())
|
225
|
+
# 动态获取磁盘与内存占用率
|
226
|
+
try:
|
227
|
+
analyzer_for_menu = SpaceAnalyzer()
|
228
|
+
disk_info = analyzer_for_menu.get_disk_usage('/')
|
229
|
+
disk_usage_display = f"{disk_info['usage_percent']:.1f}%" if disk_info else "未知"
|
230
|
+
sysinfo = analyzer_for_menu.get_system_info()
|
231
|
+
mem_usage_display = sysinfo.get("内存使用率", "未知")
|
232
|
+
except Exception:
|
233
|
+
disk_usage_display = "未知"
|
234
|
+
mem_usage_display = "未知"
|
235
|
+
|
236
|
+
print("1) \033[36m执行主要项目(系统信息 + 健康 + 应用)\033[0m")
|
237
|
+
print(f"2) \033[36m当前用户目录分析(路径: {home_path})\033[0m")
|
238
|
+
print("3) \033[36m仅显示系统信息\033[0m")
|
239
|
+
print(f"4) \033[36m仅显示磁盘健康状态\033[0m — 当前磁盘占用: \033[33m{disk_usage_display}\033[0m")
|
240
|
+
print("5) \033[36m交互式目录空间分析\033[0m")
|
241
|
+
print("6) \033[36m仅分析程序应用目录空间\033[0m")
|
242
|
+
print("7) \033[36m仅进行大文件分析(比较耗时,可随时终止)\033[0m")
|
243
|
+
print(f"8) \033[36m内存释放优化\033[0m — 当前内存使用率: \033[33m{mem_usage_display}\033[0m")
|
244
|
+
print("0) \033[36m退出\033[0m")
|
245
|
+
try:
|
246
|
+
choice = input("请选择 [回车=1]: ").strip()
|
247
|
+
except EOFError:
|
248
|
+
choice = ""
|
979
249
|
|
980
|
-
|
981
|
-
sys.exit(0)
|
982
|
-
elif choice == "2": # 仅显示当前用户目录分析
|
983
|
-
args.path = home_path
|
984
|
-
args.apps = False
|
985
|
-
args.health_only = False
|
986
|
-
args.directories_only = False
|
987
|
-
elif choice == "3": # 仅显示系统信息
|
988
|
-
args.health_only = True
|
989
|
-
args.directories_only = False
|
990
|
-
args.apps = False
|
991
|
-
elif choice == "4": # 仅显示磁盘健康状态
|
992
|
-
args.health_only = False
|
993
|
-
args.directories_only = True
|
994
|
-
args.apps = False
|
995
|
-
elif choice == "5": # 仅显示最大目录列表
|
250
|
+
# 重置
|
996
251
|
args.health_only = False
|
997
252
|
args.directories_only = False
|
998
253
|
args.apps = False
|
999
|
-
|
1000
|
-
args.
|
1001
|
-
args.
|
1002
|
-
|
1003
|
-
|
1004
|
-
|
1005
|
-
|
1006
|
-
|
1007
|
-
|
1008
|
-
|
1009
|
-
|
1010
|
-
|
1011
|
-
|
1012
|
-
|
1013
|
-
|
1014
|
-
|
1015
|
-
|
1016
|
-
|
1017
|
-
|
1018
|
-
|
1019
|
-
|
1020
|
-
|
1021
|
-
|
1022
|
-
|
1023
|
-
|
1024
|
-
|
1025
|
-
|
1026
|
-
try:
|
1027
|
-
# 显示系统信息
|
1028
|
-
if not args.directories_only:
|
1029
|
-
space_cli.print_system_info()
|
1030
|
-
|
1031
|
-
# 显示磁盘健康状态
|
1032
|
-
if not args.directories_only:
|
1033
|
-
space_cli.print_disk_health(args.path)
|
1034
|
-
|
1035
|
-
# 显示目录分析
|
1036
|
-
if not args.health_only:
|
1037
|
-
space_cli.print_largest_directories(args.path, args.top_n)
|
1038
|
-
# 若分析路径为当前用户目录,做深度分析
|
1039
|
-
if os.path.abspath(args.path) == os.path.abspath(str(Path.home())):
|
1040
|
-
space_cli.print_home_deep_analysis(args.top_n)
|
1041
|
-
|
1042
|
-
# 应用目录分析
|
1043
|
-
if args.apps:
|
1044
|
-
space_cli.print_app_analysis(args.top_n)
|
254
|
+
args.big_files = False
|
255
|
+
args.memory_cleanup = False
|
256
|
+
args.path = '/'
|
257
|
+
|
258
|
+
if choice == "0":
|
259
|
+
sys.exit(0)
|
260
|
+
elif choice == "2":
|
261
|
+
args.path = home_path
|
262
|
+
args.directories_only = True
|
263
|
+
elif choice == "3":
|
264
|
+
pass
|
265
|
+
elif choice == "4":
|
266
|
+
args.health_only = True
|
267
|
+
elif choice == "5":
|
268
|
+
args.directories_only = True
|
269
|
+
elif choice == "6":
|
270
|
+
args.apps = True
|
271
|
+
elif choice == "7":
|
272
|
+
args.big_files = True
|
273
|
+
elif choice == "8":
|
274
|
+
args.memory_cleanup = True
|
275
|
+
else:
|
276
|
+
args.health_only = True
|
277
|
+
args.apps = True
|
278
|
+
|
279
|
+
run_once(args, interactive=True)
|
1045
280
|
|
1046
|
-
|
1047
|
-
|
1048
|
-
|
1049
|
-
|
1050
|
-
|
1051
|
-
|
1052
|
-
|
1053
|
-
|
1054
|
-
|
1055
|
-
print("✅ 分析完成!")
|
1056
|
-
print("=" * 60)
|
1057
|
-
|
1058
|
-
except KeyboardInterrupt:
|
1059
|
-
print("\n\n❌ 用户中断操作")
|
1060
|
-
sys.exit(1)
|
1061
|
-
except Exception as e:
|
1062
|
-
print(f"\n❌ 发生错误: {e}")
|
1063
|
-
sys.exit(1)
|
281
|
+
try:
|
282
|
+
back = input("按回车返回菜单,输入 q 退出: ").strip().lower()
|
283
|
+
except EOFError:
|
284
|
+
back = ""
|
285
|
+
if back == 'q':
|
286
|
+
sys.exit(0)
|
287
|
+
else:
|
288
|
+
# 非交互:按参数执行一次
|
289
|
+
run_once(args, interactive=False)
|
1064
290
|
|
1065
291
|
|
1066
292
|
if __name__ == "__main__":
|
@@ -1,6 +0,0 @@
|
|
1
|
-
space_cli.py,sha256=ihUdZL8ojzxfK4Z81F-fJ6V-8Tn8AprP1mUAW2zp3iY,42738
|
2
|
-
myspace_cli-1.3.0.dist-info/METADATA,sha256=_d8mzS3A3a3XDpgzDCbRbXeE8HX9LdDQEy25g0Tek8k,10674
|
3
|
-
myspace_cli-1.3.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
4
|
-
myspace_cli-1.3.0.dist-info/entry_points.txt,sha256=sIVEmPf6W8aNa7KiqOwAI6JrsgHlQciO5xH2G29tKPQ,45
|
5
|
-
myspace_cli-1.3.0.dist-info/top_level.txt,sha256=lnUfbQX9h-9ZjecMVCOWucS2kx69FwTndlrb48S20Sc,10
|
6
|
-
myspace_cli-1.3.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|