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 ADDED
@@ -0,0 +1,44 @@
1
+ """
2
+ Redzed is a library for building small automated systems.
3
+
4
+ The redzed package allows to build a so-called "circuit" containing:
5
+ - logic Blocks with outputs reacting to events
6
+ - Triggers sending events when certain outputs change
7
+
8
+ The application code must connect the circuit with outside world.
9
+
10
+ Copyright (c) 2025 Vlado Potisk <redzed@poti.sk>.
11
+
12
+ Released under the MIT License.
13
+
14
+ # Docs: https://redzed.readthedocs.io/en/latest/
15
+ # Home: https://github.com/xitop/redzed/
16
+ """
17
+
18
+ __version_info__ = (25, 12, 30)
19
+ __version__ = '.'.join(str(n) for n in __version_info__)
20
+
21
+ from . import circuit, block, debug, formula_trigger, initializers, undef
22
+
23
+ from .block import *
24
+ from .circuit import *
25
+ from .debug import *
26
+ from .formula_trigger import *
27
+ from .initializers import *
28
+ from .undef import *
29
+ # .utils not imported
30
+
31
+ # block library
32
+ from . import blocklib
33
+ from .blocklib import *
34
+
35
+ __all__ = [
36
+ '__version__', '__version_info__',
37
+ *block.__all__,
38
+ *blocklib.__all__,
39
+ *circuit.__all__,
40
+ *debug.__all__,
41
+ *formula_trigger.__all__,
42
+ *initializers.__all__,
43
+ *undef.__all__,
44
+ ]
redzed/base_block.py ADDED
@@ -0,0 +1,132 @@
1
+ """
2
+ Base class of logic Blocks and Formulas.
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
+ import inspect
11
+ import logging
12
+ import textwrap
13
+ import typing as t
14
+
15
+ from . import circuit
16
+ from .debug import get_debug_level
17
+ from .undef import UNDEF
18
+ from .utils import check_identifier
19
+
20
+ _logger = logging.getLogger(__package__)
21
+
22
+
23
+ class BlockOrFormula:
24
+ """
25
+ The common part of a logic Block or Formula.
26
+
27
+ Check the name and register the new item.
28
+ """
29
+
30
+ def __init__(self, name: str, *, comment: str = "") -> None:
31
+ """Create new circuit component. Add it to the circuit."""
32
+ # These are the only two allowed concrete subsclasses
33
+ if not isinstance(self, (block.Block, formula_trigger.Formula)):
34
+ raise TypeError("Cannot instantiate an abstract class")
35
+ self.circuit = circuit.get_circuit()
36
+ check_identifier(name, "Block/Formula name")
37
+ if name.startswith('_') and not getattr(type(self), 'RZ_RESERVED', False):
38
+ raise ValueError(f"Name '{name}' is reserved (starting with an underscore)")
39
+ # reserved blocks cannot be created by simple mistake,
40
+ # because types of reserved blocks (e.g. Cron) are not public
41
+ self.name = name
42
+ self.comment = str(comment)
43
+ self._str_cached: str|None = None # cache for __str__ value
44
+ self.circuit.rz_add_item(self)
45
+ self._dependent_formulas: set[formula_trigger.Formula] = set()
46
+ self._dependent_triggers: set[formula_trigger.Trigger] = set()
47
+ self._output = UNDEF
48
+
49
+ @property
50
+ def type_name(self) -> str:
51
+ return type(self).__name__
52
+
53
+ def has_method(self, method_name: str, async_method: bool = False) -> bool:
54
+ if not callable(method := getattr(self, method_name, None)):
55
+ return False
56
+ if async_method and not inspect.iscoroutinefunction(method):
57
+ return False
58
+ return True
59
+
60
+ def rz_add_formula(self, formula: formula_trigger.Formula) -> None:
61
+ """Add a formula block depending on our output value."""
62
+ self._dependent_formulas.add(formula)
63
+
64
+ def rz_add_trigger(self, trigger: formula_trigger.Trigger) -> None:
65
+ """Add a trigger block depending on our output value."""
66
+ self._dependent_triggers.add(trigger)
67
+
68
+ def _set_output(self, output: t.Any) -> bool:
69
+ """Set output."""
70
+ if output is UNDEF:
71
+ raise ValueError(f"{self}: Cannot set output to <UNDEF>.")
72
+ if output == self._output:
73
+ return False
74
+ if get_debug_level() >= 1:
75
+ self.log_debug("Output: %r -> %r", self.get(), output)
76
+ self._output = output
77
+ return True
78
+
79
+ def get(self) -> t.Any:
80
+ return self._output
81
+
82
+ def is_undef(self) -> bool:
83
+ return self._output is UNDEF
84
+
85
+ def log_msg(self, msg: str, *args: t.Any, level: int, **kwargs: t.Any) -> None:
86
+ """Add own name and log the message with given severity level."""
87
+ _logger.log(level, f"{self} {msg}", *args, **kwargs)
88
+
89
+ def log_debug(self, msg: str, *args: t.Any, **kwargs: t.Any) -> None:
90
+ """Log a message with DEBUG severity."""
91
+ self.log_msg(msg, *args, level=logging.DEBUG, **kwargs)
92
+
93
+ def log_debug1(self, msg: str, *args: t.Any, **kwargs: t.Any) -> None:
94
+ """Log a message if debugging is enabled."""
95
+ if get_debug_level() >= 1:
96
+ self.log_msg(msg, *args, level=logging.DEBUG, **kwargs)
97
+
98
+ def log_debug2(self, msg: str, *args: t.Any, **kwargs: t.Any) -> None:
99
+ """Log a message if verbose debugging is enabled."""
100
+ if get_debug_level() >= 2:
101
+ self.log_msg(msg, *args, level=logging.DEBUG, **kwargs)
102
+
103
+ def log_info(self, msg: str, *args: t.Any, **kwargs: t.Any) -> None:
104
+ """Log a message with _INFO_ severity."""
105
+ self.log_msg(msg, *args, level=logging.INFO, **kwargs)
106
+
107
+ def log_warning(self, msg: str, *args: t.Any, **kwargs: t.Any) -> None:
108
+ """Log a message with _WARNING_ severity."""
109
+ self.log_msg(msg, *args, level=logging.WARNING, **kwargs)
110
+
111
+ def log_error(self, msg: str, *args: t.Any, **kwargs: t.Any) -> None:
112
+ """Log a message with _ERROR_ severity."""
113
+ self.log_msg(msg, *args, level=logging.ERROR, **kwargs)
114
+
115
+ def __str__(self) -> str:
116
+ if self._str_cached is not None:
117
+ return self._str_cached
118
+ if not hasattr(self, 'name'):
119
+ # a subclass did not call super().__init__(name, ...) yet
120
+ return f"<{self.type_name} N/A id={hex(id(self))}>"
121
+ parts = [self.type_name, self.name]
122
+ if self.comment:
123
+ short_comment = textwrap.shorten(self.comment, width=40, placeholder="...")
124
+ parts.append(f"comment='{short_comment}'")
125
+ self._str_cached = f"<{' '.join(parts)}>"
126
+ return self._str_cached
127
+
128
+ # Importing at the end resolves a circular import issue.
129
+ # formula_trigger.Formula and block.Block are subclasses of BlockOrFormula defined here.
130
+ # pylint: disable=wrong-import-position
131
+ from . import formula_trigger
132
+ from . import block
redzed/block.py ADDED
@@ -0,0 +1,290 @@
1
+ """
2
+ Logic Blocks.
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__ = ['Block', 'CircuitShutDown', 'EventData', 'PersistenceFlags', 'UnknownEvent']
11
+
12
+ import asyncio
13
+ from collections.abc import Callable
14
+ import enum
15
+ import logging
16
+ import typing as t
17
+
18
+ from .base_block import BlockOrFormula
19
+ from .debug import get_debug_level
20
+ from . import initializers
21
+ from .undef import UNDEF, UndefType
22
+ from .utils import check_identifier, is_multiple, time_period
23
+
24
+ _logger = logging.getLogger(__package__)
25
+ _DEFAULT_STOP_TIMEOUT = 10.0
26
+
27
+
28
+ EventData: t.TypeAlias = dict[str, t.Any]
29
+
30
+
31
+ class UnknownEvent(Exception):
32
+ """Event type not supported."""
33
+
34
+
35
+ class CircuitShutDown(RuntimeError):
36
+ """Cannot perform an action after shutdown."""
37
+
38
+
39
+ class PersistenceFlags(enum.IntFlag):
40
+ """When and how to save block's internal state to the persistent storage."""
41
+ ENABLED = enum.auto() # persistent state is enabled
42
+ # additional options, they may be set only if ENABLED,
43
+ # EVENT and INTERVAL are mutually exclusive
44
+ EVENT = enum.auto() # save after each event
45
+ INTERVAL = enum.auto() # save periodically
46
+
47
+
48
+ class Block(BlockOrFormula):
49
+ """
50
+ A circuit block maintaining its own state.
51
+
52
+ Logic blocks accept events and modify their state in response
53
+ to them. They have one output depending solely on the state.
54
+ """
55
+
56
+ # event handling methods _event_NAME
57
+ _edt_handlers: dict[str, Callable[[t.Self, EventData], t.Any]]
58
+ # class attr RZ_PERSISTENCE: is persistent state supported?
59
+ # instance attr rz_persistence: see PersistenceFlags
60
+ RZ_PERSISTENCE: bool
61
+
62
+ def __init_subclass__(cls, *args, **kwargs) -> None:
63
+ """
64
+ Create event dispatch table (edt) of all specialized event handlers.
65
+
66
+ Add flag if persistent state is supported.
67
+ """
68
+ super().__init_subclass__(*args, **kwargs)
69
+ cls._edt_handlers = {}
70
+ for mro in cls.__mro__:
71
+ if issubclass(mro, Block):
72
+ for mname, method in vars(mro).items():
73
+ if not callable(method):
74
+ continue # it's a plain attr
75
+ # When multiple handlers exist, save only the first one, because it has
76
+ # the highest rank in the MRO hierarchy.
77
+ if len(mname) > 7 and mname.startswith('_event_'):
78
+ cls._edt_handlers.setdefault(mname[7:], method)
79
+
80
+ cls.RZ_PERSISTENCE = (callable(getattr(cls, 'rz_export_state', None))
81
+ and callable(getattr(cls, 'rz_restore_state', None)))
82
+
83
+ def __init__(
84
+ self, *args,
85
+ initial: t.Any = UNDEF,
86
+ stop_timeout: float|str|UndefType = UNDEF,
87
+ output_counter: bool = False,
88
+ output_previous: bool = False,
89
+ **kwargs
90
+ ) -> None:
91
+ """
92
+ Process arguments.
93
+
94
+ Check if given arguments are valid for the particular block type.
95
+ """
96
+ if type(self) is Block: # pylint: disable=unidiomatic-typecheck
97
+ raise TypeError("Cannot instantiate the base class 'Block'")
98
+ # remove x_arg=... and X_ARG=... from kwargs before calling super().__init__
99
+ for key in list(kwargs):
100
+ if key.startswith(('x_', 'X_')):
101
+ setattr(self, key, kwargs.pop(key))
102
+ super().__init__(*args, **kwargs)
103
+
104
+ if initial is UNDEF:
105
+ self.rz_initializers = []
106
+ else:
107
+ if not self.has_method('rz_init'):
108
+ raise TypeError(
109
+ f"{self}: Keyword argument 'initial' is not supported by this block type")
110
+ init_types = (initializers.SyncInitializer, initializers.AsyncInitializer)
111
+ if not is_multiple(initial):
112
+ if not isinstance(initial, init_types):
113
+ initial = initializers.InitValue(initial)
114
+ self.rz_initializers = [initial]
115
+ else:
116
+ init_cnt = sum(1 for init in initial if isinstance(init, init_types))
117
+ if init_cnt == 0:
118
+ # single value which happens to be a sequence
119
+ self.rz_initializers = [initializers.InitValue(initial)]
120
+ elif init_cnt == len(initial):
121
+ # sequence of initializers
122
+ self.rz_initializers = initial
123
+ else:
124
+ raise TypeError(
125
+ f"{self}: Check the initial argument. "
126
+ + "A non-initializer was found in the sequence of initializers.")
127
+
128
+ restore_initializers = [
129
+ init for init in self.rz_initializers
130
+ if isinstance(init, initializers.RestoreState)]
131
+ self.rz_persistence = PersistenceFlags(0)
132
+ if restore_initializers:
133
+ if not type(self).RZ_PERSISTENCE:
134
+ raise TypeError(
135
+ f"{self}: 'RestoreState' initializer is not supported by this block type")
136
+ if len(restore_initializers) != 1:
137
+ raise ValueError("Multiple 'RestoreState' initializers are not allowed")
138
+ self.rz_persistence |= PersistenceFlags.ENABLED # keep the int type
139
+ init = restore_initializers[0]
140
+ checkpoints = init.rz_checkpoints
141
+ if checkpoints == 'event':
142
+ self.rz_persistence |= PersistenceFlags.EVENT
143
+ elif checkpoints == 'interval':
144
+ self.rz_persistence |= PersistenceFlags.INTERVAL
145
+ self.rz_key = f"{self.type_name}:{self.name}"
146
+
147
+ has_astop = self.has_method('rz_astop')
148
+ self.rz_stop_timeout: float|None
149
+ if stop_timeout is UNDEF:
150
+ if has_astop:
151
+ self.log_debug2("Using default: stop_timeout=%.1f", _DEFAULT_STOP_TIMEOUT)
152
+ self.rz_stop_timeout = _DEFAULT_STOP_TIMEOUT
153
+ else:
154
+ self.rz_stop_timeout = None
155
+ else:
156
+ if not has_astop:
157
+ raise TypeError(
158
+ f"{self}: Keyword argument 'stop_timeout' "
159
+ + "is not supported by this block type")
160
+ self.rz_stop_timeout = time_period(stop_timeout)
161
+ self._etypes_active: set[str] = set() # events in-progress, disallow event recursion
162
+ if output_counter and output_previous:
163
+ raise TypeError(
164
+ "Options 'output_counter' and 'output_previous' are mutually exclusive.")
165
+ self._counter = 0 if output_counter else -1
166
+ self._previous = bool(output_previous)
167
+ self._init_task: asyncio.Task[t.Any]|None = None
168
+
169
+ def rz_set_inittask(self, task: asyncio.Task[t.Any]) -> None:
170
+ """
171
+ Give a reference to the task initializing this block.
172
+
173
+ We will cancel it when the initialization is done.
174
+ """
175
+ self._init_task = task
176
+
177
+ def _set_output(self, output: t.Any) -> bool:
178
+ """
179
+ Set output, recalculate dependent formulas, run affected triggers.
180
+ """
181
+ if self._output is UNDEF and self._init_task is not None:
182
+ if not self._init_task.done():
183
+ self._init_task.cancel()
184
+ self._init_task = None
185
+ if self._counter >= 0:
186
+ output = (output, self._counter)
187
+ self._counter += 1
188
+ if self._counter >= 2**48: # more than enough for this purpose
189
+ self._counter = 0
190
+ if self._previous:
191
+ output = (output, self._output)
192
+ if not super()._set_output(output):
193
+ return False
194
+ triggers = self._dependent_triggers.copy()
195
+ for frm in self._dependent_formulas:
196
+ triggers |= frm.evaluate()
197
+ for trg in triggers:
198
+ trg.run()
199
+ return True
200
+
201
+ def rz_init_default(self) -> None:
202
+ """
203
+ Set output to None for blocks that do not have init functions.
204
+
205
+ It is assumed that these blocks do not use their output.
206
+ """
207
+ if self.is_undef() and not self.has_method('rz_init'):
208
+ self._set_output(None)
209
+
210
+ def _default_event_handler(self, etype: str, edata: EventData) -> t.Any:
211
+ """Default event handler."""
212
+ raise UnknownEvent(f"{self}: Unknown event type {etype!r}")
213
+
214
+ # note the double underscore: ...vent_ + _get...
215
+ def _event__get_names(self, _edata: EventData) -> t.Any:
216
+ """Handle '_get_names' monitoring event."""
217
+ return {'type': self.type_name, 'name': self.name, 'comment': self.comment}
218
+
219
+ def _event__get_output(self, _edata: EventData) -> t.Any:
220
+ """Handle '_get_state' monitoring event."""
221
+ return self.get()
222
+
223
+ def _event__get_state(self, _edata: EventData) -> t.Any:
224
+ """Handle '_get_state' monitoring event."""
225
+ if not self.has_method('rz_export_state'):
226
+ raise UnknownEvent(f"{self.type_name} blocks do not support this event")
227
+ if self.is_undef():
228
+ raise RuntimeError("Not initialized yet")
229
+ # pylint: disable-next=no-member
230
+ return self.rz_export_state() # type: ignore[attr-defined]
231
+
232
+ def event(self, etype: str, /, evalue: t.Any = UNDEF, **edata: t.Any) -> t.Any:
233
+ """
234
+ An entry point for events.
235
+
236
+ Call the specialized _event_ETYPE() method if it exists.
237
+ Otherwise call the _default_event_handler() as the last resort.
238
+ """
239
+ if self.circuit.after_shutdown() and not etype.startswith('_get_'):
240
+ raise CircuitShutDown("The circuit was shut down")
241
+
242
+ if evalue is not UNDEF:
243
+ edata['evalue'] = evalue
244
+
245
+ check_identifier(etype, "Event type")
246
+ if get_debug_level() >= 1:
247
+ if not edata:
248
+ self.log_debug("Got event '%s'", etype)
249
+ elif len(edata) == 1 and 'evalue' in edata:
250
+ self.log_debug("Got event '%s', evalue: %r", etype, edata['evalue'])
251
+ else:
252
+ self.log_debug("Got event '%s', edata: %s", etype, edata)
253
+ if etype in self._etypes_active:
254
+ # we must report the error both to the caller and to the runner
255
+ exc = RuntimeError(
256
+ f"{self}: Event '{etype}' generated another event of the same type")
257
+ self.circuit.abort(exc)
258
+ raise exc
259
+ self._etypes_active.add(etype)
260
+ try:
261
+ if self.is_undef():
262
+ # We will allow the event, because:
263
+ # - some blocks need an event for their initialization
264
+ # - this event could have arrived during initialization by chance or race
265
+ self.log_debug2("Pending event, initializing now")
266
+ self.circuit.init_block_sync(self)
267
+ handler = type(self)._edt_handlers.get(etype)
268
+ try:
269
+ if handler:
270
+ # handler is an unbound method => must add 'self'
271
+ retval = handler(self, edata)
272
+ else:
273
+ retval = self._default_event_handler(etype, edata)
274
+ except UnknownEvent:
275
+ self.log_debug1("Unknown event error raised")
276
+ raise
277
+ except Exception as err:
278
+ err.add_note(f"Error occurred in {self} during handling of event '{etype}'; "
279
+ + f"event data was: {edata if edata else '<EMPTY>'}")
280
+ if isinstance(err, KeyError) and err.args[0] == 'evalue':
281
+ err.add_note("A required event value is almost certainly missing")
282
+ raise
283
+ if (self.rz_persistence & PersistenceFlags.EVENT
284
+ and not etype.startswith('_get_') # nothing has changed
285
+ and not self.is_undef()): # nothing to save
286
+ self.circuit.save_persistent_state(self)
287
+ self.log_debug2("Event '%s' returned: %r", etype, retval)
288
+ return retval
289
+ finally:
290
+ self._etypes_active.remove(etype)
@@ -0,0 +1,26 @@
1
+ """
2
+ A library of pre-defined blocks.
3
+ - - - - - -
4
+ Part of the redzed package.
5
+ Docs: https://redzed.readthedocs.io/en/latest/
6
+ Home: https://github.com/xitop/redzed/
7
+ """
8
+
9
+ from . import counter, fsm, inputs, outputs, repeat, timedate, timer
10
+ from .counter import *
11
+ from .fsm import *
12
+ from .inputs import *
13
+ from .outputs import *
14
+ from .repeat import *
15
+ from .timedate import *
16
+ from .timer import *
17
+
18
+ __all__ = [
19
+ *counter.__all__,
20
+ *fsm.__all__,
21
+ *inputs.__all__,
22
+ *outputs.__all__,
23
+ *repeat.__all__,
24
+ *timedate.__all__,
25
+ *timer.__all__,
26
+ ]
@@ -0,0 +1,45 @@
1
+ """
2
+ Counter.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import redzed
8
+
9
+ __all__ = ['Counter']
10
+
11
+
12
+ class Counter(redzed.Block):
13
+ """
14
+ Counter. If modulo is set to a number M, count modulo M.
15
+ """
16
+
17
+ def __init__(self, *args, modulo: int|None = None, **kwargs) -> None:
18
+ super().__init__(*args, **kwargs)
19
+ if modulo == 0:
20
+ raise ValueError("modulo must not be zero")
21
+ self._mod = modulo
22
+
23
+ def _setmod(self, value: int) -> int:
24
+ output = value if self._mod is None else value % self._mod
25
+ self._set_output(output)
26
+ return output
27
+
28
+ def _event_inc(self, edata: redzed.EventData) -> int:
29
+ return self._setmod(self._output + edata.get('evalue', 1))
30
+
31
+ def _event_dec(self, edata: redzed.EventData) -> int:
32
+ return self._setmod(self._output - edata.get('evalue', 1))
33
+
34
+ def _event_put(self, edata: redzed.EventData) -> int:
35
+ return self._setmod(edata['evalue'])
36
+
37
+ def rz_init_default(self) -> None:
38
+ self._set_output(0)
39
+
40
+ rz_init = _setmod
41
+
42
+ def rz_export_state(self):
43
+ return self.get()
44
+
45
+ rz_restore_state = _setmod