1#!/usr/bin/env python
2# -*- coding: utf-8 -*-
3
4from __future__ import absolute_import, print_function, division
5
6import traceback as _traceback
7import copy
8import math
9import re
10import sys
11import inspect
12from time import time
13import datetime
14from dateutil.relativedelta import relativedelta
15from dateutil.tz import tzutc
16import calendar
17import binascii
18import random
19
20# as pytz is optional in thirdparty libs but we need it for good support under
21# python2, just test that it's well installed
22import pytz # noqa
23
24try:
25 from collections import OrderedDict
26except ImportError:
27 OrderedDict = dict # py26 degraded mode, expanders order will not be immutable
28
29
30M_ALPHAS = {'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'may': 5, 'jun': 6,
31 'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12}
32DOW_ALPHAS = {'sun': 0, 'mon': 1, 'tue': 2, 'wed': 3, 'thu': 4, 'fri': 5, 'sat': 6}
33ALPHAS = {}
34for i in M_ALPHAS, DOW_ALPHAS:
35 ALPHAS.update(i)
36del i
37step_search_re = re.compile(r'^([^-]+)-([^-/]+)(/(\d+))?$')
38only_int_re = re.compile(r'^\d+$')
39
40WEEKDAYS = '|'.join(DOW_ALPHAS.keys())
41MONTHS = '|'.join(M_ALPHAS.keys())
42star_or_int_re = re.compile(r'^(\d+|\*)$')
43special_dow_re = re.compile(
44 (r'^(?P<pre>((?P<he>(({WEEKDAYS})(-({WEEKDAYS}))?)').format(WEEKDAYS=WEEKDAYS) +
45 (r'|(({MONTHS})(-({MONTHS}))?)|\w+)#)|l)(?P<last>\d+)$').format(MONTHS=MONTHS)
46)
47re_star = re.compile('[*]')
48hash_expression_re = re.compile(
49 r'^(?P<hash_type>h|r)(\((?P<range_begin>\d+)-(?P<range_end>\d+)\))?(\/(?P<divisor>\d+))?$'
50)
51VALID_LEN_EXPRESSION = [5, 6]
52EXPRESSIONS = {}
53
54
55def timedelta_to_seconds(td):
56 return (td.microseconds + (td.seconds + td.days * 24 * 3600) * 10**6) \
57 / 10**6
58
59
60def datetime_to_timestamp(d):
61 if d.tzinfo is not None:
62 d = d.replace(tzinfo=None) - d.utcoffset()
63
64 return timedelta_to_seconds(d - datetime.datetime(1970, 1, 1))
65
66
67def _get_caller_globals_and_locals():
68 """
69 Returns the globals and locals of the calling frame.
70
71 Is there an alternative to frame hacking here?
72 """
73 caller_frame = inspect.stack()[2]
74 myglobals = caller_frame[0].f_globals
75 mylocals = caller_frame[0].f_locals
76 return myglobals, mylocals
77
78
79class CroniterError(ValueError):
80 """ General top-level Croniter base exception """
81 pass
82
83
84class CroniterBadTypeRangeError(TypeError):
85 """."""
86
87
88class CroniterBadCronError(CroniterError):
89 """ Syntax, unknown value, or range error within a cron expression """
90 pass
91
92
93class CroniterUnsupportedSyntaxError(CroniterBadCronError):
94 """ Valid cron syntax, but likely to produce inaccurate results """
95 # Extending CroniterBadCronError, which may be contridatory, but this allows
96 # catching both errors with a single exception. From a user perspective
97 # these will likely be handled the same way.
98 pass
99
100
101class CroniterBadDateError(CroniterError):
102 """ Unable to find next/prev timestamp match """
103 pass
104
105
106class CroniterNotAlphaError(CroniterBadCronError):
107 """ Cron syntax contains an invalid day or month abbreviation """
108 pass
109
110
111class croniter(object):
112 MONTHS_IN_YEAR = 12
113 RANGES = (
114 (0, 59),
115 (0, 23),
116 (1, 31),
117 (1, 12),
118 (0, 7),
119 (0, 59)
120 )
121 DAYS = (
122 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31
123 )
124
125 ALPHACONV = (
126 {}, # 0: min
127 {}, # 1: hour
128 {"l": "l"}, # 2: dom
129 # 3: mon
130 copy.deepcopy(M_ALPHAS),
131 # 4: dow
132 copy.deepcopy(DOW_ALPHAS),
133 # command/user
134 {}
135 )
136
137 LOWMAP = (
138 {},
139 {},
140 {0: 1},
141 {0: 1},
142 {7: 0},
143 {},
144 )
145
146 LEN_MEANS_ALL = (
147 60,
148 24,
149 31,
150 12,
151 7,
152 60
153 )
154
155 bad_length = 'Exactly 5 or 6 columns has to be specified for iterator ' \
156 'expression.'
157
158 def __init__(self, expr_format, start_time=None, ret_type=float,
159 day_or=True, max_years_between_matches=None, is_prev=False,
160 hash_id=None, implement_cron_bug=False):
161 self._ret_type = ret_type
162 self._day_or = day_or
163 self._implement_cron_bug = implement_cron_bug
164
165 if hash_id:
166 if not isinstance(hash_id, (bytes, str)):
167 raise TypeError('hash_id must be bytes or UTF-8 string')
168 if not isinstance(hash_id, bytes):
169 hash_id = hash_id.encode('UTF-8')
170
171 self._max_years_btw_matches_explicitly_set = (
172 max_years_between_matches is not None)
173 if not self._max_years_btw_matches_explicitly_set:
174 max_years_between_matches = 50
175 self._max_years_between_matches = max(int(max_years_between_matches), 1)
176
177 if start_time is None:
178 start_time = time()
179
180 self.tzinfo = None
181
182 self.start_time = None
183 self.dst_start_time = None
184 self.cur = None
185 self.set_current(start_time, force=False)
186
187 self.expanded, self.nth_weekday_of_month = self.expand(expr_format, hash_id=hash_id)
188 self.expressions = EXPRESSIONS[(expr_format, hash_id)]
189 self._is_prev = is_prev
190
191 @classmethod
192 def _alphaconv(cls, index, key, expressions):
193 try:
194 return cls.ALPHACONV[index][key]
195 except KeyError:
196 raise CroniterNotAlphaError(
197 "[{0}] is not acceptable".format(" ".join(expressions)))
198
199 def get_next(self, ret_type=None, start_time=None):
200 self.set_current(start_time, force=True)
201 return self._get_next(ret_type or self._ret_type, is_prev=False)
202
203 def get_prev(self, ret_type=None):
204 return self._get_next(ret_type or self._ret_type, is_prev=True)
205
206 def get_current(self, ret_type=None):
207 ret_type = ret_type or self._ret_type
208 if issubclass(ret_type, datetime.datetime):
209 return self._timestamp_to_datetime(self.cur)
210 return self.cur
211
212 def set_current(self, start_time, force=True):
213 if (force or (self.cur is None)) and start_time is not None:
214 if isinstance(start_time, datetime.datetime):
215 self.tzinfo = start_time.tzinfo
216 start_time = self._datetime_to_timestamp(start_time)
217
218 self.start_time = start_time
219 self.dst_start_time = start_time
220 self.cur = start_time
221 return self.cur
222
223 @classmethod
224 def _datetime_to_timestamp(cls, d):
225 """
226 Converts a `datetime` object `d` into a UNIX timestamp.
227 """
228 return datetime_to_timestamp(d)
229
230 def _timestamp_to_datetime(self, timestamp):
231 """
232 Converts a UNIX timestamp `timestamp` into a `datetime` object.
233 """
234 result = datetime.datetime.fromtimestamp(timestamp, tz=tzutc()).replace(tzinfo=None)
235 if self.tzinfo:
236 result = result.replace(tzinfo=tzutc()).astimezone(self.tzinfo)
237
238 return result
239
240 @classmethod
241 def _timedelta_to_seconds(cls, td):
242 """
243 Converts a 'datetime.timedelta' object `td` into seconds contained in
244 the duration.
245 Note: We cannot use `timedelta.total_seconds()` because this is not
246 supported by Python 2.6.
247 """
248 return timedelta_to_seconds(td)
249
250 def _get_next(self, ret_type=None, start_time=None, is_prev=None):
251 self.set_current(start_time, force=True)
252 if is_prev is None:
253 is_prev = self._is_prev
254 self._is_prev = is_prev
255 expanded = self.expanded[:]
256 nth_weekday_of_month = self.nth_weekday_of_month.copy()
257
258 ret_type = ret_type or self._ret_type
259
260 if not issubclass(ret_type, (float, datetime.datetime)):
261 raise TypeError("Invalid ret_type, only 'float' or 'datetime' "
262 "is acceptable.")
263
264 # exception to support day of month and day of week as defined in cron
265 dom_dow_exception_processed = False
266 if (expanded[2][0] != '*' and expanded[4][0] != '*') and self._day_or:
267 # If requested, handle a bug in vixie cron/ISC cron where day_of_month and day_of_week form
268 # an intersection (AND) instead of a union (OR) if either field is an asterisk or starts with an asterisk
269 # (https://crontab.guru/cron-bug.html)
270 if self._implement_cron_bug and (re_star.match(self.expressions[2]) or re_star.match(self.expressions[4])):
271 # To produce a schedule identical to the cron bug, we'll bypass the code that
272 # makes a union of DOM and DOW, and instead skip to the code that does an intersect instead
273 pass
274 else:
275 bak = expanded[4]
276 expanded[4] = ['*']
277 t1 = self._calc(self.cur, expanded, nth_weekday_of_month, is_prev)
278 expanded[4] = bak
279 expanded[2] = ['*']
280
281 t2 = self._calc(self.cur, expanded, nth_weekday_of_month, is_prev)
282 if not is_prev:
283 result = t1 if t1 < t2 else t2
284 else:
285 result = t1 if t1 > t2 else t2
286 dom_dow_exception_processed = True
287
288 if not dom_dow_exception_processed:
289 result = self._calc(self.cur, expanded,
290 nth_weekday_of_month, is_prev)
291
292 # DST Handling for cron job spanning across days
293 dtstarttime = self._timestamp_to_datetime(self.dst_start_time)
294 dtstarttime_utcoffset = (
295 dtstarttime.utcoffset() or datetime.timedelta(0))
296 dtresult = self._timestamp_to_datetime(result)
297 lag = lag_hours = 0
298 # do we trigger DST on next crontab (handle backward changes)
299 dtresult_utcoffset = dtstarttime_utcoffset
300 if dtresult and self.tzinfo:
301 dtresult_utcoffset = dtresult.utcoffset()
302 lag_hours = (
303 self._timedelta_to_seconds(dtresult - dtstarttime) / (60 * 60)
304 )
305 lag = self._timedelta_to_seconds(
306 dtresult_utcoffset - dtstarttime_utcoffset
307 )
308 hours_before_midnight = 24 - dtstarttime.hour
309 if dtresult_utcoffset != dtstarttime_utcoffset:
310 if (
311 (lag > 0 and abs(lag_hours) >= hours_before_midnight)
312 or (lag < 0 and
313 ((3600 * abs(lag_hours) + abs(lag)) >= hours_before_midnight * 3600))
314 ):
315 dtresult_adjusted = dtresult - datetime.timedelta(seconds=lag)
316 result_adjusted = self._datetime_to_timestamp(dtresult_adjusted)
317 # Do the actual adjust only if the result time actually exists
318 if self._timestamp_to_datetime(result_adjusted).tzinfo == dtresult_adjusted.tzinfo:
319 dtresult = dtresult_adjusted
320 result = result_adjusted
321 self.dst_start_time = result
322 self.cur = result
323 if issubclass(ret_type, datetime.datetime):
324 result = dtresult
325 return result
326
327 # iterator protocol, to enable direct use of croniter
328 # objects in a loop, like "for dt in croniter('5 0 * * *'): ..."
329 # or for combining multiple croniters into single
330 # dates feed using 'itertools' module
331 def all_next(self, ret_type=None):
332 '''Generator of all consecutive dates. Can be used instead of
333 implicit call to __iter__, whenever non-default
334 'ret_type' has to be specified.
335 '''
336 # In a Python 3.7+ world: contextlib.suppress and contextlib.nullcontext could be used instead
337 try:
338 while True:
339 self._is_prev = False
340 yield self._get_next(ret_type or self._ret_type)
341 except CroniterBadDateError:
342 if self._max_years_btw_matches_explicitly_set:
343 return
344 else:
345 raise
346
347 def all_prev(self, ret_type=None):
348 '''Generator of all previous dates.'''
349 try:
350 while True:
351 self._is_prev = True
352 yield self._get_next(ret_type or self._ret_type)
353 except CroniterBadDateError:
354 if self._max_years_btw_matches_explicitly_set:
355 return
356 else:
357 raise
358
359 def iter(self, *args, **kwargs):
360 return (self._is_prev and self.all_prev or self.all_next)
361
362 def __iter__(self):
363 return self
364 __next__ = next = _get_next
365
366 def _calc(self, now, expanded, nth_weekday_of_month, is_prev):
367 if is_prev:
368 now = math.ceil(now)
369 nearest_diff_method = self._get_prev_nearest_diff
370 sign = -1
371 offset = (len(expanded) == 6 or now % 60 > 0) and 1 or 60
372 else:
373 now = math.floor(now)
374 nearest_diff_method = self._get_next_nearest_diff
375 sign = 1
376 offset = (len(expanded) == 6) and 1 or 60
377
378 dst = now = self._timestamp_to_datetime(now + sign * offset)
379
380 month, year = dst.month, dst.year
381 current_year = now.year
382 DAYS = self.DAYS
383
384 def proc_month(d):
385 try:
386 expanded[3].index('*')
387 except ValueError:
388 diff_month = nearest_diff_method(
389 d.month, expanded[3], self.MONTHS_IN_YEAR)
390 days = DAYS[month - 1]
391 if month == 2 and self.is_leap(year) is True:
392 days += 1
393
394 reset_day = 1
395
396 if diff_month is not None and diff_month != 0:
397 if is_prev:
398 d += relativedelta(months=diff_month)
399 reset_day = DAYS[d.month - 1]
400 if d.month == 2 and self.is_leap(d.year) is True:
401 reset_day += 1
402 d += relativedelta(
403 day=reset_day, hour=23, minute=59, second=59)
404 else:
405 d += relativedelta(months=diff_month, day=reset_day,
406 hour=0, minute=0, second=0)
407 return True, d
408 return False, d
409
410 def proc_day_of_month(d):
411 try:
412 expanded[2].index('*')
413 except ValueError:
414 days = DAYS[month - 1]
415 if month == 2 and self.is_leap(year) is True:
416 days += 1
417 if 'l' in expanded[2] and days == d.day:
418 return False, d
419
420 if is_prev:
421 days_in_prev_month = DAYS[
422 (month - 2) % self.MONTHS_IN_YEAR]
423 diff_day = nearest_diff_method(
424 d.day, expanded[2], days_in_prev_month)
425 else:
426 diff_day = nearest_diff_method(d.day, expanded[2], days)
427
428 if diff_day is not None and diff_day != 0:
429 if is_prev:
430 d += relativedelta(
431 days=diff_day, hour=23, minute=59, second=59)
432 else:
433 d += relativedelta(
434 days=diff_day, hour=0, minute=0, second=0)
435 return True, d
436 return False, d
437
438 def proc_day_of_week(d):
439 try:
440 expanded[4].index('*')
441 except ValueError:
442 diff_day_of_week = nearest_diff_method(
443 d.isoweekday() % 7, expanded[4], 7)
444 if diff_day_of_week is not None and diff_day_of_week != 0:
445 if is_prev:
446 d += relativedelta(days=diff_day_of_week,
447 hour=23, minute=59, second=59)
448 else:
449 d += relativedelta(days=diff_day_of_week,
450 hour=0, minute=0, second=0)
451 return True, d
452 return False, d
453
454 def proc_day_of_week_nth(d):
455 if '*' in nth_weekday_of_month:
456 s = nth_weekday_of_month['*']
457 for i in range(0, 7):
458 if i in nth_weekday_of_month:
459 nth_weekday_of_month[i].update(s)
460 else:
461 nth_weekday_of_month[i] = s
462 del nth_weekday_of_month['*']
463
464 candidates = []
465 for wday, nth in nth_weekday_of_month.items():
466 c = self._get_nth_weekday_of_month(d.year, d.month, wday)
467 for n in nth:
468 if n == "l":
469 candidate = c[-1]
470 elif len(c) < n:
471 continue
472 else:
473 candidate = c[n - 1]
474 if (
475 (is_prev and candidate <= d.day) or
476 (not is_prev and d.day <= candidate)
477 ):
478 candidates.append(candidate)
479
480 if not candidates:
481 if is_prev:
482 d += relativedelta(days=-d.day,
483 hour=23, minute=59, second=59)
484 else:
485 days = DAYS[month - 1]
486 if month == 2 and self.is_leap(year) is True:
487 days += 1
488 d += relativedelta(days=(days - d.day + 1),
489 hour=0, minute=0, second=0)
490 return True, d
491
492 candidates.sort()
493 diff_day = (candidates[-1] if is_prev else candidates[0]) - d.day
494 if diff_day != 0:
495 if is_prev:
496 d += relativedelta(days=diff_day,
497 hour=23, minute=59, second=59)
498 else:
499 d += relativedelta(days=diff_day,
500 hour=0, minute=0, second=0)
501 return True, d
502 return False, d
503
504 def proc_hour(d):
505 try:
506 expanded[1].index('*')
507 except ValueError:
508 diff_hour = nearest_diff_method(d.hour, expanded[1], 24)
509 if diff_hour is not None and diff_hour != 0:
510 if is_prev:
511 d += relativedelta(
512 hours=diff_hour, minute=59, second=59)
513 else:
514 d += relativedelta(hours=diff_hour, minute=0, second=0)
515 return True, d
516 return False, d
517
518 def proc_minute(d):
519 try:
520 expanded[0].index('*')
521 except ValueError:
522 diff_min = nearest_diff_method(d.minute, expanded[0], 60)
523 if diff_min is not None and diff_min != 0:
524 if is_prev:
525 d += relativedelta(minutes=diff_min, second=59)
526 else:
527 d += relativedelta(minutes=diff_min, second=0)
528 return True, d
529 return False, d
530
531 def proc_second(d):
532 if len(expanded) == 6:
533 try:
534 expanded[5].index('*')
535 except ValueError:
536 diff_sec = nearest_diff_method(d.second, expanded[5], 60)
537 if diff_sec is not None and diff_sec != 0:
538 d += relativedelta(seconds=diff_sec)
539 return True, d
540 else:
541 d += relativedelta(second=0)
542 return False, d
543
544 procs = [proc_month,
545 proc_day_of_month,
546 (proc_day_of_week_nth if nth_weekday_of_month
547 else proc_day_of_week),
548 proc_hour,
549 proc_minute,
550 proc_second]
551
552 while abs(year - current_year) <= self._max_years_between_matches:
553 next = False
554 for proc in procs:
555 (changed, dst) = proc(dst)
556 if changed:
557 month, year = dst.month, dst.year
558 next = True
559 break
560 if next:
561 continue
562 return self._datetime_to_timestamp(dst.replace(microsecond=0))
563
564 if is_prev:
565 raise CroniterBadDateError("failed to find prev date")
566 raise CroniterBadDateError("failed to find next date")
567
568 def _get_next_nearest(self, x, to_check):
569 small = [item for item in to_check if item < x]
570 large = [item for item in to_check if item >= x]
571 large.extend(small)
572 return large[0]
573
574 def _get_prev_nearest(self, x, to_check):
575 small = [item for item in to_check if item <= x]
576 large = [item for item in to_check if item > x]
577 small.reverse()
578 large.reverse()
579 small.extend(large)
580 return small[0]
581
582 def _get_next_nearest_diff(self, x, to_check, range_val):
583 for i, d in enumerate(to_check):
584 if d == "l":
585 # if 'l' then it is the last day of month
586 # => its value of range_val
587 d = range_val
588 if d >= x:
589 return d - x
590 return to_check[0] - x + range_val
591
592 def _get_prev_nearest_diff(self, x, to_check, range_val):
593 candidates = to_check[:]
594 candidates.reverse()
595 for d in candidates:
596 if d != 'l' and d <= x:
597 return d - x
598 if 'l' in candidates:
599 return -x
600 candidate = candidates[0]
601 for c in candidates:
602 # fixed: c < range_val
603 # this code will reject all 31 day of month, 12 month, 59 second,
604 # 23 hour and so on.
605 # if candidates has just a element, this will not harmful.
606 # but candidates have multiple elements, then values equal to
607 # range_val will rejected.
608 if c <= range_val:
609 candidate = c
610 break
611 if candidate > range_val:
612 # fix crontab "0 6 30 3 *" condidates only a element,
613 # then get_prev error return 2021-03-02 06:00:00
614 return - x
615 return (candidate - x - range_val)
616
617 @staticmethod
618 def _get_nth_weekday_of_month(year, month, day_of_week):
619 """ For a given year/month return a list of days in nth-day-of-month order.
620 The last weekday of the month is always [-1].
621 """
622 w = (day_of_week + 6) % 7
623 c = calendar.Calendar(w).monthdayscalendar(year, month)
624 if c[0][0] == 0:
625 c.pop(0)
626 return tuple(i[0] for i in c)
627
628 def is_leap(self, year):
629 if year % 400 == 0 or (year % 4 == 0 and year % 100 != 0):
630 return True
631 else:
632 return False
633
634 @classmethod
635 def _expand(cls, expr_format, hash_id=None):
636 # Split the expression in components, and normalize L -> l, MON -> mon,
637 # etc. Keep expr_format untouched so we can use it in the exception
638 # messages.
639 expr_aliases = {
640 '@midnight': ('0 0 * * *', 'h h(0-2) * * * h'),
641 '@hourly': ('0 * * * *', 'h * * * * h'),
642 '@daily': ('0 0 * * *', 'h h * * * h'),
643 '@weekly': ('0 0 * * 0', 'h h * * h h'),
644 '@monthly': ('0 0 1 * *', 'h h h * * h'),
645 '@yearly': ('0 0 1 1 *', 'h h h h * h'),
646 '@annually': ('0 0 1 1 *', 'h h h h * h'),
647 }
648
649 efl = expr_format.lower()
650 hash_id_expr = hash_id is not None and 1 or 0
651 try:
652 efl = expr_aliases[efl][hash_id_expr]
653 except KeyError:
654 pass
655
656 expressions = efl.split()
657
658 if len(expressions) not in VALID_LEN_EXPRESSION:
659 raise CroniterBadCronError(cls.bad_length)
660
661 expanded = []
662 nth_weekday_of_month = {}
663
664 for i, expr in enumerate(expressions):
665 for expanderid, expander in EXPANDERS.items():
666 expr = expander(cls).expand(efl, i, expr, hash_id=hash_id)
667
668 e_list = expr.split(',')
669 res = []
670
671 while len(e_list) > 0:
672 e = e_list.pop()
673 nth = None
674
675 if i == 4:
676 # Handle special case in the dow expression: 2#3, l3
677 special_dow_rem = special_dow_re.match(str(e))
678 if special_dow_rem:
679 g = special_dow_rem.groupdict()
680 he, last = g.get('he', ''), g.get('last', '')
681 if he:
682 e = he
683 try:
684 nth = int(last)
685 assert (nth >= 1 and nth <= 5)
686 except (KeyError, ValueError, AssertionError):
687 raise CroniterBadCronError(
688 "[{0}] is not acceptable. Invalid day_of_week "
689 "value: '{1}'".format(expr_format, nth))
690 elif last:
691 e = last
692 nth = g['pre'] # 'l'
693
694 # Before matching step_search_re, normalize "*" to "{min}-{max}".
695 # Example: in the minute field, "*/5" normalizes to "0-59/5"
696 t = re.sub(r'^\*(\/.+)$', r'%d-%d\1' % (
697 cls.RANGES[i][0],
698 cls.RANGES[i][1]),
699 str(e))
700 m = step_search_re.search(t)
701
702 if not m:
703 # Before matching step_search_re,
704 # normalize "{start}/{step}" to "{start}-{max}/{step}".
705 # Example: in the minute field, "10/5" normalizes to "10-59/5"
706 t = re.sub(r'^(.+)\/(.+)$', r'\1-%d/\2' % (
707 cls.RANGES[i][1]),
708 str(e))
709 m = step_search_re.search(t)
710
711 if m:
712 # early abort if low/high are out of bounds
713
714 (low, high, step) = m.group(1), m.group(2), m.group(4) or 1
715 if i == 2 and high == 'l':
716 high = '31'
717
718 if not only_int_re.search(low):
719 low = "{0}".format(cls._alphaconv(i, low, expressions))
720
721 if not only_int_re.search(high):
722 high = "{0}".format(cls._alphaconv(i, high, expressions))
723
724 if (
725 not low or not high or int(low) > int(high)
726 or not only_int_re.search(str(step))
727 ):
728 if i == 4 and high == '0':
729 # handle -Sun notation -> 7
730 high = '7'
731 else:
732 raise CroniterBadCronError(
733 "[{0}] is not acceptable".format(expr_format))
734
735 low, high, step = map(int, [low, high, step])
736 if (
737 max(low, high) > max(cls.RANGES[i][0], cls.RANGES[i][1])
738 ):
739 raise CroniterBadCronError(
740 "{0} is out of bands".format(expr_format))
741 try:
742 rng = range(low, high + 1, step)
743 except ValueError as exc:
744 raise CroniterBadCronError(
745 'invalid range: {0}'.format(exc))
746 e_list += (["{0}#{1}".format(item, nth) for item in rng]
747 if i == 4 and nth and nth != "l" else rng)
748 else:
749 if t.startswith('-'):
750 raise CroniterBadCronError((
751 "[{0}] is not acceptable,"
752 "negative numbers not allowed"
753 ).format(expr_format))
754 if not star_or_int_re.search(t):
755 t = cls._alphaconv(i, t, expressions)
756
757 try:
758 t = int(t)
759 except ValueError:
760 pass
761
762 if t in cls.LOWMAP[i] and not (
763 # do not support 0 as a month either for classical 5 fields cron
764 # or 6fields second repeat form
765 # but still let conversion happen if day field is shifted
766 (i in [2, 3] and len(expressions) == 5) or
767 (i in [3, 4] and len(expressions) == 6)
768 ):
769 t = cls.LOWMAP[i][t]
770
771 if (
772 t not in ["*", "l"]
773 and (int(t) < cls.RANGES[i][0] or
774 int(t) > cls.RANGES[i][1])
775 ):
776 raise CroniterBadCronError(
777 "[{0}] is not acceptable, out of range".format(
778 expr_format))
779
780 res.append(t)
781
782 if i == 4 and nth:
783 if t not in nth_weekday_of_month:
784 nth_weekday_of_month[t] = set()
785 nth_weekday_of_month[t].add(nth)
786
787 res = set(res)
788 res = sorted(res, key=lambda i: "{:02}".format(i) if isinstance(i, int) else i)
789 if len(res) == cls.LEN_MEANS_ALL[i]:
790 # Make sure the wildcard is used in the correct way (avoid over-optimization)
791 if (
792 (i == 2 and '*' not in expressions[4]) or
793 (i == 4 and '*' not in expressions[2])
794 ):
795 pass
796 else:
797 res = ['*']
798
799 expanded.append(['*'] if (len(res) == 1
800 and res[0] == '*')
801 else res)
802
803 # Check to make sure the dow combo in use is supported
804 if nth_weekday_of_month:
805 dow_expanded_set = set(expanded[4])
806 dow_expanded_set = dow_expanded_set.difference(nth_weekday_of_month.keys())
807 dow_expanded_set.discard("*")
808 # Skip: if it's all weeks instead of wildcard
809 if dow_expanded_set and len(set(expanded[4])) != cls.LEN_MEANS_ALL[4]:
810 raise CroniterUnsupportedSyntaxError(
811 "day-of-week field does not support mixing literal values and nth day of week syntax. "
812 "Cron: '{}' dow={} vs nth={}".format(expr_format, dow_expanded_set, nth_weekday_of_month))
813
814 EXPRESSIONS[(expr_format, hash_id)] = expressions
815 return expanded, nth_weekday_of_month
816
817 @classmethod
818 def expand(cls, expr_format, hash_id=None):
819 """Shallow non Croniter ValueError inside a nice CroniterBadCronError"""
820 try:
821 return cls._expand(expr_format, hash_id=hash_id)
822 except (ValueError,) as exc:
823 error_type, error_instance, traceback = sys.exc_info()
824 if isinstance(exc, CroniterError):
825 raise
826 if int(sys.version[0]) >= 3:
827 trace = _traceback.format_exc()
828 globs, locs = _get_caller_globals_and_locals()
829 raise CroniterBadCronError(trace)
830 else:
831 raise CroniterBadCronError("{0}".format(exc))
832
833 @classmethod
834 def is_valid(cls, expression, hash_id=None, encoding='UTF-8'):
835 if hash_id:
836 if not isinstance(hash_id, (bytes, str)):
837 raise TypeError('hash_id must be bytes or UTF-8 string')
838 if not isinstance(hash_id, bytes):
839 hash_id = hash_id.encode(encoding)
840 try:
841 cls.expand(expression, hash_id=hash_id)
842 except CroniterError:
843 return False
844 else:
845 return True
846
847 @classmethod
848 def match(cls, cron_expression, testdate, day_or=True):
849 return cls.match_range(cron_expression, testdate, testdate, day_or)
850
851 @classmethod
852 def match_range(cls, cron_expression, from_datetime, to_datetime, day_or=True):
853 cron = cls(cron_expression, to_datetime, ret_type=datetime.datetime, day_or=day_or)
854 td, ms1 = cron.get_current(datetime.datetime), relativedelta(microseconds=1)
855 if not td.microsecond:
856 td = td + ms1
857 cron.set_current(td, force=True)
858 tdp, tdt = cron.get_current(), cron.get_prev()
859 precision_in_seconds = 1 if len(cron.expanded) == 6 else 60
860 duration_in_second = (to_datetime - from_datetime).total_seconds() + precision_in_seconds
861 return (max(tdp, tdt) - min(tdp, tdt)).total_seconds() < duration_in_second
862
863
864def croniter_range(start, stop, expr_format, ret_type=None, day_or=True, exclude_ends=False,
865 _croniter=None):
866 """
867 Generator that provides all times from start to stop matching the given cron expression.
868 If the cron expression matches either 'start' and/or 'stop', those times will be returned as
869 well unless 'exclude_ends=True' is passed.
870
871 You can think of this function as sibling to the builtin range function for datetime objects.
872 Like range(start,stop,step), except that here 'step' is a cron expression.
873 """
874 _croniter = _croniter or croniter
875 auto_rt = datetime.datetime
876 # type is used in first if branch for perfs reasons
877 if (
878 type(start) is not type(stop) and not (
879 isinstance(start, type(stop)) or
880 isinstance(stop, type(start)))
881 ):
882 raise CroniterBadTypeRangeError(
883 "The start and stop must be same type. {0} != {1}".
884 format(type(start), type(stop)))
885 if isinstance(start, (float, int)):
886 start, stop = (datetime.datetime.fromtimestamp(t, tzutc()).replace(tzinfo=None) for t in (start, stop))
887 auto_rt = float
888 if ret_type is None:
889 ret_type = auto_rt
890 if not exclude_ends:
891 ms1 = relativedelta(microseconds=1)
892 if start < stop: # Forward (normal) time order
893 start -= ms1
894 stop += ms1
895 else: # Reverse time order
896 start += ms1
897 stop -= ms1
898 year_span = math.floor(abs(stop.year - start.year)) + 1
899 ic = _croniter(expr_format, start, ret_type=datetime.datetime, day_or=day_or,
900 max_years_between_matches=year_span)
901 # define a continue (cont) condition function and step function for the main while loop
902 if start < stop: # Forward
903 def cont(v):
904 return v < stop
905 step = ic.get_next
906 else: # Reverse
907 def cont(v):
908 return v > stop
909 step = ic.get_prev
910 try:
911 dt = step()
912 while cont(dt):
913 if ret_type is float:
914 yield ic.get_current(float)
915 else:
916 yield dt
917 dt = step()
918 except CroniterBadDateError:
919 # Stop iteration when this exception is raised; no match found within the given year range
920 return
921
922
923class HashExpander:
924
925 def __init__(self, cronit):
926 self.cron = cronit
927
928 def do(self, idx, hash_type="h", hash_id=None, range_end=None, range_begin=None):
929 """Return a hashed/random integer given range/hash information"""
930 hours_or_minutes = idx in {0, 1}
931 if range_end is None:
932 range_end = self.cron.RANGES[idx][1]
933 if hours_or_minutes:
934 range_end += 1
935 if range_begin is None:
936 range_begin = self.cron.RANGES[idx][0]
937 if hash_type == 'r':
938 crc = random.randint(0, 0xFFFFFFFF)
939 else:
940 crc = binascii.crc32(hash_id) & 0xFFFFFFFF
941 if not hours_or_minutes:
942 return ((crc >> idx) % (range_end - range_begin + 1)) + range_begin
943 return ((crc >> idx) % (range_end - range_begin)) + range_begin
944
945 def match(self, efl, idx, expr, hash_id=None, **kw):
946 return hash_expression_re.match(expr)
947
948 def expand(self, efl, idx, expr, hash_id=None, match='', **kw):
949 """Expand a hashed/random expression to its normal representation"""
950 if match == '':
951 match = self.match(efl, idx, expr, hash_id, **kw)
952 if not match:
953 return expr
954 m = match.groupdict()
955
956 if m['hash_type'] == 'h' and hash_id is None:
957 raise CroniterBadCronError('Hashed definitions must include hash_id')
958
959 if m['range_begin'] and m['range_end']:
960 if int(m['range_begin']) >= int(m['range_end']):
961 raise CroniterBadCronError('Range end must be greater than range begin')
962
963 if m['range_begin'] and m['range_end'] and m['divisor']:
964 # Example: H(30-59)/10 -> 34-59/10 (i.e. 34,44,54)
965 if int(m["divisor"]) == 0:
966 raise CroniterBadCronError("Bad expression: {0}".format(expr))
967
968 return '{0}-{1}/{2}'.format(
969 self.do(
970 idx,
971 hash_type=m['hash_type'],
972 hash_id=hash_id,
973 range_end=int(m['divisor']),
974 ) + int(m['range_begin']),
975 int(m['range_end']),
976 int(m['divisor']),
977 )
978 elif m['range_begin'] and m['range_end']:
979 # Example: H(0-29) -> 12
980 return str(
981 self.do(
982 idx,
983 hash_type=m['hash_type'],
984 hash_id=hash_id,
985 range_end=int(m['range_end']),
986 range_begin=int(m['range_begin']),
987 )
988 )
989 elif m['divisor']:
990 # Example: H/15 -> 7-59/15 (i.e. 7,22,37,52)
991 if int(m["divisor"]) == 0:
992 raise CroniterBadCronError("Bad expression: {0}".format(expr))
993
994 return '{0}-{1}/{2}'.format(
995 self.do(
996 idx,
997 hash_type=m['hash_type'],
998 hash_id=hash_id,
999 range_end=int(m['divisor']),
1000 ),
1001 self.cron.RANGES[idx][1],
1002 int(m['divisor']),
1003 )
1004 else:
1005 # Example: H -> 32
1006 return str(
1007 self.do(
1008 idx,
1009 hash_type=m['hash_type'],
1010 hash_id=hash_id,
1011 )
1012 )
1013
1014
1015EXPANDERS = OrderedDict([
1016 ('hash', HashExpander),
1017])