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/initializers.py ADDED
@@ -0,0 +1,249 @@
1
+ """
2
+ Block initializers.
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__ = [
11
+ 'AsyncInitializer', 'SyncInitializer',
12
+ 'InitFunction', 'InitTask', 'InitValue', 'InitWait', 'RestoreState']
13
+
14
+ import asyncio
15
+ from collections.abc import Callable, Awaitable
16
+ import time
17
+ import typing as t
18
+
19
+ from . import block
20
+ from .undef import UNDEF, UndefType
21
+ from .utils import check_async_func, time_period
22
+
23
+
24
+ _LONG_TIMEOUT = 60 # threshold for a "long timeout" debug message
25
+
26
+
27
+ class SyncInitializer:
28
+ """
29
+ Logic block initializers are used as initial=... arguments.
30
+
31
+ A block can have multiple initializers. They will be called
32
+ in given order until the first one succeeds.
33
+ """
34
+
35
+ def __init__(self) -> None:
36
+ # Keep track in order to prevent repeated application of the same initializer.
37
+ # It could happen when a block receives an event during initialization.
38
+ self._applied = False
39
+
40
+ @property
41
+ def type_name(self) -> str:
42
+ return type(self).__name__
43
+
44
+ def _get_init(self) -> t.Any:
45
+ """Return the initial value or UNDEF if not available."""
46
+ raise NotImplementedError()
47
+
48
+ def apply_to(self, blk: block.Block) -> None:
49
+ """
50
+ Apply this initializer to a logic block *blk*.
51
+
52
+ Log exceptions, but do not propagate them. An error condition
53
+ is when a block doesn't get proper initialization after using
54
+ ALL initializers.
55
+ """
56
+ if self._applied:
57
+ return
58
+ self._applied = True
59
+ try:
60
+ init_value = self._get_init()
61
+ except Exception as err:
62
+ blk.log_error(
63
+ "%s: could not get the initialization value: %r", self.type_name, err)
64
+ return
65
+ blk.log_debug2("%s: init value: %r", self.type_name, init_value)
66
+ if init_value is UNDEF:
67
+ return
68
+ try:
69
+ blk.rz_init(init_value) # type: ignore[attr-defined]
70
+ except Exception as err:
71
+ blk.log_error(
72
+ "%s could not apply the initialization value: %r", self.type_name, err)
73
+ return
74
+
75
+
76
+ class InitFunction(SyncInitializer):
77
+ """
78
+ Initialize with a calculated value.
79
+ """
80
+
81
+ def __init__(self, func: Callable[..., t.Any], *args: t.Any) -> None:
82
+ """
83
+ Usage: InitFunction(func, arg1, arg2, ...)
84
+ Use functools.partial to pass keyword arguments.
85
+ """
86
+ super().__init__()
87
+ if not callable(func):
88
+ raise TypeError(f"{self.type_name}: {func!r} is not a function")
89
+ self._func = func
90
+ self._args = args
91
+
92
+ def _get_init(self) -> t.Any:
93
+ return self._func(*self._args)
94
+
95
+
96
+ class InitValue(SyncInitializer):
97
+ """
98
+ Initialize with a literal value.
99
+ """
100
+
101
+ def __init__(self, value: t.Any) -> None:
102
+ super().__init__()
103
+ if value is UNDEF:
104
+ raise ValueError("<UNDEF> is not a valid initialization value.")
105
+ self._value = value
106
+
107
+ def _get_init(self) -> t.Any:
108
+ return self._value
109
+
110
+
111
+ _CHECKPOINTS = [None, 'event', 'interval']
112
+
113
+ class RestoreState(SyncInitializer):
114
+ """Restore from saved state."""
115
+
116
+ def __init__(
117
+ self,
118
+ checkpoints: None|t.Literal['event', 'interval'] = None,
119
+ expiration: None|float|str = None
120
+ ) -> None:
121
+ super().__init__()
122
+ if not checkpoints in _CHECKPOINTS:
123
+ raise ValueError(
124
+ "Parameter checkpoints must be one of: "
125
+ + f"{', '.join(repr(ch) for ch in _CHECKPOINTS)}")
126
+ self.rz_checkpoints = checkpoints
127
+ self._expiration = time_period(expiration, passthrough=None)
128
+
129
+ def _get_init(self) -> t.Any:
130
+ pass
131
+
132
+ def _get_state(self, blk: block.Block) -> t.Any:
133
+ storage = blk.circuit.rz_persistent_dict
134
+ assert storage is not None
135
+ try:
136
+ state, timestamp = storage[blk.rz_key]
137
+ except KeyError:
138
+ blk.log_debug2("No saved state was found")
139
+ return UNDEF
140
+ except Exception as err:
141
+ blk.log_warning("State retrieval error: %r", err)
142
+ return UNDEF
143
+ if self._expiration is None:
144
+ return state
145
+ age = time.time() - timestamp
146
+ if age < 0:
147
+ blk.log_error(
148
+ "The timestamp of saved data is in the future, check the system time")
149
+ elif age > self._expiration:
150
+ blk.log_debug2("The saved state has expired")
151
+ return UNDEF
152
+ return state
153
+
154
+ def apply_to(self, blk: block.Block) -> None:
155
+ if self._applied:
156
+ return
157
+ self._applied = True
158
+ if not blk.rz_persistence:
159
+ return
160
+ try:
161
+ init_state = self._get_state(blk)
162
+ except Exception as err:
163
+ blk.log_error(
164
+ "%s could not get the initialization value: %r", self.type_name, err)
165
+ return
166
+ if init_state is UNDEF:
167
+ # a debug message was logged in _get_state
168
+ return
169
+ blk.log_debug2("%s: saved state: %r", self.type_name, init_state)
170
+ try:
171
+ blk.rz_restore_state(init_state) # type: ignore[attr-defined]
172
+ except Exception as err:
173
+ blk.log_error(
174
+ "%s could not apply the initialization value: %r", self.type_name, err)
175
+ return
176
+
177
+
178
+ class AsyncInitializer:
179
+ """
180
+ Asynchronous initializer.
181
+ """
182
+
183
+ def __init__(self, timeout: float|str):
184
+ self._applied = False
185
+ self._timeout = time_period(timeout, passthrough=None)
186
+
187
+ @property
188
+ def type_name(self) -> str:
189
+ return type(self).__name__
190
+
191
+ async def _async_get_init(self) -> t.Any:
192
+ """Async version of _get_init."""
193
+ raise NotImplementedError()
194
+
195
+ async def async_apply_to(self, blk: block.Block) -> None:
196
+ """Async version of apply(). Do not overwrite existing state."""
197
+ if self._applied:
198
+ return
199
+ self._applied = True
200
+ if self._timeout >= _LONG_TIMEOUT:
201
+ blk.log_debug2("%s has a long timeout of %.1f secs", self.type_name, self._timeout)
202
+ try:
203
+ init_value = await self._async_get_init()
204
+ except TimeoutError:
205
+ blk.log_debug1("%s timed out", self.type_name)
206
+ return
207
+ except Exception as err:
208
+ blk.log_error("Skipping failed %s. Error: %r", self.type_name, err)
209
+ return
210
+ blk.log_debug2("%s: init value: %r", self.type_name, init_value)
211
+ if init_value is UNDEF:
212
+ return
213
+ if not blk.is_undef():
214
+ blk.log_debug1(
215
+ "%s: not applying the init value, because the block "
216
+ + "has been initialized in the meantime", self.type_name)
217
+ return
218
+ blk.rz_init(init_value) # type: ignore[attr-defined]
219
+
220
+
221
+ class InitTask(AsyncInitializer):
222
+ """
223
+ Initialize with a value calculated by a coroutine.
224
+ """
225
+
226
+ def __init__(
227
+ self,
228
+ coro_func: Callable[..., Awaitable[t.Any]],
229
+ *args: t.Any,
230
+ timeout: float|str = 10.0
231
+ ) -> None:
232
+ """Similar to InitFunction, but asynchonous."""
233
+ check_async_func(coro_func)
234
+ super().__init__(timeout)
235
+ self._corofunc = coro_func
236
+ self._args = args
237
+
238
+ async def _async_get_init(self) -> t.Any:
239
+ async with asyncio.timeout(self._timeout):
240
+ return await self._corofunc(*self._args)
241
+
242
+
243
+ class InitWait(AsyncInitializer):
244
+ """
245
+ Passively wait for initialization by an event.
246
+ """
247
+
248
+ async def _async_get_init(self) -> UndefType:
249
+ return await asyncio.sleep(self._timeout, result=UNDEF)
@@ -0,0 +1,64 @@
1
+ """
2
+ Stop the runner with a signal.
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__ = ['TerminatingSignal']
11
+
12
+ import asyncio
13
+ from collections.abc import Callable
14
+ import logging
15
+ import signal
16
+ from types import FrameType
17
+ import typing as t
18
+
19
+ from . import circuit
20
+
21
+ _logger = logging.getLogger(__package__)
22
+
23
+
24
+ class TerminatingSignal:
25
+ """
26
+ A context manager gracefully stopping the runner after signal.
27
+ """
28
+
29
+ def __init__(self, signo: int|None) -> None:
30
+ self._signo = signo
31
+ if signo is None:
32
+ return
33
+ self._saved_handler: Callable[[int, FrameType|None], None]|int|None
34
+ self._signame = signal.strsignal(signo) or f"#{signo}"
35
+
36
+ def __enter__(self) -> None:
37
+ if self._signo is None:
38
+ return
39
+ self._saved_handler = signal.getsignal(self._signo)
40
+ if self._saved_handler is None:
41
+ _logger.warning(
42
+ "An incompatible handler for signal %s was found; "
43
+ + "Redzed will not catch this signal.",
44
+ self._signame
45
+ )
46
+ else:
47
+ signal.signal(self._signo, self._handler)
48
+
49
+ def __exit__(self, _exc_type, _exc_val, _exc_tb) -> t.Literal[False]:
50
+ if self._signo is not None and self._saved_handler is not None:
51
+ signal.signal(self._signo, self._saved_handler)
52
+ return False
53
+
54
+ def _handler(self, signo: int, frame: FrameType|None) -> None:
55
+ """A signal handler."""
56
+ # - we need the _threadsafe variant of call_soon
57
+ # - get_running loop() and get_circuit() will succeed,
58
+ # because this handler is active only during edzed.run()
59
+ msg = f"Signal {self._signame!r} caught"
60
+ call_soon = asyncio.get_running_loop().call_soon_threadsafe
61
+ call_soon(_logger.warning, "%s", msg)
62
+ call_soon(circuit.get_circuit().shutdown)
63
+ if callable(self._saved_handler):
64
+ self._saved_handler(signo, frame)
redzed/undef.py ADDED
@@ -0,0 +1,38 @@
1
+ """
2
+ The UNDEF singleton constant.
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__ = ['UNDEF', 'UndefType']
11
+
12
+ import enum
13
+ import typing as t
14
+
15
+
16
+ class DefaultEnumType(enum.EnumType):
17
+ """Allow an UndefType() call without parameters."""
18
+ # pylint: disable=keyword-arg-before-vararg
19
+ def __call__(cls, value=None, *args, **kwargs):
20
+ return super().__call__(value, *args, **kwargs)
21
+
22
+
23
+ # See PEP 484 - Support for singleton types in unions
24
+ class UndefType(enum.Enum, metaclass=DefaultEnumType):
25
+ """Undefined output value"""
26
+ UNDEF = None
27
+
28
+ def __bool__(self) -> bool:
29
+ return False
30
+
31
+ def __repr__(self) -> str:
32
+ return '<UNDEF>'
33
+
34
+ __str__ = __repr__
35
+
36
+
37
+ # Uninitialized circuit block's state value
38
+ UNDEF: t.Final = UndefType.UNDEF
@@ -0,0 +1,14 @@
1
+ """
2
+ Docs: https://redzed.readthedocs.io/en/latest/
3
+ Home: https://github.com/xitop/redzed/
4
+ """
5
+
6
+ from . import async_utils
7
+ from . import data_utils
8
+ from . import time_utils
9
+
10
+ from .async_utils import *
11
+ from .data_utils import *
12
+ from .time_utils import *
13
+
14
+ __all__ = async_utils.__all__ + data_utils.__all__ + time_utils.__all__
@@ -0,0 +1,145 @@
1
+ """
2
+ Asynchronous utilities.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ from collections.abc import Awaitable
9
+ import typing as t
10
+
11
+ __all__ = ['BufferShutDown', 'cancel_shield', 'MsgSync']
12
+
13
+ try:
14
+ BufferShutDown = asyncio.QueueShutDown # Python 3.13+
15
+ except AttributeError:
16
+ class BufferShutDown(Exception): # type: ignore[no-redef]
17
+ """asyncio.QueueShutDown substitute"""
18
+
19
+
20
+ _T = t.TypeVar("_T")
21
+ _MISSING = object()
22
+
23
+ class MsgSync(t.Generic[_T]):
24
+ """
25
+ Task synchronization based on sending messages of type _T.
26
+
27
+ The count of unread message is always either zero or one,
28
+ because messages are not queued. A new message replaces
29
+ the previous unread one, if any.
30
+
31
+ An existing unread message is available immediately.
32
+ Otherwise the recipient must wait and will be awakened when
33
+ a new message arrives. Reading a message consumes it,
34
+ so each message can reach only one receiver.
35
+ """
36
+
37
+ def __init__(self) -> None:
38
+ self._msg: t.Any = _MISSING
39
+ self._shutdown = False
40
+ self._has_data = asyncio.Event() # valid before shutdown
41
+ self._draining = False # valid after shutdown
42
+
43
+ def shutdown(self) -> None:
44
+ """
45
+ Disallow sending immediately. Disallow receiving after the MsgSync gets empty.
46
+
47
+ Calls to .send() will raise BufferShutDown.
48
+
49
+ If a message was sent before shutdown and is waiting, it can be
50
+ normally received before the receiver shuts down too. After
51
+ the shutdown all blocked callers will be unblocked with BufferShutDown.
52
+ Calls to .recv() will raise BufferShutDown as well.
53
+ """
54
+ self._shutdown = True
55
+ self._draining = self._has_data.is_set()
56
+ if not self._draining:
57
+ # wakeup all waiters
58
+ self._has_data.set()
59
+
60
+ def is_shutdown(self) -> bool:
61
+ """Check if the buffer was shut down."""
62
+ return self._shutdown
63
+
64
+ def send(self, msg: _T) -> None:
65
+ """
66
+ Send a message to one receiver.
67
+
68
+ If the previously sent message hasn't been received yet,
69
+ the new message overwrites it.
70
+ """
71
+ if self._shutdown:
72
+ raise BufferShutDown("The buffer was shut down")
73
+ self._msg = msg
74
+ self._has_data.set()
75
+
76
+ def clear(self) -> None:
77
+ """Remove the waiting message."""
78
+ if self._shutdown:
79
+ self._draining = False
80
+ else:
81
+ self._has_data.clear()
82
+ self._msg = _MISSING
83
+
84
+ def has_data(self) -> bool:
85
+ """Return True only if there is a waiting message."""
86
+ return self._draining if self._shutdown else self._has_data.is_set()
87
+
88
+ async def _recv(self) -> _T:
89
+ """Receive (consume) a message."""
90
+ while not self._has_data.is_set():
91
+ await self._has_data.wait()
92
+ if not self._shutdown:
93
+ self._has_data.clear()
94
+ elif self._draining:
95
+ self._draining = False
96
+ else:
97
+ raise BufferShutDown("The buffer was shut down")
98
+ msg, self._msg = self._msg, _MISSING
99
+ return t.cast(_T, msg)
100
+
101
+ async def recv(self, timeout: float | None = None, default: t.Any = _MISSING) -> t.Any:
102
+ """
103
+ Receive a message with optional timeout.
104
+
105
+ If a message is not available, wait until it arrives.
106
+
107
+ If a timeout [seconds] is given, return the default value
108
+ if no message is received before the timeout period elapses.
109
+ Without a default, raise the TimeoutError.
110
+ """
111
+ if timeout is None:
112
+ return await self._recv()
113
+ try:
114
+ async with asyncio.timeout(timeout):
115
+ return await self._recv()
116
+ except TimeoutError:
117
+ if default is not _MISSING:
118
+ return default
119
+ raise
120
+
121
+
122
+ async def cancel_shield(aw: Awaitable[_T]) -> _T:
123
+ """
124
+ Shield from cancellation while aw is awaited.
125
+
126
+ Any pending CancelledError is raised when aw is finished.
127
+ """
128
+ task = asyncio.ensure_future(aw)
129
+ cancel_exc = None
130
+ while True:
131
+ try:
132
+ retval = await asyncio.shield(task)
133
+ except asyncio.CancelledError as err:
134
+ if task.done():
135
+ raise
136
+ cancel_exc = err
137
+ else:
138
+ break
139
+ if cancel_exc is not None:
140
+ try:
141
+ raise cancel_exc
142
+ finally:
143
+ # break the reference loop
144
+ cancel_exc = None
145
+ return retval
@@ -0,0 +1,116 @@
1
+ """
2
+ Small utilities.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ __all__ = [
7
+ 'check_async_coro', 'check_async_func', 'check_identifier', 'func_call_string',
8
+ 'is_multiple', 'tasks_are_eager', 'to_tuple']
9
+
10
+ import asyncio
11
+ from collections.abc import Callable, Mapping, Sequence
12
+ import inspect
13
+ import itertools
14
+ import logging
15
+ import typing as t
16
+
17
+ _logger = logging.getLogger(__package__)
18
+
19
+
20
+ def check_identifier(name: t.Any, msg_prefix: str) -> None:
21
+ """Raise if *name* is not a valid identifier."""
22
+ if not isinstance(name, str):
23
+ raise TypeError(f"{msg_prefix} must be a string, got {name!r}")
24
+ if not name:
25
+ raise ValueError(f"{msg_prefix} cannot be an empty string")
26
+ if not name.isidentifier():
27
+ raise ValueError(f"{msg_prefix} must be a valid identifier, got '{name!r}'")
28
+
29
+
30
+ def is_multiple(arg: t.Any) -> bool:
31
+ """
32
+ Check if *arg* specifies multiple ordered items.
33
+
34
+ The check is based on the type. The actual item count does not
35
+ matter and can be any value including zero or one.
36
+ A string or a byte-string is considered a single argument.
37
+ """
38
+ return not isinstance(arg, (str, bytes)) and isinstance(arg, Sequence)
39
+
40
+
41
+ _T_item = t.TypeVar("_T_item")
42
+ def to_tuple(args: _T_item|Sequence[_T_item]) -> tuple[_T_item, ...]:
43
+ """Transform *args* to a tuple of items."""
44
+
45
+ if isinstance(args, tuple):
46
+ return args
47
+ if is_multiple(args):
48
+ assert isinstance(args, Sequence) # @mypy
49
+ return tuple(args)
50
+ return t.cast(tuple[_T_item], (args,))
51
+
52
+
53
+ # must not touch *kwargs* # pylint: disable-next=dangerous-default-value
54
+ def func_call_string(
55
+ func: Callable[..., t.Any]|None,
56
+ args: Sequence[t.Any],
57
+ kwargs: Mapping[str, t.Any] = {}
58
+ ) -> str:
59
+ """Convert args and kwargs to a printable string."""
60
+ arglist = '(' + ', '.join(itertools.chain(
61
+ (repr(a) for a in args),
62
+ (f"{k}={v!r}" for k, v in kwargs.items()))) + ')'
63
+ if func is None:
64
+ return arglist
65
+ return func.__name__ + arglist
66
+
67
+
68
+ def tasks_are_eager() -> bool:
69
+ """
70
+ Detect if eager tasks are enabled.
71
+
72
+ Eager tasks (Python 3.12+) change the order of execution.
73
+ The changed order may violate assumptions made in the
74
+ code written before eager tasks were introduced.
75
+ """
76
+ if not hasattr(asyncio, "eager_task_factory"):
77
+ return False
78
+ flag = False
79
+ async def test_task() -> None:
80
+ nonlocal flag
81
+ flag = True
82
+ asyncio.create_task(test_task())
83
+ return flag
84
+
85
+
86
+ def check_async_coro(arg: t.Any) -> None:
87
+ """
88
+ Check if arg is a coroutine object.
89
+
90
+ Raise a TypeError with a descriptive message if it isn't.
91
+ """
92
+ if inspect.iscoroutine(arg):
93
+ return
94
+ if inspect.iscoroutinefunction(arg):
95
+ received = f"an async function '{arg.__name__}'. Did you mean '{arg.__name__}()'?"
96
+ else:
97
+ received = repr(arg)
98
+ raise TypeError(f"Expected a coroutine, but got {received}")
99
+
100
+
101
+ def check_async_func(arg: t.Any) -> None:
102
+ """
103
+ Check if *arg* is an async function.
104
+
105
+ Raise a TypeError with a descriptive message if it isn't.
106
+ """
107
+ if inspect.iscoroutinefunction(arg):
108
+ return
109
+ if inspect.iscoroutine(arg):
110
+ received = (f"a coroutine '{arg.__name__}()'. "
111
+ + f"Did you mean '{arg.__name__}' without parentheses?")
112
+ elif callable(arg):
113
+ received = f"a non-async function/callable '{arg.__name__}'"
114
+ else:
115
+ received = repr(arg)
116
+ raise TypeError(f"Expected an async function, but got {received}")