#!/usr/bin/env python # -*- coding: utf-8 -*- from __future__ import absolute_import, print_function, division import math import re import sys import inspect from time import time import datetime from dateutil.relativedelta import relativedelta from dateutil.tz import tzutc import calendar import binascii import random try: from collections import OrderedDict except ImportError: OrderedDict = dict # py26 degraded mode, expanders order will not be immutable step_search_re = re.compile(r'^([^-]+)-([^-/]+)(/(\d+))?$') only_int_re = re.compile(r'^\d+$') star_or_int_re = re.compile(r'^(\d+|\*)$') special_weekday_re = re.compile(r'^(\w+)#(\d+)|l(\d+)$') hash_expression_re = re.compile( r'^(?Ph|r)(\((?P\d+)-(?P\d+)\))?(\/(?P\d+))?$' ) VALID_LEN_EXPRESSION = [5, 6] def _get_caller_globals_and_locals(): """ Returns the globals and locals of the calling frame. Is there an alternative to frame hacking here? """ caller_frame = inspect.stack()[2] myglobals = caller_frame[0].f_globals mylocals = caller_frame[0].f_locals return myglobals, mylocals class CroniterError(ValueError): """ General top-level Croniter base exception """ pass class CroniterBadTypeRangeError(TypeError): """.""" class CroniterBadCronError(CroniterError): """ Syntax, unknown value, or range error within a cron expression """ pass class CroniterUnsupportedSyntaxError(CroniterBadCronError): """ Valid cron syntax, but likely to produce inaccurate results """ # Extending CroniterBadCronError, which may be contridatory, but this allows # catching both errors with a single exception. From a user perspective # these will likely be handled the same way. pass class CroniterBadDateError(CroniterError): """ Unable to find next/prev timestamp match """ pass class CroniterNotAlphaError(CroniterBadCronError): """ Cron syntax contains an invalid day or month abbreviation """ pass class croniter(object): MONTHS_IN_YEAR = 12 RANGES = ( (0, 59), (0, 23), (1, 31), (1, 12), (0, 7), (0, 59) ) DAYS = ( 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ) ALPHACONV = ( {}, # 0: min {}, # 1: hour {"l": "l"}, # 2: dom # 3: mon {'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'may': 5, 'jun': 6, 'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12}, # 4: dow {'sun': 0, 'mon': 1, 'tue': 2, 'wed': 3, 'thu': 4, 'fri': 5, 'sat': 6}, # command/user {} ) LOWMAP = ( {}, {}, {0: 1}, {0: 1}, {7: 0}, {}, ) LEN_MEANS_ALL = ( 60, 24, 31, 12, 7, 60 ) bad_length = 'Exactly 5 or 6 columns has to be specified for iterator ' \ 'expression.' def __init__(self, expr_format, start_time=None, ret_type=float, day_or=True, max_years_between_matches=None, is_prev=False, hash_id=None): self._ret_type = ret_type self._day_or = day_or if hash_id: if not isinstance(hash_id, (bytes, str)): raise TypeError('hash_id must be bytes or UTF-8 string') if not isinstance(hash_id, bytes): hash_id = hash_id.encode('UTF-8') self._max_years_btw_matches_explicitly_set = ( max_years_between_matches is not None) if not self._max_years_btw_matches_explicitly_set: max_years_between_matches = 50 self._max_years_between_matches = max(int(max_years_between_matches), 1) if start_time is None: start_time = time() self.tzinfo = None self.start_time = None self.dst_start_time = None self.cur = None self.set_current(start_time) self.expanded, self.nth_weekday_of_month = self.expand(expr_format, hash_id=hash_id) self._is_prev = is_prev @classmethod def _alphaconv(cls, index, key, expressions): try: return cls.ALPHACONV[index][key] except KeyError: raise CroniterNotAlphaError( "[{0}] is not acceptable".format(" ".join(expressions))) def get_next(self, ret_type=None, start_time=None): self.set_current(start_time) return self._get_next(ret_type or self._ret_type, is_prev=False) def get_prev(self, ret_type=None): return self._get_next(ret_type or self._ret_type, is_prev=True) def get_current(self, ret_type=None): ret_type = ret_type or self._ret_type if issubclass(ret_type, datetime.datetime): return self._timestamp_to_datetime(self.cur) return self.cur def set_current(self, start_time, force=False): if (force or (self.cur is None)) and start_time is not None: if isinstance(start_time, datetime.datetime): self.tzinfo = start_time.tzinfo start_time = self._datetime_to_timestamp(start_time) self.start_time = start_time self.dst_start_time = start_time self.cur = start_time return self.cur @classmethod def _datetime_to_timestamp(cls, d): """ Converts a `datetime` object `d` into a UNIX timestamp. """ if d.tzinfo is not None: d = d.replace(tzinfo=None) - d.utcoffset() return cls._timedelta_to_seconds(d - datetime.datetime(1970, 1, 1)) def _timestamp_to_datetime(self, timestamp): """ Converts a UNIX timestamp `timestamp` into a `datetime` object. """ result = datetime.datetime.utcfromtimestamp(timestamp) if self.tzinfo: result = result.replace(tzinfo=tzutc()).astimezone(self.tzinfo) return result @classmethod def _timedelta_to_seconds(cls, td): """ Converts a 'datetime.timedelta' object `td` into seconds contained in the duration. Note: We cannot use `timedelta.total_seconds()` because this is not supported by Python 2.6. """ return (td.microseconds + (td.seconds + td.days * 24 * 3600) * 10**6) \ / 10**6 def _get_next(self, ret_type=None, start_time=None, is_prev=None): self.set_current(start_time, force=True) if is_prev is None: is_prev = self._is_prev self._is_prev = is_prev expanded = self.expanded[:] nth_weekday_of_month = self.nth_weekday_of_month.copy() ret_type = ret_type or self._ret_type if not issubclass(ret_type, (float, datetime.datetime)): raise TypeError("Invalid ret_type, only 'float' or 'datetime' " "is acceptable.") # exception to support day of month and day of week as defined in cron if (expanded[2][0] != '*' and expanded[4][0] != '*') and self._day_or: bak = expanded[4] expanded[4] = ['*'] t1 = self._calc(self.cur, expanded, nth_weekday_of_month, is_prev) expanded[4] = bak expanded[2] = ['*'] t2 = self._calc(self.cur, expanded, nth_weekday_of_month, is_prev) if not is_prev: result = t1 if t1 < t2 else t2 else: result = t1 if t1 > t2 else t2 else: result = self._calc(self.cur, expanded, nth_weekday_of_month, is_prev) # DST Handling for cron job spanning across days dtstarttime = self._timestamp_to_datetime(self.dst_start_time) dtstarttime_utcoffset = ( dtstarttime.utcoffset() or datetime.timedelta(0)) dtresult = self._timestamp_to_datetime(result) lag = lag_hours = 0 # do we trigger DST on next crontab (handle backward changes) dtresult_utcoffset = dtstarttime_utcoffset if dtresult and self.tzinfo: dtresult_utcoffset = dtresult.utcoffset() lag_hours = ( self._timedelta_to_seconds(dtresult - dtstarttime) / (60 * 60) ) lag = self._timedelta_to_seconds( dtresult_utcoffset - dtstarttime_utcoffset ) hours_before_midnight = 24 - dtstarttime.hour if dtresult_utcoffset != dtstarttime_utcoffset: if ( (lag > 0 and abs(lag_hours) >= hours_before_midnight) or (lag < 0 and ((3600 * abs(lag_hours) + abs(lag)) >= hours_before_midnight * 3600)) ): dtresult = dtresult - datetime.timedelta(seconds=lag) result = self._datetime_to_timestamp(dtresult) self.dst_start_time = result self.cur = result if issubclass(ret_type, datetime.datetime): result = dtresult return result # iterator protocol, to enable direct use of croniter # objects in a loop, like "for dt in croniter('5 0 * * *'): ..." # or for combining multiple croniters into single # dates feed using 'itertools' module def all_next(self, ret_type=None): '''Generator of all consecutive dates. Can be used instead of implicit call to __iter__, whenever non-default 'ret_type' has to be specified. ''' # In a Python 3.7+ world: contextlib.suppress and contextlib.nullcontext could be used instead try: while True: self._is_prev = False yield self._get_next(ret_type or self._ret_type) except CroniterBadDateError: if self._max_years_btw_matches_explicitly_set: return else: raise def all_prev(self, ret_type=None): '''Generator of all previous dates.''' try: while True: self._is_prev = True yield self._get_next(ret_type or self._ret_type) except CroniterBadDateError: if self._max_years_btw_matches_explicitly_set: return else: raise def iter(self, *args, **kwargs): return (self._is_prev and self.all_prev or self.all_next) def __iter__(self): return self __next__ = next = _get_next def _calc(self, now, expanded, nth_weekday_of_month, is_prev): if is_prev: now = math.ceil(now) nearest_diff_method = self._get_prev_nearest_diff sign = -1 offset = (len(expanded) == 6 or now % 60 > 0) and 1 or 60 else: now = math.floor(now) nearest_diff_method = self._get_next_nearest_diff sign = 1 offset = (len(expanded) == 6) and 1 or 60 dst = now = self._timestamp_to_datetime(now + sign * offset) month, year = dst.month, dst.year current_year = now.year DAYS = self.DAYS def proc_month(d): try: expanded[3].index('*') except ValueError: diff_month = nearest_diff_method( d.month, expanded[3], self.MONTHS_IN_YEAR) days = DAYS[month - 1] if month == 2 and self.is_leap(year) is True: days += 1 reset_day = 1 if diff_month is not None and diff_month != 0: if is_prev: d += relativedelta(months=diff_month) reset_day = DAYS[d.month - 1] d += relativedelta( day=reset_day, hour=23, minute=59, second=59) else: d += relativedelta(months=diff_month, day=reset_day, hour=0, minute=0, second=0) return True, d return False, d def proc_day_of_month(d): try: expanded[2].index('*') except ValueError: days = DAYS[month - 1] if month == 2 and self.is_leap(year) is True: days += 1 if 'l' in expanded[2] and days == d.day: return False, d if is_prev: days_in_prev_month = DAYS[ (month - 2) % self.MONTHS_IN_YEAR] diff_day = nearest_diff_method( d.day, expanded[2], days_in_prev_month) else: diff_day = nearest_diff_method(d.day, expanded[2], days) if diff_day is not None and diff_day != 0: if is_prev: d += relativedelta( days=diff_day, hour=23, minute=59, second=59) else: d += relativedelta( days=diff_day, hour=0, minute=0, second=0) return True, d return False, d def proc_day_of_week(d): try: expanded[4].index('*') except ValueError: diff_day_of_week = nearest_diff_method( d.isoweekday() % 7, expanded[4], 7) if diff_day_of_week is not None and diff_day_of_week != 0: if is_prev: d += relativedelta(days=diff_day_of_week, hour=23, minute=59, second=59) else: d += relativedelta(days=diff_day_of_week, hour=0, minute=0, second=0) return True, d return False, d def proc_day_of_week_nth(d): if '*' in nth_weekday_of_month: s = nth_weekday_of_month['*'] for i in range(0, 7): if i in nth_weekday_of_month: nth_weekday_of_month[i].update(s) else: nth_weekday_of_month[i] = s del nth_weekday_of_month['*'] candidates = [] for wday, nth in nth_weekday_of_month.items(): c = self._get_nth_weekday_of_month(d.year, d.month, wday) for n in nth: if n == "l": candidate = c[-1] elif len(c) < n: continue else: candidate = c[n - 1] if ( (is_prev and candidate <= d.day) or (not is_prev and d.day <= candidate) ): candidates.append(candidate) if not candidates: if is_prev: d += relativedelta(days=-d.day, hour=23, minute=59, second=59) else: days = DAYS[month - 1] if month == 2 and self.is_leap(year) is True: days += 1 d += relativedelta(days=(days - d.day + 1), hour=0, minute=0, second=0) return True, d candidates.sort() diff_day = (candidates[-1] if is_prev else candidates[0]) - d.day if diff_day != 0: if is_prev: d += relativedelta(days=diff_day, hour=23, minute=59, second=59) else: d += relativedelta(days=diff_day, hour=0, minute=0, second=0) return True, d return False, d def proc_hour(d): try: expanded[1].index('*') except ValueError: diff_hour = nearest_diff_method(d.hour, expanded[1], 24) if diff_hour is not None and diff_hour != 0: if is_prev: d += relativedelta( hours=diff_hour, minute=59, second=59) else: d += relativedelta(hours=diff_hour, minute=0, second=0) return True, d return False, d def proc_minute(d): try: expanded[0].index('*') except ValueError: diff_min = nearest_diff_method(d.minute, expanded[0], 60) if diff_min is not None and diff_min != 0: if is_prev: d += relativedelta(minutes=diff_min, second=59) else: d += relativedelta(minutes=diff_min, second=0) return True, d return False, d def proc_second(d): if len(expanded) == 6: try: expanded[5].index('*') except ValueError: diff_sec = nearest_diff_method(d.second, expanded[5], 60) if diff_sec is not None and diff_sec != 0: d += relativedelta(seconds=diff_sec) return True, d else: d += relativedelta(second=0) return False, d procs = [proc_month, proc_day_of_month, (proc_day_of_week_nth if nth_weekday_of_month else proc_day_of_week), proc_hour, proc_minute, proc_second] while abs(year - current_year) <= self._max_years_between_matches: next = False for proc in procs: (changed, dst) = proc(dst) if changed: month, year = dst.month, dst.year next = True break if next: continue return self._datetime_to_timestamp(dst.replace(microsecond=0)) if is_prev: raise CroniterBadDateError("failed to find prev date") raise CroniterBadDateError("failed to find next date") def _get_next_nearest(self, x, to_check): small = [item for item in to_check if item < x] large = [item for item in to_check if item >= x] large.extend(small) return large[0] def _get_prev_nearest(self, x, to_check): small = [item for item in to_check if item <= x] large = [item for item in to_check if item > x] small.reverse() large.reverse() small.extend(large) return small[0] def _get_next_nearest_diff(self, x, to_check, range_val): for i, d in enumerate(to_check): if d == "l": # if 'l' then it is the last day of month # => its value of range_val d = range_val if d >= x: return d - x return to_check[0] - x + range_val def _get_prev_nearest_diff(self, x, to_check, range_val): candidates = to_check[:] candidates.reverse() for d in candidates: if d != 'l' and d <= x: return d - x if 'l' in candidates: return -x candidate = candidates[0] for c in candidates: # fixed: c < range_val # this code will reject all 31 day of month, 12 month, 59 second, # 23 hour and so on. # if candidates has just a element, this will not harmful. # but candidates have multiple elements, then values equal to # range_val will rejected. if c <= range_val: candidate = c break if candidate > range_val: # fix crontab "0 6 30 3 *" condidates only a element, # then get_prev error return 2021-03-02 06:00:00 return - x return (candidate - x - range_val) @staticmethod def _get_nth_weekday_of_month(year, month, day_of_week): """ For a given year/month return a list of days in nth-day-of-month order. The last weekday of the month is always [-1]. """ w = (day_of_week + 6) % 7 c = calendar.Calendar(w).monthdayscalendar(year, month) if c[0][0] == 0: c.pop(0) return tuple(i[0] for i in c) def is_leap(self, year): if year % 400 == 0 or (year % 4 == 0 and year % 100 != 0): return True else: return False @classmethod def _expand(cls, expr_format, hash_id=None): # Split the expression in components, and normalize L -> l, MON -> mon, # etc. Keep expr_format untouched so we can use it in the exception # messages. expr_aliases = { '@midnight': ('0 0 * * *', 'h h(0-2) * * * h'), '@hourly': ('0 * * * *', 'h * * * * h'), '@daily': ('0 0 * * *', 'h h * * * h'), '@weekly': ('0 0 * * 0', 'h h * * h h'), '@monthly': ('0 0 1 * *', 'h h h * * h'), '@yearly': ('0 0 1 1 *', 'h h h h * h'), '@annually': ('0 0 1 1 *', 'h h h h * h'), } efl = expr_format.lower() hash_id_expr = hash_id is not None and 1 or 0 try: efl = expr_aliases[efl][hash_id_expr] except KeyError: pass expressions = efl.split() if len(expressions) not in VALID_LEN_EXPRESSION: raise CroniterBadCronError(cls.bad_length) expanded = [] nth_weekday_of_month = {} for i, expr in enumerate(expressions): for expanderid, expander in EXPANDERS.items(): expr = expander(cls).expand(efl, i, expr, hash_id=hash_id) e_list = expr.split(',') res = [] while len(e_list) > 0: e = e_list.pop() if i == 4: # Handle special case in the day-of-week expression m = special_weekday_re.match(str(e)) if m: orig_e = e e, nth, last = m.groups() if nth: try: nth = int(nth) assert (nth >= 1 and nth <= 5) except (ValueError, AssertionError): raise CroniterBadCronError( "[{0}] is not acceptable. Invalid day_of_week " "value: '{1}'".format(expr_format, orig_e)) elif last: nth = "l" e = last del last, orig_e else: nth = None # Before matching step_search_re, normalize "*" to "{min}-{max}". # Example: in the minute field, "*/5" normalizes to "0-59/5" t = re.sub(r'^\*(\/.+)$', r'%d-%d\1' % ( cls.RANGES[i][0], cls.RANGES[i][1]), str(e)) m = step_search_re.search(t) if not m: # Before matching step_search_re, # normalize "{start}/{step}" to "{start}-{max}/{step}". # Example: in the minute field, "10/5" normalizes to "10-59/5" t = re.sub(r'^(.+)\/(.+)$', r'\1-%d/\2' % ( cls.RANGES[i][1]), str(e)) m = step_search_re.search(t) if m: # early abort if low/high are out of bounds (low, high, step) = m.group(1), m.group(2), m.group(4) or 1 if i == 2 and high == 'l': high = '31' if not only_int_re.search(low): low = "{0}".format(cls._alphaconv(i, low, expressions)) if not only_int_re.search(high): high = "{0}".format(cls._alphaconv(i, high, expressions)) if ( not low or not high or int(low) > int(high) or not only_int_re.search(str(step)) ): if i == 4 and high == '0': # handle -Sun notation -> 7 high = '7' else: raise CroniterBadCronError( "[{0}] is not acceptable".format(expr_format)) low, high, step = map(int, [low, high, step]) if ( max(low, high) > max(cls.RANGES[i][0], cls.RANGES[i][1]) ): raise CroniterBadCronError( "{0} is out of bands".format(expr_format)) try: rng = range(low, high + 1, step) except ValueError as exc: raise CroniterBadCronError( 'invalid range: {0}'.format(exc)) e_list += (["{0}#{1}".format(item, nth) for item in rng] if i == 4 and nth and nth != "l" else rng) else: if t.startswith('-'): raise CroniterBadCronError(( "[{0}] is not acceptable," "negative numbers not allowed" ).format(expr_format)) if not star_or_int_re.search(t): t = cls._alphaconv(i, t, expressions) try: t = int(t) except ValueError: pass if t in cls.LOWMAP[i] and not ( # do not support 0 as a month either for classical 5 fields cron # or 6fields second repeat form # but still let conversion happen if day field is shifted (i in [2, 3] and len(expressions) == 5) or (i in [3, 4] and len(expressions) == 6) ): t = cls.LOWMAP[i][t] if ( t not in ["*", "l"] and (int(t) < cls.RANGES[i][0] or int(t) > cls.RANGES[i][1]) ): raise CroniterBadCronError( "[{0}] is not acceptable, out of range".format( expr_format)) res.append(t) if i == 4 and nth: if t not in nth_weekday_of_month: nth_weekday_of_month[t] = set() nth_weekday_of_month[t].add(nth) res = set(res) res = sorted(res, key=lambda i: "{:02}".format(i) if isinstance(i, int) else i) if len(res) == cls.LEN_MEANS_ALL[i]: res = ['*'] expanded.append(['*'] if (len(res) == 1 and res[0] == '*') else res) # Check to make sure the dow combo in use is supported if nth_weekday_of_month: dow_expanded_set = set(expanded[4]) dow_expanded_set = dow_expanded_set.difference(nth_weekday_of_month.keys()) dow_expanded_set.discard("*") if dow_expanded_set: raise CroniterUnsupportedSyntaxError( "day-of-week field does not support mixing literal values and nth day of week syntax. " "Cron: '{}' dow={} vs nth={}".format(expr_format, dow_expanded_set, nth_weekday_of_month)) return expanded, nth_weekday_of_month @classmethod def expand(cls, expr_format, hash_id=None): """Shallow non Croniter ValueError inside a nice CroniterBadCronError""" try: return cls._expand(expr_format, hash_id=hash_id) except ValueError as exc: error_type, error_instance, traceback = sys.exc_info() if isinstance(exc, CroniterError): raise if int(sys.version[0]) >= 3: globs, locs = _get_caller_globals_and_locals() exec("raise CroniterBadCronError from exc", globs, locs) else: raise CroniterBadCronError("{0}".format(exc)) @classmethod def is_valid(cls, expression, hash_id=None): try: cls.expand(expression, hash_id=hash_id) except CroniterError: return False else: return True @classmethod def match(cls, cron_expression, testdate, day_or=True): cron = cls(cron_expression, testdate, ret_type=datetime.datetime, day_or=day_or) td, ms1 = cron.get_current(datetime.datetime), relativedelta(microseconds=1) if not td.microsecond: td = td + ms1 cron.set_current(td, force=True) tdp, tdt = cron.get_current(), cron.get_prev() return (max(tdp, tdt) - min(tdp, tdt)).total_seconds() < 60 def croniter_range(start, stop, expr_format, ret_type=None, day_or=True, exclude_ends=False, _croniter=None): """ Generator that provides all times from start to stop matching the given cron expression. If the cron expression matches either 'start' and/or 'stop', those times will be returned as well unless 'exclude_ends=True' is passed. You can think of this function as sibling to the builtin range function for datetime objects. Like range(start,stop,step), except that here 'step' is a cron expression. """ _croniter = _croniter or croniter auto_rt = datetime.datetime # type is used in first if branch for perfs reasons if ( type(start) != type(stop) and not ( isinstance(start, type(stop)) or isinstance(stop, type(start))) ): raise CroniterBadTypeRangeError( "The start and stop must be same type. {0} != {1}". format(type(start), type(stop))) if isinstance(start, (float, int)): start, stop = (datetime.datetime.utcfromtimestamp(t) for t in (start, stop)) auto_rt = float if ret_type is None: ret_type = auto_rt if not exclude_ends: ms1 = relativedelta(microseconds=1) if start < stop: # Forward (normal) time order start -= ms1 stop += ms1 else: # Reverse time order start += ms1 stop -= ms1 year_span = math.floor(abs(stop.year - start.year)) + 1 ic = _croniter(expr_format, start, ret_type=datetime.datetime, day_or=day_or, max_years_between_matches=year_span) # define a continue (cont) condition function and step function for the main while loop if start < stop: # Forward def cont(v): return v < stop step = ic.get_next else: # Reverse def cont(v): return v > stop step = ic.get_prev try: dt = step() while cont(dt): if ret_type is float: yield ic.get_current(float) else: yield dt dt = step() except CroniterBadDateError: # Stop iteration when this exception is raised; no match found within the given year range return class HashExpander: def __init__(self, cronit): self.cron = cronit def do(self, idx, hash_type="h", hash_id=None, range_end=None, range_begin=None): """Return a hashed/random integer given range/hash information""" if range_end is None: range_end = self.cron.RANGES[idx][1] if range_begin is None: range_begin = self.cron.RANGES[idx][0] if hash_type == 'r': crc = random.randint(0, 0xFFFFFFFF) else: crc = binascii.crc32(hash_id) & 0xFFFFFFFF return ((crc >> idx) % (range_end - range_begin + 1)) + range_begin def match(self, efl, idx, expr, hash_id=None, **kw): return hash_expression_re.match(expr) def expand(self, efl, idx, expr, hash_id=None, match='', **kw): """Expand a hashed/random expression to its normal representation""" if match == '': match = self.match(efl, idx, expr, hash_id, **kw) if not match: return expr m = match.groupdict() if m['hash_type'] == 'h' and hash_id is None: raise CroniterBadCronError('Hashed definitions must include hash_id') if m['range_begin'] and m['range_end'] and m['divisor']: # Example: H(30-59)/10 -> 34-59/10 (i.e. 34,44,54) if int(m["divisor"]) == 0: raise CroniterBadCronError("Bad expression: {0}".format(expr)) return '{0}-{1}/{2}'.format( self.do( idx, hash_type=m['hash_type'], hash_id=hash_id, range_end=int(m['divisor']), ) + int(m['range_begin']), int(m['range_end']), int(m['divisor']), ) elif m['range_begin'] and m['range_end']: # Example: H(0-29) -> 12 return str( self.do( idx, hash_type=m['hash_type'], hash_id=hash_id, range_end=int(m['range_end']), range_begin=int(m['range_begin']), ) ) elif m['divisor']: # Example: H/15 -> 7-59/15 (i.e. 7,22,37,52) if int(m["divisor"]) == 0: raise CroniterBadCronError("Bad expression: {0}".format(expr)) return '{0}-{1}/{2}'.format( self.do( idx, hash_type=m['hash_type'], hash_id=hash_id, range_end=int(m['divisor']), ), self.cron.RANGES[idx][1], int(m['divisor']), ) else: # Example: H -> 32 return str( self.do( idx, hash_type=m['hash_type'], hash_id=hash_id, ) ) EXPANDERS = OrderedDict([ ('hash', HashExpander), ])