myspace-cli 1.4.0__py3-none-any.whl → 1.6.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.4.0.dist-info → myspace_cli-1.6.0.dist-info}/METADATA +4 -2
- myspace_cli-1.6.0.dist-info/RECORD +9 -0
- space_cli/__init__.py +10 -0
- space_cli/index_store.py +93 -0
- space_cli/space_analyzer.py +419 -0
- space_cli.py → space_cli/space_cli.py +38 -610
- myspace_cli-1.4.0.dist-info/RECORD +0 -6
- {myspace_cli-1.4.0.dist-info → myspace_cli-1.6.0.dist-info}/WHEEL +0 -0
- {myspace_cli-1.4.0.dist-info → myspace_cli-1.6.0.dist-info}/entry_points.txt +0 -0
- {myspace_cli-1.4.0.dist-info → myspace_cli-1.6.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.6.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
|
@@ -38,7 +38,6 @@ space-cli是一个开源的macOS命令行小工具,用于分析磁盘空间健
|
|
38
38
|
- 🗑️ **一键删除应用** - 在应用分析列表中输入序号即可一键删除所选应用及其缓存(含二次确认)
|
39
39
|
- 🏠 **用户目录深度分析** - 针对 `~/Library`、`~/Downloads`、`~/Documents` 分别下探并展示Top N目录
|
40
40
|
- 🗄️ **大文件分析** - 扫描并列出指定路径下最大的文件,支持数量和最小体积阈值
|
41
|
-
- 🔄 **返回上一级** - 在下探分析中支持选择0返回上一级目录
|
42
41
|
- ⏱️ **支持MCP调用** - 支持你自己的AI Agent无缝调用磁盘空间信息
|
43
42
|
|
44
43
|
## 安装
|
@@ -62,6 +61,9 @@ python3 -m space_cli
|
|
62
61
|
# 如果要使用高级的功能,请使用更复杂的命令行参数,可以运行help
|
63
62
|
space-cli --help
|
64
63
|
|
64
|
+
# 升级到最新的版本
|
65
|
+
pip install --upgrade myspace-cli
|
66
|
+
|
65
67
|
```
|
66
68
|
|
67
69
|
### 方法2:直接使用
|
@@ -0,0 +1,9 @@
|
|
1
|
+
space_cli/__init__.py,sha256=lWtTRBahXp0sPSpoN6V5Oviv6k5gXVlEkM4iw0l-dZY,232
|
2
|
+
space_cli/index_store.py,sha256=E6THjLgTbH-mOLu8s619-dW8ikto6CyNcizhvVoX5lY,2967
|
3
|
+
space_cli/space_analyzer.py,sha256=ax-faTP_4UNqTFDEzvkuN9hJ4T8KrJbvNpR1CEbKqk8,18737
|
4
|
+
space_cli/space_cli.py,sha256=6IgUzPguU-MW4rG7z2VqreJEn4ReQh6usuHYkHNME7Y,26917
|
5
|
+
myspace_cli-1.6.0.dist-info/METADATA,sha256=B4IZ05IhRUKLtH3L0IBc-0nsV6SyMZ2DCXYcVXjNIOk,12727
|
6
|
+
myspace_cli-1.6.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
7
|
+
myspace_cli-1.6.0.dist-info/entry_points.txt,sha256=sIVEmPf6W8aNa7KiqOwAI6JrsgHlQciO5xH2G29tKPQ,45
|
8
|
+
myspace_cli-1.6.0.dist-info/top_level.txt,sha256=lnUfbQX9h-9ZjecMVCOWucS2kx69FwTndlrb48S20Sc,10
|
9
|
+
myspace_cli-1.6.0.dist-info/RECORD,,
|
space_cli/__init__.py
ADDED
space_cli/index_store.py
ADDED
@@ -0,0 +1,93 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
# -*- coding: utf-8 -*-
|
3
|
+
"""
|
4
|
+
IndexStore - 目录大小索引缓存管理器
|
5
|
+
"""
|
6
|
+
|
7
|
+
import os
|
8
|
+
import json
|
9
|
+
from pathlib import Path
|
10
|
+
from typing import List, Tuple, Dict
|
11
|
+
from datetime import datetime, timedelta
|
12
|
+
|
13
|
+
|
14
|
+
class IndexStore:
|
15
|
+
"""简单的目录大小索引缓存管理器"""
|
16
|
+
|
17
|
+
def __init__(self, index_file: str = None):
|
18
|
+
home = str(Path.home())
|
19
|
+
cache_dir = os.path.join(home, ".spacecli")
|
20
|
+
os.makedirs(cache_dir, exist_ok=True)
|
21
|
+
self.index_file = index_file or os.path.join(cache_dir, "index.json")
|
22
|
+
self._data: Dict = {}
|
23
|
+
self._loaded = False
|
24
|
+
|
25
|
+
def load(self) -> None:
|
26
|
+
if self._loaded:
|
27
|
+
return
|
28
|
+
if os.path.exists(self.index_file):
|
29
|
+
try:
|
30
|
+
with open(self.index_file, "r", encoding="utf-8") as f:
|
31
|
+
self._data = json.load(f)
|
32
|
+
except Exception:
|
33
|
+
self._data = {}
|
34
|
+
self._loaded = True
|
35
|
+
|
36
|
+
def save(self) -> None:
|
37
|
+
try:
|
38
|
+
with open(self.index_file, "w", encoding="utf-8") as f:
|
39
|
+
json.dump(self._data, f, ensure_ascii=False, indent=2)
|
40
|
+
except Exception:
|
41
|
+
pass
|
42
|
+
|
43
|
+
def _key(self, root_path: str) -> str:
|
44
|
+
return os.path.abspath(root_path)
|
45
|
+
|
46
|
+
def get(self, root_path: str) -> Dict:
|
47
|
+
self.load()
|
48
|
+
return self._data.get(self._key(root_path))
|
49
|
+
|
50
|
+
def set(self, root_path: str, entries: List[Tuple[str, int]]) -> None:
|
51
|
+
self.load()
|
52
|
+
now_iso = datetime.utcnow().isoformat()
|
53
|
+
self._data[self._key(root_path)] = {
|
54
|
+
"updated_at": now_iso,
|
55
|
+
"entries": [{"path": p, "size": s} for p, s in entries],
|
56
|
+
}
|
57
|
+
self.save()
|
58
|
+
|
59
|
+
def is_fresh(self, root_path: str, ttl_hours: int) -> bool:
|
60
|
+
self.load()
|
61
|
+
rec = self._data.get(self._key(root_path))
|
62
|
+
if not rec:
|
63
|
+
return False
|
64
|
+
try:
|
65
|
+
updated_at = datetime.fromisoformat(rec.get("updated_at"))
|
66
|
+
return datetime.utcnow() - updated_at <= timedelta(hours=ttl_hours)
|
67
|
+
except Exception:
|
68
|
+
return False
|
69
|
+
|
70
|
+
# 命名缓存(非路径键),适合应用分析等聚合结果
|
71
|
+
def get_named(self, name: str) -> Dict:
|
72
|
+
self.load()
|
73
|
+
return self._data.get(name)
|
74
|
+
|
75
|
+
def set_named(self, name: str, entries: List[Tuple[str, int]]) -> None:
|
76
|
+
self.load()
|
77
|
+
now_iso = datetime.utcnow().isoformat()
|
78
|
+
self._data[name] = {
|
79
|
+
"updated_at": now_iso,
|
80
|
+
"entries": [{"name": p, "size": s} for p, s in entries],
|
81
|
+
}
|
82
|
+
self.save()
|
83
|
+
|
84
|
+
def is_fresh_named(self, name: str, ttl_hours: int) -> bool:
|
85
|
+
self.load()
|
86
|
+
rec = self._data.get(name)
|
87
|
+
if not rec:
|
88
|
+
return False
|
89
|
+
try:
|
90
|
+
updated_at = datetime.fromisoformat(rec.get("updated_at"))
|
91
|
+
return datetime.utcnow() - updated_at <= timedelta(hours=ttl_hours)
|
92
|
+
except Exception:
|
93
|
+
return False
|
@@ -0,0 +1,419 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
# -*- coding: utf-8 -*-
|
3
|
+
"""
|
4
|
+
SpaceAnalyzer - 磁盘空间分析器
|
5
|
+
"""
|
6
|
+
|
7
|
+
import os
|
8
|
+
import sys
|
9
|
+
import subprocess
|
10
|
+
import heapq
|
11
|
+
import time
|
12
|
+
from pathlib import Path
|
13
|
+
from typing import List, Tuple, Dict
|
14
|
+
from .index_store import IndexStore
|
15
|
+
|
16
|
+
|
17
|
+
class SpaceAnalyzer:
|
18
|
+
"""磁盘空间分析器"""
|
19
|
+
|
20
|
+
def __init__(self):
|
21
|
+
self.warning_threshold = 80 # 警告阈值百分比
|
22
|
+
self.critical_threshold = 90 # 严重阈值百分比
|
23
|
+
# 忽略的目录列表, 这些目录时系统目录,不需要分析
|
24
|
+
self.ignore_dir_list = [
|
25
|
+
"/System", # 系统目录
|
26
|
+
"/Volumes", # 外部挂载卷
|
27
|
+
"/private", # 私有目录
|
28
|
+
".Trash", # 垃圾桶
|
29
|
+
".localized", # 本地化目录
|
30
|
+
]
|
31
|
+
|
32
|
+
|
33
|
+
def get_disk_usage(self, path: str = "/") -> Dict:
|
34
|
+
"""获取磁盘使用情况"""
|
35
|
+
try:
|
36
|
+
statvfs = os.statvfs(path)
|
37
|
+
|
38
|
+
# 计算磁盘空间信息
|
39
|
+
total_bytes = statvfs.f_frsize * statvfs.f_blocks
|
40
|
+
free_bytes = statvfs.f_frsize * statvfs.f_bavail
|
41
|
+
used_bytes = total_bytes - free_bytes
|
42
|
+
|
43
|
+
# 计算百分比
|
44
|
+
usage_percent = (used_bytes / total_bytes) * 100
|
45
|
+
|
46
|
+
return {
|
47
|
+
'total': total_bytes,
|
48
|
+
'used': used_bytes,
|
49
|
+
'free': free_bytes,
|
50
|
+
'usage_percent': usage_percent,
|
51
|
+
'path': path
|
52
|
+
}
|
53
|
+
except Exception as e:
|
54
|
+
print(f"错误:无法获取磁盘使用情况 - {e}")
|
55
|
+
return None
|
56
|
+
|
57
|
+
def get_disk_health_status(self, usage_info: Dict) -> Tuple[str, str]:
|
58
|
+
"""评估磁盘健康状态"""
|
59
|
+
if not usage_info:
|
60
|
+
return "未知", "无法获取磁盘信息"
|
61
|
+
|
62
|
+
usage_percent = usage_info['usage_percent']
|
63
|
+
|
64
|
+
if usage_percent >= self.critical_threshold:
|
65
|
+
return "严重", "磁盘空间严重不足!请立即清理磁盘空间"
|
66
|
+
elif usage_percent >= self.warning_threshold:
|
67
|
+
return "警告", "磁盘空间不足,建议清理一些文件"
|
68
|
+
else:
|
69
|
+
return "良好", "磁盘空间充足"
|
70
|
+
|
71
|
+
def format_bytes(self, bytes_value: int) -> str:
|
72
|
+
"""格式化字节数为人类可读格式"""
|
73
|
+
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
74
|
+
if bytes_value < 1024.0:
|
75
|
+
return f"{bytes_value:.1f} {unit}"
|
76
|
+
bytes_value /= 1024.0
|
77
|
+
return f"{bytes_value:.1f} PB"
|
78
|
+
|
79
|
+
def get_directory_size(self, path: str) -> int:
|
80
|
+
"""高性能计算目录大小。
|
81
|
+
|
82
|
+
优先使用 macOS 的 du -sk(以 KiB 为单位,速度快,原生命令可处理边界情况),
|
83
|
+
若 du 调用失败则回退到基于 os.scandir 的非递归遍历实现(避免 os.walk 的函数调用开销)。
|
84
|
+
"""
|
85
|
+
# 优先尝试 du -sk(BSD du 在 macOS 可用)。
|
86
|
+
try:
|
87
|
+
# du 输出形如: "<kib>\t<path>\n"
|
88
|
+
result = subprocess.run([
|
89
|
+
'du', '-sk', path
|
90
|
+
], capture_output=True, text=True, check=True)
|
91
|
+
out = result.stdout.strip().split('\t', 1)[0].strip()
|
92
|
+
kib = int(out)
|
93
|
+
return kib * 1024
|
94
|
+
except Exception:
|
95
|
+
# du 不可用或失败时回退到 Python 实现
|
96
|
+
pass
|
97
|
+
|
98
|
+
total_size = 0
|
99
|
+
# 基于栈的迭代遍历,避免递归栈与 os.walk 的额外开销
|
100
|
+
stack = [path]
|
101
|
+
while stack:
|
102
|
+
current = stack.pop()
|
103
|
+
try:
|
104
|
+
with os.scandir(current) as it:
|
105
|
+
for entry in it:
|
106
|
+
# 跳过符号链接,避免循环与跨文件系统问题
|
107
|
+
try:
|
108
|
+
if entry.is_symlink():
|
109
|
+
continue
|
110
|
+
if entry.is_file(follow_symlinks=False):
|
111
|
+
try:
|
112
|
+
total_size += entry.stat(follow_symlinks=False).st_size
|
113
|
+
except (OSError, FileNotFoundError, PermissionError):
|
114
|
+
continue
|
115
|
+
elif entry.is_dir(follow_symlinks=False):
|
116
|
+
stack.append(entry.path)
|
117
|
+
except (OSError, FileNotFoundError, PermissionError):
|
118
|
+
continue
|
119
|
+
except (OSError, FileNotFoundError, PermissionError):
|
120
|
+
# 无法进入该目录则跳过
|
121
|
+
continue
|
122
|
+
return total_size
|
123
|
+
|
124
|
+
def analyze_largest_files(self, root_path: str = "/", top_n: int = 50,
|
125
|
+
min_size_bytes: int = 0) -> List[Tuple[str, int]]:
|
126
|
+
"""扫描并返回体积最大的文件列表"""
|
127
|
+
print("正在扫描大文件,这可能需要一些时间...")
|
128
|
+
heap: List[Tuple[int, str]] = [] # 最小堆 (size, path)
|
129
|
+
scanned = 0
|
130
|
+
try:
|
131
|
+
for dirpath, dirnames, filenames in os.walk(root_path):
|
132
|
+
|
133
|
+
# 过滤以ignore_dir_list中的目录开头的文件
|
134
|
+
if any(dirpath.startswith(dir) for dir in self.ignore_dir_list):
|
135
|
+
continue
|
136
|
+
|
137
|
+
# 进度提示:单行覆盖当前目录
|
138
|
+
dirpath_display = dirpath[-80:] # 截取最后50个字符
|
139
|
+
if dirpath_display == "":
|
140
|
+
dirpath_display = dirpath
|
141
|
+
sys.stdout.write(f"\r\033[K-> 正在读取: \033[36m{dirpath_display}\033[0m")
|
142
|
+
sys.stdout.flush()
|
143
|
+
for filename in filenames:
|
144
|
+
filepath = os.path.join(dirpath, filename)
|
145
|
+
try:
|
146
|
+
size = os.path.getsize(filepath)
|
147
|
+
except (OSError, FileNotFoundError, PermissionError):
|
148
|
+
continue
|
149
|
+
if size < min_size_bytes:
|
150
|
+
continue
|
151
|
+
if len(heap) < top_n:
|
152
|
+
heapq.heappush(heap, (size, filepath))
|
153
|
+
else:
|
154
|
+
if size > heap[0][0]:
|
155
|
+
heapq.heapreplace(heap, (size, filepath))
|
156
|
+
scanned += 1
|
157
|
+
if scanned % 500 == 0:
|
158
|
+
dirpath_display = dirpath[-80:] # 截取最后50个字符
|
159
|
+
if dirpath_display == "":
|
160
|
+
dirpath_display = dirpath
|
161
|
+
# 间隔性进度输出(单行覆盖)
|
162
|
+
sys.stdout.write(f"\r\033[K-> 正在读取: \033[36m{dirpath_display}\033[0m 已扫描: \033[32m{scanned}\033[0m")
|
163
|
+
sys.stdout.flush()
|
164
|
+
except KeyboardInterrupt:
|
165
|
+
print("\n用户中断扫描,返回当前结果...")
|
166
|
+
except Exception as e:
|
167
|
+
print(f"扫描时出错: {e}")
|
168
|
+
finally:
|
169
|
+
sys.stdout.write("\n")
|
170
|
+
sys.stdout.flush()
|
171
|
+
# 转换为按体积降序列表
|
172
|
+
result = sorted([(p, s) for s, p in heap], key=lambda x: x[1], reverse=False)
|
173
|
+
result.sort(key=lambda x: x[1])
|
174
|
+
result = sorted([(p, s) for s, p in heap], key=lambda x: x[1])
|
175
|
+
# 正确:按 size 降序
|
176
|
+
result = sorted([(p, s) for s, p in heap], key=lambda x: x[1], reverse=True)
|
177
|
+
# 以上为了避免编辑器误合并,最终以最后一行排序为准
|
178
|
+
return result
|
179
|
+
|
180
|
+
def analyze_largest_directories(self, root_path: str = "/", max_depth: int = 2, top_n: int = 20,
|
181
|
+
index: IndexStore = None, use_index: bool = True,
|
182
|
+
reindex: bool = False, index_ttl_hours: int = 24,
|
183
|
+
prompt: bool = True) -> List[Tuple[str, int]]:
|
184
|
+
"""分析占用空间最大的目录(支持索引缓存)"""
|
185
|
+
# 索引命中
|
186
|
+
if use_index and index and not reindex and index.is_fresh(root_path, index_ttl_hours):
|
187
|
+
cached = index.get(root_path)
|
188
|
+
if cached and cached.get("entries"):
|
189
|
+
if prompt and sys.stdin.isatty():
|
190
|
+
try:
|
191
|
+
ans = input("检测到最近索引,是否使用缓存结果而不重新索引?[Y/n]: ").strip().lower()
|
192
|
+
if ans in ("", "y", "yes"):
|
193
|
+
return [(e["path"], int(e["size"])) for e in cached["entries"]][:top_n]
|
194
|
+
except EOFError:
|
195
|
+
pass
|
196
|
+
else:
|
197
|
+
return [(e["path"], int(e["size"])) for e in cached["entries"]][:top_n]
|
198
|
+
|
199
|
+
print("正在分析目录大小,这可能需要一些时间...")
|
200
|
+
|
201
|
+
|
202
|
+
|
203
|
+
directory_sizes = []
|
204
|
+
|
205
|
+
try:
|
206
|
+
# 获取根目录下的直接子目录
|
207
|
+
for item in os.listdir(root_path):
|
208
|
+
item_path = os.path.join(root_path, item)
|
209
|
+
|
210
|
+
# 跳过隐藏文件和系统文件
|
211
|
+
if item.startswith('.') and item not in ['.Trash', '.localized']:
|
212
|
+
continue
|
213
|
+
|
214
|
+
if item_path in self.ignore_dir_list:
|
215
|
+
continue
|
216
|
+
|
217
|
+
if os.path.isdir(item_path):
|
218
|
+
try:
|
219
|
+
# 进度提示:当前正在读取的目录(单行覆盖)
|
220
|
+
sys.stdout.write(f"\r\033[K-> 正在读取: \033[36m{item_path}\033[0m")
|
221
|
+
sys.stdout.flush()
|
222
|
+
size = self.get_directory_size(item_path)
|
223
|
+
directory_sizes.append((item_path, size))
|
224
|
+
#print(f"已分析: {item_path} ({self.format_bytes(size)})")
|
225
|
+
print(f" ({self.format_bytes(size)})\033[0m")
|
226
|
+
except (OSError, PermissionError):
|
227
|
+
print(f"跳过无法访问的目录: {item_path}")
|
228
|
+
continue
|
229
|
+
# 结束时换行,避免后续输出粘连在同一行
|
230
|
+
sys.stdout.write("\n")
|
231
|
+
sys.stdout.flush()
|
232
|
+
|
233
|
+
# 按大小排序
|
234
|
+
directory_sizes.sort(key=lambda x: x[1], reverse=True)
|
235
|
+
# 写入索引
|
236
|
+
if index:
|
237
|
+
try:
|
238
|
+
index.set(root_path, directory_sizes)
|
239
|
+
except Exception:
|
240
|
+
pass
|
241
|
+
return directory_sizes[:top_n]
|
242
|
+
|
243
|
+
except Exception as e:
|
244
|
+
print(f"分析目录时出错: {e}")
|
245
|
+
return []
|
246
|
+
|
247
|
+
def get_system_info(self) -> Dict:
|
248
|
+
"""获取系统信息(包括 CPU、内存、GPU、硬盘等硬件信息)"""
|
249
|
+
system_info = {}
|
250
|
+
|
251
|
+
try:
|
252
|
+
# 获取系统版本信息
|
253
|
+
result = subprocess.run(['sw_vers'], capture_output=True, text=True)
|
254
|
+
for line in result.stdout.split('\n'):
|
255
|
+
if ':' in line:
|
256
|
+
key, value = line.split(':', 1)
|
257
|
+
system_info[key.strip()] = value.strip()
|
258
|
+
except Exception:
|
259
|
+
system_info["ProductName"] = "macOS"
|
260
|
+
system_info["ProductVersion"] = "未知"
|
261
|
+
|
262
|
+
try:
|
263
|
+
# 获取 CPU 信息
|
264
|
+
cpu_result = subprocess.run(['sysctl', '-n', 'machdep.cpu.brand_string'],
|
265
|
+
capture_output=True, text=True)
|
266
|
+
if cpu_result.returncode == 0:
|
267
|
+
system_info["CPU"] = cpu_result.stdout.strip()
|
268
|
+
|
269
|
+
# 获取 CPU 核心数
|
270
|
+
cores_result = subprocess.run(['sysctl', '-n', 'hw.ncpu'],
|
271
|
+
capture_output=True, text=True)
|
272
|
+
if cores_result.returncode == 0:
|
273
|
+
system_info["CPU核心数"] = cores_result.stdout.strip()
|
274
|
+
|
275
|
+
except Exception:
|
276
|
+
system_info["CPU"] = "未知"
|
277
|
+
system_info["CPU核心数"] = "未知"
|
278
|
+
|
279
|
+
try:
|
280
|
+
# 获取内存信息
|
281
|
+
mem_result = subprocess.run(['sysctl', '-n', 'hw.memsize'],
|
282
|
+
capture_output=True, text=True)
|
283
|
+
if mem_result.returncode == 0:
|
284
|
+
mem_bytes = int(mem_result.stdout.strip())
|
285
|
+
system_info["内存"] = self.format_bytes(mem_bytes)
|
286
|
+
# 计算内存使用率(基于 vm_stat 与页面大小)
|
287
|
+
try:
|
288
|
+
pagesize_res = subprocess.run(['sysctl', '-n', 'hw.pagesize'], capture_output=True, text=True)
|
289
|
+
pagesize = int(pagesize_res.stdout.strip()) if pagesize_res.returncode == 0 else 4096
|
290
|
+
vm_res = subprocess.run(['vm_stat'], capture_output=True, text=True)
|
291
|
+
if vm_res.returncode == 0:
|
292
|
+
import re
|
293
|
+
page_counts = {}
|
294
|
+
for line in vm_res.stdout.splitlines():
|
295
|
+
if ':' not in line:
|
296
|
+
continue
|
297
|
+
key, val = line.split(':', 1)
|
298
|
+
m = re.search(r"(\d+)", val.replace('.', ''))
|
299
|
+
if not m:
|
300
|
+
continue
|
301
|
+
count = int(m.group(1))
|
302
|
+
page_counts[key.strip()] = count
|
303
|
+
|
304
|
+
free_pages = page_counts.get('Pages free', 0) + page_counts.get('Pages speculative', 0)
|
305
|
+
active_pages = page_counts.get('Pages active', 0)
|
306
|
+
inactive_pages = page_counts.get('Pages inactive', 0)
|
307
|
+
wired_pages = page_counts.get('Pages wired down', 0) or page_counts.get('Pages wired', 0)
|
308
|
+
compressed_pages = page_counts.get('Pages occupied by compressor', 0)
|
309
|
+
|
310
|
+
used_pages = active_pages + inactive_pages + wired_pages + compressed_pages
|
311
|
+
total_pages = used_pages + free_pages
|
312
|
+
if total_pages > 0:
|
313
|
+
usage_percent = (used_pages / total_pages) * 100.0
|
314
|
+
system_info["内存使用率"] = f"{usage_percent:.1f}%"
|
315
|
+
except Exception:
|
316
|
+
# 安静失败,不影响主流程
|
317
|
+
print("计算内存使用率失败")
|
318
|
+
pass
|
319
|
+
except Exception:
|
320
|
+
system_info["内存"] = "未知"
|
321
|
+
|
322
|
+
|
323
|
+
try:
|
324
|
+
# 获取启动时间
|
325
|
+
boot_result = subprocess.run(['uptime'], capture_output=True, text=True)
|
326
|
+
if boot_result.returncode == 0:
|
327
|
+
uptime_line = boot_result.stdout.strip()
|
328
|
+
system_info["运行时间"] = uptime_line
|
329
|
+
except Exception:
|
330
|
+
system_info["运行时间"] = "未知"
|
331
|
+
|
332
|
+
# 添加 space-cli 版本信息
|
333
|
+
try:
|
334
|
+
# 简单解析 pyproject.toml 文件中的版本号
|
335
|
+
with open('pyproject.toml', 'r', encoding='utf-8') as f:
|
336
|
+
content = f.read()
|
337
|
+
# 查找 version = "x.x.x" 行
|
338
|
+
import re
|
339
|
+
version_match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
|
340
|
+
if version_match:
|
341
|
+
system_info['SpaceCli Version'] = version_match.group(1)
|
342
|
+
else:
|
343
|
+
system_info['SpaceCli Version'] = '未知'
|
344
|
+
except Exception:
|
345
|
+
system_info['SpaceCli Version'] = '未知'
|
346
|
+
|
347
|
+
return system_info
|
348
|
+
|
349
|
+
def memory_cleanup(self) -> Dict:
|
350
|
+
"""执行内存释放优化"""
|
351
|
+
cleanup_results = {
|
352
|
+
"purged_memory": 0,
|
353
|
+
"cleared_caches": [],
|
354
|
+
"freed_swap": 0,
|
355
|
+
"errors": []
|
356
|
+
}
|
357
|
+
|
358
|
+
try:
|
359
|
+
|
360
|
+
# 2. 强制垃圾回收
|
361
|
+
print("🔄 正在执行垃圾回收...")
|
362
|
+
import gc
|
363
|
+
collected = gc.collect()
|
364
|
+
cleanup_results["purged_memory"] += collected
|
365
|
+
|
366
|
+
# 3. 清理 Python 缓存
|
367
|
+
print("🗑️ 正在清理 Python 缓存...")
|
368
|
+
try:
|
369
|
+
# 清理 __pycache__ 目录
|
370
|
+
import shutil
|
371
|
+
for root, dirs, files in os.walk('/tmp'):
|
372
|
+
for dir_name in dirs:
|
373
|
+
if dir_name == '__pycache__':
|
374
|
+
cache_path = os.path.join(root, dir_name)
|
375
|
+
try:
|
376
|
+
shutil.rmtree(cache_path)
|
377
|
+
cleanup_results["cleared_caches"].append(f"Python缓存: {cache_path}")
|
378
|
+
except Exception:
|
379
|
+
pass
|
380
|
+
except Exception as e:
|
381
|
+
cleanup_results["errors"].append(f"Python缓存清理失败: {e}")
|
382
|
+
|
383
|
+
# 4. 清理临时文件
|
384
|
+
print("📁 正在清理临时文件...")
|
385
|
+
temp_dirs = ['/tmp', '/var/tmp']
|
386
|
+
for temp_dir in temp_dirs:
|
387
|
+
if os.path.exists(temp_dir):
|
388
|
+
try:
|
389
|
+
for item in os.listdir(temp_dir):
|
390
|
+
item_path = os.path.join(temp_dir, item)
|
391
|
+
# 只清理超过1小时的文件
|
392
|
+
if os.path.isfile(item_path):
|
393
|
+
file_age = time.time() - os.path.getmtime(item_path)
|
394
|
+
if file_age > 3600: # 1小时
|
395
|
+
try:
|
396
|
+
os.remove(item_path)
|
397
|
+
cleanup_results["cleared_caches"].append(f"临时文件: {item_path}")
|
398
|
+
except Exception:
|
399
|
+
pass
|
400
|
+
except Exception as e:
|
401
|
+
cleanup_results["errors"].append(f"临时文件清理失败: {e}")
|
402
|
+
|
403
|
+
# 5. 尝试释放交换空间(需要管理员权限)
|
404
|
+
print("💾 正在尝试释放交换空间...(需要登录密码授权此操作,此操作不会保存密码)")
|
405
|
+
try:
|
406
|
+
# 检查交换使用情况
|
407
|
+
swap_result = subprocess.run(['sysctl', 'vm.swapusage'],
|
408
|
+
capture_output=True, text=True)
|
409
|
+
if swap_result.returncode == 0:
|
410
|
+
# 尝试释放未使用的交换空间
|
411
|
+
subprocess.run(['sudo', 'purge'], capture_output=True, text=True)
|
412
|
+
cleanup_results["freed_swap"] = 1
|
413
|
+
except Exception as e:
|
414
|
+
cleanup_results["errors"].append(f"交换空间释放失败: {e}")
|
415
|
+
|
416
|
+
except Exception as e:
|
417
|
+
cleanup_results["errors"].append(f"内存清理过程出错: {e}")
|
418
|
+
|
419
|
+
return cleanup_results
|
@@ -1,4 +1,3 @@
|
|
1
|
-
#!/usr/bin/env python3
|
2
1
|
# -*- coding: utf-8 -*-
|
3
2
|
"""
|
4
3
|
SpaceCli - Mac OS 磁盘空间分析工具
|
@@ -17,364 +16,9 @@ import time
|
|
17
16
|
from datetime import datetime, timedelta
|
18
17
|
import heapq
|
19
18
|
|
19
|
+
from .space_analyzer import SpaceAnalyzer
|
20
|
+
from .index_store import IndexStore
|
20
21
|
|
21
|
-
class IndexStore:
|
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
|
-
|
159
|
-
优先使用 macOS 的 du -sk(以 KiB 为单位,速度快,原生命令可处理边界情况),
|
160
|
-
若 du 调用失败则回退到基于 os.scandir 的非递归遍历实现(避免 os.walk 的函数调用开销)。
|
161
|
-
"""
|
162
|
-
# 优先尝试 du -sk(BSD du 在 macOS 可用)。
|
163
|
-
try:
|
164
|
-
# du 输出形如: "<kib>\t<path>\n"
|
165
|
-
result = subprocess.run([
|
166
|
-
'du', '-sk', path
|
167
|
-
], capture_output=True, text=True, check=True)
|
168
|
-
out = result.stdout.strip().split('\t', 1)[0].strip()
|
169
|
-
kib = int(out)
|
170
|
-
return kib * 1024
|
171
|
-
except Exception:
|
172
|
-
# du 不可用或失败时回退到 Python 实现
|
173
|
-
pass
|
174
|
-
|
175
|
-
total_size = 0
|
176
|
-
# 基于栈的迭代遍历,避免递归栈与 os.walk 的额外开销
|
177
|
-
stack = [path]
|
178
|
-
while stack:
|
179
|
-
current = stack.pop()
|
180
|
-
try:
|
181
|
-
with os.scandir(current) as it:
|
182
|
-
for entry in it:
|
183
|
-
# 跳过符号链接,避免循环与跨文件系统问题
|
184
|
-
try:
|
185
|
-
if entry.is_symlink():
|
186
|
-
continue
|
187
|
-
if entry.is_file(follow_symlinks=False):
|
188
|
-
try:
|
189
|
-
total_size += entry.stat(follow_symlinks=False).st_size
|
190
|
-
except (OSError, FileNotFoundError, PermissionError):
|
191
|
-
continue
|
192
|
-
elif entry.is_dir(follow_symlinks=False):
|
193
|
-
stack.append(entry.path)
|
194
|
-
except (OSError, FileNotFoundError, PermissionError):
|
195
|
-
continue
|
196
|
-
except (OSError, FileNotFoundError, PermissionError):
|
197
|
-
# 无法进入该目录则跳过
|
198
|
-
continue
|
199
|
-
return total_size
|
200
|
-
|
201
|
-
def analyze_largest_files(self, root_path: str = "/", top_n: int = 50,
|
202
|
-
min_size_bytes: int = 0) -> List[Tuple[str, int]]:
|
203
|
-
"""扫描并返回体积最大的文件列表"""
|
204
|
-
print("正在扫描大文件,这可能需要一些时间...")
|
205
|
-
heap: List[Tuple[int, str]] = [] # 最小堆 (size, path)
|
206
|
-
scanned = 0
|
207
|
-
try:
|
208
|
-
for dirpath, dirnames, filenames in os.walk(root_path):
|
209
|
-
# 进度提示:单行覆盖当前目录
|
210
|
-
dirpath_display = dirpath[-80:] # 截取最后50个字符
|
211
|
-
if dirpath_display == "":
|
212
|
-
dirpath_display = dirpath
|
213
|
-
sys.stdout.write(f"\r\033[K-> 正在读取: \033[36m{dirpath_display}\033[0m")
|
214
|
-
sys.stdout.flush()
|
215
|
-
for filename in filenames:
|
216
|
-
filepath = os.path.join(dirpath, filename)
|
217
|
-
try:
|
218
|
-
size = os.path.getsize(filepath)
|
219
|
-
except (OSError, FileNotFoundError, PermissionError):
|
220
|
-
continue
|
221
|
-
if size < min_size_bytes:
|
222
|
-
continue
|
223
|
-
if len(heap) < top_n:
|
224
|
-
heapq.heappush(heap, (size, filepath))
|
225
|
-
else:
|
226
|
-
if size > heap[0][0]:
|
227
|
-
heapq.heapreplace(heap, (size, filepath))
|
228
|
-
scanned += 1
|
229
|
-
if scanned % 500 == 0:
|
230
|
-
dirpath_display = dirpath[-80:] # 截取最后50个字符
|
231
|
-
if dirpath_display == "":
|
232
|
-
dirpath_display = dirpath
|
233
|
-
# 间隔性进度输出(单行覆盖)
|
234
|
-
sys.stdout.write(f"\r\033[K-> 正在读取: \033[36m{dirpath_display}\033[0m 已扫描文件数: \033[32m{scanned}\033[0m")
|
235
|
-
sys.stdout.flush()
|
236
|
-
except KeyboardInterrupt:
|
237
|
-
print("\n用户中断扫描,返回当前结果...")
|
238
|
-
except Exception as e:
|
239
|
-
print(f"扫描时出错: {e}")
|
240
|
-
finally:
|
241
|
-
sys.stdout.write("\n")
|
242
|
-
sys.stdout.flush()
|
243
|
-
# 转换为按体积降序列表
|
244
|
-
result = sorted([(p, s) for s, p in heap], key=lambda x: x[1], reverse=False)
|
245
|
-
result.sort(key=lambda x: x[1])
|
246
|
-
result = sorted([(p, s) for s, p in heap], key=lambda x: x[1])
|
247
|
-
# 正确:按 size 降序
|
248
|
-
result = sorted([(p, s) for s, p in heap], key=lambda x: x[1], reverse=True)
|
249
|
-
# 以上为了避免编辑器误合并,最终以最后一行排序为准
|
250
|
-
return result
|
251
|
-
|
252
|
-
def analyze_largest_directories(self, root_path: str = "/", max_depth: int = 2, top_n: int = 20,
|
253
|
-
index: IndexStore = None, use_index: bool = True,
|
254
|
-
reindex: bool = False, index_ttl_hours: int = 24,
|
255
|
-
prompt: bool = True) -> List[Tuple[str, int]]:
|
256
|
-
"""分析占用空间最大的目录(支持索引缓存)"""
|
257
|
-
# 索引命中
|
258
|
-
if use_index and index and not reindex and index.is_fresh(root_path, index_ttl_hours):
|
259
|
-
cached = index.get(root_path)
|
260
|
-
if cached and cached.get("entries"):
|
261
|
-
if prompt and sys.stdin.isatty():
|
262
|
-
try:
|
263
|
-
ans = input("检测到最近索引,是否使用缓存结果而不重新索引?[Y/n]: ").strip().lower()
|
264
|
-
if ans in ("", "y", "yes"):
|
265
|
-
return [(e["path"], int(e["size"])) for e in cached["entries"]][:top_n]
|
266
|
-
except EOFError:
|
267
|
-
pass
|
268
|
-
else:
|
269
|
-
return [(e["path"], int(e["size"])) for e in cached["entries"]][:top_n]
|
270
|
-
|
271
|
-
print("正在分析目录大小,这可能需要一些时间...")
|
272
|
-
|
273
|
-
# 忽略的目录列表, 这些目录时系统目录,不需要分析
|
274
|
-
ignore_dir_list = [
|
275
|
-
"/System", # 系统目录
|
276
|
-
"/Volumes", # 外部挂载卷
|
277
|
-
"/private", # 私有目录
|
278
|
-
]
|
279
|
-
|
280
|
-
|
281
|
-
directory_sizes = []
|
282
|
-
|
283
|
-
try:
|
284
|
-
# 获取根目录下的直接子目录
|
285
|
-
for item in os.listdir(root_path):
|
286
|
-
item_path = os.path.join(root_path, item)
|
287
|
-
|
288
|
-
# 跳过隐藏文件和系统文件
|
289
|
-
if item.startswith('.') and item not in ['.Trash', '.localized']:
|
290
|
-
continue
|
291
|
-
|
292
|
-
if item_path in ignore_dir_list:
|
293
|
-
continue
|
294
|
-
|
295
|
-
if os.path.isdir(item_path):
|
296
|
-
try:
|
297
|
-
# 进度提示:当前正在读取的目录(单行覆盖)
|
298
|
-
sys.stdout.write(f"\r\033[K-> 正在读取: \033[36m{item_path}\033[0m")
|
299
|
-
sys.stdout.flush()
|
300
|
-
size = self.get_directory_size(item_path)
|
301
|
-
directory_sizes.append((item_path, size))
|
302
|
-
#print(f"已分析: {item_path} ({self.format_bytes(size)})")
|
303
|
-
print(f" ({self.format_bytes(size)})\033[0m")
|
304
|
-
except (OSError, PermissionError):
|
305
|
-
print(f"跳过无法访问的目录: {item_path}")
|
306
|
-
continue
|
307
|
-
# 结束时换行,避免后续输出粘连在同一行
|
308
|
-
sys.stdout.write("\n")
|
309
|
-
sys.stdout.flush()
|
310
|
-
|
311
|
-
# 按大小排序
|
312
|
-
directory_sizes.sort(key=lambda x: x[1], reverse=True)
|
313
|
-
# 写入索引
|
314
|
-
if index:
|
315
|
-
try:
|
316
|
-
index.set(root_path, directory_sizes)
|
317
|
-
except Exception:
|
318
|
-
pass
|
319
|
-
return directory_sizes[:top_n]
|
320
|
-
|
321
|
-
except Exception as e:
|
322
|
-
print(f"分析目录时出错: {e}")
|
323
|
-
return []
|
324
|
-
|
325
|
-
def get_system_info(self) -> Dict:
|
326
|
-
"""获取系统信息(包括 CPU、内存、GPU、硬盘等硬件信息)"""
|
327
|
-
system_info = {}
|
328
|
-
|
329
|
-
try:
|
330
|
-
# 获取系统版本信息
|
331
|
-
result = subprocess.run(['sw_vers'], capture_output=True, text=True)
|
332
|
-
for line in result.stdout.split('\n'):
|
333
|
-
if ':' in line:
|
334
|
-
key, value = line.split(':', 1)
|
335
|
-
system_info[key.strip()] = value.strip()
|
336
|
-
except Exception:
|
337
|
-
system_info["ProductName"] = "macOS"
|
338
|
-
system_info["ProductVersion"] = "未知"
|
339
|
-
|
340
|
-
try:
|
341
|
-
# 获取 CPU 信息
|
342
|
-
cpu_result = subprocess.run(['sysctl', '-n', 'machdep.cpu.brand_string'],
|
343
|
-
capture_output=True, text=True)
|
344
|
-
if cpu_result.returncode == 0:
|
345
|
-
system_info["CPU"] = cpu_result.stdout.strip()
|
346
|
-
|
347
|
-
# 获取 CPU 核心数
|
348
|
-
cores_result = subprocess.run(['sysctl', '-n', 'hw.ncpu'],
|
349
|
-
capture_output=True, text=True)
|
350
|
-
if cores_result.returncode == 0:
|
351
|
-
system_info["CPU核心数"] = cores_result.stdout.strip()
|
352
|
-
|
353
|
-
except Exception:
|
354
|
-
system_info["CPU"] = "未知"
|
355
|
-
system_info["CPU核心数"] = "未知"
|
356
|
-
|
357
|
-
try:
|
358
|
-
# 获取内存信息
|
359
|
-
mem_result = subprocess.run(['sysctl', '-n', 'hw.memsize'],
|
360
|
-
capture_output=True, text=True)
|
361
|
-
if mem_result.returncode == 0:
|
362
|
-
mem_bytes = int(mem_result.stdout.strip())
|
363
|
-
system_info["内存"] = self.format_bytes(mem_bytes)
|
364
|
-
except Exception:
|
365
|
-
system_info["内存"] = "未知"
|
366
|
-
|
367
|
-
|
368
|
-
try:
|
369
|
-
# 获取启动时间
|
370
|
-
boot_result = subprocess.run(['uptime'], capture_output=True, text=True)
|
371
|
-
if boot_result.returncode == 0:
|
372
|
-
uptime_line = boot_result.stdout.strip()
|
373
|
-
system_info["运行时间"] = uptime_line
|
374
|
-
except Exception:
|
375
|
-
system_info["运行时间"] = "未知"
|
376
|
-
|
377
|
-
return system_info
|
378
22
|
|
379
23
|
|
380
24
|
class SpaceCli:
|
@@ -388,6 +32,13 @@ class SpaceCli:
|
|
388
32
|
app_cache_dir = os.path.join(home, ".cache", "spacecli")
|
389
33
|
os.makedirs(app_cache_dir, exist_ok=True)
|
390
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
|
+
]
|
391
42
|
|
392
43
|
# —— 应用删除相关 ——
|
393
44
|
def _candidate_app_paths(self, app_name: str) -> List[str]:
|
@@ -880,6 +531,7 @@ class SpaceCli:
|
|
880
531
|
total = self.analyzer.get_disk_usage("/")
|
881
532
|
disk_total = total['total'] if total else 1
|
882
533
|
for i, (file_path, size) in enumerate(files, 1):
|
534
|
+
|
883
535
|
size_str = self.analyzer.format_bytes(size)
|
884
536
|
pct = (size / disk_total) * 100
|
885
537
|
color = "\033[31m" if size >= 5 * 1024**3 else ("\033[33m" if size >= 1024**3 else "\033[32m")
|
@@ -898,6 +550,33 @@ class SpaceCli:
|
|
898
550
|
print(f"{key}: \033[36m{value}\033[0m")
|
899
551
|
print()
|
900
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
|
+
|
901
580
|
def export_report(self, output_file: str, path: str = "/"):
|
902
581
|
"""导出分析报告到JSON文件"""
|
903
582
|
print(f"正在生成报告并保存到: {output_file}")
|
@@ -950,255 +629,4 @@ class SpaceCli:
|
|
950
629
|
json.dump(report, f, ensure_ascii=False, indent=2)
|
951
630
|
print(f"✅ 报告已保存到: {output_file}")
|
952
631
|
except Exception as e:
|
953
|
-
print(f"❌ 保存报告失败: {e}")
|
954
|
-
|
955
|
-
|
956
|
-
def main():
|
957
|
-
"""主函数"""
|
958
|
-
parser = argparse.ArgumentParser(
|
959
|
-
description="SpaceCli - Mac OS 磁盘空间分析工具",
|
960
|
-
formatter_class=argparse.RawDescriptionHelpFormatter,
|
961
|
-
epilog="""
|
962
|
-
示例用法:
|
963
|
-
python space_cli.py # 分析根目录
|
964
|
-
python space_cli.py -p /Users # 分析用户目录
|
965
|
-
python space_cli.py -n 10 # 显示前10个最大目录
|
966
|
-
python space_cli.py --export report.json # 导出报告
|
967
|
-
python space_cli.py --health-only # 只显示健康状态
|
968
|
-
"""
|
969
|
-
)
|
970
|
-
|
971
|
-
parser.add_argument(
|
972
|
-
'-p', '--path',
|
973
|
-
default='/',
|
974
|
-
help='要分析的路径 (默认: /)'
|
975
|
-
)
|
976
|
-
|
977
|
-
# 快捷:分析当前用户目录
|
978
|
-
parser.add_argument(
|
979
|
-
'--home',
|
980
|
-
action='store_true',
|
981
|
-
help='将分析路径设置为当前用户目录($HOME)'
|
982
|
-
)
|
983
|
-
|
984
|
-
parser.add_argument(
|
985
|
-
'-n', '--top-n',
|
986
|
-
type=int,
|
987
|
-
default=20,
|
988
|
-
help='显示前N个最大的目录 (默认: 20)'
|
989
|
-
)
|
990
|
-
|
991
|
-
parser.add_argument(
|
992
|
-
'--health-only',
|
993
|
-
action='store_true',
|
994
|
-
help='只显示磁盘健康状态'
|
995
|
-
)
|
996
|
-
|
997
|
-
parser.add_argument(
|
998
|
-
'--directories-only',
|
999
|
-
action='store_true',
|
1000
|
-
help='只显示目录分析'
|
1001
|
-
)
|
1002
|
-
|
1003
|
-
# 索引相关
|
1004
|
-
parser.add_argument(
|
1005
|
-
'--use-index',
|
1006
|
-
dest='use_index',
|
1007
|
-
action='store_true',
|
1008
|
-
help='使用已存在的索引缓存(若存在)'
|
1009
|
-
)
|
1010
|
-
parser.add_argument(
|
1011
|
-
'--no-index',
|
1012
|
-
dest='use_index',
|
1013
|
-
action='store_false',
|
1014
|
-
help='不使用索引缓存'
|
1015
|
-
)
|
1016
|
-
parser.set_defaults(use_index=True)
|
1017
|
-
parser.add_argument(
|
1018
|
-
'--reindex',
|
1019
|
-
action='store_true',
|
1020
|
-
help='强制重建索引'
|
1021
|
-
)
|
1022
|
-
parser.add_argument(
|
1023
|
-
'--index-ttl',
|
1024
|
-
type=int,
|
1025
|
-
default=24,
|
1026
|
-
help='索引缓存有效期(小时),默认24小时'
|
1027
|
-
)
|
1028
|
-
parser.add_argument(
|
1029
|
-
'--no-prompt',
|
1030
|
-
action='store_true',
|
1031
|
-
help='非交互模式:不提示是否使用缓存'
|
1032
|
-
)
|
1033
|
-
|
1034
|
-
# 应用分析
|
1035
|
-
parser.add_argument(
|
1036
|
-
'--apps',
|
1037
|
-
action='store_true',
|
1038
|
-
help='显示应用目录空间分析与卸载建议'
|
1039
|
-
)
|
1040
|
-
|
1041
|
-
# 大文件分析
|
1042
|
-
parser.add_argument(
|
1043
|
-
'--big-files',
|
1044
|
-
action='store_true',
|
1045
|
-
help='显示大文件分析结果'
|
1046
|
-
)
|
1047
|
-
parser.add_argument(
|
1048
|
-
'--big-files-top',
|
1049
|
-
type=int,
|
1050
|
-
default=20,
|
1051
|
-
help='大文件分析显示前N个(默认20)'
|
1052
|
-
)
|
1053
|
-
parser.add_argument(
|
1054
|
-
'--big-files-min',
|
1055
|
-
type=str,
|
1056
|
-
default='0',
|
1057
|
-
help='只显示大于该阈值的文件,支持K/M/G/T,如 500M、2G,默认0'
|
1058
|
-
)
|
1059
|
-
|
1060
|
-
parser.add_argument(
|
1061
|
-
'--export',
|
1062
|
-
metavar='FILE',
|
1063
|
-
help='导出分析报告到JSON文件'
|
1064
|
-
)
|
1065
|
-
|
1066
|
-
parser.add_argument(
|
1067
|
-
'--version',
|
1068
|
-
action='version',
|
1069
|
-
version='SpaceCli 1.0.0'
|
1070
|
-
)
|
1071
|
-
|
1072
|
-
args = parser.parse_args()
|
1073
|
-
|
1074
|
-
# 解析 --big-files-min 阈值字符串到字节
|
1075
|
-
def parse_size(s: str) -> int:
|
1076
|
-
s = (s or '0').strip().upper()
|
1077
|
-
if s.endswith('K'):
|
1078
|
-
return int(float(s[:-1]) * 1024)
|
1079
|
-
if s.endswith('M'):
|
1080
|
-
return int(float(s[:-1]) * 1024**2)
|
1081
|
-
if s.endswith('G'):
|
1082
|
-
return int(float(s[:-1]) * 1024**3)
|
1083
|
-
if s.endswith('T'):
|
1084
|
-
return int(float(s[:-1]) * 1024**4)
|
1085
|
-
try:
|
1086
|
-
return int(float(s))
|
1087
|
-
except ValueError:
|
1088
|
-
return 0
|
1089
|
-
args.big_files_min_bytes = parse_size(getattr(args, 'big_files_min', '0'))
|
1090
|
-
|
1091
|
-
# 交互式菜单:当未传入任何参数时触发(默认执行全部分析)
|
1092
|
-
if len(sys.argv) == 1:
|
1093
|
-
print("=" * 60)
|
1094
|
-
print("🧭 SpaceCli 菜单(直接回车 = 执行全部项目)")
|
1095
|
-
print("=" * 60)
|
1096
|
-
home_path = str(Path.home())
|
1097
|
-
print("1) \033[36m执行主要项目(系统信息 + 健康 + 应用)\033[0m")
|
1098
|
-
print(f"2) \033[36m当前用户目录分析(路径: {home_path})\033[0m")
|
1099
|
-
print("3) \033[36m仅显示系统信息\033[0m")
|
1100
|
-
print("4) \033[36m仅显示磁盘健康状态\033[0m")
|
1101
|
-
print("5) \033[36m交互式目录空间分析\033[0m")
|
1102
|
-
print("6) \033[36m仅分析程序应用目录空间\033[0m")
|
1103
|
-
print("7) \033[36m仅进行大文件分析(很耗时,可随时终止)\033[0m")
|
1104
|
-
print("0) \033[36m退出\033[0m")
|
1105
|
-
try:
|
1106
|
-
choice = input("请选择 [回车=1]: ").strip()
|
1107
|
-
except EOFError:
|
1108
|
-
choice = ""
|
1109
|
-
|
1110
|
-
if choice == "0": # 退出
|
1111
|
-
sys.exit(0)
|
1112
|
-
elif choice == "2": # 仅显示当前用户目录分析
|
1113
|
-
args.path = home_path
|
1114
|
-
args.apps = False
|
1115
|
-
args.health_only = False
|
1116
|
-
args.directories_only = False
|
1117
|
-
elif choice == "3": # 仅显示系统信息
|
1118
|
-
args.health_only = False
|
1119
|
-
args.directories_only = False
|
1120
|
-
args.apps = False
|
1121
|
-
args.big_files = False
|
1122
|
-
elif choice == "4": # 仅显示磁盘健康状态
|
1123
|
-
args.health_only = True
|
1124
|
-
args.directories_only = False
|
1125
|
-
args.apps = False
|
1126
|
-
args.big_files = False
|
1127
|
-
elif choice == "5": # 仅显示最大目录列表
|
1128
|
-
args.health_only = False
|
1129
|
-
args.directories_only = True
|
1130
|
-
args.apps = False
|
1131
|
-
args.big_files = False
|
1132
|
-
elif choice == "6": # 仅显示应用目录分析与建议
|
1133
|
-
args.health_only = False
|
1134
|
-
args.directories_only = False
|
1135
|
-
args.apps = True
|
1136
|
-
args.big_files = False
|
1137
|
-
elif choice == "7": # 仅显示大文件分析
|
1138
|
-
args.health_only = False
|
1139
|
-
args.directories_only = False
|
1140
|
-
args.apps = False
|
1141
|
-
args.big_files = True
|
1142
|
-
else: # 默认执行全部(用户不选择,或者选择1)
|
1143
|
-
args.health_only = True
|
1144
|
-
args.directories_only = False
|
1145
|
-
args.big_files = False
|
1146
|
-
args.apps = True
|
1147
|
-
|
1148
|
-
|
1149
|
-
# --home 优先设置路径
|
1150
|
-
if getattr(args, 'home', False):
|
1151
|
-
args.path = str(Path.home())
|
1152
|
-
|
1153
|
-
# 检查路径是否存在
|
1154
|
-
if not os.path.exists(args.path):
|
1155
|
-
print(f"❌ 错误: 路径 '{args.path}' 不存在")
|
1156
|
-
sys.exit(1)
|
1157
|
-
|
1158
|
-
# 创建SpaceCli实例
|
1159
|
-
space_cli = SpaceCli()
|
1160
|
-
# 让 SpaceCli 实例可访问参数(用于索引与提示控制)
|
1161
|
-
space_cli.args = args
|
1162
|
-
|
1163
|
-
try:
|
1164
|
-
# 显示系统信息
|
1165
|
-
space_cli.print_system_info()
|
1166
|
-
|
1167
|
-
# 显示磁盘健康状态
|
1168
|
-
if args.health_only:
|
1169
|
-
space_cli.print_disk_health(args.path)
|
1170
|
-
|
1171
|
-
# 显示目录分析
|
1172
|
-
if args.directories_only or args.path !='/':
|
1173
|
-
space_cli.print_largest_directories(args.path, args.top_n)
|
1174
|
-
# 若分析路径为当前用户目录,做深度分析
|
1175
|
-
if os.path.abspath(args.path) == os.path.abspath(str(Path.home())):
|
1176
|
-
space_cli.print_home_deep_analysis(args.top_n)
|
1177
|
-
|
1178
|
-
# 应用目录分析
|
1179
|
-
if args.apps:
|
1180
|
-
space_cli.print_app_analysis(args.top_n)
|
1181
|
-
|
1182
|
-
# 大文件分析
|
1183
|
-
#if getattr(args, 'big_files', False):
|
1184
|
-
if args.big_files:
|
1185
|
-
space_cli.print_big_files(args.path, top_n=args.big_files_top, min_size_bytes=args.big_files_min_bytes)
|
1186
|
-
|
1187
|
-
# 导出报告
|
1188
|
-
if args.export:
|
1189
|
-
space_cli.export_report(args.export, args.path)
|
1190
|
-
|
1191
|
-
print("=" * 60)
|
1192
|
-
print("✅ 分析完成!")
|
1193
|
-
print("=" * 60)
|
1194
|
-
|
1195
|
-
except KeyboardInterrupt:
|
1196
|
-
print("\n\n❌ 用户中断操作")
|
1197
|
-
sys.exit(1)
|
1198
|
-
except Exception as e:
|
1199
|
-
print(f"\n❌ 发生错误: {e}")
|
1200
|
-
sys.exit(1)
|
1201
|
-
|
1202
|
-
|
1203
|
-
if __name__ == "__main__":
|
1204
|
-
main()
|
632
|
+
print(f"❌ 保存报告失败: {e}")
|
@@ -1,6 +0,0 @@
|
|
1
|
-
space_cli.py,sha256=qEEhU_3nuiG--fWGbUDdQytrL9mL1CZTKGTMFpCyBNk,48380
|
2
|
-
myspace_cli-1.4.0.dist-info/METADATA,sha256=DDp6iHPcaGr_nCmJg5GHqeVhfqY-2fpzuJQ-4izUlVg,12747
|
3
|
-
myspace_cli-1.4.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
4
|
-
myspace_cli-1.4.0.dist-info/entry_points.txt,sha256=sIVEmPf6W8aNa7KiqOwAI6JrsgHlQciO5xH2G29tKPQ,45
|
5
|
-
myspace_cli-1.4.0.dist-info/top_level.txt,sha256=lnUfbQX9h-9ZjecMVCOWucS2kx69FwTndlrb48S20Sc,10
|
6
|
-
myspace_cli-1.4.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|