killpy 0.24.0__tar.gz → 0.24.2__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.24.0 → killpy-0.24.2}/PKG-INFO +5 -2
  2. {killpy-0.24.0 → killpy-0.24.2}/README.md +4 -1
  3. {killpy-0.24.0 → killpy-0.24.2}/killpy/__main__.py +22 -8
  4. {killpy-0.24.0 → killpy-0.24.2}/killpy/cleaner.py +19 -2
  5. {killpy-0.24.0 → killpy-0.24.2}/killpy/cli.py +54 -23
  6. {killpy-0.24.0 → killpy-0.24.2}/killpy/commands/delete.py +10 -2
  7. {killpy-0.24.0 → killpy-0.24.2}/killpy/detectors/artifacts.py +14 -3
  8. {killpy-0.24.0 → killpy-0.24.2}/killpy/detectors/cache.py +40 -13
  9. killpy-0.24.2/killpy/detectors/uv.py +96 -0
  10. {killpy-0.24.0 → killpy-0.24.2}/killpy/intelligence/suggestions.py +0 -8
  11. {killpy-0.24.0 → killpy-0.24.2}/killpy/intelligence/tracker.py +29 -1
  12. {killpy-0.24.0 → killpy-0.24.2}/killpy/models.py +2 -2
  13. {killpy-0.24.0 → killpy-0.24.2}/killpy.egg-info/PKG-INFO +5 -2
  14. {killpy-0.24.0 → killpy-0.24.2}/pyproject.toml +1 -1
  15. killpy-0.24.0/killpy/detectors/uv.py +0 -64
  16. {killpy-0.24.0 → killpy-0.24.2}/killpy/__init__.py +0 -0
  17. {killpy-0.24.0 → killpy-0.24.2}/killpy/cleaners/__init__.py +0 -0
  18. {killpy-0.24.0 → killpy-0.24.2}/killpy/commands/__init__.py +0 -0
  19. {killpy-0.24.0 → killpy-0.24.2}/killpy/commands/_utils.py +0 -0
  20. {killpy-0.24.0 → killpy-0.24.2}/killpy/commands/clean.py +0 -0
  21. {killpy-0.24.0 → killpy-0.24.2}/killpy/commands/doctor.py +0 -0
  22. {killpy-0.24.0 → killpy-0.24.2}/killpy/commands/find.py +0 -0
  23. {killpy-0.24.0 → killpy-0.24.2}/killpy/commands/list.py +0 -0
  24. {killpy-0.24.0 → killpy-0.24.2}/killpy/commands/stats.py +0 -0
  25. {killpy-0.24.0 → killpy-0.24.2}/killpy/detectors/__init__.py +0 -0
  26. {killpy-0.24.0 → killpy-0.24.2}/killpy/detectors/base.py +0 -0
  27. {killpy-0.24.0 → killpy-0.24.2}/killpy/detectors/conda.py +0 -0
  28. {killpy-0.24.0 → killpy-0.24.2}/killpy/detectors/hatch.py +0 -0
  29. {killpy-0.24.0 → killpy-0.24.2}/killpy/detectors/pipenv.py +0 -0
  30. {killpy-0.24.0 → killpy-0.24.2}/killpy/detectors/pipx.py +0 -0
  31. {killpy-0.24.0 → killpy-0.24.2}/killpy/detectors/poetry.py +0 -0
  32. {killpy-0.24.0 → killpy-0.24.2}/killpy/detectors/pyenv.py +0 -0
  33. {killpy-0.24.0 → killpy-0.24.2}/killpy/detectors/tox.py +0 -0
  34. {killpy-0.24.0 → killpy-0.24.2}/killpy/detectors/venv.py +0 -0
  35. {killpy-0.24.0 → killpy-0.24.2}/killpy/files/__init__.py +0 -0
  36. {killpy-0.24.0 → killpy-0.24.2}/killpy/intelligence/__init__.py +0 -0
  37. {killpy-0.24.0 → killpy-0.24.2}/killpy/intelligence/git_analyzer.py +0 -0
  38. {killpy-0.24.0 → killpy-0.24.2}/killpy/intelligence/scoring.py +0 -0
  39. {killpy-0.24.0 → killpy-0.24.2}/killpy/scanner.py +0 -0
  40. {killpy-0.24.0 → killpy-0.24.2}/killpy.egg-info/SOURCES.txt +0 -0
  41. {killpy-0.24.0 → killpy-0.24.2}/killpy.egg-info/dependency_links.txt +0 -0
  42. {killpy-0.24.0 → killpy-0.24.2}/killpy.egg-info/entry_points.txt +0 -0
  43. {killpy-0.24.0 → killpy-0.24.2}/killpy.egg-info/requires.txt +0 -0
  44. {killpy-0.24.0 → killpy-0.24.2}/killpy.egg-info/top_level.txt +0 -0
  45. {killpy-0.24.0 → killpy-0.24.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: killpy
3
- Version: 0.24.0
3
+ Version: 0.24.2
4
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 🧹.
5
5
  Project-URL: Homepage, https://github.com/Tlaloc-Es/killpy
6
6
  Project-URL: Documentation, https://tlaloc-es.github.io/killpy/
@@ -31,6 +31,8 @@ Find and delete old `.venv`, conda, poetry, pipenv, uv and more — safely, in s
31
31
  uvx killpy --path ~
32
32
  ```
33
33
 
34
+ > ⭐ Featured in [awesome-python](https://github.com/vinta/awesome-python) — a curated list of the best Python tools
35
+
34
36
  [Documentation](https://tlaloc-es.github.io/killpy/)
35
37
 
36
38
  [![PyPI](https://img.shields.io/pypi/v/killpy.svg)](https://pypi.org/project/killpy/)
@@ -40,6 +42,7 @@ uvx killpy --path ~
40
42
  [![Coverage](https://codecov.io/gh/Tlaloc-Es/killpy/branch/master/graph/badge.svg)](https://codecov.io/gh/Tlaloc-Es/killpy)
41
43
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
42
44
  [![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](<https://twitter.com/intent/tweet?text=%F0%9F%90%8D%20KillPy%20helps%20you%20reclaim%20disk%20space%20by%20detecting%20unused%20Python%20environments%20(.venv,%20poetry%20env,%20conda%20env)%20and%20pipx%20packages.%20Clean,%20organize%20and%20free%20up%20space%20effortlessly!%20%F0%9F%9A%80&url=https://github.com/Tlaloc-Es/KillPy>)
45
+ [![Awesome Python](https://awesome.re/badge.svg)](https://github.com/vinta/awesome-python)
43
46
 
44
47
  ![killpy in action](https://raw.githubusercontent.com/Tlaloc-Es/killpy/master/show.gif)
45
48
 
@@ -584,7 +587,7 @@ Typical minimal setup (safe for daily use):
584
587
 
585
588
  ```yaml
586
589
  - repo: https://github.com/Tlaloc-Es/KillPy
587
- rev: 0.24.0
590
+ rev: 0.24.2
588
591
  hooks:
589
592
  - id: killpy
590
593
  ```
@@ -10,6 +10,8 @@ Find and delete old `.venv`, conda, poetry, pipenv, uv and more — safely, in s
10
10
  uvx killpy --path ~
11
11
  ```
12
12
 
13
+ > ⭐ Featured in [awesome-python](https://github.com/vinta/awesome-python) — a curated list of the best Python tools
14
+
13
15
  [Documentation](https://tlaloc-es.github.io/killpy/)
14
16
 
15
17
  [![PyPI](https://img.shields.io/pypi/v/killpy.svg)](https://pypi.org/project/killpy/)
@@ -19,6 +21,7 @@ uvx killpy --path ~
19
21
  [![Coverage](https://codecov.io/gh/Tlaloc-Es/killpy/branch/master/graph/badge.svg)](https://codecov.io/gh/Tlaloc-Es/killpy)
20
22
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
21
23
  [![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](<https://twitter.com/intent/tweet?text=%F0%9F%90%8D%20KillPy%20helps%20you%20reclaim%20disk%20space%20by%20detecting%20unused%20Python%20environments%20(.venv,%20poetry%20env,%20conda%20env)%20and%20pipx%20packages.%20Clean,%20organize%20and%20free%20up%20space%20effortlessly!%20%F0%9F%9A%80&url=https://github.com/Tlaloc-Es/KillPy>)
24
+ [![Awesome Python](https://awesome.re/badge.svg)](https://github.com/vinta/awesome-python)
22
25
 
23
26
  ![killpy in action](https://raw.githubusercontent.com/Tlaloc-Es/killpy/master/show.gif)
24
27
 
@@ -563,7 +566,7 @@ Typical minimal setup (safe for daily use):
563
566
 
564
567
  ```yaml
565
568
  - repo: https://github.com/Tlaloc-Es/KillPy
566
- rev: 0.24.0
569
+ rev: 0.24.2
567
570
  hooks:
568
571
  - id: killpy
569
572
  ```
@@ -1,4 +1,5 @@
1
1
  import logging
2
+ import sys
2
3
  from pathlib import Path
3
4
 
4
5
  import click
@@ -6,7 +7,7 @@ from rich.console import Console
6
7
  from rich.progress import Progress, SpinnerColumn, TextColumn
7
8
  from rich.prompt import Confirm
8
9
 
9
- from killpy.cleaner import Cleaner
10
+ from killpy.cleaner import Cleaner, CleanerError
10
11
  from killpy.cli import TableApp
11
12
  from killpy.commands.clean import clean
12
13
  from killpy.commands.delete import delete_cmd
@@ -52,27 +53,40 @@ def _run_delete_all(path: Path, excluded: set[str], yes: bool) -> None:
52
53
  console.print("Aborted.")
53
54
  return
54
55
 
56
+ # Record this cleanup session up-front so `killpy stats --history` reflects
57
+ # it; record_deletion() below updates the same record with the freed bytes.
58
+ tracker = UsageTracker()
59
+ try:
60
+ tracker.record_scan_result(envs, path)
61
+ except Exception: # noqa: BLE001
62
+ pass
63
+
55
64
  deleted = 0
56
65
  freed = 0
66
+ errors = 0
57
67
  for env in envs:
58
- size = env.size_bytes
59
- if cleaner.delete(env):
60
- freed += size
68
+ try:
69
+ freed += cleaner.delete(env)
61
70
  deleted += 1
62
- else:
63
- console.print(f"[red]Failed to delete:[/red] {env.path}")
71
+ except CleanerError as exc:
72
+ console.print(f" [red]Failed to delete:[/red] {env.path}: {exc}")
73
+ errors += 1
64
74
 
65
75
  console.print(
66
76
  f"\n[bold green]Done.[/bold green] Deleted {deleted}/{len(envs)} environment(s), " # noqa: E501
67
77
  f"freed [bold]{format_size(freed)}[/bold]."
78
+ + (f" [red]{errors} error(s).[/red]" if errors else "")
68
79
  )
69
80
 
70
- # Best-effort: record deletion in history.
81
+ # Best-effort: update the history record created above with freed bytes.
71
82
  try:
72
- UsageTracker().record_deletion(freed)
83
+ tracker.record_deletion(freed)
73
84
  except Exception: # noqa: BLE001
74
85
  pass
75
86
 
87
+ if errors:
88
+ sys.exit(1)
89
+
76
90
 
77
91
  @click.group(invoke_without_command=True)
78
92
  @click.option(
@@ -11,8 +11,8 @@ Usage::
11
11
  The :class:`Cleaner` dispatches to the correct removal strategy based on
12
12
  :attr:`~killpy.models.Environment.managed_by`. Environments with
13
13
  ``managed_by="conda"`` are removed via ``conda env remove``;
14
- ``managed_by="pipx"`` via ``pipx uninstall``; all others via
15
- :func:`shutil.rmtree`.
14
+ ``managed_by="pipx"`` via ``pipx uninstall``; ``managed_by="uv"`` via
15
+ ``uv tool uninstall``; all others via :func:`shutil.rmtree`.
16
16
  """
17
17
 
18
18
  from __future__ import annotations
@@ -77,6 +77,8 @@ class Cleaner:
77
77
  self._remove_conda(env.name)
78
78
  elif env.managed_by == "pipx":
79
79
  self._remove_pipx(env.name)
80
+ elif env.managed_by == "uv":
81
+ self._remove_uv_tool(env.name)
80
82
  else:
81
83
  self._remove_filesystem(env.path)
82
84
  except CleanerError:
@@ -161,3 +163,18 @@ class Cleaner:
161
163
  raise CleanerError(
162
164
  f"pipx uninstall failed for '{package_name}': {result.stderr.strip()}"
163
165
  )
166
+
167
+ @staticmethod
168
+ def _remove_uv_tool(tool_name: str) -> None:
169
+ if shutil.which("uv") is None:
170
+ raise CleanerError("uv not found on PATH")
171
+ result = subprocess.run(
172
+ ["uv", "tool", "uninstall", tool_name],
173
+ check=False,
174
+ capture_output=True,
175
+ text=True,
176
+ )
177
+ if result.returncode != 0:
178
+ raise CleanerError(
179
+ f"uv tool uninstall failed for '{tool_name}': {result.stderr.strip()}"
180
+ )
@@ -11,6 +11,7 @@ from typing import Any, TypedDict
11
11
  from rich.text import Text
12
12
  from textual.app import App, ComposeResult
13
13
  from textual.binding import Binding
14
+ from textual.coordinate import Coordinate
14
15
  from textual.widgets import (
15
16
  DataTable,
16
17
  Footer,
@@ -25,7 +26,7 @@ from textual.widgets import (
25
26
  from killpy.cleaner import Cleaner, CleanerError
26
27
  from killpy.cleaners import remove_pycache
27
28
  from killpy.files import format_size
28
- from killpy.intelligence import SuggestionEngine, score_all
29
+ from killpy.intelligence import SuggestionEngine, UsageTracker, score_all
29
30
  from killpy.models import Environment
30
31
  from killpy.scanner import Scanner
31
32
 
@@ -142,9 +143,10 @@ class TableApp(App):
142
143
  self._filter_query: str = ""
143
144
  self._venv_display_indices: list[int] = []
144
145
  self._multi_select_mode: bool = False
145
- self._selected_venv_indices: set[int] = set()
146
+ self._selected_venv_paths: set[str] = set()
146
147
  self._health_by_path: dict[str, str] = {}
147
148
  self.cleaner = Cleaner()
149
+ self.tracker = UsageTracker()
148
150
  self.scanner = Scanner(
149
151
  types={
150
152
  "venv",
@@ -395,7 +397,7 @@ class TableApp(App):
395
397
  row["size"],
396
398
  row["size_human"],
397
399
  _health_text(row["health"]),
398
- self._compute_row_status(i, row),
400
+ self._compute_row_status(row),
399
401
  )
400
402
 
401
403
  def render_pipx_table(self) -> None:
@@ -416,18 +418,18 @@ class TableApp(App):
416
418
  return data_index, self.venv_rows[data_index]
417
419
  return None
418
420
 
419
- def _compute_row_status(self, data_index: int, row: VenvRow) -> str:
421
+ def _compute_row_status(self, row: VenvRow) -> str:
420
422
  """Return the status string to display, taking multi-select into account."""
421
423
  if row["status"] == EnvStatus.DELETED.value:
422
424
  return EnvStatus.DELETED.value
423
- if self._multi_select_mode and data_index in self._selected_venv_indices:
425
+ if self._multi_select_mode and row["path"] in self._selected_venv_paths:
424
426
  return "\u25cf SELECTED"
425
427
  return row["status"]
426
428
 
427
429
  def _update_multi_select_label(self) -> None:
428
430
  label = self.query_one("#multi-select-label", Label)
429
431
  if self._multi_select_mode:
430
- n = len(self._selected_venv_indices)
432
+ n = len(self._selected_venv_paths)
431
433
  label.add_class("visible")
432
434
  label.update(
433
435
  f"[bold yellow]Multi-select[/bold yellow] — "
@@ -572,8 +574,27 @@ class TableApp(App):
572
574
  status_label.update(
573
575
  f"Found {venv_count} virtual environments and {pipx_count} pipx packages"
574
576
  )
577
+ self._record_scan()
575
578
  await self._compute_health_scores()
576
579
 
580
+ def _record_scan(self) -> None:
581
+ """Persist this scan session so ``killpy stats --history`` reflects it."""
582
+ try:
583
+ envs = [row["environment"] for row in self.venv_rows]
584
+ envs += [row["environment"] for row in self.pipx_rows]
585
+ self.tracker.record_scan_result(envs, self.root_dir)
586
+ except Exception: # noqa: BLE001
587
+ pass
588
+
589
+ def _record_deletion(self, freed: int) -> None:
590
+ """Best-effort: add *freed* bytes to the current history record."""
591
+ if freed <= 0:
592
+ return
593
+ try:
594
+ self.tracker.record_deletion(freed)
595
+ except Exception: # noqa: BLE001
596
+ pass
597
+
577
598
  async def _compute_health_scores(self) -> None:
578
599
  """Score venv environments and populate the Health column."""
579
600
  if not self.venv_rows:
@@ -614,25 +635,32 @@ class TableApp(App):
614
635
 
615
636
  @is_venv_tab
616
637
  def action_confirm_delete(self):
617
- if self._multi_select_mode and self._selected_venv_indices:
618
- # Multi-select mode: delete all selected rows
619
- for data_index in list(self._selected_venv_indices):
620
- row = self.venv_rows[data_index]
638
+ freed_now = 0
639
+ if self._multi_select_mode and self._selected_venv_paths:
640
+ # Multi-select mode: delete all selected rows. Selection is
641
+ # tracked by path (not by list position) so it stays valid even
642
+ # after the rows have been re-sorted.
643
+ for row in self.venv_rows:
644
+ if row["path"] not in self._selected_venv_paths:
645
+ continue
621
646
  if row["status"] == EnvStatus.DELETED.value:
622
647
  continue
623
648
  if self.delete_environment(row["environment"]):
649
+ freed_now += int(row["size"])
624
650
  self.bytes_release += int(row["size"])
625
651
  row["status"] = EnvStatus.DELETED.value
626
- self._selected_venv_indices.clear()
652
+ self._selected_venv_paths.clear()
627
653
  else:
628
654
  # Normal mode: delete all rows marked for deletion
629
655
  for row in self.venv_rows:
630
656
  if row["status"] != EnvStatus.MARKED_TO_DELETE.value:
631
657
  continue
632
658
  if self.delete_environment(row["environment"]):
659
+ freed_now += int(row["size"])
633
660
  self.bytes_release += int(row["size"])
634
661
  row["status"] = EnvStatus.DELETED.value
635
662
 
663
+ self._record_deletion(freed_now)
636
664
  self.render_venv_table()
637
665
  self.query_one("#status-label", Label).update(
638
666
  f"{format_size(self.bytes_release)} deleted"
@@ -675,6 +703,7 @@ class TableApp(App):
675
703
  return
676
704
  if self.delete_environment(row["environment"]):
677
705
  self.bytes_release += int(row["size"])
706
+ self._record_deletion(int(row["size"]))
678
707
  row["status"] = EnvStatus.DELETED.value
679
708
  table.update_cell_at(
680
709
  (cursor_cell.row, self.VENV_COL_STATUS),
@@ -685,8 +714,9 @@ class TableApp(App):
685
714
  )
686
715
  self.bell()
687
716
 
688
- @is_venv_tab
689
717
  def delete_environment(self, environment: Environment) -> bool:
718
+ # Shared helper — no tab gating here: it is called from both the
719
+ # venv tab (delete actions) and the pipx tab (uninstall action).
690
720
  try:
691
721
  self.cleaner.delete(environment)
692
722
  return True
@@ -706,6 +736,7 @@ class TableApp(App):
706
736
  row["status"] = EnvStatus.DELETED.value
707
737
  table.update_cell_at((cursor_cell.row, 3), EnvStatus.DELETED.value)
708
738
  self.bytes_release += int(row["size"])
739
+ self._record_deletion(int(row["size"]))
709
740
  self.query_one("#status-label", Label).update(
710
741
  f"{format_size(self.bytes_release)} deleted"
711
742
  )
@@ -794,7 +825,7 @@ class TableApp(App):
794
825
  """Toggle multi-select mode on/off (T key)."""
795
826
  self._multi_select_mode = not self._multi_select_mode
796
827
  if not self._multi_select_mode:
797
- self._selected_venv_indices.clear()
828
+ self._selected_venv_paths.clear()
798
829
  self._update_multi_select_label()
799
830
  self.render_venv_table()
800
831
 
@@ -810,17 +841,17 @@ class TableApp(App):
810
841
  resolved = self._resolve_venv_row(cursor_cell.row)
811
842
  if not resolved:
812
843
  return
813
- data_index, row = resolved
844
+ _, row = resolved
814
845
  if row["status"] == EnvStatus.DELETED.value:
815
846
  return
816
- if data_index in self._selected_venv_indices:
817
- self._selected_venv_indices.discard(data_index)
847
+ if row["path"] in self._selected_venv_paths:
848
+ self._selected_venv_paths.discard(row["path"])
818
849
  else:
819
- self._selected_venv_indices.add(data_index)
850
+ self._selected_venv_paths.add(row["path"])
820
851
  self._update_multi_select_label()
821
852
  table.update_cell_at(
822
- (cursor_cell.row, self.VENV_COL_STATUS),
823
- self._compute_row_status(data_index, row),
853
+ Coordinate(cursor_cell.row, self.VENV_COL_STATUS),
854
+ self._compute_row_status(row),
824
855
  )
825
856
 
826
857
  @is_venv_tab
@@ -829,13 +860,13 @@ class TableApp(App):
829
860
  if not self._multi_select_mode:
830
861
  return
831
862
  non_deleted = {
832
- i
863
+ self.venv_rows[i]["path"]
833
864
  for i in self._venv_display_indices
834
865
  if self.venv_rows[i]["status"] != EnvStatus.DELETED.value
835
866
  }
836
- if non_deleted == self._selected_venv_indices:
837
- self._selected_venv_indices.clear()
867
+ if non_deleted == self._selected_venv_paths:
868
+ self._selected_venv_paths.clear()
838
869
  else:
839
- self._selected_venv_indices = non_deleted
870
+ self._selected_venv_paths = non_deleted
840
871
  self._update_multi_select_label()
841
872
  self.render_venv_table()
@@ -96,6 +96,14 @@ def delete_cmd(
96
96
  abort=True,
97
97
  )
98
98
 
99
+ # Record this cleanup session up-front so `killpy stats --history` reflects
100
+ # it; record_deletion() below updates the same record with the freed bytes.
101
+ tracker = UsageTracker()
102
+ try:
103
+ tracker.record_scan_result(envs, path)
104
+ except Exception: # noqa: BLE001
105
+ pass
106
+
99
107
  cleaner = Cleaner(dry_run=False)
100
108
  freed = 0
101
109
  errors = 0
@@ -114,9 +122,9 @@ def delete_cmd(
114
122
  + (f" — [red]{errors} error(s)[/red]" if errors else "")
115
123
  )
116
124
 
117
- # Best-effort: record deletion in history.
125
+ # Best-effort: update the history record created above with freed bytes.
118
126
  try:
119
- UsageTracker().record_deletion(freed)
127
+ tracker.record_deletion(freed)
120
128
  except Exception: # noqa: BLE001
121
129
  pass
122
130
 
@@ -14,6 +14,10 @@ from killpy.models import Environment
14
14
  logger = logging.getLogger(__name__)
15
15
 
16
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"})
17
21
  _EXACT_NAMES: frozenset[str] = frozenset({"dist", "build"})
18
22
  _SUFFIXES: tuple[str, ...] = (".egg-info", ".dist-info")
19
23
 
@@ -28,7 +32,10 @@ class ArtifactsDetector(AbstractDetector):
28
32
  """Detects Python build artifact directories.
29
33
 
30
34
  Finds ``dist/``, ``build/``, ``*.egg-info/``, and ``*.dist-info/``
31
- directories under the scan root.
35
+ directories under the scan root. Virtual environments (``.venv``,
36
+ any directory containing ``pyvenv.cfg``) and ``site-packages`` trees
37
+ are skipped: their ``*.dist-info`` entries are package metadata, not
38
+ build output.
32
39
  """
33
40
 
34
41
  name = "artifacts"
@@ -38,10 +45,14 @@ class ArtifactsDetector(AbstractDetector):
38
45
 
39
46
  def detect(self, path: Path) -> list[Environment]:
40
47
  envs: list[Environment] = []
41
- for current_root, directories, _ in os.walk(path, topdown=True):
48
+ for current_root, directories, files in os.walk(path, topdown=True):
49
+ if "pyvenv.cfg" in files:
50
+ # Inside a virtual environment (whatever its name) — skip it.
51
+ directories[:] = []
52
+ continue
42
53
  pruned = set()
43
54
  for d in directories:
44
- if d in _PRUNED:
55
+ if d in _PRUNED or d in _ENV_DIRS:
45
56
  pruned.add(d)
46
57
  continue
47
58
  if _is_artifact_dir(d):
@@ -5,8 +5,8 @@ Covers:
5
5
  * ``.mypy_cache``
6
6
  * ``.pytest_cache``
7
7
  * ``.ruff_cache``
8
- * Global pip cache (``~/.cache/pip``)
9
- * Global uv cache (``~/.cache/uv``)
8
+ * Global pip cache (``~/.cache/pip``) — only when inside the scanned path
9
+ * Global uv cache (``~/.cache/uv``) — only when inside the scanned path
10
10
  """
11
11
 
12
12
  from __future__ import annotations
@@ -33,6 +33,11 @@ _LOCAL_CACHE_DIRS: tuple[str, ...] = (
33
33
  # Directories to prune from the walk so we don't descend into them.
34
34
  _PRUNED: frozenset[str] = frozenset({".git", ".hg", ".svn", "node_modules"})
35
35
 
36
+ # Caches inside an environment belong to the environment: VenvDetector
37
+ # already reports the whole tree, so listing them separately double-counts
38
+ # their size in stats/list totals.
39
+ _ENV_DIRS: frozenset[str] = frozenset({".venv", "site-packages"})
40
+
36
41
 
37
42
  class CacheDetector(AbstractDetector):
38
43
  """Detects local and global Python cache directories."""
@@ -45,18 +50,26 @@ class CacheDetector(AbstractDetector):
45
50
  def detect(self, path: Path) -> list[Environment]:
46
51
  envs: list[Environment] = []
47
52
  envs.extend(self._scan_local(path))
48
- envs.extend(self._scan_global())
53
+ envs.extend(self._scan_global(path))
49
54
  return envs
50
55
 
51
56
  # ------------------------------------------------------------------ #
52
57
 
53
58
  def _scan_local(self, root: Path) -> list[Environment]:
54
- """Walk *root* and collect all known local cache directories."""
59
+ """Walk *root* and collect all known local cache directories.
60
+
61
+ Virtual environments (``.venv``, any directory containing
62
+ ``pyvenv.cfg``) and ``site-packages`` trees are skipped.
63
+ """
55
64
  results: list[Environment] = []
56
- for current_root, directories, _ in os.walk(root, topdown=True):
65
+ for current_root, directories, files in os.walk(root, topdown=True):
66
+ if "pyvenv.cfg" in files:
67
+ # Inside a virtual environment (whatever its name) — skip it.
68
+ directories[:] = []
69
+ continue
57
70
  prune = set()
58
71
  for d in directories:
59
- if d in _PRUNED:
72
+ if d in _PRUNED or d in _ENV_DIRS:
60
73
  prune.add(d)
61
74
  continue
62
75
  if d in _LOCAL_CACHE_DIRS:
@@ -70,19 +83,33 @@ class CacheDetector(AbstractDetector):
70
83
  directories[:] = [d for d in directories if d not in prune]
71
84
  return results
72
85
 
73
- def _scan_global(self) -> list[Environment]:
74
- """Return global pip and uv cache directories if they exist."""
86
+ def _scan_global(self, root: Path) -> list[Environment]:
87
+ """Return global pip/uv cache directories that live under *root*.
88
+
89
+ A scan scoped to a project directory (e.g. the pre-commit hooks or
90
+ ``killpy delete --type cache --path <repo>``) must never surface —
91
+ and therefore never delete — caches outside that directory. The
92
+ global caches are still reported when the scan root contains them
93
+ (e.g. ``killpy --path ~``).
94
+ """
95
+ try:
96
+ scan_root = root.resolve()
97
+ except OSError:
98
+ scan_root = root
75
99
  results: list[Environment] = []
76
100
  candidates = [
77
101
  (Path.home() / ".cache" / "pip", "pip-cache"),
78
102
  (Path.home() / ".cache" / "uv", "uv-cache"),
79
103
  ]
80
104
  for cache_path, tag in candidates:
81
- if cache_path.exists():
82
- try:
83
- results.append(_make_cache_env(cache_path, tag))
84
- except (FileNotFoundError, OSError) as exc:
85
- logger.debug("Skipping global cache %s: %s", cache_path, exc)
105
+ if not cache_path.exists():
106
+ continue
107
+ try:
108
+ if not cache_path.resolve().is_relative_to(scan_root):
109
+ continue
110
+ results.append(_make_cache_env(cache_path, tag))
111
+ except (FileNotFoundError, OSError) as exc:
112
+ logger.debug("Skipping global cache %s: %s", cache_path, exc)
86
113
  return results
87
114
 
88
115
 
@@ -0,0 +1,96 @@
1
+ """Detector for disk space owned by uv itself (tool envs and Pythons)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import shutil
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ from killpy.detectors.base import AbstractDetector
12
+ from killpy.files import get_total_size
13
+ from killpy.models import Environment
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def _uv_data_dir() -> Path:
19
+ xdg = os.environ.get("XDG_DATA_HOME")
20
+ base = Path(xdg) if xdg else Path.home() / ".local" / "share"
21
+ return base / "uv"
22
+
23
+
24
+ def _uv_tools_dir() -> Path:
25
+ override = os.environ.get("UV_TOOL_DIR")
26
+ return Path(override) if override else _uv_data_dir() / "tools"
27
+
28
+
29
+ def _uv_python_dir() -> Path:
30
+ override = os.environ.get("UV_PYTHON_INSTALL_DIR")
31
+ return Path(override) if override else _uv_data_dir() / "python"
32
+
33
+
34
+ class UvDetector(AbstractDetector):
35
+ """Detects environments managed by uv itself.
36
+
37
+ Covers:
38
+
39
+ * Tool environments installed with ``uv tool install`` (uv's pipx
40
+ equivalent), stored under ``~/.local/share/uv/tools``. Marked
41
+ ``managed_by="uv"`` so deletion goes through ``uv tool uninstall``
42
+ and the ``~/.local/bin`` shims are cleaned up too.
43
+ * Python versions installed with ``uv python install``, stored under
44
+ ``~/.local/share/uv/python``.
45
+
46
+ ``UV_TOOL_DIR``, ``UV_PYTHON_INSTALL_DIR`` and ``XDG_DATA_HOME`` are
47
+ honoured. Project virtualenvs created by ``uv venv`` / ``uv sync``
48
+ are regular ``pyvenv.cfg`` environments reported by
49
+ :class:`~killpy.detectors.venv.VenvDetector`, and the global uv cache
50
+ is reported by the cache detector. The scan *path* argument is
51
+ ignored.
52
+ """
53
+
54
+ name = "uv"
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
+ )
62
+
63
+ def detect(self, path: Path) -> list[Environment]: # noqa: ARG002
64
+ envs: list[Environment] = []
65
+ envs.extend(self._scan_dir(_uv_tools_dir(), managed_by="uv"))
66
+ envs.extend(self._scan_dir(_uv_python_dir(), managed_by=None))
67
+ return envs
68
+
69
+ @staticmethod
70
+ def _scan_dir(root: Path, managed_by: str | None) -> list[Environment]:
71
+ if not root.exists():
72
+ return []
73
+ envs: list[Environment] = []
74
+ try:
75
+ for env_dir in root.iterdir():
76
+ if not env_dir.is_dir():
77
+ continue
78
+ try:
79
+ stat = env_dir.stat()
80
+ size = get_total_size(env_dir)
81
+ mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
82
+ envs.append(
83
+ Environment(
84
+ path=env_dir,
85
+ name=env_dir.name,
86
+ type="uv",
87
+ last_accessed=mtime,
88
+ size_bytes=size,
89
+ managed_by=managed_by,
90
+ )
91
+ )
92
+ except (FileNotFoundError, OSError) as exc:
93
+ logger.debug("Skipping uv entry %s: %s", env_dir, exc)
94
+ except OSError as exc:
95
+ logger.error("Cannot inspect uv dir %s: %s", root, exc)
96
+ return envs
@@ -9,7 +9,6 @@ from killpy.models import ScoredEnvironment, Suggestion
9
9
 
10
10
  # Age thresholds in days.
11
11
  _HIGH_AGE_ORPHAN_THRESHOLD = 180 # orphan + age >= this → HIGH
12
- _HIGH_AGE_NO_PROJECT_THRESHOLD = 365 # no project files + age >= this → HIGH
13
12
  _MEDIUM_AGE_THRESHOLD = 120 # age >= this → MEDIUM
14
13
  _LOW_AGE_THRESHOLD = 120 # age < this → LOW
15
14
 
@@ -73,7 +72,6 @@ class SuggestionEngine:
73
72
  reasons: list[str],
74
73
  ) -> tuple[Literal["HIGH", "MEDIUM", "LOW"], str]:
75
74
  is_orphan = scored.is_orphan
76
- has_project_files = scored.has_project_files
77
75
  is_active_git = (
78
76
  scored.git_info is not None
79
77
  and scored.git_info.is_git_repo
@@ -86,12 +84,6 @@ class SuggestionEngine:
86
84
  reasons.append(f"Not used in {age_days} days")
87
85
  return "HIGH", _ACTION_HIGH
88
86
 
89
- # ── Rule 1b: HIGH — no project files and very old ──────────────────────
90
- if not has_project_files and age_days >= _HIGH_AGE_NO_PROJECT_THRESHOLD:
91
- reasons.append("Orphan — no project files found nearby")
92
- reasons.append(f"Not used in {age_days} days")
93
- return "HIGH", _ACTION_HIGH
94
-
95
87
  # ── Rule 2: LOW — active git or very recent ─────────────────────────────
96
88
  if is_active_git or age_days < _LOW_AGE_THRESHOLD:
97
89
  if is_active_git:
@@ -6,9 +6,11 @@ import json
6
6
  import logging
7
7
  import os
8
8
  import tempfile
9
+ from collections.abc import Iterable
10
+ from datetime import datetime, timezone
9
11
  from pathlib import Path
10
12
 
11
- from killpy.models import ScanRecord, ScoredEnvironment
13
+ from killpy.models import Environment, ScanRecord, ScoredEnvironment
12
14
 
13
15
  logger = logging.getLogger(__name__)
14
16
 
@@ -35,6 +37,32 @@ class UsageTracker:
35
37
  history.append(record.to_dict())
36
38
  self._save(history)
37
39
 
40
+ def record_scan_result(
41
+ self,
42
+ environments: Iterable[Environment],
43
+ scan_path: Path | str,
44
+ *,
45
+ deleted_bytes: int = 0,
46
+ ) -> None:
47
+ """Persist a :class:`~killpy.models.ScanRecord` built from a scan result.
48
+
49
+ Convenience wrapper around :meth:`record_scan` so callers don't have to
50
+ assemble a :class:`ScanRecord` by hand. Records the number of
51
+ environments found and their total size; ``deleted_bytes`` can be set
52
+ when the freed amount is already known, otherwise use
53
+ :meth:`record_deletion` afterwards to update the same record.
54
+ """
55
+ envs = list(environments)
56
+ self.record_scan(
57
+ ScanRecord(
58
+ timestamp=datetime.now(tz=timezone.utc),
59
+ total_space_found=sum(e.size_bytes for e in envs),
60
+ total_space_deleted=deleted_bytes,
61
+ environments_count=len(envs),
62
+ scan_path=str(scan_path),
63
+ )
64
+ )
65
+
38
66
  def record_deletion(self, size_bytes: int) -> None:
39
67
  """Increment the ``total_space_deleted`` counter of the last scan record."""
40
68
  history = self._load()
@@ -39,8 +39,8 @@ class Environment:
39
39
  Total size in bytes (recursive directory sum).
40
40
  managed_by:
41
41
  If not ``None``, the external tool that manages deletion. Supported
42
- values: ``"conda"`` and ``"pipx"``. When ``None`` deletion is
43
- performed via :func:`shutil.rmtree`.
42
+ values: ``"conda"``, ``"pipx"`` and ``"uv"`` (uv tool environments).
43
+ When ``None`` deletion is performed via :func:`shutil.rmtree`.
44
44
  """
45
45
 
46
46
  path: Path
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: killpy
3
- Version: 0.24.0
3
+ Version: 0.24.2
4
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 🧹.
5
5
  Project-URL: Homepage, https://github.com/Tlaloc-Es/killpy
6
6
  Project-URL: Documentation, https://tlaloc-es.github.io/killpy/
@@ -31,6 +31,8 @@ Find and delete old `.venv`, conda, poetry, pipenv, uv and more — safely, in s
31
31
  uvx killpy --path ~
32
32
  ```
33
33
 
34
+ > ⭐ Featured in [awesome-python](https://github.com/vinta/awesome-python) — a curated list of the best Python tools
35
+
34
36
  [Documentation](https://tlaloc-es.github.io/killpy/)
35
37
 
36
38
  [![PyPI](https://img.shields.io/pypi/v/killpy.svg)](https://pypi.org/project/killpy/)
@@ -40,6 +42,7 @@ uvx killpy --path ~
40
42
  [![Coverage](https://codecov.io/gh/Tlaloc-Es/killpy/branch/master/graph/badge.svg)](https://codecov.io/gh/Tlaloc-Es/killpy)
41
43
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
42
44
  [![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](<https://twitter.com/intent/tweet?text=%F0%9F%90%8D%20KillPy%20helps%20you%20reclaim%20disk%20space%20by%20detecting%20unused%20Python%20environments%20(.venv,%20poetry%20env,%20conda%20env)%20and%20pipx%20packages.%20Clean,%20organize%20and%20free%20up%20space%20effortlessly!%20%F0%9F%9A%80&url=https://github.com/Tlaloc-Es/KillPy>)
45
+ [![Awesome Python](https://awesome.re/badge.svg)](https://github.com/vinta/awesome-python)
43
46
 
44
47
  ![killpy in action](https://raw.githubusercontent.com/Tlaloc-Es/killpy/master/show.gif)
45
48
 
@@ -584,7 +587,7 @@ Typical minimal setup (safe for daily use):
584
587
 
585
588
  ```yaml
586
589
  - repo: https://github.com/Tlaloc-Es/KillPy
587
- rev: 0.24.0
590
+ rev: 0.24.2
588
591
  hooks:
589
592
  - id: killpy
590
593
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "killpy"
3
- version = "0.24.0"
3
+ version = "0.24.2"
4
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 🧹."
5
5
  readme = "README.md"
6
6
  classifiers = [
@@ -1,64 +0,0 @@
1
- """Detector for uv-managed virtual environments."""
2
-
3
- from __future__ import annotations
4
-
5
- import logging
6
- import os
7
- import shutil
8
- from datetime import datetime, timezone
9
- from pathlib import Path
10
-
11
- from killpy.detectors.base import AbstractDetector
12
- from killpy.files import get_total_size
13
- from killpy.models import Environment
14
-
15
- logger = logging.getLogger(__name__)
16
-
17
- _PRUNED: frozenset[str] = frozenset({".git", ".hg", ".svn", "node_modules"})
18
-
19
-
20
- class UvDetector(AbstractDetector):
21
- """Detects ``.uv`` directories created by `uv venv` inside projects.
22
-
23
- Also reports the global uv cache at ``~/.cache/uv`` – but only when
24
- the cache detector is *not* included in the same scan (the scanner
25
- handles deduplication by path).
26
- """
27
-
28
- name = "uv"
29
-
30
- def can_handle(self) -> bool:
31
- return shutil.which("uv") is not None
32
-
33
- def detect(self, path: Path) -> list[Environment]:
34
- envs: list[Environment] = []
35
- for current_root, directories, _ in os.walk(path, topdown=True):
36
- pruned = set()
37
- for d in directories:
38
- if d in _PRUNED:
39
- pruned.add(d)
40
- continue
41
- if d == ".uv":
42
- uv_path = Path(current_root) / d
43
- try:
44
- stat = uv_path.stat()
45
- size = get_total_size(uv_path)
46
- mtime = datetime.fromtimestamp(
47
- stat.st_mtime,
48
- tz=timezone.utc,
49
- )
50
- envs.append(
51
- Environment(
52
- path=uv_path,
53
- name=str(uv_path),
54
- type="uv",
55
- last_accessed=mtime,
56
- size_bytes=size,
57
- )
58
- )
59
- except (FileNotFoundError, OSError) as exc:
60
- logger.debug("Skipping %s: %s", uv_path, exc)
61
- pruned.add(d)
62
- directories[:] = [d for d in directories if d not in pruned]
63
-
64
- return envs
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes