diff --git a/src/borg/helpers/__init__.py b/src/borg/helpers/__init__.py index ca19f5c89..751d79497 100644 --- a/src/borg/helpers/__init__.py +++ b/src/borg/helpers/__init__.py @@ -27,7 +27,7 @@ from .misc import sysinfo, log_multi, consume from .misc import ChunkIteratorFileWrapper, open_item, chunkit, iter_separated, ErrorIgnoringTextIOWrapper from .parseformat import bin_to_hex, hex_to_bin, safe_encode, safe_decode from .parseformat import text_to_json, binary_to_json, remove_surrogates, join_cmd -from .parseformat import eval_escapes, decode_dict, positive_int_validator, interval +from .parseformat import eval_escapes, decode_dict, positive_int_validator, interval, int_or_interval from .parseformat import PathSpec, SortBySpec, ChunkerParams, FilesCacheMode, partial_format, DatetimeWrapper from .parseformat import format_file_size, parse_file_size, FileSize from .parseformat import sizeof_fmt, sizeof_fmt_iec, sizeof_fmt_decimal, Location, text_validator diff --git a/src/borg/helpers/parseformat.py b/src/borg/helpers/parseformat.py index 193023df5..e7cc8d653 100644 --- a/src/borg/helpers/parseformat.py +++ b/src/borg/helpers/parseformat.py @@ -13,7 +13,7 @@ import uuid from pathlib import Path from typing import ClassVar, Any, TYPE_CHECKING, Literal from collections import OrderedDict -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta from functools import partial from string import Formatter @@ -155,12 +155,24 @@ def interval(s): except ValueError: seconds = -1 - if seconds <= 0: - raise argparse.ArgumentTypeError(f'Invalid number "{number}": expected positive integer') + if seconds < 0: + raise argparse.ArgumentTypeError(f'Invalid number "{number}": expected nonnegative integer') return seconds +def int_or_interval(s): + try: + return int(s) + except ValueError: + pass + + try: + return timedelta(seconds=interval(s)) + except argparse.ArgumentTypeError as e: + raise argparse.ArgumentTypeError(f"Value is neither an integer nor an interval: {e}") + + def ChunkerParams(s): params = s.strip().split(",") count = len(params) diff --git a/src/borg/testsuite/helpers/parseformat_test.py b/src/borg/testsuite/helpers/parseformat_test.py index 85600c819..0948151c7 100644 --- a/src/borg/testsuite/helpers/parseformat_test.py +++ b/src/borg/testsuite/helpers/parseformat_test.py @@ -1,7 +1,8 @@ import base64 import os +import re from argparse import ArgumentTypeError -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone import pytest @@ -16,6 +17,7 @@ from ...helpers.parseformat import ( format_file_size, parse_file_size, interval, + int_or_interval, partial_format, clean_lines, format_line, @@ -376,6 +378,7 @@ def test_format_timedelta(): @pytest.mark.parametrize( "timeframe, num_secs", [ + ("0S", 0), ("5S", 5), ("2M", 2 * 60), ("1H", 60 * 60), @@ -392,9 +395,9 @@ def test_interval(timeframe, num_secs): @pytest.mark.parametrize( "invalid_interval, error_tuple", [ - ("H", ('Invalid number "": expected positive integer',)), - ("-1d", ('Invalid number "-1": expected positive integer',)), - ("food", ('Invalid number "foo": expected positive integer',)), + ("H", ('Invalid number "": expected nonnegative integer',)), + ("-1d", ('Invalid number "-1": expected nonnegative integer',)), + ("food", ('Invalid number "foo": expected nonnegative integer',)), ], ) def test_interval_time_unit(invalid_interval, error_tuple): @@ -403,10 +406,49 @@ def test_interval_time_unit(invalid_interval, error_tuple): assert exc.value.args == error_tuple -def test_interval_number(): +@pytest.mark.parametrize( + "invalid_input, error_regex", + [ + ("x", r'^Unexpected time unit "x": choose from'), + ("-1t", r'^Unexpected time unit "t": choose from'), + ("fool", r'^Unexpected time unit "l": choose from'), + ("abc", r'^Unexpected time unit "c": choose from'), + (" abc ", r'^Unexpected time unit " ": choose from'), + ], +) +def test_interval_invalid_time_format(invalid_input, error_regex): with pytest.raises(ArgumentTypeError) as exc: - interval("5") - assert exc.value.args == ('Unexpected time unit "5": choose from y, m, w, d, H, M, S',) + interval(invalid_input) + assert re.search(error_regex, exc.value.args[0]) + + +@pytest.mark.parametrize( + "input, result", + [ + ("0", 0), + ("5", 5), + (" 999 ", 999), + ("0S", timedelta(seconds=0)), + ("5S", timedelta(seconds=5)), + ("1m", timedelta(days=31)), + ], +) +def test_int_or_interval(input, result): + assert int_or_interval(input) == result + + +@pytest.mark.parametrize( + "invalid_input, error_regex", + [ + ("H", r"Value is neither an integer nor an interval:"), + ("-1d", r"Value is neither an integer nor an interval:"), + ("food", r"Value is neither an integer nor an interval:"), + ], +) +def test_int_or_interval_time_unit(invalid_input, error_regex): + with pytest.raises(ArgumentTypeError) as exc: + int_or_interval(invalid_input) + assert re.search(error_regex, exc.value.args[0]) def test_parse_timestamp():