redzed 25.12.30__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,150 @@
1
+ """
2
+ Periodic events at fixed time/date.
3
+
4
+ - - - - - -
5
+ Docs: https://edzed.readthedocs.io/en/latest/
6
+ Home: https://github.com/xitop/edzed/
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Mapping, Sequence, Set
12
+ import datetime as dt
13
+ import typing as t
14
+
15
+ import redzed
16
+ from . import timeinterval as ti
17
+ from ..cron_service import Cron
18
+
19
+
20
+ __all__ = ['TimeDate', 'TimeSpan']
21
+
22
+ def _get_cron(utc: bool) -> Cron:
23
+ name = '_cron_utc' if utc else '_cron_local'
24
+ return t.cast(Cron, redzed.get_circuit().resolve_name(name))
25
+
26
+
27
+ _MIDNIGHT = dt.time(0, 0, 0)
28
+
29
+
30
+ class _ConfigType(t.TypedDict):
31
+ times: t.NotRequired[ti.DT_Interval_Type]
32
+ dates: t.NotRequired[ti.DT_Interval_Type]
33
+ weekdays: t.NotRequired[Sequence[int]]
34
+
35
+
36
+ class TimeDate(redzed.Block):
37
+ """
38
+ Block for periodic events at given time/date.
39
+ """
40
+
41
+ def __init__(self, *args, utc: bool = False, **kwargs) -> None:
42
+ super().__init__(*args, **kwargs)
43
+ self._cron = _get_cron(utc)
44
+ self._times: ti.TimeInterval | None = None
45
+ self._dates: ti.DateInterval | None = None
46
+ self._weekdays: Set[int] | None = None
47
+
48
+ def _reconfig(self, config: _ConfigType) -> None:
49
+ """Reconfigure the block."""
50
+ if extra := config.keys() - {'times', 'dates', 'weekdays'}:
51
+ raise ValueError(f"{self}: Unexpected key '{next(iter(extra))}' in configuration")
52
+ times = config.get('times', None)
53
+ dates = config.get('dates', None)
54
+ weekdays = config.get('weekdays', None)
55
+ if all(cfg is None for cfg in [times, dates, weekdays]):
56
+ raise ValueError("Empty configuration data")
57
+ self._times = None if times is None else ti.TimeInterval(times)
58
+ self._dates = None if dates is None else ti.DateInterval(dates)
59
+ if weekdays is None:
60
+ self._weekdays = None
61
+ else:
62
+ if not all(0 <= x <= 7 for x in weekdays):
63
+ raise ValueError(
64
+ "Only numbers 0 or 7 (Sun), 1 (Mon), ... 6(Sat) are accepted as weekdays")
65
+ self._weekdays = frozenset(7 if x == 0 else x for x in weekdays)
66
+
67
+ endpoints = self._times.range_endpoints() if self._times is not None else set()
68
+ if self._dates or self._weekdays:
69
+ endpoints.add(_MIDNIGHT)
70
+ self._cron.set_schedule(self, endpoints)
71
+ self.rz_cron_event(self._cron.dtnow())
72
+
73
+ def rz_init(self, value: _ConfigType, /) -> None:
74
+ if not isinstance(value, Mapping):
75
+ raise TypeError(
76
+ "Initialization value must be a dict (mapping), "
77
+ + f"but got {type(value).__name__}")
78
+ self._reconfig(value)
79
+
80
+ def rz_init_default(self) -> None:
81
+ self._times = None
82
+ self._dates = None
83
+ self._weekdays = frozenset()
84
+ self._set_output(False)
85
+
86
+ def _event_reconfig(self, edata: redzed.EventData) -> None:
87
+ self._reconfig(edata['evalue'])
88
+
89
+ def _event__get_config(self, _edata: redzed.EventData) -> dict[str, Sequence|None]:
90
+ # _get_config event == _get_state event == rz_export_state function
91
+ return self.rz_export_state()
92
+
93
+ def rz_cron_event(self, now: dt.datetime) -> None:
94
+ """Update the output."""
95
+ self._set_output(
96
+ (self._weekdays is None or now.isoweekday() in self._weekdays)
97
+ and (self._times is None or now.time() in self._times)
98
+ and (self._dates is None
99
+ or dt.date(ti.DUMMY_YEAR, now.month, now.day) in self._dates)
100
+ )
101
+
102
+ def rz_export_state(self) -> dict[str, Sequence|None]:
103
+ return {
104
+ 'times': None if self._times is None else self._times.as_list(),
105
+ 'dates': None if self._dates is None else self._dates.as_list(),
106
+ 'weekdays': None if self._weekdays is None else sorted(self._weekdays),
107
+ }
108
+
109
+ rz_restore_state = rz_init
110
+
111
+
112
+ class TimeSpan(redzed.Block):
113
+ """
114
+ Block active between start and stop time/date.
115
+ """
116
+
117
+ def __init__(self, *args, utc: bool = False, **kwargs):
118
+ super().__init__(*args, **kwargs)
119
+ self._cron = _get_cron(utc)
120
+ self._span: ti.DateTimeInterval
121
+
122
+ def _reconfig(self, config: ti.DT_Interval_Type) -> None:
123
+ """Reconfigure the block."""
124
+ self._span = ti.DateTimeInterval(config)
125
+ now = self._cron.dtnow()
126
+ now_date = now.date()
127
+ endpoints = {ep.time() for ep in self._span.range_endpoints() if ep.date() >= now_date}
128
+ self._cron.set_schedule(self, endpoints)
129
+ self.rz_cron_event(now)
130
+
131
+ rz_init = _reconfig
132
+
133
+ def rz_init_default(self) -> None:
134
+ self._reconfig([])
135
+
136
+ def _event_reconfig(self, edata: redzed.EventData) -> None:
137
+ self._reconfig(edata['evalue'])
138
+
139
+ def _event__get_config(self, _edata: redzed.EventData) -> ti.DT_Interval_Type:
140
+ # _get_config event == _get_state event == rz_export_state function
141
+ return self.rz_export_state()
142
+
143
+ def rz_cron_event(self, now: dt.datetime) -> None:
144
+ """Update the output."""
145
+ self._set_output(now in self._span)
146
+
147
+ def rz_export_state(self) -> ti.DT_Interval_Type:
148
+ return self._span.as_list()
149
+
150
+ rz_restore_state = _reconfig
@@ -0,0 +1,192 @@
1
+ """
2
+ This module defines time/date intervals.
3
+
4
+ It is built on top of the datetime (dt) module:
5
+ - time of day -> dt.time
6
+ - date without a year -> dt.date with year set to a dummy value
7
+ - date -> dt.date
8
+ - full date+time -> dt.datetime
9
+
10
+ Intervals use those objects as endpoints:
11
+ - TimeInterval defines time intervals within a day,
12
+ - DateInterval defines periods in a year.
13
+ - DateTimeInterval defines non-recurring intervals.
14
+
15
+ All intervals support the operation "value in interval".
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Sequence
20
+ import datetime as dt
21
+ import typing as t
22
+
23
+ # any sequence when importing, always a nested list when exporting
24
+ DT_Interval_Type = Sequence[Sequence[Sequence[int]]]
25
+
26
+ # endpoint type
27
+ _DateTimeType = t.TypeVar("_DateTimeType", dt.time, dt.date, dt.datetime)
28
+
29
+
30
+ _DT_ATTRS = "year month day hour minute second microsecond".split()
31
+
32
+ class _Interval(t.Generic[_DateTimeType]):
33
+ """
34
+ The common part of Date/Time Intervals.
35
+
36
+ Warning: time/date intervals do not follow the strict mathematical
37
+ interval definition.
38
+ """
39
+
40
+ # https://en.wikipedia.org/wiki/Interval_(mathematics)#Definitions_and_terminology
41
+ # the subintervals are always left-closed
42
+ _RCLOSED_INTERVAL: bool # are the subintervals right-closed?
43
+ _EXPORT_ATTRS: Sequence[str]
44
+
45
+ @staticmethod
46
+ def _convert(seq: Sequence[int]) -> _DateTimeType:
47
+ raise NotImplementedError
48
+
49
+ def convert(self, seq: Sequence[int]) -> _DateTimeType:
50
+ try:
51
+ return self._convert(seq)
52
+ except Exception as err:
53
+ err.add_note(f"Input value was: {seq!r}")
54
+ raise
55
+
56
+ def __init__(self, ivalue: DT_Interval_Type):
57
+ """
58
+ Argument ivalue must be a sequence of ranges:
59
+ _Interval([subinterval1, subinterval2, ...])
60
+ where each range (subinterval) is a pair of endpoints [begin, end].
61
+
62
+ After splitting into endpoints, each value is converted
63
+ to time/date according to the actual interval type.
64
+ """
65
+ self._interval: list[list[_DateTimeType]]
66
+ if not isinstance(ivalue, Sequence) or isinstance(ivalue, str):
67
+ raise TypeError(f"Unsupported argument type: {type(ivalue).__name__}")
68
+ self._interval = sorted(self._parse_range(subint) for subint in ivalue)
69
+
70
+ def _parse_range(self, rng: Sequence[Sequence[int]]) -> list[_DateTimeType]:
71
+ """Parse: [begin, end]"""
72
+ if not isinstance(rng, Sequence) or isinstance(rng, str):
73
+ raise TypeError(
74
+ f"Invalid range type. Expected was a pair [begin, and], got {rng!r}")
75
+ if (length := len(rng)) != 2:
76
+ raise ValueError(f"A range must have 2 endpoints, got range with {length}: {rng!r}")
77
+ return [self.convert(rng[0]), self.convert(rng[1])]
78
+
79
+ def range_endpoints(self) -> set[_DateTimeType]:
80
+ """return all unique range start and stop values."""
81
+ enpoints = set()
82
+ for start, stop in self._interval:
83
+ enpoints.add(start)
84
+ enpoints.add(stop)
85
+ return enpoints
86
+
87
+ @staticmethod
88
+ def _cmp_open(low: _DateTimeType, item: _DateTimeType, high: _DateTimeType) -> bool:
89
+ """
90
+ The ranges are left-closed and right-open intervals, i.e.
91
+ value is in interval if and only if start <= value < stop
92
+ """
93
+ if low < high:
94
+ return low <= item < high
95
+ # low <= item < MAX or MIN <= item < high
96
+ return low <= item or item < high
97
+
98
+ @staticmethod
99
+ def _cmp_closed(low: _DateTimeType, item: _DateTimeType, high: _DateTimeType) -> bool:
100
+ """
101
+ The ranges are closed intervals, i.e.
102
+ value is in interval if and only if start <= value <= stop
103
+ """
104
+ if low <= high:
105
+ return low <= item <= high
106
+ return low <= item or item <= high
107
+
108
+ def _cmp(self, *args: _DateTimeType) -> bool:
109
+ return (self._cmp_closed if self._RCLOSED_INTERVAL else self._cmp_open)(*args)
110
+
111
+ def __contains__(self, item: _DateTimeType) -> bool:
112
+ return any(self._cmp(low, item, high) for low, high in self._interval)
113
+
114
+ @classmethod
115
+ def _export_dt(cls, dt_object: _DateTimeType) -> list[int]:
116
+ """Export a date/time object."""
117
+ return [getattr(dt_object, attr) for attr in cls._EXPORT_ATTRS]
118
+
119
+ def as_list(self) -> DT_Interval_Type:
120
+ """
121
+ Return the intervals as a nested list of integers.
122
+
123
+ The output is a list of endpoint pairs. Each endpoint is a list
124
+ of integers. The output is suitable as an input argument.
125
+ """
126
+ return [
127
+ [self._export_dt(start), self._export_dt(stop)]
128
+ for start, stop in self._interval]
129
+
130
+
131
+ class TimeInterval(_Interval[dt.time]):
132
+ """
133
+ List of time ranges.
134
+
135
+ The whole day is 00:00 - 00:00.
136
+ """
137
+
138
+ _RCLOSED_INTERVAL = False
139
+ _EXPORT_ATTRS = _DT_ATTRS[3:]
140
+
141
+ @staticmethod
142
+ def _convert(seq: Sequence[int]) -> dt.time:
143
+ """[hour, minute, second=0, microsecond=0] -> time of day"""
144
+ if not 2 <= len(seq) <= 4:
145
+ raise ValueError(
146
+ f"{seq} not in expected format: [hour, minute=0, second=0, µs=0]")
147
+ # mypy is overlooking that the seq cannot have more than 4 items
148
+ return dt.time(*seq, tzinfo=None) # type: ignore[misc, arg-type]
149
+
150
+
151
+ DUMMY_YEAR = 404
152
+ # 404 is a leap year (allows Feb 29) and is not similar
153
+ # to anything related to modern date values
154
+
155
+ class DateInterval(_Interval[dt.date]):
156
+ """
157
+ List of date ranges and single dates.
158
+ """
159
+
160
+ _RCLOSED_INTERVAL = True
161
+ _EXPORT_ATTRS = _DT_ATTRS[1:3]
162
+
163
+ @staticmethod
164
+ def _convert(seq: Sequence[int]) -> dt.date:
165
+ """[month, day] -> date (without year)"""
166
+ if len(seq) != 2:
167
+ raise ValueError(f"{seq} not in expected format: [month, day]")
168
+ return dt.date(DUMMY_YEAR, *seq)
169
+
170
+
171
+ class DateTimeInterval(_Interval[dt.datetime]):
172
+ """
173
+ List of datetime ranges.
174
+ """
175
+
176
+ _RCLOSED_INTERVAL = False
177
+ _EXPORT_ATTRS = _DT_ATTRS
178
+
179
+ @staticmethod
180
+ def _cmp_open(low: dt.datetime, item: dt.datetime, high: dt.datetime) -> bool:
181
+ """Compare function for non-recurring intervals."""
182
+ return low <= item < high
183
+
184
+ @staticmethod
185
+ def _convert(seq: Sequence[int]) -> dt.datetime:
186
+ """[year, month, day, hour, minute, second=0, microsecond=0] -> datetime"""
187
+ if not 5 <= len(seq) <= 7:
188
+ raise ValueError(
189
+ f"{seq} not in expected format: "
190
+ "[year, month, day, hour, minute, second=0, µs=0]")
191
+ # mypy is overlooking that the time_seq cannot have more than 7 items
192
+ return dt.datetime(*seq, tzinfo=None) # type: ignore[misc, arg-type]
@@ -0,0 +1,50 @@
1
+ """
2
+ A timer for general use.
3
+ - - - - - -
4
+ Part of the redzed package.
5
+ Docs: https://redzed.readthedocs.io/en/latest/
6
+ Home: https://github.com/xitop/redzed/
7
+ """
8
+ from __future__ import annotations
9
+
10
+ __all__ = ['Timer']
11
+
12
+ from redzed.utils import time_period
13
+ from .fsm import FSM
14
+
15
+
16
+ class Timer(FSM):
17
+ """
18
+ A timer.
19
+ """
20
+
21
+ ALL_STATES = ['off', 'on']
22
+ TIMED_STATES = [
23
+ ['on', float("inf"), 'off'],
24
+ ['off', float("inf"), 'on']]
25
+ EVENTS = [
26
+ ['start', ..., 'on'],
27
+ ['stop', ..., 'off'],
28
+ ['toggle', ['on'], 'off'],
29
+ ['toggle', ['off'], 'on']]
30
+
31
+ def __init__(self, *args, restartable: bool = True, **kwargs) -> None:
32
+ if 't_period' in kwargs:
33
+ if ('t_on' in kwargs or 't_off' in kwargs):
34
+ raise TypeError("t_period and t_on/t_off are mutually exclusive.")
35
+ period = time_period(kwargs.pop('t_period'))
36
+ kwargs['t_on'] = kwargs['t_off'] = period / 2
37
+ super().__init__(*args, **kwargs)
38
+ if self._t_duration.get('on') == self._t_duration.get('off') == 0.0:
39
+ raise ValueError(
40
+ f"{self}: Durations for timer states 'on' and 'off' cannot be both zero")
41
+ self._restartable = bool(restartable)
42
+
43
+ def cond_start(self) -> bool:
44
+ return self._restartable or self._state != 'on'
45
+
46
+ def cond_stop(self) -> bool:
47
+ return self._restartable or self._state != 'off'
48
+
49
+ def _set_output(self, output) -> bool:
50
+ return super()._set_output(output == 'on')