# -*- coding: utf-8 -*-# Copyright (C) 2009-2010 Max Arnold <lwarxx@gmail.com>.# All rights reserved.# Redistribution and use in source and binary forms, with or without# modification, are permitted provided that the following conditions are met:# 1. Redistributions of source code must retain the above copyright notice,# this list of conditions and the following disclaimer.# 2. Redistributions in binary form must reproduce the above copyright notice,# this list of conditions and the following disclaimer in the documentation# and/or other materials provided with the distribution.# 3. Neither the name of copyright holder nor the names of contributors may# be used to endorse or promote products derived from this software without# specific prior written permission.# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE# POSSIBILITY OF SUCH DAMAGE."""Python module for programmatic event scheduling using cron-like syntax withseconds precision.get_matched_jobs() method should be called periodically and will return list oftasks to execute if their time has arrived. Latency can be specified if strictcalling periodicity (at least once per second) can not be guaranteed. Protectionagainst time jitter also implemented.Each job is described using extended cron-like syntax with six fields:Field Allowed values------------ --------------Second 0 - 59Minute 0 - 59Hour 0 - 23Day of month 1 - 31Month 1 - 12 (jan - dec)Day of week 1 - 7 (mon - sun)Fields are separated with space and can contain ranges, comma separated listsor asterisk symbol (*) which is expanded as full range. Ranges also can be usedwith step value, for example 0-59/10 or */2. For more examples refer to crontabmanual page.Cron record is matched when all fields match current date/time (logical AND).Job itself is specified using arbitrary python object or data structure. Itsinterpretation and execution is up to calling program."""importreimporttimefromdatetimeimportdatetimefromoperatorimportitemgetterclasspycron(object):"""Main cron class """# enums are declared as lists in order to use .index methodmonth_enum=['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec']wday_enum=['mon','tue','wed','thu','fri','sat','sun']f_ranges=[{'name':'Second','min':0,'max':59},{'name':'Minute','min':0,'max':59},{'name':'Hour','min':0,'max':23},{'name':'Day of month','min':1,'max':31},{'name':'Month','min':1,'max':12,'enum':month_enum},{'name':'Day of week','min':1,'max':7,'enum':wday_enum}# TODO: 0 - sunday]cron_split_re=re.compile('(?<![/,\-\s])\s+(?![/,\-\s])')cron_int_re=re.compile('^0*\d{1,2}$')def__init__(self,latency=10):self.tlast=Noneself.latency=abs(int(latency))self.jobs=[]self.jobrecs={}self.taskhist=set()defadd_job(self,cronrecord,job,jobinfo=""):"""Add cron job cronrecord - string in form of 'SS MM HH DD MO DW', with crontab-like syntax job - job identifier, string or callable (calling is not performed) jobinfo - optional string (for debugging purposes) Return False if error, True if job already exists, total job count if success """j=pycron.parse_record(cronrecord)ifjisFalse:returnFalsej=(j,job)ifjinself.jobs:returnTrueself.jobs.append(j)self.jobrecs[j]=(cronrecord,jobinfo)returnlen(self.jobs)defprint_jobs(self):forjinself.jobs:printself.jobrecs[j]defdel_job(self):# Do something with task history!passdefwayback(self,jobs=None,wbtime=86400,tnow=None,stopfirst=True):"""Look back in time (wbtime seconds maximum) and find last matching jobs Useful right after instantiation if previous task should be executed again. Does not affect internal state """wbtasks=set()iftnowisNone:tnow=int(time.mktime(datetime.today().timetuple()))ifjobs==None:jobs=self.jobsifstopfirst:fortsinxrange(tnow,tnow-wbtime-1,-1):forjinjobs:ifpycron.match_job(j[0],ts):returntuple((j[1],))returntuple()else:forjinjobs:fortsinxrange(tnow,tnow-wbtime-1,-1):ifpycron.match_job(j[0],ts):wbtasks.add((j[1],ts))breakreturntuple([t[0]fortinsorted(wbtasks,key=itemgetter(1),reverse=True)])defget_matched_jobs(self,tnow=None):"""Return tuple of matched task identifiers """tasks=set()iftnowisNone:tnow=int(time.mktime(datetime.today().timetuple()))ifself.tlastisNone:self.tlast=tnow-1firstrun=Trueelse:firstrun=Falsetdiff=tnow-self.tlastiftdiff==0:# time not changedreturntuple(tasks)eliffirstrunorabs(tdiff)>self.latency:# time jumped back or forward too muchself.clear_taskhist()forjinself.jobs:th=(j[0],j[1],tnow)ifpycron.match_job(j[0],tnow):self.taskhist.add(th)tasks.add((j[1],tnow))else:# time jumped back or forward, but still within latency windowt1=min(self.tlast,tnow)t2=max(self.tlast,tnow)fortsinxrange(t1,t2+1):forjinself.jobs:th=(j[0],j[1],ts)ifpycron.match_job(j[0],ts)andthnotinself.taskhist:self.taskhist.add(th)tasks.add((j[1],ts))self.purge_taskhist(tnow)self.tlast=tnowreturntuple([t[0]fortinsorted(tasks,key=itemgetter(1))])defclear_taskhist(self):self.taskhist=set()defpurge_taskhist(self,tnow=None):"""Iterate over task history (previously matched jobs) and drop everything not within latency window """iftnowisNone:tnow=int(time.mktime(datetime.today().timetuple()))thist=self.taskhist# tuple is necessary since we are modifying set in place:forthintuple(self.taskhist):ifth[2]<tnow-self.latency:self.taskhist.remove(th)@staticmethoddefparse_fnum(nfield,num):"""Parse and validate field value (also accepts weekday/month enums) nfield - field number (0-5) num - field value (string) Return integer value or False if error """ifpycron.cron_int_re.match(num):n=int(num)ifpycron.f_ranges[nfield]['min']<=n<=pycron.f_ranges[nfield]['max']:returnnelif'enum'inpycron.f_ranges[nfield]andnuminpycron.f_ranges[nfield]['enum']:returnpycron.f_ranges[nfield]['enum'].index(num)+pycron.f_ranges[nfield]['min']returnFalse@staticmethoddefparse_step(step):"""Parse and validate step value step - step value (string) Return integer value or False if error """ifpycron.cron_int_re.match(step):n=int(step)ifn>0:returnnreturnFalse@staticmethoddefparse_range(nfield,frange):"""Parse and validate range/step value nfield - field number (0-5) frange - field value Return tuple (range_start, range_end, step) or False if error """# split range into value and stepfrange=frange.split('/')iflen(frange)>2:# too many "/" symbolsreturnFalse# split value into range start and endrparts=frange[0].split('-')iflen(rparts)>2:# too many "-" symbolsreturnFalse# parse stepiflen(frange)==2:step=pycron.parse_step(frange[1])ifstepisFalse:returnFalseelse:# default step valuestep=1# parse rangeiflen(rparts)==2:r1=pycron.parse_fnum(nfield,rparts[0])r2=pycron.parse_fnum(nfield,rparts[1])ifr1isFalseorr2isFalseorr1>r2:returnFalseelse:ifrparts[0]=='*':r1=pycron.f_ranges[nfield]['min']r2=pycron.f_ranges[nfield]['max']else:r1=pycron.parse_fnum(nfield,rparts[0])ifr1isFalse:returnFalser2=r1return(r1,r2,step)@staticmethoddefparse_field(nfield,field):"""Parse and validate field nfield - field number (0-5) field - field value Return tuple with unrolled range values or False if error """# remove all whitespacesfield=''.join(field.split())rangeset=[]forfrangeinfield.split(','):r=pycron.parse_range(nfield,frange)ifrisFalse:returnFalseforiinxrange(r[0],r[1]+1,r[2]):# TODO: optimize# uroll all range valuesrangeset.append(i)# return only unique valuesreturntuple(sorted(set(rangeset)))@staticmethoddefparse_record(record):"""Parse and validate cron record Return six unrolled ranges as a tuple, or False if error """# split record into six fieldsfields=pycron.cron_split_re.split(record.strip())iflen(fields)!=6:returnFalse# parse and validate each field and build unrolled tuplerec=[]fornfield,fieldinenumerate(fields):f=pycron.parse_field(nfield,field)iffisFalse:returnFalserec.append(f)returntuple(rec)@staticmethoddefmatch_job(r,ts):"""Check if parsed cron record equals timestamp """t=datetime.fromtimestamp(ts)ift.secondinr[0]andt.minuteinr[1] \
andt.hourinr[2]andt.dayinr[3] \
andt.monthinr[4]andt.isoweekday()inr[5]:returnTruereturnFalseif__name__=='__main__':c=pycron()c.add_job('*/5 * * * * *','job_id1','job_info1')c.add_job('* * 0 * * *','job_id2','job_info2')printc.wayback()whileTrue:time.sleep(1)printdatetime.today(),c.get_matched_jobs()