killpy 0.25.1__tar.gz → 1.0.1__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.1 → killpy-1.0.1}/PKG-INFO +10 -11
- {killpy-0.25.1 → killpy-1.0.1}/README.md +8 -9
- {killpy-0.25.1 → killpy-1.0.1}/killpy/cli.py +5 -9
- {killpy-0.25.1 → killpy-1.0.1}/killpy/commands/_utils.py +2 -2
- {killpy-0.25.1 → killpy-1.0.1}/killpy/commands/delete.py +1 -1
- {killpy-0.25.1 → killpy-1.0.1}/killpy/commands/doctor.py +2 -2
- {killpy-0.25.1 → killpy-1.0.1}/killpy/commands/list.py +3 -3
- killpy-1.0.1/killpy/detectors/_shared_walk.py +110 -0
- {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/artifacts.py +2 -1
- {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/base.py +14 -0
- {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/cache.py +6 -1
- {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/conda.py +1 -1
- {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/hatch.py +1 -1
- {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/pipenv.py +1 -1
- {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/pipx.py +1 -1
- {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/poetry.py +1 -1
- {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/pyenv.py +1 -1
- {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/tox.py +2 -1
- {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/uv.py +1 -1
- {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/venv.py +2 -1
- {killpy-0.25.1 → killpy-1.0.1}/killpy/intelligence/scoring.py +4 -4
- {killpy-0.25.1 → killpy-1.0.1}/killpy/intelligence/suggestions.py +21 -25
- {killpy-0.25.1 → killpy-1.0.1}/killpy/models.py +7 -6
- {killpy-0.25.1 → killpy-1.0.1}/killpy/scanner.py +69 -22
- {killpy-0.25.1 → killpy-1.0.1}/killpy.egg-info/PKG-INFO +10 -11
- {killpy-0.25.1 → killpy-1.0.1}/killpy.egg-info/SOURCES.txt +1 -0
- {killpy-0.25.1 → killpy-1.0.1}/pyproject.toml +9 -2
- {killpy-0.25.1 → killpy-1.0.1}/killpy/__init__.py +0 -0
- {killpy-0.25.1 → killpy-1.0.1}/killpy/__main__.py +0 -0
- {killpy-0.25.1 → killpy-1.0.1}/killpy/cleaner.py +0 -0
- {killpy-0.25.1 → killpy-1.0.1}/killpy/cleaners/__init__.py +0 -0
- {killpy-0.25.1 → killpy-1.0.1}/killpy/commands/__init__.py +0 -0
- {killpy-0.25.1 → killpy-1.0.1}/killpy/commands/clean.py +0 -0
- {killpy-0.25.1 → killpy-1.0.1}/killpy/commands/find.py +0 -0
- {killpy-0.25.1 → killpy-1.0.1}/killpy/commands/stats.py +0 -0
- {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/__init__.py +0 -0
- {killpy-0.25.1 → killpy-1.0.1}/killpy/files/__init__.py +0 -0
- {killpy-0.25.1 → killpy-1.0.1}/killpy/intelligence/__init__.py +0 -0
- {killpy-0.25.1 → killpy-1.0.1}/killpy/intelligence/git_analyzer.py +0 -0
- {killpy-0.25.1 → killpy-1.0.1}/killpy/intelligence/tracker.py +0 -0
- {killpy-0.25.1 → killpy-1.0.1}/killpy.egg-info/dependency_links.txt +0 -0
- {killpy-0.25.1 → killpy-1.0.1}/killpy.egg-info/entry_points.txt +0 -0
- {killpy-0.25.1 → killpy-1.0.1}/killpy.egg-info/requires.txt +0 -0
- {killpy-0.25.1 → killpy-1.0.1}/killpy.egg-info/top_level.txt +0 -0
- {killpy-0.25.1 → killpy-1.0.1}/setup.cfg +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: killpy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.1
|
|
4
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
9
|
Keywords: venv,virtualenv,conda,poetry,pipenv,pipx,pyenv,uv,cleanup,disk-space,cache,cli,tui,textual,developer-tools
|
|
10
|
-
Classifier: Development Status ::
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
11
|
Classifier: Environment :: Console
|
|
12
12
|
Classifier: Environment :: Console :: Curses
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
@@ -196,7 +196,7 @@ Environments flagged with `⚠️` are actively in use (the environment killpy r
|
|
|
196
196
|
| Key | Action |
|
|
197
197
|
|-----|--------|
|
|
198
198
|
| `↑` / `↓` or `k` / `j` | Move cursor up / down (vim-style) |
|
|
199
|
-
| `/` | Open live search/filter bar (
|
|
199
|
+
| `/` | Open live search/filter bar (substring match) |
|
|
200
200
|
| `Escape` | Close search bar and clear filter |
|
|
201
201
|
| `T` | Toggle multi-select mode on / off |
|
|
202
202
|
| `Space` | *(Multi-select)* Toggle current row selected |
|
|
@@ -211,7 +211,7 @@ Environments flagged with `⚠️` are actively in use (the environment killpy r
|
|
|
211
211
|
|
|
212
212
|
### Search / filter
|
|
213
213
|
|
|
214
|
-
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.
|
|
215
215
|
|
|
216
216
|
### Multi-select mode
|
|
217
217
|
|
|
@@ -265,7 +265,7 @@ ______________________________________________________________________
|
|
|
265
265
|
killpy list # list all detected environments
|
|
266
266
|
killpy list --path ~/projects # scan a specific path
|
|
267
267
|
killpy list --type venv --type conda # filter by type (repeatable)
|
|
268
|
-
killpy list --older-than 90 # not
|
|
268
|
+
killpy list --older-than 90 # not modified in the last 90 days
|
|
269
269
|
killpy list --json # output as a JSON array
|
|
270
270
|
killpy list --json-stream # stream as NDJSON — one line per env
|
|
271
271
|
killpy list --quiet # suppress progress output (scripts/CI)
|
|
@@ -284,7 +284,7 @@ The **Path** column mirrors the form of `--path`: a relative `--path` produces r
|
|
|
284
284
|
"absolute_path": "/home/user/projects/my-app/.venv",
|
|
285
285
|
"name": "my-app/.venv",
|
|
286
286
|
"type": "venv",
|
|
287
|
-
"
|
|
287
|
+
"last_modified": "2025-11-02T14:23:01+00:00",
|
|
288
288
|
"size_bytes": 54393984,
|
|
289
289
|
"size_human": "51.88 MB",
|
|
290
290
|
"managed_by": null,
|
|
@@ -417,8 +417,7 @@ Category is assigned deterministically by applying the following rules in order:
|
|
|
417
417
|
|----------|------|----------|
|
|
418
418
|
| 1 | Orphan (`is_orphan=True`) **and** `age ≥ 180 days` | `HIGH` |
|
|
419
419
|
| 2 | Active git repository **or** `age < 120 days` | `LOW` |
|
|
420
|
-
| 3 | `age ≥ 120 days` | `MEDIUM` |
|
|
421
|
-
| 4 | Fallback | `LOW` |
|
|
420
|
+
| 3 | `age ≥ 120 days` *(exhaustive fallback)* | `MEDIUM` |
|
|
422
421
|
|
|
423
422
|
Age and orphan status are the dominant signals. Size does not affect the category.
|
|
424
423
|
|
|
@@ -498,7 +497,7 @@ Yes. Run `killpy --path ~` to scan your home folder. The `stats` command gives a
|
|
|
498
497
|
killpy delete --type venv --older-than 90 --yes
|
|
499
498
|
```
|
|
500
499
|
|
|
501
|
-
Deletes every `.venv` / `pyvenv.cfg` env not
|
|
500
|
+
Deletes every `.venv` / `pyvenv.cfg` env not modified in the last 90 days, without prompting.
|
|
502
501
|
|
|
503
502
|
**How do I use killpy in a CI pipeline or script?**
|
|
504
503
|
|
|
@@ -604,7 +603,7 @@ ______________________________________________________________________
|
|
|
604
603
|
|
|
605
604
|
```yaml
|
|
606
605
|
- repo: https://github.com/Tlaloc-Es/KillPy
|
|
607
|
-
rev: 0.
|
|
606
|
+
rev: 1.0.1
|
|
608
607
|
hooks:
|
|
609
608
|
- id: killpy # remove __pycache__ on every commit
|
|
610
609
|
- id: killpy-clean-caches # also removes .mypy_cache, .pytest_cache, .ruff_cache
|
|
@@ -629,7 +628,7 @@ Typical minimal setup (safe for daily use):
|
|
|
629
628
|
|
|
630
629
|
```yaml
|
|
631
630
|
- repo: https://github.com/Tlaloc-Es/KillPy
|
|
632
|
-
rev: 0.
|
|
631
|
+
rev: 1.0.1
|
|
633
632
|
hooks:
|
|
634
633
|
- id: killpy
|
|
635
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.1
|
|
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.1
|
|
604
603
|
hooks:
|
|
605
604
|
- id: killpy
|
|
606
605
|
```
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
-
import re
|
|
7
6
|
import subprocess
|
|
8
7
|
import sys
|
|
9
8
|
from datetime import datetime
|
|
@@ -297,7 +296,7 @@ class TableApp(App):
|
|
|
297
296
|
yield Label("", id="status-label")
|
|
298
297
|
yield Label("", id="selected-path-label")
|
|
299
298
|
yield Input(
|
|
300
|
-
placeholder="Filter by path
|
|
299
|
+
placeholder="Filter by path… Esc to clear",
|
|
301
300
|
id="search-input",
|
|
302
301
|
)
|
|
303
302
|
yield Label("", id="multi-select-label")
|
|
@@ -337,7 +336,7 @@ class TableApp(App):
|
|
|
337
336
|
{
|
|
338
337
|
"path": str(environment.path),
|
|
339
338
|
"type": environment.type,
|
|
340
|
-
"last_modified": environment.
|
|
339
|
+
"last_modified": environment.last_modified_str,
|
|
341
340
|
"size": environment.size_bytes,
|
|
342
341
|
"size_human": environment.size_human,
|
|
343
342
|
"health": self._health_by_path.get(str(environment.path), ""),
|
|
@@ -382,13 +381,10 @@ class TableApp(App):
|
|
|
382
381
|
table.clear(columns=True)
|
|
383
382
|
table.add_columns(*self.get_headers_for_table("venv-table"))
|
|
384
383
|
self._venv_display_indices = []
|
|
384
|
+
query = self._filter_query.lower()
|
|
385
385
|
for i, row in enumerate(self.venv_rows):
|
|
386
|
-
if
|
|
387
|
-
|
|
388
|
-
if not re.search(self._filter_query, row["path"], re.IGNORECASE):
|
|
389
|
-
continue
|
|
390
|
-
except re.error:
|
|
391
|
-
pass # invalid regex — show all
|
|
386
|
+
if query and query not in row["path"].lower():
|
|
387
|
+
continue
|
|
392
388
|
self._venv_display_indices.append(i)
|
|
393
389
|
env = row["environment"]
|
|
394
390
|
type_label = ("\u26a0\ufe0f " if env.is_system_critical else "") + row[
|
|
@@ -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
|
|
@@ -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",
|
|
@@ -195,7 +195,7 @@ def _output_rich(
|
|
|
195
195
|
|
|
196
196
|
now = datetime.now(tz=timezone.utc)
|
|
197
197
|
for se in top:
|
|
198
|
-
la = se.env.
|
|
198
|
+
la = se.env.last_modified
|
|
199
199
|
if la.tzinfo is None:
|
|
200
200
|
la = la.replace(tzinfo=timezone.utc)
|
|
201
201
|
age_days = (now - la).days
|
|
@@ -264,7 +264,7 @@ def _print_category_table(
|
|
|
264
264
|
|
|
265
265
|
now = datetime.now(tz=timezone.utc)
|
|
266
266
|
for se in scored_envs:
|
|
267
|
-
la = se.env.
|
|
267
|
+
la = se.env.last_modified
|
|
268
268
|
if la.tzinfo is None:
|
|
269
269
|
la = la.replace(tzinfo=timezone.utc)
|
|
270
270
|
age_days = (now - la).days
|
|
@@ -58,7 +58,7 @@ def _print_table(envs: list, console: Console) -> None:
|
|
|
58
58
|
table = Table(show_header=True, header_style="bold cyan")
|
|
59
59
|
table.add_column("Type", style="dim", min_width=10)
|
|
60
60
|
table.add_column("Name", min_width=20)
|
|
61
|
-
table.add_column("Last
|
|
61
|
+
table.add_column("Last modified", min_width=12)
|
|
62
62
|
table.add_column("Size", justify="right", min_width=9)
|
|
63
63
|
table.add_column("Path")
|
|
64
64
|
|
|
@@ -66,7 +66,7 @@ def _print_table(envs: list, console: Console) -> None:
|
|
|
66
66
|
table.add_row(
|
|
67
67
|
env.type,
|
|
68
68
|
env.name,
|
|
69
|
-
env.
|
|
69
|
+
env.last_modified_str,
|
|
70
70
|
env.size_human,
|
|
71
71
|
str(env.path),
|
|
72
72
|
)
|
|
@@ -101,7 +101,7 @@ def _print_table(envs: list, console: Console) -> None:
|
|
|
101
101
|
type=int,
|
|
102
102
|
default=None,
|
|
103
103
|
metavar="DAYS",
|
|
104
|
-
help="Only show environments not
|
|
104
|
+
help="Only show environments not modified in the last N days.",
|
|
105
105
|
)
|
|
106
106
|
@click.option(
|
|
107
107
|
"--json-stream",
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Single shared filesystem walk for the tree-walking detectors.
|
|
2
|
+
|
|
3
|
+
``venv`` / ``cache`` / ``artifacts`` / ``tox`` each used to run their own
|
|
4
|
+
``os.walk`` over the scan root, and ``get_total_size`` re-walked every
|
|
5
|
+
environment they found — walking a large tree several times over.
|
|
6
|
+
|
|
7
|
+
This module performs **one** traversal: each directory is classified as an
|
|
8
|
+
environment *container* (a venv, a cache dir, a build artifact, or ``.tox``);
|
|
9
|
+
a match is recorded, its size is summed once, and its subtree is pruned.
|
|
10
|
+
Environment pruning is always applied — once a venv is found, nothing inside it
|
|
11
|
+
is scanned again — which also collapses the cache/artifact double-counting that
|
|
12
|
+
used to happen inside environments.
|
|
13
|
+
|
|
14
|
+
Callers pass the set of detector names whose results they want (``active``).
|
|
15
|
+
Pruning happens on every container regardless of ``active``, so asking for a
|
|
16
|
+
subset yields exactly what running those detectors alone would.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from killpy.detectors.base import VCS_PRUNE_DIRS
|
|
27
|
+
from killpy.files import get_total_size
|
|
28
|
+
from killpy.models import Environment
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
_CACHE_DIRS = frozenset({"__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache"})
|
|
33
|
+
_ARTIFACT_EXACT = frozenset({"dist", "build"})
|
|
34
|
+
_ARTIFACT_SUFFIXES = (".egg-info", ".dist-info")
|
|
35
|
+
|
|
36
|
+
#: Map every ``Environment.type`` the shared walk (and the cache global scan)
|
|
37
|
+
#: can produce back to the detector that owns it — used to group results per
|
|
38
|
+
#: detector for progress callbacks.
|
|
39
|
+
TYPE_TO_DETECTOR: dict[str, str] = {
|
|
40
|
+
".venv": "venv",
|
|
41
|
+
"pyvenv.cfg": "venv",
|
|
42
|
+
"__pycache__": "cache",
|
|
43
|
+
".mypy_cache": "cache",
|
|
44
|
+
".pytest_cache": "cache",
|
|
45
|
+
".ruff_cache": "cache",
|
|
46
|
+
"pip-cache": "cache",
|
|
47
|
+
"uv-cache": "cache",
|
|
48
|
+
"artifacts": "artifacts",
|
|
49
|
+
"tox": "tox",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _classify(basename: str, filenames: list[str]) -> tuple[str, str] | None:
|
|
54
|
+
"""Return ``(detector_name, env_type)`` if the dir is a container, else ``None``."""
|
|
55
|
+
if basename == ".venv" or "pyvenv.cfg" in filenames:
|
|
56
|
+
return ("venv", ".venv" if basename == ".venv" else "pyvenv.cfg")
|
|
57
|
+
if basename in _CACHE_DIRS:
|
|
58
|
+
return ("cache", basename)
|
|
59
|
+
if basename in _ARTIFACT_EXACT or any(
|
|
60
|
+
basename.endswith(suffix) for suffix in _ARTIFACT_SUFFIXES
|
|
61
|
+
):
|
|
62
|
+
return ("artifacts", "artifacts")
|
|
63
|
+
if basename == ".tox":
|
|
64
|
+
return ("tox", "tox")
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _make_env(path: Path, env_type: str) -> Environment | None:
|
|
69
|
+
"""Build an :class:`Environment` for *path*, summing its size once."""
|
|
70
|
+
try:
|
|
71
|
+
stat = path.stat()
|
|
72
|
+
return Environment(
|
|
73
|
+
path=path,
|
|
74
|
+
name=str(path),
|
|
75
|
+
type=env_type,
|
|
76
|
+
last_modified=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
|
77
|
+
size_bytes=get_total_size(path),
|
|
78
|
+
)
|
|
79
|
+
except (FileNotFoundError, OSError) as exc:
|
|
80
|
+
logger.debug("Skipping %s: %s", path, exc)
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def walk_environments(root: Path, active: set[str]) -> list[Environment]:
|
|
85
|
+
"""Walk *root* once and return environments for the ``active`` detector set.
|
|
86
|
+
|
|
87
|
+
Each directory that matches a container type is reported (when its detector
|
|
88
|
+
is in *active*) and its subtree pruned; VCS and ``site-packages`` trees are
|
|
89
|
+
never descended into.
|
|
90
|
+
"""
|
|
91
|
+
envs: list[Environment] = []
|
|
92
|
+
for current, dirnames, filenames in os.walk(root, topdown=True):
|
|
93
|
+
dirnames[:] = [d for d in dirnames if d not in VCS_PRUNE_DIRS]
|
|
94
|
+
current_path = Path(current)
|
|
95
|
+
if current_path != root:
|
|
96
|
+
match = _classify(current_path.name, filenames)
|
|
97
|
+
if match is not None:
|
|
98
|
+
detector_name, env_type = match
|
|
99
|
+
if detector_name in active:
|
|
100
|
+
env = _make_env(current_path, env_type)
|
|
101
|
+
if env is not None:
|
|
102
|
+
envs.append(env)
|
|
103
|
+
dirnames[:] = [] # env-pruning: never descend into a container
|
|
104
|
+
continue
|
|
105
|
+
# A bare ``site-packages`` (e.g. a conda env, which has no pyvenv.cfg) is
|
|
106
|
+
# not a container but must not be scanned for caches/artifacts. ``.venv``
|
|
107
|
+
# IS a container — detected on entry — so it is deliberately not pruned
|
|
108
|
+
# here (that would stop us from ever reporting it).
|
|
109
|
+
dirnames[:] = [d for d in dirnames if d != "site-packages"]
|
|
110
|
+
return envs
|
|
@@ -42,6 +42,7 @@ class ArtifactsDetector(AbstractDetector):
|
|
|
42
42
|
|
|
43
43
|
name = "artifacts"
|
|
44
44
|
always_available = True # pure filesystem walk
|
|
45
|
+
shared_walk = True # tree scan served by killpy.detectors._shared_walk
|
|
45
46
|
|
|
46
47
|
def detect(self, path: Path) -> list[Environment]:
|
|
47
48
|
envs: list[Environment] = []
|
|
@@ -69,7 +70,7 @@ class ArtifactsDetector(AbstractDetector):
|
|
|
69
70
|
path=artifact_path,
|
|
70
71
|
name=str(artifact_path),
|
|
71
72
|
type="artifacts",
|
|
72
|
-
|
|
73
|
+
last_modified=mtime,
|
|
73
74
|
size_bytes=size,
|
|
74
75
|
)
|
|
75
76
|
)
|
|
@@ -62,6 +62,11 @@ class AbstractDetector(ABC):
|
|
|
62
62
|
required_tool: ClassVar[str | None] = None
|
|
63
63
|
#: ``True`` for detectors that always apply (pure filesystem walks).
|
|
64
64
|
always_available: ClassVar[bool] = False
|
|
65
|
+
#: ``True`` when this detector's tree scan is served by the single shared
|
|
66
|
+
#: walk (``killpy.detectors._shared_walk``) instead of its own ``os.walk``.
|
|
67
|
+
#: The :class:`~killpy.scanner.Scanner` batches all such detectors into one
|
|
68
|
+
#: traversal; ``detect`` is kept for direct use and tests.
|
|
69
|
+
shared_walk: ClassVar[bool] = False
|
|
65
70
|
|
|
66
71
|
@abstractmethod
|
|
67
72
|
def detect(self, path: Path) -> list[Environment]:
|
|
@@ -78,6 +83,15 @@ class AbstractDetector(ABC):
|
|
|
78
83
|
use it as the walk root.
|
|
79
84
|
"""
|
|
80
85
|
|
|
86
|
+
def scan_global(self, path: Path) -> list[Environment]:
|
|
87
|
+
"""Environments found outside the shared tree walk (e.g. global caches).
|
|
88
|
+
|
|
89
|
+
Empty by default; :class:`~killpy.detectors.cache.CacheDetector` overrides
|
|
90
|
+
it to report the global pip/uv caches. Called by the Scanner only for
|
|
91
|
+
detectors with ``shared_walk = True``.
|
|
92
|
+
"""
|
|
93
|
+
return []
|
|
94
|
+
|
|
81
95
|
def _candidate_dirs(self) -> tuple[Path, ...]:
|
|
82
96
|
"""Directories whose existence makes this detector applicable.
|
|
83
97
|
|
|
@@ -72,6 +72,7 @@ class CacheDetector(AbstractDetector):
|
|
|
72
72
|
|
|
73
73
|
name = "cache"
|
|
74
74
|
always_available = True # pure filesystem walk
|
|
75
|
+
shared_walk = True # local tree scan served by killpy.detectors._shared_walk
|
|
75
76
|
|
|
76
77
|
def detect(self, path: Path) -> list[Environment]:
|
|
77
78
|
envs: list[Environment] = []
|
|
@@ -79,6 +80,10 @@ class CacheDetector(AbstractDetector):
|
|
|
79
80
|
envs.extend(self._scan_global(path))
|
|
80
81
|
return envs
|
|
81
82
|
|
|
83
|
+
def scan_global(self, path: Path) -> list[Environment]:
|
|
84
|
+
"""Global pip/uv caches — the part not covered by the shared tree walk."""
|
|
85
|
+
return self._scan_global(path)
|
|
86
|
+
|
|
82
87
|
# ------------------------------------------------------------------ #
|
|
83
88
|
|
|
84
89
|
def _scan_local(self, root: Path) -> list[Environment]:
|
|
@@ -147,6 +152,6 @@ def _make_cache_env(cache_path: Path, tag: str) -> Environment:
|
|
|
147
152
|
path=cache_path,
|
|
148
153
|
name=str(cache_path),
|
|
149
154
|
type=tag,
|
|
150
|
-
|
|
155
|
+
last_modified=mtime,
|
|
151
156
|
size_bytes=size,
|
|
152
157
|
)
|
|
@@ -19,6 +19,7 @@ class ToxDetector(AbstractDetector):
|
|
|
19
19
|
|
|
20
20
|
name = "tox"
|
|
21
21
|
always_available = True # pure filesystem walk
|
|
22
|
+
shared_walk = True # tree scan served by killpy.detectors._shared_walk
|
|
22
23
|
|
|
23
24
|
def detect(self, path: Path) -> list[Environment]:
|
|
24
25
|
envs: list[Environment] = []
|
|
@@ -42,7 +43,7 @@ class ToxDetector(AbstractDetector):
|
|
|
42
43
|
path=tox_path,
|
|
43
44
|
name=str(tox_path),
|
|
44
45
|
type="tox",
|
|
45
|
-
|
|
46
|
+
last_modified=mtime,
|
|
46
47
|
size_bytes=size,
|
|
47
48
|
)
|
|
48
49
|
)
|
|
@@ -57,6 +57,7 @@ class VenvDetector(AbstractDetector):
|
|
|
57
57
|
|
|
58
58
|
name = "venv"
|
|
59
59
|
always_available = True # pure filesystem walk
|
|
60
|
+
shared_walk = True # tree scan served by killpy.detectors._shared_walk
|
|
60
61
|
|
|
61
62
|
def detect(self, path: Path) -> list[Environment]:
|
|
62
63
|
# Inner dedup: the ".venv" scan and the pyvenv.cfg scan below can both
|
|
@@ -105,6 +106,6 @@ def _make_env(dir_path: Path, tag: str) -> Environment:
|
|
|
105
106
|
path=dir_path,
|
|
106
107
|
name=str(dir_path),
|
|
107
108
|
type=tag,
|
|
108
|
-
|
|
109
|
+
last_modified=mtime,
|
|
109
110
|
size_bytes=size,
|
|
110
111
|
)
|
|
@@ -69,8 +69,8 @@ class ScoringService:
|
|
|
69
69
|
size_score = self._normalize_size(env.size_bytes)
|
|
70
70
|
explanation.append(f"Size: {env.size_human}")
|
|
71
71
|
|
|
72
|
-
age_score, age_days = self._normalize_age(env.
|
|
73
|
-
explanation.append(f"Last
|
|
72
|
+
age_score, age_days = self._normalize_age(env.last_modified)
|
|
73
|
+
explanation.append(f"Last modified {age_days} days ago")
|
|
74
74
|
|
|
75
75
|
is_orphan, orphan_score = self._orphan_score(env.path)
|
|
76
76
|
has_project_files = not is_orphan
|
|
@@ -132,11 +132,11 @@ class ScoringService:
|
|
|
132
132
|
return 1.0 / (1.0 + math.exp(-k * (size_bytes - x0)))
|
|
133
133
|
|
|
134
134
|
@staticmethod
|
|
135
|
-
def _normalize_age(
|
|
135
|
+
def _normalize_age(last_modified: datetime) -> tuple[float, int]:
|
|
136
136
|
"""Return (age_score, age_days) where age_score is linear [0, 1]."""
|
|
137
137
|
now = datetime.now(tz=timezone.utc)
|
|
138
138
|
try:
|
|
139
|
-
la =
|
|
139
|
+
la = last_modified
|
|
140
140
|
if la.tzinfo is None:
|
|
141
141
|
la = la.replace(tzinfo=timezone.utc)
|
|
142
142
|
age_days = max(0, (now - la).days)
|
|
@@ -9,8 +9,9 @@ from killpy.models import ScoredEnvironment, Suggestion
|
|
|
9
9
|
|
|
10
10
|
# Age thresholds in days.
|
|
11
11
|
_HIGH_AGE_ORPHAN_THRESHOLD = 180 # orphan + age >= this → HIGH
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
# Single LOW/MEDIUM cutoff: age < this → LOW (recently modified), age >= this →
|
|
13
|
+
# MEDIUM (stale). Active git always forces LOW regardless of age.
|
|
14
|
+
_STALE_AGE_THRESHOLD = 120
|
|
14
15
|
|
|
15
16
|
_ACTION_HIGH = "Delete — unused and orphaned"
|
|
16
17
|
_ACTION_MEDIUM = "Review — possibly unused"
|
|
@@ -59,8 +60,8 @@ class SuggestionEngine:
|
|
|
59
60
|
|
|
60
61
|
@staticmethod
|
|
61
62
|
def _age_days(scored: ScoredEnvironment) -> int:
|
|
62
|
-
"""Return age in days based on env.
|
|
63
|
-
la = scored.env.
|
|
63
|
+
"""Return age in days based on env.last_modified."""
|
|
64
|
+
la = scored.env.last_modified
|
|
64
65
|
if la.tzinfo is None:
|
|
65
66
|
la = la.replace(tzinfo=timezone.utc)
|
|
66
67
|
return max(0, (datetime.now(tz=timezone.utc) - la).days)
|
|
@@ -81,32 +82,27 @@ class SuggestionEngine:
|
|
|
81
82
|
# ── Rule 1: HIGH — orphan and stale ────────────────────────────────────
|
|
82
83
|
if is_orphan and age_days >= _HIGH_AGE_ORPHAN_THRESHOLD:
|
|
83
84
|
reasons.append("Orphan — no project files found nearby")
|
|
84
|
-
reasons.append(f"Not
|
|
85
|
+
reasons.append(f"Not modified in {age_days} days")
|
|
85
86
|
return "HIGH", _ACTION_HIGH
|
|
86
87
|
|
|
87
88
|
# ── Rule 2: LOW — active git or very recent ─────────────────────────────
|
|
88
|
-
if is_active_git or age_days <
|
|
89
|
+
if is_active_git or age_days < _STALE_AGE_THRESHOLD:
|
|
89
90
|
if is_active_git:
|
|
90
91
|
reasons.append("Active git repository")
|
|
91
|
-
if age_days <
|
|
92
|
-
reasons.append(f"Recently
|
|
92
|
+
if age_days < _STALE_AGE_THRESHOLD:
|
|
93
|
+
reasons.append(f"Recently modified ({age_days} days ago)")
|
|
93
94
|
action = _ACTION_LOW_ACTIVE if is_active_git else _ACTION_LOW
|
|
94
95
|
return "LOW", action
|
|
95
96
|
|
|
96
|
-
# ── Rule 3: MEDIUM —
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
# ── Rule 4: FALLBACK → LOW ──────────────────────────────────────────────
|
|
110
|
-
if is_active_git:
|
|
111
|
-
reasons.append("Active git repository")
|
|
112
|
-
return "LOW", _ACTION_LOW
|
|
97
|
+
# ── Rule 3: MEDIUM — stale (not active, age >= _STALE_AGE_THRESHOLD) ────
|
|
98
|
+
# Exhaustive fallback: everything not caught by Rules 1–2 is stale.
|
|
99
|
+
if is_orphan:
|
|
100
|
+
reasons.append("Orphan — no project files found nearby")
|
|
101
|
+
reasons.append(f"Not modified in {age_days} days")
|
|
102
|
+
if (
|
|
103
|
+
scored.git_info is not None
|
|
104
|
+
and scored.git_info.is_git_repo
|
|
105
|
+
and not scored.git_info.is_active
|
|
106
|
+
):
|
|
107
|
+
reasons.append("Associated git repo has no recent commits")
|
|
108
|
+
return "MEDIUM", _ACTION_MEDIUM
|
|
@@ -35,8 +35,9 @@ class Environment:
|
|
|
35
35
|
type:
|
|
36
36
|
Short detector tag, e.g. ``"venv"``, ``"pyvenv.cfg"``, ``"poetry"``,
|
|
37
37
|
``"conda"``, ``"pipx"``, ``"cache"``, ``"uv"``, ``"artifacts"``…
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
last_modified:
|
|
39
|
+
Last modification time (``st_mtime``) of the environment root, as
|
|
40
|
+
reported by the filesystem — not an access time.
|
|
40
41
|
size_bytes:
|
|
41
42
|
Total size in bytes (recursive directory sum).
|
|
42
43
|
managed_by:
|
|
@@ -48,7 +49,7 @@ class Environment:
|
|
|
48
49
|
path: Path
|
|
49
50
|
name: str
|
|
50
51
|
type: str
|
|
51
|
-
|
|
52
|
+
last_modified: datetime
|
|
52
53
|
size_bytes: int
|
|
53
54
|
managed_by: str | None = None
|
|
54
55
|
is_system_critical: bool = False
|
|
@@ -63,9 +64,9 @@ class Environment:
|
|
|
63
64
|
return format_size(self.size_bytes)
|
|
64
65
|
|
|
65
66
|
@property
|
|
66
|
-
def
|
|
67
|
+
def last_modified_str(self) -> str:
|
|
67
68
|
"""Formatted date string ``DD/MM/YYYY`` for display."""
|
|
68
|
-
return self.
|
|
69
|
+
return self.last_modified.strftime("%d/%m/%Y")
|
|
69
70
|
|
|
70
71
|
# ------------------------------------------------------------------ #
|
|
71
72
|
# Serialisation #
|
|
@@ -83,7 +84,7 @@ class Environment:
|
|
|
83
84
|
"absolute_path": str(abs_path),
|
|
84
85
|
"name": self.name,
|
|
85
86
|
"type": self.type,
|
|
86
|
-
"
|
|
87
|
+
"last_modified": self.last_modified.isoformat(),
|
|
87
88
|
"size_bytes": self.size_bytes,
|
|
88
89
|
"size_human": self.size_human,
|
|
89
90
|
"managed_by": self.managed_by,
|
|
@@ -22,6 +22,7 @@ from collections.abc import AsyncIterator, Callable
|
|
|
22
22
|
from pathlib import Path
|
|
23
23
|
|
|
24
24
|
from killpy.detectors import ALL_DETECTORS, AbstractDetector
|
|
25
|
+
from killpy.detectors._shared_walk import TYPE_TO_DETECTOR, walk_environments
|
|
25
26
|
from killpy.detectors.pyenv import _pyenv_root
|
|
26
27
|
from killpy.models import Environment
|
|
27
28
|
|
|
@@ -85,24 +86,28 @@ class Scanner:
|
|
|
85
86
|
seen: set[Path] = set()
|
|
86
87
|
results: list[Environment] = []
|
|
87
88
|
|
|
88
|
-
for
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
applicable = [d for d in self._detectors if d.can_handle()]
|
|
90
|
+
shared = [d for d in applicable if d.shared_walk]
|
|
91
|
+
others = [d for d in applicable if not d.shared_walk]
|
|
92
|
+
|
|
93
|
+
# One traversal shared by every filesystem-walking detector.
|
|
94
|
+
for detector, found in self._shared_walk_groups(shared, path):
|
|
95
|
+
processed = self._process(found, seen)
|
|
96
|
+
results.extend(processed)
|
|
97
|
+
if on_progress is not None:
|
|
98
|
+
on_progress(detector, processed)
|
|
99
|
+
|
|
100
|
+
# The remaining detectors scan their own global directories.
|
|
101
|
+
for detector in others:
|
|
92
102
|
try:
|
|
93
103
|
found = detector.detect(path)
|
|
94
104
|
except Exception as exc: # noqa: BLE001
|
|
95
105
|
logger.warning("Detector %s raised: %s", detector.name, exc)
|
|
96
106
|
found = []
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
deduped = self._apply_exclusions(deduped)
|
|
100
|
-
for env in deduped:
|
|
101
|
-
self._mark_system_critical(env)
|
|
102
|
-
results.extend(deduped)
|
|
103
|
-
|
|
107
|
+
processed = self._process(found, seen)
|
|
108
|
+
results.extend(processed)
|
|
104
109
|
if on_progress is not None:
|
|
105
|
-
on_progress(detector,
|
|
110
|
+
on_progress(detector, processed)
|
|
106
111
|
|
|
107
112
|
results.sort(key=lambda e: e.size_bytes, reverse=True)
|
|
108
113
|
return results
|
|
@@ -123,28 +128,70 @@ class Scanner:
|
|
|
123
128
|
table.add_row(env)
|
|
124
129
|
"""
|
|
125
130
|
applicable = [d for d in self._detectors if d.can_handle()]
|
|
131
|
+
shared = [d for d in applicable if d.shared_walk]
|
|
132
|
+
others = [d for d in applicable if not d.shared_walk]
|
|
126
133
|
seen: set[Path] = set()
|
|
127
134
|
|
|
128
|
-
async def
|
|
135
|
+
async def _run_shared() -> list[tuple[AbstractDetector, list[Environment]]]:
|
|
129
136
|
try:
|
|
130
|
-
return
|
|
137
|
+
return await asyncio.to_thread(self._shared_walk_groups, shared, path)
|
|
138
|
+
except Exception as exc: # noqa: BLE001
|
|
139
|
+
logger.warning("Shared walk raised: %s", exc)
|
|
140
|
+
return [(d, []) for d in shared]
|
|
141
|
+
|
|
142
|
+
async def _run_one(
|
|
143
|
+
detector: AbstractDetector,
|
|
144
|
+
) -> list[tuple[AbstractDetector, list[Environment]]]:
|
|
145
|
+
try:
|
|
146
|
+
return [(detector, await asyncio.to_thread(detector.detect, path))]
|
|
131
147
|
except Exception as exc: # noqa: BLE001
|
|
132
148
|
logger.warning("Detector %s raised: %s", detector.name, exc)
|
|
133
|
-
return detector, []
|
|
149
|
+
return [(detector, [])]
|
|
150
|
+
|
|
151
|
+
tasks = [asyncio.create_task(_run_one(d)) for d in others]
|
|
152
|
+
if shared:
|
|
153
|
+
tasks.append(asyncio.create_task(_run_shared()))
|
|
134
154
|
|
|
135
|
-
tasks = [asyncio.create_task(_run(d)) for d in applicable]
|
|
136
155
|
for coro in asyncio.as_completed(tasks):
|
|
137
|
-
detector, found
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
for env in deduped:
|
|
141
|
-
self._mark_system_critical(env)
|
|
142
|
-
yield detector, deduped
|
|
156
|
+
for detector, found in await coro:
|
|
157
|
+
deduped = self._process(found, seen)
|
|
158
|
+
yield detector, deduped
|
|
143
159
|
|
|
144
160
|
# ------------------------------------------------------------------ #
|
|
145
161
|
# Helpers #
|
|
146
162
|
# ------------------------------------------------------------------ #
|
|
147
163
|
|
|
164
|
+
def _shared_walk_groups(
|
|
165
|
+
self, shared: list[AbstractDetector], path: Path
|
|
166
|
+
) -> list[tuple[AbstractDetector, list[Environment]]]:
|
|
167
|
+
"""Run the one shared walk, returning ``(detector, envs)`` per detector.
|
|
168
|
+
|
|
169
|
+
The local tree is walked once for the union of *shared* detector names;
|
|
170
|
+
each detector also contributes its own global scan (pip/uv caches).
|
|
171
|
+
Results are grouped back per detector via :data:`TYPE_TO_DETECTOR` so the
|
|
172
|
+
per-detector progress contract is preserved.
|
|
173
|
+
"""
|
|
174
|
+
if not shared:
|
|
175
|
+
return []
|
|
176
|
+
active = {d.name for d in shared}
|
|
177
|
+
found = walk_environments(path, active)
|
|
178
|
+
for detector in shared:
|
|
179
|
+
found.extend(detector.scan_global(path))
|
|
180
|
+
by_name: dict[str, list[Environment]] = {d.name: [] for d in shared}
|
|
181
|
+
for env in found:
|
|
182
|
+
name = TYPE_TO_DETECTOR.get(env.type)
|
|
183
|
+
if name in by_name:
|
|
184
|
+
by_name[name].append(env)
|
|
185
|
+
return [(d, by_name[d.name]) for d in shared]
|
|
186
|
+
|
|
187
|
+
def _process(self, found: list[Environment], seen: set[Path]) -> list[Environment]:
|
|
188
|
+
"""Deduplicate, apply exclusions, and flag system-critical envs."""
|
|
189
|
+
deduped = self._deduplicate(found, seen)
|
|
190
|
+
deduped = self._apply_exclusions(deduped)
|
|
191
|
+
for env in deduped:
|
|
192
|
+
self._mark_system_critical(env)
|
|
193
|
+
return deduped
|
|
194
|
+
|
|
148
195
|
@staticmethod
|
|
149
196
|
def _deduplicate(envs: list[Environment], seen: set[Path]) -> list[Environment]:
|
|
150
197
|
"""Filter out environments whose resolved path has already been seen.
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: killpy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.1
|
|
4
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
9
|
Keywords: venv,virtualenv,conda,poetry,pipenv,pipx,pyenv,uv,cleanup,disk-space,cache,cli,tui,textual,developer-tools
|
|
10
|
-
Classifier: Development Status ::
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
11
|
Classifier: Environment :: Console
|
|
12
12
|
Classifier: Environment :: Console :: Curses
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
@@ -196,7 +196,7 @@ Environments flagged with `⚠️` are actively in use (the environment killpy r
|
|
|
196
196
|
| Key | Action |
|
|
197
197
|
|-----|--------|
|
|
198
198
|
| `↑` / `↓` or `k` / `j` | Move cursor up / down (vim-style) |
|
|
199
|
-
| `/` | Open live search/filter bar (
|
|
199
|
+
| `/` | Open live search/filter bar (substring match) |
|
|
200
200
|
| `Escape` | Close search bar and clear filter |
|
|
201
201
|
| `T` | Toggle multi-select mode on / off |
|
|
202
202
|
| `Space` | *(Multi-select)* Toggle current row selected |
|
|
@@ -211,7 +211,7 @@ Environments flagged with `⚠️` are actively in use (the environment killpy r
|
|
|
211
211
|
|
|
212
212
|
### Search / filter
|
|
213
213
|
|
|
214
|
-
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.
|
|
215
215
|
|
|
216
216
|
### Multi-select mode
|
|
217
217
|
|
|
@@ -265,7 +265,7 @@ ______________________________________________________________________
|
|
|
265
265
|
killpy list # list all detected environments
|
|
266
266
|
killpy list --path ~/projects # scan a specific path
|
|
267
267
|
killpy list --type venv --type conda # filter by type (repeatable)
|
|
268
|
-
killpy list --older-than 90 # not
|
|
268
|
+
killpy list --older-than 90 # not modified in the last 90 days
|
|
269
269
|
killpy list --json # output as a JSON array
|
|
270
270
|
killpy list --json-stream # stream as NDJSON — one line per env
|
|
271
271
|
killpy list --quiet # suppress progress output (scripts/CI)
|
|
@@ -284,7 +284,7 @@ The **Path** column mirrors the form of `--path`: a relative `--path` produces r
|
|
|
284
284
|
"absolute_path": "/home/user/projects/my-app/.venv",
|
|
285
285
|
"name": "my-app/.venv",
|
|
286
286
|
"type": "venv",
|
|
287
|
-
"
|
|
287
|
+
"last_modified": "2025-11-02T14:23:01+00:00",
|
|
288
288
|
"size_bytes": 54393984,
|
|
289
289
|
"size_human": "51.88 MB",
|
|
290
290
|
"managed_by": null,
|
|
@@ -417,8 +417,7 @@ Category is assigned deterministically by applying the following rules in order:
|
|
|
417
417
|
|----------|------|----------|
|
|
418
418
|
| 1 | Orphan (`is_orphan=True`) **and** `age ≥ 180 days` | `HIGH` |
|
|
419
419
|
| 2 | Active git repository **or** `age < 120 days` | `LOW` |
|
|
420
|
-
| 3 | `age ≥ 120 days` | `MEDIUM` |
|
|
421
|
-
| 4 | Fallback | `LOW` |
|
|
420
|
+
| 3 | `age ≥ 120 days` *(exhaustive fallback)* | `MEDIUM` |
|
|
422
421
|
|
|
423
422
|
Age and orphan status are the dominant signals. Size does not affect the category.
|
|
424
423
|
|
|
@@ -498,7 +497,7 @@ Yes. Run `killpy --path ~` to scan your home folder. The `stats` command gives a
|
|
|
498
497
|
killpy delete --type venv --older-than 90 --yes
|
|
499
498
|
```
|
|
500
499
|
|
|
501
|
-
Deletes every `.venv` / `pyvenv.cfg` env not
|
|
500
|
+
Deletes every `.venv` / `pyvenv.cfg` env not modified in the last 90 days, without prompting.
|
|
502
501
|
|
|
503
502
|
**How do I use killpy in a CI pipeline or script?**
|
|
504
503
|
|
|
@@ -604,7 +603,7 @@ ______________________________________________________________________
|
|
|
604
603
|
|
|
605
604
|
```yaml
|
|
606
605
|
- repo: https://github.com/Tlaloc-Es/KillPy
|
|
607
|
-
rev: 0.
|
|
606
|
+
rev: 1.0.1
|
|
608
607
|
hooks:
|
|
609
608
|
- id: killpy # remove __pycache__ on every commit
|
|
610
609
|
- id: killpy-clean-caches # also removes .mypy_cache, .pytest_cache, .ruff_cache
|
|
@@ -629,7 +628,7 @@ Typical minimal setup (safe for daily use):
|
|
|
629
628
|
|
|
630
629
|
```yaml
|
|
631
630
|
- repo: https://github.com/Tlaloc-Es/KillPy
|
|
632
|
-
rev: 0.
|
|
631
|
+
rev: 1.0.1
|
|
633
632
|
hooks:
|
|
634
633
|
- id: killpy
|
|
635
634
|
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "killpy"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "1.0.1"
|
|
4
4
|
description = "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
|
readme = "README.md"
|
|
6
6
|
keywords = [
|
|
@@ -21,7 +21,7 @@ keywords = [
|
|
|
21
21
|
"developer-tools",
|
|
22
22
|
]
|
|
23
23
|
classifiers = [
|
|
24
|
-
"Development Status ::
|
|
24
|
+
"Development Status :: 5 - Production/Stable",
|
|
25
25
|
"Environment :: Console",
|
|
26
26
|
"Environment :: Console :: Curses",
|
|
27
27
|
"Intended Audience :: Developers",
|
|
@@ -75,6 +75,9 @@ package = true
|
|
|
75
75
|
[tool.pytest.ini_options]
|
|
76
76
|
testpaths = ["tests"]
|
|
77
77
|
addopts = "-v --tb=short --cov=killpy --cov-report=term-missing --cov-report=xml"
|
|
78
|
+
markers = [
|
|
79
|
+
"integration: system-level end-to-end tests (real envs + real deletion); require KILLPY_INTEGRATION=1",
|
|
80
|
+
]
|
|
78
81
|
|
|
79
82
|
[tool.coverage.run]
|
|
80
83
|
branch = true
|
|
@@ -103,5 +106,9 @@ lint = "ruff check killpy tests"
|
|
|
103
106
|
format = "ruff format killpy tests"
|
|
104
107
|
type = "mypy killpy"
|
|
105
108
|
|
|
109
|
+
[tool.poe.tasks.integration]
|
|
110
|
+
cmd = "pytest -m integration --no-cov tests/integration"
|
|
111
|
+
env = { KILLPY_INTEGRATION = "1" }
|
|
112
|
+
|
|
106
113
|
[project.scripts]
|
|
107
114
|
killpy = "killpy.__main__:cli"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|