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.
Files changed (45) hide show
  1. {killpy-0.25.0 → killpy-0.25.1}/PKG-INFO +12 -4
  2. {killpy-0.25.0 → killpy-0.25.1}/README.md +2 -2
  3. {killpy-0.25.0 → killpy-0.25.1}/killpy/__main__.py +6 -2
  4. {killpy-0.25.0 → killpy-0.25.1}/killpy/cleaners/__init__.py +4 -0
  5. {killpy-0.25.0 → killpy-0.25.1}/killpy/cli.py +26 -24
  6. {killpy-0.25.0 → killpy-0.25.1}/killpy/commands/clean.py +7 -2
  7. {killpy-0.25.0 → killpy-0.25.1}/killpy/commands/doctor.py +40 -43
  8. {killpy-0.25.0 → killpy-0.25.1}/killpy/commands/list.py +2 -1
  9. {killpy-0.25.0 → killpy-0.25.1}/killpy/detectors/artifacts.py +10 -10
  10. killpy-0.25.1/killpy/detectors/base.py +101 -0
  11. {killpy-0.25.0 → killpy-0.25.1}/killpy/detectors/cache.py +19 -21
  12. {killpy-0.25.0 → killpy-0.25.1}/killpy/detectors/conda.py +1 -4
  13. {killpy-0.25.0 → killpy-0.25.1}/killpy/detectors/hatch.py +4 -3
  14. {killpy-0.25.0 → killpy-0.25.1}/killpy/detectors/pipenv.py +4 -3
  15. {killpy-0.25.0 → killpy-0.25.1}/killpy/detectors/pipx.py +1 -4
  16. {killpy-0.25.0 → killpy-0.25.1}/killpy/detectors/poetry.py +3 -2
  17. {killpy-0.25.0 → killpy-0.25.1}/killpy/detectors/pyenv.py +3 -2
  18. {killpy-0.25.0 → killpy-0.25.1}/killpy/detectors/tox.py +3 -7
  19. {killpy-0.25.0 → killpy-0.25.1}/killpy/detectors/uv.py +4 -7
  20. {killpy-0.25.0 → killpy-0.25.1}/killpy/detectors/venv.py +11 -11
  21. {killpy-0.25.0 → killpy-0.25.1}/killpy/files/__init__.py +13 -8
  22. {killpy-0.25.0 → killpy-0.25.1}/killpy/intelligence/git_analyzer.py +12 -9
  23. {killpy-0.25.0 → killpy-0.25.1}/killpy/models.py +15 -3
  24. {killpy-0.25.0 → killpy-0.25.1}/killpy.egg-info/PKG-INFO +12 -4
  25. {killpy-0.25.0 → killpy-0.25.1}/pyproject.toml +26 -2
  26. killpy-0.25.0/killpy/detectors/base.py +0 -53
  27. {killpy-0.25.0 → killpy-0.25.1}/killpy/__init__.py +0 -0
  28. {killpy-0.25.0 → killpy-0.25.1}/killpy/cleaner.py +0 -0
  29. {killpy-0.25.0 → killpy-0.25.1}/killpy/commands/__init__.py +0 -0
  30. {killpy-0.25.0 → killpy-0.25.1}/killpy/commands/_utils.py +0 -0
  31. {killpy-0.25.0 → killpy-0.25.1}/killpy/commands/delete.py +0 -0
  32. {killpy-0.25.0 → killpy-0.25.1}/killpy/commands/find.py +0 -0
  33. {killpy-0.25.0 → killpy-0.25.1}/killpy/commands/stats.py +0 -0
  34. {killpy-0.25.0 → killpy-0.25.1}/killpy/detectors/__init__.py +0 -0
  35. {killpy-0.25.0 → killpy-0.25.1}/killpy/intelligence/__init__.py +0 -0
  36. {killpy-0.25.0 → killpy-0.25.1}/killpy/intelligence/scoring.py +0 -0
  37. {killpy-0.25.0 → killpy-0.25.1}/killpy/intelligence/suggestions.py +0 -0
  38. {killpy-0.25.0 → killpy-0.25.1}/killpy/intelligence/tracker.py +0 -0
  39. {killpy-0.25.0 → killpy-0.25.1}/killpy/scanner.py +0 -0
  40. {killpy-0.25.0 → killpy-0.25.1}/killpy.egg-info/SOURCES.txt +0 -0
  41. {killpy-0.25.0 → killpy-0.25.1}/killpy.egg-info/dependency_links.txt +0 -0
  42. {killpy-0.25.0 → killpy-0.25.1}/killpy.egg-info/entry_points.txt +0 -0
  43. {killpy-0.25.0 → killpy-0.25.1}/killpy.egg-info/requires.txt +0 -0
  44. {killpy-0.25.0 → killpy-0.25.1}/killpy.egg-info/top_level.txt +0 -0
  45. {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.0
4
- Summary: List all .venv directories and Conda environments 🐍 on your system and check how much space they are using. You can then choose which ones to delete in order to free up space 🧹.
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.24.1
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.0
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.24.1
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.0
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 clean
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
+ """Stateless ``__pycache__`` removal helper used by ``killpy clean``."""
2
+
3
+ from __future__ import annotations
4
+
1
5
  import os
2
6
  import shutil
3
7
  from pathlib import Path
@@ -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 is_venv_tab(func):
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 is_pipx_tab(func):
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 shorten_path_for_table(path_value, max_parts: int = 2) -> str:
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
- shorten_path_for_table(row["path"]),
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
- shorten_path_for_table(row["path"]),
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
- async def action_clean_pycache(self):
613
- total_freed_space = await asyncio.to_thread(remove_pycache, self.root_dir)
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
- @is_venv_tab
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.query_one("#status-label", Label).update(
666
- f"{format_size(self.bytes_release)} deleted"
667
- )
673
+ self._show_bytes_released()
668
674
  self.bell()
669
675
 
670
- @is_venv_tab
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
- @is_venv_tab
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.query_one("#status-label", Label).update(
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
- @is_pipx_tab
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.query_one("#status-label", Label).update(
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
- @is_venv_tab
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
- @is_venv_tab
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
- @is_venv_tab
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 clean(path: Path):
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
- # Environments smaller than this threshold are excluded from "wasted" totals.
19
+ # HIGH-category environments below this size don't count toward "wasted" totals.
20
20
  _IMPACTFUL_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
21
- _SMALL_SIZE_BYTES = 1 * 1024 * 1024 # 1 MB (matches suggestions.py)
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, envs)
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 _output_json(suggestions: list[Suggestion], envs) -> None: # type: ignore[type-arg]
93
- total_size = sum(e.size_bytes for e in envs)
94
- env_by_path = {e.path: e for e in envs}
95
- high = [s for s in suggestions if s.category == "HIGH"]
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
- env_by_path[s.env_path].size_bytes
98
- for s in high
99
- if s.env_path in env_by_path
100
- and env_by_path[s.env_path].size_bytes >= _IMPACTFUL_SIZE_BYTES
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(envs),
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
- "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
- "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, # type: ignore[type-arg]
141
+ scored_envs: list[ScoredEnvironment],
133
142
  path: Path,
134
143
  *,
135
144
  show_all: bool = False,
136
145
  ) -> None:
137
- total_size = sum(se.env.size_bytes for se in scored_envs)
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 AbstractDetector
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
- _PRUNED: frozenset[str] = frozenset({".git", ".hg", ".svn", "node_modules"})
17
- # Environment internals are not build artifacts: every installed package has
18
- # a ``site-packages/*.dist-info`` directory whose deletion corrupts the env.
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 _PRUNED or d in _ENV_DIRS:
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 AbstractDetector
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
- # Directories to prune from the walk so we don't descend into them.
35
- _PRUNED: frozenset[str] = frozenset({".git", ".hg", ".svn", "node_modules"})
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
- prune = set()
96
+ pruned = set()
99
97
  for d in directories:
100
- if d in _PRUNED or d in _ENV_DIRS:
101
- prune.add(d)
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
- prune.add(d) # don't recurse inside cache dirs
111
- directories[:] = [d for d in directories if d not in prune]
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(p: Path, tag: str) -> Environment:
145
- stat = p.stat()
146
- size = get_total_size(p)
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=p,
150
- name=str(p),
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 can_handle(self) -> bool:
47
- return shutil.which("hatch") is not None or _hatch_envs_root().exists()
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 can_handle(self) -> bool:
40
- return shutil.which("pipenv") is not None or _pipenv_venvs_root().exists()
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 can_handle(self) -> bool:
50
- return _poetry_venvs_dir().exists()
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 can_handle(self) -> bool:
46
- return _pyenv_versions_root().exists()
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 _PRUNED:
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 can_handle(self) -> bool:
57
- return (
58
- shutil.which("uv") is not None
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 scanning.
17
- _EXCLUDED_DIRS: frozenset[str] = frozenset(
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(size_in_bytes: int):
23
- if size_in_bytes >= 1 << 30:
24
- return f"{size_in_bytes / (1 << 30):.2f} GB"
25
- elif size_in_bytes >= 1 << 20:
26
- return f"{size_in_bytes / (1 << 20):.2f} MB"
27
- elif size_in_bytes >= 1 << 10:
28
- return f"{size_in_bytes / (1 << 10):.2f} KB"
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"{size_in_bytes} bytes"
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
- last = GitAnalyzer.get_last_commit(repo_root)
85
- if last is None:
86
- return False
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 = False
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. For filesystem envs this defaults to the last
31
- two parts of *path*; for named envs (conda, pipx) it is the package /
32
- environment name.
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.0
4
- Summary: List all .venv directories and Conda environments 🐍 on your system and check how much space they are using. You can then choose which ones to delete in order to free up space 🧹.
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.24.1
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.0
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.0"
4
- description = "List all .venv directories and Conda environments 🐍 on your system and check how much space they are using. You can then choose which ones to delete in order to free up space 🧹."
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