devklean 1.0.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.
- devklean/__init__.py +6 -0
- devklean/__main__.py +4 -0
- devklean/_version.py +8 -0
- devklean/cli/__init__.py +3 -0
- devklean/cli/commands/__init__.py +4 -0
- devklean/cli/commands/clean.py +106 -0
- devklean/cli/commands/common.py +38 -0
- devklean/cli/commands/doctor.py +43 -0
- devklean/cli/commands/history.py +14 -0
- devklean/cli/commands/restore.py +20 -0
- devklean/cli/commands/scan.py +16 -0
- devklean/cli/confirmation.py +51 -0
- devklean/cli/dispatcher.py +45 -0
- devklean/cli/main.py +61 -0
- devklean/cli/parser.py +164 -0
- devklean/config/__init__.py +15 -0
- devklean/config/defaults.py +14 -0
- devklean/config/manager.py +226 -0
- devklean/config/models.py +48 -0
- devklean/config/paths.py +24 -0
- devklean/config/targets.py +23 -0
- devklean/deletion/__init__.py +17 -0
- devklean/deletion/history.py +66 -0
- devklean/deletion/integrity.py +39 -0
- devklean/deletion/metadata.py +198 -0
- devklean/deletion/paths.py +24 -0
- devklean/deletion/safety.py +176 -0
- devklean/deletion/trash.py +89 -0
- devklean/formatting.py +31 -0
- devklean/logging_setup.py +74 -0
- devklean/models.py +34 -0
- devklean/output/__init__.py +5 -0
- devklean/output/base.py +38 -0
- devklean/output/console.py +94 -0
- devklean/output/history_payload.py +34 -0
- devklean/output/json.py +75 -0
- devklean/output/scan_payload.py +51 -0
- devklean/output/sorting.py +12 -0
- devklean/output/text.py +183 -0
- devklean/output/theme.py +46 -0
- devklean/scanner/__init__.py +17 -0
- devklean/scanner/filters.py +32 -0
- devklean/scanner/scanner.py +170 -0
- devklean/tui.py +126 -0
- devklean-1.0.0.dist-info/METADATA +257 -0
- devklean-1.0.0.dist-info/RECORD +49 -0
- devklean-1.0.0.dist-info/WHEEL +4 -0
- devklean-1.0.0.dist-info/entry_points.txt +2 -0
- devklean-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from devklean.config.models import AppConfig, DefaultsConfig
|
|
9
|
+
from devklean.config.paths import get_config_path
|
|
10
|
+
from devklean.config.targets import merge_targets
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import tomllib
|
|
14
|
+
except ModuleNotFoundError: # pragma: no cover - Python < 3.11
|
|
15
|
+
import tomli as tomllib
|
|
16
|
+
|
|
17
|
+
PROJECT_CONFIG_NAME = ".devklean.toml"
|
|
18
|
+
|
|
19
|
+
_KNOWN_TOP_LEVEL = {"defaults", "targets", "ignore", "exclude"}
|
|
20
|
+
_KNOWN_DEFAULTS = {
|
|
21
|
+
"dry_run",
|
|
22
|
+
"interactive",
|
|
23
|
+
"path",
|
|
24
|
+
"default_yes",
|
|
25
|
+
"theme",
|
|
26
|
+
"confirm_threshold",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ConfigLoadResult:
|
|
32
|
+
config: AppConfig
|
|
33
|
+
warnings: list[str]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ConfigManager:
|
|
37
|
+
"""Load and merge user configuration with built-in defaults.
|
|
38
|
+
|
|
39
|
+
Precedence (lowest to highest): built-in defaults < global config file <
|
|
40
|
+
project ``.devklean.toml`` discovered by walking up from the start dir.
|
|
41
|
+
Scalar keys take the highest-precedence value; list keys are unioned.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
config_path: Path | None = None,
|
|
47
|
+
project_dir: Path | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
self._config_path = config_path if config_path is not None else get_config_path()
|
|
50
|
+
self._project_dir = project_dir if project_dir is not None else Path.cwd()
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def config_path(self) -> Path:
|
|
54
|
+
return self._config_path
|
|
55
|
+
|
|
56
|
+
def load(self) -> AppConfig:
|
|
57
|
+
return self.load_full().config
|
|
58
|
+
|
|
59
|
+
def load_full(self) -> ConfigLoadResult:
|
|
60
|
+
warnings: list[str] = []
|
|
61
|
+
|
|
62
|
+
global_raw = self._read_toml(self._config_path, warnings)
|
|
63
|
+
_validate(global_raw, str(self._config_path), warnings)
|
|
64
|
+
|
|
65
|
+
project_path = self._find_project_config()
|
|
66
|
+
project_raw: dict[str, Any] = {}
|
|
67
|
+
if project_path is not None:
|
|
68
|
+
project_raw = self._read_toml(project_path, warnings)
|
|
69
|
+
_validate(project_raw, str(project_path), warnings)
|
|
70
|
+
|
|
71
|
+
# Layers in increasing precedence.
|
|
72
|
+
config = self._build_config([global_raw, project_raw])
|
|
73
|
+
return ConfigLoadResult(config=config, warnings=warnings)
|
|
74
|
+
|
|
75
|
+
def apply_defaults(self, args, raw_argv: list[str]) -> None:
|
|
76
|
+
"""Apply configured defaults when the CLI did not override them."""
|
|
77
|
+
config = getattr(args, "_config", None)
|
|
78
|
+
if config is None:
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
if getattr(args, "command", None) == "clean":
|
|
82
|
+
if "--dry-run" not in raw_argv:
|
|
83
|
+
args.dry_run = config.defaults.dry_run
|
|
84
|
+
if "-i" not in raw_argv and "--interactive" not in raw_argv:
|
|
85
|
+
args.interactive = config.defaults.interactive
|
|
86
|
+
|
|
87
|
+
if getattr(args, "path", None) == "." and not _explicit_path_provided(raw_argv):
|
|
88
|
+
args.path = os.path.expanduser(config.defaults.path)
|
|
89
|
+
|
|
90
|
+
# --- internals ---
|
|
91
|
+
|
|
92
|
+
def _read_toml(self, path: Path, warnings: list[str]) -> dict[str, Any]:
|
|
93
|
+
if not path.is_file():
|
|
94
|
+
return {}
|
|
95
|
+
try:
|
|
96
|
+
return tomllib.loads(path.read_text(encoding="utf-8"))
|
|
97
|
+
except (tomllib.TOMLDecodeError, OSError) as exc:
|
|
98
|
+
warnings.append(f"Could not parse config '{path}': {exc}; ignoring it.")
|
|
99
|
+
return {}
|
|
100
|
+
|
|
101
|
+
def _find_project_config(self) -> Path | None:
|
|
102
|
+
current = self._project_dir.resolve()
|
|
103
|
+
for directory in (current, *current.parents):
|
|
104
|
+
candidate = directory / PROJECT_CONFIG_NAME
|
|
105
|
+
if candidate.is_file():
|
|
106
|
+
return candidate
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
def _build_config(self, layers: list[dict[str, Any]]) -> AppConfig:
|
|
110
|
+
# Scalars: highest-precedence (last non-empty) layer wins.
|
|
111
|
+
# Lists: unioned across layers preserving order.
|
|
112
|
+
defaults = self._merge_defaults(layers)
|
|
113
|
+
|
|
114
|
+
exclude_dirs = _union_lists(*[_as_str_list(layer.get("exclude", [])) for layer in layers])
|
|
115
|
+
|
|
116
|
+
target_excludes = _union_lists(
|
|
117
|
+
*[_as_str_list(layer.get("targets", {}).get("exclude", [])) for layer in layers]
|
|
118
|
+
)
|
|
119
|
+
custom_targets: dict[str, str] = {}
|
|
120
|
+
for layer in layers:
|
|
121
|
+
custom = layer.get("targets", {}).get("custom", {})
|
|
122
|
+
if isinstance(custom, dict):
|
|
123
|
+
custom_targets.update({str(k): str(v) for k, v in custom.items()})
|
|
124
|
+
|
|
125
|
+
merged_targets = merge_targets(exclude=target_excludes, custom=custom_targets)
|
|
126
|
+
|
|
127
|
+
ignored_paths = tuple(
|
|
128
|
+
_normalize_path(path)
|
|
129
|
+
for path in _union_lists(
|
|
130
|
+
*[_as_str_list(layer.get("ignore", {}).get("paths", [])) for layer in layers]
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
ignored_directories = tuple(
|
|
134
|
+
_union_lists(
|
|
135
|
+
exclude_dirs,
|
|
136
|
+
*[_as_str_list(layer.get("ignore", {}).get("directories", [])) for layer in layers],
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return AppConfig(
|
|
141
|
+
targets=merged_targets,
|
|
142
|
+
ignored_paths=ignored_paths,
|
|
143
|
+
ignored_directories=ignored_directories,
|
|
144
|
+
defaults=defaults,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def _merge_defaults(self, layers: list[dict[str, Any]]) -> DefaultsConfig:
|
|
148
|
+
base = DefaultsConfig()
|
|
149
|
+
merged: dict[str, Any] = {
|
|
150
|
+
"dry_run": base.dry_run,
|
|
151
|
+
"interactive": base.interactive,
|
|
152
|
+
"path": base.path,
|
|
153
|
+
"default_yes": base.default_yes,
|
|
154
|
+
"theme": base.theme,
|
|
155
|
+
"confirm_threshold": base.confirm_threshold,
|
|
156
|
+
}
|
|
157
|
+
for layer in layers:
|
|
158
|
+
section = layer.get("defaults", {})
|
|
159
|
+
if not isinstance(section, dict):
|
|
160
|
+
continue
|
|
161
|
+
for key in ("dry_run", "interactive", "default_yes"):
|
|
162
|
+
if key in section:
|
|
163
|
+
merged[key] = bool(section[key])
|
|
164
|
+
if "path" in section:
|
|
165
|
+
merged["path"] = str(section["path"])
|
|
166
|
+
if "theme" in section:
|
|
167
|
+
merged["theme"] = str(section["theme"])
|
|
168
|
+
if "confirm_threshold" in section:
|
|
169
|
+
try:
|
|
170
|
+
merged["confirm_threshold"] = int(section["confirm_threshold"])
|
|
171
|
+
except (TypeError, ValueError):
|
|
172
|
+
pass
|
|
173
|
+
return DefaultsConfig(**merged)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _validate(data: dict[str, Any], source: str, warnings: list[str]) -> None:
|
|
177
|
+
for key in data:
|
|
178
|
+
if key not in _KNOWN_TOP_LEVEL:
|
|
179
|
+
warnings.append(f"Unknown config key '{key}' in '{source}' (ignored).")
|
|
180
|
+
defaults_section = data.get("defaults", {})
|
|
181
|
+
if isinstance(defaults_section, dict):
|
|
182
|
+
for key in defaults_section:
|
|
183
|
+
if key not in _KNOWN_DEFAULTS:
|
|
184
|
+
warnings.append(f"Unknown key 'defaults.{key}' in '{source}' (ignored).")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _union_lists(*lists: list[str]) -> list[str]:
|
|
188
|
+
seen: dict[str, None] = {}
|
|
189
|
+
for items in lists:
|
|
190
|
+
for item in items:
|
|
191
|
+
seen.setdefault(item, None)
|
|
192
|
+
return list(seen)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _as_str_list(value: Any) -> list[str]:
|
|
196
|
+
if not isinstance(value, list):
|
|
197
|
+
return []
|
|
198
|
+
return [str(item) for item in value]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _normalize_path(path: str) -> str:
|
|
202
|
+
return os.path.abspath(os.path.expanduser(path))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _explicit_path_provided(argv: list[str]) -> bool:
|
|
206
|
+
"""Return True when a positional path appears on the command line."""
|
|
207
|
+
command_names = {
|
|
208
|
+
"scan",
|
|
209
|
+
"clean",
|
|
210
|
+
"history",
|
|
211
|
+
"doctor",
|
|
212
|
+
"stats",
|
|
213
|
+
"restore",
|
|
214
|
+
"config",
|
|
215
|
+
"plugins",
|
|
216
|
+
}
|
|
217
|
+
index = 1
|
|
218
|
+
if index < len(argv) and argv[index] in command_names:
|
|
219
|
+
index += 1
|
|
220
|
+
|
|
221
|
+
while index < len(argv):
|
|
222
|
+
if argv[index].startswith("-"):
|
|
223
|
+
index += 1
|
|
224
|
+
continue
|
|
225
|
+
return True
|
|
226
|
+
return False
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from devklean.config.defaults import DEFAULT_TARGETS
|
|
6
|
+
|
|
7
|
+
# Single source of truth for the large-deletion confirmation threshold; the
|
|
8
|
+
# CLI/TUI confirmation flow re-exports this as DEFAULT_LARGE_THRESHOLD.
|
|
9
|
+
DEFAULT_CONFIRM_THRESHOLD = 1024**3 # 1 GiB
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class DefaultsConfig:
|
|
14
|
+
dry_run: bool = False
|
|
15
|
+
interactive: bool = False
|
|
16
|
+
path: str = "."
|
|
17
|
+
default_yes: bool = False
|
|
18
|
+
theme: str = "default"
|
|
19
|
+
confirm_threshold: int = DEFAULT_CONFIRM_THRESHOLD
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class AppConfig:
|
|
24
|
+
targets: dict[str, str]
|
|
25
|
+
ignored_paths: tuple[str, ...] = ()
|
|
26
|
+
ignored_directories: tuple[str, ...] = ()
|
|
27
|
+
defaults: DefaultsConfig = field(default_factory=DefaultsConfig)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def scan_settings(self) -> ScanSettings:
|
|
31
|
+
return ScanSettings(
|
|
32
|
+
targets=dict(self.targets),
|
|
33
|
+
ignored_paths=frozenset(self.ignored_paths),
|
|
34
|
+
ignored_directories=frozenset(self.ignored_directories),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class ScanSettings:
|
|
40
|
+
"""Scan-specific settings derived from application configuration."""
|
|
41
|
+
|
|
42
|
+
targets: dict[str, str]
|
|
43
|
+
ignored_paths: frozenset[str] = field(default_factory=frozenset)
|
|
44
|
+
ignored_directories: frozenset[str] = field(default_factory=frozenset)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def defaults(cls) -> ScanSettings:
|
|
48
|
+
return cls(targets=dict(DEFAULT_TARGETS))
|
devklean/config/paths.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_config_path() -> Path:
|
|
9
|
+
"""Return the platform-appropriate config file path.
|
|
10
|
+
|
|
11
|
+
An explicit ``XDG_CONFIG_HOME`` override is honored on every platform; the
|
|
12
|
+
Windows-native ``APPDATA`` location is only the fallback when it is unset.
|
|
13
|
+
"""
|
|
14
|
+
xdg_config = os.environ.get("XDG_CONFIG_HOME")
|
|
15
|
+
if xdg_config:
|
|
16
|
+
return Path(xdg_config) / "devklean" / "config.toml"
|
|
17
|
+
|
|
18
|
+
if sys.platform == "win32":
|
|
19
|
+
base = os.environ.get("APPDATA")
|
|
20
|
+
if base:
|
|
21
|
+
return Path(base) / "devklean" / "config.toml"
|
|
22
|
+
return Path.home() / "AppData" / "Roaming" / "devklean" / "config.toml"
|
|
23
|
+
|
|
24
|
+
return Path.home() / ".config" / "devklean" / "config.toml"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Target merge logic for configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from devklean.config.defaults import DEFAULT_TARGETS
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def merge_targets(
|
|
9
|
+
*,
|
|
10
|
+
exclude: list[str] | tuple[str, ...] = (),
|
|
11
|
+
custom: dict[str, str] | None = None,
|
|
12
|
+
) -> dict[str, str]:
|
|
13
|
+
"""Merge built-in defaults with user exclusions and custom targets."""
|
|
14
|
+
merged = dict(DEFAULT_TARGETS)
|
|
15
|
+
|
|
16
|
+
for name in exclude:
|
|
17
|
+
merged.pop(name, None)
|
|
18
|
+
|
|
19
|
+
if custom:
|
|
20
|
+
for name, label in custom.items():
|
|
21
|
+
merged[str(name)] = str(label)
|
|
22
|
+
|
|
23
|
+
return merged
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from devklean.deletion.integrity import IntegrityReport, check_integrity
|
|
4
|
+
from devklean.deletion.metadata import MetadataManager
|
|
5
|
+
from devklean.deletion.paths import get_deletion_metadata_dir
|
|
6
|
+
from devklean.deletion.safety import SafetyValidator, SafetyViolation
|
|
7
|
+
from devklean.deletion.trash import delete_items
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"IntegrityReport",
|
|
11
|
+
"MetadataManager",
|
|
12
|
+
"SafetyValidator",
|
|
13
|
+
"SafetyViolation",
|
|
14
|
+
"check_integrity",
|
|
15
|
+
"delete_items",
|
|
16
|
+
"get_deletion_metadata_dir",
|
|
17
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Sequence
|
|
5
|
+
|
|
6
|
+
from devklean.deletion.metadata import StoredDeletionMetadata
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class HistoryOperation:
|
|
11
|
+
"""A single cleanup operation aggregated from per-item metadata records."""
|
|
12
|
+
|
|
13
|
+
run_id: str | None
|
|
14
|
+
timestamp: str
|
|
15
|
+
strategy: str
|
|
16
|
+
item_count: int
|
|
17
|
+
reclaimed_size: int
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_history(
|
|
21
|
+
records: Sequence[StoredDeletionMetadata],
|
|
22
|
+
) -> tuple[HistoryOperation, ...]:
|
|
23
|
+
"""Aggregate per-item deletion records into cleanup operations.
|
|
24
|
+
|
|
25
|
+
Records sharing a ``run_id`` are grouped into one operation. Legacy
|
|
26
|
+
records without a ``run_id`` each become their own single-item operation.
|
|
27
|
+
Operations are returned newest-first by timestamp.
|
|
28
|
+
"""
|
|
29
|
+
grouped: dict[str, list[StoredDeletionMetadata]] = {}
|
|
30
|
+
ungrouped: list[StoredDeletionMetadata] = []
|
|
31
|
+
|
|
32
|
+
for stored in records:
|
|
33
|
+
run_id = stored.record.run_id
|
|
34
|
+
if run_id is None:
|
|
35
|
+
ungrouped.append(stored)
|
|
36
|
+
else:
|
|
37
|
+
grouped.setdefault(run_id, []).append(stored)
|
|
38
|
+
|
|
39
|
+
operations: list[HistoryOperation] = []
|
|
40
|
+
|
|
41
|
+
for run_id, group in grouped.items():
|
|
42
|
+
first = group[0].record
|
|
43
|
+
operations.append(
|
|
44
|
+
HistoryOperation(
|
|
45
|
+
run_id=run_id,
|
|
46
|
+
timestamp=first.timestamp,
|
|
47
|
+
strategy=first.strategy,
|
|
48
|
+
item_count=len(group),
|
|
49
|
+
reclaimed_size=sum(entry.record.item.size for entry in group),
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
for stored in ungrouped:
|
|
54
|
+
record = stored.record
|
|
55
|
+
operations.append(
|
|
56
|
+
HistoryOperation(
|
|
57
|
+
run_id=None,
|
|
58
|
+
timestamp=record.timestamp,
|
|
59
|
+
strategy=record.strategy,
|
|
60
|
+
item_count=1,
|
|
61
|
+
reclaimed_size=record.item.size,
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
operations.sort(key=lambda op: op.timestamp, reverse=True)
|
|
66
|
+
return tuple(operations)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from devklean.deletion.metadata import (
|
|
6
|
+
CorruptMetadata,
|
|
7
|
+
MetadataManager,
|
|
8
|
+
StoredDeletionMetadata,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class IntegrityReport:
|
|
14
|
+
"""The outcome of inspecting the metadata store."""
|
|
15
|
+
|
|
16
|
+
valid: tuple[StoredDeletionMetadata, ...]
|
|
17
|
+
corrupt: tuple[CorruptMetadata, ...]
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def has_issues(self) -> bool:
|
|
21
|
+
return bool(self.corrupt)
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def healthy(self) -> bool:
|
|
25
|
+
return not self.has_issues
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def check_integrity(manager: MetadataManager) -> IntegrityReport:
|
|
29
|
+
"""Inspect the metadata store for corruption.
|
|
30
|
+
|
|
31
|
+
Detects malformed JSON, missing fields, and type mismatches on load.
|
|
32
|
+
|
|
33
|
+
Orphan detection (records whose trashed item was later emptied from the
|
|
34
|
+
trash) is no longer possible: items go to the native OS trash via
|
|
35
|
+
``send2trash``, which devklean neither owns nor tracks, so there is no trash
|
|
36
|
+
path to check for existence.
|
|
37
|
+
"""
|
|
38
|
+
snapshot = manager.load_records()
|
|
39
|
+
return IntegrityReport(valid=snapshot.records, corrupt=snapshot.corrupt)
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Sequence
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from devklean.deletion.paths import get_deletion_metadata_dir
|
|
11
|
+
from devklean.models import CleanableItem, DeleteResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class DeletionMetadataItem:
|
|
16
|
+
original_path: str
|
|
17
|
+
display_name: str
|
|
18
|
+
size: int
|
|
19
|
+
|
|
20
|
+
def to_dict(self) -> dict[str, object]:
|
|
21
|
+
return {
|
|
22
|
+
"original_path": self.original_path,
|
|
23
|
+
"display_name": self.display_name,
|
|
24
|
+
"size": self.size,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class DeletionMetadataRecord:
|
|
30
|
+
schema_version: int
|
|
31
|
+
deletion_id: str
|
|
32
|
+
run_id: str | None
|
|
33
|
+
timestamp: str
|
|
34
|
+
strategy: str
|
|
35
|
+
item: DeletionMetadataItem
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict[str, object]:
|
|
38
|
+
return {
|
|
39
|
+
"schema_version": self.schema_version,
|
|
40
|
+
"deletion": {
|
|
41
|
+
"id": self.deletion_id,
|
|
42
|
+
"run_id": self.run_id,
|
|
43
|
+
"timestamp": self.timestamp,
|
|
44
|
+
"strategy": self.strategy,
|
|
45
|
+
},
|
|
46
|
+
"item": self.item.to_dict(),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class StoredDeletionMetadata:
|
|
52
|
+
path: Path
|
|
53
|
+
record: DeletionMetadataRecord
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class CorruptMetadata:
|
|
58
|
+
"""A metadata file that could not be parsed, with the reason why."""
|
|
59
|
+
|
|
60
|
+
path: Path
|
|
61
|
+
reason: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class MetadataLoadResult:
|
|
66
|
+
records: tuple[StoredDeletionMetadata, ...]
|
|
67
|
+
corrupt: tuple[CorruptMetadata, ...]
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def invalid_count(self) -> int:
|
|
71
|
+
return len(self.corrupt)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _parse_record(data: dict[str, object]) -> DeletionMetadataRecord:
|
|
75
|
+
deletion = data.get("deletion")
|
|
76
|
+
item = data.get("item")
|
|
77
|
+
if not isinstance(deletion, dict) or not isinstance(item, dict):
|
|
78
|
+
raise ValueError("missing or invalid 'deletion'/'item' section")
|
|
79
|
+
|
|
80
|
+
deletion_id = deletion.get("id")
|
|
81
|
+
run_id = deletion.get("run_id")
|
|
82
|
+
timestamp = deletion.get("timestamp")
|
|
83
|
+
strategy = deletion.get("strategy")
|
|
84
|
+
original_path = item.get("original_path")
|
|
85
|
+
display_name = item.get("display_name")
|
|
86
|
+
size = item.get("size")
|
|
87
|
+
|
|
88
|
+
# Records predating the schema_version field are treated as v1. Any integer
|
|
89
|
+
# version is accepted as-is; there are no migrations yet.
|
|
90
|
+
schema_version = data.get("schema_version", 1)
|
|
91
|
+
|
|
92
|
+
if not (
|
|
93
|
+
isinstance(deletion_id, str)
|
|
94
|
+
and (run_id is None or isinstance(run_id, str))
|
|
95
|
+
and isinstance(timestamp, str)
|
|
96
|
+
and isinstance(strategy, str)
|
|
97
|
+
and isinstance(original_path, str)
|
|
98
|
+
and isinstance(display_name, str)
|
|
99
|
+
and isinstance(size, int)
|
|
100
|
+
and isinstance(schema_version, int)
|
|
101
|
+
):
|
|
102
|
+
raise ValueError("missing or wrong-typed metadata fields")
|
|
103
|
+
|
|
104
|
+
return DeletionMetadataRecord(
|
|
105
|
+
schema_version=schema_version,
|
|
106
|
+
deletion_id=deletion_id,
|
|
107
|
+
run_id=run_id,
|
|
108
|
+
timestamp=timestamp,
|
|
109
|
+
strategy=strategy,
|
|
110
|
+
item=DeletionMetadataItem(
|
|
111
|
+
original_path=original_path,
|
|
112
|
+
display_name=display_name,
|
|
113
|
+
size=size,
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _metadata_sort_key(entry: StoredDeletionMetadata) -> str:
|
|
119
|
+
return entry.record.timestamp
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class MetadataManager:
|
|
123
|
+
"""Persist deletion metadata in the app data directory."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, storage_dir: Path | None = None) -> None:
|
|
126
|
+
self._storage_dir = storage_dir if storage_dir is not None else get_deletion_metadata_dir()
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def storage_dir(self) -> Path:
|
|
130
|
+
return self._storage_dir
|
|
131
|
+
|
|
132
|
+
def load_records(self) -> MetadataLoadResult:
|
|
133
|
+
if not self._storage_dir.exists():
|
|
134
|
+
return MetadataLoadResult(records=(), corrupt=())
|
|
135
|
+
|
|
136
|
+
records: list[StoredDeletionMetadata] = []
|
|
137
|
+
corrupt: list[CorruptMetadata] = []
|
|
138
|
+
|
|
139
|
+
for path in sorted(self._storage_dir.glob("*.json")):
|
|
140
|
+
try:
|
|
141
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
142
|
+
if not isinstance(data, dict):
|
|
143
|
+
raise ValueError("metadata file is not a JSON object")
|
|
144
|
+
record = _parse_record(data)
|
|
145
|
+
except json.JSONDecodeError:
|
|
146
|
+
corrupt.append(CorruptMetadata(path=path, reason="malformed JSON"))
|
|
147
|
+
continue
|
|
148
|
+
except OSError as exc:
|
|
149
|
+
corrupt.append(CorruptMetadata(path=path, reason=f"unreadable file: {exc}"))
|
|
150
|
+
continue
|
|
151
|
+
except (ValueError, TypeError) as exc:
|
|
152
|
+
corrupt.append(CorruptMetadata(path=path, reason=str(exc)))
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
records.append(StoredDeletionMetadata(path=path, record=record))
|
|
156
|
+
|
|
157
|
+
records.sort(key=_metadata_sort_key, reverse=True)
|
|
158
|
+
return MetadataLoadResult(records=tuple(records), corrupt=tuple(corrupt))
|
|
159
|
+
|
|
160
|
+
def remove_record(self, entry: StoredDeletionMetadata | CorruptMetadata) -> None:
|
|
161
|
+
"""Delete a single metadata file from the store (valid or corrupt)."""
|
|
162
|
+
entry.path.unlink(missing_ok=True)
|
|
163
|
+
|
|
164
|
+
def record_successes(
|
|
165
|
+
self,
|
|
166
|
+
items: Sequence[CleanableItem],
|
|
167
|
+
result: DeleteResult,
|
|
168
|
+
strategy: str,
|
|
169
|
+
) -> None:
|
|
170
|
+
deleted_paths = set(result.deleted)
|
|
171
|
+
if not deleted_paths:
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
self._storage_dir.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
|
|
176
|
+
run_id = uuid4().hex
|
|
177
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
178
|
+
|
|
179
|
+
for item in items:
|
|
180
|
+
if item.path not in deleted_paths:
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
record = DeletionMetadataRecord(
|
|
184
|
+
schema_version=3,
|
|
185
|
+
deletion_id=uuid4().hex,
|
|
186
|
+
run_id=run_id,
|
|
187
|
+
timestamp=timestamp,
|
|
188
|
+
strategy=strategy,
|
|
189
|
+
item=DeletionMetadataItem(
|
|
190
|
+
original_path=item.path,
|
|
191
|
+
display_name=item.display_label,
|
|
192
|
+
size=item.size,
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
stamp = record.timestamp.replace(":", "").replace("+00:00", "Z")
|
|
196
|
+
filename = f"{stamp}_{record.deletion_id}.json"
|
|
197
|
+
path = self._storage_dir / filename
|
|
198
|
+
path.write_text(json.dumps(record.to_dict(), indent=2) + "\n", encoding="utf-8")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_deletion_metadata_dir() -> Path:
|
|
9
|
+
"""Return the directory used to persist deletion metadata.
|
|
10
|
+
|
|
11
|
+
An explicit ``XDG_DATA_HOME`` override is honored on every platform; the
|
|
12
|
+
Windows-native ``APPDATA`` location is only the fallback when it is unset.
|
|
13
|
+
"""
|
|
14
|
+
xdg_data = os.environ.get("XDG_DATA_HOME")
|
|
15
|
+
if xdg_data:
|
|
16
|
+
return Path(xdg_data) / "devklean" / "deletions"
|
|
17
|
+
|
|
18
|
+
if sys.platform == "win32":
|
|
19
|
+
base = os.environ.get("APPDATA")
|
|
20
|
+
if base:
|
|
21
|
+
return Path(base) / "devklean" / "deletions"
|
|
22
|
+
return Path.home() / "AppData" / "Roaming" / "devklean" / "deletions"
|
|
23
|
+
|
|
24
|
+
return Path.home() / ".local" / "share" / "devklean" / "deletions"
|