This commit is contained in:
Timothy Jaeryang Baek committed 2026-07-14 03:46:37 -04:00
1 parent 2e8d92c7b1
commit b3aead23da
1 file changed
+23 -8
+23 -8
View File
@@ -66,7 +66,7 @@ def _resolve_tz(tz: str = None) -> Optional[ZoneInfo]:
return None return None
def _parse_rule(s: str): def _parse_rule(s: str, now: Optional[datetime] = None):
"""Parse RRULE with clock-aligned DTSTART for sub-daily frequencies. """Parse RRULE with clock-aligned DTSTART for sub-daily frequencies.
MINUTELY/HOURLY rules use a fixed epoch DTSTART (2000-01-01 00:00) MINUTELY/HOURLY rules use a fixed epoch DTSTART (2000-01-01 00:00)
@@ -78,6 +78,20 @@ def _parse_rule(s: str):
if freq in ('MINUTELY', 'HOURLY'): if freq in ('MINUTELY', 'HOURLY'):
epoch = datetime(2000, 1, 1, 0, 0, 0) epoch = datetime(2000, 1, 1, 0, 0, 0)
if (
now is not None
and s.startswith('RRULE:')
and '\n' not in s
and '\r' not in s
and set(parts) <= {'FREQ', 'INTERVAL', 'BYMINUTE', 'BYSECOND'}
):
try:
interval = int(parts.get('INTERVAL', '1'))
if interval > 0:
step = timedelta(minutes=interval) if freq == 'MINUTELY' else timedelta(hours=interval)
return rrulestr(s, dtstart=epoch + ((now - epoch) // step) * step, ignoretz=True)
except (TypeError, ValueError):
pass
return rrulestr(s, dtstart=epoch, ignoretz=True) return rrulestr(s, dtstart=epoch, ignoretz=True)
return rrulestr(s, ignoretz=True) return rrulestr(s, ignoretz=True)
@@ -89,12 +103,12 @@ def validate_rrule(s: str, tz: str = None) -> None:
clock so that near-future schedules are not incorrectly rejected clock so that near-future schedules are not incorrectly rejected
on servers whose system clock is ahead (e.g. UTC vs US timezones). on servers whose system clock is ahead (e.g. UTC vs US timezones).
""" """
try:
rule = _parse_rule(s)
except Exception as e:
raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e))
zi = _resolve_tz(tz) zi = _resolve_tz(tz)
now = datetime.now(zi).replace(tzinfo=None) if zi else datetime.now() now = datetime.now(zi).replace(tzinfo=None) if zi else datetime.now()
try:
rule = _parse_rule(s, now)
except Exception as e:
raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e))
if rule.after(now) is None: if rule.after(now) is None:
raise ValueError(ERROR_MESSAGES.AUTOMATION_NO_FUTURE_RUNS) raise ValueError(ERROR_MESSAGES.AUTOMATION_NO_FUTURE_RUNS)
@@ -103,7 +117,8 @@ def next_run_ns(s: str, tz: str = None) -> Optional[int]:
"""Next occurrence as epoch nanoseconds, respecting user timezone.""" """Next occurrence as epoch nanoseconds, respecting user timezone."""
zi = _resolve_tz(tz) zi = _resolve_tz(tz)
now = datetime.now(zi) if zi else datetime.now() now = datetime.now(zi) if zi else datetime.now()
dt = _parse_rule(s).after(now.replace(tzinfo=None)) now_naive = now.replace(tzinfo=None)
dt = _parse_rule(s, now_naive).after(now_naive)
if dt is None: if dt is None:
return None return None
if zi: if zi:
@@ -118,9 +133,9 @@ def next_n_runs_ns(s: str, n: int = 5, tz: str = None) -> list[int]:
preview matches the user's local clock (same as next_run_ns). preview matches the user's local clock (same as next_run_ns).
""" """
zi = _resolve_tz(tz) zi = _resolve_tz(tz)
rule = _parse_rule(s)
result = [] result = []
now = datetime.now(zi).replace(tzinfo=None) if zi else datetime.now() now = datetime.now(zi).replace(tzinfo=None) if zi else datetime.now()
rule = _parse_rule(s, now)
dt = now dt = now
for _ in range(n): for _ in range(n):
dt = rule.after(dt) dt = rule.after(dt)
@@ -142,8 +157,8 @@ def rrule_interval_seconds(s: str) -> Optional[int]:
""" """
if 'COUNT=1' in s: if 'COUNT=1' in s:
return None return None
rule = _parse_rule(s)
now = datetime.now() now = datetime.now()
rule = _parse_rule(s, now)
first = rule.after(now) first = rule.after(now)
if first is None: if first is None:
return None return None