#!/usr/bin/env python3 """PEP 440-aware release pruning -- replaces `sort -V` in publish_muse_release.sh. `sort -V` is a generic natural/version sort with zero knowledge of PEP 440's reserved-segment precedence (dev < a < b < rc < final, see docs/versioning.md's "Build channels" table). It compares "dev1" against "rc14" as plain text, and 'd' sorts before 'r' -- placing the *newest* channel (nightly) before older release candidates, so a naive keep-newest-N prune deletes exactly the file that should have been kept. See MuseHub issue #128. CLI usage (both cleanup call sites in publish_muse_release.sh consume this identically, so the remote SSM host never re-implements version sorting): python3 prune_releases.py --keep 3 muse-0.2.0rc14.tar.gz muse-0.2.0.dev1.tar.gz ... """ from __future__ import annotations import argparse import re import sys from packaging.version import InvalidVersion, Version _FILENAME_RE = re.compile(r"^muse-(?P.+)\.tar\.gz$") def stale_releases(filenames: list[str], keep: int) -> list[str]: """Return filenames to delete -- all but the *keep* highest-precedence releases. Ordered oldest-first (readable log output: the first line printed is the first one deleted). Never guesses on an unparseable name: raises rather than silently mis-sorting everything else around it. Args: filenames: Release tarball filenames, e.g. "muse-0.2.0rc14.tar.gz". keep: Number of highest-precedence releases to retain. Returns: Filenames to delete, oldest (lowest-precedence) first. Empty if ``keep >= len(filenames)``. Raises: ValueError: If any filename doesn't match the ``muse-{version}.tar.gz`` pattern, or its version segment isn't valid PEP 440. """ if not filenames: return [] parsed: list[tuple[Version, str]] = [] for name in filenames: match = _FILENAME_RE.match(name) if match is None: raise ValueError( f"Cannot parse release filename {name!r} -- expected " "'muse-{version}.tar.gz'. Refusing to prune: a malformed " "release name must stop the pipeline, not be silently " "skipped or lexically mis-sorted." ) try: version = Version(match.group("version")) except InvalidVersion as exc: raise ValueError( f"Release filename {name!r} has an invalid PEP 440 version " f"segment {match.group('version')!r}: {exc}" ) from exc parsed.append((version, name)) parsed.sort(key=lambda pair: pair[0]) # oldest (lowest precedence) first if keep >= len(parsed): return [] stale_count = len(parsed) - keep return [name for _version, name in parsed[:stale_count]] def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--keep", type=int, required=True, metavar="N") parser.add_argument("filenames", nargs="*", metavar="FILENAME") args = parser.parse_args(argv) try: stale = stale_releases(args.filenames, args.keep) except ValueError as exc: print(f"error: {exc}", file=sys.stderr) return 1 for name in stale: print(name) return 0 if __name__ == "__main__": raise SystemExit(main())