uv-cache-warden 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.
@@ -0,0 +1,14 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .pytest_cache/
10
+ .coverage
11
+ htmlcov/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .DS_Store
@@ -0,0 +1,62 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [semantic versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [1.0.0] - 2026-09-02
8
+
9
+ First release.
10
+
11
+ ### Added
12
+
13
+ - `uvcw report`: per-package breakdown of the uv cache with sizes and last-access times,
14
+ `--top N` and `--json`. Totals reconcile with `uv cache size --output-format machine`.
15
+ - `uvcw gc --max-size SIZE`: least-recently-used eviction down to a byte budget, with
16
+ `--min-age`, `--keep`, `--dry-run`, `--json` and `--fail-over`. Every deletion goes through
17
+ `uv cache clean <package>` followed by a single `uv cache prune`; nothing is unlinked
18
+ directly and `--force` is never passed to uv.
19
+ - `uvcw doctor`: cache location and how it was found, uv version, bucket inventory with
20
+ sizes, reconciliation against `uv cache size`, hardlink statistics and whether access times
21
+ are tracked on this filesystem.
22
+ - `--json-file PATH` on every command, so a run can print a human summary and emit machine
23
+ data in one pass.
24
+ - `uvcw gc --min-free SIZE`: budget against free space on the cache's filesystem rather than
25
+ against the cache. Combined with `--max-size`, the tighter constraint wins and the header
26
+ names which one is binding.
27
+ - `uvcw report --sort {size,age,name}` and `--all`. On a terminal the table stops at 20 rows so
28
+ the header stays on screen; piped and JSON output are never truncated.
29
+ - A `SHARE` column giving each package's percentage of the cache, and right-aligned sizes.
30
+ - Colour on terminals, honouring `NO_COLOR`, `FORCE_COLOR` and `--no-color`, never applied to
31
+ JSON or to a redirected stream. Windows consoles get VT processing enabled explicitly.
32
+ - A `scanning cache... N entries` counter on stderr for scans that run longer than 1.5 seconds.
33
+ - Composite GitHub Action with `gc`, `dry-run` and `report` modes, a `min-free` input, outputs for
34
+ `size-before`, `size-after`, `reclaimed`, `evicted`, `over-budget` and `json-path`, and a
35
+ job summary table.
36
+ - `UVCW_CACHE_DIR`, `UVCW_UV`, `UVCW_TIMEOUT`, `UVCW_MAX_SIZE`, `UVCW_MIN_AGE` and
37
+ `UVCW_KEEP` as defaults for the matching flags.
38
+
39
+ ### Notes on accuracy
40
+
41
+ - Byte accounting reproduces `diskus 0.9.0`, which is what `uv cache size` uses:
42
+ `st_blocks * 512` with `(dev, ino)` dedupe on Unix, `st_size` with no dedupe on Windows,
43
+ directories counted on both. A physical total with inode dedupe on both platforms and uv's
44
+ own reclaim estimate are reported alongside it.
45
+ - Archive links are resolved in both shapes uv writes them: a symlink on Unix, and on Windows
46
+ a regular file containing `archive-v<n>/<id>`.
47
+ - Directory traversal stops at symlinks and at Windows junctions. `S_ISLNK` is false for a
48
+ junction, so a walk that only checks for symlinks would follow one straight out of the cache.
49
+ - A package's last-used time ignores the `simple-*` bucket. `uv lock` rewrites that metadata for
50
+ every package it resolves, which would otherwise make an entire lockfile look freshly used.
51
+ - The cache root's own directory entry is counted. `uv cache size` counts the path it is given,
52
+ and on NTFS a directory reports zero until it outgrows its resident index, so omitting it was
53
+ invisible on a small cache and 8 KiB short on a larger one.
54
+ - Recency comes from regular files only; directories and symlinks contribute their mtime. On a
55
+ `relatime` mount (the Linux default) listing a directory or reading a symlink bumps its atime,
56
+ and the scan does both to every entry, so the tool was marking every package as used by its
57
+ own visit. Found by running the suite on ext4 under WSL before the first CI run finished.
58
+ - A dry run that cannot reach the budget says so, and says which rule held the bytes back. It
59
+ previously printed the projection and stopped, which read as success even while `--fail-over`
60
+ was exiting 2.
61
+
62
+ [1.0.0]: https://github.com/Booyaka101/uv-cache-warden/releases/tag/v1.0.0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Christo Bosch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,324 @@
1
+ Metadata-Version: 2.5
2
+ Name: uv-cache-warden
3
+ Version: 1.0.0
4
+ Summary: Enforce a size budget on Astral uv's package cache, with LRU eviction through uv itself.
5
+ Project-URL: Homepage, https://github.com/Booyaka101/uv-cache-warden
6
+ Project-URL: Repository, https://github.com/Booyaka101/uv-cache-warden
7
+ Project-URL: Issues, https://github.com/Booyaka101/uv-cache-warden/issues
8
+ Project-URL: Changelog, https://github.com/Booyaka101/uv-cache-warden/blob/main/CHANGELOG.md
9
+ Author-email: Christo Bosch <cbosch101@gmail.com>
10
+ License: MIT License
11
+
12
+ Copyright (c) 2026 Christo Bosch
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
31
+ License-File: LICENSE
32
+ Keywords: cache,ci,packaging,python,uv
33
+ Classifier: Development Status :: 5 - Production/Stable
34
+ Classifier: Environment :: Console
35
+ Classifier: Intended Audience :: Developers
36
+ Classifier: License :: OSI Approved :: MIT License
37
+ Classifier: Operating System :: MacOS
38
+ Classifier: Operating System :: Microsoft :: Windows
39
+ Classifier: Operating System :: POSIX :: Linux
40
+ Classifier: Programming Language :: Python :: 3
41
+ Classifier: Programming Language :: Python :: 3 :: Only
42
+ Classifier: Programming Language :: Python :: 3.9
43
+ Classifier: Programming Language :: Python :: 3.10
44
+ Classifier: Programming Language :: Python :: 3.11
45
+ Classifier: Programming Language :: Python :: 3.12
46
+ Classifier: Programming Language :: Python :: 3.13
47
+ Classifier: Topic :: Software Development :: Build Tools
48
+ Classifier: Topic :: System :: Archiving :: Packaging
49
+ Classifier: Topic :: Utilities
50
+ Requires-Python: >=3.9
51
+ Provides-Extra: dev
52
+ Requires-Dist: pytest>=7.0; extra == 'dev'
53
+ Description-Content-Type: text/markdown
54
+
55
+ # uv-cache-warden
56
+
57
+ Keep [uv](https://docs.astral.sh/uv/)'s package cache under a size budget, with least-recently-used eviction. Dependency-free Python CLI (`uvcw`) plus a GitHub Action.
58
+
59
+ ![uvcw report](docs/report.png)
60
+
61
+ ## The problem
62
+
63
+ uv's cache grows and never shrinks on its own. [astral-sh/uv#5731](https://github.com/astral-sh/uv/issues/5731) has been open since August 2024 with 53 reactions and 31 comments; people report caches at 99 GB, 140 GB, 150 GB, 200 GiB and, at NVIDIA, "routinely exceeding 1TB+". uv ships `uv cache size`, `uv cache clean [PACKAGE]` and `uv cache prune`, and [none of them takes a size or age argument](https://docs.astral.sh/uv/reference/cli/). `uv cache prune` only removes entries it considers unused, which is why [#16551](https://github.com/astral-sh/uv/issues/16551) (multi-GB of CUDA wheels left behind after `uv tool uninstall`) was closed as not planned.
64
+
65
+ So the workaround people write by hand is: measure the cache, work out what to drop, call `uv cache clean` per package, prune. That is what this does, properly.
66
+
67
+ uv's docs say it is [never safe to modify the cache directly](https://docs.astral.sh/uv/concepts/cache/). uv-cache-warden never unlinks a file. Every deletion is a `uv cache clean <package>` or one final `uv cache prune`, and it never passes uv's `--force`, so uv keeps blocking on the cache lock the way it is designed to.
68
+
69
+ ## Install
70
+
71
+ ```console
72
+ $ uv tool install uv-cache-warden
73
+ $ uvcw --version
74
+ uvcw 1.0.0
75
+ ```
76
+
77
+ Or run it without installing:
78
+
79
+ ```console
80
+ $ uvx uv-cache-warden report
81
+ ```
82
+
83
+ `pip install uv-cache-warden` and `pipx install uv-cache-warden` work too. Python 3.9 or newer, no runtime dependencies.
84
+
85
+ ## Usage
86
+
87
+ ### `uvcw report`
88
+
89
+ ```console
90
+ $ uvcw report --top 8
91
+ PACKAGE VERS SIZE SHARE LAST USED
92
+ scipy 1 106.0 MiB 43% 21 minutes ago
93
+ numpy 1 41.1 MiB 17% 3 days ago
94
+ pandas 1 34.6 MiB 14% 31 days ago
95
+ matplotlib 1 22.9 MiB 9% 14 days ago
96
+ pillow 1 14.8 MiB 6% 14 days ago
97
+ fonttools 1 12.1 MiB 5% 31 days ago
98
+ pygments 1 4.5 MiB 2% 47 days ago
99
+ pytest 1 1.4 MiB <1% 3 days ago
100
+ ... 26 more 10.2 MiB 4%
101
+ unattributed 36.1 KiB <1%
102
+ TOTAL 247.7 MiB (uv cache size: 247.7 MiB)
103
+
104
+ note: Access times on this filesystem never differ from modification times (noatime?), so LAST USED
105
+ reflects when an entry was written.
106
+ ```
107
+
108
+ On a terminal the table stops at 20 rows so the header stays on screen. `--all` shows everything, and piped output is never truncated. That closing note is a real filesystem fact, not boilerplate: Windows disables NTFS last-access updates by default, so on this machine LRU falls back to write order, and `uvcw doctor` names the registry value responsible.
109
+
110
+ `--sort age` reorders the table coldest-first, which is the order `gc` evicts in, so it previews what a budget would take:
111
+
112
+ ```console
113
+ $ uvcw report --sort age --top 5
114
+ PACKAGE VERS SIZE SHARE LAST USED
115
+ colorama 1 108.8 KiB <1% 63 days ago
116
+ python-dateutil 1 451.2 KiB <1% 63 days ago
117
+ requests 1 298.8 KiB <1% 63 days ago
118
+ httpx 1 330.7 KiB <1% 47 days ago
119
+ pygments 1 4.5 MiB 2% 47 days ago
120
+ ... 29 more 242.0 MiB 98%
121
+ unattributed 36.1 KiB <1%
122
+ TOTAL 247.7 MiB (uv cache size: 247.7 MiB)
123
+ ```
124
+
125
+ `--json` gives the same data with per-package `disk_bytes`, `physical_bytes`, `reclaim_bytes`, `age_seconds`, a bucket inventory and the reconciliation delta against `uv cache size`. `--json-file PATH` writes that alongside the table instead of replacing it, which is what you want in a CI log.
126
+
127
+ ### `uvcw gc`
128
+
129
+ ![uvcw gc](docs/gc.png)
130
+
131
+ A dry run changes nothing and shows exactly what the real run would do:
132
+
133
+ ```console
134
+ $ uvcw gc --max-size 150MB --min-age 7d --dry-run
135
+ cache D:\tmp\uvcw-live budget 143.1 MiB (--max-size) currently 247.7 MiB
136
+ 489.6 GiB free on this filesystem
137
+ note: access times are not tracked on this filesystem, so eviction order is by write time rather
138
+ than true last use.
139
+
140
+ would evict (18 of 34 packages):
141
+ colorama 108.8 KiB 63 days ago
142
+ python-dateutil 451.2 KiB 63 days ago
143
+ requests 298.8 KiB 63 days ago
144
+ ...
145
+ anyio 557.3 KiB 9 days ago
146
+ httpcore 329.5 KiB 9 days ago
147
+
148
+ dry run: nothing was removed. Projected 247.7 MiB -> 153.0 MiB (budget 143.1 MiB).
149
+ projected: still over budget by 10.0 MiB.
150
+ 153.0 MiB held back by packages used within --min-age
151
+ 36.1 KiB is unattributed (no package name to pass to `uv cache clean`); only `uv cache prune` can touch it
152
+ ```
153
+
154
+ That plan falls short of the budget, and the run says so up front rather than leaving you to compare the two numbers. `--fail-over` turns it into exit 2. Relaxing `--min-age` lets it through:
155
+
156
+ ```console
157
+ $ uvcw gc --max-size 150MB --min-age 3d --fail-over
158
+ Removed 22 files (100.8KiB)
159
+ Removed 30 files (447.2KiB)
160
+ ...
161
+ Removed 1066 files (40.9MiB)
162
+ Pruning cache at: D:\tmp\uvcw-live
163
+ No unused entries found
164
+ cache D:\tmp\uvcw-live budget 143.1 MiB (--max-size) currently 247.7 MiB
165
+ 489.6 GiB free on this filesystem
166
+
167
+ evicted (20 of 34 packages):
168
+ colorama 108.8 KiB 63 days ago
169
+ ...
170
+ numpy 41.1 MiB 3 days ago
171
+
172
+ reclaimed 136.3 MiB; cache now 111.4 MiB (budget 143.1 MiB); evicted 20 packages
173
+ under budget.
174
+ ```
175
+
176
+ The `Removed ...` and `Pruning cache at:` lines come from uv itself, streamed through as they happen so a wait on the cache lock is visible while it is still happening.
177
+
178
+ Already under budget is a no-op that exits 0:
179
+
180
+ ```console
181
+ $ uvcw gc --max-size 10GB
182
+ cache D:\tmp\uvcw-live budget 9.3 GiB (--max-size) currently 111.4 MiB
183
+ 489.6 GiB free on this filesystem
184
+ Already under budget (111.4 MiB <= 9.3 GiB). Nothing to do.
185
+ ```
186
+
187
+ `--min-free` budgets against the filesystem rather than the cache, which is what a runner that keeps filling its disk actually wants. To leave 500 GB free on a disk with 489.6 GiB spare, the cache has to give back the shortfall:
188
+
189
+ ```console
190
+ $ uvcw gc --min-free 500GB --dry-run
191
+ cache D:\tmp\uvcw-live budget 104.7 MiB (--min-free) currently 247.7 MiB
192
+ 489.6 GiB free on this filesystem
193
+ ```
194
+
195
+ Give both and the tighter one wins; the header always names which constraint is binding. Even with nothing evictable, `gc` still runs `uv cache prune` once when the cache is over budget, because unreferenced archive bytes are reclaimable only there. That is the [#16551](https://github.com/astral-sh/uv/issues/16551) case.
196
+
197
+ ### `uvcw doctor`
198
+
199
+ ![uvcw doctor](docs/doctor.png)
200
+
201
+ ```console
202
+ $ uvcw doctor
203
+ [ ok ] uv-cache-warden 1.0.0 on Python 3.12.10 (Windows-11-10.0.26200-SP0)
204
+ [ ok ] uv uv 0.12.9 (9f9286029 2026-09-01 x86_64-pc-windows-msvc) at
205
+ D:\tmp\uvcw-venv\Scripts\uv.EXE
206
+ [ ok ] cache directory D:\tmp\uvcw-live (from uv cache dir)
207
+ [ ok ] buckets archive-v0, interpreter-v4, sdists-v9, simple-v24, wheels-v6
208
+ [ ok ] bucket sizes archive-v0 241.2 MiB simple-v24 6.5 MiB wheels-v6 57.4 KiB (root) 8.0 KiB
209
+ interpreter-v4 4.0 KiB sdists-v9 0 B
210
+ [ ok ] packages 34 attributed, 36.1 KiB unattributed, 0 B protected (osv, python)
211
+ [ ok ] reconciliation uvcw 247.7 MiB vs `uv cache size` 247.7 MiB (0.00%)
212
+ [ ok ] hardlinks 6937 of 7183 files are hardlinked, 0 B of it shared within the cache. Real
213
+ footprint 247.7 MiB, uv counts 247.7 MiB.
214
+ [warn] access times access times never differ from modification times, so eviction falls back to
215
+ write order; NtfsDisableLastAccessUpdate=2147483649 (updates disabled)
216
+ [ ok ] filesystem 489.6 GiB free of 1.8 TiB where the cache lives
217
+ [ ok ] sizing mode st_size, uv does not dedupe here
218
+ ```
219
+
220
+ `doctor` exits 1 if any check fails, so it works as a preflight step.
221
+
222
+ ### Output
223
+
224
+ Colour is on for terminals and off everywhere else. `NO_COLOR` disables it, `FORCE_COLOR` forces it, `--no-color` does the same per command, and `--json` is never coloured. A scan of a large cache prints a `scanning cache... 62,000 entries` counter on stderr, but only after it has already been running for a second and a half, so quick runs stay silent and piped output stays clean.
225
+
226
+ ## GitHub Action
227
+
228
+ ```yaml
229
+ - uses: astral-sh/setup-uv@v10.0.1
230
+ - run: uv sync
231
+
232
+ - uses: Booyaka101/uv-cache-warden@v1
233
+ with:
234
+ max-size: 4GB
235
+ min-age: 3d
236
+ keep: torch
237
+ ```
238
+
239
+ Inputs: `max-size`, `min-free`, `min-age`, `keep` (comma-separated), `mode` (`gc`, `dry-run` or `report`), `fail-over`, `cache-dir`, `timeout`, `version`, `summary`.
240
+ Outputs: `size-before`, `size-after`, `reclaimed`, `evicted`, `over-budget`, `json-path`. It writes a job summary table by default.
241
+
242
+ A self-hosted runner that keeps its cache between jobs is the case this exists for:
243
+
244
+ ```yaml
245
+ - uses: Booyaka101/uv-cache-warden@v1
246
+ if: always()
247
+ with:
248
+ max-size: 20GB
249
+ min-free: 50GB
250
+ ```
251
+
252
+ `version: local` installs from the checkout instead of PyPI, which is how this repository tests its own action.
253
+
254
+ ## Configuration
255
+
256
+ Flags win; environment variables fill in defaults. There is no config file.
257
+
258
+ | Variable | Equivalent |
259
+ |---|---|
260
+ | `UVCW_CACHE_DIR` | `--cache-dir` |
261
+ | `UVCW_UV` | `--uv` |
262
+ | `UVCW_TIMEOUT` | `--timeout` (seconds, default 900) |
263
+ | `UVCW_MAX_SIZE` | `gc --max-size` |
264
+ | `UVCW_MIN_FREE` | `gc --min-free` |
265
+ | `UVCW_MIN_AGE` | `gc --min-age` |
266
+ | `UVCW_KEEP` | `gc --keep`, comma-separated |
267
+
268
+ Sizes accept `10GB`, `4GiB`, `500MiB` or a bare byte count; decimal units are powers of 1000 and binary units powers of 1024. Durations accept `s`, `m`, `h`, `d`, `w`.
269
+
270
+ Exit codes: `0` success, `1` uvcw could not do its job (no `uv`, no cache, bad arguments), `2` still over budget and `--fail-over` was given. A package uv refuses to clean is logged, skipped and named in the summary; it does not change the exit code, so use `--fail-over` if you need CI to notice.
271
+
272
+ The cache directory is found by running `uv cache dir`. If that fails, it falls back to `$UV_CACHE_DIR`, `$XDG_CACHE_HOME/uv`, `~/.cache/uv` and `%LOCALAPPDATA%\uv\cache`, in that order. A directory containing no recognisable uv bucket is refused outright, and so are your home directory and a filesystem root.
273
+
274
+ ## How the numbers work
275
+
276
+ `uv cache size` delegates to [`diskus`](https://github.com/sharkdp/diskus), so uvcw reproduces diskus exactly and the two totals agree byte for byte:
277
+
278
+ | | file bytes | directories | hardlink dedupe |
279
+ |---|---|---|---|
280
+ | Unix | `st_blocks * 512` | counted | yes, by `(dev, ino)` when `nlink > 1` |
281
+ | Windows | `st_size` | counted, at their NTFS index allocation | none, [by design](https://github.com/sharkdp/diskus/issues/32) |
282
+
283
+ The cache directory's own entry counts too, which on NTFS is zero until the directory outgrows its resident index and then jumps to 4 KiB or more.
284
+
285
+ That number, `disk_bytes`, is what the budget is measured against, because it is the number the user sees. Two others appear in `--json`:
286
+
287
+ - `physical_bytes`: inode dedupe on *both* platforms. uv 0.12.7 added the `content-addressed-cache` preview feature, which hardlinks identical files within and across cached wheels, so on Windows this can sit well below `disk_bytes`.
288
+ - `reclaim_bytes`: uv's own estimate of what removing an entry frees, from `crates/uv-cache/src/removal.rs`: `blocks * 512` when `nlink == 1` and zero otherwise on Unix, `len()` on Windows.
289
+
290
+ Package attribution comes from uv's own layout. `wheels-*/<kind>/<package>/`, `sdists-*/<kind>/<package>/<version>/` and `simple-*/<kind>/<package>.rkyv` name the package directly. Unpacked wheels sit under `archive-*/<hash>/` with no name, reached by a link from the wheels bucket: a symlink on Unix, and on Windows a small regular file whose content is literally `archive-v0/<id>`. uvcw resolves both, and ignores a link naming a stale archive version, exactly as uv's own `resolve_link` does.
291
+
292
+ ## Limitations
293
+
294
+ - **Source distributions from a URL, a local path or Git are not attributed.** uv identifies those by reading a msgpack blob inside the entry, and uvcw is stdlib-only. Their bytes land in `unattributed`, which only `uv cache prune` can reclaim. Wheels from an alternate index *are* attributed, because the index layout still puts the package name in the path.
295
+ - **An archive referenced by two packages is charged to neither.** `uv cache clean <pkg>` only frees it once every referrer is gone, so attributing it to one of them would promise bytes uvcw cannot deliver.
296
+ - **Managed Python interpreters (`python-v0`) and the vulnerability database (`osv-v0`) are never evicted.** They count toward the total and are reported separately as `protected`.
297
+ - **Recency ignores the simple-index bucket.** `uv lock` revalidates `simple-*` metadata for every package it resolves, so those files are freshly written even for a package nobody has installed in months. Their bytes count toward a package's size; their timestamps do not move its clock.
298
+ - **LRU degrades to write order where access times are not tracked.** Windows disables NTFS last-access updates by default and Linux mounts are commonly `noatime`; `uvcw doctor` and `uvcw gc` both say so when it applies. `last_used` is `max(atime, mtime)`, so it degrades to first-write rather than to nonsense.
299
+ - **Strict LRU can evict a lot to reach a little.** Eviction is oldest-first by design, so if the one package standing between you and the budget is also the newest, everything older goes first. The header says how many of your packages that is, and `--dry-run` shows the list before anything happens.
300
+ - **Projection is not measurement.** Evicting a package whose bytes are hardlinked to bytes that survive frees less than expected, so `gc` re-measures with `uv cache size` and runs the analysis again (up to three rounds) if it is still over. The printed `reclaimed` figure is always measured, never projected.
301
+ - **On a uv too old for `uv cache size`, totals come from our own walk** and there is nothing to cross-check them against. `doctor` warns when that happens.
302
+ - No daemon, no config file, no telemetry.
303
+
304
+ ## Development
305
+
306
+ ```console
307
+ $ git clone https://github.com/Booyaka101/uv-cache-warden
308
+ $ cd uv-cache-warden
309
+ $ python -m pip install -e ".[dev]"
310
+ $ python -m pytest -q # 139 unit tests, no network
311
+ $ python -m pytest -q -m integration # needs uv on PATH and PyPI access
312
+ ```
313
+
314
+ The unit tests build synthetic cache trees matching `crates/uv-cache/src/lib.rs` and use a recording fake in place of the uv wrapper. The fake lives in `tests/conftest.py` and nothing under `src/` imports it.
315
+
316
+ `PHASE0.md` records every upstream fact this tool relies on, with the quote and the URL, so it can be rechecked when uv changes.
317
+
318
+ ## Distribution
319
+
320
+ The first place to post this is [astral-sh/uv#5731](https://github.com/astral-sh/uv/issues/5731) itself. It is an open, `help wanted` issue with 31 comments of people describing exactly this workaround by hand, and a comment there reaches everyone already subscribed to the problem.
321
+
322
+ ## License
323
+
324
+ MIT.