Created
January 6, 2026 16:04
-
-
Save jamesWalker55/6179caebab33af2ff9c72b881a63e7f0 to your computer and use it in GitHub Desktop.
Python script to delete Reaper project backups according to a retention policy. Please edit `expected_working_dir`, `KEEP_LAST_X` and `POLICY` to your use case.
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
| # /// script | |
| # dependencies = [ | |
| # "retention-rules", | |
| # ] | |
| # /// | |
| import logging | |
| import os | |
| import re | |
| import sys | |
| from typing import NamedTuple, Self | |
| from datetime import datetime | |
| from retention_rules import PolicyBuilder | |
| log = logging.getLogger(__name__) | |
| del logging | |
| # failsafe check, to make sure you are executing in the right directory | |
| expected_working_dir = R"D:\Audio Projects (Reaper)" | |
| if os.getcwd() != expected_working_dir: | |
| log.warning("unexpected working directory, aborting") | |
| print( | |
| f"Current working directory is {os.getcwd()} instead of {expected_working_dir}, are you sure you ran this script in the right folder?" | |
| ) | |
| sys.exit(1) | |
| KEEP_LAST_X = 15 | |
| POLICY = PolicyBuilder().build( | |
| { | |
| "rules": [ | |
| {"applies_for": "1D", "retain_every": "H"}, | |
| {"applies_for": "3M", "retain_every": "D/8"}, | |
| {"applies_for": "6M", "retain_every": "D"}, | |
| {"applies_for": "Y", "retain_every": "W/2"}, | |
| ], | |
| "reuse": True, | |
| "retain": "newest", | |
| } | |
| ) | |
| class RppBak(NamedTuple): | |
| filename: str | |
| title: str | |
| time: datetime | |
| def __str__(self) -> str: | |
| return f"{self.time_repr()} {self.title!r}" | |
| def __repr__(self) -> str: | |
| return f"RppBak('{self.time_repr()}', {self.title!r})" | |
| def time_repr(self): | |
| return self.time.strftime(r"%Y-%m-%d %H:%M:%S") | |
| @classmethod | |
| def try_from_filename(cls, filename: str) -> Self | None: | |
| stem, ext = os.path.splitext(filename) | |
| if ext.lower() != ".rpp-bak": | |
| return None | |
| match = re.match(r"\A(.+)-(\d\d\d\d)-(\d\d)-(\d\d)_(\d\d)(\d\d)(\d\d)?\Z", stem) | |
| if match is None: | |
| return None | |
| # `s` may be None | |
| title, year, month, day, h, m, s = match.groups() | |
| time = datetime( | |
| int(year), | |
| int(month), | |
| int(day), | |
| int(h), | |
| int(m), | |
| 0 if s is None else int(s), | |
| ) | |
| return cls(filename, title, time) | |
| def scan_rpp_bak(root: str | os.PathLike): | |
| result: list[tuple[str, dict[str, list[RppBak]]]] = [] | |
| for dirpath, dirnames, filenames in os.walk(root): | |
| # filter only .rpp-bak files | |
| rppbaks = [RppBak.try_from_filename(x) for x in filenames] | |
| rppbaks = [x for x in rppbaks if x is not None] | |
| if len(rppbaks) == 0: | |
| continue | |
| # group the files by their title | |
| # (a single folder may have multiple project's backups) | |
| title_group: dict[str, list[RppBak]] = {} | |
| for rb in rppbaks: | |
| if rb.title not in title_group: | |
| title_group[rb.title] = [] | |
| title_group[rb.title].append(rb) | |
| result.append((dirpath, title_group)) | |
| return result | |
| class RppBakKeep(NamedTuple): | |
| item: RppBak | |
| keep: bool | |
| def decide_rppbak_keep(rppbak: list[RppBak]): | |
| if len(rppbak) == 0: | |
| raise RuntimeError("TODO") | |
| # retention checking is based on latest backup time | |
| latest_time = max(rppbak, key=lambda x: x.time).time | |
| # check files to delete | |
| retention = POLICY.check_retention( | |
| rppbak, | |
| key=lambda x: x.time, | |
| now=latest_time, | |
| ) | |
| # the most recent 15 files are always kept | |
| retention.sort(key=lambda x: x.item.time) | |
| for x in retention[-KEEP_LAST_X:]: | |
| x.retain = True | |
| return [RppBakKeep(x.item, x.retain) for x in retention] | |
| # Function to set up logging | |
| def setup_logging(): | |
| import logging | |
| log.setLevel(logging.DEBUG) | |
| # Custom log output format | |
| msg_fmt = "[%(asctime)s %(levelname)s %(name)s.%(funcName)s] %(message)s" | |
| fmt = logging.Formatter(fmt=msg_fmt) | |
| # Log to a file | |
| log_filename = f"trim-backups.log" | |
| h = logging.FileHandler(log_filename, "w", encoding="utf8") | |
| h.setLevel(logging.DEBUG) | |
| h.setFormatter(fmt) | |
| log.addHandler(h) | |
| # Log to stderr | |
| h = logging.StreamHandler() | |
| h.setLevel(logging.WARNING) | |
| h.setFormatter(fmt) | |
| log.addHandler(h) | |
| def main(): | |
| setup_logging() | |
| log.info("scanning .rpp-bak in: %s", expected_working_dir) | |
| result = scan_rpp_bak(expected_working_dir) | |
| log.info("found .rpp-bak in %d folders", len(result)) | |
| for dirname, title_group in result: | |
| for title, rppbaks in title_group.items(): | |
| keeps = decide_rppbak_keep(rppbaks) | |
| keep_count = len([x for x in keeps if x.keep is True]) | |
| delete_count = len([x for x in keeps if x.keep is False]) | |
| if delete_count == 0: | |
| continue | |
| log.info("dir=%s title=%s", dirname, title) | |
| log.info("keep %d files:", keep_count) | |
| log.info("delete %d files:", delete_count) | |
| for k in keeps: | |
| if k.keep is False: | |
| path = os.path.join(dirname, k.item.filename) | |
| log.info("%s", path) | |
| os.remove(path) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment