From 91bf03e39f9c7ebd2233289a735a5116d70a2338 Mon Sep 17 00:00:00 2001 From: Charlie Herz Date: Mon, 29 Jun 2026 22:09:38 -0400 Subject: [PATCH 1/4] Add date: archive match selector Scoped this down to static date matching after the review feedback. date: filters archives by creation date/time in ISO-8601 format. A calendar pattern (YYYY through second precision) matches the interval its precision implies, start-inclusive and end-exclusive. Fractional seconds and fractional Unix timestamps (@epoch.ffffff) resolve to an exact microsecond, while a plain @epoch covers one second. Z, +/-HH:MM or [Region/City] TZ suffixes are supported. Without one the pattern is local time, and Unix timestamps are always UTC so a suffix is rejected. Parsing is a unified re.VERBOSE regex with named groups, as suggested. Out-of-range arithmetic now raises DatePatternError instead of leaking uncaught ValueError/OverflowError (was latent bug). Placed docs in the match-archives helptext. --- src/borg/archiver/help_cmd.py | 30 +- src/borg/helpers/time.py | 150 +++++++- src/borg/manifest.py | 15 +- .../archiver/match_archives_date_test.py | 349 ++++++++++++++++++ 4 files changed, 541 insertions(+), 3 deletions(-) create mode 100644 src/borg/testsuite/archiver/match_archives_date_test.py diff --git a/src/borg/archiver/help_cmd.py b/src/borg/archiver/help_cmd.py index ba10f407a6..1f0fe06f1f 100644 --- a/src/borg/archiver/help_cmd.py +++ b/src/borg/archiver/help_cmd.py @@ -314,6 +314,7 @@ class HelpMixIn: - user: exact match on the username who created the archive - host: exact match on the hostname where the archive was created - tags: match on the archive tags + - date: match on the archive creation timestamp In case of a name pattern match, it uses pattern styles similar to the ones described by ``borg help patterns``: @@ -328,6 +329,28 @@ class HelpMixIn: Full regular expression support. This is very powerful, but can also get rather complicated. + Date patterns, selector ``date:`` + Match archives by creation timestamp. Supported forms are: + + - ``YYYY``: 1 year + - ``YYYY-MM``: 1 month + - ``YYYY-MM-DD``: 1 day + - ``YYYY-MM-DDTHH``: 1 hour + - ``YYYY-MM-DDTHH:MM``: 1 minute + - ``YYYY-MM-DDTHH:MM:SS``: 1 second + - ``@1735732800``: 1 second + - ``YYYY-MM-DDTHH:MM:SS.ffffff``: exact timestamp + - ``@1735732800.123456``: exact timestamp + + Date and time patterns match the interval implied by their precision, including + the start and excluding the end. Fractional-second patterns accept 1 to 6 + digits and match exactly. + + Date and time patterns may include a timezone suffix: ``Z``, ``+HH:MM``, + ``-HH:MM``, or ``[Region/City]``. Patterns without a timezone are interpreted + in the local timezone. Unix timestamp patterns are UTC and do not accept a + timezone suffix. + Examples:: # name match, id: style @@ -349,7 +372,12 @@ class HelpMixIn: borg delete -a 'host:kenny-pc' # tags match - borg delete -a 'tags:TAG1' -a 'tags:TAG2'\n\n""" + borg delete -a 'tags:TAG1' -a 'tags:TAG2' + + # archive creation date match + borg delete -a 'date:2025-01' + borg delete -a 'date:2025-01-01T14:30Z' + borg delete -a 'date:2025-01-01T09:30[America/New_York]'\n\n""" ) helptext["placeholders"] = textwrap.dedent( """ diff --git a/src/borg/helpers/time.py b/src/borg/helpers/time.py index e54394cdc0..6b3081809e 100644 --- a/src/borg/helpers/time.py +++ b/src/borg/helpers/time.py @@ -1,6 +1,7 @@ import os import re -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime, timedelta, timezone +from zoneinfo import ZoneInfo def parse_timestamp(timestamp, tzinfo=UTC): @@ -198,3 +199,150 @@ def isoformat(self): def archive_ts_now(): """return tz-aware datetime obj for current time for usage as archive timestamp""" return datetime.now(UTC) # utc time / utc timezone + + +class DatePatternError(ValueError): + """Raised when a date: archive pattern cannot be parsed.""" + + +def date_match_exact(dt: datetime): + """Return predicate matching archives whose timestamp equals dt.""" + dt_utc = dt.astimezone(UTC) + return lambda ts: ts.astimezone(UTC) == dt_utc + + +def date_match_interval(start: datetime, end: datetime): + """Return predicate matching archives in the start-inclusive, end-exclusive interval.""" + start_utc = start.astimezone(UTC) + end_utc = end.astimezone(UTC) + return lambda ts: start_utc <= ts.astimezone(UTC) < end_utc + + +def parse_date_pattern_tz(tzstr: str): + """Parse a date: pattern timezone suffix.""" + if not tzstr: + return None + if tzstr == "Z": + return UTC + if tzstr[0] in "+-": + sign = 1 if tzstr[0] == "+" else -1 + try: + hh, mm = map(int, tzstr[1:].split(":")) + if not (0 <= hh <= 23 and 0 <= mm < 60): + raise ValueError + except ValueError: + raise DatePatternError("invalid UTC offset format") + total_minutes = sign * (hh * 60 + mm) + if not (-12 * 60 <= total_minutes <= 14 * 60): + raise DatePatternError("UTC offset outside ISO-8601 bounds") + return timezone(timedelta(minutes=total_minutes)) + if tzstr.startswith("[") and tzstr.endswith("]"): + try: + return ZoneInfo(tzstr[1:-1]) + except Exception: + raise DatePatternError("invalid timezone format") + raise DatePatternError("invalid timezone format") + + +DATE_PATTERN_RE = r""" + ^ + (?: + @(?P\d+)(?:\.(?P\d{1,6}))? + | + (?P\d{4}) + (?: + -(?P\d{2}) + (?: + -(?P\d{2}) + (?: + T(?P\d{2}) + (?: + :(?P\d{2}) + (?: + :(?P\d{2})(?:\.(?P\d{1,6}))? + )? + )? + )? + )? + )? + ) + (?PZ|[+\-]\d\d:\d\d|\[[^]]+\])? + $ +""" + + +def build_date_pattern_datetime(groups: dict, tz) -> datetime: + """Build the earliest datetime represented by a date: pattern.""" + second = 0 + microsecond = 0 + if groups.get("second"): + second = int(groups["second"]) + if groups.get("fraction"): + microsecond = int((groups["fraction"] + "000000")[:6]) + try: + return datetime( + year=int(groups["year"]), + month=int(groups.get("month") or 1), + day=int(groups.get("day") or 1), + hour=int(groups.get("hour") or 0), + minute=int(groups.get("minute") or 0), + second=second, + microsecond=microsecond, + tzinfo=tz, + ) + except ValueError as exc: + raise DatePatternError(str(exc)) + + +def parse_date_pattern_interval(expr: str) -> tuple[datetime, datetime]: + """Parse a static date: pattern into the interval it represents.""" + match = re.match(DATE_PATTERN_RE, expr, re.VERBOSE) + if not match: + raise DatePatternError(f"unrecognised date: {expr!r}") + + groups = match.groupdict() + tz = parse_date_pattern_tz(groups["tz"]) + + if groups["epoch"] and groups["tz"]: + raise DatePatternError("Unix timestamps must not have timezone suffixes") + + try: + if groups["epoch"]: + if groups["epoch_fraction"]: + start = _EPOCH + timedelta( + seconds=int(groups["epoch"]), microseconds=int((groups["epoch_fraction"] + "000000")[:6]) + ) + return start, start + start = _EPOCH + timedelta(seconds=int(groups["epoch"])) + return start, start + timedelta(seconds=1) + + start = build_date_pattern_datetime(groups, tz) + if groups["second"]: + if groups["fraction"]: + return start, start + return start, start + timedelta(seconds=1) + if groups["minute"]: + return start, start + timedelta(minutes=1) + if groups["hour"]: + return start, start + timedelta(hours=1) + if groups["day"]: + return start, start + timedelta(days=1) + if groups["month"]: + return start, offset_n_months(start, 1) + return start, offset_n_months(start, 12) + except (ValueError, OverflowError) as exc: + raise DatePatternError(str(exc)) + + +def compile_date_pattern(expr: str): + """ + Compile a date: archive match expression into a timestamp predicate. + + Supported expressions are static calendar timestamps from year to fractional-second precision, + optional timezone suffixes (Z, +/-HH:MM, or [Region/City]), and Unix epoch timestamps prefixed with @. + """ + expr = expr.strip() + start, end = parse_date_pattern_interval(expr) + if start == end: + return date_match_exact(start) + return date_match_interval(start, end) diff --git a/src/borg/manifest.py b/src/borg/manifest.py index 7a84a343a6..4881cf4780 100644 --- a/src/borg/manifest.py +++ b/src/borg/manifest.py @@ -15,7 +15,13 @@ from .constants import * # NOQA from .helpers.datastruct import StableDict from .helpers.parseformat import bin_to_hex, hex_to_bin -from .helpers.time import parse_timestamp, calculate_relative_offset, archive_ts_now +from .helpers.time import ( + parse_timestamp, + calculate_relative_offset, + archive_ts_now, + compile_date_pattern, + DatePatternError, +) from .helpers.errors import Error, CommandError from .crypto.low_level import IntegrityError as IntegrityErrorBase from .item import ArchiveItem @@ -231,6 +237,13 @@ def _matching_info_tuples(self, match_patterns, match_end, *, deleted=False): elif match.startswith("host:"): wanted_host = match.removeprefix("host:") archive_infos = [x for x in archive_infos if x.host == wanted_host] + elif match.startswith("date:"): + wanted_date = match.removeprefix("date:") + try: + date_matches = compile_date_pattern(wanted_date) + except DatePatternError as exc: + raise CommandError(f"Invalid date pattern: {match} ({exc})") + archive_infos = [x for x in archive_infos if date_matches(x.ts)] else: # do a match on the name match = match.removeprefix("name:") # accept optional name: prefix regex = get_regex_from_pattern(match) diff --git a/src/borg/testsuite/archiver/match_archives_date_test.py b/src/borg/testsuite/archiver/match_archives_date_test.py new file mode 100644 index 0000000000..579fe0f639 --- /dev/null +++ b/src/borg/testsuite/archiver/match_archives_date_test.py @@ -0,0 +1,349 @@ +import calendar +import time +from datetime import datetime, timezone + +import pytest + +from ...constants import * # NOQA +from ...helpers.errors import CommandError +from ...platform import is_win32 +from . import cmd, create_src_archive, generate_archiver_tests, RK_ENCRYPTION + +pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local,remote,binary") # NOQA + + +# (archive_name, timestamp) +YEAR_ARCHIVES = [ + ("archive-year-start", "2025-01-01T00:00:00+00:00"), + ("archive-year-same", "2025-12-31T23:59:59+00:00"), + ("archive-year-diff", "2024-12-31T23:59:59+00:00"), +] + +MONTH_ARCHIVES = [ + ("archive-mon-start", "2025-02-01T00:00:00+00:00"), + ("archive-mon-same", "2025-02-28T23:59:59+00:00"), + ("archive-mon-diff", "2025-01-31T23:59:59+00:00"), +] + +DAY_ARCHIVES = [ + ("archive-day-start", "2025-01-02T00:00:00+00:00"), + ("archive-day-same", "2025-01-02T23:59:59+00:00"), + ("archive-day-diff", "2025-01-01T23:59:59+00:00"), +] + +HOUR_ARCHIVES = [ + ("archive-hour-start", "2025-01-01T14:00:00+00:00"), + ("archive-hour-same", "2025-01-01T14:59:59+00:00"), + ("archive-hour-diff", "2025-01-01T13:59:59+00:00"), +] + +MINUTE_ARCHIVES = [ + ("archive-min-start", "2025-01-01T13:31:00+00:00"), + ("archive-min-same", "2025-01-01T13:31:59+00:00"), + ("archive-min-diff", "2025-01-01T13:30:59+00:00"), +] + +SECOND_ARCHIVES = [ + ("archive-sec-target", "2025-01-01T13:30:45+00:00"), + ("archive-sec-before", "2025-01-01T13:30:44+00:00"), + ("archive-sec-after", "2025-01-01T13:30:46+00:00"), +] + + +def test_match_archives_year(archivers, request): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + for name, ts in YEAR_ARCHIVES: + create_src_archive(archiver, name, ts=ts) + + # older-year should only hit the 2024 filter + out_2024 = cmd(archiver, "repo-list", "-v", "--match-archives=date:2024Z", exit_code=0) + assert "archive-year-diff" in out_2024 + assert "archive-year-start" not in out_2024 + assert "archive-year-same" not in out_2024 + + # 2025 filter should hit both minimum and maximum possible days in 2025 + out_2025 = cmd(archiver, "repo-list", "-v", "--match-archives=date:2025Z", exit_code=0) + assert "archive-year-start" in out_2025 + assert "archive-year-same" in out_2025 + assert "archive-year-diff" not in out_2025 + + +def test_match_archives_month(archivers, request): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + for name, ts in MONTH_ARCHIVES: + create_src_archive(archiver, name, ts=ts) + + # January only includes January + out_jan = cmd(archiver, "repo-list", "-v", "--match-archives=date:2025-01Z", exit_code=0) + assert "archive-mon-diff" in out_jan + assert "archive-mon-start" not in out_jan + assert "archive-mon-same" not in out_jan + + # February includes minimum and maximum possible days in February + out_feb = cmd(archiver, "repo-list", "-v", "--match-archives=date:2025-02Z", exit_code=0) + assert "archive-mon-start" in out_feb + assert "archive-mon-same" in out_feb + assert "archive-mon-diff" not in out_feb + + +def test_match_archives_day(archivers, request): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + for name, ts in DAY_ARCHIVES: + create_src_archive(archiver, name, ts=ts) + + # 2025-01-01 only includes 2025-01-01 + out_01 = cmd(archiver, "repo-list", "-v", "--match-archives=date:2025-01-01Z", exit_code=0) + assert "archive-day-diff" in out_01 + assert "archive-day-start" not in out_01 + assert "archive-day-same" not in out_01 + + # 2025-01-02 includes minimum and maximum possible times in 2025-01-02 + out_02 = cmd(archiver, "repo-list", "-v", "--match-archives=date:2025-01-02Z", exit_code=0) + assert "archive-day-start" in out_02 + assert "archive-day-same" in out_02 + assert "archive-day-diff" not in out_02 + + +def test_match_archives_hour(archivers, request): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + for name, ts in HOUR_ARCHIVES: + create_src_archive(archiver, name, ts=ts) + + # 13:00-range only matches 13:00 hour + out_13 = cmd(archiver, "repo-list", "-v", "--match-archives=date:2025-01-01T13Z", exit_code=0) + assert "archive-hour-diff" in out_13 + assert "archive-hour-start" not in out_13 + assert "archive-hour-same" not in out_13 + + # 14:00-range matches both beginning and end of the hour + out_14 = cmd(archiver, "repo-list", "-v", "--match-archives=date:2025-01-01T14Z", exit_code=0) + assert "archive-hour-start" in out_14 + assert "archive-hour-same" in out_14 + assert "archive-hour-diff" not in out_14 + + +def test_match_archives_minute(archivers, request): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + for name, ts in MINUTE_ARCHIVES: + create_src_archive(archiver, name, ts=ts) + + # 13:30 only matches 13:30 minute + out_1330 = cmd(archiver, "repo-list", "-v", "--match-archives=date:2025-01-01T13:30Z", exit_code=0) + assert "archive-min-diff" in out_1330 + assert "archive-min-start" not in out_1330 + assert "archive-min-same" not in out_1330 + + # 13:31 matches both beginning and end of the minute + out_1331 = cmd(archiver, "repo-list", "-v", "--match-archives=date:2025-01-01T13:31Z", exit_code=0) + assert "archive-min-start" in out_1331 + assert "archive-min-same" in out_1331 + assert "archive-min-diff" not in out_1331 + + +def test_match_archives_second(archivers, request): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + for name, ts in SECOND_ARCHIVES: + create_src_archive(archiver, name, ts=ts) + + # exact-second match only + out_exact = cmd(archiver, "repo-list", "-v", "--match-archives=date:2025-01-01T13:30:45Z", exit_code=0) + assert "archive-sec-target" in out_exact + assert "archive-sec-before" not in out_exact + assert "archive-sec-after" not in out_exact + + +def test_match_archives_fractional_second(archivers, request): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + create_src_archive(archiver, "archive-fraction-target", ts="2025-01-01T13:30:45.123456+00:00") + create_src_archive(archiver, "archive-fraction-other", ts="2025-01-01T13:30:45.123457+00:00") + + output = cmd(archiver, "repo-list", "-v", "--match-archives=date:2025-01-01T13:30:45.123456Z", exit_code=0) + + assert "archive-fraction-target" in output + assert "archive-fraction-other" not in output + + +def test_unix_timestamps(archivers, request): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + create_src_archive(archiver, "archive-sec-before", ts="2025-01-01T13:30:44+00:00") + create_src_archive(archiver, "archive-sec-target", ts="2025-01-01T13:30:45+00:00") + create_src_archive(archiver, "archive-sec-after", ts="2025-01-01T13:30:46+00:00") + dt_target = datetime(2025, 1, 1, 13, 30, 45, tzinfo=timezone.utc) + utc_ts_target = calendar.timegm(dt_target.utctimetuple()) + + output = cmd(archiver, "repo-list", "-v", f"--match-archives=date:@{utc_ts_target}", exit_code=0) + + assert "archive-sec-target" in output + assert "archive-sec-before" not in output + assert "archive-sec-after" not in output + + +def test_fractional_unix_timestamps(archivers, request): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + create_src_archive(archiver, "archive-fraction-target", ts="2025-01-01T13:30:45.123456+00:00") + create_src_archive(archiver, "archive-fraction-other", ts="2025-01-01T13:30:45.123457+00:00") + dt_target = datetime(2025, 1, 1, 13, 30, 45, 123456, tzinfo=timezone.utc) + utc_ts_target = calendar.timegm(dt_target.utctimetuple()) + + output = cmd( + archiver, "repo-list", "-v", f"--match-archives=date:@{utc_ts_target}.{dt_target.microsecond:06d}", exit_code=0 + ) + + assert "archive-fraction-target" in output + assert "archive-fraction-other" not in output + + +TIMEZONE_ARCHIVES = [("archive-la", "2025-01-01T12:01:00-08:00"), ("archive-utc", "2025-01-02T12:01:00+00:00")] + + +@pytest.mark.parametrize("timezone_variant", ["2025-01-01T12:01:00-08:00", "2025-01-01T12:01:00[America/Los_Angeles]"]) +def test_match_la_equivalents(archivers, request, timezone_variant): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + for name, ts in TIMEZONE_ARCHIVES: + create_src_archive(archiver, name, ts=ts) + + output = cmd(archiver, "repo-list", "-v", f"--match-archives=date:{timezone_variant}", exit_code=0) + assert "archive-la" in output + assert "archive-utc" not in output + + +@pytest.mark.parametrize( + "timezone_variant", ["2025-01-02T12:01:00+00:00", "2025-01-02T12:01:00Z", "2025-01-02T12:01:00[Etc/UTC]"] +) +def test_match_utc_equivalents(archivers, request, timezone_variant): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + for name, ts in TIMEZONE_ARCHIVES: + create_src_archive(archiver, name, ts=ts) + + output = cmd(archiver, "repo-list", "-v", f"--match-archives=date:{timezone_variant}", exit_code=0) + assert "archive-utc" in output + assert "archive-la" not in output + + +HOUR_TZ_ARCHIVES = [ + ("archive-hour-diff", "2025-01-01T09:59:00Z"), + ("archive-hour-start", "2025-01-01T10:00:00Z"), + ("archive-hour-same", "2025-01-01T10:59:59Z"), +] + + +def test_match_hour_from_different_tz(archivers, request): + """ + Test that the date filter works for hours with archives created in a different timezone. + """ + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + for name, ts in HOUR_TZ_ARCHIVES: + create_src_archive(archiver, name, ts=ts) + + # We're filtering "local 11:00" in +01:00 zone, which is 10:00-10:59:59 UTC + out = cmd(archiver, "repo-list", "-v", "--match-archives=date:2025-01-01T11+01:00", exit_code=0) + assert "archive-hour-start" in out + assert "archive-hour-same" in out + assert "archive-hour-diff" not in out + + +LOCAL_TZ_ARCHIVES = [ + ("archive-local-in", "2025-01-15T18:30:00Z"), # 13:30 in America/New_York (EST, UTC-5) + ("archive-local-out", "2025-01-15T17:30:00Z"), # 12:30 in America/New_York +] + + +@pytest.mark.skipif(is_win32, reason="time.tzset() is not available on Windows") +def test_match_bare_pattern_uses_local_timezone(archivers, request, monkeypatch): + """A pattern without a timezone suffix is interpreted in the local timezone.""" + archiver = request.getfixturevalue(archivers) + monkeypatch.setenv("TZ", "America/New_York") + time.tzset() + cmd(archiver, "repo-create", RK_ENCRYPTION) + for name, ts in LOCAL_TZ_ARCHIVES: + create_src_archive(archiver, name, ts=ts) + + # bare 13:30 is "local" EST (UTC-5) or 18:30 UTC, matching only archive-local-in + out = cmd(archiver, "repo-list", "-v", "--match-archives=date:2025-01-15T13:30", exit_code=0) + assert "archive-local-in" in out + assert "archive-local-out" not in out + + +@pytest.mark.parametrize( + "invalid_expr", + [ + "2025-01-01T00:00:00+14:01", # beyond +14:00 (ISO 8601 boundary) + "2025-01-01T00:00:00-12:01", # beyond -12:00 (ISO 8601 boundary) + "2025-01-01T00:00:00+09:99", # invalid minutes + "2025-01-01T00:00:00[garbage]", # invalid region + "2025-01-01T00:00:00[Not/AZone]", # structured but nonexistent + ], +) +def test_invalid_timezones_rejected(archivers, request, invalid_expr): + """ + Test that invalid timezone expressions are rejected. + """ + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + + with pytest.raises(CommandError) as excinfo: + cmd(archiver, "repo-list", "-v", f"--match-archives=date:{invalid_expr}") + + msg = str(excinfo.value) + assert "Invalid date pattern" in msg + assert invalid_expr in msg + + +def test_unix_timestamp_rejects_timezone(archivers, request): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + + with pytest.raises(CommandError) as excinfo: + cmd(archiver, "repo-list", "-v", "--match-archives=date:@1735732800Z") + + msg = str(excinfo.value) + assert "Invalid date pattern" in msg + assert "@1735732800Z" in msg + + +@pytest.mark.parametrize( + "invalid_expr", + [ + "9999", # year interval end overflows datetime.max + "9999-12", # month interval end overflows datetime.max + "9999-12-31T23", # hour interval end overflows datetime.max + "@253402300799", # ~year 9999 epoch, interval end overflows + "@99999999999999999999", # epoch too large for C int + ], +) +def test_out_of_range_rejected(archivers, request, invalid_expr): + """Out-of-range patterns produce a clean CommandError, not a traceback.""" + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + + with pytest.raises(CommandError) as excinfo: + cmd(archiver, "repo-list", "-v", f"--match-archives=date:{invalid_expr}") + + msg = str(excinfo.value) + assert "Invalid date pattern" in msg + assert invalid_expr in msg + + +@pytest.mark.parametrize("invalid_expr", ["2025-01-01T00:00:00.1234567Z", "@1735732800.1234567"]) +def test_fractional_precision_rejected(archivers, request, invalid_expr): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + + with pytest.raises(CommandError) as excinfo: + cmd(archiver, "repo-list", "-v", f"--match-archives=date:{invalid_expr}") + + msg = str(excinfo.value) + assert "Invalid date pattern" in msg + assert invalid_expr in msg From dcfa8df552b4aea4a324961877fec02a60c8e898 Mon Sep 17 00:00:00 2001 From: Charlie Herz Date: Tue, 30 Jun 2026 21:13:55 -0400 Subject: [PATCH 2/4] date: address review feedback - fix a TZ leak that broke later tests. The local-timezone test set TZ + tzset() but restored only the env var, corrupting the runner's local time. Restore through a set_timezone fixture that re-runs tzset() on teardown. - In parsing, reject surrounding whitespace and trailing newlines. Use \Z instead of $ and drop the redundant strip() so 'date:2025\n' no longer parses. - add tzdata dependency on Windows so [Region/City] zones resolve there. - allow a space in place of T as the date-time separator and rewrite the date: helptext to be clearer, separating exact from interval forms and adding a note on DST/UTC. --- pyproject.toml | 1 + src/borg/archiver/help_cmd.py | 47 ++++++++++++------- src/borg/helpers/time.py | 5 +- .../archiver/match_archives_date_test.py | 38 +++++++++++++-- 4 files changed, 69 insertions(+), 22 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 078f1a3bb5..f1d9bcac09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "jsonargparse>=4.47.0", "PyYAML>=6.0.2", # we need to register our types with yaml, jsonargparse uses yaml for config files "blake3>=1.0.0,!=1.0.9", # 1.0.9 does not build on windows/msys2/mingw + "tzdata; sys_platform == 'win32'", # zoneinfo has no system tz database on Windows (date: named zones) ] [project.optional-dependencies] diff --git a/src/borg/archiver/help_cmd.py b/src/borg/archiver/help_cmd.py index 1f0fe06f1f..c0fc01e5c0 100644 --- a/src/borg/archiver/help_cmd.py +++ b/src/borg/archiver/help_cmd.py @@ -330,26 +330,41 @@ class HelpMixIn: This is very powerful, but can also get rather complicated. Date patterns, selector ``date:`` - Match archives by creation timestamp. Supported forms are: - - - ``YYYY``: 1 year - - ``YYYY-MM``: 1 month - - ``YYYY-MM-DD``: 1 day - - ``YYYY-MM-DDTHH``: 1 hour - - ``YYYY-MM-DDTHH:MM``: 1 minute - - ``YYYY-MM-DDTHH:MM:SS``: 1 second - - ``@1735732800``: 1 second - - ``YYYY-MM-DDTHH:MM:SS.ffffff``: exact timestamp - - ``@1735732800.123456``: exact timestamp + Match archives by creation timestamp. You can either match a single archive by + passing its exact creation time, or all archives created within a given time + interval. + + To match a single archive by its exact creation time, use the forms: + + - ``YYYY-MM-DDTHH:MM:SS.ffffff``: ISO-8601-like date-time string + - ``@1735732800.123456``: UNIX timestamp + + To match a single archive, the pattern must specify the archive's complete + creation timestamp, including any fractional seconds. Fractional-second + patterns accept 1 to 6 digits. + + To match all archives created within a given time interval, use the forms: + + - ``YYYY``: match all archives created within the given year + - ``YYYY-MM``: within the given month + - ``YYYY-MM-DD``: on the given day + - ``YYYY-MM-DDTHH``: in the given hour + - ``YYYY-MM-DDTHH:MM``: in the given minute + - ``YYYY-MM-DDTHH:MM:SS``: in the given second + - ``@1735732800``: within the 1 second interval from the given UNIX timestamp + + The ``T`` date-time separator may also be written as a space, e.g. + ``date:2025-01-01 14:30``. Date and time patterns match the interval implied by their precision, including - the start and excluding the end. Fractional-second patterns accept 1 to 6 - digits and match exactly. + the start and excluding the end. For example, ``date:2026-06`` matches archives + created on or after ``2026-06-01T00:00:00`` and before ``2026-07-01T00:00:00``. - Date and time patterns may include a timezone suffix: ``Z``, ``+HH:MM``, + Date and time patterns may include a timezone suffix: ``Z`` (UTC), ``+HH:MM``, ``-HH:MM``, or ``[Region/City]``. Patterns without a timezone are interpreted - in the local timezone. Unix timestamp patterns are UTC and do not accept a - timezone suffix. + in the local timezone. Be wary of Daylight Saving Time (DST) transitions, as + they can make time intervals ambiguous or nonexistent. Use UTC to avoid such + issues. Unix timestamps are always UTC and do not accept a timezone suffix. Examples:: diff --git a/src/borg/helpers/time.py b/src/borg/helpers/time.py index 6b3081809e..a05952ebe9 100644 --- a/src/borg/helpers/time.py +++ b/src/borg/helpers/time.py @@ -255,7 +255,7 @@ def parse_date_pattern_tz(tzstr: str): (?: -(?P\d{2}) (?: - T(?P\d{2}) + [ T](?P\d{2}) (?: :(?P\d{2}) (?: @@ -267,7 +267,7 @@ def parse_date_pattern_tz(tzstr: str): )? ) (?PZ|[+\-]\d\d:\d\d|\[[^]]+\])? - $ + \Z """ @@ -341,7 +341,6 @@ def compile_date_pattern(expr: str): Supported expressions are static calendar timestamps from year to fractional-second precision, optional timezone suffixes (Z, +/-HH:MM, or [Region/City]), and Unix epoch timestamps prefixed with @. """ - expr = expr.strip() start, end = parse_date_pattern_interval(expr) if start == end: return date_match_exact(start) diff --git a/src/borg/testsuite/archiver/match_archives_date_test.py b/src/borg/testsuite/archiver/match_archives_date_test.py index 579fe0f639..59b11bc91f 100644 --- a/src/borg/testsuite/archiver/match_archives_date_test.py +++ b/src/borg/testsuite/archiver/match_archives_date_test.py @@ -1,4 +1,5 @@ import calendar +import os import time from datetime import datetime, timezone @@ -12,6 +13,27 @@ pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local,remote,binary") # NOQA +@pytest.fixture +def set_timezone(request): + """Set the process-local timezone for the duration of a test, restoring it afterwards.""" + + def _set(tz): + old_tz = os.environ.get("TZ") + + def restore(): + if old_tz is None: + os.environ.pop("TZ", None) + else: + os.environ["TZ"] = old_tz + time.tzset() + + request.addfinalizer(restore) + os.environ["TZ"] = tz + time.tzset() + + return _set + + # (archive_name, timestamp) YEAR_ARCHIVES = [ ("archive-year-start", "2025-01-01T00:00:00+00:00"), @@ -261,11 +283,10 @@ def test_match_hour_from_different_tz(archivers, request): @pytest.mark.skipif(is_win32, reason="time.tzset() is not available on Windows") -def test_match_bare_pattern_uses_local_timezone(archivers, request, monkeypatch): +def test_match_bare_pattern_uses_local_timezone(archivers, request, set_timezone): """A pattern without a timezone suffix is interpreted in the local timezone.""" + set_timezone("America/New_York") archiver = request.getfixturevalue(archivers) - monkeypatch.setenv("TZ", "America/New_York") - time.tzset() cmd(archiver, "repo-create", RK_ENCRYPTION) for name, ts in LOCAL_TZ_ARCHIVES: create_src_archive(archiver, name, ts=ts) @@ -347,3 +368,14 @@ def test_fractional_precision_rejected(archivers, request, invalid_expr): msg = str(excinfo.value) assert "Invalid date pattern" in msg assert invalid_expr in msg + + +@pytest.mark.parametrize("invalid_expr", ["2025\n", " 2025", "2025 "]) +def test_surrounding_whitespace_rejected(archivers, request, invalid_expr): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + + with pytest.raises(CommandError) as excinfo: + cmd(archiver, "repo-list", "-v", f"--match-archives=date:{invalid_expr}") + + assert "Invalid date pattern" in str(excinfo.value) From 02296fd854955cf440234f940c61f176e3faec60 Mon Sep 17 00:00:00 2001 From: Charlie Herz Date: Tue, 30 Jun 2026 21:40:15 -0400 Subject: [PATCH 3/4] date: add tests + update changelog, polish DST helptext - add tests for the space date-time separator and for DST behavior (named zones track DST while a fixed offset does not) - add a changelog entry for the date: selector - helptext: improved DST/UTC guidance with a concrete example and reorganized things a bit --- docs/changes.rst | 4 ++ src/borg/archiver/help_cmd.py | 8 ++-- .../archiver/match_archives_date_test.py | 44 +++++++++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 6af6e69b32..7477f316e6 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -214,6 +214,10 @@ New features: - create/extract/compact --stats: report store statistics, #9880, #9405 - compact: show deduplication and compression factors, #9856 - json: support BORG_JSON_INDENT env var for JSON output formatting +- implement --match-archives ``date:`` selector to match archives by creation + timestamp. It supports ISO-8601-like dates/times from year to fractional-second + precision, Unix epoch timestamps (``@...``), and ``Z`` / ``+HH:MM`` / + ``[Region/City]`` timezone suffixes. #8776 Fixes: diff --git a/src/borg/archiver/help_cmd.py b/src/borg/archiver/help_cmd.py index c0fc01e5c0..c3db7c46aa 100644 --- a/src/borg/archiver/help_cmd.py +++ b/src/borg/archiver/help_cmd.py @@ -362,9 +362,11 @@ class HelpMixIn: Date and time patterns may include a timezone suffix: ``Z`` (UTC), ``+HH:MM``, ``-HH:MM``, or ``[Region/City]``. Patterns without a timezone are interpreted - in the local timezone. Be wary of Daylight Saving Time (DST) transitions, as - they can make time intervals ambiguous or nonexistent. Use UTC to avoid such - issues. Unix timestamps are always UTC and do not accept a timezone suffix. + in the local timezone. Unix timestamps are always UTC and do not accept a timezone suffix. + + Be wary of Daylight Saving Time (DST) transitions, as they can make time intervals + ambiguous or nonexistent. For example, named zones such as ``[Europe/Berlin]`` track DST, + but the equivalent (winter) UTC offset of ``+01:00`` does not. Use UTC to avoid such issues. Examples:: diff --git a/src/borg/testsuite/archiver/match_archives_date_test.py b/src/borg/testsuite/archiver/match_archives_date_test.py index 59b11bc91f..cc5d11984c 100644 --- a/src/borg/testsuite/archiver/match_archives_date_test.py +++ b/src/borg/testsuite/archiver/match_archives_date_test.py @@ -297,6 +297,50 @@ def test_match_bare_pattern_uses_local_timezone(archivers, request, set_timezone assert "archive-local-out" not in out +def test_match_space_date_time_separator(archivers, request): + """A space is accepted in place of the T date-time separator.""" + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + create_src_archive(archiver, "archive-in", ts="2025-01-01T14:30:30+00:00") + create_src_archive(archiver, "archive-out", ts="2025-01-01T14:31:30+00:00") + + out = cmd(archiver, "repo-list", "-v", "--match-archives=date:2025-01-01 14:30Z", exit_code=0) + assert "archive-in" in out + assert "archive-out" not in out + + +# Europe/Berlin springs forward 2026-03-29: 02:00 CET (+01:00) -> 03:00 CEST (+02:00). +DST_ARCHIVES = [ + ("archive-cet", "2026-03-29T00:30:00Z"), # local 01:30 CET (+01:00), before the transition + ("archive-plus2", "2026-03-28T23:30:00Z"), # 01:30 at a fixed +02:00 offset + ("archive-cest", "2026-03-29T01:00:00Z"), # local 03:00 CEST (+02:00), after the transition +] + + +def test_match_dst_transition(archivers, request): + """Named zones track DST, so they can differ from a fixed offset around a transition.""" + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + for name, ts in DST_ARCHIVES: + create_src_archive(archiver, name, ts=ts) + + # Before the transition Europe/Berlin is +01:00, so 01:30 there differs from a literal +02:00. + out_berlin = cmd(archiver, "repo-list", "-v", "--match-archives=date:2026-03-29T01:30[Europe/Berlin]", exit_code=0) + assert "archive-cet" in out_berlin + assert "archive-plus2" not in out_berlin + assert "archive-cest" not in out_berlin + + out_plus2 = cmd(archiver, "repo-list", "-v", "--match-archives=date:2026-03-29T01:30+02:00", exit_code=0) + assert "archive-plus2" in out_plus2 + assert "archive-cet" not in out_plus2 + + # After the transition Europe/Berlin is +02:00, so 03:00 there equals the fixed +02:00. + out_after = cmd(archiver, "repo-list", "-v", "--match-archives=date:2026-03-29T03:00[Europe/Berlin]", exit_code=0) + assert "archive-cest" in out_after + assert "archive-cet" not in out_after + assert "archive-plus2" not in out_after + + @pytest.mark.parametrize( "invalid_expr", [ From b8347e02a775a980fcf30bc48a7e6476dee59559 Mon Sep 17 00:00:00 2001 From: Charlie Herz Date: Mon, 13 Jul 2026 22:06:42 -0400 Subject: [PATCH 4/4] date: fix negative tests failing under binary archiver pytest.raises(CommandError) only fires in-process; under binary_archiver (fork mode) errors return as an exit code. Branch on FORK_DEFAULT as create_cmd_test.py does. --- .../archiver/match_archives_date_test.py | 35 ++++++++----------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/src/borg/testsuite/archiver/match_archives_date_test.py b/src/borg/testsuite/archiver/match_archives_date_test.py index cc5d11984c..82299fd472 100644 --- a/src/borg/testsuite/archiver/match_archives_date_test.py +++ b/src/borg/testsuite/archiver/match_archives_date_test.py @@ -341,6 +341,16 @@ def test_match_dst_transition(archivers, request): assert "archive-plus2" not in out_after +def rejected_date_pattern(archiver, invalid_expr): + """Run a rejected ``date:`` pattern and return the resulting error message.""" + args = ("repo-list", "-v", f"--match-archives=date:{invalid_expr}") + if archiver.FORK_DEFAULT: + return cmd(archiver, *args, exit_code=CommandError().exit_code) + with pytest.raises(CommandError) as excinfo: + cmd(archiver, *args) + return str(excinfo.value) + + @pytest.mark.parametrize( "invalid_expr", [ @@ -358,10 +368,7 @@ def test_invalid_timezones_rejected(archivers, request, invalid_expr): archiver = request.getfixturevalue(archivers) cmd(archiver, "repo-create", RK_ENCRYPTION) - with pytest.raises(CommandError) as excinfo: - cmd(archiver, "repo-list", "-v", f"--match-archives=date:{invalid_expr}") - - msg = str(excinfo.value) + msg = rejected_date_pattern(archiver, invalid_expr) assert "Invalid date pattern" in msg assert invalid_expr in msg @@ -370,10 +377,7 @@ def test_unix_timestamp_rejects_timezone(archivers, request): archiver = request.getfixturevalue(archivers) cmd(archiver, "repo-create", RK_ENCRYPTION) - with pytest.raises(CommandError) as excinfo: - cmd(archiver, "repo-list", "-v", "--match-archives=date:@1735732800Z") - - msg = str(excinfo.value) + msg = rejected_date_pattern(archiver, "@1735732800Z") assert "Invalid date pattern" in msg assert "@1735732800Z" in msg @@ -393,10 +397,7 @@ def test_out_of_range_rejected(archivers, request, invalid_expr): archiver = request.getfixturevalue(archivers) cmd(archiver, "repo-create", RK_ENCRYPTION) - with pytest.raises(CommandError) as excinfo: - cmd(archiver, "repo-list", "-v", f"--match-archives=date:{invalid_expr}") - - msg = str(excinfo.value) + msg = rejected_date_pattern(archiver, invalid_expr) assert "Invalid date pattern" in msg assert invalid_expr in msg @@ -406,10 +407,7 @@ def test_fractional_precision_rejected(archivers, request, invalid_expr): archiver = request.getfixturevalue(archivers) cmd(archiver, "repo-create", RK_ENCRYPTION) - with pytest.raises(CommandError) as excinfo: - cmd(archiver, "repo-list", "-v", f"--match-archives=date:{invalid_expr}") - - msg = str(excinfo.value) + msg = rejected_date_pattern(archiver, invalid_expr) assert "Invalid date pattern" in msg assert invalid_expr in msg @@ -419,7 +417,4 @@ def test_surrounding_whitespace_rejected(archivers, request, invalid_expr): archiver = request.getfixturevalue(archivers) cmd(archiver, "repo-create", RK_ENCRYPTION) - with pytest.raises(CommandError) as excinfo: - cmd(archiver, "repo-list", "-v", f"--match-archives=date:{invalid_expr}") - - assert "Invalid date pattern" in str(excinfo.value) + assert "Invalid date pattern" in rejected_date_pattern(archiver, invalid_expr)