|
|
@ -0,0 +1,291 @@ |
|
|
|
import calendar |
|
|
|
from datetime import datetime, timedelta as td, time |
|
|
|
from enum import IntEnum |
|
|
|
|
|
|
|
import pytz |
|
|
|
|
|
|
|
RANGES = [ |
|
|
|
frozenset(range(0, 60)), |
|
|
|
frozenset(range(0, 24)), |
|
|
|
frozenset(range(1, 32)), |
|
|
|
frozenset(range(1, 13)), |
|
|
|
frozenset(range(0, 8)), |
|
|
|
frozenset(range(0, 60)), |
|
|
|
] |
|
|
|
|
|
|
|
SYMBOLIC_DAYS = "SUN MON TUE WED THU FRI SAT".split() |
|
|
|
SYMBOLIC_MONTHS = "JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC".split() |
|
|
|
DAYS_IN_MONTH = [None, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] |
|
|
|
|
|
|
|
|
|
|
|
class CronSimError(Exception): |
|
|
|
pass |
|
|
|
|
|
|
|
|
|
|
|
def _int(value): |
|
|
|
if not value.isdigit(): |
|
|
|
raise CronSimError("Bad value: %s" % value) |
|
|
|
|
|
|
|
return int(value) |
|
|
|
|
|
|
|
|
|
|
|
class Field(IntEnum): |
|
|
|
MINUTE = 0 |
|
|
|
HOUR = 1 |
|
|
|
DAY = 2 |
|
|
|
MONTH = 3 |
|
|
|
DOW = 4 |
|
|
|
|
|
|
|
def int(self, s): |
|
|
|
if self == Field.MONTH and s.upper() in SYMBOLIC_MONTHS: |
|
|
|
return SYMBOLIC_MONTHS.index(s.upper()) + 1 |
|
|
|
|
|
|
|
if self == Field.DOW and s.upper() in SYMBOLIC_DAYS: |
|
|
|
return SYMBOLIC_DAYS.index(s.upper()) |
|
|
|
|
|
|
|
v = _int(s) |
|
|
|
if v not in RANGES[self]: |
|
|
|
raise CronSimError("Bad value: %s" % s) |
|
|
|
|
|
|
|
return v |
|
|
|
|
|
|
|
def parse(self, s): |
|
|
|
if s == "*": |
|
|
|
return RANGES[self] |
|
|
|
|
|
|
|
if "," in s: |
|
|
|
result = set() |
|
|
|
for term in s.split(","): |
|
|
|
result.update(self.parse(term)) |
|
|
|
return result |
|
|
|
|
|
|
|
if "#" in s and self == Field.DOW: |
|
|
|
term, nth = s.split("#", maxsplit=1) |
|
|
|
nth = _int(nth) |
|
|
|
if nth < 1 or nth > 5: |
|
|
|
raise CronSimError("Bad value: %s" % s) |
|
|
|
|
|
|
|
spec = (self.int(term), nth) |
|
|
|
return {spec} |
|
|
|
|
|
|
|
if "/" in s: |
|
|
|
term, step = s.split("/", maxsplit=1) |
|
|
|
step = _int(step) |
|
|
|
if step == 0: |
|
|
|
raise CronSimError("Step cannot be zero") |
|
|
|
|
|
|
|
items = sorted(self.parse(term)) |
|
|
|
if items == [CronSim.LAST]: |
|
|
|
return items |
|
|
|
|
|
|
|
if len(items) == 1: |
|
|
|
start = items[0] |
|
|
|
end = max(RANGES[self]) |
|
|
|
items = range(start, end + 1) |
|
|
|
return set(items[::step]) |
|
|
|
|
|
|
|
if "-" in s: |
|
|
|
start, end = s.split("-", maxsplit=1) |
|
|
|
start = self.int(start) |
|
|
|
end = self.int(end) |
|
|
|
|
|
|
|
if end < start: |
|
|
|
raise CronSimError("Range end cannot be smaller than start") |
|
|
|
|
|
|
|
return set(range(start, end + 1)) |
|
|
|
|
|
|
|
if self == Field.DAY and s in ("L", "l"): |
|
|
|
return {CronSim.LAST} |
|
|
|
|
|
|
|
return {self.int(s)} |
|
|
|
|
|
|
|
|
|
|
|
class NoTz(object): |
|
|
|
def localize(self, dt, is_dst=None): |
|
|
|
return dt |
|
|
|
|
|
|
|
def normalize(self, dt): |
|
|
|
return dt |
|
|
|
|
|
|
|
|
|
|
|
class CronSim(object): |
|
|
|
LAST = -1000 |
|
|
|
|
|
|
|
def __init__(self, expr, dt): |
|
|
|
self.tz = dt.tzinfo or NoTz() |
|
|
|
self.fixup_tz = None |
|
|
|
self.dt = dt.replace(second=0, microsecond=0) |
|
|
|
|
|
|
|
parts = expr.split() |
|
|
|
if len(parts) != 5: |
|
|
|
raise CronSimError("Wrong number of fields") |
|
|
|
|
|
|
|
self.minutes = Field.MINUTE.parse(parts[0]) |
|
|
|
self.hours = Field.HOUR.parse(parts[1]) |
|
|
|
self.days = Field.DAY.parse(parts[2]) |
|
|
|
self.months = Field.MONTH.parse(parts[3]) |
|
|
|
self.weekdays = Field.DOW.parse(parts[4]) |
|
|
|
|
|
|
|
# If day is unrestricted but dow is restricted then match only with dow: |
|
|
|
if self.days == RANGES[Field.DAY] and self.weekdays != RANGES[Field.DOW]: |
|
|
|
self.days = set() |
|
|
|
|
|
|
|
# If dow is unrestricted but day is restricted then match only with day: |
|
|
|
if self.weekdays == RANGES[Field.DOW] and self.days != RANGES[Field.DAY]: |
|
|
|
self.weekdays = set() |
|
|
|
|
|
|
|
if len(self.days) and min(self.days) > 29: |
|
|
|
# Check if we have any month with enough days |
|
|
|
if min(self.days) > max(DAYS_IN_MONTH[month] for month in self.months): |
|
|
|
raise CronSimError("Bad day-of-month") |
|
|
|
|
|
|
|
if self.dt.tzinfo in (None, pytz.utc): |
|
|
|
# No special DST handling for naive datetimes or UTC |
|
|
|
pass |
|
|
|
else: |
|
|
|
# Special handling for jobs that run at specific time, or with |
|
|
|
# a granularity greater than one hour (to mimic Debian cron). |
|
|
|
# Convert to naive datetime, will convert back to the tz-aware |
|
|
|
# in __next__, right before returning the value. |
|
|
|
if not parts[0].startswith("*") and not parts[1].startswith("*"): |
|
|
|
self.fixup_tz, self.tz = self.tz, NoTz() |
|
|
|
self.dt = self.dt.replace(tzinfo=None) |
|
|
|
|
|
|
|
def tick(self, minutes=1): |
|
|
|
""" Roll self.dt forward by 1 or more minutes and fix timezone. """ |
|
|
|
|
|
|
|
self.dt = self.tz.normalize(self.dt + td(minutes=minutes)) |
|
|
|
|
|
|
|
def advance_minute(self): |
|
|
|
"""Roll forward the minute component until it satisfies the constraints. |
|
|
|
|
|
|
|
Return False if the minute meets contraints without modification. |
|
|
|
Return True if self.dt was rolled forward. |
|
|
|
|
|
|
|
""" |
|
|
|
|
|
|
|
if self.dt.minute in self.minutes: |
|
|
|
return False |
|
|
|
|
|
|
|
if len(self.minutes) == 1: |
|
|
|
# An optimization for the special case where self.minutes has exactly |
|
|
|
# one element. Instead of advancing one minute per iteration, |
|
|
|
# make a jump from the current minute to the target minute. |
|
|
|
delta = (next(iter(self.minutes)) - self.dt.minute) % 60 |
|
|
|
self.tick(minutes=delta) |
|
|
|
|
|
|
|
while self.dt.minute not in self.minutes: |
|
|
|
self.tick() |
|
|
|
if self.dt.minute == 0: |
|
|
|
# Break out to re-check month, day and hour |
|
|
|
break |
|
|
|
|
|
|
|
return True |
|
|
|
|
|
|
|
def advance_hour(self): |
|
|
|
"""Roll forward the hour component until it satisfies the constraints. |
|
|
|
|
|
|
|
Return False if the hour meets contraints without modification. |
|
|
|
Return True if self.dt was rolled forward. |
|
|
|
|
|
|
|
""" |
|
|
|
|
|
|
|
if self.dt.hour in self.hours: |
|
|
|
return False |
|
|
|
|
|
|
|
self.dt = self.dt.replace(minute=0) |
|
|
|
while self.dt.hour not in self.hours: |
|
|
|
self.tick(minutes=60) |
|
|
|
if self.dt.hour == 0: |
|
|
|
# break out to re-check month and day |
|
|
|
break |
|
|
|
|
|
|
|
return True |
|
|
|
|
|
|
|
def match_day(self, d): |
|
|
|
# Does the day of the month match? |
|
|
|
if d.day in self.days: |
|
|
|
return True |
|
|
|
|
|
|
|
if CronSim.LAST in self.days: |
|
|
|
_, last = calendar.monthrange(d.year, d.month) |
|
|
|
if d.day == last: |
|
|
|
return True |
|
|
|
|
|
|
|
# Does the day of the week match? |
|
|
|
dow = d.weekday() + 1 |
|
|
|
if dow in self.weekdays or dow % 7 in self.weekdays: |
|
|
|
return True |
|
|
|
|
|
|
|
idx = (d.day + 6) // 7 |
|
|
|
if (dow, idx) in self.weekdays or (dow % 7, idx) in self.weekdays: |
|
|
|
return True |
|
|
|
|
|
|
|
def advance_day(self): |
|
|
|
"""Roll forward the day component until it satisfies the constraints. |
|
|
|
|
|
|
|
This method advances the date until it matches either the |
|
|
|
day-of-month, or the day-of-week constraint. |
|
|
|
|
|
|
|
Return False if the day meets contraints without modification. |
|
|
|
Return True if self.dt was rolled forward. |
|
|
|
|
|
|
|
""" |
|
|
|
|
|
|
|
needle = self.dt.date() |
|
|
|
if self.match_day(needle): |
|
|
|
return False |
|
|
|
|
|
|
|
while not self.match_day(needle): |
|
|
|
needle += td(days=1) |
|
|
|
if needle.day == 1: |
|
|
|
# We're in a different month now, break out to re-check month |
|
|
|
# This significantly speeds up the "0 0 * 2 MON#5" case |
|
|
|
break |
|
|
|
|
|
|
|
self.dt = self.tz.localize(datetime.combine(needle, time())) |
|
|
|
return True |
|
|
|
|
|
|
|
def advance_month(self): |
|
|
|
"""Roll forward the month component until it satisfies the constraints. """ |
|
|
|
|
|
|
|
if self.dt.month in self.months: |
|
|
|
return |
|
|
|
|
|
|
|
needle = self.dt.date() |
|
|
|
while needle.month not in self.months: |
|
|
|
needle = (needle.replace(day=1) + td(days=32)).replace(day=1) |
|
|
|
|
|
|
|
self.dt = self.tz.localize(datetime.combine(needle, time())) |
|
|
|
|
|
|
|
def __iter__(self): |
|
|
|
return self |
|
|
|
|
|
|
|
def __next__(self): |
|
|
|
self.tick() |
|
|
|
|
|
|
|
while True: |
|
|
|
self.advance_month() |
|
|
|
|
|
|
|
if self.advance_day(): |
|
|
|
continue |
|
|
|
|
|
|
|
if self.advance_hour(): |
|
|
|
continue |
|
|
|
|
|
|
|
if self.advance_minute(): |
|
|
|
continue |
|
|
|
|
|
|
|
# If all constraints are satisfied then we have the result. |
|
|
|
# The last step is to see if self.fixup_dst is set. If it is, |
|
|
|
# localize self.dt and handle conflicts. |
|
|
|
if self.fixup_tz: |
|
|
|
while True: |
|
|
|
try: |
|
|
|
return self.fixup_tz.localize(self.dt, is_dst=None) |
|
|
|
except pytz.AmbiguousTimeError: |
|
|
|
return self.fixup_tz.localize(self.dt, is_dst=True) |
|
|
|
except pytz.NonExistentTimeError: |
|
|
|
self.dt += td(minutes=1) |
|
|
|
|
|
|
|
return self.dt |