killpy 0.25.0__tar.gz → 0.25.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.0 → killpy-0.25.1}/PKG-INFO +12 -4
- {killpy-0.25.0 → killpy-0.25.1}/README.md +2 -2
- {killpy-0.25.0 → killpy-0.25.1}/killpy/__main__.py +6 -2
- {killpy-0.25.0 → killpy-0.25.1}/killpy/cleaners/__init__.py +4 -0
- {killpy-0.25.0 → killpy-0.25.1}/killpy/cli.py +26 -24
- {killpy-0.25.0 → killpy-0.25.1}/killpy/commands/clean.py +7 -2
- {killpy-0.25.0 → killpy-0.25.1}/killpy/commands/doctor.py +40 -43
- {killpy-0.25.0 → killpy-0.25.1}/killpy/commands/list.py +2 -1
- {killpy-0.25.0 → killpy-0.25.1}/killpy/detectors/artifacts.py +10 -10
- killpy-0.25.1/killpy/detectors/base.py +101 -0
- {killpy-0.25.0 → killpy-0.25.1}/killpy/detectors/cache.py +19 -21
- {killpy-0.25.0 → killpy-0.25.1}/killpy/detectors/conda.py +1 -4
- {killpy-0.25.0 → killpy-0.25.1}/killpy/detectors/hatch.py +4 -3
- {killpy-0.25.0 → killpy-0.25.1}/killpy/detectors/pipenv.py +4 -3
- {killpy-0.25.0 → killpy-0.25.1}/killpy/detectors/pipx.py +1 -4
- {killpy-0.25.0 → killpy-0.25.1}/killpy/detectors/poetry.py +3 -2
- {killpy-0.25.0 → killpy-0.25.1}/killpy/detectors/pyenv.py +3 -2
- {killpy-0.25.0 → killpy-0.25.1}/killpy/detectors/tox.py +3 -7
- {killpy-0.25.0 → killpy-0.25.1}/killpy/detectors/uv.py +4 -7
- {killpy-0.25.0 → killpy-0.25.1}/killpy/detectors/venv.py +11 -11
- {killpy-0.25.0 → killpy-0.25.1}/killpy/files/__init__.py +13 -8
- {killpy-0.25.0 → killpy-0.25.1}/killpy/intelligence/git_analyzer.py +12 -9
- {killpy-0.25.0 → killpy-0.25.1}/killpy/models.py +15 -3
- {killpy-0.25.0 → killpy-0.25.1}/killpy.egg-info/PKG-INFO +12 -4
- {killpy-0.25.0 → killpy-0.25.1}/pyproject.toml +26 -2
- killpy-0.25.0/killpy/detectors/base.py +0 -53
- {killpy-0.25.0 → killpy-0.25.1}/killpy/__init__.py +0 -0
- {killpy-0.25.0 → killpy-0.25.1}/killpy/cleaner.py +0 -0
- {killpy-0.25.0 → killpy-0.25.1}/killpy/commands/__init__.py +0 -0
- {killpy-0.25.0 → killpy-0.25.1}/killpy/commands/_utils.py +0 -0
- {killpy-0.25.0 → killpy-0.25.1}/killpy/commands/delete.py +0 -0
- {killpy-0.25.0 → killpy-0.25.1}/killpy/commands/find.py +0 -0
- {killpy-0.25.0 → killpy-0.25.1}/killpy/commands/stats.py +0 -0
- {killpy-0.25.0 → killpy-0.25.1}/killpy/detectors/__init__.py +0 -0
- {killpy-0.25.0 → killpy-0.25.1}/killpy/intelligence/__init__.py +0 -0
- {killpy-0.25.0 → killpy-0.25.1}/killpy/intelligence/scoring.py +0 -0
- {killpy-0.25.0 → killpy-0.25.1}/killpy/intelligence/suggestions.py +0 -0
- {killpy-0.25.0 → killpy-0.25.1}/killpy/intelligence/tracker.py +0 -0
- {killpy-0.25.0 → killpy-0.25.1}/killpy/scanner.py +0 -0
- {killpy-0.25.0 → killpy-0.25.1}/killpy.egg-info/SOURCES.txt +0 -0
- {killpy-0.25.0 → killpy-0.25.1}/killpy.egg-info/dependency_links.txt +0 -0
- {killpy-0.25.0 → killpy-0.25.1}/killpy.egg-info/entry_points.txt +0 -0
- {killpy-0.25.0 → killpy-0.25.1}/killpy.egg-info/requires.txt +0 -0
- {killpy-0.25.0 → killpy-0.25.1}/killpy.egg-info/top_level.txt +0 -0
- {killpy-0.25.0 → killpy-0.25.1}/setup.cfg +0 -0
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: killpy
|
|
3
|
-
Version: 0.25.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.25.1
|
|
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
|
|
@@ -596,7 +604,7 @@ ______________________________________________________________________
|
|
|
596
604
|
|
|
597
605
|
```yaml
|
|
598
606
|
- repo: https://github.com/Tlaloc-Es/KillPy
|
|
599
|
-
rev: 0.
|
|
607
|
+
rev: 0.25.1
|
|
600
608
|
hooks:
|
|
601
609
|
- id: killpy # remove __pycache__ on every commit
|
|
602
610
|
- id: killpy-clean-caches # also removes .mypy_cache, .pytest_cache, .ruff_cache
|
|
@@ -621,7 +629,7 @@ Typical minimal setup (safe for daily use):
|
|
|
621
629
|
|
|
622
630
|
```yaml
|
|
623
631
|
- repo: https://github.com/Tlaloc-Es/KillPy
|
|
624
|
-
rev: 0.25.
|
|
632
|
+
rev: 0.25.1
|
|
625
633
|
hooks:
|
|
626
634
|
- id: killpy
|
|
627
635
|
```
|
|
@@ -575,7 +575,7 @@ ______________________________________________________________________
|
|
|
575
575
|
|
|
576
576
|
```yaml
|
|
577
577
|
- repo: https://github.com/Tlaloc-Es/KillPy
|
|
578
|
-
rev: 0.
|
|
578
|
+
rev: 0.25.1
|
|
579
579
|
hooks:
|
|
580
580
|
- id: killpy # remove __pycache__ on every commit
|
|
581
581
|
- id: killpy-clean-caches # also removes .mypy_cache, .pytest_cache, .ruff_cache
|
|
@@ -600,7 +600,7 @@ Typical minimal setup (safe for daily use):
|
|
|
600
600
|
|
|
601
601
|
```yaml
|
|
602
602
|
- repo: https://github.com/Tlaloc-Es/KillPy
|
|
603
|
-
rev: 0.25.
|
|
603
|
+
rev: 0.25.1
|
|
604
604
|
hooks:
|
|
605
605
|
- id: killpy
|
|
606
606
|
```
|
|
@@ -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,3 +1,7 @@
|
|
|
1
|
+
"""Interactive Textual TUI (``TableApp``) for browsing and deleting environments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
1
5
|
import asyncio
|
|
2
6
|
import re
|
|
3
7
|
import subprocess
|
|
@@ -44,7 +48,7 @@ def _health_text(category: str) -> Text | str:
|
|
|
44
48
|
return ""
|
|
45
49
|
|
|
46
50
|
|
|
47
|
-
def
|
|
51
|
+
def _is_venv_tab(func):
|
|
48
52
|
def wrapper(self, *args, **kwargs):
|
|
49
53
|
if self.query_one(TabbedContent).active == "venv-tab":
|
|
50
54
|
return func(self, *args, **kwargs)
|
|
@@ -52,7 +56,7 @@ def is_venv_tab(func):
|
|
|
52
56
|
return wrapper
|
|
53
57
|
|
|
54
58
|
|
|
55
|
-
def
|
|
59
|
+
def _is_pipx_tab(func):
|
|
56
60
|
def wrapper(self, *args, **kwargs):
|
|
57
61
|
if self.query_one(TabbedContent).active == "pipx-tab":
|
|
58
62
|
return func(self, *args, **kwargs)
|
|
@@ -60,7 +64,7 @@ def is_pipx_tab(func):
|
|
|
60
64
|
return wrapper
|
|
61
65
|
|
|
62
66
|
|
|
63
|
-
def
|
|
67
|
+
def _shorten_path_for_table(path_value, max_parts: int = 2) -> str:
|
|
64
68
|
path_text = str(path_value)
|
|
65
69
|
if "/" not in path_text and "\\" not in path_text:
|
|
66
70
|
return path_text
|
|
@@ -349,7 +353,7 @@ class TableApp(App):
|
|
|
349
353
|
"type"
|
|
350
354
|
]
|
|
351
355
|
table.add_row(
|
|
352
|
-
|
|
356
|
+
_shorten_path_for_table(row["path"]),
|
|
353
357
|
type_label,
|
|
354
358
|
row["last_modified"],
|
|
355
359
|
row["size"],
|
|
@@ -391,7 +395,7 @@ class TableApp(App):
|
|
|
391
395
|
"type"
|
|
392
396
|
]
|
|
393
397
|
table.add_row(
|
|
394
|
-
|
|
398
|
+
_shorten_path_for_table(row["path"]),
|
|
395
399
|
type_label,
|
|
396
400
|
row["last_modified"],
|
|
397
401
|
row["size"],
|
|
@@ -609,12 +613,16 @@ class TableApp(App):
|
|
|
609
613
|
row["health"] = self._health_by_path.get(row["path"], "")
|
|
610
614
|
self.render_venv_table()
|
|
611
615
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
self.bytes_release += total_freed_space
|
|
616
|
+
def _show_bytes_released(self) -> None:
|
|
617
|
+
"""Update the status label with the running total of freed space."""
|
|
615
618
|
self.query_one("#status-label", Label).update(
|
|
616
619
|
f"{format_size(self.bytes_release)} deleted"
|
|
617
620
|
)
|
|
621
|
+
|
|
622
|
+
async def action_clean_pycache(self):
|
|
623
|
+
total_freed_space = await asyncio.to_thread(remove_pycache, self.root_dir)
|
|
624
|
+
self.bytes_release += total_freed_space
|
|
625
|
+
self._show_bytes_released()
|
|
618
626
|
self.bell()
|
|
619
627
|
|
|
620
628
|
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
@@ -633,7 +641,7 @@ class TableApp(App):
|
|
|
633
641
|
_, row = resolved
|
|
634
642
|
selected_path_label.update(f"Selected: {row['path']}")
|
|
635
643
|
|
|
636
|
-
@
|
|
644
|
+
@_is_venv_tab
|
|
637
645
|
def action_confirm_delete(self):
|
|
638
646
|
freed_now = 0
|
|
639
647
|
if self._multi_select_mode and self._selected_venv_paths:
|
|
@@ -662,12 +670,10 @@ class TableApp(App):
|
|
|
662
670
|
|
|
663
671
|
self._record_deletion(freed_now)
|
|
664
672
|
self.render_venv_table()
|
|
665
|
-
self.
|
|
666
|
-
f"{format_size(self.bytes_release)} deleted"
|
|
667
|
-
)
|
|
673
|
+
self._show_bytes_released()
|
|
668
674
|
self.bell()
|
|
669
675
|
|
|
670
|
-
@
|
|
676
|
+
@_is_venv_tab
|
|
671
677
|
def action_mark_for_delete(self):
|
|
672
678
|
table = self.query_one("#venv-table", DataTable)
|
|
673
679
|
|
|
@@ -690,7 +696,7 @@ class TableApp(App):
|
|
|
690
696
|
EnvStatus.MARKED_TO_DELETE.value,
|
|
691
697
|
)
|
|
692
698
|
|
|
693
|
-
@
|
|
699
|
+
@_is_venv_tab
|
|
694
700
|
def action_delete_now(self):
|
|
695
701
|
table = self.query_one("#venv-table", DataTable)
|
|
696
702
|
cursor_cell = table.cursor_coordinate
|
|
@@ -709,9 +715,7 @@ class TableApp(App):
|
|
|
709
715
|
(cursor_cell.row, self.VENV_COL_STATUS),
|
|
710
716
|
EnvStatus.DELETED.value,
|
|
711
717
|
)
|
|
712
|
-
self.
|
|
713
|
-
f"{format_size(self.bytes_release)} deleted"
|
|
714
|
-
)
|
|
718
|
+
self._show_bytes_released()
|
|
715
719
|
self.bell()
|
|
716
720
|
|
|
717
721
|
def delete_environment(self, environment: Environment) -> bool:
|
|
@@ -729,7 +733,7 @@ class TableApp(App):
|
|
|
729
733
|
self.query_one("#status-label", Label).update(str(error))
|
|
730
734
|
return False
|
|
731
735
|
|
|
732
|
-
@
|
|
736
|
+
@_is_pipx_tab
|
|
733
737
|
def action_uninstall_pipx(self):
|
|
734
738
|
table = self.query_one("#pipx-table", DataTable)
|
|
735
739
|
cursor_cell = table.cursor_coordinate
|
|
@@ -742,9 +746,7 @@ class TableApp(App):
|
|
|
742
746
|
table.update_cell_at((cursor_cell.row, 3), EnvStatus.DELETED.value)
|
|
743
747
|
self.bytes_release += int(row["size"])
|
|
744
748
|
self._record_deletion(int(row["size"]))
|
|
745
|
-
self.
|
|
746
|
-
f"{format_size(self.bytes_release)} deleted"
|
|
747
|
-
)
|
|
749
|
+
self._show_bytes_released()
|
|
748
750
|
|
|
749
751
|
self.bell()
|
|
750
752
|
|
|
@@ -772,7 +774,7 @@ class TableApp(App):
|
|
|
772
774
|
tid = "#venv-table" if active == "venv-tab" else "#pipx-table"
|
|
773
775
|
self.query_one(tid, DataTable).action_cursor_up()
|
|
774
776
|
|
|
775
|
-
@
|
|
777
|
+
@_is_venv_tab
|
|
776
778
|
def action_open_folder(self) -> None:
|
|
777
779
|
"""Open the parent directory of the selected environment in the OS file manager.""" # noqa: E501
|
|
778
780
|
table = self.query_one("#venv-table", DataTable)
|
|
@@ -834,7 +836,7 @@ class TableApp(App):
|
|
|
834
836
|
self._update_multi_select_label()
|
|
835
837
|
self.render_venv_table()
|
|
836
838
|
|
|
837
|
-
@
|
|
839
|
+
@_is_venv_tab
|
|
838
840
|
def action_multi_select_toggle_row(self) -> None:
|
|
839
841
|
"""Toggle current row selection in multi-select mode (Space key)."""
|
|
840
842
|
if not self._multi_select_mode:
|
|
@@ -859,7 +861,7 @@ class TableApp(App):
|
|
|
859
861
|
self._compute_row_status(row),
|
|
860
862
|
)
|
|
861
863
|
|
|
862
|
-
@
|
|
864
|
+
@_is_venv_tab
|
|
863
865
|
def action_multi_select_all(self) -> None:
|
|
864
866
|
"""Toggle select-all / deselect-all in multi-select mode (A key)."""
|
|
865
867
|
if not self._multi_select_mode:
|
|
@@ -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")
|
|
@@ -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']} "
|
|
@@ -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
|
|
|
@@ -7,17 +7,19 @@ import os
|
|
|
7
7
|
from datetime import datetime, timezone
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
-
from killpy.detectors.base import
|
|
10
|
+
from killpy.detectors.base import (
|
|
11
|
+
ENV_INTERNAL_DIRS,
|
|
12
|
+
VCS_PRUNE_DIRS,
|
|
13
|
+
AbstractDetector,
|
|
14
|
+
)
|
|
11
15
|
from killpy.files import get_total_size
|
|
12
16
|
from killpy.models import Environment
|
|
13
17
|
|
|
14
18
|
logger = logging.getLogger(__name__)
|
|
15
19
|
|
|
16
|
-
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
# Environments are VenvDetector territory.
|
|
20
|
-
_ENV_DIRS: frozenset[str] = frozenset({".venv", "site-packages"})
|
|
20
|
+
# Environment internals (``ENV_INTERNAL_DIRS``) are not build artifacts: every
|
|
21
|
+
# installed package has a ``site-packages/*.dist-info`` directory whose
|
|
22
|
+
# deletion corrupts the env. Environments are VenvDetector territory.
|
|
21
23
|
_EXACT_NAMES: frozenset[str] = frozenset({"dist", "build"})
|
|
22
24
|
_SUFFIXES: tuple[str, ...] = (".egg-info", ".dist-info")
|
|
23
25
|
|
|
@@ -39,9 +41,7 @@ class ArtifactsDetector(AbstractDetector):
|
|
|
39
41
|
"""
|
|
40
42
|
|
|
41
43
|
name = "artifacts"
|
|
42
|
-
|
|
43
|
-
def can_handle(self) -> bool:
|
|
44
|
-
return True # pure filesystem walk
|
|
44
|
+
always_available = True # pure filesystem walk
|
|
45
45
|
|
|
46
46
|
def detect(self, path: Path) -> list[Environment]:
|
|
47
47
|
envs: list[Environment] = []
|
|
@@ -52,7 +52,7 @@ class ArtifactsDetector(AbstractDetector):
|
|
|
52
52
|
continue
|
|
53
53
|
pruned = set()
|
|
54
54
|
for d in directories:
|
|
55
|
-
if d in
|
|
55
|
+
if d in VCS_PRUNE_DIRS or d in ENV_INTERNAL_DIRS:
|
|
56
56
|
pruned.add(d)
|
|
57
57
|
continue
|
|
58
58
|
if _is_artifact_dir(d):
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Abstract base class for all killpy detectors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import ClassVar
|
|
9
|
+
|
|
10
|
+
from killpy.models import Environment
|
|
11
|
+
|
|
12
|
+
# Directory names shared by the filesystem-walking detectors. Defined once
|
|
13
|
+
# here so the prune policy has a single source of truth (see
|
|
14
|
+
# ``dev-docs/CODING_CONVENTIONS.md`` §1).
|
|
15
|
+
|
|
16
|
+
# Version-control and JS dependency directories: never worth descending into
|
|
17
|
+
# when looking for Python environments/artifacts.
|
|
18
|
+
VCS_PRUNE_DIRS: frozenset[str] = frozenset({".git", ".hg", ".svn", "node_modules"})
|
|
19
|
+
|
|
20
|
+
# An environment's own internals: a ``.venv`` / ``site-packages`` tree belongs
|
|
21
|
+
# to the environment that owns it, so cache/artifact detectors must not descend
|
|
22
|
+
# into it and double-count (or wrongly offer to delete) its contents.
|
|
23
|
+
ENV_INTERNAL_DIRS: frozenset[str] = frozenset({".venv", "site-packages"})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AbstractDetector(ABC):
|
|
27
|
+
"""Common interface every detector must implement.
|
|
28
|
+
|
|
29
|
+
Concrete detectors live in this package (``killpy/detectors/``). The
|
|
30
|
+
:class:`~killpy.scanner.Scanner` instantiates them and calls
|
|
31
|
+
:meth:`can_handle` before :meth:`detect`, so detectors that rely on
|
|
32
|
+
optional tools (conda, pipx …) are silently skipped when those tools
|
|
33
|
+
are not present.
|
|
34
|
+
|
|
35
|
+
Class attributes
|
|
36
|
+
----------------
|
|
37
|
+
name : str
|
|
38
|
+
Short identifier used for logging, filtering and the ``--type``
|
|
39
|
+
CLI option. Must be unique across all detectors.
|
|
40
|
+
|
|
41
|
+
can_handle() contract
|
|
42
|
+
---------------------
|
|
43
|
+
:meth:`can_handle` is *not* overridden by concrete detectors. Instead each
|
|
44
|
+
declares its applicability contract as data, and the base computes the
|
|
45
|
+
result — so the declared contract and the runtime behaviour can never
|
|
46
|
+
diverge. A detector must declare exactly one of:
|
|
47
|
+
|
|
48
|
+
* ``always_available = True`` — pure filesystem walk (venv, tox, cache, artifacts).
|
|
49
|
+
* ``required_tool = "<cli>"`` — needs a CLI on ``PATH`` (conda, pipx).
|
|
50
|
+
* override :meth:`_candidate_dirs` — needs a global directory (poetry, pyenv).
|
|
51
|
+
* both ``required_tool`` and :meth:`_candidate_dirs` — tool *or* directory
|
|
52
|
+
(pipenv, hatch, uv).
|
|
53
|
+
|
|
54
|
+
``tests/unit/test_detectors.py`` asserts every detector declares one. For a
|
|
55
|
+
check the three knobs can't express, overriding :meth:`can_handle` is an
|
|
56
|
+
allowed, documented exception — see ``dev-docs/ADDING_A_DETECTOR.md``.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
name: str = "base"
|
|
60
|
+
|
|
61
|
+
#: CLI executable that, if present on ``PATH``, enables this detector.
|
|
62
|
+
required_tool: ClassVar[str | None] = None
|
|
63
|
+
#: ``True`` for detectors that always apply (pure filesystem walks).
|
|
64
|
+
always_available: ClassVar[bool] = False
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
def detect(self, path: Path) -> list[Environment]:
|
|
68
|
+
"""Scan *path* and return every matching :class:`~killpy.models.Environment`.
|
|
69
|
+
|
|
70
|
+
Implementations **must not raise** – on error they should log and
|
|
71
|
+
return an empty list so that other detectors are unaffected.
|
|
72
|
+
|
|
73
|
+
Parameters
|
|
74
|
+
----------
|
|
75
|
+
path:
|
|
76
|
+
Root directory to scan. Global-cache detectors (poetry, pipx …)
|
|
77
|
+
may ignore this argument; local detectors (venv, tox …) should
|
|
78
|
+
use it as the walk root.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def _candidate_dirs(self) -> tuple[Path, ...]:
|
|
82
|
+
"""Directories whose existence makes this detector applicable.
|
|
83
|
+
|
|
84
|
+
Empty by default; directory- and hybrid-contract detectors override it.
|
|
85
|
+
Resolved at call time (not class-definition time) so patched path
|
|
86
|
+
helpers are honoured.
|
|
87
|
+
"""
|
|
88
|
+
return ()
|
|
89
|
+
|
|
90
|
+
def can_handle(self) -> bool:
|
|
91
|
+
"""Return ``True`` when this detector can find anything on this system.
|
|
92
|
+
|
|
93
|
+
Computed from the declared contract (see the class docstring); a
|
|
94
|
+
pre-flight gate that never raises and does no I/O beyond ``which()`` /
|
|
95
|
+
``exists()``. Do not override — declare the contract instead.
|
|
96
|
+
"""
|
|
97
|
+
if self.always_available:
|
|
98
|
+
return True
|
|
99
|
+
if self.required_tool is not None and shutil.which(self.required_tool):
|
|
100
|
+
return True
|
|
101
|
+
return any(d.exists() for d in self._candidate_dirs())
|
|
@@ -17,7 +17,11 @@ import platform
|
|
|
17
17
|
from datetime import datetime, timezone
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
|
|
20
|
-
from killpy.detectors.base import
|
|
20
|
+
from killpy.detectors.base import (
|
|
21
|
+
ENV_INTERNAL_DIRS,
|
|
22
|
+
VCS_PRUNE_DIRS,
|
|
23
|
+
AbstractDetector,
|
|
24
|
+
)
|
|
21
25
|
from killpy.files import get_total_size
|
|
22
26
|
from killpy.models import Environment
|
|
23
27
|
|
|
@@ -31,13 +35,9 @@ _LOCAL_CACHE_DIRS: tuple[str, ...] = (
|
|
|
31
35
|
".ruff_cache",
|
|
32
36
|
)
|
|
33
37
|
|
|
34
|
-
#
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
# Caches inside an environment belong to the environment: VenvDetector
|
|
38
|
-
# already reports the whole tree, so listing them separately double-counts
|
|
39
|
-
# their size in stats/list totals.
|
|
40
|
-
_ENV_DIRS: frozenset[str] = frozenset({".venv", "site-packages"})
|
|
38
|
+
# Caches inside an environment (``ENV_INTERNAL_DIRS``) belong to the
|
|
39
|
+
# environment: VenvDetector already reports the whole tree, so listing them
|
|
40
|
+
# separately would double-count their size in stats/list totals.
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
def _pip_cache_dir() -> Path:
|
|
@@ -71,9 +71,7 @@ class CacheDetector(AbstractDetector):
|
|
|
71
71
|
"""Detects local and global Python cache directories."""
|
|
72
72
|
|
|
73
73
|
name = "cache"
|
|
74
|
-
|
|
75
|
-
def can_handle(self) -> bool:
|
|
76
|
-
return True
|
|
74
|
+
always_available = True # pure filesystem walk
|
|
77
75
|
|
|
78
76
|
def detect(self, path: Path) -> list[Environment]:
|
|
79
77
|
envs: list[Environment] = []
|
|
@@ -95,10 +93,10 @@ class CacheDetector(AbstractDetector):
|
|
|
95
93
|
# Inside a virtual environment (whatever its name) — skip it.
|
|
96
94
|
directories[:] = []
|
|
97
95
|
continue
|
|
98
|
-
|
|
96
|
+
pruned = set()
|
|
99
97
|
for d in directories:
|
|
100
|
-
if d in
|
|
101
|
-
|
|
98
|
+
if d in VCS_PRUNE_DIRS or d in ENV_INTERNAL_DIRS:
|
|
99
|
+
pruned.add(d)
|
|
102
100
|
continue
|
|
103
101
|
if d in _LOCAL_CACHE_DIRS:
|
|
104
102
|
cache_path = Path(current_root) / d
|
|
@@ -107,8 +105,8 @@ class CacheDetector(AbstractDetector):
|
|
|
107
105
|
results.append(env)
|
|
108
106
|
except (FileNotFoundError, OSError) as exc:
|
|
109
107
|
logger.debug("Skipping %s: %s", cache_path, exc)
|
|
110
|
-
|
|
111
|
-
directories[:] = [d for d in directories if d not in
|
|
108
|
+
pruned.add(d) # don't recurse inside cache dirs
|
|
109
|
+
directories[:] = [d for d in directories if d not in pruned]
|
|
112
110
|
return results
|
|
113
111
|
|
|
114
112
|
def _scan_global(self, root: Path) -> list[Environment]:
|
|
@@ -141,13 +139,13 @@ class CacheDetector(AbstractDetector):
|
|
|
141
139
|
return results
|
|
142
140
|
|
|
143
141
|
|
|
144
|
-
def _make_cache_env(
|
|
145
|
-
stat =
|
|
146
|
-
size = get_total_size(
|
|
142
|
+
def _make_cache_env(cache_path: Path, tag: str) -> Environment:
|
|
143
|
+
stat = cache_path.stat()
|
|
144
|
+
size = get_total_size(cache_path)
|
|
147
145
|
mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
|
|
148
146
|
return Environment(
|
|
149
|
-
path=
|
|
150
|
-
name=str(
|
|
147
|
+
path=cache_path,
|
|
148
|
+
name=str(cache_path),
|
|
151
149
|
type=tag,
|
|
152
150
|
last_accessed=mtime,
|
|
153
151
|
size_bytes=size,
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
-
import shutil
|
|
7
6
|
import subprocess
|
|
8
7
|
from datetime import datetime, timezone
|
|
9
8
|
from pathlib import Path
|
|
@@ -53,9 +52,7 @@ class CondaDetector(AbstractDetector):
|
|
|
53
52
|
"""
|
|
54
53
|
|
|
55
54
|
name = "conda"
|
|
56
|
-
|
|
57
|
-
def can_handle(self) -> bool:
|
|
58
|
-
return shutil.which("conda") is not None
|
|
55
|
+
required_tool = "conda" # needs the conda CLI on PATH
|
|
59
56
|
|
|
60
57
|
def detect(self, path: Path) -> list[Environment]: # noqa: ARG002
|
|
61
58
|
try:
|
|
@@ -5,7 +5,6 @@ from __future__ import annotations
|
|
|
5
5
|
import logging
|
|
6
6
|
import os
|
|
7
7
|
import platform
|
|
8
|
-
import shutil
|
|
9
8
|
from datetime import datetime, timezone
|
|
10
9
|
from pathlib import Path
|
|
11
10
|
|
|
@@ -42,9 +41,11 @@ class HatchDetector(AbstractDetector):
|
|
|
42
41
|
"""
|
|
43
42
|
|
|
44
43
|
name = "hatch"
|
|
44
|
+
required_tool = "hatch" # tool-or-directory contract
|
|
45
45
|
|
|
46
|
-
def
|
|
47
|
-
|
|
46
|
+
def _candidate_dirs(self) -> tuple[Path, ...]:
|
|
47
|
+
# ...or its global environments directory exists.
|
|
48
|
+
return (_hatch_envs_root(),)
|
|
48
49
|
|
|
49
50
|
def detect(self, path: Path) -> list[Environment]: # noqa: ARG002
|
|
50
51
|
envs_root = _hatch_envs_root()
|
|
@@ -5,7 +5,6 @@ from __future__ import annotations
|
|
|
5
5
|
import logging
|
|
6
6
|
import os
|
|
7
7
|
import platform
|
|
8
|
-
import shutil
|
|
9
8
|
from datetime import datetime, timezone
|
|
10
9
|
from pathlib import Path
|
|
11
10
|
|
|
@@ -35,9 +34,11 @@ class PipenvDetector(AbstractDetector):
|
|
|
35
34
|
"""
|
|
36
35
|
|
|
37
36
|
name = "pipenv"
|
|
37
|
+
required_tool = "pipenv" # tool-or-directory contract
|
|
38
38
|
|
|
39
|
-
def
|
|
40
|
-
|
|
39
|
+
def _candidate_dirs(self) -> tuple[Path, ...]:
|
|
40
|
+
# ...or its global virtualenvs directory exists.
|
|
41
|
+
return (_pipenv_venvs_root(),)
|
|
41
42
|
|
|
42
43
|
def detect(self, path: Path) -> list[Environment]: # noqa: ARG002
|
|
43
44
|
venvs_root = _pipenv_venvs_root()
|
|
@@ -6,7 +6,6 @@ import json
|
|
|
6
6
|
import logging
|
|
7
7
|
import os
|
|
8
8
|
import platform
|
|
9
|
-
import shutil
|
|
10
9
|
import subprocess
|
|
11
10
|
from datetime import datetime, timezone
|
|
12
11
|
from pathlib import Path
|
|
@@ -82,9 +81,7 @@ class PipxDetector(AbstractDetector):
|
|
|
82
81
|
"""
|
|
83
82
|
|
|
84
83
|
name = "pipx"
|
|
85
|
-
|
|
86
|
-
def can_handle(self) -> bool:
|
|
87
|
-
return shutil.which("pipx") is not None
|
|
84
|
+
required_tool = "pipx" # needs the pipx CLI on PATH
|
|
88
85
|
|
|
89
86
|
def detect(self, path: Path) -> list[Environment]: # noqa: ARG002
|
|
90
87
|
try:
|
|
@@ -46,8 +46,9 @@ class PoetryDetector(AbstractDetector):
|
|
|
46
46
|
|
|
47
47
|
name = "poetry"
|
|
48
48
|
|
|
49
|
-
def
|
|
50
|
-
|
|
49
|
+
def _candidate_dirs(self) -> tuple[Path, ...]:
|
|
50
|
+
# Contract: directory — applies only if Poetry's virtualenvs cache exists.
|
|
51
|
+
return (_poetry_venvs_dir(),)
|
|
51
52
|
|
|
52
53
|
def detect(self, path: Path) -> list[Environment]: # noqa: ARG002
|
|
53
54
|
venvs_dir = _poetry_venvs_dir()
|
|
@@ -42,8 +42,9 @@ class PyenvDetector(AbstractDetector):
|
|
|
42
42
|
|
|
43
43
|
name = "pyenv"
|
|
44
44
|
|
|
45
|
-
def
|
|
46
|
-
|
|
45
|
+
def _candidate_dirs(self) -> tuple[Path, ...]:
|
|
46
|
+
# Contract: directory — applies only if pyenv's versions directory exists.
|
|
47
|
+
return (_pyenv_versions_root(),)
|
|
47
48
|
|
|
48
49
|
def detect(self, path: Path) -> list[Environment]: # noqa: ARG002
|
|
49
50
|
versions_root = _pyenv_versions_root()
|
|
@@ -7,29 +7,25 @@ import os
|
|
|
7
7
|
from datetime import datetime, timezone
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
-
from killpy.detectors.base import AbstractDetector
|
|
10
|
+
from killpy.detectors.base import VCS_PRUNE_DIRS, AbstractDetector
|
|
11
11
|
from killpy.files import get_total_size
|
|
12
12
|
from killpy.models import Environment
|
|
13
13
|
|
|
14
14
|
logger = logging.getLogger(__name__)
|
|
15
15
|
|
|
16
|
-
_PRUNED: frozenset[str] = frozenset({".git", ".hg", ".svn", "node_modules"})
|
|
17
|
-
|
|
18
16
|
|
|
19
17
|
class ToxDetector(AbstractDetector):
|
|
20
18
|
"""Detects ``.tox`` directories created by tox test automation."""
|
|
21
19
|
|
|
22
20
|
name = "tox"
|
|
23
|
-
|
|
24
|
-
def can_handle(self) -> bool:
|
|
25
|
-
return True # pure filesystem walk
|
|
21
|
+
always_available = True # pure filesystem walk
|
|
26
22
|
|
|
27
23
|
def detect(self, path: Path) -> list[Environment]:
|
|
28
24
|
envs: list[Environment] = []
|
|
29
25
|
for current_root, directories, _ in os.walk(path, topdown=True):
|
|
30
26
|
pruned = set()
|
|
31
27
|
for d in directories:
|
|
32
|
-
if d in
|
|
28
|
+
if d in VCS_PRUNE_DIRS:
|
|
33
29
|
pruned.add(d)
|
|
34
30
|
continue
|
|
35
31
|
if d == ".tox":
|
|
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
6
|
import os
|
|
7
|
-
import shutil
|
|
8
7
|
from datetime import datetime, timezone
|
|
9
8
|
from pathlib import Path
|
|
10
9
|
|
|
@@ -52,13 +51,11 @@ class UvDetector(AbstractDetector):
|
|
|
52
51
|
"""
|
|
53
52
|
|
|
54
53
|
name = "uv"
|
|
54
|
+
required_tool = "uv" # tool-or-directory contract
|
|
55
55
|
|
|
56
|
-
def
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
or _uv_tools_dir().exists()
|
|
60
|
-
or _uv_python_dir().exists()
|
|
61
|
-
)
|
|
56
|
+
def _candidate_dirs(self) -> tuple[Path, ...]:
|
|
57
|
+
# ...or one of its tool/python data directories exists.
|
|
58
|
+
return (_uv_tools_dir(), _uv_python_dir())
|
|
62
59
|
|
|
63
60
|
def detect(self, path: Path) -> list[Environment]: # noqa: ARG002
|
|
64
61
|
envs: list[Environment] = []
|
|
@@ -7,19 +7,18 @@ import os
|
|
|
7
7
|
from datetime import datetime, timezone
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
-
from killpy.detectors.base import AbstractDetector
|
|
10
|
+
from killpy.detectors.base import VCS_PRUNE_DIRS, AbstractDetector
|
|
11
11
|
from killpy.files import get_total_size
|
|
12
12
|
from killpy.models import Environment
|
|
13
13
|
|
|
14
14
|
logger = logging.getLogger(__name__)
|
|
15
15
|
|
|
16
|
-
# Directory names that should never be walked into when
|
|
17
|
-
|
|
16
|
+
# Directory names that should never be walked into when looking for venvs.
|
|
17
|
+
# Wider than ``VCS_PRUNE_DIRS`` (it also skips tox/nox/cache/build trees) but,
|
|
18
|
+
# unlike the cache/artifact detectors, it must NOT exclude ``.venv`` /
|
|
19
|
+
# ``site-packages`` — those are exactly what this detector is looking for.
|
|
20
|
+
_EXCLUDED_DIRS: frozenset[str] = VCS_PRUNE_DIRS | frozenset(
|
|
18
21
|
{
|
|
19
|
-
".git",
|
|
20
|
-
".hg",
|
|
21
|
-
".svn",
|
|
22
|
-
"node_modules",
|
|
23
22
|
".tox",
|
|
24
23
|
".nox",
|
|
25
24
|
".mypy_cache",
|
|
@@ -57,12 +56,13 @@ class VenvDetector(AbstractDetector):
|
|
|
57
56
|
"""
|
|
58
57
|
|
|
59
58
|
name = "venv"
|
|
60
|
-
|
|
61
|
-
def can_handle(self) -> bool:
|
|
62
|
-
# Always applicable – pure filesystem scan.
|
|
63
|
-
return True
|
|
59
|
+
always_available = True # pure filesystem walk
|
|
64
60
|
|
|
65
61
|
def detect(self, path: Path) -> list[Environment]:
|
|
62
|
+
# Inner dedup: the ".venv" scan and the pyvenv.cfg scan below can both
|
|
63
|
+
# reach the same directory (a ".venv" that also contains pyvenv.cfg).
|
|
64
|
+
# This local `seen` set collapses those; the Scanner deduplicates again
|
|
65
|
+
# across detectors, so both layers are intentional (see ARCH P5).
|
|
66
66
|
seen: set[Path] = set()
|
|
67
67
|
envs: list[Environment] = []
|
|
68
68
|
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
"""File-size helpers: recursive byte totals and human-readable formatting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
1
5
|
import os
|
|
2
6
|
from pathlib import Path
|
|
3
7
|
|
|
@@ -19,12 +23,13 @@ def get_total_size(path: Path) -> int:
|
|
|
19
23
|
return total_size
|
|
20
24
|
|
|
21
25
|
|
|
22
|
-
def format_size(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
def format_size(size_bytes: int) -> str:
|
|
27
|
+
"""Return *size_bytes* as a human-readable string (GB / MB / KB / bytes)."""
|
|
28
|
+
if size_bytes >= 1 << 30:
|
|
29
|
+
return f"{size_bytes / (1 << 30):.2f} GB"
|
|
30
|
+
elif size_bytes >= 1 << 20:
|
|
31
|
+
return f"{size_bytes / (1 << 20):.2f} MB"
|
|
32
|
+
elif size_bytes >= 1 << 10:
|
|
33
|
+
return f"{size_bytes / (1 << 10):.2f} KB"
|
|
29
34
|
else:
|
|
30
|
-
return f"{
|
|
35
|
+
return f"{size_bytes} bytes"
|
|
@@ -76,16 +76,22 @@ class GitAnalyzer:
|
|
|
76
76
|
logger.debug("Could not parse git timestamp %r: %s", raw, exc)
|
|
77
77
|
return None
|
|
78
78
|
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _is_recent(commit: datetime | None, threshold_days: int) -> bool:
|
|
81
|
+
"""Return ``True`` when *commit* is within *threshold_days* of now."""
|
|
82
|
+
if commit is None:
|
|
83
|
+
return False
|
|
84
|
+
age_days = (datetime.now(tz=timezone.utc) - commit).days
|
|
85
|
+
return age_days < threshold_days
|
|
86
|
+
|
|
79
87
|
@staticmethod
|
|
80
88
|
def is_active_repo(
|
|
81
89
|
repo_root: Path, threshold_days: int = _ACTIVE_THRESHOLD_DAYS
|
|
82
90
|
) -> bool:
|
|
83
91
|
"""Return ``True`` when the repo had a commit within *threshold_days*."""
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
age_days = (datetime.now(tz=timezone.utc) - last).days
|
|
88
|
-
return age_days < threshold_days
|
|
92
|
+
return GitAnalyzer._is_recent(
|
|
93
|
+
GitAnalyzer.get_last_commit(repo_root), threshold_days
|
|
94
|
+
)
|
|
89
95
|
|
|
90
96
|
# ------------------------------------------------------------------ #
|
|
91
97
|
# Public orchestrator #
|
|
@@ -106,10 +112,7 @@ class GitAnalyzer:
|
|
|
106
112
|
return GitInfo(is_git_repo=False, is_active=False)
|
|
107
113
|
|
|
108
114
|
last_commit = GitAnalyzer.get_last_commit(repo_root)
|
|
109
|
-
is_active =
|
|
110
|
-
if last_commit is not None:
|
|
111
|
-
age_days = (datetime.now(tz=timezone.utc) - last_commit).days
|
|
112
|
-
is_active = age_days < _ACTIVE_THRESHOLD_DAYS
|
|
115
|
+
is_active = GitAnalyzer._is_recent(last_commit, _ACTIVE_THRESHOLD_DAYS)
|
|
113
116
|
|
|
114
117
|
return GitInfo(
|
|
115
118
|
is_git_repo=True,
|
|
@@ -27,9 +27,11 @@ class Environment:
|
|
|
27
27
|
environments (conda, pipx) this is the physical venv directory used
|
|
28
28
|
for size calculation and display.
|
|
29
29
|
name:
|
|
30
|
-
Human-readable name.
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
Human-readable name. Filesystem-walk detectors (venv, cache, tox,
|
|
31
|
+
artifacts) store the full path string; global/tool detectors store a
|
|
32
|
+
short identifier: the directory basename (poetry, pyenv, pipenv, uv),
|
|
33
|
+
the managing tool's env/package name (conda, pipx), or
|
|
34
|
+
``"<project>/<env>"`` (hatch).
|
|
33
35
|
type:
|
|
34
36
|
Short detector tag, e.g. ``"venv"``, ``"pyvenv.cfg"``, ``"poetry"``,
|
|
35
37
|
``"conda"``, ``"pipx"``, ``"cache"``, ``"uv"``, ``"artifacts"``…
|
|
@@ -131,6 +133,16 @@ class Suggestion:
|
|
|
131
133
|
reasons: list[str]
|
|
132
134
|
recommended_action: str
|
|
133
135
|
|
|
136
|
+
def to_dict(self) -> dict:
|
|
137
|
+
"""Return a JSON-serialisable representation."""
|
|
138
|
+
return {
|
|
139
|
+
"env_path": str(self.env_path),
|
|
140
|
+
"score": self.score,
|
|
141
|
+
"category": self.category,
|
|
142
|
+
"reasons": self.reasons,
|
|
143
|
+
"recommended_action": self.recommended_action,
|
|
144
|
+
}
|
|
145
|
+
|
|
134
146
|
|
|
135
147
|
@dataclass
|
|
136
148
|
class ScanRecord:
|
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: killpy
|
|
3
|
-
Version: 0.25.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.25.1
|
|
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
|
|
@@ -596,7 +604,7 @@ ______________________________________________________________________
|
|
|
596
604
|
|
|
597
605
|
```yaml
|
|
598
606
|
- repo: https://github.com/Tlaloc-Es/KillPy
|
|
599
|
-
rev: 0.
|
|
607
|
+
rev: 0.25.1
|
|
600
608
|
hooks:
|
|
601
609
|
- id: killpy # remove __pycache__ on every commit
|
|
602
610
|
- id: killpy-clean-caches # also removes .mypy_cache, .pytest_cache, .ruff_cache
|
|
@@ -621,7 +629,7 @@ Typical minimal setup (safe for daily use):
|
|
|
621
629
|
|
|
622
630
|
```yaml
|
|
623
631
|
- repo: https://github.com/Tlaloc-Es/KillPy
|
|
624
|
-
rev: 0.25.
|
|
632
|
+
rev: 0.25.1
|
|
625
633
|
hooks:
|
|
626
634
|
- id: killpy
|
|
627
635
|
```
|
|
@@ -1,15 +1,39 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "killpy"
|
|
3
|
-
version = "0.25.
|
|
4
|
-
description = "
|
|
3
|
+
version = "0.25.1"
|
|
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
|
+
keywords = [
|
|
7
|
+
"venv",
|
|
8
|
+
"virtualenv",
|
|
9
|
+
"conda",
|
|
10
|
+
"poetry",
|
|
11
|
+
"pipenv",
|
|
12
|
+
"pipx",
|
|
13
|
+
"pyenv",
|
|
14
|
+
"uv",
|
|
15
|
+
"cleanup",
|
|
16
|
+
"disk-space",
|
|
17
|
+
"cache",
|
|
18
|
+
"cli",
|
|
19
|
+
"tui",
|
|
20
|
+
"textual",
|
|
21
|
+
"developer-tools",
|
|
22
|
+
]
|
|
6
23
|
classifiers = [
|
|
24
|
+
"Development Status :: 4 - Beta",
|
|
7
25
|
"Environment :: Console",
|
|
26
|
+
"Environment :: Console :: Curses",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"Intended Audience :: System Administrators",
|
|
8
29
|
"License :: OSI Approved :: MIT License",
|
|
30
|
+
"Operating System :: OS Independent",
|
|
9
31
|
"Programming Language :: Python :: 3.10",
|
|
10
32
|
"Programming Language :: Python :: 3.11",
|
|
11
33
|
"Programming Language :: Python :: 3.12",
|
|
12
34
|
"Programming Language :: Python :: 3.13",
|
|
35
|
+
"Topic :: System :: Systems Administration",
|
|
36
|
+
"Topic :: Utilities",
|
|
13
37
|
]
|
|
14
38
|
dependencies = [
|
|
15
39
|
"click>=8.1.8",
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
"""Abstract base class for all killpy detectors."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from abc import ABC, abstractmethod
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
|
|
8
|
-
from killpy.models import Environment
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class AbstractDetector(ABC):
|
|
12
|
-
"""Common interface every detector must implement.
|
|
13
|
-
|
|
14
|
-
Concrete detectors live in this package (``killpy/detectors/``). The
|
|
15
|
-
:class:`~killpy.scanner.Scanner` instantiates them and calls
|
|
16
|
-
:meth:`can_handle` before :meth:`detect`, so detectors that rely on
|
|
17
|
-
optional tools (conda, pipx …) are silently skipped when those tools
|
|
18
|
-
are not present.
|
|
19
|
-
|
|
20
|
-
Class attributes
|
|
21
|
-
----------------
|
|
22
|
-
name : str
|
|
23
|
-
Short identifier used for logging, filtering and the ``--type``
|
|
24
|
-
CLI option. Must be unique across all detectors.
|
|
25
|
-
"""
|
|
26
|
-
|
|
27
|
-
name: str = "base"
|
|
28
|
-
|
|
29
|
-
@abstractmethod
|
|
30
|
-
def detect(self, path: Path) -> list[Environment]:
|
|
31
|
-
"""Scan *path* and return every matching :class:`~killpy.models.Environment`.
|
|
32
|
-
|
|
33
|
-
Implementations **must not raise** – on error they should log and
|
|
34
|
-
return an empty list so that other detectors are unaffected.
|
|
35
|
-
|
|
36
|
-
Parameters
|
|
37
|
-
----------
|
|
38
|
-
path:
|
|
39
|
-
Root directory to scan. Global-cache detectors (poetry, pipx …)
|
|
40
|
-
may ignore this argument; local detectors (venv, tox …) should
|
|
41
|
-
use it as the walk root.
|
|
42
|
-
"""
|
|
43
|
-
|
|
44
|
-
@abstractmethod
|
|
45
|
-
def can_handle(self) -> bool:
|
|
46
|
-
"""Return ``True`` when this detector is usable in the current environment.
|
|
47
|
-
|
|
48
|
-
Examples of conditions to check:
|
|
49
|
-
|
|
50
|
-
* Required CLI tool is on ``PATH`` (conda, pipx …).
|
|
51
|
-
* Required cache directory exists (poetry, pipenv …).
|
|
52
|
-
* Always ``True`` for pure filesystem detectors (venv, cache …).
|
|
53
|
-
"""
|
|
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
|
|
File without changes
|