killpy 0.25.0__tar.gz → 1.0.0__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-1.0.0}/PKG-INFO +18 -11
  2. {killpy-0.25.0 → killpy-1.0.0}/README.md +8 -9
  3. {killpy-0.25.0 → killpy-1.0.0}/killpy/__main__.py +6 -2
  4. {killpy-0.25.0 → killpy-1.0.0}/killpy/cleaners/__init__.py +4 -0
  5. {killpy-0.25.0 → killpy-1.0.0}/killpy/cli.py +31 -33
  6. {killpy-0.25.0 → killpy-1.0.0}/killpy/commands/_utils.py +2 -2
  7. {killpy-0.25.0 → killpy-1.0.0}/killpy/commands/clean.py +7 -2
  8. {killpy-0.25.0 → killpy-1.0.0}/killpy/commands/delete.py +1 -1
  9. {killpy-0.25.0 → killpy-1.0.0}/killpy/commands/doctor.py +42 -45
  10. {killpy-0.25.0 → killpy-1.0.0}/killpy/commands/list.py +5 -4
  11. {killpy-0.25.0 → killpy-1.0.0}/killpy/detectors/artifacts.py +11 -11
  12. killpy-1.0.0/killpy/detectors/base.py +101 -0
  13. {killpy-0.25.0 → killpy-1.0.0}/killpy/detectors/cache.py +20 -22
  14. {killpy-0.25.0 → killpy-1.0.0}/killpy/detectors/conda.py +2 -5
  15. {killpy-0.25.0 → killpy-1.0.0}/killpy/detectors/hatch.py +5 -4
  16. {killpy-0.25.0 → killpy-1.0.0}/killpy/detectors/pipenv.py +5 -4
  17. {killpy-0.25.0 → killpy-1.0.0}/killpy/detectors/pipx.py +2 -5
  18. {killpy-0.25.0 → killpy-1.0.0}/killpy/detectors/poetry.py +4 -3
  19. {killpy-0.25.0 → killpy-1.0.0}/killpy/detectors/pyenv.py +4 -3
  20. {killpy-0.25.0 → killpy-1.0.0}/killpy/detectors/tox.py +4 -8
  21. {killpy-0.25.0 → killpy-1.0.0}/killpy/detectors/uv.py +5 -8
  22. {killpy-0.25.0 → killpy-1.0.0}/killpy/detectors/venv.py +12 -12
  23. {killpy-0.25.0 → killpy-1.0.0}/killpy/files/__init__.py +13 -8
  24. {killpy-0.25.0 → killpy-1.0.0}/killpy/intelligence/git_analyzer.py +12 -9
  25. {killpy-0.25.0 → killpy-1.0.0}/killpy/intelligence/scoring.py +4 -4
  26. {killpy-0.25.0 → killpy-1.0.0}/killpy/intelligence/suggestions.py +21 -25
  27. {killpy-0.25.0 → killpy-1.0.0}/killpy/models.py +22 -9
  28. {killpy-0.25.0 → killpy-1.0.0}/killpy.egg-info/PKG-INFO +18 -11
  29. {killpy-0.25.0 → killpy-1.0.0}/pyproject.toml +26 -2
  30. killpy-0.25.0/killpy/detectors/base.py +0 -53
  31. {killpy-0.25.0 → killpy-1.0.0}/killpy/__init__.py +0 -0
  32. {killpy-0.25.0 → killpy-1.0.0}/killpy/cleaner.py +0 -0
  33. {killpy-0.25.0 → killpy-1.0.0}/killpy/commands/__init__.py +0 -0
  34. {killpy-0.25.0 → killpy-1.0.0}/killpy/commands/find.py +0 -0
  35. {killpy-0.25.0 → killpy-1.0.0}/killpy/commands/stats.py +0 -0
  36. {killpy-0.25.0 → killpy-1.0.0}/killpy/detectors/__init__.py +0 -0
  37. {killpy-0.25.0 → killpy-1.0.0}/killpy/intelligence/__init__.py +0 -0
  38. {killpy-0.25.0 → killpy-1.0.0}/killpy/intelligence/tracker.py +0 -0
  39. {killpy-0.25.0 → killpy-1.0.0}/killpy/scanner.py +0 -0
  40. {killpy-0.25.0 → killpy-1.0.0}/killpy.egg-info/SOURCES.txt +0 -0
  41. {killpy-0.25.0 → killpy-1.0.0}/killpy.egg-info/dependency_links.txt +0 -0
  42. {killpy-0.25.0 → killpy-1.0.0}/killpy.egg-info/entry_points.txt +0 -0
  43. {killpy-0.25.0 → killpy-1.0.0}/killpy.egg-info/requires.txt +0 -0
  44. {killpy-0.25.0 → killpy-1.0.0}/killpy.egg-info/top_level.txt +0 -0
  45. {killpy-0.25.0 → killpy-1.0.0}/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: 1.0.0
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
@@ -188,7 +196,7 @@ Environments flagged with `⚠️` are actively in use (the environment killpy r
188
196
  | Key | Action |
189
197
  |-----|--------|
190
198
  | `↑` / `↓` or `k` / `j` | Move cursor up / down (vim-style) |
191
- | `/` | Open live search/filter bar (regex supported) |
199
+ | `/` | Open live search/filter bar (substring match) |
192
200
  | `Escape` | Close search bar and clear filter |
193
201
  | `T` | Toggle multi-select mode on / off |
194
202
  | `Space` | *(Multi-select)* Toggle current row selected |
@@ -203,7 +211,7 @@ Environments flagged with `⚠️` are actively in use (the environment killpy r
203
211
 
204
212
  ### Search / filter
205
213
 
206
- Press `/` to open the filter bar at the bottom of the screen. Type any string or a Python regex — the venv table updates live as you type. Press `Escape` or submit an empty value to clear the filter and return to the full list.
214
+ Press `/` to open the filter bar at the bottom of the screen. Type any text — the venv table filters by path substring (case-insensitive) and updates live as you type. Press `Escape` or submit an empty value to clear the filter and return to the full list.
207
215
 
208
216
  ### Multi-select mode
209
217
 
@@ -257,7 +265,7 @@ ______________________________________________________________________
257
265
  killpy list # list all detected environments
258
266
  killpy list --path ~/projects # scan a specific path
259
267
  killpy list --type venv --type conda # filter by type (repeatable)
260
- killpy list --older-than 90 # not accessed in the last 90 days
268
+ killpy list --older-than 90 # not modified in the last 90 days
261
269
  killpy list --json # output as a JSON array
262
270
  killpy list --json-stream # stream as NDJSON — one line per env
263
271
  killpy list --quiet # suppress progress output (scripts/CI)
@@ -276,7 +284,7 @@ The **Path** column mirrors the form of `--path`: a relative `--path` produces r
276
284
  "absolute_path": "/home/user/projects/my-app/.venv",
277
285
  "name": "my-app/.venv",
278
286
  "type": "venv",
279
- "last_accessed": "2025-11-02T14:23:01+00:00",
287
+ "last_modified": "2025-11-02T14:23:01+00:00",
280
288
  "size_bytes": 54393984,
281
289
  "size_human": "51.88 MB",
282
290
  "managed_by": null,
@@ -409,8 +417,7 @@ Category is assigned deterministically by applying the following rules in order:
409
417
  |----------|------|----------|
410
418
  | 1 | Orphan (`is_orphan=True`) **and** `age ≥ 180 days` | `HIGH` |
411
419
  | 2 | Active git repository **or** `age < 120 days` | `LOW` |
412
- | 3 | `age ≥ 120 days` | `MEDIUM` |
413
- | 4 | Fallback | `LOW` |
420
+ | 3 | `age ≥ 120 days` *(exhaustive fallback)* | `MEDIUM` |
414
421
 
415
422
  Age and orphan status are the dominant signals. Size does not affect the category.
416
423
 
@@ -490,7 +497,7 @@ Yes. Run `killpy --path ~` to scan your home folder. The `stats` command gives a
490
497
  killpy delete --type venv --older-than 90 --yes
491
498
  ```
492
499
 
493
- Deletes every `.venv` / `pyvenv.cfg` env not accessed in the last 90 days, without prompting.
500
+ Deletes every `.venv` / `pyvenv.cfg` env not modified in the last 90 days, without prompting.
494
501
 
495
502
  **How do I use killpy in a CI pipeline or script?**
496
503
 
@@ -596,7 +603,7 @@ ______________________________________________________________________
596
603
 
597
604
  ```yaml
598
605
  - repo: https://github.com/Tlaloc-Es/KillPy
599
- rev: 0.24.1
606
+ rev: 1.0.0
600
607
  hooks:
601
608
  - id: killpy # remove __pycache__ on every commit
602
609
  - id: killpy-clean-caches # also removes .mypy_cache, .pytest_cache, .ruff_cache
@@ -621,7 +628,7 @@ Typical minimal setup (safe for daily use):
621
628
 
622
629
  ```yaml
623
630
  - repo: https://github.com/Tlaloc-Es/KillPy
624
- rev: 0.25.0
631
+ rev: 1.0.0
625
632
  hooks:
626
633
  - id: killpy
627
634
  ```
@@ -167,7 +167,7 @@ Environments flagged with `⚠️` are actively in use (the environment killpy r
167
167
  | Key | Action |
168
168
  |-----|--------|
169
169
  | `↑` / `↓` or `k` / `j` | Move cursor up / down (vim-style) |
170
- | `/` | Open live search/filter bar (regex supported) |
170
+ | `/` | Open live search/filter bar (substring match) |
171
171
  | `Escape` | Close search bar and clear filter |
172
172
  | `T` | Toggle multi-select mode on / off |
173
173
  | `Space` | *(Multi-select)* Toggle current row selected |
@@ -182,7 +182,7 @@ Environments flagged with `⚠️` are actively in use (the environment killpy r
182
182
 
183
183
  ### Search / filter
184
184
 
185
- Press `/` to open the filter bar at the bottom of the screen. Type any string or a Python regex — the venv table updates live as you type. Press `Escape` or submit an empty value to clear the filter and return to the full list.
185
+ Press `/` to open the filter bar at the bottom of the screen. Type any text — the venv table filters by path substring (case-insensitive) and updates live as you type. Press `Escape` or submit an empty value to clear the filter and return to the full list.
186
186
 
187
187
  ### Multi-select mode
188
188
 
@@ -236,7 +236,7 @@ ______________________________________________________________________
236
236
  killpy list # list all detected environments
237
237
  killpy list --path ~/projects # scan a specific path
238
238
  killpy list --type venv --type conda # filter by type (repeatable)
239
- killpy list --older-than 90 # not accessed in the last 90 days
239
+ killpy list --older-than 90 # not modified in the last 90 days
240
240
  killpy list --json # output as a JSON array
241
241
  killpy list --json-stream # stream as NDJSON — one line per env
242
242
  killpy list --quiet # suppress progress output (scripts/CI)
@@ -255,7 +255,7 @@ The **Path** column mirrors the form of `--path`: a relative `--path` produces r
255
255
  "absolute_path": "/home/user/projects/my-app/.venv",
256
256
  "name": "my-app/.venv",
257
257
  "type": "venv",
258
- "last_accessed": "2025-11-02T14:23:01+00:00",
258
+ "last_modified": "2025-11-02T14:23:01+00:00",
259
259
  "size_bytes": 54393984,
260
260
  "size_human": "51.88 MB",
261
261
  "managed_by": null,
@@ -388,8 +388,7 @@ Category is assigned deterministically by applying the following rules in order:
388
388
  |----------|------|----------|
389
389
  | 1 | Orphan (`is_orphan=True`) **and** `age ≥ 180 days` | `HIGH` |
390
390
  | 2 | Active git repository **or** `age < 120 days` | `LOW` |
391
- | 3 | `age ≥ 120 days` | `MEDIUM` |
392
- | 4 | Fallback | `LOW` |
391
+ | 3 | `age ≥ 120 days` *(exhaustive fallback)* | `MEDIUM` |
393
392
 
394
393
  Age and orphan status are the dominant signals. Size does not affect the category.
395
394
 
@@ -469,7 +468,7 @@ Yes. Run `killpy --path ~` to scan your home folder. The `stats` command gives a
469
468
  killpy delete --type venv --older-than 90 --yes
470
469
  ```
471
470
 
472
- Deletes every `.venv` / `pyvenv.cfg` env not accessed in the last 90 days, without prompting.
471
+ Deletes every `.venv` / `pyvenv.cfg` env not modified in the last 90 days, without prompting.
473
472
 
474
473
  **How do I use killpy in a CI pipeline or script?**
475
474
 
@@ -575,7 +574,7 @@ ______________________________________________________________________
575
574
 
576
575
  ```yaml
577
576
  - repo: https://github.com/Tlaloc-Es/KillPy
578
- rev: 0.24.1
577
+ rev: 1.0.0
579
578
  hooks:
580
579
  - id: killpy # remove __pycache__ on every commit
581
580
  - id: killpy-clean-caches # also removes .mypy_cache, .pytest_cache, .ruff_cache
@@ -600,7 +599,7 @@ Typical minimal setup (safe for daily use):
600
599
 
601
600
  ```yaml
602
601
  - repo: https://github.com/Tlaloc-Es/KillPy
603
- rev: 0.25.0
602
+ rev: 1.0.0
604
603
  hooks:
605
604
  - id: killpy
606
605
  ```
@@ -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,5 +1,8 @@
1
+ """Interactive Textual TUI (``TableApp``) for browsing and deleting environments."""
2
+
3
+ from __future__ import annotations
4
+
1
5
  import asyncio
2
- import re
3
6
  import subprocess
4
7
  import sys
5
8
  from datetime import datetime
@@ -44,7 +47,7 @@ def _health_text(category: str) -> Text | str:
44
47
  return ""
45
48
 
46
49
 
47
- def is_venv_tab(func):
50
+ def _is_venv_tab(func):
48
51
  def wrapper(self, *args, **kwargs):
49
52
  if self.query_one(TabbedContent).active == "venv-tab":
50
53
  return func(self, *args, **kwargs)
@@ -52,7 +55,7 @@ def is_venv_tab(func):
52
55
  return wrapper
53
56
 
54
57
 
55
- def is_pipx_tab(func):
58
+ def _is_pipx_tab(func):
56
59
  def wrapper(self, *args, **kwargs):
57
60
  if self.query_one(TabbedContent).active == "pipx-tab":
58
61
  return func(self, *args, **kwargs)
@@ -60,7 +63,7 @@ def is_pipx_tab(func):
60
63
  return wrapper
61
64
 
62
65
 
63
- def shorten_path_for_table(path_value, max_parts: int = 2) -> str:
66
+ def _shorten_path_for_table(path_value, max_parts: int = 2) -> str:
64
67
  path_text = str(path_value)
65
68
  if "/" not in path_text and "\\" not in path_text:
66
69
  return path_text
@@ -293,7 +296,7 @@ class TableApp(App):
293
296
  yield Label("", id="status-label")
294
297
  yield Label("", id="selected-path-label")
295
298
  yield Input(
296
- placeholder="Filter by path (regex)… Esc to clear",
299
+ placeholder="Filter by path… Esc to clear",
297
300
  id="search-input",
298
301
  )
299
302
  yield Label("", id="multi-select-label")
@@ -333,7 +336,7 @@ class TableApp(App):
333
336
  {
334
337
  "path": str(environment.path),
335
338
  "type": environment.type,
336
- "last_modified": environment.last_accessed_str,
339
+ "last_modified": environment.last_modified_str,
337
340
  "size": environment.size_bytes,
338
341
  "size_human": environment.size_human,
339
342
  "health": self._health_by_path.get(str(environment.path), ""),
@@ -349,7 +352,7 @@ class TableApp(App):
349
352
  "type"
350
353
  ]
351
354
  table.add_row(
352
- shorten_path_for_table(row["path"]),
355
+ _shorten_path_for_table(row["path"]),
353
356
  type_label,
354
357
  row["last_modified"],
355
358
  row["size"],
@@ -378,20 +381,17 @@ class TableApp(App):
378
381
  table.clear(columns=True)
379
382
  table.add_columns(*self.get_headers_for_table("venv-table"))
380
383
  self._venv_display_indices = []
384
+ query = self._filter_query.lower()
381
385
  for i, row in enumerate(self.venv_rows):
382
- if self._filter_query:
383
- try:
384
- if not re.search(self._filter_query, row["path"], re.IGNORECASE):
385
- continue
386
- except re.error:
387
- pass # invalid regex — show all
386
+ if query and query not in row["path"].lower():
387
+ continue
388
388
  self._venv_display_indices.append(i)
389
389
  env = row["environment"]
390
390
  type_label = ("\u26a0\ufe0f " if env.is_system_critical else "") + row[
391
391
  "type"
392
392
  ]
393
393
  table.add_row(
394
- shorten_path_for_table(row["path"]),
394
+ _shorten_path_for_table(row["path"]),
395
395
  type_label,
396
396
  row["last_modified"],
397
397
  row["size"],
@@ -609,12 +609,16 @@ class TableApp(App):
609
609
  row["health"] = self._health_by_path.get(row["path"], "")
610
610
  self.render_venv_table()
611
611
 
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
612
+ def _show_bytes_released(self) -> None:
613
+ """Update the status label with the running total of freed space."""
615
614
  self.query_one("#status-label", Label).update(
616
615
  f"{format_size(self.bytes_release)} deleted"
617
616
  )
617
+
618
+ async def action_clean_pycache(self):
619
+ total_freed_space = await asyncio.to_thread(remove_pycache, self.root_dir)
620
+ self.bytes_release += total_freed_space
621
+ self._show_bytes_released()
618
622
  self.bell()
619
623
 
620
624
  def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
@@ -633,7 +637,7 @@ class TableApp(App):
633
637
  _, row = resolved
634
638
  selected_path_label.update(f"Selected: {row['path']}")
635
639
 
636
- @is_venv_tab
640
+ @_is_venv_tab
637
641
  def action_confirm_delete(self):
638
642
  freed_now = 0
639
643
  if self._multi_select_mode and self._selected_venv_paths:
@@ -662,12 +666,10 @@ class TableApp(App):
662
666
 
663
667
  self._record_deletion(freed_now)
664
668
  self.render_venv_table()
665
- self.query_one("#status-label", Label).update(
666
- f"{format_size(self.bytes_release)} deleted"
667
- )
669
+ self._show_bytes_released()
668
670
  self.bell()
669
671
 
670
- @is_venv_tab
672
+ @_is_venv_tab
671
673
  def action_mark_for_delete(self):
672
674
  table = self.query_one("#venv-table", DataTable)
673
675
 
@@ -690,7 +692,7 @@ class TableApp(App):
690
692
  EnvStatus.MARKED_TO_DELETE.value,
691
693
  )
692
694
 
693
- @is_venv_tab
695
+ @_is_venv_tab
694
696
  def action_delete_now(self):
695
697
  table = self.query_one("#venv-table", DataTable)
696
698
  cursor_cell = table.cursor_coordinate
@@ -709,9 +711,7 @@ class TableApp(App):
709
711
  (cursor_cell.row, self.VENV_COL_STATUS),
710
712
  EnvStatus.DELETED.value,
711
713
  )
712
- self.query_one("#status-label", Label).update(
713
- f"{format_size(self.bytes_release)} deleted"
714
- )
714
+ self._show_bytes_released()
715
715
  self.bell()
716
716
 
717
717
  def delete_environment(self, environment: Environment) -> bool:
@@ -729,7 +729,7 @@ class TableApp(App):
729
729
  self.query_one("#status-label", Label).update(str(error))
730
730
  return False
731
731
 
732
- @is_pipx_tab
732
+ @_is_pipx_tab
733
733
  def action_uninstall_pipx(self):
734
734
  table = self.query_one("#pipx-table", DataTable)
735
735
  cursor_cell = table.cursor_coordinate
@@ -742,9 +742,7 @@ class TableApp(App):
742
742
  table.update_cell_at((cursor_cell.row, 3), EnvStatus.DELETED.value)
743
743
  self.bytes_release += int(row["size"])
744
744
  self._record_deletion(int(row["size"]))
745
- self.query_one("#status-label", Label).update(
746
- f"{format_size(self.bytes_release)} deleted"
747
- )
745
+ self._show_bytes_released()
748
746
 
749
747
  self.bell()
750
748
 
@@ -772,7 +770,7 @@ class TableApp(App):
772
770
  tid = "#venv-table" if active == "venv-tab" else "#pipx-table"
773
771
  self.query_one(tid, DataTable).action_cursor_up()
774
772
 
775
- @is_venv_tab
773
+ @_is_venv_tab
776
774
  def action_open_folder(self) -> None:
777
775
  """Open the parent directory of the selected environment in the OS file manager.""" # noqa: E501
778
776
  table = self.query_one("#venv-table", DataTable)
@@ -834,7 +832,7 @@ class TableApp(App):
834
832
  self._update_multi_select_label()
835
833
  self.render_venv_table()
836
834
 
837
- @is_venv_tab
835
+ @_is_venv_tab
838
836
  def action_multi_select_toggle_row(self) -> None:
839
837
  """Toggle current row selection in multi-select mode (Space key)."""
840
838
  if not self._multi_select_mode:
@@ -859,7 +857,7 @@ class TableApp(App):
859
857
  self._compute_row_status(row),
860
858
  )
861
859
 
862
- @is_venv_tab
860
+ @_is_venv_tab
863
861
  def action_multi_select_all(self) -> None:
864
862
  """Toggle select-all / deselect-all in multi-select mode (A key)."""
865
863
  if not self._multi_select_mode:
@@ -68,7 +68,7 @@ def filter_envs(
68
68
  names such as ``"venv"`` and ``"cache"`` are automatically expanded to
69
69
  their concrete sub-type values via :data:`_TYPE_ALIASES`.
70
70
  older_than:
71
- If provided, only environments not accessed in the last *older_than* days
71
+ If provided, only environments not modified in the last *older_than* days
72
72
  are kept.
73
73
  """
74
74
  now = datetime.now(tz=timezone.utc)
@@ -84,6 +84,6 @@ def filter_envs(
84
84
 
85
85
  if older_than is not None:
86
86
  cutoff = now - timedelta(days=older_than)
87
- result = [e for e in result if e.last_accessed < cutoff]
87
+ result = [e for e in result if e.last_modified < cutoff]
88
88
 
89
89
  return result
@@ -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")
@@ -37,7 +37,7 @@ from killpy.scanner import Scanner
37
37
  type=int,
38
38
  default=None,
39
39
  metavar="DAYS",
40
- help="Only delete environments not accessed in the last N days.",
40
+ help="Only delete environments not modified in the last N days.",
41
41
  )
42
42
  @click.option(
43
43
  "--dry-run",
@@ -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']} "
@@ -198,7 +195,7 @@ def _output_rich(
198
195
 
199
196
  now = datetime.now(tz=timezone.utc)
200
197
  for se in top:
201
- la = se.env.last_accessed
198
+ la = se.env.last_modified
202
199
  if la.tzinfo is None:
203
200
  la = la.replace(tzinfo=timezone.utc)
204
201
  age_days = (now - la).days
@@ -267,7 +264,7 @@ def _print_category_table(
267
264
 
268
265
  now = datetime.now(tz=timezone.utc)
269
266
  for se in scored_envs:
270
- la = se.env.last_accessed
267
+ la = se.env.last_modified
271
268
  if la.tzinfo is None:
272
269
  la = la.replace(tzinfo=timezone.utc)
273
270
  age_days = (now - la).days
@@ -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
 
@@ -57,7 +58,7 @@ def _print_table(envs: list, console: Console) -> None:
57
58
  table = Table(show_header=True, header_style="bold cyan")
58
59
  table.add_column("Type", style="dim", min_width=10)
59
60
  table.add_column("Name", min_width=20)
60
- table.add_column("Last accessed", min_width=12)
61
+ table.add_column("Last modified", min_width=12)
61
62
  table.add_column("Size", justify="right", min_width=9)
62
63
  table.add_column("Path")
63
64
 
@@ -65,7 +66,7 @@ def _print_table(envs: list, console: Console) -> None:
65
66
  table.add_row(
66
67
  env.type,
67
68
  env.name,
68
- env.last_accessed_str,
69
+ env.last_modified_str,
69
70
  env.size_human,
70
71
  str(env.path),
71
72
  )
@@ -100,7 +101,7 @@ def _print_table(envs: list, console: Console) -> None:
100
101
  type=int,
101
102
  default=None,
102
103
  metavar="DAYS",
103
- help="Only show environments not accessed in the last N days.",
104
+ help="Only show environments not modified in the last N days.",
104
105
  )
105
106
  @click.option(
106
107
  "--json-stream",