madsci.node_module 0.7.0rc1__tar.gz → 0.8.0__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 (24) hide show
  1. madsci_node_module-0.7.0rc1/README.md → madsci_node_module-0.8.0/PKG-INFO +23 -2
  2. madsci_node_module-0.7.0rc1/PKG-INFO → madsci_node_module-0.8.0/README.md +10 -15
  3. {madsci_node_module-0.7.0rc1 → madsci_node_module-0.8.0}/madsci/node_module/abstract_node_module.py +221 -0
  4. {madsci_node_module-0.7.0rc1 → madsci_node_module-0.8.0}/pyproject.toml +1 -1
  5. {madsci_node_module-0.7.0rc1 → madsci_node_module-0.8.0}/tests/conftest.py +3 -0
  6. {madsci_node_module-0.7.0rc1 → madsci_node_module-0.8.0}/tests/test_argument_parsing.py +1 -1
  7. madsci_node_module-0.8.0/tests/test_intrinsic_location_handler.py +344 -0
  8. {madsci_node_module-0.7.0rc1 → madsci_node_module-0.8.0}/tests/test_location_argument_serialization.py +7 -7
  9. madsci_node_module-0.8.0/tests/test_node_registry.py +215 -0
  10. {madsci_node_module-0.7.0rc1 → madsci_node_module-0.8.0}/tests/test_rest_functionality.py +22 -21
  11. {madsci_node_module-0.7.0rc1 → madsci_node_module-0.8.0}/tests/test_rest_node_client.py +133 -113
  12. {madsci_node_module-0.7.0rc1 → madsci_node_module-0.8.0}/tests/test_rest_node_module.py +47 -0
  13. madsci_node_module-0.8.0/tests/test_template_handler.py +362 -0
  14. {madsci_node_module-0.7.0rc1 → madsci_node_module-0.8.0}/madsci/node_module/__init__.py +0 -0
  15. {madsci_node_module-0.7.0rc1 → madsci_node_module-0.8.0}/madsci/node_module/helpers.py +0 -0
  16. {madsci_node_module-0.7.0rc1 → madsci_node_module-0.8.0}/madsci/node_module/rest_node_module.py +0 -0
  17. {madsci_node_module-0.7.0rc1 → madsci_node_module-0.8.0}/madsci/node_module/type_analyzer.py +0 -0
  18. {madsci_node_module-0.7.0rc1 → madsci_node_module-0.8.0}/tests/test_action_parsing_integration.py +0 -0
  19. {madsci_node_module-0.7.0rc1 → madsci_node_module-0.8.0}/tests/test_helpers.py +0 -0
  20. {madsci_node_module-0.7.0rc1 → madsci_node_module-0.8.0}/tests/test_node.py +0 -0
  21. {madsci_node_module-0.7.0rc1 → madsci_node_module-0.8.0}/tests/test_node_infrastructure.py +0 -0
  22. {madsci_node_module-0.7.0rc1 → madsci_node_module-0.8.0}/tests/test_openapi_schemas.py +0 -0
  23. {madsci_node_module-0.7.0rc1 → madsci_node_module-0.8.0}/tests/test_rest_utils.py +0 -0
  24. {madsci_node_module-0.7.0rc1 → madsci_node_module-0.8.0}/tests/test_type_analyzer.py +0 -0
@@ -1,3 +1,16 @@
1
+ Metadata-Version: 2.1
2
+ Name: madsci.node_module
3
+ Version: 0.8.0
4
+ Summary: The Modular Autonomous Discovery for Science (MADSci) Node Module Helper Classes.
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
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/AD-SDL/MADSci
8
+ Requires-Python: >=3.10.0
9
+ Requires-Dist: madsci.common
10
+ Requires-Dist: madsci.client
11
+ Requires-Dist: regex
12
+ Description-Content-Type: text/markdown
13
+
1
14
  # MADSci Node Module
2
15
 
3
16
  Framework for creating laboratory instrument nodes that integrate with MADSci workcells via REST APIs.
@@ -105,8 +118,8 @@ module_version: 1.0.0
105
118
  # Run directly
106
119
  python my_instrument_node.py
107
120
 
108
- # Or with a pre-defined node
109
- python my_instrument_node.py --node_definition my_instrument.node.yaml
121
+ # Configuration is provided via environment variables (NODE_NAME, NODE_URL, etc.)
122
+ # or settings files (settings.yaml, .env)
110
123
 
111
124
  # Node will be available at http://localhost:2000/docs
112
125
  ```
@@ -1266,3 +1279,11 @@ def get_multiple_files(self) -> list[Path]:
1266
1279
  ```
1267
1280
 
1268
1281
  **Working examples**: See [example_lab/](../../examples/example_lab/) for a complete working laboratory with multiple integrated nodes.
1282
+
1283
+
1284
+ ## Development Steps
1285
+ In order to ensure a MADSci module is up to standard and will run reliably, developers should:
1286
+ 1. Document expected behavior for each action exposed by the Node, and then test it on the device to ensure it behaves as documented
1287
+ 2. Ensure that the module folder contains no extraneous files unused by the code.
1288
+ 3. Ensure that the code passes automated linting and code quality checks (we use [pre-commit](https://pre-commit.com/) and [ruff](https://astral.sh/ruff), for instance. See [our pre-commit config](../../.pre-commit-config.yaml) and [ruff config](../../ruff.toml).
1289
+ 4. Where appropriate, write a Docker image for the module and ensure that it builds and runs.
@@ -1,16 +1,3 @@
1
- Metadata-Version: 2.1
2
- Name: madsci.node_module
3
- Version: 0.7.0rc1
4
- Summary: The Modular Autonomous Discovery for Science (MADSci) Node Module Helper Classes.
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
- License: MIT
7
- Project-URL: Homepage, https://github.com/AD-SDL/MADSci
8
- Requires-Python: >=3.10.0
9
- Requires-Dist: madsci.common
10
- Requires-Dist: madsci.client
11
- Requires-Dist: regex
12
- Description-Content-Type: text/markdown
13
-
14
1
  # MADSci Node Module
15
2
 
16
3
  Framework for creating laboratory instrument nodes that integrate with MADSci workcells via REST APIs.
@@ -118,8 +105,8 @@ module_version: 1.0.0
118
105
  # Run directly
119
106
  python my_instrument_node.py
120
107
 
121
- # Or with a pre-defined node
122
- python my_instrument_node.py --node_definition my_instrument.node.yaml
108
+ # Configuration is provided via environment variables (NODE_NAME, NODE_URL, etc.)
109
+ # or settings files (settings.yaml, .env)
123
110
 
124
111
  # Node will be available at http://localhost:2000/docs
125
112
  ```
@@ -1279,3 +1266,11 @@ def get_multiple_files(self) -> list[Path]:
1279
1266
  ```
1280
1267
 
1281
1268
  **Working examples**: See [example_lab/](../../examples/example_lab/) for a complete working laboratory with multiple integrated nodes.
1269
+
1270
+
1271
+ ## Development Steps
1272
+ In order to ensure a MADSci module is up to standard and will run reliably, developers should:
1273
+ 1. Document expected behavior for each action exposed by the Node, and then test it on the device to ensure it behaves as documented
1274
+ 2. Ensure that the module folder contains no extraneous files unused by the code.
1275
+ 3. Ensure that the code passes automated linting and code quality checks (we use [pre-commit](https://pre-commit.com/) and [ruff](https://astral.sh/ruff), for instance. See [our pre-commit config](../../.pre-commit-config.yaml) and [ruff config](../../ruff.toml).
1276
+ 4. Where appropriate, write a Docker image for the module and ensure that it builds and runs.
@@ -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,
@@ -39,15 +42,20 @@ from madsci.common.types.action_types import (
39
42
  LocationArgumentDefinition,
40
43
  )
41
44
  from madsci.common.types.admin_command_types import AdminCommandResponse
45
+ from madsci.common.types.auth_types import OwnershipInfo
42
46
  from madsci.common.types.base_types import Error
43
47
  from madsci.common.types.datapoint_types import DataPoint, FileDataPoint, ValueDataPoint
44
48
  from madsci.common.types.event_types import Event, EventType
49
+ from madsci.common.types.location_types import LocationManagement
45
50
  from madsci.common.types.node_types import (
46
51
  AdminCommands,
47
52
  NodeCapabilities,
48
53
  NodeClientCapabilities,
49
54
  NodeConfig,
50
55
  NodeInfo,
56
+ NodeIntrinsicLocationDefinition,
57
+ NodeRepresentationTemplateDefinition,
58
+ NodeResourceTemplateDefinition,
51
59
  NodeSetConfigResponse,
52
60
  NodeStatus,
53
61
  )
@@ -96,6 +104,17 @@ class AbstractNode(MadsciClientMixin):
96
104
  _action_lock: ClassVar[threading.Lock] = threading.Lock()
97
105
  """Ensures only one blocking action can run at a time."""
98
106
 
107
+ resource_templates: ClassVar[list[NodeResourceTemplateDefinition]] = []
108
+ """Declarative resource template definitions to register on startup."""
109
+
110
+ location_representation_templates: ClassVar[
111
+ list[NodeRepresentationTemplateDefinition]
112
+ ] = []
113
+ """Declarative location representation template definitions to register on startup."""
114
+
115
+ intrinsic_locations: ClassVar[list[NodeIntrinsicLocationDefinition]] = []
116
+ """Intrinsic location definitions to register on startup."""
117
+
99
118
  def __init__(
100
119
  self,
101
120
  node_config: Optional[NodeConfig] = None,
@@ -115,6 +134,18 @@ class AbstractNode(MadsciClientMixin):
115
134
  module_name=module_name,
116
135
  )
117
136
 
137
+ # * Populate intrinsic location definitions from class variables
138
+ self.node_info.intrinsic_locations = list(self.__class__.intrinsic_locations)
139
+ self.node_info.location_representation_templates = list(
140
+ self.__class__.location_representation_templates
141
+ )
142
+
143
+ # Resolve stable identity from registry if enabled
144
+ self._resolver = None
145
+ self._atexit_registered = False
146
+ if self.config.enable_registry_resolution:
147
+ self._resolve_identity_from_registry()
148
+
118
149
  global_ownership_info.node_id = self.node_info.node_id
119
150
  self._configure_clients()
120
151
 
@@ -136,6 +167,23 @@ class AbstractNode(MadsciClientMixin):
136
167
  """Node Lifecycle and Public Methods"""
137
168
  """------------------------------------------------------------------------------------------------"""
138
169
 
170
+ def close(self) -> None:
171
+ """Release registry identity and clean up client resources.
172
+
173
+ This method is idempotent and safe to call multiple times.
174
+ Call this before reassigning a node variable to a new node
175
+ with the same name (e.g. in notebook cells) to avoid
176
+ ``RegistryLockError`` from lingering heartbeat threads.
177
+ """
178
+ self._release_registry_identity()
179
+ self._resolver = None
180
+ self.teardown_clients()
181
+
182
+ def __del__(self) -> None:
183
+ """Best-effort cleanup when the node is garbage-collected."""
184
+ with contextlib.suppress(Exception):
185
+ self.close()
186
+
139
187
  def start_node(self) -> None:
140
188
  """
141
189
  Called once to start the node.
@@ -164,12 +212,183 @@ class AbstractNode(MadsciClientMixin):
164
212
  def state_handler(self) -> None:
165
213
  """Called periodically to update the node state. Should set `self.node_state`"""
166
214
 
215
+ def template_handler(self) -> None:
216
+ """Register declarative templates with the resource and location managers.
217
+
218
+ Iterates over ``resource_templates`` and ``location_representation_templates``
219
+ class members. Each template is registered via the appropriate client API.
220
+ Errors are caught and logged per-template (with the template name and type
221
+ clearly identified) so that a single failed registration does not prevent the
222
+ node from starting.
223
+ """
224
+ # 1. Resource templates (via resource_client.init_template)
225
+ for defn in self.resource_templates:
226
+ try:
227
+ self.resource_client.init_template(
228
+ resource=defn.resource,
229
+ template_name=defn.template_name,
230
+ description=defn.description,
231
+ required_overrides=defn.required_overrides,
232
+ tags=defn.tags,
233
+ created_by=self.node_info.node_id,
234
+ version=defn.version,
235
+ )
236
+ self.logger.info(
237
+ "Registered resource template",
238
+ event_type=EventType.LOG_INFO,
239
+ template_name=defn.template_name,
240
+ template_type="resource",
241
+ )
242
+ except Exception as e:
243
+ self.logger.warning(
244
+ "Failed to register resource template",
245
+ event_type=EventType.LOG_WARNING,
246
+ template_name=defn.template_name,
247
+ template_type="resource",
248
+ error=str(e),
249
+ )
250
+
251
+ # 2. Location representation templates (via location_client.init_representation_template)
252
+ for defn in self.location_representation_templates:
253
+ try:
254
+ self.location_client.init_representation_template(
255
+ template_name=defn.template_name,
256
+ default_values=defn.default_values,
257
+ schema_def=defn.schema_def,
258
+ required_overrides=defn.required_overrides,
259
+ tags=defn.tags,
260
+ created_by=self.node_info.node_id,
261
+ version=defn.version,
262
+ description=defn.description,
263
+ )
264
+ self.logger.info(
265
+ "Registered location representation template",
266
+ event_type=EventType.LOG_INFO,
267
+ template_name=defn.template_name,
268
+ template_type="location_representation",
269
+ )
270
+ except Exception as e:
271
+ self.logger.warning(
272
+ "Failed to register location representation template",
273
+ event_type=EventType.LOG_WARNING,
274
+ template_name=defn.template_name,
275
+ template_type="location_representation",
276
+ error=str(e),
277
+ )
278
+
279
+ def intrinsic_location_handler(self) -> None:
280
+ """Register intrinsic locations with the Location Manager.
281
+
282
+ Iterates over ``intrinsic_locations`` class variable. Each location is
283
+ registered via location_client.init_location() with automatic
284
+ '{node_name}.' prefix. Errors are caught per-location so a single
285
+ failure does not prevent the node from starting.
286
+ """
287
+ for defn in self.intrinsic_locations:
288
+ try:
289
+ location_name = f"{self.node_info.node_name}.{defn.location_name}"
290
+ representations = {
291
+ self.node_info.node_name: defn.representation_overrides
292
+ }
293
+ owner = OwnershipInfo(node_id=self.node_info.node_id)
294
+
295
+ self.location_client.init_location(
296
+ location_name=location_name,
297
+ representations=representations,
298
+ resource_template_name=defn.resource_template_name,
299
+ resource_template_overrides=defn.resource_template_overrides,
300
+ description=defn.description,
301
+ allow_transfers=defn.allow_transfers,
302
+ managed_by=LocationManagement.NODE,
303
+ owner=owner,
304
+ )
305
+ self.logger.info(
306
+ "Registered intrinsic location",
307
+ event_type=EventType.LOG_INFO,
308
+ location_name=location_name,
309
+ )
310
+ except Exception as e:
311
+ self.logger.warning(
312
+ "Failed to register intrinsic location",
313
+ event_type=EventType.LOG_WARNING,
314
+ location_name=defn.location_name,
315
+ error=str(e),
316
+ )
317
+
167
318
  def startup_handler(self) -> None:
168
319
  """Called to (re)initialize the node. Should be used to open connections to devices or initialize any other resources."""
169
320
 
170
321
  def shutdown_handler(self) -> None:
171
322
  """Called to shut down the node. Should be used to clean up any resources."""
172
323
 
324
+ def _resolve_identity_from_registry(self) -> None:
325
+ """Resolve node identity from the ID Registry.
326
+
327
+ Uses IdentityResolver to look up or create a stable ID for this
328
+ node. The resolved ID is written back to ``self.node_info.node_id``.
329
+
330
+ A ``RegistryLockError`` (after retry exhaustion) is fatal — the
331
+ node cannot start without a stable identity. Other errors
332
+ (e.g. missing registry file) are non-fatal: a warning is logged
333
+ and the generated ID is kept.
334
+ """
335
+ from madsci.common.registry.identity_resolver import ( # noqa: PLC0415
336
+ IdentityResolver,
337
+ )
338
+ from madsci.common.registry.lock_manager import ( # noqa: PLC0415
339
+ RegistryLockError,
340
+ )
341
+
342
+ try:
343
+ self._resolver = IdentityResolver(lab_url=self.config.lab_url)
344
+
345
+ node_name = self.node_info.node_name
346
+
347
+ result = self._resolver.resolve_with_info(
348
+ name=node_name,
349
+ component_type="node",
350
+ metadata={"module_name": self.node_info.module_name},
351
+ retry_timeout=self.config.registry_lock_timeout,
352
+ )
353
+
354
+ self.node_info.node_id = result.id
355
+
356
+ if not self._atexit_registered:
357
+ ref = weakref.ref(self)
358
+
359
+ def _release_on_exit(weak_ref: weakref.ref = ref) -> None:
360
+ obj = weak_ref()
361
+ if obj is not None:
362
+ obj._release_registry_identity()
363
+
364
+ atexit.register(_release_on_exit)
365
+ self._atexit_registered = True
366
+
367
+ except RegistryLockError:
368
+ raise
369
+ except Exception:
370
+ logging.getLogger(__name__).warning(
371
+ "Registry identity resolution failed; using generated ID",
372
+ exc_info=True,
373
+ )
374
+
375
+ def _release_registry_identity(self) -> None:
376
+ """Release the registry lock for this node's identity.
377
+
378
+ Called via ``atexit`` to allow other instances to acquire the
379
+ same name.
380
+ """
381
+ if self._resolver is None:
382
+ return
383
+
384
+ try:
385
+ self._resolver.release(self.node_info.node_name)
386
+ except Exception:
387
+ logging.getLogger(__name__).warning(
388
+ "Failed to release registry identity",
389
+ exc_info=True,
390
+ )
391
+
173
392
  """------------------------------------------------------------------------------------------------"""
174
393
  """Interface Methods"""
175
394
  """------------------------------------------------------------------------------------------------"""
@@ -1254,6 +1473,8 @@ class AbstractNode(MadsciClientMixin):
1254
1473
  self.node_status.locked = False
1255
1474
  self.node_status.paused = False
1256
1475
  self.node_status.stopped = False
1476
+ self.template_handler()
1477
+ self.intrinsic_location_handler()
1257
1478
  self.startup_handler()
1258
1479
  # * Start status and state update loops
1259
1480
  repeat_on_interval(
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "madsci.node_module"
3
- version = "0.7.0-rc.1"
3
+ version = "0.8.0"
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: