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
opensmi/core/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 OpenSMI Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""Common functionality for both server and client."""
|
|
6
|
+
|
|
7
|
+
from importlib import metadata
|
|
8
|
+
|
|
9
|
+
from .async_task_mixin import AsyncTaskMixin as AsyncTaskMixin
|
|
10
|
+
from .enums import LifecycleState as LifecycleState
|
|
11
|
+
from .lifecycle_mixin import LifecycleMixin as LifecycleMixin
|
|
12
|
+
from .lifecycle_mixin import lifecycle as lifecycle
|
|
13
|
+
from .logging import LogFormat as LogFormat
|
|
14
|
+
from .logging import setup_logging as setup_logging
|
|
15
|
+
from .signal import Signal as Signal
|
|
16
|
+
from .ua_enums import MachineryItemState as MachineryItemState
|
|
17
|
+
from .ua_enums import MachineryOperationMode as MachineryOperationMode
|
|
18
|
+
from .ua_enums import SkillState as SkillState
|
|
19
|
+
from .unit_all import Unit as Unit
|
|
20
|
+
|
|
21
|
+
__version__ = metadata.version("OpenSMI-Core")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 OpenSMI Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""Mixin for reliable tracking of fire-and-forget asyncio tasks."""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
from collections.abc import Coroutine
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AsyncTaskMixin:
|
|
13
|
+
"""Mixin for reliable tracking of fire-and-forget asyncio tasks.
|
|
14
|
+
|
|
15
|
+
Avoids such tasks disappearing while executing when garbage collected
|
|
16
|
+
(https://docs.python.org/3/library/asyncio-task.html#creating-tasks).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
__tasks: set[asyncio.Task[None]] | None = None
|
|
20
|
+
|
|
21
|
+
def _create_task(
|
|
22
|
+
self,
|
|
23
|
+
coro: Coroutine[Any, Any, None], # pyright: ignore[reportExplicitAny]
|
|
24
|
+
*,
|
|
25
|
+
name: str,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Schedule and track a background coroutine.
|
|
28
|
+
|
|
29
|
+
The coroutine must return ``None``.
|
|
30
|
+
"""
|
|
31
|
+
if self.__tasks is None:
|
|
32
|
+
self.__tasks = set()
|
|
33
|
+
|
|
34
|
+
task = asyncio.create_task(coro, name=name)
|
|
35
|
+
self.__tasks.add(task)
|
|
36
|
+
task.add_done_callback(self.__tasks.discard)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def _pending_tasks(self) -> set[asyncio.Task[None]]:
|
|
40
|
+
"""Return currently running tracked tasks."""
|
|
41
|
+
if self.__tasks is None:
|
|
42
|
+
return set()
|
|
43
|
+
return self.__tasks.copy()
|
|
44
|
+
|
|
45
|
+
async def _cancel_tasks(self) -> None:
|
|
46
|
+
"""Cancel and await all tracked tasks."""
|
|
47
|
+
if self.__tasks is None:
|
|
48
|
+
return # nothing to cancel
|
|
49
|
+
tasks = self.__tasks.copy()
|
|
50
|
+
|
|
51
|
+
for task in tasks:
|
|
52
|
+
_ = task.cancel()
|
|
53
|
+
|
|
54
|
+
if tasks:
|
|
55
|
+
_ = await asyncio.gather(*tasks, return_exceptions=True)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 OpenSMI Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""Base class for both client `RemoteServer` and server `Server`."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Generic, TypeVar
|
|
10
|
+
from weakref import WeakValueDictionary
|
|
11
|
+
|
|
12
|
+
from asyncua import ua
|
|
13
|
+
|
|
14
|
+
from opensmi.core.base_ua_object import BaseUaObject
|
|
15
|
+
from opensmi.core.protocols import NamespaceProvider
|
|
16
|
+
from opensmi.core.ua import NodeIdDefinition
|
|
17
|
+
|
|
18
|
+
_UaObjectType = TypeVar("_UaObjectType", bound=BaseUaObject)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BaseServer(Generic[_UaObjectType]):
|
|
22
|
+
"""Base class for both client `RemoteServer` and server `Server`."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, **kwargs) -> None:
|
|
25
|
+
"""*Cooperative* constructor."""
|
|
26
|
+
super().__init__(**kwargs)
|
|
27
|
+
|
|
28
|
+
self._ua_objects: WeakValueDictionary[ua.NodeId, _UaObjectType] = WeakValueDictionary()
|
|
29
|
+
"""Maps OPC UA nodes to UaObjects with weak references."""
|
|
30
|
+
self._namespace_map: dict[str, int] = {}
|
|
31
|
+
|
|
32
|
+
def get_ua_object(self, ua_node_id: ua.NodeId) -> _UaObjectType:
|
|
33
|
+
"""Return the ``UaObject`` which corresponds to the given ``ua_node``.
|
|
34
|
+
|
|
35
|
+
:raises KeyError: If no ``UaObject`` is found.
|
|
36
|
+
"""
|
|
37
|
+
return self._ua_objects[ua_node_id]
|
|
38
|
+
|
|
39
|
+
def get_ua_object_optional(self, ua_node_id: ua.NodeId) -> _UaObjectType | None:
|
|
40
|
+
"""Return the ``UaObject`` which corresponds to the given ``ua_node`` or ``None``."""
|
|
41
|
+
return self._ua_objects.get(ua_node_id, default=None)
|
|
42
|
+
|
|
43
|
+
def register_ua_object(self, ua_object: _UaObjectType) -> None:
|
|
44
|
+
"""Register the given ``UaObject`` to the server."""
|
|
45
|
+
assert ua_object is not None
|
|
46
|
+
assert isinstance(ua_object.ua_node.nodeid, ua.NodeId)
|
|
47
|
+
|
|
48
|
+
self._ua_objects[ua_object.ua_node.nodeid] = ua_object
|
|
49
|
+
|
|
50
|
+
def unregister_ua_object(self, ua_object: _UaObjectType) -> None:
|
|
51
|
+
"""Unregister the given ``UaObject`` from the server."""
|
|
52
|
+
assert ua_object is not None
|
|
53
|
+
assert isinstance(ua_object.ua_node.nodeid, ua.NodeId)
|
|
54
|
+
|
|
55
|
+
_ = self._ua_objects.pop(ua_object.ua_node.nodeid)
|
|
56
|
+
|
|
57
|
+
def ua_get_namespace_index(self, uri: str | type[NamespaceProvider]) -> ua.Int16:
|
|
58
|
+
"""Return the namespace index for the given namespace ``uri``.
|
|
59
|
+
|
|
60
|
+
:raises KeyError: If the namespace URI is unknown.
|
|
61
|
+
"""
|
|
62
|
+
if not isinstance(uri, str):
|
|
63
|
+
uri = uri.URI
|
|
64
|
+
|
|
65
|
+
return ua.Int16(self._namespace_map[uri])
|
|
66
|
+
|
|
67
|
+
def ua_get_node_id(self, identifier: NodeIdDefinition) -> ua.NodeId:
|
|
68
|
+
"""Return the complete OPC UA node ID for the given ``identifier``.
|
|
69
|
+
|
|
70
|
+
:raises KeyError: If the namespace URI of the identifier is unknown.
|
|
71
|
+
"""
|
|
72
|
+
return ua.NodeId(
|
|
73
|
+
Identifier=identifier.id,
|
|
74
|
+
NamespaceIndex=self.ua_get_namespace_index(identifier.ns_uri),
|
|
75
|
+
)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 OpenSMI Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""Base class for all UA objects, both client- and server-side."""
|
|
6
|
+
|
|
7
|
+
from pathlib import PurePosixPath
|
|
8
|
+
from typing import TYPE_CHECKING, Generic, TypeVar
|
|
9
|
+
|
|
10
|
+
import structlog
|
|
11
|
+
from asyncua.common.node import Node
|
|
12
|
+
from structlog.stdlib import BoundLogger
|
|
13
|
+
from typing_extensions import deprecated
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from opensmi.core.base_server import BaseServer
|
|
17
|
+
|
|
18
|
+
_ServerType = TypeVar("_ServerType", bound="BaseServer")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BaseUaObject(Generic[_ServerType]):
|
|
22
|
+
"""Base class for all UA objects, both client- and server-side."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
name: str | None = None,
|
|
28
|
+
server: _ServerType | None = None,
|
|
29
|
+
**kwargs,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""*Cooperative* constructor.
|
|
32
|
+
|
|
33
|
+
:param name: The name of the UA object. Defaults to the class name.
|
|
34
|
+
:param server: The associated server of the UA object. Note that there must be at least one non ``None`` in
|
|
35
|
+
the hierarchy.
|
|
36
|
+
"""
|
|
37
|
+
if name is None:
|
|
38
|
+
name = self.__class__.__name__
|
|
39
|
+
elif not isinstance(name, str) or name == "":
|
|
40
|
+
msg = f"Given name '{name}' is not valid!"
|
|
41
|
+
raise TypeError(msg)
|
|
42
|
+
|
|
43
|
+
self._name: str = name
|
|
44
|
+
self._server: _ServerType | None = server
|
|
45
|
+
self._ua_node: Node | None = None
|
|
46
|
+
self._logger: structlog.BoundLogger = structlog.get_logger(
|
|
47
|
+
f"opensmi.{self.__class__.__name__}", name=self.name
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
super().__init__(**kwargs) # cooperative __init__() after we are done
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def ua_node(self) -> Node:
|
|
54
|
+
"""Return the internal OPC UA node."""
|
|
55
|
+
if self._ua_node is not None:
|
|
56
|
+
return self._ua_node
|
|
57
|
+
|
|
58
|
+
msg = f"UA node of {self!r} is unknown, something went wrong!"
|
|
59
|
+
raise RuntimeError(msg)
|
|
60
|
+
|
|
61
|
+
@ua_node.setter
|
|
62
|
+
def ua_node(self, ua_node: Node) -> None:
|
|
63
|
+
"""Set main OPC UA node to an existing node (due to XML import, etc.). Only possible once."""
|
|
64
|
+
if self._ua_node is not None:
|
|
65
|
+
msg = f"Main OPC UA node of {self!r} already exists!"
|
|
66
|
+
raise RuntimeError(msg)
|
|
67
|
+
|
|
68
|
+
self._ua_node = ua_node
|
|
69
|
+
self.server.register_ua_object(self)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def name(self) -> str:
|
|
73
|
+
"""Name of the object."""
|
|
74
|
+
return self._name
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
@deprecated("Use path instead")
|
|
78
|
+
def full_name(self) -> str:
|
|
79
|
+
parent = getattr(self, "parent", None)
|
|
80
|
+
if isinstance(parent, BaseUaObject):
|
|
81
|
+
return f"{parent.full_name}/{self.name}"
|
|
82
|
+
return f"/{self.name}"
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def path(self) -> PurePosixPath:
|
|
86
|
+
"""Return the full path of the object."""
|
|
87
|
+
if isinstance(parent := getattr(self, "parent", None), BaseUaObject):
|
|
88
|
+
return parent.path / self.name
|
|
89
|
+
return PurePosixPath("/") / self.name
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def logger(self) -> BoundLogger:
|
|
93
|
+
"""Logger associated with the `UaObject` instance."""
|
|
94
|
+
return self._logger # type: ignore
|
|
95
|
+
|
|
96
|
+
# TODO
|
|
97
|
+
# @override
|
|
98
|
+
# def _repr_items(self) -> Iterator[tuple[str, object]]:
|
|
99
|
+
# yield "name", self._name
|
|
100
|
+
# yield "ua_node", self._ua_node
|
|
101
|
+
# yield from super()._repr_items()
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def server(self) -> _ServerType:
|
|
105
|
+
"""Return the associated server."""
|
|
106
|
+
if self._server is not None:
|
|
107
|
+
return self._server
|
|
108
|
+
|
|
109
|
+
parent = getattr(self, "parent", None)
|
|
110
|
+
assert parent is not None
|
|
111
|
+
|
|
112
|
+
server = getattr(parent, "server", None)
|
|
113
|
+
assert server is not None
|
|
114
|
+
|
|
115
|
+
self._server = server
|
|
116
|
+
return server # type: ignore
|
|
117
|
+
|
|
118
|
+
@server.setter
|
|
119
|
+
def server(self, server: _ServerType) -> None:
|
|
120
|
+
self._server = server
|
opensmi/core/config.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 OpenSMI Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""Functions to load configuration dataclasses from TOML files."""
|
|
6
|
+
|
|
7
|
+
import tomllib
|
|
8
|
+
from dataclasses import fields, is_dataclass
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import UnionType
|
|
12
|
+
from typing import Any, TypeVar, Union, get_args, get_origin, get_type_hints
|
|
13
|
+
|
|
14
|
+
_T = TypeVar("_T")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_toml(path: str | Path) -> dict[str, Any]:
|
|
18
|
+
"""Load a TOML file into a dictionary."""
|
|
19
|
+
with Path(path).open("rb") as f:
|
|
20
|
+
return tomllib.load(f)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_dataclass(data_cls: type[_T], path: str | Path) -> _T:
|
|
24
|
+
"""Load a dataclass configuration from a TOML file."""
|
|
25
|
+
return from_dict(data_cls, load_toml(path))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def from_dict(data_cls: type[_T], data: dict[str, Any]) -> _T:
|
|
29
|
+
"""Create a dataclass instance from a (TOML) dictionary."""
|
|
30
|
+
if not is_dataclass(data_cls):
|
|
31
|
+
msg = f"{data_cls.__name__} is not a dataclass"
|
|
32
|
+
raise TypeError(msg)
|
|
33
|
+
|
|
34
|
+
type_hints = get_type_hints(data_cls)
|
|
35
|
+
known_fields: set[str] = {field_.name for field_ in fields(data_cls)}
|
|
36
|
+
|
|
37
|
+
unknown: set[str] = set(data) - known_fields
|
|
38
|
+
if unknown:
|
|
39
|
+
msg = f"Unknown configuration option(s) for {data_cls.__name__}: " + ", ".join(sorted(unknown))
|
|
40
|
+
raise ValueError(msg)
|
|
41
|
+
|
|
42
|
+
kwargs = {name: _convert(value, type_hints[name]) for name, value in data.items()}
|
|
43
|
+
# Fields not present in TOML are deliberately omitted.
|
|
44
|
+
# The dataclass default/default_factory will be used.
|
|
45
|
+
return data_cls(**kwargs)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _convert(value: Any, target_type: Any) -> Any:
|
|
49
|
+
"""Convert a (TOML) value to the requested Python type."""
|
|
50
|
+
origin = get_origin(target_type)
|
|
51
|
+
|
|
52
|
+
# Optional[T] / T | None
|
|
53
|
+
if origin in (Union, UnionType):
|
|
54
|
+
args = get_args(target_type)
|
|
55
|
+
|
|
56
|
+
if value is None and type(None) in args:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
non_none = [arg for arg in args if arg is not type(None)]
|
|
60
|
+
if len(non_none) == 1:
|
|
61
|
+
return _convert(value, non_none[0])
|
|
62
|
+
|
|
63
|
+
# Enum
|
|
64
|
+
if isinstance(target_type, type) and issubclass(target_type, Enum):
|
|
65
|
+
try:
|
|
66
|
+
return target_type(value)
|
|
67
|
+
except ValueError as exc:
|
|
68
|
+
valid = ", ".join(repr(member.value) for member in target_type)
|
|
69
|
+
msg = f"Invalid value {value!r} for {target_type.__name__}; expected one of: {valid}"
|
|
70
|
+
raise ValueError(msg) from exc
|
|
71
|
+
|
|
72
|
+
# Nested dataclass
|
|
73
|
+
if isinstance(target_type, type) and is_dataclass(target_type):
|
|
74
|
+
if not isinstance(value, dict):
|
|
75
|
+
msg = f"Expected a TOML table for {target_type.__name__}, got {type(value).__name__}"
|
|
76
|
+
raise TypeError(msg)
|
|
77
|
+
return from_dict(target_type, value)
|
|
78
|
+
|
|
79
|
+
# list[T]
|
|
80
|
+
if origin is list:
|
|
81
|
+
assert isinstance(value, list)
|
|
82
|
+
(item_type,) = get_args(target_type)
|
|
83
|
+
return [_convert(item, item_type) for item in value]
|
|
84
|
+
|
|
85
|
+
# dict[K, V]
|
|
86
|
+
if origin is dict:
|
|
87
|
+
assert isinstance(value, dict)
|
|
88
|
+
key_type, value_type = get_args(target_type)
|
|
89
|
+
return {_convert(key, key_type): _convert(item, value_type) for key, item in value.items()}
|
|
90
|
+
|
|
91
|
+
# Basic types are already returned by tomllib.
|
|
92
|
+
return value
|
opensmi/core/enums.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 OpenSMI Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
from enum import IntEnum, auto
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LifecycleState(IntEnum):
|
|
9
|
+
"""Represents the lifecycle state of an instance of machine, skill, component, etc."""
|
|
10
|
+
|
|
11
|
+
NEW = auto()
|
|
12
|
+
""" Instance is created, but not (async) initialized. """
|
|
13
|
+
INITIALIZING = auto()
|
|
14
|
+
""" Instance is currently (async) initializing. """
|
|
15
|
+
INITIALIZED = auto()
|
|
16
|
+
""" Instance is initialized successfully and ready to be used. """
|
|
17
|
+
INITIALIZATION_FAILED = auto()
|
|
18
|
+
""" Instance initialization failed and instance may need to be shut down. """
|
|
19
|
+
SHUTTING_DOWN = auto()
|
|
20
|
+
""" Instance is currently (async) shutting down. """
|
|
21
|
+
SHUTDOWN = auto()
|
|
22
|
+
""" Instance is shut down successfully and cannot be used anymore. Final state."""
|
|
23
|
+
SHUTDOWN_FAILED = auto()
|
|
24
|
+
""" Instance shutdown failed and cannot be used anymore. Final state."""
|
opensmi/core/errors.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 OpenSMI Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OpenSmiError(Exception):
|
|
9
|
+
"""Base for all OpenSMI related errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SkillNotSuspendableError(OpenSmiError):
|
|
13
|
+
"""Is raised when failing to suspend a skill that is not suspendable."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OpenSmiRuntimeError(OpenSmiError):
|
|
17
|
+
"""Is raised by user code when something goes wrong.
|
|
18
|
+
|
|
19
|
+
Use this exception instead of the build-in ``RuntimeError``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, msg: str, error_code: str = "") -> None:
|
|
23
|
+
"""Construct a new PyUaAdapterError with the given error message and error code."""
|
|
24
|
+
self.msg = msg
|
|
25
|
+
self.error_code = error_code
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NoValidValueReadError(OpenSmiError):
|
|
29
|
+
"""Is raised when trying to read a `UaVariable` whose value is ``None``."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SkillHaltedError(OpenSmiRuntimeError):
|
|
33
|
+
"""Is raised when a skill other than the own skill is halted.
|
|
34
|
+
|
|
35
|
+
(i.e. when calling other skills)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class OutOfRangeError(OpenSmiRuntimeError):
|
|
40
|
+
"""Is raised when failing to write a value to a variable that is out of range."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class NamespaceUninitializedError(OpenSmiError):
|
|
44
|
+
"""Is raised when trying to get node ids from a namespace before it was initialized."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RequiredVariableMissingError(OpenSmiError):
|
|
48
|
+
"""Is raised when a required variable is missing."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ValidationError(ValueError):
|
|
52
|
+
"""Is raised when trying to validate an object that doesn't pass validation.
|
|
53
|
+
|
|
54
|
+
Will only be raised during initialization.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CycleError(Exception):
|
|
59
|
+
"""Raised when a dependency graph of ``@lifecycle`` decorated methods contains a cycle.
|
|
60
|
+
|
|
61
|
+
See `LifecycleMixin` for more details.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, path: list[Hashable]) -> None:
|
|
65
|
+
"""Construct a new CycleError with the given path containing the cycle."""
|
|
66
|
+
node: Hashable = path[-1]
|
|
67
|
+
super().__init__(f"cycle detected at {node!r} (path: {path!r})")
|
opensmi/core/frozen.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 OpenSMI Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""Metaclass for frozen classes."""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Never
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FrozenMeta(type):
|
|
11
|
+
"""Metaclass for frozen classes."""
|
|
12
|
+
|
|
13
|
+
def __call__(cls, *args, **kwargs) -> Never:
|
|
14
|
+
"""Prevent instantiation of frozen classes."""
|
|
15
|
+
msg = f"{cls.__name__} cannot be instantiated"
|
|
16
|
+
raise TypeError(msg)
|
|
17
|
+
|
|
18
|
+
def __setattr__(cls, name: str, value: Any) -> Never:
|
|
19
|
+
"""Prevent overriding attributes of frozen classes."""
|
|
20
|
+
msg = f"{cls.__name__} is frozen"
|
|
21
|
+
raise TypeError(msg)
|
|
22
|
+
|
|
23
|
+
def __delattr__(cls, name: str) -> Never:
|
|
24
|
+
"""Prevent deleting attributes of frozen classes.."""
|
|
25
|
+
msg = f"{cls.__name__} is frozen"
|
|
26
|
+
raise TypeError(msg)
|
opensmi/core/hook.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 OpenSMI Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""Helper for dealing with async hooks."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import inspect
|
|
10
|
+
from collections.abc import Awaitable, Callable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def call_hook(
|
|
14
|
+
hook: Callable[[], Awaitable[None]],
|
|
15
|
+
) -> None:
|
|
16
|
+
"""Call coroutine ``hook``, but provide helpful ``TypeError`` if `hook` is not a coroutine."""
|
|
17
|
+
if not inspect.iscoroutinefunction(hook):
|
|
18
|
+
hook_class = f"{hook.__self__.__class__.__name__}." # pyright: ignore[reportFunctionMemberAccess]
|
|
19
|
+
msg = f"{hook_class}{hook.__name__}() must be declared with 'async def'."
|
|
20
|
+
raise TypeError(msg)
|
|
21
|
+
|
|
22
|
+
await hook()
|