From 79c050549329a5adbe759843b7514d2ebc4eff3d Mon Sep 17 00:00:00 2001 From: Mathieu Le Marec - Pasquet Date: Sat, 17 Nov 2018 20:00:39 +0300 Subject: [PATCH] Reallow DOW=7, & rework range calculations. Related to #90. --- CHANGELOG.rst | 5 +- README.rst | 19 ++- src/croniter/croniter.py | 94 +++++++----- src/croniter/tests/test_croniter.py | 220 ++++++++++++++++------------ 4 files changed, 205 insertions(+), 133 deletions(-) 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..a9e2930 100644 --- a/README.rst +++ b/README.rst @@ -285,6 +285,24 @@ 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 DAY 7, allowing such expressions:: + + 0 0 * * 7 + 0 0 * * 6-7 + 0 0 * * 6,7 + + Keyword expressions =================== @@ -303,7 +321,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 @@ def is_leap(self, year): 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 @@ def _expand(cls, expr_format, hash_id=None, second_at_beginning=False, from_time 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 @@ def _expand(cls, expr_format, hash_id=None, second_at_beginning=False, from_time 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 @@ def _expand(cls, expr_format, hash_id=None, second_at_beginning=False, from_time 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 @@ def _expand(cls, expr_format, hash_id=None, second_at_beginning=False, from_time 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"] 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 @@ def testError(self): 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 @@ def test_issue_2038y(self): 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__':