killpy 0.25.0__tar.gz → 1.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.
- {killpy-0.25.0 → killpy-1.0.0}/PKG-INFO +18 -11
- {killpy-0.25.0 → killpy-1.0.0}/README.md +8 -9
- {killpy-0.25.0 → killpy-1.0.0}/killpy/__main__.py +6 -2
- {killpy-0.25.0 → killpy-1.0.0}/killpy/cleaners/__init__.py +4 -0
- {killpy-0.25.0 → killpy-1.0.0}/killpy/cli.py +31 -33
- {killpy-0.25.0 → killpy-1.0.0}/killpy/commands/_utils.py +2 -2
- {killpy-0.25.0 → killpy-1.0.0}/killpy/commands/clean.py +7 -2
- {killpy-0.25.0 → killpy-1.0.0}/killpy/commands/delete.py +1 -1
- {killpy-0.25.0 → killpy-1.0.0}/killpy/commands/doctor.py +42 -45
- {killpy-0.25.0 → killpy-1.0.0}/killpy/commands/list.py +5 -4
- {killpy-0.25.0 → killpy-1.0.0}/killpy/detectors/artifacts.py +11 -11
- killpy-1.0.0/killpy/detectors/base.py +101 -0
- {killpy-0.25.0 → killpy-1.0.0}/killpy/detectors/cache.py +20 -22
- {killpy-0.25.0 → killpy-1.0.0}/killpy/detectors/conda.py +2 -5
- {killpy-0.25.0 → killpy-1.0.0}/killpy/detectors/hatch.py +5 -4
- {killpy-0.25.0 → killpy-1.0.0}/killpy/detectors/pipenv.py +5 -4
- {killpy-0.25.0 → killpy-1.0.0}/killpy/detectors/pipx.py +2 -5
- {killpy-0.25.0 → killpy-1.0.0}/killpy/detectors/poetry.py +4 -3
- {killpy-0.25.0 → killpy-1.0.0}/killpy/detectors/pyenv.py +4 -3
- {killpy-0.25.0 → killpy-1.0.0}/killpy/detectors/tox.py +4 -8
- {killpy-0.25.0 → killpy-1.0.0}/killpy/detectors/uv.py +5 -8
- {killpy-0.25.0 → killpy-1.0.0}/killpy/detectors/venv.py +12 -12
- {killpy-0.25.0 → killpy-1.0.0}/killpy/files/__init__.py +13 -8
- {killpy-0.25.0 → killpy-1.0.0}/killpy/intelligence/git_analyzer.py +12 -9
- {killpy-0.25.0 → killpy-1.0.0}/killpy/intelligence/scoring.py +4 -4
- {killpy-0.25.0 → killpy-1.0.0}/killpy/intelligence/suggestions.py +21 -25
- {killpy-0.25.0 → killpy-1.0.0}/killpy/models.py +22 -9
- {killpy-0.25.0 → killpy-1.0.0}/killpy.egg-info/PKG-INFO +18 -11
- {killpy-0.25.0 → killpy-1.0.0}/pyproject.toml +26 -2
- killpy-0.25.0/killpy/detectors/base.py +0 -53
- {killpy-0.25.0 → killpy-1.0.0}/killpy/__init__.py +0 -0
- {killpy-0.25.0 → killpy-1.0.0}/killpy/cleaner.py +0 -0
- {killpy-0.25.0 → killpy-1.0.0}/killpy/commands/__init__.py +0 -0
- {killpy-0.25.0 → killpy-1.0.0}/killpy/commands/find.py +0 -0
- {killpy-0.25.0 → killpy-1.0.0}/killpy/commands/stats.py +0 -0
- {killpy-0.25.0 → killpy-1.0.0}/killpy/detectors/__init__.py +0 -0
- {killpy-0.25.0 → killpy-1.0.0}/killpy/intelligence/__init__.py +0 -0
- {killpy-0.25.0 → killpy-1.0.0}/killpy/intelligence/tracker.py +0 -0
- {killpy-0.25.0 → killpy-1.0.0}/killpy/scanner.py +0 -0
- {killpy-0.25.0 → killpy-1.0.0}/killpy.egg-info/SOURCES.txt +0 -0
- {killpy-0.25.0 → killpy-1.0.0}/killpy.egg-info/dependency_links.txt +0 -0
- {killpy-0.25.0 → killpy-1.0.0}/killpy.egg-info/entry_points.txt +0 -0
- {killpy-0.25.0 → killpy-1.0.0}/killpy.egg-info/requires.txt +0 -0
- {killpy-0.25.0 → killpy-1.0.0}/killpy.egg-info/top_level.txt +0 -0
- {killpy-0.25.0 → killpy-1.0.0}/setup.cfg +0 -0
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: killpy
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Find and delete unused Python environments (.venv, conda, poetry, pipenv, pipx, pyenv, uv) and caches to free disk space — from a fast terminal UI or a single CLI command.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Tlaloc-Es/killpy
|
|
6
6
|
Project-URL: Documentation, https://tlaloc-es.github.io/killpy/
|
|
7
7
|
Project-URL: Repository, https://github.com/Tlaloc-Es/killpy
|
|
8
8
|
Project-URL: Bug Tracker, https://github.com/Tlaloc-Es/killpy/issues
|
|
9
|
+
Keywords: venv,virtualenv,conda,poetry,pipenv,pipx,pyenv,uv,cleanup,disk-space,cache,cli,tui,textual,developer-tools
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
11
|
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Environment :: Console :: Curses
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
10
15
|
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
11
17
|
Classifier: Programming Language :: Python :: 3.10
|
|
12
18
|
Classifier: Programming Language :: Python :: 3.11
|
|
13
19
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
20
|
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: System :: Systems Administration
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
15
23
|
Requires-Python: >=3.10
|
|
16
24
|
Description-Content-Type: text/markdown
|
|
17
25
|
Requires-Dist: click>=8.1.8
|
|
@@ -188,7 +196,7 @@ Environments flagged with `⚠️` are actively in use (the environment killpy r
|
|
|
188
196
|
| Key | Action |
|
|
189
197
|
|-----|--------|
|
|
190
198
|
| `↑` / `↓` or `k` / `j` | Move cursor up / down (vim-style) |
|
|
191
|
-
| `/` | Open live search/filter bar (
|
|
199
|
+
| `/` | Open live search/filter bar (substring match) |
|
|
192
200
|
| `Escape` | Close search bar and clear filter |
|
|
193
201
|
| `T` | Toggle multi-select mode on / off |
|
|
194
202
|
| `Space` | *(Multi-select)* Toggle current row selected |
|
|
@@ -203,7 +211,7 @@ Environments flagged with `⚠️` are actively in use (the environment killpy r
|
|
|
203
211
|
|
|
204
212
|
### Search / filter
|
|
205
213
|
|
|
206
|
-
Press `/` to open the filter bar at the bottom of the screen. Type any
|
|
214
|
+
Press `/` to open the filter bar at the bottom of the screen. Type any text — the venv table filters by path substring (case-insensitive) and updates live as you type. Press `Escape` or submit an empty value to clear the filter and return to the full list.
|
|
207
215
|
|
|
208
216
|
### Multi-select mode
|
|
209
217
|
|
|
@@ -257,7 +265,7 @@ ______________________________________________________________________
|
|
|
257
265
|
killpy list # list all detected environments
|
|
258
266
|
killpy list --path ~/projects # scan a specific path
|
|
259
267
|
killpy list --type venv --type conda # filter by type (repeatable)
|
|
260
|
-
killpy list --older-than 90 # not
|
|
268
|
+
killpy list --older-than 90 # not modified in the last 90 days
|
|
261
269
|
killpy list --json # output as a JSON array
|
|
262
270
|
killpy list --json-stream # stream as NDJSON — one line per env
|
|
263
271
|
killpy list --quiet # suppress progress output (scripts/CI)
|
|
@@ -276,7 +284,7 @@ The **Path** column mirrors the form of `--path`: a relative `--path` produces r
|
|
|
276
284
|
"absolute_path": "/home/user/projects/my-app/.venv",
|
|
277
285
|
"name": "my-app/.venv",
|
|
278
286
|
"type": "venv",
|
|
279
|
-
"
|
|
287
|
+
"last_modified": "2025-11-02T14:23:01+00:00",
|
|
280
288
|
"size_bytes": 54393984,
|
|
281
289
|
"size_human": "51.88 MB",
|
|
282
290
|
"managed_by": null,
|
|
@@ -409,8 +417,7 @@ Category is assigned deterministically by applying the following rules in order:
|
|
|
409
417
|
|----------|------|----------|
|
|
410
418
|
| 1 | Orphan (`is_orphan=True`) **and** `age ≥ 180 days` | `HIGH` |
|
|
411
419
|
| 2 | Active git repository **or** `age < 120 days` | `LOW` |
|
|
412
|
-
| 3 | `age ≥ 120 days` | `MEDIUM` |
|
|
413
|
-
| 4 | Fallback | `LOW` |
|
|
420
|
+
| 3 | `age ≥ 120 days` *(exhaustive fallback)* | `MEDIUM` |
|
|
414
421
|
|
|
415
422
|
Age and orphan status are the dominant signals. Size does not affect the category.
|
|
416
423
|
|
|
@@ -490,7 +497,7 @@ Yes. Run `killpy --path ~` to scan your home folder. The `stats` command gives a
|
|
|
490
497
|
killpy delete --type venv --older-than 90 --yes
|
|
491
498
|
```
|
|
492
499
|
|
|
493
|
-
Deletes every `.venv` / `pyvenv.cfg` env not
|
|
500
|
+
Deletes every `.venv` / `pyvenv.cfg` env not modified in the last 90 days, without prompting.
|
|
494
501
|
|
|
495
502
|
**How do I use killpy in a CI pipeline or script?**
|
|
496
503
|
|
|
@@ -596,7 +603,7 @@ ______________________________________________________________________
|
|
|
596
603
|
|
|
597
604
|
```yaml
|
|
598
605
|
- repo: https://github.com/Tlaloc-Es/KillPy
|
|
599
|
-
rev: 0.
|
|
606
|
+
rev: 1.0.0
|
|
600
607
|
hooks:
|
|
601
608
|
- id: killpy # remove __pycache__ on every commit
|
|
602
609
|
- id: killpy-clean-caches # also removes .mypy_cache, .pytest_cache, .ruff_cache
|
|
@@ -621,7 +628,7 @@ Typical minimal setup (safe for daily use):
|
|
|
621
628
|
|
|
622
629
|
```yaml
|
|
623
630
|
- repo: https://github.com/Tlaloc-Es/KillPy
|
|
624
|
-
rev: 0.
|
|
631
|
+
rev: 1.0.0
|
|
625
632
|
hooks:
|
|
626
633
|
- id: killpy
|
|
627
634
|
```
|
|
@@ -167,7 +167,7 @@ Environments flagged with `⚠️` are actively in use (the environment killpy r
|
|
|
167
167
|
| Key | Action |
|
|
168
168
|
|-----|--------|
|
|
169
169
|
| `↑` / `↓` or `k` / `j` | Move cursor up / down (vim-style) |
|
|
170
|
-
| `/` | Open live search/filter bar (
|
|
170
|
+
| `/` | Open live search/filter bar (substring match) |
|
|
171
171
|
| `Escape` | Close search bar and clear filter |
|
|
172
172
|
| `T` | Toggle multi-select mode on / off |
|
|
173
173
|
| `Space` | *(Multi-select)* Toggle current row selected |
|
|
@@ -182,7 +182,7 @@ Environments flagged with `⚠️` are actively in use (the environment killpy r
|
|
|
182
182
|
|
|
183
183
|
### Search / filter
|
|
184
184
|
|
|
185
|
-
Press `/` to open the filter bar at the bottom of the screen. Type any
|
|
185
|
+
Press `/` to open the filter bar at the bottom of the screen. Type any text — the venv table filters by path substring (case-insensitive) and updates live as you type. Press `Escape` or submit an empty value to clear the filter and return to the full list.
|
|
186
186
|
|
|
187
187
|
### Multi-select mode
|
|
188
188
|
|
|
@@ -236,7 +236,7 @@ ______________________________________________________________________
|
|
|
236
236
|
killpy list # list all detected environments
|
|
237
237
|
killpy list --path ~/projects # scan a specific path
|
|
238
238
|
killpy list --type venv --type conda # filter by type (repeatable)
|
|
239
|
-
killpy list --older-than 90 # not
|
|
239
|
+
killpy list --older-than 90 # not modified in the last 90 days
|
|
240
240
|
killpy list --json # output as a JSON array
|
|
241
241
|
killpy list --json-stream # stream as NDJSON — one line per env
|
|
242
242
|
killpy list --quiet # suppress progress output (scripts/CI)
|
|
@@ -255,7 +255,7 @@ The **Path** column mirrors the form of `--path`: a relative `--path` produces r
|
|
|
255
255
|
"absolute_path": "/home/user/projects/my-app/.venv",
|
|
256
256
|
"name": "my-app/.venv",
|
|
257
257
|
"type": "venv",
|
|
258
|
-
"
|
|
258
|
+
"last_modified": "2025-11-02T14:23:01+00:00",
|
|
259
259
|
"size_bytes": 54393984,
|
|
260
260
|
"size_human": "51.88 MB",
|
|
261
261
|
"managed_by": null,
|
|
@@ -388,8 +388,7 @@ Category is assigned deterministically by applying the following rules in order:
|
|
|
388
388
|
|----------|------|----------|
|
|
389
389
|
| 1 | Orphan (`is_orphan=True`) **and** `age ≥ 180 days` | `HIGH` |
|
|
390
390
|
| 2 | Active git repository **or** `age < 120 days` | `LOW` |
|
|
391
|
-
| 3 | `age ≥ 120 days` | `MEDIUM` |
|
|
392
|
-
| 4 | Fallback | `LOW` |
|
|
391
|
+
| 3 | `age ≥ 120 days` *(exhaustive fallback)* | `MEDIUM` |
|
|
393
392
|
|
|
394
393
|
Age and orphan status are the dominant signals. Size does not affect the category.
|
|
395
394
|
|
|
@@ -469,7 +468,7 @@ Yes. Run `killpy --path ~` to scan your home folder. The `stats` command gives a
|
|
|
469
468
|
killpy delete --type venv --older-than 90 --yes
|
|
470
469
|
```
|
|
471
470
|
|
|
472
|
-
Deletes every `.venv` / `pyvenv.cfg` env not
|
|
471
|
+
Deletes every `.venv` / `pyvenv.cfg` env not modified in the last 90 days, without prompting.
|
|
473
472
|
|
|
474
473
|
**How do I use killpy in a CI pipeline or script?**
|
|
475
474
|
|
|
@@ -575,7 +574,7 @@ ______________________________________________________________________
|
|
|
575
574
|
|
|
576
575
|
```yaml
|
|
577
576
|
- repo: https://github.com/Tlaloc-Es/KillPy
|
|
578
|
-
rev: 0.
|
|
577
|
+
rev: 1.0.0
|
|
579
578
|
hooks:
|
|
580
579
|
- id: killpy # remove __pycache__ on every commit
|
|
581
580
|
- id: killpy-clean-caches # also removes .mypy_cache, .pytest_cache, .ruff_cache
|
|
@@ -600,7 +599,7 @@ Typical minimal setup (safe for daily use):
|
|
|
600
599
|
|
|
601
600
|
```yaml
|
|
602
601
|
- repo: https://github.com/Tlaloc-Es/KillPy
|
|
603
|
-
rev: 0.
|
|
602
|
+
rev: 1.0.0
|
|
604
603
|
hooks:
|
|
605
604
|
- id: killpy
|
|
606
605
|
```
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
"""killpy CLI entry point: the ``cli`` click group and its subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
1
5
|
import logging
|
|
2
6
|
import sys
|
|
3
7
|
from pathlib import Path
|
|
@@ -10,7 +14,7 @@ from rich.prompt import Confirm
|
|
|
10
14
|
from killpy.cleaner import Cleaner, CleanerError
|
|
11
15
|
from killpy.cli import TableApp
|
|
12
16
|
from killpy.commands._utils import partition_in_use
|
|
13
|
-
from killpy.commands.clean import
|
|
17
|
+
from killpy.commands.clean import clean_cmd
|
|
14
18
|
from killpy.commands.delete import delete_cmd
|
|
15
19
|
from killpy.commands.doctor import doctor_cmd
|
|
16
20
|
from killpy.commands.find import find_cmd
|
|
@@ -144,7 +148,7 @@ def cli(ctx, path: Path, exclude: str, delete_all: bool, yes: bool, force: bool)
|
|
|
144
148
|
app.run()
|
|
145
149
|
|
|
146
150
|
|
|
147
|
-
cli.add_command(clean)
|
|
151
|
+
cli.add_command(clean_cmd, name="clean")
|
|
148
152
|
cli.add_command(list_cmd, name="list")
|
|
149
153
|
cli.add_command(delete_cmd, name="delete")
|
|
150
154
|
cli.add_command(stats_cmd, name="stats")
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
"""Interactive Textual TUI (``TableApp``) for browsing and deleting environments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
1
5
|
import asyncio
|
|
2
|
-
import re
|
|
3
6
|
import subprocess
|
|
4
7
|
import sys
|
|
5
8
|
from datetime import datetime
|
|
@@ -44,7 +47,7 @@ def _health_text(category: str) -> Text | str:
|
|
|
44
47
|
return ""
|
|
45
48
|
|
|
46
49
|
|
|
47
|
-
def
|
|
50
|
+
def _is_venv_tab(func):
|
|
48
51
|
def wrapper(self, *args, **kwargs):
|
|
49
52
|
if self.query_one(TabbedContent).active == "venv-tab":
|
|
50
53
|
return func(self, *args, **kwargs)
|
|
@@ -52,7 +55,7 @@ def is_venv_tab(func):
|
|
|
52
55
|
return wrapper
|
|
53
56
|
|
|
54
57
|
|
|
55
|
-
def
|
|
58
|
+
def _is_pipx_tab(func):
|
|
56
59
|
def wrapper(self, *args, **kwargs):
|
|
57
60
|
if self.query_one(TabbedContent).active == "pipx-tab":
|
|
58
61
|
return func(self, *args, **kwargs)
|
|
@@ -60,7 +63,7 @@ def is_pipx_tab(func):
|
|
|
60
63
|
return wrapper
|
|
61
64
|
|
|
62
65
|
|
|
63
|
-
def
|
|
66
|
+
def _shorten_path_for_table(path_value, max_parts: int = 2) -> str:
|
|
64
67
|
path_text = str(path_value)
|
|
65
68
|
if "/" not in path_text and "\\" not in path_text:
|
|
66
69
|
return path_text
|
|
@@ -293,7 +296,7 @@ class TableApp(App):
|
|
|
293
296
|
yield Label("", id="status-label")
|
|
294
297
|
yield Label("", id="selected-path-label")
|
|
295
298
|
yield Input(
|
|
296
|
-
placeholder="Filter by path
|
|
299
|
+
placeholder="Filter by path… Esc to clear",
|
|
297
300
|
id="search-input",
|
|
298
301
|
)
|
|
299
302
|
yield Label("", id="multi-select-label")
|
|
@@ -333,7 +336,7 @@ class TableApp(App):
|
|
|
333
336
|
{
|
|
334
337
|
"path": str(environment.path),
|
|
335
338
|
"type": environment.type,
|
|
336
|
-
"last_modified": environment.
|
|
339
|
+
"last_modified": environment.last_modified_str,
|
|
337
340
|
"size": environment.size_bytes,
|
|
338
341
|
"size_human": environment.size_human,
|
|
339
342
|
"health": self._health_by_path.get(str(environment.path), ""),
|
|
@@ -349,7 +352,7 @@ class TableApp(App):
|
|
|
349
352
|
"type"
|
|
350
353
|
]
|
|
351
354
|
table.add_row(
|
|
352
|
-
|
|
355
|
+
_shorten_path_for_table(row["path"]),
|
|
353
356
|
type_label,
|
|
354
357
|
row["last_modified"],
|
|
355
358
|
row["size"],
|
|
@@ -378,20 +381,17 @@ class TableApp(App):
|
|
|
378
381
|
table.clear(columns=True)
|
|
379
382
|
table.add_columns(*self.get_headers_for_table("venv-table"))
|
|
380
383
|
self._venv_display_indices = []
|
|
384
|
+
query = self._filter_query.lower()
|
|
381
385
|
for i, row in enumerate(self.venv_rows):
|
|
382
|
-
if
|
|
383
|
-
|
|
384
|
-
if not re.search(self._filter_query, row["path"], re.IGNORECASE):
|
|
385
|
-
continue
|
|
386
|
-
except re.error:
|
|
387
|
-
pass # invalid regex — show all
|
|
386
|
+
if query and query not in row["path"].lower():
|
|
387
|
+
continue
|
|
388
388
|
self._venv_display_indices.append(i)
|
|
389
389
|
env = row["environment"]
|
|
390
390
|
type_label = ("\u26a0\ufe0f " if env.is_system_critical else "") + row[
|
|
391
391
|
"type"
|
|
392
392
|
]
|
|
393
393
|
table.add_row(
|
|
394
|
-
|
|
394
|
+
_shorten_path_for_table(row["path"]),
|
|
395
395
|
type_label,
|
|
396
396
|
row["last_modified"],
|
|
397
397
|
row["size"],
|
|
@@ -609,12 +609,16 @@ class TableApp(App):
|
|
|
609
609
|
row["health"] = self._health_by_path.get(row["path"], "")
|
|
610
610
|
self.render_venv_table()
|
|
611
611
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
self.bytes_release += total_freed_space
|
|
612
|
+
def _show_bytes_released(self) -> None:
|
|
613
|
+
"""Update the status label with the running total of freed space."""
|
|
615
614
|
self.query_one("#status-label", Label).update(
|
|
616
615
|
f"{format_size(self.bytes_release)} deleted"
|
|
617
616
|
)
|
|
617
|
+
|
|
618
|
+
async def action_clean_pycache(self):
|
|
619
|
+
total_freed_space = await asyncio.to_thread(remove_pycache, self.root_dir)
|
|
620
|
+
self.bytes_release += total_freed_space
|
|
621
|
+
self._show_bytes_released()
|
|
618
622
|
self.bell()
|
|
619
623
|
|
|
620
624
|
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
@@ -633,7 +637,7 @@ class TableApp(App):
|
|
|
633
637
|
_, row = resolved
|
|
634
638
|
selected_path_label.update(f"Selected: {row['path']}")
|
|
635
639
|
|
|
636
|
-
@
|
|
640
|
+
@_is_venv_tab
|
|
637
641
|
def action_confirm_delete(self):
|
|
638
642
|
freed_now = 0
|
|
639
643
|
if self._multi_select_mode and self._selected_venv_paths:
|
|
@@ -662,12 +666,10 @@ class TableApp(App):
|
|
|
662
666
|
|
|
663
667
|
self._record_deletion(freed_now)
|
|
664
668
|
self.render_venv_table()
|
|
665
|
-
self.
|
|
666
|
-
f"{format_size(self.bytes_release)} deleted"
|
|
667
|
-
)
|
|
669
|
+
self._show_bytes_released()
|
|
668
670
|
self.bell()
|
|
669
671
|
|
|
670
|
-
@
|
|
672
|
+
@_is_venv_tab
|
|
671
673
|
def action_mark_for_delete(self):
|
|
672
674
|
table = self.query_one("#venv-table", DataTable)
|
|
673
675
|
|
|
@@ -690,7 +692,7 @@ class TableApp(App):
|
|
|
690
692
|
EnvStatus.MARKED_TO_DELETE.value,
|
|
691
693
|
)
|
|
692
694
|
|
|
693
|
-
@
|
|
695
|
+
@_is_venv_tab
|
|
694
696
|
def action_delete_now(self):
|
|
695
697
|
table = self.query_one("#venv-table", DataTable)
|
|
696
698
|
cursor_cell = table.cursor_coordinate
|
|
@@ -709,9 +711,7 @@ class TableApp(App):
|
|
|
709
711
|
(cursor_cell.row, self.VENV_COL_STATUS),
|
|
710
712
|
EnvStatus.DELETED.value,
|
|
711
713
|
)
|
|
712
|
-
self.
|
|
713
|
-
f"{format_size(self.bytes_release)} deleted"
|
|
714
|
-
)
|
|
714
|
+
self._show_bytes_released()
|
|
715
715
|
self.bell()
|
|
716
716
|
|
|
717
717
|
def delete_environment(self, environment: Environment) -> bool:
|
|
@@ -729,7 +729,7 @@ class TableApp(App):
|
|
|
729
729
|
self.query_one("#status-label", Label).update(str(error))
|
|
730
730
|
return False
|
|
731
731
|
|
|
732
|
-
@
|
|
732
|
+
@_is_pipx_tab
|
|
733
733
|
def action_uninstall_pipx(self):
|
|
734
734
|
table = self.query_one("#pipx-table", DataTable)
|
|
735
735
|
cursor_cell = table.cursor_coordinate
|
|
@@ -742,9 +742,7 @@ class TableApp(App):
|
|
|
742
742
|
table.update_cell_at((cursor_cell.row, 3), EnvStatus.DELETED.value)
|
|
743
743
|
self.bytes_release += int(row["size"])
|
|
744
744
|
self._record_deletion(int(row["size"]))
|
|
745
|
-
self.
|
|
746
|
-
f"{format_size(self.bytes_release)} deleted"
|
|
747
|
-
)
|
|
745
|
+
self._show_bytes_released()
|
|
748
746
|
|
|
749
747
|
self.bell()
|
|
750
748
|
|
|
@@ -772,7 +770,7 @@ class TableApp(App):
|
|
|
772
770
|
tid = "#venv-table" if active == "venv-tab" else "#pipx-table"
|
|
773
771
|
self.query_one(tid, DataTable).action_cursor_up()
|
|
774
772
|
|
|
775
|
-
@
|
|
773
|
+
@_is_venv_tab
|
|
776
774
|
def action_open_folder(self) -> None:
|
|
777
775
|
"""Open the parent directory of the selected environment in the OS file manager.""" # noqa: E501
|
|
778
776
|
table = self.query_one("#venv-table", DataTable)
|
|
@@ -834,7 +832,7 @@ class TableApp(App):
|
|
|
834
832
|
self._update_multi_select_label()
|
|
835
833
|
self.render_venv_table()
|
|
836
834
|
|
|
837
|
-
@
|
|
835
|
+
@_is_venv_tab
|
|
838
836
|
def action_multi_select_toggle_row(self) -> None:
|
|
839
837
|
"""Toggle current row selection in multi-select mode (Space key)."""
|
|
840
838
|
if not self._multi_select_mode:
|
|
@@ -859,7 +857,7 @@ class TableApp(App):
|
|
|
859
857
|
self._compute_row_status(row),
|
|
860
858
|
)
|
|
861
859
|
|
|
862
|
-
@
|
|
860
|
+
@_is_venv_tab
|
|
863
861
|
def action_multi_select_all(self) -> None:
|
|
864
862
|
"""Toggle select-all / deselect-all in multi-select mode (A key)."""
|
|
865
863
|
if not self._multi_select_mode:
|
|
@@ -68,7 +68,7 @@ def filter_envs(
|
|
|
68
68
|
names such as ``"venv"`` and ``"cache"`` are automatically expanded to
|
|
69
69
|
their concrete sub-type values via :data:`_TYPE_ALIASES`.
|
|
70
70
|
older_than:
|
|
71
|
-
If provided, only environments not
|
|
71
|
+
If provided, only environments not modified in the last *older_than* days
|
|
72
72
|
are kept.
|
|
73
73
|
"""
|
|
74
74
|
now = datetime.now(tz=timezone.utc)
|
|
@@ -84,6 +84,6 @@ def filter_envs(
|
|
|
84
84
|
|
|
85
85
|
if older_than is not None:
|
|
86
86
|
cutoff = now - timedelta(days=older_than)
|
|
87
|
-
result = [e for e in result if e.
|
|
87
|
+
result = [e for e in result if e.last_modified < cutoff]
|
|
88
88
|
|
|
89
89
|
return result
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
"""``killpy clean`` – remove ``__pycache__`` directories under a path."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
1
5
|
from pathlib import Path
|
|
2
6
|
|
|
3
7
|
import click
|
|
@@ -6,14 +10,15 @@ from killpy.cleaners import remove_pycache
|
|
|
6
10
|
from killpy.files import format_size
|
|
7
11
|
|
|
8
12
|
|
|
9
|
-
@click.command()
|
|
13
|
+
@click.command("clean")
|
|
10
14
|
@click.option(
|
|
11
15
|
"--path",
|
|
12
16
|
default=Path.cwd,
|
|
13
17
|
type=click.Path(path_type=Path, exists=True, file_okay=False, dir_okay=True),
|
|
14
18
|
help="Path to the directory to clean",
|
|
15
19
|
)
|
|
16
|
-
def
|
|
20
|
+
def clean_cmd(path: Path) -> None:
|
|
21
|
+
"""Remove all ``__pycache__`` directories under PATH and report freed space."""
|
|
17
22
|
click.echo(f"Cleaning {path}…")
|
|
18
23
|
total_freed_space = remove_pycache(path)
|
|
19
24
|
click.echo(f"{format_size(total_freed_space)} freed")
|
|
@@ -37,7 +37,7 @@ from killpy.scanner import Scanner
|
|
|
37
37
|
type=int,
|
|
38
38
|
default=None,
|
|
39
39
|
metavar="DAYS",
|
|
40
|
-
help="Only delete environments not
|
|
40
|
+
help="Only delete environments not modified in the last N days.",
|
|
41
41
|
)
|
|
42
42
|
@click.option(
|
|
43
43
|
"--dry-run",
|
|
@@ -13,12 +13,13 @@ from rich.table import Table
|
|
|
13
13
|
from killpy.detectors import ALL_DETECTORS
|
|
14
14
|
from killpy.files import format_size
|
|
15
15
|
from killpy.intelligence import SuggestionEngine, UsageTracker, score_all
|
|
16
|
-
from killpy.models import Suggestion
|
|
16
|
+
from killpy.models import ScoredEnvironment, Suggestion
|
|
17
17
|
from killpy.scanner import Scanner
|
|
18
18
|
|
|
19
|
-
#
|
|
19
|
+
# HIGH-category environments below this size don't count toward "wasted" totals.
|
|
20
20
|
_IMPACTFUL_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
21
|
-
|
|
21
|
+
# Environments below this size are reported as "ignored (too small to matter)".
|
|
22
|
+
_SMALL_SIZE_BYTES = 1 * 1024 * 1024 # 1 MB
|
|
22
23
|
|
|
23
24
|
_CATEGORY_STYLE = {
|
|
24
25
|
"HIGH": "bold red",
|
|
@@ -78,7 +79,7 @@ def doctor_cmd(path: Path, as_json: bool, show_all: bool) -> None:
|
|
|
78
79
|
suggestions = engine.classify_all(scored_envs)
|
|
79
80
|
|
|
80
81
|
if as_json:
|
|
81
|
-
_output_json(suggestions,
|
|
82
|
+
_output_json(suggestions, scored_envs)
|
|
82
83
|
return
|
|
83
84
|
|
|
84
85
|
_output_rich(console, suggestions, scored_envs, path, show_all=show_all)
|
|
@@ -89,39 +90,47 @@ def doctor_cmd(path: Path, as_json: bool, show_all: bool) -> None:
|
|
|
89
90
|
# ---------------------------------------------------------------------------
|
|
90
91
|
|
|
91
92
|
|
|
92
|
-
def
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
def _summarise(
|
|
94
|
+
suggestions: list[Suggestion],
|
|
95
|
+
scored_envs: list[ScoredEnvironment],
|
|
96
|
+
) -> tuple[int, int, dict[str, int]]:
|
|
97
|
+
"""Return ``(total_size_bytes, wasted_bytes, category_counts)``.
|
|
98
|
+
|
|
99
|
+
*wasted* sums the sizes of HIGH-category environments at or above
|
|
100
|
+
:data:`_IMPACTFUL_SIZE_BYTES`; *category_counts* maps each category to the
|
|
101
|
+
number of suggestions in it. Computed once and shared by both output paths.
|
|
102
|
+
"""
|
|
103
|
+
total_size = sum(se.env.size_bytes for se in scored_envs)
|
|
104
|
+
high_paths = {s.env_path for s in suggestions if s.category == "HIGH"}
|
|
96
105
|
wasted = sum(
|
|
97
|
-
|
|
98
|
-
for
|
|
99
|
-
if
|
|
100
|
-
|
|
106
|
+
se.env.size_bytes
|
|
107
|
+
for se in scored_envs
|
|
108
|
+
if se.env.path in high_paths and se.env.size_bytes >= _IMPACTFUL_SIZE_BYTES
|
|
109
|
+
)
|
|
110
|
+
counts = {
|
|
111
|
+
"HIGH": sum(1 for s in suggestions if s.category == "HIGH"),
|
|
112
|
+
"MEDIUM": sum(1 for s in suggestions if s.category == "MEDIUM"),
|
|
113
|
+
"LOW": sum(1 for s in suggestions if s.category == "LOW"),
|
|
114
|
+
}
|
|
115
|
+
return total_size, wasted, counts
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _output_json(
|
|
119
|
+
suggestions: list[Suggestion], scored_envs: list[ScoredEnvironment]
|
|
120
|
+
) -> None:
|
|
121
|
+
total_size, wasted, counts = _summarise(suggestions, scored_envs)
|
|
122
|
+
ignored_small = sum(
|
|
123
|
+
1 for se in scored_envs if se.env.size_bytes < _SMALL_SIZE_BYTES
|
|
101
124
|
)
|
|
102
|
-
ignored_small = sum(1 for e in envs if e.size_bytes < _SMALL_SIZE_BYTES)
|
|
103
125
|
data = {
|
|
104
|
-
"total_environments": len(
|
|
126
|
+
"total_environments": len(scored_envs),
|
|
105
127
|
"total_size_bytes": total_size,
|
|
106
128
|
"total_size_human": format_size(total_size),
|
|
107
129
|
"wasted_size_bytes": wasted,
|
|
108
130
|
"wasted_size_human": format_size(wasted),
|
|
109
131
|
"ignored_small_envs_count": ignored_small,
|
|
110
|
-
"counts":
|
|
111
|
-
|
|
112
|
-
"MEDIUM": sum(1 for s in suggestions if s.category == "MEDIUM"),
|
|
113
|
-
"LOW": sum(1 for s in suggestions if s.category == "LOW"),
|
|
114
|
-
},
|
|
115
|
-
"suggestions": [
|
|
116
|
-
{
|
|
117
|
-
"env_path": str(s.env_path),
|
|
118
|
-
"score": s.score,
|
|
119
|
-
"category": s.category,
|
|
120
|
-
"reasons": s.reasons,
|
|
121
|
-
"recommended_action": s.recommended_action,
|
|
122
|
-
}
|
|
123
|
-
for s in suggestions
|
|
124
|
-
],
|
|
132
|
+
"counts": counts,
|
|
133
|
+
"suggestions": [s.to_dict() for s in suggestions],
|
|
125
134
|
}
|
|
126
135
|
click.echo(json.dumps(data, indent=2))
|
|
127
136
|
|
|
@@ -129,19 +138,12 @@ def _output_json(suggestions: list[Suggestion], envs) -> None: # type: ignore[t
|
|
|
129
138
|
def _output_rich(
|
|
130
139
|
console: Console,
|
|
131
140
|
suggestions: list[Suggestion],
|
|
132
|
-
scored_envs
|
|
141
|
+
scored_envs: list[ScoredEnvironment],
|
|
133
142
|
path: Path,
|
|
134
143
|
*,
|
|
135
144
|
show_all: bool = False,
|
|
136
145
|
) -> None:
|
|
137
|
-
total_size =
|
|
138
|
-
high_suggestions = [s for s in suggestions if s.category == "HIGH"]
|
|
139
|
-
high_paths = {s.env_path for s in high_suggestions}
|
|
140
|
-
wasted = sum(
|
|
141
|
-
se.env.size_bytes
|
|
142
|
-
for se in scored_envs
|
|
143
|
-
if se.env.path in high_paths and se.env.size_bytes >= _IMPACTFUL_SIZE_BYTES
|
|
144
|
-
)
|
|
146
|
+
total_size, wasted, counts = _summarise(suggestions, scored_envs)
|
|
145
147
|
|
|
146
148
|
console.print()
|
|
147
149
|
console.rule("[bold cyan]Environment Health Report[/bold cyan]")
|
|
@@ -153,11 +155,6 @@ def _output_rich(
|
|
|
153
155
|
)
|
|
154
156
|
|
|
155
157
|
# Category summary
|
|
156
|
-
counts = {
|
|
157
|
-
"HIGH": sum(1 for s in suggestions if s.category == "HIGH"),
|
|
158
|
-
"MEDIUM": sum(1 for s in suggestions if s.category == "MEDIUM"),
|
|
159
|
-
"LOW": sum(1 for s in suggestions if s.category == "LOW"),
|
|
160
|
-
}
|
|
161
158
|
console.print(
|
|
162
159
|
f" [bold red]HIGH[/bold red] (safe to delete): {counts['HIGH']} "
|
|
163
160
|
f"[bold yellow]MEDIUM[/bold yellow] (review): {counts['MEDIUM']} "
|
|
@@ -198,7 +195,7 @@ def _output_rich(
|
|
|
198
195
|
|
|
199
196
|
now = datetime.now(tz=timezone.utc)
|
|
200
197
|
for se in top:
|
|
201
|
-
la = se.env.
|
|
198
|
+
la = se.env.last_modified
|
|
202
199
|
if la.tzinfo is None:
|
|
203
200
|
la = la.replace(tzinfo=timezone.utc)
|
|
204
201
|
age_days = (now - la).days
|
|
@@ -267,7 +264,7 @@ def _print_category_table(
|
|
|
267
264
|
|
|
268
265
|
now = datetime.now(tz=timezone.utc)
|
|
269
266
|
for se in scored_envs:
|
|
270
|
-
la = se.env.
|
|
267
|
+
la = se.env.last_modified
|
|
271
268
|
if la.tzinfo is None:
|
|
272
269
|
la = la.replace(tzinfo=timezone.utc)
|
|
273
270
|
age_days = (now - la).days
|
|
@@ -11,6 +11,7 @@ from rich.table import Table
|
|
|
11
11
|
|
|
12
12
|
from killpy.commands._utils import filter_envs
|
|
13
13
|
from killpy.files import format_size
|
|
14
|
+
from killpy.models import Environment
|
|
14
15
|
from killpy.scanner import Scanner
|
|
15
16
|
|
|
16
17
|
|
|
@@ -38,7 +39,7 @@ def _run_json_stream(
|
|
|
38
39
|
|
|
39
40
|
def _scan_with_progress(
|
|
40
41
|
scanner: Scanner, path: Path, quiet: bool, stderr_console: Console
|
|
41
|
-
):
|
|
42
|
+
) -> list[Environment]:
|
|
42
43
|
if quiet:
|
|
43
44
|
return scanner.scan(path)
|
|
44
45
|
|
|
@@ -57,7 +58,7 @@ def _print_table(envs: list, console: Console) -> None:
|
|
|
57
58
|
table = Table(show_header=True, header_style="bold cyan")
|
|
58
59
|
table.add_column("Type", style="dim", min_width=10)
|
|
59
60
|
table.add_column("Name", min_width=20)
|
|
60
|
-
table.add_column("Last
|
|
61
|
+
table.add_column("Last modified", min_width=12)
|
|
61
62
|
table.add_column("Size", justify="right", min_width=9)
|
|
62
63
|
table.add_column("Path")
|
|
63
64
|
|
|
@@ -65,7 +66,7 @@ def _print_table(envs: list, console: Console) -> None:
|
|
|
65
66
|
table.add_row(
|
|
66
67
|
env.type,
|
|
67
68
|
env.name,
|
|
68
|
-
env.
|
|
69
|
+
env.last_modified_str,
|
|
69
70
|
env.size_human,
|
|
70
71
|
str(env.path),
|
|
71
72
|
)
|
|
@@ -100,7 +101,7 @@ def _print_table(envs: list, console: Console) -> None:
|
|
|
100
101
|
type=int,
|
|
101
102
|
default=None,
|
|
102
103
|
metavar="DAYS",
|
|
103
|
-
help="Only show environments not
|
|
104
|
+
help="Only show environments not modified in the last N days.",
|
|
104
105
|
)
|
|
105
106
|
@click.option(
|
|
106
107
|
"--json-stream",
|