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.
- opensmi/core/__init__.py +21 -0
- opensmi/core/async_task_mixin.py +55 -0
- opensmi/core/base_server.py +75 -0
- opensmi/core/base_ua_object.py +120 -0
- opensmi/core/config.py +92 -0
- opensmi/core/enums.py +24 -0
- opensmi/core/errors.py +67 -0
- opensmi/core/frozen.py +26 -0
- opensmi/core/hook.py +22 -0
- opensmi/core/lifecycle_mixin.py +275 -0
- opensmi/core/logging.py +161 -0
- opensmi/core/meta.py +51 -0
- opensmi/core/mixin_helpers.py +19 -0
- opensmi/core/protocols.py +32 -0
- opensmi/core/repr_mixin.py +33 -0
- opensmi/core/signal.py +36 -0
- opensmi/core/subscription_manager.py +190 -0
- opensmi/core/ua.py +17 -0
- opensmi/core/ua_enums.py +93 -0
- opensmi/core/ua_node_util.py +239 -0
- opensmi/core/unit_all.py +2187 -0
- opensmi/core/unit_base.py +71 -0
- opensmi/core/unset.py +22 -0
- opensmi_core-0.1.dist-info/METADATA +32 -0
- opensmi_core-0.1.dist-info/RECORD +28 -0
- opensmi_core-0.1.dist-info/WHEEL +5 -0
- opensmi_core-0.1.dist-info/licenses/LICENSE +18 -0
- opensmi_core-0.1.dist-info/top_level.txt +1 -0
|
@@ -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
|
opensmi/core/logging.py
ADDED
|
@@ -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)))
|