#!/usr/bin/env python3 # retoor import argparse import sys import time from pathlib import Path MIN_AGE_SECONDS = 3600 PARTIAL_SUFFIX = ".partial" def human_bytes(size: int) -> str: value = float(max(0, int(size or 0))) for unit in ("B", "KB", "MB", "GB", "TB"): if value < 1024 or unit == "TB": return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}" value /= 1024 return f"{value:.1f} TB" def backups_root() -> Path: return Path(__file__).resolve().parent / "data" / "backups" def find_partials( root: Path, now: float, min_age: int ) -> tuple[list[tuple[Path, int, float]], list[tuple[Path, int, float]]]: abandoned: list[tuple[Path, int, float]] = [] in_progress: list[tuple[Path, int, float]] = [] if not root.is_dir(): return abandoned, in_progress for path in root.rglob(f"*{PARTIAL_SUFFIX}"): if not path.is_file() or path.suffix != PARTIAL_SUFFIX: continue try: info = path.stat() except OSError as exc: print(f"skip {path}: {exc}", file=sys.stderr) continue age = now - info.st_mtime row = (path, info.st_size, age) if age < min_age: in_progress.append(row) else: abandoned.append(row) abandoned.sort(key=lambda item: item[1], reverse=True) in_progress.sort(key=lambda item: item[1], reverse=True) return abandoned, in_progress def prune_empty_dirs(root: Path) -> int: removed = 0 if not root.is_dir(): return 0 for directory in sorted( (path for path in root.rglob("*") if path.is_dir()), key=lambda path: len(path.parts), reverse=True, ): try: next(directory.iterdir()) except StopIteration: try: directory.rmdir() removed += 1 except OSError: pass except OSError: pass return removed def report(label: str, rows: list[tuple[Path, int, float]]) -> int: total = 0 if not rows: return total print(label) for path, size, age in rows: total += size print(f" {human_bytes(size):>10} {age / 3600:.1f}h {path}") print(f" {human_bytes(total):>10} total") return total def main() -> int: parser = argparse.ArgumentParser( description=( "Remove abandoned backup *.partial files under data/backups. " "Does not open SQLite and does not touch completed .tar.gz archives." ) ) parser.add_argument( "--apply", action="store_true", help="Delete the abandoned files. Default is a dry run.", ) parser.add_argument( "--min-age-seconds", type=int, default=MIN_AGE_SECONDS, help="Skip files newer than this many seconds (in-flight backup guard).", ) args = parser.parse_args() if args.min_age_seconds < 0: print("--min-age-seconds must be >= 0", file=sys.stderr) return 2 root = backups_root() abandoned, in_progress = find_partials(root, time.time(), args.min_age_seconds) if in_progress: report("In-flight (kept)", in_progress) if not abandoned: print(f"No abandoned {PARTIAL_SUFFIX} files under {root}") return 0 mode = "DELETE" if args.apply else "DRY RUN" report(f"{mode}: {len(abandoned)} abandoned file(s) under {root}", abandoned) if not args.apply: print("Re-run with --apply to delete.") return 0 freed = 0 failed = 0 for path, size, _age in abandoned: try: path.unlink() freed += size except OSError as exc: print(f"failed {path}: {exc}", file=sys.stderr) failed += 1 empty = prune_empty_dirs(root) print( f"Removed {human_bytes(freed)} in {len(abandoned) - failed} file(s), " f"{failed} failed, {empty} empty dir(s)" ) return 1 if failed else 0 if __name__ == "__main__": raise SystemExit(main())