diff --git a/attic/archiver.py b/attic/archiver.py index 59daa9f59..0f8eb1960 100644 --- a/attic/archiver.py +++ b/attic/archiver.py @@ -13,8 +13,8 @@ from attic.key import key_creator from attic.helpers import Error, location_validator, format_time, \ format_file_mode, ExcludePattern, exclude_path, adjust_patterns, to_localtime, \ - get_cache_dir, get_keys_dir, format_timedelta, prune_split, Manifest, remove_surrogates, \ - is_a_terminal, update_excludes + get_cache_dir, get_keys_dir, format_timedelta, prune_within, prune_split, \ + Manifest, remove_surrogates, is_a_terminal, update_excludes from attic.remote import RepositoryServer, RemoteRepository @@ -312,15 +312,17 @@ def do_prune(self, args): cache = Cache(repository, key, manifest) archives = list(sorted(Archive.list_archives(repository, key, manifest, cache), key=attrgetter('ts'), reverse=True)) - if args.hourly + args.daily + args.weekly + args.monthly + args.yearly == 0: - self.print_error('At least one of the "hourly", "daily", "weekly", "monthly" or "yearly" ' + if args.hourly + args.daily + args.weekly + args.monthly + args.yearly == 0 and args.within is None: + self.print_error('At least one of the "within", "hourly", "daily", "weekly", "monthly" or "yearly" ' 'settings must be specified') return 1 if args.prefix: archives = [archive for archive in archives if archive.name.startswith(args.prefix)] keep = [] + if args.within: + keep += prune_within(archives, args.within) if args.hourly: - keep += prune_split(archives, '%Y-%m-%d %H', args.hourly) + keep += prune_split(archives, '%Y-%m-%d %H', args.hourly, keep) if args.daily: keep += prune_split(archives, '%Y-%m-%d', args.daily, keep) if args.weekly: @@ -496,7 +498,12 @@ def run(self, args=None): are applied from hourly to yearly, and backups selected by previous rules do not count towards those of later rules. Dates and times are interpreted in the local timezone, and weeks go from Monday to Sunday. Specifying a - negative number of archives to keep means that there is no limit. If a + negative number of archives to keep means that there is no limit. + The "--within" option takes an argument of the form "", + where char is "H", "d", "w", "m", "y". For example, "--within 2d" means + to keep all archives that were created within the past 48 hours. + "1m" is taken to mean "31d". The archives kept with this option do not + count towards the totals specified by any other options. If a prefix is set with -p, then only archives that start with the prefix are considered for deletion and only those archives count towards the totals specified by the rules.''' @@ -505,6 +512,8 @@ def run(self, args=None): description=self.do_prune.__doc__, epilog=prune_epilog) subparser.set_defaults(func=self.do_prune) + subparser.add_argument('--within', dest='within', type=str, metavar='WITHIN', + help='keep all archives within this time interval') subparser.add_argument('-H', '--hourly', dest='hourly', type=int, default=0, help='number of hourly archives to keep') subparser.add_argument('-d', '--daily', dest='daily', type=int, default=0, diff --git a/attic/helpers.py b/attic/helpers.py index 401ff7f50..6e8159e66 100644 --- a/attic/helpers.py +++ b/attic/helpers.py @@ -8,7 +8,7 @@ import stat import sys import time -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta from fnmatch import translate from operator import attrgetter import fcntl @@ -91,6 +91,19 @@ def write(self): self.repository.put(self.MANIFEST_ID, self.key.encrypt(data)) +def prune_within(archives, within): + multiplier = {'H': 1, 'd': 24, 'w': 24*7, 'm': 24*31, 'y': 24*365} + try: + hours = int(within[:-1]) * multiplier[within[-1]] + except (KeyError, ValueError): + # I don't like how this displays the original exception too: + raise argparse.ArgumentTypeError('Unable to parse --within option: "%s"' % within) + if hours <= 0: + raise argparse.ArgumentTypeError('Number specified using --within option must be positive') + target = datetime.now(timezone.utc) - timedelta(seconds=hours*60*60) + return [a for a in archives if a.ts > target] + + def prune_split(archives, pattern, n, skip=[]): last = None keep = [] diff --git a/attic/testsuite/helpers.py b/attic/testsuite/helpers.py index 792f401ab..0afc4c608 100644 --- a/attic/testsuite/helpers.py +++ b/attic/testsuite/helpers.py @@ -1,9 +1,9 @@ from time import mktime, strptime -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta import os import tempfile import unittest -from attic.helpers import adjust_patterns, exclude_path, Location, format_timedelta, IncludePattern, ExcludePattern, make_path_safe, UpgradableLock, prune_split, to_localtime +from attic.helpers import adjust_patterns, exclude_path, Location, format_timedelta, IncludePattern, ExcludePattern, make_path_safe, UpgradableLock, prune_within, prune_split, to_localtime from attic.testsuite import AtticTestCase @@ -145,3 +145,34 @@ def dotest(test_archives, n, skip, indices): dotest(test_archives, 3, [test_archives[5]], [6, 2, 0]) dotest(test_archives, 3, [test_archives[4]], [6, 5, 2]) dotest(test_archives, 0, [], []) + + +class PruneWithinTestCase(AtticTestCase): + + def test(self): + + def subset(lst, indices): + return {lst[i] for i in indices} + + def dotest(test_archives, within, indices): + for ta in test_archives, reversed(test_archives): + self.assert_equal(set(prune_within(ta, within)), + subset(test_archives, indices)) + + # 1 minute, 1.5 hours, 2.5 hours, 3.5 hours, 25 hours, 49 hours + test_offsets = [60, 90*60, 150*60, 210*60, 25*60*60, 49*60*60] + now = datetime.now(timezone.utc) + test_dates = [now - timedelta(seconds=s) for s in test_offsets] + test_archives = [MockArchive(date) for date in test_dates] + + dotest(test_archives, '1H', [0]) + dotest(test_archives, '2H', [0, 1]) + dotest(test_archives, '3H', [0, 1, 2]) + dotest(test_archives, '24H', [0, 1, 2, 3]) + dotest(test_archives, '26H', [0, 1, 2, 3, 4]) + dotest(test_archives, '2d', [0, 1, 2, 3, 4]) + dotest(test_archives, '50H', [0, 1, 2, 3, 4, 5]) + dotest(test_archives, '3d', [0, 1, 2, 3, 4, 5]) + dotest(test_archives, '1w', [0, 1, 2, 3, 4, 5]) + dotest(test_archives, '1m', [0, 1, 2, 3, 4, 5]) + dotest(test_archives, '1y', [0, 1, 2, 3, 4, 5])