killpy 0.25.1__tar.gz → 1.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {killpy-0.25.1 → killpy-1.0.1}/PKG-INFO +10 -11
  2. {killpy-0.25.1 → killpy-1.0.1}/README.md +8 -9
  3. {killpy-0.25.1 → killpy-1.0.1}/killpy/cli.py +5 -9
  4. {killpy-0.25.1 → killpy-1.0.1}/killpy/commands/_utils.py +2 -2
  5. {killpy-0.25.1 → killpy-1.0.1}/killpy/commands/delete.py +1 -1
  6. {killpy-0.25.1 → killpy-1.0.1}/killpy/commands/doctor.py +2 -2
  7. {killpy-0.25.1 → killpy-1.0.1}/killpy/commands/list.py +3 -3
  8. killpy-1.0.1/killpy/detectors/_shared_walk.py +110 -0
  9. {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/artifacts.py +2 -1
  10. {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/base.py +14 -0
  11. {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/cache.py +6 -1
  12. {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/conda.py +1 -1
  13. {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/hatch.py +1 -1
  14. {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/pipenv.py +1 -1
  15. {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/pipx.py +1 -1
  16. {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/poetry.py +1 -1
  17. {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/pyenv.py +1 -1
  18. {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/tox.py +2 -1
  19. {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/uv.py +1 -1
  20. {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/venv.py +2 -1
  21. {killpy-0.25.1 → killpy-1.0.1}/killpy/intelligence/scoring.py +4 -4
  22. {killpy-0.25.1 → killpy-1.0.1}/killpy/intelligence/suggestions.py +21 -25
  23. {killpy-0.25.1 → killpy-1.0.1}/killpy/models.py +7 -6
  24. {killpy-0.25.1 → killpy-1.0.1}/killpy/scanner.py +69 -22
  25. {killpy-0.25.1 → killpy-1.0.1}/killpy.egg-info/PKG-INFO +10 -11
  26. {killpy-0.25.1 → killpy-1.0.1}/killpy.egg-info/SOURCES.txt +1 -0
  27. {killpy-0.25.1 → killpy-1.0.1}/pyproject.toml +9 -2
  28. {killpy-0.25.1 → killpy-1.0.1}/killpy/__init__.py +0 -0
  29. {killpy-0.25.1 → killpy-1.0.1}/killpy/__main__.py +0 -0
  30. {killpy-0.25.1 → killpy-1.0.1}/killpy/cleaner.py +0 -0
  31. {killpy-0.25.1 → killpy-1.0.1}/killpy/cleaners/__init__.py +0 -0
  32. {killpy-0.25.1 → killpy-1.0.1}/killpy/commands/__init__.py +0 -0
  33. {killpy-0.25.1 → killpy-1.0.1}/killpy/commands/clean.py +0 -0
  34. {killpy-0.25.1 → killpy-1.0.1}/killpy/commands/find.py +0 -0
  35. {killpy-0.25.1 → killpy-1.0.1}/killpy/commands/stats.py +0 -0
  36. {killpy-0.25.1 → killpy-1.0.1}/killpy/detectors/__init__.py +0 -0
  37. {killpy-0.25.1 → killpy-1.0.1}/killpy/files/__init__.py +0 -0
  38. {killpy-0.25.1 → killpy-1.0.1}/killpy/intelligence/__init__.py +0 -0
  39. {killpy-0.25.1 → killpy-1.0.1}/killpy/intelligence/git_analyzer.py +0 -0
  40. {killpy-0.25.1 → killpy-1.0.1}/killpy/intelligence/tracker.py +0 -0
  41. {killpy-0.25.1 → killpy-1.0.1}/killpy.egg-info/dependency_links.txt +0 -0
  42. {killpy-0.25.1 → killpy-1.0.1}/killpy.egg-info/entry_points.txt +0 -0
  43. {killpy-0.25.1 → killpy-1.0.1}/killpy.egg-info/requires.txt +0 -0
  44. {killpy-0.25.1 → killpy-1.0.1}/killpy.egg-info/top_level.txt +0 -0
  45. {killpy-0.25.1 → killpy-1.0.1}/setup.cfg +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: killpy
3
- Version: 0.25.1
3
+ Version: 1.0.1
4
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
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
10
+ Classifier: Development Status :: 5 - Production/Stable
11
11
  Classifier: Environment :: Console
12
12
  Classifier: Environment :: Console :: Curses
13
13
  Classifier: Intended Audience :: Developers
@@ -196,7 +196,7 @@ Environments flagged with `⚠️` are actively in use (the environment killpy r
196
196
  | Key | Action |
197
197
  |-----|--------|
198
198
  | `↑` / `↓` or `k` / `j` | Move cursor up / down (vim-style) |
199
- | `/` | Open live search/filter bar (regex supported) |
199
+ | `/` | Open live search/filter bar (substring match) |
200
200
  | `Escape` | Close search bar and clear filter |
201
201
  | `T` | Toggle multi-select mode on / off |
202
202
  | `Space` | *(Multi-select)* Toggle current row selected |
@@ -211,7 +211,7 @@ Environments flagged with `⚠️` are actively in use (the environment killpy r
211
211
 
212
212
  ### Search / filter
213
213
 
214
- 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.
215
215
 
216
216
  ### Multi-select mode
217
217
 
@@ -265,7 +265,7 @@ ______________________________________________________________________
265
265
  killpy list # list all detected environments
266
266
  killpy list --path ~/projects # scan a specific path
267
267
  killpy list --type venv --type conda # filter by type (repeatable)
268
- 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
269
269
  killpy list --json # output as a JSON array
270
270
  killpy list --json-stream # stream as NDJSON — one line per env
271
271
  killpy list --quiet # suppress progress output (scripts/CI)
@@ -284,7 +284,7 @@ The **Path** column mirrors the form of `--path`: a relative `--path` produces r
284
284
  "absolute_path": "/home/user/projects/my-app/.venv",
285
285
  "name": "my-app/.venv",
286
286
  "type": "venv",
287
- "last_accessed": "2025-11-02T14:23:01+00:00",
287
+ "last_modified": "2025-11-02T14:23:01+00:00",
288
288
  "size_bytes": 54393984,
289
289
  "size_human": "51.88 MB",
290
290
  "managed_by": null,
@@ -417,8 +417,7 @@ Category is assigned deterministically by applying the following rules in order:
417
417
  |----------|------|----------|
418
418
  | 1 | Orphan (`is_orphan=True`) **and** `age ≥ 180 days` | `HIGH` |
419
419
  | 2 | Active git repository **or** `age < 120 days` | `LOW` |
420
- | 3 | `age ≥ 120 days` | `MEDIUM` |
421
- | 4 | Fallback | `LOW` |
420
+ | 3 | `age ≥ 120 days` *(exhaustive fallback)* | `MEDIUM` |
422
421
 
423
422
  Age and orphan status are the dominant signals. Size does not affect the category.
424
423
 
@@ -498,7 +497,7 @@ Yes. Run `killpy --path ~` to scan your home folder. The `stats` command gives a
498
497
  killpy delete --type venv --older-than 90 --yes
499
498
  ```
500
499
 
501
- 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.
502
501
 
503
502
  **How do I use killpy in a CI pipeline or script?**
504
503
 
@@ -604,7 +603,7 @@ ______________________________________________________________________
604
603
 
605
604
  ```yaml
606
605
  - repo: https://github.com/Tlaloc-Es/KillPy
607
- rev: 0.25.1
606
+ rev: 1.0.1
608
607
  hooks:
609
608
  - id: killpy # remove __pycache__ on every commit
610
609
  - id: killpy-clean-caches # also removes .mypy_cache, .pytest_cache, .ruff_cache
@@ -629,7 +628,7 @@ Typical minimal setup (safe for daily use):
629
628
 
630
629
  ```yaml
631
630
  - repo: https://github.com/Tlaloc-Es/KillPy
632
- rev: 0.25.1
631
+ rev: 1.0.1
633
632
  hooks:
634
633
  - id: killpy
635
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.25.1
577
+ rev: 1.0.1
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.1
602
+ rev: 1.0.1
604
603
  hooks:
605
604
  - id: killpy
606
605
  ```
@@ -3,7 +3,6 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- import re
7
6
  import subprocess
8
7
  import sys
9
8
  from datetime import datetime
@@ -297,7 +296,7 @@ class TableApp(App):
297
296
  yield Label("", id="status-label")
298
297
  yield Label("", id="selected-path-label")
299
298
  yield Input(
300
- placeholder="Filter by path (regex)… Esc to clear",
299
+ placeholder="Filter by path… Esc to clear",
301
300
  id="search-input",
302
301
  )
303
302
  yield Label("", id="multi-select-label")
@@ -337,7 +336,7 @@ class TableApp(App):
337
336
  {
338
337
  "path": str(environment.path),
339
338
  "type": environment.type,
340
- "last_modified": environment.last_accessed_str,
339
+ "last_modified": environment.last_modified_str,
341
340
  "size": environment.size_bytes,
342
341
  "size_human": environment.size_human,
343
342
  "health": self._health_by_path.get(str(environment.path), ""),
@@ -382,13 +381,10 @@ class TableApp(App):
382
381
  table.clear(columns=True)
383
382
  table.add_columns(*self.get_headers_for_table("venv-table"))
384
383
  self._venv_display_indices = []
384
+ query = self._filter_query.lower()
385
385
  for i, row in enumerate(self.venv_rows):
386
- if self._filter_query:
387
- try:
388
- if not re.search(self._filter_query, row["path"], re.IGNORECASE):
389
- continue
390
- except re.error:
391
- pass # invalid regex — show all
386
+ if query and query not in row["path"].lower():
387
+ continue
392
388
  self._venv_display_indices.append(i)
393
389
  env = row["environment"]
394
390
  type_label = ("\u26a0\ufe0f " if env.is_system_critical else "") + row[
@@ -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
@@ -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",
@@ -195,7 +195,7 @@ def _output_rich(
195
195
 
196
196
  now = datetime.now(tz=timezone.utc)
197
197
  for se in top:
198
- la = se.env.last_accessed
198
+ la = se.env.last_modified
199
199
  if la.tzinfo is None:
200
200
  la = la.replace(tzinfo=timezone.utc)
201
201
  age_days = (now - la).days
@@ -264,7 +264,7 @@ def _print_category_table(
264
264
 
265
265
  now = datetime.now(tz=timezone.utc)
266
266
  for se in scored_envs:
267
- la = se.env.last_accessed
267
+ la = se.env.last_modified
268
268
  if la.tzinfo is None:
269
269
  la = la.replace(tzinfo=timezone.utc)
270
270
  age_days = (now - la).days
@@ -58,7 +58,7 @@ def _print_table(envs: list, console: Console) -> None:
58
58
  table = Table(show_header=True, header_style="bold cyan")
59
59
  table.add_column("Type", style="dim", min_width=10)
60
60
  table.add_column("Name", min_width=20)
61
- table.add_column("Last accessed", min_width=12)
61
+ table.add_column("Last modified", min_width=12)
62
62
  table.add_column("Size", justify="right", min_width=9)
63
63
  table.add_column("Path")
64
64
 
@@ -66,7 +66,7 @@ def _print_table(envs: list, console: Console) -> None:
66
66
  table.add_row(
67
67
  env.type,
68
68
  env.name,
69
- env.last_accessed_str,
69
+ env.last_modified_str,
70
70
  env.size_human,
71
71
  str(env.path),
72
72
  )
@@ -101,7 +101,7 @@ def _print_table(envs: list, console: Console) -> None:
101
101
  type=int,
102
102
  default=None,
103
103
  metavar="DAYS",
104
- help="Only show environments not accessed in the last N days.",
104
+ help="Only show environments not modified in the last N days.",
105
105
  )
106
106
  @click.option(
107
107
  "--json-stream",
@@ -0,0 +1,110 @@
1
+ """Single shared filesystem walk for the tree-walking detectors.
2
+
3
+ ``venv`` / ``cache`` / ``artifacts`` / ``tox`` each used to run their own
4
+ ``os.walk`` over the scan root, and ``get_total_size`` re-walked every
5
+ environment they found — walking a large tree several times over.
6
+
7
+ This module performs **one** traversal: each directory is classified as an
8
+ environment *container* (a venv, a cache dir, a build artifact, or ``.tox``);
9
+ a match is recorded, its size is summed once, and its subtree is pruned.
10
+ Environment pruning is always applied — once a venv is found, nothing inside it
11
+ is scanned again — which also collapses the cache/artifact double-counting that
12
+ used to happen inside environments.
13
+
14
+ Callers pass the set of detector names whose results they want (``active``).
15
+ Pruning happens on every container regardless of ``active``, so asking for a
16
+ subset yields exactly what running those detectors alone would.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import os
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+
26
+ from killpy.detectors.base import VCS_PRUNE_DIRS
27
+ from killpy.files import get_total_size
28
+ from killpy.models import Environment
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ _CACHE_DIRS = frozenset({"__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache"})
33
+ _ARTIFACT_EXACT = frozenset({"dist", "build"})
34
+ _ARTIFACT_SUFFIXES = (".egg-info", ".dist-info")
35
+
36
+ #: Map every ``Environment.type`` the shared walk (and the cache global scan)
37
+ #: can produce back to the detector that owns it — used to group results per
38
+ #: detector for progress callbacks.
39
+ TYPE_TO_DETECTOR: dict[str, str] = {
40
+ ".venv": "venv",
41
+ "pyvenv.cfg": "venv",
42
+ "__pycache__": "cache",
43
+ ".mypy_cache": "cache",
44
+ ".pytest_cache": "cache",
45
+ ".ruff_cache": "cache",
46
+ "pip-cache": "cache",
47
+ "uv-cache": "cache",
48
+ "artifacts": "artifacts",
49
+ "tox": "tox",
50
+ }
51
+
52
+
53
+ def _classify(basename: str, filenames: list[str]) -> tuple[str, str] | None:
54
+ """Return ``(detector_name, env_type)`` if the dir is a container, else ``None``."""
55
+ if basename == ".venv" or "pyvenv.cfg" in filenames:
56
+ return ("venv", ".venv" if basename == ".venv" else "pyvenv.cfg")
57
+ if basename in _CACHE_DIRS:
58
+ return ("cache", basename)
59
+ if basename in _ARTIFACT_EXACT or any(
60
+ basename.endswith(suffix) for suffix in _ARTIFACT_SUFFIXES
61
+ ):
62
+ return ("artifacts", "artifacts")
63
+ if basename == ".tox":
64
+ return ("tox", "tox")
65
+ return None
66
+
67
+
68
+ def _make_env(path: Path, env_type: str) -> Environment | None:
69
+ """Build an :class:`Environment` for *path*, summing its size once."""
70
+ try:
71
+ stat = path.stat()
72
+ return Environment(
73
+ path=path,
74
+ name=str(path),
75
+ type=env_type,
76
+ last_modified=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
77
+ size_bytes=get_total_size(path),
78
+ )
79
+ except (FileNotFoundError, OSError) as exc:
80
+ logger.debug("Skipping %s: %s", path, exc)
81
+ return None
82
+
83
+
84
+ def walk_environments(root: Path, active: set[str]) -> list[Environment]:
85
+ """Walk *root* once and return environments for the ``active`` detector set.
86
+
87
+ Each directory that matches a container type is reported (when its detector
88
+ is in *active*) and its subtree pruned; VCS and ``site-packages`` trees are
89
+ never descended into.
90
+ """
91
+ envs: list[Environment] = []
92
+ for current, dirnames, filenames in os.walk(root, topdown=True):
93
+ dirnames[:] = [d for d in dirnames if d not in VCS_PRUNE_DIRS]
94
+ current_path = Path(current)
95
+ if current_path != root:
96
+ match = _classify(current_path.name, filenames)
97
+ if match is not None:
98
+ detector_name, env_type = match
99
+ if detector_name in active:
100
+ env = _make_env(current_path, env_type)
101
+ if env is not None:
102
+ envs.append(env)
103
+ dirnames[:] = [] # env-pruning: never descend into a container
104
+ continue
105
+ # A bare ``site-packages`` (e.g. a conda env, which has no pyvenv.cfg) is
106
+ # not a container but must not be scanned for caches/artifacts. ``.venv``
107
+ # IS a container — detected on entry — so it is deliberately not pruned
108
+ # here (that would stop us from ever reporting it).
109
+ dirnames[:] = [d for d in dirnames if d != "site-packages"]
110
+ return envs
@@ -42,6 +42,7 @@ class ArtifactsDetector(AbstractDetector):
42
42
 
43
43
  name = "artifacts"
44
44
  always_available = True # pure filesystem walk
45
+ shared_walk = True # tree scan served by killpy.detectors._shared_walk
45
46
 
46
47
  def detect(self, path: Path) -> list[Environment]:
47
48
  envs: list[Environment] = []
@@ -69,7 +70,7 @@ class ArtifactsDetector(AbstractDetector):
69
70
  path=artifact_path,
70
71
  name=str(artifact_path),
71
72
  type="artifacts",
72
- last_accessed=mtime,
73
+ last_modified=mtime,
73
74
  size_bytes=size,
74
75
  )
75
76
  )
@@ -62,6 +62,11 @@ class AbstractDetector(ABC):
62
62
  required_tool: ClassVar[str | None] = None
63
63
  #: ``True`` for detectors that always apply (pure filesystem walks).
64
64
  always_available: ClassVar[bool] = False
65
+ #: ``True`` when this detector's tree scan is served by the single shared
66
+ #: walk (``killpy.detectors._shared_walk``) instead of its own ``os.walk``.
67
+ #: The :class:`~killpy.scanner.Scanner` batches all such detectors into one
68
+ #: traversal; ``detect`` is kept for direct use and tests.
69
+ shared_walk: ClassVar[bool] = False
65
70
 
66
71
  @abstractmethod
67
72
  def detect(self, path: Path) -> list[Environment]:
@@ -78,6 +83,15 @@ class AbstractDetector(ABC):
78
83
  use it as the walk root.
79
84
  """
80
85
 
86
+ def scan_global(self, path: Path) -> list[Environment]:
87
+ """Environments found outside the shared tree walk (e.g. global caches).
88
+
89
+ Empty by default; :class:`~killpy.detectors.cache.CacheDetector` overrides
90
+ it to report the global pip/uv caches. Called by the Scanner only for
91
+ detectors with ``shared_walk = True``.
92
+ """
93
+ return []
94
+
81
95
  def _candidate_dirs(self) -> tuple[Path, ...]:
82
96
  """Directories whose existence makes this detector applicable.
83
97
 
@@ -72,6 +72,7 @@ class CacheDetector(AbstractDetector):
72
72
 
73
73
  name = "cache"
74
74
  always_available = True # pure filesystem walk
75
+ shared_walk = True # local tree scan served by killpy.detectors._shared_walk
75
76
 
76
77
  def detect(self, path: Path) -> list[Environment]:
77
78
  envs: list[Environment] = []
@@ -79,6 +80,10 @@ class CacheDetector(AbstractDetector):
79
80
  envs.extend(self._scan_global(path))
80
81
  return envs
81
82
 
83
+ def scan_global(self, path: Path) -> list[Environment]:
84
+ """Global pip/uv caches — the part not covered by the shared tree walk."""
85
+ return self._scan_global(path)
86
+
82
87
  # ------------------------------------------------------------------ #
83
88
 
84
89
  def _scan_local(self, root: Path) -> list[Environment]:
@@ -147,6 +152,6 @@ def _make_cache_env(cache_path: Path, tag: str) -> Environment:
147
152
  path=cache_path,
148
153
  name=str(cache_path),
149
154
  type=tag,
150
- last_accessed=mtime,
155
+ last_modified=mtime,
151
156
  size_bytes=size,
152
157
  )
@@ -97,7 +97,7 @@ class CondaDetector(AbstractDetector):
97
97
  path=env_path,
98
98
  name=env_name,
99
99
  type="conda",
100
- last_accessed=mtime,
100
+ last_modified=mtime,
101
101
  size_bytes=size,
102
102
  managed_by="conda",
103
103
  )
@@ -73,7 +73,7 @@ class HatchDetector(AbstractDetector):
73
73
  path=env_dir,
74
74
  name=f"{project_dir.name}/{env_dir.name}",
75
75
  type="hatch",
76
- last_accessed=mtime,
76
+ last_modified=mtime,
77
77
  size_bytes=size,
78
78
  )
79
79
  )
@@ -62,7 +62,7 @@ class PipenvDetector(AbstractDetector):
62
62
  path=venv_path,
63
63
  name=venv_path.name,
64
64
  type="pipenv",
65
- last_accessed=mtime,
65
+ last_modified=mtime,
66
66
  size_bytes=size,
67
67
  )
68
68
  )
@@ -126,7 +126,7 @@ class PipxDetector(AbstractDetector):
126
126
  path=candidate,
127
127
  name=package_name,
128
128
  type="pipx",
129
- last_accessed=mtime,
129
+ last_modified=mtime,
130
130
  size_bytes=size,
131
131
  managed_by="pipx",
132
132
  )
@@ -72,7 +72,7 @@ class PoetryDetector(AbstractDetector):
72
72
  path=venv_path,
73
73
  name=venv_path.name,
74
74
  type="poetry",
75
- last_accessed=mtime,
75
+ last_modified=mtime,
76
76
  size_bytes=size,
77
77
  )
78
78
  )
@@ -68,7 +68,7 @@ class PyenvDetector(AbstractDetector):
68
68
  path=version_dir,
69
69
  name=version_dir.name,
70
70
  type="pyenv",
71
- last_accessed=mtime,
71
+ last_modified=mtime,
72
72
  size_bytes=size,
73
73
  )
74
74
  )
@@ -19,6 +19,7 @@ class ToxDetector(AbstractDetector):
19
19
 
20
20
  name = "tox"
21
21
  always_available = True # pure filesystem walk
22
+ shared_walk = True # tree scan served by killpy.detectors._shared_walk
22
23
 
23
24
  def detect(self, path: Path) -> list[Environment]:
24
25
  envs: list[Environment] = []
@@ -42,7 +43,7 @@ class ToxDetector(AbstractDetector):
42
43
  path=tox_path,
43
44
  name=str(tox_path),
44
45
  type="tox",
45
- last_accessed=mtime,
46
+ last_modified=mtime,
46
47
  size_bytes=size,
47
48
  )
48
49
  )
@@ -81,7 +81,7 @@ class UvDetector(AbstractDetector):
81
81
  path=env_dir,
82
82
  name=env_dir.name,
83
83
  type="uv",
84
- last_accessed=mtime,
84
+ last_modified=mtime,
85
85
  size_bytes=size,
86
86
  managed_by=managed_by,
87
87
  )
@@ -57,6 +57,7 @@ class VenvDetector(AbstractDetector):
57
57
 
58
58
  name = "venv"
59
59
  always_available = True # pure filesystem walk
60
+ shared_walk = True # tree scan served by killpy.detectors._shared_walk
60
61
 
61
62
  def detect(self, path: Path) -> list[Environment]:
62
63
  # Inner dedup: the ".venv" scan and the pyvenv.cfg scan below can both
@@ -105,6 +106,6 @@ def _make_env(dir_path: Path, tag: str) -> Environment:
105
106
  path=dir_path,
106
107
  name=str(dir_path),
107
108
  type=tag,
108
- last_accessed=mtime,
109
+ last_modified=mtime,
109
110
  size_bytes=size,
110
111
  )
@@ -69,8 +69,8 @@ class ScoringService:
69
69
  size_score = self._normalize_size(env.size_bytes)
70
70
  explanation.append(f"Size: {env.size_human}")
71
71
 
72
- age_score, age_days = self._normalize_age(env.last_accessed)
73
- explanation.append(f"Last accessed {age_days} days ago")
72
+ age_score, age_days = self._normalize_age(env.last_modified)
73
+ explanation.append(f"Last modified {age_days} days ago")
74
74
 
75
75
  is_orphan, orphan_score = self._orphan_score(env.path)
76
76
  has_project_files = not is_orphan
@@ -132,11 +132,11 @@ class ScoringService:
132
132
  return 1.0 / (1.0 + math.exp(-k * (size_bytes - x0)))
133
133
 
134
134
  @staticmethod
135
- def _normalize_age(last_accessed: datetime) -> tuple[float, int]:
135
+ def _normalize_age(last_modified: datetime) -> tuple[float, int]:
136
136
  """Return (age_score, age_days) where age_score is linear [0, 1]."""
137
137
  now = datetime.now(tz=timezone.utc)
138
138
  try:
139
- la = last_accessed
139
+ la = last_modified
140
140
  if la.tzinfo is None:
141
141
  la = la.replace(tzinfo=timezone.utc)
142
142
  age_days = max(0, (now - la).days)
@@ -9,8 +9,9 @@ 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
- _MEDIUM_AGE_THRESHOLD = 120 # age >= this → MEDIUM
13
- _LOW_AGE_THRESHOLD = 120 # age < this LOW
12
+ # Single LOW/MEDIUM cutoff: age < this → LOW (recently modified), age >= this →
13
+ # MEDIUM (stale). Active git always forces LOW regardless of age.
14
+ _STALE_AGE_THRESHOLD = 120
14
15
 
15
16
  _ACTION_HIGH = "Delete — unused and orphaned"
16
17
  _ACTION_MEDIUM = "Review — possibly unused"
@@ -59,8 +60,8 @@ class SuggestionEngine:
59
60
 
60
61
  @staticmethod
61
62
  def _age_days(scored: ScoredEnvironment) -> int:
62
- """Return age in days based on env.last_accessed."""
63
- la = scored.env.last_accessed
63
+ """Return age in days based on env.last_modified."""
64
+ la = scored.env.last_modified
64
65
  if la.tzinfo is None:
65
66
  la = la.replace(tzinfo=timezone.utc)
66
67
  return max(0, (datetime.now(tz=timezone.utc) - la).days)
@@ -81,32 +82,27 @@ class SuggestionEngine:
81
82
  # ── Rule 1: HIGH — orphan and stale ────────────────────────────────────
82
83
  if is_orphan and age_days >= _HIGH_AGE_ORPHAN_THRESHOLD:
83
84
  reasons.append("Orphan — no project files found nearby")
84
- reasons.append(f"Not used in {age_days} days")
85
+ reasons.append(f"Not modified in {age_days} days")
85
86
  return "HIGH", _ACTION_HIGH
86
87
 
87
88
  # ── Rule 2: LOW — active git or very recent ─────────────────────────────
88
- if is_active_git or age_days < _LOW_AGE_THRESHOLD:
89
+ if is_active_git or age_days < _STALE_AGE_THRESHOLD:
89
90
  if is_active_git:
90
91
  reasons.append("Active git repository")
91
- if age_days < _LOW_AGE_THRESHOLD:
92
- reasons.append(f"Recently used ({age_days} days ago)")
92
+ if age_days < _STALE_AGE_THRESHOLD:
93
+ reasons.append(f"Recently modified ({age_days} days ago)")
93
94
  action = _ACTION_LOW_ACTIVE if is_active_git else _ACTION_LOW
94
95
  return "LOW", action
95
96
 
96
- # ── Rule 3: MEDIUM — moderately old ────────────────────────────────────
97
- if age_days >= _MEDIUM_AGE_THRESHOLD:
98
- if is_orphan:
99
- reasons.append("Orphan — no project files found nearby")
100
- reasons.append(f"Not used in {age_days} days")
101
- if (
102
- scored.git_info is not None
103
- and scored.git_info.is_git_repo
104
- and not scored.git_info.is_active
105
- ):
106
- reasons.append("Associated git repo has no recent commits")
107
- return "MEDIUM", _ACTION_MEDIUM
108
-
109
- # ── Rule 4: FALLBACK → LOW ──────────────────────────────────────────────
110
- if is_active_git:
111
- reasons.append("Active git repository")
112
- return "LOW", _ACTION_LOW
97
+ # ── Rule 3: MEDIUM — stale (not active, age >= _STALE_AGE_THRESHOLD) ────
98
+ # Exhaustive fallback: everything not caught by Rules 1–2 is stale.
99
+ if is_orphan:
100
+ reasons.append("Orphan — no project files found nearby")
101
+ reasons.append(f"Not modified in {age_days} days")
102
+ if (
103
+ scored.git_info is not None
104
+ and scored.git_info.is_git_repo
105
+ and not scored.git_info.is_active
106
+ ):
107
+ reasons.append("Associated git repo has no recent commits")
108
+ return "MEDIUM", _ACTION_MEDIUM
@@ -35,8 +35,9 @@ class Environment:
35
35
  type:
36
36
  Short detector tag, e.g. ``"venv"``, ``"pyvenv.cfg"``, ``"poetry"``,
37
37
  ``"conda"``, ``"pipx"``, ``"cache"``, ``"uv"``, ``"artifacts"``…
38
- last_accessed:
39
- Timestamp of the last modification reported by the filesystem.
38
+ last_modified:
39
+ Last modification time (``st_mtime``) of the environment root, as
40
+ reported by the filesystem — not an access time.
40
41
  size_bytes:
41
42
  Total size in bytes (recursive directory sum).
42
43
  managed_by:
@@ -48,7 +49,7 @@ class Environment:
48
49
  path: Path
49
50
  name: str
50
51
  type: str
51
- last_accessed: datetime
52
+ last_modified: datetime
52
53
  size_bytes: int
53
54
  managed_by: str | None = None
54
55
  is_system_critical: bool = False
@@ -63,9 +64,9 @@ class Environment:
63
64
  return format_size(self.size_bytes)
64
65
 
65
66
  @property
66
- def last_accessed_str(self) -> str:
67
+ def last_modified_str(self) -> str:
67
68
  """Formatted date string ``DD/MM/YYYY`` for display."""
68
- return self.last_accessed.strftime("%d/%m/%Y")
69
+ return self.last_modified.strftime("%d/%m/%Y")
69
70
 
70
71
  # ------------------------------------------------------------------ #
71
72
  # Serialisation #
@@ -83,7 +84,7 @@ class Environment:
83
84
  "absolute_path": str(abs_path),
84
85
  "name": self.name,
85
86
  "type": self.type,
86
- "last_accessed": self.last_accessed.isoformat(),
87
+ "last_modified": self.last_modified.isoformat(),
87
88
  "size_bytes": self.size_bytes,
88
89
  "size_human": self.size_human,
89
90
  "managed_by": self.managed_by,
@@ -22,6 +22,7 @@ from collections.abc import AsyncIterator, Callable
22
22
  from pathlib import Path
23
23
 
24
24
  from killpy.detectors import ALL_DETECTORS, AbstractDetector
25
+ from killpy.detectors._shared_walk import TYPE_TO_DETECTOR, walk_environments
25
26
  from killpy.detectors.pyenv import _pyenv_root
26
27
  from killpy.models import Environment
27
28
 
@@ -85,24 +86,28 @@ class Scanner:
85
86
  seen: set[Path] = set()
86
87
  results: list[Environment] = []
87
88
 
88
- for detector in self._detectors:
89
- if not detector.can_handle():
90
- logger.debug("Skipping %s (can_handle=False)", detector.name)
91
- continue
89
+ applicable = [d for d in self._detectors if d.can_handle()]
90
+ shared = [d for d in applicable if d.shared_walk]
91
+ others = [d for d in applicable if not d.shared_walk]
92
+
93
+ # One traversal shared by every filesystem-walking detector.
94
+ for detector, found in self._shared_walk_groups(shared, path):
95
+ processed = self._process(found, seen)
96
+ results.extend(processed)
97
+ if on_progress is not None:
98
+ on_progress(detector, processed)
99
+
100
+ # The remaining detectors scan their own global directories.
101
+ for detector in others:
92
102
  try:
93
103
  found = detector.detect(path)
94
104
  except Exception as exc: # noqa: BLE001
95
105
  logger.warning("Detector %s raised: %s", detector.name, exc)
96
106
  found = []
97
-
98
- deduped = self._deduplicate(found, seen)
99
- deduped = self._apply_exclusions(deduped)
100
- for env in deduped:
101
- self._mark_system_critical(env)
102
- results.extend(deduped)
103
-
107
+ processed = self._process(found, seen)
108
+ results.extend(processed)
104
109
  if on_progress is not None:
105
- on_progress(detector, deduped)
110
+ on_progress(detector, processed)
106
111
 
107
112
  results.sort(key=lambda e: e.size_bytes, reverse=True)
108
113
  return results
@@ -123,28 +128,70 @@ class Scanner:
123
128
  table.add_row(env)
124
129
  """
125
130
  applicable = [d for d in self._detectors if d.can_handle()]
131
+ shared = [d for d in applicable if d.shared_walk]
132
+ others = [d for d in applicable if not d.shared_walk]
126
133
  seen: set[Path] = set()
127
134
 
128
- async def _run(detector: AbstractDetector):
135
+ async def _run_shared() -> list[tuple[AbstractDetector, list[Environment]]]:
129
136
  try:
130
- return detector, await asyncio.to_thread(detector.detect, path)
137
+ return await asyncio.to_thread(self._shared_walk_groups, shared, path)
138
+ except Exception as exc: # noqa: BLE001
139
+ logger.warning("Shared walk raised: %s", exc)
140
+ return [(d, []) for d in shared]
141
+
142
+ async def _run_one(
143
+ detector: AbstractDetector,
144
+ ) -> list[tuple[AbstractDetector, list[Environment]]]:
145
+ try:
146
+ return [(detector, await asyncio.to_thread(detector.detect, path))]
131
147
  except Exception as exc: # noqa: BLE001
132
148
  logger.warning("Detector %s raised: %s", detector.name, exc)
133
- return detector, []
149
+ return [(detector, [])]
150
+
151
+ tasks = [asyncio.create_task(_run_one(d)) for d in others]
152
+ if shared:
153
+ tasks.append(asyncio.create_task(_run_shared()))
134
154
 
135
- tasks = [asyncio.create_task(_run(d)) for d in applicable]
136
155
  for coro in asyncio.as_completed(tasks):
137
- detector, found = await coro
138
- deduped = self._deduplicate(found, seen)
139
- deduped = self._apply_exclusions(deduped)
140
- for env in deduped:
141
- self._mark_system_critical(env)
142
- yield detector, deduped
156
+ for detector, found in await coro:
157
+ deduped = self._process(found, seen)
158
+ yield detector, deduped
143
159
 
144
160
  # ------------------------------------------------------------------ #
145
161
  # Helpers #
146
162
  # ------------------------------------------------------------------ #
147
163
 
164
+ def _shared_walk_groups(
165
+ self, shared: list[AbstractDetector], path: Path
166
+ ) -> list[tuple[AbstractDetector, list[Environment]]]:
167
+ """Run the one shared walk, returning ``(detector, envs)`` per detector.
168
+
169
+ The local tree is walked once for the union of *shared* detector names;
170
+ each detector also contributes its own global scan (pip/uv caches).
171
+ Results are grouped back per detector via :data:`TYPE_TO_DETECTOR` so the
172
+ per-detector progress contract is preserved.
173
+ """
174
+ if not shared:
175
+ return []
176
+ active = {d.name for d in shared}
177
+ found = walk_environments(path, active)
178
+ for detector in shared:
179
+ found.extend(detector.scan_global(path))
180
+ by_name: dict[str, list[Environment]] = {d.name: [] for d in shared}
181
+ for env in found:
182
+ name = TYPE_TO_DETECTOR.get(env.type)
183
+ if name in by_name:
184
+ by_name[name].append(env)
185
+ return [(d, by_name[d.name]) for d in shared]
186
+
187
+ def _process(self, found: list[Environment], seen: set[Path]) -> list[Environment]:
188
+ """Deduplicate, apply exclusions, and flag system-critical envs."""
189
+ deduped = self._deduplicate(found, seen)
190
+ deduped = self._apply_exclusions(deduped)
191
+ for env in deduped:
192
+ self._mark_system_critical(env)
193
+ return deduped
194
+
148
195
  @staticmethod
149
196
  def _deduplicate(envs: list[Environment], seen: set[Path]) -> list[Environment]:
150
197
  """Filter out environments whose resolved path has already been seen.
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: killpy
3
- Version: 0.25.1
3
+ Version: 1.0.1
4
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
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
10
+ Classifier: Development Status :: 5 - Production/Stable
11
11
  Classifier: Environment :: Console
12
12
  Classifier: Environment :: Console :: Curses
13
13
  Classifier: Intended Audience :: Developers
@@ -196,7 +196,7 @@ Environments flagged with `⚠️` are actively in use (the environment killpy r
196
196
  | Key | Action |
197
197
  |-----|--------|
198
198
  | `↑` / `↓` or `k` / `j` | Move cursor up / down (vim-style) |
199
- | `/` | Open live search/filter bar (regex supported) |
199
+ | `/` | Open live search/filter bar (substring match) |
200
200
  | `Escape` | Close search bar and clear filter |
201
201
  | `T` | Toggle multi-select mode on / off |
202
202
  | `Space` | *(Multi-select)* Toggle current row selected |
@@ -211,7 +211,7 @@ Environments flagged with `⚠️` are actively in use (the environment killpy r
211
211
 
212
212
  ### Search / filter
213
213
 
214
- 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.
215
215
 
216
216
  ### Multi-select mode
217
217
 
@@ -265,7 +265,7 @@ ______________________________________________________________________
265
265
  killpy list # list all detected environments
266
266
  killpy list --path ~/projects # scan a specific path
267
267
  killpy list --type venv --type conda # filter by type (repeatable)
268
- 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
269
269
  killpy list --json # output as a JSON array
270
270
  killpy list --json-stream # stream as NDJSON — one line per env
271
271
  killpy list --quiet # suppress progress output (scripts/CI)
@@ -284,7 +284,7 @@ The **Path** column mirrors the form of `--path`: a relative `--path` produces r
284
284
  "absolute_path": "/home/user/projects/my-app/.venv",
285
285
  "name": "my-app/.venv",
286
286
  "type": "venv",
287
- "last_accessed": "2025-11-02T14:23:01+00:00",
287
+ "last_modified": "2025-11-02T14:23:01+00:00",
288
288
  "size_bytes": 54393984,
289
289
  "size_human": "51.88 MB",
290
290
  "managed_by": null,
@@ -417,8 +417,7 @@ Category is assigned deterministically by applying the following rules in order:
417
417
  |----------|------|----------|
418
418
  | 1 | Orphan (`is_orphan=True`) **and** `age ≥ 180 days` | `HIGH` |
419
419
  | 2 | Active git repository **or** `age < 120 days` | `LOW` |
420
- | 3 | `age ≥ 120 days` | `MEDIUM` |
421
- | 4 | Fallback | `LOW` |
420
+ | 3 | `age ≥ 120 days` *(exhaustive fallback)* | `MEDIUM` |
422
421
 
423
422
  Age and orphan status are the dominant signals. Size does not affect the category.
424
423
 
@@ -498,7 +497,7 @@ Yes. Run `killpy --path ~` to scan your home folder. The `stats` command gives a
498
497
  killpy delete --type venv --older-than 90 --yes
499
498
  ```
500
499
 
501
- 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.
502
501
 
503
502
  **How do I use killpy in a CI pipeline or script?**
504
503
 
@@ -604,7 +603,7 @@ ______________________________________________________________________
604
603
 
605
604
  ```yaml
606
605
  - repo: https://github.com/Tlaloc-Es/KillPy
607
- rev: 0.25.1
606
+ rev: 1.0.1
608
607
  hooks:
609
608
  - id: killpy # remove __pycache__ on every commit
610
609
  - id: killpy-clean-caches # also removes .mypy_cache, .pytest_cache, .ruff_cache
@@ -629,7 +628,7 @@ Typical minimal setup (safe for daily use):
629
628
 
630
629
  ```yaml
631
630
  - repo: https://github.com/Tlaloc-Es/KillPy
632
- rev: 0.25.1
631
+ rev: 1.0.1
633
632
  hooks:
634
633
  - id: killpy
635
634
  ```
@@ -22,6 +22,7 @@ killpy/commands/find.py
22
22
  killpy/commands/list.py
23
23
  killpy/commands/stats.py
24
24
  killpy/detectors/__init__.py
25
+ killpy/detectors/_shared_walk.py
25
26
  killpy/detectors/artifacts.py
26
27
  killpy/detectors/base.py
27
28
  killpy/detectors/cache.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "killpy"
3
- version = "0.25.1"
3
+ version = "1.0.1"
4
4
  description = "Find and delete unused Python environments (.venv, conda, poetry, pipenv, pipx, pyenv, uv) and caches to free disk space — from a fast terminal UI or a single CLI command."
5
5
  readme = "README.md"
6
6
  keywords = [
@@ -21,7 +21,7 @@ keywords = [
21
21
  "developer-tools",
22
22
  ]
23
23
  classifiers = [
24
- "Development Status :: 4 - Beta",
24
+ "Development Status :: 5 - Production/Stable",
25
25
  "Environment :: Console",
26
26
  "Environment :: Console :: Curses",
27
27
  "Intended Audience :: Developers",
@@ -75,6 +75,9 @@ package = true
75
75
  [tool.pytest.ini_options]
76
76
  testpaths = ["tests"]
77
77
  addopts = "-v --tb=short --cov=killpy --cov-report=term-missing --cov-report=xml"
78
+ markers = [
79
+ "integration: system-level end-to-end tests (real envs + real deletion); require KILLPY_INTEGRATION=1",
80
+ ]
78
81
 
79
82
  [tool.coverage.run]
80
83
  branch = true
@@ -103,5 +106,9 @@ lint = "ruff check killpy tests"
103
106
  format = "ruff format killpy tests"
104
107
  type = "mypy killpy"
105
108
 
109
+ [tool.poe.tasks.integration]
110
+ cmd = "pytest -m integration --no-cov tests/integration"
111
+ env = { KILLPY_INTEGRATION = "1" }
112
+
106
113
  [project.scripts]
107
114
  killpy = "killpy.__main__:cli"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes