purger 0.2.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.
- purger/__init__.py +3 -0
- purger/clean_flow.py +178 -0
- purger/cli.py +504 -0
- purger/concurrency.py +55 -0
- purger/execution.py +131 -0
- purger/help_text.py +173 -0
- purger/measure_display.py +72 -0
- purger/output.py +221 -0
- purger/plugin.py +112 -0
- purger/plugins/__init__.py +1 -0
- purger/plugins/_shared.py +45 -0
- purger/plugins/_template.py +113 -0
- purger/plugins/ansible.py +82 -0
- purger/plugins/apt.py +29 -0
- purger/plugins/brew.py +128 -0
- purger/plugins/bun.py +71 -0
- purger/plugins/cocoapods.py +33 -0
- purger/plugins/conda.py +45 -0
- purger/plugins/corepack.py +34 -0
- purger/plugins/dnf.py +24 -0
- purger/plugins/docker.py +113 -0
- purger/plugins/flatpak.py +37 -0
- purger/plugins/go.py +34 -0
- purger/plugins/macports.py +43 -0
- purger/plugins/maestro.py +63 -0
- purger/plugins/mise.py +77 -0
- purger/plugins/npm.py +65 -0
- purger/plugins/pacman.py +57 -0
- purger/plugins/pip.py +46 -0
- purger/plugins/playwright.py +82 -0
- purger/plugins/pnpm.py +70 -0
- purger/plugins/podman.py +126 -0
- purger/plugins/pre_commit.py +49 -0
- purger/plugins/prek.py +27 -0
- purger/plugins/rip2.py +45 -0
- purger/plugins/simulators.py +78 -0
- purger/plugins/swiftpm.py +37 -0
- purger/plugins/thumbnails.py +20 -0
- purger/plugins/trash.py +67 -0
- purger/plugins/uv.py +35 -0
- purger/plugins/xcode.py +35 -0
- purger/plugins/yarn.py +55 -0
- purger/registry.py +126 -0
- purger/reports.py +70 -0
- purger/runner.py +142 -0
- purger/safety.py +176 -0
- purger/sizing.py +354 -0
- purger-0.2.0.dist-info/METADATA +179 -0
- purger-0.2.0.dist-info/RECORD +52 -0
- purger-0.2.0.dist-info/WHEEL +4 -0
- purger-0.2.0.dist-info/entry_points.txt +3 -0
- purger-0.2.0.dist-info/licenses/LICENSE +21 -0
purger/__init__.py
ADDED
purger/clean_flow.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Decide which cleanup actions run, based on risk tier and user flags."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from nclutils.strings import human_size
|
|
7
|
+
|
|
8
|
+
from purger.plugin import Tier
|
|
9
|
+
from purger.reports import ActionReport, PluginReport, Status, sum_known
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, kw_only=True)
|
|
13
|
+
class CleanOptions:
|
|
14
|
+
"""User flags that shape a clean run.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
dangerous: Allow DANGEROUS actions to be offered.
|
|
18
|
+
dry_run: Plan only; never approve or run anything.
|
|
19
|
+
yes: Skip the single SAFE confirmation. Never affects CAUTION or DANGEROUS.
|
|
20
|
+
interactive: Whether a human can answer prompts. Defaults to False so a
|
|
21
|
+
caller that forgets to set it never gets prompts it cannot answer.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
dangerous: bool = False
|
|
25
|
+
dry_run: bool = False
|
|
26
|
+
yes: bool = False
|
|
27
|
+
interactive: bool = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class _Entry:
|
|
32
|
+
plugin_name: str
|
|
33
|
+
report: ActionReport
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def safe_prompt(items: list[tuple[str, ActionReport]]) -> str:
|
|
37
|
+
"""Build the single confirmation text for all SAFE actions.
|
|
38
|
+
|
|
39
|
+
The prompt names tools rather than actions, so users never need to know
|
|
40
|
+
which commands purger runs. A tool is marked `(sudo)` when any of its
|
|
41
|
+
actions runs with sudo, so the user knows before agreeing to the batch.
|
|
42
|
+
"""
|
|
43
|
+
sudo_tools: dict[str, bool] = {}
|
|
44
|
+
for plugin, report in items:
|
|
45
|
+
sudo_tools[plugin] = sudo_tools.get(plugin, False) or report.action.needs_sudo
|
|
46
|
+
names = ", ".join(
|
|
47
|
+
f"{plugin} (sudo)" if needs_sudo else plugin for plugin, needs_sudo in sudo_tools.items()
|
|
48
|
+
)
|
|
49
|
+
text = f"Run safe cleanups for {names}"
|
|
50
|
+
sizes = [report.size_bytes for _, report in items]
|
|
51
|
+
total = sum_known(sizes) if None not in sizes else None
|
|
52
|
+
if total is not None:
|
|
53
|
+
text += f" (about {human_size(total)})"
|
|
54
|
+
return f"{text}?"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def risky_prompt(plugin_name: str, report: ActionReport) -> str:
|
|
58
|
+
"""Build the per-action confirmation text for a CAUTION or DANGEROUS action.
|
|
59
|
+
|
|
60
|
+
The help sentence explains the cost in plain words, so it stands in for the
|
|
61
|
+
action name.
|
|
62
|
+
"""
|
|
63
|
+
action = report.action
|
|
64
|
+
text = plugin_name
|
|
65
|
+
if report.size_bytes is not None:
|
|
66
|
+
# Before the help, which can hold several sentences the size must not attach to.
|
|
67
|
+
text += f" (about {human_size(report.size_bytes)})"
|
|
68
|
+
text += f": {action.help.strip().rstrip('.')}"
|
|
69
|
+
if action.tier is Tier.DANGEROUS:
|
|
70
|
+
text += ". This may permanently delete data"
|
|
71
|
+
if action.needs_sudo:
|
|
72
|
+
text += ". Requires sudo"
|
|
73
|
+
return f"{text}. Continue?"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def plan_clean(
|
|
77
|
+
reports: list[PluginReport],
|
|
78
|
+
opts: CleanOptions,
|
|
79
|
+
*,
|
|
80
|
+
confirm: Callable[[str], bool],
|
|
81
|
+
sudo_ready: Callable[[], bool],
|
|
82
|
+
) -> list[ActionReport]:
|
|
83
|
+
"""Decide which actions run and set a status on every other action.
|
|
84
|
+
|
|
85
|
+
Centralizing the decision enforces one safety rule: `--yes` or a
|
|
86
|
+
non-interactive run can never approve a CAUTION or DANGEROUS action.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
reports: Measured plugin reports.
|
|
90
|
+
opts: User flags.
|
|
91
|
+
confirm: Ask a yes/no question. It may raise KeyboardInterrupt to stop the run.
|
|
92
|
+
sudo_ready: Report whether sudo can run without a prompt.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
list[ActionReport]: Approved actions in plugin order; their status stays None.
|
|
96
|
+
"""
|
|
97
|
+
candidates = _filter_candidates(reports=reports, opts=opts)
|
|
98
|
+
|
|
99
|
+
if opts.dry_run:
|
|
100
|
+
for entry in candidates:
|
|
101
|
+
entry.report.status = Status.PLANNED
|
|
102
|
+
return []
|
|
103
|
+
|
|
104
|
+
approved: list[_Entry] = []
|
|
105
|
+
safe = [e for e in candidates if e.report.action.tier is Tier.SAFE]
|
|
106
|
+
if safe:
|
|
107
|
+
approved.extend(_decide_safe(safe=safe, opts=opts, confirm=confirm, sudo_ready=sudo_ready))
|
|
108
|
+
|
|
109
|
+
for tier in (Tier.CAUTION, Tier.DANGEROUS):
|
|
110
|
+
for entry in (e for e in candidates if e.report.action.tier is tier):
|
|
111
|
+
if opts.interactive and confirm(
|
|
112
|
+
risky_prompt(plugin_name=entry.plugin_name, report=entry.report)
|
|
113
|
+
):
|
|
114
|
+
approved.append(entry)
|
|
115
|
+
else:
|
|
116
|
+
entry.report.status = Status.DECLINED
|
|
117
|
+
|
|
118
|
+
approved_ids = {id(entry) for entry in approved}
|
|
119
|
+
return [entry.report for entry in candidates if id(entry) in approved_ids]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _decide_safe(
|
|
123
|
+
safe: list[_Entry],
|
|
124
|
+
opts: CleanOptions,
|
|
125
|
+
*,
|
|
126
|
+
confirm: Callable[[str], bool],
|
|
127
|
+
sudo_ready: Callable[[], bool],
|
|
128
|
+
) -> list[_Entry]:
|
|
129
|
+
"""Resolve the SAFE group and return the approved entries."""
|
|
130
|
+
if opts.yes:
|
|
131
|
+
granted = True
|
|
132
|
+
elif opts.interactive:
|
|
133
|
+
granted = confirm(safe_prompt([(e.plugin_name, e.report) for e in safe]))
|
|
134
|
+
else:
|
|
135
|
+
granted = False
|
|
136
|
+
|
|
137
|
+
if not granted:
|
|
138
|
+
for entry in safe:
|
|
139
|
+
entry.report.status = Status.DECLINED
|
|
140
|
+
return []
|
|
141
|
+
|
|
142
|
+
if opts.interactive:
|
|
143
|
+
return safe
|
|
144
|
+
|
|
145
|
+
approved: list[_Entry] = []
|
|
146
|
+
sudo_ok: bool | None = None
|
|
147
|
+
for entry in safe:
|
|
148
|
+
if entry.report.action.needs_sudo:
|
|
149
|
+
if sudo_ok is None:
|
|
150
|
+
sudo_ok = sudo_ready()
|
|
151
|
+
if not sudo_ok:
|
|
152
|
+
entry.report.status = Status.SKIPPED
|
|
153
|
+
continue
|
|
154
|
+
approved.append(entry)
|
|
155
|
+
return approved
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _nothing_to_clean(action_report: ActionReport, plugin_report: PluginReport) -> bool:
|
|
159
|
+
"""Judge emptiness by the action's own size, falling back to the plugin footprint."""
|
|
160
|
+
if action_report.size_bytes is not None:
|
|
161
|
+
return action_report.size_bytes == 0
|
|
162
|
+
return plugin_report.footprint_bytes == 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _filter_candidates(reports: list[PluginReport], opts: CleanOptions) -> list[_Entry]:
|
|
166
|
+
"""Mark undeliverable actions and return the ones still up for approval."""
|
|
167
|
+
candidates: list[_Entry] = []
|
|
168
|
+
for plugin_report in reports:
|
|
169
|
+
for action_report in plugin_report.actions:
|
|
170
|
+
if action_report.action.tier is Tier.DANGEROUS and not opts.dangerous:
|
|
171
|
+
action_report.status = Status.DECLINED
|
|
172
|
+
elif _nothing_to_clean(action_report=action_report, plugin_report=plugin_report):
|
|
173
|
+
action_report.status = Status.NOTHING_TO_CLEAN
|
|
174
|
+
else:
|
|
175
|
+
candidates.append(
|
|
176
|
+
_Entry(plugin_name=plugin_report.plugin.name, report=action_report)
|
|
177
|
+
)
|
|
178
|
+
return candidates
|
purger/cli.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""Command line interface: the `info` and `clean` commands."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shlex
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
8
|
+
from contextvars import ContextVar
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import IntEnum
|
|
11
|
+
from functools import partial
|
|
12
|
+
from typing import Annotated, Any
|
|
13
|
+
|
|
14
|
+
from cyclopts import App, CycloptsError, Parameter
|
|
15
|
+
from nclutils import pp
|
|
16
|
+
from nclutils.ask import choose_one_from_list
|
|
17
|
+
|
|
18
|
+
from purger import __version__
|
|
19
|
+
from purger.clean_flow import CleanOptions, plan_clean
|
|
20
|
+
from purger.concurrency import map_as_completed
|
|
21
|
+
from purger.execution import execute, mark_unfinished, record_freed
|
|
22
|
+
from purger.help_text import (
|
|
23
|
+
APP_HELP,
|
|
24
|
+
CLEAN_EPILOGUE,
|
|
25
|
+
CLEAN_HELP,
|
|
26
|
+
INFO_EPILOGUE,
|
|
27
|
+
INFO_HELP,
|
|
28
|
+
build_epilogue,
|
|
29
|
+
)
|
|
30
|
+
from purger.measure_display import MeasureDisplay
|
|
31
|
+
from purger.output import (
|
|
32
|
+
clean_document,
|
|
33
|
+
error_document,
|
|
34
|
+
info_document,
|
|
35
|
+
render_results,
|
|
36
|
+
render_table,
|
|
37
|
+
)
|
|
38
|
+
from purger.plugin import Plugin, Tier
|
|
39
|
+
from purger.registry import UnknownPluginError, discover_plugins, select_plugins, skip_reason
|
|
40
|
+
from purger.reports import (
|
|
41
|
+
ActionReport,
|
|
42
|
+
PluginReport,
|
|
43
|
+
SkippedPlugin,
|
|
44
|
+
SkipReason,
|
|
45
|
+
Status,
|
|
46
|
+
)
|
|
47
|
+
from purger.runner import sudo_ready
|
|
48
|
+
from purger.sizing import measure_plugin
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ExitCode(IntEnum):
|
|
52
|
+
"""Process exit codes, part of the CLI's public contract."""
|
|
53
|
+
|
|
54
|
+
OK = 0
|
|
55
|
+
ACTION_FAILED = 1
|
|
56
|
+
USAGE = 2
|
|
57
|
+
INTERRUPTED = 130
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_ROOT_MESSAGE = (
|
|
61
|
+
"purger must not run as root. Run it as your normal user: "
|
|
62
|
+
"it asks for sudo only for the actions that need it."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Verbosity given before the command name (`purger -v clean`), added to the command's own.
|
|
66
|
+
_GLOBAL_VERBOSITY: ContextVar[int] = ContextVar("purger_global_verbosity", default=0)
|
|
67
|
+
|
|
68
|
+
app = App(
|
|
69
|
+
name="purger",
|
|
70
|
+
version=__version__,
|
|
71
|
+
help=APP_HELP,
|
|
72
|
+
help_format="markdown",
|
|
73
|
+
default_parameter=Parameter(negative=(), show_default=False),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
PluginNames = Annotated[
|
|
77
|
+
str,
|
|
78
|
+
Parameter(help="Plugin names to include, such as `uv brew`. Omit to include every plugin."),
|
|
79
|
+
]
|
|
80
|
+
DangerousFlag = Annotated[
|
|
81
|
+
bool,
|
|
82
|
+
Parameter(help="Include `dangerous` actions, which can delete data for good."),
|
|
83
|
+
]
|
|
84
|
+
JsonFlag = Annotated[
|
|
85
|
+
bool,
|
|
86
|
+
Parameter(
|
|
87
|
+
name="--json",
|
|
88
|
+
help="Print exactly one JSON document on stdout instead of tables and messages.",
|
|
89
|
+
),
|
|
90
|
+
]
|
|
91
|
+
VerboseFlag = Annotated[
|
|
92
|
+
int,
|
|
93
|
+
Parameter(
|
|
94
|
+
name=["-v", "--verbose"],
|
|
95
|
+
count=True,
|
|
96
|
+
help="Show more detail, such as tool output. Repeat for more (`-vv`).",
|
|
97
|
+
),
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class _Scan:
|
|
103
|
+
"""Measured plugins plus the plugins left out of the run."""
|
|
104
|
+
|
|
105
|
+
reports: list[PluginReport]
|
|
106
|
+
skipped: list[SkippedPlugin]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def attach_plugin_catalog() -> None:
|
|
110
|
+
"""Add the plugin list generated from the registry to every help page.
|
|
111
|
+
|
|
112
|
+
Discovery runs here rather than at import, so importing the CLI and the
|
|
113
|
+
root refusal never depend on plugin modules loading.
|
|
114
|
+
"""
|
|
115
|
+
plugins = list(discover_plugins().values())
|
|
116
|
+
catalog = build_epilogue(static="", plugins=plugins)
|
|
117
|
+
app.help_epilogue = catalog
|
|
118
|
+
app.meta.help_epilogue = catalog
|
|
119
|
+
app["info"].help_epilogue = build_epilogue(static=INFO_EPILOGUE, plugins=plugins)
|
|
120
|
+
app["clean"].help_epilogue = build_epilogue(static=CLEAN_EPILOGUE, plugins=plugins)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def confirm(message: str) -> bool:
|
|
124
|
+
"""Ask a yes/no question so a person approves each risky step.
|
|
125
|
+
|
|
126
|
+
The prompt reports Ctrl-C as None, so a cancelled prompt is raised as an
|
|
127
|
+
interrupt to stop the run rather than read as No.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
message: The question to show.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
bool: True only when the user chose Yes.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
KeyboardInterrupt: If the user cancelled the prompt.
|
|
137
|
+
"""
|
|
138
|
+
answer = choose_one_from_list([("Yes", "yes"), ("No", "no")], message)
|
|
139
|
+
if answer is None:
|
|
140
|
+
raise KeyboardInterrupt
|
|
141
|
+
return answer == "yes"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _reason_text(reason: SkipReason) -> str:
|
|
145
|
+
match reason:
|
|
146
|
+
case SkipReason.PLATFORM:
|
|
147
|
+
return f"not supported on {sys.platform}"
|
|
148
|
+
case SkipReason.MISSING_BINARY:
|
|
149
|
+
return "its tool is not installed"
|
|
150
|
+
case SkipReason.UNAVAILABLE:
|
|
151
|
+
return "its tool is not available (for example, not running)"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _emit_json(document: dict[str, Any]) -> None:
|
|
155
|
+
"""Write a JSON document to stdout without rich, so it is never wrapped or styled."""
|
|
156
|
+
sys.stdout.write(json.dumps(document, indent=2) + "\n")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _error(code: str, message: str, *, exit_code: ExitCode, json_output: bool) -> int:
|
|
160
|
+
if json_output:
|
|
161
|
+
_emit_json(error_document(code=code, message=message))
|
|
162
|
+
else:
|
|
163
|
+
pp.error(message)
|
|
164
|
+
return exit_code
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _measure_one(plugin: Plugin, *, dangerous: bool) -> PluginReport | SkippedPlugin:
|
|
168
|
+
reason = skip_reason(plugin)
|
|
169
|
+
if reason is not None:
|
|
170
|
+
return SkippedPlugin(name=plugin.name, reason=reason)
|
|
171
|
+
return measure_plugin(plugin, include_dangerous=dangerous)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _measure(
|
|
175
|
+
plugins: Sequence[Plugin],
|
|
176
|
+
*,
|
|
177
|
+
dangerous: bool,
|
|
178
|
+
display: MeasureDisplay | None,
|
|
179
|
+
) -> _Scan:
|
|
180
|
+
"""Check and measure plugins concurrently, keeping results in plugin order.
|
|
181
|
+
|
|
182
|
+
Plugins spend most of their time waiting on tool commands, so measuring
|
|
183
|
+
them side by side shortens the wait.
|
|
184
|
+
"""
|
|
185
|
+
results: dict[int, PluginReport | SkippedPlugin] = {}
|
|
186
|
+
for index, result in map_as_completed(
|
|
187
|
+
partial(_measure_one, dangerous=dangerous), items=plugins
|
|
188
|
+
):
|
|
189
|
+
results[index] = result
|
|
190
|
+
if display is not None:
|
|
191
|
+
display.finish(name=plugins[index].name, result=result)
|
|
192
|
+
scan = _Scan(reports=[], skipped=[])
|
|
193
|
+
for _, result in sorted(results.items()):
|
|
194
|
+
if isinstance(result, SkippedPlugin):
|
|
195
|
+
scan.skipped.append(result)
|
|
196
|
+
else:
|
|
197
|
+
scan.reports.append(result)
|
|
198
|
+
return scan
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _scan(names: Sequence[str], *, dangerous: bool, json_output: bool) -> _Scan:
|
|
202
|
+
"""Select and measure plugins, with a live row per plugin unless output is JSON.
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
UnknownPluginError: If a requested plugin name is not registered.
|
|
206
|
+
"""
|
|
207
|
+
selected = select_plugins(registry=discover_plugins(), names=names)
|
|
208
|
+
if json_output:
|
|
209
|
+
return _measure(selected, dangerous=dangerous, display=None)
|
|
210
|
+
with MeasureDisplay([plugin.name for plugin in selected]) as display:
|
|
211
|
+
return _measure(selected, dangerous=dangerous, display=display)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _begin(
|
|
215
|
+
names: Sequence[str], *, dangerous: bool, json_output: bool, verbose: int
|
|
216
|
+
) -> _Scan | int:
|
|
217
|
+
"""Configure output and measure the selected plugins.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
_Scan | int: The measurements, or an exit code when the run cannot continue.
|
|
221
|
+
"""
|
|
222
|
+
pp.configure(verbosity=verbose, quiet=json_output)
|
|
223
|
+
try:
|
|
224
|
+
return _scan(names, dangerous=dangerous, json_output=json_output)
|
|
225
|
+
except UnknownPluginError as exc:
|
|
226
|
+
return _error(
|
|
227
|
+
code="unknown_plugin",
|
|
228
|
+
message=str(exc),
|
|
229
|
+
exit_code=ExitCode.USAGE,
|
|
230
|
+
json_output=json_output,
|
|
231
|
+
)
|
|
232
|
+
except KeyboardInterrupt:
|
|
233
|
+
return _error(
|
|
234
|
+
code="interrupted",
|
|
235
|
+
message="Interrupted while measuring caches. Nothing was cleaned.",
|
|
236
|
+
exit_code=ExitCode.INTERRUPTED,
|
|
237
|
+
json_output=json_output,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _report_skipped(skipped: list[SkippedPlugin], names: Sequence[str]) -> None:
|
|
242
|
+
"""Warn about plugins the user named that cannot run here."""
|
|
243
|
+
requested = set(names)
|
|
244
|
+
for item in skipped:
|
|
245
|
+
if item.name in requested:
|
|
246
|
+
pp.warning(f"{item.name} is not available on this machine: {_reason_text(item.reason)}")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _print_table(reports: list[PluginReport]) -> None:
|
|
250
|
+
if reports:
|
|
251
|
+
pp.console().print(render_table(reports))
|
|
252
|
+
else:
|
|
253
|
+
pp.info("No supported caches found on this machine.")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _labeled(
|
|
257
|
+
reports: list[PluginReport], *, separator: str = "/"
|
|
258
|
+
) -> Iterable[tuple[str, ActionReport]]:
|
|
259
|
+
for plugin_report in reports:
|
|
260
|
+
for action_report in plugin_report.actions:
|
|
261
|
+
yield (
|
|
262
|
+
f"{plugin_report.plugin.name}{separator}{action_report.action.name}",
|
|
263
|
+
action_report,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _warn_not_run(reports: list[PluginReport]) -> None:
|
|
268
|
+
"""Explain which actions a non-interactive run left alone, and why."""
|
|
269
|
+
safe: list[str] = []
|
|
270
|
+
risky: list[str] = []
|
|
271
|
+
sudo: list[str] = []
|
|
272
|
+
for label, report in _labeled(reports):
|
|
273
|
+
if report.status is Status.SKIPPED:
|
|
274
|
+
sudo.append(label)
|
|
275
|
+
elif report.status is Status.DECLINED:
|
|
276
|
+
(safe if report.action.tier is Tier.SAFE else risky).append(label)
|
|
277
|
+
if safe:
|
|
278
|
+
pp.warning(f"Not run because purger cannot prompt here (pass --yes): {', '.join(safe)}")
|
|
279
|
+
if risky:
|
|
280
|
+
pp.warning(f"Not run because they need an interactive confirmation: {', '.join(risky)}")
|
|
281
|
+
if sudo:
|
|
282
|
+
pp.warning(f"Not run because sudo needs a password: {', '.join(sudo)}")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _print_plan(reports: list[PluginReport]) -> None:
|
|
286
|
+
planned = [(label, r) for label, r in _labeled(reports) if r.status is Status.PLANNED]
|
|
287
|
+
if not planned:
|
|
288
|
+
pp.info("Nothing to clean.")
|
|
289
|
+
return
|
|
290
|
+
for label, report in planned:
|
|
291
|
+
action = report.action
|
|
292
|
+
prefix = f"{label} ({action.tier.value})"
|
|
293
|
+
if action.command is not None:
|
|
294
|
+
sudo = "sudo " if action.needs_sudo else ""
|
|
295
|
+
pp.dryrun(f"{prefix}: would run {sudo}{shlex.join(action.command)}")
|
|
296
|
+
else:
|
|
297
|
+
for path in report.resolved_paths:
|
|
298
|
+
pp.dryrun(f"{prefix}: would delete {path}")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _print_results(reports: list[PluginReport], ran: list[PluginReport]) -> None:
|
|
302
|
+
if not ran:
|
|
303
|
+
pp.info("Nothing was cleaned.")
|
|
304
|
+
return
|
|
305
|
+
pp.console().print(render_results(ran))
|
|
306
|
+
for label, report in _labeled(reports, separator=": "):
|
|
307
|
+
if report.status is Status.FAILED:
|
|
308
|
+
pp.error(f"{label} failed: {report.error}")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _show_clean(scan: _Scan, *, json_output: bool, show: Callable[[], None]) -> None:
|
|
312
|
+
"""Emit the clean document for agents, or call show to tell a person the outcome."""
|
|
313
|
+
if json_output:
|
|
314
|
+
_emit_json(clean_document(reports=scan.reports, skipped=scan.skipped))
|
|
315
|
+
else:
|
|
316
|
+
show()
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _run_approved(
|
|
320
|
+
scan: _Scan,
|
|
321
|
+
approved: list[ActionReport],
|
|
322
|
+
*,
|
|
323
|
+
dangerous: bool,
|
|
324
|
+
json_output: bool,
|
|
325
|
+
verbose: bool,
|
|
326
|
+
interactive: bool,
|
|
327
|
+
) -> int:
|
|
328
|
+
"""Run approved actions, report the outcome, and choose the exit code."""
|
|
329
|
+
execution = execute(
|
|
330
|
+
reports=scan.reports,
|
|
331
|
+
approved=approved,
|
|
332
|
+
verbose=verbose and not json_output,
|
|
333
|
+
interactive=interactive,
|
|
334
|
+
)
|
|
335
|
+
interrupted = execution.interrupted or record_freed(
|
|
336
|
+
execution.ran, dangerous=dangerous, show_progress=not json_output
|
|
337
|
+
)
|
|
338
|
+
_show_clean(
|
|
339
|
+
scan,
|
|
340
|
+
json_output=json_output,
|
|
341
|
+
show=lambda: _print_results(reports=scan.reports, ran=execution.ran),
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
if interrupted:
|
|
345
|
+
pp.warning("Interrupted. The remaining work was skipped.")
|
|
346
|
+
return ExitCode.INTERRUPTED
|
|
347
|
+
if any(report.status is Status.FAILED for report in approved):
|
|
348
|
+
return ExitCode.ACTION_FAILED
|
|
349
|
+
return ExitCode.OK
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@app.meta.default
|
|
353
|
+
def _launcher(
|
|
354
|
+
*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)],
|
|
355
|
+
verbose: VerboseFlag = 0,
|
|
356
|
+
) -> int:
|
|
357
|
+
"""Accept -v/--verbose before the command name, then run the command."""
|
|
358
|
+
token = _GLOBAL_VERBOSITY.set(verbose)
|
|
359
|
+
try:
|
|
360
|
+
return app(tokens)
|
|
361
|
+
finally:
|
|
362
|
+
_GLOBAL_VERBOSITY.reset(token)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@app.command(help=INFO_HELP)
|
|
366
|
+
def info(
|
|
367
|
+
*plugins: PluginNames,
|
|
368
|
+
dangerous: DangerousFlag = False,
|
|
369
|
+
json_output: JsonFlag = False,
|
|
370
|
+
verbose: VerboseFlag = 0,
|
|
371
|
+
) -> int:
|
|
372
|
+
"""Show cached data per plugin so users can decide what to clean.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
plugins: Plugin names to include; empty means all.
|
|
376
|
+
dangerous: Include DANGEROUS actions.
|
|
377
|
+
json_output: Print one JSON document instead of human output.
|
|
378
|
+
verbose: Verbosity level.
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
int: The process exit code.
|
|
382
|
+
"""
|
|
383
|
+
verbosity = verbose + _GLOBAL_VERBOSITY.get()
|
|
384
|
+
scan = _begin(plugins, dangerous=dangerous, json_output=json_output, verbose=verbosity)
|
|
385
|
+
if isinstance(scan, int):
|
|
386
|
+
return scan
|
|
387
|
+
|
|
388
|
+
if json_output:
|
|
389
|
+
_emit_json(info_document(reports=scan.reports, skipped=scan.skipped))
|
|
390
|
+
else:
|
|
391
|
+
_print_table(scan.reports)
|
|
392
|
+
_report_skipped(skipped=scan.skipped, names=plugins)
|
|
393
|
+
return ExitCode.OK
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@app.command(help=CLEAN_HELP)
|
|
397
|
+
def clean(
|
|
398
|
+
*plugins: PluginNames,
|
|
399
|
+
dangerous: DangerousFlag = False,
|
|
400
|
+
dry_run: Annotated[
|
|
401
|
+
bool, Parameter(help="List what would be cleaned. Asks nothing and changes nothing.")
|
|
402
|
+
] = False,
|
|
403
|
+
yes: Annotated[
|
|
404
|
+
bool,
|
|
405
|
+
Parameter(
|
|
406
|
+
name=["--yes", "-y"],
|
|
407
|
+
help="Skip the single confirmation for `safe` actions. Never skips `caution` or `dangerous` prompts.",
|
|
408
|
+
),
|
|
409
|
+
] = False,
|
|
410
|
+
json_output: JsonFlag = False,
|
|
411
|
+
verbose: VerboseFlag = 0,
|
|
412
|
+
) -> int:
|
|
413
|
+
"""Remove caches after tiered confirmation so risky deletions always need a person.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
plugins: Plugin names to include; empty means all.
|
|
417
|
+
dangerous: Offer DANGEROUS actions.
|
|
418
|
+
dry_run: Plan only.
|
|
419
|
+
yes: Skip the SAFE confirmation.
|
|
420
|
+
json_output: Print one JSON document instead of human output.
|
|
421
|
+
verbose: Verbosity level.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
int: The process exit code.
|
|
425
|
+
"""
|
|
426
|
+
verbosity = verbose + _GLOBAL_VERBOSITY.get()
|
|
427
|
+
scan = _begin(plugins, dangerous=dangerous, json_output=json_output, verbose=verbosity)
|
|
428
|
+
if isinstance(scan, int):
|
|
429
|
+
return scan
|
|
430
|
+
|
|
431
|
+
if not json_output:
|
|
432
|
+
_print_table(scan.reports)
|
|
433
|
+
_report_skipped(skipped=scan.skipped, names=plugins)
|
|
434
|
+
|
|
435
|
+
interactive = sys.stdin.isatty() and not json_output
|
|
436
|
+
opts = CleanOptions(dangerous=dangerous, dry_run=dry_run, yes=yes, interactive=interactive)
|
|
437
|
+
try:
|
|
438
|
+
approved = plan_clean(
|
|
439
|
+
reports=scan.reports, opts=opts, confirm=confirm, sudo_ready=sudo_ready
|
|
440
|
+
)
|
|
441
|
+
except KeyboardInterrupt:
|
|
442
|
+
mark_unfinished(report for _, report in _labeled(scan.reports))
|
|
443
|
+
_show_clean(
|
|
444
|
+
scan,
|
|
445
|
+
json_output=json_output,
|
|
446
|
+
show=lambda: pp.warning("Interrupted. Nothing was cleaned."),
|
|
447
|
+
)
|
|
448
|
+
return ExitCode.INTERRUPTED
|
|
449
|
+
|
|
450
|
+
if not interactive:
|
|
451
|
+
_warn_not_run(scan.reports)
|
|
452
|
+
|
|
453
|
+
if dry_run:
|
|
454
|
+
_show_clean(scan, json_output=json_output, show=lambda: _print_plan(scan.reports))
|
|
455
|
+
return ExitCode.OK
|
|
456
|
+
|
|
457
|
+
return _run_approved(
|
|
458
|
+
scan,
|
|
459
|
+
approved,
|
|
460
|
+
dangerous=dangerous,
|
|
461
|
+
json_output=json_output,
|
|
462
|
+
verbose=verbosity > 0,
|
|
463
|
+
interactive=interactive,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
_FALSY_FLAG_VALUES = frozenset({"", "0", "false", "f", "no", "n", "off"})
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _wants_json(tokens: Sequence[str]) -> bool:
|
|
471
|
+
"""Report whether the command line asks for JSON output, as `--json` or `--json=<truthy>`."""
|
|
472
|
+
for arg in tokens:
|
|
473
|
+
if arg == "--json":
|
|
474
|
+
return True
|
|
475
|
+
if arg.startswith("--json=") and arg.partition("=")[2].lower() not in _FALSY_FLAG_VALUES:
|
|
476
|
+
return True
|
|
477
|
+
return False
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def main() -> None:
|
|
481
|
+
"""Run the CLI, refusing to start as root so caches are cleaned for the real user.
|
|
482
|
+
|
|
483
|
+
With `--json`, argument errors are printed as the JSON error document on
|
|
484
|
+
stdout, so agents get a parseable answer even when the command line is wrong.
|
|
485
|
+
"""
|
|
486
|
+
tokens = sys.argv[1:]
|
|
487
|
+
json_output = _wants_json(tokens)
|
|
488
|
+
if os.geteuid() == 0:
|
|
489
|
+
_error(
|
|
490
|
+
code="running_as_root",
|
|
491
|
+
message=_ROOT_MESSAGE,
|
|
492
|
+
exit_code=ExitCode.USAGE,
|
|
493
|
+
json_output=json_output,
|
|
494
|
+
)
|
|
495
|
+
sys.exit(ExitCode.USAGE)
|
|
496
|
+
attach_plugin_catalog()
|
|
497
|
+
if not json_output:
|
|
498
|
+
app.meta(tokens)
|
|
499
|
+
return
|
|
500
|
+
try:
|
|
501
|
+
app.meta(tokens, exit_on_error=False, print_error=False)
|
|
502
|
+
except CycloptsError as exc:
|
|
503
|
+
_emit_json(error_document(code="usage", message=str(exc)))
|
|
504
|
+
sys.exit(ExitCode.USAGE)
|