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.
Files changed (44) hide show
  1. {opensmi_core-0.4 → opensmi_core-0.5}/PKG-INFO +1 -1
  2. {opensmi_core-0.4 → opensmi_core-0.5}/src/OpenSMI_Core.egg-info/PKG-INFO +1 -1
  3. opensmi_core-0.5/src/OpenSMI_Core.egg-info/scm_version.json +8 -0
  4. {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/lifecycle_mixin.py +34 -27
  5. {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/logging.py +74 -36
  6. {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/subscription_manager.py +131 -37
  7. opensmi_core-0.4/src/OpenSMI_Core.egg-info/scm_version.json +0 -8
  8. {opensmi_core-0.4 → opensmi_core-0.5}/.github/workflows/api-docs.yml +0 -0
  9. {opensmi_core-0.4 → opensmi_core-0.5}/.github/workflows/python-publish.yml +0 -0
  10. {opensmi_core-0.4 → opensmi_core-0.5}/.gitignore +0 -0
  11. {opensmi_core-0.4 → opensmi_core-0.5}/.gitlab-ci.yml +0 -0
  12. {opensmi_core-0.4 → opensmi_core-0.5}/LICENSE +0 -0
  13. {opensmi_core-0.4 → opensmi_core-0.5}/LICENSES/MIT.txt +0 -0
  14. {opensmi_core-0.4 → opensmi_core-0.5}/README.md +0 -0
  15. {opensmi_core-0.4 → opensmi_core-0.5}/pyproject.toml +0 -0
  16. {opensmi_core-0.4 → opensmi_core-0.5}/setup.cfg +0 -0
  17. {opensmi_core-0.4 → opensmi_core-0.5}/src/OpenSMI_Core.egg-info/SOURCES.txt +0 -0
  18. {opensmi_core-0.4 → opensmi_core-0.5}/src/OpenSMI_Core.egg-info/dependency_links.txt +0 -0
  19. {opensmi_core-0.4 → opensmi_core-0.5}/src/OpenSMI_Core.egg-info/requires.txt +0 -0
  20. {opensmi_core-0.4 → opensmi_core-0.5}/src/OpenSMI_Core.egg-info/scm_file_list.json +0 -0
  21. {opensmi_core-0.4 → opensmi_core-0.5}/src/OpenSMI_Core.egg-info/top_level.txt +0 -0
  22. {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/__init__.py +0 -0
  23. {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/async_task_mixin.py +0 -0
  24. {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/base_server.py +0 -0
  25. {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/base_ua_object.py +0 -0
  26. {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/config.py +0 -0
  27. {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/enums.py +0 -0
  28. {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/errors.py +0 -0
  29. {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/frozen.py +0 -0
  30. {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/hook.py +0 -0
  31. {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/meta.py +0 -0
  32. {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/mixin_helpers.py +0 -0
  33. {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/protocols.py +0 -0
  34. {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/repr_mixin.py +0 -0
  35. {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/signal.py +0 -0
  36. {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/ua.py +0 -0
  37. {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/ua_enums.py +0 -0
  38. {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/ua_node_util.py +0 -0
  39. {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/unit_all.py +0 -0
  40. {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/unit_base.py +0 -0
  41. {opensmi_core-0.4 → opensmi_core-0.5}/src/opensmi/core/unset.py +0 -0
  42. {opensmi_core-0.4 → opensmi_core-0.5}/tests/test_signal.py +0 -0
  43. {opensmi_core-0.4 → opensmi_core-0.5}/tests/test_unit.py +0 -0
  44. {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.4
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.4
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
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.5",
3
+ "distance": 0,
4
+ "node": "gddccea703057c24b9ad0c2411b5d680aed029421",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-15"
8
+ }
@@ -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 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.
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
- Composed class: the concrete class that inherits its resource mixins and
14
- `LifecycleMixin`. This is the only class that should inherit `LifecycleMixin`.
15
+ Resource mixin
16
+ A plain class (does not inherit `LifecycleMixin`) that provides one or more resources.
15
17
 
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.
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 lifecycle resource.
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 @resource, no args
102
+ if _func is not None: # bare @lifecycle, no args
96
103
  return _decorator(_func)
97
- return _decorator # @resource(after=(...))
104
+ return _decorator # @lifecycle(after=(...))
98
105
 
99
106
 
100
107
  class LifecycleMixin:
101
- """Mixin managing the lifecycle of classes with ``@resource`` decorated methods."""
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 ExceptionGroup: If exceptions were caught during rollback.
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 isinstance(init_exception, CancelledError):
148
- get_logger(self, _LOGGER).error("Initialization cancelled")
149
- else:
150
- get_logger(self, _LOGGER).exception("Failed to initialize!")
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 ExceptionGroup(
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[type, ...] = getattr(func, _ATTR_LIFECYCLE_REQUIRES, ())
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[type, ...] = getattr(func, _ATTR_LIFECYCLE_AFTER, ())
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[type, ...] = getattr(func, _ATTR_LIFECYCLE_BEFORE, ())
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 typing import Any
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
- """ Global flag indicated whether logging is initialized. """
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 _setup_console_logging() -> None:
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(logging.NOTSET),
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 _setup_key_value_logging() -> None:
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
- 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
- ]
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 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
- ]
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(logging.NOTSET),
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(force=True, format="")
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(f"Set logger level of '{name}' to {level}")
172
+ _LOGGER.debug("Set logger level of %r to %s", name, level)
135
173
  except (TypeError, ValueError):
136
- _LOGGER.exception(f"Could not set logger level of '{name}' to {level}")
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 ValueError:
146
- _LOGGER.warning(f"Invalid log format '{env_log_format}'")
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
- _setup_console_logging()
195
+ _setup_key_value_logging(min_level=log_min_level, enable_rich_tracebacks=enable_rich_tracebacks)
158
196
  else: # JSON
159
- _setup_key_value_logging()
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
- await self._create_subscription()
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
- for node_id, monitored_item in list(self._data_subscription_map.items()):
131
- try:
132
- handle = await self._ua_subscription.subscribe_data_change(self._ua_client.get_node(node_id))
133
- monitored_item.handle = handle
134
- except UaStatusCodeError as err:
135
- monitored_item.handle = None
136
- self.logger.warning("Could not renew data change subscription", node=node_id.to_string(), reason=err)
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
- ) -> None:
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
- for node in nodes:
203
- if isinstance(node, NodeId):
204
- node = self._ua_client.get_node(node)
205
-
206
- if node.nodeid not in self._data_subscription_map:
207
- try:
208
- handle = await self._ua_subscription.subscribe_data_change(node)
209
- self._data_subscription_map[node.nodeid] = _MonitoredItem(handle=handle, handlers=[handler])
210
- self.logger.debug(
211
- "Added data change handler", handler=handler, node=node.nodeid.to_string(), handle=handle
212
- )
213
- except BadAttributeIdInvalid:
214
- self.logger.warning("Node does not support subscription!", node=node.nodeid.to_string())
215
- except UaStatusCodeError as err:
216
- self.logger.exception("Unexpected UA error!", err=err)
217
- else:
218
- # We only need 1 subscription for n handlers.
219
- self.logger.debug(
220
- "Added data change handler, reusing existing subscription.",
221
- handler=handler,
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."""
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.4",
3
- "distance": 0,
4
- "node": "gf395f24046a13a5a9c4b6536f16e250aada34582",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-10-12"
8
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes