From 2dc0aa15a2bf2540c9ba8fff4bc0ad5cd5666e5d Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Sat, 6 Aug 2022 22:25:52 +0300 Subject: [PATCH 01/42] fix: TimeOfYear and also int anchoring in time --- rocketry/core/time/anchor.py | 34 +++- rocketry/test/time/interval/test_construct.py | 145 +++++++++++++++++- rocketry/test/time/test_contains.py | 38 +++++ rocketry/time/interval.py | 66 ++++++-- 4 files changed, 251 insertions(+), 32 deletions(-) diff --git a/rocketry/core/time/anchor.py b/rocketry/core/time/anchor.py index 3ee4d724..54bce71e 100644 --- a/rocketry/core/time/anchor.py +++ b/rocketry/core/time/anchor.py @@ -40,10 +40,13 @@ class AnchoredInterval(TimeInterval): """ components = ("year", "month", "week", "day", "hour", "minute", "second", "microsecond", "nanosecond") + # Components that have always fixed length (exactly the same amount of time) _fixed_components = ("week", "day", "hour", "minute", "second", "microsecond", "nanosecond") - _scope = None # ie. day, hour, second, microsecond - _scope_max = None + _scope:str = None # Scope of the full period. Ie. day, hour, second, microsecond + _scope_max:int = None # Max in nanoseconds of the + + _unit_resolution: int = None # Nanoseconds of one unit (if start/end is int) def __init__(self, start=None, end=None, time_point=None): #self.start = start @@ -66,8 +69,10 @@ def anchor(self, value, **kwargs): return self.anchor_str(value, **kwargs) raise TypeError(value) - def anchor_int(self, i, **kwargs): - return to_nanoseconds(**{self._scope: i}) + def anchor_int(self, i, side=None, time_point=None, **kwargs): + if side == "end": + return (i + 1) * self._unit_resolution - 1 + return i * self._unit_resolution def anchor_dict(self, d, **kwargs): comps = self._fixed_components[(self._fixed_components.index(self._scope) + 1):] @@ -98,17 +103,22 @@ def set_start(self, val): def set_end(self, val, time_point=False): if time_point and val is None: # Interval is "at" type, ie. on monday, at 10:00 (10:00 - 10:59:59) - ns = self._start + self._unit_resolution - 1 + ns = self.to_timepoint(self._start) elif val is None: ns = self._scope_max else: - ns = self.anchor(val, side="end") - - has_time = (ns % to_nanoseconds(day=1)) != 0 + ns = self.anchor(val, side="end", time_point=time_point) self._end = ns self._end_orig = val + def to_timepoint(self, ns:int): + "Turn nanoseconds to the period's timepoint" + # Ie. Monday --> Monday 00:00 to Monday 24:00 + # By default assumes linear scale (like week) + # but can be overridden for non linear such as year + return ns + self._unit_resolution - 1 + @property def start(self): delta = to_timedelta(self._start, unit="ns") @@ -360,3 +370,11 @@ def __str__(self): start_str = f"0 {repr_scope}s" if not start_str else start_str return f"{start_str} - {end_str}" + + @classmethod + def at(cls, value): + return cls(value, time_point=True) + + @classmethod + def starting(cls, value): + return cls(value, value) \ No newline at end of file diff --git a/rocketry/test/time/interval/test_construct.py b/rocketry/test/time/interval/test_construct.py index f9fef375..6252b24a 100644 --- a/rocketry/test/time/interval/test_construct.py +++ b/rocketry/test/time/interval/test_construct.py @@ -2,14 +2,16 @@ import pytest from rocketry.time.interval import ( TimeOfDay, + TimeOfHour, TimeOfMonth, - TimeOfWeek + TimeOfWeek, + TimeOfYear ) -NS_IN_SECOND = 1e+9 -NS_IN_MINUTE = 1e+9 * 60 -NS_IN_HOUR = 1e+9 * 60 * 60 -NS_IN_DAY = 1e+9 * 60 * 60 * 24 +NS_IN_SECOND = int(1e+9) +NS_IN_MINUTE = int(1e+9 * 60) +NS_IN_HOUR = int(1e+9 * 60 * 60) +NS_IN_DAY = int(1e+9 * 60 * 60 * 24) def pytest_generate_tests(metafunc): if metafunc.cls is not None: @@ -67,6 +69,51 @@ def test_time_point(self, start, expected_start, expected_end, **kwargs): assert expected_start == time._start assert expected_end == time._end + time = self.cls.at(start) + assert expected_start == time._start + assert expected_end == time._end + +class TestTimeOfHour(ConstructTester): + + cls = TimeOfHour + + max_ns = NS_IN_HOUR - 1 + + scen_closed = [ + { + "start": "15:00", + "end": "45:00", + "expected_start": 15 * NS_IN_MINUTE, + "expected_end": 45 * NS_IN_MINUTE, + }, + { + "start": 15, + "end": 45, + "expected_start": 15 * NS_IN_MINUTE, + "expected_end": 46 * NS_IN_MINUTE - 1, + }, + ] + + scen_open_left = [ + { + "end": "45:00", + "expected_end": 45 * NS_IN_MINUTE + } + ] + scen_open_right = [ + { + "start": "45:00", + "expected_start": 45 * NS_IN_MINUTE + } + ] + scen_time_point = [ + { + "start": "12:00", + "expected_start": 12 * NS_IN_MINUTE, + "expected_end": 13 * NS_IN_MINUTE - 1, + } + ] + class TestTimeOfDay(ConstructTester): cls = TimeOfDay @@ -79,7 +126,13 @@ class TestTimeOfDay(ConstructTester): "end": "12:00", "expected_start": 10 * NS_IN_HOUR, "expected_end": 12 * NS_IN_HOUR, - } + }, + { + "start": 10, + "end": 12, + "expected_start": 10 * NS_IN_HOUR, + "expected_end": 13 * NS_IN_HOUR - 1, + }, ] scen_open_left = [ @@ -123,7 +176,14 @@ class TestTimeOfWeek(ConstructTester): "end": "Wednesday", "expected_start": 1 * NS_IN_DAY, "expected_end": 3 * NS_IN_DAY - 1, - } + }, + { + # Spans from Tue 00:00:00 to Wed 23:59:59 999 + "start": 1, + "end": 2, + "expected_start": 1 * NS_IN_DAY, + "expected_end": 3 * NS_IN_DAY - 1, + }, ] scen_open_left = [ @@ -166,6 +226,12 @@ class TestTimeOfMonth(ConstructTester): "expected_start": 1 * NS_IN_DAY, "expected_end": 4 * NS_IN_DAY - 1, }, + { + "start": 1, + "end": 3, + "expected_start": 1 * NS_IN_DAY, + "expected_end": 4 * NS_IN_DAY - 1, + }, ] scen_open_left = [ @@ -187,3 +253,68 @@ class TestTimeOfMonth(ConstructTester): "expected_end": 2 * NS_IN_DAY - 1, } ] + +class TestTimeOfYear(ConstructTester): + + cls = TimeOfYear + + max_ns = 366 * NS_IN_DAY - 1 # Leap year has 366 days + + scen_closed = [ + { + "start": "February", + "end": "April", + "expected_start": 31 * NS_IN_DAY, + "expected_end": (31 + 29 + 31 + 30) * NS_IN_DAY - 1, + }, + { + "start": "Feb", + "end": "Apr", + "expected_start": 31 * NS_IN_DAY, + "expected_end": (31 + 29 + 31 + 30) * NS_IN_DAY - 1, + }, + { + "start": 1, + "end": 3, + "expected_start": 31 * NS_IN_DAY, + "expected_end": (31 + 29 + 31 + 30) * NS_IN_DAY - 1, + }, + ] + + scen_open_left = [ + { + "end": "Apr", + "expected_end": (31 + 29 + 31 + 30) * NS_IN_DAY - 1 + }, + { + "end": "Jan", + "expected_end": 31 * NS_IN_DAY - 1 + }, + ] + scen_open_right = [ + { + "start": "Apr", + "expected_start": (31 + 29 + 31) * NS_IN_DAY + }, + { + "start": "Dec", + "expected_start": (366 - 31) * NS_IN_DAY + }, + ] + scen_time_point = [ + { + "start": "Jan", + "expected_start": 0, + "expected_end": 31 * NS_IN_DAY - 1, + }, + { + "start": "Feb", + "expected_start": 31 * NS_IN_DAY, + "expected_end": (31 + 29) * NS_IN_DAY - 1, + }, + { + "start": "Dec", + "expected_start": (366 - 31) * NS_IN_DAY, + "expected_end": 366 * NS_IN_DAY - 1, + }, + ] diff --git a/rocketry/test/time/test_contains.py b/rocketry/test/time/test_contains.py index d67dbc53..cd9a2f70 100644 --- a/rocketry/test/time/test_contains.py +++ b/rocketry/test/time/test_contains.py @@ -163,6 +163,31 @@ "start": "Jan", "end": "Feb", }, + { + "cls": TimeOfYear, + "dt": "2024-12-01 00:00", + "start": "Dec", + "end": None, + }, + { + "cls": TimeOfYear, + "dt": "2024-12-31 23:00", + "start": "Nov", + "end": "Dec", + }, + # Not leap year + { + "cls": TimeOfYear, + "dt": "2023-12-01 00:00", + "start": "Dec", + "end": None, + }, + { + "cls": TimeOfYear, + "dt": "2023-12-31 23:00", + "start": "Nov", + "end": "Dec", + }, ] false_cases = [ @@ -321,6 +346,19 @@ "start": "Feb", "end": "Jul", }, + { + "cls": TimeOfYear, + "dt": "2024-11-30 23:59:59", + "start": "Dec", + "end": None, + }, + # Not leap year + { + "cls": TimeOfYear, + "dt": "2023-11-30 23:59:59", + "start": "Dec", + "end": None, + }, ] def _to_pyparams(cases): diff --git a/rocketry/time/interval.py b/rocketry/time/interval.py index 9b226413..4cc38140 100644 --- a/rocketry/time/interval.py +++ b/rocketry/time/interval.py @@ -23,6 +23,7 @@ class TimeOfMinute(AnchoredInterval): """ _scope = "minute" + _scope_max = to_nanoseconds(minute=1) - 1 _unit_resolution = to_nanoseconds(second=1) @@ -197,9 +198,6 @@ def anchor_str(self, s, side=None, **kwargs): return to_nanoseconds(day=1) * (nth_day - 1) + nanoseconds - def anchor_int(self, i, **kwargs): - return i * self._unit_resolution - def anchor_dt(self, dt, **kwargs): "Turn datetime to nanoseconds according to the scope (by removing higher time elements)" d = to_dict(dt) @@ -235,6 +233,9 @@ class TimeOfYear(AnchoredInterval): TimeOfYear("Jan", "Feb") """ + # We take the longest year there is and translate all years to that + # using first the month and then the day of month + _scope = "year" _scope_max = to_nanoseconds(day=1) * 366 - 1 @@ -243,35 +244,66 @@ class TimeOfYear(AnchoredInterval): **dict(zip(calendar.month_abbr[1:], range(12))), **dict(zip(range(12), range(12))) } + + _month_start_mapping = { + 0: 0, # January + 1: to_nanoseconds(day=31), # February (31 days from year start) + 2: to_nanoseconds(day=60), # March (31 + 29, leap year has 29 days in February) + 3: to_nanoseconds(day=91), # April (31 + 29 + 31) + 4: to_nanoseconds(day=121), # May (31 + 29 + 31 + 30) + 5: to_nanoseconds(day=152), # June + 6: to_nanoseconds(day=182), # July + 7: to_nanoseconds(day=213), # August + + 8: to_nanoseconds(day=244), # September + 9: to_nanoseconds(day=274), # October + 10: to_nanoseconds(day=305), # November + 11: to_nanoseconds(day=335), # December + 12: to_nanoseconds(day=366), # End of the year (on leap years) + } + # Reverse the _month_start_mapping to nanoseconds to month num + _year_start_mapping = dict((v, k) for k, v in _month_start_mapping.items()) + # NOTE: Floating def anchor_str(self, s, side=None, **kwargs): # Allowed: # "January", "Dec", "12", "Dec last 5th 10:00:00" - res = re.search(r"(?P[a-z]+) ?(?P.*)", s, flags=re.IGNORECASE) + res = re.search(r"(?P[a-z]+) ?(?P.*)", s, flags=re.IGNORECASE) comps = res.groupdict() - monthofyear = comps.pop("monthofyear") - month_str = comps.pop("month") + monthofyear = comps.pop("monthofyear") # This is jan, january + day_of_month_str = comps.pop("day_of_month") nth_month = self.monthnum_mapping[monthofyear] - # TODO: TimeOfDay.anchor_str as function - # nanoseconds = TimeOfMonth().anchor_str(month_str) if month_str else 0 - - ceil_time = not month_str and side == "end" + ceil_time = not day_of_month_str and side == "end" if ceil_time: # If time is not defined and the end # is being anchored, the time is ceiled. # If one says 'thing X was organized between - # 15th and 17th of July', the sentence - # includes 17th till midnight. - nanoseconds = to_nanoseconds(day=31) - 1 - elif month_str: - nanoseconds = TimeOfMonth().anchor_str(month_str) + # May and June', the sentence includes + # time between 1st of May to 30th of June. + return self._month_start_mapping[nth_month+1] - 1 + elif day_of_month_str: + nanoseconds = TimeOfMonth().anchor_str(day_of_month_str) else: nanoseconds = 0 - return nth_month * to_nanoseconds(day=31) + nanoseconds + return self._month_start_mapping[nth_month] + nanoseconds + + def to_timepoint(self, ns:int): + "Turn nanoseconds to the period's timepoint" + # Ie. Monday --> Monday 00:00 to Monday 24:00 + # By default assumes linear scale (like week) + # but can be overridden for non linear such as year + month_num = self._year_start_mapping[ns] + return self._month_start_mapping[month_num + 1] - 1 + + def anchor_int(self, i, side=None, **kwargs): + # i is the month + if side == "end": + return self._month_start_mapping[i+1] - 1 + return self._month_start_mapping[i] def anchor_dt(self, dt, **kwargs): "Turn datetime to nanoseconds according to the scope (by removing higher time elements)" @@ -285,7 +317,7 @@ def anchor_dt(self, dt, **kwargs): if "day" in d: # Day (of month) does not start from 0 (but from 1) d["day"] = d["day"] - 1 - return nth_month * to_nanoseconds(day=31) + to_nanoseconds(**d) + return self._month_start_mapping[nth_month] + to_nanoseconds(**d) class RelativeDay(TimeInterval): From dd2d60ef0f85bf5cd5ad16bf074f2d9c44b29bec Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Sun, 7 Aug 2022 21:23:00 +0300 Subject: [PATCH 02/42] add: always time period "singleton" --- rocketry/core/time/__init__.py | 5 ++++- rocketry/core/time/base.py | 25 ++++++++++++++++++++++--- rocketry/test/time/test_core.py | 19 +++++++++++++++++-- rocketry/time/__init__.py | 3 ++- 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/rocketry/core/time/__init__.py b/rocketry/core/time/__init__.py index b52ef67c..58f5eb94 100644 --- a/rocketry/core/time/__init__.py +++ b/rocketry/core/time/__init__.py @@ -5,7 +5,10 @@ StaticInterval, All, Any, - PARSERS + PARSERS, + + # Constants + always, ) diff --git a/rocketry/core/time/base.py b/rocketry/core/time/base.py index cf12b596..e10c70c2 100644 --- a/rocketry/core/time/base.py +++ b/rocketry/core/time/base.py @@ -47,14 +47,31 @@ def __and__(self, other): # self & other # bitwise and # using & operator - - return All(self, other) + is_time_period = isinstance(other, TimePeriod) + if not is_time_period: + raise TypeError(f"AND operator only supports TimePeriod. Given: {type(other)}") + + if self is always: + # Reducing the operation + return other + elif other is always: + # Reducing the operation + return self + else: + return All(self, other) def __or__(self, other): # self | other # bitwise or + is_time_period = isinstance(other, TimePeriod) + if not is_time_period: + raise TypeError(f"AND operator only supports TimePeriod. Given: {type(other)}") - return Any(self, other) + if self is always or other is always: + # Reducing the operation + return always + else: + return Any(self, other) @abstractmethod def rollforward(self, dt): @@ -421,3 +438,5 @@ def rollforward(self, dt): @property def is_max_interval(self): return (self.start == self.min) and (self.end == self.max) + +always = StaticInterval() \ No newline at end of file diff --git a/rocketry/test/time/test_core.py b/rocketry/test/time/test_core.py index f4ce6d33..850e623c 100644 --- a/rocketry/test/time/test_core.py +++ b/rocketry/test/time/test_core.py @@ -6,7 +6,7 @@ TimeOfMonth, TimeOfYear, ) -from rocketry.time import All, Any +from rocketry.time import All, Any, always def test_equal(): assert not (TimeOfHour("10:00") == TimeOfHour("11:00")) @@ -18,6 +18,21 @@ def test_and(): time = TimeOfHour("10:00", "14:00") & TimeOfHour("09:00", "12:00") assert time == All(TimeOfHour("10:00", "14:00"), TimeOfHour("09:00", "12:00")) +def test_and_reduce_always(): + time = always & always + assert time is always + + time = TimeOfHour("10:00", "14:00") & always + assert time == TimeOfHour("10:00", "14:00") + + def test_any(): time = TimeOfHour("10:00", "14:00") | TimeOfHour("09:00", "12:00") - assert time == Any(TimeOfHour("10:00", "14:00"), TimeOfHour("09:00", "12:00")) \ No newline at end of file + assert time == Any(TimeOfHour("10:00", "14:00"), TimeOfHour("09:00", "12:00")) + +def test_any_reduce_always(): + time = always | always + assert time is always + + time = TimeOfHour("10:00", "14:00") | always + assert time is always \ No newline at end of file diff --git a/rocketry/time/__init__.py b/rocketry/time/__init__.py index 670c8a2e..c4838218 100644 --- a/rocketry/time/__init__.py +++ b/rocketry/time/__init__.py @@ -3,6 +3,7 @@ from .construct import get_between, get_before, get_after, get_full_cycle, get_on from .delta import TimeSpanDelta +from rocketry.core.time import always from rocketry.session import Session @@ -15,7 +16,7 @@ re.compile(r"every (?P.+)"): TimeDelta, re.compile(r"past (?P.+)"): TimeDelta, - "always": StaticInterval(), + "always": always, "never": StaticInterval(start=StaticInterval.max - StaticInterval.resolution), } ) \ No newline at end of file From 84e34c412d9c37869b5a17489a4453098419f056 Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Sun, 7 Aug 2022 22:17:04 +0300 Subject: [PATCH 03/42] upd: anchor time periods extends for full periods --- rocketry/core/time/anchor.py | 6 +++++- rocketry/core/time/base.py | 36 +++++++++++++++++++++++++++++------- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/rocketry/core/time/anchor.py b/rocketry/core/time/anchor.py index 54bce71e..eb80b4e5 100644 --- a/rocketry/core/time/anchor.py +++ b/rocketry/core/time/anchor.py @@ -149,7 +149,7 @@ def __contains__(self, dt) -> bool: ns_start = self._start ns_end = self._end - if ns_start == ns_end: + if self.is_full(): # As there is no time in between, # the interval is considered full # cycle (ie. from 10:00 to 10:00) @@ -163,6 +163,10 @@ def __contains__(self, dt) -> bool: else: return ns >= ns_start or ns <= ns_end + def is_full(self): + "Whether every time belongs to the period (but there is still distinct intervals)" + return self._start == self._end + def get_scope_back(self, dt): "Override if offsetting back is different than forward" return self._scope_max + 1 diff --git a/rocketry/core/time/base.py b/rocketry/core/time/base.py index e10c70c2..465fad9c 100644 --- a/rocketry/core/time/base.py +++ b/rocketry/core/time/base.py @@ -152,27 +152,49 @@ def prev_end(self, dt): def from_between(start, end) -> Interval: raise NotImplementedError("__between__ not implemented.") + def is_full(self): + "Whether every time belongs to the period (but there is still distinct intervals)" + return False + def rollforward(self, dt): "Get next time interval of the period" - start = self.rollstart(dt) - end = self.next_end(dt) + if self.is_full(): + # Full period so dt always belongs on it + start = dt + end = self.next_end(dt) + if end == start: + # Expanding the interval + end = self.next_end(dt + self.resolution) + else: + start = self.rollstart(dt) + end = self.next_end(dt) start = to_datetime(start) end = to_datetime(end) - return Interval(start, end, closed="both") + closed = "both" if start == end else 'left' + return Interval(start, end, closed=closed) def rollback(self, dt) -> Interval: "Get previous time interval of the period" - end = self.rollend(dt) - start = self.prev_start(dt) + if self.is_full(): + # Full period so dt always belongs on it + end = dt + start = self.prev_start(dt) + if end == start: + # Expanding the interval + start = self.prev_start(dt - self.resolution) + else: + end = self.rollend(dt) + start = self.prev_start(dt) start = to_datetime(start) end = to_datetime(end) - - return Interval(start, end, closed="both") + + closed = "both" if start == end or self.is_full() else 'left' + return Interval(start, end, closed=closed) def __eq__(self, other): "Test whether self and other are essentially the same periods" From a0ec20f608918fbc0dfaefff4fa985ca6877e120 Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Sun, 7 Aug 2022 22:19:44 +0300 Subject: [PATCH 04/42] fix: month/week names/abbrs always in English Also now they are case insensitive. --- rocketry/time/interval.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/rocketry/time/interval.py b/rocketry/time/interval.py index 4cc38140..3ceff09b 100644 --- a/rocketry/time/interval.py +++ b/rocketry/time/interval.py @@ -117,9 +117,17 @@ class TimeOfWeek(AnchoredInterval): _unit_resolution = to_nanoseconds(day=1) weeknum_mapping = { - **dict(zip(calendar.day_name, range(7))), - **dict(zip(calendar.day_abbr, range(7))), - **dict(zip(range(7), range(7))) + **dict(zip(range(1, 8), range(7))), + + # English + **{ + day: i + for i, day in enumerate(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']) + }, + **{ + day: i + for i, day in enumerate(['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']) + }, } # TODO: ceil end def anchor_str(self, s, side=None, **kwargs): @@ -129,7 +137,7 @@ def anchor_str(self, s, side=None, **kwargs): comps = res.groupdict() dayofweek = comps.pop("dayofweek") time = comps.pop("time") - nth_day = self.weeknum_mapping[dayofweek] + nth_day = self.weeknum_mapping[dayofweek.lower()] # TODO: TimeOfDay.anchor_str as function if not time: @@ -240,9 +248,11 @@ class TimeOfYear(AnchoredInterval): _scope_max = to_nanoseconds(day=1) * 366 - 1 monthnum_mapping = { - **dict(zip(calendar.month_name[1:], range(12))), - **dict(zip(calendar.month_abbr[1:], range(12))), - **dict(zip(range(12), range(12))) + **dict(zip(range(12), range(12))), + + # English + **{day.lower(): i for i, day in enumerate(['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december'])}, + **{day.lower(): i for i, day in enumerate(['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'])}, } _month_start_mapping = { @@ -273,7 +283,7 @@ def anchor_str(self, s, side=None, **kwargs): comps = res.groupdict() monthofyear = comps.pop("monthofyear") # This is jan, january day_of_month_str = comps.pop("day_of_month") - nth_month = self.monthnum_mapping[monthofyear] + nth_month = self.monthnum_mapping[monthofyear.lower()] ceil_time = not day_of_month_str and side == "end" if ceil_time: From 9d09dd5f03f5ca6e5a2c9827e5e68e1f77eec84f Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Sun, 7 Aug 2022 22:22:24 +0300 Subject: [PATCH 05/42] fix: now interval is left by default Also took off some incorrect closed. --- rocketry/core/time/base.py | 4 ++-- rocketry/pybox/time/interval.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rocketry/core/time/base.py b/rocketry/core/time/base.py index 465fad9c..d6002874 100644 --- a/rocketry/core/time/base.py +++ b/rocketry/core/time/base.py @@ -454,8 +454,8 @@ def rollforward(self, dt): end = to_datetime(self.end) if end < dt: # The actual interval is already gone - return Interval(self.max, self.max, closed="both") - return Interval(dt, end, closed="both") + return Interval(self.max, self.max) + return Interval(dt, end) @property def is_max_interval(self): diff --git a/rocketry/pybox/time/interval.py b/rocketry/pybox/time/interval.py index 15ef5edc..7c8dc461 100644 --- a/rocketry/pybox/time/interval.py +++ b/rocketry/pybox/time/interval.py @@ -3,7 +3,7 @@ class Interval: "Mimics pandas.Interval" - def __init__(self, left, right, closed="right"): + def __init__(self, left, right, closed="left"): self.left = left self.right = right self.closed = closed From 26af4ce45ed4ae721bfd20bb7eb5b17ceb6334f3 Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Sun, 7 Aug 2022 22:23:31 +0300 Subject: [PATCH 06/42] fix: Now anchored periods are full by default This is if no start or end is given. --- rocketry/core/time/anchor.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/rocketry/core/time/anchor.py b/rocketry/core/time/anchor.py index eb80b4e5..94302ec6 100644 --- a/rocketry/core/time/anchor.py +++ b/rocketry/core/time/anchor.py @@ -49,11 +49,15 @@ class AnchoredInterval(TimeInterval): _unit_resolution: int = None # Nanoseconds of one unit (if start/end is int) def __init__(self, start=None, end=None, time_point=None): - #self.start = start - #self.end = end - # time_point = True if start is None and end is None else time_point - self.set_start(start) - self.set_end(end, time_point=time_point) + + if start is None and end is None: + if time_point: + raise ValueError("Full cycle cannot be point of time") + self._start = 0 + self._end = 0 + else: + self.set_start(start) + self.set_end(end, time_point=time_point) def anchor(self, value, **kwargs): "Turn value to nanoseconds relative to scope of the class" From b483ce7cf1a1931bd526f015efd0a037d3b17b06 Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Sun, 7 Aug 2022 22:25:50 +0300 Subject: [PATCH 07/42] fix: And and Any time periods --- rocketry/core/time/base.py | 157 ++++++++++++++++---------- rocketry/pybox/time/interval.py | 61 +++++++++- rocketry/test/time/logic/test_in.py | 117 +++++++++++++++++++ rocketry/test/time/logic/test_roll.py | 123 +++++++++++++++++++- 4 files changed, 390 insertions(+), 68 deletions(-) create mode 100644 rocketry/test/time/logic/test_in.py diff --git a/rocketry/core/time/base.py b/rocketry/core/time/base.py index d6002874..d8d5d429 100644 --- a/rocketry/core/time/base.py +++ b/rocketry/core/time/base.py @@ -1,4 +1,5 @@ import datetime +from functools import reduce import time from abc import abstractmethod from typing import Callable, Dict, List, Pattern, Union @@ -296,46 +297,71 @@ def __init__(self, *args): self.periods = args def rollback(self, dt): + + # We solve this iteratively + # 1. rollback + # 2. check if everything overlaps + # 3. If not overlaps, take max and check again + # 4. If overlaps, get the period that overlaps + intervals = [ period.rollback(dt) for period in self.periods ] - - if all_overlap(intervals): + all_overlaps = reduce(lambda a, b: a.overlaps(b), intervals) + if all_overlaps: + return reduce(lambda a, b: a & b, intervals) + else: + # Not found, trying again with next period # Example: - # A: <--------------> - # B: <------> + # Current: | + # A: <--------------> + # B: <---> <---> # C: <------> - # Out: <--> - return get_overlapping(intervals) - else: - # A: <----------------> - # B: <---> <---> - # C: <-------> - # Try from: <-| - - starts = [interval.left for interval in intervals] - return self.rollback(max(starts) - datetime.datetime.resolution) + # Next try: | + next_dt = min(intervals, key=lambda x: x.right).right + + opened = any( + interv.closed not in ('right', 'both') + for interv in intervals + if interv.right == next_dt + ) + # TODO: If + if dt == next_dt: + next_dt -= self.resolution + return self.rollback(next_dt) def rollforward(self, dt): + # We solve this iteratively + # 1. rollforward + # 2. check if everything overlaps + # 3. If not overlaps, take max and check again + # 4. If overlaps, get the period that overlaps + intervals = [ period.rollforward(dt) for period in self.periods ] - if all_overlap(intervals): - # Example: - # A: <--------------> - # B: <------> - # C: <------> - # Out: <--> - return get_overlapping(intervals) + all_overlaps = reduce(lambda a, b: a.overlaps(b), intervals) + if all_overlaps: + return reduce(lambda a, b: a & b, intervals) else: - # A: <----------------> - # B: <---> <---> - # C: <-------> - # Try from: |-> - ends = [interval.right for interval in intervals] - return self.rollforward(min(ends) + datetime.datetime.resolution) + # Not found, trying again with next period + # Example: + # Current: | + # A: <--------------> + # B: <---> <---> + # C: <------> + # Next try: | + next_dt = max(intervals, key=lambda x: x.left).left + opened = any( + interv.closed not in ('left', 'both') + for interv in intervals + if interv.left == next_dt + ) + if opened: + next_dt -= self.resolution + return self.rollforward(next_dt) def __eq__(self, other): # self | other @@ -361,42 +387,48 @@ def rollback(self, dt): ] # Example: + # Current time | # A: <--------------> # B: <------> # C: <-------------> # Out: <------------------> # Example: + # Current time | # A: <--> # B: <---> <---> # C: <------> - # Out: <-------> + # Out: <---> # Example: + # Current time | # A: <--> # B: <---> <---> # C: <-----> # Out: <-------------> - starts = [interval.left for interval in intervals] - ends = [interval.right for interval in intervals] - start = min(starts) - end = max(ends) + # We solve the problem iteratively + # 1. Find the interval that ends closest to the dt + # 2. Check if there is an interval overlapping with longer end + # 3. Repeat 2 until there is none - next_intervals = [ - period.rollback(start - datetime.datetime.resolution) - for period in self.periods - ] - if any(Interval(start, end).overlaps(interval) for interval in next_intervals): - # Example: - # A: <--> - # B: <---> <---> - # C: <-----> - # Out: <---------|---> - extended = self.rollback(start - datetime.datetime.resolution) - start = extended.left + # Sorting the closest first (right is oldest) + intervals = sorted(intervals, key=lambda x: x.right, reverse=True) - return Interval(start, end) + curr_interval = intervals.pop(0) + end_interval = curr_interval + + for interv in intervals: + extends = interv.left < curr_interval.left + if extends and interv.overlaps(curr_interval): + curr_interval = interv + else: + break + + return Interval( + curr_interval.left, + end_interval.right + ) def rollforward(self, dt): intervals = [ @@ -404,27 +436,28 @@ def rollforward(self, dt): for period in self.periods ] - starts = [interval.left for interval in intervals] - ends = [interval.right for interval in intervals] + # We solve the problem iteratively + # 1. Find the interval that starts closest to the dt + # 2. Check if there is an interval overlapping with longer end + # 3. Repeat 2 until there is none - start = min(starts) - end = max(ends) + # Sorting the closest first (left is newest) + intervals = sorted(intervals, key=lambda x: x.left, reverse=False) - next_intervals = [ - period.rollforward(end + datetime.datetime.resolution) - for period in self.periods - ] + curr_interval = intervals.pop(0) + start_interval = curr_interval - if any(Interval(start, end).overlaps(interval) for interval in next_intervals): - # Example: - # A: <--> - # B: <---> <---> - # C: <-----> - # Out: <---------|---> - extended = self.rollforward(end + datetime.datetime.resolution) - end = extended.right + for interv in intervals: + extends = interv.right > curr_interval.right + if extends and interv.overlaps(curr_interval): + curr_interval = interv + else: + break - return Interval(start, end) + return Interval( + start_interval.left, + curr_interval.right + ) def __eq__(self, other): # self | other diff --git a/rocketry/pybox/time/interval.py b/rocketry/pybox/time/interval.py index 7c8dc461..7fe4d91e 100644 --- a/rocketry/pybox/time/interval.py +++ b/rocketry/pybox/time/interval.py @@ -8,6 +8,18 @@ def __init__(self, left, right, closed="left"): self.right = right self.closed = closed + if left > right: + raise ValueError("Left cannot be greater than right") + + def __contains__(self, dt): + if self.closed == "right": + return self.left < dt <= self.right + elif self.closed == "left": + return self.left <= dt < self.right + elif self.closed == "both": + return self.left <= dt <= self.right + else: + return self.left < dt < self.right def overlaps(self, other:'Interval'): # Simple case: No overlap if: @@ -47,4 +59,51 @@ def overlaps(self, other:'Interval'): # other: |------* return False - return True \ No newline at end of file + return True + + def __and__(self, other): + "Find interval that both overlaps" + + if self.left > other.left: + left = self.left + left_closed = self.closed in ('left', 'both') + elif self.left < other.left: + left = other.left + left_closed = other.closed in ('left', 'both') + else: + # Equal + left = self.left + left_closed = self.closed in ('left', 'both') and other.closed in ('left', 'both') + + if self.right < other.right: + right = self.right + right_closed = self.closed in ('right', 'both') + elif self.right > other.right: + right = other.right + right_closed = other.closed in ('right', 'both') + else: + # Equal + right = self.right + right_closed = self.closed in ('right', 'both') and other.closed in ('right', 'both') + + closed = ( + "both" if left_closed and right_closed + else 'left' if left_closed + else 'right' if right_closed + else 'neither' + ) + + return Interval(left, right, closed=closed) + + @property + def is_empty(self): + "Check if constains no points" + if self.closed == "both": + has_points = self.left <= self.right + elif self.closed in ('left', 'right', 'neither'): + has_points = self.left < self.right + + return not has_points + + def __repr__(self): + return f'Interval({repr(self.left)}, {repr(self.right)}, closed={repr(self.closed)})' \ No newline at end of file diff --git a/rocketry/test/time/logic/test_in.py b/rocketry/test/time/logic/test_in.py new file mode 100644 index 00000000..d12c680f --- /dev/null +++ b/rocketry/test/time/logic/test_in.py @@ -0,0 +1,117 @@ + +import pytest +import datetime + +from rocketry.core.time.base import ( + All, Any +) +from rocketry.time.interval import TimeOfDay, TimeOfMinute + +from_iso = datetime.datetime.fromisoformat + +@pytest.mark.parametrize( + "dt,periods", + [ + # Regular + pytest.param( + from_iso("2020-01-01 13:00:00"), + [ + # Valid range should be 12:00 - 14:00 + TimeOfDay("08:00", "18:00"), # Dominant + TimeOfDay("10:00", "14:00"), + TimeOfDay("12:00", "16:00"), + ], + id="Combination (center)"), + ], +) +def test_all_in(dt, periods): + time = All(*periods) + assert dt in time + +@pytest.mark.parametrize( + "dt,periods", + [ + # Regular + pytest.param( + from_iso("2020-01-01 11:00:00"), + [ + # Valid range should be 12:00 - 14:00 + TimeOfDay("08:00", "18:00"), + TimeOfDay("10:00", "14:00"), + TimeOfDay("12:00", "16:00"), + ], + id="Combination (partial outside, left)"), + pytest.param( + from_iso("2020-01-01 15:00:00"), + [ + # Valid range should be 12:00 - 14:00 + TimeOfDay("08:00", "18:00"), + TimeOfDay("10:00", "14:00"), + TimeOfDay("12:00", "16:00"), + ], + id="Combination (partial outside, right)"), + ], +) +def test_all_not_in(dt, periods): + time = All(*periods) + assert dt not in time + + +@pytest.mark.parametrize( + "dt,periods", + [ + pytest.param( + from_iso("2020-01-01 08:00:00"), + [ + TimeOfDay("08:00", "10:00"), + TimeOfDay("10:00", "14:00"), + TimeOfDay("14:00", "16:00"), + ], + id="Combination (left edge)"), + pytest.param( + from_iso("2020-01-01 11:00:00"), + [ + TimeOfDay("08:00", "10:00"), + TimeOfDay("10:00", "14:00"), + TimeOfDay("14:00", "16:00"), + ], + id="Combination (center)"), + pytest.param( + from_iso("2020-01-01 15:59:59"), + [ + TimeOfDay("08:00", "10:00"), + TimeOfDay("10:00", "14:00"), + TimeOfDay("14:00", "16:00"), + ], + id="Combination (right edge)"), + ], +) +def test_any_in(dt, periods): + time = Any(*periods) + assert dt in time + +@pytest.mark.parametrize( + "dt,periods", + [ + # Regular + pytest.param( + from_iso("2020-01-01 07:00:00"), + [ + TimeOfDay("08:00", "10:00"), + TimeOfDay("10:00", "14:00"), + TimeOfDay("14:00", "16:00"), + ], + id="Combination (left)"), + pytest.param( + from_iso("2020-01-01 17:00:00"), + [ + TimeOfDay("08:00", "10:00"), + TimeOfDay("10:00", "14:00"), + TimeOfDay("14:00", "16:00"), + ], + id="Combination (right)"), + ], +) +def test_any_not_in(dt, periods): + time = Any(*periods) + assert dt not in time \ No newline at end of file diff --git a/rocketry/test/time/logic/test_roll.py b/rocketry/test/time/logic/test_roll.py index fe0f2a80..04bdab42 100644 --- a/rocketry/test/time/logic/test_roll.py +++ b/rocketry/test/time/logic/test_roll.py @@ -5,7 +5,7 @@ from rocketry.core.time.base import ( All, Any ) -from rocketry.time.interval import TimeOfDay +from rocketry.time.interval import TimeOfDay, TimeOfMinute from_iso = datetime.datetime.fromisoformat @@ -21,7 +21,36 @@ TimeOfDay("12:00", "16:00"), ], from_iso("2020-01-01 12:00:00"), from_iso("2020-01-01 14:00:00"), - id="Left from interval"), + id="Combination"), + + pytest.param( + from_iso("2020-01-01 07:00:00"), + [ + TimeOfDay("08:00", "18:00"), + TimeOfDay("10:00", "14:00"), + TimeOfDay("11:00", "12:00"), + ], + from_iso("2020-01-01 11:00:00"), from_iso("2020-01-01 12:00:00"), + id="Defined by one"), + + pytest.param( + from_iso("2020-01-01 12:00:00"), + [ + TimeOfDay("08:00", "18:00"), + TimeOfDay("10:00", "14:00"), + TimeOfDay("12:00", "16:00"), + ], + from_iso("2020-01-01 12:00:00"), from_iso("2020-01-01 14:00:00"), + id="Inside period"), + + pytest.param( + from_iso("2020-01-01 12:00:00"), + [ + TimeOfDay("08:00", "18:00"), + TimeOfMinute(), + ], + from_iso("2020-01-01 12:00:00"), from_iso("2020-01-01 12:01:00"), + id="Cron-like"), ], ) def test_rollforward_all(dt, periods, roll_start, roll_end): @@ -43,7 +72,17 @@ def test_rollforward_all(dt, periods, roll_start, roll_end): TimeOfDay("12:00", "16:00"), ], from_iso("2020-01-01 12:00:00"), from_iso("2020-01-01 14:00:00"), - id="Left from interval"), + id="Combination"), + + pytest.param( + from_iso("2020-01-01 13:30:00"), + [ + TimeOfDay("08:00", "18:00"), + TimeOfDay("10:00", "14:00"), + TimeOfDay("12:00", "16:00"), + ], + from_iso("2020-01-01 12:00:00"), from_iso("2020-01-01 13:30:00"), + id="Inside period"), ], ) def test_rollback_all(dt, periods, roll_start, roll_end): @@ -65,7 +104,44 @@ def test_rollback_all(dt, periods, roll_start, roll_end): TimeOfDay("12:00", "16:00"), ], from_iso("2020-01-01 08:00:00"), from_iso("2020-01-01 18:00:00"), - id="Left from interval"), + id="Dominant (one interval from start to end)"), + + pytest.param( + from_iso("2020-01-01 10:00:00"), + [ + TimeOfDay("08:00", "09:00"), + TimeOfDay("11:00", "12:00"), + TimeOfDay("15:00", "18:00"), + ], + from_iso("2020-01-01 11:00:00"), from_iso("2020-01-01 12:00:00"), + id="Sequential (not overlap)"), + + pytest.param( + from_iso("2020-01-01 07:00:00"), + [ + TimeOfDay("08:00", "09:00"), + TimeOfDay("09:00", "12:00"), + TimeOfDay("12:00", "18:00"), + ], + from_iso("2020-01-01 08:00:00"), from_iso("2020-01-01 18:00:00"), + id="Sequential (overlap)"), + + pytest.param( + from_iso("2020-01-01 08:30:00"), + [ + TimeOfDay("08:00", "09:00"), + TimeOfDay("09:00", "10:00"), + ], + from_iso("2020-01-01 08:30:00"), from_iso("2020-01-01 10:00:00"), + id="On interval"), + + pytest.param( + from_iso("2020-01-01 07:00:00"), + [ + TimeOfDay("08:00", "09:00"), + ], + from_iso("2020-01-01 08:00:00"), from_iso("2020-01-01 09:00:00"), + id="Single interval"), ], ) def test_rollforward_any(dt, periods, roll_start, roll_end): @@ -87,7 +163,44 @@ def test_rollforward_any(dt, periods, roll_start, roll_end): TimeOfDay("12:00", "16:00"), ], from_iso("2020-01-01 08:00:00"), from_iso("2020-01-01 18:00:00"), - id="Left from interval"), + id="Dominant (one interval from start to end)"), + + pytest.param( + from_iso("2020-01-01 13:00:00"), + [ + TimeOfDay("08:00", "09:00"), + TimeOfDay("11:00", "12:00"), + TimeOfDay("15:00", "18:00"), + ], + from_iso("2020-01-01 11:00:00"), from_iso("2020-01-01 12:00:00"), + id="Sequential (not overlap)"), + + pytest.param( + from_iso("2020-01-01 19:00:00"), + [ + TimeOfDay("08:00", "09:00"), + TimeOfDay("09:00", "12:00"), + TimeOfDay("12:00", "18:00"), + ], + from_iso("2020-01-01 08:00:00"), from_iso("2020-01-01 18:00:00"), + id="Sequential (overlap)"), + + pytest.param( + from_iso("2020-01-01 09:30:00"), + [ + TimeOfDay("08:00", "09:00"), + TimeOfDay("09:00", "10:00"), + ], + from_iso("2020-01-01 08:00:00"), from_iso("2020-01-01 09:30:00"), + id="On interval"), + + pytest.param( + from_iso("2020-01-01 10:00:00"), + [ + TimeOfDay("08:00", "09:00"), + ], + from_iso("2020-01-01 08:00:00"), from_iso("2020-01-01 09:00:00"), + id="Single interval"), ], ) def test_rollback_any(dt, periods, roll_start, roll_end): From 14f8137278c69bbedec295dbdd7ab3ae9c8a0e1a Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Sun, 7 Aug 2022 22:26:26 +0300 Subject: [PATCH 08/42] fix: anchor_int with anchor periods Now int goes by natural language --- rocketry/time/interval.py | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/rocketry/time/interval.py b/rocketry/time/interval.py index 3ceff09b..5caa63ff 100644 --- a/rocketry/time/interval.py +++ b/rocketry/time/interval.py @@ -56,6 +56,11 @@ class TimeOfHour(AnchoredInterval): _scope_max = to_nanoseconds(hour=1) - 1 _unit_resolution = to_nanoseconds(minute=1) + def anchor_int(self, i, **kwargs): + if not 0 <= i <= 59: + raise ValueError(f"Invalid minute: {i}. Minute is from 0 to 59") + return super().anchor_int(i, **kwargs) + def anchor_str(self, s, **kwargs): # ie. 12:30.123 res = re.search(r"(?P[0-9][0-9]):(?P[0-9][0-9])([.](?P[0-9]{0,6}))?(?P[0-9]+)?", s, flags=re.IGNORECASE) @@ -85,6 +90,11 @@ class TimeOfDay(AnchoredInterval): _scope_max = to_nanoseconds(day=1) - 1 _unit_resolution = to_nanoseconds(hour=1) + def anchor_int(self, i, **kwargs): + if not 0 <= i <= 23: + raise ValueError(f"Invalid hour: {i}. Day is from 00 to 23") + return super().anchor_int(i, **kwargs) + def anchor_str(self, s, **kwargs): # ie. "10:00:15" dt = dateutil.parser.parse(s) @@ -129,7 +139,15 @@ class TimeOfWeek(AnchoredInterval): for i, day in enumerate(['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']) }, } - # TODO: ceil end + + def anchor_int(self, i, **kwargs): + # The axis is from 0 to 6 times nanoseconds per day + # but if start/end is passed as int, it's considered from 1-7 + # (Monday is 1) + if not 1 <= i <= 7: + raise ValueError("Invalid day of week. Week day is from 1 (Monday) to 7 (Sunday).") + return super().anchor_int(i-1, **kwargs) + def anchor_str(self, s, side=None, **kwargs): # Allowed: # "Mon", "Monday", "Mon 10:00:00" @@ -179,6 +197,13 @@ class TimeOfMonth(AnchoredInterval): # NOTE: Floating # TODO: ceil end and implement reversion (last 5th day) + def anchor_int(self, i, **kwargs): + if not 1 <= i <= 31: + raise ValueError("Invalid day of month. Day of month can be from 1 to 31") + # We allow passing days from 1-31 but the axis is built on starting from zero + i -= 1 + return super().anchor_int(i, **kwargs) + def anchor_str(self, s, side=None, **kwargs): # Allowed: # "5th", "1st", "5", "3rd 12:30:00" @@ -310,7 +335,11 @@ def to_timepoint(self, ns:int): return self._month_start_mapping[month_num + 1] - 1 def anchor_int(self, i, side=None, **kwargs): - # i is the month + # i is the month (Jan = 1) + # The axis is from 0 to 365 * nanoseconds per day + if not 1 <= i <= 12: + raise ValueError(f"Invalid month: {i} (Jan is 1 and Dec is 12)") + i -= 1 if side == "end": return self._month_start_mapping[i+1] - 1 return self._month_start_mapping[i] From abfbdcdfb1288f87e1397a30e35e133d6403787c Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Sun, 7 Aug 2022 22:26:54 +0300 Subject: [PATCH 09/42] test: add more time construct tests --- rocketry/test/time/interval/test_construct.py | 57 +++++++++++++++++-- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/rocketry/test/time/interval/test_construct.py b/rocketry/test/time/interval/test_construct.py index 6252b24a..f1f1a7a7 100644 --- a/rocketry/test/time/interval/test_construct.py +++ b/rocketry/test/time/interval/test_construct.py @@ -29,6 +29,8 @@ def pytest_generate_tests(metafunc): params = cls.scen_open_right elif method_name == "test_time_point": params = cls.scen_time_point + elif method_name == "test_value_error": + params = cls.scen_value_error else: return @@ -73,6 +75,10 @@ def test_time_point(self, start, expected_start, expected_end, **kwargs): assert expected_start == time._start assert expected_end == time._end + def test_value_error(self, start, end): + with pytest.raises(ValueError): + time = self.cls(start, end) + class TestTimeOfHour(ConstructTester): cls = TimeOfHour @@ -114,6 +120,13 @@ class TestTimeOfHour(ConstructTester): } ] + scen_value_error = [ + { + "start": 60, + "end": None + } + ] + class TestTimeOfDay(ConstructTester): cls = TimeOfDay @@ -154,6 +167,12 @@ class TestTimeOfDay(ConstructTester): "expected_end": 13 * NS_IN_HOUR - 1, } ] + scen_value_error = [ + { + "start": 24, + "end": None, + } + ] class TestTimeOfWeek(ConstructTester): @@ -179,8 +198,8 @@ class TestTimeOfWeek(ConstructTester): }, { # Spans from Tue 00:00:00 to Wed 23:59:59 999 - "start": 1, - "end": 2, + "start": 2, + "end": 3, "expected_start": 1 * NS_IN_DAY, "expected_end": 3 * NS_IN_DAY - 1, }, @@ -205,6 +224,12 @@ class TestTimeOfWeek(ConstructTester): "expected_end": 2 * NS_IN_DAY - 1, } ] + scen_value_error = [ + { + "start": 0, + "end": None + } + ] class TestTimeOfMonth(ConstructTester): @@ -227,8 +252,8 @@ class TestTimeOfMonth(ConstructTester): "expected_end": 4 * NS_IN_DAY - 1, }, { - "start": 1, - "end": 3, + "start": 2, + "end": 4, "expected_start": 1 * NS_IN_DAY, "expected_end": 4 * NS_IN_DAY - 1, }, @@ -253,6 +278,16 @@ class TestTimeOfMonth(ConstructTester): "expected_end": 2 * NS_IN_DAY - 1, } ] + scen_value_error = [ + { + "start": 0, + "end": None, + }, + { + "start": None, + "end": 32, + } + ] class TestTimeOfYear(ConstructTester): @@ -274,8 +309,8 @@ class TestTimeOfYear(ConstructTester): "expected_end": (31 + 29 + 31 + 30) * NS_IN_DAY - 1, }, { - "start": 1, - "end": 3, + "start": 2, + "end": 4, "expected_start": 31 * NS_IN_DAY, "expected_end": (31 + 29 + 31 + 30) * NS_IN_DAY - 1, }, @@ -318,3 +353,13 @@ class TestTimeOfYear(ConstructTester): "expected_end": 366 * NS_IN_DAY - 1, }, ] + scen_value_error = [ + { + "start": 0, + "end": None, + }, + { + "start": None, + "end": 13, + }, + ] \ No newline at end of file From 2c56550b29193f8b66b2804e7e0e000654e91389 Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Sun, 7 Aug 2022 22:27:24 +0300 Subject: [PATCH 10/42] upd: better reprs in time --- rocketry/core/time/anchor.py | 8 +++++++- rocketry/core/time/base.py | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/rocketry/core/time/anchor.py b/rocketry/core/time/anchor.py index 94302ec6..5c924ade 100644 --- a/rocketry/core/time/anchor.py +++ b/rocketry/core/time/anchor.py @@ -358,9 +358,15 @@ def prev_end(self, dt): return dt + offset + def repr_ns(self, n:int): + "Nanoseconds to representative format" + return repr(n) + def __repr__(self): cls_name = type(self).__name__ - return f'{cls_name}({repr(self._start_orig)}, {repr(self._end_orig)})' + start = self.repr_ns(self._start) if not hasattr(self, "_start_orgi") else self._start_orig + end = self.repr_ns(self._end) if not hasattr(self, "_end_orgi") else self._end_orig + return f'{cls_name}({start}, {end})' def __str__(self): # Hour: '15:' diff --git a/rocketry/core/time/base.py b/rocketry/core/time/base.py index d8d5d429..2a7eaf5f 100644 --- a/rocketry/core/time/base.py +++ b/rocketry/core/time/base.py @@ -371,6 +371,13 @@ def __eq__(self, other): else: return False + def __repr__(self): + sub = ', '.join(repr(p) for p in self.periods) + return f"All({sub})" + + def __str__(self): + return ' & '.join(str(p) for p in self.periods) + class Any(TimePeriod): def __init__(self, *args): @@ -467,6 +474,13 @@ def __eq__(self, other): else: return False + def __repr__(self): + sub = ', '.join(repr(p) for p in self.periods) + return f"Any({sub})" + + def __str__(self): + return ' | '.join(str(p) for p in self.periods) + class StaticInterval(TimePeriod): """Inverval that is fixed in specific datetimes.""" From 63b13dfb2f2ccb72902dfbec922750eca6bb718d Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Sun, 7 Aug 2022 23:31:36 +0300 Subject: [PATCH 11/42] add: crontab period --- rocketry/test/time/test_cron.py | 170 ++++++++++++++++++++++++++++++++ rocketry/time/__init__.py | 2 + rocketry/time/crontab.py | 85 ++++++++++++++++ 3 files changed, 257 insertions(+) create mode 100644 rocketry/test/time/test_cron.py create mode 100644 rocketry/time/crontab.py diff --git a/rocketry/test/time/test_cron.py b/rocketry/test/time/test_cron.py new file mode 100644 index 00000000..064436e8 --- /dev/null +++ b/rocketry/test/time/test_cron.py @@ -0,0 +1,170 @@ +import datetime +import pytest +from rocketry.time import Crontab, always +from rocketry.time.interval import TimeOfDay, TimeOfHour, TimeOfMinute, TimeOfMonth, TimeOfWeek, TimeOfYear + +every_minute = TimeOfMinute() + +@pytest.mark.parametrize( + "period,expected", + [ + # Test at + pytest.param(Crontab(), every_minute, id="* * * *"), + pytest.param(Crontab("30", "*", "*", "*", "*"), every_minute & TimeOfHour.at(30), id="30 * * * *"), + pytest.param(Crontab("*", "12", "*", "*", "*"), every_minute & TimeOfDay.at(12), id="* 12 * * *"), + pytest.param(Crontab("*", "*", "28", "*", "*"), every_minute & TimeOfMonth.at(28), id="* * 28 * *"), + pytest.param(Crontab("*", "*", "*", "6", "*"), every_minute & TimeOfYear.at(6), id="* * * 6 *"), + pytest.param(Crontab("*", "*", "*", "*", "0"), every_minute & TimeOfWeek.at("Sunday"), id="* * * * 0"), + + # Test at synonyms + pytest.param(Crontab("*", "*", "*", "JUN", "*"), every_minute & TimeOfYear.at("June"), id="* * * JUN *"), + pytest.param(Crontab("*", "*", "*", "*", "SUN"), every_minute & TimeOfWeek.at("Sunday"), id="* * * * SUN"), + + # Test ranges + pytest.param(Crontab("45-59", "*", "*", "*", "*"), every_minute & TimeOfHour(45, 59), id="45-59 * * * *"), + pytest.param(Crontab("*", "10-13", "*", "*", "*"), every_minute & TimeOfDay(10, 13), id="* 10-13 * * *"), + pytest.param(Crontab("*", "*", "28-30", "*", "*"), every_minute & TimeOfMonth(28, 30), id="* * 28-30 * *"), + pytest.param(Crontab("*", "*", "*", "FEB-MAR", "*"), every_minute & TimeOfYear("feb", "mar"), id="* * * FEB-MAR *"), + pytest.param(Crontab("*", "*", "*", "*", "FRI-SUN"), every_minute & TimeOfWeek("fri", "sun"), id="* * * * FRI-SUN"), + + # Test list + pytest.param(Crontab("0,15,30,45", "*", "*", "*", "*"), TimeOfHour.at(0) | TimeOfHour.at(15) | TimeOfHour.at(30) | TimeOfHour.at(45), id="0,15,30,45 * * * *"), + + # Test combinations + pytest.param( + Crontab("45-59", "10-13", "28-30", "FEB-MAR", "FRI-SUN"), + TimeOfHour(45, 59) & TimeOfDay(10, 13) & TimeOfYear("feb", "mar") & (TimeOfMonth(28, 30) | TimeOfWeek("fri", "sun")), + id="45-59 10-13 28-30 FEB-MAR FRI-SUN" + ), + pytest.param( + Crontab("45-59", "10-13", "28-30", "FEB-MAR", "*"), + TimeOfHour(45, 59) & TimeOfDay(10, 13) & TimeOfYear("feb", "mar") & TimeOfMonth(28, 30), + id="45-59 10-13 28-30 FEB-MAR *" + ), + pytest.param( + Crontab("0-29,45-59", "0-10,20-23", "*", "*", "*"), + (TimeOfHour(0, 29) | TimeOfHour(45, 59)) & (TimeOfDay(0, 10) | TimeOfDay(20, 23)), + id="0-29,45-59 0-10,20-23 * * *" + ), + ] +) +def test_subperiod(period, expected): + subperiod = period.get_subperiod() + assert subperiod == expected + +def test_in(): + period = Crontab("30", "*", "*", "*", "*") + assert datetime.datetime(2022, 8, 7, 12, 29, 59) not in period + assert datetime.datetime(2022, 8, 7, 12, 30, 00) in period + assert datetime.datetime(2022, 8, 7, 12, 30, 59) in period + assert datetime.datetime(2022, 8, 7, 12, 31, 00) not in period + +def test_roll_forward_simple(): + period = Crontab("30", "*", "*", "*", "*") + + # Roll tiny amount + interv = period.rollforward(datetime.datetime(2022, 8, 7, 12, 29, 59)) + assert interv.left == datetime.datetime(2022, 8, 7, 12, 30, 00) + assert interv.right == datetime.datetime(2022, 8, 7, 12, 31, 00) + + # No roll (at left) + interv = period.rollforward(datetime.datetime(2022, 8, 7, 12, 30, 0)) + assert interv.left == datetime.datetime(2022, 8, 7, 12, 30, 00) + assert interv.right == datetime.datetime(2022, 8, 7, 12, 31, 00) + + # No roll (at center) + interv = period.rollforward(datetime.datetime(2022, 8, 7, 12, 30, 30)) + assert interv.left == datetime.datetime(2022, 8, 7, 12, 30, 30) + assert interv.right == datetime.datetime(2022, 8, 7, 12, 31, 00) + + # No roll (at right) + interv = period.rollforward(datetime.datetime(2022, 8, 7, 12, 30, 59, 999999)) + assert interv.left == datetime.datetime(2022, 8, 7, 12, 30, 59, 999999) + assert interv.right == datetime.datetime(2022, 8, 7, 12, 31, 00) + + # Roll (at right) + interv = period.rollforward(datetime.datetime(2022, 8, 7, 12, 31)) + assert interv.left == datetime.datetime(2022, 8, 7, 13, 30) + assert interv.right == datetime.datetime(2022, 8, 7, 13, 31) + +def test_roll_back_simple(): + period = Crontab("30", "*", "*", "*", "*") + + # Roll tiny amount + interv = period.rollback(datetime.datetime(2022, 8, 7, 12, 31, 1)) + assert interv.left == datetime.datetime(2022, 8, 7, 12, 30, 00) + assert interv.right == datetime.datetime(2022, 8, 7, 12, 31, 00) + assert interv.closed == "left" + + # No roll (at left, single point) + interv = period.rollback(datetime.datetime(2022, 8, 7, 12, 30, 0)) + assert interv.left == datetime.datetime(2022, 8, 7, 12, 30, 00) + assert interv.right == datetime.datetime(2022, 8, 7, 12, 30, 00) + assert interv.closed == "both" + + # No roll (at center) + interv = period.rollback(datetime.datetime(2022, 8, 7, 12, 30, 30)) + assert interv.left == datetime.datetime(2022, 8, 7, 12, 30, 00) + assert interv.right == datetime.datetime(2022, 8, 7, 12, 30, 30) + assert interv.closed == "left" + + # No roll (at right) + interv = period.rollback(datetime.datetime(2022, 8, 7, 12, 30, 59, 999999)) + assert interv.left == datetime.datetime(2022, 8, 7, 12, 30, 00) + assert interv.right == datetime.datetime(2022, 8, 7, 12, 30, 59, 999999) + + # Roll (at right) + interv = period.rollback(datetime.datetime(2022, 8, 7, 14, 15)) + assert interv.left == datetime.datetime(2022, 8, 7, 13, 30) + assert interv.right == datetime.datetime(2022, 8, 7, 13, 31) + +def test_roll_minute_range(): + period = Crontab("30-45", "*", "*", "*", "*") + + interv = period.rollforward(datetime.datetime.fromisoformat("2022-08-07 12:33:00")) + assert interv.left == datetime.datetime.fromisoformat("2022-08-07 12:33:00") + assert interv.right == datetime.datetime.fromisoformat("2022-08-07 12:34:00") + + interv = period.rollback(datetime.datetime.fromisoformat("2022-08-07 12:32:59")) + assert interv.left == datetime.datetime.fromisoformat("2022-08-07 12:32:00") + assert interv.right == datetime.datetime.fromisoformat("2022-08-07 12:32:59") + +def test_roll_complex(): + period = Crontab(*"15,30 18-22 20 OCT *".split(" ")) + + interv = period.rollforward(datetime.datetime(2022, 8, 7, 10, 0, 0)) + assert interv.left == datetime.datetime.fromisoformat("2022-10-20 18:15:00") + assert interv.right == datetime.datetime.fromisoformat("2022-10-20 18:16:00") + + interv = period.rollback(datetime.datetime(2022, 12, 7, 10, 0, 0)) + assert interv.left == datetime.datetime.fromisoformat("2022-10-20 22:30:00") + assert interv.right == datetime.datetime.fromisoformat("2022-10-20 22:31:00") + +def test_roll_conflict_day_of_week_first(): + # If day_of_month and day_of_week are passed + # Crontab seems to prefer the one that is sooner (OR) + period = Crontab(*"15 18-22 20 OCT MON".split(" ")) + + # Prefer day of week + interv = period.rollforward(datetime.datetime(2022, 8, 7, 10, 0, 0)) + assert interv.left == datetime.datetime.fromisoformat("2022-10-03 18:15:00") + assert interv.right == datetime.datetime.fromisoformat("2022-10-03 18:16:00") + + # Prefer day of week + interv = period.rollback(datetime.datetime(2022, 12, 7, 10, 0, 0)) + assert interv.left == datetime.datetime.fromisoformat("2022-10-31 22:15:00") + assert interv.right == datetime.datetime.fromisoformat("2022-10-31 22:16:00") + +def test_roll_conflict_day_of_month_first(): + # If day_of_month and day_of_week are passed + # Crontab seems to prefer the one that is sooner (OR) + period = Crontab(*"15 18-22 3 OCT FRI".split(" ")) + + interv = period.rollforward(datetime.datetime(2022, 8, 7, 10, 0, 0)) + assert interv.left == datetime.datetime.fromisoformat("2022-10-03 18:15:00") + assert interv.right == datetime.datetime.fromisoformat("2022-10-03 18:16:00") + + period = Crontab(*"15 18-22 29 OCT FRI".split(" ")) + interv = period.rollback(datetime.datetime(2022, 12, 7, 10, 0, 0)) + assert interv.left == datetime.datetime.fromisoformat("2022-10-29 22:15:00") + assert interv.right == datetime.datetime.fromisoformat("2022-10-29 22:16:00") \ No newline at end of file diff --git a/rocketry/time/__init__.py b/rocketry/time/__init__.py index c4838218..0dac624d 100644 --- a/rocketry/time/__init__.py +++ b/rocketry/time/__init__.py @@ -3,6 +3,8 @@ from .construct import get_between, get_before, get_after, get_full_cycle, get_on from .delta import TimeSpanDelta +from .crontab import Crontab + from rocketry.core.time import always from rocketry.session import Session diff --git a/rocketry/time/crontab.py b/rocketry/time/crontab.py new file mode 100644 index 00000000..6ac2a4b9 --- /dev/null +++ b/rocketry/time/crontab.py @@ -0,0 +1,85 @@ +from functools import reduce +from typing import Callable + +from .interval import TimeOfHour, TimeOfDay, TimeOfMinute, TimeOfWeek, TimeOfMonth, TimeOfYear +from rocketry.core.time.base import TimePeriod, always + +class Crontab(TimePeriod): + + def __init__(self, minute="*", hour="*", day_of_month="*", month="*", day_of_week="*"): + self.minute = minute + self.hour = hour + self.day_of_month = day_of_month + self.month = month + self.day_of_week = day_of_week + + # *: any value + # ,: list of values + # -: range of values + # /: step values + + def rollforward(self, dt): + "Get previous time interval of the period." + return self.get_subperiod().rollforward(dt) + + def rollback(self, dt): + "Get previous time interval of the period." + return self.get_subperiod().rollback(dt) + + def _get_period_from_expr(self, cls, expression:str, conv:Callable=None, default=always): + + conv = (lambda i: i) if conv is None else conv + exprs = expression.split(",") + full_period = None + for expr in exprs: + if expr == "*": + # Any + continue + if "/" in expr: + raise NotImplementedError("Crontab skip is not yet implemented.") + + if "-" in expr: + # From to + start, end = expr.split("-") + if start.isdigit(): + start = conv(int(start)) + if end.isdigit(): + end = conv(int(end)) + period = cls(start, end) + else: + # At + value = conv(int(expr)) if expr.isdigit() else expr + period = cls.at(value) + + if full_period is None: + full_period = period + else: + full_period |= period + + if full_period is None: + return default + return full_period + + def _convert_day_of_week(self, i:int): + # In cron, 0 is Sunday but DayOfWeek don't allow zero + return 7 if i == 0 else i + + def get_subperiod(self): + minutely = TimeOfMinute() + + day_of_month = self._get_period_from_expr(TimeOfMonth, self.day_of_month) + day_of_week = self._get_period_from_expr(TimeOfWeek, self.day_of_week, conv=self._convert_day_of_week) + + if day_of_month is not always and day_of_week is not always: + # Both specified: OR + day_of_week_month = day_of_month | day_of_week + else: + # Either is specified or neither: AND (the other should be "always") + day_of_week_month = day_of_month & day_of_week + + return ( + (self._get_period_from_expr(TimeOfHour, self.minute) & minutely) + & self._get_period_from_expr(TimeOfDay, self.hour) + & self._get_period_from_expr(TimeOfYear, self.month) + & day_of_week_month + ) From db37efa8909d6647f04876122e5b529bde3ca5e4 Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Mon, 8 Aug 2022 23:05:13 +0300 Subject: [PATCH 12/42] fix: full if end is scope_max in anchor --- rocketry/core/time/anchor.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/rocketry/core/time/anchor.py b/rocketry/core/time/anchor.py index 5c924ade..043711f3 100644 --- a/rocketry/core/time/anchor.py +++ b/rocketry/core/time/anchor.py @@ -169,7 +169,7 @@ def __contains__(self, dt) -> bool: def is_full(self): "Whether every time belongs to the period (but there is still distinct intervals)" - return self._start == self._end + return (self._start == self._end) or (self._start == 0 and self._end == self._scope_max) def get_scope_back(self, dt): "Override if offsetting back is different than forward" @@ -279,7 +279,6 @@ def prev_start(self, dt): "Get previous start point of the period" ns = self.anchor_dt(dt) # In relative nanoseconds (removed more accurate than scope) ns_start = self._start - ns_end = self._end if ns < ns_start: # not in period, over night @@ -319,7 +318,6 @@ def prev_start(self, dt): def prev_end(self, dt): "Get pervious end point of the period" ns = self.anchor_dt(dt) # In relative nanoseconds (removed more accurate than scope) - ns_start = self._start ns_end = self._end if ns < ns_end: From 5a9dd5c9ce49a99e5a98a04b1db4d4951ef55e88 Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Mon, 8 Aug 2022 23:06:23 +0300 Subject: [PATCH 13/42] add: time periods to frozen dataclasses --- rocketry/core/time/anchor.py | 31 ++++---- rocketry/core/time/base.py | 70 +++++++++---------- .../test/time/delta/span/test_contains.py | 9 +-- rocketry/test/time/delta/test_contains.py | 6 +- rocketry/time/crontab.py | 12 ++-- rocketry/time/delta.py | 16 +++-- rocketry/time/interval.py | 62 +++++++++------- 7 files changed, 113 insertions(+), 93 deletions(-) diff --git a/rocketry/core/time/anchor.py b/rocketry/core/time/anchor.py index 043711f3..04b4f98e 100644 --- a/rocketry/core/time/anchor.py +++ b/rocketry/core/time/anchor.py @@ -1,13 +1,14 @@ from datetime import datetime -from typing import Union +from typing import ClassVar, Tuple, Union from abc import abstractmethod +from dataclasses import dataclass, field from .utils import to_nanoseconds, timedelta_to_str, to_dict, to_timedelta from .base import TimeInterval - +@dataclass(frozen=True, repr=False) class AnchoredInterval(TimeInterval): """Base class for interval for those that have fixed time unit (that can be converted to nanoseconds). @@ -38,23 +39,26 @@ class AnchoredInterval(TimeInterval): ----------------- _scope [str] : """ - components = ("year", "month", "week", "day", "hour", "minute", "second", "microsecond", "nanosecond") + _start: int + _end: int + + components: ClassVar[Tuple[str]] = ("year", "month", "week", "day", "hour", "minute", "second", "microsecond", "nanosecond") # Components that have always fixed length (exactly the same amount of time) - _fixed_components = ("week", "day", "hour", "minute", "second", "microsecond", "nanosecond") + _fixed_components: ClassVar[Tuple[str]] = ("week", "day", "hour", "minute", "second", "microsecond", "nanosecond") - _scope:str = None # Scope of the full period. Ie. day, hour, second, microsecond - _scope_max:int = None # Max in nanoseconds of the + _scope: ClassVar[str] = None # Scope of the full period. Ie. day, hour, second, microsecond + _scope_max: ClassVar[int] = None # Max in nanoseconds of the - _unit_resolution: int = None # Nanoseconds of one unit (if start/end is int) + _unit_resolution: ClassVar[int] = None # Nanoseconds of one unit (if start/end is int) def __init__(self, start=None, end=None, time_point=None): if start is None and end is None: if time_point: raise ValueError("Full cycle cannot be point of time") - self._start = 0 - self._end = 0 + object.__setattr__(self, "_start", 0) + object.__setattr__(self, "_end", self._scope_max) else: self.set_start(start) self.set_end(end, time_point=time_point) @@ -101,8 +105,9 @@ def set_start(self, val): ns = 0 else: ns = self.anchor(val, side="start") - self._start = ns - self._start_orig = val + + object.__setattr__(self, "_start", ns) + object.__setattr__(self, "_start_orig", val) def set_end(self, val, time_point=False): if time_point and val is None: @@ -113,8 +118,8 @@ def set_end(self, val, time_point=False): else: ns = self.anchor(val, side="end", time_point=time_point) - self._end = ns - self._end_orig = val + object.__setattr__(self, "_end", ns) + object.__setattr__(self, "_end_orig", val) def to_timepoint(self, ns:int): "Turn nanoseconds to the period's timepoint" diff --git a/rocketry/core/time/base.py b/rocketry/core/time/base.py index 2a7eaf5f..92a8135d 100644 --- a/rocketry/core/time/base.py +++ b/rocketry/core/time/base.py @@ -2,8 +2,9 @@ from functools import reduce import time from abc import abstractmethod -from typing import Callable, Dict, List, Pattern, Union +from typing import Callable, ClassVar, Dict, FrozenSet, List, Optional, Pattern, Set, Union import itertools +from dataclasses import dataclass, field from rocketry._base import RedBase from rocketry.core.meta import _add_parser @@ -12,20 +13,8 @@ PARSERS: Dict[Union[str, Pattern], Union[Callable, 'TimePeriod']] = {} -class _TimeMeta(type): - def __new__(mcs, name, bases, class_dict): - cls = type.__new__(mcs, name, bases, class_dict) - # Add the parsers - if cls.session is None: - # Package defaults - _add_parser(cls, container=Session._time_parsers) - else: - # User defined - _add_parser(cls, container=cls.session.time_parsers) - return cls - - -class TimePeriod(RedBase, metaclass=_TimeMeta): +@dataclass(frozen=True) +class TimePeriod(RedBase): """Base for all classes that represent a time period. Time period is a period in time with a start and an end. @@ -34,9 +23,9 @@ class TimePeriod(RedBase, metaclass=_TimeMeta): is in a given time span. """ - resolution = datetime.timedelta.resolution - min = datetime.datetime(1970, 1, 3, 2, 0) - max = datetime.datetime(2260, 1, 1, 0, 0) + resolution: ClassVar[datetime.timedelta] = datetime.timedelta.resolution + min: ClassVar[datetime.datetime] = datetime.datetime(1970, 1, 3, 2, 0) + max: ClassVar[datetime.datetime] = datetime.datetime(2260, 1, 1, 0, 0) def __contains__(self, other): """Whether a given point of time is in @@ -113,7 +102,7 @@ class TimeInterval(TimePeriod): Answers to "between 11:00 and 12:00" and "from monday to tuesday" """ - _type_name = "interval" + _type_name: ClassVar = "interval" @abstractmethod def __contains__(self, dt): "Check whether the datetime is on the period" @@ -157,7 +146,7 @@ def is_full(self): "Whether every time belongs to the period (but there is still distinct intervals)" return False - def rollforward(self, dt): + def rollforward(self, dt) -> datetime.datetime: "Get next time interval of the period" if self.is_full(): @@ -205,7 +194,7 @@ def __eq__(self, other): else: return False - +@dataclass(frozen=True) class TimeDelta(TimePeriod): """Base for all time deltas @@ -215,11 +204,11 @@ class TimeDelta(TimePeriod): the reference point is set. This reference point is typically current datetime. """ - _type_name = "delta" + _type_name: ClassVar = "delta" - reference: datetime.datetime + reference: Optional[datetime.datetime] = field(default=None) - def __init__(self, past=None, future=None, kws_past=None, kws_future=None): + def __init__(self, past=None, future=None, reference=None, *, kws_past=None, kws_future=None): past = 0 if past is None else past future = 0 if future is None else future @@ -227,13 +216,14 @@ def __init__(self, past=None, future=None, kws_past=None, kws_future=None): kws_past = {} if kws_past is None else kws_past kws_future = {} if kws_future is None else kws_future - self.past = abs(to_timedelta(past, **kws_past)) - self.future = abs(to_timedelta(future, **kws_future)) + object.__setattr__(self, "past", abs(to_timedelta(past, **kws_past))) + object.__setattr__(self, "future", abs(to_timedelta(future, **kws_future))) + object.__setattr__(self, "reference", reference) @abstractmethod def __contains__(self, dt): "Check whether the datetime is in " - reference = getattr(self, "reference", datetime.datetime.fromtimestamp(time.time())) + reference = self.reference if self.reference is not None else datetime.datetime.fromtimestamp(time.time()) start = reference - abs(self.past) end = reference + abs(self.future) return start <= dt <= end @@ -287,14 +277,17 @@ def get_overlapping(times): end = min(ends) return Interval(start, end) +@dataclass(frozen=True) class All(TimePeriod): + periods: FrozenSet[TimePeriod] + def __init__(self, *args): if any(not isinstance(arg, TimePeriod) for arg in args): - raise TypeError("All is only supported with TimePeriods") + raise TypeError("Only TimePeriods supported") elif not args: raise ValueError("No TimePeriods to wrap") - self.periods = args + object.__setattr__(self, "periods", frozenset(args)) def rollback(self, dt): @@ -308,7 +301,7 @@ def rollback(self, dt): period.rollback(dt) for period in self.periods ] - all_overlaps = reduce(lambda a, b: a.overlaps(b), intervals) + all_overlaps = all(inter.overlaps(intervals[0]) for inter in intervals[1:]) if all_overlaps: return reduce(lambda a, b: a & b, intervals) else: @@ -342,7 +335,7 @@ def rollforward(self, dt): period.rollforward(dt) for period in self.periods ] - all_overlaps = reduce(lambda a, b: a.overlaps(b), intervals) + all_overlaps = all(inter.overlaps(intervals[0]) for inter in intervals[1:]) if all_overlaps: return reduce(lambda a, b: a & b, intervals) else: @@ -378,14 +371,17 @@ def __repr__(self): def __str__(self): return ' & '.join(str(p) for p in self.periods) +@dataclass(frozen=True) class Any(TimePeriod): + periods: FrozenSet[TimePeriod] + def __init__(self, *args): if any(not isinstance(arg, TimePeriod) for arg in args): - raise TypeError("Any is only supported with TimePeriods") + raise TypeError("Only TimePeriods supported") elif not args: raise ValueError("No TimePeriods to wrap") - self.periods = args + object.__setattr__(self, "periods", frozenset(args)) def rollback(self, dt): intervals = [ @@ -481,12 +477,16 @@ def __repr__(self): def __str__(self): return ' | '.join(str(p) for p in self.periods) +@dataclass(frozen=True) class StaticInterval(TimePeriod): """Inverval that is fixed in specific datetimes.""" + start: datetime.datetime + end: datetime.datetime + def __init__(self, start=None, end=None): - self.start = start if start is not None else self.min - self.end = end if end is not None else self.max + object.__setattr__(self, "start", start) + object.__setattr__(self, "end", end) def rollback(self, dt): dt = to_datetime(dt) diff --git a/rocketry/test/time/delta/span/test_contains.py b/rocketry/test/time/delta/span/test_contains.py index 6d0922ac..43ab7a5c 100644 --- a/rocketry/test/time/delta/span/test_contains.py +++ b/rocketry/test/time/delta/span/test_contains.py @@ -43,8 +43,8 @@ ], ) def test_in_offset(dt, dt_ref, near, far): - time = TimeSpanDelta(near, far) - time.reference = dt_ref + time = TimeSpanDelta(near, far, reference=dt_ref) + assert time.reference is dt_ref assert dt in time @pytest.mark.parametrize( @@ -74,8 +74,9 @@ def test_in_offset(dt, dt_ref, near, far): ], ) def test_not_in_offset(dt, dt_ref, near, far): - time = TimeSpanDelta(near, far) - time.reference = dt_ref + time = TimeSpanDelta(near, far, reference=dt_ref) + #time.reference = dt_ref + assert time.reference is dt_ref assert dt not in time diff --git a/rocketry/test/time/delta/test_contains.py b/rocketry/test/time/delta/test_contains.py index 1e8728db..77926f2c 100644 --- a/rocketry/test/time/delta/test_contains.py +++ b/rocketry/test/time/delta/test_contains.py @@ -27,8 +27,7 @@ ], ) def test_in_offset(dt, dt_ref, offset): - time = TimeDelta(offset) - time.reference = dt_ref + time = TimeDelta(offset, reference=dt_ref) assert dt in time @pytest.mark.parametrize( @@ -48,8 +47,7 @@ def test_in_offset(dt, dt_ref, offset): ], ) def test_not_in_offset(dt, dt_ref, offset): - time = TimeDelta(offset) - time.reference = dt_ref + time = TimeDelta(offset, reference=dt_ref) assert dt not in time diff --git a/rocketry/time/crontab.py b/rocketry/time/crontab.py index 6ac2a4b9..a0c894a9 100644 --- a/rocketry/time/crontab.py +++ b/rocketry/time/crontab.py @@ -1,17 +1,19 @@ from functools import reduce from typing import Callable +from dataclasses import dataclass from .interval import TimeOfHour, TimeOfDay, TimeOfMinute, TimeOfWeek, TimeOfMonth, TimeOfYear from rocketry.core.time.base import TimePeriod, always +@dataclass(frozen=True) class Crontab(TimePeriod): def __init__(self, minute="*", hour="*", day_of_month="*", month="*", day_of_week="*"): - self.minute = minute - self.hour = hour - self.day_of_month = day_of_month - self.month = month - self.day_of_week = day_of_week + object.__setattr__(self, "minute", minute) + object.__setattr__(self, "hour", hour) + object.__setattr__(self, "day_of_month", day_of_month) + object.__setattr__(self, "month", month) + object.__setattr__(self, "day_of_week", day_of_week) # *: any value # ,: list of values diff --git a/rocketry/time/delta.py b/rocketry/time/delta.py index 66c1d48a..93398be7 100644 --- a/rocketry/time/delta.py +++ b/rocketry/time/delta.py @@ -1,21 +1,27 @@ import time import datetime +from dataclasses import dataclass, field from rocketry.core.time import TimeDelta from rocketry.pybox.time import to_datetime, to_timedelta, Interval +@dataclass(frozen=True, init=False) class TimeSpanDelta(TimeDelta): - def __init__(self, near=None, far=None, **kwargs): + near: int + far: int + reference: datetime.datetime = field(default=None) - near = 0 if near is None else near - self.near = abs(to_timedelta(near, **kwargs)) - self.far = abs(to_timedelta(far, **kwargs)) if far is not None else None + def __init__(self, near=None, far=None, reference=None, **kwargs): + near = 0 if near is None else near + object.__setattr__(self, "near", abs(to_timedelta(near, **kwargs))) + object.__setattr__(self, "far", abs(to_timedelta(far, **kwargs)) if far is not None else None) + object.__setattr__(self, "reference", reference) def __contains__(self, dt): "Check whether the datetime is in " - reference = getattr(self, "reference", datetime.datetime.fromtimestamp(time.time())) + reference = self.reference if self.reference is not None else datetime.datetime.fromtimestamp(time.time()) if reference >= dt: start = reference - self.far if self.far is not None else self.min end = reference - self.near diff --git a/rocketry/time/interval.py b/rocketry/time/interval.py index 5caa63ff..71238ef8 100644 --- a/rocketry/time/interval.py +++ b/rocketry/time/interval.py @@ -2,6 +2,8 @@ import calendar import datetime import re +from dataclasses import dataclass +from typing import ClassVar import dateutil @@ -10,7 +12,7 @@ from rocketry.core.time.utils import timedelta_to_str, to_dict, to_nanoseconds from rocketry.pybox.time.interval import Interval - +@dataclass(frozen=True, init=False) class TimeOfMinute(AnchoredInterval): """Time interval anchored to minute cycle of a clock @@ -22,10 +24,10 @@ class TimeOfMinute(AnchoredInterval): TimeOfHour("5:00", "30:00") """ - _scope = "minute" + _scope: ClassVar[str] = "minute" - _scope_max = to_nanoseconds(minute=1) - 1 - _unit_resolution = to_nanoseconds(second=1) + _scope_max: ClassVar[int] = to_nanoseconds(minute=1) - 1 + _unit_resolution: ClassVar[int] = to_nanoseconds(second=1) def anchor_str(self, s, **kwargs): # ie. 30.123 @@ -42,6 +44,7 @@ def anchor_str(self, s, **kwargs): return (self._scope_max + 1) / 4 * n_quarters - 1 +@dataclass(frozen=True, init=False) class TimeOfHour(AnchoredInterval): """Time interval anchored to hour cycle of a clock @@ -52,9 +55,9 @@ class TimeOfHour(AnchoredInterval): # From 15 past to half past TimeOfHour("15:00", "30:00") """ - _scope = "hour" - _scope_max = to_nanoseconds(hour=1) - 1 - _unit_resolution = to_nanoseconds(minute=1) + _scope: ClassVar[str] = "hour" + _scope_max: ClassVar[int] = to_nanoseconds(hour=1) - 1 + _unit_resolution: ClassVar[int] = to_nanoseconds(minute=1) def anchor_int(self, i, **kwargs): if not 0 <= i <= 59: @@ -76,6 +79,7 @@ def anchor_str(self, s, **kwargs): return (self._scope_max + 1) / 4 * n_quarters - 1 +@dataclass(frozen=True, init=False) class TimeOfDay(AnchoredInterval): """Time interval anchored to day cycle of a clock @@ -86,9 +90,9 @@ class TimeOfDay(AnchoredInterval): # From 10 o'clock to 15 o'clock TimeOfDay("10:00", "15:00") """ - _scope = "day" - _scope_max = to_nanoseconds(day=1) - 1 - _unit_resolution = to_nanoseconds(hour=1) + _scope: ClassVar[str] = "day" + _scope_max: ClassVar[int] = to_nanoseconds(day=1) - 1 + _unit_resolution: ClassVar[int] = to_nanoseconds(hour=1) def anchor_int(self, i, **kwargs): if not 0 <= i <= 23: @@ -112,6 +116,7 @@ def anchor_dt(self, dt, **kwargs): } return to_nanoseconds(**d) +@dataclass(frozen=True, init=False) class TimeOfWeek(AnchoredInterval): """Time interval anchored to week cycle @@ -122,9 +127,9 @@ class TimeOfWeek(AnchoredInterval): # From Monday 3 PM to Wednesday 4 PM TimeOfWeek("Mon 15:00", "Wed 16:00") """ - _scope = "week" - _scope_max = to_nanoseconds(day=7) - 1 # Sun day end of day - _unit_resolution = to_nanoseconds(day=1) + _scope: ClassVar[str] = "week" + _scope_max: ClassVar[int] = to_nanoseconds(day=7) - 1 # Sun day end of day + _unit_resolution: ClassVar[int] = to_nanoseconds(day=1) weeknum_mapping = { **dict(zip(range(1, 8), range(7))), @@ -177,6 +182,7 @@ def anchor_dt(self, dt, **kwargs): return to_nanoseconds(**d) + dayofweek * to_nanoseconds(day=1) +@dataclass(frozen=True, init=False) class TimeOfMonth(AnchoredInterval): """Time interval anchored to day cycle of a clock @@ -191,9 +197,9 @@ class TimeOfMonth(AnchoredInterval): # Could be implemented by allowing minus _start and minus _end # rollforward/rollback/contains would need slight changes - _scope = "year" - _scope_max = to_nanoseconds(day=31) - 1 # 31st end of day - _unit_resolution = to_nanoseconds(day=1) + _scope: ClassVar[str] = "year" + _scope_max: ClassVar[int] = to_nanoseconds(day=31) - 1 # 31st end of day + _unit_resolution: ClassVar[int] = to_nanoseconds(day=1) # NOTE: Floating # TODO: ceil end and implement reversion (last 5th day) @@ -255,6 +261,7 @@ def get_scope_back(self, dt): n_days = calendar.monthrange(year, month)[1] return to_nanoseconds(day=1) * n_days +@dataclass(frozen=True, init=False) class TimeOfYear(AnchoredInterval): """Time interval anchored to day cycle of a clock @@ -269,10 +276,10 @@ class TimeOfYear(AnchoredInterval): # We take the longest year there is and translate all years to that # using first the month and then the day of month - _scope = "year" - _scope_max = to_nanoseconds(day=1) * 366 - 1 + _scope: ClassVar[str] = "year" + _scope_max: ClassVar[int] = to_nanoseconds(day=1) * 366 - 1 - monthnum_mapping = { + monthnum_mapping: ClassVar = { **dict(zip(range(12), range(12))), # English @@ -280,7 +287,7 @@ class TimeOfYear(AnchoredInterval): **{day.lower(): i for i, day in enumerate(['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'])}, } - _month_start_mapping = { + _month_start_mapping: ClassVar = { 0: 0, # January 1: to_nanoseconds(day=31), # February (31 days from year start) 2: to_nanoseconds(day=60), # March (31 + 29, leap year has 29 days in February) @@ -297,7 +304,7 @@ class TimeOfYear(AnchoredInterval): 12: to_nanoseconds(day=366), # End of the year (on leap years) } # Reverse the _month_start_mapping to nanoseconds to month num - _year_start_mapping = dict((v, k) for k, v in _month_start_mapping.items()) + _year_start_mapping: ClassVar = dict((v, k) for k, v in _month_start_mapping.items()) # NOTE: Floating @@ -359,6 +366,7 @@ def anchor_dt(self, dt, **kwargs): return self._month_start_mapping[nth_month] + to_nanoseconds(**d) +@dataclass(frozen=True, init=False) class RelativeDay(TimeInterval): """Specific day @@ -369,20 +377,20 @@ class RelativeDay(TimeInterval): Day("yesterday") """ - offsets = { + offsets: ClassVar = { "today": datetime.timedelta(), "yesterday": datetime.timedelta(days=1), "the_day_before":datetime.timedelta(days=2), #"first_day_of_year": get_first_day_of_year, } - min_time = datetime.time.min - max_time = datetime.time.max + min_time: ClassVar[datetime.time] = datetime.time.min + max_time: ClassVar[datetime.time] = datetime.time.max def __init__(self, day, *, start_time=None, end_time=None): - self.day = day - self.start_time = self.min_time if start_time is None else start_time - self.end_time = self.max_time if end_time is None else end_time + object.__setattr__(self, "day", day) + object.__setattr__(self, "start_time", self.min_time if start_time is None else start_time) + object.__setattr__(self, "end_time", self.max_time if end_time is None else end_time) def rollback(self, dt): offset = self.offsets[self.day] From 958bab5f2dcb162ede3687fc1cc4d884c88e6827 Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Tue, 9 Aug 2022 00:39:26 +0300 Subject: [PATCH 14/42] upd: time periods operates on microseconds Python's datetime has resolution of one microsecond so microseconds makes more sense. Also makes it possible to increment the datetimes with minimum resolution (adding one nanosecond adds nothing). --- rocketry/core/time/anchor.py | 134 ++++++++--------- rocketry/core/time/base.py | 5 +- rocketry/core/time/utils.py | 2 +- rocketry/pybox/time/__init__.py | 2 +- rocketry/pybox/time/convert.py | 25 ++- rocketry/test/time/interval/test_construct.py | 124 +++++++-------- .../time/interval/timeofweek/test_core.py | 34 ++--- rocketry/time/interval.py | 142 +++++++++--------- 8 files changed, 241 insertions(+), 227 deletions(-) diff --git a/rocketry/core/time/anchor.py b/rocketry/core/time/anchor.py index 04b4f98e..d5bfde32 100644 --- a/rocketry/core/time/anchor.py +++ b/rocketry/core/time/anchor.py @@ -5,21 +5,21 @@ from abc import abstractmethod from dataclasses import dataclass, field -from .utils import to_nanoseconds, timedelta_to_str, to_dict, to_timedelta +from .utils import to_microseconds, timedelta_to_str, to_dict, to_timedelta from .base import TimeInterval @dataclass(frozen=True, repr=False) class AnchoredInterval(TimeInterval): """Base class for interval for those that have - fixed time unit (that can be converted to nanoseconds). + fixed time unit (that can be converted to microseconds). - Converts start and end to nanoseconds where + Converts start and end to microseconds where 0 represents the beginning of the interval (ie. for a week: monday 00:00 AM) and max - is number of nanoseconds from start to the + is number of microseconds from start to the end of the interval. - Examples for start=0 nanoseconds + Examples for start=0 microseconds - for a week: Monday - for a day: 00:00 - for a month: 1st day @@ -27,13 +27,13 @@ class AnchoredInterval(TimeInterval): Methods: -------- - anchor_dict --> int : Calculate corresponding nanoseconds from dict - anchor_str --> int : Calculate corresponding nanoseconds from string - anchor_int --> int : Calculate corresponding nanoseconds from integer + anchor_dict --> int : Calculate corresponding microseconds from dict + anchor_str --> int : Calculate corresponding microseconds from string + anchor_int --> int : Calculate corresponding microseconds from integer Properties: - start --> int : Nanoseconds on the interval till the start - end --> int : Nanoseconds on the interval till the end + start --> int : microseconds on the interval till the start + end --> int : microseconds on the interval till the end Class attributes: ----------------- @@ -42,15 +42,15 @@ class AnchoredInterval(TimeInterval): _start: int _end: int - components: ClassVar[Tuple[str]] = ("year", "month", "week", "day", "hour", "minute", "second", "microsecond", "nanosecond") + components: ClassVar[Tuple[str]] = ("year", "month", "week", "day", "hour", "minute", "second", "microsecond") # Components that have always fixed length (exactly the same amount of time) - _fixed_components: ClassVar[Tuple[str]] = ("week", "day", "hour", "minute", "second", "microsecond", "nanosecond") + _fixed_components: ClassVar[Tuple[str]] = ("week", "day", "hour", "minute", "second", "microsecond") _scope: ClassVar[str] = None # Scope of the full period. Ie. day, hour, second, microsecond - _scope_max: ClassVar[int] = None # Max in nanoseconds of the + _scope_max: ClassVar[int] = None # Max in microseconds of the - _unit_resolution: ClassVar[int] = None # Nanoseconds of one unit (if start/end is int) + _unit_resolution: ClassVar[int] = None # Microseconds of one unit (if start/end is int) def __init__(self, start=None, end=None, time_point=None): @@ -85,7 +85,7 @@ def anchor_int(self, i, side=None, time_point=None, **kwargs): def anchor_dict(self, d, **kwargs): comps = self._fixed_components[(self._fixed_components.index(self._scope) + 1):] kwargs = {key: val for key, val in d.items() if key in comps} - return to_nanoseconds(**kwargs) + return to_microseconds(**kwargs) def anchor_dt(self, dt: datetime, **kwargs) -> int: "Turn datetime to nanoseconds according to the scope (by removing higher time elements)" @@ -98,39 +98,39 @@ def anchor_dt(self, dt: datetime, **kwargs) -> int: if key in components } - return to_nanoseconds(**d) + return to_microseconds(**d) def set_start(self, val): if val is None: - ns = 0 + ms = 0 else: - ns = self.anchor(val, side="start") + ms = self.anchor(val, side="start") - object.__setattr__(self, "_start", ns) + object.__setattr__(self, "_start", ms) object.__setattr__(self, "_start_orig", val) def set_end(self, val, time_point=False): if time_point and val is None: # Interval is "at" type, ie. on monday, at 10:00 (10:00 - 10:59:59) - ns = self.to_timepoint(self._start) + ms = self.to_timepoint(self._start) elif val is None: - ns = self._scope_max + ms = self._scope_max else: - ns = self.anchor(val, side="end", time_point=time_point) + ms = self.anchor(val, side="end", time_point=time_point) object.__setattr__(self, "_end", ns) object.__setattr__(self, "_end_orig", val) - def to_timepoint(self, ns:int): - "Turn nanoseconds to the period's timepoint" + def to_timepoint(self, ms:int): + "Turn microseconds to the period's timepoint" # Ie. Monday --> Monday 00:00 to Monday 24:00 # By default assumes linear scale (like week) # but can be overridden for non linear such as year - return ns + self._unit_resolution - 1 + return ms + self._unit_resolution - 1 @property def start(self): - delta = to_timedelta(self._start, unit="ns") + delta = to_timedelta(self._start, unit="microsecond") repr_scope = self.components[self.components.index(self._scope) + 1] return timedelta_to_str(delta, default_scope=repr_scope) @@ -140,7 +140,7 @@ def start(self, val): @property def end(self): - delta = to_timedelta(self._end, unit="ns") + delta = to_timedelta(self._end, unit="microsecond") repr_scope = self.components[self.components.index(self._scope) + 1] return timedelta_to_str(delta, default_scope=repr_scope) @@ -155,8 +155,8 @@ def anchor_str(self, s, **kwargs) -> int: def __contains__(self, dt) -> bool: "Whether dt is in the interval" - ns_start = self._start - ns_end = self._end + ms_start = self._start + ms_end = self._end if self.is_full(): # As there is no time in between, @@ -164,13 +164,13 @@ def __contains__(self, dt) -> bool: # cycle (ie. from 10:00 to 10:00) return True - ns = self.anchor_dt(dt) # In relative nanoseconds (removed more accurate than scope) + ms = self.anchor_dt(dt) # In relative nanoseconds (removed more accurate than scope) - is_over_period = ns_start > ns_end # period is overnight, over weekend etc. + is_over_period = ms_start > ms_end # period is overnight, over weekend etc. if not is_over_period: - return ns_start <= ns <= ns_end + return ms_start <= ms <= ms_end else: - return ns >= ns_start or ns <= ns_end + return ms >= ms_start or ms <= ms_end def is_full(self): "Whether every time belongs to the period (but there is still distinct intervals)" @@ -200,11 +200,11 @@ def rollend(self, dt): def next_start(self, dt): "Get next start point of the period" - ns = self.anchor_dt(dt) # In relative nanoseconds (removed more accurate than scope) - ns_start = self._start - ns_end = self._end + ms = self.anchor_dt(dt) # In relative nanoseconds (removed more accurate than scope) + ms_start = self._start + ms_end = self._end - if ns < ns_start: + if ms < ms_start: # not in period, over night # dt # -->----------<----------->--------------<- @@ -219,7 +219,7 @@ def next_start(self, dt): # dt # -->----------<----------->--------------<- # start | end start | end - offset = to_timedelta(int(ns_start) - int(ns), unit="ns") + offset = to_timedelta(int(ms_start) - int(ms), unit="microsecond") else: # not in period, later than start # dt @@ -235,17 +235,17 @@ def next_start(self, dt): # dt # --<---------->-----------<-------------->-- # end | start end | start - ns_scope = self.get_scope_forward(dt) - offset = to_timedelta(int(ns_start) - int(ns) + ns_scope, unit="ns") + ms_scope = self.get_scope_forward(dt) + offset = to_timedelta(int(ms_start) - int(ms) + ms_scope, unit="microsecond") return dt + offset def next_end(self, dt): "Get next end point of the period" - ns = self.anchor_dt(dt) # In relative nanoseconds (removed more accurate than scope) - ns_start = self._start - ns_end = self._end + ms = self.anchor_dt(dt) # In relative nanoseconds (removed more accurate than scope) + ms_start = self._start + ms_end = self._end - if ns <= ns_end: + if ms <= ms_end: # in period # dt # --<---------->-----------<-------------->-- @@ -260,7 +260,7 @@ def next_end(self, dt): # dt # --<---------->-----------<-------------->-- # end | start end | start - offset = to_timedelta(int(ns_end) - int(ns), unit="ns") + offset = to_timedelta(int(ms_end) - int(ms), unit="microsecond") else: # not in period, over night # dt @@ -276,16 +276,16 @@ def next_end(self, dt): # dt # -->----------<----------->--------------<- # start | end start | end - ns_scope = self.get_scope_forward(dt) - offset = to_timedelta(int(ns_end) - int(ns) + ns_scope, unit="ns") + ms_scope = self.get_scope_forward(dt) + offset = to_timedelta(int(ms_end) - int(ms) + ms_scope, unit="microsecond") return dt + offset def prev_start(self, dt): "Get previous start point of the period" - ns = self.anchor_dt(dt) # In relative nanoseconds (removed more accurate than scope) - ns_start = self._start + ms = self.anchor_dt(dt) # In relative nanoseconds (removed more accurate than scope) + ms_start = self._start - if ns < ns_start: + if ms < ms_start: # not in period, over night # dt # -->----------<----------->--------------<- @@ -300,8 +300,8 @@ def prev_start(self, dt): # dt # -->----------<----------->--------------<- # start | end start | end - ns_scope = self.get_scope_back(dt) - offset = to_timedelta(int(ns_start) - int(ns) - ns_scope, unit="ns") + ms_scope = self.get_scope_back(dt) + offset = to_timedelta(int(ms_start) - int(ms) - ms_scope, unit="microsecond") else: # not in period, later than start # dt @@ -317,15 +317,15 @@ def prev_start(self, dt): # dt # --<---------->-----------<-------------->-- # end | start end | start - offset = to_timedelta(int(ns_start) - int(ns), unit="ns") + offset = to_timedelta(int(ms_start) - int(ms), unit="microsecond") return dt + offset def prev_end(self, dt): "Get pervious end point of the period" - ns = self.anchor_dt(dt) # In relative nanoseconds (removed more accurate than scope) - ns_end = self._end + ms = self.anchor_dt(dt) # In relative nanoseconds (removed more accurate than scope) + ms_end = self._end - if ns < ns_end: + if ms < ms_end: # in period # dt # --<---------->-----------<-------------->-- @@ -340,8 +340,8 @@ def prev_end(self, dt): # dt # --<---------->-----------<-------------->-- # end | start end | start - ns_scope = self.get_scope_back(dt) - offset = to_timedelta(int(ns_end) - int(ns) - ns_scope, unit="ns") + ms_scope = self.get_scope_back(dt) + offset = to_timedelta(int(ms_end) - int(ms) - ms_scope, unit="microsecond") else: # not in period, over night # dt @@ -357,30 +357,30 @@ def prev_end(self, dt): # dt # -->----------<----------->--------------<- # start | end start | end - offset = to_timedelta(int(ns_end) - int(ns), unit="ns") + offset = to_timedelta(int(ms_end) - int(ms), unit="microsecond") return dt + offset - def repr_ns(self, n:int): - "Nanoseconds to representative format" + def repr_ms(self, n:int): + "Microseconds to representative format" return repr(n) def __repr__(self): cls_name = type(self).__name__ - start = self.repr_ns(self._start) if not hasattr(self, "_start_orgi") else self._start_orig - end = self.repr_ns(self._end) if not hasattr(self, "_end_orgi") else self._end_orig + start = self.repr_ms(self._start) if not hasattr(self, "_start_orgi") else self._start_orig + end = self.repr_ms(self._end) if not hasattr(self, "_end_orgi") else self._end_orig return f'{cls_name}({start}, {end})' def __str__(self): # Hour: '15:' - start_ns = self._start - end_ns = self._end + start_ms = self._start + end_ms = self._end scope = self._scope repr_scope = self.components[self.components.index(self._scope) + 1] - to_start = to_timedelta(start_ns, unit="ns") - to_end = to_timedelta(end_ns, unit="ns") + to_start = to_timedelta(start_ms, unit="microsecond") + to_end = to_timedelta(end_ms, unit="microsecond") start_str = timedelta_to_str(to_start, default_scope=repr_scope) end_str = timedelta_to_str(to_end, default_scope=repr_scope) diff --git a/rocketry/core/time/base.py b/rocketry/core/time/base.py index 92a8135d..6eb8203d 100644 --- a/rocketry/core/time/base.py +++ b/rocketry/core/time/base.py @@ -223,11 +223,14 @@ def __init__(self, past=None, future=None, reference=None, *, kws_past=None, kws @abstractmethod def __contains__(self, dt): "Check whether the datetime is in " - reference = self.reference if self.reference is not None else datetime.datetime.fromtimestamp(time.time()) + reference = self.get_reference() start = reference - abs(self.past) end = reference + abs(self.future) return start <= dt <= end + def get_reference(self) -> datetime.datetime: + return self.reference if self.reference is not None else datetime.datetime.fromtimestamp(time.time()) + def rollback(self, dt): "Get previous interval (including currently ongoing)" start = dt - abs(self.past) diff --git a/rocketry/core/time/utils.py b/rocketry/core/time/utils.py index f63ca528..c9a55002 100644 --- a/rocketry/core/time/utils.py +++ b/rocketry/core/time/utils.py @@ -4,7 +4,7 @@ from typing import Tuple, Union from .base import TimePeriod -from rocketry.pybox.time import to_nanoseconds, to_timedelta +from rocketry.pybox.time import to_microseconds, to_timedelta # Conversions def to_dict(dt): diff --git a/rocketry/pybox/time/__init__.py b/rocketry/pybox/time/__init__.py index 8d168ee0..1ad3d2f5 100644 --- a/rocketry/pybox/time/__init__.py +++ b/rocketry/pybox/time/__init__.py @@ -1,2 +1,2 @@ -from .convert import to_nanoseconds, to_timedelta, to_datetime +from .convert import to_microseconds, to_timedelta, to_datetime from .interval import Interval \ No newline at end of file diff --git a/rocketry/pybox/time/convert.py b/rocketry/pybox/time/convert.py index bfddd922..4d858822 100644 --- a/rocketry/pybox/time/convert.py +++ b/rocketry/pybox/time/convert.py @@ -9,6 +9,13 @@ 's': 'second', 'm': 'minute', 'h': 'hour', + + 'nanosecond': 'nanosecond', + 'microsecond': 'microsecond', + 'millisecond': 'millisecond', + 'second': 'second', + 'minute': 'minute', + 'hour': 'hour', } def to_datetime(s): @@ -40,7 +47,7 @@ def string_to_datetime(s): return parse(s) -def numb_to_timedelta(n: Union[float, int], unit="ms"): +def numb_to_timedelta(n: Union[float, int], unit="μs"): if unit == "ns": unit = 'μs' @@ -86,7 +93,7 @@ def get_unit(s): def get_hhmmss(s): hh, mm, ss = s.split(":") - return to_nanoseconds(hour=int(hh), minute=int(mm), second=float(ss)) + return to_microseconds(hour=int(hh), minute=int(mm), second=float(ss)) # https://github.com/pandas-dev/pandas/blob/e8093ba372f9adfe79439d90fe74b0b5b6dea9d6/pandas/_libs/tslibs/timedeltas.pyx#L296 abbrs = { @@ -113,7 +120,7 @@ def get_hhmmss(s): 'd': 'day', } - ns = 0 + ms = 0 # Finding out the leading "-" is_negative = False for i, char in enumerate(s): @@ -137,7 +144,7 @@ def get_hhmmss(s): if s[0] == ":": # Expecting HH:MM:SS - ns += get_hhmmss(numb + s) + ms += get_hhmmss(numb + s) break # Example: "- 2.5 days ..." @@ -152,11 +159,15 @@ def get_hhmmss(s): abbr, pos = get_unit(s) s = s[pos:] - ns += to_nanoseconds(**{abbr: float(numb)}) + ms += to_microseconds(**{abbr: float(numb)}) if is_negative: - ns = -ns - return datetime.timedelta(microseconds=ns / 1000) + ms = -ms + return datetime.timedelta(microseconds=ms) + +def to_microseconds(day=0, hour=0, minute=0, second=0, millisecond=0, microsecond=0) -> int: + "Turn time components to microseconds" + return microsecond + millisecond * 1_000 + second * int(1e+6) + minute * int(6e+7) + hour * int(3.6e+9) + day * int(8.64e+10) def to_nanoseconds(day=0, hour=0, minute=0, second=0, millisecond=0, microsecond=0, nanosecond=0) -> int: "Turn time components to nanoseconds" diff --git a/rocketry/test/time/interval/test_construct.py b/rocketry/test/time/interval/test_construct.py index f1f1a7a7..3e9909c7 100644 --- a/rocketry/test/time/interval/test_construct.py +++ b/rocketry/test/time/interval/test_construct.py @@ -8,10 +8,10 @@ TimeOfYear ) -NS_IN_SECOND = int(1e+9) -NS_IN_MINUTE = int(1e+9 * 60) -NS_IN_HOUR = int(1e+9 * 60 * 60) -NS_IN_DAY = int(1e+9 * 60 * 60 * 24) +MS_IN_SECOND = int(1e+6) +MS_IN_MINUTE = int(1e+6 * 60) +MS_IN_HOUR = int(1e+6 * 60 * 60) +MS_IN_DAY = int(1e+6 * 60 * 60 * 24) def pytest_generate_tests(metafunc): if metafunc.cls is not None: @@ -54,7 +54,7 @@ def test_closed(self, start, end, expected_start, expected_end): def test_open(self): time = self.cls(None, None) assert 0 == time._start - assert self.max_ns == time._end + assert self.max_ms == time._end def test_open_left(self, end, expected_end, **kwargs): time = self.cls(None, end) @@ -64,7 +64,7 @@ def test_open_left(self, end, expected_end, **kwargs): def test_open_right(self, start, expected_start, **kwargs): time = self.cls(start, None) assert expected_start == time._start - assert self.max_ns == time._end + assert self.max_ms == time._end def test_time_point(self, start, expected_start, expected_end, **kwargs): time = self.cls(start, time_point=True) @@ -83,40 +83,40 @@ class TestTimeOfHour(ConstructTester): cls = TimeOfHour - max_ns = NS_IN_HOUR - 1 + max_ms = MS_IN_HOUR - 1 scen_closed = [ { "start": "15:00", "end": "45:00", - "expected_start": 15 * NS_IN_MINUTE, - "expected_end": 45 * NS_IN_MINUTE, + "expected_start": 15 * MS_IN_MINUTE, + "expected_end": 45 * MS_IN_MINUTE, }, { "start": 15, "end": 45, - "expected_start": 15 * NS_IN_MINUTE, - "expected_end": 46 * NS_IN_MINUTE - 1, + "expected_start": 15 * MS_IN_MINUTE, + "expected_end": 46 * MS_IN_MINUTE - 1, }, ] scen_open_left = [ { "end": "45:00", - "expected_end": 45 * NS_IN_MINUTE + "expected_end": 45 * MS_IN_MINUTE } ] scen_open_right = [ { "start": "45:00", - "expected_start": 45 * NS_IN_MINUTE + "expected_start": 45 * MS_IN_MINUTE } ] scen_time_point = [ { "start": "12:00", - "expected_start": 12 * NS_IN_MINUTE, - "expected_end": 13 * NS_IN_MINUTE - 1, + "expected_start": 12 * MS_IN_MINUTE, + "expected_end": 13 * MS_IN_MINUTE - 1, } ] @@ -131,40 +131,40 @@ class TestTimeOfDay(ConstructTester): cls = TimeOfDay - max_ns = 24 * NS_IN_HOUR - 1 + max_ms = 24 * MS_IN_HOUR - 1 scen_closed = [ { "start": "10:00", "end": "12:00", - "expected_start": 10 * NS_IN_HOUR, - "expected_end": 12 * NS_IN_HOUR, + "expected_start": 10 * MS_IN_HOUR, + "expected_end": 12 * MS_IN_HOUR, }, { "start": 10, "end": 12, - "expected_start": 10 * NS_IN_HOUR, - "expected_end": 13 * NS_IN_HOUR - 1, + "expected_start": 10 * MS_IN_HOUR, + "expected_end": 13 * MS_IN_HOUR - 1, }, ] scen_open_left = [ { "end": "12:00", - "expected_end": 12 * NS_IN_HOUR + "expected_end": 12 * MS_IN_HOUR } ] scen_open_right = [ { "start": "12:00", - "expected_start": 12 * NS_IN_HOUR + "expected_start": 12 * MS_IN_HOUR } ] scen_time_point = [ { "start": "12:00", - "expected_start": 12 * NS_IN_HOUR, - "expected_end": 13 * NS_IN_HOUR - 1, + "expected_start": 12 * MS_IN_HOUR, + "expected_end": 13 * MS_IN_HOUR - 1, } ] scen_value_error = [ @@ -179,49 +179,49 @@ class TestTimeOfWeek(ConstructTester): cls = TimeOfWeek - max_ns = 7 * NS_IN_DAY - 1 + max_ms = 7 * MS_IN_DAY - 1 scen_closed = [ { # Spans from Tue 00:00:00 to Wed 23:59:59 999 "start": "Tue", "end": "Wed", - "expected_start": 1 * NS_IN_DAY, - "expected_end": 3 * NS_IN_DAY - 1, + "expected_start": 1 * MS_IN_DAY, + "expected_end": 3 * MS_IN_DAY - 1, }, { # Spans from Tue 00:00:00 to Wed 23:59:59 999 "start": "Tuesday", "end": "Wednesday", - "expected_start": 1 * NS_IN_DAY, - "expected_end": 3 * NS_IN_DAY - 1, + "expected_start": 1 * MS_IN_DAY, + "expected_end": 3 * MS_IN_DAY - 1, }, { # Spans from Tue 00:00:00 to Wed 23:59:59 999 "start": 2, "end": 3, - "expected_start": 1 * NS_IN_DAY, - "expected_end": 3 * NS_IN_DAY - 1, + "expected_start": 1 * MS_IN_DAY, + "expected_end": 3 * MS_IN_DAY - 1, }, ] scen_open_left = [ { "end": "Tue", - "expected_end": 2 * NS_IN_DAY - 1 # Tuesday 23:59:59 ... + "expected_end": 2 * MS_IN_DAY - 1 # Tuesday 23:59:59 ... } ] scen_open_right = [ { "start": "Tue", - "expected_start": 1 * NS_IN_DAY # Tuesday 00:00:00 + "expected_start": 1 * MS_IN_DAY # Tuesday 00:00:00 } ] scen_time_point = [ { "start": "Tue", - "expected_start": 1 * NS_IN_DAY, - "expected_end": 2 * NS_IN_DAY - 1, + "expected_start": 1 * MS_IN_DAY, + "expected_end": 2 * MS_IN_DAY - 1, } ] scen_value_error = [ @@ -236,46 +236,46 @@ class TestTimeOfMonth(ConstructTester): cls = TimeOfMonth - max_ns = 31 * NS_IN_DAY - 1 + max_ms = 31 * MS_IN_DAY - 1 scen_closed = [ { "start": "2.", "end": "3.", - "expected_start": 1 * NS_IN_DAY, - "expected_end": 3 * NS_IN_DAY - 1, + "expected_start": 1 * MS_IN_DAY, + "expected_end": 3 * MS_IN_DAY - 1, }, { "start": "2nd", "end": "4th", - "expected_start": 1 * NS_IN_DAY, - "expected_end": 4 * NS_IN_DAY - 1, + "expected_start": 1 * MS_IN_DAY, + "expected_end": 4 * MS_IN_DAY - 1, }, { "start": 2, "end": 4, - "expected_start": 1 * NS_IN_DAY, - "expected_end": 4 * NS_IN_DAY - 1, + "expected_start": 1 * MS_IN_DAY, + "expected_end": 4 * MS_IN_DAY - 1, }, ] scen_open_left = [ { "end": "3.", - "expected_end": 3 * NS_IN_DAY - 1 + "expected_end": 3 * MS_IN_DAY - 1 } ] scen_open_right = [ { "start": "2.", - "expected_start": 1 * NS_IN_DAY + "expected_start": 1 * MS_IN_DAY } ] scen_time_point = [ { "start": "2.", - "expected_start": 1 * NS_IN_DAY, - "expected_end": 2 * NS_IN_DAY - 1, + "expected_start": 1 * MS_IN_DAY, + "expected_end": 2 * MS_IN_DAY - 1, } ] scen_value_error = [ @@ -293,64 +293,64 @@ class TestTimeOfYear(ConstructTester): cls = TimeOfYear - max_ns = 366 * NS_IN_DAY - 1 # Leap year has 366 days + max_ms = 366 * MS_IN_DAY - 1 # Leap year has 366 days scen_closed = [ { "start": "February", "end": "April", - "expected_start": 31 * NS_IN_DAY, - "expected_end": (31 + 29 + 31 + 30) * NS_IN_DAY - 1, + "expected_start": 31 * MS_IN_DAY, + "expected_end": (31 + 29 + 31 + 30) * MS_IN_DAY - 1, }, { "start": "Feb", "end": "Apr", - "expected_start": 31 * NS_IN_DAY, - "expected_end": (31 + 29 + 31 + 30) * NS_IN_DAY - 1, + "expected_start": 31 * MS_IN_DAY, + "expected_end": (31 + 29 + 31 + 30) * MS_IN_DAY - 1, }, { "start": 2, "end": 4, - "expected_start": 31 * NS_IN_DAY, - "expected_end": (31 + 29 + 31 + 30) * NS_IN_DAY - 1, + "expected_start": 31 * MS_IN_DAY, + "expected_end": (31 + 29 + 31 + 30) * MS_IN_DAY - 1, }, ] scen_open_left = [ { "end": "Apr", - "expected_end": (31 + 29 + 31 + 30) * NS_IN_DAY - 1 + "expected_end": (31 + 29 + 31 + 30) * MS_IN_DAY - 1 }, { "end": "Jan", - "expected_end": 31 * NS_IN_DAY - 1 + "expected_end": 31 * MS_IN_DAY - 1 }, ] scen_open_right = [ { "start": "Apr", - "expected_start": (31 + 29 + 31) * NS_IN_DAY + "expected_start": (31 + 29 + 31) * MS_IN_DAY }, { "start": "Dec", - "expected_start": (366 - 31) * NS_IN_DAY + "expected_start": (366 - 31) * MS_IN_DAY }, ] scen_time_point = [ { "start": "Jan", "expected_start": 0, - "expected_end": 31 * NS_IN_DAY - 1, + "expected_end": 31 * MS_IN_DAY - 1, }, { "start": "Feb", - "expected_start": 31 * NS_IN_DAY, - "expected_end": (31 + 29) * NS_IN_DAY - 1, + "expected_start": 31 * MS_IN_DAY, + "expected_end": (31 + 29) * MS_IN_DAY - 1, }, { "start": "Dec", - "expected_start": (366 - 31) * NS_IN_DAY, - "expected_end": 366 * NS_IN_DAY - 1, + "expected_start": (366 - 31) * MS_IN_DAY, + "expected_end": 366 * MS_IN_DAY - 1, }, ] scen_value_error = [ diff --git a/rocketry/test/time/interval/timeofweek/test_core.py b/rocketry/test/time/interval/timeofweek/test_core.py index 882e9c88..4cff3661 100644 --- a/rocketry/test/time/interval/timeofweek/test_core.py +++ b/rocketry/test/time/interval/timeofweek/test_core.py @@ -6,16 +6,16 @@ TimeOfWeek ) -NS_IN_SECOND = 1e+9 -NS_IN_MINUTE = 1e+9 * 60 -NS_IN_HOUR = 1e+9 * 60 * 60 -NS_IN_DAY = 1e+9 * 60 * 60 * 24 -NS_IN_WEEK = 1e+9 * 60 * 60 * 24 * 7 +MS_IN_SECOND = 1e+6 +MS_IN_MINUTE = 1e+6 * 60 +MS_IN_HOUR = 1e+6 * 60 * 60 +MS_IN_DAY = 1e+6 * 60 * 60 * 24 +MS_IN_WEEK = 1e+6 * 60 * 60 * 24 * 7 # TimeOfWeek # Year 2024 was chosen as it starts on monday @pytest.mark.parametrize( - "dt,string,ns", + "dt,string,ms", [ # Regular pytest.param( @@ -24,35 +24,35 @@ 0, id="Beginning"), pytest.param( - to_datetime("2024-01-07 23:59:59.999999000"), - "Sun 23:59:59.999999000", - 604799999999000.0, + to_datetime("2024-01-07 23:59:59.999999"), + "Sun 23:59:59.999999", + 604799999999, id="Ending"), ], ) -def test_anchor_equal(dt, string, ns): +def test_anchor_equal(dt, string, ms): time = TimeOfWeek(None, None) - assert time.anchor_dt(dt) == time.anchor_str(string) == ns + assert time.anchor_dt(dt) == time.anchor_str(string) == ms @pytest.mark.parametrize( - "start,end,start_ns,end_ns", + "start,end,start_ms,end_ms", [ # Regular pytest.param( "Mon 00:00:00", "Sun 23:59:59.999999000", - 0, NS_IN_WEEK - 1 - 999, # datetime stuff are often microsecond accuracy + 0, MS_IN_WEEK - 1, id="Strings: full"), pytest.param( # From Tue 00:00 to Wed 23:59:59.000 () "Tue", "Wed", # - NS_IN_DAY, NS_IN_DAY * 3 - 1, + MS_IN_DAY, MS_IN_DAY * 3 - 1, id="Strings: minimal"), ], ) -def test_construct(start, end, start_ns, end_ns): +def test_construct(start, end, start_ms, end_ms): time = TimeOfWeek(start, end) - assert time._start == start_ns - assert time._end == end_ns + assert time._start == start_ms + assert time._end == end_ms diff --git a/rocketry/time/interval.py b/rocketry/time/interval.py index 71238ef8..8c56d58a 100644 --- a/rocketry/time/interval.py +++ b/rocketry/time/interval.py @@ -9,15 +9,15 @@ from rocketry.core.time.anchor import AnchoredInterval from rocketry.core.time.base import TimeInterval -from rocketry.core.time.utils import timedelta_to_str, to_dict, to_nanoseconds +from rocketry.core.time.utils import timedelta_to_str, to_dict, to_microseconds from rocketry.pybox.time.interval import Interval @dataclass(frozen=True, init=False) class TimeOfMinute(AnchoredInterval): """Time interval anchored to minute cycle of a clock - min: 0 seconds, 0 microsecond, 0 nanosecond - max: 59 seconds, 999999 microsecond, 999 nanosecond + min: 0 seconds, 0 microsecond + max: 59 seconds, 999999 microsecond Example: # From 5th second of a minute to 30th second of a minute @@ -26,16 +26,16 @@ class TimeOfMinute(AnchoredInterval): _scope: ClassVar[str] = "minute" - _scope_max: ClassVar[int] = to_nanoseconds(minute=1) - 1 - _unit_resolution: ClassVar[int] = to_nanoseconds(second=1) + _scope_max: ClassVar[int] = to_microseconds(minute=1) - 1 + _unit_resolution: ClassVar[int] = to_microseconds(second=1) def anchor_str(self, s, **kwargs): # ie. 30.123 - res = re.search(r"(?P[0-9][0-9])([.](?P[0-9]{0,6}))?(?P[0-9]+)?", s, flags=re.IGNORECASE) + res = re.search(r"(?P[0-9][0-9])([.](?P[0-9]{0,6}))?", s, flags=re.IGNORECASE) if res: if res["microsecond"] is not None: res["microsecond"] = res["microsecond"].ljust(6, "0") - return to_nanoseconds(**{key: int(val) for key, val in res.groupdict().items() if val is not None}) + return to_microseconds(**{key: int(val) for key, val in res.groupdict().items() if val is not None}) res = re.search(r"(?P[1-4] ?(quarter|q))", s, flags=re.IGNORECASE) if res: @@ -48,16 +48,16 @@ def anchor_str(self, s, **kwargs): class TimeOfHour(AnchoredInterval): """Time interval anchored to hour cycle of a clock - min: 0 minutes, 0 seconds, 0 microsecond, 0 nanosecond - max: 59 minutes, 59 seconds, 999999 microsecond, 999 nanosecond + min: 0 minutes, 0 seconds, 0 microsecond + max: 59 minutes, 59 seconds, 999999 microsecond Example: # From 15 past to half past TimeOfHour("15:00", "30:00") """ _scope: ClassVar[str] = "hour" - _scope_max: ClassVar[int] = to_nanoseconds(hour=1) - 1 - _unit_resolution: ClassVar[int] = to_nanoseconds(minute=1) + _scope_max: ClassVar[int] = to_microseconds(hour=1) - 1 + _unit_resolution: ClassVar[int] = to_microseconds(minute=1) def anchor_int(self, i, **kwargs): if not 0 <= i <= 59: @@ -66,11 +66,11 @@ def anchor_int(self, i, **kwargs): def anchor_str(self, s, **kwargs): # ie. 12:30.123 - res = re.search(r"(?P[0-9][0-9]):(?P[0-9][0-9])([.](?P[0-9]{0,6}))?(?P[0-9]+)?", s, flags=re.IGNORECASE) + res = re.search(r"(?P[0-9][0-9]):(?P[0-9][0-9])([.](?P[0-9]{0,6}))?", s, flags=re.IGNORECASE) if res: if res["microsecond"] is not None: res["microsecond"] = res["microsecond"].ljust(6, "0") - return to_nanoseconds(**{key: int(val) for key, val in res.groupdict().items() if val is not None}) + return to_microseconds(**{key: int(val) for key, val in res.groupdict().items() if val is not None}) res = re.search(r"(?P[1-4]) ?(quarter|q)", s, flags=re.IGNORECASE) if res: @@ -83,16 +83,16 @@ def anchor_str(self, s, **kwargs): class TimeOfDay(AnchoredInterval): """Time interval anchored to day cycle of a clock - min: 0 hours, 0 minutes, 0 seconds, 0 microsecond, 0 nanosecond - max: 23 hours, 59 minutes, 59 seconds, 999999 microsecond, 999 nanosecond + min: 0 hours, 0 minutes, 0 seconds, 0 microsecond + max: 23 hours, 59 minutes, 59 seconds, 999999 microsecond Example: # From 10 o'clock to 15 o'clock TimeOfDay("10:00", "15:00") """ _scope: ClassVar[str] = "day" - _scope_max: ClassVar[int] = to_nanoseconds(day=1) - 1 - _unit_resolution: ClassVar[int] = to_nanoseconds(hour=1) + _scope_max: ClassVar[int] = to_microseconds(day=1) - 1 + _unit_resolution: ClassVar[int] = to_microseconds(hour=1) def anchor_int(self, i, **kwargs): if not 0 <= i <= 23: @@ -103,33 +103,33 @@ def anchor_str(self, s, **kwargs): # ie. "10:00:15" dt = dateutil.parser.parse(s) d = to_dict(dt) - components = ("hour", "minute", "second", "microsecond", "nanosecond") - return to_nanoseconds(**{key: int(val) for key, val in d.items() if key in components}) + components = ("hour", "minute", "second", "microsecond") + return to_microseconds(**{key: int(val) for key, val in d.items() if key in components}) def anchor_dt(self, dt, **kwargs): - "Turn datetime to nanoseconds according to the scope (by removing higher time elements)" + "Turn datetime to microseconds according to the scope (by removing higher time elements)" d = to_dict(dt) d = { key: val for key, val in d.items() - if key in ("hour", "minute", "second", "microsecond", "nanosecond") + if key in ("hour", "minute", "second", "microsecond") } - return to_nanoseconds(**d) + return to_microseconds(**d) @dataclass(frozen=True, init=False) class TimeOfWeek(AnchoredInterval): """Time interval anchored to week cycle - min: Monday, 0 hours, 0 minutes, 0 seconds, 0 microsecond, 0 nanosecond - max: Sunday, 23 hours, 59 minutes, 59 seconds, 999999 microsecond, 999 nanosecond + min: Monday, 0 hours, 0 minutes, 0 seconds, 0 microsecond + max: Sunday, 23 hours, 59 minutes, 59 seconds, 999999 microsecond Example: # From Monday 3 PM to Wednesday 4 PM TimeOfWeek("Mon 15:00", "Wed 16:00") """ _scope: ClassVar[str] = "week" - _scope_max: ClassVar[int] = to_nanoseconds(day=7) - 1 # Sun day end of day - _unit_resolution: ClassVar[int] = to_nanoseconds(day=1) + _scope_max: ClassVar[int] = to_microseconds(day=7) - 1 # Sun day end of day + _unit_resolution: ClassVar[int] = to_microseconds(day=1) weeknum_mapping = { **dict(zip(range(1, 8), range(7))), @@ -146,7 +146,7 @@ class TimeOfWeek(AnchoredInterval): } def anchor_int(self, i, **kwargs): - # The axis is from 0 to 6 times nanoseconds per day + # The axis is from 0 to 6 times microseconds per day # but if start/end is passed as int, it's considered from 1-7 # (Monday is 1) if not 1 <= i <= 7: @@ -164,30 +164,30 @@ def anchor_str(self, s, side=None, **kwargs): # TODO: TimeOfDay.anchor_str as function if not time: - nanoseconds = to_nanoseconds(day=1) - 1 if side == "end" else 0 + microseconds = to_microseconds(day=1) - 1 if side == "end" else 0 else: - nanoseconds = TimeOfDay().anchor_str(time) + microseconds = TimeOfDay().anchor_str(time) - return to_nanoseconds(day=1) * nth_day + nanoseconds + return to_microseconds(day=1) * nth_day + microseconds def anchor_dt(self, dt, **kwargs): - "Turn datetime to nanoseconds according to the scope (by removing higher time elements)" + "Turn datetime to microseconds according to the scope (by removing higher time elements)" d = to_dict(dt) d = { key: val for key, val in d.items() - if key in ("hour", "minute", "second", "microsecond", "nanosecond") + if key in ("hour", "minute", "second", "microsecond") } dayofweek = dt.weekday() - return to_nanoseconds(**d) + dayofweek * to_nanoseconds(day=1) + return to_microseconds(**d) + dayofweek * to_microseconds(day=1) @dataclass(frozen=True, init=False) class TimeOfMonth(AnchoredInterval): """Time interval anchored to day cycle of a clock - min: first day of month, 0 hours, 0 minutes, 0 seconds, 0 microsecond, 0 nanosecond - max: last day of month, 23 hours, 59 minutes, 59 seconds, 999999 microsecond, 999 nanosecond + min: first day of month, 0 hours, 0 minutes, 0 seconds, 0 microsecond + max: last day of month, 23 hours, 59 minutes, 59 seconds, 999999 microsecond Example: # From 10 o'clock to 15 o'clock @@ -198,8 +198,8 @@ class TimeOfMonth(AnchoredInterval): # rollforward/rollback/contains would need slight changes _scope: ClassVar[str] = "year" - _scope_max: ClassVar[int] = to_nanoseconds(day=31) - 1 # 31st end of day - _unit_resolution: ClassVar[int] = to_nanoseconds(day=1) + _scope_max: ClassVar[int] = to_microseconds(day=31) - 1 # 31st end of day + _unit_resolution: ClassVar[int] = to_microseconds(day=1) # NOTE: Floating # TODO: ceil end and implement reversion (last 5th day) @@ -229,44 +229,44 @@ def anchor_str(self, s, side=None, **kwargs): # If one says 'thing X was organized between # 15th and 17th of July', the sentence # includes 17th till midnight. - nanoseconds = to_nanoseconds(day=1) - 1 + microseconds = to_microseconds(day=1) - 1 elif time: - nanoseconds = TimeOfDay().anchor_str(time) + microseconds = TimeOfDay().anchor_str(time) else: - nanoseconds = 0 + microseconds = 0 - return to_nanoseconds(day=1) * (nth_day - 1) + nanoseconds + return to_microseconds(day=1) * (nth_day - 1) + microseconds def anchor_dt(self, dt, **kwargs): - "Turn datetime to nanoseconds according to the scope (by removing higher time elements)" + "Turn datetime to microseconds according to the scope (by removing higher time elements)" d = to_dict(dt) d = { key: val for key, val in d.items() - if key in ("day", "hour", "minute", "second", "microsecond", "nanosecond") + if key in ("day", "hour", "minute", "second", "microsecond") } if "day" in d: # Day (of month) does not start from 0 (but from 1) d["day"] = d["day"] - 1 - return to_nanoseconds(**d) + return to_microseconds(**d) def get_scope_forward(self, dt): n_days = calendar.monthrange(dt.year, dt.month)[1] - return to_nanoseconds(day=1) * n_days + return to_microseconds(day=1) * n_days def get_scope_back(self, dt): month = 12 if dt.month == 1 else dt.month - 1 year = dt.year - 1 if dt.month == 1 else dt.year n_days = calendar.monthrange(year, month)[1] - return to_nanoseconds(day=1) * n_days + return to_microseconds(day=1) * n_days @dataclass(frozen=True, init=False) class TimeOfYear(AnchoredInterval): """Time interval anchored to day cycle of a clock - min: 1st Jan, 0 hours, 0 minutes, 0 seconds, 0 microsecond, 0 nanosecond - max: 31st Dec, 23 hours, 59 minutes, 59 seconds, 999999 microsecond, 999 nanosecond + min: 1st Jan, 0 hours, 0 minutes, 0 seconds, 0 microsecond + max: 31st Dec, 23 hours, 59 minutes, 59 seconds, 999999 microsecond Example: # From 10 o'clock to 15 o'clock @@ -277,7 +277,7 @@ class TimeOfYear(AnchoredInterval): # using first the month and then the day of month _scope: ClassVar[str] = "year" - _scope_max: ClassVar[int] = to_nanoseconds(day=1) * 366 - 1 + _scope_max: ClassVar[int] = to_microseconds(day=1) * 366 - 1 monthnum_mapping: ClassVar = { **dict(zip(range(12), range(12))), @@ -289,21 +289,21 @@ class TimeOfYear(AnchoredInterval): _month_start_mapping: ClassVar = { 0: 0, # January - 1: to_nanoseconds(day=31), # February (31 days from year start) - 2: to_nanoseconds(day=60), # March (31 + 29, leap year has 29 days in February) - 3: to_nanoseconds(day=91), # April (31 + 29 + 31) - 4: to_nanoseconds(day=121), # May (31 + 29 + 31 + 30) - 5: to_nanoseconds(day=152), # June - 6: to_nanoseconds(day=182), # July - 7: to_nanoseconds(day=213), # August - - 8: to_nanoseconds(day=244), # September - 9: to_nanoseconds(day=274), # October - 10: to_nanoseconds(day=305), # November - 11: to_nanoseconds(day=335), # December - 12: to_nanoseconds(day=366), # End of the year (on leap years) + 1: to_microseconds(day=31), # February (31 days from year start) + 2: to_microseconds(day=60), # March (31 + 29, leap year has 29 days in February) + 3: to_microseconds(day=91), # April (31 + 29 + 31) + 4: to_microseconds(day=121), # May (31 + 29 + 31 + 30) + 5: to_microseconds(day=152), # June + 6: to_microseconds(day=182), # July + 7: to_microseconds(day=213), # August + + 8: to_microseconds(day=244), # September + 9: to_microseconds(day=274), # October + 10: to_microseconds(day=305), # November + 11: to_microseconds(day=335), # December + 12: to_microseconds(day=366), # End of the year (on leap years) } - # Reverse the _month_start_mapping to nanoseconds to month num + # Reverse the _month_start_mapping to microseconds to month num _year_start_mapping: ClassVar = dict((v, k) for k, v in _month_start_mapping.items()) # NOTE: Floating @@ -327,14 +327,14 @@ def anchor_str(self, s, side=None, **kwargs): # time between 1st of May to 30th of June. return self._month_start_mapping[nth_month+1] - 1 elif day_of_month_str: - nanoseconds = TimeOfMonth().anchor_str(day_of_month_str) + microseconds = TimeOfMonth().anchor_str(day_of_month_str) else: - nanoseconds = 0 + microseconds = 0 - return self._month_start_mapping[nth_month] + nanoseconds + return self._month_start_mapping[nth_month] + microseconds def to_timepoint(self, ns:int): - "Turn nanoseconds to the period's timepoint" + "Turn microseconds to the period's timepoint" # Ie. Monday --> Monday 00:00 to Monday 24:00 # By default assumes linear scale (like week) # but can be overridden for non linear such as year @@ -343,7 +343,7 @@ def to_timepoint(self, ns:int): def anchor_int(self, i, side=None, **kwargs): # i is the month (Jan = 1) - # The axis is from 0 to 365 * nanoseconds per day + # The axis is from 0 to 365 * microseconds per day if not 1 <= i <= 12: raise ValueError(f"Invalid month: {i} (Jan is 1 and Dec is 12)") i -= 1 @@ -352,18 +352,18 @@ def anchor_int(self, i, side=None, **kwargs): return self._month_start_mapping[i] def anchor_dt(self, dt, **kwargs): - "Turn datetime to nanoseconds according to the scope (by removing higher time elements)" + "Turn datetime to microseconds according to the scope (by removing higher time elements)" dt_dict = to_dict(dt) d = { key: val for key, val in dt_dict.items() - if key in ("day", "hour", "minute", "second", "microsecond", "nanosecond") + if key in ("day", "hour", "minute", "second", "microsecond") } nth_month = dt_dict["month"] - 1 if "day" in d: # Day (of month) does not start from 0 (but from 1) d["day"] = d["day"] - 1 - return self._month_start_mapping[nth_month] + to_nanoseconds(**d) + return self._month_start_mapping[nth_month] + to_microseconds(**d) @dataclass(frozen=True, init=False) From 091527f0546c8d44056649cc7369c4eeef08181a Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Tue, 9 Aug 2022 08:52:32 +0300 Subject: [PATCH 15/42] fix: static interval Now also works with non permanent (start and end as None) --- rocketry/core/time/base.py | 10 ++++++---- rocketry/test/time/test_static.py | 32 +++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 rocketry/test/time/test_static.py diff --git a/rocketry/core/time/base.py b/rocketry/core/time/base.py index 6eb8203d..cb711e8e 100644 --- a/rocketry/core/time/base.py +++ b/rocketry/core/time/base.py @@ -488,8 +488,8 @@ class StaticInterval(TimePeriod): end: datetime.datetime def __init__(self, start=None, end=None): - object.__setattr__(self, "start", start) - object.__setattr__(self, "end", end) + object.__setattr__(self, "start", to_datetime(start) if start is not None else self.min) + object.__setattr__(self, "end", to_datetime(end) if end is not None else self.max) def rollback(self, dt): dt = to_datetime(dt) @@ -497,7 +497,8 @@ def rollback(self, dt): if start > dt: # The actual interval is in the future return Interval(self.min, self.min) - return Interval(start, dt) + end = min(self.end, dt) + return Interval(start, end) def rollforward(self, dt): dt = to_datetime(dt) @@ -505,7 +506,8 @@ def rollforward(self, dt): if end < dt: # The actual interval is already gone return Interval(self.max, self.max) - return Interval(dt, end) + start = max(self.start, dt) + return Interval(start, end) @property def is_max_interval(self): diff --git a/rocketry/test/time/test_static.py b/rocketry/test/time/test_static.py new file mode 100644 index 00000000..c9762c37 --- /dev/null +++ b/rocketry/test/time/test_static.py @@ -0,0 +1,32 @@ +from datetime import datetime +from rocketry.time import StaticInterval +from rocketry.pybox.time import Interval + +def test_static(): + t = StaticInterval("2022-08-01", "2022-08-10") + assert datetime.fromisoformat("2022-07-31 23:59:59") not in t + assert datetime.fromisoformat("2022-08-01 00:00:00") in t + assert datetime.fromisoformat("2022-08-09 12:59:00") in t + assert datetime.fromisoformat("2022-08-10 00:00:00") not in t + + assert t.rollforward("2022-07-01 00:00:00") == Interval( + datetime.fromisoformat("2022-08-01 00:00:00"), + datetime.fromisoformat("2022-08-10 00:00:00"), + closed="left" + ) + assert t.rollback("2022-09-01 00:00:00") == Interval( + datetime.fromisoformat("2022-08-01 00:00:00"), + datetime.fromisoformat("2022-08-10 00:00:00"), + closed="left" + ) + + assert t.rollforward("2022-08-03 00:00:00") == Interval( + datetime.fromisoformat("2022-08-03 00:00:00"), + datetime.fromisoformat("2022-08-10 00:00:00"), + closed="left" + ) + assert t.rollback("2022-08-03 00:00:00") == Interval( + datetime.fromisoformat("2022-08-01 00:00:00"), + datetime.fromisoformat("2022-08-03 00:00:00"), + closed="left" + ) \ No newline at end of file From 3cb7db7f98868d8d2f0338ee41ab724e9d1be327 Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Tue, 9 Aug 2022 22:41:17 +0300 Subject: [PATCH 16/42] upd: now Interval is frozen dataclass as well --- rocketry/pybox/time/interval.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/rocketry/pybox/time/interval.py b/rocketry/pybox/time/interval.py index 7fe4d91e..e48283a1 100644 --- a/rocketry/pybox/time/interval.py +++ b/rocketry/pybox/time/interval.py @@ -1,15 +1,23 @@ +from dataclasses import dataclass, field +from typing import Any, Literal + +@dataclass(frozen=True) class Interval: "Mimics pandas.Interval" - def __init__(self, left, right, closed="left"): - self.left = left - self.right = right - self.closed = closed + left: Any + right: Any + closed: Literal['left', 'right', 'both', 'neither'] = "left" - if left > right: + def __post_init__(self): + if self.left > self.right: raise ValueError("Left cannot be greater than right") + + if self.closed not in ('left', 'right', 'both', 'neither'): + raise ValueError(f"Invalid close: {self.closed}") + def __contains__(self, dt): if self.closed == "right": From 76f36ba35509c3917fc0f06772115505bb81abd4 Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Tue, 9 Aug 2022 22:42:50 +0300 Subject: [PATCH 17/42] fix: anchor end of the time period and time point --- rocketry/core/time/anchor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rocketry/core/time/anchor.py b/rocketry/core/time/anchor.py index d5bfde32..3081935a 100644 --- a/rocketry/core/time/anchor.py +++ b/rocketry/core/time/anchor.py @@ -126,7 +126,7 @@ def to_timepoint(self, ms:int): # Ie. Monday --> Monday 00:00 to Monday 24:00 # By default assumes linear scale (like week) # but can be overridden for non linear such as year - return ms + self._unit_resolution - 1 + return ms + self._unit_resolution @property def start(self): @@ -178,11 +178,11 @@ def is_full(self): def get_scope_back(self, dt): "Override if offsetting back is different than forward" - return self._scope_max + 1 + return self._scope_max def get_scope_forward(self, dt): "Override if offsetting back is different than forward" - return self._scope_max + 1 + return self._scope_max def rollstart(self, dt): "Roll forward to next point in time that on the period" From 626c4c57af6711460bbb193bda6da68fe5880086 Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Tue, 9 Aug 2022 22:43:20 +0300 Subject: [PATCH 18/42] fix: time interval maximum includes end --- rocketry/time/interval.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/rocketry/time/interval.py b/rocketry/time/interval.py index 8c56d58a..7c702269 100644 --- a/rocketry/time/interval.py +++ b/rocketry/time/interval.py @@ -26,7 +26,7 @@ class TimeOfMinute(AnchoredInterval): _scope: ClassVar[str] = "minute" - _scope_max: ClassVar[int] = to_microseconds(minute=1) - 1 + _scope_max: ClassVar[int] = to_microseconds(minute=1) _unit_resolution: ClassVar[int] = to_microseconds(second=1) def anchor_str(self, s, **kwargs): @@ -56,7 +56,7 @@ class TimeOfHour(AnchoredInterval): TimeOfHour("15:00", "30:00") """ _scope: ClassVar[str] = "hour" - _scope_max: ClassVar[int] = to_microseconds(hour=1) - 1 + _scope_max: ClassVar[int] = to_microseconds(hour=1) _unit_resolution: ClassVar[int] = to_microseconds(minute=1) def anchor_int(self, i, **kwargs): @@ -91,7 +91,7 @@ class TimeOfDay(AnchoredInterval): TimeOfDay("10:00", "15:00") """ _scope: ClassVar[str] = "day" - _scope_max: ClassVar[int] = to_microseconds(day=1) - 1 + _scope_max: ClassVar[int] = to_microseconds(day=1) _unit_resolution: ClassVar[int] = to_microseconds(hour=1) def anchor_int(self, i, **kwargs): @@ -128,7 +128,7 @@ class TimeOfWeek(AnchoredInterval): TimeOfWeek("Mon 15:00", "Wed 16:00") """ _scope: ClassVar[str] = "week" - _scope_max: ClassVar[int] = to_microseconds(day=7) - 1 # Sun day end of day + _scope_max: ClassVar[int] = to_microseconds(day=7) # Sun day end of day _unit_resolution: ClassVar[int] = to_microseconds(day=1) weeknum_mapping = { @@ -198,7 +198,7 @@ class TimeOfMonth(AnchoredInterval): # rollforward/rollback/contains would need slight changes _scope: ClassVar[str] = "year" - _scope_max: ClassVar[int] = to_microseconds(day=31) - 1 # 31st end of day + _scope_max: ClassVar[int] = to_microseconds(day=31) # 31st end of day _unit_resolution: ClassVar[int] = to_microseconds(day=1) # NOTE: Floating # TODO: ceil end and implement reversion (last 5th day) @@ -277,7 +277,7 @@ class TimeOfYear(AnchoredInterval): # using first the month and then the day of month _scope: ClassVar[str] = "year" - _scope_max: ClassVar[int] = to_microseconds(day=1) * 366 - 1 + _scope_max: ClassVar[int] = to_microseconds(day=1) * 366 monthnum_mapping: ClassVar = { **dict(zip(range(12), range(12))), From 0a2de2d442ecf33463964072bf28e02e351061dc Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Tue, 9 Aug 2022 22:45:38 +0300 Subject: [PATCH 19/42] test: fix some tests for new interpretation of end Now the intervals are always right opened and left closed. --- rocketry/test/time/interval/test_construct.py | 18 +++++++++--------- .../test/time/interval/timeofday/test_roll.py | 4 ++-- rocketry/test/time/test_cron.py | 5 ++--- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/rocketry/test/time/interval/test_construct.py b/rocketry/test/time/interval/test_construct.py index 3e9909c7..b1ba87b0 100644 --- a/rocketry/test/time/interval/test_construct.py +++ b/rocketry/test/time/interval/test_construct.py @@ -83,7 +83,7 @@ class TestTimeOfHour(ConstructTester): cls = TimeOfHour - max_ms = MS_IN_HOUR - 1 + max_ms = MS_IN_HOUR scen_closed = [ { @@ -116,7 +116,7 @@ class TestTimeOfHour(ConstructTester): { "start": "12:00", "expected_start": 12 * MS_IN_MINUTE, - "expected_end": 13 * MS_IN_MINUTE - 1, + "expected_end": 13 * MS_IN_MINUTE, } ] @@ -131,7 +131,7 @@ class TestTimeOfDay(ConstructTester): cls = TimeOfDay - max_ms = 24 * MS_IN_HOUR - 1 + max_ms = 24 * MS_IN_HOUR scen_closed = [ { @@ -164,7 +164,7 @@ class TestTimeOfDay(ConstructTester): { "start": "12:00", "expected_start": 12 * MS_IN_HOUR, - "expected_end": 13 * MS_IN_HOUR - 1, + "expected_end": 13 * MS_IN_HOUR, } ] scen_value_error = [ @@ -179,7 +179,7 @@ class TestTimeOfWeek(ConstructTester): cls = TimeOfWeek - max_ms = 7 * MS_IN_DAY - 1 + max_ms = 7 * MS_IN_DAY scen_closed = [ { @@ -221,7 +221,7 @@ class TestTimeOfWeek(ConstructTester): { "start": "Tue", "expected_start": 1 * MS_IN_DAY, - "expected_end": 2 * MS_IN_DAY - 1, + "expected_end": 2 * MS_IN_DAY, } ] scen_value_error = [ @@ -236,7 +236,7 @@ class TestTimeOfMonth(ConstructTester): cls = TimeOfMonth - max_ms = 31 * MS_IN_DAY - 1 + max_ms = 31 * MS_IN_DAY scen_closed = [ { @@ -275,7 +275,7 @@ class TestTimeOfMonth(ConstructTester): { "start": "2.", "expected_start": 1 * MS_IN_DAY, - "expected_end": 2 * MS_IN_DAY - 1, + "expected_end": 2 * MS_IN_DAY, } ] scen_value_error = [ @@ -293,7 +293,7 @@ class TestTimeOfYear(ConstructTester): cls = TimeOfYear - max_ms = 366 * MS_IN_DAY - 1 # Leap year has 366 days + max_ms = 366 * MS_IN_DAY # Leap year has 366 days scen_closed = [ { diff --git a/rocketry/test/time/interval/timeofday/test_roll.py b/rocketry/test/time/interval/timeofday/test_roll.py index fc7ce7a0..41796cd8 100644 --- a/rocketry/test/time/interval/timeofday/test_roll.py +++ b/rocketry/test/time/interval/timeofday/test_roll.py @@ -20,7 +20,7 @@ pytest.param( from_iso("2020-01-01 12:00:00"), "10:00", "12:00", - from_iso("2020-01-01 12:00:00"), from_iso("2020-01-01 12:00:00"), + from_iso("2020-01-02 10:00:00"), from_iso("2020-01-02 12:00:00"), id="Right of interval"), pytest.param( from_iso("2020-01-01 11:00:00"), @@ -37,7 +37,7 @@ pytest.param( from_iso("2020-01-01 02:00:00"), "22:00", "02:00", - from_iso("2020-01-01 02:00:00"), from_iso("2020-01-01 02:00:00"), + from_iso("2020-01-01 22:00:00"), from_iso("2020-01-02 02:00:00"), id="Right of overnight interval"), pytest.param( from_iso("2020-01-01 23:59:59.999999"), diff --git a/rocketry/test/time/test_cron.py b/rocketry/test/time/test_cron.py index 064436e8..bf2e5184 100644 --- a/rocketry/test/time/test_cron.py +++ b/rocketry/test/time/test_cron.py @@ -98,9 +98,8 @@ def test_roll_back_simple(): # No roll (at left, single point) interv = period.rollback(datetime.datetime(2022, 8, 7, 12, 30, 0)) - assert interv.left == datetime.datetime(2022, 8, 7, 12, 30, 00) - assert interv.right == datetime.datetime(2022, 8, 7, 12, 30, 00) - assert interv.closed == "both" + assert interv.left == datetime.datetime(2022, 8, 7, 11, 30, 00) + assert interv.right == datetime.datetime(2022, 8, 7, 11, 31, 00) # No roll (at center) interv = period.rollback(datetime.datetime(2022, 8, 7, 12, 30, 30)) From ff67cd205b8c3489a4c923b79dd623c4611f2f57 Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Tue, 9 Aug 2022 22:47:34 +0300 Subject: [PATCH 20/42] fix: time interval rollforward if it's empty Note: this is not needed for rollback as left point is always included. --- rocketry/core/time/base.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/rocketry/core/time/base.py b/rocketry/core/time/base.py index cb711e8e..0e282aa1 100644 --- a/rocketry/core/time/base.py +++ b/rocketry/core/time/base.py @@ -149,6 +149,7 @@ def is_full(self): def rollforward(self, dt) -> datetime.datetime: "Get next time interval of the period" + closed = "left" if self.is_full(): # Full period so dt always belongs on it start = dt @@ -159,16 +160,21 @@ def rollforward(self, dt) -> datetime.datetime: else: start = self.rollstart(dt) end = self.next_end(dt) + if start == end: + # The interval is left closed so this should + # not contain any points. We look for another + # one + return self.rollforward(end + self.resolution) start = to_datetime(start) end = to_datetime(end) - closed = "both" if start == end else 'left' return Interval(start, end, closed=closed) def rollback(self, dt) -> Interval: "Get previous time interval of the period" + closed = "left" if self.is_full(): # Full period so dt always belongs on it end = dt @@ -179,11 +185,15 @@ def rollback(self, dt) -> Interval: else: end = self.rollend(dt) start = self.prev_start(dt) + if start == end: + # The interval is left closed but the start + # is included in the interval. Therefore + # we include a single point (both sides closed) + closed = "both" start = to_datetime(start) end = to_datetime(end) - closed = "both" if start == end or self.is_full() else 'left' return Interval(start, end, closed=closed) def __eq__(self, other): From e91a32e78617b534e0a537f730f0c8bb9e0fbcfd Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Tue, 9 Aug 2022 22:48:00 +0300 Subject: [PATCH 21/42] test: add is_full to tests --- rocketry/test/time/interval/test_construct.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/rocketry/test/time/interval/test_construct.py b/rocketry/test/time/interval/test_construct.py index b1ba87b0..f7580fd4 100644 --- a/rocketry/test/time/interval/test_construct.py +++ b/rocketry/test/time/interval/test_construct.py @@ -48,30 +48,36 @@ class ConstructTester: def test_closed(self, start, end, expected_start, expected_end): time = self.cls(start, end) + assert not time.is_full() assert expected_start == time._start assert expected_end == time._end def test_open(self): time = self.cls(None, None) + assert time.is_full() assert 0 == time._start assert self.max_ms == time._end def test_open_left(self, end, expected_end, **kwargs): time = self.cls(None, end) + assert not time.is_full() assert 0 == time._start assert expected_end == time._end def test_open_right(self, start, expected_start, **kwargs): time = self.cls(start, None) + assert not time.is_full() assert expected_start == time._start assert self.max_ms == time._end def test_time_point(self, start, expected_start, expected_end, **kwargs): time = self.cls(start, time_point=True) + assert not time.is_full() assert expected_start == time._start assert expected_end == time._end time = self.cls.at(start) + assert not time.is_full() assert expected_start == time._start assert expected_end == time._end From f65b2bbdd0fc6d06a1b833d1fb6001e6a235ad95 Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Tue, 9 Aug 2022 22:48:40 +0300 Subject: [PATCH 22/42] test: fix some tests related to timepoints and end --- rocketry/test/condition/test_parse.py | 2 +- rocketry/test/time/logic/test_roll.py | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/rocketry/test/condition/test_parse.py b/rocketry/test/condition/test_parse.py index 1575a2c1..84687b42 100644 --- a/rocketry/test/condition/test_parse.py +++ b/rocketry/test/condition/test_parse.py @@ -84,7 +84,7 @@ pytest.param("time of hour before 45:00", IsPeriod(period=TimeOfHour(None, "45:00")), id="time of hour before"), pytest.param("time of day before 10:00", IsPeriod(period=TimeOfDay(None, "10:00")), id="time of day before"), pytest.param("time of week before Tuesday", IsPeriod(period=TimeOfWeek(None, "Tue")), id="time of week before"), - pytest.param("time of week on Tuesday", IsPeriod(period=TimeOfWeek("Tue", "Tue")), id="time of week on"), + pytest.param("time of week on Tuesday", IsPeriod(period=TimeOfWeek.at("Tue")), id="time of week on"), pytest.param("time of month before 1.", IsPeriod(period=TimeOfMonth(None, "1.")), id="time of month before"), ] diff --git a/rocketry/test/time/logic/test_roll.py b/rocketry/test/time/logic/test_roll.py index 04bdab42..59393e2d 100644 --- a/rocketry/test/time/logic/test_roll.py +++ b/rocketry/test/time/logic/test_roll.py @@ -119,8 +119,8 @@ def test_rollback_all(dt, periods, roll_start, roll_end): pytest.param( from_iso("2020-01-01 07:00:00"), [ - TimeOfDay("08:00", "09:00"), - TimeOfDay("09:00", "12:00"), + TimeOfDay("08:00", "09:00", right_closed=True), + TimeOfDay("09:00", "12:00", right_closed=True), TimeOfDay("12:00", "18:00"), ], from_iso("2020-01-01 08:00:00"), from_iso("2020-01-01 18:00:00"), @@ -129,7 +129,7 @@ def test_rollback_all(dt, periods, roll_start, roll_end): pytest.param( from_iso("2020-01-01 08:30:00"), [ - TimeOfDay("08:00", "09:00"), + TimeOfDay("08:00", "09:00", right_closed=True), TimeOfDay("09:00", "10:00"), ], from_iso("2020-01-01 08:30:00"), from_iso("2020-01-01 10:00:00"), @@ -150,6 +150,7 @@ def test_rollforward_any(dt, periods, roll_start, roll_end): interval = time.rollforward(dt) assert roll_start == interval.left assert roll_end == interval.right + assert interval.closed == "left" @pytest.mark.parametrize( "dt,periods,roll_start,roll_end", @@ -178,8 +179,8 @@ def test_rollforward_any(dt, periods, roll_start, roll_end): pytest.param( from_iso("2020-01-01 19:00:00"), [ - TimeOfDay("08:00", "09:00"), - TimeOfDay("09:00", "12:00"), + TimeOfDay("08:00", "09:00", right_closed=True), + TimeOfDay("09:00", "12:00", right_closed=True), TimeOfDay("12:00", "18:00"), ], from_iso("2020-01-01 08:00:00"), from_iso("2020-01-01 18:00:00"), @@ -188,7 +189,7 @@ def test_rollforward_any(dt, periods, roll_start, roll_end): pytest.param( from_iso("2020-01-01 09:30:00"), [ - TimeOfDay("08:00", "09:00"), + TimeOfDay("08:00", "09:00", right_closed=True), TimeOfDay("09:00", "10:00"), ], from_iso("2020-01-01 08:00:00"), from_iso("2020-01-01 09:30:00"), From cda07cc386dad5403edc15f45926f3827cddb6bd Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Tue, 9 Aug 2022 22:48:59 +0300 Subject: [PATCH 23/42] add: option for right close --- rocketry/core/time/anchor.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/rocketry/core/time/anchor.py b/rocketry/core/time/anchor.py index 3081935a..3ada4b0a 100644 --- a/rocketry/core/time/anchor.py +++ b/rocketry/core/time/anchor.py @@ -52,7 +52,7 @@ class AnchoredInterval(TimeInterval): _unit_resolution: ClassVar[int] = None # Microseconds of one unit (if start/end is int) - def __init__(self, start=None, end=None, time_point=None): + def __init__(self, start=None, end=None, time_point=None, right_closed=False): if start is None and end is None: if time_point: @@ -61,7 +61,7 @@ def __init__(self, start=None, end=None, time_point=None): object.__setattr__(self, "_end", self._scope_max) else: self.set_start(start) - self.set_end(end, time_point=time_point) + self.set_end(end, time_point=time_point, right_closed=right_closed) def anchor(self, value, **kwargs): "Turn value to nanoseconds relative to scope of the class" @@ -100,6 +100,10 @@ def anchor_dt(self, dt: datetime, **kwargs) -> int: return to_microseconds(**d) + def count_steps(self, ): + "Count number of periods " + + def set_start(self, val): if val is None: ms = 0 @@ -109,7 +113,7 @@ def set_start(self, val): object.__setattr__(self, "_start", ms) object.__setattr__(self, "_start_orig", val) - def set_end(self, val, time_point=False): + def set_end(self, val, right_closed=False, time_point=False): if time_point and val is None: # Interval is "at" type, ie. on monday, at 10:00 (10:00 - 10:59:59) ms = self.to_timepoint(self._start) @@ -118,7 +122,13 @@ def set_end(self, val, time_point=False): else: ms = self.anchor(val, side="end", time_point=time_point) - object.__setattr__(self, "_end", ns) + if right_closed: + # We use left closed in intervals so we add one unit to make it closed + # given the end argument, ie. if "09:00 to 10:00" excludes 10:00 + # we can include it by adding one nanosecond to 10:00 + ms += 1 + + object.__setattr__(self, "_end", ms) object.__setattr__(self, "_end_orig", val) def to_timepoint(self, ms:int): From db2bf75e3431a7f8b15ac99772e052992b837e4a Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Tue, 9 Aug 2022 22:49:15 +0300 Subject: [PATCH 24/42] fix: immutable error in string setting in periods --- rocketry/parse/time.py | 1 - 1 file changed, 1 deletion(-) diff --git a/rocketry/parse/time.py b/rocketry/parse/time.py index 50d3bc74..c79a8f35 100644 --- a/rocketry/parse/time.py +++ b/rocketry/parse/time.py @@ -6,7 +6,6 @@ def _parse_time_string(s:str, **kwargs): time = parse_time_string(s, **kwargs) - time._str = s return time parse_time = ParserPicker( From af9ec97c7273666fbf8c2f008449078c7c4c7804 Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Tue, 9 Aug 2022 22:50:20 +0300 Subject: [PATCH 25/42] test: add closed assertions --- rocketry/test/time/interval/timeofday/test_roll.py | 1 + rocketry/test/time/test_cron.py | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/rocketry/test/time/interval/timeofday/test_roll.py b/rocketry/test/time/interval/timeofday/test_roll.py index 41796cd8..00787cbb 100644 --- a/rocketry/test/time/interval/timeofday/test_roll.py +++ b/rocketry/test/time/interval/timeofday/test_roll.py @@ -140,6 +140,7 @@ def test_rollback(start, end, dt, roll_start, roll_end): time = TimeOfDay(start, end) interval = time.rollback(dt) + assert interval.closed == 'left' assert roll_start == interval.left assert roll_end == interval.right diff --git a/rocketry/test/time/test_cron.py b/rocketry/test/time/test_cron.py index bf2e5184..5d50c773 100644 --- a/rocketry/test/time/test_cron.py +++ b/rocketry/test/time/test_cron.py @@ -64,6 +64,7 @@ def test_roll_forward_simple(): # Roll tiny amount interv = period.rollforward(datetime.datetime(2022, 8, 7, 12, 29, 59)) + assert interv.closed == 'left' assert interv.left == datetime.datetime(2022, 8, 7, 12, 30, 00) assert interv.right == datetime.datetime(2022, 8, 7, 12, 31, 00) @@ -76,16 +77,19 @@ def test_roll_forward_simple(): interv = period.rollforward(datetime.datetime(2022, 8, 7, 12, 30, 30)) assert interv.left == datetime.datetime(2022, 8, 7, 12, 30, 30) assert interv.right == datetime.datetime(2022, 8, 7, 12, 31, 00) + assert interv.closed == 'left' # No roll (at right) interv = period.rollforward(datetime.datetime(2022, 8, 7, 12, 30, 59, 999999)) assert interv.left == datetime.datetime(2022, 8, 7, 12, 30, 59, 999999) assert interv.right == datetime.datetime(2022, 8, 7, 12, 31, 00) + assert interv.closed == 'left' # Roll (at right) interv = period.rollforward(datetime.datetime(2022, 8, 7, 12, 31)) assert interv.left == datetime.datetime(2022, 8, 7, 13, 30) assert interv.right == datetime.datetime(2022, 8, 7, 13, 31) + assert interv.closed == 'left' def test_roll_back_simple(): period = Crontab("30", "*", "*", "*", "*") @@ -100,6 +104,7 @@ def test_roll_back_simple(): interv = period.rollback(datetime.datetime(2022, 8, 7, 12, 30, 0)) assert interv.left == datetime.datetime(2022, 8, 7, 11, 30, 00) assert interv.right == datetime.datetime(2022, 8, 7, 11, 31, 00) + assert interv.closed == "left" # No roll (at center) interv = period.rollback(datetime.datetime(2022, 8, 7, 12, 30, 30)) From 7bbfff070e86cf977528146fe44d053dea5b0e60 Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Wed, 10 Aug 2022 23:20:21 +0300 Subject: [PATCH 26/42] fix: time interval overlaps in All roll --- rocketry/core/time/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rocketry/core/time/base.py b/rocketry/core/time/base.py index 0e282aa1..558820b7 100644 --- a/rocketry/core/time/base.py +++ b/rocketry/core/time/base.py @@ -314,7 +314,7 @@ def rollback(self, dt): period.rollback(dt) for period in self.periods ] - all_overlaps = all(inter.overlaps(intervals[0]) for inter in intervals[1:]) + all_overlaps = all(a.overlaps(b) for a, b in itertools.combinations(intervals, 2)) if all_overlaps: return reduce(lambda a, b: a & b, intervals) else: @@ -348,7 +348,7 @@ def rollforward(self, dt): period.rollforward(dt) for period in self.periods ] - all_overlaps = all(inter.overlaps(intervals[0]) for inter in intervals[1:]) + all_overlaps = all(a.overlaps(b) for a, b in itertools.combinations(intervals, 2)) if all_overlaps: return reduce(lambda a, b: a & b, intervals) else: From 2df17de6acc3a5e52d6a209986e56ea97d95dc85 Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Wed, 10 Aug 2022 23:21:14 +0300 Subject: [PATCH 27/42] add: time period All and Any compression --- rocketry/core/time/base.py | 31 +++++++++++- rocketry/test/time/logic/test_construct.py | 55 ++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 rocketry/test/time/logic/test_construct.py diff --git a/rocketry/core/time/base.py b/rocketry/core/time/base.py index 558820b7..d626919e 100644 --- a/rocketry/core/time/base.py +++ b/rocketry/core/time/base.py @@ -300,7 +300,20 @@ def __init__(self, *args): raise TypeError("Only TimePeriods supported") elif not args: raise ValueError("No TimePeriods to wrap") - object.__setattr__(self, "periods", frozenset(args)) + + # Compress the time periods + periods = [] + for arg in args: + if isinstance(arg, All): + # Don't nest unnecessarily + periods += list(arg.periods) + elif arg is always: + # Does not really have an effect + continue + else: + periods.append(arg) + + object.__setattr__(self, "periods", frozenset(periods)) def rollback(self, dt): @@ -394,7 +407,21 @@ def __init__(self, *args): raise TypeError("Only TimePeriods supported") elif not args: raise ValueError("No TimePeriods to wrap") - object.__setattr__(self, "periods", frozenset(args)) + + # Compress the time periods + periods = [] + for arg in args: + if isinstance(arg, Any): + # Don't nest unnecessarily + periods += list(arg.periods) + elif arg is always: + # Does not really have an effect + periods = [always] + break + else: + periods.append(arg) + + object.__setattr__(self, "periods", frozenset(periods)) def rollback(self, dt): intervals = [ diff --git a/rocketry/test/time/logic/test_construct.py b/rocketry/test/time/logic/test_construct.py new file mode 100644 index 00000000..5a3f74ce --- /dev/null +++ b/rocketry/test/time/logic/test_construct.py @@ -0,0 +1,55 @@ + +import pytest +import datetime + +from rocketry.core.time.base import ( + All, Any +) +from rocketry.time import TimeOfDay, TimeOfMinute, always + +from_iso = datetime.datetime.fromisoformat + +def test_compress_any(): + assert Any( + TimeOfDay(), + TimeOfDay(), + Any(TimeOfMinute(), TimeOfMinute()), # This is unnested + All(TimeOfDay(), TimeOfMinute()), + ) == Any( + TimeOfDay(), + TimeOfDay(), + TimeOfMinute(), TimeOfMinute(), # This was unnested + All(TimeOfDay(), TimeOfMinute()), + ) + + assert Any( + TimeOfDay(), + All(TimeOfDay(), TimeOfMinute()), + always, # The other periods does not matter + Any(TimeOfMinute(), TimeOfMinute()), + ) == Any(always) + + +def test_compress_all(): + assert All( + TimeOfDay(), + TimeOfDay(), + All(TimeOfMinute(), TimeOfMinute()), # This is unnested + Any(TimeOfDay(), TimeOfMinute()), + ) == All( + TimeOfDay(), + TimeOfDay(), + TimeOfMinute(), TimeOfMinute(), # This was unnested + Any(TimeOfDay(), TimeOfMinute()), + ) + + assert All( + TimeOfDay(), + All(TimeOfDay(), TimeOfMinute()), + always, # This has no effect + Any(TimeOfMinute(), TimeOfMinute()), + ) == All( + TimeOfDay(), + All(TimeOfDay(), TimeOfMinute()), + Any(TimeOfMinute(), TimeOfMinute()), + ) \ No newline at end of file From f77360569c9d02b5e04e553a9e75a3e061588c56 Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Wed, 10 Aug 2022 23:22:59 +0300 Subject: [PATCH 28/42] test: fix cron tests --- rocketry/test/time/test_cron.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/rocketry/test/time/test_cron.py b/rocketry/test/time/test_cron.py index 5d50c773..b0546b3f 100644 --- a/rocketry/test/time/test_cron.py +++ b/rocketry/test/time/test_cron.py @@ -28,22 +28,22 @@ pytest.param(Crontab("*", "*", "*", "*", "FRI-SUN"), every_minute & TimeOfWeek("fri", "sun"), id="* * * * FRI-SUN"), # Test list - pytest.param(Crontab("0,15,30,45", "*", "*", "*", "*"), TimeOfHour.at(0) | TimeOfHour.at(15) | TimeOfHour.at(30) | TimeOfHour.at(45), id="0,15,30,45 * * * *"), + pytest.param(Crontab("0,15,30,45", "*", "*", "*", "*"), every_minute & (TimeOfHour.at(0) | TimeOfHour.at(15) | TimeOfHour.at(30) | TimeOfHour.at(45)), id="0,15,30,45 * * * *"), # Test combinations pytest.param( Crontab("45-59", "10-13", "28-30", "FEB-MAR", "FRI-SUN"), - TimeOfHour(45, 59) & TimeOfDay(10, 13) & TimeOfYear("feb", "mar") & (TimeOfMonth(28, 30) | TimeOfWeek("fri", "sun")), + every_minute & TimeOfHour(45, 59) & TimeOfDay(10, 13) & TimeOfYear("feb", "mar") & (TimeOfMonth(28, 30) | TimeOfWeek("fri", "sun")), id="45-59 10-13 28-30 FEB-MAR FRI-SUN" ), pytest.param( Crontab("45-59", "10-13", "28-30", "FEB-MAR", "*"), - TimeOfHour(45, 59) & TimeOfDay(10, 13) & TimeOfYear("feb", "mar") & TimeOfMonth(28, 30), + every_minute & TimeOfHour(45, 59) & TimeOfDay(10, 13) & TimeOfYear("feb", "mar") & TimeOfMonth(28, 30), id="45-59 10-13 28-30 FEB-MAR *" ), pytest.param( Crontab("0-29,45-59", "0-10,20-23", "*", "*", "*"), - (TimeOfHour(0, 29) | TimeOfHour(45, 59)) & (TimeOfDay(0, 10) | TimeOfDay(20, 23)), + (TimeOfHour(0, 29) | TimeOfHour(45, 59)) & (TimeOfDay(0, 10) | TimeOfDay(20, 23)) & every_minute, id="0-29,45-59 0-10,20-23 * * *" ), ] From ebfd020a599ef85a3d5955c6329fda9695cb20e7 Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Wed, 10 Aug 2022 23:24:04 +0300 Subject: [PATCH 29/42] add: create_range for anchored time periods This is useful for Cron skip. --- rocketry/core/time/anchor.py | 15 +++-- rocketry/test/time/test_skip.py | 107 ++++++++++++++++++++++++++++++++ rocketry/time/interval.py | 12 +++- 3 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 rocketry/test/time/test_skip.py diff --git a/rocketry/core/time/anchor.py b/rocketry/core/time/anchor.py index 3ada4b0a..4228563b 100644 --- a/rocketry/core/time/anchor.py +++ b/rocketry/core/time/anchor.py @@ -1,12 +1,12 @@ from datetime import datetime -from typing import ClassVar, Tuple, Union +from typing import ClassVar, List, Tuple from abc import abstractmethod from dataclasses import dataclass, field from .utils import to_microseconds, timedelta_to_str, to_dict, to_timedelta -from .base import TimeInterval +from .base import Any, TimeInterval @dataclass(frozen=True, repr=False) class AnchoredInterval(TimeInterval): @@ -51,6 +51,7 @@ class AnchoredInterval(TimeInterval): _scope_max: ClassVar[int] = None # Max in microseconds of the _unit_resolution: ClassVar[int] = None # Microseconds of one unit (if start/end is int) + _unit_names: ClassVar[List] = None def __init__(self, start=None, end=None, time_point=None, right_closed=False): @@ -100,9 +101,13 @@ def anchor_dt(self, dt: datetime, **kwargs) -> int: return to_microseconds(**d) - def count_steps(self, ): - "Count number of periods " - + @classmethod + def create_range(cls, step:int): + periods = tuple( + cls.at(step) + for step in cls._unit_names[::step] + ) + return Any(*periods) def set_start(self, val): if val is None: diff --git a/rocketry/test/time/test_skip.py b/rocketry/test/time/test_skip.py new file mode 100644 index 00000000..c05daaf3 --- /dev/null +++ b/rocketry/test/time/test_skip.py @@ -0,0 +1,107 @@ +from datetime import timedelta + +import pytest +from rocketry.core.time.base import TimeDelta +from rocketry.time import Any +from rocketry.time import TimeOfDay, TimeOfHour, TimeOfWeek +from rocketry.time.interval import TimeOfMinute, TimeOfMonth, TimeOfYear + +def test_time_of_day_construct(): + assert TimeOfDay.create_range(2) == TimeOfDay.create_range(step=2) + +def test_time_of_day_every_second(): + every_second_hour = TimeOfDay.create_range(2) + assert isinstance(every_second_hour, Any) + assert len(every_second_hour.periods) == 12 + assert every_second_hour == Any( + TimeOfDay("00:00", "01:00"), + #TimeOfDay("01:00", "02:00"), + TimeOfDay("02:00", "03:00"), + #TimeOfDay("03:00", "04:00"), + TimeOfDay("04:00", "05:00"), + #TimeOfDay("05:00", "06:00"), + TimeOfDay("06:00", "07:00"), + #TimeOfDay("07:00", "08:00"), + TimeOfDay("08:00", "09:00"), + #TimeOfDay("09:00", "10:00"), + TimeOfDay("10:00", "11:00"), + #TimeOfDay("11:00", "12:00"), + TimeOfDay("12:00", "13:00"), + #TimeOfDay("13:00", "14:00"), + TimeOfDay("14:00", "15:00"), + #TimeOfDay("15:00", "16:00"), + TimeOfDay("16:00", "17:00"), + #TimeOfDay("17:00", "18:00"), + TimeOfDay("18:00", "19:00"), + #TimeOfDay("19:00", "20:00"), + TimeOfDay("20:00", "21:00"), + #TimeOfDay("21:00", "22:00"), + TimeOfDay("22:00", "23:00"), + #TimeOfDay("23:00", "24:00"), + ) + +def test_time_of_day_every_third(): + every_second_hour = TimeOfDay.create_range(3) + assert isinstance(every_second_hour, Any) + assert every_second_hour == Any( + TimeOfDay("00:00", "01:00"), + #TimeOfDay("01:00", "02:00"), + #TimeOfDay("02:00", "03:00"), + TimeOfDay("03:00", "04:00"), + #TimeOfDay("04:00", "05:00"), + #TimeOfDay("05:00", "06:00"), + TimeOfDay("06:00", "07:00"), + #TimeOfDay("07:00", "08:00"), + #TimeOfDay("08:00", "09:00"), + TimeOfDay("09:00", "10:00"), + #TimeOfDay("10:00", "11:00"), + #TimeOfDay("11:00", "12:00"), + TimeOfDay("12:00", "13:00"), + #TimeOfDay("13:00", "14:00"), + #TimeOfDay("14:00", "15:00"), + TimeOfDay("15:00", "16:00"), + #TimeOfDay("16:00", "17:00"), + #TimeOfDay("17:00", "18:00"), + TimeOfDay("18:00", "19:00"), + #TimeOfDay("19:00", "20:00"), + #TimeOfDay("20:00", "21:00"), + TimeOfDay("21:00", "22:00"), + #TimeOfDay("22:00", "23:00"), + #TimeOfDay("23:00", "24:00"), + ) + +@pytest.mark.parametrize("cls,step,n_periods", + [ + pytest.param(TimeOfMinute, 1, 60, id="TimeOfMinute (every second)"), + pytest.param(TimeOfHour, 1, 60, id="TimeOfHour (every minute)"), + pytest.param(TimeOfDay, 1, 24, id="TimeOfDay (every hour)"), + pytest.param(TimeOfWeek, 1, 7, id="TimeOfWeek (every day of week)"), + pytest.param(TimeOfMonth, 1, 31, id="TimeOfMonth (every day of month)"), + pytest.param(TimeOfYear, 1, 12, id="TimeOfYear (every month)"), + + pytest.param(TimeOfMinute, 2, 30, id="TimeOfMinute (every second second)"), + pytest.param(TimeOfHour, 2, 30, id="TimeOfHour (every second minute)"), + pytest.param(TimeOfDay, 2, 12, id="TimeOfDay (every second hour)"), + pytest.param(TimeOfWeek, 2, 4, id="TimeOfWeek (every second day of week)"), + pytest.param(TimeOfMonth, 2, 16, id="TimeOfMonth (every second day of month)"), + pytest.param(TimeOfYear, 2, 6, id="TimeOfYear (every second month)"), + + pytest.param(TimeOfMinute, 3, 20, id="TimeOfMinute (every third second)"), + pytest.param(TimeOfHour, 3, 20, id="TimeOfHour (every third minute)"), + pytest.param(TimeOfDay, 3, 8, id="TimeOfDay (every third hour)"), + pytest.param(TimeOfWeek, 3, 3, id="TimeOfWeek (every third day of week)"), + pytest.param(TimeOfMonth, 3, 11, id="TimeOfMonth (every third day of month)"), + pytest.param(TimeOfYear, 3, 4, id="TimeOfYear (every third month)"), + + pytest.param(TimeOfMinute, 61, 1, id="TimeOfMinute (over the period)"), + pytest.param(TimeOfHour, 61, 1, id="TimeOfHour (over the period)"), + pytest.param(TimeOfDay, 24, 1, id="TimeOfDay (over the period)"), + pytest.param(TimeOfWeek, 7, 1, id="TimeOfWeek (over the period)"), + pytest.param(TimeOfMonth, 31, 1, id="TimeOfMonth (over the period)"), + pytest.param(TimeOfYear, 12, 1, id="TimeOfYear (over the period)"), + ] +) +def test_every_second(cls, step, n_periods): + every = cls.create_range(step) + assert isinstance(every, Any) + assert len(every.periods) == n_periods \ No newline at end of file diff --git a/rocketry/time/interval.py b/rocketry/time/interval.py index 7c702269..1b4beba9 100644 --- a/rocketry/time/interval.py +++ b/rocketry/time/interval.py @@ -3,7 +3,7 @@ import datetime import re from dataclasses import dataclass -from typing import ClassVar +from typing import ClassVar, List import dateutil @@ -28,6 +28,7 @@ class TimeOfMinute(AnchoredInterval): _scope_max: ClassVar[int] = to_microseconds(minute=1) _unit_resolution: ClassVar[int] = to_microseconds(second=1) + _unit_names: ClassVar[List[str]] = [f"{i:02d}" for i in range(60)] # 00, 01 etc. till 59 def anchor_str(self, s, **kwargs): # ie. 30.123 @@ -58,6 +59,7 @@ class TimeOfHour(AnchoredInterval): _scope: ClassVar[str] = "hour" _scope_max: ClassVar[int] = to_microseconds(hour=1) _unit_resolution: ClassVar[int] = to_microseconds(minute=1) + _unit_names: ClassVar[List[str]] = [f"{i:02d}:00" for i in range(60)] # 00:00, 01:00 etc. till 59:00 def anchor_int(self, i, **kwargs): if not 0 <= i <= 59: @@ -93,6 +95,7 @@ class TimeOfDay(AnchoredInterval): _scope: ClassVar[str] = "day" _scope_max: ClassVar[int] = to_microseconds(day=1) _unit_resolution: ClassVar[int] = to_microseconds(hour=1) + _unit_names: ClassVar[List[str]] = [f"{i:02d}:00" for i in range(24)] # 00:00, 01:00, 02:00 etc. till 23:00 def anchor_int(self, i, **kwargs): if not 0 <= i <= 23: @@ -130,6 +133,7 @@ class TimeOfWeek(AnchoredInterval): _scope: ClassVar[str] = "week" _scope_max: ClassVar[int] = to_microseconds(day=7) # Sun day end of day _unit_resolution: ClassVar[int] = to_microseconds(day=1) + _unit_names: ClassVar[List[str]] = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] weeknum_mapping = { **dict(zip(range(1, 8), range(7))), @@ -200,6 +204,7 @@ class TimeOfMonth(AnchoredInterval): _scope: ClassVar[str] = "year" _scope_max: ClassVar[int] = to_microseconds(day=31) # 31st end of day _unit_resolution: ClassVar[int] = to_microseconds(day=1) + _unit_names: ClassVar[List[str]] = ["1st", "2nd", "3rd"] + [f"{i}th" for i in range(4, 32)] # NOTE: Floating # TODO: ceil end and implement reversion (last 5th day) @@ -278,13 +283,14 @@ class TimeOfYear(AnchoredInterval): _scope: ClassVar[str] = "year" _scope_max: ClassVar[int] = to_microseconds(day=1) * 366 + _unit_names: ClassVar[List[str]] = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] monthnum_mapping: ClassVar = { **dict(zip(range(12), range(12))), # English - **{day.lower(): i for i, day in enumerate(['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december'])}, - **{day.lower(): i for i, day in enumerate(['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'])}, + **{day.lower(): i for i, day in enumerate(['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'])}, + **{day.lower(): i for i, day in enumerate(_unit_names)}, } _month_start_mapping: ClassVar = { From 17b78ce1926f515e28acc388ffa361f9aa38d877 Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Thu, 11 Aug 2022 08:10:10 +0300 Subject: [PATCH 30/42] upd: add start and end to time period create_range --- rocketry/core/time/anchor.py | 4 ++-- rocketry/test/time/test_skip.py | 9 +++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/rocketry/core/time/anchor.py b/rocketry/core/time/anchor.py index 4228563b..ec1459ac 100644 --- a/rocketry/core/time/anchor.py +++ b/rocketry/core/time/anchor.py @@ -102,10 +102,10 @@ def anchor_dt(self, dt: datetime, **kwargs) -> int: return to_microseconds(**d) @classmethod - def create_range(cls, step:int): + def create_range(cls, start=None, end=None, step:int=None): periods = tuple( cls.at(step) - for step in cls._unit_names[::step] + for step in cls._unit_names[start:end:step] ) return Any(*periods) diff --git a/rocketry/test/time/test_skip.py b/rocketry/test/time/test_skip.py index c05daaf3..588e6b42 100644 --- a/rocketry/test/time/test_skip.py +++ b/rocketry/test/time/test_skip.py @@ -6,11 +6,8 @@ from rocketry.time import TimeOfDay, TimeOfHour, TimeOfWeek from rocketry.time.interval import TimeOfMinute, TimeOfMonth, TimeOfYear -def test_time_of_day_construct(): - assert TimeOfDay.create_range(2) == TimeOfDay.create_range(step=2) - def test_time_of_day_every_second(): - every_second_hour = TimeOfDay.create_range(2) + every_second_hour = TimeOfDay.create_range(step=2) assert isinstance(every_second_hour, Any) assert len(every_second_hour.periods) == 12 assert every_second_hour == Any( @@ -41,7 +38,7 @@ def test_time_of_day_every_second(): ) def test_time_of_day_every_third(): - every_second_hour = TimeOfDay.create_range(3) + every_second_hour = TimeOfDay.create_range(step=3) assert isinstance(every_second_hour, Any) assert every_second_hour == Any( TimeOfDay("00:00", "01:00"), @@ -102,6 +99,6 @@ def test_time_of_day_every_third(): ] ) def test_every_second(cls, step, n_periods): - every = cls.create_range(step) + every = cls.create_range(step=step) assert isinstance(every, Any) assert len(every.periods) == n_periods \ No newline at end of file From 8d03b011aba03699d6d35186415bafbc56341cd5 Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Thu, 11 Aug 2022 08:13:09 +0300 Subject: [PATCH 31/42] upd: renamed crontab to cron and add step tests --- rocketry/test/time/test_cron.py | 82 ++++++++++++++++++--------- rocketry/time/__init__.py | 2 +- rocketry/time/{crontab.py => cron.py} | 23 ++++++-- 3 files changed, 75 insertions(+), 32 deletions(-) rename rocketry/time/{crontab.py => cron.py} (80%) diff --git a/rocketry/test/time/test_cron.py b/rocketry/test/time/test_cron.py index b0546b3f..5c3a4cb7 100644 --- a/rocketry/test/time/test_cron.py +++ b/rocketry/test/time/test_cron.py @@ -1,6 +1,6 @@ import datetime import pytest -from rocketry.time import Crontab, always +from rocketry.time import Cron, always from rocketry.time.interval import TimeOfDay, TimeOfHour, TimeOfMinute, TimeOfMonth, TimeOfWeek, TimeOfYear every_minute = TimeOfMinute() @@ -9,43 +9,73 @@ "period,expected", [ # Test at - pytest.param(Crontab(), every_minute, id="* * * *"), - pytest.param(Crontab("30", "*", "*", "*", "*"), every_minute & TimeOfHour.at(30), id="30 * * * *"), - pytest.param(Crontab("*", "12", "*", "*", "*"), every_minute & TimeOfDay.at(12), id="* 12 * * *"), - pytest.param(Crontab("*", "*", "28", "*", "*"), every_minute & TimeOfMonth.at(28), id="* * 28 * *"), - pytest.param(Crontab("*", "*", "*", "6", "*"), every_minute & TimeOfYear.at(6), id="* * * 6 *"), - pytest.param(Crontab("*", "*", "*", "*", "0"), every_minute & TimeOfWeek.at("Sunday"), id="* * * * 0"), + pytest.param(Cron(), every_minute, id="* * * * *"), + pytest.param(Cron("30", "*", "*", "*", "*"), every_minute & TimeOfHour.at(30), id="30 * * * *"), + pytest.param(Cron("*", "12", "*", "*", "*"), every_minute & TimeOfDay.at(12), id="* 12 * * *"), + pytest.param(Cron("*", "*", "28", "*", "*"), every_minute & TimeOfMonth.at(28), id="* * 28 * *"), + pytest.param(Cron("*", "*", "*", "6", "*"), every_minute & TimeOfYear.at(6), id="* * * 6 *"), + pytest.param(Cron("*", "*", "*", "*", "0"), every_minute & TimeOfWeek.at("Sunday"), id="* * * * 0"), # Test at synonyms - pytest.param(Crontab("*", "*", "*", "JUN", "*"), every_minute & TimeOfYear.at("June"), id="* * * JUN *"), - pytest.param(Crontab("*", "*", "*", "*", "SUN"), every_minute & TimeOfWeek.at("Sunday"), id="* * * * SUN"), + pytest.param(Cron("*", "*", "*", "JUN", "*"), every_minute & TimeOfYear.at("June"), id="* * * JUN *"), + pytest.param(Cron("*", "*", "*", "*", "SUN"), every_minute & TimeOfWeek.at("Sunday"), id="* * * * SUN"), # Test ranges - pytest.param(Crontab("45-59", "*", "*", "*", "*"), every_minute & TimeOfHour(45, 59), id="45-59 * * * *"), - pytest.param(Crontab("*", "10-13", "*", "*", "*"), every_minute & TimeOfDay(10, 13), id="* 10-13 * * *"), - pytest.param(Crontab("*", "*", "28-30", "*", "*"), every_minute & TimeOfMonth(28, 30), id="* * 28-30 * *"), - pytest.param(Crontab("*", "*", "*", "FEB-MAR", "*"), every_minute & TimeOfYear("feb", "mar"), id="* * * FEB-MAR *"), - pytest.param(Crontab("*", "*", "*", "*", "FRI-SUN"), every_minute & TimeOfWeek("fri", "sun"), id="* * * * FRI-SUN"), + pytest.param(Cron("45-59", "*", "*", "*", "*"), every_minute & TimeOfHour(45, 59), id="45-59 * * * *"), + pytest.param(Cron("*", "10-13", "*", "*", "*"), every_minute & TimeOfDay(10, 13), id="* 10-13 * * *"), + pytest.param(Cron("*", "*", "28-30", "*", "*"), every_minute & TimeOfMonth(28, 30), id="* * 28-30 * *"), + pytest.param(Cron("*", "*", "*", "FEB-MAR", "*"), every_minute & TimeOfYear("feb", "mar"), id="* * * FEB-MAR *"), + pytest.param(Cron("*", "*", "*", "*", "FRI-SUN"), every_minute & TimeOfWeek("fri", "sun"), id="* * * * FRI-SUN"), # Test list - pytest.param(Crontab("0,15,30,45", "*", "*", "*", "*"), every_minute & (TimeOfHour.at(0) | TimeOfHour.at(15) | TimeOfHour.at(30) | TimeOfHour.at(45)), id="0,15,30,45 * * * *"), + pytest.param(Cron("0,15,30,45", "*", "*", "*", "*"), every_minute & (TimeOfHour.at(0) | TimeOfHour.at(15) | TimeOfHour.at(30) | TimeOfHour.at(45)), id="0,15,30,45 * * * *"), # Test combinations pytest.param( - Crontab("45-59", "10-13", "28-30", "FEB-MAR", "FRI-SUN"), + Cron("45-59", "10-13", "28-30", "FEB-MAR", "FRI-SUN"), every_minute & TimeOfHour(45, 59) & TimeOfDay(10, 13) & TimeOfYear("feb", "mar") & (TimeOfMonth(28, 30) | TimeOfWeek("fri", "sun")), id="45-59 10-13 28-30 FEB-MAR FRI-SUN" ), pytest.param( - Crontab("45-59", "10-13", "28-30", "FEB-MAR", "*"), + Cron("45-59", "10-13", "28-30", "FEB-MAR", "*"), every_minute & TimeOfHour(45, 59) & TimeOfDay(10, 13) & TimeOfYear("feb", "mar") & TimeOfMonth(28, 30), id="45-59 10-13 28-30 FEB-MAR *" ), pytest.param( - Crontab("0-29,45-59", "0-10,20-23", "*", "*", "*"), + Cron("0-29,45-59", "0-10,20-23", "*", "*", "*"), (TimeOfHour(0, 29) | TimeOfHour(45, 59)) & (TimeOfDay(0, 10) | TimeOfDay(20, 23)) & every_minute, id="0-29,45-59 0-10,20-23 * * *" ), + + # Test skip + pytest.param( + Cron("*/15", "*", "*", "*", "*"), + every_minute & ( + TimeOfHour.at("00:00") | TimeOfHour.at("15:00") | TimeOfHour.at("30:00") | TimeOfHour.at("45:00") + ), + id="*/15 * * * *" + ), + pytest.param( + Cron("*", "*/6", "*", "*", "*"), + every_minute & ( + TimeOfDay.at("00:00") | TimeOfDay.at("06:00") | TimeOfDay.at("12:00") | TimeOfDay.at("18:00") + ), + id="* */6 * * *" + ), + pytest.param( + Cron("*", "2-17/6", "*", "*", "*"), + every_minute & ( + TimeOfDay.at("02:00") | TimeOfDay.at("08:00") | TimeOfDay.at("14:00") + ), + id="* 2-17/6 * * *" + ), + pytest.param( + Cron("*", "*", "*", "*", "Tue-Fri/2"), + every_minute & ( + TimeOfWeek.at("Tue") | TimeOfWeek.at("Thu") + ), + id="* * * * Tue-Fri/2" + ), ] ) def test_subperiod(period, expected): @@ -53,14 +83,14 @@ def test_subperiod(period, expected): assert subperiod == expected def test_in(): - period = Crontab("30", "*", "*", "*", "*") + period = Cron("30", "*", "*", "*", "*") assert datetime.datetime(2022, 8, 7, 12, 29, 59) not in period assert datetime.datetime(2022, 8, 7, 12, 30, 00) in period assert datetime.datetime(2022, 8, 7, 12, 30, 59) in period assert datetime.datetime(2022, 8, 7, 12, 31, 00) not in period def test_roll_forward_simple(): - period = Crontab("30", "*", "*", "*", "*") + period = Cron("30", "*", "*", "*", "*") # Roll tiny amount interv = period.rollforward(datetime.datetime(2022, 8, 7, 12, 29, 59)) @@ -92,7 +122,7 @@ def test_roll_forward_simple(): assert interv.closed == 'left' def test_roll_back_simple(): - period = Crontab("30", "*", "*", "*", "*") + period = Cron("30", "*", "*", "*", "*") # Roll tiny amount interv = period.rollback(datetime.datetime(2022, 8, 7, 12, 31, 1)) @@ -123,7 +153,7 @@ def test_roll_back_simple(): assert interv.right == datetime.datetime(2022, 8, 7, 13, 31) def test_roll_minute_range(): - period = Crontab("30-45", "*", "*", "*", "*") + period = Cron("30-45", "*", "*", "*", "*") interv = period.rollforward(datetime.datetime.fromisoformat("2022-08-07 12:33:00")) assert interv.left == datetime.datetime.fromisoformat("2022-08-07 12:33:00") @@ -134,7 +164,7 @@ def test_roll_minute_range(): assert interv.right == datetime.datetime.fromisoformat("2022-08-07 12:32:59") def test_roll_complex(): - period = Crontab(*"15,30 18-22 20 OCT *".split(" ")) + period = Cron(*"15,30 18-22 20 OCT *".split(" ")) interv = period.rollforward(datetime.datetime(2022, 8, 7, 10, 0, 0)) assert interv.left == datetime.datetime.fromisoformat("2022-10-20 18:15:00") @@ -147,7 +177,7 @@ def test_roll_complex(): def test_roll_conflict_day_of_week_first(): # If day_of_month and day_of_week are passed # Crontab seems to prefer the one that is sooner (OR) - period = Crontab(*"15 18-22 20 OCT MON".split(" ")) + period = Cron(*"15 18-22 20 OCT MON".split(" ")) # Prefer day of week interv = period.rollforward(datetime.datetime(2022, 8, 7, 10, 0, 0)) @@ -162,13 +192,13 @@ def test_roll_conflict_day_of_week_first(): def test_roll_conflict_day_of_month_first(): # If day_of_month and day_of_week are passed # Crontab seems to prefer the one that is sooner (OR) - period = Crontab(*"15 18-22 3 OCT FRI".split(" ")) + period = Cron(*"15 18-22 3 OCT FRI".split(" ")) interv = period.rollforward(datetime.datetime(2022, 8, 7, 10, 0, 0)) assert interv.left == datetime.datetime.fromisoformat("2022-10-03 18:15:00") assert interv.right == datetime.datetime.fromisoformat("2022-10-03 18:16:00") - period = Crontab(*"15 18-22 29 OCT FRI".split(" ")) + period = Cron(*"15 18-22 29 OCT FRI".split(" ")) interv = period.rollback(datetime.datetime(2022, 12, 7, 10, 0, 0)) assert interv.left == datetime.datetime.fromisoformat("2022-10-29 22:15:00") assert interv.right == datetime.datetime.fromisoformat("2022-10-29 22:16:00") \ No newline at end of file diff --git a/rocketry/time/__init__.py b/rocketry/time/__init__.py index 0dac624d..532f3d2d 100644 --- a/rocketry/time/__init__.py +++ b/rocketry/time/__init__.py @@ -3,7 +3,7 @@ from .construct import get_between, get_before, get_after, get_full_cycle, get_on from .delta import TimeSpanDelta -from .crontab import Crontab +from .cron import Cron from rocketry.core.time import always diff --git a/rocketry/time/crontab.py b/rocketry/time/cron.py similarity index 80% rename from rocketry/time/crontab.py rename to rocketry/time/cron.py index a0c894a9..b508172a 100644 --- a/rocketry/time/crontab.py +++ b/rocketry/time/cron.py @@ -1,12 +1,18 @@ -from functools import reduce from typing import Callable from dataclasses import dataclass +from datetime import timedelta from .interval import TimeOfHour, TimeOfDay, TimeOfMinute, TimeOfWeek, TimeOfMonth, TimeOfYear from rocketry.core.time.base import TimePeriod, always @dataclass(frozen=True) -class Crontab(TimePeriod): +class Cron(TimePeriod): + + minute: str = "*" + hour: str = "*" + day_of_month: str = "*" + month: str = "*" + day_of_week: str = "*" def __init__(self, minute="*", hour="*", day_of_month="*", month="*", day_of_week="*"): object.__setattr__(self, "minute", minute) @@ -38,7 +44,10 @@ def _get_period_from_expr(self, cls, expression:str, conv:Callable=None, default # Any continue if "/" in expr: - raise NotImplementedError("Crontab skip is not yet implemented.") + expr, step = expr.split("/") + step_period = cls.create_range(step=int(step)) + else: + step = None if "-" in expr: # From to @@ -47,11 +56,15 @@ def _get_period_from_expr(self, cls, expression:str, conv:Callable=None, default start = conv(int(start)) if end.isdigit(): end = conv(int(end)) - period = cls(start, end) + period = cls(start, end) if step is None else cls.create_range(start, end, step=int(step)) else: # At + step_period = cls.create_range(step=int(step)) if step is not None else always value = conv(int(expr)) if expr.isdigit() else expr - period = cls.at(value) + if value == "*": + period = always & step_period + else: + period = cls.at(value) & step_period if full_period is None: full_period = period From 6367d9ebd26cec2fcfcf3889888882aa8a310cae Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Thu, 11 Aug 2022 08:34:09 +0300 Subject: [PATCH 32/42] add: TaskRunnable (similar to TaskExecutable) This condition is used similarly as TaskExecutable but instead of relying on when the task finished, this relies on when the task started. --- rocketry/conditions/task/task.py | 31 +++++ .../test/condition/task/test_time_runnable.py | 120 ++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 rocketry/test/condition/task/test_time_runnable.py diff --git a/rocketry/conditions/task/task.py b/rocketry/conditions/task/task.py index 9d177764..46884279 100644 --- a/rocketry/conditions/task/task.py +++ b/rocketry/conditions/task/task.py @@ -257,6 +257,7 @@ def get_state(self, task=Task(), session=Session()): isin_period = ( # TimeDelta has no __contains__. One cannot say whether now is "past 2 hours". # And please tell why this does not raise an exception then? - Future me + # Because the period is used in the sub statements and TimeDelta is still accepted - Senior me True if isinstance(period, TimeDelta) else IsPeriod(period=period).observe() @@ -292,6 +293,36 @@ def _from_period(cls, span_type=None, **kwargs): return cls(period=period) +class TaskRunnable(BaseCondition): + """Condition for checking whether a given + task has not run (for given period). + Useful to set the given task to run once + in given period. + """ + + def __init__(self, task=None, period=None): + self.period = period + self.task = task + super().__init__() + + def get_state(self, task=Task(), session=Session()): + task = self.task if self.task is not None else task + period = self.period + + has_not_run = TaskStarted(period=period, task=task) == 0 + + isin_period = ( + True + if isinstance(period, TimeDelta) + else IsPeriod(period=period).observe() + ) + + return ( + isin_period + and has_not_run.observe(task=task, session=session) + ) + + class DependFinish(DependMixin): """Condition for checking whether a given task has not finished after running a dependent diff --git a/rocketry/test/condition/task/test_time_runnable.py b/rocketry/test/condition/task/test_time_runnable.py new file mode 100644 index 00000000..48e14244 --- /dev/null +++ b/rocketry/test/condition/task/test_time_runnable.py @@ -0,0 +1,120 @@ + +import datetime +import logging + +import pytest +from dateutil.tz import tzlocal + +from rocketry.conditions import ( + TaskRunnable, +) +from rocketry.pybox.time.convert import to_datetime +from rocketry.time import ( + TimeDelta, + TimeOfDay +) +from rocketry.tasks import FuncTask + +@pytest.mark.parametrize("from_logs", [pytest.param(True, id="from logs"), pytest.param(False, id="optimized")]) +@pytest.mark.parametrize( + "get_condition,logs,time_after,outcome", + [ + pytest.param( + lambda:TaskRunnable(task="the task", period=TimeOfDay("07:00", "08:00")), + [ + ("2020-01-01 07:10", "run"), + ("2020-01-01 07:20", "success"), + ], + "2020-01-01 07:30", + False, + id="Don't run (already ran)"), + + pytest.param( + lambda:TaskRunnable(task="the task", period=TimeOfDay("07:00", "08:00")), + [ + ("2020-01-01 07:10", "run"), + ("2020-01-01 07:20", "success"), + ], + "2020-01-01 08:30", + False, + id="Don't run (already ran, out of the period)"), + + pytest.param( + lambda:TaskRunnable(task="the task", period=TimeOfDay("07:00", "08:00")), + [], + "2020-01-01 08:30", + False, + id="Don't run (out of the period)"), + + pytest.param( + lambda:TaskRunnable(task="the task", period=TimeOfDay("07:00", "08:00")), + [], + "2020-01-02 07:30", + True, + id="Do run (has not run)"), + + pytest.param( + lambda:TaskRunnable(task="the task", period=TimeOfDay("07:00", "08:00")), + [ + ("2020-01-01 07:10", "run"), + ("2020-01-01 07:20", "inaction"), + ], + "2020-01-02 07:30", + True, + id="Do run (ran yesterday)"), + + pytest.param( + lambda:TaskRunnable(task="the task", period=TimeOfDay("07:00", "08:00")), + [ + ("2020-01-01 06:50", "run"), + ("2020-01-01 07:20", "success"), + ], + "2020-01-01 07:30", + True, + id="Do run (ran outside the period)"), + + ], +) +def test_runnable(tmpdir, mock_datetime_now, logs, time_after, get_condition, outcome, session, from_logs): + session.config.force_status_from_logs = from_logs + def to_epoch(dt): + # Hack as time.tzlocal() does not work for 1970-01-01 + if dt.tz: + dt = dt.tz_convert("utc").tz_localize(None) + return (dt - datetime.datetime(1970, 1, 1)) // datetime.timedelta(seconds=1) + + with tmpdir.as_cwd() as old_dir: + + + task = FuncTask( + lambda:None, + name="the task", + execution="main" + ) + + condition = get_condition() + + for log in logs: + log_time, log_action = log[0], log[1] + log_created = to_datetime(log_time).timestamp() + record = logging.LogRecord( + # The content here should not matter for task status + name='rocketry.core.task', level=logging.INFO, lineno=1, + pathname='d:\\Projects\\rocketry\\rocketry\\core\\task\\base.py', + msg="Logging of 'task'", args=(), exc_info=None, + ) + + record.created = log_created + record.action = log_action + record.task_name = "the task" + + task.logger.handle(record) + setattr(task, f'last_{log_action}', to_datetime(log_time)) + mock_datetime_now(time_after) + + if outcome: + assert condition.observe(session=session) + assert condition.observe(task=task) + else: + assert not condition.observe(session=session) + assert not condition.observe(task=task) \ No newline at end of file From 9341e7207a3aee44bf87a9007bfb5b8caa506dcd Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Thu, 11 Aug 2022 08:38:02 +0300 Subject: [PATCH 33/42] add: cron to cond API and cond syntax --- rocketry/conditions/api.py | 14 ++++++++++++-- rocketry/conds/__init__.py | 3 ++- rocketry/parse/_setup_cond_parsers.py | 6 +++++- rocketry/test/condition/test_api.py | 13 ++++++++++--- rocketry/test/condition/test_parse.py | 8 +++++++- 5 files changed, 36 insertions(+), 8 deletions(-) diff --git a/rocketry/conditions/api.py b/rocketry/conditions/api.py index d4730ebd..bacbceac 100644 --- a/rocketry/conditions/api.py +++ b/rocketry/conditions/api.py @@ -1,6 +1,6 @@ from typing import Callable, Union from rocketry.conditions.scheduler import SchedulerStarted -from rocketry.conditions.task.task import DependFailure, DependFinish, DependSuccess, TaskFailed, TaskFinished, TaskStarted, TaskSucceeded +from rocketry.conditions.task.task import DependFailure, DependFinish, DependSuccess, TaskFailed, TaskFinished, TaskRunnable, TaskStarted, TaskSucceeded from rocketry.core import ( BaseCondition ) @@ -9,6 +9,7 @@ ) from rocketry.core.condition.base import All, Any, Not from rocketry.core.task import Task +from rocketry.time import Cron from .time import IsPeriod from .task import TaskExecutable, TaskRunning from rocketry.time import ( @@ -137,6 +138,15 @@ def every(past:str, based="run"): else: raise ValueError(f"Invalid status: {based}") +def cron(__expr=None, **kwargs): + if __expr: + args = __expr.split(" ") + else: + args = () + + period = Cron(*args, **kwargs) + return TaskRunnable(period=period) + # Task pipelining # --------------- @@ -188,4 +198,4 @@ def running(more_than:str=None, less_than=None, task=None): # --------- def scheduler_running(more_than:str=None, less_than=None): - return SchedulerStarted(period=TimeSpanDelta(near=more_than, far=less_than)) \ No newline at end of file + return SchedulerStarted(period=TimeSpanDelta(near=more_than, far=less_than)) diff --git a/rocketry/conds/__init__.py b/rocketry/conds/__init__.py index 2b43d7b6..f64cda11 100644 --- a/rocketry/conds/__init__.py +++ b/rocketry/conds/__init__.py @@ -13,5 +13,6 @@ started, succeeded, failed, finished, scheduler_running, - running + running, + cron ) \ No newline at end of file diff --git a/rocketry/parse/_setup_cond_parsers.py b/rocketry/parse/_setup_cond_parsers.py index 1cbf3985..cf7ffaaf 100644 --- a/rocketry/parse/_setup_cond_parsers.py +++ b/rocketry/parse/_setup_cond_parsers.py @@ -13,7 +13,8 @@ from rocketry.core.condition import AlwaysFalse, AlwaysTrue, All, Any, Not, BaseCondition from rocketry.conds import ( - minutely, hourly, daily, weekly, monthly, every + minutely, hourly, daily, weekly, monthly, every, + cron ) def _from_period_task_has(cls, span_type=None, inverse=False, **kwargs): @@ -135,6 +136,9 @@ def _set_task_exec_parsing(): # Add "every ..." cond_parsers[re.compile(r"every (?P.+)")] = every + # Cron + cond_parsers[re.compile(r"cron (?P<__expr>.+)")] = cron + def _set_task_running_parsing(): cond_parsers = Session._cls_cond_parsers diff --git a/rocketry/test/condition/test_api.py b/rocketry/test/condition/test_api.py index 0696398e..a2f45cbc 100644 --- a/rocketry/test/condition/test_api.py +++ b/rocketry/test/condition/test_api.py @@ -14,13 +14,16 @@ scheduler_running, succeeded, failed, finished, started, - running + running, + + cron ) -from rocketry.conditions import TaskExecutable, IsPeriod, DependSuccess, DependFailure, DependFinish +from rocketry.conditions import TaskExecutable, IsPeriod, DependSuccess, DependFailure, DependFinish, TaskRunnable from rocketry.core.condition import AlwaysFalse, AlwaysTrue, Any, All from rocketry.core.condition.base import Not from rocketry.time import TimeDelta +from rocketry.time import Cron from rocketry.time.delta import TimeSpanDelta from rocketry.time.interval import TimeOfDay, TimeOfHour, TimeOfMinute, TimeOfMonth, TimeOfWeek @@ -109,9 +112,13 @@ pytest.param(scheduler_running("10 mins"), SchedulerStarted(period=TimeSpanDelta("10 mins")), id="scheduler running (at least)"), ] +cron_like = [ + pytest.param(cron("1 2 3 4 5"), TaskRunnable(period=Cron('1', '2', '3', '4', '5')), id="cron 1 2 3 4 5"), +] + @pytest.mark.parametrize( "cond,result", - params_basic + params_time + params_task_exec + params_pipeline + params_action + params_running+ params_schedule + params_basic + params_time + params_task_exec + params_pipeline + params_action + params_running+ params_schedule + cron_like ) def test_api(cond, result): assert cond == result diff --git a/rocketry/test/condition/test_parse.py b/rocketry/test/condition/test_parse.py index 84687b42..11b37faa 100644 --- a/rocketry/test/condition/test_parse.py +++ b/rocketry/test/condition/test_parse.py @@ -2,6 +2,7 @@ import itertools import pytest +from rocketry.conditions.task.task import TaskRunnable from rocketry.parse.utils import ParserError from rocketry.conditions.scheduler import SchedulerCycles, SchedulerStarted @@ -35,6 +36,7 @@ from rocketry.conds import ( minutely, hourly, daily, weekly, monthly ) +from rocketry.time.cron import Cron cases_time = [ pytest.param("minutely", minutely, id="hourly"), @@ -171,9 +173,13 @@ pytest.param("param 'x' is 'myval'", ParamExists(x='myval'), id="ParamExists 'x=5'"), ] +cron = [ + pytest.param("cron * * * * *", TaskRunnable(period=Cron("*", "*", "*", "*", "*")), id="cron * * * * *"), + pytest.param("cron 1 2 3 4 5", TaskRunnable(period=Cron("1", "2", "3", "4", "5")), id="cron 1 2 3 4 5"), +] # All cases -cases = cases_logical + cases_task + cases_time + cases_misc + cases_scheduler +cases = cases_logical + cases_task + cases_time + cases_misc + cases_scheduler + cron @pytest.mark.parametrize( "cond_str,expected", cases From 1f7c5ad6c51f7288a54e2a6da729fb1531121578 Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Thu, 11 Aug 2022 08:38:37 +0300 Subject: [PATCH 34/42] test: add sanity checks for continuous periods --- .../condition/task/test_time_executable.py | 74 ++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/rocketry/test/condition/task/test_time_executable.py b/rocketry/test/condition/task/test_time_executable.py index 215262e5..9312aaf9 100644 --- a/rocketry/test/condition/task/test_time_executable.py +++ b/rocketry/test/condition/task/test_time_executable.py @@ -14,6 +14,7 @@ TimeOfDay ) from rocketry.tasks import FuncTask +from rocketry.time.interval import TimeOfMinute @pytest.mark.parametrize("from_logs", [pytest.param(True, id="from logs"), pytest.param(False, id="optimized")]) @pytest.mark.parametrize( @@ -216,4 +217,75 @@ def to_epoch(dt): assert condition.observe(task=task) else: assert not condition.observe(session=session) - assert not condition.observe(task=task) \ No newline at end of file + assert not condition.observe(task=task) + + +@pytest.mark.parametrize("from_logs", [pytest.param(True, id="from logs"), pytest.param(False, id="optimized")]) +@pytest.mark.parametrize( + "get_condition,logs,time_after,outcome", + [ + pytest.param( + lambda:TaskExecutable(task="the task", period=TimeOfDay()), + [ + ("2020-01-01 07:10", "run"), + ("2020-01-01 07:20", "success"), + ], + "2020-01-02 00:01", + True, + id="Do run (continuous cycle, daily)"), + pytest.param( + lambda:TaskExecutable(task="the task", period=TimeOfDay() & TimeOfMinute()), + [ + ("2020-01-01 23:59", "run"), + ("2020-01-01 23:59", "success"), + ], + "2020-01-02 00:01", + True, + id="Do run (continuous cycle, daily & minutely)"), + pytest.param( + lambda:TaskExecutable(task="the task", period=TimeOfDay() | TimeOfMinute()), + [ + ("2020-01-01 23:59", "run"), + ("2020-01-01 23:59", "success"), + ], + "2020-01-02 00:01", + True, + id="Do run (continuous cycle, daily | minutely)"), + ] +) +def test_periods(mock_datetime_now, logs, time_after, get_condition, outcome, session, from_logs): + "Sanity check that periods work correctly" + session.config.force_status_from_logs = from_logs + + task = FuncTask( + lambda:None, + name="the task", + execution="main" + ) + + condition = get_condition() + + for log in logs: + log_time, log_action = log[0], log[1] + log_created = to_datetime(log_time).timestamp() + record = logging.LogRecord( + # The content here should not matter for task status + name='rocketry.core.task', level=logging.INFO, lineno=1, + pathname='d:\\Projects\\rocketry\\rocketry\\core\\task\\base.py', + msg="Logging of 'task'", args=(), exc_info=None, + ) + + record.created = log_created + record.action = log_action + record.task_name = "the task" + + task.logger.handle(record) + setattr(task, f'last_{log_action}', to_datetime(log_time)) + mock_datetime_now(time_after) + + if outcome: + assert condition.observe(session=session) + assert condition.observe(task=task) + else: + assert not condition.observe(session=session) + assert not condition.observe(task=task) \ No newline at end of file From c7441231e7ec7bdd3aa7693e94ec3598c3d7bf86 Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Thu, 11 Aug 2022 08:49:28 +0300 Subject: [PATCH 35/42] fix: Literal import for older Python --- rocketry/pybox/time/interval.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rocketry/pybox/time/interval.py b/rocketry/pybox/time/interval.py index e48283a1..14be2cd3 100644 --- a/rocketry/pybox/time/interval.py +++ b/rocketry/pybox/time/interval.py @@ -1,7 +1,11 @@ from dataclasses import dataclass, field -from typing import Any, Literal +from typing import Any +try: + from typing import Literal +except ImportError: # pragma: no cover + from typing_extensions import Literal @dataclass(frozen=True) class Interval: From 5c6480e3caab1eab37fcceb94879715001088c43 Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Thu, 11 Aug 2022 21:39:30 +0300 Subject: [PATCH 36/42] fix: right of the interval is now always open --- rocketry/core/time/anchor.py | 8 +++-- .../time/interval/timeofday/test_contains.py | 30 +++++++++++-------- .../test/time/interval/timeofday/test_roll.py | 2 +- .../time/interval/timeofweek/test_contains.py | 18 ++++++----- 4 files changed, 33 insertions(+), 25 deletions(-) diff --git a/rocketry/core/time/anchor.py b/rocketry/core/time/anchor.py index ec1459ac..62432902 100644 --- a/rocketry/core/time/anchor.py +++ b/rocketry/core/time/anchor.py @@ -183,9 +183,11 @@ def __contains__(self, dt) -> bool: is_over_period = ms_start > ms_end # period is overnight, over weekend etc. if not is_over_period: - return ms_start <= ms <= ms_end + # Note that the period is right opened (end point excluded) + return ms_start <= ms < ms_end else: - return ms >= ms_start or ms <= ms_end + # Note that the period is right opened (end point excluded) + return ms >= ms_start or ms < ms_end def is_full(self): "Whether every time belongs to the period (but there is still distinct intervals)" @@ -260,7 +262,7 @@ def next_end(self, dt): ms_start = self._start ms_end = self._end - if ms <= ms_end: + if ms < ms_end: # in period # dt # --<---------->-----------<-------------->-- diff --git a/rocketry/test/time/interval/timeofday/test_contains.py b/rocketry/test/time/interval/timeofday/test_contains.py index 43e1e4e6..cf2c47ef 100644 --- a/rocketry/test/time/interval/timeofday/test_contains.py +++ b/rocketry/test/time/interval/timeofday/test_contains.py @@ -9,28 +9,22 @@ "dt,start,end", [ # Regular - pytest.param( - datetime(2020, 1, 1, 10, 00), - "10:00", "12:00", - id="Left of interval"), - pytest.param( - datetime(2020, 1, 1, 12, 00), - "10:00", "12:00", - id="Right of interval"), pytest.param( datetime(2020, 1, 1, 11, 00), "10:00", "12:00", id="Middle of interval"), - # Overnight + # Left is closed + pytest.param( + datetime(2020, 1, 1, 10, 00), + "10:00", "12:00", + id="Left of interval"), pytest.param( datetime(2020, 1, 1, 22, 00), "22:00", "02:00", id="Left of overnight interval"), - pytest.param( - datetime(2020, 1, 1, 2, 00), - "22:00", "02:00", - id="Right of overnight interval"), + + # Overnight pytest.param( datetime(2020, 1, 1, 23, 59, 59, 999999), "22:00", "02:00", @@ -77,6 +71,16 @@ def test_in(start, end, dt): "10:00", "12:00", id="Right from interval"), + # Right is opened + pytest.param( + datetime(2020, 1, 1, 12, 00), + "10:00", "12:00", + id="Right of interval"), + pytest.param( + datetime(2020, 1, 1, 2, 00), + "22:00", "02:00", + id="Right of overnight interval"), + # Overnight pytest.param( datetime(2020, 1, 1, 21, 59, 59, 999999), diff --git a/rocketry/test/time/interval/timeofday/test_roll.py b/rocketry/test/time/interval/timeofday/test_roll.py index 00787cbb..1c0ec08a 100644 --- a/rocketry/test/time/interval/timeofday/test_roll.py +++ b/rocketry/test/time/interval/timeofday/test_roll.py @@ -140,7 +140,7 @@ def test_rollback(start, end, dt, roll_start, roll_end): time = TimeOfDay(start, end) interval = time.rollback(dt) - assert interval.closed == 'left' + assert interval.closed == 'left' if roll_start != roll_end else interval.closed == "both" assert roll_start == interval.left assert roll_end == interval.right diff --git a/rocketry/test/time/interval/timeofweek/test_contains.py b/rocketry/test/time/interval/timeofweek/test_contains.py index c5797007..31ea6c87 100644 --- a/rocketry/test/time/interval/timeofweek/test_contains.py +++ b/rocketry/test/time/interval/timeofweek/test_contains.py @@ -16,10 +16,6 @@ datetime(2024, 1, 2, 10, 00), "Tue 10:00", "Sat 12:00", id="Left of interval"), - pytest.param( - datetime(2024, 1, 6, 12, 00), - "Tue 10:00", "Sat 12:00", - id="Right of interval"), pytest.param( datetime(2024, 1, 4, 11, 00), "Tue 10:00", "Sat 12:00", @@ -30,10 +26,6 @@ datetime(2024, 1, 6, 10, 00), "Sat 10:00", "Tue 12:00", id="Left of over weekend interval"), - pytest.param( - datetime(2024, 1, 9, 12, 00), - "Sat 10:00", "Tue 12:00", - id="Right of over weekend interval"), pytest.param( datetime(2024, 1, 7, 23, 59, 59, 999999), "Sat 10:00", "Tue 12:00", @@ -80,6 +72,16 @@ def test_in(start, end, dt): "Tue 10:00", "Sat 12:00", id="Right from interval"), + # Right is opened + pytest.param( + datetime(2024, 1, 6, 12, 00), + "Tue 10:00", "Sat 12:00", + id="Right of interval"), + pytest.param( + datetime(2024, 1, 9, 12, 00), + "Sat 10:00", "Tue 12:00", + id="Right of over weekend interval"), + # Over weekend pytest.param( datetime(2024, 1, 6, 9, 59, 59, 999999), From 8d120ca8d60fa50d1ce70fac01b607cc7dc32647 Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Thu, 11 Aug 2022 21:42:18 +0300 Subject: [PATCH 37/42] fix: now cron supports non-standard names in steps Also fixed that the step end is +1. --- rocketry/core/time/anchor.py | 10 ++++++++-- rocketry/test/time/test_cron.py | 9 ++++++++- rocketry/time/cron.py | 13 ++++++++++++- rocketry/time/interval.py | 8 ++++---- 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/rocketry/core/time/anchor.py b/rocketry/core/time/anchor.py index 62432902..cda10dd8 100644 --- a/rocketry/core/time/anchor.py +++ b/rocketry/core/time/anchor.py @@ -1,7 +1,7 @@ from datetime import datetime -from typing import ClassVar, List, Tuple +from typing import ClassVar, Dict, List, Tuple, Union from abc import abstractmethod from dataclasses import dataclass, field @@ -52,6 +52,7 @@ class AnchoredInterval(TimeInterval): _unit_resolution: ClassVar[int] = None # Microseconds of one unit (if start/end is int) _unit_names: ClassVar[List] = None + _unit_mapping: ClassVar[Dict[str, int]] = {} def __init__(self, start=None, end=None, time_point=None, right_closed=False): @@ -102,7 +103,12 @@ def anchor_dt(self, dt: datetime, **kwargs) -> int: return to_microseconds(**d) @classmethod - def create_range(cls, start=None, end=None, step:int=None): + def create_range(cls, start:Union[str, int]=None, end:Union[str, int]=None, step:int=None): + if isinstance(start, str): + start = cls._unit_mapping[start.lower()] + if isinstance(end, str): + end = cls._unit_mapping[end.lower()] + periods = tuple( cls.at(step) for step in cls._unit_names[start:end:step] diff --git a/rocketry/test/time/test_cron.py b/rocketry/test/time/test_cron.py index 5c3a4cb7..ccf769d5 100644 --- a/rocketry/test/time/test_cron.py +++ b/rocketry/test/time/test_cron.py @@ -69,13 +69,20 @@ ), id="* 2-17/6 * * *" ), - pytest.param( + pytest.param( Cron("*", "*", "*", "*", "Tue-Fri/2"), every_minute & ( TimeOfWeek.at("Tue") | TimeOfWeek.at("Thu") ), id="* * * * Tue-Fri/2" ), + pytest.param( + Cron("*", "*", "*", "Feb-Aug/3", "*"), + every_minute & ( + TimeOfYear.at("Feb") | TimeOfYear.at("May") | TimeOfYear.at("Aug") + ), + id="* * * Feb-Aug/3 *" + ), ] ) def test_subperiod(period, expected): diff --git a/rocketry/time/cron.py b/rocketry/time/cron.py index b508172a..059be92b 100644 --- a/rocketry/time/cron.py +++ b/rocketry/time/cron.py @@ -56,7 +56,18 @@ def _get_period_from_expr(self, cls, expression:str, conv:Callable=None, default start = conv(int(start)) if end.isdigit(): end = conv(int(end)) - period = cls(start, end) if step is None else cls.create_range(start, end, step=int(step)) + + if step is not None: + # Unlike in traditional Python ranges, + # cron includes also the endpoint thus + # we convert to int (if needed) and add + # one + if isinstance(end, str): + end = cls._unit_mapping[end.lower()] + end += 1 + period = cls.create_range(start, end, step=int(step)) + else: + period = cls(start, end) else: # At step_period = cls.create_range(step=int(step)) if step is not None else always diff --git a/rocketry/time/interval.py b/rocketry/time/interval.py index 1b4beba9..cee6fdc8 100644 --- a/rocketry/time/interval.py +++ b/rocketry/time/interval.py @@ -135,7 +135,7 @@ class TimeOfWeek(AnchoredInterval): _unit_resolution: ClassVar[int] = to_microseconds(day=1) _unit_names: ClassVar[List[str]] = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] - weeknum_mapping = { + _unit_mapping = { **dict(zip(range(1, 8), range(7))), # English @@ -164,7 +164,7 @@ def anchor_str(self, s, side=None, **kwargs): comps = res.groupdict() dayofweek = comps.pop("dayofweek") time = comps.pop("time") - nth_day = self.weeknum_mapping[dayofweek.lower()] + nth_day = self._unit_mapping[dayofweek.lower()] # TODO: TimeOfDay.anchor_str as function if not time: @@ -285,7 +285,7 @@ class TimeOfYear(AnchoredInterval): _scope_max: ClassVar[int] = to_microseconds(day=1) * 366 _unit_names: ClassVar[List[str]] = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] - monthnum_mapping: ClassVar = { + _unit_mapping: ClassVar = { **dict(zip(range(12), range(12))), # English @@ -321,7 +321,7 @@ def anchor_str(self, s, side=None, **kwargs): comps = res.groupdict() monthofyear = comps.pop("monthofyear") # This is jan, january day_of_month_str = comps.pop("day_of_month") - nth_month = self.monthnum_mapping[monthofyear.lower()] + nth_month = self._unit_mapping[monthofyear.lower()] ceil_time = not day_of_month_str and side == "end" if ceil_time: From 04c350144955a56fdbc5aa69dc7aa773fb2273cd Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Thu, 11 Aug 2022 21:42:47 +0300 Subject: [PATCH 38/42] test: fix parse test --- rocketry/test/time/test_parse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rocketry/test/time/test_parse.py b/rocketry/test/time/test_parse.py index 88373b2d..edd20034 100644 --- a/rocketry/test/time/test_parse.py +++ b/rocketry/test/time/test_parse.py @@ -20,7 +20,7 @@ pytest.param("time of day before 12:00", TimeOfDay(None, "12:00"), id="TimeOfDay before"), pytest.param("time of day after 12:00", TimeOfDay("12:00", None), id="TimeOfDay after"), - pytest.param("time of week on Monday", TimeOfWeek("Mon", "Mon"), id="TimeOfWeek on"), + pytest.param("time of week on Monday", TimeOfWeek.at("Mon"), id="TimeOfWeek on"), pytest.param("time of month between 1st and 2nd", TimeOfMonth("1st", "2nd"), id="TimeOfMonth between (1st, 2nd)"), pytest.param("time of month between 3rd and 4th", TimeOfMonth("3rd", "4th"), id="TimeOfMonth between (3rd, 4th)"), From ce387b111ef921a6dc7aee930425768d2a37e4a1 Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Thu, 11 Aug 2022 21:54:34 +0300 Subject: [PATCH 39/42] test: add interval tests --- rocketry/test/pybox/test_interval.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/rocketry/test/pybox/test_interval.py b/rocketry/test/pybox/test_interval.py index 6f645047..7961399c 100644 --- a/rocketry/test/pybox/test_interval.py +++ b/rocketry/test/pybox/test_interval.py @@ -57,4 +57,21 @@ def test_overlaps(l, r): ] ) def test_not_overlaps(l, r): - assert not l.overlaps(r) \ No newline at end of file + assert not l.overlaps(r) + +def test_fail(): + with pytest.raises(ValueError): + Interval(datetime(2022, 1, 1), datetime(2019, 1, 1)) + with pytest.raises(ValueError): + Interval(datetime(2022, 1, 1), datetime(2022, 1, 2), closed="typo") + +def test_empty(): + assert not Interval(datetime(2022, 1, 1), datetime(2022, 1, 1), closed="both").is_empty + for closed in ("left", "right", "both", "neither"): + assert not Interval(datetime(2022, 1, 1), datetime(2022, 1, 2), closed=closed).is_empty + for closed in ("left", "right", "neither"): + assert Interval(datetime(2022, 1, 1), datetime(2022, 1, 1), closed=closed).is_empty + +def test_repr(): + for closed in ("left", "right", "neither"): + assert repr(Interval(datetime(2022, 1, 1), datetime(2022, 1, 1), closed=closed)) \ No newline at end of file From ce8fc272c99aebb0600c5f19988c13643be6143a Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Thu, 11 Aug 2022 21:58:18 +0300 Subject: [PATCH 40/42] test: add kwargs cron test --- rocketry/test/condition/test_api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/rocketry/test/condition/test_api.py b/rocketry/test/condition/test_api.py index a2f45cbc..72a10537 100644 --- a/rocketry/test/condition/test_api.py +++ b/rocketry/test/condition/test_api.py @@ -114,6 +114,7 @@ cron_like = [ pytest.param(cron("1 2 3 4 5"), TaskRunnable(period=Cron('1', '2', '3', '4', '5')), id="cron 1 2 3 4 5"), + pytest.param(cron(minute="1", hour="2", day_of_month="3", month="4", day_of_week="5"), TaskRunnable(period=Cron('1', '2', '3', '4', '5')), id="cron 1 2 3 4 5 (kwargs)"), ] @pytest.mark.parametrize( From c8a70282ad16e4847be488fc8fe4ba2fec3fa1ff Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Thu, 11 Aug 2022 23:07:58 +0300 Subject: [PATCH 41/42] docs: add cron to documentation --- docs/code/conds/api/cron.py | 10 +++++++++ docs/code/conds/api/cron_kwargs.py | 16 ++++++++++++++ docs/handbooks/conditions/api/cron.rst | 28 +++++++++++++++++++++++++ docs/handbooks/conditions/api/index.rst | 1 + docs/index.rst | 2 +- 5 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 docs/code/conds/api/cron.py create mode 100644 docs/code/conds/api/cron_kwargs.py create mode 100644 docs/handbooks/conditions/api/cron.rst diff --git a/docs/code/conds/api/cron.py b/docs/code/conds/api/cron.py new file mode 100644 index 00000000..1bcb2cb4 --- /dev/null +++ b/docs/code/conds/api/cron.py @@ -0,0 +1,10 @@ +from rocketry.conds import cron + +@app.task(cron('* * * * *')) +def do_minutely(): + ... + +@app.task(cron('*/2 12-18 * Oct Fri')) +def do_complex(): + "Run at every 2nd minute past every hour from 12 through 18 on Friday in October." + ... \ No newline at end of file diff --git a/docs/code/conds/api/cron_kwargs.py b/docs/code/conds/api/cron_kwargs.py new file mode 100644 index 00000000..adc9ca41 --- /dev/null +++ b/docs/code/conds/api/cron_kwargs.py @@ -0,0 +1,16 @@ +from rocketry.conds import cron + +@app.task(cron(minute="*/5")) +def do_simple(): + "Run at every 5th minute" + ... + +@app.task(cron(minute="*/2", hour="7-18", day_of_month="1,2,3", month="Feb-Aug/2")) +def do_complex(): + """Run at: + - Every second minute + - Between 07:00 (7 a.m.) - 18:00 (6 p.m.) + - On 1st, 2nd and 3rd day of month + - From February to August every second month + """ + ... \ No newline at end of file diff --git a/docs/handbooks/conditions/api/cron.rst b/docs/handbooks/conditions/api/cron.rst new file mode 100644 index 00000000..6d7fc76d --- /dev/null +++ b/docs/handbooks/conditions/api/cron.rst @@ -0,0 +1,28 @@ + +.. _cron: + +Cron Scheduling +=============== + +Rocketry also natively supports `cron-like scheduling `_. + +You can input a cron statement as a string: + +Examples +-------- + +.. literalinclude:: /code/conds/api/cron.py + :language: py + +Or you can use named arguments: + +.. literalinclude:: /code/conds/api/cron_kwargs.py + :language: py + +See more from the official definition of cron. + +.. note:: + + Unlike most of the condition, the cron condition checks whether the task + has run on the period (as standard with cron schedulers) and not whether + the task has finished on the given period. \ No newline at end of file diff --git a/docs/handbooks/conditions/api/index.rst b/docs/handbooks/conditions/api/index.rst index 98cb6467..97756851 100644 --- a/docs/handbooks/conditions/api/index.rst +++ b/docs/handbooks/conditions/api/index.rst @@ -21,6 +21,7 @@ Here are some examples: logical periodical + cron pipeline task_status scheduler diff --git a/docs/index.rst b/docs/index.rst index 5876c7cd..c26f3e92 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -49,7 +49,7 @@ applications. It is simple, clean and extensive. **Core functionalities:** - Powerful scheduling syntax -- A lot of built-in scheduling options +- A lot of built-in scheduling options (including :ref:`cron `) - Task parallelization - Task parametrization - Task pipelining From 09e5bccb925e4ae4c35d5ca3c73db673a5322c2d Mon Sep 17 00:00:00 2001 From: Mikael Koli Date: Fri, 12 Aug 2022 23:25:34 +0300 Subject: [PATCH 42/42] docs: add PR changes to versions.rst --- docs/versions.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/versions.rst b/docs/versions.rst index 49388618..15143234 100644 --- a/docs/versions.rst +++ b/docs/versions.rst @@ -2,6 +2,15 @@ Version history =============== +- ``2.3.0`` + + - Add: Cron style scheduling + - Add: New condition, ``TaskRunnable`` + - Add: ``always`` time period + - Fix: Various bugs related to ``Any``, ``All`` and ``StaticInterval`` time periods + - Fix: Integers as start and end in time periods + - Upd: Now time periods are immutable + - ``2.2.0`` - Add: Async support