madsci.node_module 0.7.1__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.
- madsci_node_module-0.7.1/README.md → madsci_node_module-0.8.0/PKG-INFO +23 -2
- madsci_node_module-0.7.1/PKG-INFO → madsci_node_module-0.8.0/README.md +10 -15
- {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/madsci/node_module/abstract_node_module.py +127 -0
- {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/pyproject.toml +1 -1
- madsci_node_module-0.8.0/tests/test_intrinsic_location_handler.py +344 -0
- {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/test_location_argument_serialization.py +7 -7
- {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/test_rest_functionality.py +22 -21
- {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/test_rest_node_client.py +133 -113
- {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/test_rest_node_module.py +47 -0
- madsci_node_module-0.8.0/tests/test_template_handler.py +362 -0
- {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/madsci/node_module/__init__.py +0 -0
- {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/madsci/node_module/helpers.py +0 -0
- {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/madsci/node_module/rest_node_module.py +0 -0
- {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/madsci/node_module/type_analyzer.py +0 -0
- {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/conftest.py +0 -0
- {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/test_action_parsing_integration.py +0 -0
- {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/test_argument_parsing.py +0 -0
- {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/test_helpers.py +0 -0
- {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/test_node.py +0 -0
- {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/test_node_infrastructure.py +0 -0
- {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/test_node_registry.py +0 -0
- {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/test_openapi_schemas.py +0 -0
- {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/test_rest_utils.py +0 -0
- {madsci_node_module-0.7.1 → 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
|
-
#
|
|
109
|
-
|
|
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.1
|
|
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
|
-
#
|
|
122
|
-
|
|
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.
|
{madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/madsci/node_module/abstract_node_module.py
RENAMED
|
@@ -42,15 +42,20 @@ from madsci.common.types.action_types import (
|
|
|
42
42
|
LocationArgumentDefinition,
|
|
43
43
|
)
|
|
44
44
|
from madsci.common.types.admin_command_types import AdminCommandResponse
|
|
45
|
+
from madsci.common.types.auth_types import OwnershipInfo
|
|
45
46
|
from madsci.common.types.base_types import Error
|
|
46
47
|
from madsci.common.types.datapoint_types import DataPoint, FileDataPoint, ValueDataPoint
|
|
47
48
|
from madsci.common.types.event_types import Event, EventType
|
|
49
|
+
from madsci.common.types.location_types import LocationManagement
|
|
48
50
|
from madsci.common.types.node_types import (
|
|
49
51
|
AdminCommands,
|
|
50
52
|
NodeCapabilities,
|
|
51
53
|
NodeClientCapabilities,
|
|
52
54
|
NodeConfig,
|
|
53
55
|
NodeInfo,
|
|
56
|
+
NodeIntrinsicLocationDefinition,
|
|
57
|
+
NodeRepresentationTemplateDefinition,
|
|
58
|
+
NodeResourceTemplateDefinition,
|
|
54
59
|
NodeSetConfigResponse,
|
|
55
60
|
NodeStatus,
|
|
56
61
|
)
|
|
@@ -99,6 +104,17 @@ class AbstractNode(MadsciClientMixin):
|
|
|
99
104
|
_action_lock: ClassVar[threading.Lock] = threading.Lock()
|
|
100
105
|
"""Ensures only one blocking action can run at a time."""
|
|
101
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
|
+
|
|
102
118
|
def __init__(
|
|
103
119
|
self,
|
|
104
120
|
node_config: Optional[NodeConfig] = None,
|
|
@@ -118,6 +134,12 @@ class AbstractNode(MadsciClientMixin):
|
|
|
118
134
|
module_name=module_name,
|
|
119
135
|
)
|
|
120
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
|
+
|
|
121
143
|
# Resolve stable identity from registry if enabled
|
|
122
144
|
self._resolver = None
|
|
123
145
|
self._atexit_registered = False
|
|
@@ -190,6 +212,109 @@ class AbstractNode(MadsciClientMixin):
|
|
|
190
212
|
def state_handler(self) -> None:
|
|
191
213
|
"""Called periodically to update the node state. Should set `self.node_state`"""
|
|
192
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
|
+
|
|
193
318
|
def startup_handler(self) -> None:
|
|
194
319
|
"""Called to (re)initialize the node. Should be used to open connections to devices or initialize any other resources."""
|
|
195
320
|
|
|
@@ -1348,6 +1473,8 @@ class AbstractNode(MadsciClientMixin):
|
|
|
1348
1473
|
self.node_status.locked = False
|
|
1349
1474
|
self.node_status.paused = False
|
|
1350
1475
|
self.node_status.stopped = False
|
|
1476
|
+
self.template_handler()
|
|
1477
|
+
self.intrinsic_location_handler()
|
|
1351
1478
|
self.startup_handler()
|
|
1352
1479
|
# * Start status and state update loops
|
|
1353
1480
|
repeat_on_interval(
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Tests for the intrinsic_location_handler lifecycle on AbstractNode."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from madsci.common.types.location_types import LocationManagement
|
|
8
|
+
from madsci.common.types.node_types import (
|
|
9
|
+
NodeInfo,
|
|
10
|
+
NodeIntrinsicLocationDefinition,
|
|
11
|
+
RestNodeConfig,
|
|
12
|
+
)
|
|
13
|
+
from madsci.node_module.rest_node_module import RestNode
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IntrinsicLocationTestConfig(RestNodeConfig):
|
|
17
|
+
"""Configuration for the intrinsic location test node."""
|
|
18
|
+
|
|
19
|
+
__test__ = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class IntrinsicLocationTestNode(RestNode):
|
|
23
|
+
"""A node subclass with intrinsic locations for testing."""
|
|
24
|
+
|
|
25
|
+
__test__ = False
|
|
26
|
+
|
|
27
|
+
config: IntrinsicLocationTestConfig = IntrinsicLocationTestConfig(
|
|
28
|
+
node_name="intrinsic_loc_test_node",
|
|
29
|
+
module_name="intrinsic_loc_test_node",
|
|
30
|
+
enable_registry_resolution=False,
|
|
31
|
+
)
|
|
32
|
+
config_model = IntrinsicLocationTestConfig
|
|
33
|
+
|
|
34
|
+
intrinsic_locations: ClassVar[list[NodeIntrinsicLocationDefinition]] = [
|
|
35
|
+
NodeIntrinsicLocationDefinition(
|
|
36
|
+
location_name="deck_1",
|
|
37
|
+
description="First deck slot",
|
|
38
|
+
representation_template_name="deck_repr",
|
|
39
|
+
representation_overrides={"rows": 8, "cols": 12},
|
|
40
|
+
resource_template_name="plate_resource",
|
|
41
|
+
resource_template_overrides={"capacity": 96},
|
|
42
|
+
allow_transfers=True,
|
|
43
|
+
),
|
|
44
|
+
NodeIntrinsicLocationDefinition(
|
|
45
|
+
location_name="deck_2",
|
|
46
|
+
description="Second deck slot",
|
|
47
|
+
representation_template_name="deck_repr",
|
|
48
|
+
representation_overrides={"rows": 4, "cols": 6},
|
|
49
|
+
allow_transfers=False,
|
|
50
|
+
),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
def startup_handler(self) -> None:
|
|
54
|
+
"""Minimal startup handler."""
|
|
55
|
+
self.startup_has_run = True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class EmptyIntrinsicLocationTestNode(RestNode):
|
|
59
|
+
"""A node subclass with no intrinsic locations (default behavior)."""
|
|
60
|
+
|
|
61
|
+
__test__ = False
|
|
62
|
+
|
|
63
|
+
config: IntrinsicLocationTestConfig = IntrinsicLocationTestConfig(
|
|
64
|
+
node_name="empty_intrinsic_loc_node",
|
|
65
|
+
module_name="empty_intrinsic_loc_node",
|
|
66
|
+
enable_registry_resolution=False,
|
|
67
|
+
)
|
|
68
|
+
config_model = IntrinsicLocationTestConfig
|
|
69
|
+
|
|
70
|
+
def startup_handler(self) -> None:
|
|
71
|
+
"""Minimal startup handler."""
|
|
72
|
+
self.startup_has_run = True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.fixture
|
|
76
|
+
def mock_location_client():
|
|
77
|
+
"""Create a mock location client."""
|
|
78
|
+
client = MagicMock()
|
|
79
|
+
client.init_location.return_value = MagicMock()
|
|
80
|
+
return client
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.fixture
|
|
84
|
+
def intrinsic_node(mock_location_client):
|
|
85
|
+
"""Create an IntrinsicLocationTestNode with mocked clients."""
|
|
86
|
+
node = IntrinsicLocationTestNode(
|
|
87
|
+
node_config=IntrinsicLocationTestConfig(
|
|
88
|
+
node_name="intrinsic_loc_test_node",
|
|
89
|
+
module_name="intrinsic_loc_test_node",
|
|
90
|
+
enable_registry_resolution=False,
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
node.location_client = mock_location_client
|
|
94
|
+
return node
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pytest.fixture
|
|
98
|
+
def empty_intrinsic_node(mock_location_client):
|
|
99
|
+
"""Create an EmptyIntrinsicLocationTestNode with mocked clients."""
|
|
100
|
+
node = EmptyIntrinsicLocationTestNode(
|
|
101
|
+
node_config=IntrinsicLocationTestConfig(
|
|
102
|
+
node_name="empty_intrinsic_loc_node",
|
|
103
|
+
module_name="empty_intrinsic_loc_node",
|
|
104
|
+
enable_registry_resolution=False,
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
node.location_client = mock_location_client
|
|
108
|
+
return node
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _find_call_by_location(call_args_list, location_name):
|
|
112
|
+
"""Helper: find the call whose location_name kwarg matches."""
|
|
113
|
+
return next(c for c in call_args_list if c.kwargs["location_name"] == location_name)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TestIntrinsicLocationHandlerRegistration:
|
|
117
|
+
"""Test that intrinsic locations are registered on intrinsic_location_handler() call."""
|
|
118
|
+
|
|
119
|
+
def test_init_location_called_for_each_intrinsic_location(
|
|
120
|
+
self, intrinsic_node, mock_location_client
|
|
121
|
+
):
|
|
122
|
+
"""init_location is called once per intrinsic location definition."""
|
|
123
|
+
intrinsic_node.intrinsic_location_handler()
|
|
124
|
+
|
|
125
|
+
assert mock_location_client.init_location.call_count == 2
|
|
126
|
+
|
|
127
|
+
def test_location_name_auto_prefixed_with_node_name(
|
|
128
|
+
self, intrinsic_node, mock_location_client
|
|
129
|
+
):
|
|
130
|
+
"""Each location name is prefixed with '{node_name}.'."""
|
|
131
|
+
intrinsic_node.intrinsic_location_handler()
|
|
132
|
+
|
|
133
|
+
call_args_list = mock_location_client.init_location.call_args_list
|
|
134
|
+
location_names = [call.kwargs["location_name"] for call in call_args_list]
|
|
135
|
+
assert "intrinsic_loc_test_node.deck_1" in location_names
|
|
136
|
+
assert "intrinsic_loc_test_node.deck_2" in location_names
|
|
137
|
+
|
|
138
|
+
def test_managed_by_set_to_node(self, intrinsic_node, mock_location_client):
|
|
139
|
+
"""managed_by is set to LocationManagement.NODE for each location."""
|
|
140
|
+
intrinsic_node.intrinsic_location_handler()
|
|
141
|
+
|
|
142
|
+
for call in mock_location_client.init_location.call_args_list:
|
|
143
|
+
assert call.kwargs["managed_by"] == LocationManagement.NODE
|
|
144
|
+
|
|
145
|
+
def test_owner_has_node_id(self, intrinsic_node, mock_location_client):
|
|
146
|
+
"""owner.node_id is set to the node's node_id."""
|
|
147
|
+
intrinsic_node.intrinsic_location_handler()
|
|
148
|
+
|
|
149
|
+
for call in mock_location_client.init_location.call_args_list:
|
|
150
|
+
owner = call.kwargs["owner"]
|
|
151
|
+
assert owner.node_id == intrinsic_node.node_info.node_id
|
|
152
|
+
|
|
153
|
+
def test_representations_include_node_name_key(
|
|
154
|
+
self, intrinsic_node, mock_location_client
|
|
155
|
+
):
|
|
156
|
+
"""representations dict uses node_name as key with overrides as value."""
|
|
157
|
+
intrinsic_node.intrinsic_location_handler()
|
|
158
|
+
|
|
159
|
+
deck_1_call = _find_call_by_location(
|
|
160
|
+
mock_location_client.init_location.call_args_list,
|
|
161
|
+
"intrinsic_loc_test_node.deck_1",
|
|
162
|
+
)
|
|
163
|
+
assert deck_1_call.kwargs["representations"] == {
|
|
164
|
+
"intrinsic_loc_test_node": {"rows": 8, "cols": 12}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
def test_resource_template_fields_passed(
|
|
168
|
+
self, intrinsic_node, mock_location_client
|
|
169
|
+
):
|
|
170
|
+
"""Resource template name and overrides are passed correctly."""
|
|
171
|
+
intrinsic_node.intrinsic_location_handler()
|
|
172
|
+
|
|
173
|
+
deck_1_call = _find_call_by_location(
|
|
174
|
+
mock_location_client.init_location.call_args_list,
|
|
175
|
+
"intrinsic_loc_test_node.deck_1",
|
|
176
|
+
)
|
|
177
|
+
assert deck_1_call.kwargs["resource_template_name"] == "plate_resource"
|
|
178
|
+
assert deck_1_call.kwargs["resource_template_overrides"] == {"capacity": 96}
|
|
179
|
+
|
|
180
|
+
def test_description_passed(self, intrinsic_node, mock_location_client):
|
|
181
|
+
"""Description is forwarded from the definition."""
|
|
182
|
+
intrinsic_node.intrinsic_location_handler()
|
|
183
|
+
|
|
184
|
+
deck_1_call = _find_call_by_location(
|
|
185
|
+
mock_location_client.init_location.call_args_list,
|
|
186
|
+
"intrinsic_loc_test_node.deck_1",
|
|
187
|
+
)
|
|
188
|
+
assert deck_1_call.kwargs["description"] == "First deck slot"
|
|
189
|
+
|
|
190
|
+
def test_allow_transfers_passed(self, intrinsic_node, mock_location_client):
|
|
191
|
+
"""allow_transfers is forwarded from the definition."""
|
|
192
|
+
intrinsic_node.intrinsic_location_handler()
|
|
193
|
+
|
|
194
|
+
deck_2_call = _find_call_by_location(
|
|
195
|
+
mock_location_client.init_location.call_args_list,
|
|
196
|
+
"intrinsic_loc_test_node.deck_2",
|
|
197
|
+
)
|
|
198
|
+
assert deck_2_call.kwargs["allow_transfers"] is False
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class TestIntrinsicLocationHandlerErrorIsolation:
|
|
202
|
+
"""Test that per-location errors do not prevent other locations from registering."""
|
|
203
|
+
|
|
204
|
+
def test_first_failure_does_not_prevent_second(
|
|
205
|
+
self, intrinsic_node, mock_location_client
|
|
206
|
+
):
|
|
207
|
+
"""If first location registration fails, second still registers."""
|
|
208
|
+
call_count = 0
|
|
209
|
+
|
|
210
|
+
def side_effect(**_kwargs):
|
|
211
|
+
nonlocal call_count
|
|
212
|
+
call_count += 1
|
|
213
|
+
if call_count == 1:
|
|
214
|
+
raise ConnectionError("server unavailable")
|
|
215
|
+
return MagicMock()
|
|
216
|
+
|
|
217
|
+
mock_location_client.init_location.side_effect = side_effect
|
|
218
|
+
|
|
219
|
+
# Should not raise
|
|
220
|
+
intrinsic_node.intrinsic_location_handler()
|
|
221
|
+
|
|
222
|
+
assert mock_location_client.init_location.call_count == 2
|
|
223
|
+
|
|
224
|
+
def test_handler_never_raises_even_if_all_fail(
|
|
225
|
+
self, intrinsic_node, mock_location_client
|
|
226
|
+
):
|
|
227
|
+
"""intrinsic_location_handler() never raises, even if all registrations fail."""
|
|
228
|
+
mock_location_client.init_location.side_effect = Exception("boom")
|
|
229
|
+
|
|
230
|
+
# Should not raise
|
|
231
|
+
intrinsic_node.intrinsic_location_handler()
|
|
232
|
+
|
|
233
|
+
def test_failure_logged_with_location_name(
|
|
234
|
+
self, intrinsic_node, mock_location_client
|
|
235
|
+
):
|
|
236
|
+
"""Failed registration logs a warning with the location name."""
|
|
237
|
+
mock_location_client.init_location.side_effect = ConnectionError("down")
|
|
238
|
+
|
|
239
|
+
with patch.object(intrinsic_node.logger, "warning") as mock_warning:
|
|
240
|
+
intrinsic_node.intrinsic_location_handler()
|
|
241
|
+
|
|
242
|
+
assert mock_warning.call_count == 2
|
|
243
|
+
for call in mock_warning.call_args_list:
|
|
244
|
+
assert "location_name" in call.kwargs
|
|
245
|
+
assert "error" in call.kwargs
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class TestIntrinsicLocationHandlerEmptyList:
|
|
249
|
+
"""Test backward compatibility when no intrinsic locations are declared."""
|
|
250
|
+
|
|
251
|
+
def test_empty_intrinsic_locations_no_calls(
|
|
252
|
+
self, empty_intrinsic_node, mock_location_client
|
|
253
|
+
):
|
|
254
|
+
"""intrinsic_location_handler() runs cleanly when list is empty (default)."""
|
|
255
|
+
empty_intrinsic_node.intrinsic_location_handler()
|
|
256
|
+
|
|
257
|
+
mock_location_client.init_location.assert_not_called()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class TestIntrinsicLocationHandlerCallOrder:
|
|
261
|
+
"""Test that intrinsic_location_handler runs between template_handler and startup_handler."""
|
|
262
|
+
|
|
263
|
+
def test_intrinsic_location_handler_called_between_template_and_startup(
|
|
264
|
+
self, intrinsic_node
|
|
265
|
+
):
|
|
266
|
+
"""Verify intrinsic_location_handler is called between template_handler and startup_handler."""
|
|
267
|
+
call_order = []
|
|
268
|
+
|
|
269
|
+
original_template_handler = intrinsic_node.template_handler
|
|
270
|
+
original_intrinsic_location_handler = intrinsic_node.intrinsic_location_handler
|
|
271
|
+
original_startup_handler = intrinsic_node.startup_handler
|
|
272
|
+
|
|
273
|
+
def tracked_template():
|
|
274
|
+
call_order.append("template_handler")
|
|
275
|
+
original_template_handler()
|
|
276
|
+
|
|
277
|
+
def tracked_intrinsic():
|
|
278
|
+
call_order.append("intrinsic_location_handler")
|
|
279
|
+
original_intrinsic_location_handler()
|
|
280
|
+
|
|
281
|
+
def tracked_startup():
|
|
282
|
+
call_order.append("startup_handler")
|
|
283
|
+
original_startup_handler()
|
|
284
|
+
|
|
285
|
+
intrinsic_node.template_handler = tracked_template
|
|
286
|
+
intrinsic_node.intrinsic_location_handler = tracked_intrinsic
|
|
287
|
+
intrinsic_node.startup_handler = tracked_startup
|
|
288
|
+
|
|
289
|
+
# Simulate _startup sequence
|
|
290
|
+
intrinsic_node.node_status.initializing = True
|
|
291
|
+
intrinsic_node.node_status.errored = False
|
|
292
|
+
intrinsic_node.node_status.locked = False
|
|
293
|
+
intrinsic_node.node_status.paused = False
|
|
294
|
+
intrinsic_node.node_status.stopped = False
|
|
295
|
+
intrinsic_node.template_handler()
|
|
296
|
+
intrinsic_node.intrinsic_location_handler()
|
|
297
|
+
intrinsic_node.startup_handler()
|
|
298
|
+
|
|
299
|
+
assert call_order == [
|
|
300
|
+
"template_handler",
|
|
301
|
+
"intrinsic_location_handler",
|
|
302
|
+
"startup_handler",
|
|
303
|
+
]
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class TestNodeInfoIntrinsicLocationPopulation:
|
|
307
|
+
"""Test that NodeInfo is populated with intrinsic location definitions."""
|
|
308
|
+
|
|
309
|
+
def test_node_info_has_intrinsic_locations(self, intrinsic_node):
|
|
310
|
+
"""NodeInfo.intrinsic_locations is populated from the class variable."""
|
|
311
|
+
assert len(intrinsic_node.node_info.intrinsic_locations) == 2
|
|
312
|
+
names = [
|
|
313
|
+
loc.location_name for loc in intrinsic_node.node_info.intrinsic_locations
|
|
314
|
+
]
|
|
315
|
+
assert "deck_1" in names
|
|
316
|
+
assert "deck_2" in names
|
|
317
|
+
|
|
318
|
+
def test_empty_node_has_empty_intrinsic_locations(self, empty_intrinsic_node):
|
|
319
|
+
"""NodeInfo has empty list when node declares no intrinsic locations."""
|
|
320
|
+
assert empty_intrinsic_node.node_info.intrinsic_locations == []
|
|
321
|
+
|
|
322
|
+
def test_node_info_intrinsic_locations_are_copies(self, intrinsic_node):
|
|
323
|
+
"""NodeInfo intrinsic_locations list is a copy, not a reference to the class variable."""
|
|
324
|
+
intrinsic_node.node_info.intrinsic_locations.append(
|
|
325
|
+
NodeIntrinsicLocationDefinition(
|
|
326
|
+
location_name="extra",
|
|
327
|
+
representation_template_name="extra_repr",
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
# Class variable should not be affected
|
|
331
|
+
assert len(IntrinsicLocationTestNode.intrinsic_locations) == 2
|
|
332
|
+
|
|
333
|
+
def test_node_info_intrinsic_location_serialization_roundtrip(self, intrinsic_node):
|
|
334
|
+
"""NodeInfo with intrinsic_locations can be serialized and deserialized."""
|
|
335
|
+
dumped = intrinsic_node.node_info.model_dump(mode="json")
|
|
336
|
+
assert "intrinsic_locations" in dumped
|
|
337
|
+
assert len(dumped["intrinsic_locations"]) == 2
|
|
338
|
+
|
|
339
|
+
# Round-trip
|
|
340
|
+
restored = NodeInfo.model_validate(dumped)
|
|
341
|
+
assert len(restored.intrinsic_locations) == 2
|
|
342
|
+
names = [loc.location_name for loc in restored.intrinsic_locations]
|
|
343
|
+
assert "deck_1" in names
|
|
344
|
+
assert "deck_2" in names
|
{madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/test_location_argument_serialization.py
RENAMED
|
@@ -91,7 +91,7 @@ class TestLocationArgumentSerialization:
|
|
|
91
91
|
)
|
|
92
92
|
assert serialized_var_kwargs["regular_kwarg"] == "value"
|
|
93
93
|
|
|
94
|
-
@patch("madsci.client.node.rest_node_client.
|
|
94
|
+
@patch("madsci.client.node.rest_node_client.create_httpx_client")
|
|
95
95
|
def test_rest_client_serializes_location_arguments(self, mock_create_session):
|
|
96
96
|
"""Test that RestNodeClient properly serializes LocationArguments."""
|
|
97
97
|
# Mock the response
|
|
@@ -101,7 +101,7 @@ class TestLocationArgumentSerialization:
|
|
|
101
101
|
mock_response.raise_for_status.return_value = None
|
|
102
102
|
|
|
103
103
|
mock_session = MagicMock()
|
|
104
|
-
mock_session.
|
|
104
|
+
mock_session.request.return_value = mock_response
|
|
105
105
|
mock_create_session.return_value = mock_session
|
|
106
106
|
|
|
107
107
|
# Create client and LocationArgument
|
|
@@ -125,8 +125,8 @@ class TestLocationArgumentSerialization:
|
|
|
125
125
|
assert result == "test_action_id"
|
|
126
126
|
|
|
127
127
|
# Verify the call was made correctly
|
|
128
|
-
assert mock_session.
|
|
129
|
-
call_args = mock_session.
|
|
128
|
+
assert mock_session.request.called
|
|
129
|
+
call_args = mock_session.request.call_args
|
|
130
130
|
|
|
131
131
|
# Check the JSON payload structure
|
|
132
132
|
json_payload = call_args[1]["json"]
|
|
@@ -147,7 +147,7 @@ class TestLocationArgumentSerialization:
|
|
|
147
147
|
# Other args should be preserved
|
|
148
148
|
assert args["speed"] == 75
|
|
149
149
|
|
|
150
|
-
@patch("madsci.client.node.rest_node_client.
|
|
150
|
+
@patch("madsci.client.node.rest_node_client.create_httpx_client")
|
|
151
151
|
def test_rest_client_serializes_location_arguments_in_var_args_kwargs(
|
|
152
152
|
self, mock_create_session
|
|
153
153
|
):
|
|
@@ -159,7 +159,7 @@ class TestLocationArgumentSerialization:
|
|
|
159
159
|
mock_response.raise_for_status.return_value = None
|
|
160
160
|
|
|
161
161
|
mock_session = MagicMock()
|
|
162
|
-
mock_session.
|
|
162
|
+
mock_session.request.return_value = mock_response
|
|
163
163
|
mock_create_session.return_value = mock_session
|
|
164
164
|
|
|
165
165
|
client = RestNodeClient(url="http://localhost:8000")
|
|
@@ -179,7 +179,7 @@ class TestLocationArgumentSerialization:
|
|
|
179
179
|
client._create_action(action_request)
|
|
180
180
|
|
|
181
181
|
# Verify the call
|
|
182
|
-
call_args = mock_session.
|
|
182
|
+
call_args = mock_session.request.call_args
|
|
183
183
|
json_payload = call_args[1]["json"]
|
|
184
184
|
|
|
185
185
|
# Check var_args serialization
|