OpenSMI-Core 0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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()