diff options
author | Ryan Finnie <ryan@finnie.org> | 2021-04-10 13:13:43 -0700 |
---|---|---|
committer | Ryan Finnie <ryan@finnie.org> | 2021-04-12 09:38:28 -0700 |
commit | 36778574728491ee4fd18bae4586a029300708cc (patch) | |
tree | a718dc39194649b3a3a31cd3dc665cc99be7b448 | |
parent | 0bad4e6a984ef38ce1b0ff22388ca1552060ed34 (diff) | |
download | croniter-36778574728491ee4fd18bae4586a029300708cc.tar.gz |
Add support for hashed/random/keyword expressions
Closes: taichino/croniter#162
-rw-r--r-- | README.rst | 55 | ||||
-rw-r--r-- | src/croniter/croniter.py | 114 | ||||
-rw-r--r-- | src/croniter/tests/test_croniter_hash.py | 171 | ||||
-rw-r--r-- | src/croniter/tests/test_croniter_random.py | 38 | ||||
-rwxr-xr-x | src/croniter/tests/test_croniter_range.py | 4 |
5 files changed, 372 insertions, 10 deletions
@@ -182,6 +182,60 @@ List the first Saturday of every month in 2019:: >>> print(dt) +Hashed expressions +================== + +croniter supports Jenkins-style hashed expressions, using the "H" definition keyword and the required hash_id keyword argument. +Hashed expressions remain consistent, given the same hash_id, but different hash_ids will evaluate completely different to each other. +This allows, for example, for an even distribution of differently-named jobs without needing to manually spread them out. + + >>> itr = croniter("H H * * *", hash_id="hello") + >>> itr.get_next(datetime) + datetime.datetime(2021, 4, 10, 11, 10) + >>> itr.get_next(datetime) + datetime.datetime(2021, 4, 11, 11, 10) + >>> itr = croniter("H H * * *", hash_id="hello") + >>> itr.get_next(datetime) + datetime.datetime(2021, 4, 10, 11, 10) + >>> itr = croniter("H H * * *", hash_id="bonjour") + >>> itr.get_next(datetime) + datetime.datetime(2021, 4, 10, 20, 52) + + +Random expressions +================== + +Random "R" definition keywords are supported, and remain consistent only within their croniter() instance. + + >>> itr = croniter("R R * * *") + >>> itr.get_next(datetime) + datetime.datetime(2021, 4, 10, 22, 56) + >>> itr.get_next(datetime) + datetime.datetime(2021, 4, 11, 22, 56) + >>> itr = croniter("R R * * *") + >>> itr.get_next(datetime) + datetime.datetime(2021, 4, 11, 4, 19) + + +Keyword expressions +=================== + +Vixie cron-style "@" keyword expressions are supported. +What they evaluate to depends on whether you supply hash_id: no hash_id corresponds to Vixie cron definitions (exact times, minute resolution), while with hash_id corresponds to Jenkins definitions (hashed within the period, second resolution). + + ============ ============ ================ + Keyword No hash_id With hash_id + ============ ============ ================ + @midnight 0 0 * * * H H(0-2) * * * H + @hourly 0 * * * * H * * * * H + @daily 0 0 * * * H H * * * H + @weekly 0 0 * * 0 H H * * H H + @monthly 0 0 1 * * H H H * * H + @yearly 0 0 1 1 * H H H H * H + @annually 0 0 1 1 * H H H H * H + ============ ============ ================ + + Develop this package ==================== @@ -226,4 +280,5 @@ If you have contributed and your name is not listed below please let me know. - lowell80 (Kintyre) - scop - zed2015 + - Ryan Finnie (rfinnie) diff --git a/src/croniter/croniter.py b/src/croniter/croniter.py index 683bcb4..3bd11db 100644 --- a/src/croniter/croniter.py +++ b/src/croniter/croniter.py @@ -12,12 +12,17 @@ import datetime from dateutil.relativedelta import relativedelta from dateutil.tz import tzutc import calendar +import binascii +import random step_search_re = re.compile(r'^([^-]+)-([^-/]+)(/(\d+))?$') only_int_re = re.compile(r'^\d+$') star_or_int_re = re.compile(r'^(\d+|\*)$') special_weekday_re = re.compile(r'^(\w+)#(\d+)|l(\d+)$') +hash_expression_re = re.compile( + r'^(?P<hash_type>h|r)(\((?P<range_begin>\d+)-(?P<range_end>\d+)\))?(\/(?P<divisor>\d+))?$' +) VALID_LEN_EXPRESSION = [5, 6] @@ -110,10 +115,18 @@ class croniter(object): 'expression.' def __init__(self, expr_format, start_time=None, ret_type=float, - day_or=True, max_years_between_matches=None, is_prev=False): + day_or=True, max_years_between_matches=None, is_prev=False, + hash_id=None): self._ret_type = ret_type self._day_or = day_or + if hash_id is None or isinstance(hash_id, bytes): + pass + elif isinstance(hash_id, str): + hash_id = hash_id.encode('UTF-8') + else: + raise TypeError('hash_id must be bytes or UTF-8 string') + self._max_years_btw_matches_explicitly_set = ( max_years_between_matches is not None) if not self._max_years_btw_matches_explicitly_set: @@ -130,7 +143,7 @@ class croniter(object): self.cur = None self.set_current(start_time) - self.expanded, self.nth_weekday_of_month = self.expand(expr_format) + self.expanded, self.nth_weekday_of_month = self.expand(expr_format, hash_id=hash_id) self._is_prev = is_prev @classmethod @@ -563,11 +576,95 @@ class croniter(object): return False @classmethod - def _expand(cls, expr_format): + def _hash_do(cls, idx, hash_type="h", hash_id=None, range_end=None, range_begin=None): + """Return a hashed/random integer given range/hash information""" + if range_end is None: + range_end = cls.RANGES[idx][1] + if range_begin is None: + range_begin = cls.RANGES[idx][0] + if hash_type == 'r': + crc = random.randint(0, 0xFFFFFFFF) + else: + crc = binascii.crc32(hash_id) & 0xFFFFFFFF + return ((crc >> idx) % (range_end - range_begin + 1)) + range_begin + + @classmethod + def _hash_expand_expr(cls, expr, idx, hash_id=None): + """Expand a hashed/random expression to its normal representation""" + hash_expression_re_match = hash_expression_re.match(expr) + if not hash_expression_re_match: + return expr + m = hash_expression_re_match.groupdict() + + if m['hash_type'] == 'h' and hash_id is None: + raise CroniterBadCronError('Hashed definitions must include hash_id') + + if m['range_begin'] and m['range_end'] and m['divisor']: + # Example: H(30-59)/10 -> 34-59/10 (i.e. 34,44,54) + return '{:n}-{:n}/{:n}'.format( + cls._hash_do( + idx, + hash_type=m['hash_type'], + hash_id=hash_id, + range_end=int(m['divisor']), + ) + int(m['range_begin']), + int(m['range_end']), + int(m['divisor']), + ) + elif m['range_begin'] and m['range_end']: + # Example: H(0-29) -> 12 + return str( + cls._hash_do( + idx, + hash_type=m['hash_type'], + hash_id=hash_id, + range_end=int(m['range_end']), + range_begin=int(m['range_begin']), + ) + ) + elif m['divisor']: + # Example: H/15 -> 7-59/15 (i.e. 7,22,37,52) + return '{:n}-{:n}/{:n}'.format( + cls._hash_do( + idx, + hash_type=m['hash_type'], + hash_id=hash_id, + range_end=int(m['divisor']), + ), + cls.RANGES[idx][1], + int(m['divisor']), + ) + else: + # Example: H -> 32 + return str( + cls._hash_do( + idx, + hash_type=m['hash_type'], + hash_id=hash_id, + ) + ) + + @classmethod + def _expand(cls, expr_format, hash_id=None): # Split the expression in components, and normalize L -> l, MON -> mon, # etc. Keep expr_format untouched so we can use it in the exception # messages. - expressions = [e.lower() for e in expr_format.split()] + expr_aliases = { + '@midnight': ('0 0 * * *', 'h h(0-2) * * * h'), + '@hourly': ('0 * * * *', 'h * * * * h'), + '@daily': ('0 0 * * *', 'h h * * * h'), + '@weekly': ('0 0 * * 0', 'h h * * h h'), + '@monthly': ('0 0 1 * *', 'h h h * * h'), + '@yearly': ('0 0 1 1 *', 'h h h h * h'), + '@annually': ('0 0 1 1 *', 'h h h h * h'), + } + + efl = expr_format.lower() + hash_id_expr = hash_id is not None and 1 or 0 + try: + expressions = expr_aliases[efl][hash_id_expr].split() + except KeyError: + expressions = efl.split() if len(expressions) not in VALID_LEN_EXPRESSION: raise CroniterBadCronError(cls.bad_length) @@ -576,6 +673,7 @@ class croniter(object): nth_weekday_of_month = {} for i, expr in enumerate(expressions): + expr = cls._hash_expand_expr(expr, i, hash_id=hash_id) e_list = expr.split(',') res = [] @@ -712,10 +810,10 @@ class croniter(object): return expanded, nth_weekday_of_month @classmethod - def expand(cls, expr_format): + def expand(cls, expr_format, hash_id=None): """Shallow non Croniter ValueError inside a nice CroniterBadCronError""" try: - return cls._expand(expr_format) + return cls._expand(expr_format, hash_id=hash_id) except ValueError as exc: error_type, error_instance, traceback = sys.exc_info() if isinstance(exc, CroniterError): @@ -727,9 +825,9 @@ class croniter(object): raise CroniterBadCronError("{0}".format(exc)) @classmethod - def is_valid(cls, expression): + def is_valid(cls, expression, hash_id=None): try: - cls.expand(expression) + cls.expand(expression, hash_id=hash_id) except CroniterError: return False else: diff --git a/src/croniter/tests/test_croniter_hash.py b/src/croniter/tests/test_croniter_hash.py new file mode 100644 index 0000000..e9e3ca2 --- /dev/null +++ b/src/croniter/tests/test_croniter_hash.py @@ -0,0 +1,171 @@ +from datetime import datetime, timedelta + +from croniter import croniter, CroniterNotAlphaError +from croniter.tests import base + + +class CroniterHashBase(base.TestCase): + epoch = datetime(2020, 1, 1, 0, 0) + hash_id = 'hello' + + def _test_iter( + self, definition, expectations, delta, epoch=None, hash_id=None, next_type=None + ): + if epoch is None: + epoch = self.epoch + if hash_id is None: + hash_id = self.hash_id + if next_type is None: + next_type = datetime + if not isinstance(expectations, (list, tuple)): + expectations = (expectations,) + obj = croniter(definition, epoch, hash_id=hash_id) + testval = obj.get_next(next_type) + self.assertIn(testval, expectations) + if delta is not None: + self.assertEqual(obj.get_next(next_type), testval + delta) + +class CroniterHashTest(CroniterHashBase): + def test_hash_hourly(self): + """Test manually-defined hourly""" + self._test_iter('H * * * *', datetime(2020, 1, 1, 0, 10), timedelta(hours=1)) + + def test_hash_daily(self): + """Test manually-defined daily""" + self._test_iter('H H * * *', datetime(2020, 1, 1, 11, 10), timedelta(days=1)) + + def test_hash_weekly(self): + """Test manually-defined weekly""" + # croniter 1.0.5 changes the defined weekly range from (0, 6) + # to (0, 7), to match cron's behavior that Sunday is 0 or 7. + # This changes the hash, so test for either. + self._test_iter( + 'H H * * H', + (datetime(2020, 1, 3, 11, 10), datetime(2020, 1, 5, 11, 10)), + timedelta(weeks=1), + ) + + def test_hash_monthly(self): + """Test manually-defined monthly""" + self._test_iter('H H H * *', datetime(2020, 1, 1, 11, 10), timedelta(days=31)) + + def test_hash_yearly(self): + """Test manually-defined yearly""" + self._test_iter('H H H H *', datetime(2020, 9, 1, 11, 10), timedelta(days=365)) + + def test_hash_second(self): + """Test seconds + + If a sixth field is provided, seconds are included in the datetime() + """ + self._test_iter( + 'H H * * * H', datetime(2020, 1, 1, 11, 10, 32), timedelta(days=1) + ) + + def test_hash_id_change(self): + """Test a different hash_id returns different results given same definition and epoch""" + self._test_iter('H H * * *', datetime(2020, 1, 1, 11, 10), timedelta(days=1)) + self._test_iter( + 'H H * * *', + datetime(2020, 1, 1, 0, 24), + timedelta(days=1), + hash_id='different id', + ) + + def test_hash_epoch_change(self): + """Test a different epoch returns different results given same definition and hash_id""" + self._test_iter('H H * * *', datetime(2020, 1, 1, 11, 10), timedelta(days=1)) + self._test_iter( + 'H H * * *', + datetime(2011, 11, 12, 11, 10), + timedelta(days=1), + epoch=datetime(2011, 11, 11, 11, 11), + ) + + def test_hash_range(self): + """Test a hashed range definition""" + self._test_iter( + 'H H H(3-5) * *', datetime(2020, 1, 5, 11, 10), timedelta(days=31) + ) + + def test_hash_division(self): + """Test a hashed division definition""" + self._test_iter('H H/3 * * *', datetime(2020, 1, 1, 3, 10), timedelta(hours=3)) + + def test_hash_range_division(self): + """Test a hashed range + division definition""" + self._test_iter( + 'H(30-59)/10 H * * *', datetime(2020, 1, 1, 11, 31), timedelta(minutes=10) + ) + + def test_hash_id_bytes(self): + """Test hash_id as a bytes object""" + self._test_iter( + 'H H * * *', + datetime(2020, 1, 1, 14, 53), + timedelta(days=1), + hash_id=b'\x01\x02\x03\x04', + ) + + def test_hash_float(self): + """Test result as a float object""" + self._test_iter('H H * * *', 1577877000.0, (60 * 60 * 24), next_type=float) + + def test_invalid_definition(self): + """Test an invalid defition raises CroniterNotAlphaError""" + with self.assertRaises(CroniterNotAlphaError): + croniter('X X * * *', self.epoch, hash_id=self.hash_id) + + def test_invalid_hash_id_type(self): + """Test an invalid hash_id type raises TypeError""" + with self.assertRaises(TypeError): + croniter('H H * * *', self.epoch, hash_id={1: 2}) + +class CroniterWordAliasTest(CroniterHashBase): + def test_hash_word_midnight(self): + """Test built-in @midnight + + @midnight is actually up to 3 hours after midnight, not exactly midnight + """ + self._test_iter('@midnight', datetime(2020, 1, 1, 2, 10, 32), timedelta(days=1)) + + def test_hash_word_hourly(self): + """Test built-in @hourly""" + self._test_iter('@hourly', datetime(2020, 1, 1, 0, 10, 32), timedelta(hours=1)) + + def test_hash_word_daily(self): + """Test built-in @daily""" + self._test_iter('@daily', datetime(2020, 1, 1, 11, 10, 32), timedelta(days=1)) + + def test_hash_word_weekly(self): + """Test built-in @weekly""" + # croniter 1.0.5 changes the defined weekly range from (0, 6) + # to (0, 7), to match cron's behavior that Sunday is 0 or 7. + # This changes the hash, so test for either. + self._test_iter( + '@weekly', + (datetime(2020, 1, 3, 11, 10, 32), datetime(2020, 1, 5, 11, 10, 32)), + timedelta(weeks=1), + ) + + def test_hash_word_monthly(self): + """Test built-in @monthly""" + self._test_iter( + '@monthly', datetime(2020, 1, 1, 11, 10, 32), timedelta(days=31) + ) + + def test_hash_word_yearly(self): + """Test built-in @yearly""" + self._test_iter( + '@yearly', datetime(2020, 9, 1, 11, 10, 32), timedelta(days=365) + ) + + def test_hash_word_annually(self): + """Test built-in @annually + + @annually is the same as @yearly + """ + obj_annually = croniter('@annually', self.epoch, hash_id=self.hash_id) + obj_yearly = croniter('@yearly', self.epoch, hash_id=self.hash_id) + self.assertEqual(obj_annually.get_next(datetime), obj_yearly.get_next(datetime)) + self.assertEqual(obj_annually.get_next(datetime), obj_yearly.get_next(datetime)) diff --git a/src/croniter/tests/test_croniter_random.py b/src/croniter/tests/test_croniter_random.py new file mode 100644 index 0000000..7a54f50 --- /dev/null +++ b/src/croniter/tests/test_croniter_random.py @@ -0,0 +1,38 @@ +from datetime import datetime, timedelta + +from croniter import croniter +from croniter.tests import base + + +class CroniterRandomTest(base.TestCase): + epoch = datetime(2020, 1, 1, 0, 0) + + def test_random(self): + """Test random definition""" + obj = croniter('R R * * *', self.epoch) + result_1 = obj.get_next(datetime) + self.assertGreaterEqual(result_1, datetime(2020, 1, 1, 0, 0)) + self.assertLessEqual(result_1, datetime(2020, 1, 1, 0, 0) + timedelta(days=1)) + result_2 = obj.get_next(datetime) + self.assertGreaterEqual(result_2, datetime(2020, 1, 2, 0, 0)) + self.assertLessEqual(result_2, datetime(2020, 1, 2, 0, 0) + timedelta(days=1)) + + def test_random_range(self): + """Test random definition within a range""" + obj = croniter('R R R(10-20) * *', self.epoch) + result_1 = obj.get_next(datetime) + self.assertGreaterEqual(result_1, datetime(2020, 1, 10, 0, 0)) + self.assertLessEqual(result_1, datetime(2020, 1, 10, 0, 0) + timedelta(days=11)) + result_2 = obj.get_next(datetime) + self.assertGreaterEqual(result_2, datetime(2020, 2, 10, 0, 0)) + self.assertLessEqual(result_2, datetime(2020, 2, 10, 0, 0) + timedelta(days=11)) + + def test_random_float(self): + """Test random definition, float result""" + obj = croniter('R R * * *', self.epoch) + result_1 = obj.get_next(float) + self.assertGreaterEqual(result_1, 1577836800.0) + self.assertLessEqual(result_1, 1577836800.0 + (60 * 60 * 24)) + result_2 = obj.get_next(float) + self.assertGreaterEqual(result_2, 1577923200.0) + self.assertLessEqual(result_2, 1577923200.0 + (60 * 60 * 24)) diff --git a/src/croniter/tests/test_croniter_range.py b/src/croniter/tests/test_croniter_range.py index ac17487..b9cace0 100755 --- a/src/croniter/tests/test_croniter_range.py +++ b/src/croniter/tests/test_croniter_range.py @@ -129,10 +129,10 @@ class CroniterRangeTest(base.TestCase): class croniter_nosec(croniter): """ Like croniter, but it forbids second-level cron expressions. """ @classmethod - def expand(cls, expr_format): + def expand(cls, expr_format, *args, **kwargs): if len(expr_format.split()) == 6: raise CroniterBadCronError("Expected 'min hour day mon dow'") - return croniter.expand(expr_format) + return croniter.expand(expr_format, *args, **kwargs) cron = "0 13 8 1,4,7,10 wed" matches = list(croniter_range(datetime(2020, 1, 1), datetime(2020, 12, 31), cron, day_or=False, _croniter=croniter_nosec)) |