ivoryos 1.0.9__py3-none-any.whl → 1.4.4__py3-none-any.whl

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 (107) hide show
  1. docs/source/conf.py +84 -0
  2. ivoryos/__init__.py +17 -207
  3. ivoryos/app.py +154 -0
  4. ivoryos/config.py +1 -0
  5. ivoryos/optimizer/ax_optimizer.py +191 -0
  6. ivoryos/optimizer/base_optimizer.py +84 -0
  7. ivoryos/optimizer/baybe_optimizer.py +193 -0
  8. ivoryos/optimizer/nimo_optimizer.py +173 -0
  9. ivoryos/optimizer/registry.py +11 -0
  10. ivoryos/routes/auth/auth.py +43 -14
  11. ivoryos/routes/auth/templates/change_password.html +32 -0
  12. ivoryos/routes/control/control.py +101 -366
  13. ivoryos/routes/control/control_file.py +33 -0
  14. ivoryos/routes/control/control_new_device.py +152 -0
  15. ivoryos/routes/control/templates/controllers.html +193 -0
  16. ivoryos/routes/control/templates/controllers_new.html +112 -0
  17. ivoryos/routes/control/utils.py +40 -0
  18. ivoryos/routes/data/data.py +197 -0
  19. ivoryos/routes/data/templates/components/step_card.html +78 -0
  20. ivoryos/routes/{database/templates/database → data/templates}/workflow_database.html +14 -8
  21. ivoryos/routes/data/templates/workflow_view.html +360 -0
  22. ivoryos/routes/design/__init__.py +4 -0
  23. ivoryos/routes/design/design.py +348 -657
  24. ivoryos/routes/design/design_file.py +68 -0
  25. ivoryos/routes/design/design_step.py +171 -0
  26. ivoryos/routes/design/templates/components/action_form.html +53 -0
  27. ivoryos/routes/design/templates/components/actions_panel.html +25 -0
  28. ivoryos/routes/design/templates/components/autofill_toggle.html +10 -0
  29. ivoryos/routes/design/templates/components/canvas.html +5 -0
  30. ivoryos/routes/design/templates/components/canvas_footer.html +9 -0
  31. ivoryos/routes/design/templates/components/canvas_header.html +75 -0
  32. ivoryos/routes/design/templates/components/canvas_main.html +39 -0
  33. ivoryos/routes/design/templates/components/deck_selector.html +10 -0
  34. ivoryos/routes/design/templates/components/edit_action_form.html +53 -0
  35. ivoryos/routes/design/templates/components/info_modal.html +318 -0
  36. ivoryos/routes/design/templates/components/instruments_panel.html +88 -0
  37. ivoryos/routes/design/templates/components/modals/drop_modal.html +17 -0
  38. ivoryos/routes/design/templates/components/modals/json_modal.html +22 -0
  39. ivoryos/routes/design/templates/components/modals/new_script_modal.html +17 -0
  40. ivoryos/routes/design/templates/components/modals/rename_modal.html +23 -0
  41. ivoryos/routes/design/templates/components/modals/saveas_modal.html +27 -0
  42. ivoryos/routes/design/templates/components/modals.html +6 -0
  43. ivoryos/routes/design/templates/components/python_code_overlay.html +56 -0
  44. ivoryos/routes/design/templates/components/sidebar.html +15 -0
  45. ivoryos/routes/design/templates/components/text_to_code_panel.html +20 -0
  46. ivoryos/routes/design/templates/experiment_builder.html +44 -0
  47. ivoryos/routes/execute/__init__.py +0 -0
  48. ivoryos/routes/execute/execute.py +377 -0
  49. ivoryos/routes/execute/execute_file.py +78 -0
  50. ivoryos/routes/execute/templates/components/error_modal.html +20 -0
  51. ivoryos/routes/execute/templates/components/logging_panel.html +56 -0
  52. ivoryos/routes/execute/templates/components/progress_panel.html +27 -0
  53. ivoryos/routes/execute/templates/components/run_panel.html +9 -0
  54. ivoryos/routes/execute/templates/components/run_tabs.html +60 -0
  55. ivoryos/routes/execute/templates/components/tab_bayesian.html +520 -0
  56. ivoryos/routes/execute/templates/components/tab_configuration.html +383 -0
  57. ivoryos/routes/execute/templates/components/tab_repeat.html +18 -0
  58. ivoryos/routes/execute/templates/experiment_run.html +30 -0
  59. ivoryos/routes/library/__init__.py +0 -0
  60. ivoryos/routes/library/library.py +157 -0
  61. ivoryos/routes/{database/templates/database/scripts_database.html → library/templates/library.html} +32 -23
  62. ivoryos/routes/main/main.py +31 -3
  63. ivoryos/routes/main/templates/{main/home.html → home.html} +4 -4
  64. ivoryos/server.py +180 -0
  65. ivoryos/socket_handlers.py +52 -0
  66. ivoryos/static/ivoryos_logo.png +0 -0
  67. ivoryos/static/js/action_handlers.js +384 -0
  68. ivoryos/static/js/db_delete.js +23 -0
  69. ivoryos/static/js/script_metadata.js +39 -0
  70. ivoryos/static/js/socket_handler.js +40 -5
  71. ivoryos/static/js/sortable_design.js +107 -56
  72. ivoryos/static/js/ui_state.js +114 -0
  73. ivoryos/templates/base.html +67 -8
  74. ivoryos/utils/bo_campaign.py +180 -3
  75. ivoryos/utils/client_proxy.py +267 -36
  76. ivoryos/utils/db_models.py +300 -65
  77. ivoryos/utils/decorators.py +34 -0
  78. ivoryos/utils/form.py +63 -29
  79. ivoryos/utils/global_config.py +34 -1
  80. ivoryos/utils/nest_script.py +314 -0
  81. ivoryos/utils/py_to_json.py +295 -0
  82. ivoryos/utils/script_runner.py +599 -165
  83. ivoryos/utils/serilize.py +201 -0
  84. ivoryos/utils/task_runner.py +71 -21
  85. ivoryos/utils/utils.py +50 -6
  86. ivoryos/version.py +1 -1
  87. ivoryos-1.4.4.dist-info/METADATA +263 -0
  88. ivoryos-1.4.4.dist-info/RECORD +119 -0
  89. {ivoryos-1.0.9.dist-info → ivoryos-1.4.4.dist-info}/WHEEL +1 -1
  90. {ivoryos-1.0.9.dist-info → ivoryos-1.4.4.dist-info}/top_level.txt +1 -0
  91. tests/unit/test_type_conversion.py +42 -0
  92. tests/unit/test_util.py +3 -0
  93. ivoryos/routes/control/templates/control/controllers.html +0 -78
  94. ivoryos/routes/control/templates/control/controllers_home.html +0 -55
  95. ivoryos/routes/control/templates/control/controllers_new.html +0 -89
  96. ivoryos/routes/database/database.py +0 -306
  97. ivoryos/routes/database/templates/database/step_card.html +0 -7
  98. ivoryos/routes/database/templates/database/workflow_view.html +0 -130
  99. ivoryos/routes/design/templates/design/experiment_builder.html +0 -521
  100. ivoryos/routes/design/templates/design/experiment_run.html +0 -558
  101. ivoryos-1.0.9.dist-info/METADATA +0 -218
  102. ivoryos-1.0.9.dist-info/RECORD +0 -61
  103. /ivoryos/routes/auth/templates/{auth/login.html → login.html} +0 -0
  104. /ivoryos/routes/auth/templates/{auth/signup.html → signup.html} +0 -0
  105. /ivoryos/routes/{database → data}/__init__.py +0 -0
  106. /ivoryos/routes/main/templates/{main/help.html → help.html} +0 -0
  107. {ivoryos-1.0.9.dist-info → ivoryos-1.4.4.dist-info/licenses}/LICENSE +0 -0
ivoryos/utils/form.py CHANGED
@@ -1,5 +1,10 @@
1
1
  from enum import Enum
2
- from typing import get_origin, get_args, Union, Any
2
+ from typing import Union, Any
3
+ try:
4
+ from typing import get_origin, get_args
5
+ except ImportError:
6
+ # For Python versions = 3.7, use typing_extensions
7
+ from typing_extensions import get_origin, get_args
3
8
 
4
9
  from wtforms.fields.choices import SelectField
5
10
  from wtforms.fields.core import Field
@@ -205,20 +210,15 @@ class FlexibleEnumField(StringField):
205
210
  if key in self.choices:
206
211
  # Convert the string key to Enum instance
207
212
  self.data = self.enum_class[key].value
208
- elif self.data.startswith("#"):
213
+ elif key.startswith("#"):
209
214
  if not self.script.editing_type == "script":
210
215
  raise ValueError(self.gettext("Variable is not supported in prep/cleanup"))
211
- self.data = self.data
216
+ self.data = key
212
217
  else:
213
218
  raise ValidationError(
214
219
  f"Invalid choice: '{key}'. Must match one of {list(self.enum_class.__members__.keys())}")
215
220
 
216
221
 
217
- def format_name(name):
218
- """Converts 'example_name' to 'Example Name'."""
219
- name = name.split(".")[-1]
220
- text = ' '.join(word for word in name.split('_'))
221
- return text.capitalize()
222
222
 
223
223
  def parse_annotation(annotation):
224
224
  """
@@ -264,7 +264,7 @@ def create_form_for_method(method, autofill, script=None, design=True):
264
264
  for param in sig.parameters.values():
265
265
  if param.name == 'self':
266
266
  continue
267
- formatted_param_name = format_name(param.name)
267
+ # formatted_param_name = format_name(param.name)
268
268
 
269
269
  default_value = None
270
270
  if autofill:
@@ -277,7 +277,7 @@ def create_form_for_method(method, autofill, script=None, design=True):
277
277
  default_value = param.default
278
278
 
279
279
  field_kwargs = {
280
- "label": formatted_param_name,
280
+ "label": param.name,
281
281
  "default": default_value,
282
282
  "validators": [InputRequired()] if param.default is param.empty else [Optional()],
283
283
  **({"script": script} if (autofill or design) else {})
@@ -286,7 +286,9 @@ def create_form_for_method(method, autofill, script=None, design=True):
286
286
  # enum_class = [(e.name, e.value) for e in param.annotation]
287
287
  field_class = FlexibleEnumField
288
288
  placeholder_text = f"Choose or type a value for {param.annotation.__name__} (start with # for custom)"
289
+
289
290
  extra_kwargs = {"choices": param.annotation}
291
+
290
292
  else:
291
293
  # print(param.annotation)
292
294
  annotation, optional = parse_annotation(param.annotation)
@@ -299,6 +301,11 @@ def create_form_for_method(method, autofill, script=None, design=True):
299
301
  if optional:
300
302
  field_kwargs["filters"] = [lambda x: x if x != '' else None]
301
303
 
304
+ if annotation is bool:
305
+ # Boolean fields should not use InputRequired
306
+ field_kwargs["validators"] = [] # or [Optional()]
307
+ else:
308
+ field_kwargs["validators"] = [InputRequired()] if param.default is param.empty else [Optional()]
302
309
 
303
310
  render_kwargs = {"placeholder": placeholder_text}
304
311
 
@@ -321,10 +328,13 @@ def create_add_form(attr, attr_name, autofill: bool, script=None, design: bool =
321
328
  """
322
329
  signature = attr.get('signature', {})
323
330
  docstring = attr.get('docstring', "")
331
+ # print(signature, docstring)
324
332
  dynamic_form = create_form_for_method(signature, autofill, script, design)
325
333
  if design:
326
334
  return_value = StringField(label='Save value as', render_kw={"placeholder": "Optional"})
335
+ batch_action = BooleanField(label='Batch Action', render_kw={"placeholder": "Optional"})
327
336
  setattr(dynamic_form, 'return', return_value)
337
+ setattr(dynamic_form, 'batch_action', batch_action)
328
338
  hidden_method_name = HiddenField(name=f'hidden_name', description=docstring, render_kw={"value": f'{attr_name}'})
329
339
  setattr(dynamic_form, 'hidden_name', hidden_method_name)
330
340
  return dynamic_form
@@ -340,13 +350,16 @@ def create_form_from_module(sdl_module, autofill: bool = False, script=None, des
340
350
  """
341
351
  method_forms = {}
342
352
  for attr_name in dir(sdl_module):
343
- method = getattr(sdl_module, attr_name)
344
- if inspect.ismethod(method) and not attr_name.startswith('_'):
345
- signature = inspect.signature(method)
346
- docstring = inspect.getdoc(method)
347
- attr = dict(signature=signature, docstring=docstring)
348
- form_class = create_add_form(attr, attr_name, autofill, script, design)
349
- method_forms[attr_name] = form_class()
353
+ try:
354
+ method = getattr(sdl_module, attr_name)
355
+ if inspect.ismethod(method) and not attr_name.startswith('_'):
356
+ signature = inspect.signature(method)
357
+ docstring = inspect.getdoc(method)
358
+ attr = dict(signature=signature, docstring=docstring)
359
+ form_class = create_add_form(attr, attr_name, autofill, script, design)
360
+ method_forms[attr_name] = form_class()
361
+ except Exception as e:
362
+ print(f"Error creating form for {attr_name}: {e}")
350
363
  return method_forms
351
364
 
352
365
 
@@ -395,16 +408,26 @@ def create_form_from_action(action: dict, script=None, design=True):
395
408
  }
396
409
 
397
410
  for name, param_type in arg_types.items():
398
- formatted_param_name = format_name(name)
411
+ # formatted_param_name = format_name(name)
399
412
  value = args.get(name, "")
400
- if type(value) is dict:
413
+ if type(value) is dict and value:
401
414
  value = next(iter(value))
415
+ if not value or value == "None":
416
+ value = None
417
+ else:
418
+ value = f'{value}'
419
+
402
420
  field_kwargs = {
403
- "label": formatted_param_name,
404
- "default": f'{value}',
405
- "validators": [InputRequired()],
421
+ "label": name,
422
+ "default": f'{value}' if value else None,
423
+ # todo get optional/required from snapshot
424
+ "validators": [Optional()],
406
425
  **({"script": script})
407
426
  }
427
+ if type(param_type) is list:
428
+ none_type = param_type[1]
429
+ if none_type == "NoneType":
430
+ param_type = param_type[0]
408
431
  param_type = param_type if type(param_type) is str else f"{param_type}"
409
432
  field_class, placeholder_text = annotation_mapping.get(
410
433
  param_type,
@@ -417,13 +440,16 @@ def create_form_from_action(action: dict, script=None, design=True):
417
440
  setattr(DynamicForm, name, field)
418
441
 
419
442
  if design:
443
+ if "batch_action" in action:
444
+ batch_action = BooleanField(label='Batch Action', default=bool(action["batch_action"]))
445
+ setattr(DynamicForm, 'batch_action', batch_action)
420
446
  return_value = StringField(label='Save value as', default=f"{save_as}", render_kw={"placeholder": "Optional"})
421
447
  setattr(DynamicForm, 'return', return_value)
422
448
  return DynamicForm()
423
449
 
424
450
  def create_all_builtin_forms(script):
425
451
  all_builtin_forms = {}
426
- for logic_name in ['if', 'while', 'variable', 'wait', 'repeat']:
452
+ for logic_name in ['if', 'while', 'variable', 'wait', 'repeat', 'pause']:
427
453
  # signature = info.get('signature', {})
428
454
  form_class = create_builtin_form(logic_name, script)
429
455
  all_builtin_forms[logic_name] = form_class()
@@ -438,7 +464,8 @@ def create_builtin_form(logic_type, script):
438
464
 
439
465
  placeholder_text = {
440
466
  'wait': 'Enter second',
441
- 'repeat': 'Enter an integer'
467
+ 'repeat': 'Enter an integer',
468
+ 'pause': 'Human Intervention Message'
442
469
  }.get(logic_type, 'Enter statement')
443
470
  description_text = {
444
471
  'variable': 'Your variable can be numbers, boolean (True or False) or text ("text")',
@@ -528,9 +555,12 @@ def _action_button(action: dict, variables: dict):
528
555
  """
529
556
  style = {
530
557
  "repeat": "background-color: lightsteelblue",
531
- "if": "background-color: salmon",
532
- "while": "background-color: salmon",
558
+ "if": "background-color: mistyrose",
559
+ "while": "background-color: #a8b5a2",
560
+ "pause": "background-color: palegoldenrod",
533
561
  }.get(action['instrument'], "")
562
+ if not style:
563
+ style = "background-color: thistle" if 'batch_action' in action and action["batch_action"] else ""
534
564
 
535
565
  if action['instrument'] in ['if', 'while', 'repeat']:
536
566
  text = f"{action['action']} {action['args'].get('statement', '')}"
@@ -546,9 +576,13 @@ def _action_button(action: dict, variables: dict):
546
576
  arg_list = []
547
577
  for k, v in action['args'].items():
548
578
  if isinstance(v, dict):
549
- value = next(iter(v)) # Extract the first key if it's a dict
550
- # show warning color for variable calling when there is no definition
551
- style = "background-color: khaki" if value not in variables.keys() else ""
579
+ if not v:
580
+ value = v # Keep the original value if not a dict
581
+ else:
582
+ value = next(iter(v)) # Extract the first key if it's a dict
583
+ # show warning color for variable calling when there is no definition
584
+
585
+ style = "background-color: khaki" if v.get(value) == "function_output" and value not in variables.keys() else ""
552
586
  else:
553
587
  value = v # Keep the original value if not a dict
554
588
  arg_list.append(f"{k} = {value}") # Format the key-value pair
@@ -8,6 +8,7 @@ class GlobalConfig:
8
8
  if cls._instance is None:
9
9
  cls._instance = super(GlobalConfig, cls).__new__(cls, *args, **kwargs)
10
10
  cls._instance._deck = None
11
+ cls._instance._building_blocks = None
11
12
  cls._instance._registered_workflows = None
12
13
  cls._instance._agent = None
13
14
  cls._instance._defined_variables = {}
@@ -15,6 +16,9 @@ class GlobalConfig:
15
16
  cls._instance._deck_snapshot = {}
16
17
  cls._instance._runner_lock = threading.Lock()
17
18
  cls._instance._runner_status = None
19
+ cls._instance._optimizers = {}
20
+ cls._instance._notification_handlers = []
21
+
18
22
  return cls._instance
19
23
 
20
24
  @property
@@ -26,6 +30,24 @@ class GlobalConfig:
26
30
  if self._deck is None:
27
31
  self._deck = value
28
32
 
33
+ def register_notification(self, handler):
34
+ if not callable(handler):
35
+ raise ValueError("Handler must be callable")
36
+ self._notification_handlers.append(handler)
37
+
38
+ @property
39
+ def notification_handlers(self):
40
+ return self._notification_handlers
41
+
42
+ @property
43
+ def building_blocks(self):
44
+ return self._building_blocks
45
+
46
+ @building_blocks.setter
47
+ def building_blocks(self, value):
48
+ if self._building_blocks is None:
49
+ self._building_blocks = value
50
+
29
51
  @property
30
52
  def registered_workflows(self):
31
53
  return self._registered_workflows
@@ -84,4 +106,15 @@ class GlobalConfig:
84
106
 
85
107
  @runner_status.setter
86
108
  def runner_status(self, value):
87
- self._runner_status = value
109
+ self._runner_status = value
110
+
111
+ @property
112
+ def optimizers(self):
113
+ return self._optimizers
114
+
115
+ @optimizers.setter
116
+ def optimizers(self, value):
117
+ if isinstance(value, dict):
118
+ self._optimizers = value
119
+ else:
120
+ raise ValueError("Optimizers must be a dictionary.")
@@ -0,0 +1,314 @@
1
+ class ConditionalStructureError(Exception):
2
+ """Raised when conditional structure is invalid"""
3
+ pass
4
+
5
+
6
+ class ConditionalStructureError(Exception):
7
+ """Raised when control flow structure is invalid"""
8
+ pass
9
+
10
+
11
+ class ConditionalStructureError(Exception):
12
+ """Raised when control flow structure is invalid"""
13
+ pass
14
+
15
+
16
+ def validate_and_nest_control_flow(flat_steps):
17
+ """
18
+ Validates and converts flat control flow structures to nested blocks.
19
+
20
+ Handles:
21
+ - if/else/endif -> if_block/else_block
22
+ - repeat/endrepeat -> repeat_block
23
+ - while/endwhile -> while_block
24
+
25
+ Raises:
26
+ ConditionalStructureError: If the control flow structure is invalid
27
+ """
28
+ nested_steps = []
29
+ i = 0
30
+
31
+ while i < len(flat_steps):
32
+ step = flat_steps[i]
33
+ action = step["action"]
34
+
35
+ # Check for misplaced closing/middle statements
36
+ if action in ["else", "endif", "endrepeat", "endwhile"]:
37
+ raise ConditionalStructureError(
38
+ f"Found '{action}' at position {i} without matching opening statement. "
39
+ f"UUID: {step.get('uuid')}"
40
+ )
41
+
42
+ # Handle IF statements
43
+ if action == "if":
44
+ nested_step, steps_consumed = process_if_block(flat_steps, i)
45
+ nested_steps.append(nested_step)
46
+ i += steps_consumed
47
+
48
+ # Handle REPEAT statements
49
+ elif action == "repeat":
50
+ nested_step, steps_consumed = process_repeat_block(flat_steps, i)
51
+ nested_steps.append(nested_step)
52
+ i += steps_consumed
53
+
54
+ # Handle WHILE statements
55
+ elif action == "while":
56
+ nested_step, steps_consumed = process_while_block(flat_steps, i)
57
+ nested_steps.append(nested_step)
58
+ i += steps_consumed
59
+
60
+ else:
61
+ # Regular step - add as-is
62
+ nested_steps.append(step)
63
+ i += 1
64
+
65
+ return nested_steps
66
+
67
+
68
+ def process_if_block(flat_steps, start_index):
69
+ """
70
+ Process an if/else/endif block starting at start_index.
71
+
72
+ Returns:
73
+ tuple: (nested_step_with_blocks, total_steps_consumed)
74
+ """
75
+ step = flat_steps[start_index].copy()
76
+ if_uuid = step["uuid"]
77
+ if_block = []
78
+ else_block = []
79
+ current_block = if_block
80
+ found_else = False
81
+ found_endif = False
82
+ i = start_index + 1
83
+
84
+ while i < len(flat_steps):
85
+ current_step = flat_steps[i]
86
+ current_action = current_step["action"]
87
+
88
+ if current_action == "else":
89
+ if current_step["uuid"] == if_uuid:
90
+ if found_else:
91
+ raise ConditionalStructureError(
92
+ f"Multiple 'else' blocks found for if statement with UUID {if_uuid}. "
93
+ f"Second 'else' at position {i}"
94
+ )
95
+ current_block = else_block
96
+ found_else = True
97
+ i += 1
98
+ continue
99
+ else:
100
+ raise ConditionalStructureError(
101
+ f"Found 'else' with UUID {current_step['uuid']} at position {i}, "
102
+ f"but expecting endif for if with UUID {if_uuid}"
103
+ )
104
+
105
+ elif current_action == "endif":
106
+ if current_step["uuid"] == if_uuid:
107
+ found_endif = True
108
+ i += 1
109
+ break
110
+ else:
111
+ raise ConditionalStructureError(
112
+ f"Found 'endif' with UUID {current_step['uuid']} at position {i}, "
113
+ f"but expecting endif for if with UUID {if_uuid}"
114
+ )
115
+
116
+ elif current_action == "if":
117
+ # Nested if - process it
118
+ try:
119
+ nested_step, steps_consumed = process_if_block(flat_steps, i)
120
+ current_block.append(nested_step)
121
+ i += steps_consumed
122
+ except ConditionalStructureError as e:
123
+ raise ConditionalStructureError(
124
+ f"Error in nested if starting at position {i}: {str(e)}"
125
+ )
126
+
127
+ elif current_action == "repeat":
128
+ # Nested repeat - process it
129
+ try:
130
+ nested_step, steps_consumed = process_repeat_block(flat_steps, i)
131
+ current_block.append(nested_step)
132
+ i += steps_consumed
133
+ except ConditionalStructureError as e:
134
+ raise ConditionalStructureError(
135
+ f"Error in nested repeat starting at position {i}: {str(e)}"
136
+ )
137
+
138
+ elif current_action == "while":
139
+ # Nested while - process it
140
+ try:
141
+ nested_step, steps_consumed = process_while_block(flat_steps, i)
142
+ current_block.append(nested_step)
143
+ i += steps_consumed
144
+ except ConditionalStructureError as e:
145
+ raise ConditionalStructureError(
146
+ f"Error in nested while starting at position {i}: {str(e)}"
147
+ )
148
+
149
+ else:
150
+ # Regular step
151
+ current_block.append(current_step)
152
+ i += 1
153
+
154
+ if not found_endif:
155
+ raise ConditionalStructureError(
156
+ f"Missing 'endif' for if statement with UUID {if_uuid} starting at position {start_index}"
157
+ )
158
+
159
+ step["if_block"] = if_block
160
+ step["else_block"] = else_block
161
+
162
+ return step, i - start_index
163
+
164
+
165
+ def process_repeat_block(flat_steps, start_index):
166
+ """
167
+ Process a repeat/endrepeat block starting at start_index.
168
+
169
+ Returns:
170
+ tuple: (nested_step_with_block, total_steps_consumed)
171
+ """
172
+ step = flat_steps[start_index].copy()
173
+ repeat_uuid = step["uuid"]
174
+ repeat_block = []
175
+ found_endrepeat = False
176
+ i = start_index + 1
177
+
178
+ while i < len(flat_steps):
179
+ current_step = flat_steps[i]
180
+ current_action = current_step["action"]
181
+
182
+ if current_action == "endrepeat":
183
+ if current_step["uuid"] == repeat_uuid:
184
+ found_endrepeat = True
185
+ i += 1
186
+ break
187
+ else:
188
+ raise ConditionalStructureError(
189
+ f"Found 'endrepeat' with UUID {current_step['uuid']} at position {i}, "
190
+ f"but expecting endrepeat for repeat with UUID {repeat_uuid}"
191
+ )
192
+
193
+ elif current_action == "if":
194
+ # Nested if - process it
195
+ try:
196
+ nested_step, steps_consumed = process_if_block(flat_steps, i)
197
+ repeat_block.append(nested_step)
198
+ i += steps_consumed
199
+ except ConditionalStructureError as e:
200
+ raise ConditionalStructureError(
201
+ f"Error in nested if starting at position {i}: {str(e)}"
202
+ )
203
+
204
+ elif current_action == "repeat":
205
+ # Nested repeat - process it
206
+ try:
207
+ nested_step, steps_consumed = process_repeat_block(flat_steps, i)
208
+ repeat_block.append(nested_step)
209
+ i += steps_consumed
210
+ except ConditionalStructureError as e:
211
+ raise ConditionalStructureError(
212
+ f"Error in nested repeat starting at position {i}: {str(e)}"
213
+ )
214
+
215
+ elif current_action == "while":
216
+ # Nested while - process it
217
+ try:
218
+ nested_step, steps_consumed = process_while_block(flat_steps, i)
219
+ repeat_block.append(nested_step)
220
+ i += steps_consumed
221
+ except ConditionalStructureError as e:
222
+ raise ConditionalStructureError(
223
+ f"Error in nested while starting at position {i}: {str(e)}"
224
+ )
225
+
226
+ else:
227
+ # Regular step
228
+ repeat_block.append(current_step)
229
+ i += 1
230
+
231
+ if not found_endrepeat:
232
+ raise ConditionalStructureError(
233
+ f"Missing 'endrepeat' for repeat statement with UUID {repeat_uuid} starting at position {start_index}"
234
+ )
235
+
236
+ step["repeat_block"] = repeat_block
237
+
238
+ return step, i - start_index
239
+
240
+
241
+ def process_while_block(flat_steps, start_index):
242
+ """
243
+ Process a while/endwhile block starting at start_index.
244
+
245
+ Returns:
246
+ tuple: (nested_step_with_block, total_steps_consumed)
247
+ """
248
+ step = flat_steps[start_index].copy()
249
+ while_uuid = step["uuid"]
250
+ while_block = []
251
+ found_endwhile = False
252
+ i = start_index + 1
253
+
254
+ while i < len(flat_steps):
255
+ current_step = flat_steps[i]
256
+ current_action = current_step["action"]
257
+
258
+ if current_action == "endwhile":
259
+ if current_step["uuid"] == while_uuid:
260
+ found_endwhile = True
261
+ i += 1
262
+ break
263
+ else:
264
+ raise ConditionalStructureError(
265
+ f"Found 'endwhile' with UUID {current_step['uuid']} at position {i}, "
266
+ f"but expecting endwhile for while with UUID {while_uuid}"
267
+ )
268
+
269
+ elif current_action == "if":
270
+ # Nested if - process it
271
+ try:
272
+ nested_step, steps_consumed = process_if_block(flat_steps, i)
273
+ while_block.append(nested_step)
274
+ i += steps_consumed
275
+ except ConditionalStructureError as e:
276
+ raise ConditionalStructureError(
277
+ f"Error in nested if starting at position {i}: {str(e)}"
278
+ )
279
+
280
+ elif current_action == "repeat":
281
+ # Nested repeat - process it
282
+ try:
283
+ nested_step, steps_consumed = process_repeat_block(flat_steps, i)
284
+ while_block.append(nested_step)
285
+ i += steps_consumed
286
+ except ConditionalStructureError as e:
287
+ raise ConditionalStructureError(
288
+ f"Error in nested repeat starting at position {i}: {str(e)}"
289
+ )
290
+
291
+ elif current_action == "while":
292
+ # Nested while - process it
293
+ try:
294
+ nested_step, steps_consumed = process_while_block(flat_steps, i)
295
+ while_block.append(nested_step)
296
+ i += steps_consumed
297
+ except ConditionalStructureError as e:
298
+ raise ConditionalStructureError(
299
+ f"Error in nested while starting at position {i}: {str(e)}"
300
+ )
301
+
302
+ else:
303
+ # Regular step
304
+ while_block.append(current_step)
305
+ i += 1
306
+
307
+ if not found_endwhile:
308
+ raise ConditionalStructureError(
309
+ f"Missing 'endwhile' for while statement with UUID {while_uuid} starting at position {start_index}"
310
+ )
311
+
312
+ step["while_block"] = while_block
313
+
314
+ return step, i - start_index