killpy 0.23.0__tar.gz → 0.24.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 (44) hide show
  1. {killpy-0.23.0 → killpy-0.24.1}/PKG-INFO +39 -3
  2. {killpy-0.23.0 → killpy-0.24.1}/README.md +38 -2
  3. {killpy-0.23.0 → killpy-0.24.1}/killpy/__main__.py +10 -2
  4. {killpy-0.23.0 → killpy-0.24.1}/killpy/cli.py +52 -22
  5. {killpy-0.23.0 → killpy-0.24.1}/killpy/commands/delete.py +10 -2
  6. {killpy-0.23.0 → killpy-0.24.1}/killpy/commands/list.py +76 -30
  7. {killpy-0.23.0 → killpy-0.24.1}/killpy/intelligence/tracker.py +29 -1
  8. {killpy-0.23.0 → killpy-0.24.1}/killpy.egg-info/PKG-INFO +39 -3
  9. {killpy-0.23.0 → killpy-0.24.1}/pyproject.toml +1 -1
  10. {killpy-0.23.0 → killpy-0.24.1}/killpy/__init__.py +0 -0
  11. {killpy-0.23.0 → killpy-0.24.1}/killpy/cleaner.py +0 -0
  12. {killpy-0.23.0 → killpy-0.24.1}/killpy/cleaners/__init__.py +0 -0
  13. {killpy-0.23.0 → killpy-0.24.1}/killpy/commands/__init__.py +0 -0
  14. {killpy-0.23.0 → killpy-0.24.1}/killpy/commands/_utils.py +0 -0
  15. {killpy-0.23.0 → killpy-0.24.1}/killpy/commands/clean.py +0 -0
  16. {killpy-0.23.0 → killpy-0.24.1}/killpy/commands/doctor.py +0 -0
  17. {killpy-0.23.0 → killpy-0.24.1}/killpy/commands/find.py +0 -0
  18. {killpy-0.23.0 → killpy-0.24.1}/killpy/commands/stats.py +0 -0
  19. {killpy-0.23.0 → killpy-0.24.1}/killpy/detectors/__init__.py +0 -0
  20. {killpy-0.23.0 → killpy-0.24.1}/killpy/detectors/artifacts.py +0 -0
  21. {killpy-0.23.0 → killpy-0.24.1}/killpy/detectors/base.py +0 -0
  22. {killpy-0.23.0 → killpy-0.24.1}/killpy/detectors/cache.py +0 -0
  23. {killpy-0.23.0 → killpy-0.24.1}/killpy/detectors/conda.py +0 -0
  24. {killpy-0.23.0 → killpy-0.24.1}/killpy/detectors/hatch.py +0 -0
  25. {killpy-0.23.0 → killpy-0.24.1}/killpy/detectors/pipenv.py +0 -0
  26. {killpy-0.23.0 → killpy-0.24.1}/killpy/detectors/pipx.py +0 -0
  27. {killpy-0.23.0 → killpy-0.24.1}/killpy/detectors/poetry.py +0 -0
  28. {killpy-0.23.0 → killpy-0.24.1}/killpy/detectors/pyenv.py +0 -0
  29. {killpy-0.23.0 → killpy-0.24.1}/killpy/detectors/tox.py +0 -0
  30. {killpy-0.23.0 → killpy-0.24.1}/killpy/detectors/uv.py +0 -0
  31. {killpy-0.23.0 → killpy-0.24.1}/killpy/detectors/venv.py +0 -0
  32. {killpy-0.23.0 → killpy-0.24.1}/killpy/files/__init__.py +0 -0
  33. {killpy-0.23.0 → killpy-0.24.1}/killpy/intelligence/__init__.py +0 -0
  34. {killpy-0.23.0 → killpy-0.24.1}/killpy/intelligence/git_analyzer.py +0 -0
  35. {killpy-0.23.0 → killpy-0.24.1}/killpy/intelligence/scoring.py +0 -0
  36. {killpy-0.23.0 → killpy-0.24.1}/killpy/intelligence/suggestions.py +0 -0
  37. {killpy-0.23.0 → killpy-0.24.1}/killpy/models.py +0 -0
  38. {killpy-0.23.0 → killpy-0.24.1}/killpy/scanner.py +0 -0
  39. {killpy-0.23.0 → killpy-0.24.1}/killpy.egg-info/SOURCES.txt +0 -0
  40. {killpy-0.23.0 → killpy-0.24.1}/killpy.egg-info/dependency_links.txt +0 -0
  41. {killpy-0.23.0 → killpy-0.24.1}/killpy.egg-info/entry_points.txt +0 -0
  42. {killpy-0.23.0 → killpy-0.24.1}/killpy.egg-info/requires.txt +0 -0
  43. {killpy-0.23.0 → killpy-0.24.1}/killpy.egg-info/top_level.txt +0 -0
  44. {killpy-0.23.0 → killpy-0.24.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: killpy
3
- Version: 0.23.0
3
+ Version: 0.24.1
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
 
@@ -47,6 +50,31 @@ uvx killpy --path ~
47
50
 
48
51
  ______________________________________________________________________
49
52
 
53
+ ## Table of Contents
54
+
55
+ - [The Problem](#the-problem)
56
+ - [What killpy detects](#what-killpy-detects)
57
+ - [Quickstart](#quickstart)
58
+ - [Interactive TUI](#interactive-tui)
59
+ - [Keyboard shortcuts](#keyboard-shortcuts)
60
+ - [Search / filter](#search--filter)
61
+ - [Multi-select mode](#multi-select-mode)
62
+ - [CLI reference](#cli-reference)
63
+ - [`killpy` — launch TUI or headless delete](#killpy--launch-tui-or-headless-delete)
64
+ - [`killpy list` — inspect environments](#killpy-list--inspect-environments)
65
+ - [`killpy delete` — remove environments](#killpy-delete--remove-environments)
66
+ - [`killpy stats` — disk usage summary](#killpy-stats--disk-usage-summary)
67
+ - [`killpy clean` — remove cache directories](#killpy-clean--remove-cache-directories)
68
+ - [`killpy doctor` — smart health report](#killpy-doctor--smart-health-report)
69
+ - [killpy vs alternatives](#killpy-vs-alternatives)
70
+ - [FAQ](#faq)
71
+ - [Pre-commit hooks](#pre-commit-hooks)
72
+ - [Safety](#safety)
73
+ - [Roadmap](#roadmap)
74
+ - [Contributing](#contributing)
75
+
76
+ ______________________________________________________________________
77
+
50
78
  ## The Problem
51
79
 
52
80
  If you have been writing Python for more than a year, your disk is probably full of environments you forgot about.
@@ -227,8 +255,11 @@ killpy list --type venv --type conda # filter by type (repeatable)
227
255
  killpy list --older-than 90 # not accessed in the last 90 days
228
256
  killpy list --json # output as a JSON array
229
257
  killpy list --json-stream # stream as NDJSON — one line per env
258
+ killpy list --quiet # suppress progress output (scripts/CI)
230
259
  ```
231
260
 
261
+ While scanning, `killpy list` shows a live progress indicator on **stderr** so you can see which detector is running. Stdout receives only the final output (table, JSON, or NDJSON), so pipes and redirections are never polluted. Use `--quiet` / `-q` to silence the progress indicator entirely.
262
+
232
263
  `--json` example output:
233
264
 
234
265
  ```json
@@ -430,9 +461,12 @@ Deletes every `.venv` / `pyvenv.cfg` env not accessed in the last 90 days, witho
430
461
  **How do I use killpy in a CI pipeline or script?**
431
462
 
432
463
  ```bash
433
- # List as machine-readable JSON
464
+ # List as machine-readable JSON (progress goes to stderr, JSON to stdout)
434
465
  killpy list --json
435
466
 
467
+ # Suppress progress output entirely with --quiet / -q
468
+ killpy list --json --quiet | jq '.[] | .size_human'
469
+
436
470
  # Stream results as NDJSON in real time
437
471
  killpy list --json-stream | jq '.size_bytes'
438
472
 
@@ -440,6 +474,8 @@ killpy list --json-stream | jq '.size_bytes'
440
474
  killpy --path ./build_artifacts --delete-all --yes
441
475
  ```
442
476
 
477
+ Progress messages always go to **stderr**, so stdout is clean for piping even without `--quiet`. Use `-q` when you want no output at all on stderr.
478
+
443
479
  **How do I skip certain directories?**
444
480
 
445
481
  ```bash
@@ -551,7 +587,7 @@ Typical minimal setup (safe for daily use):
551
587
 
552
588
  ```yaml
553
589
  - repo: https://github.com/Tlaloc-Es/KillPy
554
- rev: 0.23.0
590
+ rev: 0.24.1
555
591
  hooks:
556
592
  - id: killpy
557
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
 
@@ -26,6 +29,31 @@ uvx killpy --path ~
26
29
 
27
30
  ______________________________________________________________________
28
31
 
32
+ ## Table of Contents
33
+
34
+ - [The Problem](#the-problem)
35
+ - [What killpy detects](#what-killpy-detects)
36
+ - [Quickstart](#quickstart)
37
+ - [Interactive TUI](#interactive-tui)
38
+ - [Keyboard shortcuts](#keyboard-shortcuts)
39
+ - [Search / filter](#search--filter)
40
+ - [Multi-select mode](#multi-select-mode)
41
+ - [CLI reference](#cli-reference)
42
+ - [`killpy` — launch TUI or headless delete](#killpy--launch-tui-or-headless-delete)
43
+ - [`killpy list` — inspect environments](#killpy-list--inspect-environments)
44
+ - [`killpy delete` — remove environments](#killpy-delete--remove-environments)
45
+ - [`killpy stats` — disk usage summary](#killpy-stats--disk-usage-summary)
46
+ - [`killpy clean` — remove cache directories](#killpy-clean--remove-cache-directories)
47
+ - [`killpy doctor` — smart health report](#killpy-doctor--smart-health-report)
48
+ - [killpy vs alternatives](#killpy-vs-alternatives)
49
+ - [FAQ](#faq)
50
+ - [Pre-commit hooks](#pre-commit-hooks)
51
+ - [Safety](#safety)
52
+ - [Roadmap](#roadmap)
53
+ - [Contributing](#contributing)
54
+
55
+ ______________________________________________________________________
56
+
29
57
  ## The Problem
30
58
 
31
59
  If you have been writing Python for more than a year, your disk is probably full of environments you forgot about.
@@ -206,8 +234,11 @@ killpy list --type venv --type conda # filter by type (repeatable)
206
234
  killpy list --older-than 90 # not accessed in the last 90 days
207
235
  killpy list --json # output as a JSON array
208
236
  killpy list --json-stream # stream as NDJSON — one line per env
237
+ killpy list --quiet # suppress progress output (scripts/CI)
209
238
  ```
210
239
 
240
+ While scanning, `killpy list` shows a live progress indicator on **stderr** so you can see which detector is running. Stdout receives only the final output (table, JSON, or NDJSON), so pipes and redirections are never polluted. Use `--quiet` / `-q` to silence the progress indicator entirely.
241
+
211
242
  `--json` example output:
212
243
 
213
244
  ```json
@@ -409,9 +440,12 @@ Deletes every `.venv` / `pyvenv.cfg` env not accessed in the last 90 days, witho
409
440
  **How do I use killpy in a CI pipeline or script?**
410
441
 
411
442
  ```bash
412
- # List as machine-readable JSON
443
+ # List as machine-readable JSON (progress goes to stderr, JSON to stdout)
413
444
  killpy list --json
414
445
 
446
+ # Suppress progress output entirely with --quiet / -q
447
+ killpy list --json --quiet | jq '.[] | .size_human'
448
+
415
449
  # Stream results as NDJSON in real time
416
450
  killpy list --json-stream | jq '.size_bytes'
417
451
 
@@ -419,6 +453,8 @@ killpy list --json-stream | jq '.size_bytes'
419
453
  killpy --path ./build_artifacts --delete-all --yes
420
454
  ```
421
455
 
456
+ Progress messages always go to **stderr**, so stdout is clean for piping even without `--quiet`. Use `-q` when you want no output at all on stderr.
457
+
422
458
  **How do I skip certain directories?**
423
459
 
424
460
  ```bash
@@ -530,7 +566,7 @@ Typical minimal setup (safe for daily use):
530
566
 
531
567
  ```yaml
532
568
  - repo: https://github.com/Tlaloc-Es/KillPy
533
- rev: 0.23.0
569
+ rev: 0.24.1
534
570
  hooks:
535
571
  - id: killpy
536
572
  ```
@@ -52,6 +52,14 @@ def _run_delete_all(path: Path, excluded: set[str], yes: bool) -> None:
52
52
  console.print("Aborted.")
53
53
  return
54
54
 
55
+ # Record this cleanup session up-front so `killpy stats --history` reflects
56
+ # it; record_deletion() below updates the same record with the freed bytes.
57
+ tracker = UsageTracker()
58
+ try:
59
+ tracker.record_scan_result(envs, path)
60
+ except Exception: # noqa: BLE001
61
+ pass
62
+
55
63
  deleted = 0
56
64
  freed = 0
57
65
  for env in envs:
@@ -67,9 +75,9 @@ def _run_delete_all(path: Path, excluded: set[str], yes: bool) -> None:
67
75
  f"freed [bold]{format_size(freed)}[/bold]."
68
76
  )
69
77
 
70
- # Best-effort: record deletion in history.
78
+ # Best-effort: update the history record created above with freed bytes.
71
79
  try:
72
- UsageTracker().record_deletion(freed)
80
+ tracker.record_deletion(freed)
73
81
  except Exception: # noqa: BLE001
74
82
  pass
75
83
 
@@ -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),
@@ -706,6 +735,7 @@ class TableApp(App):
706
735
  row["status"] = EnvStatus.DELETED.value
707
736
  table.update_cell_at((cursor_cell.row, 3), EnvStatus.DELETED.value)
708
737
  self.bytes_release += int(row["size"])
738
+ self._record_deletion(int(row["size"]))
709
739
  self.query_one("#status-label", Label).update(
710
740
  f"{format_size(self.bytes_release)} deleted"
711
741
  )
@@ -794,7 +824,7 @@ class TableApp(App):
794
824
  """Toggle multi-select mode on/off (T key)."""
795
825
  self._multi_select_mode = not self._multi_select_mode
796
826
  if not self._multi_select_mode:
797
- self._selected_venv_indices.clear()
827
+ self._selected_venv_paths.clear()
798
828
  self._update_multi_select_label()
799
829
  self.render_venv_table()
800
830
 
@@ -810,17 +840,17 @@ class TableApp(App):
810
840
  resolved = self._resolve_venv_row(cursor_cell.row)
811
841
  if not resolved:
812
842
  return
813
- data_index, row = resolved
843
+ _, row = resolved
814
844
  if row["status"] == EnvStatus.DELETED.value:
815
845
  return
816
- if data_index in self._selected_venv_indices:
817
- self._selected_venv_indices.discard(data_index)
846
+ if row["path"] in self._selected_venv_paths:
847
+ self._selected_venv_paths.discard(row["path"])
818
848
  else:
819
- self._selected_venv_indices.add(data_index)
849
+ self._selected_venv_paths.add(row["path"])
820
850
  self._update_multi_select_label()
821
851
  table.update_cell_at(
822
- (cursor_cell.row, self.VENV_COL_STATUS),
823
- self._compute_row_status(data_index, row),
852
+ Coordinate(cursor_cell.row, self.VENV_COL_STATUS),
853
+ self._compute_row_status(row),
824
854
  )
825
855
 
826
856
  @is_venv_tab
@@ -829,13 +859,13 @@ class TableApp(App):
829
859
  if not self._multi_select_mode:
830
860
  return
831
861
  non_deleted = {
832
- i
862
+ self.venv_rows[i]["path"]
833
863
  for i in self._venv_display_indices
834
864
  if self.venv_rows[i]["status"] != EnvStatus.DELETED.value
835
865
  }
836
- if non_deleted == self._selected_venv_indices:
837
- self._selected_venv_indices.clear()
866
+ if non_deleted == self._selected_venv_paths:
867
+ self._selected_venv_paths.clear()
838
868
  else:
839
- self._selected_venv_indices = non_deleted
869
+ self._selected_venv_paths = non_deleted
840
870
  self._update_multi_select_label()
841
871
  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,70 @@ from killpy.files import format_size
14
14
  from killpy.scanner import Scanner
15
15
 
16
16
 
17
+ def _run_json_stream(
18
+ scanner: Scanner,
19
+ path: Path,
20
+ types: tuple[str, ...],
21
+ older_than: int | None,
22
+ quiet: bool,
23
+ stderr_console: Console,
24
+ ) -> None:
25
+ if not quiet:
26
+ stderr_console.print("[dim]Scanning…[/dim]")
27
+
28
+ def _progress(detector, envs):
29
+ if not quiet:
30
+ stderr_console.print(
31
+ f"[dim] {detector.name}[/dim] — [dim]{len(envs)} found[/dim]",
32
+ )
33
+ for env in filter_envs(envs, types or None, older_than):
34
+ click.echo(json.dumps(env.to_dict()))
35
+
36
+ scanner.scan(path, on_progress=_progress)
37
+
38
+
39
+ def _scan_with_progress(
40
+ scanner: Scanner, path: Path, quiet: bool, stderr_console: Console
41
+ ):
42
+ if quiet:
43
+ return scanner.scan(path)
44
+
45
+ status = stderr_console.status("Scanning…", spinner="dots")
46
+ status.start()
47
+
48
+ def _progress(detector, _envs):
49
+ status.update(f"Scanning… [dim]{detector.name}[/dim]")
50
+
51
+ envs = scanner.scan(path, on_progress=_progress)
52
+ status.stop()
53
+ return envs
54
+
55
+
56
+ def _print_table(envs: list, console: Console) -> None:
57
+ table = Table(show_header=True, header_style="bold cyan")
58
+ table.add_column("Type", style="dim", min_width=10)
59
+ table.add_column("Name", min_width=20)
60
+ table.add_column("Last accessed", min_width=12)
61
+ table.add_column("Size", justify="right", min_width=9)
62
+ table.add_column("Path")
63
+
64
+ for env in envs:
65
+ table.add_row(
66
+ env.type,
67
+ env.name,
68
+ env.last_accessed_str,
69
+ env.size_human,
70
+ str(env.path),
71
+ )
72
+
73
+ console.print(table)
74
+ total = sum(e.size_bytes for e in envs)
75
+ console.print(
76
+ f"\n[bold]{len(envs)}[/bold] environment(s) — "
77
+ f"[bold]{format_size(total)}[/bold] total"
78
+ )
79
+
80
+
17
81
  @click.command("list")
18
82
  @click.option(
19
83
  "--path",
@@ -52,26 +116,30 @@ from killpy.scanner import Scanner
52
116
  default=False,
53
117
  help="Output as JSON array.",
54
118
  )
119
+ @click.option(
120
+ "--quiet",
121
+ "-q",
122
+ is_flag=True,
123
+ default=False,
124
+ help="Suppress progress messages (useful in scripts/pipelines).",
125
+ )
55
126
  def list_cmd(
56
127
  path: Path,
57
128
  types: tuple[str, ...],
58
129
  older_than: int | None,
59
130
  as_json: bool,
60
131
  as_json_stream: bool,
132
+ quiet: bool,
61
133
  ) -> None:
62
134
  """List all detected Python environments under PATH."""
63
135
  scanner = Scanner(types=set(types) if types else None)
136
+ stderr_console = Console(stderr=True)
64
137
 
65
138
  if as_json_stream:
66
-
67
- def _stream_progress(_detector, envs):
68
- for env in filter_envs(envs, types or None, older_than):
69
- click.echo(json.dumps(env.to_dict()))
70
-
71
- scanner.scan(path, on_progress=_stream_progress)
139
+ _run_json_stream(scanner, path, types, older_than, quiet, stderr_console)
72
140
  return
73
141
 
74
- envs = scanner.scan(path)
142
+ envs = _scan_with_progress(scanner, path, quiet, stderr_console)
75
143
  envs = filter_envs(envs, types or None, older_than)
76
144
 
77
145
  if as_json:
@@ -82,26 +150,4 @@ def list_cmd(
82
150
  click.echo("No environments found.")
83
151
  return
84
152
 
85
- console = Console()
86
- table = Table(show_header=True, header_style="bold cyan")
87
- table.add_column("Type", style="dim", min_width=10)
88
- table.add_column("Name", min_width=20)
89
- table.add_column("Last accessed", min_width=12)
90
- table.add_column("Size", justify="right", min_width=9)
91
- table.add_column("Path")
92
-
93
- for env in envs:
94
- table.add_row(
95
- env.type,
96
- env.name,
97
- env.last_accessed_str,
98
- env.size_human,
99
- str(env.path),
100
- )
101
-
102
- console.print(table)
103
- total = sum(e.size_bytes for e in envs)
104
- console.print(
105
- f"\n[bold]{len(envs)}[/bold] environment(s) — "
106
- f"[bold]{format_size(total)}[/bold] total"
107
- )
153
+ _print_table(envs, Console())
@@ -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()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: killpy
3
- Version: 0.23.0
3
+ Version: 0.24.1
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
 
@@ -47,6 +50,31 @@ uvx killpy --path ~
47
50
 
48
51
  ______________________________________________________________________
49
52
 
53
+ ## Table of Contents
54
+
55
+ - [The Problem](#the-problem)
56
+ - [What killpy detects](#what-killpy-detects)
57
+ - [Quickstart](#quickstart)
58
+ - [Interactive TUI](#interactive-tui)
59
+ - [Keyboard shortcuts](#keyboard-shortcuts)
60
+ - [Search / filter](#search--filter)
61
+ - [Multi-select mode](#multi-select-mode)
62
+ - [CLI reference](#cli-reference)
63
+ - [`killpy` — launch TUI or headless delete](#killpy--launch-tui-or-headless-delete)
64
+ - [`killpy list` — inspect environments](#killpy-list--inspect-environments)
65
+ - [`killpy delete` — remove environments](#killpy-delete--remove-environments)
66
+ - [`killpy stats` — disk usage summary](#killpy-stats--disk-usage-summary)
67
+ - [`killpy clean` — remove cache directories](#killpy-clean--remove-cache-directories)
68
+ - [`killpy doctor` — smart health report](#killpy-doctor--smart-health-report)
69
+ - [killpy vs alternatives](#killpy-vs-alternatives)
70
+ - [FAQ](#faq)
71
+ - [Pre-commit hooks](#pre-commit-hooks)
72
+ - [Safety](#safety)
73
+ - [Roadmap](#roadmap)
74
+ - [Contributing](#contributing)
75
+
76
+ ______________________________________________________________________
77
+
50
78
  ## The Problem
51
79
 
52
80
  If you have been writing Python for more than a year, your disk is probably full of environments you forgot about.
@@ -227,8 +255,11 @@ killpy list --type venv --type conda # filter by type (repeatable)
227
255
  killpy list --older-than 90 # not accessed in the last 90 days
228
256
  killpy list --json # output as a JSON array
229
257
  killpy list --json-stream # stream as NDJSON — one line per env
258
+ killpy list --quiet # suppress progress output (scripts/CI)
230
259
  ```
231
260
 
261
+ While scanning, `killpy list` shows a live progress indicator on **stderr** so you can see which detector is running. Stdout receives only the final output (table, JSON, or NDJSON), so pipes and redirections are never polluted. Use `--quiet` / `-q` to silence the progress indicator entirely.
262
+
232
263
  `--json` example output:
233
264
 
234
265
  ```json
@@ -430,9 +461,12 @@ Deletes every `.venv` / `pyvenv.cfg` env not accessed in the last 90 days, witho
430
461
  **How do I use killpy in a CI pipeline or script?**
431
462
 
432
463
  ```bash
433
- # List as machine-readable JSON
464
+ # List as machine-readable JSON (progress goes to stderr, JSON to stdout)
434
465
  killpy list --json
435
466
 
467
+ # Suppress progress output entirely with --quiet / -q
468
+ killpy list --json --quiet | jq '.[] | .size_human'
469
+
436
470
  # Stream results as NDJSON in real time
437
471
  killpy list --json-stream | jq '.size_bytes'
438
472
 
@@ -440,6 +474,8 @@ killpy list --json-stream | jq '.size_bytes'
440
474
  killpy --path ./build_artifacts --delete-all --yes
441
475
  ```
442
476
 
477
+ Progress messages always go to **stderr**, so stdout is clean for piping even without `--quiet`. Use `-q` when you want no output at all on stderr.
478
+
443
479
  **How do I skip certain directories?**
444
480
 
445
481
  ```bash
@@ -551,7 +587,7 @@ Typical minimal setup (safe for daily use):
551
587
 
552
588
  ```yaml
553
589
  - repo: https://github.com/Tlaloc-Es/KillPy
554
- rev: 0.23.0
590
+ rev: 0.24.1
555
591
  hooks:
556
592
  - id: killpy
557
593
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "killpy"
3
- version = "0.23.0"
3
+ version = "0.24.1"
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 = [
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes