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,176 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Sequence
|
|
8
|
+
|
|
9
|
+
from devklean.models import CleanableItem
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class SafetyViolation:
|
|
14
|
+
"""A single safety rule that a path violated."""
|
|
15
|
+
|
|
16
|
+
rule: str
|
|
17
|
+
path: str
|
|
18
|
+
message: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _posix_protected_paths() -> set[str]:
|
|
22
|
+
paths = {
|
|
23
|
+
"/",
|
|
24
|
+
"/bin",
|
|
25
|
+
"/sbin",
|
|
26
|
+
"/boot",
|
|
27
|
+
"/dev",
|
|
28
|
+
"/etc",
|
|
29
|
+
"/lib",
|
|
30
|
+
"/lib32",
|
|
31
|
+
"/lib64",
|
|
32
|
+
"/proc",
|
|
33
|
+
"/sys",
|
|
34
|
+
"/usr",
|
|
35
|
+
"/var",
|
|
36
|
+
"/opt",
|
|
37
|
+
"/root",
|
|
38
|
+
"/run",
|
|
39
|
+
"/srv",
|
|
40
|
+
}
|
|
41
|
+
if sys.platform == "darwin":
|
|
42
|
+
paths |= {
|
|
43
|
+
"/System",
|
|
44
|
+
"/Library",
|
|
45
|
+
"/Applications",
|
|
46
|
+
"/private",
|
|
47
|
+
"/cores",
|
|
48
|
+
"/Volumes",
|
|
49
|
+
}
|
|
50
|
+
return paths
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _windows_protected_paths() -> set[str]:
|
|
54
|
+
paths: set[str] = set()
|
|
55
|
+
system_root = os.environ.get("SystemRoot")
|
|
56
|
+
if system_root:
|
|
57
|
+
paths.add(system_root)
|
|
58
|
+
system_drive = os.environ.get("SystemDrive", "C:")
|
|
59
|
+
paths.add(system_drive + "\\")
|
|
60
|
+
paths.add(system_drive + "\\Program Files")
|
|
61
|
+
paths.add(system_drive + "\\Program Files (x86)")
|
|
62
|
+
return paths
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def protected_system_paths() -> frozenset[str]:
|
|
66
|
+
"""Return the platform-aware set of protected system directories.
|
|
67
|
+
|
|
68
|
+
Paths are normalized (and lower-cased on Windows) for exact-match
|
|
69
|
+
comparison against a resolved real path.
|
|
70
|
+
"""
|
|
71
|
+
raw = _windows_protected_paths() if os.name == "nt" else _posix_protected_paths()
|
|
72
|
+
return frozenset(_normalize(path) for path in raw)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _normalize(path: str) -> str:
|
|
76
|
+
normalized = os.path.normpath(path)
|
|
77
|
+
if os.name == "nt":
|
|
78
|
+
return normalized.lower()
|
|
79
|
+
return normalized
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class SafetyValidator:
|
|
83
|
+
"""The single place safety checks live for all deletion strategies.
|
|
84
|
+
|
|
85
|
+
Every rule is checked here so strategies never duplicate validation.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
allow_symlinks: bool = False,
|
|
91
|
+
protected_paths: frozenset[str] | None = None,
|
|
92
|
+
) -> None:
|
|
93
|
+
self._allow_symlinks = allow_symlinks
|
|
94
|
+
# Normalize whatever set we hold so injected protected_paths match the
|
|
95
|
+
# same way the default set does (_normalize is idempotent). Without this
|
|
96
|
+
# an injected raw path never matches the normalized candidate on Windows,
|
|
97
|
+
# where _normalize also lower-cases.
|
|
98
|
+
raw = protected_paths if protected_paths is not None else protected_system_paths()
|
|
99
|
+
self._protected = frozenset(_normalize(path) for path in raw)
|
|
100
|
+
|
|
101
|
+
def validate(self, path: str) -> SafetyViolation | None:
|
|
102
|
+
"""Return the first violated rule for ``path``, or None if safe.
|
|
103
|
+
|
|
104
|
+
Structural location rules (root, home, mount, protected) are checked
|
|
105
|
+
before the symlink rule, and against both the literal and the resolved
|
|
106
|
+
path. This matters on macOS, where ``/etc``, ``/var``, and ``/tmp`` are
|
|
107
|
+
themselves symlinks into ``/private``: such a path must be reported as a
|
|
108
|
+
protected system directory — and must not be bypassable with
|
|
109
|
+
``--allow-symlinks`` — rather than as a plain symbolic link.
|
|
110
|
+
"""
|
|
111
|
+
real = os.path.realpath(path)
|
|
112
|
+
|
|
113
|
+
if real == os.path.abspath(os.sep) or self._normalized(real) == os.sep:
|
|
114
|
+
return SafetyViolation(
|
|
115
|
+
rule="filesystem_root",
|
|
116
|
+
path=path,
|
|
117
|
+
message=f"Refusing to delete '{path}': it is the filesystem root.",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
home = self._home_dir()
|
|
121
|
+
if home is not None and self._normalized(real) == home:
|
|
122
|
+
return SafetyViolation(
|
|
123
|
+
rule="home_directory",
|
|
124
|
+
path=path,
|
|
125
|
+
message=f"Refusing to delete '{path}': it is your home directory.",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if os.path.ismount(real):
|
|
129
|
+
return SafetyViolation(
|
|
130
|
+
rule="mount_point",
|
|
131
|
+
path=path,
|
|
132
|
+
message=f"Refusing to delete '{path}': it is a mounted drive root.",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if self._normalized(path) in self._protected or self._normalized(real) in self._protected:
|
|
136
|
+
return SafetyViolation(
|
|
137
|
+
rule="protected_system_directory",
|
|
138
|
+
path=path,
|
|
139
|
+
message=f"Refusing to delete '{path}': protected system directory.",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if not self._allow_symlinks and os.path.islink(path):
|
|
143
|
+
return SafetyViolation(
|
|
144
|
+
rule="symlink",
|
|
145
|
+
path=path,
|
|
146
|
+
message=(
|
|
147
|
+
f"Refusing to delete '{path}': it is a symbolic link "
|
|
148
|
+
f"(use --allow-symlinks to override)."
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
def partition(
|
|
155
|
+
self,
|
|
156
|
+
items: Sequence[CleanableItem],
|
|
157
|
+
) -> tuple[list[CleanableItem], list[tuple[CleanableItem, SafetyViolation]]]:
|
|
158
|
+
"""Split items into (safe, blocked) where blocked carries the violation."""
|
|
159
|
+
safe: list[CleanableItem] = []
|
|
160
|
+
blocked: list[tuple[CleanableItem, SafetyViolation]] = []
|
|
161
|
+
for item in items:
|
|
162
|
+
violation = self.validate(item.path)
|
|
163
|
+
if violation is None:
|
|
164
|
+
safe.append(item)
|
|
165
|
+
else:
|
|
166
|
+
blocked.append((item, violation))
|
|
167
|
+
return safe, blocked
|
|
168
|
+
|
|
169
|
+
def _normalized(self, real: str) -> str:
|
|
170
|
+
return _normalize(real)
|
|
171
|
+
|
|
172
|
+
def _home_dir(self) -> str | None:
|
|
173
|
+
try:
|
|
174
|
+
return _normalize(os.path.realpath(Path.home()))
|
|
175
|
+
except (RuntimeError, OSError):
|
|
176
|
+
return None
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
|
|
5
|
+
from send2trash import send2trash
|
|
6
|
+
|
|
7
|
+
from devklean.deletion.metadata import MetadataManager
|
|
8
|
+
from devklean.deletion.safety import SafetyValidator
|
|
9
|
+
from devklean.logging_setup import get_logger
|
|
10
|
+
from devklean.models import CleanableItem, DeleteFailure, DeleteResult
|
|
11
|
+
|
|
12
|
+
# The single deletion backend is the native OS trash (Recycle Bin on Windows,
|
|
13
|
+
# ~/.Trash on macOS, the freedesktop trash on Linux) via send2trash. There is
|
|
14
|
+
# only one method, so the name recorded in metadata/history is a literal.
|
|
15
|
+
STRATEGY_NAME = "trash"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def delete_items(
|
|
19
|
+
items: Sequence[CleanableItem],
|
|
20
|
+
total_size: int,
|
|
21
|
+
*,
|
|
22
|
+
validator: SafetyValidator | None = None,
|
|
23
|
+
metadata_manager: MetadataManager | None = None,
|
|
24
|
+
dry_run: bool = False,
|
|
25
|
+
) -> DeleteResult:
|
|
26
|
+
"""Validate, then move safe items to the native OS trash via ``send2trash``.
|
|
27
|
+
|
|
28
|
+
Safety validation runs first; only validated ("safe") items are ever passed
|
|
29
|
+
to ``send2trash``. Under ``dry_run`` the function returns the planned result
|
|
30
|
+
*before any ``send2trash`` call is reachable* — the structural guarantee
|
|
31
|
+
that a dry run performs no filesystem operations. Successful deletions are
|
|
32
|
+
recorded in the metadata store (used by ``history`` and ``doctor``).
|
|
33
|
+
"""
|
|
34
|
+
validator = validator or SafetyValidator()
|
|
35
|
+
safe, blocked = validator.partition(items)
|
|
36
|
+
blocked_failures = tuple(
|
|
37
|
+
DeleteFailure(path=item.path, error=violation.message) for item, violation in blocked
|
|
38
|
+
)
|
|
39
|
+
safe_total = sum(item.size for item in safe)
|
|
40
|
+
logger = get_logger()
|
|
41
|
+
|
|
42
|
+
if dry_run:
|
|
43
|
+
# Structural dry-run guard: no send2trash call below is reachable.
|
|
44
|
+
result = DeleteResult(
|
|
45
|
+
deleted=tuple(item.path for item in safe),
|
|
46
|
+
failed=blocked_failures,
|
|
47
|
+
total_size=safe_total,
|
|
48
|
+
)
|
|
49
|
+
logger.info(
|
|
50
|
+
"dry-run plan strategy=%s would_delete=%d size=%d",
|
|
51
|
+
STRATEGY_NAME,
|
|
52
|
+
result.deleted_count,
|
|
53
|
+
result.total_size,
|
|
54
|
+
)
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
deleted: list[str] = []
|
|
58
|
+
failures: list[DeleteFailure] = []
|
|
59
|
+
for item in safe:
|
|
60
|
+
try:
|
|
61
|
+
send2trash(item.path)
|
|
62
|
+
deleted.append(item.path)
|
|
63
|
+
except OSError as exc:
|
|
64
|
+
# TrashPermissionError subclasses OSError; ENOENT/EACCES and
|
|
65
|
+
# platform-specific failures surface here too. Report the path and
|
|
66
|
+
# keep going so one bad item never aborts the batch.
|
|
67
|
+
failures.append(DeleteFailure(path=item.path, error=str(exc)))
|
|
68
|
+
|
|
69
|
+
result = DeleteResult(
|
|
70
|
+
deleted=tuple(deleted),
|
|
71
|
+
failed=tuple(failures) + blocked_failures,
|
|
72
|
+
total_size=safe_total,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
for path in result.deleted:
|
|
76
|
+
logger.info("deleted strategy=%s path=%s", STRATEGY_NAME, path)
|
|
77
|
+
for failure in result.failed:
|
|
78
|
+
logger.warning("delete failed path=%s error=%s", failure.path, failure.error)
|
|
79
|
+
logger.info(
|
|
80
|
+
"deletion summary strategy=%s deleted=%d failed=%d size=%d",
|
|
81
|
+
STRATEGY_NAME,
|
|
82
|
+
result.deleted_count,
|
|
83
|
+
result.failed_count,
|
|
84
|
+
result.total_size,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
manager = metadata_manager or MetadataManager()
|
|
88
|
+
manager.record_successes(items, result, STRATEGY_NAME)
|
|
89
|
+
return result
|
devklean/formatting.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def format_size(size_bytes: float) -> str:
|
|
5
|
+
for unit in ["B", "KB", "MB", "GB"]:
|
|
6
|
+
if size_bytes < 1024:
|
|
7
|
+
return f"{size_bytes:.1f} {unit}"
|
|
8
|
+
size_bytes /= 1024
|
|
9
|
+
return f"{size_bytes:.1f} TB"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def format_timestamp(value: str) -> str:
|
|
13
|
+
"""Render a stored UTC ISO timestamp as local ``YYYY-MM-DD HH:MM``.
|
|
14
|
+
|
|
15
|
+
Falls back to the raw value if it cannot be parsed.
|
|
16
|
+
"""
|
|
17
|
+
try:
|
|
18
|
+
parsed = datetime.fromisoformat(value)
|
|
19
|
+
except ValueError:
|
|
20
|
+
return value
|
|
21
|
+
return parsed.astimezone().strftime("%Y-%m-%d %H:%M")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def truncate(text: str, max_len: int) -> str:
|
|
25
|
+
if max_len <= 0:
|
|
26
|
+
return ""
|
|
27
|
+
if len(text) <= max_len:
|
|
28
|
+
return text
|
|
29
|
+
if max_len <= 3:
|
|
30
|
+
return text[:max_len]
|
|
31
|
+
return text[: max_len - 3] + "..."
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""File-based structured logging, kept entirely separate from terminal output.
|
|
2
|
+
|
|
3
|
+
The renderers own everything the user sees on stdout/stderr. This logger writes
|
|
4
|
+
detail to a rotating file and never attaches a console handler, so the two
|
|
5
|
+
concerns cannot interfere.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from logging.handlers import RotatingFileHandler
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
_LOGGER_NAME = "devklean"
|
|
17
|
+
_MAX_BYTES = 1_000_000
|
|
18
|
+
_BACKUP_COUNT = 5
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_log_path() -> Path:
|
|
22
|
+
"""Return the rotating log file path (XDG cache / Windows LOCALAPPDATA aware).
|
|
23
|
+
|
|
24
|
+
An explicit ``XDG_CACHE_HOME`` override is honored on every platform; the
|
|
25
|
+
Windows-native ``LOCALAPPDATA`` location is only the fallback when it is unset.
|
|
26
|
+
"""
|
|
27
|
+
cache = os.environ.get("XDG_CACHE_HOME")
|
|
28
|
+
if cache:
|
|
29
|
+
return Path(cache) / "devklean" / "logs" / "latest.log"
|
|
30
|
+
|
|
31
|
+
if sys.platform == "win32":
|
|
32
|
+
base = os.environ.get("LOCALAPPDATA")
|
|
33
|
+
root = Path(base) if base else Path.home() / "AppData" / "Local"
|
|
34
|
+
return root / "devklean" / "logs" / "latest.log"
|
|
35
|
+
|
|
36
|
+
return Path.home() / ".cache" / "devklean" / "logs" / "latest.log"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_logger() -> logging.Logger:
|
|
40
|
+
return logging.getLogger(_LOGGER_NAME)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def configure_logging(level: int = logging.INFO) -> logging.Logger:
|
|
44
|
+
"""Configure the devklean file logger. Idempotent and re-pointable."""
|
|
45
|
+
logger = get_logger()
|
|
46
|
+
logger.setLevel(level)
|
|
47
|
+
logger.propagate = False
|
|
48
|
+
|
|
49
|
+
# Reset handlers so repeated calls (and tests pointing elsewhere) re-target.
|
|
50
|
+
for handler in list(logger.handlers):
|
|
51
|
+
logger.removeHandler(handler)
|
|
52
|
+
handler.close()
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
log_path = get_log_path()
|
|
56
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
handler = RotatingFileHandler(
|
|
58
|
+
log_path, maxBytes=_MAX_BYTES, backupCount=_BACKUP_COUNT, encoding="utf-8"
|
|
59
|
+
)
|
|
60
|
+
handler.setFormatter(
|
|
61
|
+
logging.Formatter(
|
|
62
|
+
fmt="%(asctime)s %(levelname)s %(message)s",
|
|
63
|
+
datefmt="%Y-%m-%dT%H:%M:%S%z",
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
logger.addHandler(handler)
|
|
67
|
+
except OSError:
|
|
68
|
+
# Logging must never break the CLI; degrade to no file logging.
|
|
69
|
+
logger.addHandler(logging.NullHandler())
|
|
70
|
+
return logger
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def log_invocation(argv: list[str], command: str | None) -> None:
|
|
74
|
+
get_logger().info("invoke command=%s argv=%s", command, " ".join(argv))
|
devklean/models.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class CleanableItem:
|
|
8
|
+
"""A discovered directory that devklean can remove."""
|
|
9
|
+
|
|
10
|
+
path: str
|
|
11
|
+
name: str
|
|
12
|
+
size: int
|
|
13
|
+
display_label: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class DeleteFailure:
|
|
18
|
+
path: str
|
|
19
|
+
error: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class DeleteResult:
|
|
24
|
+
deleted: tuple[str, ...]
|
|
25
|
+
failed: tuple[DeleteFailure, ...]
|
|
26
|
+
total_size: int
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def deleted_count(self) -> int:
|
|
30
|
+
return len(self.deleted)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def failed_count(self) -> int:
|
|
34
|
+
return len(self.failed)
|
devklean/output/base.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Protocol, Sequence
|
|
4
|
+
|
|
5
|
+
from devklean.deletion.history import HistoryOperation
|
|
6
|
+
from devklean.models import CleanableItem, DeleteResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Renderer(Protocol):
|
|
10
|
+
"""Output abstraction for presenting devklean results to the user."""
|
|
11
|
+
|
|
12
|
+
def scan_start(self, root: str) -> None: ...
|
|
13
|
+
|
|
14
|
+
def scan_summary(self, items: list[CleanableItem]) -> int: ...
|
|
15
|
+
|
|
16
|
+
def nothing_to_clean(self) -> None: ...
|
|
17
|
+
|
|
18
|
+
def dry_run_nothing_deleted(self) -> None: ...
|
|
19
|
+
|
|
20
|
+
def dry_run_selected(self, count: int) -> None: ...
|
|
21
|
+
|
|
22
|
+
def aborted(self) -> None: ...
|
|
23
|
+
|
|
24
|
+
def no_items_selected(self) -> None: ...
|
|
25
|
+
|
|
26
|
+
def invalid_directory(self, path: str) -> None: ...
|
|
27
|
+
|
|
28
|
+
def confirm_prompt(self, count: int, total_size: int = 0) -> str: ...
|
|
29
|
+
|
|
30
|
+
def deletion_result(self, result: DeleteResult) -> None: ...
|
|
31
|
+
|
|
32
|
+
def permission_warnings(self, paths: Sequence[str]) -> None: ...
|
|
33
|
+
|
|
34
|
+
def history(
|
|
35
|
+
self,
|
|
36
|
+
operations: Sequence[HistoryOperation],
|
|
37
|
+
invalid_count: int,
|
|
38
|
+
) -> None: ...
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from typing import TextIO
|
|
6
|
+
|
|
7
|
+
from devklean.output.theme import Palette, get_theme
|
|
8
|
+
|
|
9
|
+
# Semantic symbols — fixed across every command and renderer.
|
|
10
|
+
SYM_SUCCESS = "✓"
|
|
11
|
+
SYM_ERROR = "✗"
|
|
12
|
+
SYM_WARNING = "⚠"
|
|
13
|
+
SYM_INFO = "•"
|
|
14
|
+
|
|
15
|
+
_ROLE_TO_CODE = {
|
|
16
|
+
"success": "green",
|
|
17
|
+
"error": "red",
|
|
18
|
+
"warning": "yellow",
|
|
19
|
+
"info": "cyan",
|
|
20
|
+
"detail": "dim",
|
|
21
|
+
"bold": "bold",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def should_use_color(stream: TextIO, theme_name: str) -> bool:
|
|
26
|
+
"""Color is on only when NO_COLOR is unset, stream is a tty, theme != mono."""
|
|
27
|
+
if os.environ.get("NO_COLOR") is not None:
|
|
28
|
+
return False
|
|
29
|
+
if theme_name == "mono":
|
|
30
|
+
return False
|
|
31
|
+
isatty = getattr(stream, "isatty", None)
|
|
32
|
+
return bool(isatty and isatty())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Console:
|
|
36
|
+
"""Centralized, color-gated terminal output with a fixed symbol scheme."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
stream: TextIO | None = None,
|
|
41
|
+
theme: str = "default",
|
|
42
|
+
color: bool | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._stream = stream if stream is not None else sys.stdout
|
|
45
|
+
self._theme = get_theme(theme)
|
|
46
|
+
self._use_color = color if color is not None else should_use_color(self._stream, theme)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def palette(self) -> Palette:
|
|
50
|
+
return self._theme.palette
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def use_color(self) -> bool:
|
|
54
|
+
return self._use_color
|
|
55
|
+
|
|
56
|
+
def code(self, role: str) -> str:
|
|
57
|
+
"""Return the ANSI code for a semantic role, or '' when color is off."""
|
|
58
|
+
if not self._use_color:
|
|
59
|
+
return ""
|
|
60
|
+
attr = _ROLE_TO_CODE.get(role)
|
|
61
|
+
return getattr(self.palette, attr) if attr else ""
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def reset(self) -> str:
|
|
65
|
+
return self.palette.reset if self._use_color else ""
|
|
66
|
+
|
|
67
|
+
def paint(self, text: str, role: str) -> str:
|
|
68
|
+
"""Wrap text in the role's color, or return it unchanged when color is off."""
|
|
69
|
+
code = self.code(role)
|
|
70
|
+
if not code:
|
|
71
|
+
return text
|
|
72
|
+
return f"{code}{text}{self.reset}"
|
|
73
|
+
|
|
74
|
+
def _line(self, symbol: str, role: str, message: str) -> None:
|
|
75
|
+
prefix = self.paint(symbol, role)
|
|
76
|
+
print(f"{prefix} {message}", file=self._stream)
|
|
77
|
+
|
|
78
|
+
def success(self, message: str) -> None:
|
|
79
|
+
self._line(SYM_SUCCESS, "success", message)
|
|
80
|
+
|
|
81
|
+
def error(self, message: str) -> None:
|
|
82
|
+
self._line(SYM_ERROR, "error", message)
|
|
83
|
+
|
|
84
|
+
def warning(self, message: str) -> None:
|
|
85
|
+
self._line(SYM_WARNING, "warning", message)
|
|
86
|
+
|
|
87
|
+
def info(self, message: str) -> None:
|
|
88
|
+
self._line(SYM_INFO, "info", message)
|
|
89
|
+
|
|
90
|
+
def detail(self, message: str) -> None:
|
|
91
|
+
print(self.paint(message, "detail"), file=self._stream)
|
|
92
|
+
|
|
93
|
+
def plain(self, message: str = "") -> None:
|
|
94
|
+
print(message, file=self._stream)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Sequence
|
|
4
|
+
|
|
5
|
+
from devklean.deletion.history import HistoryOperation
|
|
6
|
+
from devklean.formatting import format_size
|
|
7
|
+
|
|
8
|
+
HISTORY_OUTPUT_VERSION = "1.0"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_history_payload(operations: Sequence[HistoryOperation]) -> dict:
|
|
12
|
+
"""Build the canonical history result structure for renderers and tooling."""
|
|
13
|
+
total_reclaimed_size = sum(op.reclaimed_size for op in operations)
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
"version": HISTORY_OUTPUT_VERSION,
|
|
17
|
+
"operations": [serialize_history_operation(op) for op in operations],
|
|
18
|
+
"summary": {
|
|
19
|
+
"count": len(operations),
|
|
20
|
+
"total_reclaimed_size": total_reclaimed_size,
|
|
21
|
+
"formatted_total_reclaimed_size": format_size(total_reclaimed_size),
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def serialize_history_operation(operation: HistoryOperation) -> dict:
|
|
27
|
+
return {
|
|
28
|
+
"run_id": operation.run_id,
|
|
29
|
+
"timestamp": operation.timestamp,
|
|
30
|
+
"strategy": operation.strategy,
|
|
31
|
+
"item_count": operation.item_count,
|
|
32
|
+
"reclaimed_size": operation.reclaimed_size,
|
|
33
|
+
"formatted_reclaimed_size": format_size(operation.reclaimed_size),
|
|
34
|
+
}
|
devklean/output/json.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Sequence
|
|
6
|
+
|
|
7
|
+
from devklean.deletion.history import HistoryOperation
|
|
8
|
+
from devklean.models import CleanableItem, DeleteResult
|
|
9
|
+
from devklean.output.history_payload import build_history_payload
|
|
10
|
+
from devklean.output.scan_payload import (
|
|
11
|
+
build_error_payload,
|
|
12
|
+
build_scan_payload,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class JsonRenderer:
|
|
17
|
+
"""Machine-readable JSON output for tooling and automation."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, *, stream=None) -> None:
|
|
20
|
+
self._stream = stream if stream is not None else sys.stdout
|
|
21
|
+
self._root = ""
|
|
22
|
+
self._permission_errors: list[str] = []
|
|
23
|
+
|
|
24
|
+
def scan_start(self, root: str) -> None:
|
|
25
|
+
self._root = root
|
|
26
|
+
|
|
27
|
+
def scan_summary(self, items: list[CleanableItem]) -> int:
|
|
28
|
+
payload = build_scan_payload(self._root, items, self._permission_errors)
|
|
29
|
+
self._emit(payload)
|
|
30
|
+
return payload["summary"]["total_size"]
|
|
31
|
+
|
|
32
|
+
def nothing_to_clean(self) -> None:
|
|
33
|
+
self._emit(build_scan_payload(self._root, [], self._permission_errors))
|
|
34
|
+
|
|
35
|
+
def dry_run_nothing_deleted(self) -> None:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
def dry_run_selected(self, count: int) -> None:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
def aborted(self) -> None:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
def no_items_selected(self) -> None:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
def invalid_directory(self, path: str) -> None:
|
|
48
|
+
self._emit(
|
|
49
|
+
build_error_payload(
|
|
50
|
+
"invalid_directory",
|
|
51
|
+
f"'{path}' is not a directory.",
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def confirm_prompt(self, count: int, total_size: int = 0) -> str:
|
|
56
|
+
raise NotImplementedError("confirm_prompt is not supported in JSON mode")
|
|
57
|
+
|
|
58
|
+
def deletion_result(self, result: DeleteResult) -> None:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
def permission_warnings(self, paths) -> None:
|
|
62
|
+
# Captured here and emitted in the scan payload so JSON consumers see
|
|
63
|
+
# the same skipped-path information the text renderer prints.
|
|
64
|
+
self._permission_errors = list(paths)
|
|
65
|
+
|
|
66
|
+
def history(
|
|
67
|
+
self,
|
|
68
|
+
operations: Sequence[HistoryOperation],
|
|
69
|
+
invalid_count: int,
|
|
70
|
+
) -> None:
|
|
71
|
+
self._emit(build_history_payload(operations))
|
|
72
|
+
|
|
73
|
+
def _emit(self, payload: dict) -> None:
|
|
74
|
+
json.dump(payload, self._stream, indent=2)
|
|
75
|
+
self._stream.write("\n")
|