From 78a50b4f6124b884817dbcc357f630accfc40b25 Mon Sep 17 00:00:00 2001 From: NIne-WIngEd Date: Thu, 22 Oct 2026 20:50:14 -0500 Subject: [PATCH] prune: Use calendar-aware retention intervals Fixes borgbackup/borg#9929. --- docs/misc/prune-example-interval.txt | 24 ++-- docs/usage/general/date-time.rst.inc | 16 ++- src/borg/archiver/prune_cmd.py | 24 +++- src/borg/helpers/__init__.py | 2 +- src/borg/helpers/parseformat.py | 51 +------ src/borg/testsuite/archiver/prune_cmd_test.py | 127 +++++++++++++++--- .../testsuite/helpers/parseformat_test.py | 68 ++-------- 7 files changed, 167 insertions(+), 145 deletions(-) diff --git a/docs/misc/prune-example-interval.txt b/docs/misc/prune-example-interval.txt index e96e0a0baa..b8ba9a6938 100644 --- a/docs/misc/prune-example-interval.txt +++ b/docs/misc/prune-example-interval.txt @@ -59,7 +59,7 @@ Calendar view 8 9 10 11 12 13 14 15y16 15 16 17 18 19 20 21 17 18 19 20 21 22 23 22 23 24 25 26 27 28 - 24 25 26 27 28 29 30 29 30 31m + 24 25 26 27 28 29 30 29 30 31y 2026 January February March @@ -83,12 +83,13 @@ List view --keep-daily 1w --keep-weekly 4 --keep-monthly 5m --keep-yearly 2 -------------------------------------------------------------------------------- - 1. 2025-11-15 (oldest) + 1. 2025-12-31 + 2. 2025-11-15 (oldest) 1. 2026-06-02 1. 2026-05-23 1. 2026-04-30 2. 2026-06-01 2. 2026-05-17 2. 2026-03-30 3. 2026-05-31 3. 2026-05-10 3. 2026-02-28 4. 2026-05-30 4. 2026-05-03 4. 2026-01-31 -5. 2026-05-29 5. 2025-12-31 +5. 2026-05-29 6. 2026-05-28 2026-06-04 is additionally kept due to `--from`. @@ -132,14 +133,12 @@ Since 2026-05-24 and 2026-03-31 were skipped, Borg substitutes the next best candidate for each: 2026-05-23 fills the week 21 weekly slot, and 2026-03-30 fills the March monthly slot. -The implementation of `--keep-monthly 5m` is somewhat special: Borg defines a -month as a fixed 31-day period, independent of the actual calendar dates -involved. As a result, `5m` corresponds to 5 × 31 = 155 days. The archive from -2025-12-31 16:00 is exactly 155 days older than the reference time and is -therefore retained by `--keep-monthly`. +The `--keep-monthly 5m` window starts five calendar months before the +reference time, on 2026-01-04 at 16:00. The archive from 2025-12-31 is +outside this window, so it is not retained by `--keep-monthly`. -As a result, there are no true yearly candidates. In the absence of a better -candidate, `--keep-yearly 2` only matches the oldest archive, 2025-11-15. +Instead, `--keep-yearly 2` keeps 2025-12-31 as its yearly candidate. It +also keeps the oldest archive, 2025-11-15, to fill the remaining slot. Since interval rules define time windows rather than competing for a fixed number of slots, their interplay is simpler than count-based rules. An archive @@ -150,6 +149,5 @@ Intervals and counts can be mixed freely. Yearly retention in this example is done by retention count instead of intervals. A count rule paired with interval rules behaves just as it would if all preceding rules were also counts: Archives already kept by earlier rules are excluded from consideration. In this -example, every yearly slot is consumed by earlier rules; no true yearly -candidate remains, so the oldest archive at 2025-11-15 is kept to preserve -rolling backup semantics. +example, the 2026 yearly slot is consumed by earlier rules. December 31 +fills the 2025 slot, and November 15 is retained as the oldest archive. diff --git a/docs/usage/general/date-time.rst.inc b/docs/usage/general/date-time.rst.inc index db1fcca608..9e2420f845 100644 --- a/docs/usage/general/date-time.rst.inc +++ b/docs/usage/general/date-time.rst.inc @@ -23,7 +23,15 @@ The ``borg prune`` ``--keep-*`` retention options accept either a plain count When using interval-based retention, ``--from`` may be specified to set the reference timestamp for the interval (defaults to the current time). -Please note that Borg treats months (e.g. ``12m``) as fixed 31-day periods -rather than calendar months. As a result, ``12m`` corresponds to -12 × 31 = 372 days. Similarly, years (e.g. ``2y``) are treated as fixed -365-day periods and do not take leap years into account. +Months and years are calendar-aware. For example, ``12m`` before +2026-07-22 is 2025-07-22, and ``3y`` before 2026-07-22 is 2023-07-22. +The time of day is preserved. If the target month has fewer days, the date +is clamped to its last day: one month before March 31 is February 28 +(or February 29 in a leap year). + +Weeks, days, hours, minutes, and seconds keep their fixed-length meanings. +For example, ``1w`` is seven days. Plain counts and ``all`` are unchanged. + +When combining interval-based pruning rules, their lengths are compared at +the same reference timestamp. Thus, ``1m`` can be shorter than, equal to, +or longer than ``30d`` depending on the month. diff --git a/src/borg/archiver/prune_cmd.py b/src/borg/archiver/prune_cmd.py index 0e5efcfe93..757589b195 100644 --- a/src/borg/archiver/prune_cmd.py +++ b/src/borg/archiver/prune_cmd.py @@ -12,6 +12,7 @@ from ..helpers import GroupBySpec from ..helpers import json_print, basic_json_data from ..helpers.argparsing import ArgumentParser +from ..helpers.time import calculate_relative_offset from ..manifest import AI_GROUP_BY_KEYS, ArchiveInfo, format_group_key, group_archives from ..logger import create_logger @@ -198,6 +199,18 @@ class PruneMixIn: @with_repository() def do_prune(self, args, repository, manifest): """Prune archives according to specified rules.""" + from_timestamp = getattr(args, PRUNE_FROM.key) + base_timestamp = from_timestamp if from_timestamp is not None else datetime.now().astimezone() + + for rule in PRUNING_RULES: + value = getattr(args, rule.key) + if not isinstance(value, str): + continue + try: + cutoff = calculate_relative_offset(value, base_timestamp, earlier=True) + except (ValueError, OverflowError) as error: + raise CommandError(f"Invalid retention interval {value!r} for {rule.key}: {error}") from None + setattr(args, rule.key, base_timestamp - cutoff) self._validate_prune_args(args) match = archive_match_patterns(args) @@ -210,10 +223,6 @@ def do_prune(self, args, repository, manifest): group_by = tuple(args.group_by.split(",")) if args.group_by else () groups = group_archives(archives, group_by) - # All groups use the same reference timestamp, so interval based rules do not drift apart. - from_timestamp = getattr(args, PRUNE_FROM.key) - base_timestamp = from_timestamp if from_timestamp is not None else datetime.now().astimezone() - # Archives to keep along with the rule that ensured them being kept keep = {} group_of = {} @@ -465,7 +474,8 @@ def build_parser_prune(self, subparsers, common_parser, mid_common_parser): that time window. When ``--from`` is given together with an interval retention, the interval is measured backwards from that timestamp instead of from the current time. See ``Date and Time`` docs for exact - INTERVAL format. + INTERVAL format. Months and years are calendar-aware: a month before + March 31 is February 28 (or 29 in a leap year), at the same time of day. The ``--keep-secondly``, ``--keep-minutely``, ``--keep-hourly``, ``--keep-daily``, ``--keep-weekly``, ``--keep-monthly``, @@ -505,7 +515,9 @@ def build_parser_prune(self, subparsers, common_parser, mid_common_parser): weekly interval is already covered by the daily one and so the weekly interval is effectively useless. An error is emitted upon running ``borg prune`` if such a combination of flags is given. The order of - flags on the command line is not significant. + flags on the command line is not significant. Calendar intervals are + compared at the reference timestamp: ``1m`` can be shorter than, equal + to, or longer than ``30d`` depending on the month. A practical approach for recurring backups is to use rules diff --git a/src/borg/helpers/__init__.py b/src/borg/helpers/__init__.py index 9587a715e1..3ba11a3826 100644 --- a/src/borg/helpers/__init__.py +++ b/src/borg/helpers/__init__.py @@ -34,7 +34,7 @@ from .misc import ChunkIteratorFileWrapper, open_item, chunkit, iter_separated, ErrorIgnoringTextIOWrapper from .parseformat import octal_int, 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, interval, int_or_interval, nonnegative_seconds +from .parseformat import eval_escapes, decode_dict, int_or_interval, nonnegative_seconds from .parseformat import ( PathSpec, FilesystemPathSpec, diff --git a/src/borg/helpers/parseformat.py b/src/borg/helpers/parseformat.py index 789232a5d7..73146bf336 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 datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from functools import lru_cache, partial from hashlib import sha256 from string import Formatter @@ -141,58 +141,17 @@ def nonnegative_seconds(s): return seconds -def interval(s): - """Parse an interval string (e.g. ``7d``, ``2w``, ``30M``) into a timedelta. - - Supported units: y (years, 365d), m (months, 31d), w (weeks), d (days), - H (hours), M (minutes), S (seconds). The value must be nonnegative. - """ - if isinstance(s, timedelta): - return s - - seconds_in_a_minute = 60 - seconds_in_an_hour = 60 * seconds_in_a_minute - seconds_in_a_day = 24 * seconds_in_an_hour - seconds_in_a_week = 7 * seconds_in_a_day - seconds_in_a_month = 31 * seconds_in_a_day - seconds_in_a_year = 365 * seconds_in_a_day - multiplier = dict( - y=seconds_in_a_year, - m=seconds_in_a_month, - w=seconds_in_a_week, - d=seconds_in_a_day, - H=seconds_in_an_hour, - M=seconds_in_a_minute, - S=1, - ) - - if s.endswith(tuple(multiplier.keys())): - number = s[:-1] - suffix = s[-1] - else: - raise ArgumentTypeError(f'Unexpected time unit "{s[-1]}": choose from {", ".join(multiplier)}') - - try: - seconds = int(number) * multiplier[suffix] - except ValueError: - seconds = -1 - - if seconds < 0: - raise ArgumentTypeError(f'Invalid number "{number}": expected nonnegative integer') - - return timedelta(seconds=seconds) - - def int_or_interval(s): """Parse *s* as an :class:`int` or, failing that, as an interval string. Returns :class:`int` if *s* can be parsed as an integer (e.g. ``"7"``), - or :class:`datetime.timedelta` if *s* is a valid interval (e.g. ``"7d"``). + or the validated interval string (e.g. ``"7d"``). Months and years need a + reference timestamp before they can be converted to a duration. Raises :class:`ArgumentTypeError` if *s* is neither an integer nor a valid interval. """ - if isinstance(s, (int, timedelta)): + if isinstance(s, int): return s # Explicitly check 'all' as a shortcut to 'infinite' sentinel value `-1`. @@ -205,7 +164,7 @@ def int_or_interval(s): pass try: - return interval(s) + return relative_time_marker_validator(s) except ArgumentTypeError as e: raise ArgumentTypeError(f"Value is neither an integer nor an interval: {e}") diff --git a/src/borg/testsuite/archiver/prune_cmd_test.py b/src/borg/testsuite/archiver/prune_cmd_test.py index e101d4cd1a..aaa15bd8b3 100644 --- a/src/borg/testsuite/archiver/prune_cmd_test.py +++ b/src/borg/testsuite/archiver/prune_cmd_test.py @@ -22,6 +22,7 @@ from argparse import ArgumentTypeError from ...helpers import CommandError +from ...helpers.time import calculate_relative_offset from ...manifest import ArchiveInfo from . import cmd, RK_ENCRYPTION, generate_archiver_tests @@ -202,18 +203,12 @@ def test_prune_repository_example_interval(archivers, request, backup_files): for i, name in enumerate(weekly_kept, 1): assert re.search(rf"Keeping archive \(rule: weekly #{i}\):\s+{name}", output) - monthly_kept = [ - "backup_2026-04-30", - "backup_2026-03-30", - "backup_2026-02-28", - "backup_2026-01-31", - "backup_2025-12-31", - ] + monthly_kept = ["backup_2026-04-30", "backup_2026-03-30", "backup_2026-02-28", "backup_2026-01-31"] for i, name in enumerate(monthly_kept, 1): assert re.search(rf"Keeping archive \(rule: monthly #{i}\):\s+{name}", output) - # No true yearly candidates remain; only the oldest archive fills the slot. - assert re.search(r"Keeping archive \(rule: yearly\[oldest\] #1\):\s+backup_2025-11-15", output) + assert re.search(r"Keeping archive \(rule: yearly #1\):\s+backup_2025-12-31", output) + assert re.search(r"Keeping archive \(rule: yearly\[oldest\] #2\):\s+backup_2025-11-15", output) # 05-27 was kept by daily yesterday (window started 05-26) but falls out # today (window shifted to 05-28). W22 slot is consumed by daily-held 05-31, @@ -653,11 +648,13 @@ def test_prune_keep_monthly_int_or_interval(archivers, request, backup_files, ke archiver = request.getfixturevalue(archivers) cmd(archiver, "repo-create", RK_ENCRYPTION) dt = datetime(2023, 12, 31, 0, 0, 0, tzinfo=timezone.utc) - _create_archive_dt(archiver, backup_files, "test-1", dt - timedelta(days=31)) - _create_archive_dt(archiver, backup_files, "test-2", (dt - timedelta(days=62)).replace(microsecond=1)) - _create_archive_dt(archiver, backup_files, "test-3", dt - timedelta(days=62)) - _create_archive_dt(archiver, backup_files, "test-4", dt - timedelta(days=93)) - _create_archive_dt(archiver, backup_files, "test-5", dt - timedelta(days=93, microseconds=1)) + _create_archive_dt(archiver, backup_files, "test-1", datetime(2023, 11, 30, tzinfo=timezone.utc)) + _create_archive_dt(archiver, backup_files, "test-2", datetime(2023, 10, 31, microsecond=1, tzinfo=timezone.utc)) + _create_archive_dt(archiver, backup_files, "test-3", datetime(2023, 10, 31, tzinfo=timezone.utc)) + _create_archive_dt(archiver, backup_files, "test-4", datetime(2023, 9, 30, tzinfo=timezone.utc)) + _create_archive_dt( + archiver, backup_files, "test-5", datetime(2023, 9, 30, tzinfo=timezone.utc) - timedelta(microseconds=1) + ) output = prune_ungrouped(archiver, "--list", "--dry-run", "--from", dt.isoformat(), keep_arg) assert re.search(r"Keeping archive \(rule: monthly #1\):\s+test-1", output) assert re.search(r"Keeping archive \(rule: monthly #2\):\s+test-2", output) @@ -774,7 +771,7 @@ def test_prune_no_args(archivers, request): assert f"keep-{flag}" in output -@pytest.mark.parametrize("keep_arg,value", list(product([rule.key for rule in PRUNING_RULES], ["0", "0S"]))) +@pytest.mark.parametrize("keep_arg,value", list(product([rule.key for rule in PRUNING_RULES], ["0", "0S", "0m", "0y"]))) def test_prune_all_zero_args_one(archivers, request, keep_arg, value): archiver = request.getfixturevalue(archivers) cmd(archiver, "repo-create", RK_ENCRYPTION) @@ -861,17 +858,17 @@ def test_prune_from_prefiltered_archives_ignored_in_pruning(archivers, request, def test_prune_interval_rolling_schedule_oldest_retention(): daily_interval = timedelta(days=6) - monthly_interval = timedelta(days=31 * 3) # Matching --keep-monthly=3m after argument parsing start_date = datetime(2024, 1, 1, tzinfo=timezone.utc) previous_archives = [] archives = [] - for day_offset in range(95): + for day_offset in range(93): backup_ts = start_date + timedelta(days=day_offset) previous_archives = archives archives = [mock_archive(backup_ts, day_offset), *archives] + monthly_interval = backup_ts - calculate_relative_offset("3m", backup_ts, earlier=True) keep = {} keep |= prune(archives, PRUNE_DAILY, daily_interval, backup_ts, False, keep) keep |= prune(archives, PRUNE_MONTHLY, monthly_interval, backup_ts, True, keep) @@ -885,10 +882,6 @@ def test_prune_interval_rolling_schedule_oldest_retention(): archives = sorted(keep.keys(), key=lambda a: a.ts, reverse=True) - # It is now 2024-04-04. On this final run jan-01 falls outside the - # 93d monthly interval and is pruned. It was kept until now by the - # oldest-retention rule — on 2024-04-03 it was exactly at the - # inclusive boundary and retained as oldest. assert previous_archives[-1].ts.strftime("%m-%d") == "01-01" assert archives[-1].ts.strftime("%m-%d") == "01-31" @@ -1074,3 +1067,95 @@ def test_prune_group_by_invalid_key(archivers, request): cmd(archiver, "repo-create", RK_ENCRYPTION) output = cmd(archiver, "prune", "--group-by", "bogus", "--keep-daily=1", exit_code=2) assert "Invalid group-by key: bogus" in output + + +@pytest.mark.parametrize( + "reference,interval,cutoff", + [ + ("2026-07-22", "12m", "2025-07-22"), + ("2026-07-22", "3y", "2023-07-22"), + ("2026-03-31", "1m", "2026-02-28"), + ("2024-03-31", "1m", "2024-02-29"), + ("2024-02-29", "1y", "2023-02-28"), + ("2026-01-31", "2m", "2025-11-30"), + ("2026-03-31T12:34:56.123456+05:30", "1m", "2026-02-28T12:34:56.123456+05:30"), + ], +) +def test_prune_calendar_interval(archivers, request, backup_files, reference, interval, cutoff): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + reference = datetime.fromisoformat(reference) + cutoff = datetime.fromisoformat(cutoff) + if reference.tzinfo is None: + reference = reference.replace(tzinfo=timezone.utc) + cutoff = cutoff.replace(tzinfo=timezone.utc) + archive_times = { + "too-old": cutoff - timedelta(microseconds=1), + "boundary": cutoff, + "inside": cutoff + timedelta(microseconds=1), + "from": reference, + "newer": reference + timedelta(microseconds=1), + } + for name, archive_time in archive_times.items(): + _create_archive_dt(archiver, backup_files, name, archive_time) + + prune_args = ("--from", reference.isoformat(), "--keep", interval) + output = json.loads(prune_ungrouped(archiver, "--json", "--dry-run", *prune_args)) + assert {a["name"]: a["kept"] for a in output["archives"]} == {name: name != "too-old" for name in archive_times} + assert {a["name"] for a in json.loads(cmd(archiver, "repo-list", "--json"))["archives"]} == set(archive_times) + + prune_ungrouped(archiver, *prune_args) + remaining = json.loads(cmd(archiver, "repo-list", "--json"))["archives"] + assert {a["name"] for a in remaining} == set(archive_times) - {"too-old"} + + +@pytest.mark.parametrize( + "reference,shorter,longer,valid", + [ + ("2026-03-01", "1m", "30d", True), + ("2026-04-01", "1m", "30d", False), + ("2026-05-01", "1m", "30d", False), + ("2024-07-22", "365d", "1y", True), + ("2025-07-22", "365d", "1y", False), + ("2026-07-22", "12m", "1y", False), + ("2026-03-01", "1m", "all", True), + ("2026-03-01", "all", "1m", False), + ], +) +def test_prune_calendar_interval_order(archivers, request, reference, shorter, longer, valid): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + prune_args = ("--from", reference + "T00:00:00+00:00", "--keep-daily", shorter, "--keep-monthly", longer) + if valid: + cmd(archiver, "prune", "--dry-run", *prune_args) + else: + assert "effectively useless" in _cmd_prune_error(archiver, "--dry-run", *prune_args) + + +def test_prune_calendar_interval_without_from(archiver, backup_files, monkeypatch): + from ...archiver import prune_cmd + + reference = datetime(2026, 3, 1, tzinfo=timezone.utc) + + class FixedDatetime(datetime): + @classmethod + def now(cls, tz=None): + return reference.astimezone(tz) + + cmd(archiver, "repo-create", RK_ENCRYPTION) + _create_archive_ts(archiver, backup_files, "too-old", 2026, 1, 31) + _create_archive_ts(archiver, backup_files, "boundary", 2026, 2, 1) + monkeypatch.setattr(prune_cmd, "datetime", FixedDatetime) + output = json.loads(prune_ungrouped(archiver, "--json", "--dry-run", "--keep", "1m")) + assert {a["name"]: a["kept"] for a in output["archives"]} == {"too-old": False, "boundary": True} + + +@pytest.mark.parametrize("interval", ["99999y", "99999999999999999999d"]) +def test_prune_calendar_interval_out_of_range(archivers, request, backup_files, interval): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "untouched", backup_files) + output = _cmd_prune_error(archiver, "--keep", interval, "--from", "2026-07-22T00:00:00+00:00") + assert "Invalid retention interval" in output + archives = json.loads(cmd(archiver, "repo-list", "--json"))["archives"] + assert [a["name"] for a in archives] == ["untouched"] diff --git a/src/borg/testsuite/helpers/parseformat_test.py b/src/borg/testsuite/helpers/parseformat_test.py index c1abc6f728..fbf739f7aa 100644 --- a/src/borg/testsuite/helpers/parseformat_test.py +++ b/src/borg/testsuite/helpers/parseformat_test.py @@ -5,7 +5,7 @@ import re import unicodedata -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone import pytest @@ -21,7 +21,6 @@ text_validator, format_file_size, parse_file_size, - interval, int_or_interval, nonnegative_seconds, partial_format, @@ -444,53 +443,6 @@ def test_format_timedelta(): assert format_timedelta(t1 - t0) == "2 hours 1.100 seconds" -@pytest.mark.parametrize( - "timeframe, num_secs", - [ - ("0S", timedelta(seconds=0)), - ("5S", timedelta(seconds=5)), - ("2M", timedelta(minutes=2)), - ("1H", timedelta(hours=1)), - ("1d", timedelta(days=1)), - ("1w", timedelta(days=7)), - ("1m", timedelta(days=31)), - ("1y", timedelta(days=365)), - ], -) -def test_interval(timeframe, num_secs): - assert interval(timeframe) == num_secs - - -@pytest.mark.parametrize( - "invalid_interval, error_tuple", - [ - ("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): - with pytest.raises(ArgumentTypeError) as exc: - interval(invalid_interval) - assert exc.value.args == error_tuple - - -@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(invalid_input) - assert re.search(error_regex, exc.value.args[0]) - - @pytest.mark.parametrize("input, result", [("0", 0.0), ("30", 30.0), ("2.5", 2.5), ("1800", 1800.0)]) def test_nonnegative_seconds(input, result): assert nonnegative_seconds(input) == result @@ -510,14 +462,12 @@ def test_nonnegative_seconds_invalid(invalid_input): (" 999 ", 999), ("-1", -1), ("all", -1), - ("0S", timedelta(seconds=0)), - ("5S", timedelta(seconds=5)), - ("1m", timedelta(days=31)), + ("0S", "0S"), + ("5S", "5S"), + ("1m", "1m"), # already-converted values (jsonargparse idempotency) (0, 0), (5, 5), - (timedelta(seconds=5), timedelta(seconds=5)), - (timedelta(days=31), timedelta(days=31)), ], ) def test_int_or_interval(input, result): @@ -530,6 +480,10 @@ def test_int_or_interval(input, result): ("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:"), + ("", r"Value is neither an integer nor an interval:"), + ("1t", r"Value is neither an integer nor an interval:"), + ("1m2d", r"Value is neither an integer nor an interval:"), + ("1.5y", r"Value is neither an integer nor an interval:"), ], ) def test_int_or_interval_time_unit(invalid_input, error_regex): @@ -1021,3 +975,9 @@ def test_diff_formatter_time_precision(ctime1_ns, ctime2_ns): assert re.search(r"\.\d{9} ", m.group(1)) # 9-digit fraction is shown assert re.search(r"\.\d{9} ", m.group(2)) assert m.group(1) != m.group(2) # timestamps are distinguishable + + +@pytest.mark.parametrize("marker", ["0S", "0m", "0y", "5S", "2M", "1H", "7d", "2w", "1m", "12m", "3y"]) +def test_int_or_interval_preserves_calendar_marker(marker): + assert int_or_interval(marker) == marker + assert int_or_interval(int_or_interval(marker)) == marker