Skip to content

Commit 04c5569

Browse files
committed
Add preliminary support for ISO-8601 timestamps (no timezones at the moment)
1 parent a34f4a1 commit 04c5569

File tree

2 files changed

+105
-1
lines changed

2 files changed

+105
-1
lines changed

src/borg/helpers/time.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,100 @@ def isoformat(self):
185185
def archive_ts_now():
186186
"""return tz-aware datetime obj for current time for usage as archive timestamp"""
187187
return datetime.now(timezone.utc) # utc time / utc timezone
188+
189+
class DatePatternError(ValueError):
190+
"""Raised when a date: archive pattern cannot be parsed."""
191+
192+
193+
def local(dt: datetime) -> datetime:
194+
"""Attach the system local timezone to naive dt without converting."""
195+
if dt.tzinfo is None:
196+
dt = dt.replace(tzinfo=datetime.now().astimezone().tzinfo)
197+
return dt
198+
199+
200+
def exact_predicate(dt: datetime):
201+
"""Return predicate matching archives whose ts equals dt (UTC)."""
202+
dt_utc = local(dt).astimezone(timezone.utc)
203+
return lambda ts: ts == dt_utc
204+
205+
206+
def interval_predicate(start: datetime, end: datetime):
207+
start_utc = local(start).astimezone(timezone.utc)
208+
end_utc = local(end).astimezone(timezone.utc)
209+
return lambda ts: start_utc <= ts < end_utc
210+
211+
212+
def compile_date_pattern(expr: str):
213+
"""
214+
Turn a date: expression into a predicate ts->bool.
215+
Supports:
216+
1) Full ISO‑8601 timestamps with minute (and optional seconds/fraction)
217+
2) Hour-only: YYYY‑MM‑DDTHH -> interval of 1 hour
218+
3) Minute-only: YYYY‑MM‑DDTHH:MM -> interval of 1 minute
219+
4) YYYY, YYYY‑MM, YYYY‑MM‑DD -> day/month/year intervals
220+
5) Unix epoch (@123456789) -> exact match
221+
Naive inputs are assumed local, then converted into UTC.
222+
TODO: verify working for fractional seconds; add timezone support.
223+
"""
224+
expr = expr.strip()
225+
226+
# 1) Full timestamp (with fraction)
227+
full_re = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+")
228+
if full_re.match(expr):
229+
dt = parse_local_timestamp(expr, tzinfo=timezone.utc)
230+
return exact_predicate(dt) # no interval, since we have a fractional timestamp
231+
232+
# 2) Seconds-only
233+
second_re = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$")
234+
if second_re.match(expr):
235+
start = parse_local_timestamp(expr, tzinfo=timezone.utc)
236+
return interval_predicate(start, start + timedelta(seconds=1))
237+
238+
# 2) Minute-only
239+
minute_re = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$")
240+
if minute_re.match(expr):
241+
start = parse_local_timestamp(expr + ":00", tzinfo=timezone.utc)
242+
return interval_predicate(start, start + timedelta(minutes=1))
243+
244+
# 3) Hour-only
245+
hour_re = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}$")
246+
if hour_re.match(expr):
247+
start = parse_local_timestamp(expr + ":00:00", tzinfo=timezone.utc)
248+
return interval_predicate(start, start + timedelta(hours=1))
249+
250+
251+
# Unix epoch (@123456789) - Note: We don't support fractional seconds here, since Unix epochs are almost always whole numbers.
252+
if expr.startswith("@"):
253+
try:
254+
epoch = int(expr[1:])
255+
except ValueError:
256+
raise DatePatternError(f"invalid epoch: {expr!r}")
257+
start = datetime.fromtimestamp(epoch, tz=timezone.utc)
258+
return interval_predicate(start, start + timedelta(seconds=1)) # match within the second
259+
260+
# Year/Year-month/Year-month-day
261+
parts = expr.split("-")
262+
try:
263+
if len(parts) == 1: # YYYY
264+
year = int(parts[0])
265+
start = datetime(year, 1, 1)
266+
end = datetime(year + 1, 1, 1)
267+
268+
elif len(parts) == 2: # YYYY‑MM
269+
year, month = map(int, parts)
270+
start = datetime(year, month, 1)
271+
end = offset_n_months(start, 1)
272+
273+
elif len(parts) == 3: # YYYY‑MM‑DD
274+
year, month, day = map(int, parts)
275+
start = datetime(year, month, day)
276+
end = start + timedelta(days=1)
277+
278+
else:
279+
raise DatePatternError(f"unrecognised date: {expr!r}")
280+
281+
except ValueError as e:
282+
raise DatePatternError(str(e)) from None
283+
284+
return interval_predicate(start, end)

src/borg/manifest.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from .constants import * # NOQA
1515
from .helpers.datastruct import StableDict
1616
from .helpers.parseformat import bin_to_hex, hex_to_bin
17-
from .helpers.time import parse_timestamp, calculate_relative_offset, archive_ts_now
17+
from .helpers.time import parse_timestamp, calculate_relative_offset, archive_ts_now, compile_date_pattern, DatePatternError
1818
from .helpers.errors import Error, CommandError
1919
from .item import ArchiveItem
2020
from .patterns import get_regex_from_pattern
@@ -198,6 +198,13 @@ def _matching_info_tuples(self, match_patterns, match_end, *, deleted=False):
198198
elif match.startswith("host:"):
199199
wanted_host = match.removeprefix("host:")
200200
archive_infos = [x for x in archive_infos if x.host == wanted_host]
201+
elif match.startswith("date:"):
202+
wanted_date = match.removeprefix("date:")
203+
try:
204+
pred = compile_date_pattern(wanted_date)
205+
except DatePatternError as e:
206+
raise CommandError(f"Invalid date pattern: {match} ({e})")
207+
archive_infos = [ai for ai in archive_infos if pred(ai.ts)]
201208
else: # do a match on the name
202209
match = match.removeprefix("name:") # accept optional name: prefix
203210
regex = get_regex_from_pattern(match)

0 commit comments

Comments
 (0)