MONTH_OFFSET = {'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'may': 5, 'jun': 6,
'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 'nov':11, 'dec':12}
WEEK_OFFSET = {'sun': 0, 'mon': 1, 'tue': 2, 'wed': 3, 'thu': 4, 'fri': 5,
'sat': 6}
ALIASES = {
'@yearly': '0 0 1 1 *',
'@annually': '0 0 1 1 *',
'@monthly': '0 0 1 * *',
'@weekly': '0 0 * * 0',
'@daily': '0 0 * * *',
'@hourly': '0 * * * *',
}
class Job(object):
"""
A periodic job as used by :class:`Scheduler`.
"""
def __init__(self, crontab, job_func=None, scheduler=None):
"""
Constructor
:param crontab:
string containing crontab pattern
Its tokens may be either: 1 (if alias), 5 (without year token), 6 (with year token)
:param job_func:
the job 0-ary function to run
if None, you should set it later
:param scheduler:
scheduler to register with
if None, you should set it later
"""
self.job_func = job_func
self.scheduler = scheduler
self.running = False
if crontab in ALIASES.keys():
crontab = ALIASES[crontab]
crontab_lst = crontab.split()
if crontab_lst[0] in ALIASES.keys():
raise ValueError("Cannot mix @Aliases and other tokens")
if len(crontab_lst) == 5:
crontab_lst.append("*")
if len(crontab_lst) != 6:
raise ValueError("Each crontab pattern *must* contain either 5 or 6 items")
self.allowed_min = self._parse_min(crontab_lst[0])
self.allowed_hours = self._parse_hour(crontab_lst[1])
self.allowed_days_of_month = self._parse_day_in_month(crontab_lst[2])
self.allowed_months = self._parse_month(crontab_lst[3])
self.allowed_days_of_week = self._parse_day_in_week(crontab_lst[4])
self.allowed_years = self._parse_year(crontab_lst[5])
self.allowed_last_day_of_month = True if -1 in self.allowed_days_of_month else False
if -1 in self.allowed_years:
raise ValueError("Wrong format '%s' : 'L' is meaningless talking about Years" % crontab_lst[5])
self.crontab_pattern = crontab_lst
def _parse_token(self, token, offsets):
if token in offsets.keys():
newtoken = offsets[token]
try:
return int(newtoken)
except ValueError:
raise ValueError("token %s maps to %s, however the latter is not an integer" % (token, newtoken))
try:
return int(token)
except ValueError:
raise ValueError("token %s is not an integer, nor it is a known constant" % token)
def _parse_common(self, s, maxval, offsets={}):
"""
generate a set of integers, corresponding to "allowed values".
Work for minute, hours, weeks, month, ad days of week, because they are all "similar".
Does not work very well for years and days of month
Supported formats: "*", "*/3", "1,2,3", "L", "1,2-5,jul,10-L", "50-10"
:param maxval: es. 60 for minutes, 12 for month, ...
:param offsets: a dict mapping names (es. "mar") to their offsets (es. 2).
"""
if "L" not in offsets:
offsets["L"] = maxval-1
if s == "*":
return range(0, maxval) #every minute
elif s.startswith("*/"): #every tot minutes
try:
step = int(s[2:])
except ValueError:
raise ValueError("Wrong format '%s' - expecting an integer after '*/'" % s)
return range(0, maxval, step)
else: #at given minutes
ranges = s.split(",") # ["1","2-5","jul","10-L"]
ranges = [x.split("-") for x in ranges] # [["1"],["2","5"],["aug"], ["10","L"]]
ranges = [[self._parse_token(w, offsets) for w in x] for x in ranges] # [[1],[2,5],[7], [10,11]]
#DEBUG
#import pdb
#pdb.set_trace()
if max([len(x) for x in ranges]) > 2:
raise ValueError("Wrong format '%s' - a string x-y-z is meaningless" % s)
ranges1 = [x for x in ranges if len(x)==1] # [[1], [7]]
ranges1.extend([range(x[0], x[1]+1) for x in ranges if len(x)==2 and x[0]<=x[1]]) # [[2,3,4,5], [10, 11]]
ranges1.extend([range(x[0], maxval) for x in ranges if len(x)==2 and x[0]>x[1]]) # [] in this case
ranges1.extend([range(0, x[1]+1) for x in ranges if len(x)==2 and x[0]>x[1]]) # [] in this case
return set([z for rng in ranges1 for z in rng])
def _parse_min(self, s):
return self._parse_common(s, 60)
def _parse_hour(self, s):
return self._parse_common(s, 24)
def _parse_month(self, s):
return self._parse_common(s, 12, MONTH_OFFSET)
def _parse_day_in_week(self, s):
return self._parse_common(s, 7, WEEK_OFFSET)
def _parse_year(self, s):
""" to put things simple, I assume a range of years between 0 and 3000 ... This is mostly useless. """
return self._parse_common(s, 3000, {"L" : -1})
def _parse_day_in_month(self, s):
return self._parse_common(s, 31, {"L" : -1}) #this works by chance
def _check_day_in_month(self, now):
if self.allowed_last_day_of_month:
#this is a hack for avoiding to calculate "L" when not needed
import calendar
last_day_of_month = calendar.monthrange(now.year, now.month)[1]
if now.day == last_day_of_month:
return True;
return now.day in self.allowed_days_of_month;
def __lt__(self, other):
"""
Periodic Jobs are sortable based on the scheduled time they
run next.
"""
return self.next_run < other.next_run
def should_run(self):
"""
:return: ``True`` if the job should be run now.
"""
import datetime
now = datetime.datetime.now()
#FIXME was: return datetime.datetime.now() >= self.next_run
return not self.running \
and now.year in self.allowed_years \
and now.month in self.allowed_months \
and now.weekday() in self.allowed_days_in_week \
and now.hour in self.allowed_hours \
and now.minute in self.allowed_minutes \
and self._check_day_in_month(now);
def run(self):
"""
Run the job.
:return: The return value returned by the `job_func`
"""
self.running = True
ret = self.job_func()
self.running = false
return ret
def run_if_should(self):
"""
Run the job if needed.
:return: The return value returned by the `job_func`
"""
if self.should_run():
logger.info('Running job %s', self)
return self.run()