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
|