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.
@@ -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))