Development of an internal social media platform with personalised dashboards for students
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.

croniter.py 20KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
  1. #!/usr/bin/env python
  2. # -*- coding: utf-8 -*-
  3. from __future__ import absolute_import, print_function
  4. import re
  5. from time import time
  6. import datetime
  7. from dateutil.relativedelta import relativedelta
  8. from dateutil.tz import tzutc
  9. import calendar
  10. step_search_re = re.compile(r'^([^-]+)-([^-/]+)(/(.*))?$')
  11. search_re = re.compile(r'^([^-]+)-([^-/]+)(/(.*))?$')
  12. only_int_re = re.compile(r'^\d+$')
  13. any_int_re = re.compile(r'^\d+')
  14. star_or_int_re = re.compile(r'^(\d+|\*)$')
  15. VALID_LEN_EXPRESSION = [5, 6]
  16. class CroniterError(ValueError):
  17. pass
  18. class CroniterBadCronError(CroniterError):
  19. pass
  20. class CroniterBadDateError(CroniterError):
  21. pass
  22. class CroniterNotAlphaError(CroniterError):
  23. pass
  24. class croniter(object):
  25. MONTHS_IN_YEAR = 12
  26. RANGES = (
  27. (0, 59),
  28. (0, 23),
  29. (1, 31),
  30. (1, 12),
  31. (0, 6),
  32. (0, 59)
  33. )
  34. DAYS = (
  35. 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31
  36. )
  37. ALPHACONV = (
  38. {},
  39. {},
  40. {"l": "l"},
  41. {'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'may': 5, 'jun': 6,
  42. 'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12},
  43. {'sun': 0, 'mon': 1, 'tue': 2, 'wed': 3, 'thu': 4, 'fri': 5, 'sat': 6},
  44. {}
  45. )
  46. LOWMAP = (
  47. {},
  48. {},
  49. {0: 1},
  50. {0: 1},
  51. {7: 0},
  52. {},
  53. )
  54. bad_length = 'Exactly 5 or 6 columns has to be specified for iterator' \
  55. 'expression.'
  56. def __init__(self, expr_format, start_time=None, ret_type=float,
  57. day_or=True):
  58. self._ret_type = ret_type
  59. self._day_or = day_or
  60. if start_time is None:
  61. start_time = time()
  62. self.tzinfo = None
  63. if isinstance(start_time, datetime.datetime):
  64. self.tzinfo = start_time.tzinfo
  65. # milliseconds/microseconds rounds
  66. if start_time.microsecond:
  67. start_time = start_time + relativedelta(seconds=1)
  68. start_time = self._datetime_to_timestamp(start_time)
  69. self.start_time = start_time
  70. self.dst_start_time = start_time
  71. self.cur = start_time
  72. self.expanded, self.nth_weekday_of_month = self.expand(expr_format)
  73. @classmethod
  74. def _alphaconv(cls, index, key, expressions):
  75. try:
  76. return cls.ALPHACONV[index][key.lower()]
  77. except KeyError:
  78. raise CroniterNotAlphaError(
  79. "[{0}] is not acceptable".format(" ".join(expressions)))
  80. def get_next(self, ret_type=None):
  81. return self._get_next(ret_type or self._ret_type, is_prev=False)
  82. def get_prev(self, ret_type=None):
  83. return self._get_next(ret_type or self._ret_type, is_prev=True)
  84. def get_current(self, ret_type=None):
  85. ret_type = ret_type or self._ret_type
  86. if issubclass(ret_type, datetime.datetime):
  87. return self._timestamp_to_datetime(self.cur)
  88. return self.cur
  89. @classmethod
  90. def _datetime_to_timestamp(cls, d):
  91. """
  92. Converts a `datetime` object `d` into a UNIX timestamp.
  93. """
  94. if d.tzinfo is not None:
  95. d = d.replace(tzinfo=None) - d.utcoffset()
  96. return cls._timedelta_to_seconds(d - datetime.datetime(1970, 1, 1))
  97. def _timestamp_to_datetime(self, timestamp):
  98. """
  99. Converts a UNIX timestamp `timestamp` into a `datetime` object.
  100. """
  101. result = datetime.datetime.utcfromtimestamp(timestamp)
  102. if self.tzinfo:
  103. result = result.replace(tzinfo=tzutc()).astimezone(self.tzinfo)
  104. return result
  105. @classmethod
  106. def _timedelta_to_seconds(cls, td):
  107. """
  108. Converts a 'datetime.timedelta' object `td` into seconds contained in
  109. the duration.
  110. Note: We cannot use `timedelta.total_seconds()` because this is not
  111. supported by Python 2.6.
  112. """
  113. return (td.microseconds + (td.seconds + td.days * 24 * 3600) * 10**6) \
  114. / 10**6
  115. # iterator protocol, to enable direct use of croniter
  116. # objects in a loop, like "for dt in croniter('5 0 * * *'): ..."
  117. # or for combining multiple croniters into single
  118. # dates feed using 'itertools' module
  119. def __iter__(self):
  120. return self
  121. __next__ = next = get_next
  122. def all_next(self, ret_type=None):
  123. '''Generator of all consecutive dates. Can be used instead of
  124. implicit call to __iter__, whenever non-default
  125. 'ret_type' has to be specified.
  126. '''
  127. while True:
  128. yield self._get_next(ret_type or self._ret_type, is_prev=False)
  129. def all_prev(self, ret_type=None):
  130. '''Generator of all previous dates.'''
  131. while True:
  132. yield self._get_next(ret_type or self._ret_type, is_prev=True)
  133. iter = all_next # alias, you can call .iter() instead of .all_next()
  134. def _get_next(self, ret_type=None, is_prev=False):
  135. expanded = self.expanded[:]
  136. nth_weekday_of_month = self.nth_weekday_of_month.copy()
  137. ret_type = ret_type or self._ret_type
  138. if not issubclass(ret_type, (float, datetime.datetime)):
  139. raise TypeError("Invalid ret_type, only 'float' or 'datetime' "
  140. "is acceptable.")
  141. # exception to support day of month and day of week as defined in cron
  142. if (expanded[2][0] != '*' and expanded[4][0] != '*') and self._day_or:
  143. bak = expanded[4]
  144. expanded[4] = ['*']
  145. t1 = self._calc(self.cur, expanded, nth_weekday_of_month, is_prev)
  146. expanded[4] = bak
  147. expanded[2] = ['*']
  148. t2 = self._calc(self.cur, expanded, nth_weekday_of_month, is_prev)
  149. if not is_prev:
  150. result = t1 if t1 < t2 else t2
  151. else:
  152. result = t1 if t1 > t2 else t2
  153. else:
  154. result = self._calc(self.cur, expanded,
  155. nth_weekday_of_month, is_prev)
  156. # DST Handling for cron job spanning accross days
  157. dtstarttime = self._timestamp_to_datetime(self.dst_start_time)
  158. dtstarttime_utcoffset = (
  159. dtstarttime.utcoffset() or datetime.timedelta(0))
  160. dtresult = self._timestamp_to_datetime(result)
  161. lag = lag_hours = 0
  162. # do we trigger DST on next crontab (handle backward changes)
  163. dtresult_utcoffset = dtstarttime_utcoffset
  164. if dtresult and self.tzinfo:
  165. dtresult_utcoffset = dtresult.utcoffset()
  166. lag_hours = (
  167. self._timedelta_to_seconds(dtresult - dtstarttime) / (60*60)
  168. )
  169. lag = self._timedelta_to_seconds(
  170. dtresult_utcoffset - dtstarttime_utcoffset
  171. )
  172. hours_before_midnight = 24 - dtstarttime.hour
  173. if dtresult_utcoffset != dtstarttime_utcoffset:
  174. if ((lag > 0 and lag_hours >= hours_before_midnight)
  175. or (lag < 0 and
  176. ((3600*lag_hours+abs(lag)) >= hours_before_midnight*3600))
  177. ):
  178. dtresult = dtresult - datetime.timedelta(seconds=lag)
  179. result = self._datetime_to_timestamp(dtresult)
  180. self.dst_start_time = result
  181. self.cur = result
  182. if issubclass(ret_type, datetime.datetime):
  183. result = dtresult
  184. return result
  185. def _calc(self, now, expanded, nth_weekday_of_month, is_prev):
  186. if is_prev:
  187. nearest_diff_method = self._get_prev_nearest_diff
  188. sign = -1
  189. offset = (len(expanded) == 6 or now % 60 > 0) and 1 or 60
  190. else:
  191. nearest_diff_method = self._get_next_nearest_diff
  192. sign = 1
  193. offset = (len(expanded) == 6) and 1 or 60
  194. dst = now = self._timestamp_to_datetime(now + sign * offset)
  195. month, year = dst.month, dst.year
  196. current_year = now.year
  197. DAYS = self.DAYS
  198. def proc_month(d):
  199. if expanded[3][0] != '*':
  200. diff_month = nearest_diff_method(
  201. d.month, expanded[3], self.MONTHS_IN_YEAR)
  202. days = DAYS[month - 1]
  203. if month == 2 and self.is_leap(year) is True:
  204. days += 1
  205. reset_day = 1
  206. if diff_month is not None and diff_month != 0:
  207. if is_prev:
  208. d += relativedelta(months=diff_month)
  209. reset_day = DAYS[d.month - 1]
  210. d += relativedelta(
  211. day=reset_day, hour=23, minute=59, second=59)
  212. else:
  213. d += relativedelta(months=diff_month, day=reset_day,
  214. hour=0, minute=0, second=0)
  215. return True, d
  216. return False, d
  217. def proc_day_of_month(d):
  218. if expanded[2][0] != '*':
  219. days = DAYS[month - 1]
  220. if month == 2 and self.is_leap(year) is True:
  221. days += 1
  222. if 'l' in expanded[2] and days == d.day:
  223. return False, d
  224. if is_prev:
  225. days_in_prev_month = DAYS[
  226. (month - 2) % self.MONTHS_IN_YEAR]
  227. diff_day = nearest_diff_method(
  228. d.day, expanded[2], days_in_prev_month)
  229. else:
  230. diff_day = nearest_diff_method(d.day, expanded[2], days)
  231. if diff_day is not None and diff_day != 0:
  232. if is_prev:
  233. d += relativedelta(
  234. days=diff_day, hour=23, minute=59, second=59)
  235. else:
  236. d += relativedelta(
  237. days=diff_day, hour=0, minute=0, second=0)
  238. return True, d
  239. return False, d
  240. def proc_day_of_week(d):
  241. if expanded[4][0] != '*':
  242. diff_day_of_week = nearest_diff_method(
  243. d.isoweekday() % 7, expanded[4], 7)
  244. if diff_day_of_week is not None and diff_day_of_week != 0:
  245. if is_prev:
  246. d += relativedelta(days=diff_day_of_week,
  247. hour=23, minute=59, second=59)
  248. else:
  249. d += relativedelta(days=diff_day_of_week,
  250. hour=0, minute=0, second=0)
  251. return True, d
  252. return False, d
  253. def proc_day_of_week_nth(d):
  254. if '*' in nth_weekday_of_month:
  255. s = nth_weekday_of_month['*']
  256. for i in range(0, 7):
  257. if i in nth_weekday_of_month:
  258. nth_weekday_of_month[i].update(s)
  259. else:
  260. nth_weekday_of_month[i] = s
  261. del nth_weekday_of_month['*']
  262. candidates = []
  263. for wday, nth in nth_weekday_of_month.items():
  264. w = (wday + 6) % 7
  265. c = calendar.Calendar(w).monthdayscalendar(d.year, d.month)
  266. if c[0][0] == 0: c.pop(0)
  267. for n in nth:
  268. if len(c) < n:
  269. continue
  270. candidate = c[n - 1][0]
  271. if (
  272. (is_prev and candidate <= d.day) or
  273. (not is_prev and d.day <= candidate)
  274. ):
  275. candidates.append(candidate)
  276. if not candidates:
  277. if is_prev:
  278. d += relativedelta(days=-d.day,
  279. hour=23, minute=59, second=59)
  280. else:
  281. days = DAYS[month - 1]
  282. if month == 2 and self.is_leap(year) is True:
  283. days += 1
  284. d += relativedelta(days=(days - d.day + 1),
  285. hour=0, minute=0, second=0)
  286. return True, d
  287. candidates.sort()
  288. diff_day = (candidates[-1] if is_prev else candidates[0]) - d.day
  289. if diff_day != 0:
  290. if is_prev:
  291. d += relativedelta(days=diff_day,
  292. hour=23, minute=59, second=59)
  293. else:
  294. d += relativedelta(days=diff_day,
  295. hour=0, minute=0, second=0)
  296. return True, d
  297. return False, d
  298. def proc_hour(d):
  299. if expanded[1][0] != '*':
  300. diff_hour = nearest_diff_method(d.hour, expanded[1], 24)
  301. if diff_hour is not None and diff_hour != 0:
  302. if is_prev:
  303. d += relativedelta(
  304. hours=diff_hour, minute=59, second=59)
  305. else:
  306. d += relativedelta(hours=diff_hour, minute=0, second=0)
  307. return True, d
  308. return False, d
  309. def proc_minute(d):
  310. if expanded[0][0] != '*':
  311. diff_min = nearest_diff_method(d.minute, expanded[0], 60)
  312. if diff_min is not None and diff_min != 0:
  313. if is_prev:
  314. d += relativedelta(minutes=diff_min, second=59)
  315. else:
  316. d += relativedelta(minutes=diff_min, second=0)
  317. return True, d
  318. return False, d
  319. def proc_second(d):
  320. if len(expanded) == 6:
  321. if expanded[5][0] != '*':
  322. diff_sec = nearest_diff_method(d.second, expanded[5], 60)
  323. if diff_sec is not None and diff_sec != 0:
  324. d += relativedelta(seconds=diff_sec)
  325. return True, d
  326. else:
  327. d += relativedelta(second=0)
  328. return False, d
  329. procs = [proc_month,
  330. proc_day_of_month,
  331. (proc_day_of_week_nth if nth_weekday_of_month
  332. else proc_day_of_week),
  333. proc_hour,
  334. proc_minute,
  335. proc_second]
  336. while abs(year - current_year) <= 1:
  337. next = False
  338. for proc in procs:
  339. (changed, dst) = proc(dst)
  340. if changed:
  341. month, year = dst.month, dst.year
  342. next = True
  343. break
  344. if next:
  345. continue
  346. return self._datetime_to_timestamp(dst.replace(microsecond=0))
  347. if is_prev:
  348. raise CroniterBadDateError("failed to find prev date")
  349. raise CroniterBadDateError("failed to find next date")
  350. def _get_next_nearest(self, x, to_check):
  351. small = [item for item in to_check if item < x]
  352. large = [item for item in to_check if item >= x]
  353. large.extend(small)
  354. return large[0]
  355. def _get_prev_nearest(self, x, to_check):
  356. small = [item for item in to_check if item <= x]
  357. large = [item for item in to_check if item > x]
  358. small.reverse()
  359. large.reverse()
  360. small.extend(large)
  361. return small[0]
  362. def _get_next_nearest_diff(self, x, to_check, range_val):
  363. for i, d in enumerate(to_check):
  364. if d == "l":
  365. # if 'l' then it is the last day of month
  366. # => its value of range_val
  367. d = range_val
  368. if d >= x:
  369. return d - x
  370. return to_check[0] - x + range_val
  371. def _get_prev_nearest_diff(self, x, to_check, range_val):
  372. candidates = to_check[:]
  373. candidates.reverse()
  374. for d in candidates:
  375. if d != 'l' and d <= x:
  376. return d - x
  377. if 'l' in candidates:
  378. return -x
  379. candidate = candidates[0]
  380. for c in candidates:
  381. # fixed: c < range_val
  382. # this code will reject all 31 day of month, 12 month, 59 second,
  383. # 23 hour and so on.
  384. # if candidates has just a element, this will not harmful.
  385. # but candidates have multiple elements, then values equal to
  386. # range_val will rejected.
  387. if c <= range_val:
  388. candidate = c
  389. break
  390. return (candidate - x - range_val)
  391. def is_leap(self, year):
  392. if year % 400 == 0 or (year % 4 == 0 and year % 100 != 0):
  393. return True
  394. else:
  395. return False
  396. @classmethod
  397. def expand(cls, expr_format):
  398. expressions = expr_format.split()
  399. if len(expressions) not in VALID_LEN_EXPRESSION:
  400. raise CroniterBadCronError(cls.bad_length)
  401. expanded = []
  402. nth_weekday_of_month = {}
  403. for i, expr in enumerate(expressions):
  404. e_list = expr.split(',')
  405. res = []
  406. while len(e_list) > 0:
  407. e = e_list.pop()
  408. if i == 4:
  409. e, sep, nth = str(e).partition('#')
  410. if nth and not re.match(r'[1-5]', nth):
  411. raise CroniterBadDateError(
  412. "[{0}] is not acceptable".format(expr_format))
  413. t = re.sub(r'^\*(\/.+)$', r'%d-%d\1' % (
  414. cls.RANGES[i][0],
  415. cls.RANGES[i][1]),
  416. str(e))
  417. m = search_re.search(t)
  418. if not m:
  419. t = re.sub(r'^(.+)\/(.+)$', r'\1-%d/\2' % (
  420. cls.RANGES[i][1]),
  421. str(e))
  422. m = step_search_re.search(t)
  423. if m:
  424. (low, high, step) = m.group(1), m.group(2), m.group(4) or 1
  425. if not any_int_re.search(low):
  426. low = "{0}".format(cls._alphaconv(i, low, expressions))
  427. if not any_int_re.search(high):
  428. high = "{0}".format(cls._alphaconv(i, high, expressions))
  429. if (
  430. not low or not high or int(low) > int(high)
  431. or not only_int_re.search(str(step))
  432. ):
  433. raise CroniterBadDateError(
  434. "[{0}] is not acceptable".format(expr_format))
  435. low, high, step = map(int, [low, high, step])
  436. rng = range(low, high + 1, step)
  437. e_list += (["{0}#{1}".format(item, nth) for item in rng]
  438. if i == 4 and nth else rng)
  439. else:
  440. if t.startswith('-'):
  441. raise CroniterBadCronError(
  442. "[{0}] is not acceptable,\
  443. negative numbers not allowed".format(
  444. expr_format))
  445. if not star_or_int_re.search(t):
  446. t = cls._alphaconv(i, t, expressions)
  447. try:
  448. t = int(t)
  449. except:
  450. pass
  451. if t in cls.LOWMAP[i]:
  452. t = cls.LOWMAP[i][t]
  453. if (
  454. t not in ["*", "l"]
  455. and (int(t) < cls.RANGES[i][0] or
  456. int(t) > cls.RANGES[i][1])
  457. ):
  458. raise CroniterBadCronError(
  459. "[{0}] is not acceptable, out of range".format(
  460. expr_format))
  461. res.append(t)
  462. if i == 4 and nth:
  463. if t not in nth_weekday_of_month:
  464. nth_weekday_of_month[t] = set()
  465. nth_weekday_of_month[t].add(int(nth))
  466. res.sort()
  467. expanded.append(['*'] if (len(res) == 1
  468. and res[0] == '*')
  469. else res)
  470. return expanded, nth_weekday_of_month
  471. @classmethod
  472. def is_valid(cls, expression):
  473. try:
  474. cls.expand(expression)
  475. except CroniterError:
  476. return False
  477. else:
  478. return True