OpenSMI-Core 0.1__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,275 @@
1
+ # SPDX-FileCopyrightText: 2026 OpenSMI Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """Mixin for async lifecycle management for mixin-composed classes.
6
+
7
+ Concepts
8
+
9
+ Resource mixin: a plain class (does not inherit `LifecycleMixin`) with one or
10
+ multiple ``@lifecycle``-decorated async-generator method(s). Code before yield is init;
11
+ code after yield is shutdown.
12
+
13
+ Composed class: the concrete class that inherits its resource mixins and
14
+ `LifecycleMixin`. This is the only class that should inherit `LifecycleMixin`.
15
+
16
+ Ordering: resources init in dependency order and shut down in the exact
17
+ reverse order. Order is resolved once, in `LifecycleMixin.__init_subclass__`, from
18
+ ``after`` / ``before`` arguments of ``@lifecycle``-decorated methods.
19
+ """
20
+
21
+ import graphlib
22
+ import inspect
23
+ import time
24
+ from collections.abc import AsyncGenerator, Callable
25
+ from typing import Final, Self, TypeVar, overload
26
+
27
+ import structlog
28
+
29
+ from opensmi.core.enums import LifecycleState
30
+ from opensmi.core.errors import CycleError
31
+ from opensmi.core.mixin_helpers import get_logger
32
+
33
+ _LOGGER = structlog.get_logger("opensmi." + __name__)
34
+
35
+ _ATTR_LIFECYCLE_AFTER: Final[str] = "_lifecycle_after"
36
+ _ATTR_LIFECYCLE_BEFORE: Final[str] = "_lifecycle_before"
37
+ _ATTR_LIFECYCLE_REQUIRES: Final[str] = "_lifecycle_requires"
38
+ _ATTR_LIFECYCLE_RESOURCE: Final[str] = "_is_lifecycle_resource"
39
+
40
+ _LifecycleFunc = TypeVar("_LifecycleFunc", bound=Callable[..., AsyncGenerator[None, None]])
41
+ _Func = Callable[..., AsyncGenerator[None, None]]
42
+ _FuncOrFuncs = _Func | tuple[_Func, ...] | list[_Func]
43
+
44
+
45
+ @overload
46
+ def lifecycle(_func: _LifecycleFunc) -> _LifecycleFunc: ...
47
+ @overload
48
+ def lifecycle(
49
+ _func: None = None, *, after: _FuncOrFuncs = (), before: _FuncOrFuncs = ()
50
+ ) -> Callable[[_LifecycleFunc], _LifecycleFunc]: ...
51
+ def lifecycle(
52
+ _func: _LifecycleFunc | None = None,
53
+ *,
54
+ after: _FuncOrFuncs = (),
55
+ before: _FuncOrFuncs = (),
56
+ requires: _FuncOrFuncs = (),
57
+ ) -> _LifecycleFunc | Callable[[_LifecycleFunc], _LifecycleFunc]:
58
+ """Mark an async generator method as a lifecycle resource.
59
+
60
+ Code before ``yield`` runs during initialization; code after ``yield``
61
+ runs during shutdown, in reverse lifecycle order.
62
+
63
+ :param after: Ordering constraint. If the referenced resources are present
64
+ in the final composed class, they are started before and stopped after
65
+ this resource. Silently ignored for resources that are absent.
66
+ :param before: Ordering constraint. If the referenced resources are present
67
+ in the final composed class, this resource is started before and stopped
68
+ after them. Silently ignored for resources that are absent.
69
+ :param requires: Presence constraint. All referenced resources must be
70
+ present in the final composed class. This does not impose an ordering
71
+ constraint. Checked by `LifecycleMixin.__init_subclass__`.
72
+ """
73
+
74
+ def _normalize(funcs: _FuncOrFuncs) -> tuple[_Func, ...]:
75
+ funcs = (funcs,) if callable(funcs) and not isinstance(funcs, (tuple, list)) else tuple(funcs)
76
+
77
+ for func in funcs:
78
+ if not inspect.isasyncgenfunction(func):
79
+ msg = f"@lifecycle '{func!r}' not an async generator function"
80
+ raise TypeError(msg)
81
+
82
+ return funcs
83
+
84
+ def _decorator(func: _LifecycleFunc) -> _LifecycleFunc:
85
+ if not inspect.isasyncgenfunction(func):
86
+ msg = f"@lifecycle requires an async generator function, got {func!r}"
87
+ raise TypeError(msg)
88
+ setattr(func, _ATTR_LIFECYCLE_RESOURCE, True)
89
+ setattr(func, _ATTR_LIFECYCLE_AFTER, tuple(_normalize(after)))
90
+ setattr(func, _ATTR_LIFECYCLE_BEFORE, tuple(_normalize(before)))
91
+ setattr(func, _ATTR_LIFECYCLE_REQUIRES, tuple(_normalize(requires)))
92
+ return func
93
+
94
+ if _func is not None: # bare @resource, no args
95
+ return _decorator(_func)
96
+ return _decorator # @resource(after=(...))
97
+
98
+
99
+ class LifecycleMixin:
100
+ """Mixin managing the lifecycle of classes with ``@resource`` decorated methods."""
101
+
102
+ __order: list[_Func] | None = None
103
+ __generators: dict[_Func, AsyncGenerator[None, None]] | None = None
104
+ __state: LifecycleState = LifecycleState.NEW
105
+
106
+ def __init_subclass__(cls, **kwargs) -> None:
107
+ """Determine the initialization order for a specific subclass.
108
+
109
+ :raises CycleError: If there is a cycle in the dependency graph.
110
+ """
111
+ super().__init_subclass__(**kwargs)
112
+ funcs = _collect_funcs(cls)
113
+ edges = _build_edges(funcs)
114
+ sorter = graphlib.TopologicalSorter(graph={node: tuple(edges.get(node, ())) for node in funcs})
115
+ try:
116
+ cls.__order = list(sorter.static_order())
117
+ except graphlib.CycleError as err:
118
+ raise CycleError(err.args[1]) from None
119
+
120
+ async def init(self) -> Self:
121
+ """Idempotent. Initialize all resources in dependency order.
122
+
123
+ On failure, rollback anything already started, in reverse.
124
+
125
+ :raises BaseException: Reraised exception from failed resource initialization.
126
+ :raises ExceptionGroup: If exceptions were caught during rollback.
127
+ """
128
+ if self.__state != LifecycleState.NEW:
129
+ return self
130
+ self.__state = LifecycleState.INITIALIZING
131
+ get_logger(self, _LOGGER).debug("Initialization started", order=self.lifecycle_order)
132
+ self.__generators = {}
133
+
134
+ started: list[_Func] = []
135
+ time_start = time.monotonic()
136
+ try:
137
+ assert self.__order is not None
138
+ for func in self.__order:
139
+ get_logger(self, _LOGGER).debug("starting...", func=func.__qualname__)
140
+ agen = func(self)
141
+ await agen.__anext__() # run resource init (until it yields)
142
+ self.__generators[func] = agen
143
+ started.append(func)
144
+ get_logger(self, _LOGGER).debug("started successfully", func=func.__qualname__)
145
+ except BaseException as init_exception:
146
+ get_logger(self, _LOGGER).exception("Failed to initialize!")
147
+ self.__state = LifecycleState.INITIALIZATION_FAILED
148
+ rollback_exceptions: list[Exception] = await self.__shutdown(started)
149
+ if rollback_exceptions:
150
+ msg = (
151
+ f"Resource failed to initialize, and rollback {len(rollback_exceptions)} "
152
+ "resource(s) failed to shut down cleanly."
153
+ )
154
+ raise ExceptionGroup(
155
+ msg,
156
+ [init_exception, *rollback_exceptions], # pyright: ignore[reportArgumentType]
157
+ ) from init_exception
158
+ raise
159
+
160
+ self.__state = LifecycleState.INITIALIZED
161
+ get_logger(self, _LOGGER).info("Initialization completed", elapsed=time.monotonic() - time_start)
162
+ return self
163
+
164
+ async def __shutdown(self, funcs: list[_Func]) -> list[Exception]:
165
+ """Shut down in reverse order, best-effort. Returns all caught exceptions."""
166
+ exceptions: list[Exception] = []
167
+ for func in reversed(funcs):
168
+ try:
169
+ get_logger(self, _LOGGER).debug("stopping...", func=func.__qualname__)
170
+ await self.__close_one(func)
171
+ get_logger(self, _LOGGER).debug("stopped", func=func.__qualname__)
172
+ except Exception as exception:
173
+ exceptions.append(exception)
174
+ return exceptions
175
+
176
+ async def shutdown(self) -> None:
177
+ """Idempotent. Run each resource's post-yield code in reverse dependency order."""
178
+ if not self.is_initialized:
179
+ return
180
+
181
+ time_start = time.monotonic()
182
+ self.__state = LifecycleState.SHUTTING_DOWN
183
+
184
+ assert self.__order is not None
185
+ exceptions: list[Exception] = await self.__shutdown(self.__order)
186
+
187
+ if exceptions:
188
+ self.__state = LifecycleState.SHUTDOWN_FAILED
189
+ msg = f"{len(exceptions)} resource(s) failed to shut down cleanly."
190
+ raise ExceptionGroup(msg, exceptions)
191
+
192
+ self.__state = LifecycleState.SHUTDOWN
193
+ get_logger(self, _LOGGER).info("Shutdown completed", elapsed=time.monotonic() - time_start)
194
+
195
+ async def __close_one(self, func: _Func) -> None:
196
+ if not self.__generators:
197
+ get_logger(self, _LOGGER).warning("Generators empty! Skipping", func=func.__qualname__)
198
+ return
199
+
200
+ agen = self.__generators.pop(func, None)
201
+ if agen is None:
202
+ return
203
+ try:
204
+ await agen.__anext__()
205
+ except StopAsyncIteration: # we expect exactly one yield
206
+ pass
207
+ else:
208
+ msg = f"resource on {func.__name__} yielded more than once"
209
+ raise RuntimeError(msg)
210
+
211
+ @property
212
+ def is_initialized(self) -> bool:
213
+ """Return ``True`` if initialized, ``False`` otherwise."""
214
+ return self.__state == LifecycleState.INITIALIZED
215
+
216
+ @property
217
+ def lifecycle_state(self) -> LifecycleState:
218
+ """Return the lifecycle state."""
219
+ return self.__state
220
+
221
+ @property
222
+ def lifecycle_order(self) -> list[_Func]:
223
+ """Return resolved init order (shutdown runs in reverse)."""
224
+ assert self.__order is not None
225
+ return list(self.__order)
226
+
227
+
228
+ # --------------------------------------------------------------------------
229
+ # helpers
230
+ # --------------------------------------------------------------------------
231
+
232
+
233
+ def _collect_funcs(cls: type) -> set[_Func]:
234
+ """Collect all methods from given class decorated with ``@lifecycle``."""
235
+ candidates: set[str] = set()
236
+ for klass in cls.__mro__:
237
+ for attr_name, attr_value in vars(klass).items():
238
+ if getattr(attr_value, _ATTR_LIFECYCLE_RESOURCE, False):
239
+ candidates.add(attr_name)
240
+
241
+ funcs: dict[str, _Func] = {}
242
+ for attr_name in candidates:
243
+ resolved: _Func = getattr(cls, attr_name)
244
+ if getattr(resolved, _ATTR_LIFECYCLE_RESOURCE, False):
245
+ funcs[attr_name] = resolved
246
+ return set(funcs.values())
247
+
248
+
249
+ def _build_edges(funcs: set[_Func]) -> dict[_Func, set[_Func]]:
250
+ """Validate required resources and build lifecycle ordering edges."""
251
+ edges: dict[_Func, set[_Func]] = {f: set() for f in funcs}
252
+
253
+ for func in funcs:
254
+ # hard requirement: target must be present
255
+ required_funcs: tuple[type, ...] = getattr(func, _ATTR_LIFECYCLE_REQUIRES, ())
256
+ for dep in required_funcs:
257
+ if dep not in funcs:
258
+ msg = (
259
+ f"{func.__qualname__} requires {dep.__qualname__} to be "
260
+ f"present as an active lifecycle, but it isn't."
261
+ )
262
+ raise TypeError(msg)
263
+
264
+ # soft hint: only wire the edge if the target actually ended up present
265
+ after_funcs: tuple[type, ...] = getattr(func, _ATTR_LIFECYCLE_AFTER, ())
266
+ for after_func in after_funcs:
267
+ if after_func in funcs:
268
+ edges[func].add(after_func)
269
+
270
+ before_funcs: tuple[type, ...] = getattr(func, _ATTR_LIFECYCLE_BEFORE, ())
271
+ for before_func in before_funcs:
272
+ if before_func in funcs:
273
+ edges[before_func].add(func)
274
+
275
+ return edges
@@ -0,0 +1,161 @@
1
+ # SPDX-FileCopyrightText: 2026 OpenSMI Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """Common structured logging utilities."""
6
+
7
+ import logging
8
+ import os
9
+ import shutil
10
+ import sys
11
+ from collections.abc import Mapping, MutableMapping
12
+ from enum import Enum
13
+ from typing import Any
14
+
15
+ import structlog
16
+
17
+ _LOGGER = logging.getLogger(__name__)
18
+
19
+
20
+ def _format_elapsed(seconds: float) -> str:
21
+ """Format given ``seconds`` into short human-readable string."""
22
+ if seconds >= 1:
23
+ return f"{seconds:.3f}s"
24
+ return f"{seconds * 1000:.1f}ms"
25
+
26
+
27
+ def _elapsed_to_ms(_, __, event_dict: MutableMapping[str, Any]) -> MutableMapping[str, Any]: # noqa: ANN001
28
+ """Convert ``elapsed`` entry in structlog processor event dict to shorter format."""
29
+ if "elapsed" in event_dict:
30
+ event_dict["elapsed"] = _format_elapsed(event_dict["elapsed"])
31
+ return event_dict
32
+
33
+
34
+ _logging_initialized = False
35
+ """ Global flag indicated whether logging is initialized. """
36
+
37
+
38
+ class LogFormat(Enum):
39
+ """Structured logging format to use."""
40
+
41
+ AUTOMATIC = "automatic"
42
+ """Automatic logging output, KEY_VALUE if there is a terminal, otherwise JSON."""
43
+ KEY_VALUE = "key_value"
44
+ """Key=Value formatted logging output"""
45
+ JSON = "json"
46
+ """JSON formatted logging output"""
47
+
48
+
49
+ def _setup_console_logging() -> None:
50
+ from importlib.util import find_spec
51
+
52
+ # see https://www.structlog.org/en/stable/logging-best-practices.html#pretty-printing-vs-structured-output
53
+ if find_spec("rich") is not None:
54
+ console_renderer = structlog.dev.ConsoleRenderer(
55
+ sort_keys=False,
56
+ exception_formatter=structlog.dev.RichTracebackFormatter(
57
+ width=shutil.get_terminal_size(fallback=(160, 24)).columns
58
+ ),
59
+ )
60
+ else:
61
+ console_renderer = structlog.dev.ConsoleRenderer(sort_keys=False)
62
+
63
+ processors = [
64
+ structlog.contextvars.merge_contextvars,
65
+ structlog.stdlib.filter_by_level,
66
+ structlog.stdlib.add_logger_name,
67
+ structlog.stdlib.add_log_level,
68
+ _elapsed_to_ms,
69
+ structlog.processors.TimeStamper(fmt="iso", utc=False),
70
+ structlog.processors.UnicodeDecoder(),
71
+ console_renderer,
72
+ ]
73
+
74
+ structlog.configure(
75
+ processors=processors,
76
+ wrapper_class=structlog.make_filtering_bound_logger(logging.NOTSET),
77
+ logger_factory=structlog.stdlib.LoggerFactory(),
78
+ cache_logger_on_first_use=True,
79
+ )
80
+
81
+
82
+ def _setup_key_value_logging() -> None:
83
+ try:
84
+ import orjson
85
+
86
+ _LOGGER.info("Using orjson serialization")
87
+ processors = [
88
+ structlog.contextvars.merge_contextvars,
89
+ # structlog.stdlib.filter_by_level,
90
+ # structlog.stdlib.add_logger_name,
91
+ structlog.stdlib.add_log_level,
92
+ _elapsed_to_ms,
93
+ structlog.processors.dict_tracebacks,
94
+ structlog.processors.JSONRenderer(serializer=orjson.dumps),
95
+ ]
96
+ logger_factory = structlog.BytesLoggerFactory()
97
+ except ImportError:
98
+ _LOGGER.warning("Could not import orjson, fallback to buildin serializer.")
99
+
100
+ processors = [
101
+ structlog.contextvars.merge_contextvars,
102
+ # structlog.stdlib.filter_by_level,
103
+ # structlog.stdlib.add_logger_name,
104
+ structlog.stdlib.add_log_level,
105
+ _elapsed_to_ms,
106
+ structlog.processors.dict_tracebacks,
107
+ structlog.processors.JSONRenderer(),
108
+ ]
109
+ logger_factory = structlog.stdlib.LoggerFactory()
110
+
111
+ structlog.configure(
112
+ processors=processors,
113
+ wrapper_class=structlog.make_filtering_bound_logger(logging.NOTSET),
114
+ logger_factory=logger_factory,
115
+ cache_logger_on_first_use=True,
116
+ )
117
+
118
+
119
+ def setup_logging(
120
+ *,
121
+ log_levels: Mapping[str, int | str] | None = None,
122
+ log_format: LogFormat = LogFormat.AUTOMATIC,
123
+ ) -> None:
124
+ """Set up structured logging."""
125
+ global _logging_initialized
126
+ if _logging_initialized:
127
+ return
128
+
129
+ logging.basicConfig(force=True, format="")
130
+ if log_levels is not None:
131
+ for name, level in log_levels.items():
132
+ try:
133
+ logging.getLogger(name).setLevel(level)
134
+ _LOGGER.debug(f"Set logger level of '{name}' to {level}")
135
+ except (TypeError, ValueError):
136
+ _LOGGER.exception(f"Could not set logger level of '{name}' to {level}")
137
+ if _LOGGER.level == logging.NOTSET:
138
+ _LOGGER.setLevel(logging.INFO)
139
+
140
+ # override given log_format from environment
141
+ env_log_format = os.getenv("LOG_FORMAT", None)
142
+ if env_log_format is not None:
143
+ try:
144
+ log_format = LogFormat[env_log_format.upper()]
145
+ except ValueError:
146
+ _LOGGER.warning(f"Invalid log format '{env_log_format}'")
147
+
148
+ if log_format == LogFormat.AUTOMATIC:
149
+ if sys.stderr.isatty():
150
+ _LOGGER.info("Automatic logging with attached terminal.")
151
+ log_format = LogFormat.KEY_VALUE
152
+ else:
153
+ _LOGGER.info("Automatic logging without attached terminal.")
154
+ log_format = LogFormat.JSON
155
+
156
+ if log_format == LogFormat.KEY_VALUE:
157
+ _setup_console_logging()
158
+ else: # JSON
159
+ _setup_key_value_logging()
160
+
161
+ _logging_initialized = True
opensmi/core/meta.py ADDED
@@ -0,0 +1,51 @@
1
+ # SPDX-FileCopyrightText: 2026 OpenSMI Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """Contains helper functions used for Python Metaprogramming magic."""
6
+
7
+ from typing import Any, TypeVar, get_args, get_origin
8
+
9
+
10
+ def resolve_generic_arguments(
11
+ cls: type,
12
+ target: type,
13
+ ) -> tuple[type, ...] | None:
14
+ """Find ``target[...]`` generic typing arguments through an inheritance tree.
15
+
16
+ :param cls: concrete class to resolve generic arguments from.
17
+ :param target: subclass of ``cls`` to resolve generic arguments for.
18
+ :returns: tuple containing all resolved arguments.
19
+ """
20
+
21
+ def walk(current: type, substitutions: dict[TypeVar, Any]) -> tuple[Any, ...] | None:
22
+ for base in getattr(current, "__orig_bases__", ()):
23
+ origin = get_origin(base)
24
+ args = get_args(base)
25
+
26
+ if origin is None:
27
+ continue
28
+
29
+ # Replace already-known TypeVars
30
+ resolved_args = tuple(substitutions.get(arg, arg) for arg in args)
31
+
32
+ # Found the target
33
+ if origin is target:
34
+ return resolved_args
35
+
36
+ # Build substitutions for this base
37
+ parameters = getattr(origin, "__parameters__", ())
38
+
39
+ child_substitutions = substitutions.copy()
40
+
41
+ for parameter, value in zip(parameters, resolved_args, strict=False):
42
+ child_substitutions[parameter] = value
43
+
44
+ result = walk(origin, child_substitutions)
45
+
46
+ if result is not None:
47
+ return result
48
+
49
+ return None
50
+
51
+ return walk(cls, {})
@@ -0,0 +1,19 @@
1
+ # SPDX-FileCopyrightText: 2026 OpenSMI Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """Helpers for Mixins."""
6
+
7
+ from typing import Any
8
+
9
+ import structlog
10
+
11
+ from opensmi.core.base_ua_object import BaseUaObject
12
+
13
+
14
+ def get_logger(obj: Any, logger: structlog.stdlib.BoundLogger) -> structlog.stdlib.BoundLogger:
15
+ """Get a logger for the given instance."""
16
+ if isinstance(obj, BaseUaObject):
17
+ # return logger.bind(name=obj.full_name)
18
+ return obj.logger
19
+ return logger.bind(obj=obj)
@@ -0,0 +1,32 @@
1
+ # SPDX-FileCopyrightText: 2026 OpenSMI Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """Shared Python protocols."""
6
+
7
+ from datetime import datetime
8
+ from typing import ClassVar, Protocol
9
+
10
+ from asyncua import ua
11
+
12
+
13
+ class UaEvent(Protocol):
14
+ """OPC UA Event."""
15
+
16
+ EventType: ua.NodeId
17
+ Message: ua.LocalizedText
18
+ Severity: int
19
+ ErrorCode: str
20
+ SourceName: str
21
+ SourceNode: ua.NodeId
22
+ Time: datetime
23
+ emitting_node: ua.NodeId
24
+
25
+
26
+ class NamespaceProvider(Protocol):
27
+ """Protocol describing the metadata exposed by generated OPC UA namespace classes."""
28
+
29
+ FILE_NAME: ClassVar[str]
30
+ URI: ClassVar[str]
31
+ VERSION: ClassVar[str]
32
+ REQUIRED_URIS: ClassVar[tuple[str, ...]]
@@ -0,0 +1,33 @@
1
+ # SPDX-FileCopyrightText: 2026 OpenSMI Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """Mixin providing default ``__repr__`` and ``__str__`` implementations."""
6
+
7
+ from collections.abc import Iterator
8
+
9
+ from typing_extensions import override
10
+
11
+
12
+ class ReprStrMixin:
13
+ """Mixin providing default `__repr__` and `__str__` implementations.
14
+
15
+ Classes contribute representation items by overriding `_repr_items()`.
16
+ Subclasses should call ``super()._repr_items()`` and yield additional
17
+ ``(name, value)`` pairs.
18
+ """
19
+
20
+ def _repr_items(self) -> Iterator[tuple[str, object]]:
21
+ """Yield ``(name, value)`` pairs for the string/repr representation."""
22
+ yield from ()
23
+
24
+ @override
25
+ def __repr__(self) -> str:
26
+ """Return an unambiguous representation of the instance."""
27
+ args = ", ".join(f"{name}={value!r}" for name, value in self._repr_items())
28
+ return f"{type(self).__name__}({args})"
29
+
30
+ @override
31
+ def __str__(self) -> str:
32
+ """Return a human-readable representation of the instance."""
33
+ return ", ".join(f"{name}={value}" for name, value in self._repr_items())
opensmi/core/signal.py ADDED
@@ -0,0 +1,36 @@
1
+ # SPDX-FileCopyrightText: 2026 OpenSMI Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """A mutable async signal for registering and invoking callbacks."""
6
+
7
+ import asyncio
8
+ from collections.abc import Awaitable, Callable
9
+ from typing import Generic, ParamSpec
10
+
11
+ _P = ParamSpec("_P")
12
+
13
+ Callback = Callable[_P, Awaitable[None]]
14
+
15
+
16
+ class Signal(Generic[_P]):
17
+ """A mutable async signal for registering and invoking callbacks."""
18
+
19
+ def __init__(self) -> None:
20
+ self._callbacks: set[Callback[_P]] = set()
21
+
22
+ def connect(self, callback: Callback[_P]) -> None:
23
+ """Register a callback."""
24
+ self._callbacks.add(callback)
25
+
26
+ def disconnect(self, callback: Callback[_P]) -> None:
27
+ """Unregister a callback if present."""
28
+ self._callbacks.discard(callback)
29
+
30
+ async def send(self, *args: _P.args, **kwargs: _P.kwargs) -> None:
31
+ """Invoke all registered callbacks concurrently.
32
+
33
+ The callback set is snapshot before dispatch, so callbacks may safely connect or disconnect
34
+ callbacks while handling the signal.
35
+ """
36
+ await asyncio.gather(*(callback(*args, **kwargs) for callback in tuple(self._callbacks)))