procclean 1.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.
procclean/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Process cleanup TUI application."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("procclean")
6
+
7
+ # Re-export main entry point and core types
8
+ from procclean.__main__ import main
9
+ from procclean.core import ProcessInfo
10
+
11
+ __all__ = ["ProcessInfo", "__version__", "main"]
procclean/__main__.py ADDED
@@ -0,0 +1,22 @@
1
+ """Entry point for procclean - runs as python -m procclean or via console script."""
2
+
3
+ from .cli import run_cli
4
+ from .tui import ProcessCleanerApp
5
+
6
+
7
+ def main() -> None:
8
+ """Dispatch to CLI or run TUI.
9
+
10
+ Raises:
11
+ SystemExit: When CLI command returns non-zero exit code.
12
+ """
13
+ result = run_cli()
14
+ if result == -1:
15
+ # No subcommand - run TUI
16
+ ProcessCleanerApp().run()
17
+ else:
18
+ raise SystemExit(result)
19
+
20
+
21
+ if __name__ == "__main__":
22
+ main()
@@ -0,0 +1,27 @@
1
+ """CLI interface for procclean."""
2
+
3
+ # Internal helpers - exported for testing
4
+ from .commands import (
5
+ _confirm_kill,
6
+ _do_preview,
7
+ _get_kill_targets,
8
+ cmd_groups,
9
+ cmd_kill,
10
+ cmd_list,
11
+ cmd_memory,
12
+ get_filtered_processes,
13
+ )
14
+ from .parser import create_parser, run_cli
15
+
16
+ __all__ = [
17
+ "_confirm_kill",
18
+ "_do_preview",
19
+ "_get_kill_targets",
20
+ "cmd_groups",
21
+ "cmd_kill",
22
+ "cmd_list",
23
+ "cmd_memory",
24
+ "create_parser",
25
+ "get_filtered_processes",
26
+ "run_cli",
27
+ ]
@@ -0,0 +1,213 @@
1
+ """CLI command handlers."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from rich import print # pylint: disable=redefined-builtin
9
+
10
+ from procclean.core import (
11
+ PREVIEW_LIMIT,
12
+ filter_by_cwd,
13
+ filter_high_memory,
14
+ filter_killable,
15
+ filter_orphans,
16
+ find_similar_processes,
17
+ get_memory_summary,
18
+ get_process_list,
19
+ kill_processes,
20
+ sort_processes,
21
+ )
22
+ from procclean.formatters import format_output
23
+
24
+
25
+ def cmd_list(args: argparse.Namespace) -> int:
26
+ """List processes command.
27
+
28
+ Returns:
29
+ int: Exit code (0 on success).
30
+ """
31
+ procs = get_filtered_processes(args)
32
+
33
+ # Apply sorting
34
+ reverse = not args.ascending
35
+ procs = sort_processes(procs, sort_by=args.sort, reverse=reverse)
36
+
37
+ # Limit output
38
+ if args.limit:
39
+ procs = procs[: args.limit]
40
+
41
+ # Parse columns
42
+ columns = args.columns.split(",") if args.columns else None
43
+
44
+ print(format_output(procs, args.format, columns=columns))
45
+ return 0
46
+
47
+
48
+ def cmd_groups(args: argparse.Namespace) -> int:
49
+ """Show grouped processes command.
50
+
51
+ Returns:
52
+ int: Exit code (0 on success).
53
+ """
54
+ procs = get_process_list(min_memory_mb=args.min_memory)
55
+ groups = find_similar_processes(procs)
56
+
57
+ if not groups:
58
+ print("No process groups found.")
59
+ return 0
60
+
61
+ if args.format == "json":
62
+ data = {
63
+ cmd: [
64
+ {"pid": p.pid, "name": p.name, "rss_mb": round(p.rss_mb, 2)}
65
+ for p in group_procs
66
+ ]
67
+ for cmd, group_procs in groups.items()
68
+ }
69
+ print(json.dumps(data, indent=2))
70
+ else:
71
+ for cmd, group_procs in sorted(
72
+ groups.items(), key=lambda x: sum(p.rss_mb for p in x[1]), reverse=True
73
+ ):
74
+ total_mb = sum(p.rss_mb for p in group_procs)
75
+ print(f"\n{cmd} ({len(group_procs)} processes, {total_mb:.1f} MB total)")
76
+ for p in sorted(group_procs, key=lambda x: x.rss_mb, reverse=True):
77
+ print(f" PID {p.pid}: {p.rss_mb:.1f} MB")
78
+
79
+ return 0
80
+
81
+
82
+ def get_filtered_processes(args: argparse.Namespace) -> list:
83
+ """Get processes with all filters from args applied.
84
+
85
+ Returns:
86
+ list: Filtered list of processes.
87
+ """
88
+ procs = get_process_list(min_memory_mb=getattr(args, "min_memory", 5.0))
89
+
90
+ # Apply cwd filter
91
+ if getattr(args, "cwd", None) is not None:
92
+ cwd_path = args.cwd or str(Path.cwd())
93
+ procs = filter_by_cwd(procs, cwd_path)
94
+
95
+ # Apply preset filters
96
+ filt = getattr(args, "filter", None)
97
+ threshold = getattr(args, "high_memory_threshold", 500.0)
98
+ if filt == "killable" or getattr(args, "killable", False):
99
+ procs = filter_killable(procs)
100
+ elif filt == "orphans" or getattr(args, "orphans", False):
101
+ procs = filter_orphans(procs)
102
+ elif filt == "high-memory" or getattr(args, "high_memory", False):
103
+ procs = filter_high_memory(procs, threshold_mb=threshold)
104
+
105
+ return procs
106
+
107
+
108
+ def _get_kill_targets(args: argparse.Namespace) -> list:
109
+ """Get target processes for kill command from PIDs or filters.
110
+
111
+ Returns:
112
+ list: Target processes to kill.
113
+ """
114
+ if args.pids:
115
+ all_procs = get_process_list(min_memory_mb=0)
116
+ pid_set = set(args.pids)
117
+ procs = [p for p in all_procs if p.pid in pid_set]
118
+ found_pids = {p.pid for p in procs}
119
+ for pid in args.pids:
120
+ if pid not in found_pids:
121
+ print(f"Warning: PID {pid} not found")
122
+ return procs
123
+ return get_filtered_processes(args)
124
+
125
+
126
+ def _do_preview(args: argparse.Namespace, procs: list) -> int:
127
+ """Show preview of what would be killed.
128
+
129
+ Returns:
130
+ int: Exit code (0 on success).
131
+ """
132
+ if hasattr(args, "sort") and args.sort:
133
+ procs = sort_processes(procs, sort_by=args.sort, reverse=True)
134
+ if hasattr(args, "limit") and args.limit:
135
+ procs = procs[: args.limit]
136
+ columns = args.columns.split(",") if getattr(args, "columns", None) else None
137
+ fmt = getattr(args, "out_format", "table")
138
+ print(format_output(procs, fmt, columns=columns))
139
+ print(f"\n{len(procs)} process(es) would be killed.")
140
+ return 0
141
+
142
+
143
+ def _confirm_kill(args: argparse.Namespace, procs: list) -> bool:
144
+ """Prompt for kill confirmation.
145
+
146
+ Args:
147
+ args: Parsed CLI arguments.
148
+ procs: Processes that would be killed.
149
+
150
+ Returns:
151
+ True if the kill action is confirmed (or confirmation is skipped), otherwise
152
+ False.
153
+ """
154
+ if args.yes or not sys.stdin.isatty():
155
+ return True
156
+ action = "FORCE KILL" if args.force else "terminate"
157
+ print(f"About to {action} {len(procs)} process(es):")
158
+ for p in procs[:PREVIEW_LIMIT]:
159
+ print(f" {p.pid}: {p.name} ({p.rss_mb:.1f} MB)")
160
+ if len(procs) > PREVIEW_LIMIT:
161
+ print(f" ... and {len(procs) - PREVIEW_LIMIT} more")
162
+ try:
163
+ response = input("Continue? [y/N] ")
164
+ return response.lower() in {"y", "yes"}
165
+ except EOFError:
166
+ return True # Non-interactive
167
+
168
+
169
+ def cmd_kill(args: argparse.Namespace) -> int:
170
+ """Kill processes command.
171
+
172
+ Returns:
173
+ int: Exit code (0 on success).
174
+ """
175
+ procs = _get_kill_targets(args)
176
+ if not procs:
177
+ print("No processes match the filters.")
178
+ return 0
179
+
180
+ if getattr(args, "preview", False):
181
+ return _do_preview(args, procs)
182
+
183
+ if not _confirm_kill(args, procs):
184
+ print("Aborted.")
185
+ return 1
186
+
187
+ results = kill_processes([p.pid for p in procs], force=args.force)
188
+ exit_code = 0
189
+ for _, success, msg in results:
190
+ status = "OK" if success else "FAILED"
191
+ print(f"[{status}] {msg}")
192
+ if not success:
193
+ exit_code = 1
194
+ return exit_code
195
+
196
+
197
+ def cmd_memory(args: argparse.Namespace) -> int:
198
+ """Show memory summary command.
199
+
200
+ Returns:
201
+ int: Exit code (0 on success).
202
+ """
203
+ mem = get_memory_summary()
204
+
205
+ if args.format == "json":
206
+ print(json.dumps(mem, indent=2))
207
+ else:
208
+ print(f"Total: {mem['total_gb']:.2f} GB")
209
+ print(f"Used: {mem['used_gb']:.2f} GB ({mem['percent']:.1f}%)")
210
+ print(f"Free: {mem['free_gb']:.2f} GB")
211
+ print(f"Swap: {mem['swap_used_gb']:.2f} / {mem['swap_total_gb']:.2f} GB")
212
+
213
+ return 0
procclean/cli/docs.py ADDED
@@ -0,0 +1,234 @@
1
+ """CLI documentation generation utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import io
7
+ import os
8
+ import re
9
+ from collections import defaultdict
10
+ from pathlib import Path
11
+
12
+ from markdown_it import MarkdownIt
13
+ from rich.console import Console
14
+ from rich.text import Text
15
+ from rich_argparse import RichHelpFormatter
16
+
17
+ from .parser import create_parser
18
+
19
+ DOCS_CLI_PATH = Path(__file__).parent.parent.parent.parent / "docs" / "cli.md"
20
+ HEADER_COMMENT = (
21
+ "<!-- AUTO-GENERATED - DO NOT EDIT -->\n"
22
+ "<!-- Run: ./scripts/generate_cli_docs.py -->\n"
23
+ "<!-- dprint-ignore-file -->\n"
24
+ "<!-- markdownlint-disable-file -->\n\n"
25
+ )
26
+
27
+ # Apply custom styles for CLI help output (once at module load)
28
+ _HELP_STYLES = {
29
+ "prog": "#98f641",
30
+ "args": "#54ebdd",
31
+ "groups": "#98f641",
32
+ "metavar": "#7af0e5",
33
+ "syntax": "#98f641",
34
+ "help": "white",
35
+ "text": "white",
36
+ "default": "#888888",
37
+ }
38
+ for _key, _value in _HELP_STYLES.items():
39
+ RichHelpFormatter.styles[f"argparse.{_key}"] = _value
40
+
41
+ _PRE_STYLE = "font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace"
42
+ _CODE_FORMAT = (
43
+ f'<pre style="{_PRE_STYLE}">\n'
44
+ '<code style="font-family:inherit" class="nohighlight">{code}</code>\n'
45
+ "</pre>\n"
46
+ )
47
+
48
+
49
+ def _capture_help(parser: argparse.ArgumentParser) -> str:
50
+ """Capture the help text of an argparse parser as colored HTML.
51
+
52
+ Returns:
53
+ HTML string with colored help output.
54
+ """
55
+ # Force colored output even when not in a TTY
56
+ old_force_color = os.environ.get("FORCE_COLOR")
57
+ os.environ["FORCE_COLOR"] = "1"
58
+ try:
59
+ parser.formatter_class = RichHelpFormatter
60
+ text = Text.from_ansi(parser.format_help())
61
+ finally:
62
+ if old_force_color is None:
63
+ os.environ.pop("FORCE_COLOR", None)
64
+ else:
65
+ os.environ["FORCE_COLOR"] = old_force_color
66
+
67
+ # force_terminal=True ensures colored output even when not in a TTY
68
+ console = Console(file=io.StringIO(), record=True, force_terminal=True)
69
+ console.print(text, crop=False)
70
+
71
+ return console.export_html(code_format=_CODE_FORMAT, inline_styles=True)
72
+
73
+
74
+ def _argparser_to_markdown(
75
+ parser: argparse.ArgumentParser,
76
+ heading: str = "CLI Reference",
77
+ ) -> str:
78
+ """Convert an argparse parser to markdown documentation with colored output.
79
+
80
+ Args:
81
+ parser: The argparse parser to document.
82
+ heading: The main heading for the documentation.
83
+
84
+ Returns:
85
+ Markdown string with colored HTML help blocks.
86
+ """
87
+ prog = parser.prog
88
+ main_help = _capture_help(parser).rstrip()
89
+
90
+ lines = [
91
+ f"# {heading}",
92
+ "",
93
+ f"Documentation for the `{prog}` script.",
94
+ "",
95
+ "```console",
96
+ f"{prog} --help",
97
+ "```",
98
+ "",
99
+ main_help,
100
+ ]
101
+
102
+ # Find subparsers
103
+ subparsers_actions = [
104
+ action
105
+ for action in parser._actions # noqa: SLF001
106
+ if isinstance(action, argparse._SubParsersAction) # noqa: SLF001
107
+ ]
108
+
109
+ if subparsers_actions:
110
+ current_subparsers_action = subparsers_actions[0]
111
+ for sub_cmd_name, sub_cmd_parser in current_subparsers_action.choices.items():
112
+ sub_cmd_help_text = _capture_help(sub_cmd_parser).rstrip()
113
+ lines.extend([
114
+ "",
115
+ f"## {sub_cmd_name}",
116
+ "",
117
+ "```console",
118
+ f"{prog} {sub_cmd_name} --help",
119
+ "```",
120
+ "",
121
+ sub_cmd_help_text,
122
+ ])
123
+
124
+ return "\n".join(lines)
125
+
126
+
127
+ def _extract_h2_sections(
128
+ markdown: str,
129
+ ) -> tuple[list[str], list[tuple[str, int, int]]]:
130
+ """Extract h2 sections from markdown using AST.
131
+
132
+ Uses markdown-it parser to reliably detect headings
133
+ (won't match ## inside fenced code blocks).
134
+
135
+ Returns:
136
+ Tuple of (lines, sections) where sections is list of (title, start, end).
137
+ """
138
+ md = MarkdownIt()
139
+ tokens = md.parse(markdown)
140
+ lines = markdown.split("\n")
141
+
142
+ h2_starts: list[tuple[str, int]] = []
143
+ for i, token in enumerate(tokens):
144
+ if token.type == "heading_open" and token.tag == "h2":
145
+ title = tokens[i + 1].content
146
+ start_line = token.map[0] # type: ignore[index]
147
+ h2_starts.append((title, start_line))
148
+
149
+ h2_sections: list[tuple[str, int, int]] = []
150
+ for i, (title, start) in enumerate(h2_starts):
151
+ end = h2_starts[i + 1][1] if i + 1 < len(h2_starts) else len(lines)
152
+ h2_sections.append((title, start, end))
153
+
154
+ return lines, h2_sections
155
+
156
+
157
+ def _normalize_content(content: str) -> str:
158
+ """Normalize content for comparison by removing command-specific parts.
159
+
160
+ Returns:
161
+ Content with command invocations normalized and whitespace stripped.
162
+ """
163
+ normalized = re.sub(r"procclean \w+ --help", "procclean CMD --help", content)
164
+ return normalized.strip()
165
+
166
+
167
+ def merge_alias_sections(markdown: str) -> str:
168
+ """Merge duplicate argparse alias sections in CLI markdown.
169
+
170
+ Detects sections with identical help content (modulo command name)
171
+ and merges their headers, e.g., `## list` + `## ls` -> `## list / ls`.
172
+
173
+ Returns:
174
+ Markdown with merged alias sections.
175
+ """
176
+ lines, h2_sections = _extract_h2_sections(markdown)
177
+ if not h2_sections:
178
+ return markdown
179
+
180
+ # Group by normalized content
181
+ content_to_titles: dict[str, list[str]] = defaultdict(list)
182
+ for title, start, end in h2_sections:
183
+ content = "\n".join(lines[start + 1 : end])
184
+ normalized = _normalize_content(content)
185
+ content_to_titles[normalized].append(title)
186
+
187
+ # Rebuild with merged headers
188
+ result = lines[: h2_sections[0][1]][:]
189
+ seen: set[str] = set()
190
+
191
+ for _title, start, end in h2_sections:
192
+ content = "\n".join(lines[start + 1 : end])
193
+ normalized = _normalize_content(content)
194
+
195
+ if normalized in seen:
196
+ continue
197
+ seen.add(normalized)
198
+
199
+ aliases = content_to_titles[normalized]
200
+ header = " / ".join(f"`{a}`" for a in aliases)
201
+
202
+ result.append(f"## {header}")
203
+ result.extend(lines[start + 1 : end])
204
+
205
+ return "\n".join(result)
206
+
207
+
208
+ def generate_cli_docs() -> str:
209
+ """Generate merged CLI documentation from argparse parser.
210
+
211
+ Returns:
212
+ Complete CLI reference markdown with header comment.
213
+ """
214
+ parser = create_parser()
215
+ markdown = _argparser_to_markdown(parser, heading="CLI Reference")
216
+ merged = merge_alias_sections(markdown)
217
+ return HEADER_COMMENT + merged
218
+
219
+
220
+ def write_cli_docs() -> bool:
221
+ """Generate and write CLI docs to docs/cli.md.
222
+
223
+ Returns:
224
+ True if file was updated, False if unchanged.
225
+ """
226
+ new_content = generate_cli_docs()
227
+
228
+ if DOCS_CLI_PATH.exists():
229
+ existing = DOCS_CLI_PATH.read_text()
230
+ if existing == new_content:
231
+ return False
232
+
233
+ DOCS_CLI_PATH.write_text(new_content)
234
+ return True