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.
- redzed/__init__.py +44 -0
- redzed/base_block.py +132 -0
- redzed/block.py +290 -0
- redzed/blocklib/__init__.py +26 -0
- redzed/blocklib/counter.py +45 -0
- redzed/blocklib/fsm.py +554 -0
- redzed/blocklib/inputs.py +210 -0
- redzed/blocklib/outputs.py +361 -0
- redzed/blocklib/repeat.py +72 -0
- redzed/blocklib/timedate.py +150 -0
- redzed/blocklib/timeinterval.py +192 -0
- redzed/blocklib/timer.py +50 -0
- redzed/circuit.py +756 -0
- redzed/cron_service.py +243 -0
- redzed/debug.py +86 -0
- redzed/formula_trigger.py +205 -0
- redzed/initializers.py +249 -0
- redzed/signal_shutdown.py +64 -0
- redzed/undef.py +38 -0
- redzed/utils/__init__.py +14 -0
- redzed/utils/async_utils.py +145 -0
- redzed/utils/data_utils.py +116 -0
- redzed/utils/time_utils.py +262 -0
- redzed-25.12.30.dist-info/METADATA +52 -0
- redzed-25.12.30.dist-info/RECORD +28 -0
- redzed-25.12.30.dist-info/WHEEL +5 -0
- redzed-25.12.30.dist-info/licenses/LICENSE.txt +21 -0
- redzed-25.12.30.dist-info/top_level.txt +1 -0
|
@@ -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]
|
redzed/blocklib/timer.py
ADDED
|
@@ -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')
|