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/blocklib/fsm.py ADDED
@@ -0,0 +1,554 @@
1
+ """
2
+ Event driven finite-state machine (FSM) extended with optional timers.
3
+
4
+ - - - - - -
5
+ Docs: https://redzed.readthedocs.io/en/latest/
6
+ Home: https://github.com/xitop/redzed/
7
+ """
8
+ from __future__ import annotations
9
+
10
+ __all__ = ['FSM']
11
+
12
+ import asyncio
13
+ from collections.abc import Callable, Iterable, Mapping, Sequence
14
+ import functools
15
+ import inspect
16
+ import logging
17
+ import time
18
+ import types
19
+ import typing as t
20
+
21
+ import redzed
22
+ from redzed.utils import check_identifier, is_multiple, time_period, to_tuple
23
+
24
+
25
+ _logger = logging.getLogger(__package__)
26
+
27
+
28
+ def _loop_to_unixtime(looptime: float) -> float:
29
+ """Convert event loop time to standard Unix time."""
30
+ unixtime_func = time.time
31
+ looptime_func = asyncio.get_running_loop().time
32
+ unixbefore = unixtime_func()
33
+ loopnow = looptime_func()
34
+ unixafter = unixtime_func()
35
+ timediff = (unixbefore + unixafter) / 2 - loopnow
36
+ return looptime + timediff
37
+
38
+
39
+ _ALLOWED_KINDS = [inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD]
40
+
41
+ @functools.cache
42
+ def _hook_args(func: Callable[..., t.Any]) -> int:
43
+ """Check if *func* takes 0 or 1 argument."""
44
+ params = inspect.signature(func).parameters
45
+ plen = len(params)
46
+ if plen > 1 or any(p.kind not in _ALLOWED_KINDS for p in params.values()):
47
+ raise TypeError(
48
+ f"Function {func.__name__} is not usable as an FSM hook "
49
+ + "(incompatible call signature)")
50
+ return plen
51
+
52
+
53
+ class FSM(redzed.Block):
54
+ """
55
+ A base class for a Finite-state Machine with optional timers.
56
+ """
57
+
58
+ # subclasses should define:
59
+ ALL_STATES: Sequence[str] = []
60
+ TIMED_STATES: Sequence[Sequence] = []
61
+ # each timed state: [state, duration, next_state]
62
+ EVENTS: Sequence[Sequence] = []
63
+ # each item: [event, [state1, state2, ..., stateN], next_state]
64
+ # or: [event, ..., next_state] <- literal ellipsis
65
+ # --- and redzed will translate that to: ---
66
+ _ct_default_state: str
67
+ # the default initial state (first item of ALL_STATES)
68
+ _ct_duration: dict[str, float]
69
+ # {timed_state: default_duration_in_seconds}
70
+ _ct_events: set[str]
71
+ # all valid events
72
+ _ct_methods: dict[str, dict[str, Callable[[t.Self, str], t.Any]]]
73
+ # summary of cond_EVENT, duration_TIMED_STATE enter_STATE and exit_STATE methods
74
+ _ct_valid_names: dict[str, Iterable[str]]
75
+ # helper for parsing
76
+ _ct_states: set[str]
77
+ # all valid states
78
+ _ct_timed_states: dict[str, str]
79
+ # {timed_state: following_state}
80
+ # keys: all timed states
81
+ _ct_transition: dict[tuple[str, str|None], str|None]
82
+ # the transition table - higher priority: {(event, state): next_state}
83
+ # the transition table - lower priority: {(event, None): next_state}
84
+
85
+ @classmethod
86
+ def _check_state(cls, state: str) -> None:
87
+ if state not in cls._ct_states:
88
+ raise ValueError(f"FSM state '{state}' is unknown (missing in ALL_STATES)")
89
+
90
+ @classmethod
91
+ def _add_transition(cls, event: str, state: str|None, next_state: str|None) -> None:
92
+ if state is None and next_state is None and redzed.get_debug_level() >= 2:
93
+ _logger.warning("Useless transition rule: [%s, ..., None]", event)
94
+ key = (event, state)
95
+ if key in cls._ct_transition:
96
+ state_msg = "..." if state is None else state
97
+ raise ValueError(f"Multiple transitions rules ['{event}', {state_msg}, ???]")
98
+ cls._ct_transition[key] = next_state
99
+
100
+ @classmethod
101
+ def _build_tables(cls) -> None:
102
+ """
103
+ Build control tables from ALL_STATES, TIMED_STATES and EVENTS.
104
+
105
+ Control tables must be created for each subclass. The original
106
+ tables are left unchanged. All control tables are class
107
+ variables and have the '_ct_' prefix.
108
+ """
109
+ # states
110
+ if not is_multiple(cls.ALL_STATES) or not cls.ALL_STATES:
111
+ raise ValueError("ALL_STATES: Expecting non-empty sequence of names")
112
+ for state in cls.ALL_STATES:
113
+ check_identifier(state, "FSM state name")
114
+ cls._ct_states = set(cls.ALL_STATES)
115
+ cls._ct_default_state = cls.ALL_STATES[0]
116
+ # timed states
117
+ cls._ct_duration = {}
118
+ cls._ct_timed_states = {}
119
+ for state, duration, next_state in cls.TIMED_STATES:
120
+ cls._check_state(state)
121
+ cls._check_state(next_state)
122
+ if state in cls._ct_timed_states:
123
+ raise ValueError(f"TIMED_STATES: Multiple rules for timed state '{state}'")
124
+ if duration is not None:
125
+ try:
126
+ cls._ct_duration[state] = time_period(duration, zero_ok=True)
127
+ except (ValueError, TypeError) as err:
128
+ raise ValueError(
129
+ f"TIMED_STATES: could not convert duration of state '{state}' "
130
+ + f"to seconds: {err}") from None
131
+ cls._ct_timed_states[state] = next_state
132
+
133
+ # events and state transitions
134
+ cls._ct_transition = {}
135
+ cls._ct_events = set()
136
+ for event, from_states, next_state in cls.EVENTS:
137
+ check_identifier(event, "FSM event name")
138
+ if event in cls._edt_handlers:
139
+ raise ValueError(
140
+ f"Ambiguous event '{event}': "
141
+ + "the name is used for both FSM and Block event")
142
+ cls._ct_events.add(event)
143
+ if next_state is not None:
144
+ cls._check_state(next_state)
145
+ if from_states is ...:
146
+ # The ellipsis means any state. In control tables we are using None instead
147
+ cls._add_transition(event, None, next_state)
148
+ else:
149
+ if not is_multiple(from_states):
150
+ exc = ValueError(
151
+ "Expected is a literal ellipsis (...) or a sequence of states, "
152
+ + f"got {from_states!r}")
153
+ exc.add_note(
154
+ f"Problem was found in the transition rule for event '{event}'")
155
+ if from_states in cls._ct_states:
156
+ exc.add_note(f"Did you mean: ['{from_states}'] ?")
157
+ raise exc
158
+ for fstate in from_states:
159
+ cls._check_state(fstate)
160
+ cls._add_transition(event, fstate, next_state)
161
+
162
+ # helper table: name 'prefix_suffix' is valid if prefix is a dict key
163
+ # and suffix is listed in the corresponding dict value
164
+ cls._ct_valid_names = {
165
+ 'cond': cls._ct_events,
166
+ 'duration': cls._ct_timed_states,
167
+ 'enter': cls._ct_states,
168
+ 'exit': cls._ct_states,
169
+ 't': cls._ct_timed_states,
170
+ }
171
+
172
+ # class hooks
173
+ cls._ct_methods = {
174
+ 'cond': {}, # data format: {EVENT: cond_EVENT method}
175
+ 'duration': {}, # data format: {TIMED_STATE: duration_TIMED_STATE method}
176
+ 'enter': {}, # {STATE: enter_STATE method}
177
+ 'exit': {}, # {STATE: exit_STATE method}
178
+ }
179
+ for method_name, method in inspect.getmembers(cls):
180
+ if method_name.startswith('_') or not callable(method):
181
+ continue
182
+ try:
183
+ # ValueError in assignment if split into two pieces fails:
184
+ hook_type, name = method_name.split('_', 1)
185
+ hook_dict = cls._ct_methods[hook_type]
186
+ except (ValueError, KeyError):
187
+ continue
188
+ if name in cls._ct_valid_names[hook_type]:
189
+ hook_dict[name] = method
190
+ elif redzed.get_debug_level() >= 2:
191
+ _logger.warning(
192
+ "Method .%s() was not accepted by the FSM. "
193
+ "Check the name '%s' if necessary",
194
+ method_name, name)
195
+
196
+ def __init_subclass__(cls, *args, **kwargs) -> None:
197
+ """Build control tables."""
198
+ # call super().__init_subclass__ first, we will then check for possible
199
+ # event name clashes with the Block._edt_handlers
200
+ super().__init_subclass__(*args, **kwargs)
201
+ try:
202
+ cls._build_tables()
203
+ except Exception as err:
204
+ err.add_note(
205
+ f"Error occurred in FSM '{cls.__name__}' during validation of control tables")
206
+ raise
207
+
208
+ def __init__(self, *args, **kwargs) -> None:
209
+ """
210
+ Create FSM.
211
+
212
+ Handle keyword arguments named t_TIMED_STATE, cond_EVENT,
213
+ enter_STATE and exit_STATE.
214
+ """
215
+ if type(self) is FSM: # pylint: disable=unidiomatic-typecheck
216
+ raise TypeError("Can't instantiate abstract FSM class")
217
+ prefixed: dict[str, list[tuple[str, t.Any]]] = {
218
+ 'cond': [],
219
+ 'enter': [],
220
+ 'exit': [],
221
+ 't': [],
222
+ }
223
+ for arg in list(kwargs):
224
+ try:
225
+ hook_type, name = arg.split('_', 1)
226
+ hook_list = prefixed[hook_type]
227
+ except (ValueError, KeyError):
228
+ continue
229
+ valid_names = type(self)._ct_valid_names[hook_type]
230
+ if name in valid_names:
231
+ value = kwargs.pop(arg)
232
+ if value is not redzed.UNDEF:
233
+ hook_list.append((name, value))
234
+ else:
235
+ err = TypeError(
236
+ f"'{arg}' is an invalid keyword argument for {self.type_name}")
237
+ # Python 3.11 doesn't allow nested quotes in f-strings
238
+ names_msg = ', '.join(f"'{hook_type}_{n}'" for n in valid_names)
239
+ err.add_note(f"Valid are: {names_msg}")
240
+ raise err
241
+ # extra arguments are now removed from kwargs -> can call super().__init__
242
+ super().__init__(*args, **kwargs)
243
+
244
+ self._t_duration: dict[str, float] = {} # values passed as t_TIMED_STATE=duration
245
+ for state, value in prefixed['t']:
246
+ if (duration := time_period(value, passthrough=None, zero_ok=True)) is not None:
247
+ self._t_duration[state] = duration
248
+ self._instance_hooks = {
249
+ hook_type: {name: to_tuple(value) for name, value in prefixed[hook_type]}
250
+ for hook_type in ['cond', 'enter', 'exit']}
251
+
252
+ self._state: str|redzed.UndefType = redzed.UNDEF # storage for FSM state
253
+ self.sdata: dict[str, t.Any] = {} # storage for additional internal state data
254
+ # Restoring state differs in details from setting a new state.
255
+ # _restore_timer values:
256
+ # UNDEF = no state to be restored
257
+ # None = restore a not-timed state
258
+ # float = restore a timed state and this is its expiration time (UNIX timestamp)
259
+ self._restore_timer: float|None|redzed.UndefType = redzed.UNDEF
260
+ self._active_timer: asyncio.Handle|None = None
261
+ self._event_handler_lock = False # detection of recursive calls
262
+ self._edata: Mapping[str, t.Any]|None = None
263
+ # read-only data of the currently processed event
264
+
265
+ @property
266
+ def state(self) -> str|redzed.UndefType:
267
+ """Return the FSM state (string) or UNDEF if not initialized."""
268
+ return self._state
269
+
270
+ def rz_export_state(self) -> tuple[str, float|None, dict[str, t.Any]]:
271
+ """
272
+ Return the block's internal state.
273
+
274
+ The internal state is a broader term than the FSM state.
275
+ Internal state consist of 3 items:
276
+ - FSM state (str)
277
+ - timer expiration timestamp or None if there is no timer.
278
+ The timestamp uses UNIX time (float).
279
+ - additional state data (sdata, a dict)
280
+ """
281
+ assert self._state is not redzed.UNDEF # @mypy: contract
282
+ timer = self._active_timer
283
+ # we are using both plain Handle (call_soon) and TimerHandle (call_later)
284
+ if isinstance(timer, asyncio.TimerHandle) and not timer.cancelled():
285
+ timestamp = _loop_to_unixtime(timer.when())
286
+ else:
287
+ timestamp = None
288
+ return (self._state, timestamp, self.sdata)
289
+
290
+ def rz_restore_state(self, internal_state: Sequence, /) -> None:
291
+ """
292
+ Restore the internal state created by rz_export_state().
293
+
294
+ cond_STATE and enter_STATE are not executed, because the state
295
+ was already entered in the past. Now it is only restored.
296
+ """
297
+ assert self._state is redzed.UNDEF # this is the very first init function
298
+ state, timestamp, sdata = internal_state
299
+ self._check_state(state)
300
+ if timestamp is not None:
301
+ if state not in self._ct_timed_states:
302
+ self.log_debug2(
303
+ "Rejecting saved state: a timer was saved in FSM state '%s', "
304
+ + "but this state is now not timed", state)
305
+ return
306
+ if timestamp <= time.time():
307
+ self.log_debug2("Rejecting saved timed state, because it has expired")
308
+ return
309
+ # state accepted
310
+ self._restore_timer = timestamp
311
+ self.sdata = sdata
312
+ self._state = state
313
+ self._set_output(self._state)
314
+
315
+ def rz_init(self, init_value: str, /) -> None:
316
+ """Set the initial FSM state."""
317
+ # Do not call self.event() for initialization.
318
+ # It would try to initialize before processing the event.
319
+ self._check_state(init_value)
320
+ self.log_debug1("initial state: %s", init_value)
321
+ self._state = init_value
322
+ self._set_output(self._state)
323
+
324
+ def rz_init_default(self) -> None:
325
+ """Initialize the internal state."""
326
+ self.rz_init(self._ct_default_state)
327
+
328
+ def rz_start(self) -> None:
329
+ """
330
+ Start activities according to the initial state.
331
+
332
+ Run 'enter' hooks unless the initial state was restored
333
+ from the persistent storage, i.e. has been entered already
334
+ in the past.
335
+
336
+ Start the timer if the initial state is timed.
337
+ """
338
+ assert self._state is not redzed.UNDEF
339
+ # restored state
340
+ if self._restore_timer is not redzed.UNDEF:
341
+ if self._restore_timer is not None:
342
+ remaining = max(self._restore_timer - time.time(), 0.0)
343
+ self._set_timer(remaining, self._ct_timed_states[self._state])
344
+ self._restore_timer = redzed.UNDEF # value no longer needed
345
+ return
346
+ # new state
347
+ self._start_now(self._state)
348
+
349
+ def rz_stop(self) -> None:
350
+ """Cleanup."""
351
+ self._stop_timer()
352
+
353
+ def _set_timer(self, duration: float, following_state: str) -> None:
354
+ """Start the timer (low-level)."""
355
+ if zero_delay := duration <= 0.0:
356
+ duration = 0.0
357
+ self.log_debug1("timer: %.3fs before entering '%s'", duration, following_state)
358
+ loop = asyncio.get_running_loop()
359
+ if zero_delay:
360
+ self.log_debug2("note: zero delay is not possible due to overhead")
361
+ self._active_timer = loop.call_soon(self._goto, following_state)
362
+ else:
363
+ self._active_timer = loop.call_later(duration, self._goto, following_state)
364
+
365
+ def _start_timer(
366
+ self, edata_duration: float|str|None, following_state: str) -> None:
367
+ """Start the timer before enterint the following_state."""
368
+ state = self._state
369
+ assert state is not redzed.UNDEF # starting a timer implies a timed state
370
+ duration = time_period(edata_duration, passthrough=None, zero_ok=True)
371
+ if duration is None:
372
+ duration = self._run_hooks('duration', state)
373
+ if duration is None:
374
+ duration = self._t_duration.get(state)
375
+ if duration is None:
376
+ duration = type(self)._ct_duration.get(state)
377
+ if duration is None: # not found or explicitly set to None
378
+ raise RuntimeError(f"Timer duration for state '{state}' not set")
379
+ if duration == float('inf'):
380
+ return
381
+ self._set_timer(duration, following_state)
382
+
383
+ def _stop_timer(self) -> None:
384
+ """Stop the timer, if any."""
385
+ if (timer := self._active_timer) is not None:
386
+ if not timer.cancelled():
387
+ timer.cancel()
388
+ # do not rely on the existence of the private attribute '_scheduled'
389
+ if getattr(timer, '_scheduled', True):
390
+ self.log_debug2("timer: cancelled")
391
+ self._active_timer = None
392
+
393
+ def _yield_hooks(
394
+ self, hook_type: t.Literal['cond', 'duration', 'enter', 'exit'], name: str
395
+ ) -> t.Iterator[Callable[[], t.Any]|Callable[[Mapping[str, t.Any]], t.Any]]:
396
+ """
397
+ Yield all hooks of given type for given state/event.
398
+ """
399
+ try:
400
+ hook = self._ct_methods[hook_type][name]
401
+ except KeyError:
402
+ pass
403
+ else:
404
+ # class_hook is an unbound method -> bind self
405
+ # pylint: disable-next=unnecessary-dunder-call
406
+ yield hook.__get__(self, type(self))
407
+ try:
408
+ hooks = self._instance_hooks[hook_type][name]
409
+ except KeyError:
410
+ pass
411
+ else:
412
+ yield from hooks
413
+
414
+ @t.overload
415
+ def _run_hooks(self, hook_type: t.Literal['cond'], name: str) -> bool: ...
416
+ # Return the logical conjunction of return values using short-circuit evaluation
417
+ @t.overload
418
+ def _run_hooks(self, hook_type: t.Literal['duration'], name: str) -> float|None: ...
419
+ # There are no 'duration' instance hooks. Return the result of the class hook or None
420
+ @t.overload
421
+ def _run_hooks(self, hook_type: t.Literal['enter', 'exit'], name: str) -> list[t.Any]: ...
422
+ # Return individual values (they are currently ignored).
423
+ def _run_hooks(
424
+ self, hook_type: t.Literal['cond', 'duration', 'enter', 'exit'], name: str
425
+ ) -> bool|float|None|list[t.Any]:
426
+ """Run hooks 'cond', 'duration', 'enter' or 'exit'."""
427
+ called = False
428
+ retvals = []
429
+ for hook in self._yield_hooks(hook_type, name):
430
+ if not called:
431
+ self.log_debug2("Calling hooks '%s_%s'", hook_type, name)
432
+ called = True
433
+ assert self._edata is not None # @mypy
434
+ rv = hook() if _hook_args(hook) == 0 else hook(self._edata) # type: ignore[call-arg]
435
+ retvals.append(rv)
436
+ if not rv and hook_type == 'cond':
437
+ break
438
+ if not called:
439
+ self.log_debug2("No '%s_%s' hooks found", hook_type, name)
440
+ if hook_type == 'cond':
441
+ return not retvals or bool(retvals[-1])
442
+ if hook_type == 'duration':
443
+ assert len(retvals) <= 1
444
+ return time_period(retvals[0], passthrough=None, zero_ok=True) if retvals else None
445
+ return retvals
446
+
447
+ def _event__get_config(self, _edata: redzed.EventData) -> dict[str, t.Any]:
448
+ """Debugging aid '_get_config'."""
449
+ cls = type(self)
450
+ # pylint: disable=protected-access
451
+ return {
452
+ 'durations': cls._ct_duration | self._t_duration,
453
+ 'events': cls._ct_events,
454
+ 'states': cls._ct_states,
455
+ 'timed_transitions': cls._ct_timed_states,
456
+ 'transitions': cls._ct_transition,
457
+ }
458
+
459
+ def _fsm_event_handler(self, etype: str) -> bool:
460
+ """
461
+ Handle event. Check validity and conditions.
462
+
463
+ Timed states look for 'duration' key in 'edata'. If it is
464
+ present, the value overrides the default timer duration.
465
+
466
+ Return value:
467
+ True = transition accepted and executed
468
+ False = transition rejected
469
+ """
470
+ assert self._state is not redzed.UNDEF # @mypy: tested in caller
471
+ start_event = False # special event used only when booting the FSM
472
+ next_state: str|None
473
+ if etype.startswith("Goto:"):
474
+ next_state = etype[5:] # strip "Goto:" prefix
475
+ self._check_state(next_state)
476
+ elif etype.startswith("Start:"):
477
+ next_state = etype[6:] # strip prefix
478
+ if next_state != self._state:
479
+ raise RuntimeError("Event 'Start:STATE' was used incorrectly")
480
+ start_event = True
481
+ else:
482
+ if etype not in self._ct_events:
483
+ raise redzed.UnknownEvent("Unknown event type")
484
+ # not using .get(key) because None and not found are different cases
485
+ key: tuple[str, str|None]
486
+ if (key := (etype, self._state)) not in self._ct_transition:
487
+ key = (etype, None)
488
+ next_state = self._ct_transition.get(key)
489
+ if next_state is None:
490
+ self.log_debug2(
491
+ "No transition defined for event '%s' in state '%s'", etype, self._state)
492
+ return False
493
+ if not start_event and not self._run_hooks('cond', etype):
494
+ self.log_debug2(
495
+ "event '%s' (%s -> %s) was rejected by cond_%s",
496
+ etype, self._state, next_state, etype)
497
+ return False
498
+
499
+ if not start_event:
500
+ self._run_hooks('exit', self._state)
501
+ self._stop_timer()
502
+ self.log_debug1("state: %s -> %s (event: %s)", self._state, next_state, etype)
503
+ self._state = next_state
504
+ self._set_output(self._state)
505
+ if (following_state := self._ct_timed_states.get(self._state)) is not None:
506
+ assert self._edata is not None # @mypy
507
+ self._start_timer(self._edata.get('duration'), following_state)
508
+ self._run_hooks('enter', self._state)
509
+ return True
510
+
511
+ def _default_event_handler(self, etype: str, edata: redzed.EventData) -> bool:
512
+ """
513
+ Wrapper creating a separate context.
514
+
515
+ Refer to _fsm_event_handler().
516
+ """
517
+ if self._state is redzed.UNDEF:
518
+ raise RuntimeError(f"{self}: Received event '{etype}' before initialization")
519
+ if self._event_handler_lock:
520
+ raise RuntimeError("Recursion error: Got an event while handling an event.")
521
+ self._event_handler_lock = True
522
+ self._edata = types.MappingProxyType(edata)
523
+ try:
524
+ return self._fsm_event_handler(etype)
525
+ except Exception as err:
526
+ err.add_note(f"{self}: Error occurred while handling event '{etype}'")
527
+ raise
528
+ finally:
529
+ self._event_handler_lock = False
530
+ self._edata = None
531
+
532
+ def _send_synthetic_event(self, etype: str, edata: redzed.EventData|None = None) -> None:
533
+ """
534
+ Send an synthetic event (implementation detail).
535
+
536
+ To protect the FSM, the used event names are not
537
+ accepted by the .event() entry point.
538
+ """
539
+ if edata:
540
+ self.log_debug2("Synthetic event '%s', edata: %s", etype, edata)
541
+ else:
542
+ self.log_debug2("Synthetic event '%s'", etype)
543
+ # we muss bypass .event()
544
+ self._default_event_handler(etype, {} if edata is None else edata)
545
+
546
+ def _goto(self, state: str) -> None:
547
+ """Unconditionally go to 'state'. To be used by the FSM itself only!"""
548
+ self._send_synthetic_event(f"Goto:{state}")
549
+ if self.rz_persistence & redzed.PersistenceFlags.EVENT:
550
+ self.circuit.save_persistent_state(self)
551
+
552
+ def _start_now(self, state: str) -> None:
553
+ """Start after initialization. To be used by the FSM itself only!"""
554
+ self._send_synthetic_event(f"Start:{state}")