Files
devplacepy/xx.py
T
retoor cae139ead9
DevPlace CI / test (push) Failing after 28m20s
Add personal notes, DeepSearch history, backup offload, and gateway auth throttling
Also streamline the top navigation: drop the Tools dropdown, make Quizzes
and Battles icon-only entries, and remove the Workspace, Containers and
Editor entry points from the project detail page.
2026-09-12 20:32:31 +02:00

139 lines
4.0 KiB
Python
Executable File

#!/usr/bin/env python3
# retoor <retoor@molodetz.nl>
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())