Commits

Tetsuya Morimoto committed aa6d681

added CronScheduler component tests and refactoring
fixed #10072 about cron syntax issue

Comments (0)

Files changed (2)

src/traccron/scheduler.py

 ##
 ###############################################################################
 
+import re
+
 from trac.core import Component, implements
 from traccron.api import ISchedulerType
 from traccron.core import CronConfig
         for schedule_value in self._get_task_schedule_value_list(task):
             msg = 'task is scheduled: %s, %s' % (task.getId(), schedule_value)
             self.env.log.debug(msg)
-            if schedule_value and self.compareTime(currentTime,
-                                                   schedule_value):
+            if self.compareTime(currentTime, schedule_value):
                 return True
         self.env.log.debug('no matching schedule found')
         return False
     Scheduler that used a cron-like syntax to specified when task must
     be triggered
     """
-    def __init__(self):
-        SchedulerType.__init__(self)
+    cron_syntax = re.compile(r"""
+        (?P<value>
+            \* |
+            (?P<pre>\d+)(?P<sep>[-|\/])(?P<post>\d+) |  # e.g.) 1-31, 0/2
+            \d+
+        )""", re.X)
 
-        # Some utility classes / functions first
     class AllMatch(set):
         """
         Universal set - match everything
 
     _allMatch = AllMatch()
     _omitMatch = OmitMatch()
-    _event_parameter_for_cron_pos = {
-        0: None,
-        1: "min",
-        2: "hour",
-        3: "day",
-        4: "month",
-        5: "dow",
-        6: "year"
-    }
 
     # The actual Event class
     class Event(object):
         def conv_to_set(self, obj):  # Allow single integer to be provided
             if isinstance(obj, (int, long)):
                 return set([obj])  # Single item
-            if not isinstance(obj, set):
-                obj = set(obj)
-                return obj
+            elif not isinstance(obj, set):
+                return set(obj)
             else:
                 return obj
 
                     (t.tm_wday in self.dow) and
                     (t.tm_year in self.year))
 
+    def __init__(self):
+        SchedulerType.__init__(self)
+
     def getId(self):
-        return "cron"
+        return 'cron'
 
     def getHint(self):
-        return "use cron like expression"
+        return """Use cron like expression,
+        set either day-of-month or day-of-week and other one must be '?'
+
+        ex: *  *  *  ?  *  ?  *
+            ┬  ┬  ┬  ┬  ┬  ┬  ┬
+            │  │  │  │  │  │  └--── year (omissible)
+            │  │  │  │  │  └─────── day of week (1 - 7, 1 is Monday)
+            │  │  │  │  └────────── month (1 - 12)
+            │  │  │  └───────────── day of month (1 - 31)
+            │  │  └──────────────── hour (0 - 23)
+            │  └─────────────────── min (0 - 59)
+            └────────────────────── None (reserved?)
+        """
 
     def compareTime(self, currentTime, schedule_value):
-        if schedule_value:
-            self.env.log.debug(self.getId() + " compare currentTime=" + str(currentTime) + " with schedule_value " + schedule_value)
+        self._output_comp_debug_log(currentTime, schedule_value)
+        try:
+            cron_settings = self._parse_cron_expression(schedule_value)
+        except CronScheduler.CronExpressionError:
+            self.env.log.debug('Failed to parse cron expression')
+            return False
         else:
-            self.env.log.debug(self.getId() + " compare currentTime=" + str(currentTime) + " with NO schedule_value ")
-        if schedule_value:
-            try:
-                kwargs = self._parse_cron_expression(cron=schedule_value)
-            except CronScheduler.CronExpressionError:
-                self.env.log.debug("Failed to parse cron expression, can't compare current time")
-                return False
-            else:
-                return CronScheduler.Event(**kwargs).matchtime(t=currentTime)
-        else:
-            return False
+            return CronScheduler.Event(**cron_settings).matchtime(currentTime)
 
-    def _parse_cron_default(self, kwargs, event_param, value, min_value, max_value, adjust=0):
+    def _output_range_error(self, value, name, min_value, max_value):
+        msg = 'invalid cron expression: %s is out of range [%d-%d] for %s'
+        self.env.log.error(msg % (value, min_value, max_value, name))
+
+    def _get_cron_value_range(self, field, name, min_value, max_value, adjust):
+        _begin = int(field.get('pre'))
+        _end = int(field.get('post'))
+        if _begin < min_value:
+            self._output_range_error(_begin, name, min_value, max_value)
+            raise CronScheduler.CronExpressionError()
+        elif _end > max_value:
+            self._output_range_error(_end, name, min_value, max_value)
+            raise CronScheduler.CronExpressionError()
+        # cron range expression is inclusive
+        return range(_begin + adjust, _end + 1 + adjust)
+
+    def _get_cron_value_every(self, field, name, min_value, max_value, adjust):
+        _begin = int(field.get('pre'))
+        if ((_begin < min_value) or (_begin > max_value)):
+            self._output_range_error(value, name, min_value, max_value)
+            raise CronScheduler.CronExpressionError()
+        _step = int(field.get('post'))
+        # cron range expression is inclusive
+        return range(_begin + adjust, max_value + 1 + adjust, _step)
+
+    def _get_cron_value_int(self, value, name, min_value, max_value, adjust):
+        _value = int(value)
+        if (_value < min_value) or (_value > max_value):
+            self._output_range_error(value, name, min_value, max_value)
+            raise CronScheduler.CronExpressionError()
+        return _value + adjust
+
+    def _parse_cron_value(self, name, value, min_value, max_value, adjust=0):
         """
         utility method to parse value of a cron item.
         Support of *, range expression (ex 1-10)
         adjust is used to translate value
         (ex: first day of week is 0 in python and 1 in Cron)
         """
-        if value == "*":
-            kwargs[event_param] = CronScheduler._allMatch
+        match = re.match(CronScheduler.cron_syntax, value)
+        if not match:
+            self.env.log.error('invalid cron expression: ')
+            raise CronScheduler.CronExpressionError()
+
+        cron_field = match.groupdict()
+        cron_value = cron_field.get('value')
+        cron_sep = cron_field.get('sep')
+        if cron_value == '*':
+            return CronScheduler._allMatch
+        elif cron_sep == '-':
+            return self._get_cron_value_range(cron_field, name,
+                                              min_value, max_value, adjust)
+        elif cron_sep == '/':
+            return self._get_cron_value_every(cron_field, name,
+                                              min_value, max_value, adjust)
         else:
-            begin, sep, end = value.partition("-")
-            if sep == '-':
-                # sanity check
-                _begin = int(begin)
-                _end = int(end)
-                if _begin < min_value:
-                    self.env.log.error("invalid cron expression: start value of %s out of range [%d-%d] for %s" % (value, min_value, max_value, event_param))
-                    raise CronScheduler.CronExpressionError()
-                if _end > max_value:
-                    self.env.log.error("invalid cron expression: end value of %s out of range [%d-%d] for %s" % (value, min_value, max_value, event_param))
-                    raise CronScheduler.CronExpressionError()
-                # cron range expression is inclusive
-                kwargs[event_param] = range(_begin + adjust, _end + 1 + adjust)
-            else:
-                begin, sep, step = value.partition("/")
-                if sep == '/':
-                    # sanity check
-                    _begin = int(begin)
-                    if ((_begin < min_value) or (_begin > max_value)):
-                        self.env.log.error("invalid cron expression: start value of %s out of range [%d-%d] for %s" % (value, min_value, max_value, event_param))
-                        raise CronScheduler.CronExpressionError()
-                    _step = int(step)
-                    # cron range expression is inclusive
-                    kwargs[event_param] = range(_begin + adjust, max_value + 1 + adjust, _step)
-                else:
-                    # assuming  int single value
-                    _value = int(value)
-                    if ((_value < min_value) or (_value > max_value)):
-                        self.env.log.error("invalid cron expression: value of %s out of range [%d-%d] for %s" % (value, min_value, max_value, event_param))
-                        raise CronScheduler.CronExpressionError()
+            return self._get_cron_value_int(cron_value, name,
+                                            min_value, max_value, adjust)
 
-                    kwargs[event_param] = _value + adjust
+    def _parse_cron_day_fields(self, day_of_month, day_of_week):
+        if (day_of_month == '?' and day_of_week == '?') or \
+           (day_of_month != '?' and day_of_week != '?'):
+            self.env.log.error('invalid cron expression: set value '
+                               'either day-of-month or day-of-week')
+            raise CronScheduler.CronExpressionError()
 
-        # range value
+        if day_of_month != '?':
+            day_value = self._parse_cron_value('day', day_of_month, 1, 31)
+            dow_value = CronScheduler._omitMatch
+        elif day_of_week != '?':
+            day_value = CronScheduler._omitMatch
+            dow_value = self._parse_cron_value('day-of-week', day_of_week,
+                                               1, 7, adjust=-1)
+        return day_value, dow_value
 
-    def _parse_cron_dmonth(self, kwargs, __event_parameter_for_cron_pos, event_param, value):
-        other_event_parm = __event_parameter_for_cron_pos.get(5)
-        if value != '?':
-            if kwargs.has_key(other_event_parm) and kwargs[other_event_parm] != '?':
-                self.env.log.error("invalid cron expression: ? %s already have a value" % other_event_parm)
-                raise CronScheduler.CronExpressionError()
-        if value == '?':
-            if kwargs.has_key(other_event_parm) and kwargs[other_event_parm] == '?':
-                self.env.log.error("invalid cron expression: ? is already used for %s" % other_event_parm)
-                raise CronScheduler.CronExpressionError()
-            else:
-                kwargs[event_param] = CronScheduler._omitMatch
-        else:
-            self._parse_cron_default(kwargs, event_param, value, 1, 31)
-
-    def _parse_cron_dweek(self, kwargs, __event_parameter_for_cron_pos, event_param, value):
-        other_event_parm = __event_parameter_for_cron_pos.get(3)
-        if value != '?':
-            if kwargs.has_key(other_event_parm) and kwargs[other_event_parm] != '?':
-                self.env.log.error("invalid cron expression: ? %s already have a value" % other_event_parm)
-                raise CronScheduler.CronExpressionError()
-        if value == '?':
-            if kwargs.has_key(other_event_parm) and kwargs[other_event_parm] == '?':
-                self.env.log.error("invalid cron expression: ? is already used for %s" % other_event_parm)
-                raise CronScheduler.CronExpressionError()
-            else:
-                kwargs[event_param] = CronScheduler._omitMatch
-        else:
-            # day of week starts at 1
-            # since python localtime day of week start from 0
-            self._parse_cron_default(kwargs, event_param, value, 1, 7, adjust=-1)
-
-    def _parse_cron_expression(self, cron):
-        '''
+    def _parse_cron_expression(self, cron_text):
+        """
         Parse cron expression and return dictionary of argument key/value
         suitable for Event object
-        '''
-        self.env.log.debug("parsing cron expression %s" % cron)
-        kwargs = {}
-        arglist = cron.split()
-        if len(arglist) < 6:
-            self.env.log.error("cron expression must have at least 6 items")
+        """
+        self.env.log.debug('parsing cron expression: "%s"' % cron_text)
+        if not cron_text:
             raise CronScheduler.CronExpressionError()
-        __event_parameter_for_cron_pos = CronScheduler._event_parameter_for_cron_pos
-        for pos in __event_parameter_for_cron_pos.keys():
-            event_param = __event_parameter_for_cron_pos.get(pos)
-            if event_param and pos < len(arglist):
-                value = arglist[pos]
-                if pos == 1:
-                    self._parse_cron_default(kwargs, event_param, value, 0, 59)
-                elif pos == 2:
-                    self._parse_cron_default(kwargs, event_param, value, 0, 23)
-                elif pos == 3:
-                    self._parse_cron_dmonth(kwargs, __event_parameter_for_cron_pos, event_param, value)
-                elif pos == 4:
-                    self._parse_cron_default(kwargs, event_param, value, 1, 12)
-                elif pos == 5:
-                    self._parse_cron_dweek(kwargs, __event_parameter_for_cron_pos, event_param, value)
-                elif pos == 6:
-                    self._parse_cron_default(kwargs, event_param, value, 1970, 2099)
 
-        # deal with optional item
-        year_parameter_name = __event_parameter_for_cron_pos.get(6)
-        if not kwargs.has_key(year_parameter_name):
-            kwargs[year_parameter_name] = CronScheduler._allMatch
+        cron_params = cron_text.split()[0:7]
+        cron_params_length = len(cron_params)
 
-        self.env.log.debug("result of parsing is %s" % str(kwargs))
-        return kwargs
+        if cron_params_length < 6:
+            self.env.log.error('cron expression must have at least 6 items')
+            raise CronScheduler.CronExpressionError()
+        elif cron_params_length == 6:
+            cron_params.append('*')  # year field can be abbreviate
+
+        day_dow = cron_params[3], cron_params[5]
+        day_value, dow_value = self._parse_cron_day_fields(*day_dow)
+        cron_settings = {
+            'min': self._parse_cron_value('min', cron_params[1], 0, 59),
+            'hour': self._parse_cron_value('hour', cron_params[2], 0, 23),
+            'day': day_value,
+            'month': self._parse_cron_value('month', cron_params[4], 1, 12),
+            'dow': dow_value,
+            'year': self._parse_cron_value('year', cron_params[6], 1970, 2099)
+        }
+        self.env.log.debug('result of parsing is %s' % cron_settings)
+        return cron_settings

tests/test_cron_scheduler.py

+# -*- coding: utf-8 -*-
+from time import localtime, time
+
+import pytest
+from utils import compat_attrgetter
+
+time_now = time()
+local_now = localtime(time_now)
+local_now_plus_1hour = localtime(time_now + 3600)
+
+_getter_all = compat_attrgetter('tm_min', 'tm_hour', 'tm_mday',
+                                'tm_mon', 'tm_wday', 'tm_year')
+_getter_mday = compat_attrgetter('tm_min', 'tm_hour', 'tm_mday',
+                                 'tm_mon', 'tm_year')
+
+sch_value_invalid_all = '* %s %s %s %s %s %s' % _getter_all(local_now)
+sch_value_now_mday = '* %s %s %s %s ? %s' % _getter_mday(local_now)
+sch_value_now_wday = '* %s %s ? %s %s %s' % (local_now.tm_min,
+                                             local_now.tm_hour,
+                                             local_now.tm_mon,
+                                             local_now.tm_wday + 1,
+                                             local_now.tm_year)
+sch_value_every_min = '* 0/1 * * * ? *'
+sch_value_every_hour = '* %s 0/1 * * ? *' % local_now.tm_min
+sch_value_range_mday = '* %s %s 1-31 * ? *' % (local_now.tm_min,
+                                               local_now.tm_hour)
+sch_value_range_wday = '* %s %s ? * 1-7 *' % (local_now.tm_min,
+                                              local_now.tm_hour)
+sch_value_now_plus_1hour = '* %s %s %s %s ? %s' % (_getter_mday(
+                                                   local_now_plus_1hour))
+sch_value_no_year = '* %s %s * * ?' % (local_now.tm_min, local_now.tm_hour)
+
+# error cron sentences
+sch_value_out_of_min = '* 0/80 * * * ? *'
+sch_value_out_of_hour = '* 1 20-25 * * ? *'
+sch_value_out_of_mday = '* 2 7 32 * ? *'
+sch_value_out_of_month = '* 3 8 ? 13 1 *'
+sch_value_out_of_wday = '* 4 9 ? * 0 *'
+
+
+def pytest_funcarg__cron_scheduler(request, component):
+    cron_scheduler = component['cron_scheduler']
+    return cron_scheduler
+
+
+def test_cron_scheduler_basic(cron_scheduler):
+    assert 'cron' == cron_scheduler.getId()
+
+
+@pytest.mark.parametrize(('cur_time', 'sch_value', 'expected'), [
+    (local_now, sch_value_invalid_all, False),
+    (local_now, sch_value_now_mday, True),
+    (local_now, sch_value_now_wday, True),
+    (local_now, sch_value_every_min, True),
+    (local_now, sch_value_every_hour, True),
+    (local_now, sch_value_range_mday, True),
+    (local_now, sch_value_range_wday, True),
+    (local_now, sch_value_now_plus_1hour, False),
+    (local_now, sch_value_no_year, True),
+    (local_now, None, False),
+    (local_now, sch_value_out_of_min, False),
+    (local_now, sch_value_out_of_hour, False),
+    (local_now, sch_value_out_of_mday, False),
+    (local_now, sch_value_out_of_month, False),
+    (local_now, sch_value_out_of_wday, False),
+    (local_now, '', False),
+])
+def test_cron_scheduler_compareTime(cron_scheduler,
+                                    cur_time, sch_value, expected):
+    assert expected is cron_scheduler.compareTime(cur_time, sch_value)