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 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 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/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/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/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/anchor.py b/rocketry/core/time/anchor.py index 3ee4d724..cda10dd8 100644 --- a/rocketry/core/time/anchor.py +++ b/rocketry/core/time/anchor.py @@ -1,24 +1,25 @@ from datetime import datetime -from typing import Union +from typing import ClassVar, Dict, List, 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 - +from .utils import to_microseconds, timedelta_to_str, to_dict, to_timedelta +from .base import Any, 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 @@ -26,31 +27,43 @@ 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: ----------------- _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") - _fixed_components = ("week", "day", "hour", "minute", "second", "microsecond", "nanosecond") + # Components that have always fixed length (exactly the same amount of time) + _fixed_components: ClassVar[Tuple[str]] = ("week", "day", "hour", "minute", "second", "microsecond") - _scope = None # ie. day, hour, second, microsecond - _scope_max = None + _scope: ClassVar[str] = None # Scope of the full period. Ie. day, hour, second, microsecond + _scope_max: ClassVar[int] = None # Max in microseconds of the - 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) + _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): + + if start is None and end is None: + if time_point: + raise ValueError("Full cycle cannot be point of time") + 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, right_closed=right_closed) def anchor(self, value, **kwargs): "Turn value to nanoseconds relative to scope of the class" @@ -66,13 +79,15 @@ 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):] 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)" @@ -85,33 +100,58 @@ def anchor_dt(self, dt: datetime, **kwargs) -> int: if key in components } - return to_nanoseconds(**d) + return to_microseconds(**d) + + @classmethod + 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] + ) + return Any(*periods) def set_start(self, val): if val is None: - ns = 0 + ms = 0 else: - ns = self.anchor(val, side="start") - self._start = ns - self._start_orig = val + ms = self.anchor(val, side="start") - def set_end(self, val, time_point=False): + object.__setattr__(self, "_start", ms) + object.__setattr__(self, "_start_orig", val) + + 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) - ns = self._start + self._unit_resolution - 1 + 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") + ms = self.anchor(val, side="end", time_point=time_point) + + 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 - has_time = (ns % to_nanoseconds(day=1)) != 0 + object.__setattr__(self, "_end", ms) + object.__setattr__(self, "_end_orig", val) - self._end = ns - self._end_orig = val + 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 ms + self._unit_resolution @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) @@ -121,7 +161,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) @@ -136,30 +176,36 @@ 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 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) 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 + # Note that the period is right opened (end point excluded) + return ms_start <= ms < ms_end else: - return ns >= ns_start or ns <= ns_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)" + 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" - 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" @@ -177,11 +223,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 # -->----------<----------->--------------<- @@ -196,7 +242,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 @@ -212,17 +258,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 # --<---------->-----------<-------------->-- @@ -237,7 +283,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 @@ -253,17 +299,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 - ns_end = self._end + 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 # -->----------<----------->--------------<- @@ -278,8 +323,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 @@ -295,16 +340,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_start = self._start - 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 # --<---------->-----------<-------------->-- @@ -319,8 +363,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 @@ -336,27 +380,41 @@ 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_ms(self, n:int): + "Microseconds 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_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) 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/core/time/base.py b/rocketry/core/time/base.py index cf12b596..d626919e 100644 --- a/rocketry/core/time/base.py +++ b/rocketry/core/time/base.py @@ -1,8 +1,10 @@ import datetime +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 @@ -11,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. @@ -33,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 @@ -47,14 +37,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): @@ -95,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" @@ -135,27 +142,59 @@ def prev_end(self, dt): def from_between(start, end) -> Interval: raise NotImplementedError("__between__ not implemented.") - def rollforward(self, dt): + def is_full(self): + "Whether every time belongs to the period (but there is still distinct intervals)" + return False + + def rollforward(self, dt) -> datetime.datetime: "Get next time interval of the period" - start = self.rollstart(dt) - end = self.next_end(dt) + closed = "left" + 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) + 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) - return Interval(start, end, closed="both") + 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) + closed = "left" + 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) + 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) - - return Interval(start, end, closed="both") + + return Interval(start, end, closed=closed) def __eq__(self, other): "Test whether self and other are essentially the same periods" @@ -165,7 +204,7 @@ def __eq__(self, other): else: return False - +@dataclass(frozen=True) class TimeDelta(TimePeriod): """Base for all time deltas @@ -175,11 +214,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 @@ -187,17 +226,21 @@ 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.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) @@ -247,56 +290,97 @@ 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 + + # 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): + + # 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 = all(a.overlaps(b) for a, b in itertools.combinations(intervals, 2)) + 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 = all(a.overlaps(b) for a, b in itertools.combinations(intervals, 2)) + 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 @@ -306,14 +390,38 @@ 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) + +@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 + + # 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 = [ @@ -322,42 +430,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 = [ @@ -365,27 +479,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 @@ -395,12 +510,23 @@ 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) + +@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", 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) @@ -408,16 +534,20 @@ 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) 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) + start = max(self.start, dt) + return Interval(start, end) @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/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/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/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( 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/pybox/time/interval.py b/rocketry/pybox/time/interval.py index 15ef5edc..14be2cd3 100644 --- a/rocketry/pybox/time/interval.py +++ b/rocketry/pybox/time/interval.py @@ -1,13 +1,37 @@ +from dataclasses import dataclass, field +from typing import Any +try: + from typing import Literal +except ImportError: # pragma: no cover + from typing_extensions import Literal + +@dataclass(frozen=True) class Interval: "Mimics pandas.Interval" - def __init__(self, left, right, closed="right"): - self.left = left - self.right = right - self.closed = closed + left: Any + right: Any + closed: Literal['left', 'right', 'both', 'neither'] = "left" + + 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": + 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 +71,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/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 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 diff --git a/rocketry/test/condition/test_api.py b/rocketry/test/condition/test_api.py index 0696398e..72a10537 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,14 @@ 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.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( "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 1575a2c1..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"), @@ -84,7 +86,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"), ] @@ -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 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 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/test/time/interval/test_construct.py b/rocketry/test/time/interval/test_construct.py index f9fef375..f7580fd4 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 +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: @@ -27,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 @@ -44,61 +48,135 @@ 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_ns == time._end + 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_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) + 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 + + def test_value_error(self, start, end): + with pytest.raises(ValueError): + time = self.cls(start, end) + +class TestTimeOfHour(ConstructTester): + + cls = TimeOfHour + + max_ms = MS_IN_HOUR + + scen_closed = [ + { + "start": "15:00", + "end": "45:00", + "expected_start": 15 * MS_IN_MINUTE, + "expected_end": 45 * MS_IN_MINUTE, + }, + { + "start": 15, + "end": 45, + "expected_start": 15 * MS_IN_MINUTE, + "expected_end": 46 * MS_IN_MINUTE - 1, + }, + ] + + scen_open_left = [ + { + "end": "45:00", + "expected_end": 45 * MS_IN_MINUTE + } + ] + scen_open_right = [ + { + "start": "45:00", + "expected_start": 45 * MS_IN_MINUTE + } + ] + scen_time_point = [ + { + "start": "12:00", + "expected_start": 12 * MS_IN_MINUTE, + "expected_end": 13 * MS_IN_MINUTE, + } + ] + + scen_value_error = [ + { + "start": 60, + "end": None + } + ] + class TestTimeOfDay(ConstructTester): cls = TimeOfDay - max_ns = 24 * NS_IN_HOUR - 1 + max_ms = 24 * MS_IN_HOUR 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 * 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, + } + ] + scen_value_error = [ + { + "start": 24, + "end": None, } ] @@ -107,42 +185,55 @@ class TestTimeOfWeek(ConstructTester): cls = TimeOfWeek - max_ns = 7 * NS_IN_DAY - 1 + max_ms = 7 * MS_IN_DAY 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 * 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, + } + ] + scen_value_error = [ + { + "start": 0, + "end": None } ] @@ -151,39 +242,130 @@ class TestTimeOfMonth(ConstructTester): cls = TimeOfMonth - max_ns = 31 * NS_IN_DAY - 1 + max_ms = 31 * MS_IN_DAY 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 * 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, + } + ] + scen_value_error = [ + { + "start": 0, + "end": None, + }, + { + "start": None, + "end": 32, } ] + +class TestTimeOfYear(ConstructTester): + + cls = TimeOfYear + + max_ms = 366 * MS_IN_DAY # Leap year has 366 days + + scen_closed = [ + { + "start": "February", + "end": "April", + "expected_start": 31 * MS_IN_DAY, + "expected_end": (31 + 29 + 31 + 30) * MS_IN_DAY - 1, + }, + { + "start": "Feb", + "end": "Apr", + "expected_start": 31 * MS_IN_DAY, + "expected_end": (31 + 29 + 31 + 30) * MS_IN_DAY - 1, + }, + { + "start": 2, + "end": 4, + "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) * MS_IN_DAY - 1 + }, + { + "end": "Jan", + "expected_end": 31 * MS_IN_DAY - 1 + }, + ] + scen_open_right = [ + { + "start": "Apr", + "expected_start": (31 + 29 + 31) * MS_IN_DAY + }, + { + "start": "Dec", + "expected_start": (366 - 31) * MS_IN_DAY + }, + ] + scen_time_point = [ + { + "start": "Jan", + "expected_start": 0, + "expected_end": 31 * MS_IN_DAY - 1, + }, + { + "start": "Feb", + "expected_start": 31 * MS_IN_DAY, + "expected_end": (31 + 29) * MS_IN_DAY - 1, + }, + { + "start": "Dec", + "expected_start": (366 - 31) * MS_IN_DAY, + "expected_end": 366 * MS_IN_DAY - 1, + }, + ] + scen_value_error = [ + { + "start": 0, + "end": None, + }, + { + "start": None, + "end": 13, + }, + ] \ No newline at end of file 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 fc7ce7a0..1c0ec08a 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"), @@ -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' 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), 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/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 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..59393e2d 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", 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"), + id="Sequential (overlap)"), + + pytest.param( + from_iso("2020-01-01 08:30: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"), + 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): @@ -74,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", @@ -87,7 +164,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", 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"), + id="Sequential (overlap)"), + + pytest.param( + from_iso("2020-01-01 09:30: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"), + 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): 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/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/test/time/test_cron.py b/rocketry/test/time/test_cron.py new file mode 100644 index 00000000..ccf769d5 --- /dev/null +++ b/rocketry/test/time/test_cron.py @@ -0,0 +1,211 @@ +import datetime +import pytest +from rocketry.time import Cron, always +from rocketry.time.interval import TimeOfDay, TimeOfHour, TimeOfMinute, TimeOfMonth, TimeOfWeek, TimeOfYear + +every_minute = TimeOfMinute() + +@pytest.mark.parametrize( + "period,expected", + [ + # Test at + 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(Cron("*", "*", "*", "JUN", "*"), every_minute & TimeOfYear.at("June"), id="* * * JUN *"), + pytest.param(Cron("*", "*", "*", "*", "SUN"), every_minute & TimeOfWeek.at("Sunday"), id="* * * * SUN"), + + # Test ranges + 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(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( + 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( + 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( + 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" + ), + 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): + subperiod = period.get_subperiod() + assert subperiod == expected + +def test_in(): + 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 = Cron("30", "*", "*", "*", "*") + + # 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) + + # 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) + 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 = Cron("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, 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)) + 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 = 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") + 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 = 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") + 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 = Cron(*"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 = 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 = 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/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)"), diff --git a/rocketry/test/time/test_skip.py b/rocketry/test/time/test_skip.py new file mode 100644 index 00000000..588e6b42 --- /dev/null +++ b/rocketry/test/time/test_skip.py @@ -0,0 +1,104 @@ +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_every_second(): + 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( + 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(step=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=step) + assert isinstance(every, Any) + assert len(every.periods) == n_periods \ No newline at end of file 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 diff --git a/rocketry/time/__init__.py b/rocketry/time/__init__.py index 670c8a2e..532f3d2d 100644 --- a/rocketry/time/__init__.py +++ b/rocketry/time/__init__.py @@ -3,6 +3,9 @@ from .construct import get_between, get_before, get_after, get_full_cycle, get_on from .delta import TimeSpanDelta +from .cron import Cron + +from rocketry.core.time import always from rocketry.session import Session @@ -15,7 +18,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 diff --git a/rocketry/time/cron.py b/rocketry/time/cron.py new file mode 100644 index 00000000..059be92b --- /dev/null +++ b/rocketry/time/cron.py @@ -0,0 +1,111 @@ +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 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) + 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 + # -: 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: + expr, step = expr.split("/") + step_period = cls.create_range(step=int(step)) + else: + step = None + + if "-" in expr: + # From to + start, end = expr.split("-") + if start.isdigit(): + start = conv(int(start)) + if end.isdigit(): + end = conv(int(end)) + + 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 + value = conv(int(expr)) if expr.isdigit() else expr + if value == "*": + period = always & step_period + else: + period = cls.at(value) & step_period + + 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 + ) 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 9b226413..cee6fdc8 100644 --- a/rocketry/time/interval.py +++ b/rocketry/time/interval.py @@ -2,37 +2,41 @@ import calendar import datetime import re +from dataclasses import dataclass +from typing import ClassVar, List import dateutil 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 TimeOfHour("5:00", "30:00") """ - _scope = "minute" - _scope_max = to_nanoseconds(minute=1) - 1 - _unit_resolution = to_nanoseconds(second=1) + _scope: ClassVar[str] = "minute" + + _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 - 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: @@ -41,27 +45,34 @@ 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 - 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 = "hour" - _scope_max = to_nanoseconds(hour=1) - 1 - _unit_resolution = to_nanoseconds(minute=1) + _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: + 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) + 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: @@ -70,57 +81,82 @@ 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 - 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 = "day" - _scope_max = to_nanoseconds(day=1) - 1 - _unit_resolution = to_nanoseconds(hour=1) + _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: + 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) 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 = "week" - _scope_max = to_nanoseconds(day=7) - 1 # Sun day end of day - _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))) + _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'] + + _unit_mapping = { + **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_int(self, i, **kwargs): + # 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: + 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" @@ -128,33 +164,34 @@ 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._unit_mapping[dayofweek.lower()] # 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 @@ -164,12 +201,20 @@ 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_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) + 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" @@ -189,105 +234,145 @@ 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 - - def anchor_int(self, i, **kwargs): - return i * self._unit_resolution + 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 TimeOfYear("Jan", "Feb") """ - _scope = "year" - _scope_max = to_nanoseconds(day=1) * 366 - 1 + # We take the longest year there is and translate all years to that + # using first the month and then the day of month + + _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 = { - **dict(zip(calendar.month_name[1:], range(12))), - **dict(zip(calendar.month_abbr[1:], range(12))), - **dict(zip(range(12), range(12))) + _unit_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(_unit_names)}, } + + _month_start_mapping: ClassVar = { + 0: 0, # January + 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 microseconds to month num + _year_start_mapping: ClassVar = 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") - nth_month = self.monthnum_mapping[monthofyear] + monthofyear = comps.pop("monthofyear") # This is jan, january + day_of_month_str = comps.pop("day_of_month") + nth_month = self._unit_mapping[monthofyear.lower()] - # 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: + microseconds = TimeOfMonth().anchor_str(day_of_month_str) else: - nanoseconds = 0 - - return nth_month * to_nanoseconds(day=31) + nanoseconds + microseconds = 0 + + return self._month_start_mapping[nth_month] + microseconds + + def to_timepoint(self, ns: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 + 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 (Jan = 1) + # 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 + 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)" + "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 nth_month * to_nanoseconds(day=31) + to_nanoseconds(**d) + return self._month_start_mapping[nth_month] + to_microseconds(**d) +@dataclass(frozen=True, init=False) class RelativeDay(TimeInterval): """Specific day @@ -298,20 +383,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]