Repository: pallets-eco/croniter

Issue:
Wrong weekday range
The weekday range should be 0-6 as specified in [Cron](https://en.wikipedia.org/wiki/Cron#Cron_expression). However, croniter is accepting `7` as a valid weekday value.

Current:
```python
>>> from croniter import croniter
>>> croniter.is_valid("* * * * 7")
True
>>>
```

Expecting:
```python
>>> from croniter import croniter
>>> croniter.is_valid("* * * * 7")
False
>>>
```

The line causing the error seems to be: https://github.com/kiorky/croniter/blob/4d6bb9747ddaaaf5cf44af7e4e821065cf9969fa/src/croniter/croniter.py#L140

Hints from issue comments:
@kiorky: Well, 7 is an alias to day `0`, see: https://github.com/kiorky/croniter/blob/3.0.4/src/croniter/croniter.py#L221
This is an old feature that i never was fond of, but the project is now dead.

I removed it in already released `4.0.0` to stick to standard cron.@gibsondan: Chiming in in support of the previous behavior after we got at least one bug report after upgrading - That same wiki page from the original post here indicates that 7 is allowed on some systems, and I think most Linux crontabs support it.@WillDaSilva: I'll add that we can't change how our library handles cron expressions without making a major version bump ourselves. To avoid that, but still upgrade to croniter v4, we'll have to add code that ~~replaces 7 in the day-of-week field with 0~~ works around 7 not being supported.

I haven't fully thought through whether having the 7 be an alias for 0 is a good idea in a greenfield project, but since this isn't a greenfield project, and instead has many users, the upgrade to croniter v4 will likely cause bugs in lots of software that uses it, or else impose a burden as everyone who uses it who doesn't want to break things has to implement workarounds to preserve the historical behaviour.

Regardless, @kiorky, you have my thanks for maintaining this useful project.@gibsondan: (+1 re: thanks!)

I thought about swapping in 0 for 7 as well but wasn't immediately sure if that would break "6-7" or not, haven't had a chance to test to confirm yet. Maybe swapping in the day of the week strings (6 => Sat, 7 = Sun, etc.)?@kiorky: > I'll add that we can't change how our library handles cron expressions without making a major version bump ourselves. To avoid that, but still upgrade to croniter v4, we'll have to add code that ~replaces 7 in the day-of-week field with 0~ works around 7 not being supported.

On that specific point, please note that a major version bump has been done, 3->4.@kiorky: > I thought about swapping in 0 for 7 as well but wasn't immediately sure if that would break "6-7" or not, haven't had a chance to test to confirm yet. Maybe swapping in the day of the week strings (6 => Sat, 7 = Sun, etc.)?

If you had took the time to read last changelog, and last changesets along that release, you would have been insured of what behavior to expect around the SUNDAY ranges which indeed need particular care.
You can refer to the https://github.com/kiorky/croniter/blob/4.0.0/src/croniter/tests/test_croniter.py#L1979 `test_sunday_ranges_to` & `test_sunday_ranges_from` tests to know what to expect.

As of today, `Sat-Sun` or `6-0` would work meaning saturday followed by the nearest sunday, but not `0-6` as this would mean each day of week.@kiorky: So as asked, DOW7 behavior is restored, and now documented and will stay as-is.
Also, ranges calculation have again been reworked to support all backtracking expressions (sat-sun, wed-sun, apr-dec, apr-feb, etc).
Please see documentation and tests added in 5.0.1: https://github.com/kiorky/croniter/compare/4.0.0...5.0.1

Solution patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 7bf6d25..efde536 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,11 +1,10 @@
 Changelog
 ==============
 
-4.0.1 (unreleased)
+5.0.1 (unreleased)
 ------------------
 
-- Nothing changed yet.
-
+- Community wanted: Reintroduce 7 as DayOfWeek in deviation from standard cron (#90). [kiorky]
 
 4.0.0 (2024-10-28)
 ------------------
diff --git a/README.rst b/README.rst
index 4caf6ce..9a38505 100644
--- a/README.rst
+++ b/README.rst
@@ -285,6 +285,27 @@ Random "R" definition keywords are supported, and remain consistent only within
     datetime.datetime(2021, 4, 11, 4, 19)
 
 
+Note about Ranges
+=================
+
+Note that as a deviation from cron standard, croniter is somehow laxist with ranges and will allow ranges of ``Jan-Dec``, & ``Sun-Sat`` in reverse way and interpret them as following examples:
+
+    - ``Apr-Jan``: from April to january
+    - ``Sat-Sun``: Saturday, Sunday
+    - ``Wed-Sun``: Wednesday to Saturday, Sunday
+
+Please note that if a /step is given, it will be respected.
+
+Note about Sunday
+=================
+
+Note that as a deviation from cron standard, croniter like numerous cron implementations supports ``SUNDAY`` to be expressed as ``DAY7``, allowing such expressions:
+
+    - ``0 0 * * 7``
+    - ``0 0 * * 6-7``
+    - ``0 0 * * 6,7``
+
+
 Keyword expressions
 ===================
 
@@ -303,7 +324,6 @@ What they evaluate to depends on whether you supply hash_id: no hash_id correspo
     @annually    0 0 1 1 *    H H H H * H
     ============ ============ ================
 
-
 Upgrading
 ==========
 
diff --git a/src/croniter/croniter.py b/src/croniter/croniter.py
index 41fd8f2..54dbeb0 100644
--- a/src/croniter/croniter.py
+++ b/src/croniter/croniter.py
@@ -218,7 +218,7 @@ class croniter(object):
         {},
         {0: 1},
         {0: 1},
-        {},
+        {7: 0},
         {},
         {}
     )
@@ -791,6 +791,21 @@ class croniter(object):
         else:
             return False
 
+    @classmethod
+    def value_alias(cls, val, field, len_expressions=UNIX_CRON_LEN):
+        if isinstance(len_expressions, (list, dict, tuple, set)):
+            len_expressions = len(len_expressions)
+        if val in cls.LOWMAP[field] and not (
+            # do not support 0 as a month either for classical 5 fields cron,
+            # 6fields second repeat form or 7 fields year form
+            # but still let conversion happen if day field is shifted
+            (field in [DAY_FIELD, MONTH_FIELD] and len_expressions == UNIX_CRON_LEN) or
+            (field in [MONTH_FIELD, DOW_FIELD] and len_expressions == SECOND_CRON_LEN) or
+            (field in [DAY_FIELD, MONTH_FIELD, DOW_FIELD] and len_expressions == YEAR_CRON_LEN)
+        ):
+            val = cls.LOWMAP[field][val]
+        return val
+
     @classmethod
     def _expand(cls, expr_format, hash_id=None, second_at_beginning=False, from_timestamp=None):
         # Split the expression in components, and normalize L -> l, MON -> mon,
@@ -886,8 +901,6 @@ class croniter(object):
 
                 if m:
                     # early abort if low/high are out of bounds
-                    add_sunday = False
-
                     (low, high, step) = m.group(1), m.group(2), m.group(4) or 1
                     if i == DAY_FIELD and high == 'l':
                         high = '31'
@@ -898,19 +911,21 @@ class croniter(object):
                     if not only_int_re.search(high):
                         high = "{0}".format(cls._alphaconv(i, high, expressions))
 
-                    if (
-                        not low or not high or int(low) > int(high)
-                        or not only_int_re.search(str(step))
-                    ):
-                        # handle -Sun notation as Sunday is DOW-0
-                        if i == DOW_FIELD and high == '0':
-                            add_sunday = True
-                            high = '6'
-                        else:
+                    # normally, it's already guarded by the RE that should not accept not-int values.
+                    if not only_int_re.search(str(step)):
+                        raise CroniterBadCronError(
+                            "[{0}] step '{2}' in field {1} is not acceptable".format(
+                                expr_format, i, step))
+                    step = int(step)
+
+                    for band in low, high:
+                        if not only_int_re.search(str(band)):
                             raise CroniterBadCronError(
-                                "[{0}] is not acceptable".format(expr_format))
+                                "[{0}] bands '{2}-{3}' in field {1} are not acceptable".format(
+                                    expr_format, i, low, high))
+
+                    low, high = [cls.value_alias(int(_val), i, expressions) for _val in (low, high)]
 
-                    low, high, step = map(int, [low, high, step])
                     if (
                         max(low, high) > max(cls.RANGES[i][0], cls.RANGES[i][1])
                     ):
@@ -920,19 +935,34 @@ class croniter(object):
                     if from_timestamp:
                         low = cls._get_low_from_current_date_number(i, int(step), int(from_timestamp))
 
-                    try:
-                        rng = range(low, high + 1, step)
-                    except ValueError as exc:
-                        raise CroniterBadCronError(
-                            'invalid range: {0}'.format(exc))
-
-                    e_list += (["{0}#{1}".format(item, nth) for item in rng]
-                               if i == DOW_FIELD and nth and nth != "l" else rng)
-                    # if low == high, this means all week
-                    if (i == DOW_FIELD) and low == high and not (add_sunday and low == 6):
-                        _ = [e_list.append(dow) for dow in range(7) if dow not in e_list]
-                    if (i == DOW_FIELD) and add_sunday and (0 not in e_list):
-                        e_list.insert(0, 0)
+                    # Handle when the second bound of the range is in backtracking order:
+                    # eg: X-Sun or X-7 (Sat-Sun) in DOW, or X-Jan (Apr-Jan) in MONTH
+                    if low > high:
+                        whole_field_range = list(range(cls.RANGES[i][0], cls.RANGES[i][1] + 1, 1))
+                        # Add FirstBound -> ENDRANGE, respecting step
+                        rng = list(range(low, cls.RANGES[i][1] + 1, step))
+                        # Then 0 -> SecondBound, but skipping n first occurences according to step
+                        # EG to respect such expressions : Apr-Jan/3
+                        to_skip = 0
+                        if rng:
+                            already_skipped = list(reversed(whole_field_range)).index(rng[-1])
+                            curpos = whole_field_range.index(rng[-1])
+                            if ((curpos + step) > len(whole_field_range)) and (already_skipped < step):
+                                to_skip = step - already_skipped
+                        rng += list(range(cls.RANGES[i][0] + to_skip, high + 1, step))
+                    # if we include a range type: Jan-Jan, or Sun-Sun,
+                    #  it means the whole cycle (all days of week, # all monthes of year, etc)
+                    elif low == high:
+                        rng = list(range(cls.RANGES[i][0], cls.RANGES[i][1] + 1, step))
+                    else:
+                        try:
+                            rng = list(range(low, high + 1, step))
+                        except ValueError as exc:
+                            raise CroniterBadCronError('invalid range: {0}'.format(exc))
+
+                    rng = (["{0}#{1}".format(item, nth) for item in rng]
+                           if i == DOW_FIELD and nth and nth != "l" else rng)
+                    e_list += [a for a in rng if a not in e_list]
                 else:
                     if t.startswith('-'):
                         raise CroniterBadCronError((
@@ -947,15 +977,7 @@ class croniter(object):
                     except ValueError:
                         pass
 
-                    if t in cls.LOWMAP[i] and not (
-                        # do not support 0 as a month either for classical 5 fields cron,
-                        # 6fields second repeat form or 7 fields year form
-                        # but still let conversion happen if day field is shifted
-                        (i in [DAY_FIELD, MONTH_FIELD] and len(expressions) == UNIX_CRON_LEN) or
-                        (i in [MONTH_FIELD, DOW_FIELD] and len(expressions) == SECOND_CRON_LEN) or
-                        (i in [DAY_FIELD, MONTH_FIELD, DOW_FIELD] and len(expressions) == YEAR_CRON_LEN)
-                    ):
-                        t = cls.LOWMAP[i][t]
+                    t = cls.value_alias(t, i, expressions)
 
                     if (
                         t not in ["*", "l"]


Test patch:
diff --git a/src/croniter/tests/test_croniter.py b/src/croniter/tests/test_croniter.py
index 6e00109..f6bee05 100755
--- a/src/croniter/tests/test_croniter.py
+++ b/src/croniter/tests/test_croniter.py
@@ -319,13 +319,14 @@ class CroniterTest(base.TestCase):
         itr = croniter('* * * * *')
         self.assertRaises(TypeError, itr.get_next, str)
         self.assertRaises(ValueError, croniter, '* * * *')
-        self.assertRaises(ValueError, croniter, '* * 5-1 * *')
         self.assertRaises(ValueError, croniter, '-90 * * * *')
         self.assertRaises(ValueError, croniter, 'a * * * *')
         self.assertRaises(ValueError, croniter, '* * * janu-jun *')
         self.assertRaises(ValueError, croniter, '1-1_0 * * * *')
+        self.assertRaises(ValueError, croniter, '0-10/error * * * *')
         self.assertRaises(ValueError, croniter, '0-10/ * * * *')
         self.assertRaises(CroniterBadCronError, croniter, "0-1& * * * *", datetime.now())
+        self.assertRaises(ValueError, croniter, '* * 5-100 * *')
 
     def testSundayToThursdayWithAlphaConversion(self):
         base = datetime(2010, 8, 25, 15, 56)  # wednesday
@@ -1972,102 +1973,135 @@ class CroniterTest(base.TestCase):
         except OverflowError:
             raise Exception("overflow not fixed!")
 
-    def test_issue_90(self):
-        self.assertFalse(croniter.is_valid("* * * * 7"))
-        # self.assertFalse(croniter.is_valid('0 0 Sun-Sun * *'))
+    def test_revert_issue_90_aka_support_DOW7(self):
+        base = datetime(2040, 1, 1, 0, 0)
+        itr = croniter('* * * * 1-7').get_next()
+        self.assertTrue(croniter.is_valid("* * * * 1-7"))
+        self.assertTrue(croniter.is_valid("* * * * 7"))
+
+    def test_sunday_ranges_to(self):
+        self._test_sunday_ranges('0 0 * * Sun-Sun',
+                                 [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
+                                  17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31])
 
     def test_sunday_ranges_to(self):
-        cron = croniter('0 0 * * Sun-Sun', base)
-        cron.set_current(datetime(2024, 1, 1), force=True)
-        ret = [cron.get_next(datetime) for a in range(30)]
-        aret = [a.day for a in ret]
-        self.assertEqual(aret, [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
-                                17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31])
-
-        cron = croniter('0 0 * * Mon-Sun', base)
-        cron.set_current(datetime(2024, 1, 1), force=True)
-        ret = [cron.get_next(datetime) for a in range(30)]
-        aret = [a.day for a in ret]
-        self.assertEqual(aret, [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
-                                17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31])
-
-        cron = croniter('0 0 * * Tue-Sun', base)
-        cron.set_current(datetime(2024, 1, 1), force=True)
-        ret = [cron.get_next(datetime) for a in range(30)]
-        aret = [a.day for a in ret]
-        self.assertEqual(aret, [2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 14, 16, 17, 18, 19,
-                                20, 21, 23, 24, 25, 26, 27, 28, 30, 31, 1, 2, 3, 4])
-
-        cron = croniter('0 0 * * Wed-Sun', base)
-        cron.set_current(datetime(2024, 1, 1), force=True)
-        ret = [cron.get_next(datetime) for a in range(30)]
-        aret = [a.day for a in ret]
-        self.assertEqual(aret, [3, 4, 5, 6, 7, 10, 11, 12, 13, 14, 17, 18, 19, 20, 21, 24,
-                                25, 26, 27, 28, 31, 1, 2, 3, 4, 7, 8, 9, 10, 11])
-
-        cron = croniter('0 0 * * Thu-Sun', base)
-        cron.set_current(datetime(2024, 1, 1), force=True)
-        ret = [cron.get_next(datetime) for a in range(30)]
-        aret = [a.day for a in ret]
-        self.assertEqual(aret, [4, 5, 6, 7, 11, 12, 13, 14, 18, 19, 20, 21, 25, 26,
-                                27, 28, 1, 2, 3, 4, 8, 9, 10, 11, 15, 16, 17, 18, 22, 23])
-
-        cron = croniter('0 0 * * Fri-Sun', base)
-        cron.set_current(datetime(2024, 1, 1), force=True)
-        ret = [cron.get_next(datetime) for a in range(30)]
-        aret = [a.day for a in ret]
-        self.assertEqual(aret, [5, 6, 7, 12, 13, 14, 19, 20, 21, 26, 27, 28, 2, 3, 4, 9,
-                                10, 11, 16, 17, 18, 23, 24, 25, 1, 2, 3, 8, 9, 10])
-
-        cron = croniter('0 0 * * Sat-Sun', base)
-        cron.set_current(datetime(2024, 1, 1), force=True)
-        ret = [cron.get_next(datetime) for a in range(30)]
-        aret = [a.day for a in ret]
-        self.assertEqual(aret, [6, 7, 13, 14, 20, 21, 27, 28, 3, 4, 10, 11, 17, 18, 24,
-                                25, 2, 3, 9, 10, 16, 17, 23, 24, 30, 31, 6, 7, 13, 14])
+        self._test_sunday_ranges('0 0 * * Sun-Sun',
+                                 [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
+                                  17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31])
+
+        self._test_sunday_ranges('0 0 * * Mon-Sun',
+                                 [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
+                                  17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31])
+
+        self._test_sunday_ranges('0 0 * * Tue-Sun',
+                                 [2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 14, 16, 17, 18, 19,
+                                  20, 21, 23, 24, 25, 26, 27, 28, 30, 31, 1, 2, 3, 4])
+
+        self._test_sunday_ranges('0 0 * * Wed-Sun',
+                                 [3, 4, 5, 6, 7, 10, 11, 12, 13, 14, 17, 18, 19, 20, 21, 24,
+                                  25, 26, 27, 28, 31, 1, 2, 3, 4, 7, 8, 9, 10, 11])
+
+        self._test_sunday_ranges('0 0 * * Thu-Sun',
+                                 [4, 5, 6, 7, 11, 12, 13, 14, 18, 19, 20, 21, 25, 26,
+                                  27, 28, 1, 2, 3, 4, 8, 9, 10, 11, 15, 16, 17, 18, 22, 23])
+
+        self._test_sunday_ranges('0 0 * * Fri-Sun',
+                                 [5, 6, 7, 12, 13, 14, 19, 20, 21, 26, 27, 28, 2, 3, 4, 9,
+                                  10, 11, 16, 17, 18, 23, 24, 25, 1, 2, 3, 8, 9, 10])
+
+        self._test_sunday_ranges('0 0 * * Sat-Sun',
+                                 [6, 7, 13, 14, 20, 21, 27, 28, 3, 4, 10, 11, 17, 18, 24,
+                                  25, 2, 3, 9, 10, 16, 17, 23, 24, 30, 31, 6, 7, 13, 14])
 
     def test_sunday_ranges_from(self):
-        cron = croniter('0 0 * * Sun-Mon', base)
-        cron.set_current(datetime(2024, 1, 1), force=True)
-        ret = [cron.get_next(datetime) for a in range(30)]
-        aret = [a.day for a in ret]
-        self.assertEqual(aret, [7, 8, 14, 15, 21, 22, 28, 29, 4, 5, 11, 12, 18, 19, 25,
-                                26, 3, 4, 10, 11, 17, 18, 24, 25, 31, 1, 7, 8, 14, 15])
-
-        cron = croniter('0 0 * * Sun-Tue', base)
-        cron.set_current(datetime(2024, 1, 1), force=True)
-        ret = [cron.get_next(datetime) for a in range(30)]
-        aret = [a.day for a in ret]
-        self.assertEqual(aret, [2, 7, 8, 9, 14, 15, 16, 21, 22, 23, 28, 29, 30, 4, 5, 6, 11,
-                                12, 13, 18, 19, 20, 25, 26, 27, 3, 4, 5, 10, 11])
-
-        cron = croniter('0 0 * * Sun-Wed', base)
-        cron.set_current(datetime(2024, 1, 1), force=True)
-        ret = [cron.get_next(datetime) for a in range(30)]
-        aret = [a.day for a in ret]
-        self.assertEqual(aret, [2, 3, 7, 8, 9, 10, 14, 15, 16, 17, 21, 22, 23, 24, 28, 29,
-                                30, 31, 4, 5, 6, 7, 11, 12, 13, 14, 18, 19, 20, 21])
-
-        cron = croniter('0 0 * * Sun-Thu', base)
-        cron.set_current(datetime(2024, 1, 1), force=True)
-        ret = [cron.get_next(datetime) for a in range(30)]
-        aret = [a.day for a in ret]
-        self.assertEqual(aret, [2, 3, 4, 7, 8, 9, 10, 11, 14, 15, 16, 17, 18, 21, 22, 23, 24,
-                                25, 28, 29, 30, 31, 1, 4, 5, 6, 7, 8, 11, 12])
-
-        cron = croniter('0 0 * * Sun-Fri', base)
-        cron.set_current(datetime(2024, 1, 1), force=True)
-        ret = [cron.get_next(datetime) for a in range(30)]
-        aret = [a.day for a in ret]
-        self.assertEqual(aret, [2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 14, 15, 16, 17, 18, 19, 21,
-                                22, 23, 24, 25, 26, 28, 29, 30, 31, 1, 2, 4, 5])
-
-        cron = croniter('0 0 * * Sun-Sat', base)
-        cron.set_current(datetime(2024, 1, 1), force=True)
-        ret = [cron.get_next(datetime) for a in range(30)]
-        aret = [a.day for a in ret]
-        self.assertEqual(aret, [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
-                                18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31])
+        self._test_sunday_ranges('0 0 * * Sun-Mon',
+                                 [7, 8, 14, 15, 21, 22, 28, 29, 4, 5, 11, 12, 18, 19, 25,
+                                  26, 3, 4, 10, 11, 17, 18, 24, 25, 31, 1, 7, 8, 14, 15])
+
+        self._test_sunday_ranges('0 0 * * Sun-Tue',
+                                 [2, 7, 8, 9, 14, 15, 16, 21, 22, 23, 28, 29, 30, 4, 5, 6, 11,
+                                  12, 13, 18, 19, 20, 25, 26, 27, 3, 4, 5, 10, 11])
+
+        self._test_sunday_ranges('0 0 * * Sun-Wed',
+                                 [2, 3, 7, 8, 9, 10, 14, 15, 16, 17, 21, 22, 23, 24, 28, 29,
+                                  30, 31, 4, 5, 6, 7, 11, 12, 13, 14, 18, 19, 20, 21])
+
+        self._test_sunday_ranges('0 0 * * Sun-Thu',
+                                 [2, 3, 4, 7, 8, 9, 10, 11, 14, 15, 16, 17, 18, 21, 22, 23, 24,
+                                  25, 28, 29, 30, 31, 1, 4, 5, 6, 7, 8, 11, 12])
+
+        self._test_sunday_ranges('0 0 * * Sun-Fri',
+                                 [2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 14, 15, 16, 17, 18, 19, 21,
+                                  22, 23, 24, 25, 26, 28, 29, 30, 31, 1, 2, 4, 5])
+
+        self._test_sunday_ranges('0 0 * * Sun-Sat',
+                                 [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
+                                  18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31])
+
+        self._test_sunday_ranges('0 0 * * Thu-Tue/2',
+                                 [2, 4, 6, 9, 11, 13, 16, 18, 20, 23, 25, 27, 30, 1, 3, 6, 8,
+                                  10, 13, 15, 17, 20, 22, 24, 27, 29, 2, 5, 7, 9])
+
+        self._test_sunday_ranges('0 0 * * Thu-Tue/3',
+                                 [4, 7, 11, 14, 18, 21, 25, 28, 1, 4, 8, 11, 15, 18, 22, 25, 29, 3,
+                                  7, 10, 14, 17, 21, 24, 28, 31, 4, 7, 11, 14])
+
+    def test_mth_ranges_from(self):
+        self._test_mth_cron_ranges('0 0 1 Jan-Dec *', [
+            '24 2', '24 3', '24 4', '24 5', '24 6', '24 7', '24 8', '24 9', '24 10',
+            '24 11', '24 12', '25 1', '25 2', '25 3', '25 4', '25 5',])
+        self._test_mth_cron_ranges('0 0 1 Nov-Mar *', [
+            '24 2', '24 3', '24 11', '24 12', '25 1', '25 2', '25 3', '25 11', '25 12',
+            '26 1', '26 2', '26 3', '26 11', '26 12', '27 1', '27 2'])
+        self._test_mth_cron_ranges('0 0 1 Apr-Feb *', [
+            '24 2', '24 4', '24 5', '24 6', '24 7', '24 8', '24 9', '24 10', '24 11', '24 12',
+            '25 1', '25 2', '25 4', '25 5', '25 6', '25 7',])
+        self._test_mth_cron_ranges('0 0 1 Apr-Mar/3 *', [
+            '24 4', '24 7', '24 10', '25 1', '25 4', '25 7', '25 10',
+            '26 1', '26 4', '26 7', '26 10', '27 1', '27 4', '27 7', '27 10', '28 1'])
+        self._test_mth_cron_ranges('0 0 1 Apr-Mar/2 *', [
+            '24 3', '24 4', '24 6', '24 8', '24 10', '24 12', '25 3', '25 4', '25 6',
+            '25 8', '25 10', '25 12', '26 3', '26 4', '26 6', '26 8'])
+        self._test_mth_cron_ranges('0 0 1 Jan-Aug/2 *', [
+            '24 3', '24 5', '24 7', '25 1', '25 3', '25 5', '25 7',
+            '26 1', '26 3', '26 5', '26 7', '27 1', '27 3', '27 5', '27 7', '28 1'])
+        self._test_mth_cron_ranges('0 0 1 Jan-Aug/4 *', [
+            '24 5', '25 1', '25 5', '26 1', '26 5', '27 1', '27 5', '28 1', '28 5',
+            '29 1', '29 5', '30 1', '30 5', '31 1', '31 5', '32 1'])
+
+    def _test_cron_ranges(self, res_generator, expr, wanted, iterations=None, start_time=None):
+        rets = res_generator(expr, iterations=iterations, start_time=start_time)
+        for ret in rets:
+            self.assertEqual(wanted, ret)
+
+    def _test_mth_cron_ranges(self, expr, wanted, iterations=None, res_generator=None, start_time=None):
+        return self._test_cron_ranges(gen_x_mth_results, expr, wanted, iterations=iterations, start_time=start_time)
+
+    def _test_sunday_ranges(self, expr, wanted, iterations=None, start_time=None):
+        return self._test_cron_ranges(gen_all_sunday_forms, expr, wanted, iterations=iterations, start_time=start_time)
+
+
+def gen_x_mth_results(expr, iterations=None, start_time=None):
+    start_time = start_time or datetime(2024, 1, 1)
+    cron = croniter(expr, start_time=start_time)
+    return [['{0} {1}'.format(str(a.year)[-2:], a.month) for a in [cron.get_next(datetime) for i in range(iterations or 16)]]]
+
+
+def gen_x_results(expr, iterations=None, start_time=None):
+    start_time = start_time or datetime(2024, 1, 1)
+    cron = croniter(expr, start_time=start_time)
+    return [[a.day for a in [cron.get_next(datetime) for i in range(iterations or 30)]]]
+
+
+def gen_all_sunday_forms(expr, iterations=None, start_time=None):
+    start_time = start_time or datetime(2024, 1, 1)
+    cron = croniter(expr, start_time=start_time)
+    ret1 = [a.day for a in [cron.get_next(datetime) for i in range(iterations or 30)]]
+    cron = croniter(expr.lower().replace('sun', '7'), start_time=start_time)
+    ret2 = [a.day for a in [cron.get_next(datetime) for i in range(iterations or 30)]]
+    cron = croniter(expr.lower().replace('sun', '0'), start_time=start_time)
+    ret3 = [a.day for a in [cron.get_next(datetime) for i in range(iterations or 30)]]
+    return ret1, ret2, ret3
 
 
 if __name__ == '__main__':