Ticket #2474: crontab.2.patch
| File crontab.2.patch, 13.6 KB (added by bochecha, 2 years ago) |
|---|
-
setup.py
20 20 license=license, 21 21 platforms=["any"], 22 22 zip_safe=False, 23 install_requires=[ 24 "python-dateutil >= 1.5" 25 ], 23 26 packages=packages, 24 27 #package_data=package_data, 25 28 keywords=[ -
tgscheduler/__init__.py
11 11 12 12 from scheduler import start_scheduler, stop_scheduler, \ 13 13 add_interval_task, add_monthly_task, add_single_task, \ 14 add_weekday_task, cancel14 add_weekday_task, add_cron_like_task, cancel -
tgscheduler/kronos.py
53 53 "ForkedSingleTask", 54 54 "ForkedTaskMixin", 55 55 "ForkedWeekdayTask", 56 "ForkedCronLikeTask", 56 57 "IntervalTask", 57 58 "MonthdayTask", 59 "CronLikeTask", 58 60 "Scheduler", 59 61 "SingleTask", 60 62 "Task", … … 64 66 "ThreadedSingleTask", 65 67 "ThreadedTaskMixin", 66 68 "ThreadedWeekdayTask", 69 "ThreadedCronLikeTask", 67 70 "WeekdayTask", 68 71 "add_interval_task", 69 72 "add_monthday_task", 70 73 "add_single_task", 71 74 "add_weekday_task", 75 "add_cron_like_task", 72 76 "cancel", 73 77 "method", 74 78 ] … … 83 87 import logging 84 88 log = logging.getLogger(__name__) 85 89 90 import datetime 91 from dateutil.rrule import rrule, SECONDLY 92 93 # bounds for each field of the cron-like syntax 94 MINUTE_BOUNDS = (0, 59) 95 HOUR_BOUNDS = (0, 23) 96 DOM_BOUNDS = (1, 31) 97 MONTH_BOUNDS = (1, 12) 98 DOW_BOUNDS = (0, 7) 99 100 # some fields of the cron-like syntax can be specified as names 101 MONTH_MAPPING = {'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'may': 5, 'jun': 6, 102 'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12} 103 DOW_MAPPING = {'sun': 0, 'mon': 1, 'tue': 2, 'wed': 3, 'thu': 4, 'fri': 5, 104 'sat': 6} 105 106 86 107 class method: 87 108 sequential = "sequential" 88 109 forked = "forked" … … 259 280 self.schedule_task_abs(task, firsttime) 260 281 return task 261 282 283 def add_cron_like_task(self, action, taskname, cron_str, 284 processmethod, args, kw): 285 """ Add a new Cron-like Task to the schedule. """ 286 if not args: 287 args = [] 288 if not kw: 289 kw = {} 290 if processmethod == method.sequential: 291 TaskClass = CronLikeTask 292 elif processmethod == method.threaded: 293 TaskClass = ThreadedCronLikeTask 294 elif processmethod == method.forked: 295 TaskClass = ForkedCronLikeTask 296 else: 297 raise ValueError("Invalid processmethod") 298 if self.running: 299 self._acquire_lock() 300 try: 301 if self.tasks.has_key(taskname): 302 raise ValueError("A task with the name %s already exists" % taskname) 303 finally: 304 self._release_lock() 305 else: 306 if self.tasks.has_key(taskname): 307 raise ValueError("A task with the name %s already exists" % taskname) 308 task = TaskClass(taskname, action, cron_str, args, kw) 309 firsttime = task.get_schedule_time() 310 self.schedule_task_abs(task, firsttime) 311 return task 312 262 313 def schedule_task(self, task, delay): 263 314 """ 264 315 Add a new task to the scheduler with the given delay (seconds). … … 497 548 self.action(*self.args, **self.kw) 498 549 499 550 551 class CronLikeTask(Task): 552 """A class that is scheduled with a cron-like syntax. 553 """ 554 def __init__(self, name, action, cron_str, args=None, kw=None): 555 Task.__init__(self, name, action, args, kw) 556 557 self.cron_str = cron_str 558 try: 559 min_str, hour_str, dom_str, month_str, dow_str = cron_str.split() 560 except: 561 raise ValueError("Invalid value: %s" % cron_str) 562 563 self.minutes = self.__process_str(min_str, MINUTE_BOUNDS) 564 self.hours = self.__process_str(hour_str, HOUR_BOUNDS) 565 self.doms = self.__process_str(dom_str, DOM_BOUNDS) 566 self.months = self.__process_str(month_str, MONTH_BOUNDS, mapping=MONTH_MAPPING) 567 568 # dows are somewhat special: 569 # * Cron accepts both 0 and 7 for Sunday 570 # => we deal with that using the "% 7" operator and a temporary set 571 # * Python starts with Monday = 0 while Cron starts with Sunday = 0 572 # => we deal with that by substracting 1 573 # * (SUN - 1) % 7 = (0 - 1) % 7 = 6 574 # => we need to sort the list 575 self.dows = list(set([ (dow - 1) % 7 \ 576 for dow in self.__process_str(dow_str, 577 DOW_BOUNDS, mapping=DOW_MAPPING) ])) 578 self.dows.sort() 579 580 def __process_str(self, time_str, bounds, mapping=None): 581 """Transforms a field of the cron-like string into a list 582 583 Note: specifying a range as a mix of integers and names 584 (months and dows) is NOT supported 585 586 @param time_str: a field in the cron-like string 587 @type time_str: string 588 589 @param bounds: the acceptable limits for this field 590 @type bounds: 2-tuple of integers 591 592 @param mapping: the mapping between names and integer values 593 @type mapping: dict 594 """ 595 freq = 1 596 if '/' in time_str: 597 try: 598 time_str, freq = time_str.split('/') 599 freq = int(freq) 600 except: 601 raise ValueError("Invalid value: '%s'" % time_str) 602 603 if time_str == '*': 604 result = range(bounds[0], bounds[1] + 1) 605 return result[::freq] 606 607 result = list() 608 609 for item in time_str.split(','): 610 if not '-' in item: 611 # ex: time_str = "1,4,23" 612 try: 613 time = int(item) 614 except: 615 if mapping and mapping.has_key(item.lower()): 616 time = mapping[item.lower()] 617 else: 618 raise ValueError("Invalid value: '%s'" % time_str) 619 620 if time < bounds[0] or time > bounds [1]: 621 raise ValueError("Invalid value: '%s'" % time_str) 622 623 result.append(time) 624 else: 625 # ex: time_str = "1-4,7-9" 626 try: 627 interval_low, interval_high = item.split('-') 628 except: 629 # an interval can only have one dash 630 raise ValueError("Invalid value: '%s'" % time_str) 631 632 try: 633 # intervals are specified as integers 634 time_low = int(interval_low) 635 time_high = int(interval_high) 636 except: 637 if mapping and mapping.has_key(interval_low.lower()) \ 638 and mapping.has_key(interval_high.lower()): 639 # in some cases names can be used (months or dows) 640 time_low = mapping[interval_low.lower()] 641 time_high = mapping[interval_high.lower()] 642 else: 643 raise ValueError("Invalid value: '%s'" % time_str) 644 645 if time_low < bounds[0] or time_high > bounds [1]: 646 raise ValueError("Invalid value: '%s'" % time_str) 647 648 result.extend(range(time_low, time_high + 1)) 649 650 # filter results by frequency 651 return result[::freq] 652 653 def get_schedule_time(self): 654 """Determine the next execution time of the task. 655 """ 656 now = datetime.datetime.now() 657 658 # rrule will return `now` as the next execution time if `now` fills the 659 # criteria. This has the nasty effect of relaunching the same task over 660 # and over during one second. 661 # That only happens when `now.second == 0` (which is always the case 662 # after the first execution as cron doesn't handle anything below the 663 # minute), so let's add one second to `now`, just to be sure 664 now = now + datetime.timedelta(seconds=1) 665 666 next = list(rrule(SECONDLY, count=1, bysecond=0, byminute=self.minutes, 667 byhour=self.hours, bymonthday=self.doms, bymonth=self.months, 668 byweekday=self.dows, dtstart=now)[0].timetuple()) 669 670 return time.mktime(next) 671 672 def reschedule(self, scheduler): 673 """Reschedule this task according to its cron-like string. 674 """ 675 if scheduler.running: 676 abstime = self.get_schedule_time() 677 scheduler.schedule_task_abs(self, abstime) 678 679 500 680 try: 501 681 import threading 502 682 … … 566 746 """Monthday Task that executes in its own thread.""" 567 747 pass 568 748 749 class ThreadedCronLikeTask(ThreadedTaskMixin, CronLikeTask): 750 """Cron-like Task that executes in its own thread.""" 751 pass 752 569 753 except ImportError: 570 754 # threading is not available 571 755 pass … … 638 822 """Monthday Task that executes in its own process.""" 639 823 pass 640 824 825 class ForkedCronLikeTask(ForkedTaskMixin, CronLikeTask): 826 """Cron-like Task that executes in its own process.""" 827 pass 641 828 642 829 if __name__ == "__main__": 643 830 log.setLevel(logging.DEBUG) -
tgscheduler/scheduler.py
147 147 weekdays=None, monthdays=monthdays, timeonday=timeonday, 148 148 processmethod=processmethod, args=args, kw=kw) 149 149 150 def add_cron_like_task(self, action, cron_str, args=None, kw=None, 151 processmethod=method.threaded, taskname=None): 152 """ 153 Runs a task based on a cron-like syntax. 154 155 @param cron_str: The scheduling information, written in a cron-like syntax 156 157 @param action: The callable that will be called at the time you request 158 @param args: Tuple of positional parameters to pass to the action 159 @param kw: Keyword arguments to pass to the action 160 @param taskname: Tasks can have a name (stored in task.name), which can 161 help if you're trying to keep track of many tasks. 162 @param processmethod: By default, each task will be run in a new thread. 163 You can also pass in turbogears.scheduler.method.sequential or 164 turbogears.scheduler.method.forked. 165 """ 166 si = self._get_scheduler() 167 return si.add_cron_like_task(action=action, taskname=taskname, 168 cron_str=cron_str, processmethod=processmethod, 169 args=args, kw=kw) 170 150 171 def get_task(self, taskname): 151 172 """ 152 173 Retrieve a task by task name … … 181 202 add_weekday_task = scheduler.add_weekday_task 182 203 add_single_task = scheduler.add_single_task 183 204 add_interval_task = scheduler.add_interval_task 205 add_cron_like_task = scheduler.add_cron_like_task 184 206 get_task = scheduler.get_task 185 207 get_tasks = scheduler.get_tasks 186 208 cancel = scheduler.cancel -
tgscheduler/tests/test_scheduler.py
1 from nose.tools import raises 2 1 3 from tgscheduler import start_scheduler, stop_scheduler, add_single_task,\ 2 add_interval_task, add_monthly_task, add_weekday_task 4 add_interval_task, add_monthly_task, add_weekday_task, \ 5 add_cron_like_task 3 6 4 7 def functest(*args, **kws): 5 8 pass … … 25 28 assert task.action == functest 26 29 assert task.name == "intervaltest" 27 30 31 def test_cron_like_business_hours(): 32 task = add_cron_like_task(functest, "*/30 8-12,14-18 * * MON-FRI", taskname="crontest_business_hours") 33 assert task.action == functest 34 assert task.name == "crontest_business_hours" 35 assert task.minutes == [0, 30], \ 36 "Invalid minutes: %s" % t.minutes 37 assert task.hours == [8, 9, 10, 11, 12, 14, 15, 16, 17, 18], \ 38 "Invalid hours: %s" % t.hours 39 assert task.doms == range(1, 32), \ 40 "Invalid doms: %s" % t.doms 41 assert task.months == range(1, 13), \ 42 "Invalid months: %s" % t.months 43 assert task.dows == range(0, 5), \ 44 "Invalid dows: %s" % t.dows 45 46 def test_cron_like_complex(): 47 task = add_cron_like_task(functest, "5-10,25-30,57 8-12,14-18 2,25-28 Jan-mar,10-12 SuN-wEd", taskname="crontest_complex") 48 assert task.action == functest 49 assert task.name == "crontest_complex" 50 assert task.minutes == [5, 6, 7, 8, 9, 10, 25, 26, 27, 28, 29, 30, 57], \ 51 "Invalid minutes: %s" % t.minutes 52 assert task.hours == [8, 9, 10, 11, 12, 14, 15, 16, 17, 18], \ 53 "Invalid hours: %s" % t.hours 54 assert task.doms == [2, 25, 26, 27, 28], \ 55 "Invalid doms: %s" % t.doms 56 assert task.months == [1, 2, 3, 10, 11, 12], \ 57 "Invalid months: %s" % t.months 58 assert task.dows == [0, 1, 2, 6], \ 59 "Invalid dows: %s" % t.dows 60 61 @raises(ValueError) 62 def test_cron_like_failure(): 63 """ This test should fail with ValueError as we feed the scheduler with 64 an incorrect value: `Jan-12` (mixing names and integers is not supported) 65 """ 66 task = add_cron_like_task(functest, "* * * Jan-12 *", taskname="crontest3") 67 assert False, "This shouldn't have worked" 68 69 def test_stop_scheduler(): 28 70 stop_scheduler() 29 71 30