inorbit-edge-executor 3.2.0__tar.gz → 3.2.3__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 (28) hide show
  1. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/PKG-INFO +1 -1
  2. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/inorbit_edge_executor/behavior_tree.py +42 -5
  3. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/inorbit_edge_executor.egg-info/PKG-INFO +1 -1
  4. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/inorbit_edge_executor.egg-info/SOURCES.txt +1 -0
  5. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/pyproject.toml +2 -2
  6. inorbit_edge_executor-3.2.3/tests/test_run_action_node.py +288 -0
  7. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/LICENSE +0 -0
  8. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/README.md +0 -0
  9. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/inorbit_edge_executor/__init__.py +0 -0
  10. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/inorbit_edge_executor/datatypes.py +0 -0
  11. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/inorbit_edge_executor/db.py +0 -0
  12. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/inorbit_edge_executor/dummy_backend.py +0 -0
  13. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/inorbit_edge_executor/exceptions.py +0 -0
  14. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/inorbit_edge_executor/inorbit.py +0 -0
  15. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/inorbit_edge_executor/logger.py +0 -0
  16. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/inorbit_edge_executor/mission.py +0 -0
  17. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/inorbit_edge_executor/observable.py +0 -0
  18. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/inorbit_edge_executor/sqlite_backend.py +0 -0
  19. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/inorbit_edge_executor/worker.py +0 -0
  20. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/inorbit_edge_executor/worker_pool.py +0 -0
  21. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/inorbit_edge_executor.egg-info/dependency_links.txt +0 -0
  22. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/inorbit_edge_executor.egg-info/requires.txt +0 -0
  23. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/inorbit_edge_executor.egg-info/top_level.txt +0 -0
  24. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/setup.cfg +0 -0
  25. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/tests/test_behavior_tree.py +0 -0
  26. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/tests/test_behavior_tree_error_handler.py +0 -0
  27. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/tests/test_set_data_node.py +0 -0
  28. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.3}/tests/test_wait_expression_node.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inorbit_edge_executor
3
- Version: 3.2.0
3
+ Version: 3.2.3
4
4
  Summary: InOrbit Edge Missions Executor
5
5
  Author-email: Mariano Cereda <mariano.cereda@inorbit.ai>, Hernan Badenes <herchu@inorbit.ai>, Miguel Garcia <mike@inorbit.ai>
6
6
  License: MIT
@@ -557,6 +557,8 @@ class RunActionNode(BehaviorTree):
557
557
  action_id,
558
558
  arguments,
559
559
  target: Target = None,
560
+ max_retries: int = 3,
561
+ retry_wait_seconds: float = 5.0,
560
562
  **kwargs,
561
563
  ):
562
564
  super().__init__(**kwargs)
@@ -564,6 +566,8 @@ class RunActionNode(BehaviorTree):
564
566
  self.action_id = action_id
565
567
  self.arguments = arguments
566
568
  self.target = target
569
+ self.max_retries = max_retries
570
+ self.retry_wait_seconds = retry_wait_seconds
567
571
  if self.target is None:
568
572
  self.robot = context.robot_api
569
573
  else:
@@ -571,9 +575,29 @@ class RunActionNode(BehaviorTree):
571
575
 
572
576
  async def _execute(self):
573
577
  arguments = await self.mt.resolve_arguments(self.arguments)
574
- resp = await self.robot.execute_action(self.action_id, arguments=arguments)
575
- # TODO track action execution, as done in the app. This JSON response only guarantees
576
- # the action was *started*.
578
+
579
+ last_exception = None
580
+ for attempt in range(self.max_retries + 1):
581
+ try:
582
+ resp = await self.robot.execute_action(self.action_id, arguments=arguments)
583
+ # TODO track action execution, as done in the app. This JSON response only guarantees
584
+ # the action was *started*.
585
+ return # Success, exit retry loop
586
+ except Exception as e:
587
+ last_exception = e
588
+ if attempt < self.max_retries:
589
+ logger.warning(
590
+ f"Action execution failed (attempt {attempt + 1}/{self.max_retries + 1}): {e}. "
591
+ f"Retrying in {self.retry_wait_seconds} seconds..."
592
+ )
593
+ await asyncio.sleep(self.retry_wait_seconds)
594
+ else:
595
+ logger.error(
596
+ f"Action execution failed after {self.max_retries + 1} attempts: {e}"
597
+ )
598
+
599
+ # All retries exhausted, raise the last exception
600
+ raise last_exception
577
601
 
578
602
  async def on_pause(self):
579
603
  # TODO (Elvio): Here goes the logic to stop an action when a Mission is paused
@@ -584,15 +608,28 @@ class RunActionNode(BehaviorTree):
584
608
  object = super().dump_object()
585
609
  object["action_id"] = self.action_id
586
610
  object["arguments"] = self.arguments
611
+ object["max_retries"] = self.max_retries
612
+ object["retry_wait_seconds"] = self.retry_wait_seconds
587
613
  if self.target is not None:
588
614
  object["target"] = self.target.dump_object()
589
615
  return object
590
616
 
591
617
  @classmethod
592
- def from_object(cls, context, action_id, arguments, target=None, **kwargs):
618
+ def from_object(
619
+ cls,
620
+ context,
621
+ action_id,
622
+ arguments,
623
+ target=None,
624
+ max_retries=3,
625
+ retry_wait_seconds=5.0,
626
+ **kwargs,
627
+ ):
593
628
  if target is not None:
594
629
  target = Target.from_object(**target)
595
- return RunActionNode(context, action_id, arguments, target, **kwargs)
630
+ return RunActionNode(
631
+ context, action_id, arguments, target, max_retries, retry_wait_seconds, **kwargs
632
+ )
596
633
 
597
634
 
598
635
  class WaitExpressionNode(BehaviorTree):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inorbit_edge_executor
3
- Version: 3.2.0
3
+ Version: 3.2.3
4
4
  Summary: InOrbit Edge Missions Executor
5
5
  Author-email: Mariano Cereda <mariano.cereda@inorbit.ai>, Hernan Badenes <herchu@inorbit.ai>, Miguel Garcia <mike@inorbit.ai>
6
6
  License: MIT
@@ -21,5 +21,6 @@ inorbit_edge_executor.egg-info/requires.txt
21
21
  inorbit_edge_executor.egg-info/top_level.txt
22
22
  tests/test_behavior_tree.py
23
23
  tests/test_behavior_tree_error_handler.py
24
+ tests/test_run_action_node.py
24
25
  tests/test_set_data_node.py
25
26
  tests/test_wait_expression_node.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "inorbit_edge_executor"
7
- version = "3.2.0"
7
+ version = "3.2.3"
8
8
  description = "InOrbit Edge Missions Executor"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -47,7 +47,7 @@ target-version = ['py310']
47
47
  line-length = 100
48
48
 
49
49
  [tool.bumpversion]
50
- current_version = "3.2.0"
50
+ current_version = "3.2.3"
51
51
  commit = true
52
52
  tag = true
53
53
  tag_name = "v{new_version}"
@@ -0,0 +1,288 @@
1
+ import pytest
2
+ from unittest.mock import AsyncMock, Mock
3
+
4
+ from inorbit_edge_executor.behavior_tree import RunActionNode, BehaviorTreeBuilderContext
5
+ from inorbit_edge_executor.datatypes import Target
6
+ from inorbit_edge_executor.inorbit import RobotApi, RobotApiFactory
7
+
8
+
9
+ class TestRunActionNodeRetry:
10
+ @pytest.fixture
11
+ def mock_robot_api(self):
12
+ """Mock RobotApi for testing"""
13
+ mock_api = Mock(spec=RobotApi)
14
+ mock_api.robot_id = "test_robot"
15
+ mock_api.execute_action = AsyncMock()
16
+ return mock_api
17
+
18
+ @pytest.fixture
19
+ def mock_mission_tracking(self):
20
+ """Mock mission tracking for testing"""
21
+ mock_mt = Mock()
22
+ mock_mt.resolve_arguments = AsyncMock(return_value={"param": "value"})
23
+ return mock_mt
24
+
25
+ @pytest.fixture
26
+ def context(self, mock_robot_api, mock_mission_tracking):
27
+ """Create a test context with mocked dependencies"""
28
+ context = BehaviorTreeBuilderContext()
29
+ context.robot_api = mock_robot_api
30
+ context.mt = mock_mission_tracking
31
+ return context
32
+
33
+ @pytest.mark.asyncio
34
+ async def test_execute_action_success_first_try(self, context, mock_robot_api):
35
+ """Test successful action execution on first attempt"""
36
+ # Setup
37
+ mock_robot_api.execute_action.return_value = {"status": "success"}
38
+
39
+ node = RunActionNode(
40
+ context=context,
41
+ action_id="test_action",
42
+ arguments={"param": "value"},
43
+ max_retries=3,
44
+ retry_wait_seconds=0.01,
45
+ )
46
+
47
+ # Execute
48
+ await node._execute()
49
+
50
+ # Verify
51
+ mock_robot_api.execute_action.assert_called_once_with(
52
+ "test_action", arguments={"param": "value"}
53
+ )
54
+
55
+ @pytest.mark.asyncio
56
+ async def test_execute_action_success_after_retries(self, context, mock_robot_api):
57
+ """Test successful action execution after some failures"""
58
+ # Setup - fail first 2 attempts, succeed on 3rd
59
+ mock_robot_api.execute_action.side_effect = [
60
+ Exception("Network timeout"),
61
+ Exception("Connection refused"),
62
+ {"status": "success"}, # Success on third try
63
+ ]
64
+
65
+ node = RunActionNode(
66
+ context=context,
67
+ action_id="test_action",
68
+ arguments={"param": "value"},
69
+ max_retries=3,
70
+ retry_wait_seconds=0.01,
71
+ )
72
+
73
+ # Execute
74
+ await node._execute()
75
+
76
+ # Verify
77
+ assert mock_robot_api.execute_action.call_count == 3
78
+
79
+ @pytest.mark.asyncio
80
+ async def test_execute_action_fail_all_retries(self, context, mock_robot_api):
81
+ """Test action execution failure after exhausting all retries"""
82
+ # Setup - all attempts fail
83
+ test_exception = Exception("Persistent network error")
84
+ mock_robot_api.execute_action.side_effect = test_exception
85
+
86
+ node = RunActionNode(
87
+ context=context,
88
+ action_id="test_action",
89
+ arguments={"param": "value"},
90
+ max_retries=2,
91
+ retry_wait_seconds=0.01,
92
+ )
93
+
94
+ # Execute and verify exception is raised
95
+ with pytest.raises(Exception, match="Persistent network error"):
96
+ await node._execute()
97
+
98
+ # Verify all retry attempts were made (max_retries + 1 = 3 attempts)
99
+ assert mock_robot_api.execute_action.call_count == 3
100
+
101
+ @pytest.mark.asyncio
102
+ async def test_execute_action_different_exception_types(self, context, mock_robot_api):
103
+ """Test that different types of exceptions are handled consistently"""
104
+ exceptions_to_test = [
105
+ ValueError("Invalid parameter"),
106
+ ConnectionError("Network unreachable"),
107
+ TimeoutError("Request timeout"),
108
+ RuntimeError("Service unavailable"),
109
+ ]
110
+
111
+ for exception in exceptions_to_test:
112
+ # Reset mock
113
+ mock_robot_api.execute_action.reset_mock()
114
+ mock_robot_api.execute_action.side_effect = exception
115
+
116
+ node = RunActionNode(
117
+ context=context,
118
+ action_id="test_action",
119
+ arguments={"param": "value"},
120
+ max_retries=1,
121
+ retry_wait_seconds=0.01,
122
+ )
123
+
124
+ # Execute and verify exception is raised
125
+ with pytest.raises(type(exception)):
126
+ await node._execute()
127
+
128
+ # Verify retries were attempted
129
+ assert mock_robot_api.execute_action.call_count == 2
130
+
131
+ @pytest.mark.asyncio
132
+ async def test_execute_action_with_custom_max_retries(self, context, mock_robot_api):
133
+ """Test action execution with different max_retries values"""
134
+ test_cases = [0, 1, 5, 10]
135
+
136
+ for max_retries in test_cases:
137
+ # Reset mock
138
+ mock_robot_api.execute_action.reset_mock()
139
+ mock_robot_api.execute_action.side_effect = Exception("Always fail")
140
+
141
+ node = RunActionNode(
142
+ context=context,
143
+ action_id="test_action",
144
+ arguments={"param": "value"},
145
+ max_retries=max_retries,
146
+ retry_wait_seconds=0.01,
147
+ )
148
+
149
+ # Execute and verify exception is raised
150
+ with pytest.raises(Exception):
151
+ await node._execute()
152
+
153
+ # Verify correct number of attempts (max_retries + 1)
154
+ expected_attempts = max_retries + 1
155
+ assert mock_robot_api.execute_action.call_count == expected_attempts
156
+
157
+ @pytest.mark.asyncio
158
+ async def test_retry_delay_timing(self, context, mock_robot_api):
159
+ """Test that retry delays are working (approximate timing)"""
160
+ # Setup
161
+ mock_robot_api.execute_action.side_effect = [
162
+ Exception("Fail 1"),
163
+ Exception("Fail 2"),
164
+ {"status": "success"},
165
+ ]
166
+
167
+ node = RunActionNode(
168
+ context=context,
169
+ action_id="test_action",
170
+ arguments={"param": "value"},
171
+ max_retries=2,
172
+ retry_wait_seconds=0.01,
173
+ )
174
+
175
+ # Execute with timing
176
+ import time
177
+
178
+ start_time = time.time()
179
+ await node._execute()
180
+ end_time = time.time()
181
+
182
+ # Should take at least 0.02 seconds (2 retries * 0.01 seconds each)
183
+ # but allow some tolerance for test execution overhead
184
+ assert end_time - start_time >= 0.015
185
+
186
+ @pytest.mark.asyncio
187
+ async def test_execute_action_with_target_robot(self, mock_mission_tracking):
188
+ """Test action execution with a different target robot"""
189
+ # Setup target robot
190
+ target = Target(robotId="target_robot")
191
+
192
+ # Create mock robot API factory
193
+ mock_target_robot_api = Mock(spec=RobotApi)
194
+ mock_target_robot_api.execute_action = AsyncMock(return_value={"status": "success"})
195
+
196
+ mock_factory = Mock(spec=RobotApiFactory)
197
+ mock_factory.build.return_value = mock_target_robot_api
198
+
199
+ context = BehaviorTreeBuilderContext()
200
+ context.robot_api_factory = mock_factory
201
+ context.mt = mock_mission_tracking
202
+
203
+ node = RunActionNode(
204
+ context=context,
205
+ action_id="test_action",
206
+ arguments={"param": "value"},
207
+ target=target,
208
+ max_retries=2,
209
+ retry_wait_seconds=0.01,
210
+ )
211
+
212
+ # Execute
213
+ await node._execute()
214
+
215
+ # Verify
216
+ mock_factory.build.assert_called_once_with("target_robot")
217
+ mock_target_robot_api.execute_action.assert_called_once_with(
218
+ "test_action", arguments={"param": "value"}
219
+ )
220
+
221
+ def test_dump_object_includes_max_retries(self, context):
222
+ """Test that dump_object includes max_retries parameter"""
223
+ node = RunActionNode(
224
+ context=context,
225
+ action_id="test_action",
226
+ arguments={"param": "value"},
227
+ max_retries=5,
228
+ retry_wait_seconds=0.01,
229
+ )
230
+
231
+ dumped = node.dump_object()
232
+
233
+ assert dumped["max_retries"] == 5
234
+ assert dumped["retry_wait_seconds"] == 0.01
235
+ assert dumped["action_id"] == "test_action"
236
+ assert dumped["arguments"] == {"param": "value"}
237
+
238
+ def test_from_object_creates_node_with_max_retries(self, context):
239
+ """Test that from_object correctly handles max_retries parameter"""
240
+ node = RunActionNode.from_object(
241
+ context=context,
242
+ action_id="test_action",
243
+ arguments={"param": "value"},
244
+ max_retries=7,
245
+ retry_wait_seconds=0.01,
246
+ )
247
+
248
+ assert node.max_retries == 7
249
+ assert node.retry_wait_seconds == 0.01
250
+ assert node.action_id == "test_action"
251
+ assert node.arguments == {"param": "value"}
252
+
253
+ def test_from_object_defaults_max_retries(self, context):
254
+ """Test that from_object uses default max_retries when not specified"""
255
+ node = RunActionNode.from_object(
256
+ context=context, action_id="test_action", arguments={"param": "value"}
257
+ )
258
+
259
+ assert node.max_retries == 3 # Default value
260
+ assert node.retry_wait_seconds == 5.0 # Default value
261
+
262
+ def test_serialization_round_trip(self, context):
263
+ """Test that serialization and deserialization preserve max_retries"""
264
+ original_node = RunActionNode(
265
+ context=context,
266
+ action_id="test_action",
267
+ arguments={"param": "value"},
268
+ max_retries=4,
269
+ retry_wait_seconds=0.01,
270
+ label="test_label",
271
+ )
272
+
273
+ # Serialize
274
+ dumped = original_node.dump_object()
275
+
276
+ # Remove type field that's used by the serialization system
277
+ dumped_for_restore = dumped.copy()
278
+ del dumped_for_restore["type"]
279
+
280
+ # Deserialize
281
+ restored_node = RunActionNode.from_object(context=context, **dumped_for_restore)
282
+
283
+ # Verify all properties are preserved
284
+ assert restored_node.max_retries == 4
285
+ assert restored_node.retry_wait_seconds == 0.01
286
+ assert restored_node.action_id == "test_action"
287
+ assert restored_node.arguments == {"param": "value"}
288
+ assert restored_node.label == "test_label"