OpenSMI-Core 0.4__tar.gz → 0.5__tar.gz
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-0.4 → opensmi_core-0.5}/PKG-INFO +1 -1
- {opensmi_core-0.4 → opensmi_core-0.5}/src/OpenSMI_Core.egg-info/PKG-INFO +1 -1
- opensmi_core-0.5/src/OpenSMI_Core.egg-info/scm_version.json +8 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/lifecycle_mixin.py +34 -27
- {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/logging.py +74 -36
- {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/subscription_manager.py +131 -37
- opensmi_core-0.4/src/OpenSMI_Core.egg-info/scm_version.json +0 -8
- {opensmi_core-0.4 → opensmi_core-0.5}/.github/workflows/api-docs.yml +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/.github/workflows/python-publish.yml +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/.gitignore +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/.gitlab-ci.yml +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/LICENSE +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/LICENSES/MIT.txt +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/README.md +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/pyproject.toml +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/setup.cfg +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/OpenSMI_Core.egg-info/SOURCES.txt +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/OpenSMI_Core.egg-info/dependency_links.txt +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/OpenSMI_Core.egg-info/requires.txt +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/OpenSMI_Core.egg-info/scm_file_list.json +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/OpenSMI_Core.egg-info/top_level.txt +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/__init__.py +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/async_task_mixin.py +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/base_server.py +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/base_ua_object.py +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/config.py +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/enums.py +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/errors.py +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/frozen.py +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/hook.py +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/meta.py +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/mixin_helpers.py +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/protocols.py +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/repr_mixin.py +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/signal.py +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/ua.py +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/ua_enums.py +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/ua_node_util.py +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/unit_all.py +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/unit_base.py +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/unset.py +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/tests/test_signal.py +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/tests/test_unit.py +0 -0
- {opensmi_core-0.4 → opensmi_core-0.5}/tools/auto_generate_units.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: OpenSMI-Core
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5
|
|
4
4
|
Summary: Smart Machine Interface core functionality for server and client applications.
|
|
5
5
|
Author-email: Carsten Harms <Carsten.Harms@dfki.de>, Simon Jungbluth <Simon.Jungbluth@smartfactory.de>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: OpenSMI-Core
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5
|
|
4
4
|
Summary: Smart Machine Interface core functionality for server and client applications.
|
|
5
5
|
Author-email: Carsten Harms <Carsten.Harms@dfki.de>, Simon Jungbluth <Simon.Jungbluth@smartfactory.de>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -5,23 +5,30 @@
|
|
|
5
5
|
"""Mixin for async lifecycle management for mixin-composed classes.
|
|
6
6
|
|
|
7
7
|
Concepts
|
|
8
|
+
========
|
|
8
9
|
|
|
9
|
-
Resource
|
|
10
|
-
|
|
11
|
-
code after yield is
|
|
10
|
+
Resource
|
|
11
|
+
A single `lifecycle`-decorated async-generator method. Code before ``yield`` initializes it;
|
|
12
|
+
code after ``yield`` shuts it down. A resource is the unit of ordering:
|
|
13
|
+
``after``, ``before`` and ``requires`` refer to resources, not classes.
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
Resource mixin
|
|
16
|
+
A plain class (does not inherit `LifecycleMixin`) that provides one or more resources.
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
Composed class
|
|
19
|
+
The concrete class that inherits its resource mixins and `LifecycleMixin`.
|
|
20
|
+
This is the only class that should inherit `LifecycleMixin`.
|
|
21
|
+
|
|
22
|
+
Ordering
|
|
23
|
+
Resources are initialized in dependency order and shut down in the exact reverse order. Order is
|
|
24
|
+
resolved once, in `LifecycleMixin.__init_subclass__`, from ``after`` / ``before``
|
|
25
|
+
arguments of `lifecycle`-decorated methods. ``requires`` is checked at the same point
|
|
26
|
+
but imposes no ordering.
|
|
19
27
|
"""
|
|
20
28
|
|
|
21
29
|
import graphlib
|
|
22
30
|
import inspect
|
|
23
31
|
import time
|
|
24
|
-
from asyncio import CancelledError
|
|
25
32
|
from collections.abc import AsyncGenerator, Callable
|
|
26
33
|
from typing import Final, Self, TypeVar, overload
|
|
27
34
|
|
|
@@ -47,7 +54,7 @@ _FuncOrFuncs = _Func | tuple[_Func, ...] | list[_Func]
|
|
|
47
54
|
def lifecycle(_func: _LifecycleFunc) -> _LifecycleFunc: ...
|
|
48
55
|
@overload
|
|
49
56
|
def lifecycle(
|
|
50
|
-
_func: None = None, *, after: _FuncOrFuncs = (), before: _FuncOrFuncs = ()
|
|
57
|
+
_func: None = None, *, after: _FuncOrFuncs = (), before: _FuncOrFuncs = (), requires: _FuncOrFuncs = ()
|
|
51
58
|
) -> Callable[[_LifecycleFunc], _LifecycleFunc]: ...
|
|
52
59
|
def lifecycle(
|
|
53
60
|
_func: _LifecycleFunc | None = None,
|
|
@@ -56,7 +63,7 @@ def lifecycle(
|
|
|
56
63
|
before: _FuncOrFuncs = (),
|
|
57
64
|
requires: _FuncOrFuncs = (),
|
|
58
65
|
) -> _LifecycleFunc | Callable[[_LifecycleFunc], _LifecycleFunc]:
|
|
59
|
-
"""Mark an async generator method as a
|
|
66
|
+
"""Mark an async generator method as a resource.
|
|
60
67
|
|
|
61
68
|
Code before ``yield`` runs during initialization; code after ``yield``
|
|
62
69
|
runs during shutdown, in reverse lifecycle order.
|
|
@@ -92,13 +99,13 @@ def lifecycle(
|
|
|
92
99
|
setattr(func, _ATTR_LIFECYCLE_REQUIRES, tuple(_normalize(requires)))
|
|
93
100
|
return func
|
|
94
101
|
|
|
95
|
-
if _func is not None: # bare @
|
|
102
|
+
if _func is not None: # bare @lifecycle, no args
|
|
96
103
|
return _decorator(_func)
|
|
97
|
-
return _decorator # @
|
|
104
|
+
return _decorator # @lifecycle(after=(...))
|
|
98
105
|
|
|
99
106
|
|
|
100
107
|
class LifecycleMixin:
|
|
101
|
-
"""Mixin managing the lifecycle of classes with
|
|
108
|
+
"""Mixin managing the lifecycle of classes with `lifecycle` decorated methods."""
|
|
102
109
|
|
|
103
110
|
__order: list[_Func] | None = None
|
|
104
111
|
__generators: dict[_Func, AsyncGenerator[None, None]] | None = None
|
|
@@ -124,7 +131,7 @@ class LifecycleMixin:
|
|
|
124
131
|
On failure, rollback anything already started, in reverse.
|
|
125
132
|
|
|
126
133
|
:raises BaseException: Reraised exception from failed resource initialization.
|
|
127
|
-
:raises
|
|
134
|
+
:raises BaseExceptionGroup: If exceptions were caught during rollback.
|
|
128
135
|
"""
|
|
129
136
|
if self.__state != LifecycleState.NEW:
|
|
130
137
|
return self
|
|
@@ -133,10 +140,12 @@ class LifecycleMixin:
|
|
|
133
140
|
self.__generators = {}
|
|
134
141
|
|
|
135
142
|
started: list[_Func] = []
|
|
143
|
+
current: _Func | None = None
|
|
136
144
|
time_start = time.monotonic()
|
|
137
145
|
try:
|
|
138
146
|
assert self.__order is not None
|
|
139
147
|
for func in self.__order:
|
|
148
|
+
current = func
|
|
140
149
|
get_logger(self, _LOGGER).debug("starting...", func=func.__qualname__)
|
|
141
150
|
agen = func(self)
|
|
142
151
|
await agen.__anext__() # run resource init (until it yields)
|
|
@@ -144,21 +153,19 @@ class LifecycleMixin:
|
|
|
144
153
|
started.append(func)
|
|
145
154
|
get_logger(self, _LOGGER).debug("started successfully", func=func.__qualname__)
|
|
146
155
|
except BaseException as init_exception:
|
|
147
|
-
if
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
156
|
+
if current is not None:
|
|
157
|
+
init_exception.add_note(
|
|
158
|
+
f"{type(self).__name__}: failed starting {current.__qualname__}; "
|
|
159
|
+
f"{len(started)} resource(s) had been started before it."
|
|
160
|
+
)
|
|
151
161
|
self.__state = LifecycleState.INITIALIZATION_FAILED
|
|
152
162
|
rollback_exceptions: list[Exception] = await self.__shutdown(started)
|
|
153
163
|
if rollback_exceptions:
|
|
154
164
|
msg = (
|
|
155
|
-
f"Resource failed to initialize, and rollback {len(rollback_exceptions)} "
|
|
165
|
+
f"Resource failed to initialize, and rollback of {len(rollback_exceptions)} "
|
|
156
166
|
"resource(s) failed to shut down cleanly."
|
|
157
167
|
)
|
|
158
|
-
raise
|
|
159
|
-
msg,
|
|
160
|
-
[init_exception, *rollback_exceptions], # pyright: ignore[reportArgumentType]
|
|
161
|
-
) from init_exception
|
|
168
|
+
raise BaseExceptionGroup(msg, [init_exception, *rollback_exceptions]) from init_exception
|
|
162
169
|
raise
|
|
163
170
|
|
|
164
171
|
self.__state = LifecycleState.INITIALIZED
|
|
@@ -256,7 +263,7 @@ def _build_edges(funcs: set[_Func]) -> dict[_Func, set[_Func]]:
|
|
|
256
263
|
|
|
257
264
|
for func in funcs:
|
|
258
265
|
# hard requirement: target must be present
|
|
259
|
-
required_funcs: tuple[
|
|
266
|
+
required_funcs: tuple[_Func, ...] = getattr(func, _ATTR_LIFECYCLE_REQUIRES, ())
|
|
260
267
|
for dep in required_funcs:
|
|
261
268
|
if dep not in funcs:
|
|
262
269
|
msg = (
|
|
@@ -266,12 +273,12 @@ def _build_edges(funcs: set[_Func]) -> dict[_Func, set[_Func]]:
|
|
|
266
273
|
raise TypeError(msg)
|
|
267
274
|
|
|
268
275
|
# soft hint: only wire the edge if the target actually ended up present
|
|
269
|
-
after_funcs: tuple[
|
|
276
|
+
after_funcs: tuple[_Func, ...] = getattr(func, _ATTR_LIFECYCLE_AFTER, ())
|
|
270
277
|
for after_func in after_funcs:
|
|
271
278
|
if after_func in funcs:
|
|
272
279
|
edges[func].add(after_func)
|
|
273
280
|
|
|
274
|
-
before_funcs: tuple[
|
|
281
|
+
before_funcs: tuple[_Func, ...] = getattr(func, _ATTR_LIFECYCLE_BEFORE, ())
|
|
275
282
|
for before_func in before_funcs:
|
|
276
283
|
if before_func in funcs:
|
|
277
284
|
edges[before_func].add(func)
|
|
@@ -10,7 +10,8 @@ import shutil
|
|
|
10
10
|
import sys
|
|
11
11
|
from collections.abc import Mapping, MutableMapping
|
|
12
12
|
from enum import Enum
|
|
13
|
-
from
|
|
13
|
+
from pathlib import PurePath
|
|
14
|
+
from typing import Any, Final
|
|
14
15
|
|
|
15
16
|
import structlog
|
|
16
17
|
|
|
@@ -31,8 +32,17 @@ def _elapsed_to_ms(_, __, event_dict: MutableMapping[str, Any]) -> MutableMappin
|
|
|
31
32
|
return event_dict
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
def _path_to_str(_, __, event_dict: MutableMapping[str, Any]) -> MutableMapping[str, Any]: # noqa: ANN001
|
|
36
|
+
"""Convert ``path`` entry in structlog processor event dict to simple string."""
|
|
37
|
+
if "path" in event_dict:
|
|
38
|
+
path = event_dict["path"]
|
|
39
|
+
if isinstance(path, PurePath):
|
|
40
|
+
event_dict["path"] = str(path)
|
|
41
|
+
return event_dict
|
|
42
|
+
|
|
43
|
+
|
|
34
44
|
_logging_initialized = False
|
|
35
|
-
"""
|
|
45
|
+
"""Global flag indicated whether logging is initialized."""
|
|
36
46
|
|
|
37
47
|
|
|
38
48
|
class LogFormat(Enum):
|
|
@@ -46,11 +56,15 @@ class LogFormat(Enum):
|
|
|
46
56
|
"""JSON formatted logging output"""
|
|
47
57
|
|
|
48
58
|
|
|
49
|
-
def
|
|
59
|
+
def _setup_key_value_logging(
|
|
60
|
+
*,
|
|
61
|
+
min_level: int | str = logging.NOTSET,
|
|
62
|
+
enable_rich_tracebacks: bool = True,
|
|
63
|
+
) -> None:
|
|
50
64
|
from importlib.util import find_spec
|
|
51
65
|
|
|
52
66
|
# see https://www.structlog.org/en/stable/logging-best-practices.html#pretty-printing-vs-structured-output
|
|
53
|
-
if find_spec("rich") is not None:
|
|
67
|
+
if find_spec("rich") is not None and enable_rich_tracebacks:
|
|
54
68
|
console_renderer = structlog.dev.ConsoleRenderer(
|
|
55
69
|
sort_keys=False,
|
|
56
70
|
exception_formatter=structlog.dev.RichTracebackFormatter(
|
|
@@ -62,10 +76,10 @@ def _setup_console_logging() -> None:
|
|
|
62
76
|
|
|
63
77
|
processors = [
|
|
64
78
|
structlog.contextvars.merge_contextvars,
|
|
65
|
-
structlog.stdlib.filter_by_level,
|
|
66
79
|
structlog.stdlib.add_logger_name,
|
|
67
80
|
structlog.stdlib.add_log_level,
|
|
68
81
|
_elapsed_to_ms,
|
|
82
|
+
_path_to_str,
|
|
69
83
|
structlog.processors.TimeStamper(fmt="iso", utc=False),
|
|
70
84
|
structlog.processors.UnicodeDecoder(),
|
|
71
85
|
console_renderer,
|
|
@@ -73,67 +87,91 @@ def _setup_console_logging() -> None:
|
|
|
73
87
|
|
|
74
88
|
structlog.configure(
|
|
75
89
|
processors=processors,
|
|
76
|
-
wrapper_class=structlog.make_filtering_bound_logger(
|
|
90
|
+
wrapper_class=structlog.make_filtering_bound_logger(min_level=min_level),
|
|
77
91
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
78
92
|
cache_logger_on_first_use=True,
|
|
79
93
|
)
|
|
80
94
|
|
|
81
95
|
|
|
82
|
-
def
|
|
96
|
+
def _setup_json_logging(
|
|
97
|
+
*,
|
|
98
|
+
min_level: int | str = logging.NOTSET,
|
|
99
|
+
) -> None:
|
|
100
|
+
|
|
101
|
+
processors = [
|
|
102
|
+
structlog.contextvars.merge_contextvars,
|
|
103
|
+
structlog.stdlib.add_logger_name,
|
|
104
|
+
structlog.stdlib.add_log_level,
|
|
105
|
+
_path_to_str,
|
|
106
|
+
structlog.processors.dict_tracebacks,
|
|
107
|
+
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
|
108
|
+
]
|
|
109
|
+
|
|
83
110
|
try:
|
|
84
111
|
import orjson
|
|
85
112
|
|
|
86
113
|
_LOGGER.info("Using orjson serialization")
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
]
|
|
114
|
+
|
|
115
|
+
processors.append(structlog.processors.JSONRenderer(serializer=orjson.dumps))
|
|
96
116
|
logger_factory = structlog.BytesLoggerFactory()
|
|
97
117
|
except ImportError:
|
|
98
|
-
_LOGGER.warning("Could not import orjson, fallback to
|
|
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
|
-
]
|
|
118
|
+
_LOGGER.warning("Could not import orjson, fallback to builtin serializer.")
|
|
119
|
+
|
|
120
|
+
processors.append(structlog.processors.JSONRenderer())
|
|
109
121
|
logger_factory = structlog.stdlib.LoggerFactory()
|
|
110
122
|
|
|
111
123
|
structlog.configure(
|
|
112
124
|
processors=processors,
|
|
113
|
-
wrapper_class=structlog.make_filtering_bound_logger(
|
|
125
|
+
wrapper_class=structlog.make_filtering_bound_logger(min_level=min_level),
|
|
114
126
|
logger_factory=logger_factory,
|
|
115
127
|
cache_logger_on_first_use=True,
|
|
116
128
|
)
|
|
117
129
|
|
|
118
130
|
|
|
131
|
+
DEFAULT_LOG_LEVELS: Final[dict[str, int | str]] = {
|
|
132
|
+
"opensmi": logging.INFO,
|
|
133
|
+
"asyncua": logging.ERROR,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
119
137
|
def setup_logging(
|
|
120
138
|
*,
|
|
139
|
+
log_min_level: int | str = logging.NOTSET,
|
|
121
140
|
log_levels: Mapping[str, int | str] | None = None,
|
|
122
141
|
log_format: LogFormat = LogFormat.AUTOMATIC,
|
|
142
|
+
enable_rich_tracebacks: bool = True,
|
|
143
|
+
force_basic_config: bool = False,
|
|
123
144
|
) -> None:
|
|
124
|
-
"""Set up structured logging.
|
|
145
|
+
"""Set up structured logging.
|
|
146
|
+
|
|
147
|
+
``log_format`` can be overridden with the ``LOG_FORMAT`` environment variable.
|
|
148
|
+
|
|
149
|
+
:param log_min_level: Minimum logging level for structlog loggers.
|
|
150
|
+
:param log_levels: Logging levels for *all* loggers. Maps logger name to level. Defaults to `DEFAULT_LOG_LEVELS`.
|
|
151
|
+
:param log_format: Logging format for *all* loggers.
|
|
152
|
+
:param enable_rich_tracebacks: Enable Rich tracebacks if `KEY_VALUE` log format is used.
|
|
153
|
+
:param force_basic_config: ``force`` parameter of ``logging.basicConfig``.
|
|
154
|
+
"""
|
|
125
155
|
global _logging_initialized
|
|
126
156
|
if _logging_initialized:
|
|
127
157
|
return
|
|
128
158
|
|
|
129
|
-
logging.basicConfig(
|
|
159
|
+
logging.basicConfig(format="", force=force_basic_config)
|
|
160
|
+
|
|
161
|
+
if log_levels is None:
|
|
162
|
+
log_levels = DEFAULT_LOG_LEVELS
|
|
163
|
+
|
|
164
|
+
# structlog loggers created via structlog.stdlib.LoggerFactory are also gated by the stdlib level
|
|
165
|
+
if log_min_level != logging.NOTSET and (log_levels is None or "" not in log_levels):
|
|
166
|
+
logging.getLogger().setLevel(log_min_level)
|
|
167
|
+
|
|
130
168
|
if log_levels is not None:
|
|
131
169
|
for name, level in log_levels.items():
|
|
132
170
|
try:
|
|
133
171
|
logging.getLogger(name).setLevel(level)
|
|
134
|
-
_LOGGER.debug(
|
|
172
|
+
_LOGGER.debug("Set logger level of %r to %s", name, level)
|
|
135
173
|
except (TypeError, ValueError):
|
|
136
|
-
_LOGGER.exception(
|
|
174
|
+
_LOGGER.exception("Could not set logger level of %r to %s", name, level)
|
|
137
175
|
if _LOGGER.level == logging.NOTSET:
|
|
138
176
|
_LOGGER.setLevel(logging.INFO)
|
|
139
177
|
|
|
@@ -142,8 +180,8 @@ def setup_logging(
|
|
|
142
180
|
if env_log_format is not None:
|
|
143
181
|
try:
|
|
144
182
|
log_format = LogFormat[env_log_format.upper()]
|
|
145
|
-
except
|
|
146
|
-
_LOGGER.warning(
|
|
183
|
+
except KeyError:
|
|
184
|
+
_LOGGER.warning("Invalid log format %r", env_log_format)
|
|
147
185
|
|
|
148
186
|
if log_format == LogFormat.AUTOMATIC:
|
|
149
187
|
if sys.stderr.isatty():
|
|
@@ -154,8 +192,8 @@ def setup_logging(
|
|
|
154
192
|
log_format = LogFormat.JSON
|
|
155
193
|
|
|
156
194
|
if log_format == LogFormat.KEY_VALUE:
|
|
157
|
-
|
|
195
|
+
_setup_key_value_logging(min_level=log_min_level, enable_rich_tracebacks=enable_rich_tracebacks)
|
|
158
196
|
else: # JSON
|
|
159
|
-
|
|
197
|
+
_setup_json_logging(min_level=log_min_level)
|
|
160
198
|
|
|
161
199
|
_logging_initialized = True
|
|
@@ -9,7 +9,7 @@ import contextlib
|
|
|
9
9
|
import time
|
|
10
10
|
from abc import ABC, abstractmethod
|
|
11
11
|
from asyncio import Task
|
|
12
|
-
from collections.abc import Iterable
|
|
12
|
+
from collections.abc import Iterable, Sequence
|
|
13
13
|
from dataclasses import dataclass, field
|
|
14
14
|
from typing import Any, Generic, Never, TypeVar
|
|
15
15
|
|
|
@@ -19,7 +19,6 @@ from asyncua.client.client import Client
|
|
|
19
19
|
from asyncua.common.node import Node
|
|
20
20
|
from asyncua.common.subscription import DataChangeNotif, Subscription
|
|
21
21
|
from asyncua.ua import NodeId, UaStatusCodeError
|
|
22
|
-
from asyncua.ua.uaerrors import BadAttributeIdInvalid
|
|
23
22
|
|
|
24
23
|
from opensmi.core.protocols import UaEvent
|
|
25
24
|
|
|
@@ -48,6 +47,20 @@ class _MonitoredItem(Generic[_HandlerType]):
|
|
|
48
47
|
handle: int | None
|
|
49
48
|
"""Handle as returned by ``asyncua``. Is ``None`` when renew failed."""
|
|
50
49
|
handlers: list[_HandlerType] = field(default_factory=list)
|
|
50
|
+
"""List of handler that are subscribed to the monitored item. Shall not contain duplicates."""
|
|
51
|
+
|
|
52
|
+
def add_handlers(self, handlers: Iterable[_HandlerType]) -> None:
|
|
53
|
+
"""Add given handlers if they are not already in the ``handlers`` list."""
|
|
54
|
+
for handler in handlers:
|
|
55
|
+
if handler not in self.handlers:
|
|
56
|
+
self.handlers.append(handler)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class _Wanted:
|
|
61
|
+
node: Node
|
|
62
|
+
handlers: list[UaDataChangeSubscriber] = field(default_factory=list)
|
|
63
|
+
"""List of handlers that asked to be subscribed to ``node``."""
|
|
51
64
|
|
|
52
65
|
|
|
53
66
|
class SubscriptionManager:
|
|
@@ -66,12 +79,15 @@ class SubscriptionManager:
|
|
|
66
79
|
ua_client: Client,
|
|
67
80
|
publishing_interval_ms: float = 100,
|
|
68
81
|
heartbeat_stale_after: float = 10.0,
|
|
82
|
+
max_items_per_call: int | None = None,
|
|
69
83
|
) -> None:
|
|
70
84
|
"""Create a new instance.
|
|
71
85
|
|
|
72
86
|
:param ua_client: OPC UA client to use.
|
|
73
87
|
:param publishing_interval_ms: Publishing interval in milliseconds for OPC UA subscription
|
|
74
88
|
:param heartbeat_stale_after: Time in seconds until any subscription should be received.
|
|
89
|
+
:param max_items_per_call: Maximum number of OPC UA monitored items per call. If ``None``, will be read from the
|
|
90
|
+
servers ``MaxMonitoredItemsPerCall``.
|
|
75
91
|
"""
|
|
76
92
|
self._publishing_interval_ms = publishing_interval_ms
|
|
77
93
|
self._ua_client = ua_client
|
|
@@ -84,6 +100,7 @@ class SubscriptionManager:
|
|
|
84
100
|
self._heartbeat_task: Task[Never] | None = None
|
|
85
101
|
self._last_heartbeat = time.monotonic()
|
|
86
102
|
self._heartbeat_stale_after = heartbeat_stale_after
|
|
103
|
+
self._max_items_per_call: int | None = max_items_per_call
|
|
87
104
|
|
|
88
105
|
self.logger = structlog.getLogger("opensmi.SubscriptionManager")
|
|
89
106
|
|
|
@@ -93,6 +110,20 @@ class SubscriptionManager:
|
|
|
93
110
|
)
|
|
94
111
|
self.logger.info("Created subscription!", parameters=self._ua_subscription.parameters)
|
|
95
112
|
|
|
113
|
+
async def _read_max_items_per_call(self) -> None:
|
|
114
|
+
fallback_value = 100
|
|
115
|
+
ua_max_monitored_items_per_call = self._ua_client.get_node(
|
|
116
|
+
ua.object_ids.ObjectIds.Server_ServerCapabilities_OperationLimits_MaxMonitoredItemsPerCall
|
|
117
|
+
)
|
|
118
|
+
try:
|
|
119
|
+
self._max_items_per_call = await ua_max_monitored_items_per_call.read_value() or fallback_value
|
|
120
|
+
except UaStatusCodeError as err:
|
|
121
|
+
self.logger.warning(
|
|
122
|
+
"Could not read server limit", ua_node=ua_max_monitored_items_per_call.nodeid.to_string(), reason=err
|
|
123
|
+
)
|
|
124
|
+
self._max_items_per_call = fallback_value
|
|
125
|
+
self.logger.debug("Updated max_items_per_call", value=self._max_items_per_call)
|
|
126
|
+
|
|
96
127
|
async def init(self) -> None:
|
|
97
128
|
"""Create the subscription and start heartbeat monitoring.
|
|
98
129
|
|
|
@@ -101,7 +132,10 @@ class SubscriptionManager:
|
|
|
101
132
|
if self._heartbeat_task is not None:
|
|
102
133
|
return
|
|
103
134
|
|
|
104
|
-
|
|
135
|
+
async with asyncio.TaskGroup() as tg:
|
|
136
|
+
if self._max_items_per_call is None:
|
|
137
|
+
tg.create_task(self._read_max_items_per_call())
|
|
138
|
+
tg.create_task(self._create_subscription())
|
|
105
139
|
|
|
106
140
|
# subscribe to a node that always changes reliably, for example the current time of the server
|
|
107
141
|
# this is done to prevent the subscription from timing out
|
|
@@ -127,13 +161,21 @@ class SubscriptionManager:
|
|
|
127
161
|
# TODO(CaHa): What about the subscriptions that could not be renewed?
|
|
128
162
|
|
|
129
163
|
async def _renew_data_change_subscriptions(self) -> None:
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
164
|
+
node_ids = list(self._data_subscription_map)
|
|
165
|
+
nodes = [self._ua_client.get_node(node_id) for node_id in node_ids]
|
|
166
|
+
results = await self._create_data_items(nodes)
|
|
167
|
+
for node_id, result in zip(node_ids, results, strict=True):
|
|
168
|
+
item = self._data_subscription_map.get(node_id) # may be gone
|
|
169
|
+
if item is None:
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
if isinstance(result, ua.StatusCode):
|
|
173
|
+
item.handle = None
|
|
174
|
+
self.logger.warning(
|
|
175
|
+
"Could not renew data change subscription", node=node_id.to_string(), reason=result.name
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
item.handle = result
|
|
137
179
|
|
|
138
180
|
async def _renew_event_subscriptions(self) -> None:
|
|
139
181
|
for node_id, monitored_item in list(self._event_subscription_map.items()):
|
|
@@ -192,36 +234,88 @@ class SubscriptionManager:
|
|
|
192
234
|
"""Return the number of monitored nodes."""
|
|
193
235
|
return len(self._data_subscription_map) + len(self._event_subscription_map)
|
|
194
236
|
|
|
237
|
+
async def _create_data_items(self, nodes: Sequence[Node]) -> list[int | ua.StatusCode]:
|
|
238
|
+
"""Batches node subscriptions respecting server's max items per call limit.
|
|
239
|
+
|
|
240
|
+
:return: List of handles of the subscription or status codes on failure.
|
|
241
|
+
"""
|
|
242
|
+
results: list[int | ua.StatusCode] = []
|
|
243
|
+
size = self._max_items_per_call or len(nodes)
|
|
244
|
+
for i in range(0, len(nodes), size):
|
|
245
|
+
batch = nodes[i : i + size]
|
|
246
|
+
self.logger.debug("Subscribing to data changes", count=len(batch))
|
|
247
|
+
results.extend(await self._ua_subscription.subscribe_data_change(batch))
|
|
248
|
+
return results
|
|
249
|
+
|
|
250
|
+
def _collect_wanted(
|
|
251
|
+
self,
|
|
252
|
+
subscriptions: Iterable[tuple[UaDataChangeSubscriber, Node | NodeId | Iterable[Node | NodeId]]],
|
|
253
|
+
) -> dict[NodeId, _Wanted]:
|
|
254
|
+
wanted: dict[NodeId, _Wanted] = {}
|
|
255
|
+
for handler, nodes in subscriptions:
|
|
256
|
+
if not isinstance(nodes, Iterable):
|
|
257
|
+
nodes = [nodes]
|
|
258
|
+
for node in nodes:
|
|
259
|
+
if isinstance(node, NodeId):
|
|
260
|
+
node = self._ua_client.get_node(node)
|
|
261
|
+
entry = wanted.setdefault(node.nodeid, _Wanted(node))
|
|
262
|
+
if handler not in entry.handlers:
|
|
263
|
+
entry.handlers.append(handler)
|
|
264
|
+
return wanted
|
|
265
|
+
|
|
266
|
+
def _attach_to_monitored(self, wanted: dict[NodeId, _Wanted]) -> list[Node]:
|
|
267
|
+
new_nodes: list[Node] = []
|
|
268
|
+
for node_id, entry in wanted.items():
|
|
269
|
+
item = self._data_subscription_map.get(node_id)
|
|
270
|
+
if item is None:
|
|
271
|
+
new_nodes.append(entry.node)
|
|
272
|
+
else:
|
|
273
|
+
item.add_handlers(entry.handlers)
|
|
274
|
+
return new_nodes
|
|
275
|
+
|
|
276
|
+
async def _subscribe_new_nodes(
|
|
277
|
+
self, new_nodes: list[Node], wanted: dict[NodeId, _Wanted]
|
|
278
|
+
) -> dict[Node, ua.StatusCode]:
|
|
279
|
+
results: list[int | ua.StatusCode] = await self._create_data_items(new_nodes)
|
|
280
|
+
|
|
281
|
+
errors: dict[Node, ua.StatusCode] = {}
|
|
282
|
+
for node, result in zip(new_nodes, results, strict=True):
|
|
283
|
+
if isinstance(result, ua.StatusCode):
|
|
284
|
+
errors[node] = result
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
handlers: list[UaDataChangeSubscriber] = wanted[node.nodeid].handlers
|
|
288
|
+
if (item := self._data_subscription_map.get(node.nodeid)) is None:
|
|
289
|
+
self._data_subscription_map[node.nodeid] = _MonitoredItem(handle=result, handlers=handlers)
|
|
290
|
+
else:
|
|
291
|
+
item.add_handlers(handlers)
|
|
292
|
+
return errors
|
|
293
|
+
|
|
195
294
|
async def subscribe_data_change(
|
|
196
295
|
self, handler: UaDataChangeSubscriber, nodes: Node | NodeId | Iterable[Node | NodeId]
|
|
197
|
-
) ->
|
|
198
|
-
"""Add given handler to receive data change notifications for given nodes.
|
|
199
|
-
if not isinstance(nodes, Iterable):
|
|
200
|
-
nodes = [nodes] # create a list out of the given single node or node id
|
|
296
|
+
) -> dict[Node, ua.StatusCode]:
|
|
297
|
+
"""Add given ``handler`` to receive data change notifications for given nodes.
|
|
201
298
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
node=node.nodeid.to_string(),
|
|
223
|
-
)
|
|
224
|
-
self._data_subscription_map[node.nodeid].handlers.append(handler)
|
|
299
|
+
Note: Any given ``handler`` can only be subscribed once to each node.
|
|
300
|
+
|
|
301
|
+
:return: OPC UA error status codes for all failed subscriptions. Mapping of node to status code.
|
|
302
|
+
"""
|
|
303
|
+
return await self.subscribe_data_change_batch([(handler, nodes)])
|
|
304
|
+
|
|
305
|
+
async def subscribe_data_change_batch(
|
|
306
|
+
self,
|
|
307
|
+
subscriptions: Iterable[tuple[UaDataChangeSubscriber, Node | NodeId | Iterable[Node | NodeId]]],
|
|
308
|
+
) -> dict[Node, ua.StatusCode]:
|
|
309
|
+
"""Subscribe many handlers to many nodes using as few server calls as possible.
|
|
310
|
+
|
|
311
|
+
:param subscriptions: Pairs of ``(handler, nodes)``. A node may appear for several handlers.
|
|
312
|
+
:return: OPC UA error status codes for all failed subscriptions. Mapping of node to status code.
|
|
313
|
+
"""
|
|
314
|
+
wanted = self._collect_wanted(subscriptions)
|
|
315
|
+
new_nodes = self._attach_to_monitored(wanted)
|
|
316
|
+
if not new_nodes:
|
|
317
|
+
return {}
|
|
318
|
+
return await self._subscribe_new_nodes(new_nodes, wanted)
|
|
225
319
|
|
|
226
320
|
async def subscribe_event(self, handler: UaEventSubscriber, source_node: Node | NodeId) -> None:
|
|
227
321
|
"""Add given handler to receive event notifications for given node."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|