summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRyan Finnie <ryan@finnie.org>2021-04-10 13:13:43 -0700
committerRyan Finnie <ryan@finnie.org>2021-04-12 09:38:28 -0700
commit36778574728491ee4fd18bae4586a029300708cc (patch)
treea718dc39194649b3a3a31cd3dc665cc99be7b448
parent0bad4e6a984ef38ce1b0ff22388ca1552060ed34 (diff)
downloadcroniter-36778574728491ee4fd18bae4586a029300708cc.tar.gz
Add support for hashed/random/keyword expressions
Closes: taichino/croniter#162
-rw-r--r--README.rst55
-rw-r--r--src/croniter/croniter.py114
-rw-r--r--src/croniter/tests/test_croniter_hash.py171
-rw-r--r--src/croniter/tests/test_croniter_random.py38
-rwxr-xr-xsrc/croniter/tests/test_croniter_range.py4
5 files changed, 372 insertions, 10 deletions
diff --git a/README.rst b/README.rst
index dfc05f9..d5a2b98 100644
--- a/README.rst
+++ b/README.rst
@@ -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))