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.
Files changed (24) hide show
  1. madsci_node_module-0.7.1/README.md → madsci_node_module-0.8.0/PKG-INFO +23 -2
  2. madsci_node_module-0.7.1/PKG-INFO → madsci_node_module-0.8.0/README.md +10 -15
  3. {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/madsci/node_module/abstract_node_module.py +127 -0
  4. {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/pyproject.toml +1 -1
  5. madsci_node_module-0.8.0/tests/test_intrinsic_location_handler.py +344 -0
  6. {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/test_location_argument_serialization.py +7 -7
  7. {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/test_rest_functionality.py +22 -21
  8. {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/test_rest_node_client.py +133 -113
  9. {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/test_rest_node_module.py +47 -0
  10. madsci_node_module-0.8.0/tests/test_template_handler.py +362 -0
  11. {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/madsci/node_module/__init__.py +0 -0
  12. {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/madsci/node_module/helpers.py +0 -0
  13. {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/madsci/node_module/rest_node_module.py +0 -0
  14. {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/madsci/node_module/type_analyzer.py +0 -0
  15. {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/conftest.py +0 -0
  16. {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/test_action_parsing_integration.py +0 -0
  17. {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/test_argument_parsing.py +0 -0
  18. {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/test_helpers.py +0 -0
  19. {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/test_node.py +0 -0
  20. {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/test_node_infrastructure.py +0 -0
  21. {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/test_node_registry.py +0 -0
  22. {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/test_openapi_schemas.py +0 -0
  23. {madsci_node_module-0.7.1 → madsci_node_module-0.8.0}/tests/test_rest_utils.py +0 -0
  24. {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
- # 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.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
- # 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.
@@ -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(
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "madsci.node_module"
3
- version = "0.7.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" },
@@ -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
@@ -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.create_http_session")
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.post.return_value = mock_response
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.post.called
129
- call_args = mock_session.post.call_args
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.create_http_session")
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.post.return_value = mock_response
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.post.call_args
182
+ call_args = mock_session.request.call_args
183
183
  json_payload = call_args[1]["json"]
184
184
 
185
185
  # Check var_args serialization