Repository: borgbackup/borg

Issue:
Confusing exception when passing invalid --keep-within interval
I made some changes to my borg wrapper scripts which impacted how borg prune was called, and in the process stopped adding a timespan to "--keep-within" without really meaning to.  In my tests I got a very confusing exception that took me a few minutes to decode.
```
borg prune --info --stats --list --prefix projects=loghyr --keep-within 5 --keep-daily 30 --keep-weekly 26 --keep-monthly 60 borg@cantard.lan:/srv/borg/ephemeral
Local Exception.
Traceback (most recent call last):
  File "/home/backups/borg/venv/lib/python3.5/site-packages/borg/helpers.py", line 190, in prune_within
    hours = int(within[:-1]) * multiplier[within[-1]]
ValueError: invalid literal for int() with base 10: ''

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/backups/borg/venv/lib/python3.5/site-packages/borg/archiver.py", line 2089, in main
    exit_code = archiver.run(args)
  File "/home/backups/borg/venv/lib/python3.5/site-packages/borg/archiver.py", line 2025, in run
    return func(args)
  File "/home/backups/borg/venv/lib/python3.5/site-packages/borg/archiver.py", line 91, in wrapper
    return method(self, args, repository=repository, **kwargs)
  File "/home/backups/borg/venv/lib/python3.5/site-packages/borg/archiver.py", line 698, in do_prune
    keep += prune_within(archives, args.within)
  File "/home/backups/borg/venv/lib/python3.5/site-packages/borg/helpers.py", line 193, in prune_within
    raise argparse.ArgumentTypeError('Unable to parse --within option: "%s"' % within)
argparse.ArgumentTypeError: Unable to parse --within option: "5"
```
There's a hint at the problem in that last line of the stack trace (note, not the last line of the output), but it's not very explicit, and I missed it entirely for a minute or two.

I'd suggest "Invalid interval "5", expected one of ["d", ... ]" with no stacktrace, either by adding a custom argparse validator for WITHIN, or by more granular parsing of the argument in prune_within.

I'd be willing to take a shot at fixing this, especially if the custom argparse validator is the preferred solution (which I think it should be, to catch errors as early as possible).

Here's the end of the exception output for completeness:
```
Platform: Linux loghyr 3.2.0-120-generic #163-Ubuntu SMP Tue Dec 20 15:13:12 UTC 2016 i686 i686
Linux: Ubuntu 12.04 precise
Borg: 1.0.10  Python: CPython 3.5.2
PID: 2809  CWD: /home/backups
sys.argv: ['/home/backups/borg/venv/bin/borg', 'prune', '--info', '--stats', '--list', '--prefix',
'projects=loghyr', '--keep-within', '5', '--keep-daily', '30', '--keep-weekly', '26',
'--keep-monthly', '60', 'borg@cantard.lan:/srv/borg/ephemeral']
SSH_ORIGINAL_COMMAND: None
```



Discussion:
Good idea. :)
@edgewood you're on it?
Sorry for the delay, I had a conference last weekend that I had to prepare for last week. I have some spare time this week though, so yes, I'm on it.

Resolving patch:
diff --git a/borg/archiver.py b/borg/archiver.py
--- a/borg/archiver.py
+++ b/borg/archiver.py
@@ -21,7 +21,7 @@
 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 @@ def build_parser(self, args=None, prog=None):
         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
--- a/borg/helpers.py
+++ b/borg/helpers.py
@@ -279,15 +279,32 @@ def list_archive_infos(self, sort_by=None, reverse=False):
         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
--- 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 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 @@ def dotest(test_archives, n, skip, indices):
         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


Test patch: