deepclean-mydisk 0.12.0 → 0.13.0

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.
@@ -10,6 +10,13 @@ One post-order traversal produces the root listing plus:
10
10
  云盘(iCloud Drive / OneDrive / Dropbox / Google Drive)与 App 包目录(照片图库等)
11
11
  不进入 bigFiles、也不播种下钻缓存:前者搬走会同步删除云端文件,后者移动单个文件
12
12
  会破坏应用数据。它们仍然计入目录大小,避免空间统计失真。
13
+
14
+ 口径说明:
15
+ - 同一个 inode 的硬链接只计一次大小、只进一条 bigFiles;重复名字以 kb=0/dedup=True
16
+ 列出,避免一个 10MB 文件被 500 个链接放大成 5GB。
17
+ - 云占位文件(OneDrive 按需下载)带 REPARSE 标记,必须先按云占位归类,不能算成
18
+ 「链接」。
19
+ - Windows 上 >260 字符的路径用 `\\\\?\\` 前缀访问,输出仍是常规路径。
13
20
  """
14
21
  import heapq
15
22
  import json
@@ -26,6 +33,7 @@ DIR_BIG = 20 # top files kept per recorded directory
26
33
  BIG_FLOOR = 1024 * 1024 # files below 1 MB never enter the big-file heaps
27
34
  REPARSE = 0x400 # FILE_ATTRIBUTE_REPARSE_POINT (Windows)
28
35
  CLOUD_ATTRS = 0x400000 | 0x40000 | 0x1000 # RECALL_ON_DATA_ACCESS | RECALL_ON_OPEN | OFFLINE
36
+ WIN_LONG_THRESHOLD = 248 # leave headroom below MAX_PATH for child names
29
37
 
30
38
  PACKAGE_SUFFIXES = (
31
39
  ".photoslibrary", ".photolibrary", ".sparsebundle", ".fcpbundle", ".logicx",
@@ -68,7 +76,7 @@ def cloud_roots():
68
76
 
69
77
  def norm_path(path):
70
78
  """同时给出 abspath 与 realpath 两种形态:临时目录(/var → /private/var)与
71
- 云盘根可能因符号链接前缀不同而比不中。"""
79
+ 云盘根可能因符号链接前缀不同而比不中。仅用于单个路径;遍历内部走 _Matcher。"""
72
80
  raw = os.path.normcase(os.path.abspath(str(path)))
73
81
  try:
74
82
  real = os.path.normcase(os.path.realpath(str(path)))
@@ -77,17 +85,45 @@ def norm_path(path):
77
85
  return {raw, real}
78
86
 
79
87
 
88
+ def _raw(path):
89
+ return os.path.normcase(os.path.abspath(str(path)))
90
+
91
+
92
+ def _form(path):
93
+ raw = _raw(path)
94
+ try:
95
+ real = os.path.normcase(os.path.realpath(str(path)))
96
+ except OSError:
97
+ real = raw
98
+ return raw, real
99
+
100
+
101
+ def _under(path, base):
102
+ if path == base:
103
+ return True
104
+ prefix = base if base.endswith(os.sep) else base + os.sep
105
+ return path.startswith(prefix)
106
+
107
+
108
+ class _Matcher:
109
+ """预归一化的根目录集合:遍历时每个条目只做字符串比较,不再逐条 realpath。
110
+
111
+ 根目录的 raw/real 形态各算一次,命中判断同时比较两种形态,保留
112
+ 「/var → /private/var」这类符号链接前缀的旧语义。
113
+ """
114
+
115
+ def __init__(self, roots):
116
+ self.roots = [_form(root) for root in roots]
117
+
118
+ def hit(self, raw, real):
119
+ for base_raw, base_real in self.roots:
120
+ if _under(raw, base_raw) or _under(real, base_real):
121
+ return True
122
+ return False
123
+
124
+
80
125
  def is_cloud_path(path, roots):
81
- targets = norm_path(path)
82
- for root in roots:
83
- bases = norm_path(root)
84
- if targets & bases:
85
- return True
86
- for target in targets:
87
- for base in bases:
88
- if target == base or target.startswith(base + os.sep):
89
- return True
90
- return False
126
+ return _Matcher(roots).hit(*_form(path))
91
127
 
92
128
 
93
129
  def is_cloud_placeholder(st):
@@ -99,115 +135,207 @@ def is_package_dir(name):
99
135
  return any(low.endswith(suffix) for suffix in PACKAGE_SUFFIXES)
100
136
 
101
137
 
102
- def is_app_data_path(path):
103
- parts = [p.lower() for p in Path(os.path.abspath(str(path))).parts]
138
+ def app_data_roots():
139
+ home = Path(os.environ.get("HOME") or Path.home())
140
+ return [home / "AppData"] + [Path(os.environ[k]) for k in ("LOCALAPPDATA", "APPDATA") if os.environ.get(k)]
141
+
142
+
143
+ def is_app_data_path(path, matcher=None, raw=None, real=None):
144
+ raw = raw or _raw(path)
145
+ parts = [p.lower() for p in raw.split(os.sep) if p]
104
146
  pairs = set(zip(parts, parts[1:]))
105
147
  if pairs & {("library", "application support"), ("library", "containers"),
106
148
  ("library", "group containers"), ("library", "mail"), ("library", "messages")}:
107
149
  return True
108
- home = Path(os.environ.get("HOME") or Path.home())
109
- roots = [home / "AppData"] + [Path(os.environ[k]) for k in ("LOCALAPPDATA", "APPDATA") if os.environ.get(k)]
110
- return "windowsapps" in parts or is_cloud_path(path, roots)
150
+ if "windowsapps" in parts:
151
+ return True
152
+ if real is None:
153
+ real = _form(path)[1]
154
+ return (matcher or _Matcher(app_data_roots())).hit(raw, real)
155
+
156
+
157
+ def win_long(path):
158
+ """Windows 上 >260 的路径需要 \\\\?\\ 前缀;只用于访问,输出仍是常规路径。"""
159
+ if os.name != "nt":
160
+ return str(path)
161
+ absolute = os.path.abspath(str(path))
162
+ if absolute.startswith("\\\\?\\"):
163
+ return absolute
164
+ return absolute if len(absolute) < WIN_LONG_THRESHOLD else "\\\\?\\" + absolute
111
165
 
112
166
 
113
167
  def inspect_tree(target):
114
168
  logical = os.name == 'nt'
115
169
  dirs = {}
116
170
  clouds = cloud_roots()
117
- skipped_cloud = 0
171
+ cloud_matcher = _Matcher(clouds)
172
+ app_matcher = _Matcher(app_data_roots())
173
+ target_raw = _raw(target)
174
+ try:
175
+ target_real = os.path.normcase(os.path.realpath(str(target)))
176
+ except OSError:
177
+ target_real = target_raw
178
+ seen_inodes = set()
179
+
180
+ def forms(path):
181
+ """遍历内路径的 (raw, real):目标之外才 realpath;目标内用前缀映射,避免逐条系统调用。"""
182
+ raw = _raw(path)
183
+ if _under(raw, target_raw):
184
+ suffix = raw[len(target_raw):]
185
+ return raw, target_real + suffix
186
+ return _form(path)
118
187
 
119
- def push(heap, size_bytes, file_path, mtime):
188
+ def push(heap, size_bytes, file_path, mtime, shared=False):
120
189
  if size_bytes < BIG_FLOOR:
121
190
  return
122
- item = (size_bytes, file_path, mtime)
191
+ item = (size_bytes, file_path, mtime, shared)
123
192
  if len(heap) < ROOT_BIG:
124
193
  heapq.heappush(heap, item)
125
194
  elif item > heap[0]:
126
195
  heapq.heapreplace(heap, item)
127
196
 
128
197
  def top(heap, k):
129
- return [dict(path=p, kb=b / 1024, mtime=m)
130
- for b, p, m in sorted(heap, reverse=True)[:k]]
198
+ return [dict(path=p, kb=b / 1024, mtime=m, shared=s)
199
+ for b, p, m, s in sorted(heap, reverse=True)[:k]]
131
200
 
132
- def walk(dir_path, hidden=False):
133
- """Return (total_bytes, heap, unavailable, skipped, children).
201
+ def walk(dir_path, hidden=False, in_cloud=False):
202
+ """Return a dict with total/heap/unavailable/skipped/cloud/cloud_files/shared/children.
134
203
 
135
- `hidden` 表示当前子树位于云盘/包目录内:照常统计大小,但不产出 bigFiles、
204
+ `hidden` 表示当前子树位于云盘/包目录/App 数据内:照常统计大小,但不产出 bigFiles、
136
205
  不播种下钻缓存(避免被搬家/去重流程选中)。
137
206
  """
138
- nonlocal skipped_cloud
139
207
  children = []
140
208
  heap = []
141
209
  total = 0
142
210
  unavailable = 0
143
211
  skipped = 0
212
+ cloud = 0
213
+ cloud_files = 0
214
+ shared = 0
215
+ shared_dup = 0
144
216
  try:
145
- entries = list(os.scandir(dir_path))
217
+ entries = list(os.scandir(win_long(dir_path)))
146
218
  except OSError:
147
- return 0, heap, 1, 0, None
219
+ return dict(total=0, heap=heap, unavailable=1, skipped=0, cloud=0,
220
+ cloud_files=0, shared=0, shared_dup=0, children=None)
148
221
  for e in entries:
222
+ child = os.path.join(dir_path, e.name)
149
223
  try:
150
224
  st = e.stat(follow_symlinks=False)
151
225
  except OSError:
152
226
  unavailable += 1
153
227
  continue
154
- if stat.S_ISLNK(st.st_mode) or getattr(st, 'st_file_attributes', 0) & REPARSE:
228
+ reparse = bool(getattr(st, 'st_file_attributes', 0) & REPARSE)
229
+ if stat.S_ISLNK(st.st_mode):
155
230
  skipped += 1
156
231
  continue
157
232
  if stat.S_ISDIR(st.st_mode):
158
- if is_cloud_path(e.path, clouds):
159
- skipped_cloud += 1
160
- # 云盘目录只计大小,不进入大文件清单/下钻缓存。
161
- size_b, _sub_heap, sub_un, sub_sk, _sub_children = walk(e.path, True)
162
- total += size_b
163
- unavailable += sub_un
164
- skipped += sub_sk
165
- children.append(dict(name=e.name, path=e.path, kb=size_b / 1024,
166
- isDir=True, partial=sub_un > 0))
233
+ raw, real = forms(child)
234
+ if cloud_matcher.hit(raw, real):
235
+ # 云盘根/子目录:只计大小,不进入大文件清单、不播种下钻缓存。
236
+ if not in_cloud:
237
+ cloud += 1
238
+ sub = walk(child, True, True)
239
+ total += sub['total']
240
+ unavailable += sub['unavailable']
241
+ skipped += sub['skipped']
242
+ cloud_files += sub['cloud_files']
243
+ shared += sub['shared']
244
+ shared_dup += sub['shared_dup']
245
+ children.append(dict(name=e.name, path=child, kb=sub['total'] / 1024,
246
+ isDir=True, partial=sub['unavailable'] > 0, cloud=True))
167
247
  continue
168
- hidden_child = hidden or is_package_dir(e.name) or is_app_data_path(e.path)
169
- size_b, sub_heap, sub_un, sub_sk, sub_children = walk(e.path, hidden_child)
170
- if sub_children is None:
248
+ if reparse:
249
+ # 云占位目录先归类为云盘,不能当成普通链接;不遍历(可能是 reparse 环)。
250
+ if is_cloud_placeholder(st):
251
+ if not in_cloud:
252
+ cloud += 1
253
+ children.append(dict(name=e.name, path=child, kb=0, isDir=True,
254
+ partial=True, cloud=True))
255
+ else:
256
+ skipped += 1
257
+ continue
258
+ hidden_child = (hidden or is_package_dir(e.name)
259
+ or is_app_data_path(child, app_matcher, raw, real))
260
+ sub = walk(child, hidden_child, False)
261
+ if sub['children'] is None:
171
262
  unavailable += 1
172
- children.append(dict(name=e.name, path=e.path, kb=0, isDir=True, partial=True))
263
+ children.append(dict(name=e.name, path=child, kb=0, isDir=True, partial=True))
173
264
  continue
174
- total += size_b
175
- unavailable += sub_un
176
- skipped += sub_sk
265
+ total += sub['total']
266
+ unavailable += sub['unavailable']
267
+ skipped += sub['skipped']
268
+ cloud += sub['cloud']
269
+ cloud_files += sub['cloud_files']
270
+ shared += sub['shared']
271
+ shared_dup += sub['shared_dup']
177
272
  if not hidden_child:
178
- for size_i, path_i, mtime_i in sub_heap:
179
- push(heap, size_i, path_i, mtime_i)
180
- children.append(dict(name=e.name, path=e.path, kb=size_b / 1024,
181
- isDir=True, partial=sub_un > 0))
182
- if size_b >= MIN_DIR_KB * 1024 and not hidden_child:
183
- dirs[e.path] = dict(kb=size_b / 1024,
184
- children=sorted(sub_children, key=lambda c: -c['kb']),
185
- unavailable=sub_un, skipped=sub_sk,
186
- bigFiles=top(sub_heap, DIR_BIG))
187
- else:
188
- if is_cloud_placeholder(st):
189
- skipped_cloud += 1
273
+ for item in sub['heap']:
274
+ push(heap, *item)
275
+ children.append(dict(name=e.name, path=child, kb=sub['total'] / 1024,
276
+ isDir=True, partial=sub['unavailable'] > 0))
277
+ if sub['total'] >= MIN_DIR_KB * 1024 and not hidden_child:
278
+ dirs[child] = dict(
279
+ kb=sub['total'] / 1024,
280
+ children=sorted(sub['children'], key=lambda c: -c['kb']),
281
+ unavailable=sub['unavailable'], skipped=sub['skipped'],
282
+ skippedCloud=sub['cloud'], cloudFiles=sub['cloud_files'],
283
+ sharedFiles=sub['shared'], sharedDupKb=sub['shared_dup'] / 1024,
284
+ bigFilesCap=DIR_BIG,
285
+ bigFiles=top(sub['heap'], DIR_BIG))
286
+ continue
287
+ # file
288
+ if is_cloud_placeholder(st):
289
+ cloud_files += 1
290
+ continue
291
+ size_b = st.st_size if logical else getattr(st, 'st_blocks', 0) * 512
292
+ nlink = getattr(st, 'st_nlink', 1)
293
+ ino = getattr(st, 'st_ino', 0)
294
+ dev = getattr(st, 'st_dev', 0)
295
+ # Windows 的 DirEntry.stat() 不填 nlink/ino(都是 0);只对可能进 bigFiles
296
+ # 的大文件补一次完整 stat,避免在每个文件上多一次系统调用。
297
+ if (not nlink or not ino) and size_b >= BIG_FLOOR:
298
+ try:
299
+ full = os.stat(child)
300
+ nlink, ino, dev = full.st_nlink, full.st_ino, full.st_dev
301
+ except OSError:
302
+ nlink, ino = 1, 0
303
+ key = (dev, ino) if nlink > 1 and ino else None
304
+ is_shared = key is not None
305
+ if is_shared:
306
+ shared += 1
307
+ if key in seen_inodes:
308
+ shared_dup += size_b
309
+ children.append(dict(name=e.name, path=child, kb=0.0, isDir=False,
310
+ partial=False, shared=True, dedup=True))
190
311
  continue
191
- size_b = st.st_size if logical else getattr(st, 'st_blocks', 0) * 512
192
- total += size_b
193
- if not hidden:
194
- push(heap, size_b, e.path, int(st.st_mtime))
195
- children.append(dict(name=e.name, path=e.path, kb=size_b / 1024,
196
- isDir=False, partial=False))
197
- return total, heap, unavailable, skipped, children
198
-
199
- root_hidden = is_app_data_path(target) or is_cloud_path(target, clouds) or any(is_package_dir(p) for p in Path(target).parts)
200
- _total, heap, unavailable, skipped, children = walk(target, root_hidden)
201
- if children is None:
312
+ seen_inodes.add(key)
313
+ total += size_b
314
+ if not hidden:
315
+ push(heap, size_b, child, int(st.st_mtime), is_shared)
316
+ children.append(dict(name=e.name, path=child, kb=size_b / 1024,
317
+ isDir=False, partial=False, shared=is_shared))
318
+ return dict(total=total, heap=heap, unavailable=unavailable, skipped=skipped,
319
+ cloud=cloud, cloud_files=cloud_files, shared=shared,
320
+ shared_dup=shared_dup, children=children)
321
+
322
+ root_cloud = cloud_matcher.hit(*forms(target))
323
+ root_hidden = (is_app_data_path(target, app_matcher)
324
+ or any(is_package_dir(p) for p in Path(target).parts)
325
+ or root_cloud)
326
+ root = walk(target, root_hidden, root_cloud)
327
+ if root['children'] is None:
202
328
  return None
203
329
  if len(dirs) > MAX_DIRS:
204
330
  keep = sorted(dirs, key=lambda p: -dirs[p]['kb'])[:MAX_DIRS]
205
331
  dirs = {p: dirs[p] for p in keep}
206
- return dict(path=target, children=sorted(children, key=lambda c: -c['kb']),
207
- at=int(time.time() * 1000), unavailable=unavailable, skipped=skipped,
208
- skippedCloud=skipped_cloud,
332
+ return dict(path=target, children=sorted(root['children'], key=lambda c: -c['kb']),
333
+ at=int(time.time() * 1000), unavailable=root['unavailable'],
334
+ skipped=root['skipped'], skippedCloud=root['cloud'],
335
+ cloudFiles=root['cloud_files'], sharedFiles=root['shared'],
336
+ sharedDupKb=root['shared_dup'] / 1024, bigFilesCap=ROOT_BIG,
209
337
  metric='logical' if logical else 'allocated',
210
- bigFiles=top(heap, ROOT_BIG), dirs=dirs)
338
+ bigFiles=top(root['heap'], ROOT_BIG), dirs=dirs)
211
339
 
212
340
 
213
341
  if __name__ == '__main__':