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/cron_service.py ADDED
@@ -0,0 +1,243 @@
1
+ """
2
+ Call the .rz_cron_event() method of all registered blocks at given times of day.
3
+
4
+ Blocks acting on given time, date and weekdays are implemented
5
+ on top of this low-level service.
6
+
7
+ - - - - - -
8
+ Docs: https://redzed.readthedocs.io/en/latest/
9
+ Home: https://github.com/xitop/redzed/
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import bisect
15
+ from collections.abc import Collection
16
+ import datetime as dt
17
+ import time
18
+ import typing as t
19
+
20
+ from .block import Block, EventData
21
+ from .debug import get_debug_level
22
+ from .utils import SEC_PER_HOUR, SEC_PER_MIN, SEC_PER_DAY
23
+
24
+ # time tracking accuracy (in seconds)
25
+ _TT_OK = 0.001 # desired accuracy
26
+ _TT_WARNING = 0.1 # log a warning when exceeded
27
+ _TT_ERROR = 2.5 # do a reset when exceeded
28
+ _SYNC_SLEEP = 0.000_2 # sleeps <= 200 µs can be blocking for sake of accuracy
29
+
30
+ # hourly wake-ups for precise time tracking and early detection of DST changes
31
+ _SET24 = frozenset(dt.time(hour, 0, 0) for hour in range(24))
32
+
33
+
34
+ class Cron(Block):
35
+ """
36
+ Simple cron service.
37
+
38
+ Do not use directly in circuits. It has a form of a logic block
39
+ only to allow monitoring through the event interface.
40
+ """
41
+
42
+ RZ_RESERVED = True
43
+
44
+ def __init__(self, *args, utc: bool, **kwargs) -> None:
45
+ super().__init__(*args, **kwargs)
46
+ self._utc = bool(utc)
47
+ self._alarms: dict[dt.time, set[Block]] = {}
48
+ self._reversed: dict[Block, set[dt.time]] = {}
49
+ self._do_reload = asyncio.Event()
50
+
51
+ def rz_start(self) -> None:
52
+ tz = 'UTC' if self._utc else 'local time'
53
+ self.circuit.create_service(self._cron_daemon(), name=f"cron daemon ({tz})")
54
+
55
+ def dtnow(self) -> dt.datetime:
56
+ """Return the current date/time."""
57
+ if self._utc:
58
+ # but we need to keep all date/time object timezone naive
59
+ # in order to make them mutually comparable
60
+ return dt.datetime.now(dt.UTC).replace(tzinfo=None)
61
+ return dt.datetime.now()
62
+
63
+ def _check_tz(self, time_of_day: dt.time) -> None:
64
+ """Check if the time zone is left unspecified (i.e. object is "naive")."""
65
+ if not isinstance(time_of_day, dt.time):
66
+ raise TypeError(
67
+ f"time_of_day should be a datetime.time object, but got {time_of_day!r}")
68
+ if time_of_day.tzinfo is not None:
69
+ raise ValueError("time_of_day must not contain timezone data")
70
+
71
+ def set_schedule(self, blk: Block, times_of_day: Collection[dt.time]) -> None:
72
+ """
73
+ Add a block to be activated at given times or update its schedule.
74
+
75
+ The block's 'rz_cron_event' method will be called at given time
76
+ and also when this service is started, reset or reloaded.
77
+
78
+ A datetime.datetime object will be passed to the blk as its
79
+ only argument.
80
+ """
81
+ if not hasattr(blk, 'rz_cron_event'):
82
+ raise TypeError(f"{blk} is not compatible with the cron service")
83
+
84
+ times_of_day = set(times_of_day) # make a set, make a copy
85
+ # remove old times of day
86
+ old_times = self._reversed.get(blk, set())
87
+ for tod in old_times - times_of_day:
88
+ self._alarms[tod].discard(blk)
89
+
90
+ do_reload = False
91
+ # add new times of day
92
+ for tod in times_of_day - old_times:
93
+ self._check_tz(tod)
94
+ if tod in self._alarms:
95
+ self._alarms[tod].add(blk)
96
+ else:
97
+ self._alarms[tod] = {blk}
98
+ if tod not in _SET24:
99
+ do_reload = True # new entry added to timetable
100
+ self._reversed[blk] = times_of_day
101
+
102
+ # cleanup
103
+ unused = [tod for tod, blkset in self._alarms.items() if not blkset]
104
+ for tod in unused:
105
+ del self._alarms[tod]
106
+ if tod not in _SET24:
107
+ do_reload = True # empty entry removed from the timetable
108
+ if do_reload:
109
+ self._do_reload.set()
110
+
111
+ async def _cron_daemon(self) -> t.NoReturn:
112
+ """Recalculate registered blocks according to the schedule."""
113
+ overhead = _TT_OK # initial value, will be adjusted
114
+ # the sleeptime is reduced by this value
115
+ reset_flag = False
116
+ reload_flag = True # reload will also initialize the index
117
+ short_sleep = False # alternative sleep function used => do not compute overhead
118
+ while True:
119
+ if reload_flag:
120
+ timetable = sorted(_SET24.union(self._alarms))
121
+ tlen = len(timetable)
122
+ self.log_debug1("time schedule reloaded")
123
+ index = None
124
+ reload_flag = False
125
+
126
+ nowdt = self.dtnow()
127
+ nowt = nowdt.time()
128
+ if index is None:
129
+ # reload is set before entering the loop -> "tlen" gets initialized
130
+ # pylint: disable-next=possibly-used-before-assignment
131
+ index = bisect.bisect_left(timetable, nowt) % tlen
132
+ wakeup = timetable[index]
133
+ self.log_debug1("wakeup time: %s", wakeup)
134
+
135
+ # sleep until the wakeup time:
136
+ # step 0 - compute the delay until wakeup time
137
+ # - sleep
138
+ # step 1 - check the current time, adjust overhead estimate,
139
+ # A: finish if the time is correct, or
140
+ # B: add a tiny sleep if woken up too early, because
141
+ # continuing before wakeup time is not acceptable
142
+ # C: do a reset if the time is way off
143
+ # step 2 - check time after 1B,
144
+ # A: finish if the time is correct
145
+ # B: do a reset otherwise
146
+ for step in range(3):
147
+ # datetime.time does not support time arithmetic
148
+ sleeptime = (SEC_PER_HOUR*(wakeup.hour - nowt.hour)
149
+ + SEC_PER_MIN*(wakeup.minute - nowt.minute)
150
+ + (wakeup.second - nowt.second)
151
+ + (wakeup.microsecond - nowt.microsecond)/ 1_000_000.0)
152
+ if nowt.hour == 23 and wakeup.hour == 0:
153
+ # wrap around midnight (relying on hourly wakeups in SET24)
154
+ sleeptime += SEC_PER_DAY
155
+ # sleeptime: negative = after the alarm time; positive = before the alarm time
156
+ if step == 0:
157
+ self.log_debug2("sleep until wakeup: %.3f sec", sleeptime)
158
+ if step >= 1 or sleeptime < 0:
159
+ diff = abs(sleeptime)
160
+ if get_debug_level() >= 2:
161
+ msg = 'BEFORE' if sleeptime > 0 else 'after'
162
+ self.log_debug(
163
+ "step %d, diff %.2f ms %s, estimated overhead: %.2f ms",
164
+ step, 1000*diff, msg, 1000*overhead)
165
+ if diff > _TT_WARNING:
166
+ self.log_warning(
167
+ "expected time: %s, current time: %s, diff: %.2f ms.",
168
+ wakeup, nowt, 1000*diff)
169
+ if (step == 2 and sleeptime > 0) or diff > _TT_ERROR:
170
+ reset_flag = True
171
+ if reset_flag:
172
+ break
173
+ if step == 1 and not short_sleep and not -_TT_OK <= sleeptime <= 0:
174
+ overhead -= (sleeptime + _TT_OK/2) / 2 # average of new and old
175
+ if sleeptime <= 0:
176
+ break
177
+ if get_debug_level() >= 2:
178
+ self.log_debug("additional sleep %.2f ms", 1000*sleeptime)
179
+
180
+ if sleeptime == 0.0:
181
+ pass # how likely is this?
182
+ elif sleeptime <= _SYNC_SLEEP:
183
+ short_sleep = True
184
+ # breaking the asyncio rules for max time tracking accuracy:
185
+ # doing a blocking sleep, but only for a fraction of a millisecond
186
+ time.sleep(sleeptime)
187
+ elif sleeptime <= overhead:
188
+ short_sleep = True
189
+ await asyncio.sleep(sleeptime)
190
+ else:
191
+ short_sleep = False
192
+ try:
193
+ async with asyncio.timeout(sleeptime - overhead):
194
+ await self._do_reload.wait()
195
+ except TimeoutError:
196
+ pass
197
+ else:
198
+ self._do_reload.clear()
199
+ reload_flag = True
200
+ break
201
+ nowdt = self.dtnow()
202
+ nowt = nowdt.time()
203
+
204
+ if reset_flag:
205
+ # DST begin/end or other computer clock related reason
206
+ if (not self._utc
207
+ and nowdt.isoweekday() >= 6
208
+ and abs(diff - SEC_PER_HOUR) <= _TT_ERROR
209
+ ):
210
+ self.log_warning("Apparently a DST (summer time) clock change has occured.")
211
+ self.log_warning(
212
+ "Resetting due to a time tracking problem. "
213
+ + "Notifying all registered blocks.")
214
+ # .rz_cron_event() may alter the dict we are iterating over
215
+ for blk in list(self._reversed): # all blocks
216
+ assert hasattr(blk, 'rz_cron_event')
217
+ blk.rz_cron_event(nowdt)
218
+ index = None
219
+ reset_flag = False
220
+ continue
221
+ if reload_flag:
222
+ continue
223
+
224
+ if wakeup in self._alarms:
225
+ # .rz_cron_event() may alter the set we are iterating over
226
+ block_list = list(self._alarms[wakeup])
227
+ if get_debug_level() >= 1:
228
+ self.log_debug(
229
+ "Notifying blocks: %s", ", ".join(blk.name for blk in block_list))
230
+ for blk in block_list:
231
+ assert hasattr(blk, 'rz_cron_event')
232
+ blk.rz_cron_event(nowdt)
233
+ index = (index + 1) % tlen
234
+
235
+ def _event__get_config(self, _edata: EventData) -> dict[str, dict[str, list[str]]]:
236
+ """Return the internal scheduling data for debugging or monitoring."""
237
+ return {
238
+ 'alarms': {
239
+ str(recalc_time): sorted(blk.name for blk in blk_set)
240
+ for recalc_time, blk_set in self._alarms.items()},
241
+ 'blocks': {blk.name: sorted(str(recalc_time) for recalc_time in times_set)
242
+ for blk, times_set in self._reversed.items()},
243
+ }
redzed/debug.py ADDED
@@ -0,0 +1,86 @@
1
+ """
2
+ Debug level.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ __all__ = ['get_debug_level', 'set_debug_level']
7
+
8
+ import logging
9
+ import os
10
+
11
+ from . import circuit
12
+
13
+ _logger = logging.getLogger(__package__)
14
+ _debug_handler: logging.StreamHandler|None = None
15
+
16
+
17
+ class _CircuitTimeFormatter(logging.Formatter):
18
+ """Formatter for debug level 3."""
19
+ def format(self, record: logging.LogRecord) -> str:
20
+ msg = super().format(record)
21
+ if get_debug_level() >= 3:
22
+ msg = f"{circuit.get_circuit().runtime():.03f} {msg}"
23
+ return msg
24
+
25
+
26
+ class _DebugLevel:
27
+ """
28
+ Global debug level.
29
+
30
+ The initial level is set according to the environment variable REDZED_DEBUG.
31
+ """
32
+
33
+ def __init__(self) -> None:
34
+ self._level = -1 # make sure the set_level's core will run
35
+ self.set_level(self._get_level_from_env())
36
+
37
+ def _get_level_from_env(self) -> int:
38
+ try:
39
+ level = os.environ['REDZED_DEBUG']
40
+ except KeyError:
41
+ return 0
42
+ level = level.strip()
43
+ if not level:
44
+ return 0
45
+ if level in {'0', '1', '2', '3'}:
46
+ return int(level)
47
+ _logger.warning(
48
+ "Envvar 'REDZED_DEBUG' should be: 0 (disabled), 1 (normal), "
49
+ + "2 (verbose) or 3 (verbose with circuit timestamps)")
50
+ _logger.warning("Ignoring REDZED_DEBUG='%s'. Please use a correct value.", level)
51
+ return 0
52
+
53
+ def get_level(self) -> int:
54
+ return self._level
55
+
56
+ def set_level(self, level: int) -> None:
57
+ """Set debug level."""
58
+ global _debug_handler # pylint: disable=global-statement
59
+
60
+ if not isinstance(level, int):
61
+ raise TypeError(f"Expected an integer, got {level!r}")
62
+ if level == self._level:
63
+ return
64
+ if not 0 <= level <= 3:
65
+ raise ValueError(f"Debug level must be an integer 0 to 3, but got {level}")
66
+ # self._level is -1 on initial call
67
+ if self._level >= 0:
68
+ _logger.debug("Debug level: %d -> %d", self._level, level)
69
+ elif level > 0:
70
+ _logger.debug("Debug level: %d", level)
71
+ self._level = level
72
+ if level == 0:
73
+ if _debug_handler is not None:
74
+ _logger.removeHandler(_debug_handler)
75
+ _debug_handler = None
76
+ _logger.setLevel(logging.WARNING)
77
+ elif not _logger.hasHandlers() and _debug_handler is None:
78
+ _logger.addHandler(_debug_handler := logging.StreamHandler())
79
+ _debug_handler.setFormatter(_CircuitTimeFormatter("%(levelname)s - %(message)s"))
80
+ _logger.setLevel(logging.DEBUG)
81
+
82
+
83
+ _global_debug_level = _DebugLevel()
84
+
85
+ get_debug_level = _global_debug_level.get_level
86
+ set_debug_level = _global_debug_level.set_level
@@ -0,0 +1,205 @@
1
+ """
2
+ Formulas and Triggers.
3
+ - - - - - -
4
+ Part of the redzed package.
5
+ Docs: https://redzed.readthedocs.io/en/latest/
6
+ Project home: https://github.com/xitop/redzed/
7
+ """
8
+ from __future__ import annotations
9
+
10
+ __all__ = ['Formula', 'formula', 'Trigger', 'triggered']
11
+
12
+ from collections.abc import Callable
13
+ import inspect
14
+ import logging
15
+ import typing as t
16
+
17
+ from . import base_block
18
+ from . import block
19
+ from . import circuit
20
+ from .debug import get_debug_level
21
+ from .undef import UNDEF
22
+ from .utils import func_call_string
23
+
24
+ _logger = logging.getLogger(__package__)
25
+
26
+
27
+ class _ExtFunction:
28
+ """
29
+ Manage calls to a function associated with a Trigger or a Formula.
30
+ """
31
+
32
+ def __init__(self, func: Callable[..., t.Any], owner: Formula|Trigger) -> None:
33
+ """Check if the function's signature is compatible."""
34
+ self._owner = owner
35
+ self._func = func
36
+ self._parameters: list[str] = []
37
+ self._inputs: list[str|block.Block|Formula] = []
38
+ # names (strings) must be resolved to objects before start
39
+ arglist = []
40
+ Param = inspect.Parameter
41
+ try:
42
+ for name, param in inspect.signature(func).parameters.items():
43
+ # support of positional-only arguments is possible,
44
+ # but the benefit would be minimal
45
+ if param.kind not in [Param.POSITIONAL_OR_KEYWORD, Param.KEYWORD_ONLY]:
46
+ raise ValueError(
47
+ "Function takes *args or **kwargs or positional-only arguments")
48
+ self._parameters.append(name)
49
+ if (default := param.default) is Param.empty:
50
+ self._inputs.append(name)
51
+ arglist.append(name)
52
+ else:
53
+ self._inputs.append(default)
54
+ if isinstance(default, str):
55
+ arglist.append(f"{name}='{default}'")
56
+ elif isinstance(default, (block.Block, Formula)):
57
+ arglist.append(f"{name}='{default.name}'")
58
+ else:
59
+ raise ValueError(
60
+ f"The default value in '{name}={default!r}' "
61
+ + "does not specify a circuit block")
62
+ if not arglist:
63
+ raise ValueError("Function does take any arguments")
64
+ except ValueError as inspect_error:
65
+ # Re-raise with more descriptive message and with the original message as a note.
66
+ # The owner is not initialized yet, so do not try to print e.g. its name.
67
+ exc = ValueError(f"{owner.type_name} cannot accept function {func.__qualname__}()")
68
+ exc.add_note(inspect_error.args[0])
69
+ raise exc from None
70
+ self.signature = f"{self._func.__name__}({', '.join(arglist)})"
71
+
72
+ def resolve_names(self) -> list[block.Block|Formula]:
73
+ """Resolve names to block or formula objects."""
74
+ resolve_name = self._owner.circuit.resolve_name
75
+ self._inputs = [resolve_name(ref) for ref in self._inputs]
76
+ return t.cast(list[block.Block|Formula], self._inputs)
77
+
78
+ def run_function(self) -> t.Any:
79
+ """Run with output values of referenced blocks."""
80
+ # union-attr: after pre-init the _inputs does not contain strings
81
+ kwargs = dict(zip(
82
+ self._parameters,
83
+ (blk.get() for blk in self._inputs), # type: ignore[union-attr]
84
+ strict=True))
85
+ if UNDEF in kwargs.values():
86
+ assert self._owner.circuit.get_state() < circuit.CircuitState.RUNNING
87
+ if get_debug_level() >= 2:
88
+ undef = next(iter(param for param, value in kwargs.items() if value is UNDEF))
89
+ _logger.debug(
90
+ "%s: NOT calling the function, because '%s' is UNDEF", self._owner, undef)
91
+ return UNDEF
92
+ if get_debug_level() >= 1:
93
+ _logger.debug(
94
+ "%s: Calling %s", self._owner, func_call_string(self._func, (), kwargs))
95
+ try:
96
+ return self._func(**kwargs)
97
+ except Exception as err:
98
+ err.add_note(f"Failed function call originated from {self._owner}")
99
+ self._owner.circuit.abort(err)
100
+ raise
101
+
102
+
103
+ # Triggers do not have a name nor an output.
104
+ @t.final
105
+ class Trigger:
106
+ """
107
+ A circuit item monitoring output changes of selected blocks.
108
+ """
109
+ def __init__(self, func: Callable[..., t.Any]) -> None:
110
+ self._ext_func = _ExtFunction(func, owner=self)
111
+ self._str = f"<{self.type_name} for {self._ext_func.signature}>"
112
+ self.circuit = circuit.get_circuit()
113
+ self.circuit.rz_add_item(self)
114
+ self._enabled = False
115
+
116
+ def __str__(self) -> str:
117
+ return self._str
118
+
119
+ # for compatibility with Blocks and Formulas
120
+ @property
121
+ def type_name(self) -> str:
122
+ return type(self).__name__
123
+
124
+ def rz_pre_init(self) -> None:
125
+ for inp in self._ext_func.resolve_names():
126
+ inp.rz_add_trigger(self)
127
+
128
+ def run(self) -> None:
129
+ if self._enabled:
130
+ self._ext_func.run_function()
131
+
132
+ def rz_start(self) -> None:
133
+ self._enabled = True
134
+ self.run()
135
+
136
+ def rz_stop(self) -> None:
137
+ self._enabled = False
138
+
139
+
140
+ _FUNC = t.TypeVar("_FUNC", bound=Callable[..., t.Any])
141
+ def triggered(func: _FUNC) -> _FUNC:
142
+ """
143
+ @triggered adds a trigger to a function.
144
+
145
+ The function *func* itself it not changed.
146
+
147
+ All parameters of the decorated function refer to blocks with
148
+ the same name. When the output of any of those block changes
149
+ and none of them is UNDEF, the function will be called
150
+ with corresponding output values passed as arguments.
151
+ """
152
+ Trigger(func)
153
+ return func
154
+
155
+
156
+ @t.final
157
+ class Formula(base_block.BlockOrFormula):
158
+ """
159
+ A block with its output computed from other blocks` outputs.
160
+
161
+ The output is computed on demand with a provided function.
162
+ The function should be a pure function, i.e. without side effects.
163
+
164
+ The most convenient way to create a Formula block is the
165
+ @formula decorator.
166
+ """
167
+ def __init__(self, *args, func: Callable[..., t.Any], **kwargs) -> None:
168
+ super().__init__(*args, **kwargs)
169
+ self._ext_func = _ExtFunction(func, self)
170
+ self._evaluate_active = False
171
+
172
+ def rz_pre_init(self) -> None:
173
+ for inp in self._ext_func.resolve_names():
174
+ inp.rz_add_formula(self)
175
+
176
+ def rz_start(self) -> None:
177
+ self.evaluate()
178
+
179
+ def evaluate(self) -> set[Trigger]:
180
+ """
181
+ Evaluate this formula and dependent formulas.
182
+
183
+ Return a set of affected triggers.
184
+ """
185
+ if self._evaluate_active:
186
+ raise RuntimeError(f"{self}: detected a dependency loop")
187
+ result = self._ext_func.run_function()
188
+ if result is UNDEF or not self._set_output(result):
189
+ return set()
190
+ triggers = self._dependent_triggers.copy()
191
+ self._evaluate_active = True
192
+ for frm in self._dependent_formulas:
193
+ triggers |= frm.evaluate()
194
+ self._evaluate_active = False
195
+ return triggers
196
+
197
+ def formula(name: str, *args, **kwargs) -> Callable[[_FUNC], _FUNC]:
198
+ """@formula() creates a Formula block with the decorated function."""
199
+ if 'func' in kwargs:
200
+ # Argument func=... will be supplied by us
201
+ raise TypeError("@formula() got an unexpected keyword argument 'func='")
202
+ def decorator(func: _FUNC) -> _FUNC:
203
+ Formula(name, *args, func=func, **kwargs)
204
+ return func
205
+ return decorator