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

  1. import calendar
  2. from datetime import datetime, timedelta as td, time
  3. from enum import IntEnum
  4. import pytz
  5. RANGES = [
  6. frozenset(range(0, 60)),
  7. frozenset(range(0, 24)),
  8. frozenset(range(1, 32)),
  9. frozenset(range(1, 13)),
  10. frozenset(range(0, 8)),
  11. frozenset(range(0, 60)),
  12. ]
  13. SYMBOLIC_DAYS = "SUN MON TUE WED THU FRI SAT".split()
  14. SYMBOLIC_MONTHS = "JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC".split()
  15. DAYS_IN_MONTH = [None, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
  16. class CronSimError(Exception):
  17. pass
  18. def _int(value):
  19. if not value.isdigit():
  20. raise CronSimError("Bad value: %s" % value)
  21. return int(value)
  22. class Field(IntEnum):
  23. MINUTE = 0
  24. HOUR = 1
  25. DAY = 2
  26. MONTH = 3
  27. DOW = 4
  28. def int(self, s):
  29. if self == Field.MONTH and s.upper() in SYMBOLIC_MONTHS:
  30. return SYMBOLIC_MONTHS.index(s.upper()) + 1
  31. if self == Field.DOW and s.upper() in SYMBOLIC_DAYS:
  32. return SYMBOLIC_DAYS.index(s.upper())
  33. v = _int(s)
  34. if v not in RANGES[self]:
  35. raise CronSimError("Bad value: %s" % s)
  36. return v
  37. def parse(self, s):
  38. if s == "*":
  39. return RANGES[self]
  40. if "," in s:
  41. result = set()
  42. for term in s.split(","):
  43. result.update(self.parse(term))
  44. return result
  45. if "#" in s and self == Field.DOW:
  46. term, nth = s.split("#", maxsplit=1)
  47. nth = _int(nth)
  48. if nth < 1 or nth > 5:
  49. raise CronSimError("Bad value: %s" % s)
  50. spec = (self.int(term), nth)
  51. return {spec}
  52. if "/" in s:
  53. term, step = s.split("/", maxsplit=1)
  54. step = _int(step)
  55. if step == 0:
  56. raise CronSimError("Step cannot be zero")
  57. items = sorted(self.parse(term))
  58. if items == [CronSim.LAST]:
  59. return items
  60. if len(items) == 1:
  61. start = items[0]
  62. end = max(RANGES[self])
  63. items = range(start, end + 1)
  64. return set(items[::step])
  65. if "-" in s:
  66. start, end = s.split("-", maxsplit=1)
  67. start = self.int(start)
  68. end = self.int(end)
  69. if end < start:
  70. raise CronSimError("Range end cannot be smaller than start")
  71. return set(range(start, end + 1))
  72. if self == Field.DAY and s in ("L", "l"):
  73. return {CronSim.LAST}
  74. return {self.int(s)}
  75. class NoTz(object):
  76. def localize(self, dt, is_dst=None):
  77. return dt
  78. def normalize(self, dt):
  79. return dt
  80. class CronSim(object):
  81. LAST = -1000
  82. def __init__(self, expr, dt):
  83. self.tz = dt.tzinfo or NoTz()
  84. self.fixup_tz = None
  85. self.dt = dt.replace(second=0, microsecond=0)
  86. parts = expr.split()
  87. if len(parts) != 5:
  88. raise CronSimError("Wrong number of fields")
  89. self.minutes = Field.MINUTE.parse(parts[0])
  90. self.hours = Field.HOUR.parse(parts[1])
  91. self.days = Field.DAY.parse(parts[2])
  92. self.months = Field.MONTH.parse(parts[3])
  93. self.weekdays = Field.DOW.parse(parts[4])
  94. # If day is unrestricted but dow is restricted then match only with dow:
  95. if self.days == RANGES[Field.DAY] and self.weekdays != RANGES[Field.DOW]:
  96. self.days = set()
  97. # If dow is unrestricted but day is restricted then match only with day:
  98. if self.weekdays == RANGES[Field.DOW] and self.days != RANGES[Field.DAY]:
  99. self.weekdays = set()
  100. if len(self.days) and min(self.days) > 29:
  101. # Check if we have any month with enough days
  102. if min(self.days) > max(DAYS_IN_MONTH[month] for month in self.months):
  103. raise CronSimError("Bad day-of-month")
  104. if self.dt.tzinfo in (None, pytz.utc):
  105. # No special DST handling for naive datetimes or UTC
  106. pass
  107. else:
  108. # Special handling for jobs that run at specific time, or with
  109. # a granularity greater than one hour (to mimic Debian cron).
  110. # Convert to naive datetime, will convert back to the tz-aware
  111. # in __next__, right before returning the value.
  112. if not parts[0].startswith("*") and not parts[1].startswith("*"):
  113. self.fixup_tz, self.tz = self.tz, NoTz()
  114. self.dt = self.dt.replace(tzinfo=None)
  115. def tick(self, minutes=1):
  116. """ Roll self.dt forward by 1 or more minutes and fix timezone. """
  117. self.dt = self.tz.normalize(self.dt + td(minutes=minutes))
  118. def advance_minute(self):
  119. """Roll forward the minute component until it satisfies the constraints.
  120. Return False if the minute meets contraints without modification.
  121. Return True if self.dt was rolled forward.
  122. """
  123. if self.dt.minute in self.minutes:
  124. return False
  125. if len(self.minutes) == 1:
  126. # An optimization for the special case where self.minutes has exactly
  127. # one element. Instead of advancing one minute per iteration,
  128. # make a jump from the current minute to the target minute.
  129. delta = (next(iter(self.minutes)) - self.dt.minute) % 60
  130. self.tick(minutes=delta)
  131. while self.dt.minute not in self.minutes:
  132. self.tick()
  133. if self.dt.minute == 0:
  134. # Break out to re-check month, day and hour
  135. break
  136. return True
  137. def advance_hour(self):
  138. """Roll forward the hour component until it satisfies the constraints.
  139. Return False if the hour meets contraints without modification.
  140. Return True if self.dt was rolled forward.
  141. """
  142. if self.dt.hour in self.hours:
  143. return False
  144. self.dt = self.dt.replace(minute=0)
  145. while self.dt.hour not in self.hours:
  146. self.tick(minutes=60)
  147. if self.dt.hour == 0:
  148. # break out to re-check month and day
  149. break
  150. return True
  151. def match_day(self, d):
  152. # Does the day of the month match?
  153. if d.day in self.days:
  154. return True
  155. if CronSim.LAST in self.days:
  156. _, last = calendar.monthrange(d.year, d.month)
  157. if d.day == last:
  158. return True
  159. # Does the day of the week match?
  160. dow = d.weekday() + 1
  161. if dow in self.weekdays or dow % 7 in self.weekdays:
  162. return True
  163. idx = (d.day + 6) // 7
  164. if (dow, idx) in self.weekdays or (dow % 7, idx) in self.weekdays:
  165. return True
  166. def advance_day(self):
  167. """Roll forward the day component until it satisfies the constraints.
  168. This method advances the date until it matches either the
  169. day-of-month, or the day-of-week constraint.
  170. Return False if the day meets contraints without modification.
  171. Return True if self.dt was rolled forward.
  172. """
  173. needle = self.dt.date()
  174. if self.match_day(needle):
  175. return False
  176. while not self.match_day(needle):
  177. needle += td(days=1)
  178. if needle.day == 1:
  179. # We're in a different month now, break out to re-check month
  180. # This significantly speeds up the "0 0 * 2 MON#5" case
  181. break
  182. self.dt = self.tz.localize(datetime.combine(needle, time()))
  183. return True
  184. def advance_month(self):
  185. """Roll forward the month component until it satisfies the constraints. """
  186. if self.dt.month in self.months:
  187. return
  188. needle = self.dt.date()
  189. while needle.month not in self.months:
  190. needle = (needle.replace(day=1) + td(days=32)).replace(day=1)
  191. self.dt = self.tz.localize(datetime.combine(needle, time()))
  192. def __iter__(self):
  193. return self
  194. def __next__(self):
  195. self.tick()
  196. while True:
  197. self.advance_month()
  198. if self.advance_day():
  199. continue
  200. if self.advance_hour():
  201. continue
  202. if self.advance_minute():
  203. continue
  204. # If all constraints are satisfied then we have the result.
  205. # The last step is to see if self.fixup_dst is set. If it is,
  206. # localize self.dt and handle conflicts.
  207. if self.fixup_tz:
  208. while True:
  209. try:
  210. return self.fixup_tz.localize(self.dt, is_dst=None)
  211. except pytz.AmbiguousTimeError:
  212. return self.fixup_tz.localize(self.dt, is_dst=True)
  213. except pytz.NonExistentTimeError:
  214. self.dt += td(minutes=1)
  215. return self.dt