procclean 1.2.1__tar.gz → 2.0.0__tar.gz
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-2.0.0/LICENSE +21 -0
- {procclean-1.2.1 → procclean-2.0.0}/PKG-INFO +65 -9
- {procclean-1.2.1 → procclean-2.0.0}/README.md +61 -7
- {procclean-1.2.1 → procclean-2.0.0}/pyproject.toml +5 -2
- {procclean-1.2.1 → procclean-2.0.0}/src/procclean/cli/docs.py +22 -14
- {procclean-1.2.1 → procclean-2.0.0}/src/procclean/core/__init__.py +10 -1
- {procclean-1.2.1 → procclean-2.0.0}/src/procclean/core/filters.py +15 -0
- {procclean-1.2.1 → procclean-2.0.0}/src/procclean/core/models.py +1 -0
- {procclean-1.2.1 → procclean-2.0.0}/src/procclean/core/process.py +29 -5
- {procclean-1.2.1 → procclean-2.0.0}/src/procclean/tui/app.py +108 -23
- {procclean-1.2.1 → procclean-2.0.0}/src/procclean/__init__.py +0 -0
- {procclean-1.2.1 → procclean-2.0.0}/src/procclean/__main__.py +0 -0
- {procclean-1.2.1 → procclean-2.0.0}/src/procclean/cli/__init__.py +0 -0
- {procclean-1.2.1 → procclean-2.0.0}/src/procclean/cli/commands.py +0 -0
- {procclean-1.2.1 → procclean-2.0.0}/src/procclean/cli/parser.py +0 -0
- {procclean-1.2.1 → procclean-2.0.0}/src/procclean/core/actions.py +0 -0
- {procclean-1.2.1 → procclean-2.0.0}/src/procclean/core/constants.py +0 -0
- {procclean-1.2.1 → procclean-2.0.0}/src/procclean/core/memory.py +0 -0
- {procclean-1.2.1 → procclean-2.0.0}/src/procclean/formatters/__init__.py +0 -0
- {procclean-1.2.1 → procclean-2.0.0}/src/procclean/formatters/columns.py +0 -0
- {procclean-1.2.1 → procclean-2.0.0}/src/procclean/formatters/output.py +0 -0
- {procclean-1.2.1 → procclean-2.0.0}/src/procclean/tui/__init__.py +0 -0
- {procclean-1.2.1 → procclean-2.0.0}/src/procclean/tui/app.tcss +0 -0
- {procclean-1.2.1 → procclean-2.0.0}/src/procclean/tui/screens.py +0 -0
procclean-2.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kaj Kowalski
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: procclean
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: Interactive TUI for exploring and cleaning up processes - find orphans, memory hogs, and kill them
|
|
5
5
|
Keywords: cleanup,kill,memory,orphan,process,terminal,tui
|
|
6
6
|
Author: Kaj Kowalski
|
|
7
7
|
Author-email: Kaj Kowalski <info@kajkowalski.nl>
|
|
8
8
|
License-Expression: MIT
|
|
9
|
-
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
11
|
Classifier: Environment :: Console
|
|
11
12
|
Classifier: Intended Audience :: Developers
|
|
12
13
|
Classifier: Intended Audience :: System Administrators
|
|
@@ -22,6 +23,7 @@ Requires-Dist: tabulate>=0.9.0
|
|
|
22
23
|
Requires-Dist: textual>=7.0.0
|
|
23
24
|
Requires-Python: >=3.14
|
|
24
25
|
Project-URL: Homepage, https://procclean.kjanat.com
|
|
26
|
+
Project-URL: Issues, https://github.com/kjanat/procclean/issues
|
|
25
27
|
Project-URL: Repository, https://github.com/kjanat/procclean
|
|
26
28
|
Description-Content-Type: text/markdown
|
|
27
29
|
|
|
@@ -35,6 +37,8 @@ Description-Content-Type: text/markdown
|
|
|
35
37
|
|
|
36
38
|
<p align="center">
|
|
37
39
|
<a href="https://pypi.org/project/procclean/"><img src="https://img.shields.io/pypi/v/procclean" alt="PyPI"></a>
|
|
40
|
+
<a href="https://pypi.org/project/procclean/"><img src="https://img.shields.io/pypi/dm/procclean" alt="Downloads"></a>
|
|
41
|
+
<a href="https://github.com/kjanat/procclean/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/kjanat/procclean/ci.yml?branch=master" alt="CI"></a>
|
|
38
42
|
<a href="https://github.com/kjanat/procclean/blob/master/LICENSE"><img src="https://img.shields.io/github/license/kjanat/procclean" alt="License"></a>
|
|
39
43
|
<a href="https://procclean.kjanat.com"><img src="https://img.shields.io/badge/docs-mkdocs-blue" alt="Docs"></a>
|
|
40
44
|
<img src="https://img.shields.io/badge/python-3.14%2B-blue" alt="Python 3.14+">
|
|
@@ -44,13 +48,18 @@ Description-Content-Type: text/markdown
|
|
|
44
48
|
## Features
|
|
45
49
|
|
|
46
50
|
- **Memory overview** - Real-time total/used/free/swap display
|
|
47
|
-
- **Multiple views** - All
|
|
48
|
-
(>500MB)
|
|
51
|
+
- **Multiple views** - All, Orphaned, Killable, Process Groups, High Memory
|
|
49
52
|
- **Orphan detection** - Finds processes whose parent died (PPID=1)
|
|
53
|
+
- **Killable detection** - Orphans safe to kill (not tmux, not system services)
|
|
54
|
+
- **Stale detection** - Flags processes with deleted executables
|
|
50
55
|
- **Tmux awareness** - Won't flag tmux processes as orphan candidates
|
|
51
56
|
- **Batch operations** - Select multiple processes and kill them at once
|
|
52
57
|
- **Process grouping** - Find duplicate/similar processes consuming resources
|
|
58
|
+
- **Custom columns** - Select which columns to display in CLI output
|
|
59
|
+
- **Configurable thresholds** - Adjust memory filters via CLI flags
|
|
60
|
+
- **Preview mode** - Dry-run for kill operations with formatting options
|
|
53
61
|
- **CLI mode** - Scriptable commands with JSON/CSV/Markdown output
|
|
62
|
+
- **Clickable TUI** - Click headers to sort, rows to select
|
|
54
63
|
|
|
55
64
|
## Installation
|
|
56
65
|
|
|
@@ -58,16 +67,24 @@ Description-Content-Type: text/markdown
|
|
|
58
67
|
pip install procclean
|
|
59
68
|
```
|
|
60
69
|
|
|
61
|
-
Or with [uv](https://docs.astral.sh/uv/):
|
|
70
|
+
Or with [uv](https://docs.astral.sh/uv/) (recommended):
|
|
62
71
|
|
|
63
72
|
```bash
|
|
64
73
|
uv tool install procclean
|
|
65
74
|
```
|
|
66
75
|
|
|
76
|
+
Or with [pipx](https://pipx.pypa.io/):
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pipx install procclean
|
|
80
|
+
```
|
|
81
|
+
|
|
67
82
|
Run without installing:
|
|
68
83
|
|
|
69
84
|
```bash
|
|
70
85
|
uvx procclean
|
|
86
|
+
# or
|
|
87
|
+
pipx run procclean
|
|
71
88
|
```
|
|
72
89
|
|
|
73
90
|
## Usage
|
|
@@ -81,24 +98,40 @@ procclean
|
|
|
81
98
|
### CLI Commands
|
|
82
99
|
|
|
83
100
|
```bash
|
|
101
|
+
# List processes
|
|
84
102
|
procclean list # List processes (table)
|
|
103
|
+
procclean ls # Alias for 'list'
|
|
85
104
|
procclean list -f json|csv|md # Different output formats
|
|
86
105
|
procclean list -s mem|cpu|pid|name|cwd # Sort by field
|
|
106
|
+
procclean list -a # Sort ascending (default: descending)
|
|
87
107
|
procclean list -o # Orphans only
|
|
88
|
-
procclean list -m # High memory only
|
|
108
|
+
procclean list -m # High memory only (>500MB)
|
|
89
109
|
procclean list -k # Killable orphans only
|
|
90
110
|
procclean list --cwd # Filter by current directory
|
|
91
111
|
procclean list --cwd /path/to/dir # Filter by specific cwd
|
|
112
|
+
procclean list -n 20 # Limit output to 20 processes
|
|
113
|
+
procclean list -c pid,name,rss_mb # Custom columns
|
|
114
|
+
procclean list --min-memory 10 # Only processes using >10 MB
|
|
115
|
+
procclean list --high-memory-threshold 1000 # High-mem at 1000 MB
|
|
92
116
|
|
|
117
|
+
# Process groups
|
|
93
118
|
procclean groups # Show process groups
|
|
119
|
+
procclean g # Alias for 'groups'
|
|
120
|
+
procclean groups -f json # Groups as JSON
|
|
94
121
|
|
|
122
|
+
# Kill processes
|
|
95
123
|
procclean kill <PID> [PID...] # Kill process(es)
|
|
96
124
|
procclean kill -f <PID> # Force kill (SIGKILL)
|
|
97
125
|
procclean kill --cwd /path -y # Kill all in cwd (skip confirm)
|
|
98
126
|
procclean kill -k -y # Kill all killable orphans
|
|
99
127
|
procclean kill -k --preview # Preview what would be killed
|
|
128
|
+
procclean kill -k --dry-run # Alias for --preview
|
|
129
|
+
procclean kill -k --preview -O json # Preview in JSON format
|
|
100
130
|
|
|
131
|
+
# Memory summary
|
|
101
132
|
procclean mem # Show memory summary
|
|
133
|
+
procclean memory # Full name for 'mem'
|
|
134
|
+
procclean mem -f json # Memory info as JSON
|
|
102
135
|
```
|
|
103
136
|
|
|
104
137
|
## TUI Keybindings
|
|
@@ -110,6 +143,7 @@ procclean mem # Show memory summary
|
|
|
110
143
|
| `k` | Kill selected (SIGTERM) |
|
|
111
144
|
| `K` | Force kill (SIGKILL) |
|
|
112
145
|
| `o` | Show orphans |
|
|
146
|
+
| `O` | Show killable |
|
|
113
147
|
| `a` | Show all |
|
|
114
148
|
| `g` | Show groups |
|
|
115
149
|
| `w` | Filter by selected cwd |
|
|
@@ -124,12 +158,15 @@ procclean mem # Show memory summary
|
|
|
124
158
|
| `5` | Sort by cwd |
|
|
125
159
|
| `!` | Reverse sort order |
|
|
126
160
|
|
|
161
|
+
Click column headers to sort, click rows to toggle selection.
|
|
162
|
+
|
|
127
163
|
## Views
|
|
128
164
|
|
|
129
165
|
- **All Processes** - All user processes sorted by memory usage
|
|
130
166
|
- **Orphaned** - Processes with PPID=1 (parent died)
|
|
167
|
+
- **Killable** - Orphans safe to kill (not in tmux, not system services)
|
|
131
168
|
- **Process Groups** - Similar processes grouped together
|
|
132
|
-
- **High Memory** - Processes using >500MB RAM
|
|
169
|
+
- **High Memory** - Processes using >500MB RAM (configurable)
|
|
133
170
|
|
|
134
171
|
## Output Formats
|
|
135
172
|
|
|
@@ -140,6 +177,17 @@ CLI supports multiple output formats via `-f`:
|
|
|
140
177
|
- `csv` - CSV for spreadsheets
|
|
141
178
|
- `md` - Markdown table
|
|
142
179
|
|
|
180
|
+
## Custom Columns
|
|
181
|
+
|
|
182
|
+
Use `-c` to specify which columns to display:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
procclean list -c pid,name,rss_mb,cwd
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Available columns: `pid`, `name`, `rss_mb`, `cpu_percent`, `cwd`, `ppid`,
|
|
189
|
+
`parent_name`, `status`, `cmdline`, `username`
|
|
190
|
+
|
|
143
191
|
## Requirements
|
|
144
192
|
|
|
145
193
|
- Python 3.14+
|
|
@@ -151,13 +199,21 @@ CLI supports multiple output formats via `-f`:
|
|
|
151
199
|
git clone https://github.com/kjanat/procclean
|
|
152
200
|
cd procclean
|
|
153
201
|
uv sync
|
|
154
|
-
uv run pre-commit install
|
|
202
|
+
uv run pre-commit install --install-hooks
|
|
155
203
|
```
|
|
156
204
|
|
|
157
205
|
Run tests:
|
|
158
206
|
|
|
159
207
|
```bash
|
|
160
|
-
uv run pytest
|
|
208
|
+
uv run pytest # All tests
|
|
209
|
+
uv run pytest --cov -vv # With coverage
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Lint and type check:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
uv run ruff check src/
|
|
216
|
+
uv run ty check
|
|
161
217
|
```
|
|
162
218
|
|
|
163
219
|
## License
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
10
|
<a href="https://pypi.org/project/procclean/"><img src="https://img.shields.io/pypi/v/procclean" alt="PyPI"></a>
|
|
11
|
+
<a href="https://pypi.org/project/procclean/"><img src="https://img.shields.io/pypi/dm/procclean" alt="Downloads"></a>
|
|
12
|
+
<a href="https://github.com/kjanat/procclean/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/kjanat/procclean/ci.yml?branch=master" alt="CI"></a>
|
|
11
13
|
<a href="https://github.com/kjanat/procclean/blob/master/LICENSE"><img src="https://img.shields.io/github/license/kjanat/procclean" alt="License"></a>
|
|
12
14
|
<a href="https://procclean.kjanat.com"><img src="https://img.shields.io/badge/docs-mkdocs-blue" alt="Docs"></a>
|
|
13
15
|
<img src="https://img.shields.io/badge/python-3.14%2B-blue" alt="Python 3.14+">
|
|
@@ -17,13 +19,18 @@
|
|
|
17
19
|
## Features
|
|
18
20
|
|
|
19
21
|
- **Memory overview** - Real-time total/used/free/swap display
|
|
20
|
-
- **Multiple views** - All
|
|
21
|
-
(>500MB)
|
|
22
|
+
- **Multiple views** - All, Orphaned, Killable, Process Groups, High Memory
|
|
22
23
|
- **Orphan detection** - Finds processes whose parent died (PPID=1)
|
|
24
|
+
- **Killable detection** - Orphans safe to kill (not tmux, not system services)
|
|
25
|
+
- **Stale detection** - Flags processes with deleted executables
|
|
23
26
|
- **Tmux awareness** - Won't flag tmux processes as orphan candidates
|
|
24
27
|
- **Batch operations** - Select multiple processes and kill them at once
|
|
25
28
|
- **Process grouping** - Find duplicate/similar processes consuming resources
|
|
29
|
+
- **Custom columns** - Select which columns to display in CLI output
|
|
30
|
+
- **Configurable thresholds** - Adjust memory filters via CLI flags
|
|
31
|
+
- **Preview mode** - Dry-run for kill operations with formatting options
|
|
26
32
|
- **CLI mode** - Scriptable commands with JSON/CSV/Markdown output
|
|
33
|
+
- **Clickable TUI** - Click headers to sort, rows to select
|
|
27
34
|
|
|
28
35
|
## Installation
|
|
29
36
|
|
|
@@ -31,16 +38,24 @@
|
|
|
31
38
|
pip install procclean
|
|
32
39
|
```
|
|
33
40
|
|
|
34
|
-
Or with [uv](https://docs.astral.sh/uv/):
|
|
41
|
+
Or with [uv](https://docs.astral.sh/uv/) (recommended):
|
|
35
42
|
|
|
36
43
|
```bash
|
|
37
44
|
uv tool install procclean
|
|
38
45
|
```
|
|
39
46
|
|
|
47
|
+
Or with [pipx](https://pipx.pypa.io/):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pipx install procclean
|
|
51
|
+
```
|
|
52
|
+
|
|
40
53
|
Run without installing:
|
|
41
54
|
|
|
42
55
|
```bash
|
|
43
56
|
uvx procclean
|
|
57
|
+
# or
|
|
58
|
+
pipx run procclean
|
|
44
59
|
```
|
|
45
60
|
|
|
46
61
|
## Usage
|
|
@@ -54,24 +69,40 @@ procclean
|
|
|
54
69
|
### CLI Commands
|
|
55
70
|
|
|
56
71
|
```bash
|
|
72
|
+
# List processes
|
|
57
73
|
procclean list # List processes (table)
|
|
74
|
+
procclean ls # Alias for 'list'
|
|
58
75
|
procclean list -f json|csv|md # Different output formats
|
|
59
76
|
procclean list -s mem|cpu|pid|name|cwd # Sort by field
|
|
77
|
+
procclean list -a # Sort ascending (default: descending)
|
|
60
78
|
procclean list -o # Orphans only
|
|
61
|
-
procclean list -m # High memory only
|
|
79
|
+
procclean list -m # High memory only (>500MB)
|
|
62
80
|
procclean list -k # Killable orphans only
|
|
63
81
|
procclean list --cwd # Filter by current directory
|
|
64
82
|
procclean list --cwd /path/to/dir # Filter by specific cwd
|
|
83
|
+
procclean list -n 20 # Limit output to 20 processes
|
|
84
|
+
procclean list -c pid,name,rss_mb # Custom columns
|
|
85
|
+
procclean list --min-memory 10 # Only processes using >10 MB
|
|
86
|
+
procclean list --high-memory-threshold 1000 # High-mem at 1000 MB
|
|
65
87
|
|
|
88
|
+
# Process groups
|
|
66
89
|
procclean groups # Show process groups
|
|
90
|
+
procclean g # Alias for 'groups'
|
|
91
|
+
procclean groups -f json # Groups as JSON
|
|
67
92
|
|
|
93
|
+
# Kill processes
|
|
68
94
|
procclean kill <PID> [PID...] # Kill process(es)
|
|
69
95
|
procclean kill -f <PID> # Force kill (SIGKILL)
|
|
70
96
|
procclean kill --cwd /path -y # Kill all in cwd (skip confirm)
|
|
71
97
|
procclean kill -k -y # Kill all killable orphans
|
|
72
98
|
procclean kill -k --preview # Preview what would be killed
|
|
99
|
+
procclean kill -k --dry-run # Alias for --preview
|
|
100
|
+
procclean kill -k --preview -O json # Preview in JSON format
|
|
73
101
|
|
|
102
|
+
# Memory summary
|
|
74
103
|
procclean mem # Show memory summary
|
|
104
|
+
procclean memory # Full name for 'mem'
|
|
105
|
+
procclean mem -f json # Memory info as JSON
|
|
75
106
|
```
|
|
76
107
|
|
|
77
108
|
## TUI Keybindings
|
|
@@ -83,6 +114,7 @@ procclean mem # Show memory summary
|
|
|
83
114
|
| `k` | Kill selected (SIGTERM) |
|
|
84
115
|
| `K` | Force kill (SIGKILL) |
|
|
85
116
|
| `o` | Show orphans |
|
|
117
|
+
| `O` | Show killable |
|
|
86
118
|
| `a` | Show all |
|
|
87
119
|
| `g` | Show groups |
|
|
88
120
|
| `w` | Filter by selected cwd |
|
|
@@ -97,12 +129,15 @@ procclean mem # Show memory summary
|
|
|
97
129
|
| `5` | Sort by cwd |
|
|
98
130
|
| `!` | Reverse sort order |
|
|
99
131
|
|
|
132
|
+
Click column headers to sort, click rows to toggle selection.
|
|
133
|
+
|
|
100
134
|
## Views
|
|
101
135
|
|
|
102
136
|
- **All Processes** - All user processes sorted by memory usage
|
|
103
137
|
- **Orphaned** - Processes with PPID=1 (parent died)
|
|
138
|
+
- **Killable** - Orphans safe to kill (not in tmux, not system services)
|
|
104
139
|
- **Process Groups** - Similar processes grouped together
|
|
105
|
-
- **High Memory** - Processes using >500MB RAM
|
|
140
|
+
- **High Memory** - Processes using >500MB RAM (configurable)
|
|
106
141
|
|
|
107
142
|
## Output Formats
|
|
108
143
|
|
|
@@ -113,6 +148,17 @@ CLI supports multiple output formats via `-f`:
|
|
|
113
148
|
- `csv` - CSV for spreadsheets
|
|
114
149
|
- `md` - Markdown table
|
|
115
150
|
|
|
151
|
+
## Custom Columns
|
|
152
|
+
|
|
153
|
+
Use `-c` to specify which columns to display:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
procclean list -c pid,name,rss_mb,cwd
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Available columns: `pid`, `name`, `rss_mb`, `cpu_percent`, `cwd`, `ppid`,
|
|
160
|
+
`parent_name`, `status`, `cmdline`, `username`
|
|
161
|
+
|
|
116
162
|
## Requirements
|
|
117
163
|
|
|
118
164
|
- Python 3.14+
|
|
@@ -124,13 +170,21 @@ CLI supports multiple output formats via `-f`:
|
|
|
124
170
|
git clone https://github.com/kjanat/procclean
|
|
125
171
|
cd procclean
|
|
126
172
|
uv sync
|
|
127
|
-
uv run pre-commit install
|
|
173
|
+
uv run pre-commit install --install-hooks
|
|
128
174
|
```
|
|
129
175
|
|
|
130
176
|
Run tests:
|
|
131
177
|
|
|
132
178
|
```bash
|
|
133
|
-
uv run pytest
|
|
179
|
+
uv run pytest # All tests
|
|
180
|
+
uv run pytest --cov -vv # With coverage
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Lint and type check:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
uv run ruff check src/
|
|
187
|
+
uv run ty check
|
|
134
188
|
```
|
|
135
189
|
|
|
136
190
|
## License
|
|
@@ -3,15 +3,16 @@
|
|
|
3
3
|
|
|
4
4
|
[project]
|
|
5
5
|
name = "procclean"
|
|
6
|
-
version = "
|
|
6
|
+
version = "2.0.0"
|
|
7
7
|
description = "Interactive TUI for exploring and cleaning up processes - find orphans, memory hogs, and kill them"
|
|
8
8
|
readme = "README.md"
|
|
9
9
|
requires-python = ">=3.14"
|
|
10
10
|
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
11
12
|
authors = [{ name = "Kaj Kowalski", email = "info@kajkowalski.nl" }]
|
|
12
13
|
keywords = ["cleanup", "kill", "memory", "orphan", "process", "terminal", "tui"]
|
|
13
14
|
classifiers = [
|
|
14
|
-
"Development Status ::
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
15
16
|
"Environment :: Console",
|
|
16
17
|
"Intended Audience :: Developers",
|
|
17
18
|
"Intended Audience :: System Administrators",
|
|
@@ -31,6 +32,7 @@ dependencies = [
|
|
|
31
32
|
|
|
32
33
|
[project.urls]
|
|
33
34
|
Homepage = "https://procclean.kjanat.com"
|
|
35
|
+
Issues = "https://github.com/kjanat/procclean/issues"
|
|
34
36
|
Repository = "https://github.com/kjanat/procclean"
|
|
35
37
|
|
|
36
38
|
[project.scripts]
|
|
@@ -41,6 +43,7 @@ dev = [
|
|
|
41
43
|
"pre-commit>=4.5.1",
|
|
42
44
|
]
|
|
43
45
|
lint = [
|
|
46
|
+
"dprint-py>=0.51.1.0",
|
|
44
47
|
"pylint>=4.0.4",
|
|
45
48
|
"ruff>=0.14.10",
|
|
46
49
|
"tombi>=0.7.14",
|
|
@@ -4,9 +4,9 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
6
|
import io
|
|
7
|
-
import os
|
|
8
7
|
import re
|
|
9
8
|
from collections import defaultdict
|
|
9
|
+
from functools import partial
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
12
|
from markdown_it import MarkdownIt
|
|
@@ -52,23 +52,31 @@ def _capture_help(parser: argparse.ArgumentParser) -> str:
|
|
|
52
52
|
Returns:
|
|
53
53
|
HTML string with colored help output.
|
|
54
54
|
"""
|
|
55
|
-
#
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
# Create a truecolor console for both formatting and export
|
|
56
|
+
# - color_system="truecolor" ensures hex colors like #98f641 aren't downgraded
|
|
57
|
+
# - width=80 ensures consistent text wrapping across environments
|
|
58
|
+
truecolor_console = Console(
|
|
59
|
+
file=io.StringIO(),
|
|
60
|
+
record=True,
|
|
61
|
+
force_terminal=True,
|
|
62
|
+
color_system="truecolor",
|
|
63
|
+
width=80,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Save original formatter_class and restore after use
|
|
67
|
+
orig_formatter_class = parser.formatter_class
|
|
58
68
|
try:
|
|
59
|
-
|
|
60
|
-
|
|
69
|
+
# Pass the truecolor console to RichHelpFormatter via partial
|
|
70
|
+
parser.formatter_class = partial(RichHelpFormatter, console=truecolor_console)
|
|
71
|
+
help_text = parser.format_help()
|
|
61
72
|
finally:
|
|
62
|
-
|
|
63
|
-
os.environ.pop("FORCE_COLOR", None)
|
|
64
|
-
else:
|
|
65
|
-
os.environ["FORCE_COLOR"] = old_force_color
|
|
73
|
+
parser.formatter_class = orig_formatter_class
|
|
66
74
|
|
|
67
|
-
#
|
|
68
|
-
|
|
69
|
-
|
|
75
|
+
# Parse ANSI back to Rich Text and re-render for HTML export
|
|
76
|
+
text = Text.from_ansi(help_text)
|
|
77
|
+
truecolor_console.print(text, crop=False)
|
|
70
78
|
|
|
71
|
-
return
|
|
79
|
+
return truecolor_console.export_html(code_format=_CODE_FORMAT, inline_styles=True)
|
|
72
80
|
|
|
73
81
|
|
|
74
82
|
def _argparser_to_markdown(
|
|
@@ -15,12 +15,19 @@ from .filters import (
|
|
|
15
15
|
filter_high_memory,
|
|
16
16
|
filter_killable,
|
|
17
17
|
filter_orphans,
|
|
18
|
+
filter_stale,
|
|
18
19
|
is_system_service,
|
|
19
20
|
sort_processes,
|
|
20
21
|
)
|
|
21
22
|
from .memory import get_memory_summary
|
|
22
23
|
from .models import ProcessInfo
|
|
23
|
-
from .process import
|
|
24
|
+
from .process import (
|
|
25
|
+
find_similar_processes,
|
|
26
|
+
get_cwd,
|
|
27
|
+
get_process_list,
|
|
28
|
+
get_tmux_env,
|
|
29
|
+
is_exe_deleted,
|
|
30
|
+
)
|
|
24
31
|
|
|
25
32
|
__all__ = [
|
|
26
33
|
"CONFIRM_PREVIEW_LIMIT",
|
|
@@ -35,11 +42,13 @@ __all__ = [
|
|
|
35
42
|
"filter_high_memory",
|
|
36
43
|
"filter_killable",
|
|
37
44
|
"filter_orphans",
|
|
45
|
+
"filter_stale",
|
|
38
46
|
"find_similar_processes",
|
|
39
47
|
"get_cwd",
|
|
40
48
|
"get_memory_summary",
|
|
41
49
|
"get_process_list",
|
|
42
50
|
"get_tmux_env",
|
|
51
|
+
"is_exe_deleted",
|
|
43
52
|
"is_system_service",
|
|
44
53
|
"kill_process",
|
|
45
54
|
"kill_processes",
|
|
@@ -69,6 +69,21 @@ def filter_high_memory(
|
|
|
69
69
|
return [p for p in procs if p.rss_mb > threshold_mb]
|
|
70
70
|
|
|
71
71
|
|
|
72
|
+
def filter_stale(procs: list[ProcessInfo]) -> list[ProcessInfo]:
|
|
73
|
+
"""Filter to processes with deleted/updated executables.
|
|
74
|
+
|
|
75
|
+
These are processes running outdated binaries after a package update.
|
|
76
|
+
Common on rolling release distros (Arch, Manjaro) after system updates.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
procs: List of processes to filter.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Processes whose executable has been deleted or replaced.
|
|
83
|
+
"""
|
|
84
|
+
return [p for p in procs if p.exe_deleted]
|
|
85
|
+
|
|
86
|
+
|
|
72
87
|
def filter_by_cwd(procs: list[ProcessInfo], cwd_path: str) -> list[ProcessInfo]:
|
|
73
88
|
"""Filter processes by current working directory.
|
|
74
89
|
|
|
@@ -44,6 +44,26 @@ def get_cwd(pid: int) -> str:
|
|
|
44
44
|
return "?"
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def is_exe_deleted(pid: int) -> bool:
|
|
48
|
+
"""Check if process executable has been deleted or updated.
|
|
49
|
+
|
|
50
|
+
This happens when a package is updated while the process is running.
|
|
51
|
+
The process continues with the old binary in memory, but the exe symlink
|
|
52
|
+
shows "(deleted)" suffix.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
pid: Process ID.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
True if the executable file was deleted/updated, False otherwise.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
exe_link = Path(f"/proc/{pid}/exe").readlink()
|
|
62
|
+
return str(exe_link).endswith("(deleted)")
|
|
63
|
+
except (PermissionError, FileNotFoundError, ProcessLookupError):
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
47
67
|
def get_process_list(
|
|
48
68
|
sort_by: str = "memory",
|
|
49
69
|
filter_user: str | None = None,
|
|
@@ -93,19 +113,22 @@ def get_process_list(
|
|
|
93
113
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
94
114
|
parent_name = "?"
|
|
95
115
|
|
|
96
|
-
# Check if orphaned (
|
|
97
|
-
|
|
116
|
+
# Check if orphaned (reparented to PID 1 system init)
|
|
117
|
+
# Note:
|
|
118
|
+
# ppid != 1 with parent "systemd" means user session service, NOT orphan
|
|
119
|
+
is_orphan = ppid == 1
|
|
98
120
|
|
|
99
121
|
cmdline = " ".join(info["cmdline"] or [])[:200]
|
|
100
122
|
if not cmdline:
|
|
101
123
|
cmdline = info["name"]
|
|
102
124
|
|
|
125
|
+
pid = info["pid"]
|
|
103
126
|
processes.append(
|
|
104
127
|
ProcessInfo(
|
|
105
|
-
pid=
|
|
128
|
+
pid=pid,
|
|
106
129
|
name=info["name"],
|
|
107
130
|
cmdline=cmdline,
|
|
108
|
-
cwd=get_cwd(
|
|
131
|
+
cwd=get_cwd(pid),
|
|
109
132
|
ppid=ppid,
|
|
110
133
|
parent_name=parent_name,
|
|
111
134
|
rss_mb=rss_mb,
|
|
@@ -113,8 +136,9 @@ def get_process_list(
|
|
|
113
136
|
username=info["username"],
|
|
114
137
|
create_time=info["create_time"] or 0,
|
|
115
138
|
is_orphan=is_orphan,
|
|
116
|
-
in_tmux=get_tmux_env(
|
|
139
|
+
in_tmux=get_tmux_env(pid) if is_orphan else False,
|
|
117
140
|
status=info["status"] or "?",
|
|
141
|
+
exe_deleted=is_exe_deleted(pid),
|
|
118
142
|
)
|
|
119
143
|
)
|
|
120
144
|
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
|
@@ -6,6 +6,7 @@ from textual import on, work
|
|
|
6
6
|
from textual.app import App, ComposeResult
|
|
7
7
|
from textual.binding import Binding
|
|
8
8
|
from textual.containers import Horizontal, Vertical
|
|
9
|
+
from textual.coordinate import Coordinate
|
|
9
10
|
from textual.reactive import reactive
|
|
10
11
|
from textual.widgets import (
|
|
11
12
|
DataTable,
|
|
@@ -15,6 +16,7 @@ from textual.widgets import (
|
|
|
15
16
|
OptionList,
|
|
16
17
|
Static,
|
|
17
18
|
)
|
|
19
|
+
from textual.widgets.data_table import RowDoesNotExist
|
|
18
20
|
from textual.widgets.option_list import Option
|
|
19
21
|
|
|
20
22
|
from procclean.core import (
|
|
@@ -32,7 +34,7 @@ from procclean.core import (
|
|
|
32
34
|
from .screens import ConfirmKillScreen
|
|
33
35
|
|
|
34
36
|
# Type aliases
|
|
35
|
-
ViewType = Literal["all", "orphans", "groups", "high-mem"]
|
|
37
|
+
ViewType = Literal["all", "orphans", "killable", "groups", "high-mem"]
|
|
36
38
|
SortKey = Literal["memory", "cpu", "pid", "name", "cwd"]
|
|
37
39
|
|
|
38
40
|
|
|
@@ -53,6 +55,7 @@ class ProcessCleanerApp(App):
|
|
|
53
55
|
Binding("k", "kill_selected", "Kill"),
|
|
54
56
|
Binding("K", "force_kill_selected", "Force Kill"),
|
|
55
57
|
Binding("o", "show_orphans", "Orphans"),
|
|
58
|
+
Binding("O", "show_killable", "Killable"),
|
|
56
59
|
Binding("a", "show_all", "All"),
|
|
57
60
|
Binding("g", "show_groups", "Groups"),
|
|
58
61
|
Binding("w", "filter_cwd", "Filter CWD"),
|
|
@@ -93,6 +96,7 @@ class ProcessCleanerApp(App):
|
|
|
93
96
|
yield OptionList(
|
|
94
97
|
Option("All Processes", id="view-all"),
|
|
95
98
|
Option("Orphaned", id="view-orphans"),
|
|
99
|
+
Option("Killable", id="view-killable"),
|
|
96
100
|
Option("Process Groups", id="view-groups"),
|
|
97
101
|
Option("High Memory (>500MB)", id="view-high-mem"),
|
|
98
102
|
id="view-selector",
|
|
@@ -177,35 +181,58 @@ class ProcessCleanerApp(App):
|
|
|
177
181
|
key_func = sort_keys.get(self.sort_key, sort_keys["memory"])
|
|
178
182
|
return sorted(procs, key=key_func, reverse=self.sort_reverse)
|
|
179
183
|
|
|
184
|
+
def _filter_by_view(self) -> list[ProcessInfo]:
|
|
185
|
+
"""Filter processes based on current view.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Filtered list of processes for the current view.
|
|
189
|
+
"""
|
|
190
|
+
if self.current_view == "orphans":
|
|
191
|
+
return [p for p in self.processes if p.is_orphan]
|
|
192
|
+
if self.current_view == "killable":
|
|
193
|
+
return [p for p in self.processes if p.is_orphan_candidate]
|
|
194
|
+
if self.current_view == "high-mem":
|
|
195
|
+
return [p for p in self.processes if p.rss_mb > HIGH_MEMORY_THRESHOLD_MB]
|
|
196
|
+
if self.current_view == "groups":
|
|
197
|
+
groups = find_similar_processes(self.processes)
|
|
198
|
+
return [p for group in groups.values() for p in group]
|
|
199
|
+
return list(self.processes)
|
|
200
|
+
|
|
201
|
+
@staticmethod
|
|
202
|
+
def _restore_cursor(table: DataTable, cursor_pid: int | None) -> None:
|
|
203
|
+
"""Restore cursor to the row with the given PID.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
table: The DataTable to restore cursor in.
|
|
207
|
+
cursor_pid: The PID to restore cursor to, or None to skip.
|
|
208
|
+
"""
|
|
209
|
+
if cursor_pid is None:
|
|
210
|
+
return
|
|
211
|
+
try:
|
|
212
|
+
row_idx = table.get_row_index(str(cursor_pid))
|
|
213
|
+
except RowDoesNotExist:
|
|
214
|
+
if table.row_count:
|
|
215
|
+
table.move_cursor(row=0)
|
|
216
|
+
return
|
|
217
|
+
table.move_cursor(row=row_idx)
|
|
218
|
+
|
|
180
219
|
def update_table(self) -> None:
|
|
181
220
|
"""Update the process table based on current view and sort."""
|
|
182
221
|
table = self.query_one("#process-table", DataTable)
|
|
222
|
+
cursor_pid = self._get_pid_at_cursor()
|
|
183
223
|
table.clear()
|
|
184
224
|
|
|
185
|
-
|
|
186
|
-
procs = [p for p in self.processes if p.is_orphan]
|
|
187
|
-
elif self.current_view == "high-mem":
|
|
188
|
-
procs = [p for p in self.processes if p.rss_mb > HIGH_MEMORY_THRESHOLD_MB]
|
|
189
|
-
elif self.current_view == "groups":
|
|
190
|
-
groups = find_similar_processes(self.processes)
|
|
191
|
-
procs = []
|
|
192
|
-
for group_procs in groups.values():
|
|
193
|
-
procs.extend(group_procs)
|
|
194
|
-
else:
|
|
195
|
-
procs = self.processes
|
|
196
|
-
|
|
197
|
-
# Apply cwd filter
|
|
225
|
+
procs = self._filter_by_view()
|
|
198
226
|
if self.cwd_filter:
|
|
199
227
|
procs = filter_by_cwd(procs, self.cwd_filter)
|
|
200
|
-
|
|
201
|
-
# Apply sorting
|
|
202
228
|
procs = self._sort_processes(procs)
|
|
203
229
|
|
|
204
230
|
for proc in procs:
|
|
205
231
|
selected = "[X]" if proc.pid in self.selected_pids else "[ ]"
|
|
206
232
|
orphan_marker = " [orphan]" if proc.is_orphan else ""
|
|
207
233
|
tmux_marker = " [tmux]" if proc.in_tmux else ""
|
|
208
|
-
|
|
234
|
+
stale_marker = " [stale]" if proc.exe_deleted else ""
|
|
235
|
+
status = f"{proc.status}{orphan_marker}{tmux_marker}{stale_marker}"
|
|
209
236
|
|
|
210
237
|
cwd = proc.cwd or "?"
|
|
211
238
|
if len(cwd) > CWD_MAX_WIDTH:
|
|
@@ -224,6 +251,7 @@ class ProcessCleanerApp(App):
|
|
|
224
251
|
key=str(proc.pid),
|
|
225
252
|
)
|
|
226
253
|
|
|
254
|
+
self._restore_cursor(table, cursor_pid)
|
|
227
255
|
self.update_status()
|
|
228
256
|
|
|
229
257
|
def update_status(self) -> None:
|
|
@@ -240,12 +268,55 @@ class ProcessCleanerApp(App):
|
|
|
240
268
|
view_map: dict[str, ViewType] = {
|
|
241
269
|
"view-all": "all",
|
|
242
270
|
"view-orphans": "orphans",
|
|
271
|
+
"view-killable": "killable",
|
|
243
272
|
"view-groups": "groups",
|
|
244
273
|
"view-high-mem": "high-mem",
|
|
245
274
|
}
|
|
246
275
|
if event.option.id and event.option.id in view_map:
|
|
247
276
|
self.current_view = view_map[event.option.id]
|
|
248
277
|
|
|
278
|
+
@on(DataTable.RowSelected, "#process-table")
|
|
279
|
+
def on_row_clicked(self, event: DataTable.RowSelected) -> None:
|
|
280
|
+
"""Toggle selection when a row is clicked."""
|
|
281
|
+
# Get PID from the row data (column 1 is PID)
|
|
282
|
+
# Guard against race: auto-refresh can remove rows mid-flight
|
|
283
|
+
try:
|
|
284
|
+
row_data = event.data_table.get_row(event.row_key)
|
|
285
|
+
pid = int(row_data[1])
|
|
286
|
+
except RowDoesNotExist:
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
# Toggle selection
|
|
290
|
+
if pid in self.selected_pids:
|
|
291
|
+
self.selected_pids.remove(pid)
|
|
292
|
+
new_value = "[ ]"
|
|
293
|
+
else:
|
|
294
|
+
self.selected_pids.add(pid)
|
|
295
|
+
new_value = "[X]"
|
|
296
|
+
|
|
297
|
+
# Update the clicked row's selection cell using row_key (not cursor_row)
|
|
298
|
+
selection_column_key = event.data_table.ordered_columns[0].key
|
|
299
|
+
event.data_table.update_cell(event.row_key, selection_column_key, new_value)
|
|
300
|
+
self.update_status()
|
|
301
|
+
|
|
302
|
+
@on(DataTable.HeaderSelected, "#process-table")
|
|
303
|
+
def on_header_clicked(self, event: DataTable.HeaderSelected) -> None:
|
|
304
|
+
"""Sort by column when header is clicked."""
|
|
305
|
+
# Map column index to sort key.
|
|
306
|
+
# Sortable: PID(1), Name(2), RAM(3), CPU(4), CWD(5)
|
|
307
|
+
# Not sortable (no-op): Selection(0), PPID(6), Parent(7), Status(8)
|
|
308
|
+
# NOTE: Indexes must be updated if column order changes in update_table()
|
|
309
|
+
column_sort_map: dict[int, SortKey] = {
|
|
310
|
+
1: "pid",
|
|
311
|
+
2: "name",
|
|
312
|
+
3: "memory",
|
|
313
|
+
4: "cpu",
|
|
314
|
+
5: "cwd",
|
|
315
|
+
}
|
|
316
|
+
col_idx = event.column_index
|
|
317
|
+
if col_idx in column_sort_map:
|
|
318
|
+
self._set_sort(column_sort_map[col_idx])
|
|
319
|
+
|
|
249
320
|
def action_refresh(self) -> None:
|
|
250
321
|
"""Refresh process data."""
|
|
251
322
|
self.refresh_data()
|
|
@@ -256,14 +327,14 @@ class ProcessCleanerApp(App):
|
|
|
256
327
|
|
|
257
328
|
Returns:
|
|
258
329
|
The PID at the current cursor position, or ``None`` if there is no
|
|
259
|
-
current row selected.
|
|
330
|
+
current row selected or the table is empty.
|
|
260
331
|
"""
|
|
261
332
|
table = self.query_one("#process-table", DataTable)
|
|
262
|
-
if table.cursor_row is None:
|
|
333
|
+
if table.cursor_row is None or table.row_count == 0:
|
|
263
334
|
return None
|
|
264
|
-
|
|
265
|
-
#
|
|
266
|
-
return int(
|
|
335
|
+
row_data = table.get_row_at(table.cursor_row)
|
|
336
|
+
# row_data is a list of cell values: [selected, pid, name, ...]
|
|
337
|
+
return int(row_data[1])
|
|
267
338
|
|
|
268
339
|
def _get_process_at_cursor(self) -> ProcessInfo | None:
|
|
269
340
|
"""Get the ProcessInfo at the current cursor position.
|
|
@@ -280,13 +351,23 @@ class ProcessCleanerApp(App):
|
|
|
280
351
|
|
|
281
352
|
def action_toggle_select(self) -> None:
|
|
282
353
|
"""Toggle selection of current row."""
|
|
354
|
+
table = self.query_one("#process-table", DataTable)
|
|
355
|
+
if table.cursor_row is None:
|
|
356
|
+
return
|
|
357
|
+
|
|
283
358
|
pid = self._get_pid_at_cursor()
|
|
284
359
|
if pid is not None:
|
|
360
|
+
# Toggle selection
|
|
285
361
|
if pid in self.selected_pids:
|
|
286
362
|
self.selected_pids.remove(pid)
|
|
363
|
+
new_value = "[ ]"
|
|
287
364
|
else:
|
|
288
365
|
self.selected_pids.add(pid)
|
|
289
|
-
|
|
366
|
+
new_value = "[X]"
|
|
367
|
+
|
|
368
|
+
# Update just the selection cell, not the entire table
|
|
369
|
+
table.update_cell_at(Coordinate(table.cursor_row, 0), new_value)
|
|
370
|
+
self.update_status()
|
|
290
371
|
|
|
291
372
|
def action_select_all_visible(self) -> None:
|
|
292
373
|
"""Select all visible processes."""
|
|
@@ -306,6 +387,10 @@ class ProcessCleanerApp(App):
|
|
|
306
387
|
"""Switch to orphans view."""
|
|
307
388
|
self.current_view = "orphans"
|
|
308
389
|
|
|
390
|
+
def action_show_killable(self) -> None:
|
|
391
|
+
"""Switch to killable orphans view (orphans not in tmux)."""
|
|
392
|
+
self.current_view = "killable"
|
|
393
|
+
|
|
309
394
|
def action_show_all(self) -> None:
|
|
310
395
|
"""Switch to all processes view."""
|
|
311
396
|
self.current_view = "all"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|