ivoryos 1.2.5__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 (75) hide show
  1. docs/source/conf.py +84 -0
  2. ivoryos/__init__.py +16 -246
  3. ivoryos/app.py +154 -0
  4. ivoryos/optimizer/ax_optimizer.py +55 -28
  5. ivoryos/optimizer/base_optimizer.py +20 -1
  6. ivoryos/optimizer/baybe_optimizer.py +27 -17
  7. ivoryos/optimizer/nimo_optimizer.py +173 -0
  8. ivoryos/optimizer/registry.py +3 -1
  9. ivoryos/routes/auth/auth.py +35 -8
  10. ivoryos/routes/auth/templates/change_password.html +32 -0
  11. ivoryos/routes/control/control.py +58 -28
  12. ivoryos/routes/control/control_file.py +12 -15
  13. ivoryos/routes/control/control_new_device.py +21 -11
  14. ivoryos/routes/control/templates/controllers.html +27 -0
  15. ivoryos/routes/control/utils.py +2 -0
  16. ivoryos/routes/data/data.py +110 -44
  17. ivoryos/routes/data/templates/components/step_card.html +78 -13
  18. ivoryos/routes/data/templates/workflow_view.html +343 -113
  19. ivoryos/routes/design/design.py +59 -10
  20. ivoryos/routes/design/design_file.py +3 -3
  21. ivoryos/routes/design/design_step.py +43 -17
  22. ivoryos/routes/design/templates/components/action_form.html +2 -2
  23. ivoryos/routes/design/templates/components/canvas_main.html +6 -1
  24. ivoryos/routes/design/templates/components/edit_action_form.html +18 -3
  25. ivoryos/routes/design/templates/components/info_modal.html +318 -0
  26. ivoryos/routes/design/templates/components/instruments_panel.html +23 -1
  27. ivoryos/routes/design/templates/components/python_code_overlay.html +27 -10
  28. ivoryos/routes/design/templates/experiment_builder.html +3 -0
  29. ivoryos/routes/execute/execute.py +82 -22
  30. ivoryos/routes/execute/templates/components/logging_panel.html +50 -25
  31. ivoryos/routes/execute/templates/components/run_tabs.html +45 -2
  32. ivoryos/routes/execute/templates/components/tab_bayesian.html +447 -325
  33. ivoryos/routes/execute/templates/components/tab_configuration.html +303 -18
  34. ivoryos/routes/execute/templates/components/tab_repeat.html +6 -2
  35. ivoryos/routes/execute/templates/experiment_run.html +0 -264
  36. ivoryos/routes/library/library.py +9 -11
  37. ivoryos/routes/main/main.py +30 -2
  38. ivoryos/server.py +180 -0
  39. ivoryos/socket_handlers.py +1 -1
  40. ivoryos/static/ivoryos_logo.png +0 -0
  41. ivoryos/static/js/action_handlers.js +259 -88
  42. ivoryos/static/js/socket_handler.js +40 -5
  43. ivoryos/static/js/sortable_design.js +29 -11
  44. ivoryos/templates/base.html +61 -2
  45. ivoryos/utils/bo_campaign.py +18 -17
  46. ivoryos/utils/client_proxy.py +267 -36
  47. ivoryos/utils/db_models.py +286 -60
  48. ivoryos/utils/decorators.py +34 -0
  49. ivoryos/utils/form.py +52 -19
  50. ivoryos/utils/global_config.py +21 -0
  51. ivoryos/utils/nest_script.py +314 -0
  52. ivoryos/utils/py_to_json.py +80 -10
  53. ivoryos/utils/script_runner.py +573 -189
  54. ivoryos/utils/task_runner.py +69 -22
  55. ivoryos/utils/utils.py +48 -5
  56. ivoryos/version.py +1 -1
  57. {ivoryos-1.2.5.dist-info → ivoryos-1.4.4.dist-info}/METADATA +109 -47
  58. ivoryos-1.4.4.dist-info/RECORD +119 -0
  59. ivoryos-1.4.4.dist-info/top_level.txt +3 -0
  60. tests/__init__.py +0 -0
  61. tests/conftest.py +133 -0
  62. tests/integration/__init__.py +0 -0
  63. tests/integration/test_route_auth.py +80 -0
  64. tests/integration/test_route_control.py +94 -0
  65. tests/integration/test_route_database.py +61 -0
  66. tests/integration/test_route_design.py +36 -0
  67. tests/integration/test_route_main.py +35 -0
  68. tests/integration/test_sockets.py +26 -0
  69. tests/unit/test_type_conversion.py +42 -0
  70. tests/unit/test_util.py +3 -0
  71. ivoryos/routes/api/api.py +0 -56
  72. ivoryos-1.2.5.dist-info/RECORD +0 -100
  73. ivoryos-1.2.5.dist-info/top_level.txt +0 -1
  74. {ivoryos-1.2.5.dist-info → ivoryos-1.4.4.dist-info}/WHEEL +0 -0
  75. {ivoryos-1.2.5.dist-info → ivoryos-1.4.4.dist-info}/licenses/LICENSE +0 -0
@@ -27,6 +27,9 @@ class User(db.Model, UserMixin):
27
27
  # email = db.Column(db.String)
28
28
  hashPassword = db.Column(db.String(255))
29
29
 
30
+ # New columns for logo customization
31
+ settings = db.Column(JSONType, nullable=True)
32
+
30
33
  # password = db.Column()
31
34
  def __init__(self, username, password):
32
35
  # self.id = id
@@ -122,7 +125,7 @@ class Script(db.Model):
122
125
  pass
123
126
  raise TypeError(f"Input type error: cannot convert '{args}' to {arg_type}.")
124
127
 
125
- def update_by_uuid(self, uuid, args, output):
128
+ def update_by_uuid(self, uuid, args, output, batch_action=False):
126
129
  action = self.find_by_uuid(uuid)
127
130
  if not action:
128
131
  return
@@ -134,6 +137,7 @@ class Script(db.Model):
134
137
  pass
135
138
  action['args'] = args
136
139
  action['return'] = output
140
+ action['batch_action'] = batch_action
137
141
 
138
142
  @staticmethod
139
143
  def eval_list(args, arg_types):
@@ -268,7 +272,7 @@ class Script(db.Model):
268
272
  if isinstance(value, str):
269
273
  if value in output_variables:
270
274
  var_type = output_variables[value]
271
- kwargs[key] = {value: var_type}
275
+ kwargs[key] = f"#{value}"
272
276
  elif value.startswith("#"):
273
277
  kwargs[key] = f"#{self.validate_function_name(value[1:])}"
274
278
  else:
@@ -318,6 +322,12 @@ class Script(db.Model):
318
322
  {"id": current_len + 2, "instrument": 'repeat', "action": 'endrepeat',
319
323
  "args": {}, "return": '', "uuid": uid},
320
324
  ],
325
+ "pause":
326
+ [
327
+ {"id": current_len + 1, "instrument": 'pause', "action": "pause",
328
+ "args": {"statement": 1 if statement == '' else statement}, "return": '', "uuid": uid,
329
+ "arg_types": {"statement": "str"}}
330
+ ],
321
331
  }
322
332
  action_list = logic_dict[logic_type]
323
333
  self.currently_editing_script.extend(action_list)
@@ -364,28 +374,32 @@ class Script(db.Model):
364
374
  :return: list of variable that require input
365
375
  """
366
376
  configure = []
377
+ variables = self.get_variables()
378
+ # print(variables)
367
379
  config_type_dict = {}
368
380
  for action in self.script_dict[stype]:
369
381
  args = action['args']
370
382
  if args is not None:
371
383
  if type(args) is not dict:
372
- if type(args) is str and args.startswith("#") and not args[1:] in configure:
373
- configure.append(args[1:])
374
- config_type_dict[args[1:]] = action['arg_types']
384
+ if type(args) is str and args.startswith("#"):
385
+ key = args[1:]
386
+ if key not in (*variables, *configure):
387
+ configure.append(key)
388
+ config_type_dict[key] = action['arg_types']
375
389
 
376
390
  else:
377
391
  for arg in args:
378
- if type(args[arg]) is str \
379
- and args[arg].startswith("#") \
380
- and not args[arg][1:] in configure:
381
- configure.append(args[arg][1:])
382
- if arg in action['arg_types']:
383
- if action['arg_types'][arg] == '':
384
- config_type_dict[args[arg][1:]] = "any"
392
+ if type(args[arg]) is str and args[arg].startswith("#"):
393
+ key = args[arg][1:]
394
+ if key not in (*variables, *configure):
395
+ configure.append(key)
396
+ if arg in action['arg_types']:
397
+ if action['arg_types'][arg] == '':
398
+ config_type_dict[key] = "any"
399
+ else:
400
+ config_type_dict[key] = action['arg_types'][arg]
385
401
  else:
386
- config_type_dict[args[arg][1:]] = action['arg_types'][arg]
387
- else:
388
- config_type_dict[args[arg][1:]] = "any"
402
+ config_type_dict[key] = "any"
389
403
  # todo
390
404
  return configure, config_type_dict
391
405
 
@@ -396,7 +410,7 @@ class Script(db.Model):
396
410
  """
397
411
 
398
412
  return_list = set([action['return'] for action in self.script_dict['script'] if not action['return'] == ''])
399
- output_str = "return {"
413
+ output_str = "{"
400
414
  for i in return_list:
401
415
  output_str += "'" + i + "':" + i + ","
402
416
  output_str += "}"
@@ -428,21 +442,114 @@ class Script(db.Model):
428
442
  :return: A dict containing script types as keys and lists of function body lines as values.
429
443
  """
430
444
  line_collection = {}
445
+
431
446
  for stype, func_str in exec_str_collection.items():
432
447
  if func_str:
433
448
  module = ast.parse(func_str)
434
- func_def = next(node for node in module.body if isinstance(node, ast.FunctionDef))
435
449
 
436
- # Extract function body as source lines
437
- line_collection[stype] = [ast_unparse(node) for node in func_def.body if not isinstance(node, ast.Return)]
438
- # print(line_collection[stype])
450
+ # Find the first function (regular or async)
451
+ func_def = next(
452
+ node for node in module.body
453
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
454
+ )
455
+
456
+ # Extract function body as source lines, skipping 'return' nodes
457
+ line_collection[stype] = [
458
+ ast_unparse(node) for node in func_def.body if not isinstance(node, ast.Return)
459
+ ]
439
460
  return line_collection
440
461
 
441
- def compile(self, script_path=None):
462
+ def render_script_lines(self, script_dict):
463
+ """
464
+ Convert the script_dict structure into a dict of displayable Python-like lines,
465
+ keeping ID consistency for highlighting.
466
+ """
467
+
468
+ def render_args(args):
469
+ if not args:
470
+ return ""
471
+ return ", ".join(f"{k}={v}" for k, v in args.items())
472
+
473
+ def parse_block(block):
474
+ lines = []
475
+ indent = 0
476
+ stack = []
477
+
478
+ for action in block:
479
+ act = action["action"]
480
+ _id = action["id"]
481
+ instrument = action["instrument"]
482
+
483
+ # Handle control structures
484
+ if act == "if":
485
+ stmt = action["args"].get("statement", "")
486
+ lines.append(" " * indent + f"if {stmt}:")
487
+ indent += 1
488
+ stack.append("if")
489
+
490
+ elif act == "else":
491
+ indent -= 1
492
+ lines.append(" " * indent + f"else:")
493
+ indent += 1
494
+
495
+ elif act in ("endif", "endwhile"):
496
+ if stack:
497
+ stack.pop()
498
+ indent = max(indent - 1, 0)
499
+ lines.append(" " * indent + f"# {act}")
500
+
501
+ elif act == "while":
502
+ stmt = action["args"].get("statement", "")
503
+ lines.append(" " * indent + f"while {stmt}:")
504
+ indent += 1
505
+ stack.append("while")
506
+
507
+ elif act == "wait":
508
+ stmt = action["args"].get("statement", "")
509
+ lines.append(" " * indent + f"time.sleep({stmt})")
510
+
511
+ elif instrument == "variable":
512
+ stmt = action["args"].get("statement", "")
513
+ lines.append(" " * indent + f"{act} = {stmt}")
514
+
515
+
516
+ else:
517
+ # Regular function call
518
+ instr = action["instrument"]
519
+ args = render_args(action.get("args", {}))
520
+ ret = action.get("return")
521
+ line = " " * indent
522
+ if ret:
523
+ line += f"{ret} = {instr}.{act}({args})"
524
+ else:
525
+ line += f"{instr}.{act}({args})"
526
+ lines.append(line)
527
+
528
+ # Ensure empty control blocks get "pass"
529
+ final_lines = []
530
+ for i, line in enumerate(lines):
531
+ final_lines.append(line)
532
+ # if line.strip().startswith("else") and (
533
+ # i == len(lines) - 1 or lines[i + 1].startswith("#") or "endif" in lines[i + 1]
534
+ # ):
535
+ # final_lines.append(" " * (indent) + "pass")
536
+
537
+ return final_lines
538
+
539
+ return {
540
+ "prep": parse_block(script_dict.get("prep", [])),
541
+ "script": parse_block(script_dict.get("script", [])),
542
+ "cleanup": parse_block(script_dict.get("cleanup", []))
543
+ }
544
+
545
+ def compile(self, script_path=None, batch=False, mode="sample"):
442
546
  """
443
547
  Compile the current script to a Python file.
444
548
  :return: String to write to a Python file.
445
549
  """
550
+ self.needs_call_human = False
551
+ self.blocks_included = False
552
+
446
553
  self.sort_actions()
447
554
  run_name = self.name if self.name else "untitled"
448
555
  run_name = self.validate_function_name(run_name)
@@ -450,7 +557,8 @@ class Script(db.Model):
450
557
 
451
558
  for i in self.stypes:
452
559
  if self.script_dict[i]:
453
- func_str = self._generate_function_header(run_name, i) + self._generate_function_body(i)
560
+ is_async = any(a.get("coroutine", False) for a in self.script_dict[i])
561
+ func_str = self._generate_function_header(run_name, i, is_async, batch) + self._generate_function_body(i, batch, mode)
454
562
  exec_str_collection[i] = func_str
455
563
  if script_path:
456
564
  self._write_to_file(script_path, run_name, exec_str_collection)
@@ -468,50 +576,70 @@ class Script(db.Model):
468
576
  name += '_'
469
577
  return name
470
578
 
471
- def _generate_function_header(self, run_name, stype):
579
+ def _generate_function_header(self, run_name, stype, is_async, batch=False):
472
580
  """
473
581
  Generate the function header.
474
582
  """
475
583
  configure, config_type = self.config(stype)
476
-
477
584
  configure = [param + f":{param_type}" if not param_type == "any" else param for param, param_type in
478
585
  config_type.items()]
479
586
 
480
587
  script_type = f"_{stype}" if stype != "script" else ""
481
- function_header = f"def {run_name}{script_type}("
588
+ async_str = "async " if is_async else ""
589
+ function_header = f"{async_str}def {run_name}{script_type}("
482
590
 
483
591
  if stype == "script":
484
- function_header += ", ".join(configure)
485
-
592
+ if batch:
593
+ function_header += "param_list" if configure else "n: int"
594
+ else:
595
+ function_header += ", ".join(configure)
486
596
  function_header += "):"
487
- # function_header += self.indent(1) + f"global {run_name}_{stype}"
597
+
598
+ if stype == "script" and batch:
599
+ function_header += self.indent(1) + f'"""Batch mode is experimental and may have bugs."""'
488
600
  return function_header
489
601
 
490
- def _generate_function_body(self, stype):
602
+ def _generate_function_body(self, stype, batch=False, mode="sample"):
491
603
  """
492
604
  Generate the function body for each type in stypes.
493
605
  """
494
606
  body = ''
495
607
  indent_unit = 1
608
+ if batch and stype == "script":
609
+ return_str, return_list = self.config_return()
610
+ configure, config_type = self.config(stype)
611
+ if not configure:
612
+ body += self.indent(indent_unit) + "param_list = [{} for _ in range(n)]"
613
+ for index, action in enumerate(self.script_dict[stype]):
614
+ text, indent_unit = self._process_action(indent_unit, action, index, stype, batch)
615
+ body += text
616
+ if return_list:
617
+ # body += self.indent(indent_unit) + f"result_list.append({return_str})"
618
+ body += self.indent(indent_unit) + "return param_list"
496
619
 
497
- for index, action in enumerate(self.script_dict[stype]):
498
- text, indent_unit = self._process_action(indent_unit, action, index, stype)
499
- body += text
500
- return_str, return_list = self.config_return()
501
- if return_list and stype == "script":
502
- body += self.indent(indent_unit) + return_str
620
+ else:
621
+ for index, action in enumerate(self.script_dict[stype]):
622
+ text, indent_unit = self._process_action(indent_unit, action, index, stype)
623
+ body += text
624
+ return_str, return_list = self.config_return()
625
+ if return_list and stype == "script":
626
+ body += self.indent(indent_unit) + f"return {return_str}"
503
627
  return body
504
628
 
505
- def _process_action(self, indent_unit, action, index, stype):
629
+ def _process_action(self, indent_unit, action, index, stype, batch=False, mode="sample"):
506
630
  """
507
631
  Process each action within the script dictionary.
508
632
  """
633
+ configure, config_type = self.config(stype)
634
+
509
635
  instrument = action['instrument']
510
636
  statement = action['args'].get('statement')
511
637
  args = self._process_args(action['args'])
512
638
 
513
639
  save_data = action['return']
514
640
  action_name = action['action']
641
+ batch_action = action.get("batch_action", False)
642
+
515
643
  next_action = self._get_next_action(stype, index)
516
644
  # print(args)
517
645
  if instrument == 'if':
@@ -519,16 +647,29 @@ class Script(db.Model):
519
647
  elif instrument == 'while':
520
648
  return self._process_while(indent_unit, action_name, statement, next_action)
521
649
  elif instrument == 'variable':
650
+ if batch:
651
+ return self.indent(indent_unit) + "for param in param_list:" + self.indent(indent_unit + 1) + f"param['{action_name}'] = {statement}", indent_unit
652
+
522
653
  return self.indent(indent_unit) + f"{action_name} = {statement}", indent_unit
523
654
  elif instrument == 'wait':
655
+ if batch:
656
+ return f"{self.indent(indent_unit)}for param in param_list:" + f"{self.indent(indent_unit+1)}time.sleep({statement})", indent_unit
524
657
  return f"{self.indent(indent_unit)}time.sleep({statement})", indent_unit
525
658
  elif instrument == 'repeat':
659
+
526
660
  return self._process_repeat(indent_unit, action_name, statement, next_action)
527
- #todo
661
+ elif instrument == 'pause':
662
+ self.needs_call_human = True
663
+ if batch:
664
+ return f"{self.indent(indent_unit)}for param in param_list:" + f"{self.indent(indent_unit+1)}pause('{statement}')", indent_unit
665
+ return f"{self.indent(indent_unit)}pause('{statement}')", indent_unit
666
+ # todo
528
667
  # elif instrument == 'registered_workflows':
529
668
  # return inspect.getsource(my_function)
530
669
  else:
531
- return self._process_instrument_action(indent_unit, instrument, action_name, args, save_data)
670
+ is_async = action.get("coroutine", False)
671
+ dynamic_arg = len(self.get_variables()) > 0
672
+ return self._process_instrument_action(indent_unit, instrument, action_name, args, save_data, is_async, dynamic_arg, batch, batch_action)
532
673
 
533
674
  def _process_args(self, args):
534
675
  """
@@ -588,23 +729,44 @@ class Script(db.Model):
588
729
  indent_unit -= 1
589
730
  return exec_string, indent_unit
590
731
 
591
- def _process_instrument_action(self, indent_unit, instrument, action, args, save_data):
732
+ def _process_instrument_action(self, indent_unit, instrument, action, args, save_data, is_async=False, dynamic_arg=False,
733
+ batch=False, batch_action=False):
592
734
  """
593
735
  Process actions related to instruments.
594
736
  """
737
+ async_str = "await " if is_async else ""
738
+
739
+ function_call = f"{instrument}.{action}"
740
+ if instrument.startswith("blocks"):
741
+ self.blocks_included = True
742
+ function_call = action
595
743
 
596
- if isinstance(args, dict):
744
+ if isinstance(args, dict) and args != {}:
597
745
  args_str = self._process_dict_args(args)
598
- single_line = f"{instrument}.{action}(**{args_str})"
746
+ single_line = f"{async_str}{function_call}(**{args_str})"
599
747
  elif isinstance(args, str):
600
- single_line = f"{instrument}.{action} = {args}"
748
+ single_line = f"{function_call} = {args}"
601
749
  else:
602
- single_line = f"{instrument}.{action}()"
750
+ single_line = f"{async_str}{function_call}()"
603
751
 
604
- if save_data:
605
- save_data += " = "
606
752
 
607
- return self.indent(indent_unit) + save_data + single_line, indent_unit
753
+ save_data_str = save_data + " = " if save_data else ''
754
+
755
+ if batch and not batch_action:
756
+ arg_list = [args[arg][1:] for arg in args if isinstance(args[arg], str) and args[arg].startswith("#")]
757
+ param_str = [f"param['{arg_list}']" for arg_list in arg_list if arg_list]
758
+ args_str = self.indent(indent_unit + 1) + ", ".join(arg_list) + " = " + ", ".join(param_str) if arg_list else ""
759
+ if dynamic_arg:
760
+ for_string = self.indent(indent_unit) + "for param in param_list:" + args_str
761
+ else:
762
+ for_string = self.indent(indent_unit) + "for i in range(n):"
763
+ output_code = for_string + self.indent(indent_unit + 1) + save_data_str + single_line
764
+ if save_data:
765
+ output_code = output_code + self.indent(indent_unit + 1) + f"param['{save_data}'] = {save_data}"
766
+ else:
767
+ output_code = self.indent(indent_unit) + save_data_str + single_line
768
+
769
+ return output_code, indent_unit
608
770
 
609
771
  def _process_dict_args(self, args):
610
772
  """
@@ -616,12 +778,21 @@ class Script(db.Model):
616
778
  args_str = args_str.replace(f"'#{args[arg][1:]}'", args[arg][1:])
617
779
  elif isinstance(args[arg], dict):
618
780
  # print(args[arg])
619
- variables = self.get_variables()
781
+ if not args[arg]:
782
+ continue
783
+ # Extract the variable name (first key in the dict)
620
784
  value = next(iter(args[arg]))
621
- if value not in variables:
622
- raise ValueError(f"Variable ({value}) is not defined.")
623
- args_str = args_str.replace(f"{args[arg]}", next(iter(args[arg])))
624
- # elif self._is_variable(arg):
785
+ var_type = args[arg].get(value)
786
+
787
+ # Only process if it's a function_output variable reference
788
+ if var_type == "function_output":
789
+ variables = self.get_variables()
790
+ if value not in variables:
791
+ raise ValueError(f"Variable ({value}) is not defined.")
792
+ # Replace the dict string representation with just the variable name
793
+ args_str = args_str.replace(f"{args[arg]}", value)
794
+
795
+ # elif self._is_variable(arg):
625
796
  # print("is variable")
626
797
  # args_str = args_str.replace(f"'{args[arg]}'", args[arg])
627
798
  return args_str
@@ -640,7 +811,7 @@ class Script(db.Model):
640
811
  """
641
812
  return arg in self.script_dict and self.script_dict[arg].get("arg_types") == "variable"
642
813
 
643
- def _write_to_file(self, script_path, run_name, exec_string):
814
+ def _write_to_file(self, script_path, run_name, exec_string, call_human=False):
644
815
  """
645
816
  Write the compiled script to a file.
646
817
  """
@@ -650,10 +821,30 @@ class Script(db.Model):
650
821
  else:
651
822
  s.write("deck = None")
652
823
  s.write("\nimport time")
824
+ if self.blocks_included:
825
+ s.write(f"\n{self._create_block_import()}")
826
+ if self.needs_call_human:
827
+ s.write("""\n\ndef pause(reason="Manual intervention required"):\n\tprint(f"\\nHUMAN INTERVENTION REQUIRED: {reason}")\n\tinput("Press Enter to continue...\\n")""")
828
+
653
829
  for i in exec_string.values():
654
830
  s.write(f"\n\n\n{i}")
655
831
 
832
+ def _create_block_import(self):
833
+ imports = {}
834
+ from ivoryos.utils.decorators import BUILDING_BLOCKS
835
+ for category, methods in BUILDING_BLOCKS.items():
836
+ for method_name, meta in methods.items():
837
+ func = meta["func"]
838
+ module = meta["path"]
839
+ name = func.__name__
840
+ imports.setdefault(module, set()).add(name)
841
+ lines = []
842
+ for module, funcs in imports.items():
843
+ lines.append(f"from {module} import {', '.join(sorted(funcs))}")
844
+ return "\n".join(lines)
845
+
656
846
  class WorkflowRun(db.Model):
847
+ """Represents the entire experiment"""
657
848
  __tablename__ = 'workflow_runs'
658
849
 
659
850
  id = db.Column(db.Integer, primary_key=True)
@@ -662,30 +853,65 @@ class WorkflowRun(db.Model):
662
853
  start_time = db.Column(db.DateTime, default=datetime.now())
663
854
  end_time = db.Column(db.DateTime)
664
855
  data_path = db.Column(db.String(256))
665
- steps = db.relationship(
666
- 'WorkflowStep',
667
- backref='workflow_runs',
856
+ repeat_mode = db.Column(db.String(64), default="none") # static_repeat, sweep, optimizer
857
+
858
+ # A run contains multiple iterations
859
+ phases = db.relationship(
860
+ 'WorkflowPhase',
861
+ backref='workflow_runs', # Clearer back-reference name
668
862
  cascade='all, delete-orphan',
669
- passive_deletes=True
863
+ lazy='dynamic' # Good for handling many iterations
670
864
  )
671
865
  def as_dict(self):
672
866
  dict = self.__dict__
673
867
  dict.pop('_sa_instance_state', None)
674
868
  return dict
675
869
 
870
+ class WorkflowPhase(db.Model):
871
+ """Represents a single function call within a WorkflowRun."""
872
+ __tablename__ = 'workflow_phases'
873
+
874
+ id = db.Column(db.Integer, primary_key=True)
875
+ # Foreign key to link this iteration to its parent run
876
+ run_id = db.Column(db.Integer, db.ForeignKey('workflow_runs.id', ondelete='CASCADE'), nullable=False)
877
+
878
+ # NEW: Store iteration-specific parameters here
879
+ name = db.Column(db.String(64), nullable=False) # 'prep', 'main', 'cleanup'
880
+ repeat_index = db.Column(db.Integer, default=0)
881
+
882
+ parameters = db.Column(JSONType) # Use db.JSON for general support
883
+ outputs = db.Column(JSONType)
884
+ start_time = db.Column(db.DateTime, default=datetime.now)
885
+ end_time = db.Column(db.DateTime)
886
+
887
+ # An iteration contains multiple steps
888
+ steps = db.relationship(
889
+ 'WorkflowStep',
890
+ backref='workflow_phases', # Clearer back-reference name
891
+ cascade='all, delete-orphan'
892
+ )
893
+
894
+ def as_dict(self):
895
+ dict = self.__dict__.copy()
896
+ dict.pop('_sa_instance_state', None)
897
+ return dict
898
+
676
899
  class WorkflowStep(db.Model):
677
900
  __tablename__ = 'workflow_steps'
678
901
 
679
902
  id = db.Column(db.Integer, primary_key=True)
680
- workflow_id = db.Column(db.Integer, db.ForeignKey('workflow_runs.id', ondelete='CASCADE'), nullable=False)
903
+ # workflow_id = db.Column(db.Integer, db.ForeignKey('workflow_runs.id', ondelete='CASCADE'), nullable=True)
904
+ phase_id = db.Column(db.Integer, db.ForeignKey('workflow_phases.id', ondelete='CASCADE'), nullable=True)
681
905
 
682
- phase = db.Column(db.String(64), nullable=False) # 'prep', 'main', 'cleanup'
683
- repeat_index = db.Column(db.Integer, default=0) # Only applies to 'main' phase
906
+ # phase = db.Column(db.String(64), nullable=False) # 'prep', 'main', 'cleanup'
907
+ # repeat_index = db.Column(db.Integer, default=0) # Only applies to 'main' phase
684
908
  step_index = db.Column(db.Integer, default=0)
685
909
  method_name = db.Column(db.String(128), nullable=False)
686
910
  start_time = db.Column(db.DateTime)
687
911
  end_time = db.Column(db.DateTime)
688
912
  run_error = db.Column(db.Boolean, default=False)
913
+ output = db.Column(JSONType, default={})
914
+ # Using as_dict method from ModelBase
689
915
 
690
916
  def as_dict(self):
691
917
  dict = self.__dict__.copy()
@@ -702,7 +928,7 @@ class SingleStep(db.Model):
702
928
  start_time = db.Column(db.DateTime)
703
929
  end_time = db.Column(db.DateTime)
704
930
  run_error = db.Column(db.String(128))
705
- output = db.Column(JSONType)
931
+ output = db.Column(JSONType, nullable=True)
706
932
 
707
933
  def as_dict(self):
708
934
  dict = self.__dict__.copy()
@@ -0,0 +1,34 @@
1
+ import inspect
2
+ import os
3
+
4
+ BUILDING_BLOCKS = {}
5
+
6
+ def block(_func=None, *, category="general"):
7
+ def decorator(func):
8
+ if category not in BUILDING_BLOCKS:
9
+ BUILDING_BLOCKS[category] = {}
10
+ if func.__module__ == "__main__":
11
+ file_path = inspect.getfile(func) # e.g. /path/to/math_blocks.py
12
+ module = os.path.splitext(os.path.basename(file_path))[0]
13
+ else:
14
+ module = func.__module__
15
+ BUILDING_BLOCKS[category][func.__name__] = {
16
+ "func": func,
17
+ "signature": inspect.signature(func),
18
+ "docstring": inspect.getdoc(func),
19
+ "coroutine": inspect.iscoroutinefunction(func),
20
+ "path": module
21
+ }
22
+ return func
23
+ if _func is None:
24
+ return decorator
25
+ else:
26
+ return decorator(_func)
27
+
28
+
29
+ class BlockNamespace:
30
+ """[not in use] Expose methods for one block category as attributes."""
31
+
32
+ def __init__(self, methods):
33
+ for name, meta in methods.items():
34
+ setattr(self, name, meta["func"])