diff --git a/src/borg/archiver.py b/src/borg/archiver.py index 68cf0e2f..5a3f1632 100644 --- a/src/borg/archiver.py +++ b/src/borg/archiver.py @@ -1518,10 +1518,10 @@ def _info_repository(self, args, repository, manifest, key, cache): def do_prune(self, args, repository, manifest, key): """Prune repository archives according to specified rules""" if not any((args.secondly, args.minutely, args.hourly, args.daily, - args.weekly, args.monthly, args.yearly, args.within)): + args.weekly, args.monthly, args.quarterly, args.yearly, args.within)): raise CommandError('At least one of the "keep-within", "keep-last", ' '"keep-secondly", "keep-minutely", "keep-hourly", "keep-daily", ' - '"keep-weekly", "keep-monthly" or "keep-yearly" settings must be specified.') + '"keep-weekly", "keep-monthly", "keep-quarterly" or "keep-yearly" settings must be specified.') if args.prefix is not None: args.glob_archives = args.prefix + '*' checkpoint_re = r'\.checkpoint(\.\d+)?' @@ -4715,6 +4715,8 @@ def define_borg_mount(parser): help='number of weekly archives to keep') subparser.add_argument('-m', '--keep-monthly', dest='monthly', type=int, default=0, help='number of monthly archives to keep') + subparser.add_argument('-q', '--keep-quarterly', dest='quarterly', type=int, default=0, + help='number of quarterly archives to keep') subparser.add_argument('-y', '--keep-yearly', dest='yearly', type=int, default=0, help='number of yearly archives to keep') define_archive_filters_group(subparser, sort_by=False, first_last=False) diff --git a/src/borg/helpers/misc.py b/src/borg/helpers/misc.py index b62e066b..ae4f7743 100644 --- a/src/borg/helpers/misc.py +++ b/src/borg/helpers/misc.py @@ -31,21 +31,50 @@ def prune_within(archives, hours, kept_because): return result +def default_period_func(pattern): + def inner(a): + return to_localtime(a.ts).strftime(pattern) + + return inner + + +def quarterly_period_func(a): + (year, week, _) = to_localtime(a.ts).isocalendar() + if week <= 13: + # Weeks containing Jan 4th to Mar 28th (leap year) or 29th- 91 (13*7) + # days later. + return (year, 1) + elif week > 13 and week <= 26: + # Weeks containing Apr 4th (leap year) or 5th to Jun 27th or 28th- 91 + # days later + return (year, 2) + elif week > 26 and week <= 39: + # Weeks containing Jul 4th (leap year) or 5th to Sep 26th or 27th- + # at least 91 days later + return (year, 3) + else: + # Everything else, Oct 3rd (leap year) or 4th onward, will always + # include week of Dec 26th (leap year) or Dec 27th, may also include + # up to possibly Jan 3rd of next year. + return (year, 4) + + PRUNING_PATTERNS = OrderedDict([ - ("secondly", '%Y-%m-%d %H:%M:%S'), - ("minutely", '%Y-%m-%d %H:%M'), - ("hourly", '%Y-%m-%d %H'), - ("daily", '%Y-%m-%d'), - ("weekly", '%G-%V'), - ("monthly", '%Y-%m'), - ("yearly", '%Y'), + ("secondly", default_period_func('%Y-%m-%d %H:%M:%S')), + ("minutely", default_period_func('%Y-%m-%d %H:%M')), + ("hourly", default_period_func('%Y-%m-%d %H')), + ("daily", default_period_func('%Y-%m-%d')), + ("weekly", default_period_func('%G-%V')), + ("monthly", default_period_func('%Y-%m')), + ("quarterly", quarterly_period_func), + ("yearly", default_period_func('%Y')), ]) def prune_split(archives, rule, n, kept_because=None): last = None keep = [] - pattern = PRUNING_PATTERNS[rule] + period_func = PRUNING_PATTERNS[rule] if kept_because is None: kept_because = {} if n == 0: @@ -53,7 +82,7 @@ def prune_split(archives, rule, n, kept_because=None): a = None for a in sorted(archives, key=attrgetter('ts'), reverse=True): - period = to_localtime(a.ts).strftime(pattern) + period = period_func(a) if period != last: last = period if a.id not in kept_because: diff --git a/src/borg/testsuite/conftest.py b/src/borg/testsuite/conftest.py index bc40f33b..4c99b822 100644 --- a/src/borg/testsuite/conftest.py +++ b/src/borg/testsuite/conftest.py @@ -1,6 +1,7 @@ import os import pytest +from packaging.version import parse as parse_version, Version # IMPORTANT keep this above all other borg imports to avoid inconsistent values # for `from borg.constants import PBKDF2_ITERATIONS` (or star import) usages before @@ -37,8 +38,7 @@ def clean_env(tmpdir_factory, monkeypatch): for key in keys: monkeypatch.delenv(key, raising=False) - -def pytest_report_header(config, start_path): +def _pytest_report_header(config, start_path): tests = { "BSD flags": has_lchflags, "fuse2": has_llfuse, @@ -60,6 +60,15 @@ def pytest_report_header(config, start_path): output += "Tests disabled: " + ", ".join(disabled) return output +# Avoid "Argument(s) {'start_path'} are declared in the hookimpl but can not +# be found in the hookspec" for pytest 6.2.x. +if parse_version(pytest.__version__) < Version("7.0.0"): + def pytest_report_header(config): + _pytest_report_header(config, None) +else: + def pytest_report_header(config, start_path): + _pytest_report_header(config, start_path) + class DefaultPatches: def __init__(self, request):