Last active
August 30, 2026 11:45
-
-
Save mikeckennedy/2f45134b3281b3ccf2729e3a7c21ea4f to your computer and use it in GitHub Desktop.
prune_uv_pythons.py - Prune uv-managed Python installs, keeping only the newest patch per minor version
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # /// script | |
| # requires-python = ">=3.10" | |
| # dependencies = [] | |
| # /// | |
| """Prune uv-managed Python installs, keeping only the newest patch per minor version. | |
| Why: uv installs a fresh patch release whenever one lands and never removes the old | |
| one, so `uv python list` slowly fills with 3.13.12, 3.13.13, 3.13.14, 3.13.15, and so | |
| on. Each of those is a complete standalone copy of Python sitting in | |
| ~/.local/share/uv/python (run `du -sh` on it to see what you're carrying). This script | |
| deletes the stale patches and leaves the newest one for every minor version you have. | |
| What it keeps: the highest patch in each (implementation, minor version, variant, | |
| platform) group. Variants are their own groups, so cpython 3.14.7 and | |
| cpython 3.14.5+freethreaded both survive - pruning them together would break the | |
| python3.14t symlink. Rows marked `<download available>` are ignored, so an | |
| uninstalled 3.15.0rc1 never counts as "the latest 3.15." Final releases outrank | |
| prereleases at the same patch, so 3.15.0 beats 3.15.0rc1. Duplicate rows for one | |
| install (the bin symlink plus the cpython-3.14-... version dir) collapse to a single | |
| uninstall. | |
| Uninstalls go through `uv python uninstall <full install key>`, one call per version, | |
| so a single failure doesn't block the rest. | |
| Caveat: if a project pins an exact patch in .python-version and that patch gets | |
| pruned, the next `uv sync` re-downloads it. | |
| uv run prune_uv_pythons.py # dry run, shows what would go | |
| uv run prune_uv_pythons.py --apply # actually uninstall (asks first) | |
| uv run prune_uv_pythons.py --keep 2 # keep the 2 newest patches per minor | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import re | |
| import subprocess | |
| import sys | |
| from collections import defaultdict | |
| # cpython-3.14.5+freethreaded-macos-aarch64-none | |
| KEY_RE = re.compile( | |
| r'^(?P<impl>[A-Za-z]+)-' | |
| r'(?P<version>\d+(?:\.\d+)*(?:a|b|rc)?\d*)' | |
| r'(?:\+(?P<variant>[^-]+))?-' | |
| r'(?P<platform>.+)$' | |
| ) | |
| VERSION_RE = re.compile(r'^(\d+)\.(\d+)(?:\.(\d+))?(?:(a|b|rc)(\d+))?$') | |
| PRE_RANK = {'a': 0, 'b': 1, 'rc': 2} | |
| def sort_key(version: str) -> tuple: | |
| m = VERSION_RE.match(version) | |
| if not m: | |
| return (0, 0, 0, 0, 0) | |
| major, minor, patch, pre, pre_n = m.groups() | |
| # final releases outrank rc/b/a of the same patch | |
| return (int(major), int(minor), int(patch or 0), PRE_RANK.get(pre, 3), int(pre_n or 0)) | |
| def list_installed(uv: str) -> dict[str, dict]: | |
| cmd = [uv, 'python', 'list', '--managed-python', '--all-versions', '--only-installed'] | |
| try: | |
| out = subprocess.run(cmd, capture_output=True, text=True, check=True).stdout | |
| except FileNotFoundError: | |
| sys.exit(f'error: {uv!r} not found on PATH') | |
| except subprocess.CalledProcessError as e: | |
| sys.exit(f'error: {" ".join(cmd)} failed\n{e.stderr.strip()}') | |
| installed: dict[str, dict] = {} | |
| for line in out.splitlines(): | |
| line = line.strip() | |
| if not line: | |
| continue | |
| key, _, rest = line.partition(' ') | |
| rest = rest.strip() | |
| if not rest or rest.startswith('<download'): # not actually installed | |
| continue | |
| m = KEY_RE.match(key) | |
| if not m: | |
| print(f'warning: skipping unparsed entry {key!r}', file=sys.stderr) | |
| continue | |
| entry = installed.setdefault(key, dict(m.groupdict(), key=key, paths=[])) | |
| entry['paths'].append(rest) # same install can show up under several symlink dirs | |
| return installed | |
| def plan(installed: dict[str, dict], keep: int) -> tuple[list[dict], list[dict]]: | |
| groups: dict[tuple, list[dict]] = defaultdict(list) | |
| for entry in installed.values(): | |
| m = VERSION_RE.match(entry['version']) | |
| minor = f'{m.group(1)}.{m.group(2)}' if m else entry['version'] | |
| groups[(entry['impl'], minor, entry['variant'] or '', entry['platform'])].append(entry) | |
| keepers, doomed = [], [] | |
| for group_key in sorted(groups): | |
| ordered = sorted(groups[group_key], key=lambda e: sort_key(e['version']), reverse=True) | |
| keepers.extend(ordered[:keep]) | |
| doomed.extend(ordered[keep:]) | |
| return keepers, doomed | |
| def label(entry: dict) -> str: | |
| return entry['impl'] + (f'+{entry["variant"]}' if entry['variant'] else '') | |
| def main() -> int: | |
| ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) | |
| ap.add_argument('--apply', action='store_true', help='actually uninstall (default: dry run)') | |
| ap.add_argument('--keep', type=int, default=1, metavar='N', help='newest N patches to keep per minor (default: 1)') | |
| ap.add_argument('--yes', action='store_true', help='skip the confirmation prompt') | |
| ap.add_argument('--uv', default='uv', help='path to the uv binary') | |
| args = ap.parse_args() | |
| if args.keep < 1: | |
| sys.exit('error: --keep must be at least 1') | |
| installed = list_installed(args.uv) | |
| if not installed: | |
| print('No managed Python installs found.') | |
| return 0 | |
| keepers, doomed = plan(installed, args.keep) | |
| width = max(len(label(e)) for e in installed.values()) + 2 | |
| for entry in sorted(keepers, key=lambda e: (e['impl'], e['variant'] or '', sort_key(e['version'])), reverse=True): | |
| print(f' keep {label(entry):<{width}} {entry["version"]}') | |
| for entry in sorted(doomed, key=lambda e: (e['impl'], e['variant'] or '', sort_key(e['version'])), reverse=True): | |
| print(f' remove {label(entry):<{width}} {entry["version"]}') | |
| if not doomed: | |
| print('\nNothing to prune.') | |
| return 0 | |
| print(f'\n{len(doomed)} install(s) to remove, {len(keepers)} to keep.') | |
| if not args.apply: | |
| print('\nDry run. Re-run with --apply to uninstall, or do it by hand:') | |
| print(' uv python uninstall ' + ' '.join(e['key'] for e in doomed)) | |
| return 0 | |
| if not args.yes: | |
| if input('\nUninstall these? [y/N] ').strip().lower() not in {'y', 'yes'}: | |
| print('Aborted.') | |
| return 1 | |
| failures = 0 | |
| for entry in doomed: | |
| print(f'\n$ uv python uninstall {entry["key"]}') | |
| result = subprocess.run([args.uv, 'python', 'uninstall', entry['key']]) | |
| if result.returncode != 0: | |
| failures += 1 | |
| print(f' failed: {entry["key"]}', file=sys.stderr) | |
| return 1 if failures else 0 | |
| if __name__ == '__main__': | |
| raise SystemExit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@mikeckennedy Thanks for a useful script! I tried it in the wild and stumbled upon a caveat. The script doesn't account for
uv toolinstallations. For example, giventhe script still sugests to remove
3.13.2and
uv tool unistallproceed without a warning as wellA feasible solution would be to resolve the format used by
uv tool list --show-python(CPython 3.13.2) intocpython-3.13.2-macos-aarch64-noneand exclude it from thedoomedones. Theoretically, it's possible to do it in an unambiguous way, for anos-archpair is constant within the machine.