headroom-pc 0.1.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.
- headroom/__init__.py +3 -0
- headroom/__main__.py +8 -0
- headroom/alerts.py +125 -0
- headroom/analyze.py +555 -0
- headroom/autostart.py +312 -0
- headroom/cleanup.py +773 -0
- headroom/cli.py +1250 -0
- headroom/config.py +512 -0
- headroom/dashboard.py +628 -0
- headroom/downloads.py +227 -0
- headroom/dupes.py +196 -0
- headroom/explain.py +196 -0
- headroom/firstrun.py +98 -0
- headroom/formatutil.py +31 -0
- headroom/history.py +605 -0
- headroom/instance.py +115 -0
- headroom/logsetup.py +47 -0
- headroom/mcp_prompts.py +23 -0
- headroom/mcp_server.py +38 -0
- headroom/mcp_tools.py +175 -0
- headroom/notify.py +129 -0
- headroom/pet.py +1334 -0
- headroom/pets/__init__.py +1 -0
- headroom/pets/draw.py +544 -0
- headroom/pets/midnight.png +0 -0
- headroom/pets/roomy.png +0 -0
- headroom/pets/tower.png +0 -0
- headroom/plans.py +108 -0
- headroom/plugins.py +152 -0
- headroom/probe.py +197 -0
- headroom/processes.py +77 -0
- headroom/profiles/__init__.py +1 -0
- headroom/profiles/developer.rules +64 -0
- headroom/profiles/gamer.rules +36 -0
- headroom/profiles/student.rules +34 -0
- headroom/py.typed +0 -0
- headroom/render.py +457 -0
- headroom/review.py +353 -0
- headroom/rules.py +424 -0
- headroom/safety.py +211 -0
- headroom/scan.py +82 -0
- headroom/schedule.py +104 -0
- headroom/selfcheck.py +78 -0
- headroom/service.py +120 -0
- headroom/startup.py +525 -0
- headroom/tracker.py +317 -0
- headroom/walk.py +230 -0
- headroom/watch.py +324 -0
- headroom/weekly.py +154 -0
- headroom_pc-0.1.0.dist-info/METADATA +193 -0
- headroom_pc-0.1.0.dist-info/RECORD +55 -0
- headroom_pc-0.1.0.dist-info/WHEEL +5 -0
- headroom_pc-0.1.0.dist-info/entry_points.txt +3 -0
- headroom_pc-0.1.0.dist-info/licenses/LICENSE +21 -0
- headroom_pc-0.1.0.dist-info/top_level.txt +1 -0
headroom/__init__.py
ADDED
headroom/__main__.py
ADDED
headroom/alerts.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Thresholds for sustained RAM pressure and low free space.
|
|
2
|
+
|
|
3
|
+
RAM must stay above the configured percent for the full sustained window.
|
|
4
|
+
A single spike does not notify. Disk alerts fire when a watched drive crosses
|
|
5
|
+
into a worse state, and they do not repeat while it stays there.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from headroom.config import Config
|
|
13
|
+
from headroom.formatutil import format_bytes, format_duration, format_percent
|
|
14
|
+
from headroom.probe import Sample
|
|
15
|
+
|
|
16
|
+
_RANK = {"ok": 0, "warn": 1, "critical": 2}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class AlertState:
|
|
21
|
+
ram_high_since: float | None = None
|
|
22
|
+
ram_alerted: bool = False
|
|
23
|
+
disk_level: dict[str, str] = field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Alert:
|
|
28
|
+
level: str
|
|
29
|
+
code: str
|
|
30
|
+
title: str
|
|
31
|
+
message: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def disk_pressure(free_bytes: int, config: Config) -> str:
|
|
35
|
+
"""Return ``ok``, ``warn``, or ``critical``. Equal to a line is still ok."""
|
|
36
|
+
if free_bytes < config.disk_critical_free_bytes:
|
|
37
|
+
return "critical"
|
|
38
|
+
if free_bytes < config.disk_warn_free_bytes:
|
|
39
|
+
return "warn"
|
|
40
|
+
return "ok"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def evaluate(sample: Sample, state: AlertState, config: Config, now: float) -> list[Alert]:
|
|
44
|
+
"""Update ``state`` and return alerts that should be delivered now."""
|
|
45
|
+
alerts: list[Alert] = []
|
|
46
|
+
alerts.extend(_ram_alerts(sample, state, config, now))
|
|
47
|
+
alerts.extend(_disk_alerts(sample, state, config))
|
|
48
|
+
return alerts
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _ram_alerts(
|
|
52
|
+
sample: Sample,
|
|
53
|
+
state: AlertState,
|
|
54
|
+
config: Config,
|
|
55
|
+
now: float,
|
|
56
|
+
) -> list[Alert]:
|
|
57
|
+
percent = sample.memory.ram_percent
|
|
58
|
+
if percent > config.ram_percent:
|
|
59
|
+
if state.ram_high_since is None:
|
|
60
|
+
state.ram_high_since = now
|
|
61
|
+
elapsed = now - state.ram_high_since
|
|
62
|
+
if elapsed >= config.ram_sustained_seconds and not state.ram_alerted:
|
|
63
|
+
state.ram_alerted = True
|
|
64
|
+
window = format_duration(config.ram_sustained_seconds)
|
|
65
|
+
return [
|
|
66
|
+
Alert(
|
|
67
|
+
level="warning",
|
|
68
|
+
code="ram_sustained",
|
|
69
|
+
title="Headroom: RAM is high",
|
|
70
|
+
message=(
|
|
71
|
+
f"RAM has stayed above {format_percent(config.ram_percent)} "
|
|
72
|
+
f"for {window} (now {format_percent(percent)}). "
|
|
73
|
+
"Run `headroom top` to see which processes are using it."
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
]
|
|
77
|
+
return []
|
|
78
|
+
state.ram_high_since = None
|
|
79
|
+
state.ram_alerted = False
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _disk_alerts(sample: Sample, state: AlertState, config: Config) -> list[Alert]:
|
|
84
|
+
alerts: list[Alert] = []
|
|
85
|
+
seen: set[str] = set()
|
|
86
|
+
watched = set(config.drives)
|
|
87
|
+
for disk in sample.disks:
|
|
88
|
+
if disk.key not in watched or disk.key in seen:
|
|
89
|
+
continue
|
|
90
|
+
seen.add(disk.key)
|
|
91
|
+
level = disk_pressure(disk.free, config)
|
|
92
|
+
previous = state.disk_level.get(disk.key, "ok")
|
|
93
|
+
state.disk_level[disk.key] = level
|
|
94
|
+
if _RANK[level] <= _RANK[previous]:
|
|
95
|
+
continue
|
|
96
|
+
if level == "critical":
|
|
97
|
+
alerts.append(
|
|
98
|
+
Alert(
|
|
99
|
+
level="critical",
|
|
100
|
+
code="disk_critical",
|
|
101
|
+
title=f"Headroom: {disk.key} is critically low",
|
|
102
|
+
message=(
|
|
103
|
+
f"{disk.key} has {format_bytes(disk.free)} free, under the "
|
|
104
|
+
f"{format_bytes(config.disk_critical_free_bytes)} critical line. "
|
|
105
|
+
"Run `headroom clean --dry-run` to preview safe cleanup."
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
elif level == "warn":
|
|
110
|
+
alerts.append(
|
|
111
|
+
Alert(
|
|
112
|
+
level="warning",
|
|
113
|
+
code="disk_low",
|
|
114
|
+
title=f"Headroom: {disk.key} is low on space",
|
|
115
|
+
message=(
|
|
116
|
+
f"{disk.key} has {format_bytes(disk.free)} free, under the "
|
|
117
|
+
f"{format_bytes(config.disk_warn_free_bytes)} warning line."
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
for key in list(state.disk_level):
|
|
122
|
+
if key not in seen and key in watched:
|
|
123
|
+
# The drive disappeared. Forget it so a later return can alert again.
|
|
124
|
+
del state.disk_level[key]
|
|
125
|
+
return alerts
|
headroom/analyze.py
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
"""Read-only categorization of files the user might care about.
|
|
2
|
+
|
|
3
|
+
The scan never deletes, moves, or writes inside the folders it reads. It stores
|
|
4
|
+
its report under Headroom's own state directory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path, PurePosixPath
|
|
15
|
+
|
|
16
|
+
from headroom.formatutil import format_bytes
|
|
17
|
+
from headroom.rules import CATEGORIES, state_dir
|
|
18
|
+
from headroom.walk import classify_path, list_child_names, measure_tree
|
|
19
|
+
|
|
20
|
+
STALE_DAYS = 180
|
|
21
|
+
RECENT_DAYS = 14
|
|
22
|
+
MAX_RECORDS = 8000
|
|
23
|
+
_HASH_LIMIT = 8 * 1024 * 1024
|
|
24
|
+
|
|
25
|
+
CATEGORY_LABELS = {
|
|
26
|
+
"school": "School and documents",
|
|
27
|
+
"code": "Code projects",
|
|
28
|
+
"games": "Games",
|
|
29
|
+
"media": "Media",
|
|
30
|
+
"downloads": "Downloads",
|
|
31
|
+
"installers": "Installers",
|
|
32
|
+
"duplicates": "Duplicates",
|
|
33
|
+
"caches": "Caches",
|
|
34
|
+
"old": "Old or unopened",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_IMPORTANCE = {
|
|
38
|
+
"school": 80,
|
|
39
|
+
"code": 85,
|
|
40
|
+
"games": 75,
|
|
41
|
+
"media": 55,
|
|
42
|
+
"downloads": 35,
|
|
43
|
+
"installers": 20,
|
|
44
|
+
"duplicates": 15,
|
|
45
|
+
"caches": 10,
|
|
46
|
+
"old": 25,
|
|
47
|
+
}
|
|
48
|
+
_CACHE_DIRS = {
|
|
49
|
+
"node_modules",
|
|
50
|
+
"__pycache__",
|
|
51
|
+
".next",
|
|
52
|
+
"bower_components",
|
|
53
|
+
"shadercache",
|
|
54
|
+
"gpucache",
|
|
55
|
+
"dxcache",
|
|
56
|
+
"appcache",
|
|
57
|
+
"htmlcache",
|
|
58
|
+
}
|
|
59
|
+
_GAME_PARTS = {
|
|
60
|
+
"steamapps",
|
|
61
|
+
"epic games",
|
|
62
|
+
"gog games",
|
|
63
|
+
"gog galaxy",
|
|
64
|
+
"riot games",
|
|
65
|
+
"battlenet",
|
|
66
|
+
}
|
|
67
|
+
_PROJECT_FILES = {
|
|
68
|
+
".git",
|
|
69
|
+
"package.json",
|
|
70
|
+
"pyproject.toml",
|
|
71
|
+
"cargo.toml",
|
|
72
|
+
"go.mod",
|
|
73
|
+
"pom.xml",
|
|
74
|
+
"composer.json",
|
|
75
|
+
"setup.py",
|
|
76
|
+
}
|
|
77
|
+
_CODE_EXT = {
|
|
78
|
+
".py", ".pyi", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".rs", ".go",
|
|
79
|
+
".java", ".c", ".h", ".hpp", ".cpp", ".cs", ".rb", ".php", ".swift", ".kt",
|
|
80
|
+
".scala",
|
|
81
|
+
}
|
|
82
|
+
_MEDIA_EXT = {
|
|
83
|
+
".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".mp4", ".mov", ".mkv",
|
|
84
|
+
".avi", ".mp3", ".wav", ".flac", ".m4a",
|
|
85
|
+
}
|
|
86
|
+
_DOC_EXT = {
|
|
87
|
+
".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".txt", ".rtf",
|
|
88
|
+
".odt", ".odp", ".ods",
|
|
89
|
+
}
|
|
90
|
+
_INSTALLER_EXT = {".msi", ".iso", ".dmg", ".pkg"}
|
|
91
|
+
_SCHOOL_HINTS = (
|
|
92
|
+
"syllabus",
|
|
93
|
+
"homework",
|
|
94
|
+
"assignment",
|
|
95
|
+
"lecture",
|
|
96
|
+
"essay",
|
|
97
|
+
"course",
|
|
98
|
+
"semester",
|
|
99
|
+
)
|
|
100
|
+
_FOLDER_NAMES = (
|
|
101
|
+
"Documents",
|
|
102
|
+
"Desktop",
|
|
103
|
+
"Downloads",
|
|
104
|
+
"Pictures",
|
|
105
|
+
"Videos",
|
|
106
|
+
"Music",
|
|
107
|
+
"Saved Games",
|
|
108
|
+
"Games",
|
|
109
|
+
"source",
|
|
110
|
+
"projects",
|
|
111
|
+
"dev",
|
|
112
|
+
"code",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class FileRecord:
|
|
118
|
+
path: str
|
|
119
|
+
size: int
|
|
120
|
+
accessed: float
|
|
121
|
+
category: str
|
|
122
|
+
importance: int
|
|
123
|
+
is_dir: bool = False
|
|
124
|
+
stale: bool = False
|
|
125
|
+
duplicate_of: str | None = None
|
|
126
|
+
base_category: str = ""
|
|
127
|
+
|
|
128
|
+
def to_json(self) -> dict[str, object]:
|
|
129
|
+
return {
|
|
130
|
+
"path": self.path,
|
|
131
|
+
"size": self.size,
|
|
132
|
+
"accessed": self.accessed,
|
|
133
|
+
"category": self.category,
|
|
134
|
+
"importance": self.importance,
|
|
135
|
+
"is_dir": self.is_dir,
|
|
136
|
+
"stale": self.stale,
|
|
137
|
+
"duplicate_of": self.duplicate_of,
|
|
138
|
+
"base_category": self.base_category or self.category,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def from_json(cls, data: dict[str, object]) -> FileRecord:
|
|
143
|
+
base = str(data.get("base_category") or data["category"])
|
|
144
|
+
return cls(
|
|
145
|
+
path=str(data["path"]),
|
|
146
|
+
size=int(data["size"]),
|
|
147
|
+
accessed=float(data["accessed"]),
|
|
148
|
+
category=str(data["category"]),
|
|
149
|
+
importance=int(data["importance"]),
|
|
150
|
+
is_dir=bool(data.get("is_dir", False)),
|
|
151
|
+
stale=bool(data.get("stale", False)),
|
|
152
|
+
duplicate_of=None if data.get("duplicate_of") is None else str(data["duplicate_of"]),
|
|
153
|
+
base_category=base,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class CategoryTotal:
|
|
159
|
+
count: int = 0
|
|
160
|
+
bytes: int = 0
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class Analysis:
|
|
165
|
+
scanned_at: float
|
|
166
|
+
roots: tuple[str, ...]
|
|
167
|
+
totals: dict[str, CategoryTotal] = field(default_factory=dict)
|
|
168
|
+
records: tuple[FileRecord, ...] = ()
|
|
169
|
+
errors: int = 0
|
|
170
|
+
skipped_links: int = 0
|
|
171
|
+
capped: bool = False
|
|
172
|
+
stale_count: int = 0
|
|
173
|
+
stale_bytes: int = 0
|
|
174
|
+
|
|
175
|
+
def to_json(self) -> dict[str, object]:
|
|
176
|
+
return {
|
|
177
|
+
"scanned_at": self.scanned_at,
|
|
178
|
+
"roots": list(self.roots),
|
|
179
|
+
"totals": {
|
|
180
|
+
key: {"count": total.count, "bytes": total.bytes}
|
|
181
|
+
for key, total in self.totals.items()
|
|
182
|
+
},
|
|
183
|
+
"records": [record.to_json() for record in self.records],
|
|
184
|
+
"errors": self.errors,
|
|
185
|
+
"skipped_links": self.skipped_links,
|
|
186
|
+
"capped": self.capped,
|
|
187
|
+
"stale_count": self.stale_count,
|
|
188
|
+
"stale_bytes": self.stale_bytes,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
def from_json(cls, data: dict[str, object]) -> Analysis:
|
|
193
|
+
totals: dict[str, CategoryTotal] = {}
|
|
194
|
+
raw_totals = data.get("totals") or {}
|
|
195
|
+
if isinstance(raw_totals, dict):
|
|
196
|
+
for key, value in raw_totals.items():
|
|
197
|
+
if isinstance(value, dict):
|
|
198
|
+
totals[str(key)] = CategoryTotal(
|
|
199
|
+
count=int(value.get("count", 0)),
|
|
200
|
+
bytes=int(value.get("bytes", 0)),
|
|
201
|
+
)
|
|
202
|
+
raw_records = data.get("records") or []
|
|
203
|
+
records = tuple(
|
|
204
|
+
FileRecord.from_json(item)
|
|
205
|
+
for item in raw_records
|
|
206
|
+
if isinstance(item, dict)
|
|
207
|
+
)
|
|
208
|
+
roots = data.get("roots") or []
|
|
209
|
+
return cls(
|
|
210
|
+
scanned_at=float(data.get("scanned_at", 0)),
|
|
211
|
+
roots=tuple(str(item) for item in roots) if isinstance(roots, list) else (),
|
|
212
|
+
totals=totals,
|
|
213
|
+
records=records,
|
|
214
|
+
errors=int(data.get("errors", 0)),
|
|
215
|
+
skipped_links=int(data.get("skipped_links", 0)),
|
|
216
|
+
capped=bool(data.get("capped", False)),
|
|
217
|
+
stale_count=int(data.get("stale_count", 0)),
|
|
218
|
+
stale_bytes=int(data.get("stale_bytes", 0)),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def importance(category: str, accessed: float, now: float) -> int:
|
|
223
|
+
"""0 to 100. Recent files score higher. Old files score lower."""
|
|
224
|
+
score = _IMPORTANCE.get(category, _IMPORTANCE["old"])
|
|
225
|
+
age_days = (now - accessed) / 86400
|
|
226
|
+
if age_days <= RECENT_DAYS:
|
|
227
|
+
score += 10
|
|
228
|
+
elif age_days >= STALE_DAYS:
|
|
229
|
+
score -= 15
|
|
230
|
+
return max(0, min(100, score))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def classify_entry(path: str, *, is_dir: bool, inside_project: bool) -> str:
|
|
234
|
+
"""Pick one category id from the path. The caller marks duplicates later."""
|
|
235
|
+
posix = path.replace("\\", "/")
|
|
236
|
+
parts = [part.casefold() for part in PurePosixPath(posix).parts]
|
|
237
|
+
name = parts[-1] if parts else ""
|
|
238
|
+
ext = "" if is_dir else PurePosixPath(name).suffix.casefold()
|
|
239
|
+
joined = "/".join(parts)
|
|
240
|
+
if any(part in _CACHE_DIRS for part in parts):
|
|
241
|
+
return "caches"
|
|
242
|
+
if "saved games" in joined or any(part in _GAME_PARTS for part in parts):
|
|
243
|
+
return "games"
|
|
244
|
+
if ext in _INSTALLER_EXT or (
|
|
245
|
+
ext == ".exe" and any(token in name for token in ("setup", "install"))
|
|
246
|
+
):
|
|
247
|
+
return "installers"
|
|
248
|
+
if inside_project or ext in _CODE_EXT:
|
|
249
|
+
return "code"
|
|
250
|
+
if ext in _MEDIA_EXT:
|
|
251
|
+
return "media"
|
|
252
|
+
if any(hint in joined for hint in _SCHOOL_HINTS):
|
|
253
|
+
return "school"
|
|
254
|
+
if "downloads" in parts:
|
|
255
|
+
return "downloads"
|
|
256
|
+
if ext in _DOC_EXT or any(part in {"documents", "desktop"} for part in parts):
|
|
257
|
+
return "school"
|
|
258
|
+
return "old"
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def default_roots(home: Path | None = None, env: dict[str, str] | None = None) -> list[Path]:
|
|
262
|
+
"""User folders that usually hold personal files, plus a few known caches."""
|
|
263
|
+
home_dir = Path.home() if home is None else home
|
|
264
|
+
environ = os.environ if env is None else env
|
|
265
|
+
roots = [home_dir / name for name in _FOLDER_NAMES if (home_dir / name).is_dir()]
|
|
266
|
+
local = environ.get("LOCALAPPDATA")
|
|
267
|
+
if local:
|
|
268
|
+
base = Path(local)
|
|
269
|
+
for relative in (
|
|
270
|
+
"NVIDIA/DXCache",
|
|
271
|
+
"D3DSCache",
|
|
272
|
+
"Steam/appcache",
|
|
273
|
+
"Steam/htmlcache",
|
|
274
|
+
"NVIDIA Corporation/NV_Cache",
|
|
275
|
+
):
|
|
276
|
+
candidate = base / relative
|
|
277
|
+
if candidate.is_dir():
|
|
278
|
+
roots.append(candidate)
|
|
279
|
+
dot_cache = home_dir / ".cache"
|
|
280
|
+
if dot_cache.is_dir():
|
|
281
|
+
roots.append(dot_cache)
|
|
282
|
+
if not roots:
|
|
283
|
+
roots.append(home_dir)
|
|
284
|
+
return roots
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def analysis_path(directory: Path | None = None) -> Path:
|
|
288
|
+
return (state_dir() if directory is None else directory) / "analysis.json"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def save_analysis(analysis: Analysis, directory: Path | None = None) -> Path:
|
|
292
|
+
path = analysis_path(directory)
|
|
293
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
294
|
+
path.write_text(json.dumps(analysis.to_json()), encoding="utf-8")
|
|
295
|
+
return path
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def load_analysis(directory: Path | None = None) -> Analysis | None:
|
|
299
|
+
path = analysis_path(directory)
|
|
300
|
+
if not path.is_file():
|
|
301
|
+
return None
|
|
302
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
303
|
+
if not isinstance(data, dict):
|
|
304
|
+
return None
|
|
305
|
+
return Analysis.from_json(data)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def scan(
|
|
309
|
+
roots: list[Path],
|
|
310
|
+
*,
|
|
311
|
+
now: float,
|
|
312
|
+
skip_under: Path | None = None,
|
|
313
|
+
max_records: int = MAX_RECORDS,
|
|
314
|
+
) -> Analysis:
|
|
315
|
+
"""Walk ``roots`` without following links. The tree is not modified."""
|
|
316
|
+
totals = {key: CategoryTotal() for key in CATEGORIES}
|
|
317
|
+
seen: list[FileRecord] = []
|
|
318
|
+
errors = 0
|
|
319
|
+
skipped_links = 0
|
|
320
|
+
stale_count = 0
|
|
321
|
+
stale_bytes = 0
|
|
322
|
+
skip = skip_under
|
|
323
|
+
for root in roots:
|
|
324
|
+
kind = classify_path(root)
|
|
325
|
+
if kind == "link":
|
|
326
|
+
skipped_links += 1
|
|
327
|
+
continue
|
|
328
|
+
if kind == "file":
|
|
329
|
+
_note_file(root, inside_project=False, now=now, totals=totals, seen=seen)
|
|
330
|
+
continue
|
|
331
|
+
if kind != "dir":
|
|
332
|
+
errors += 1
|
|
333
|
+
continue
|
|
334
|
+
stack: list[tuple[Path, bool]] = [(root, False)]
|
|
335
|
+
while stack:
|
|
336
|
+
current, inherited = stack.pop()
|
|
337
|
+
if skip is not None and _is_under(current, skip):
|
|
338
|
+
continue
|
|
339
|
+
try:
|
|
340
|
+
names = list_child_names(current)
|
|
341
|
+
except OSError:
|
|
342
|
+
errors += 1
|
|
343
|
+
continue
|
|
344
|
+
if names is None:
|
|
345
|
+
skipped_links += 1
|
|
346
|
+
continue
|
|
347
|
+
project = inherited or any(_project_marker(name) for name in names)
|
|
348
|
+
for name in names:
|
|
349
|
+
child = current / name
|
|
350
|
+
if skip is not None and _is_under(child, skip):
|
|
351
|
+
continue
|
|
352
|
+
child_kind = classify_path(child)
|
|
353
|
+
if child_kind == "link":
|
|
354
|
+
skipped_links += 1
|
|
355
|
+
continue
|
|
356
|
+
if child_kind == "dir" and name.casefold() in _CACHE_DIRS:
|
|
357
|
+
added = _note_bundle(child, now=now, totals=totals, seen=seen)
|
|
358
|
+
if not added:
|
|
359
|
+
errors += 1
|
|
360
|
+
continue
|
|
361
|
+
if child_kind == "dir":
|
|
362
|
+
stack.append((child, project))
|
|
363
|
+
continue
|
|
364
|
+
if child_kind == "file":
|
|
365
|
+
if not _note_file(child, inside_project=project, now=now, totals=totals, seen=seen):
|
|
366
|
+
errors += 1
|
|
367
|
+
continue
|
|
368
|
+
if child_kind == "error":
|
|
369
|
+
errors += 1
|
|
370
|
+
for record in seen:
|
|
371
|
+
if record.stale:
|
|
372
|
+
stale_count += 1
|
|
373
|
+
stale_bytes += record.size
|
|
374
|
+
capped = len(seen) > max_records
|
|
375
|
+
if capped:
|
|
376
|
+
seen.sort(key=lambda item: (-item.size, item.path))
|
|
377
|
+
seen = seen[:max_records]
|
|
378
|
+
_mark_duplicates(seen, now)
|
|
379
|
+
seen.sort(key=lambda item: (-item.size, item.path))
|
|
380
|
+
return Analysis(
|
|
381
|
+
scanned_at=now,
|
|
382
|
+
roots=tuple(str(root) for root in roots),
|
|
383
|
+
totals=totals,
|
|
384
|
+
records=tuple(seen),
|
|
385
|
+
errors=errors,
|
|
386
|
+
skipped_links=skipped_links,
|
|
387
|
+
capped=capped,
|
|
388
|
+
stale_count=stale_count,
|
|
389
|
+
stale_bytes=stale_bytes,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def format_analysis(analysis: Analysis) -> str:
|
|
394
|
+
when = datetime.fromtimestamp(analysis.scanned_at).strftime("%Y-%m-%d %H:%M:%S")
|
|
395
|
+
lines = [
|
|
396
|
+
f"Headroom analysis {when}",
|
|
397
|
+
"Read-only. Nothing was moved or deleted.",
|
|
398
|
+
"",
|
|
399
|
+
f"{'CATEGORY':<24} {'FILES':>8} {'SIZE':>12}",
|
|
400
|
+
]
|
|
401
|
+
for key in CATEGORIES:
|
|
402
|
+
total = analysis.totals.get(key) or CategoryTotal()
|
|
403
|
+
if total.count == 0:
|
|
404
|
+
continue
|
|
405
|
+
lines.append(
|
|
406
|
+
f"{CATEGORY_LABELS[key]:<24} {total.count:8d} {format_bytes(total.bytes):>12}"
|
|
407
|
+
)
|
|
408
|
+
if not any(total.count for total in analysis.totals.values()):
|
|
409
|
+
lines.append("No files were read.")
|
|
410
|
+
lines.append("")
|
|
411
|
+
lines.append(
|
|
412
|
+
f"Old or unopened (last opened about {STALE_DAYS} days ago or more): "
|
|
413
|
+
f"{analysis.stale_count} files, {format_bytes(analysis.stale_bytes)}."
|
|
414
|
+
)
|
|
415
|
+
if analysis.skipped_links:
|
|
416
|
+
lines.append(f"Skipped links: {analysis.skipped_links}. Their targets were not read.")
|
|
417
|
+
if analysis.errors:
|
|
418
|
+
lines.append(f"Unreadable entries: {analysis.errors}.")
|
|
419
|
+
if analysis.capped:
|
|
420
|
+
lines.append(
|
|
421
|
+
f"The review list keeps the largest {len(analysis.records)} items. Totals include the rest."
|
|
422
|
+
)
|
|
423
|
+
notable = [record for record in analysis.records if record.duplicate_of or record.importance >= 70]
|
|
424
|
+
if notable:
|
|
425
|
+
lines.append("")
|
|
426
|
+
lines.append("Higher-importance items and duplicate copies:")
|
|
427
|
+
for record in notable[:12]:
|
|
428
|
+
extra = ""
|
|
429
|
+
if record.duplicate_of:
|
|
430
|
+
extra = f" duplicate of {record.duplicate_of}"
|
|
431
|
+
lines.append(
|
|
432
|
+
f" {format_bytes(record.size):>10} {record.importance:3d} "
|
|
433
|
+
f"{CATEGORY_LABELS.get(record.category, record.category):<22} {record.path}{extra}"
|
|
434
|
+
)
|
|
435
|
+
lines.append("")
|
|
436
|
+
lines.append("Suggestions stay suggestions until you review them.")
|
|
437
|
+
return "\n".join(lines) + "\n"
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _note_file(path: Path, *, inside_project: bool, now: float, totals: dict, seen: list) -> bool:
|
|
441
|
+
try:
|
|
442
|
+
stat_result = path.lstat()
|
|
443
|
+
size = int(stat_result.st_size)
|
|
444
|
+
accessed = float(stat_result.st_atime or stat_result.st_mtime or now)
|
|
445
|
+
except OSError:
|
|
446
|
+
return False
|
|
447
|
+
_append(str(path), size, accessed, str(path), inside_project, False, now, totals, seen)
|
|
448
|
+
return True
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _note_bundle(path: Path, *, now: float, totals: dict, seen: list) -> bool:
|
|
452
|
+
measured = measure_tree(path)
|
|
453
|
+
try:
|
|
454
|
+
stat_result = path.lstat()
|
|
455
|
+
accessed = float(stat_result.st_atime or stat_result.st_mtime or now)
|
|
456
|
+
except OSError:
|
|
457
|
+
accessed = now
|
|
458
|
+
_append(str(path), measured.bytes, accessed, str(path), False, True, now, totals, seen)
|
|
459
|
+
return True
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _append(
|
|
463
|
+
text: str,
|
|
464
|
+
size: int,
|
|
465
|
+
accessed: float,
|
|
466
|
+
display: str,
|
|
467
|
+
inside_project: bool,
|
|
468
|
+
is_dir: bool,
|
|
469
|
+
now: float,
|
|
470
|
+
totals: dict[str, CategoryTotal],
|
|
471
|
+
seen: list[FileRecord],
|
|
472
|
+
) -> None:
|
|
473
|
+
category = classify_entry(display, is_dir=is_dir, inside_project=inside_project)
|
|
474
|
+
age_days = (now - accessed) / 86400
|
|
475
|
+
stale = age_days >= STALE_DAYS
|
|
476
|
+
record = FileRecord(
|
|
477
|
+
path=display,
|
|
478
|
+
size=max(0, size),
|
|
479
|
+
accessed=accessed,
|
|
480
|
+
category=category,
|
|
481
|
+
importance=importance(category, accessed, now),
|
|
482
|
+
is_dir=is_dir,
|
|
483
|
+
stale=stale,
|
|
484
|
+
base_category=category,
|
|
485
|
+
)
|
|
486
|
+
bucket = totals.setdefault(category, CategoryTotal())
|
|
487
|
+
bucket.count += 1
|
|
488
|
+
bucket.bytes += record.size
|
|
489
|
+
seen.append(record)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _mark_duplicates(records: list[FileRecord], now: float) -> None:
|
|
493
|
+
by_size: dict[int, list[FileRecord]] = {}
|
|
494
|
+
for record in records:
|
|
495
|
+
if record.is_dir or record.size <= 0:
|
|
496
|
+
continue
|
|
497
|
+
by_size.setdefault(record.size, []).append(record)
|
|
498
|
+
for group in by_size.values():
|
|
499
|
+
if len(group) < 2:
|
|
500
|
+
continue
|
|
501
|
+
group.sort(key=lambda item: item.path)
|
|
502
|
+
hashed: dict[str, str] = {}
|
|
503
|
+
for record in group:
|
|
504
|
+
digest = _digest(Path(record.path))
|
|
505
|
+
if digest is None:
|
|
506
|
+
continue
|
|
507
|
+
original = hashed.get(digest)
|
|
508
|
+
if original is None:
|
|
509
|
+
hashed[digest] = record.path
|
|
510
|
+
continue
|
|
511
|
+
record.duplicate_of = original
|
|
512
|
+
record.base_category = record.base_category or record.category
|
|
513
|
+
record.category = "duplicates"
|
|
514
|
+
record.importance = importance("duplicates", record.accessed, now)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _digest(path: Path) -> str | None:
|
|
518
|
+
flags = os.O_RDONLY
|
|
519
|
+
nofollow = getattr(os, "O_NOFOLLOW", 0)
|
|
520
|
+
noatime = getattr(os, "O_NOATIME", 0)
|
|
521
|
+
if nofollow:
|
|
522
|
+
flags |= nofollow
|
|
523
|
+
fd = None
|
|
524
|
+
try:
|
|
525
|
+
try:
|
|
526
|
+
fd = os.open(path, flags | noatime) if noatime else os.open(path, flags)
|
|
527
|
+
except OSError:
|
|
528
|
+
fd = os.open(path, flags)
|
|
529
|
+
hasher = hashlib.sha256()
|
|
530
|
+
remaining = _HASH_LIMIT
|
|
531
|
+
while remaining > 0:
|
|
532
|
+
chunk = os.read(fd, min(65536, remaining))
|
|
533
|
+
if not chunk:
|
|
534
|
+
break
|
|
535
|
+
hasher.update(chunk)
|
|
536
|
+
remaining -= len(chunk)
|
|
537
|
+
return hasher.hexdigest()
|
|
538
|
+
except OSError:
|
|
539
|
+
return None
|
|
540
|
+
finally:
|
|
541
|
+
if fd is not None:
|
|
542
|
+
os.close(fd)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _project_marker(name: str) -> bool:
|
|
546
|
+
folded = name.casefold()
|
|
547
|
+
return folded in _PROJECT_FILES or folded.endswith(".sln")
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _is_under(path: Path, parent: Path) -> bool:
|
|
551
|
+
try:
|
|
552
|
+
path.relative_to(parent)
|
|
553
|
+
except ValueError:
|
|
554
|
+
return False
|
|
555
|
+
return True
|