You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

291 lines
8.7 KiB

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