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.
- {killpy-0.24.0 → killpy-0.24.2}/PKG-INFO +5 -2
- {killpy-0.24.0 → killpy-0.24.2}/README.md +4 -1
- {killpy-0.24.0 → killpy-0.24.2}/killpy/__main__.py +22 -8
- {killpy-0.24.0 → killpy-0.24.2}/killpy/cleaner.py +19 -2
- {killpy-0.24.0 → killpy-0.24.2}/killpy/cli.py +54 -23
- {killpy-0.24.0 → killpy-0.24.2}/killpy/commands/delete.py +10 -2
- {killpy-0.24.0 → killpy-0.24.2}/killpy/detectors/artifacts.py +14 -3
- {killpy-0.24.0 → killpy-0.24.2}/killpy/detectors/cache.py +40 -13
- killpy-0.24.2/killpy/detectors/uv.py +96 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy/intelligence/suggestions.py +0 -8
- {killpy-0.24.0 → killpy-0.24.2}/killpy/intelligence/tracker.py +29 -1
- {killpy-0.24.0 → killpy-0.24.2}/killpy/models.py +2 -2
- {killpy-0.24.0 → killpy-0.24.2}/killpy.egg-info/PKG-INFO +5 -2
- {killpy-0.24.0 → killpy-0.24.2}/pyproject.toml +1 -1
- killpy-0.24.0/killpy/detectors/uv.py +0 -64
- {killpy-0.24.0 → killpy-0.24.2}/killpy/__init__.py +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy/cleaners/__init__.py +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy/commands/__init__.py +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy/commands/_utils.py +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy/commands/clean.py +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy/commands/doctor.py +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy/commands/find.py +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy/commands/list.py +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy/commands/stats.py +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy/detectors/__init__.py +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy/detectors/base.py +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy/detectors/conda.py +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy/detectors/hatch.py +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy/detectors/pipenv.py +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy/detectors/pipx.py +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy/detectors/poetry.py +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy/detectors/pyenv.py +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy/detectors/tox.py +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy/detectors/venv.py +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy/files/__init__.py +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy/intelligence/__init__.py +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy/intelligence/git_analyzer.py +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy/intelligence/scoring.py +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy/scanner.py +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy.egg-info/SOURCES.txt +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy.egg-info/dependency_links.txt +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy.egg-info/entry_points.txt +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy.egg-info/requires.txt +0 -0
- {killpy-0.24.0 → killpy-0.24.2}/killpy.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
[](https://pypi.org/project/killpy/)
|
|
@@ -40,6 +42,7 @@ uvx killpy --path ~
|
|
|
40
42
|
[](https://codecov.io/gh/Tlaloc-Es/killpy)
|
|
41
43
|
[](LICENSE)
|
|
42
44
|
[](<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
|
+
[](https://github.com/vinta/awesome-python)
|
|
43
46
|
|
|
44
47
|

|
|
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.
|
|
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
|
[](https://pypi.org/project/killpy/)
|
|
@@ -19,6 +21,7 @@ uvx killpy --path ~
|
|
|
19
21
|
[](https://codecov.io/gh/Tlaloc-Es/killpy)
|
|
20
22
|
[](LICENSE)
|
|
21
23
|
[](<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
|
+
[](https://github.com/vinta/awesome-python)
|
|
22
25
|
|
|
23
26
|

|
|
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.
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
freed += size
|
|
68
|
+
try:
|
|
69
|
+
freed += cleaner.delete(env)
|
|
61
70
|
deleted += 1
|
|
62
|
-
|
|
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
|
|
81
|
+
# Best-effort: update the history record created above with freed bytes.
|
|
71
82
|
try:
|
|
72
|
-
|
|
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``;
|
|
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.
|
|
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(
|
|
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,
|
|
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
|
|
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.
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
844
|
+
_, row = resolved
|
|
814
845
|
if row["status"] == EnvStatus.DELETED.value:
|
|
815
846
|
return
|
|
816
|
-
if
|
|
817
|
-
self.
|
|
847
|
+
if row["path"] in self._selected_venv_paths:
|
|
848
|
+
self._selected_venv_paths.discard(row["path"])
|
|
818
849
|
else:
|
|
819
|
-
self.
|
|
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(
|
|
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.
|
|
837
|
-
self.
|
|
867
|
+
if non_deleted == self._selected_venv_paths:
|
|
868
|
+
self._selected_venv_paths.clear()
|
|
838
869
|
else:
|
|
839
|
-
self.
|
|
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
|
|
125
|
+
# Best-effort: update the history record created above with freed bytes.
|
|
118
126
|
try:
|
|
119
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 ``"
|
|
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.
|
|
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
|
[](https://pypi.org/project/killpy/)
|
|
@@ -40,6 +42,7 @@ uvx killpy --path ~
|
|
|
40
42
|
[](https://codecov.io/gh/Tlaloc-Es/killpy)
|
|
41
43
|
[](LICENSE)
|
|
42
44
|
[](<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
|
+
[](https://github.com/vinta/awesome-python)
|
|
43
46
|
|
|
44
47
|

|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|