|
|
- 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
|