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.
@@ -0,0 +1,160 @@
1
+ """Process listing and grouping utilities."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import psutil
7
+
8
+ from .models import ProcessInfo
9
+
10
+
11
+ def get_tmux_env(pid: int) -> bool:
12
+ """Check whether the process has a TMUX environment variable.
13
+
14
+ Args:
15
+ pid: Process ID.
16
+
17
+ Returns:
18
+ True if the process environment contains ``TMUX=``, otherwise False.
19
+ """
20
+ try:
21
+ environ_path = Path(f"/proc/{pid}/environ")
22
+ if environ_path.exists():
23
+ environ = environ_path.read_bytes().decode("utf-8", errors="ignore")
24
+ return "TMUX=" in environ
25
+ except (PermissionError, FileNotFoundError, ProcessLookupError):
26
+ pass
27
+ return False
28
+
29
+
30
+ def get_cwd(pid: int) -> str:
31
+ """Get process working directory.
32
+
33
+ Args:
34
+ pid: Process ID.
35
+
36
+ Returns:
37
+ The resolved current working directory for the process, or "?" if it
38
+ cannot be determined due to permissions or the process no longer
39
+ existing.
40
+ """
41
+ try:
42
+ return str(Path(f"/proc/{pid}/cwd").readlink())
43
+ except (PermissionError, FileNotFoundError, ProcessLookupError):
44
+ return "?"
45
+
46
+
47
+ def get_process_list(
48
+ sort_by: str = "memory",
49
+ filter_user: str | None = None,
50
+ min_memory_mb: float = 10.0,
51
+ ) -> list[ProcessInfo]:
52
+ """Get list of processes with detailed info.
53
+
54
+ Args:
55
+ sort_by: Field to sort by ("memory", "cpu", or "name").
56
+ filter_user: Only include processes owned by this user. Defaults to the
57
+ current user.
58
+ min_memory_mb: Minimum RSS (in MB) for a process to be included.
59
+
60
+ Returns:
61
+ A list of ProcessInfo entries matching the filters, sorted by ``sort_by``.
62
+ """
63
+ processes = []
64
+ current_user = os.getlogin()
65
+ filter_user = filter_user or current_user
66
+
67
+ for proc in psutil.process_iter([
68
+ "pid",
69
+ "name",
70
+ "cmdline",
71
+ "ppid",
72
+ "memory_info",
73
+ "cpu_percent",
74
+ "username",
75
+ "create_time",
76
+ "status",
77
+ ]):
78
+ try:
79
+ info = proc.info
80
+ if info["username"] != filter_user:
81
+ continue
82
+
83
+ rss_mb = (
84
+ (info["memory_info"].rss / 1024 / 1024) if info["memory_info"] else 0
85
+ )
86
+ if rss_mb < min_memory_mb:
87
+ continue
88
+
89
+ ppid = info["ppid"] or 0
90
+ try:
91
+ parent = psutil.Process(ppid)
92
+ parent_name = parent.name()
93
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
94
+ parent_name = "?"
95
+
96
+ # Check if orphaned (parent is init/systemd)
97
+ is_orphan = ppid == 1 or parent_name in {"systemd", "init"}
98
+
99
+ cmdline = " ".join(info["cmdline"] or [])[:200]
100
+ if not cmdline:
101
+ cmdline = info["name"]
102
+
103
+ processes.append(
104
+ ProcessInfo(
105
+ pid=info["pid"],
106
+ name=info["name"],
107
+ cmdline=cmdline,
108
+ cwd=get_cwd(info["pid"]),
109
+ ppid=ppid,
110
+ parent_name=parent_name,
111
+ rss_mb=rss_mb,
112
+ cpu_percent=info["cpu_percent"] or 0,
113
+ username=info["username"],
114
+ create_time=info["create_time"] or 0,
115
+ is_orphan=is_orphan,
116
+ in_tmux=get_tmux_env(info["pid"]) if is_orphan else False,
117
+ status=info["status"] or "?",
118
+ )
119
+ )
120
+ except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
121
+ continue
122
+
123
+ if sort_by == "memory":
124
+ processes.sort(key=lambda p: p.rss_mb, reverse=True)
125
+ elif sort_by == "cpu":
126
+ processes.sort(key=lambda p: p.cpu_percent, reverse=True)
127
+ elif sort_by == "name":
128
+ processes.sort(key=lambda p: p.name.lower())
129
+
130
+ return processes
131
+
132
+
133
+ def find_similar_processes(
134
+ processes: list[ProcessInfo],
135
+ ) -> dict[str, list[ProcessInfo]]:
136
+ """Group processes by similar command patterns.
137
+
138
+ Args:
139
+ processes: Processes to group.
140
+
141
+ Returns:
142
+ A mapping of group keys (normalized executable/command names) to the list
143
+ of processes in that group. Only groups containing more than one process
144
+ are returned.
145
+ """
146
+ groups: dict[str, list[ProcessInfo]] = {}
147
+
148
+ for proc in processes:
149
+ # Extract key identifier from cmdline
150
+ cmd = proc.cmdline.split()[0] if proc.cmdline else proc.name
151
+ # Normalize paths
152
+ if "/" in cmd:
153
+ cmd = cmd.split("/")[-1]
154
+
155
+ if cmd not in groups:
156
+ groups[cmd] = []
157
+ groups[cmd].append(proc)
158
+
159
+ # Only return groups with multiple processes
160
+ return {k: v for k, v in groups.items() if len(v) > 1}
@@ -0,0 +1,33 @@
1
+ """Output formatters for process data."""
2
+
3
+ from .columns import (
4
+ COLUMNS,
5
+ DEFAULT_COLUMNS,
6
+ ClipSide,
7
+ ColumnSpec,
8
+ clip,
9
+ get_available_columns,
10
+ )
11
+ from .output import (
12
+ format_csv,
13
+ format_json,
14
+ format_markdown,
15
+ format_output,
16
+ format_table,
17
+ get_rows,
18
+ )
19
+
20
+ __all__ = [
21
+ "COLUMNS",
22
+ "DEFAULT_COLUMNS",
23
+ "ClipSide",
24
+ "ColumnSpec",
25
+ "clip",
26
+ "format_csv",
27
+ "format_json",
28
+ "format_markdown",
29
+ "format_output",
30
+ "format_table",
31
+ "get_available_columns",
32
+ "get_rows",
33
+ ]
@@ -0,0 +1,128 @@
1
+ """Column specifications for process tables."""
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass, replace
5
+ from enum import StrEnum, auto
6
+ from typing import Self
7
+
8
+ from procclean.core import ProcessInfo
9
+
10
+
11
+ class ClipSide(StrEnum):
12
+ """Which side to truncate when clipping."""
13
+
14
+ LEFT = auto() # Keep right portion (good for paths)
15
+ RIGHT = auto() # Keep left portion (good for names)
16
+
17
+
18
+ def clip(s: str, max_len: int, side: ClipSide = ClipSide.RIGHT) -> str:
19
+ """Truncate string, adding ellipsis on clipped side.
20
+
21
+ Args:
22
+ s: Input string to potentially clip.
23
+ max_len: Maximum length of returned string, including ellipsis.
24
+ side: Which side to truncate when clipping.
25
+
26
+ Returns:
27
+ The original string if it fits within ``max_len``; otherwise a clipped
28
+ version with an ellipsis on the clipped side.
29
+ """
30
+ if len(s) <= max_len:
31
+ return s
32
+ match side:
33
+ case ClipSide.LEFT:
34
+ return f"...{s[-(max_len - 3) :]}"
35
+ case ClipSide.RIGHT:
36
+ return f"{s[: max_len - 3]}..."
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class ColumnSpec[T]:
41
+ """Specification for a table column."""
42
+
43
+ key: str
44
+ header: str
45
+ get: Callable[[ProcessInfo], T]
46
+ fmt: Callable[[T], str] = str
47
+ max_width: int | None = None
48
+ clip_side: ClipSide = ClipSide.RIGHT
49
+
50
+ def extract(self, proc: ProcessInfo) -> str:
51
+ """Extract and format value from a process.
52
+
53
+ Args:
54
+ proc: Process to extract the value from.
55
+
56
+ Returns:
57
+ The formatted value, clipped with an ellipsis if it exceeds
58
+ ``max_width``.
59
+ """
60
+ formatted = self.fmt(self.get(proc))
61
+ if self.max_width and len(formatted) > self.max_width:
62
+ return clip(formatted, self.max_width, self.clip_side)
63
+ return formatted
64
+
65
+ def with_width(self, width: int, side: ClipSide = ClipSide.RIGHT) -> Self:
66
+ """Return a copy with specified max width and clip side.
67
+
68
+ Args:
69
+ width: Maximum width for the column output.
70
+ side: Which side to truncate when clipping.
71
+
72
+ Returns:
73
+ A new ``ColumnSpec`` with ``max_width`` and ``clip_side`` set.
74
+ """
75
+ return replace(self, max_width=width, clip_side=side)
76
+
77
+
78
+ def _fmt_float1(v: float) -> str:
79
+ return f"{v:.1f}"
80
+
81
+
82
+ def _fmt_status(p: ProcessInfo) -> str:
83
+ parts = [p.status]
84
+ if p.is_orphan:
85
+ parts.append("[orphan]")
86
+ if p.in_tmux:
87
+ parts.append("[tmux]")
88
+ return " ".join(parts)
89
+
90
+
91
+ # Column definitions
92
+ COLUMNS: dict[str, ColumnSpec] = {
93
+ "pid": ColumnSpec("pid", "PID", lambda p: p.pid),
94
+ "name": ColumnSpec("name", "Name", lambda p: p.name, max_width=25),
95
+ "rss_mb": ColumnSpec("rss_mb", "RAM (MB)", lambda p: p.rss_mb, _fmt_float1),
96
+ "cpu_percent": ColumnSpec(
97
+ "cpu_percent", "CPU%", lambda p: p.cpu_percent, _fmt_float1
98
+ ),
99
+ "cwd": ColumnSpec(
100
+ "cwd", "CWD", lambda p: p.cwd, max_width=40, clip_side=ClipSide.LEFT
101
+ ),
102
+ "ppid": ColumnSpec("ppid", "PPID", lambda p: p.ppid),
103
+ "parent_name": ColumnSpec(
104
+ "parent_name", "Parent", lambda p: p.parent_name, max_width=15
105
+ ),
106
+ "status": ColumnSpec("status", "Status", lambda p: p, _fmt_status),
107
+ "cmdline": ColumnSpec("cmdline", "Command", lambda p: p.cmdline, max_width=60),
108
+ "username": ColumnSpec("username", "User", lambda p: p.username),
109
+ }
110
+
111
+ DEFAULT_COLUMNS: tuple[str, ...] = (
112
+ "pid",
113
+ "name",
114
+ "rss_mb",
115
+ "cpu_percent",
116
+ "cwd",
117
+ "ppid",
118
+ "status",
119
+ )
120
+
121
+
122
+ def get_available_columns() -> list[str]:
123
+ """Return list of available column keys.
124
+
125
+ Returns:
126
+ A list of keys for all available columns.
127
+ """
128
+ return list(COLUMNS)
@@ -0,0 +1,158 @@
1
+ """Output format functions for process data."""
2
+
3
+ import csv
4
+ import io
5
+ import json
6
+ from collections.abc import Sequence
7
+ from dataclasses import asdict, fields
8
+
9
+ from tabulate import tabulate
10
+
11
+ from procclean.core import ProcessInfo
12
+
13
+ from .columns import COLUMNS, DEFAULT_COLUMNS
14
+
15
+
16
+ def get_rows(
17
+ procs: list[ProcessInfo],
18
+ columns: Sequence[str] | None = None,
19
+ ) -> tuple[list[str], list[list[str]]]:
20
+ """Extract headers and formatted rows from processes.
21
+
22
+ Args:
23
+ procs: Processes to extract rows from.
24
+ columns: Optional ordered list of column keys to include.
25
+
26
+ Returns:
27
+ A tuple of (headers, rows), where headers is a list of column headers and
28
+ rows is a list of formatted string rows.
29
+ """
30
+ cols = columns or DEFAULT_COLUMNS
31
+ specs = [COLUMNS[c] for c in cols if c in COLUMNS]
32
+ headers = [s.header for s in specs]
33
+ rows = [[s.extract(p) for s in specs] for p in procs]
34
+ return headers, rows
35
+
36
+
37
+ def format_table(
38
+ procs: list[ProcessInfo],
39
+ columns: Sequence[str] | None = None,
40
+ ) -> str:
41
+ """Format processes as ASCII table.
42
+
43
+ Args:
44
+ procs: Processes to format.
45
+ columns: Optional ordered list of column keys to include.
46
+
47
+ Returns:
48
+ A formatted ASCII table string, or a message if no processes are found.
49
+ """
50
+ if not procs:
51
+ return "No processes found."
52
+ headers, rows = get_rows(procs, columns)
53
+ return tabulate(rows, headers=headers, tablefmt="simple_outline")
54
+
55
+
56
+ def format_markdown(
57
+ procs: list[ProcessInfo],
58
+ columns: Sequence[str] | None = None,
59
+ ) -> str:
60
+ """Format processes as a GitHub-flavored Markdown table.
61
+
62
+ Args:
63
+ procs: Processes to format.
64
+ columns: Optional ordered list of column keys to include.
65
+
66
+ Returns:
67
+ A formatted Markdown table string, or a message if no processes are found.
68
+ """
69
+ if not procs:
70
+ return "No processes found."
71
+ headers, rows = get_rows(procs, columns)
72
+ return tabulate(rows, headers=headers, tablefmt="pipe")
73
+
74
+
75
+ def _serialize_process(p: ProcessInfo) -> dict:
76
+ """Convert a process to a JSON-serializable dictionary.
77
+
78
+ Float values are rounded to 2 decimal places for stable output.
79
+
80
+ Args:
81
+ p: Process to serialize.
82
+
83
+ Returns:
84
+ A JSON-serializable dict representation of the process.
85
+ """
86
+ data = asdict(p)
87
+ data["rss_mb"] = round(data["rss_mb"], 2)
88
+ data["cpu_percent"] = round(data["cpu_percent"], 2)
89
+ return data
90
+
91
+
92
+ def format_json(procs: list[ProcessInfo]) -> str:
93
+ """Format processes as JSON.
94
+
95
+ Args:
96
+ procs: Processes to format.
97
+
98
+ Returns:
99
+ A pretty-printed JSON string representing the processes.
100
+ """
101
+ return json.dumps([_serialize_process(p) for p in procs], indent=2)
102
+
103
+
104
+ def format_csv(procs: list[ProcessInfo]) -> str:
105
+ """Format processes as CSV.
106
+
107
+ Args:
108
+ procs: Processes to format.
109
+
110
+ Returns:
111
+ A CSV string representing the processes. If no processes are provided,
112
+ returns an empty string.
113
+ """
114
+ if not procs:
115
+ return ""
116
+ output = io.StringIO()
117
+ writer = csv.writer(output)
118
+
119
+ # Get field names from dataclass
120
+ fieldnames = [f.name for f in fields(ProcessInfo)]
121
+ writer.writerow(fieldnames)
122
+
123
+ for p in procs:
124
+ row = []
125
+ for name in fieldnames:
126
+ val = getattr(p, name)
127
+ if isinstance(val, float):
128
+ val = f"{val:.2f}"
129
+ row.append(val)
130
+ writer.writerow(row)
131
+
132
+ return output.getvalue()
133
+
134
+
135
+ def format_output(
136
+ procs: list[ProcessInfo],
137
+ fmt: str,
138
+ columns: Sequence[str] | None = None,
139
+ ) -> str:
140
+ """Format processes in the requested format.
141
+
142
+ Args:
143
+ procs: Processes to format.
144
+ fmt: Output format key (e.g., "json", "csv", "md"/"markdown").
145
+ columns: Optional ordered list of column keys to include (table/markdown).
146
+
147
+ Returns:
148
+ The formatted output string.
149
+ """
150
+ match fmt:
151
+ case "json":
152
+ return format_json(procs)
153
+ case "csv":
154
+ return format_csv(procs)
155
+ case "md" | "markdown":
156
+ return format_markdown(procs, columns)
157
+ case _:
158
+ return format_table(procs, columns)
@@ -0,0 +1,6 @@
1
+ """TUI interface for procclean."""
2
+
3
+ from .app import ProcessCleanerApp
4
+ from .screens import ConfirmKillScreen
5
+
6
+ __all__ = ["ConfirmKillScreen", "ProcessCleanerApp"]