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,210 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A single memory cell blocks for general use.
|
|
3
|
+
- - - - - -
|
|
4
|
+
Part of the redzed package.
|
|
5
|
+
Docs: https://edzed.readthedocs.io/en/latest/
|
|
6
|
+
Home: https://github.com/xitop/edzed/
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__all__ = ['Memory', 'MemoryExp', 'DataPoll']
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from collections.abc import Callable, Sequence
|
|
14
|
+
import time
|
|
15
|
+
import typing as t
|
|
16
|
+
|
|
17
|
+
import redzed
|
|
18
|
+
from redzed.utils import is_multiple, time_period
|
|
19
|
+
from .fsm import FSM
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _Validate(redzed.Block):
|
|
23
|
+
"""
|
|
24
|
+
Add a value validator.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self, *args,
|
|
29
|
+
validator: Callable[[t.Any], t.Any]|None = None,
|
|
30
|
+
**kwargs) -> None:
|
|
31
|
+
self._validator = validator
|
|
32
|
+
super().__init__(*args, **kwargs)
|
|
33
|
+
|
|
34
|
+
def _validate(self, value: t.Any) -> t.Any:
|
|
35
|
+
"""
|
|
36
|
+
Return the value processed by the validator.
|
|
37
|
+
|
|
38
|
+
Return UNDEF if the validator rejected the value by raising
|
|
39
|
+
an exception. Return the value unchanged if a validator was
|
|
40
|
+
not configured.
|
|
41
|
+
"""
|
|
42
|
+
if self._validator is None or value is redzed.UNDEF:
|
|
43
|
+
return value
|
|
44
|
+
try:
|
|
45
|
+
validated = self._validator(value)
|
|
46
|
+
except Exception as err:
|
|
47
|
+
self.log_debug1(
|
|
48
|
+
"Validator rejected value %r with %s: %s", value, type(err).__name__, err)
|
|
49
|
+
return redzed.UNDEF
|
|
50
|
+
if validated != value:
|
|
51
|
+
self.log_debug2("Validator has rewritten %r -> %r", value, validated)
|
|
52
|
+
return validated
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Memory(_Validate, redzed.Block):
|
|
56
|
+
"""
|
|
57
|
+
Memory cell with optional value validation.
|
|
58
|
+
|
|
59
|
+
Memory is typically used as an input block.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def _store_value(self, value: t.Any) -> bool:
|
|
63
|
+
"""
|
|
64
|
+
Validate and store a value.
|
|
65
|
+
|
|
66
|
+
Return True on success, False on validation error.
|
|
67
|
+
"""
|
|
68
|
+
if (validated := self._validate(value)) is redzed.UNDEF:
|
|
69
|
+
return False
|
|
70
|
+
self._set_output(validated)
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
def _event_store(self, edata: redzed.EventData) -> bool:
|
|
74
|
+
evalue = edata['evalue']
|
|
75
|
+
return self._store_value(evalue)
|
|
76
|
+
|
|
77
|
+
def rz_init(self, init_value: t.Any, /) -> None:
|
|
78
|
+
self._store_value(init_value)
|
|
79
|
+
|
|
80
|
+
def rz_export_state(self) -> t.Any:
|
|
81
|
+
return self.get()
|
|
82
|
+
|
|
83
|
+
def rz_restore_state(self, state: t.Any, /) -> None:
|
|
84
|
+
self._store_value(state)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class MemoryExp(_Validate, FSM):
|
|
88
|
+
"""
|
|
89
|
+
Memory cell with an expiration time.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
ALL_STATES = ['expired', 'valid']
|
|
93
|
+
TIMED_STATES = [ ['valid', None, 'expired'], ]
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self, *args,
|
|
97
|
+
duration: float|str|None,
|
|
98
|
+
expired: t.Any = None,
|
|
99
|
+
**kwargs) -> None: # kwargs may contain a validator
|
|
100
|
+
super().__init__(*args, t_valid=duration, **kwargs)
|
|
101
|
+
self._expired = self._validate(expired)
|
|
102
|
+
if self._expired is redzed.UNDEF:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"{self} The 'expired' argument {expired!r} was rejected by the validator")
|
|
105
|
+
|
|
106
|
+
def _event_store(self, edata: redzed.EventData) -> bool:
|
|
107
|
+
evalue = edata['evalue']
|
|
108
|
+
if (validated := self._validate(evalue)) is redzed.UNDEF:
|
|
109
|
+
return False
|
|
110
|
+
if validated == self._expired:
|
|
111
|
+
self._goto('expired')
|
|
112
|
+
else:
|
|
113
|
+
self.sdata['memory'] = validated
|
|
114
|
+
self._goto('valid')
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
def rz_init(self, init_value: t.Any, /) -> None:
|
|
118
|
+
if (validated := self._validate(init_value)) is redzed.UNDEF:
|
|
119
|
+
return
|
|
120
|
+
if validated == self._expired:
|
|
121
|
+
super().rz_init('expired')
|
|
122
|
+
else:
|
|
123
|
+
self.sdata['memory'] = validated
|
|
124
|
+
super().rz_init('valid')
|
|
125
|
+
|
|
126
|
+
def enter_expired(self) -> None:
|
|
127
|
+
self.sdata.pop('memory', None)
|
|
128
|
+
|
|
129
|
+
def _set_output(self, output: t.Any) -> bool:
|
|
130
|
+
return super()._set_output(
|
|
131
|
+
self.sdata['memory'] if self.state == 'valid' else self._expired)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class DataPoll(_Validate, redzed.Block):
|
|
135
|
+
"""
|
|
136
|
+
A source of sampled or computed values.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self, *args,
|
|
141
|
+
func: Callable[[], t.Any],
|
|
142
|
+
interval: float|str,
|
|
143
|
+
retry_interval: None|float|str|Sequence[float|str] = None,
|
|
144
|
+
abort_after_failures: int = 0,
|
|
145
|
+
**kwargs) -> None:
|
|
146
|
+
self._func = func
|
|
147
|
+
self._interval = time_period(interval)
|
|
148
|
+
if retry_interval is None:
|
|
149
|
+
self._r_interval_min = self._r_interval_max = self._interval
|
|
150
|
+
elif is_multiple(retry_interval):
|
|
151
|
+
assert isinstance(retry_interval, Sequence) # @mypy
|
|
152
|
+
if (ri_len := len(retry_interval)) != 2:
|
|
153
|
+
raise ValueError(
|
|
154
|
+
"Exponential backoff expects exactly two values [T_min, T_max], "
|
|
155
|
+
+ f"but got {ri_len}")
|
|
156
|
+
self._r_interval_min = time_period(retry_interval[0])
|
|
157
|
+
self._r_interval_max = time_period(retry_interval[1])
|
|
158
|
+
if self._r_interval_max < self._r_interval_min * 2:
|
|
159
|
+
raise ValueError("Exponential backoff requires T_max >= 2*T_min")
|
|
160
|
+
else:
|
|
161
|
+
self._r_interval_min = self._r_interval_max = time_period(retry_interval)
|
|
162
|
+
if any (t <= 0.0 for t in [self._interval, self._r_interval_min, self._r_interval_max]):
|
|
163
|
+
raise ValueError(f"{self} Time intervals must be positive")
|
|
164
|
+
self._abort_after_failures = abort_after_failures
|
|
165
|
+
super().__init__(*args, **kwargs)
|
|
166
|
+
|
|
167
|
+
def rz_pre_init(self) -> None:
|
|
168
|
+
self.circuit.create_service(
|
|
169
|
+
self._poller(), name=f"Data polling task at {self}", immediate_start=True)
|
|
170
|
+
|
|
171
|
+
async def _poller(self) -> t.NoReturn:
|
|
172
|
+
"""Data polling task: repeatedly get a value."""
|
|
173
|
+
await self.circuit.reached_state(redzed.CircuitState.INIT_BLOCKS)
|
|
174
|
+
failures = 0
|
|
175
|
+
while True:
|
|
176
|
+
value = self._func()
|
|
177
|
+
if asyncio.iscoroutine(value):
|
|
178
|
+
start_ts = time.monotonic()
|
|
179
|
+
value = await value
|
|
180
|
+
duration = time.monotonic() - start_ts
|
|
181
|
+
else:
|
|
182
|
+
duration = 0
|
|
183
|
+
if (value := self._validate(value)) is redzed.UNDEF:
|
|
184
|
+
failures += 1
|
|
185
|
+
self.log_debug1("Data acquisition failure(s): %d", failures)
|
|
186
|
+
if 0 < self._abort_after_failures <= failures:
|
|
187
|
+
self.circuit.abort(
|
|
188
|
+
RuntimeError(f"{self}: No data in {failures} polling cycle(s)"))
|
|
189
|
+
if failures == 1:
|
|
190
|
+
interval = self._r_interval_min
|
|
191
|
+
elif interval < self._r_interval_max:
|
|
192
|
+
interval = min(2*interval, self._r_interval_max)
|
|
193
|
+
else:
|
|
194
|
+
failures = 0
|
|
195
|
+
self._set_output(value)
|
|
196
|
+
interval = self._interval
|
|
197
|
+
if duration > 0:
|
|
198
|
+
interval = max(interval - duration, 0.0)
|
|
199
|
+
await asyncio.sleep(interval)
|
|
200
|
+
assert False, "Not reached"
|
|
201
|
+
|
|
202
|
+
def rz_init(self, init_value: t.Any, /) -> None:
|
|
203
|
+
self._set_output(init_value)
|
|
204
|
+
|
|
205
|
+
def rz_export_state(self) -> t.Any:
|
|
206
|
+
return self.get()
|
|
207
|
+
|
|
208
|
+
def rz_restore_state(self, state: t.Any, /) -> None:
|
|
209
|
+
if (value := self._validate(state)) is not redzed.UNDEF:
|
|
210
|
+
self._set_output(value)
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Output blocks.
|
|
3
|
+
- - - - - -
|
|
4
|
+
Docs: https://edzed.readthedocs.io/en/latest/
|
|
5
|
+
Home: https://github.com/xitop/edzed/
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
__all__ = ['MemoryBuffer', 'OutputFunc', 'OutputController', 'OutputWorker', 'QueueBuffer']
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from collections.abc import Callable, Awaitable
|
|
13
|
+
import typing as t
|
|
14
|
+
|
|
15
|
+
import redzed
|
|
16
|
+
from redzed.utils import BufferShutDown, cancel_shield, func_call_string, MsgSync, time_period
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OutputFunc(redzed.Block):
|
|
20
|
+
"""
|
|
21
|
+
Run a function when a value arrives.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self, *args,
|
|
26
|
+
func: Callable[..., t.Any],
|
|
27
|
+
stop_value: t.Any = redzed.UNDEF,
|
|
28
|
+
triggered_by: str|redzed.Block|redzed.Formula|redzed.UndefType = redzed.UNDEF,
|
|
29
|
+
**kwargs) -> None:
|
|
30
|
+
super().__init__(*args, **kwargs)
|
|
31
|
+
self._stop_value = stop_value
|
|
32
|
+
self._func = func
|
|
33
|
+
self._parameters: list[tuple[str, bool]] = [] # item: (name, is_required)
|
|
34
|
+
if triggered_by is not redzed.UNDEF:
|
|
35
|
+
@redzed.triggered
|
|
36
|
+
def trigger(value=triggered_by) -> None:
|
|
37
|
+
self.event('put', value)
|
|
38
|
+
|
|
39
|
+
def _event_put(self, edata: redzed.EventData) -> t.Any:
|
|
40
|
+
arg = edata['evalue']
|
|
41
|
+
if redzed.get_debug_level() >= 1:
|
|
42
|
+
self.log_debug("Running %s", func_call_string(self._func, (arg,)))
|
|
43
|
+
try:
|
|
44
|
+
result = self._func(arg)
|
|
45
|
+
except Exception as err:
|
|
46
|
+
func_args = func_call_string(self._func, (arg,))
|
|
47
|
+
self.log_error("Output function failed; call: %s; error: %r", func_args, err)
|
|
48
|
+
err.add_note(f"Error occurred in function call {func_args}")
|
|
49
|
+
self.circuit.abort(err)
|
|
50
|
+
raise
|
|
51
|
+
self.log_debug2("output function returned: %r", result)
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
def rz_stop(self) -> None:
|
|
55
|
+
if self._stop_value is not redzed.UNDEF:
|
|
56
|
+
self._event_put({'evalue': self._stop_value})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _Buffer(redzed.Block):
|
|
60
|
+
def __init__(
|
|
61
|
+
self, *args,
|
|
62
|
+
stop_value: t.Any = redzed.UNDEF,
|
|
63
|
+
triggered_by: str|redzed.Block|redzed.Formula|redzed.UndefType = redzed.UNDEF,
|
|
64
|
+
**kwargs) -> None:
|
|
65
|
+
super().__init__(*args, **kwargs)
|
|
66
|
+
self._stop_value = stop_value
|
|
67
|
+
if triggered_by is not redzed.UNDEF:
|
|
68
|
+
@redzed.triggered
|
|
69
|
+
def trigger(value=triggered_by) -> None:
|
|
70
|
+
self.event('put', value)
|
|
71
|
+
|
|
72
|
+
def rz_get_size(self) -> int:
|
|
73
|
+
raise NotImplementedError
|
|
74
|
+
|
|
75
|
+
async def rz_buffer_get(self) -> t.Any:
|
|
76
|
+
raise NotImplementedError
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class QueueBuffer(_Buffer):
|
|
80
|
+
"""
|
|
81
|
+
FIFO buffer for output values.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self, *args,
|
|
86
|
+
maxsize: int = 0,
|
|
87
|
+
priority_queue: bool = False,
|
|
88
|
+
**kwargs) -> None:
|
|
89
|
+
super().__init__(*args, **kwargs)
|
|
90
|
+
queue_type = asyncio.PriorityQueue if priority_queue else asyncio.Queue
|
|
91
|
+
self._queue: asyncio.Queue[t.Any] = queue_type(maxsize)
|
|
92
|
+
# Queue.shutdown is available in Python 3.13, but we want to support 3.11+
|
|
93
|
+
self._waiters = 0
|
|
94
|
+
|
|
95
|
+
def _event_put(self, edata: redzed.EventData) -> None:
|
|
96
|
+
"""Put an item into the queue."""
|
|
97
|
+
evalue = edata['evalue']
|
|
98
|
+
if evalue is redzed.UNDEF:
|
|
99
|
+
raise ValueError(f"{self}: Cannot put UNDEF into the buffer")
|
|
100
|
+
self._queue.put_nowait(evalue)
|
|
101
|
+
|
|
102
|
+
def rz_get_size(self) -> int:
|
|
103
|
+
"""Get the number of items in the buffer."""
|
|
104
|
+
return self._queue.qsize()
|
|
105
|
+
|
|
106
|
+
def _event__get_size(self, _edata: redzed.EventData) -> int:
|
|
107
|
+
return self.rz_get_size()
|
|
108
|
+
|
|
109
|
+
def rz_stop(self) -> None:
|
|
110
|
+
# stop_value might be UNDEF
|
|
111
|
+
self._queue.put_nowait(self._stop_value)
|
|
112
|
+
# unblock waiters (-1 accounts for the inserted stop value)
|
|
113
|
+
for _ in range(self._waiters - 1):
|
|
114
|
+
self._queue.put_nowait(redzed.UNDEF)
|
|
115
|
+
|
|
116
|
+
async def rz_buffer_get(self) -> t.Any:
|
|
117
|
+
"""
|
|
118
|
+
Remove and return the next value from the queue.
|
|
119
|
+
Wait for a value if the queue is empty.
|
|
120
|
+
|
|
121
|
+
After the shutdown drain the queue
|
|
122
|
+
and then raise BufferShutDown to each caller.
|
|
123
|
+
"""
|
|
124
|
+
if self.circuit.after_shutdown() and self._queue.qsize() == 0:
|
|
125
|
+
raise BufferShutDown("The buffer was shut down")
|
|
126
|
+
self._waiters += 1
|
|
127
|
+
try:
|
|
128
|
+
value = await self._queue.get()
|
|
129
|
+
finally:
|
|
130
|
+
self._waiters -= 1
|
|
131
|
+
if value is redzed.UNDEF:
|
|
132
|
+
# unblocked in rz_stop
|
|
133
|
+
raise BufferShutDown("The buffer was shut down")
|
|
134
|
+
return value
|
|
135
|
+
|
|
136
|
+
def rz_close(self) -> None:
|
|
137
|
+
if (size := self._queue.qsize()) > 0:
|
|
138
|
+
self.log_warning("%d unprocessed item(s) left in the buffer", size)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class MemoryBuffer(_Buffer):
|
|
142
|
+
"""
|
|
143
|
+
Single memory cell buffer.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
147
|
+
super().__init__(*args, **kwargs)
|
|
148
|
+
self._cell: MsgSync[t.Any] = MsgSync()
|
|
149
|
+
self._stop_pending = False
|
|
150
|
+
|
|
151
|
+
def rz_get_size(self) -> int:
|
|
152
|
+
"""Get the number of items (0 or 1) in the buffer."""
|
|
153
|
+
has_data = self._cell.has_data() or self._cell.is_shutdown() and self._stop_pending
|
|
154
|
+
return 1 if has_data else 0
|
|
155
|
+
|
|
156
|
+
def _event__get_size(self, _edata: redzed.EventData) -> int:
|
|
157
|
+
return self.rz_get_size()
|
|
158
|
+
|
|
159
|
+
def _event_put(self, edata: redzed.EventData) -> None:
|
|
160
|
+
"""Put an item into the memory cell. Existing value will be overwritten."""
|
|
161
|
+
self._cell.send(edata['evalue'])
|
|
162
|
+
|
|
163
|
+
def rz_stop(self) -> None:
|
|
164
|
+
"""Shut down"""
|
|
165
|
+
self._cell.shutdown()
|
|
166
|
+
self._stop_pending = self._stop_value is not redzed.UNDEF
|
|
167
|
+
|
|
168
|
+
async def rz_buffer_get(self) -> t.Any:
|
|
169
|
+
"""
|
|
170
|
+
Remove and return an item from the memory cell.
|
|
171
|
+
|
|
172
|
+
When the buffer is empty:
|
|
173
|
+
- before shutdown wait
|
|
174
|
+
- after shutdown return the *stop_value* once (if defined),
|
|
175
|
+
then raise BufferShutDown
|
|
176
|
+
"""
|
|
177
|
+
try:
|
|
178
|
+
return await self._cell.recv()
|
|
179
|
+
except BufferShutDown:
|
|
180
|
+
if not self._stop_pending:
|
|
181
|
+
raise
|
|
182
|
+
self._stop_pending = False
|
|
183
|
+
return self._stop_value
|
|
184
|
+
|
|
185
|
+
def rz_close(self) -> None:
|
|
186
|
+
if self._stop_pending:
|
|
187
|
+
self.log_error("The stop_value was not retrieved")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class OutputWorker(redzed.Block):
|
|
191
|
+
"""
|
|
192
|
+
Run a coroutine for each value from a buffer.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
def __init__(
|
|
196
|
+
self, *args,
|
|
197
|
+
buffer: str|redzed.Block,
|
|
198
|
+
coro_func: Callable[[t.Any], Awaitable[t.Any]], # i.e. an async function
|
|
199
|
+
workers: int = 1,
|
|
200
|
+
**kwargs) -> None:
|
|
201
|
+
super().__init__(*args, **kwargs)
|
|
202
|
+
if workers < 1:
|
|
203
|
+
raise ValueError(f"{self}: At least one worker is required")
|
|
204
|
+
self._buffer = buffer
|
|
205
|
+
self._corofunc = coro_func
|
|
206
|
+
self._workers = workers
|
|
207
|
+
self._worker_tasks: list[asyncio.Task[None]] = []
|
|
208
|
+
|
|
209
|
+
def rz_pre_init(self) -> None:
|
|
210
|
+
self._buffer = self.circuit.resolve_name(self._buffer) # type: ignore[assignment]
|
|
211
|
+
if not isinstance(self._buffer, _Buffer):
|
|
212
|
+
raise TypeError(
|
|
213
|
+
f"Check the buffer parameter; {self._buffer} is not a compatible block")
|
|
214
|
+
|
|
215
|
+
async def _worker(self, wname: str) -> None:
|
|
216
|
+
getter = self._buffer.rz_buffer_get # type: ignore[union-attr]
|
|
217
|
+
while True:
|
|
218
|
+
try:
|
|
219
|
+
value = await getter()
|
|
220
|
+
except BufferShutDown:
|
|
221
|
+
return
|
|
222
|
+
try:
|
|
223
|
+
if redzed.get_debug_level() >= 1:
|
|
224
|
+
self.log_debug(
|
|
225
|
+
"%s: Awaiting coroutine %s",
|
|
226
|
+
wname, func_call_string(self._corofunc, (value,)))
|
|
227
|
+
await self._corofunc(value)
|
|
228
|
+
self.log_debug2("%s: coroutine done", wname)
|
|
229
|
+
except Exception as err:
|
|
230
|
+
err.add_note(f"Processed value was: {value!r}")
|
|
231
|
+
raise
|
|
232
|
+
|
|
233
|
+
def rz_start(self) -> None:
|
|
234
|
+
workers = self._workers
|
|
235
|
+
for n in range(workers):
|
|
236
|
+
wname = "worker"
|
|
237
|
+
if workers > 1:
|
|
238
|
+
wname += f"={n+1}/{workers}"
|
|
239
|
+
self._worker_tasks.append(
|
|
240
|
+
self.circuit.create_service(
|
|
241
|
+
self._worker(wname), auto_cancel=False, name=f"{self}: {wname}"))
|
|
242
|
+
|
|
243
|
+
async def rz_astop(self) -> None:
|
|
244
|
+
try:
|
|
245
|
+
await asyncio.wait(self._worker_tasks)
|
|
246
|
+
except asyncio.CancelledError:
|
|
247
|
+
# stop_timeout from the circuit runner
|
|
248
|
+
for worker in self._worker_tasks:
|
|
249
|
+
if not worker.done():
|
|
250
|
+
worker.cancel()
|
|
251
|
+
await asyncio.sleep(0)
|
|
252
|
+
if (running := sum(1 for worker in self._worker_tasks if not worker.done())) > 0:
|
|
253
|
+
self.log_warning("%d worker(s) did not stop", running)
|
|
254
|
+
raise
|
|
255
|
+
|
|
256
|
+
class OutputController(redzed.Block):
|
|
257
|
+
"""
|
|
258
|
+
Run a coroutine for the latest value from a buffer.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
def __init__(
|
|
262
|
+
self, *args,
|
|
263
|
+
buffer: str|redzed.Block,
|
|
264
|
+
coro_func: Callable[[t.Any], Awaitable[t.Any]], # i.e. an async function
|
|
265
|
+
rest_time: float|str = 0.0,
|
|
266
|
+
**kwargs) -> None:
|
|
267
|
+
super().__init__(*args, **kwargs)
|
|
268
|
+
self._rest_time = time_period(rest_time, zero_ok=True)
|
|
269
|
+
if self._rest_time >= self.rz_stop_timeout:
|
|
270
|
+
raise ValueError(f"{self}: rest_time must be shorter than the stop_timeout")
|
|
271
|
+
self._buffer = buffer
|
|
272
|
+
self._corofunc = coro_func
|
|
273
|
+
self._main_loop_task: asyncio.Task[None]|None = None
|
|
274
|
+
|
|
275
|
+
def rz_pre_init(self) -> None:
|
|
276
|
+
self._buffer = self.circuit.resolve_name(self._buffer) # type: ignore[assignment]
|
|
277
|
+
if not isinstance(self._buffer, _Buffer):
|
|
278
|
+
raise TypeError(
|
|
279
|
+
f"Check the buffer parameter; {self._buffer} is not a compatible block")
|
|
280
|
+
|
|
281
|
+
async def _rest_time_delay(self) -> None:
|
|
282
|
+
if self._rest_time > 0:
|
|
283
|
+
await cancel_shield(asyncio.sleep(self._rest_time))
|
|
284
|
+
|
|
285
|
+
async def _run_with_rest_time(self, value: t.Any) -> None:
|
|
286
|
+
try:
|
|
287
|
+
await self._corofunc(value)
|
|
288
|
+
except asyncio.CancelledError:
|
|
289
|
+
self.log_debug2("Task cancelled")
|
|
290
|
+
await self._rest_time_delay()
|
|
291
|
+
raise
|
|
292
|
+
except Exception as err:
|
|
293
|
+
err.add_note(f"Processed value was: {value!r}")
|
|
294
|
+
# this task is awaited only when a new task waits for its start
|
|
295
|
+
self.circuit.abort(err)
|
|
296
|
+
await self._rest_time_delay()
|
|
297
|
+
raise
|
|
298
|
+
await self._rest_time_delay()
|
|
299
|
+
|
|
300
|
+
async def _main_loop(self) -> None:
|
|
301
|
+
"""
|
|
302
|
+
For each value from the buffer cancel the old task (if any)
|
|
303
|
+
and create a task. Exit after buffer's shutdown.
|
|
304
|
+
"""
|
|
305
|
+
buffer = self._buffer
|
|
306
|
+
assert isinstance(buffer, _Buffer) # @mypy
|
|
307
|
+
shutdown = False
|
|
308
|
+
task = None
|
|
309
|
+
while True:
|
|
310
|
+
try:
|
|
311
|
+
value = await buffer.rz_buffer_get()
|
|
312
|
+
except BufferShutDown:
|
|
313
|
+
shutdown = True
|
|
314
|
+
if task is not None:
|
|
315
|
+
if task.done():
|
|
316
|
+
task.result() # will re-raise task exception if any
|
|
317
|
+
else:
|
|
318
|
+
if not shutdown:
|
|
319
|
+
task.cancel()
|
|
320
|
+
try:
|
|
321
|
+
await task
|
|
322
|
+
except asyncio.CancelledError:
|
|
323
|
+
if asyncio.current_task().cancelling() > 0: # type: ignore[union-attr]
|
|
324
|
+
raise
|
|
325
|
+
task = None
|
|
326
|
+
if shutdown:
|
|
327
|
+
return
|
|
328
|
+
if buffer.rz_get_size():
|
|
329
|
+
# a new value has arrived while we were awaiting the task
|
|
330
|
+
continue
|
|
331
|
+
if redzed.get_debug_level() >= 1:
|
|
332
|
+
self.log_debug(
|
|
333
|
+
"Creating task: %s%s",
|
|
334
|
+
func_call_string(self._corofunc, (value,)),
|
|
335
|
+
" + rest time" if self._rest_time > 0.0 else "",
|
|
336
|
+
)
|
|
337
|
+
task = asyncio.create_task(self._run_with_rest_time(value))
|
|
338
|
+
|
|
339
|
+
def rz_start(self) -> None:
|
|
340
|
+
self._main_loop_task = self.circuit.create_service(
|
|
341
|
+
self._main_loop(), auto_cancel=False, name=f"{self}: main loop")
|
|
342
|
+
|
|
343
|
+
async def rz_astop(self) -> None:
|
|
344
|
+
task = self._main_loop_task
|
|
345
|
+
if task is None or task.done():
|
|
346
|
+
return
|
|
347
|
+
try:
|
|
348
|
+
await task
|
|
349
|
+
except asyncio.CancelledError:
|
|
350
|
+
# stop_timeout from the circuit runner
|
|
351
|
+
task.cancel()
|
|
352
|
+
await asyncio.sleep(0)
|
|
353
|
+
if not task.done():
|
|
354
|
+
try:
|
|
355
|
+
async with asyncio.timeout(self._rest_time):
|
|
356
|
+
await task
|
|
357
|
+
except TimeoutError:
|
|
358
|
+
pass
|
|
359
|
+
if not task.done():
|
|
360
|
+
self.log_warning("The main task did not stop")
|
|
361
|
+
raise
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
An event repeater.
|
|
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__ = ['Repeat']
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import typing as t
|
|
14
|
+
|
|
15
|
+
import redzed
|
|
16
|
+
from redzed.utils import MsgSync, time_period
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Repeat(redzed.Block):
|
|
20
|
+
"""
|
|
21
|
+
Periodically repeat the last received event.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self, *args,
|
|
26
|
+
dest: str|redzed.Block, interval: float|str, count: int|None = None,
|
|
27
|
+
**kwargs
|
|
28
|
+
) -> None:
|
|
29
|
+
self._dest = dest
|
|
30
|
+
self._interval = time_period(interval)
|
|
31
|
+
if count is not None and count < 0:
|
|
32
|
+
# count = 0 (no repeating) is accepted
|
|
33
|
+
raise ValueError("argument 'count' must not be negative")
|
|
34
|
+
self._count = count
|
|
35
|
+
self._sync: MsgSync[tuple[str, redzed.EventData]] = MsgSync()
|
|
36
|
+
self._warning_logged = False
|
|
37
|
+
super().__init__(*args, **kwargs)
|
|
38
|
+
|
|
39
|
+
def rz_pre_init(self) -> None:
|
|
40
|
+
"""Resolve destination block name."""
|
|
41
|
+
if not isinstance(dest := self.circuit.resolve_name(self._dest), redzed.Block):
|
|
42
|
+
raise TypeError(
|
|
43
|
+
f"{self}: {dest} is not a Block, but a Formula; cannot send events to it.")
|
|
44
|
+
self._dest = dest
|
|
45
|
+
self.circuit.create_service(self._repeater(), name=f"Event repeating task at {self}")
|
|
46
|
+
|
|
47
|
+
def rz_init_default(self) -> None:
|
|
48
|
+
self._set_output(0)
|
|
49
|
+
|
|
50
|
+
async def _repeater(self) -> t.NoReturn:
|
|
51
|
+
repeating = False
|
|
52
|
+
repeat = 0 # prevent pylint warning
|
|
53
|
+
while True:
|
|
54
|
+
try:
|
|
55
|
+
etype, edata = await self._sync.recv(
|
|
56
|
+
timeout=self._interval if repeating else None)
|
|
57
|
+
repeat = 0
|
|
58
|
+
except asyncio.TimeoutError:
|
|
59
|
+
repeat += 1
|
|
60
|
+
|
|
61
|
+
if repeat > 0: # skip the original event
|
|
62
|
+
self._set_output(repeat)
|
|
63
|
+
assert isinstance(self._dest, redzed.Block) # @mypy: name resolved
|
|
64
|
+
self._dest.event(etype, **(edata | {'repeat': repeat}))
|
|
65
|
+
repeating = self._count is None or repeat < self._count
|
|
66
|
+
|
|
67
|
+
def _default_event_handler(self, etype: str, edata: redzed.EventData) -> None:
|
|
68
|
+
# send the original event synchronously
|
|
69
|
+
self._set_output(0)
|
|
70
|
+
assert isinstance(self._dest, redzed.Block) # mypy: name is resolved
|
|
71
|
+
self._dest.event(etype, **(edata | {'repeat': 0}))
|
|
72
|
+
self._sync.send((etype, edata))
|