ivoryos 1.3.9__py3-none-any.whl → 1.4.1__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.

Potentially problematic release.


This version of ivoryos might be problematic. Click here for more details.

Files changed (35) hide show
  1. ivoryos/optimizer/ax_optimizer.py +47 -25
  2. ivoryos/optimizer/base_optimizer.py +19 -1
  3. ivoryos/optimizer/baybe_optimizer.py +27 -17
  4. ivoryos/optimizer/nimo_optimizer.py +25 -16
  5. ivoryos/routes/data/data.py +27 -9
  6. ivoryos/routes/data/templates/components/step_card.html +47 -11
  7. ivoryos/routes/data/templates/workflow_view.html +14 -5
  8. ivoryos/routes/design/design.py +31 -1
  9. ivoryos/routes/design/design_step.py +2 -1
  10. ivoryos/routes/design/templates/components/edit_action_form.html +16 -3
  11. ivoryos/routes/design/templates/components/python_code_overlay.html +27 -10
  12. ivoryos/routes/design/templates/experiment_builder.html +1 -0
  13. ivoryos/routes/execute/execute.py +71 -13
  14. ivoryos/routes/execute/templates/components/logging_panel.html +50 -25
  15. ivoryos/routes/execute/templates/components/run_tabs.html +45 -2
  16. ivoryos/routes/execute/templates/components/tab_bayesian.html +447 -325
  17. ivoryos/routes/execute/templates/components/tab_configuration.html +303 -18
  18. ivoryos/routes/execute/templates/components/tab_repeat.html +6 -2
  19. ivoryos/routes/execute/templates/experiment_run.html +0 -264
  20. ivoryos/socket_handlers.py +1 -1
  21. ivoryos/static/js/action_handlers.js +252 -118
  22. ivoryos/static/js/sortable_design.js +1 -0
  23. ivoryos/utils/bo_campaign.py +17 -16
  24. ivoryos/utils/db_models.py +122 -18
  25. ivoryos/utils/decorators.py +1 -0
  26. ivoryos/utils/form.py +32 -12
  27. ivoryos/utils/nest_script.py +314 -0
  28. ivoryos/utils/script_runner.py +436 -143
  29. ivoryos/utils/utils.py +11 -1
  30. ivoryos/version.py +1 -1
  31. {ivoryos-1.3.9.dist-info → ivoryos-1.4.1.dist-info}/METADATA +6 -4
  32. {ivoryos-1.3.9.dist-info → ivoryos-1.4.1.dist-info}/RECORD +35 -34
  33. {ivoryos-1.3.9.dist-info → ivoryos-1.4.1.dist-info}/WHEEL +0 -0
  34. {ivoryos-1.3.9.dist-info → ivoryos-1.4.1.dist-info}/licenses/LICENSE +0 -0
  35. {ivoryos-1.3.9.dist-info → ivoryos-1.4.1.dist-info}/top_level.txt +0 -0
@@ -122,7 +122,7 @@ class Script(db.Model):
122
122
  pass
123
123
  raise TypeError(f"Input type error: cannot convert '{args}' to {arg_type}.")
124
124
 
125
- def update_by_uuid(self, uuid, args, output):
125
+ def update_by_uuid(self, uuid, args, output, batch_action=False):
126
126
  action = self.find_by_uuid(uuid)
127
127
  if not action:
128
128
  return
@@ -134,6 +134,7 @@ class Script(db.Model):
134
134
  pass
135
135
  action['args'] = args
136
136
  action['return'] = output
137
+ action['batch_action'] = batch_action
137
138
 
138
139
  @staticmethod
139
140
  def eval_list(args, arg_types):
@@ -402,7 +403,7 @@ class Script(db.Model):
402
403
  """
403
404
 
404
405
  return_list = set([action['return'] for action in self.script_dict['script'] if not action['return'] == ''])
405
- output_str = "return {"
406
+ output_str = "{"
406
407
  for i in return_list:
407
408
  output_str += "'" + i + "':" + i + ","
408
409
  output_str += "}"
@@ -451,7 +452,80 @@ class Script(db.Model):
451
452
  ]
452
453
  return line_collection
453
454
 
454
- def compile(self, script_path=None):
455
+ def render_script_lines(self, script_dict):
456
+ """
457
+ Convert the script_dict structure into a dict of displayable Python-like lines,
458
+ keeping ID consistency for highlighting.
459
+ """
460
+
461
+ def render_args(args):
462
+ if not args:
463
+ return ""
464
+ return ", ".join(f"{k}={v}" for k, v in args.items())
465
+
466
+ def parse_block(block):
467
+ lines = []
468
+ indent = 0
469
+ stack = []
470
+
471
+ for action in block:
472
+ act = action["action"]
473
+ _id = action["id"]
474
+
475
+ # Handle control structures
476
+ if act == "if":
477
+ stmt = action["args"].get("statement", "")
478
+ lines.append(" " * indent + f"if {stmt}: # id:{_id}")
479
+ indent += 1
480
+ stack.append("if")
481
+
482
+ elif act == "else":
483
+ indent -= 1
484
+ lines.append(" " * indent + f"else: # id:{_id}")
485
+ indent += 1
486
+
487
+ elif act in ("endif", "endwhile"):
488
+ if stack:
489
+ stack.pop()
490
+ indent = max(indent - 1, 0)
491
+ lines.append(" " * indent + f"# {act} # id:{_id}")
492
+
493
+ elif act == "while":
494
+ stmt = action["args"].get("statement", "")
495
+ lines.append(" " * indent + f"while {stmt}: # id:{_id}")
496
+ indent += 1
497
+ stack.append("while")
498
+
499
+ else:
500
+ # Regular function call
501
+ instr = action["instrument"]
502
+ args = render_args(action.get("args", {}))
503
+ ret = action.get("return")
504
+ line = " " * indent
505
+ if ret:
506
+ line += f"{ret} = {instr}.{act}({args}) # id:{_id}"
507
+ else:
508
+ line += f"{instr}.{act}({args}) # id:{_id}"
509
+ lines.append(line)
510
+
511
+ # Ensure empty control blocks get "pass"
512
+ final_lines = []
513
+ for i, line in enumerate(lines):
514
+ final_lines.append(line)
515
+ # if line.strip().startswith("else") and (
516
+ # i == len(lines) - 1 or lines[i + 1].startswith("#") or "endif" in lines[i + 1]
517
+ # ):
518
+ # final_lines.append(" " * (indent) + "pass")
519
+
520
+ return final_lines
521
+
522
+ return {
523
+ "prep": parse_block(script_dict.get("prep", [])),
524
+ "script": parse_block(script_dict.get("script", [])),
525
+ "cleanup": parse_block(script_dict.get("cleanup", []))
526
+ }
527
+
528
+ def compile(self, script_path=None, batch=False, mode="sample"):
455
529
  """
456
530
  Compile the current script to a Python file.
457
531
  :return: String to write to a Python file.
@@ -467,7 +541,7 @@ class Script(db.Model):
467
541
  for i in self.stypes:
468
542
  if self.script_dict[i]:
469
543
  is_async = any(a.get("coroutine", False) for a in self.script_dict[i])
470
- func_str = self._generate_function_header(run_name, i, is_async) + self._generate_function_body(i)
544
+ func_str = self._generate_function_header(run_name, i, is_async, batch) + self._generate_function_body(i, batch, mode)
471
545
  exec_str_collection[i] = func_str
472
546
  if script_path:
473
547
  self._write_to_file(script_path, run_name, exec_str_collection)
@@ -485,7 +559,7 @@ class Script(db.Model):
485
559
  name += '_'
486
560
  return name
487
561
 
488
- def _generate_function_header(self, run_name, stype, is_async):
562
+ def _generate_function_header(self, run_name, stype, is_async, batch=False):
489
563
  """
490
564
  Generate the function header.
491
565
  """
@@ -499,37 +573,54 @@ class Script(db.Model):
499
573
  function_header = f"{async_str}def {run_name}{script_type}("
500
574
 
501
575
  if stype == "script":
502
- function_header += ", ".join(configure)
503
-
576
+ if batch:
577
+ function_header += "param_list" if configure else "n: int"
578
+ else:
579
+ function_header += ", ".join(configure)
504
580
  function_header += "):"
505
581
  # function_header += self.indent(1) + f"global {run_name}_{stype}"
506
582
  return function_header
507
583
 
508
- def _generate_function_body(self, stype):
584
+ def _generate_function_body(self, stype, batch=False, mode="sample"):
509
585
  """
510
586
  Generate the function body for each type in stypes.
511
587
  """
512
588
  body = ''
513
589
  indent_unit = 1
590
+ if batch and stype == "script":
591
+ return_str, return_list = self.config_return()
592
+ if return_list and stype == "script":
593
+ body += self.indent(indent_unit) + "result_list = []"
594
+ for index, action in enumerate(self.script_dict[stype]):
595
+ text, indent_unit = self._process_action(indent_unit, action, index, stype, batch)
596
+ body += text
597
+ if return_list and stype == "script":
598
+ body += self.indent(indent_unit) + f"result_list.append({return_str})"
599
+ body += self.indent(indent_unit) + "return result_list"
514
600
 
515
- for index, action in enumerate(self.script_dict[stype]):
516
- text, indent_unit = self._process_action(indent_unit, action, index, stype)
517
- body += text
518
- return_str, return_list = self.config_return()
519
- if return_list and stype == "script":
520
- body += self.indent(indent_unit) + return_str
601
+ else:
602
+ for index, action in enumerate(self.script_dict[stype]):
603
+ text, indent_unit = self._process_action(indent_unit, action, index, stype)
604
+ body += text
605
+ return_str, return_list = self.config_return()
606
+ if return_list and stype == "script":
607
+ body += self.indent(indent_unit) + f"return {return_str}"
521
608
  return body
522
609
 
523
- def _process_action(self, indent_unit, action, index, stype):
610
+ def _process_action(self, indent_unit, action, index, stype, batch=False, mode="sample"):
524
611
  """
525
612
  Process each action within the script dictionary.
526
613
  """
614
+ configure, config_type = self.config(stype)
615
+
527
616
  instrument = action['instrument']
528
617
  statement = action['args'].get('statement')
529
618
  args = self._process_args(action['args'])
530
619
 
531
620
  save_data = action['return']
532
621
  action_name = action['action']
622
+ batch_action = action.get("batch_action", False)
623
+
533
624
  next_action = self._get_next_action(stype, index)
534
625
  # print(args)
535
626
  if instrument == 'if':
@@ -550,7 +641,8 @@ class Script(db.Model):
550
641
  # return inspect.getsource(my_function)
551
642
  else:
552
643
  is_async = action.get("coroutine", False)
553
- return self._process_instrument_action(indent_unit, instrument, action_name, args, save_data, is_async)
644
+ dynamic_arg = len(configure) > 0
645
+ return self._process_instrument_action(indent_unit, instrument, action_name, args, save_data, is_async, dynamic_arg, batch, batch_action)
554
646
 
555
647
  def _process_args(self, args):
556
648
  """
@@ -610,7 +702,8 @@ class Script(db.Model):
610
702
  indent_unit -= 1
611
703
  return exec_string, indent_unit
612
704
 
613
- def _process_instrument_action(self, indent_unit, instrument, action, args, save_data, is_async=False):
705
+ def _process_instrument_action(self, indent_unit, instrument, action, args, save_data, is_async=False, dynamic_arg=False,
706
+ batch=False, batch_action=False):
614
707
  """
615
708
  Process actions related to instruments.
616
709
  """
@@ -632,7 +725,18 @@ class Script(db.Model):
632
725
  if save_data:
633
726
  save_data += " = "
634
727
 
635
- return self.indent(indent_unit) + save_data + single_line, indent_unit
728
+ if batch and not batch_action:
729
+ arg_list = [args[arg][1:] for arg in args if isinstance(args[arg], str) and args[arg].startswith("#")]
730
+ param_str = [f"param['{arg_list}']" for arg_list in arg_list if arg_list]
731
+ args_str = self.indent(indent_unit + 1) + ", ".join(arg_list) + " = " + ", ".join(param_str) if arg_list else ""
732
+ if dynamic_arg:
733
+ for_string = self.indent(indent_unit) + "for param in param_list:" + args_str
734
+ else:
735
+ for_string = self.indent(indent_unit) + "for _ in range(n):"
736
+ output_code = for_string + self.indent(indent_unit + 1) + save_data + single_line
737
+ else:
738
+ output_code = self.indent(indent_unit) + save_data + single_line
739
+ return output_code, indent_unit
636
740
 
637
741
  def _process_dict_args(self, args):
638
742
  """
@@ -16,6 +16,7 @@ def block(_func=None, *, category="general"):
16
16
  "func": func,
17
17
  "signature": inspect.signature(func),
18
18
  "docstring": inspect.getdoc(func),
19
+ "coroutine": inspect.iscoroutinefunction(func),
19
20
  "path": module
20
21
  }
21
22
  return func
ivoryos/utils/form.py CHANGED
@@ -332,7 +332,9 @@ def create_add_form(attr, attr_name, autofill: bool, script=None, design: bool =
332
332
  dynamic_form = create_form_for_method(signature, autofill, script, design)
333
333
  if design:
334
334
  return_value = StringField(label='Save value as', render_kw={"placeholder": "Optional"})
335
+ batch_action = BooleanField(label='Batch Action', render_kw={"placeholder": "Optional"})
335
336
  setattr(dynamic_form, 'return', return_value)
337
+ setattr(dynamic_form, 'batch_action', batch_action)
336
338
  hidden_method_name = HiddenField(name=f'hidden_name', description=docstring, render_kw={"value": f'{attr_name}'})
337
339
  setattr(dynamic_form, 'hidden_name', hidden_method_name)
338
340
  return dynamic_form
@@ -348,13 +350,16 @@ def create_form_from_module(sdl_module, autofill: bool = False, script=None, des
348
350
  """
349
351
  method_forms = {}
350
352
  for attr_name in dir(sdl_module):
351
- method = getattr(sdl_module, attr_name)
352
- if inspect.ismethod(method) and not attr_name.startswith('_'):
353
- signature = inspect.signature(method)
354
- docstring = inspect.getdoc(method)
355
- attr = dict(signature=signature, docstring=docstring)
356
- form_class = create_add_form(attr, attr_name, autofill, script, design)
357
- 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}")
358
363
  return method_forms
359
364
 
360
365
 
@@ -407,12 +412,22 @@ def create_form_from_action(action: dict, script=None, design=True):
407
412
  value = args.get(name, "")
408
413
  if type(value) is dict and value:
409
414
  value = next(iter(value))
415
+ if not value or value == "None":
416
+ value = None
417
+ else:
418
+ value = f'{value}'
419
+
410
420
  field_kwargs = {
411
421
  "label": name,
412
- "default": f'{value}',
413
- "validators": [InputRequired()],
422
+ "default": f'{value}' if value else None,
423
+ # todo get optional/required from snapshot
424
+ "validators": [Optional()],
414
425
  **({"script": script})
415
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]
416
431
  param_type = param_type if type(param_type) is str else f"{param_type}"
417
432
  field_class, placeholder_text = annotation_mapping.get(
418
433
  param_type,
@@ -425,6 +440,9 @@ def create_form_from_action(action: dict, script=None, design=True):
425
440
  setattr(DynamicForm, name, field)
426
441
 
427
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)
428
446
  return_value = StringField(label='Save value as', default=f"{save_as}", render_kw={"placeholder": "Optional"})
429
447
  setattr(DynamicForm, 'return', return_value)
430
448
  return DynamicForm()
@@ -537,10 +555,12 @@ def _action_button(action: dict, variables: dict):
537
555
  """
538
556
  style = {
539
557
  "repeat": "background-color: lightsteelblue",
540
- "if": "background-color: salmon",
541
- "while": "background-color: salmon",
542
- "pause": "background-color: goldenrod",
558
+ "if": "background-color: mistyrose",
559
+ "while": "background-color: #a8b5a2",
560
+ "pause": "background-color: palegoldenrod",
543
561
  }.get(action['instrument'], "")
562
+ if not style:
563
+ style = "background-color: thistle" if 'batch_action' in action and action["batch_action"] else ""
544
564
 
545
565
  if action['instrument'] in ['if', 'while', 'repeat']:
546
566
  text = f"{action['action']} {action['args'].get('statement', '')}"
@@ -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