Skip to content

Commit 6032c4a

Browse files
committed
add support for ISO week-date and ordinal-date matching in date: archive patterns; include tests
1 parent 904853d commit 6032c4a

File tree

2 files changed

+149
-16
lines changed

2 files changed

+149
-16
lines changed

src/borg/helpers/time.py

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import os
22
import re
3-
from datetime import datetime, timezone, timedelta
3+
from datetime import datetime, timezone, timedelta, date
44
from zoneinfo import ZoneInfo
55

66

@@ -268,26 +268,39 @@ def build_datetime_from_groups(gd: dict, tz: timezone) -> datetime:
268268
return datetime(year, month, day, hour, minute, second, microsecond, tzinfo=tz)
269269

270270

271+
# Regex for ISO-8601 timestamps:
272+
# Accepts both 'T' and space as separators between date and time per RFC-3339/IXDTF.
271273
MAIN_RE = r"""
272274
^
273275
(?:
274-
@(?P<epoch>\d+) # unix epoch
275-
| (?P<year> \d{4}|\*) # year (YYYY or *)
276-
(?:-(?P<month> \d{2}|\*) # month (MM or *)
277-
(?:-(?P<day> \d{2}|\*) # day (DD or *)
278-
(?:[T ](?P<hour> \d{2}|\*) # hour (HH or *)
279-
(?::(?P<minute>\d{2}|\*) # minute (MM or *)
280-
(?::(?P<second>\d{2}(?:\.\d+)?|\*))? # second (SS or SS.fff or *)
276+
# ISO week date: YYYY-Www or YYYY-Www-D
277+
(?P<isoweek_year>\d{4})-W(?P<isoweek_week>\d{2})(?:-(?P<isoweek_day>\d))?
278+
| # Ordinal date: YYYY-DDD
279+
(?P<ordinal_year>\d{4})-(?P<ordinal_day>\d{3})
280+
| # Unix epoch
281+
@(?P<epoch>\d+)
282+
| # Calendar date
283+
(?P<year>\d{4}|\*) # year (YYYY or *)
284+
(?:- # start month/day/time block
285+
(?P<month>\d{2}|\*) # month (MM or *)
286+
(?:- # start day/time block
287+
(?P<day>\d{2}|\*) # day (DD or *)
288+
(?:[T ] # date/time separator (T or space)
289+
(?P<hour>\d{2}|\*) # hour (HH or *)
290+
(?:
291+
:(?P<minute>\d{2}|\*) # minute (MM or *)
292+
(?:
293+
:(?P<second>\d{2}(?:\.\d+)?|\*) # second (SS or SS.fff or *)
294+
)?
295+
)?
281296
)?
282-
)?
283-
)?
297+
)?
284298
)?
285299
)
286-
(?P<tz>Z|[+\-]\d\d:\d\d|\[[^\]]+\])? # optional timezone suffix
300+
(?P<tz>Z|[+\-]\d\d:\d\d|\[[^\]]+\])? # optional timezone suffix (Z, ±HH:MM or [Zone])
287301
$
288302
"""
289303

290-
291304
DURATION_RE = re.compile(
292305
r"^D"
293306
r"(?:(?P<years>\d+)Y)?"
@@ -328,15 +341,40 @@ def parse_to_interval(expr: str) -> tuple[datetime, datetime]:
328341
m = re.match(MAIN_RE, expr, re.VERBOSE)
329342
if not m:
330343
raise DatePatternError(f"unrecognised date: {expr!r}")
344+
331345
gd = m.groupdict()
346+
tz = parse_tz(gd["tz"])
347+
# ISO week-date support (YYYY-Www or YYYY-Www-D)
348+
if gd.get("isoweek_year"):
349+
y = int(gd["isoweek_year"])
350+
w = int(gd["isoweek_week"])
351+
d = int(gd.get("isoweek_day") or 1)
352+
# fromisocalendar returns a date
353+
iso_date = date.fromisocalendar(y, w, d)
354+
start = datetime(iso_date.year, iso_date.month, iso_date.day, tzinfo=tz)
355+
if gd.get("isoweek_day"):
356+
# if we have a day, we want to end at the next day
357+
end = start + timedelta(days=1)
358+
else:
359+
# match the whole week
360+
end = start + timedelta(weeks=1)
361+
return start, end
362+
363+
# Ordinal date support (YYYY-DDD)
364+
if gd.get("ordinal_year"):
365+
y = int(gd["ordinal_year"])
366+
doy = int(gd["ordinal_day"])
367+
start = datetime(y, 1, 1, tzinfo=tz) + timedelta(days=doy - 1)
368+
end = start + timedelta(days=1)
369+
return start, end
370+
332371
# handle unix-epoch forms directly
333372
if gd["epoch"]:
334373
epoch = int(gd["epoch"])
335374
start = datetime.fromtimestamp(epoch, tz=timezone.utc)
336375
end = start + timedelta(seconds=1)
337376
return start, end
338377

339-
tz = parse_tz(gd["tz"])
340378
# build the start moment
341379
start = build_datetime_from_groups(gd, tz)
342380
# determine the end moment based on the highest precision present
@@ -365,9 +403,8 @@ def compile_date_pattern(expr: str):
365403
YYYY
366404
YYYY-MM
367405
YYYY-MM-DD
368-
YYYY-MM-DDTHH
369-
YYYY-MM-DDTHH:MM
370-
YYYY-MM-DDTHH:MM:SS
406+
YYYY-MM-DDTHH (with 'T') or YYYY-MM-DD HH:MM (with space)
407+
YYYY-MM-DD HH:MM:SS (RFC-3339 space-separated)
371408
Unix epoch (@123456789)
372409
…with an optional trailing timezone (Z or ±HH:MM or [Region/City]).
373410
Additionally supports wildcards (`*`) in year, month, or day (or any combination), e.g.:

src/borg/testsuite/archiver/match_archives_date_test.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -724,3 +724,99 @@ def test_match_keyword_exact(archivers, request):
724724
assert "k3" in out
725725
assert "k2" not in out
726726
assert "k1" not in out
727+
728+
729+
# ISO week-date and ordinal-date support tests
730+
731+
732+
def test_match_iso_week(archivers, request):
733+
"""
734+
Test matching archives by ISO week number (YYYY-Www).
735+
Week 10 of 2025 runs from 2025-03-03 to 2025-03-09 inclusive.
736+
"""
737+
archiver = request.getfixturevalue(archivers)
738+
cmd(archiver, "repo-create", RK_ENCRYPTION)
739+
WEEK10_ARCHIVES = [
740+
("iso-week-before", "2025-03-02T23:59:59"),
741+
("iso-week-start", "2025-03-03T00:00:00"),
742+
("iso-week-mid", "2025-03-05T12:00:00"),
743+
("iso-week-end", "2025-03-09T23:59:59"),
744+
("iso-week-after", "2025-03-10T00:00:00"),
745+
]
746+
for name, ts in WEEK10_ARCHIVES:
747+
create_src_archive(archiver, name, ts=ts)
748+
749+
out = cmd(archiver, "repo-list", "-v", "--match-archives=date:2025-W10", exit_code=0)
750+
assert "iso-week-before" not in out
751+
assert "iso-week-start" in out
752+
assert "iso-week-mid" in out
753+
assert "iso-week-end" in out
754+
assert "iso-week-after" not in out
755+
756+
757+
def test_match_iso_weekday(archivers, request):
758+
"""
759+
Test matching archives by ISO week and weekday (YYYY-Www-D).
760+
Week 10 Day 3 of 2025 is Wednesday 2025-03-05.
761+
"""
762+
archiver = request.getfixturevalue(archivers)
763+
cmd(archiver, "repo-create", RK_ENCRYPTION)
764+
WEEKDAY_ARCHIVES = [
765+
("iso-wed", "2025-03-05T08:00:00"),
766+
("iso-tue", "2025-03-04T12:00:00"),
767+
("iso-thu", "2025-03-06T18:00:00"),
768+
]
769+
for name, ts in WEEKDAY_ARCHIVES:
770+
create_src_archive(archiver, name, ts=ts)
771+
772+
out = cmd(archiver, "repo-list", "-v", "--match-archives=date:2025-W10-3", exit_code=0)
773+
assert "iso-wed" in out
774+
assert "iso-tue" not in out
775+
assert "iso-thu" not in out
776+
777+
778+
def test_match_ordinal_date(archivers, request):
779+
"""
780+
Test matching archives by ordinal day of year (YYYY-DDD).
781+
Day 032 of 2025 is 2025-02-01.
782+
"""
783+
archiver = request.getfixturevalue(archivers)
784+
cmd(archiver, "repo-create", RK_ENCRYPTION)
785+
ORDINAL_ARCHIVES = [
786+
("ord-jan31", "2025-01-31T23:59:59"), # day 031
787+
("ord-feb1", "2025-02-01T00:00:00"), # day 032
788+
("ord-feb1-end", "2025-02-01T23:59:59"),
789+
("ord-feb2", "2025-02-02T00:00:00"), # day 033
790+
]
791+
for name, ts in ORDINAL_ARCHIVES:
792+
create_src_archive(archiver, name, ts=ts)
793+
794+
out = cmd(archiver, "repo-list", "-v", "--match-archives=date:2025-032", exit_code=0)
795+
assert "ord-jan31" not in out
796+
assert "ord-feb1" in out
797+
assert "ord-feb1-end" in out
798+
assert "ord-feb2" not in out
799+
800+
801+
def test_match_rfc3339(archivers, request):
802+
"""
803+
Test matching archives by RFC 3339 date format (use ' ' as delimiter rather than 'T').
804+
"""
805+
archiver = request.getfixturevalue(archivers)
806+
cmd(archiver, "repo-create", RK_ENCRYPTION)
807+
RFC_ARCHIVES = [
808+
("rfc-start", "2025-01-01T00:00:00Z"),
809+
("rfc-mid", "2025-01-01T12:00:00Z"),
810+
("rfc-max", "2025-01-01T23:59:59Z"),
811+
("rfc-after", "2025-01-02T00:00:00Z"),
812+
]
813+
for name, ts in RFC_ARCHIVES:
814+
create_src_archive(archiver, name, ts=ts)
815+
816+
out = cmd(
817+
archiver, "repo-list", "-v", "--match-archives=date:2025-01-01 00:00:00Z/2025-01-02 00:00:00Z", exit_code=0
818+
)
819+
assert "rfc-start" in out
820+
assert "rfc-mid" in out
821+
assert "rfc-max" in out
822+
assert "rfc-after" not in out

0 commit comments

Comments
 (0)