deepclean-mydisk 0.0.0-stage → 0.12.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.
@@ -0,0 +1,218 @@
1
+ """Read-only directory totals from a single walk. Never follow symlinks, junctions or reparse points.
2
+
3
+ One post-order traversal produces the root listing plus:
4
+ - `dirs`: per-directory listings for subtrees >= MIN_DIR_KB (capped at MAX_DIRS by
5
+ size), so the host can seed its tree cache for every level at once and drill-down
6
+ does not re-walk the subtree;
7
+ - `bigFiles`: top files per directory from heaps maintained during the same walk,
8
+ so the space page can list large files of any folder without a full-disk scan.
9
+
10
+ 云盘(iCloud Drive / OneDrive / Dropbox / Google Drive)与 App 包目录(照片图库等)
11
+ 不进入 bigFiles、也不播种下钻缓存:前者搬走会同步删除云端文件,后者移动单个文件
12
+ 会破坏应用数据。它们仍然计入目录大小,避免空间统计失真。
13
+ """
14
+ import heapq
15
+ import json
16
+ import os
17
+ import stat
18
+ import sys
19
+ import time
20
+ from pathlib import Path
21
+
22
+ MAX_DIRS = 500 # recorded subdirectory listings, largest subtrees first
23
+ MIN_DIR_KB = 10 * 1024 # only record subtrees >= 10 MB in `dirs`
24
+ ROOT_BIG = 50 # top files reported for the scanned root
25
+ DIR_BIG = 20 # top files kept per recorded directory
26
+ BIG_FLOOR = 1024 * 1024 # files below 1 MB never enter the big-file heaps
27
+ REPARSE = 0x400 # FILE_ATTRIBUTE_REPARSE_POINT (Windows)
28
+ CLOUD_ATTRS = 0x400000 | 0x40000 | 0x1000 # RECALL_ON_DATA_ACCESS | RECALL_ON_OPEN | OFFLINE
29
+
30
+ PACKAGE_SUFFIXES = (
31
+ ".photoslibrary", ".photolibrary", ".sparsebundle", ".fcpbundle", ".logicx",
32
+ ".band", ".imovielibrary", ".tvlibrary", ".aplibrary",
33
+ )
34
+ CLOUD_DIR_NAMES = ("onedrive", "dropbox", "google drive", "icloud drive")
35
+
36
+
37
+ def cloud_roots():
38
+ """云盘根目录:平台固定位置 + Windows 已知环境变量/目录 + DEEPCLEAN_CLOUD_ROOTS(宿主注入)。"""
39
+ roots = []
40
+ home = Path.home()
41
+ if sys.platform == "darwin":
42
+ roots += [home / "Library" / "CloudStorage", home / "Library" / "Mobile Documents"]
43
+ else:
44
+ for name in ("OneDrive", "OneDriveConsumer", "OneDriveCommercial"):
45
+ value = os.environ.get(name)
46
+ if value:
47
+ roots.append(Path(value))
48
+ try:
49
+ for child in home.iterdir():
50
+ if child.name.lower().startswith(CLOUD_DIR_NAMES):
51
+ roots.append(child)
52
+ except OSError:
53
+ pass
54
+ extra = os.environ.get("DEEPCLEAN_CLOUD_ROOTS")
55
+ if extra:
56
+ try:
57
+ roots += [Path(p) for p in json.loads(extra) if isinstance(p, str)]
58
+ except (ValueError, TypeError):
59
+ pass
60
+ out = []
61
+ for root in roots:
62
+ try:
63
+ out.append(str(root.resolve()))
64
+ except OSError:
65
+ continue
66
+ return out
67
+
68
+
69
+ def norm_path(path):
70
+ """同时给出 abspath 与 realpath 两种形态:临时目录(/var → /private/var)与
71
+ 云盘根可能因符号链接前缀不同而比不中。"""
72
+ raw = os.path.normcase(os.path.abspath(str(path)))
73
+ try:
74
+ real = os.path.normcase(os.path.realpath(str(path)))
75
+ except OSError:
76
+ real = raw
77
+ return {raw, real}
78
+
79
+
80
+ 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
91
+
92
+
93
+ def is_cloud_placeholder(st):
94
+ return bool(getattr(st, "st_file_attributes", 0) & CLOUD_ATTRS)
95
+
96
+
97
+ def is_package_dir(name):
98
+ low = name.lower()
99
+ return any(low.endswith(suffix) for suffix in PACKAGE_SUFFIXES)
100
+
101
+
102
+ def is_app_data_path(path):
103
+ parts = [p.lower() for p in Path(os.path.abspath(str(path))).parts]
104
+ pairs = set(zip(parts, parts[1:]))
105
+ if pairs & {("library", "application support"), ("library", "containers"),
106
+ ("library", "group containers"), ("library", "mail"), ("library", "messages")}:
107
+ 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)
111
+
112
+
113
+ def inspect_tree(target):
114
+ logical = os.name == 'nt'
115
+ dirs = {}
116
+ clouds = cloud_roots()
117
+ skipped_cloud = 0
118
+
119
+ def push(heap, size_bytes, file_path, mtime):
120
+ if size_bytes < BIG_FLOOR:
121
+ return
122
+ item = (size_bytes, file_path, mtime)
123
+ if len(heap) < ROOT_BIG:
124
+ heapq.heappush(heap, item)
125
+ elif item > heap[0]:
126
+ heapq.heapreplace(heap, item)
127
+
128
+ 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]]
131
+
132
+ def walk(dir_path, hidden=False):
133
+ """Return (total_bytes, heap, unavailable, skipped, children).
134
+
135
+ `hidden` 表示当前子树位于云盘/包目录内:照常统计大小,但不产出 bigFiles、
136
+ 不播种下钻缓存(避免被搬家/去重流程选中)。
137
+ """
138
+ nonlocal skipped_cloud
139
+ children = []
140
+ heap = []
141
+ total = 0
142
+ unavailable = 0
143
+ skipped = 0
144
+ try:
145
+ entries = list(os.scandir(dir_path))
146
+ except OSError:
147
+ return 0, heap, 1, 0, None
148
+ for e in entries:
149
+ try:
150
+ st = e.stat(follow_symlinks=False)
151
+ except OSError:
152
+ unavailable += 1
153
+ continue
154
+ if stat.S_ISLNK(st.st_mode) or getattr(st, 'st_file_attributes', 0) & REPARSE:
155
+ skipped += 1
156
+ continue
157
+ 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))
167
+ 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:
171
+ unavailable += 1
172
+ children.append(dict(name=e.name, path=e.path, kb=0, isDir=True, partial=True))
173
+ continue
174
+ total += size_b
175
+ unavailable += sub_un
176
+ skipped += sub_sk
177
+ 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
190
+ 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:
202
+ return None
203
+ if len(dirs) > MAX_DIRS:
204
+ keep = sorted(dirs, key=lambda p: -dirs[p]['kb'])[:MAX_DIRS]
205
+ 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,
209
+ metric='logical' if logical else 'allocated',
210
+ bigFiles=top(heap, ROOT_BIG), dirs=dirs)
211
+
212
+
213
+ if __name__ == '__main__':
214
+ result = inspect_tree(sys.argv[1])
215
+ if result is None:
216
+ print(f'无法读取目录:{sys.argv[1]}', file=sys.stderr)
217
+ sys.exit(1)
218
+ print(json.dumps(result, ensure_ascii=True))