Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion src/borg/archiver/help_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``:
Expand All @@ -328,6 +329,28 @@ class HelpMixIn:
Full regular expression support.
This is very powerful, but can also get rather complicated.

Date patterns, selector ``date:``

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At first glance, I found this a little hard to follow. Here's a suggestion for the help text:

        Date patterns, selector ``date:``
            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

            Date and time patterns match the interval implied by their precision, including
            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`` (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.

It separates exact timestamp matches from interval matches, explains why exact matches include fractional seconds, is a bit more explicit about how interval matching works, and adds a note about timezones and DST.

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
Expand All @@ -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(
"""
Expand Down
150 changes: 149 additions & 1 deletion src/borg/helpers/time.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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<epoch>\d+)(?:\.(?P<epoch_fraction>\d{1,6}))?
|
(?P<year>\d{4})
(?:
-(?P<month>\d{2})
(?:
-(?P<day>\d{2})
(?:
T(?P<hour>\d{2})
(?:
:(?P<minute>\d{2})
(?:
:(?P<second>\d{2})(?:\.(?P<fraction>\d{1,6}))?
)?
)?
)?
)?
)?
)
(?P<tz>Z|[+\-]\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)
15 changes: 14 additions & 1 deletion src/borg/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading