From dcfa8df552b4aea4a324961877fec02a60c8e898 Mon Sep 17 00:00:00 2001 From: Charlie Herz Date: Tue, 30 Jun 2026 21:13:55 -0400 Subject: [PATCH] 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 | 45 ++++++++++++------- src/borg/helpers/time.py | 5 +-- .../archiver/match_archives_date_test.py | 38 ++++++++++++++-- 4 files changed, 68 insertions(+), 21 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 078f1a3bb..f1d9bcac0 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 1f0fe06f1..c0fc01e5c 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: + 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. - - ``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 + 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 6b3081809..a05952ebe 100644 --- a/src/borg/helpers/time.py +++ b/src/borg/helpers/time.py @@ -255,7 +255,7 @@ DATE_PATTERN_RE = r""" (?: -(?P\d{2}) (?: - T(?P\d{2}) + [ T](?P\d{2}) (?: :(?P\d{2}) (?: @@ -267,7 +267,7 @@ DATE_PATTERN_RE = r""" )? ) (?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 579fe0f63..59b11bc91 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 @@ 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 +@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 @@ LOCAL_TZ_ARCHIVES = [ @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)