@@ -185,3 +185,100 @@ def isoformat(self):
185185def 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+ # 3) 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+ # 4) 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 )
0 commit comments