OpenSMI-Core 0.6__tar.gz → 0.7__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 (68) hide show
  1. {opensmi_core-0.6 → opensmi_core-0.7}/.github/workflows/python-publish.yml +2 -0
  2. {opensmi_core-0.6 → opensmi_core-0.7}/.gitignore +2 -1
  3. {opensmi_core-0.6 → opensmi_core-0.7}/PKG-INFO +1 -1
  4. {opensmi_core-0.6 → opensmi_core-0.7}/pyproject.toml +3 -0
  5. {opensmi_core-0.6 → opensmi_core-0.7}/src/OpenSMI_Core.egg-info/PKG-INFO +1 -1
  6. {opensmi_core-0.6 → opensmi_core-0.7}/src/OpenSMI_Core.egg-info/SOURCES.txt +20 -2
  7. {opensmi_core-0.6 → opensmi_core-0.7}/src/OpenSMI_Core.egg-info/scm_file_list.json +20 -2
  8. opensmi_core-0.7/src/OpenSMI_Core.egg-info/scm_version.json +8 -0
  9. {opensmi_core-0.6 → opensmi_core-0.7}/src/opensmi/core/__init__.py +5 -0
  10. opensmi_core-0.7/src/opensmi/core/base_server.py +181 -0
  11. {opensmi_core-0.6 → opensmi_core-0.7}/src/opensmi/core/base_ua_object.py +78 -9
  12. opensmi_core-0.7/src/opensmi/core/nodesets/Opc.Ua.Di.NodeSet2.xml +5719 -0
  13. opensmi_core-0.7/src/opensmi/core/nodesets/Opc.Ua.Machinery.NodeSet2.xml +1713 -0
  14. opensmi_core-0.7/src/opensmi/core/nodesets/Opc.Ua.RSL.NodeSet2.xml +601 -0
  15. opensmi_core-0.7/src/opensmi/core/nodesets/Opc.Ua.SmartFactory.MachineSet.NodeSet2.xml +2886 -0
  16. opensmi_core-0.7/src/opensmi/core/nodesets/Opc.Ua.SmartFactory.SkillSet.NodeSet2.xml +856 -0
  17. opensmi_core-0.7/src/opensmi/core/nodesets/__init__.py +26 -0
  18. opensmi_core-0.7/src/opensmi/core/nodesets/di.py +73 -0
  19. opensmi_core-0.7/src/opensmi/core/nodesets/machinery.py +35 -0
  20. opensmi_core-0.7/src/opensmi/core/nodesets/rsl.py +30 -0
  21. opensmi_core-0.7/src/opensmi/core/nodesets/smart_factory_machine_set.py +51 -0
  22. opensmi_core-0.7/src/opensmi/core/nodesets/smart_factory_skill_set.py +31 -0
  23. opensmi_core-0.7/src/opensmi/core/paths.py +216 -0
  24. {opensmi_core-0.6 → opensmi_core-0.7}/src/opensmi/core/subscription_manager.py +35 -7
  25. opensmi_core-0.7/src/opensmi/core/ua.py +59 -0
  26. opensmi_core-0.7/tests/test_nodesets.py +48 -0
  27. opensmi_core-0.7/tests/test_object_path.py +268 -0
  28. opensmi_core-0.7/tests/test_paths.py +110 -0
  29. opensmi_core-0.7/tests/test_resolve.py +296 -0
  30. opensmi_core-0.7/tests/test_ua_values.py +268 -0
  31. opensmi_core-0.7/tools/auto_generate_nodesets.py +220 -0
  32. opensmi_core-0.7/tools/nodeset.py.template +25 -0
  33. opensmi_core-0.6/.gitlab-ci.yml +0 -49
  34. opensmi_core-0.6/src/OpenSMI_Core.egg-info/scm_version.json +0 -8
  35. opensmi_core-0.6/src/opensmi/core/base_server.py +0 -75
  36. opensmi_core-0.6/src/opensmi/core/ua.py +0 -17
  37. {opensmi_core-0.6 → opensmi_core-0.7}/.github/workflows/api-docs.yml +0 -0
  38. {opensmi_core-0.6 → opensmi_core-0.7}/.github/workflows/checks.yml +0 -0
  39. {opensmi_core-0.6 → opensmi_core-0.7}/LICENSE +0 -0
  40. {opensmi_core-0.6 → opensmi_core-0.7}/LICENSES/MIT.txt +0 -0
  41. {opensmi_core-0.6 → opensmi_core-0.7}/README.md +0 -0
  42. {opensmi_core-0.6 → opensmi_core-0.7}/setup.cfg +0 -0
  43. {opensmi_core-0.6 → opensmi_core-0.7}/src/OpenSMI_Core.egg-info/dependency_links.txt +0 -0
  44. {opensmi_core-0.6 → opensmi_core-0.7}/src/OpenSMI_Core.egg-info/requires.txt +0 -0
  45. {opensmi_core-0.6 → opensmi_core-0.7}/src/OpenSMI_Core.egg-info/top_level.txt +0 -0
  46. {opensmi_core-0.6 → opensmi_core-0.7}/src/opensmi/core/async_task_mixin.py +0 -0
  47. {opensmi_core-0.6 → opensmi_core-0.7}/src/opensmi/core/config.py +0 -0
  48. {opensmi_core-0.6 → opensmi_core-0.7}/src/opensmi/core/enums.py +0 -0
  49. {opensmi_core-0.6 → opensmi_core-0.7}/src/opensmi/core/errors.py +0 -0
  50. {opensmi_core-0.6 → opensmi_core-0.7}/src/opensmi/core/frozen.py +0 -0
  51. {opensmi_core-0.6 → opensmi_core-0.7}/src/opensmi/core/hook.py +0 -0
  52. {opensmi_core-0.6 → opensmi_core-0.7}/src/opensmi/core/lifecycle_mixin.py +0 -0
  53. {opensmi_core-0.6 → opensmi_core-0.7}/src/opensmi/core/logging.py +0 -0
  54. {opensmi_core-0.6 → opensmi_core-0.7}/src/opensmi/core/meta.py +0 -0
  55. {opensmi_core-0.6 → opensmi_core-0.7}/src/opensmi/core/mixin_helpers.py +0 -0
  56. {opensmi_core-0.6 → opensmi_core-0.7}/src/opensmi/core/protocols.py +0 -0
  57. {opensmi_core-0.6 → opensmi_core-0.7}/src/opensmi/core/repr_mixin.py +0 -0
  58. {opensmi_core-0.6 → opensmi_core-0.7}/src/opensmi/core/rsl.py +0 -0
  59. {opensmi_core-0.6 → opensmi_core-0.7}/src/opensmi/core/signal.py +0 -0
  60. {opensmi_core-0.6 → opensmi_core-0.7}/src/opensmi/core/ua_enums.py +0 -0
  61. {opensmi_core-0.6 → opensmi_core-0.7}/src/opensmi/core/ua_node_util.py +0 -0
  62. {opensmi_core-0.6 → opensmi_core-0.7}/src/opensmi/core/unit_all.py +0 -0
  63. {opensmi_core-0.6 → opensmi_core-0.7}/src/opensmi/core/unit_base.py +0 -0
  64. {opensmi_core-0.6 → opensmi_core-0.7}/src/opensmi/core/unset.py +0 -0
  65. {opensmi_core-0.6 → opensmi_core-0.7}/tests/test_rsl.py +0 -0
  66. {opensmi_core-0.6 → opensmi_core-0.7}/tests/test_signal.py +0 -0
  67. {opensmi_core-0.6 → opensmi_core-0.7}/tests/test_unit.py +0 -0
  68. {opensmi_core-0.6 → opensmi_core-0.7}/tools/auto_generate_units.py +0 -0
@@ -18,8 +18,10 @@ permissions:
18
18
  jobs:
19
19
  checks:
20
20
  uses: ./.github/workflows/checks.yml
21
+ secrets: inherit # passes CODECOV_TOKEN through
21
22
 
22
23
  release-build:
24
+ needs: [ checks ]
23
25
  runs-on: ubuntu-latest
24
26
 
25
27
  steps:
@@ -15,4 +15,5 @@
15
15
  /apidocs
16
16
  *.der
17
17
  *.pem
18
- *.sql
18
+ *.sql
19
+ tools/rec20_Rev17e-2021_0.xlsx
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: OpenSMI-Core
3
- Version: 0.6
3
+ Version: 0.7
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
@@ -74,6 +74,9 @@ where = ["src"]
74
74
  namespaces = true
75
75
  # exclude = ["tests", "doc*", "examples"]
76
76
 
77
+ [tool.setuptools.package-data]
78
+ "opensmi.core.nodesets" = ["*.NodeSet2.xml"]
79
+
77
80
  [tool.ruff]
78
81
  # Exclude a variety of commonly ignored directories.
79
82
  exclude = [
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: OpenSMI-Core
3
- Version: 0.6
3
+ Version: 0.7
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,5 +1,4 @@
1
1
  .gitignore
2
- .gitlab-ci.yml
3
2
  LICENSE
4
3
  README.md
5
4
  pyproject.toml
@@ -27,6 +26,7 @@ src/opensmi/core/lifecycle_mixin.py
27
26
  src/opensmi/core/logging.py
28
27
  src/opensmi/core/meta.py
29
28
  src/opensmi/core/mixin_helpers.py
29
+ src/opensmi/core/paths.py
30
30
  src/opensmi/core/protocols.py
31
31
  src/opensmi/core/repr_mixin.py
32
32
  src/opensmi/core/rsl.py
@@ -38,7 +38,25 @@ src/opensmi/core/ua_node_util.py
38
38
  src/opensmi/core/unit_all.py
39
39
  src/opensmi/core/unit_base.py
40
40
  src/opensmi/core/unset.py
41
+ src/opensmi/core/nodesets/Opc.Ua.Di.NodeSet2.xml
42
+ src/opensmi/core/nodesets/Opc.Ua.Machinery.NodeSet2.xml
43
+ src/opensmi/core/nodesets/Opc.Ua.RSL.NodeSet2.xml
44
+ src/opensmi/core/nodesets/Opc.Ua.SmartFactory.MachineSet.NodeSet2.xml
45
+ src/opensmi/core/nodesets/Opc.Ua.SmartFactory.SkillSet.NodeSet2.xml
46
+ src/opensmi/core/nodesets/__init__.py
47
+ src/opensmi/core/nodesets/di.py
48
+ src/opensmi/core/nodesets/machinery.py
49
+ src/opensmi/core/nodesets/rsl.py
50
+ src/opensmi/core/nodesets/smart_factory_machine_set.py
51
+ src/opensmi/core/nodesets/smart_factory_skill_set.py
52
+ tests/test_nodesets.py
53
+ tests/test_object_path.py
54
+ tests/test_paths.py
55
+ tests/test_resolve.py
41
56
  tests/test_rsl.py
42
57
  tests/test_signal.py
58
+ tests/test_ua_values.py
43
59
  tests/test_unit.py
44
- tools/auto_generate_units.py
60
+ tools/auto_generate_nodesets.py
61
+ tools/auto_generate_units.py
62
+ tools/nodeset.py.template
@@ -4,7 +4,6 @@
4
4
  ".github/workflows/checks.yml",
5
5
  ".github/workflows/python-publish.yml",
6
6
  ".gitignore",
7
- ".gitlab-ci.yml",
8
7
  "LICENSE",
9
8
  "LICENSES/MIT.txt",
10
9
  "README.md",
@@ -22,6 +21,18 @@
22
21
  "src/opensmi/core/logging.py",
23
22
  "src/opensmi/core/meta.py",
24
23
  "src/opensmi/core/mixin_helpers.py",
24
+ "src/opensmi/core/nodesets/Opc.Ua.Di.NodeSet2.xml",
25
+ "src/opensmi/core/nodesets/Opc.Ua.Machinery.NodeSet2.xml",
26
+ "src/opensmi/core/nodesets/Opc.Ua.RSL.NodeSet2.xml",
27
+ "src/opensmi/core/nodesets/Opc.Ua.SmartFactory.MachineSet.NodeSet2.xml",
28
+ "src/opensmi/core/nodesets/Opc.Ua.SmartFactory.SkillSet.NodeSet2.xml",
29
+ "src/opensmi/core/nodesets/__init__.py",
30
+ "src/opensmi/core/nodesets/di.py",
31
+ "src/opensmi/core/nodesets/machinery.py",
32
+ "src/opensmi/core/nodesets/rsl.py",
33
+ "src/opensmi/core/nodesets/smart_factory_machine_set.py",
34
+ "src/opensmi/core/nodesets/smart_factory_skill_set.py",
35
+ "src/opensmi/core/paths.py",
25
36
  "src/opensmi/core/protocols.py",
26
37
  "src/opensmi/core/repr_mixin.py",
27
38
  "src/opensmi/core/rsl.py",
@@ -33,9 +44,16 @@
33
44
  "src/opensmi/core/unit_all.py",
34
45
  "src/opensmi/core/unit_base.py",
35
46
  "src/opensmi/core/unset.py",
47
+ "tests/test_nodesets.py",
48
+ "tests/test_object_path.py",
49
+ "tests/test_paths.py",
50
+ "tests/test_resolve.py",
36
51
  "tests/test_rsl.py",
37
52
  "tests/test_signal.py",
53
+ "tests/test_ua_values.py",
38
54
  "tests/test_unit.py",
39
- "tools/auto_generate_units.py"
55
+ "tools/auto_generate_nodesets.py",
56
+ "tools/auto_generate_units.py",
57
+ "tools/nodeset.py.template"
40
58
  ]
41
59
  }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.7",
3
+ "distance": 0,
4
+ "node": "g5454212df575c25f82aff1ff7352c793dac40fb6",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-11-04"
8
+ }
@@ -12,7 +12,12 @@ from .lifecycle_mixin import LifecycleMixin as LifecycleMixin
12
12
  from .lifecycle_mixin import lifecycle as lifecycle
13
13
  from .logging import LogFormat as LogFormat
14
14
  from .logging import setup_logging as setup_logging
15
+ from .paths import ObjectPath as ObjectPath
15
16
  from .signal import Signal as Signal
17
+ from .ua import OPC_UA_NS as OPC_UA_NS
18
+ from .ua import LocalizedText as LocalizedText
19
+ from .ua import NodeIdDefinition as NodeIdDefinition
20
+ from .ua import QualifiedName as QualifiedName
16
21
  from .ua_enums import MachineryItemState as MachineryItemState
17
22
  from .ua_enums import MachineryOperationMode as MachineryOperationMode
18
23
  from .ua_enums import SkillState as SkillState
@@ -0,0 +1,181 @@
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
+ import structlog
13
+ from asyncua import ua
14
+
15
+ from opensmi.core.base_ua_object import BaseUaObject
16
+ from opensmi.core.paths import ObjectPath
17
+ from opensmi.core.protocols import NamespaceProvider
18
+ from opensmi.core.ua import NodeIdDefinition, QualifiedName
19
+
20
+ _LOGGER = structlog.get_logger("opensmi." + __name__)
21
+
22
+ _UaObjectType = TypeVar("_UaObjectType", bound=BaseUaObject)
23
+
24
+
25
+ class BaseServer(Generic[_UaObjectType]):
26
+ """Base class for both client `RemoteServer` and server `Server`."""
27
+
28
+ def __init__(self, **kwargs) -> None:
29
+ """*Cooperative* constructor."""
30
+ super().__init__(**kwargs)
31
+
32
+ self._ua_objects: WeakValueDictionary[ua.NodeId, _UaObjectType] = WeakValueDictionary()
33
+ """Maps OPC UA nodes to UaObjects with weak references."""
34
+ self._ua_objects_by_path: WeakValueDictionary[ObjectPath, _UaObjectType] = WeakValueDictionary()
35
+ """Maps canonical paths and aliases to UaObjects with weak references."""
36
+ self._namespace_map: dict[str, int] = {}
37
+
38
+ def resolve(self, path: ObjectPath | str) -> _UaObjectType:
39
+ """Return the object at ``path``, its canonical path or one of its aliases.
40
+
41
+ :raises KeyError: If no object has this path.
42
+ :raises ValueError: If ``path`` is a ``str`` that is no valid path.
43
+ """
44
+ return self._ua_objects_by_path[ObjectPath.of(path)]
45
+
46
+ def resolve_optional(self, path: ObjectPath | str) -> _UaObjectType | None:
47
+ """Return the object at ``path``, its canonical path or one of its aliases, or ``None``.
48
+
49
+ :raises ValueError: If ``path`` is a ``str`` that is no valid path.
50
+ """
51
+ return self._ua_objects_by_path.get(ObjectPath.of(path))
52
+
53
+ def find(self, pattern: str) -> list[_UaObjectType]:
54
+ """Return all objects whose canonical path matches the glob ``pattern`` (see `ObjectPath.match`), in tree order.
55
+
56
+ :raises ValueError: If ``pattern`` is no valid pattern.
57
+ """
58
+ found = [
59
+ (path, ua_object)
60
+ for path, ua_object in list(self._ua_objects_by_path.items())
61
+ if path == ua_object.path and path.match(pattern)
62
+ ]
63
+ return [ua_object for _, ua_object in sorted(found, key=lambda item: item[0])]
64
+
65
+ def get_ua_object(self, ua_node_id: ua.NodeId) -> _UaObjectType:
66
+ """Return the ``UaObject`` which corresponds to the given ``ua_node``.
67
+
68
+ :raises KeyError: If no ``UaObject`` is found.
69
+ """
70
+ return self._ua_objects[ua_node_id]
71
+
72
+ def get_ua_object_optional(self, ua_node_id: ua.NodeId) -> _UaObjectType | None:
73
+ """Return the ``UaObject`` which corresponds to the given ``ua_node`` or ``None``."""
74
+ return self._ua_objects.get(ua_node_id, default=None)
75
+
76
+ def register_ua_object(self, ua_object: _UaObjectType) -> None:
77
+ """Register the given ``UaObject`` to the server, by node and by its canonical path and aliases.
78
+
79
+ Objects register top-down: the path is taken now, so the parent must already be registered and can't change
80
+ anymore (`BaseUaObject._set_parent`).
81
+
82
+ :raises RuntimeError: If the object has a parent that isn't registered.
83
+ """
84
+ assert ua_object is not None
85
+ assert isinstance(ua_object.ua_node.nodeid, ua.NodeId)
86
+
87
+ parent = ua_object._parent # noqa: SLF001 the public getter may refuse an unset parent
88
+ if parent is not None and (
89
+ parent._ua_node is None # noqa: SLF001
90
+ or self._ua_objects.get(parent._ua_node.nodeid) is not parent # noqa: SLF001
91
+ ):
92
+ msg = f"Cannot register {ua_object!r} at {ua_object.path}: its parent {parent!r} isn't registered yet"
93
+ raise RuntimeError(msg)
94
+
95
+ self._ua_objects[ua_object.ua_node.nodeid] = ua_object
96
+ for path in (ua_object.path, *ua_object.aliases):
97
+ self._register_path(path, ua_object)
98
+
99
+ def unregister_ua_object(self, ua_object: _UaObjectType) -> None:
100
+ """Unregister the given ``UaObject`` from the server, by node and by its canonical path and aliases."""
101
+ assert ua_object is not None
102
+ assert isinstance(ua_object.ua_node.nodeid, ua.NodeId)
103
+
104
+ _ = self._ua_objects.pop(ua_object.ua_node.nodeid)
105
+ for path in (ua_object.path, *ua_object.aliases):
106
+ if self._ua_objects_by_path.get(path) is ua_object:
107
+ del self._ua_objects_by_path[path]
108
+
109
+ def _register_path(self, path: ObjectPath, ua_object: _UaObjectType) -> None:
110
+ """Map ``path`` to ``ua_object``; a path already taken by another object keeps that object."""
111
+ existing = self._ua_objects_by_path.get(path)
112
+ if existing is not None and existing is not ua_object:
113
+ _LOGGER.error("Path already registered, keeping the first object", path=str(path), existing=repr(existing))
114
+ return
115
+ self._ua_objects_by_path[path] = ua_object
116
+
117
+ def _clear_ua_objects(self) -> None:
118
+ """Forget all registered objects, by node and by path (e.g. before a client browses again)."""
119
+ self._ua_objects.clear()
120
+ self._ua_objects_by_path.clear()
121
+
122
+ def ua_get_namespace_index(self, uri: str | type[NamespaceProvider]) -> ua.Int16:
123
+ """Return the namespace index for the given namespace ``uri``.
124
+
125
+ :raises KeyError: If the namespace URI is unknown.
126
+ """
127
+ if not isinstance(uri, str):
128
+ uri = uri.URI
129
+
130
+ return ua.Int16(self._namespace_map[uri])
131
+
132
+ def ua_get_namespace_uri(self, index: int) -> str:
133
+ """Return the namespace URI for the given namespace ``index``.
134
+
135
+ :raises KeyError: If the server has no namespace at ``index``.
136
+ """
137
+ for uri, ns_index in self._namespace_map.items():
138
+ if ns_index == index:
139
+ return uri
140
+ raise KeyError(index)
141
+
142
+ def ua_get_node_id(self, definition: NodeIdDefinition) -> ua.NodeId:
143
+ """Return the complete OPC UA node ID for the given ``definition``.
144
+
145
+ :raises KeyError: If the namespace URI of the definition is unknown.
146
+ """
147
+ return ua.NodeId(
148
+ Identifier=definition.identifier, # pyright: ignore[reportArgumentType]
149
+ NamespaceIndex=self.ua_get_namespace_index(definition.namespace_uri),
150
+ )
151
+
152
+ def ua_to_node_id_definition(self, node_id: ua.NodeId) -> NodeIdDefinition:
153
+ """Convert the given node ID to a node ID definition.
154
+
155
+ An ``ua.ExpandedNodeId`` uses its ``NamespaceUri`` if set.
156
+
157
+ :raises KeyError: If the namespace index is unknown.
158
+ :raises ValueError: If an ``ua.ExpandedNodeId`` points to another server.
159
+ """
160
+ namespace_uri = None
161
+ if isinstance(node_id, ua.ExpandedNodeId):
162
+ if node_id.ServerIndex:
163
+ msg = f"{node_id.to_string()} is on another server (ServerIndex {node_id.ServerIndex})"
164
+ raise ValueError(msg)
165
+ namespace_uri = node_id.NamespaceUri
166
+ return NodeIdDefinition(
167
+ identifier=node_id.Identifier,
168
+ namespace_uri=namespace_uri or self.ua_get_namespace_uri(node_id.NamespaceIndex),
169
+ )
170
+
171
+ def ua_to_qualified_name(self, qualified_name: ua.QualifiedName) -> QualifiedName:
172
+ """Convert `ua.QualifiedName` to our `QualifiedName`."""
173
+ return QualifiedName(
174
+ namespace_uri=self.ua_get_namespace_uri(qualified_name.NamespaceIndex), name=qualified_name.Name
175
+ )
176
+
177
+ def ua_from_qualified_name(self, qualified_name: QualifiedName) -> ua.QualifiedName:
178
+ """Convert from our `QualifiedName` to `ua.QualifiedName`."""
179
+ return ua.QualifiedName(
180
+ Name=qualified_name.name, NamespaceIndex=self.ua_get_namespace_index(qualified_name.namespace_uri)
181
+ )
@@ -4,14 +4,15 @@
4
4
 
5
5
  """Base class for all UA objects, both client- and server-side."""
6
6
 
7
- from pathlib import PurePosixPath
8
- from typing import TYPE_CHECKING, Generic, TypeVar
7
+ from typing import TYPE_CHECKING, ClassVar, Generic, TypeVar
9
8
 
10
9
  import structlog
11
10
  from asyncua.common.node import Node
12
11
  from structlog.stdlib import BoundLogger
13
12
  from typing_extensions import deprecated
14
13
 
14
+ from opensmi.core.paths import ObjectPath, sanitize_segment
15
+
15
16
  if TYPE_CHECKING:
16
17
  from opensmi.core.base_server import BaseServer
17
18
 
@@ -23,17 +24,24 @@ class BaseUaObject(Generic[_ServerType]):
23
24
 
24
25
  _parent: "BaseUaObject[_ServerType] | None" = None
25
26
 
27
+ _folder: ClassVar[str | None] = None
28
+ """OpenSMI folder between the parent and this object in the path, e.g. ``"SkillSet"``; ``None`` for none."""
29
+
26
30
  def __init__(
27
31
  self,
28
32
  *,
29
33
  name: str | None = None,
34
+ segment: str | None = None,
30
35
  server: _ServerType | None = None,
31
36
  parent: "BaseUaObject[_ServerType] | None" = None,
32
37
  **kwargs,
33
38
  ) -> None:
34
39
  """*Cooperative* constructor.
35
40
 
36
- :param name: The name of the UA object. Defaults to the class name.
41
+ :param name: The name of the UA object, as on the server (may be empty on servers we don't control; the
42
+ segment never is). Defaults to the class name.
43
+ :param segment: The last segment of the object's path. Defaults to the sanitized ``name``; clients pass the
44
+ unique segment from ``opensmi.core.paths.unique_segments()``.
37
45
  :param server: The associated server of the UA object. Note that there must be at least one non ``None`` in
38
46
  the hierarchy.
39
47
  :param parent: The parent of the UA object. Only Machines are allowed to have no parent. Can be set later before
@@ -41,15 +49,18 @@ class BaseUaObject(Generic[_ServerType]):
41
49
  """
42
50
  if name is None:
43
51
  name = self.__class__.__name__
44
- elif not isinstance(name, str) or name == "":
52
+ elif not isinstance(name, str):
45
53
  msg = f"Given name '{name}' is not valid!"
46
54
  raise TypeError(msg)
47
55
 
48
56
  self._name: str = name
57
+ self._segment: str = sanitize_segment(name) if segment is None else segment
58
+ _ = ObjectPath((self._segment,)) # check the segment early
59
+ self._aliases: set[ObjectPath] = set()
49
60
  self._server: _ServerType | None = server
61
+ self._ua_node: Node | None = None
50
62
  if parent is not None:
51
63
  self.parent = parent # via setter to allow optional runtime type checking
52
- self._ua_node: Node | None = None
53
64
  self._logger: structlog.BoundLogger = structlog.get_logger(f"opensmi.{self.__class__.__name__}", name=self.name)
54
65
 
55
66
  super().__init__(**kwargs) # cooperative __init__() after we are done
@@ -87,12 +98,60 @@ class BaseUaObject(Generic[_ServerType]):
87
98
  return f"/{self.name}"
88
99
 
89
100
  @property
90
- def path(self) -> PurePosixPath:
91
- """Return the full path of the object."""
101
+ def path(self) -> ObjectPath:
102
+ """Return the full path of the object: the parent's path, the OpenSMI folder (if any) and the segment."""
103
+ base = ObjectPath()
92
104
  if self.parent is not None:
93
105
  assert isinstance(self.parent, BaseUaObject)
94
- return self.parent.path / self.name
95
- return PurePosixPath("/") / self.name
106
+ base = self.parent.path
107
+ if self._folder is not None:
108
+ base /= self._folder
109
+ return base / self._segment
110
+
111
+ @property
112
+ def aliases(self) -> frozenset[ObjectPath]:
113
+ """Further paths of this object, e.g. of a variable also listed in other containers; ``path`` is canonical."""
114
+ return frozenset(self._aliases)
115
+
116
+ def _add_alias(self, path: ObjectPath) -> None:
117
+ """Add ``path`` as a further path of this object; ``server.resolve()`` finds it once the object has a node."""
118
+ self._aliases.add(path)
119
+ if self._ua_node is not None:
120
+ self.server._register_path(path, self) # noqa: SLF001
121
+
122
+ def resolve(self, relative: str) -> "BaseUaObject[_ServerType]":
123
+ """Return the object at ``relative`` below this one, e.g. ``"Components/Gripper"``.
124
+
125
+ :raises KeyError: If no object has this path.
126
+ :raises ValueError: If ``relative`` starts with ``/`` or is no valid relative path.
127
+ """
128
+ return self.server.resolve(self._absolute(relative))
129
+
130
+ def resolve_optional(self, relative: str) -> "BaseUaObject[_ServerType] | None":
131
+ """Return the object at ``relative`` below this one, or ``None``.
132
+
133
+ :raises ValueError: If ``relative`` starts with ``/`` or is no valid relative path.
134
+ """
135
+ return self.server.resolve_optional(self._absolute(relative))
136
+
137
+ def find(self, pattern: str) -> "list[BaseUaObject[_ServerType]]":
138
+ """Return all objects below this one whose path matches the relative glob ``pattern``, e.g. ``"**/SkillSet/*"``.
139
+
140
+ :raises ValueError: If ``pattern`` starts with ``/`` or is no valid pattern.
141
+ """
142
+ self._check_relative(pattern)
143
+ prefix = "".join(f"/{ObjectPath.escape(segment)}" for segment in self.path.segments)
144
+ return [found for found in self.server.find(f"{prefix}/{pattern}") if found is not self]
145
+
146
+ def _absolute(self, relative: str) -> ObjectPath:
147
+ self._check_relative(relative)
148
+ return ObjectPath((*self.path.segments, *relative.split("/")))
149
+
150
+ @staticmethod
151
+ def _check_relative(relative: str) -> None:
152
+ if relative.startswith("/"):
153
+ msg = f"Expected a path relative to this object, got an absolute one: {relative!r}"
154
+ raise ValueError(msg)
96
155
 
97
156
  @property
98
157
  def logger(self) -> BoundLogger:
@@ -108,6 +167,16 @@ class BaseUaObject(Generic[_ServerType]):
108
167
  def parent(self, parent: "BaseUaObject[_ServerType]") -> None:
109
168
  """Set the parent of the UA object. Given ``parent`` must be an instance of `BaseUaObject`."""
110
169
  assert isinstance(parent, BaseUaObject)
170
+ self._set_parent(parent)
171
+
172
+ def _set_parent(self, parent: "BaseUaObject[_ServerType]") -> None:
173
+ """Set the parent; subclasses overriding the ``parent`` setter call this instead of setting ``_parent``.
174
+
175
+ :raises RuntimeError: If the object already has a node: its path is registered and must not change.
176
+ """
177
+ if self._ua_node is not None:
178
+ msg = f"Cannot change the parent of {self!r}: its path {self.path} is already registered"
179
+ raise RuntimeError(msg)
111
180
  self._parent = parent
112
181
 
113
182
  # TODO