ivoryos 1.3.3__tar.gz → 1.3.5a0__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.

Potentially problematic release.


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

Files changed (109) hide show
  1. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/PKG-INFO +31 -6
  2. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/README.md +30 -5
  3. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/app.py +2 -1
  4. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/control/control.py +2 -2
  5. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/design/design.py +4 -1
  6. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/server.py +29 -22
  7. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/utils/db_models.py +23 -11
  8. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/utils/global_config.py +11 -0
  9. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/utils/py_to_json.py +19 -4
  10. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/utils/script_runner.py +34 -7
  11. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/utils/task_runner.py +30 -18
  12. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/utils/utils.py +3 -1
  13. ivoryos-1.3.5a0/ivoryos/version.py +1 -0
  14. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos.egg-info/PKG-INFO +31 -6
  15. ivoryos-1.3.3/ivoryos/version.py +0 -1
  16. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/LICENSE +0 -0
  17. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/__init__.py +0 -0
  18. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/config.py +0 -0
  19. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/optimizer/ax_optimizer.py +0 -0
  20. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/optimizer/base_optimizer.py +0 -0
  21. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/optimizer/baybe_optimizer.py +0 -0
  22. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/optimizer/registry.py +0 -0
  23. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/__init__.py +0 -0
  24. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/api/api.py +0 -0
  25. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/auth/__init__.py +0 -0
  26. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/auth/auth.py +0 -0
  27. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/auth/templates/login.html +0 -0
  28. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/auth/templates/signup.html +0 -0
  29. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/control/__init__.py +0 -0
  30. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/control/control_file.py +0 -0
  31. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/control/control_new_device.py +0 -0
  32. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/control/templates/controllers.html +0 -0
  33. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/control/templates/controllers_new.html +0 -0
  34. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/control/utils.py +0 -0
  35. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/data/__init__.py +0 -0
  36. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/data/data.py +0 -0
  37. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/data/templates/components/step_card.html +0 -0
  38. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/data/templates/workflow_database.html +0 -0
  39. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/data/templates/workflow_view.html +0 -0
  40. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/design/__init__.py +0 -0
  41. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/design/design_file.py +0 -0
  42. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/design/design_step.py +0 -0
  43. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/design/templates/components/action_form.html +0 -0
  44. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/design/templates/components/actions_panel.html +0 -0
  45. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/design/templates/components/autofill_toggle.html +0 -0
  46. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/design/templates/components/canvas.html +0 -0
  47. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/design/templates/components/canvas_footer.html +0 -0
  48. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/design/templates/components/canvas_header.html +0 -0
  49. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/design/templates/components/canvas_main.html +0 -0
  50. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/design/templates/components/deck_selector.html +0 -0
  51. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/design/templates/components/edit_action_form.html +0 -0
  52. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/design/templates/components/instruments_panel.html +0 -0
  53. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/design/templates/components/modals/drop_modal.html +0 -0
  54. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/design/templates/components/modals/json_modal.html +0 -0
  55. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/design/templates/components/modals/new_script_modal.html +0 -0
  56. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/design/templates/components/modals/rename_modal.html +0 -0
  57. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/design/templates/components/modals/saveas_modal.html +0 -0
  58. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/design/templates/components/modals.html +0 -0
  59. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/design/templates/components/python_code_overlay.html +0 -0
  60. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/design/templates/components/sidebar.html +0 -0
  61. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/design/templates/components/text_to_code_panel.html +0 -0
  62. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/design/templates/experiment_builder.html +0 -0
  63. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/execute/__init__.py +0 -0
  64. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/execute/execute.py +0 -0
  65. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/execute/execute_file.py +0 -0
  66. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/execute/templates/components/error_modal.html +0 -0
  67. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/execute/templates/components/logging_panel.html +0 -0
  68. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/execute/templates/components/progress_panel.html +0 -0
  69. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/execute/templates/components/run_panel.html +0 -0
  70. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/execute/templates/components/run_tabs.html +0 -0
  71. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/execute/templates/components/tab_bayesian.html +0 -0
  72. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/execute/templates/components/tab_configuration.html +0 -0
  73. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/execute/templates/components/tab_repeat.html +0 -0
  74. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/execute/templates/experiment_run.html +0 -0
  75. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/library/__init__.py +0 -0
  76. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/library/library.py +0 -0
  77. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/library/templates/library.html +0 -0
  78. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/main/__init__.py +0 -0
  79. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/main/main.py +0 -0
  80. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/main/templates/help.html +0 -0
  81. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/routes/main/templates/home.html +0 -0
  82. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/socket_handlers.py +0 -0
  83. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/static/favicon.ico +0 -0
  84. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/static/gui_annotation/Slide1.png +0 -0
  85. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/static/gui_annotation/Slide2.PNG +0 -0
  86. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/static/js/action_handlers.js +0 -0
  87. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/static/js/db_delete.js +0 -0
  88. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/static/js/overlay.js +0 -0
  89. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/static/js/script_metadata.js +0 -0
  90. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/static/js/socket_handler.js +0 -0
  91. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/static/js/sortable_card.js +0 -0
  92. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/static/js/sortable_design.js +0 -0
  93. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/static/js/ui_state.js +0 -0
  94. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/static/logo.webp +0 -0
  95. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/static/style.css +0 -0
  96. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/templates/base.html +0 -0
  97. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/utils/__init__.py +0 -0
  98. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/utils/bo_campaign.py +0 -0
  99. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/utils/client_proxy.py +0 -0
  100. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/utils/decorators.py +0 -0
  101. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/utils/form.py +0 -0
  102. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/utils/llm_agent.py +0 -0
  103. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos/utils/serilize.py +0 -0
  104. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos.egg-info/SOURCES.txt +0 -0
  105. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos.egg-info/dependency_links.txt +0 -0
  106. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos.egg-info/requires.txt +0 -0
  107. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/ivoryos.egg-info/top_level.txt +0 -0
  108. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/pyproject.toml +0 -0
  109. {ivoryos-1.3.3 → ivoryos-1.3.5a0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ivoryos
3
- Version: 1.3.3
3
+ Version: 1.3.5a0
4
4
  Summary: an open-source Python package enabling Self-Driving Labs (SDLs) interoperability
5
5
  Author-email: Ivory Zhang <ivoryzhang@chem.ubc.ca>
6
6
  License: MIT
@@ -28,7 +28,8 @@ Dynamic: license-file
28
28
  [![YouTube](https://img.shields.io/badge/YouTube-tutorial-red?logo=youtube)](https://youtu.be/dFfJv9I2-1g)
29
29
  [![YouTube](https://img.shields.io/badge/YouTube-demo-red?logo=youtube)](https://youtu.be/flr5ydiE96s)
30
30
  [![Published](https://img.shields.io/badge/Nature_Comm.-paper-blue)](https://www.nature.com/articles/s41467-025-60514-w)
31
- [![Discord](https://img.shields.io/discord/1313641159356059770?label=Discord&logo=discord&color=5865F2)](https://discord.gg/AX5P9EdGVX)
31
+
32
+ [//]: # ([![Discord]&#40;https://img.shields.io/discord/1313641159356059770?label=Discord&logo=discord&color=5865F2&#41;]&#40;https://discord.gg/AX5P9EdGVX&#41;)
32
33
 
33
34
  ![](https://gitlab.com/heingroup/ivoryos/raw/main/docs/source/_static/ivoryos.png)
34
35
  # ivoryOS: interoperable Web UI for self-driving laboratories (SDLs)
@@ -101,11 +102,14 @@ pip install -e .
101
102
  ## Quick start
102
103
  In your SDL script,
103
104
  ```python
105
+ my_robot = Robot()
106
+
104
107
  import ivoryos
105
108
 
106
109
  ivoryos.run(__name__)
107
110
  ```
108
- Login: Create an account (local DB, bcrypt password)
111
+ You can now access the web UI at http://127.0.0.1:8000,
112
+ create an account, login, and start designing workflows!
109
113
 
110
114
  ----
111
115
  ## Features
@@ -127,6 +131,28 @@ Add single or multiple loggers:
127
131
  ivoryos.run(__name__, logger="logger name")
128
132
  ivoryos.run(__name__, logger=["logger 1", "logger 2"])
129
133
  ```
134
+ ### Human-in-the-loop
135
+ Add single or multiple notification handlers for `pause` feature in flow control:
136
+ ```python
137
+
138
+ def slack_bot(msg: str = "Hi"):
139
+ """
140
+ a function that can be used as a notification handler function("msg")
141
+ :param msg: message to send
142
+ """
143
+ from slack_sdk import WebClient
144
+
145
+ slack_token = "your slack token"
146
+ client = WebClient(token=slack_token)
147
+
148
+ my_user_id = "your user id" # replace with your actual Slack user ID
149
+
150
+ client.chat_postMessage(channel=my_user_id, text=msg)
151
+
152
+ import ivoryos
153
+ ivoryos.run(__name__, notification_handler=slack_bot)
154
+ ```
155
+
130
156
  ### Directory Structure
131
157
 
132
158
  Created automatically on first run:
@@ -152,11 +178,10 @@ ivoryos.run(__name__)
152
178
 
153
179
  ## Roadmap
154
180
 
155
- - [x] Allow plugin pages ✅
156
- - [x] pause, resume, abort current and pending workflows ✅
157
181
  - [ ] dropdown input
158
182
  - [ ] snapshot version control
159
183
  - [ ] optimizer-agnostic
184
+ - [ ] prefect compatibility
160
185
  - [ ] check batch-config file compatibility
161
186
 
162
187
  ---
@@ -198,4 +223,4 @@ For an additional perspective related to the development of the tool, please see
198
223
  ```
199
224
  ---
200
225
  ## Acknowledgements
201
- Authors acknowledge Telescope Innovations Corp., Hein Lab members for their valuable suggestions and contributions.
226
+ Authors acknowledge Telescope Innovations Corp., UBC Hein Lab, and Acceleration Consortium members for their valuable suggestions and contributions.
@@ -4,7 +4,8 @@
4
4
  [![YouTube](https://img.shields.io/badge/YouTube-tutorial-red?logo=youtube)](https://youtu.be/dFfJv9I2-1g)
5
5
  [![YouTube](https://img.shields.io/badge/YouTube-demo-red?logo=youtube)](https://youtu.be/flr5ydiE96s)
6
6
  [![Published](https://img.shields.io/badge/Nature_Comm.-paper-blue)](https://www.nature.com/articles/s41467-025-60514-w)
7
- [![Discord](https://img.shields.io/discord/1313641159356059770?label=Discord&logo=discord&color=5865F2)](https://discord.gg/AX5P9EdGVX)
7
+
8
+ [//]: # ([![Discord]&#40;https://img.shields.io/discord/1313641159356059770?label=Discord&logo=discord&color=5865F2&#41;]&#40;https://discord.gg/AX5P9EdGVX&#41;)
8
9
 
9
10
  ![](https://gitlab.com/heingroup/ivoryos/raw/main/docs/source/_static/ivoryos.png)
10
11
  # ivoryOS: interoperable Web UI for self-driving laboratories (SDLs)
@@ -77,11 +78,14 @@ pip install -e .
77
78
  ## Quick start
78
79
  In your SDL script,
79
80
  ```python
81
+ my_robot = Robot()
82
+
80
83
  import ivoryos
81
84
 
82
85
  ivoryos.run(__name__)
83
86
  ```
84
- Login: Create an account (local DB, bcrypt password)
87
+ You can now access the web UI at http://127.0.0.1:8000,
88
+ create an account, login, and start designing workflows!
85
89
 
86
90
  ----
87
91
  ## Features
@@ -103,6 +107,28 @@ Add single or multiple loggers:
103
107
  ivoryos.run(__name__, logger="logger name")
104
108
  ivoryos.run(__name__, logger=["logger 1", "logger 2"])
105
109
  ```
110
+ ### Human-in-the-loop
111
+ Add single or multiple notification handlers for `pause` feature in flow control:
112
+ ```python
113
+
114
+ def slack_bot(msg: str = "Hi"):
115
+ """
116
+ a function that can be used as a notification handler function("msg")
117
+ :param msg: message to send
118
+ """
119
+ from slack_sdk import WebClient
120
+
121
+ slack_token = "your slack token"
122
+ client = WebClient(token=slack_token)
123
+
124
+ my_user_id = "your user id" # replace with your actual Slack user ID
125
+
126
+ client.chat_postMessage(channel=my_user_id, text=msg)
127
+
128
+ import ivoryos
129
+ ivoryos.run(__name__, notification_handler=slack_bot)
130
+ ```
131
+
106
132
  ### Directory Structure
107
133
 
108
134
  Created automatically on first run:
@@ -128,11 +154,10 @@ ivoryos.run(__name__)
128
154
 
129
155
  ## Roadmap
130
156
 
131
- - [x] Allow plugin pages ✅
132
- - [x] pause, resume, abort current and pending workflows ✅
133
157
  - [ ] dropdown input
134
158
  - [ ] snapshot version control
135
159
  - [ ] optimizer-agnostic
160
+ - [ ] prefect compatibility
136
161
  - [ ] check batch-config file compatibility
137
162
 
138
163
  ---
@@ -174,4 +199,4 @@ For an additional perspective related to the development of the tool, please see
174
199
  ```
175
200
  ---
176
201
  ## Acknowledgements
177
- Authors acknowledge Telescope Innovations Corp., Hein Lab members for their valuable suggestions and contributions.
202
+ Authors acknowledge Telescope Innovations Corp., UBC Hein Lab, and Acceleration Consortium members for their valuable suggestions and contributions.
@@ -41,7 +41,8 @@ def reset_old_schema(engine, db_dir):
41
41
  old_workflow_run = 'workflow_runs' in tables
42
42
  old_workflow_step = 'workflow_steps' in tables
43
43
 
44
- if not has_workflow_phase:
44
+ # v1.3.4 only delete and backup when there is runs but no phases
45
+ if not has_workflow_phase and old_workflow_run:
45
46
  print("⚠️ Old workflow database detected! All previous workflows have been reset to support the new schema.")
46
47
  # Backup old DB
47
48
  db_path = os.path.join(db_dir, "ivoryos.db")
@@ -23,7 +23,7 @@ control.register_blueprint(control_temp)
23
23
  @control.route("/", strict_slashes=False, methods=["GET", "POST"])
24
24
  @control.route("/<string:instrument>", strict_slashes=False, methods=["GET", "POST"])
25
25
  @login_required
26
- def deck_controllers(instrument: str = None):
26
+ async def deck_controllers(instrument: str = None):
27
27
  """
28
28
  .. :quickref: Direct Control; device (instruments) and methods
29
29
 
@@ -82,7 +82,7 @@ def deck_controllers(instrument: str = None):
82
82
 
83
83
  wait = str(payload.get("hidden_wait", "true")).lower() == "true"
84
84
 
85
- output = runner.run_single_step(
85
+ output = await runner.run_single_step(
86
86
  component=instrument, method=method_name, kwargs=kwargs, wait=wait,
87
87
  current_app=current_app._get_current_object()
88
88
  )
@@ -316,6 +316,7 @@ def methods_handler(instrument: str = ''):
316
316
  msg = ""
317
317
  request.form
318
318
  if "hidden_name" in request.form:
319
+ deck_snapshot = global_config.deck_snapshot
319
320
  method_name = request.form.get("hidden_name", None)
320
321
  form = forms.get(method_name) if forms else None
321
322
  insert_position = request.form.get("drop_target_id", None)
@@ -334,7 +335,9 @@ def methods_handler(instrument: str = ''):
334
335
  action = {"instrument": instrument, "action": function_name,
335
336
  "args": kwargs,
336
337
  "return": save_data,
337
- 'arg_types': primitive_arg_types}
338
+ 'arg_types': primitive_arg_types,
339
+ "coroutine": deck_snapshot[instrument][function_name].get("coroutine", False) if deck_snapshot else False,
340
+ }
338
341
  script.add_action(action=action, insert_position=insert_position)
339
342
  else:
340
343
  msg = [f"{field}: {', '.join(messages)}" for field, messages in form.errors.items()]
@@ -49,6 +49,7 @@ def run(module=None, host="0.0.0.0", port=None, debug=None, llm_server=None, mod
49
49
  enable_design: bool = True,
50
50
  blueprint_plugins: Union[list, Blueprint] = [],
51
51
  exclude_names: list = [],
52
+ notification_handler=None,
52
53
  ):
53
54
  """
54
55
  Start ivoryOS app server.
@@ -65,6 +66,7 @@ def run(module=None, host="0.0.0.0", port=None, debug=None, llm_server=None, mod
65
66
  :param enable_design: enable design canvas, database and workflow execution
66
67
  :param blueprint_plugins: Union[list[Blueprint], Blueprint] custom Blueprint pages
67
68
  :param exclude_names: list[str] module names to exclude from parsing
69
+ :param notification_handler: notification handler function
68
70
  """
69
71
  app = create_app(config_class=config or get_config()) # Create app instance using factory function
70
72
 
@@ -113,11 +115,33 @@ def run(module=None, host="0.0.0.0", port=None, debug=None, llm_server=None, mod
113
115
  output_path=app.config["OUTPUT_FOLDER"] if module is not None else None)
114
116
  else:
115
117
  app.config["ENABLE_LLM"] = False
116
- if logger and type(logger) is str:
117
- utils.start_logger(socketio, log_filename=logger_path, logger_name=logger)
118
- elif type(logger) is list:
119
- for log in logger:
120
- utils.start_logger(socketio, log_filename=logger_path, logger_name=log)
118
+
119
+
120
+ # --- Logger registration ---
121
+ if logger:
122
+ if isinstance(logger, str):
123
+ logger = [logger] # convert single logger to list
124
+ elif not isinstance(logger, list):
125
+ raise TypeError("logger must be a string or a list of strings.")
126
+
127
+ for log_name in logger:
128
+ utils.start_logger(socketio, log_filename=logger_path, logger_name=log_name)
129
+
130
+ # --- Notification handler registration ---
131
+ if notification_handler:
132
+
133
+ # make it a list if a single function is passed
134
+ if callable(notification_handler):
135
+ notification_handler = [notification_handler]
136
+
137
+ if not isinstance(notification_handler, list):
138
+ raise ValueError("notification_handlers must be a callable or a list of callables.")
139
+
140
+ # validate all items are callable
141
+ for handler in notification_handler:
142
+ if not callable(handler):
143
+ raise TypeError(f"Handler {handler} is not callable.")
144
+ global_config.register_notification(handler)
121
145
 
122
146
  # TODO in case Python 3.12 or higher doesn't log URL
123
147
  # if sys.version_info >= (3, 12):
@@ -129,23 +153,6 @@ def run(module=None, host="0.0.0.0", port=None, debug=None, llm_server=None, mod
129
153
  # return app
130
154
 
131
155
 
132
- # def load_installed_plugins(app, socketio):
133
- # """
134
- # Dynamically load installed plugins and attach Flask-SocketIO.
135
- # """
136
- # plugin_names = []
137
- # for entry_point in entry_points().get("ivoryos.plugins", []):
138
- # plugin = entry_point.load()
139
- #
140
- # # If the plugin has an `init_socketio()` function, pass socketio
141
- # if hasattr(plugin, 'init_socketio'):
142
- # plugin.init_socketio(socketio)
143
- #
144
- # plugin_names.append(entry_point.name)
145
- # app.register_blueprint(getattr(plugin, entry_point.name), url_prefix=f"{url_prefix}/{entry_point.name}")
146
- #
147
- # return plugin_names
148
-
149
156
 
150
157
  def load_plugins(blueprints: Union[list, Blueprint], app, socketio):
151
158
  """
@@ -434,14 +434,21 @@ class Script(db.Model):
434
434
  :return: A dict containing script types as keys and lists of function body lines as values.
435
435
  """
436
436
  line_collection = {}
437
+
437
438
  for stype, func_str in exec_str_collection.items():
438
439
  if func_str:
439
440
  module = ast.parse(func_str)
440
- func_def = next(node for node in module.body if isinstance(node, ast.FunctionDef))
441
441
 
442
- # Extract function body as source lines
443
- line_collection[stype] = [ast_unparse(node) for node in func_def.body if not isinstance(node, ast.Return)]
444
- # print(line_collection[stype])
442
+ # Find the first function (regular or async)
443
+ func_def = next(
444
+ node for node in module.body
445
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
446
+ )
447
+
448
+ # Extract function body as source lines, skipping 'return' nodes
449
+ line_collection[stype] = [
450
+ ast_unparse(node) for node in func_def.body if not isinstance(node, ast.Return)
451
+ ]
445
452
  return line_collection
446
453
 
447
454
  def compile(self, script_path=None):
@@ -459,7 +466,8 @@ class Script(db.Model):
459
466
 
460
467
  for i in self.stypes:
461
468
  if self.script_dict[i]:
462
- func_str = self._generate_function_header(run_name, i) + self._generate_function_body(i)
469
+ 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)
463
471
  exec_str_collection[i] = func_str
464
472
  if script_path:
465
473
  self._write_to_file(script_path, run_name, exec_str_collection)
@@ -477,7 +485,7 @@ class Script(db.Model):
477
485
  name += '_'
478
486
  return name
479
487
 
480
- def _generate_function_header(self, run_name, stype):
488
+ def _generate_function_header(self, run_name, stype, is_async):
481
489
  """
482
490
  Generate the function header.
483
491
  """
@@ -487,7 +495,8 @@ class Script(db.Model):
487
495
  config_type.items()]
488
496
 
489
497
  script_type = f"_{stype}" if stype != "script" else ""
490
- function_header = f"def {run_name}{script_type}("
498
+ async_str = "async " if is_async else ""
499
+ function_header = f"{async_str}def {run_name}{script_type}("
491
500
 
492
501
  if stype == "script":
493
502
  function_header += ", ".join(configure)
@@ -540,7 +549,8 @@ class Script(db.Model):
540
549
  # elif instrument == 'registered_workflows':
541
550
  # return inspect.getsource(my_function)
542
551
  else:
543
- return self._process_instrument_action(indent_unit, instrument, action_name, args, save_data)
552
+ is_async = action.get("coroutine", False)
553
+ return self._process_instrument_action(indent_unit, instrument, action_name, args, save_data, is_async)
544
554
 
545
555
  def _process_args(self, args):
546
556
  """
@@ -600,10 +610,12 @@ class Script(db.Model):
600
610
  indent_unit -= 1
601
611
  return exec_string, indent_unit
602
612
 
603
- def _process_instrument_action(self, indent_unit, instrument, action, args, save_data):
613
+ def _process_instrument_action(self, indent_unit, instrument, action, args, save_data, is_async=False):
604
614
  """
605
615
  Process actions related to instruments.
606
616
  """
617
+ async_str = "await " if is_async else ""
618
+
607
619
  function_call = f"{instrument}.{action}"
608
620
  if instrument.startswith("blocks"):
609
621
  self.blocks_included = True
@@ -611,11 +623,11 @@ class Script(db.Model):
611
623
 
612
624
  if isinstance(args, dict) and args != {}:
613
625
  args_str = self._process_dict_args(args)
614
- single_line = f"{function_call}(**{args_str})"
626
+ single_line = f"{async_str}{function_call}(**{args_str})"
615
627
  elif isinstance(args, str):
616
628
  single_line = f"{function_call} = {args}"
617
629
  else:
618
- single_line = f"{function_call}()"
630
+ single_line = f"{async_str}{function_call}()"
619
631
 
620
632
  if save_data:
621
633
  save_data += " = "
@@ -17,6 +17,8 @@ class GlobalConfig:
17
17
  cls._instance._runner_lock = threading.Lock()
18
18
  cls._instance._runner_status = None
19
19
  cls._instance._optimizers = {}
20
+ cls._instance._notification_handlers = []
21
+
20
22
  return cls._instance
21
23
 
22
24
  @property
@@ -28,6 +30,15 @@ class GlobalConfig:
28
30
  if self._deck is None:
29
31
  self._deck = value
30
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
+
31
42
  @property
32
43
  def building_blocks(self):
33
44
  return self._building_blocks
@@ -55,6 +55,10 @@ def convert_to_cards(source_code: str):
55
55
  )
56
56
 
57
57
  class CardVisitor(ast.NodeVisitor):
58
+ def __init__(self):
59
+ self.defined_types = {} # <-- always exists
60
+
61
+
58
62
  def visit_FunctionDef(self, node):
59
63
  self.defined_types = {
60
64
  arg.arg: ast.unparse(arg.annotation) if arg.annotation else "float"
@@ -142,14 +146,20 @@ def convert_to_cards(source_code: str):
142
146
  "return": "",
143
147
  "uuid": generate_uuid()
144
148
  })
149
+ elif isinstance(node.value, ast.Await):
150
+ self.handle_call(node.value.value, ret_var=node.targets[0].id, awaited=True)
151
+
145
152
  elif isinstance(node.value, ast.Call):
146
153
  self.handle_call(node.value, ret_var=node.targets[0].id)
147
154
 
148
155
  def visit_Expr(self, node):
149
- if isinstance(node.value, ast.Call):
156
+ if isinstance(node.value, ast.Await):
157
+ # node.value is ast.Await
158
+ self.handle_call(node.value.value, awaited=True)
159
+ elif isinstance(node.value, ast.Call):
150
160
  self.handle_call(node.value)
151
161
 
152
- def handle_call(self, node, ret_var=""):
162
+ def handle_call(self, node, ret_var="", awaited=False):
153
163
  func_parts = []
154
164
  f = node.func
155
165
  while isinstance(f, ast.Attribute):
@@ -229,7 +239,7 @@ def convert_to_cards(source_code: str):
229
239
  else infer_type(value)
230
240
  )
231
241
 
232
- add_card({
242
+ card = {
233
243
  "action": action,
234
244
  "arg_types": arg_types,
235
245
  "args": args,
@@ -237,7 +247,12 @@ def convert_to_cards(source_code: str):
237
247
  "instrument": instrument,
238
248
  "return": ret_var,
239
249
  "uuid": generate_uuid()
240
- })
250
+ }
251
+
252
+ if awaited:
253
+ card["coroutine"] = True # mark as coroutine if awaited
254
+
255
+ add_card(card)
241
256
 
242
257
  CardVisitor().visit(tree)
243
258
  return cards
@@ -1,4 +1,5 @@
1
1
  import ast
2
+ import asyncio
2
3
  import os
3
4
  import csv
4
5
  import threading
@@ -19,6 +20,14 @@ class HumanInterventionRequired(Exception):
19
20
  pass
20
21
 
21
22
  def pause(reason="Human intervention required"):
23
+ handlers = global_config.notification_handlers
24
+ if handlers:
25
+ for handler in handlers:
26
+ try:
27
+ handler(reason)
28
+ except Exception as e:
29
+ print(f"[notify] handler {handler} failed: {e}")
30
+ # raise error to pause workflow in gui
22
31
  raise HumanInterventionRequired(reason)
23
32
 
24
33
  class ScriptRunner:
@@ -161,7 +170,7 @@ class ScriptRunner:
161
170
  start_time=datetime.now(),
162
171
  )
163
172
  db.session.add(step)
164
- db.session.commit()
173
+ db.session.flush()
165
174
 
166
175
  logger.info(f"Executing: {line}")
167
176
  socketio.emit('execution', {'section': f"{section_name}-{index}"})
@@ -174,7 +183,25 @@ class ScriptRunner:
174
183
  duration = float(duration_str)
175
184
  self.safe_sleep(duration)
176
185
  else:
177
- exec(line, exec_globals, exec_locals)
186
+ if "await " in line:
187
+ async_code = f"async def __async_exec_wrapper():\n"
188
+ # indent all code lines by 4 spaces
189
+ async_code += "\n".join(" " + line for line in line.splitlines())
190
+ async_code += f"\n return locals()"
191
+ exec(async_code, exec_globals, exec_locals)
192
+ func = exec_locals.get("__async_exec_wrapper") or exec_globals.get("__async_exec_wrapper")
193
+ # Capture the return value from asyncio.run
194
+ result_locals = asyncio.run(func())
195
+
196
+ # Update exec_locals with the returned locals
197
+ exec_locals.update(result_locals)
198
+ exec_locals.pop("__async_exec_wrapper", None)
199
+
200
+ else:
201
+ print("just exec synchronously")
202
+ exec(line, exec_globals, exec_locals)
203
+ # return locals_dict
204
+ # exec(line, exec_globals, exec_locals)
178
205
  # step.run_error = False
179
206
 
180
207
  except HumanInterventionRequired as e:
@@ -219,7 +246,7 @@ class ScriptRunner:
219
246
  repeat_mode=repeat_mode
220
247
  )
221
248
  db.session.add(run)
222
- db.session.commit()
249
+ db.session.flush()
223
250
  run_id = run.id # Save the ID
224
251
  try:
225
252
 
@@ -241,7 +268,7 @@ class ScriptRunner:
241
268
  self._run_actions(script, section_name="cleanup", logger=logger, socketio=socketio,run_id=run_id)
242
269
  # Reset the running flag when done
243
270
  # Save results if necessary
244
- if not script.python_script and output_list:
271
+ if not script.python_script and return_list:
245
272
  filename = self._save_results(run_name, arg_type, return_list, output_list, logger, output_path)
246
273
  self._emit_progress(socketio, 100)
247
274
 
@@ -277,7 +304,7 @@ class ScriptRunner:
277
304
  start_time=datetime.now()
278
305
  )
279
306
  db.session.add(phase)
280
- db.session.commit()
307
+ db.session.flush()
281
308
  phase_id = phase.id
282
309
 
283
310
  step_outputs = self.exec_steps(script, section_name, logger, socketio, phase_id=phase_id)
@@ -317,7 +344,7 @@ class ScriptRunner:
317
344
  start_time=datetime.now()
318
345
  )
319
346
  db.session.add(phase)
320
- db.session.commit()
347
+ db.session.flush()
321
348
 
322
349
  phase_id = phase.id
323
350
  output = self.exec_steps(script, "script", logger, socketio, phase_id, **kwargs)
@@ -371,7 +398,7 @@ class ScriptRunner:
371
398
  start_time=datetime.now()
372
399
  )
373
400
  db.session.add(phase)
374
- db.session.commit()
401
+ db.session.flush()
375
402
  phase_id = phase.id
376
403
 
377
404
  logger.info(f'Executing {run_name} experiment: {i_progress + 1}/{int(repeat_count)}')
@@ -1,3 +1,5 @@
1
+ import inspect
2
+ import asyncio
1
3
  import threading
2
4
  import time
3
5
  from datetime import datetime
@@ -19,8 +21,7 @@ class TaskRunner:
19
21
  self.globals_dict = globals_dict
20
22
  self.lock = global_config.runner_lock
21
23
 
22
-
23
- def run_single_step(self, component, method, kwargs, wait=True, current_app=None):
24
+ async def run_single_step(self, component, method, kwargs, wait=True, current_app=None):
24
25
  global deck
25
26
  if deck is None:
26
27
  deck = global_config.deck
@@ -29,18 +30,18 @@ class TaskRunner:
29
30
  if not self.lock.acquire(blocking=False):
30
31
  current_status = global_config.runner_status
31
32
  current_status["status"] = "busy"
33
+ current_status["output"] = "busy"
32
34
  return current_status
33
35
 
34
-
35
36
  if wait:
36
- output = self._run_single_step(component, method, kwargs, current_app)
37
+ output = await self._run_single_step(component, method, kwargs, current_app)
37
38
  else:
38
- print("running with thread")
39
- thread = threading.Thread(
40
- target=self._run_single_step, args=(component, method, kwargs, current_app)
41
- )
42
- thread.start()
43
- time.sleep(0.1)
39
+ # Create background task properly
40
+ async def background_runner():
41
+ await self._run_single_step(component, method, kwargs, current_app)
42
+
43
+ asyncio.create_task(background_runner())
44
+ await asyncio.sleep(0.1) # Change time.sleep to await asyncio.sleep
44
45
  output = {"status": "task started", "task_id": global_config.runner_status.get("id")}
45
46
 
46
47
  return output
@@ -59,22 +60,32 @@ class TaskRunner:
59
60
  function_executable = getattr(instrument, method)
60
61
  return function_executable
61
62
 
62
- def _run_single_step(self, component, method, kwargs, current_app=None):
63
+ async def _run_single_step(self, component, method, kwargs, current_app=None):
63
64
  try:
64
65
  function_executable = self._get_executable(component, deck, method)
65
66
  method_name = f"{component}.{method}"
66
67
  except Exception as e:
67
68
  self.lock.release()
68
- return {"status": "error", "msg": e.__str__()}
69
+ return {"status": "error", "msg": str(e)}
69
70
 
70
- # with self.lock:
71
+ # Flask context is NOT async → just use normal "with"
71
72
  with current_app.app_context():
72
- step = SingleStep(method_name=method_name, kwargs=kwargs, run_error=None, start_time=datetime.now())
73
+ step = SingleStep(
74
+ method_name=method_name,
75
+ kwargs=kwargs,
76
+ run_error=None,
77
+ start_time=datetime.now()
78
+ )
73
79
  db.session.add(step)
74
- db.session.commit()
75
- global_config.runner_status = {"id":step.id, "type": "task"}
80
+ db.session.flush()
81
+ global_config.runner_status = {"id": step.id, "type": "task"}
82
+
76
83
  try:
77
- output = function_executable(**kwargs)
84
+ if inspect.iscoroutinefunction(function_executable):
85
+ output = await function_executable(**kwargs)
86
+ else:
87
+ output = function_executable(**kwargs)
88
+
78
89
  step.output = output
79
90
  step.end_time = datetime.now()
80
91
  success = True
@@ -86,4 +97,5 @@ class TaskRunner:
86
97
  finally:
87
98
  db.session.commit()
88
99
  self.lock.release()
89
- return dict(success=success, output=output)
100
+
101
+ return dict(success=success, output=output)
@@ -105,7 +105,8 @@ def _inspect_class(class_object=None, debug=False):
105
105
  try:
106
106
  annotation = inspect.signature(method)
107
107
  docstring = inspect.getdoc(method)
108
- functions[function] = dict(signature=annotation, docstring=docstring)
108
+ coroutine = inspect.iscoroutinefunction(method)
109
+ functions[function] = dict(signature=annotation, docstring=docstring, coroutine=coroutine,)
109
110
 
110
111
  except Exception:
111
112
  pass
@@ -141,6 +142,7 @@ def _get_type_from_parameters(arg, parameters):
141
142
  def _convert_by_str(args, arg_types):
142
143
  """
143
144
  Converts a value to type through eval(f'{type}("{args}")')
145
+ v1.3.4 TODO try str lastly, otherwise it's always converted to str
144
146
  """
145
147
  if type(arg_types) is not list:
146
148
  arg_types = [arg_types]
@@ -0,0 +1 @@
1
+ __version__ = "1.3.5a0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ivoryos
3
- Version: 1.3.3
3
+ Version: 1.3.5a0
4
4
  Summary: an open-source Python package enabling Self-Driving Labs (SDLs) interoperability
5
5
  Author-email: Ivory Zhang <ivoryzhang@chem.ubc.ca>
6
6
  License: MIT
@@ -28,7 +28,8 @@ Dynamic: license-file
28
28
  [![YouTube](https://img.shields.io/badge/YouTube-tutorial-red?logo=youtube)](https://youtu.be/dFfJv9I2-1g)
29
29
  [![YouTube](https://img.shields.io/badge/YouTube-demo-red?logo=youtube)](https://youtu.be/flr5ydiE96s)
30
30
  [![Published](https://img.shields.io/badge/Nature_Comm.-paper-blue)](https://www.nature.com/articles/s41467-025-60514-w)
31
- [![Discord](https://img.shields.io/discord/1313641159356059770?label=Discord&logo=discord&color=5865F2)](https://discord.gg/AX5P9EdGVX)
31
+
32
+ [//]: # ([![Discord]&#40;https://img.shields.io/discord/1313641159356059770?label=Discord&logo=discord&color=5865F2&#41;]&#40;https://discord.gg/AX5P9EdGVX&#41;)
32
33
 
33
34
  ![](https://gitlab.com/heingroup/ivoryos/raw/main/docs/source/_static/ivoryos.png)
34
35
  # ivoryOS: interoperable Web UI for self-driving laboratories (SDLs)
@@ -101,11 +102,14 @@ pip install -e .
101
102
  ## Quick start
102
103
  In your SDL script,
103
104
  ```python
105
+ my_robot = Robot()
106
+
104
107
  import ivoryos
105
108
 
106
109
  ivoryos.run(__name__)
107
110
  ```
108
- Login: Create an account (local DB, bcrypt password)
111
+ You can now access the web UI at http://127.0.0.1:8000,
112
+ create an account, login, and start designing workflows!
109
113
 
110
114
  ----
111
115
  ## Features
@@ -127,6 +131,28 @@ Add single or multiple loggers:
127
131
  ivoryos.run(__name__, logger="logger name")
128
132
  ivoryos.run(__name__, logger=["logger 1", "logger 2"])
129
133
  ```
134
+ ### Human-in-the-loop
135
+ Add single or multiple notification handlers for `pause` feature in flow control:
136
+ ```python
137
+
138
+ def slack_bot(msg: str = "Hi"):
139
+ """
140
+ a function that can be used as a notification handler function("msg")
141
+ :param msg: message to send
142
+ """
143
+ from slack_sdk import WebClient
144
+
145
+ slack_token = "your slack token"
146
+ client = WebClient(token=slack_token)
147
+
148
+ my_user_id = "your user id" # replace with your actual Slack user ID
149
+
150
+ client.chat_postMessage(channel=my_user_id, text=msg)
151
+
152
+ import ivoryos
153
+ ivoryos.run(__name__, notification_handler=slack_bot)
154
+ ```
155
+
130
156
  ### Directory Structure
131
157
 
132
158
  Created automatically on first run:
@@ -152,11 +178,10 @@ ivoryos.run(__name__)
152
178
 
153
179
  ## Roadmap
154
180
 
155
- - [x] Allow plugin pages ✅
156
- - [x] pause, resume, abort current and pending workflows ✅
157
181
  - [ ] dropdown input
158
182
  - [ ] snapshot version control
159
183
  - [ ] optimizer-agnostic
184
+ - [ ] prefect compatibility
160
185
  - [ ] check batch-config file compatibility
161
186
 
162
187
  ---
@@ -198,4 +223,4 @@ For an additional perspective related to the development of the tool, please see
198
223
  ```
199
224
  ---
200
225
  ## Acknowledgements
201
- Authors acknowledge Telescope Innovations Corp., Hein Lab members for their valuable suggestions and contributions.
226
+ Authors acknowledge Telescope Innovations Corp., UBC Hein Lab, and Acceleration Consortium members for their valuable suggestions and contributions.
@@ -1 +0,0 @@
1
- __version__ = "1.3.3"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes