diff --git a/borg/archiver.py b/borg/archiver.py index a2f4c9a70..d10d95331 100644 --- a/borg/archiver.py +++ b/borg/archiver.py @@ -21,7 +21,7 @@ import collections from . import __version__ from .helpers import Error, location_validator, archivename_validator, format_line, format_time, format_file_size, \ parse_pattern, PathPrefixPattern, to_localtime, timestamp, safe_timestamp, bin_to_hex, \ - get_cache_dir, prune_within, prune_split, check_python, \ + get_cache_dir, interval, prune_within, prune_split, check_python, \ Manifest, NoManifestError, remove_surrogates, update_excludes, format_archive, check_extension_modules, Statistics, \ dir_is_tagged, bigint_to_int, ChunkerParams, CompressionSpec, PrefixSpec, is_slow_msgpack, yes, sysinfo, \ EXIT_SUCCESS, EXIT_WARNING, EXIT_ERROR, log_multi, PatternMatcher, ErrorIgnoringTextIOWrapper, set_ec, \ @@ -1717,7 +1717,7 @@ class Archiver: subparser.add_argument('--list', dest='output_list', action='store_true', default=False, help='output verbose list of archives it keeps/prunes. Requires -v/--verbose.') - subparser.add_argument('--keep-within', dest='within', type=str, metavar='WITHIN', + subparser.add_argument('--keep-within', dest='within', type=interval, metavar='INTERVAL', help='keep all archives within this time interval') subparser.add_argument('-H', '--keep-hourly', dest='hourly', type=int, default=0, help='number of hourly archives to keep') diff --git a/borg/helpers.py b/borg/helpers.py index 82a86c3c9..26292eb4a 100644 --- a/borg/helpers.py +++ b/borg/helpers.py @@ -279,15 +279,32 @@ class Manifest: return archives -def prune_within(archives, within): +def interval(s): + """Convert a string representing a valid interval to a number of hours.""" multiplier = {'H': 1, 'd': 24, 'w': 24 * 7, 'm': 24 * 31, 'y': 24 * 365} + + if s.endswith(tuple(multiplier.keys())): + number = s[:-1] + suffix = s[-1] + else: + # range suffixes in ascending multiplier order + ranges = [k for k, v in sorted(multiplier.items(), key=lambda t: t[1])] + raise argparse.ArgumentTypeError( + 'Unexpected interval time unit "%s": expected one of %r' % (s[-1], ranges)) + 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 --keep-within option: "%s"' % within) + hours = int(number) * multiplier[suffix] + except ValueError: + hours = -1 + if hours <= 0: - raise argparse.ArgumentTypeError('Number specified using --keep-within option must be positive') + raise argparse.ArgumentTypeError( + 'Unexpected interval number "%s": expected an integer greater than 0' % number) + + return hours + + +def prune_within(archives, hours): target = datetime.now(timezone.utc) - timedelta(seconds=hours * 3600) return [a for a in archives if a.ts > target] diff --git a/borg/testsuite/helpers.py b/borg/testsuite/helpers.py index b63ff6f3e..aa6e62b2c 100644 --- a/borg/testsuite/helpers.py +++ b/borg/testsuite/helpers.py @@ -1,4 +1,5 @@ import hashlib +from argparse import ArgumentTypeError from time import mktime, strptime from datetime import datetime, timezone, timedelta from io import StringIO @@ -11,7 +12,7 @@ import msgpack.fallback import time from ..helpers import Location, format_file_size, format_timedelta, format_line, PlaceholderError, make_path_safe, \ - prune_within, prune_split, get_cache_dir, get_keys_dir, get_security_dir, Statistics, is_slow_msgpack, \ + interval, prune_within, prune_split, get_cache_dir, get_keys_dir, get_security_dir, Statistics, is_slow_msgpack, \ yes, TRUISH, FALSISH, DEFAULTISH, \ StableDict, int_to_bigint, bigint_to_int, parse_timestamp, CompressionSpec, ChunkerParams, \ ProgressIndicatorPercent, ProgressIndicatorEndless, load_excludes, parse_pattern, \ @@ -664,16 +665,48 @@ class PruneSplitTestCase(BaseTestCase): dotest(test_archives, 0, [], []) -class PruneWithinTestCase(BaseTestCase): +class IntervalTestCase(BaseTestCase): + def test_interval(self): + self.assert_equal(interval('1H'), 1) + self.assert_equal(interval('1d'), 24) + self.assert_equal(interval('1w'), 168) + self.assert_equal(interval('1m'), 744) + self.assert_equal(interval('1y'), 8760) - def test(self): + def test_interval_time_unit(self): + with pytest.raises(ArgumentTypeError) as exc: + interval('H') + self.assert_equal( + exc.value.args, + ('Unexpected interval number "": expected an integer greater than 0',)) + with pytest.raises(ArgumentTypeError) as exc: + interval('-1d') + self.assert_equal( + exc.value.args, + ('Unexpected interval number "-1": expected an integer greater than 0',)) + with pytest.raises(ArgumentTypeError) as exc: + interval('food') + self.assert_equal( + exc.value.args, + ('Unexpected interval number "foo": expected an integer greater than 0',)) + + def test_interval_number(self): + with pytest.raises(ArgumentTypeError) as exc: + interval('5') + self.assert_equal( + exc.value.args, + ("Unexpected interval time unit \"5\": expected one of ['H', 'd', 'w', 'm', 'y']",)) + + +class PruneWithinTestCase(BaseTestCase): + def test_prune_within(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)), + self.assert_equal(set(prune_within(ta, interval(within))), subset(test_archives, indices)) # 1 minute, 1.5 hours, 2.5 hours, 3.5 hours, 25 hours, 49 hours