madsci.node_module 0.7.0__tar.gz → 0.7.1__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 (22) hide show
  1. {madsci_node_module-0.7.0 → madsci_node_module-0.7.1}/PKG-INFO +1 -1
  2. {madsci_node_module-0.7.0 → madsci_node_module-0.7.1}/madsci/node_module/abstract_node_module.py +94 -0
  3. {madsci_node_module-0.7.0 → madsci_node_module-0.7.1}/pyproject.toml +1 -1
  4. {madsci_node_module-0.7.0 → madsci_node_module-0.7.1}/tests/conftest.py +3 -0
  5. {madsci_node_module-0.7.0 → madsci_node_module-0.7.1}/tests/test_argument_parsing.py +1 -1
  6. madsci_node_module-0.7.1/tests/test_node_registry.py +215 -0
  7. {madsci_node_module-0.7.0 → madsci_node_module-0.7.1}/README.md +0 -0
  8. {madsci_node_module-0.7.0 → madsci_node_module-0.7.1}/madsci/node_module/__init__.py +0 -0
  9. {madsci_node_module-0.7.0 → madsci_node_module-0.7.1}/madsci/node_module/helpers.py +0 -0
  10. {madsci_node_module-0.7.0 → madsci_node_module-0.7.1}/madsci/node_module/rest_node_module.py +0 -0
  11. {madsci_node_module-0.7.0 → madsci_node_module-0.7.1}/madsci/node_module/type_analyzer.py +0 -0
  12. {madsci_node_module-0.7.0 → madsci_node_module-0.7.1}/tests/test_action_parsing_integration.py +0 -0
  13. {madsci_node_module-0.7.0 → madsci_node_module-0.7.1}/tests/test_helpers.py +0 -0
  14. {madsci_node_module-0.7.0 → madsci_node_module-0.7.1}/tests/test_location_argument_serialization.py +0 -0
  15. {madsci_node_module-0.7.0 → madsci_node_module-0.7.1}/tests/test_node.py +0 -0
  16. {madsci_node_module-0.7.0 → madsci_node_module-0.7.1}/tests/test_node_infrastructure.py +0 -0
  17. {madsci_node_module-0.7.0 → madsci_node_module-0.7.1}/tests/test_openapi_schemas.py +0 -0
  18. {madsci_node_module-0.7.0 → madsci_node_module-0.7.1}/tests/test_rest_functionality.py +0 -0
  19. {madsci_node_module-0.7.0 → madsci_node_module-0.7.1}/tests/test_rest_node_client.py +0 -0
  20. {madsci_node_module-0.7.0 → madsci_node_module-0.7.1}/tests/test_rest_node_module.py +0 -0
  21. {madsci_node_module-0.7.0 → madsci_node_module-0.7.1}/tests/test_rest_utils.py +0 -0
  22. {madsci_node_module-0.7.0 → madsci_node_module-0.7.1}/tests/test_type_analyzer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: madsci.node_module
3
- Version: 0.7.0
3
+ Version: 0.7.1
4
4
  Summary: The Modular Autonomous Discovery for Science (MADSci) Node Module Helper Classes.
5
5
  Author-Email: Tobias Ginsburg <tginsburg@anl.gov>, "Ryan D. Lewis" <ryan.lewis@anl.gov>, Casey Stone <cstone@anl.gov>, Doga Ozgulbas <dozgulbas@anl.gov>
6
6
  License: MIT
@@ -1,8 +1,11 @@
1
1
  """Base Node Module helper classes."""
2
2
 
3
+ import atexit
3
4
  import contextlib
4
5
  import inspect
6
+ import logging
5
7
  import threading
8
+ import weakref
6
9
  from pathlib import Path
7
10
  from typing import (
8
11
  Annotated,
@@ -115,6 +118,12 @@ class AbstractNode(MadsciClientMixin):
115
118
  module_name=module_name,
116
119
  )
117
120
 
121
+ # Resolve stable identity from registry if enabled
122
+ self._resolver = None
123
+ self._atexit_registered = False
124
+ if self.config.enable_registry_resolution:
125
+ self._resolve_identity_from_registry()
126
+
118
127
  global_ownership_info.node_id = self.node_info.node_id
119
128
  self._configure_clients()
120
129
 
@@ -136,6 +145,23 @@ class AbstractNode(MadsciClientMixin):
136
145
  """Node Lifecycle and Public Methods"""
137
146
  """------------------------------------------------------------------------------------------------"""
138
147
 
148
+ def close(self) -> None:
149
+ """Release registry identity and clean up client resources.
150
+
151
+ This method is idempotent and safe to call multiple times.
152
+ Call this before reassigning a node variable to a new node
153
+ with the same name (e.g. in notebook cells) to avoid
154
+ ``RegistryLockError`` from lingering heartbeat threads.
155
+ """
156
+ self._release_registry_identity()
157
+ self._resolver = None
158
+ self.teardown_clients()
159
+
160
+ def __del__(self) -> None:
161
+ """Best-effort cleanup when the node is garbage-collected."""
162
+ with contextlib.suppress(Exception):
163
+ self.close()
164
+
139
165
  def start_node(self) -> None:
140
166
  """
141
167
  Called once to start the node.
@@ -170,6 +196,74 @@ class AbstractNode(MadsciClientMixin):
170
196
  def shutdown_handler(self) -> None:
171
197
  """Called to shut down the node. Should be used to clean up any resources."""
172
198
 
199
+ def _resolve_identity_from_registry(self) -> None:
200
+ """Resolve node identity from the ID Registry.
201
+
202
+ Uses IdentityResolver to look up or create a stable ID for this
203
+ node. The resolved ID is written back to ``self.node_info.node_id``.
204
+
205
+ A ``RegistryLockError`` (after retry exhaustion) is fatal — the
206
+ node cannot start without a stable identity. Other errors
207
+ (e.g. missing registry file) are non-fatal: a warning is logged
208
+ and the generated ID is kept.
209
+ """
210
+ from madsci.common.registry.identity_resolver import ( # noqa: PLC0415
211
+ IdentityResolver,
212
+ )
213
+ from madsci.common.registry.lock_manager import ( # noqa: PLC0415
214
+ RegistryLockError,
215
+ )
216
+
217
+ try:
218
+ self._resolver = IdentityResolver(lab_url=self.config.lab_url)
219
+
220
+ node_name = self.node_info.node_name
221
+
222
+ result = self._resolver.resolve_with_info(
223
+ name=node_name,
224
+ component_type="node",
225
+ metadata={"module_name": self.node_info.module_name},
226
+ retry_timeout=self.config.registry_lock_timeout,
227
+ )
228
+
229
+ self.node_info.node_id = result.id
230
+
231
+ if not self._atexit_registered:
232
+ ref = weakref.ref(self)
233
+
234
+ def _release_on_exit(weak_ref: weakref.ref = ref) -> None:
235
+ obj = weak_ref()
236
+ if obj is not None:
237
+ obj._release_registry_identity()
238
+
239
+ atexit.register(_release_on_exit)
240
+ self._atexit_registered = True
241
+
242
+ except RegistryLockError:
243
+ raise
244
+ except Exception:
245
+ logging.getLogger(__name__).warning(
246
+ "Registry identity resolution failed; using generated ID",
247
+ exc_info=True,
248
+ )
249
+
250
+ def _release_registry_identity(self) -> None:
251
+ """Release the registry lock for this node's identity.
252
+
253
+ Called via ``atexit`` to allow other instances to acquire the
254
+ same name.
255
+ """
256
+ if self._resolver is None:
257
+ return
258
+
259
+ try:
260
+ self._resolver.release(self.node_info.node_name)
261
+ except Exception:
262
+ logging.getLogger(__name__).warning(
263
+ "Failed to release registry identity",
264
+ exc_info=True,
265
+ )
266
+
173
267
  """------------------------------------------------------------------------------------------------"""
174
268
  """Interface Methods"""
175
269
  """------------------------------------------------------------------------------------------------"""
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "madsci.node_module"
3
- version = "0.7.0"
3
+ version = "0.7.1"
4
4
  description = "The Modular Autonomous Discovery for Science (MADSci) Node Module Helper Classes."
5
5
  authors = [
6
6
  { name = "Tobias Ginsburg", email = "tginsburg@anl.gov" },
@@ -117,10 +117,13 @@ def test_node_factory() -> Generator[Callable[..., TestNode], None, None]:
117
117
  Configured TestNode instance
118
118
  """
119
119
  # Create base config with identity fields
120
+ # Disable registry resolution by default in tests to prevent
121
+ # lock contention and side effects from shared registry files.
120
122
  config_params = {
121
123
  "test_required_param": 1,
122
124
  "node_name": node_name,
123
125
  "module_name": module_name,
126
+ "enable_registry_resolution": False,
124
127
  **config_kwargs,
125
128
  }
126
129
  if config_overrides:
@@ -30,7 +30,7 @@ class TestArgumentParsingNode(RestNode):
30
30
  """Test node for argument parsing tests."""
31
31
 
32
32
  __test__ = False
33
- config: TestConfig = TestConfig()
33
+ config: TestConfig = TestConfig(enable_registry_resolution=False)
34
34
  config_model = TestConfig
35
35
 
36
36
  def startup_handler(self) -> None:
@@ -0,0 +1,215 @@
1
+ """Unit tests for registry resolution in AbstractNode."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ from madsci.common.types.node_types import NodeConfig
6
+ from madsci.common.types.registry_types import RegistryResolveResult
7
+ from madsci.common.utils import new_ulid_str
8
+
9
+ from madsci_node_module.tests.test_node import TestNode, TestNodeConfig
10
+
11
+
12
+ class TestNodeRegistrySettings:
13
+ """Test the registry-related fields on NodeConfig."""
14
+
15
+ def test_registry_resolution_enabled_by_default(self) -> None:
16
+ """Registry resolution is enabled by default in production.
17
+
18
+ Note: The test suite patches the default to False to prevent lock
19
+ contention. We verify explicit True still works.
20
+ """
21
+ config = NodeConfig(enable_registry_resolution=True)
22
+ assert config.enable_registry_resolution is True
23
+
24
+ def test_lab_url_none_by_default(self) -> None:
25
+ """Lab URL should be None by default."""
26
+ config = NodeConfig()
27
+ assert config.lab_url is None
28
+
29
+ def test_registry_lock_timeout_default(self) -> None:
30
+ """Registry lock timeout should default to 60 seconds."""
31
+ config = NodeConfig()
32
+ assert config.registry_lock_timeout == 60.0
33
+
34
+ def test_can_disable_registry_resolution(self) -> None:
35
+ """Should be able to disable registry resolution."""
36
+ config = NodeConfig(enable_registry_resolution=False)
37
+ assert config.enable_registry_resolution is False
38
+
39
+
40
+ class TestNodeRegistryResolution:
41
+ """Test the registry resolution integration in AbstractNode."""
42
+
43
+ def test_resolver_not_called_when_disabled(self) -> None:
44
+ """When registry resolution is disabled, resolver should not be called."""
45
+ config = TestNodeConfig(
46
+ test_required_param=1,
47
+ node_name="test_node",
48
+ enable_registry_resolution=False,
49
+ )
50
+ node = TestNode(node_config=config)
51
+
52
+ assert node._resolver is None
53
+
54
+ def test_resolver_called_when_enabled(self) -> None:
55
+ """When registry resolution is enabled, resolver should update node_info.node_id."""
56
+ resolved_id = new_ulid_str()
57
+ mock_resolver = MagicMock()
58
+ mock_resolver.resolve_with_info.return_value = RegistryResolveResult(
59
+ name="test_node",
60
+ id=resolved_id,
61
+ component_type="node",
62
+ is_new=False,
63
+ source="local",
64
+ )
65
+ mock_resolver_cls = MagicMock(return_value=mock_resolver)
66
+
67
+ config = TestNodeConfig(
68
+ test_required_param=1,
69
+ node_name="test_node",
70
+ enable_registry_resolution=True,
71
+ )
72
+
73
+ with patch(
74
+ "madsci.common.registry.identity_resolver.IdentityResolver",
75
+ mock_resolver_cls,
76
+ ):
77
+ node = TestNode(node_config=config)
78
+
79
+ assert node.node_info.node_id == resolved_id
80
+
81
+ def test_resolution_failure_falls_back_to_generated_id(self) -> None:
82
+ """If registry resolution fails, the generated ID should be preserved."""
83
+ config = TestNodeConfig(
84
+ test_required_param=1,
85
+ node_name="test_node",
86
+ enable_registry_resolution=True,
87
+ )
88
+
89
+ with patch(
90
+ "madsci.common.registry.identity_resolver.IdentityResolver",
91
+ side_effect=Exception("Connection refused"),
92
+ ):
93
+ node = TestNode(node_config=config)
94
+
95
+ # The node should still have a valid node_id (the originally generated one)
96
+ assert node.node_info.node_id is not None
97
+ assert len(node.node_info.node_id) > 0
98
+
99
+ def test_stable_id_across_restarts(self) -> None:
100
+ """Two sequential node instances with the same name should get the same ID."""
101
+ stable_id = new_ulid_str()
102
+ mock_resolver = MagicMock()
103
+ mock_resolver.resolve_with_info.return_value = RegistryResolveResult(
104
+ name="stable_node",
105
+ id=stable_id,
106
+ component_type="node",
107
+ is_new=False,
108
+ source="local",
109
+ )
110
+ mock_resolver_cls = MagicMock(return_value=mock_resolver)
111
+
112
+ with patch(
113
+ "madsci.common.registry.identity_resolver.IdentityResolver",
114
+ mock_resolver_cls,
115
+ ):
116
+ node1 = TestNode(
117
+ node_config=TestNodeConfig(
118
+ test_required_param=1,
119
+ node_name="stable_node",
120
+ enable_registry_resolution=True,
121
+ )
122
+ )
123
+ node1._release_registry_identity()
124
+
125
+ node2 = TestNode(
126
+ node_config=TestNodeConfig(
127
+ test_required_param=1,
128
+ node_name="stable_node",
129
+ enable_registry_resolution=True,
130
+ )
131
+ )
132
+
133
+ assert node1.node_info.node_id == stable_id
134
+ assert node2.node_info.node_id == stable_id
135
+
136
+ def test_registry_disabled_generates_ulid(self) -> None:
137
+ """With enable_registry_resolution=False, a fresh ULID should be generated."""
138
+ node1 = TestNode(
139
+ node_config=TestNodeConfig(
140
+ test_required_param=1,
141
+ node_name="ulid_node",
142
+ enable_registry_resolution=False,
143
+ )
144
+ )
145
+ node2 = TestNode(
146
+ node_config=TestNodeConfig(
147
+ test_required_param=1,
148
+ node_name="ulid_node",
149
+ enable_registry_resolution=False,
150
+ )
151
+ )
152
+
153
+ # Both should have IDs, but they should be different (fresh ULIDs)
154
+ assert node1.node_info.node_id is not None
155
+ assert node2.node_info.node_id is not None
156
+ assert node1.node_info.node_id != node2.node_info.node_id
157
+
158
+ def test_release_identity_calls_resolver_release(self) -> None:
159
+ """_release_registry_identity() should call resolver.release()."""
160
+ config = TestNodeConfig(
161
+ test_required_param=1,
162
+ node_name="release_test_node",
163
+ enable_registry_resolution=False,
164
+ )
165
+ node = TestNode(node_config=config)
166
+ node._resolver = MagicMock()
167
+
168
+ node._release_registry_identity()
169
+
170
+ node._resolver.release.assert_called_once_with("release_test_node")
171
+
172
+ def test_release_identity_noop_without_resolver(self) -> None:
173
+ """_release_registry_identity() should be a no-op when no resolver is set."""
174
+ config = TestNodeConfig(
175
+ test_required_param=1,
176
+ node_name="noop_node",
177
+ enable_registry_resolution=False,
178
+ )
179
+ node = TestNode(node_config=config)
180
+
181
+ # Should not raise
182
+ node._release_registry_identity()
183
+
184
+ def test_node_name_used_for_resolution(self) -> None:
185
+ """The node name should be used for registry lookup."""
186
+ resolved_id = new_ulid_str()
187
+ mock_resolver = MagicMock()
188
+ mock_resolver.resolve_with_info.return_value = RegistryResolveResult(
189
+ name="custom_node",
190
+ id=resolved_id,
191
+ component_type="node",
192
+ is_new=True,
193
+ source="local",
194
+ )
195
+ mock_resolver_cls = MagicMock(return_value=mock_resolver)
196
+
197
+ config = TestNodeConfig(
198
+ test_required_param=1,
199
+ node_name="custom_node",
200
+ module_name="custom_module",
201
+ enable_registry_resolution=True,
202
+ )
203
+
204
+ with patch(
205
+ "madsci.common.registry.identity_resolver.IdentityResolver",
206
+ mock_resolver_cls,
207
+ ):
208
+ TestNode(node_config=config)
209
+
210
+ mock_resolver.resolve_with_info.assert_called_once_with(
211
+ name="custom_node",
212
+ component_type="node",
213
+ metadata={"module_name": "custom_module"},
214
+ retry_timeout=60.0,
215
+ )