Browse Source

Adds day of month and month of year cronspec options to crontab scheduler

Closes #569
Keith Perkins 13 years ago
parent
commit
dc6508fdc1
2 changed files with 420 additions and 45 deletions
  1. 165 33
      celery/schedules.py
  2. 255 12
      celery/tests/tasks/test_tasks.py

+ 165 - 33
celery/schedules.py

@@ -14,13 +14,14 @@ from __future__ import absolute_import
 
 import re
 
-from datetime import timedelta
+from datetime import datetime, timedelta
 from dateutil.relativedelta import relativedelta
 
 from . import current_app
 from .utils import is_iterable
 from .utils.timeutils import (timedelta_seconds, weekday, maybe_timedelta,
                               remaining, humanize_seconds)
+from .datastructures import AttributeDict
 
 
 class ParseException(Exception):
@@ -119,6 +120,20 @@ class crontab_parser(object):
         >>> day_of_week = crontab_parser(7).parse("*")
         [0, 1, 2, 3, 4, 5, 6]
 
+    It can also parse day_of_month and month_of_year expressions if initialized
+    with an minimum of 1.  Example usage::
+
+        >>> days_of_month = crontab_parser(31, 1).parse("*/3")
+        [1, 4, 7, 10, 13, 16, 19, 22, 25, 28, 31]
+        >>> months_of_year = crontab_parser(12, 1).parse("*/2")
+        [1, 3, 5, 7, 9, 11]
+        >>> months_of_year = crontab_parser(12, 1).parse("2-12/2")
+        [2, 4, 6, 8, 10, 12]
+
+    The maximum possible expanded value returned is found by the formula::
+
+        max_ + min_ - 1
+
     """
     ParseException = ParseException
 
@@ -126,8 +141,9 @@ class crontab_parser(object):
     _steps = r'/(\w+)?'
     _star = r'\*'
 
-    def __init__(self, max_=60):
+    def __init__(self, max_=60, min_=0):
         self.max_ = max_
+        self.min_ = min_
         self.pats = (
                 (re.compile(self._range + self._steps), self._range_steps),
                 (re.compile(self._range), self._expand_range),
@@ -167,7 +183,7 @@ class crontab_parser(object):
         return self._expand_star()[::int(toks[0])]
 
     def _expand_star(self, *args):
-        return range(self.max_)
+        return range(self.min_, self.max_ + self.min_)
 
     def _expand_number(self, s):
         if isinstance(s, basestring) and s[0] == '-':
@@ -179,6 +195,10 @@ class crontab_parser(object):
                 i = weekday(s)
             except KeyError:
                 raise ValueError("Invalid weekday literal '%s'." % s)
+
+        if i < self.min_:
+            raise ValueError("Invalid beginning range - %s < %s." %
+                                                   (i, self.min_))
         return i
 
 
@@ -191,8 +211,8 @@ class crontab(schedule):
     implementation of cron's features, so it should provide a fair
     degree of scheduling needs.
 
-    You can specify a minute, an hour, and/or a day of the week in any
-    of the following formats:
+    You can specify a minute, an hour, a day of the week, a day of the
+    month, and/or a month in the year in any of the following formats:
 
     .. attribute:: minute
 
@@ -221,10 +241,36 @@ class crontab(schedule):
           (Beware that `day_of_week="*/2"` does not literally mean
           "every two days", but "every day that is divisible by two"!)
 
+    .. attribute:: day_of_month
+
+        - A (list of) integers from 1-31 that represents the days of the
+          month that execution should occur.
+        - A string representing a crontab pattern.  This may get pretty
+          advanced, such as `day_of_month="2-30/3"` (for every even
+          numbered day) or `day_of_month="1-7,15-21"` (for the first and
+          third weeks of the month).
+
+    .. attribute:: month_of_year
+
+        - A (list of) integers from 1-12 that represents the months of
+          the year during which execution can occur.
+        - A string representing a crontab pattern.  This may get pretty
+          advanced, such as `month_of_year="*/3"` (for the first month
+          of every quarter) or `month_of_year="2-12/2"` (for every even
+          numbered month).
+
+    It is important to realize that any day on which execution should
+    occur must be represented by entries in all three of the day and
+    month attributes.  For example, if `day_of_week` is 0 and `day_of_month`
+    is every seventh day, only months that begin on Sunday and are also
+    in the `month_of_year` attribute will have execution events.  Or,
+    `day_of_week` is 1 and `day_of_month` is "1-7,15-21" means every
+    first and third monday of every month present in `month_of_year`.
+
     """
 
     @staticmethod
-    def _expand_cronspec(cronspec, max_):
+    def _expand_cronspec(cronspec, max_, min_=0):
         """Takes the given cronspec argument in one of the forms::
 
             int         (like 7)
@@ -240,13 +286,18 @@ class crontab(schedule):
 
         For the other base types, merely Python type conversions happen.
 
-        The argument `max_` is needed to determine the expansion of '*'.
+        The argument `max_` is needed to determine the expansion of '*'
+        and ranges.
+        The argument `min_` is needed to determine the expansion of '*'
+        and ranges for 1-based cronspecs, such as day of month or month
+        of year. The default is sufficient for minute, hour, and day of
+        week.
 
         """
         if isinstance(cronspec, int):
             result = set([cronspec])
         elif isinstance(cronspec, basestring):
-            result = crontab_parser(max_).parse(cronspec)
+            result = crontab_parser(max_, min_).parse(cronspec)
         elif isinstance(cronspec, set):
             result = cronspec
         elif is_iterable(cronspec):
@@ -257,40 +308,113 @@ class crontab(schedule):
                     "following types: int, basestring, or an iterable type. "
                     "'%s' was given." % type(cronspec))
 
-        # assure the result does not exceed the max
+        # assure the result does not preceed the min or exceed the max
         for number in result:
-            if number >= max_:
+            if number >= max_ + min_ or number < min_:
                 raise ValueError(
                         "Invalid crontab pattern. Valid "
-                        "range is 0-%d. '%d' was found." % (max_ - 1, number))
+                        "range is %d-%d. '%d' was found." %
+                        (min_, max_ - 1 + min_, number))
 
         return result
 
-    def __init__(self, minute='*', hour='*', day_of_week='*', nowfun=None):
+    def _delta_to_next(self, last_run_at, next_hour, next_minute):
+        """
+        Takes a datetime of last run, next minute and hour, and
+        returns a relativedelta for the next scheduled day and time.
+        Only called when day_of_month and/or month_of_year cronspec
+        is specified to further limit scheduled task execution.
+        """
+        from bisect import bisect, bisect_left
+
+        datedata = AttributeDict(year=last_run_at.year)
+        days_of_month = sorted(self.day_of_month)
+        months_of_year = sorted(self.month_of_year)
+
+        def day_out_of_range(year, month, day):
+            try:
+                datetime(year=year, month=month, day=day)
+            except ValueError:
+                return True
+            return False
+
+        def roll_over():
+            while True:
+                flag = (datedata.dom == len(days_of_month) or
+                            day_out_of_range(datedata.year,
+                                             months_of_year[datedata.moy],
+                                             days_of_month[datedata.dom]))
+                if flag:
+                    datedata.dom = 0
+                    datedata.moy += 1
+                    if datedata.moy == len(months_of_year):
+                        datedata.moy = 0
+                        datedata.year += 1
+                else:
+                    break
+
+        if last_run_at.month in self.month_of_year:
+            datedata.dom = bisect(days_of_month, last_run_at.day)
+            datedata.moy = bisect_left(months_of_year, last_run_at.month)
+        else:
+            datedata.dom = 0
+            datedata.moy = bisect(months_of_year, last_run_at.month)
+        roll_over()
+
+        while not (datetime(year=datedata.year,
+                            month=months_of_year[datedata.moy],
+                            day=days_of_month[datedata.dom]
+                           ).isoweekday() % 7
+                  ) in self.day_of_week:
+            datedata.dom += 1
+            roll_over()
+
+        return relativedelta(year=datedata.year,
+                             month=months_of_year[datedata.moy],
+                             day=days_of_month[datedata.dom],
+                             hour=next_hour,
+                             minute=next_minute,
+                             second=0,
+                             microsecond=0)
+
+    def __init__(self, minute='*', hour='*', day_of_week='*',
+            day_of_month='*', month_of_year='*', nowfun=None):
         self._orig_minute = minute
         self._orig_hour = hour
         self._orig_day_of_week = day_of_week
+        self._orig_day_of_month = day_of_month
+        self._orig_month_of_year = month_of_year
         self.hour = self._expand_cronspec(hour, 24)
         self.minute = self._expand_cronspec(minute, 60)
         self.day_of_week = self._expand_cronspec(day_of_week, 7)
+        self.day_of_month = self._expand_cronspec(day_of_month, 31, 1)
+        self.month_of_year = self._expand_cronspec(month_of_year, 12, 1)
         self.nowfun = nowfun or current_app.now
 
     def __repr__(self):
-        return "<crontab: %s %s %s (m/h/d)>" % (self._orig_minute or "*",
-                                                self._orig_hour or "*",
-                                                self._orig_day_of_week or "*")
+        return ("<crontab: %s %s %s %s %s (m/h/d/dM/MY)>" %
+                                            (self._orig_minute or "*",
+                                             self._orig_hour or "*",
+                                             self._orig_day_of_week or "*",
+                                             self._orig_day_of_month or "*",
+                                             self._orig_month_of_year or "*"))
 
     def __reduce__(self):
         return (self.__class__, (self._orig_minute,
                                  self._orig_hour,
-                                 self._orig_day_of_week), None)
+                                 self._orig_day_of_week,
+                                 self._orig_day_of_month,
+                                 self._orig_month_of_year), None)
 
     def remaining_estimate(self, last_run_at):
         """Returns when the periodic task should run next as a timedelta."""
-        weekday = last_run_at.isoweekday()
-        weekday = 0 if weekday == 7 else weekday  # Sunday is day 0, not day 7.
+        dow_num = last_run_at.isoweekday() % 7  # Sunday is day 0, not day 7
+
+        execute_this_date = (last_run_at.month in self.month_of_year and
+                                last_run_at.day in self.day_of_month and
+                                    dow_num in self.day_of_week)
 
-        execute_this_hour = (weekday in self.day_of_week and
+        execute_this_hour = (execute_this_date and
                                 last_run_at.hour in self.hour and
                                     last_run_at.minute < max(self.minute))
 
@@ -302,8 +426,8 @@ class crontab(schedule):
                                   microsecond=0)
         else:
             next_minute = min(self.minute)
-            execute_today = (weekday in self.day_of_week and
-                                 last_run_at.hour < max(self.hour))
+            execute_today = (execute_this_date and
+                                last_run_at.hour < max(self.hour))
 
             if execute_today:
                 next_hour = min(hour for hour in self.hour
@@ -314,17 +438,23 @@ class crontab(schedule):
                                       microsecond=0)
             else:
                 next_hour = min(self.hour)
-                next_day = min([day for day in self.day_of_week
-                                    if day > weekday] or
-                               self.day_of_week)
-                add_week = next_day == weekday
-
-                delta = relativedelta(weeks=add_week and 1 or 0,
-                                      weekday=(next_day - 1) % 7,
-                                      hour=next_hour,
-                                      minute=next_minute,
-                                      second=0,
-                                      microsecond=0)
+                all_dom_moy = (self._orig_day_of_month == "*" and
+                                  self._orig_month_of_year == "*")
+                if all_dom_moy:
+                    next_day = min([day for day in self.day_of_week
+                                        if day > dow_num] or
+                                self.day_of_week)
+                    add_week = next_day == dow_num
+
+                    delta = relativedelta(weeks=add_week and 1 or 0,
+                                          weekday=(next_day - 1) % 7,
+                                          hour=next_hour,
+                                          minute=next_minute,
+                                          second=0,
+                                          microsecond=0)
+                else:
+                    delta = self._delta_to_next(last_run_at,
+                                                next_hour, next_minute)
 
         return remaining(last_run_at, delta, now=self.nowfun())
 
@@ -345,7 +475,9 @@ class crontab(schedule):
 
     def __eq__(self, other):
         if isinstance(other, crontab):
-            return (other.day_of_week == self.day_of_week and
+            return (other.month_of_year == self.month_of_year and
+                    other.day_of_month == self.day_of_month and
+                    other.day_of_week == self.day_of_week and
                     other.hour == self.hour and
                     other.minute == self.minute)
         return other is self

+ 255 - 12
celery/tests/tasks/test_tasks.py

@@ -558,6 +558,21 @@ def weekly():
     pass
 
 
+@task.periodic_task(run_every=crontab(hour=7, minute=30,
+                                      day_of_week="thursday",
+                                      day_of_month="8-14"))
+def monthly():
+    pass
+
+
+@task.periodic_task(run_every=crontab(hour=7, minute=30,
+                                      day_of_week="thursday",
+                                      day_of_month="8-14",
+                                      month_of_year=3))
+def yearly():
+    pass
+
+
 def patch_crontab_nowfun(cls, retval):
 
     def create_patcher(fun):
@@ -589,6 +604,10 @@ class test_crontab_parser(Case):
         self.assertEqual(crontab_parser(24).parse('*'), set(range(24)))
         self.assertEqual(crontab_parser(60).parse('*'), set(range(60)))
         self.assertEqual(crontab_parser(7).parse('*'), set(range(7)))
+        self.assertEqual(crontab_parser(31, 1).parse('*'),
+                          set(range(1, 31 + 1)))
+        self.assertEqual(crontab_parser(12, 1).parse('*'),
+                          set(range(1, 12 + 1)))
 
     def test_parse_range(self):
         self.assertEqual(crontab_parser(60).parse('1-10'),
@@ -597,12 +616,16 @@ class test_crontab_parser(Case):
                           set(range(0, 20 + 1)))
         self.assertEqual(crontab_parser().parse('2-10'),
                           set(range(2, 10 + 1)))
+        self.assertEqual(crontab_parser(60, 1).parse('1-10'),
+                          set(range(1, 10 + 1)))
 
     def test_parse_groups(self):
         self.assertEqual(crontab_parser().parse('1,2,3,4'),
                           set([1, 2, 3, 4]))
         self.assertEqual(crontab_parser().parse('0,15,30,45'),
                           set([0, 15, 30, 45]))
+        self.assertEqual(crontab_parser(min_=1).parse('1,2,3,4'),
+                          set([1, 2, 3, 4]))
 
     def test_parse_steps(self):
         self.assertEqual(crontab_parser(8).parse('*/2'),
@@ -611,6 +634,12 @@ class test_crontab_parser(Case):
                           set(i * 2 for i in xrange(30)))
         self.assertEqual(crontab_parser().parse('*/3'),
                           set(i * 3 for i in xrange(20)))
+        self.assertEqual(crontab_parser(8, 1).parse('*/2'),
+                          set([1, 3, 5, 7]))
+        self.assertEqual(crontab_parser(min_=1).parse('*/2'),
+                          set(i * 2 + 1 for i in xrange(30)))
+        self.assertEqual(crontab_parser(min_=1).parse('*/3'),
+                          set(i * 3 + 1 for i in xrange(20)))
 
     def test_parse_composite(self):
         self.assertEqual(crontab_parser(8).parse('*/2'), set([0, 2, 4, 6]))
@@ -622,6 +651,16 @@ class test_crontab_parser(Case):
                      20, 25, 30, 35, 40, 45, 50, 55]))
         self.assertEqual(crontab_parser().parse('1-9/2'),
                 set([1, 3, 5, 7, 9]))
+        self.assertEqual(crontab_parser(8, 1).parse('*/2'), set([1, 3, 5, 7]))
+        self.assertEqual(crontab_parser(min_=1).parse('2-9/5'), set([2, 7]))
+        self.assertEqual(crontab_parser(min_=1).parse('2-10/5'), set([2, 7]))
+        self.assertEqual(crontab_parser(min_=1).parse('2-11/5,3'),
+                set([2, 3, 7]))
+        self.assertEqual(crontab_parser(min_=1).parse('2-4/3,*/5,1-21/4'),
+                set([1, 2, 5, 6, 9, 11, 13, 16, 17,
+                     21, 26, 31, 36, 41, 46, 51, 56]))
+        self.assertEqual(crontab_parser(min_=1).parse('1-9/2'),
+                set([1, 3, 5, 7, 9]))
 
     def test_parse_errors_on_empty_string(self):
         with self.assertRaises(ParseException):
@@ -642,6 +681,8 @@ class test_crontab_parser(Case):
     def test_expand_cronspec_eats_iterables(self):
         self.assertEqual(crontab._expand_cronspec(iter([1, 2, 3]), 100),
                          set([1, 2, 3]))
+        self.assertEqual(crontab._expand_cronspec(iter([1, 2, 3]), 100, 1),
+                         set([1, 2, 3]))
 
     def test_expand_cronspec_invalid_type(self):
         with self.assertRaises(TypeError):
@@ -653,9 +694,15 @@ class test_crontab_parser(Case):
     def test_eq(self):
         self.assertEqual(crontab(day_of_week="1, 2"),
                          crontab(day_of_week="1-2"))
-        self.assertEqual(crontab(minute="1", hour="2", day_of_week="5"),
-                         crontab(minute="1", hour="2", day_of_week="5"))
+        self.assertEqual(crontab(day_of_month="1, 16, 31"),
+                         crontab(day_of_month="*/15"))
+        self.assertEqual(crontab(minute="1", hour="2", day_of_week="5",
+                                 day_of_month="10", month_of_year="5"),
+                         crontab(minute="1", hour="2", day_of_week="5",
+                                 day_of_month="10", month_of_year="5"))
         self.assertNotEqual(crontab(minute="1"), crontab(minute="2"))
+        self.assertNotEqual(crontab(month_of_year="1"),
+                            crontab(month_of_year="2"))
         self.assertFalse(object() == crontab(minute="1"))
         self.assertFalse(crontab(minute="1") == object())
 
@@ -709,6 +756,128 @@ class test_crontab_remaining_estimate(Case):
                                    datetime(2010, 9, 11, 14, 30, 15))
         self.assertEqual(next, datetime(2010, 9, 13, 0, 5))
 
+    def test_monthday(self):
+        next = self.next_ocurrance(crontab(minute=30,
+                                           hour=14,
+                                           day_of_month=18),
+                                   datetime(2010, 9, 11, 14, 30, 15))
+        self.assertEqual(next, datetime(2010, 9, 18, 14, 30))
+
+    def test_not_monthday(self):
+        next = self.next_ocurrance(crontab(minute=[5, 42],
+                                           day_of_month=29),
+                                   datetime(2010, 1, 22, 14, 30, 15))
+        self.assertEqual(next, datetime(2010, 1, 29, 0, 5))
+
+    def test_weekday_monthday(self):
+        next = self.next_ocurrance(crontab(minute=30,
+                                           hour=14,
+                                           day_of_week="mon",
+                                           day_of_month=18),
+                                   datetime(2010, 1, 18, 14, 30, 15))
+        self.assertEqual(next, datetime(2010, 10, 18, 14, 30))
+
+    def test_monthday_not_weekday(self):
+        next = self.next_ocurrance(crontab(minute=[5, 42],
+                                           day_of_week="sat",
+                                           day_of_month=29),
+                                   datetime(2010, 1, 29, 0, 5, 15))
+        self.assertEqual(next, datetime(2010, 5, 29, 0, 5))
+
+    def test_weekday_not_monthday(self):
+        next = self.next_ocurrance(crontab(minute=[5, 42],
+                                           day_of_week="mon",
+                                           day_of_month=18),
+                                   datetime(2010, 1, 11, 0, 5, 15))
+        self.assertEqual(next, datetime(2010, 1, 18, 0, 5))
+
+    def test_not_weekday_not_monthday(self):
+        next = self.next_ocurrance(crontab(minute=[5, 42],
+                                           day_of_week="mon",
+                                           day_of_month=18),
+                                   datetime(2010, 1, 10, 0, 5, 15))
+        self.assertEqual(next, datetime(2010, 1, 18, 0, 5))
+
+    def test_leapday(self):
+        next = self.next_ocurrance(crontab(minute=30,
+                                           hour=14,
+                                           day_of_month=29),
+                                   datetime(2012, 1, 29, 14, 30, 15))
+        self.assertEqual(next, datetime(2012, 2, 29, 14, 30))
+
+    def test_not_leapday(self):
+        next = self.next_ocurrance(crontab(minute=30,
+                                           hour=14,
+                                           day_of_month=29),
+                                   datetime(2010, 1, 29, 14, 30, 15))
+        self.assertEqual(next, datetime(2010, 3, 29, 14, 30))
+
+    def test_weekmonthdayyear(self):
+        next = self.next_ocurrance(crontab(minute=30,
+                                           hour=14,
+                                           day_of_week="fri",
+                                           day_of_month=29,
+                                           month_of_year=1),
+                                   datetime(2010, 1, 22, 14, 30, 15))
+        self.assertEqual(next, datetime(2010, 1, 29, 14, 30))
+
+    def test_monthdayyear_not_week(self):
+        next = self.next_ocurrance(crontab(minute=[5, 42],
+                                           day_of_week="wed,thu",
+                                           day_of_month=29,
+                                           month_of_year="1,4,7"),
+                                   datetime(2010, 1, 29, 14, 30, 15))
+        self.assertEqual(next, datetime(2010, 4, 29, 0, 5))
+
+    def test_weekdaymonthyear_not_monthday(self):
+        next = self.next_ocurrance(crontab(minute=30,
+                                           hour=14,
+                                           day_of_week="fri",
+                                           day_of_month=29,
+                                           month_of_year="1-10"),
+                                   datetime(2010, 1, 29, 14, 30, 15))
+        self.assertEqual(next, datetime(2010, 10, 29, 14, 30))
+
+    def test_weekmonthday_not_monthyear(self):
+        next = self.next_ocurrance(crontab(minute=[5, 42],
+                                           day_of_week="fri",
+                                           day_of_month=29,
+                                           month_of_year="2-10"),
+                                   datetime(2010, 1, 29, 14, 30, 15))
+        self.assertEqual(next, datetime(2010, 10, 29, 0, 5))
+
+    def test_weekday_not_monthdayyear(self):
+        next = self.next_ocurrance(crontab(minute=[5, 42],
+                                           day_of_week="mon",
+                                           day_of_month=18,
+                                           month_of_year="2-10"),
+                                   datetime(2010, 1, 11, 0, 5, 15))
+        self.assertEqual(next, datetime(2010, 10, 18, 0, 5))
+
+    def test_monthday_not_weekdaymonthyear(self):
+        next = self.next_ocurrance(crontab(minute=[5, 42],
+                                           day_of_week="mon",
+                                           day_of_month=29,
+                                           month_of_year="2-4"),
+                                   datetime(2010, 1, 29, 0, 5, 15))
+        self.assertEqual(next, datetime(2010, 3, 29, 0, 5))
+
+    def test_monthyear_not_weekmonthday(self):
+        next = self.next_ocurrance(crontab(minute=[5, 42],
+                                           day_of_week="mon",
+                                           day_of_month=29,
+                                           month_of_year="2-4"),
+                                   datetime(2010, 2, 28, 0, 5, 15))
+        self.assertEqual(next, datetime(2010, 3, 29, 0, 5))
+
+    def test_not_weekmonthdayyear(self):
+        next = self.next_ocurrance(crontab(minute=[5, 42],
+                                           day_of_week="fri,sat",
+                                           day_of_month=29,
+                                           month_of_year="2-10"),
+                                   datetime(2010, 1, 28, 14, 30, 15))
+        self.assertEqual(next, datetime(2010, 5, 29, 0, 5))
+
 
 class test_crontab_is_due(Case):
 
@@ -721,12 +890,16 @@ class test_crontab_is_due(Case):
         self.assertEqual(c.minute, set(range(60)))
         self.assertEqual(c.hour, set(range(24)))
         self.assertEqual(c.day_of_week, set(range(7)))
+        self.assertEqual(c.day_of_month, set(range(1, 32)))
+        self.assertEqual(c.month_of_year, set(range(1, 13)))
 
     def test_simple_crontab_spec(self):
         c = crontab(minute=30)
         self.assertEqual(c.minute, set([30]))
         self.assertEqual(c.hour, set(range(24)))
         self.assertEqual(c.day_of_week, set(range(7)))
+        self.assertEqual(c.day_of_month, set(range(1, 32)))
+        self.assertEqual(c.month_of_year, set(range(1, 13)))
 
     def test_crontab_spec_minute_formats(self):
         c = crontab(minute=30)
@@ -772,16 +945,6 @@ class test_crontab_is_due(Case):
         c = crontab(day_of_week='*/2')
         self.assertEqual(c.day_of_week, set([0, 2, 4, 6]))
 
-    def seconds_almost_equal(self, a, b, precision):
-        for index, skew in enumerate((+0.1, 0, -0.1)):
-            try:
-                self.assertAlmostEqual(a, b + skew, precision)
-            except AssertionError:
-                if index + 1 >= 3:
-                    raise
-            else:
-                break
-
     def test_crontab_spec_invalid_dow(self):
         with self.assertRaises(ValueError):
             crontab(day_of_week='fooday-barday')
@@ -792,6 +955,58 @@ class test_crontab_is_due(Case):
         with self.assertRaises(ValueError):
             crontab(day_of_week='12')
 
+    def test_crontab_spec_dom_formats(self):
+        c = crontab(day_of_month=5)
+        self.assertEqual(c.day_of_month, set([5]))
+        c = crontab(day_of_month='5')
+        self.assertEqual(c.day_of_month, set([5]))
+        c = crontab(day_of_month='2,4,6')
+        self.assertEqual(c.day_of_month, set([2, 4, 6]))
+        c = crontab(day_of_month='*/5')
+        self.assertEqual(c.day_of_month, set([1, 6, 11, 16, 21, 26, 31]))
+
+    def test_crontab_spec_invalid_dom(self):
+        with self.assertRaises(ValueError):
+            crontab(day_of_month=0)
+        with self.assertRaises(ValueError):
+            crontab(day_of_month='0-10')
+        with self.assertRaises(ValueError):
+            crontab(day_of_month=32)
+        with self.assertRaises(ValueError):
+            crontab(day_of_month='31,32')
+
+    def test_crontab_spec_moy_formats(self):
+        c = crontab(month_of_year=1)
+        self.assertEqual(c.month_of_year, set([1]))
+        c = crontab(month_of_year='1')
+        self.assertEqual(c.month_of_year, set([1]))
+        c = crontab(month_of_year='2,4,6')
+        self.assertEqual(c.month_of_year, set([2, 4, 6]))
+        c = crontab(month_of_year='*/2')
+        self.assertEqual(c.month_of_year, set([1, 3, 5, 7, 9, 11]))
+        c = crontab(month_of_year='2-12/2')
+        self.assertEqual(c.month_of_year, set([2, 4, 6, 8, 10, 12]))
+
+    def test_crontab_spec_invalid_moy(self):
+        with self.assertRaises(ValueError):
+            crontab(month_of_year=0)
+        with self.assertRaises(ValueError):
+            crontab(month_of_year='0-5')
+        with self.assertRaises(ValueError):
+            crontab(month_of_year=13)
+        with self.assertRaises(ValueError):
+            crontab(month_of_year='12,13')
+
+    def seconds_almost_equal(self, a, b, precision):
+        for index, skew in enumerate((+0.1, 0, -0.1)):
+            try:
+                self.assertAlmostEqual(a, b + skew, precision)
+            except AssertionError:
+                if index + 1 >= 3:
+                    raise
+            else:
+                break
+
     def test_every_minute_execution_is_due(self):
         last_ran = self.now - timedelta(seconds=61)
         due, remaining = every_minute.run_every.is_due(last_ran)
@@ -897,3 +1112,31 @@ class test_crontab_is_due(Case):
                 datetime(2010, 5, 6, 7, 30))
         self.assertFalse(due)
         self.assertEqual(remaining, 6 * 24 * 60 * 60 - 3 * 60 * 60)
+
+    @patch_crontab_nowfun(monthly, datetime(2010, 5, 13, 7, 30))
+    def test_monthly_execution_is_due(self):
+        due, remaining = monthly.run_every.is_due(
+                datetime(2010, 4, 8, 7, 30))
+        self.assertTrue(due)
+        self.assertEqual(remaining, 28 * 24 * 60 * 60)
+
+    @patch_crontab_nowfun(monthly, datetime(2010, 5, 9, 10, 30))
+    def test_monthly_execution_is_not_due(self):
+        due, remaining = monthly.run_every.is_due(
+                datetime(2010, 4, 8, 7, 30))
+        self.assertFalse(due)
+        self.assertEqual(remaining, 4 * 24 * 60 * 60 - 3 * 60 * 60)
+
+    @patch_crontab_nowfun(yearly, datetime(2010, 3, 11, 7, 30))
+    def test_yearly_execution_is_due(self):
+        due, remaining = yearly.run_every.is_due(
+                datetime(2009, 3, 12, 7, 30))
+        self.assertTrue(due)
+        self.assertEqual(remaining, 364 * 24 * 60 * 60)
+
+    @patch_crontab_nowfun(yearly, datetime(2010, 3, 7, 10, 30))
+    def test_yearly_execution_is_not_due(self):
+        due, remaining = yearly.run_every.is_due(
+                datetime(2009, 3, 12, 7, 30))
+        self.assertFalse(due)
+        self.assertEqual(remaining, 4 * 24 * 60 * 60 - 3 * 60 * 60)