sapiopycommons 2025.10.16a785__py3-none-any.whl → 2025.10.17a787__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 sapiopycommons might be problematic. Click here for more details.
- sapiopycommons/ai/tool_of_tools.py +917 -0
- sapiopycommons/callbacks/callback_util.py +18 -4
- sapiopycommons/files/file_util.py +1 -128
- sapiopycommons/general/aliases.py +3 -0
- sapiopycommons/webhook/webservice_handlers.py +1 -1
- {sapiopycommons-2025.10.16a785.dist-info → sapiopycommons-2025.10.17a787.dist-info}/METADATA +1 -1
- {sapiopycommons-2025.10.16a785.dist-info → sapiopycommons-2025.10.17a787.dist-info}/RECORD +9 -49
- sapiopycommons/ai/agent_service_base.py +0 -1226
- sapiopycommons/ai/converter_service_base.py +0 -163
- sapiopycommons/ai/external_credentials.py +0 -128
- sapiopycommons/ai/protoapi/externalcredentials/external_credentials_pb2.py +0 -41
- sapiopycommons/ai/protoapi/externalcredentials/external_credentials_pb2.pyi +0 -35
- sapiopycommons/ai/protoapi/externalcredentials/external_credentials_pb2_grpc.py +0 -24
- sapiopycommons/ai/protoapi/fielddefinitions/fields_pb2.py +0 -43
- sapiopycommons/ai/protoapi/fielddefinitions/fields_pb2.pyi +0 -31
- sapiopycommons/ai/protoapi/fielddefinitions/fields_pb2_grpc.py +0 -24
- sapiopycommons/ai/protoapi/fielddefinitions/velox_field_def_pb2.py +0 -123
- sapiopycommons/ai/protoapi/fielddefinitions/velox_field_def_pb2.pyi +0 -598
- sapiopycommons/ai/protoapi/fielddefinitions/velox_field_def_pb2_grpc.py +0 -24
- sapiopycommons/ai/protoapi/plan/converter/converter_pb2.py +0 -51
- sapiopycommons/ai/protoapi/plan/converter/converter_pb2.pyi +0 -63
- sapiopycommons/ai/protoapi/plan/converter/converter_pb2_grpc.py +0 -149
- sapiopycommons/ai/protoapi/plan/item/item_container_pb2.py +0 -55
- sapiopycommons/ai/protoapi/plan/item/item_container_pb2.pyi +0 -90
- sapiopycommons/ai/protoapi/plan/item/item_container_pb2_grpc.py +0 -24
- sapiopycommons/ai/protoapi/plan/script/script_pb2.py +0 -61
- sapiopycommons/ai/protoapi/plan/script/script_pb2.pyi +0 -108
- sapiopycommons/ai/protoapi/plan/script/script_pb2_grpc.py +0 -153
- sapiopycommons/ai/protoapi/plan/step_output_pb2.py +0 -45
- sapiopycommons/ai/protoapi/plan/step_output_pb2.pyi +0 -42
- sapiopycommons/ai/protoapi/plan/step_output_pb2_grpc.py +0 -24
- sapiopycommons/ai/protoapi/plan/step_pb2.py +0 -43
- sapiopycommons/ai/protoapi/plan/step_pb2.pyi +0 -43
- sapiopycommons/ai/protoapi/plan/step_pb2_grpc.py +0 -24
- sapiopycommons/ai/protoapi/plan/tool/entry_pb2.py +0 -41
- sapiopycommons/ai/protoapi/plan/tool/entry_pb2.pyi +0 -35
- sapiopycommons/ai/protoapi/plan/tool/entry_pb2_grpc.py +0 -24
- sapiopycommons/ai/protoapi/plan/tool/tool_pb2.py +0 -79
- sapiopycommons/ai/protoapi/plan/tool/tool_pb2.pyi +0 -261
- sapiopycommons/ai/protoapi/plan/tool/tool_pb2_grpc.py +0 -154
- sapiopycommons/ai/protoapi/session/sapio_conn_info_pb2.py +0 -39
- sapiopycommons/ai/protoapi/session/sapio_conn_info_pb2.pyi +0 -32
- sapiopycommons/ai/protoapi/session/sapio_conn_info_pb2_grpc.py +0 -24
- sapiopycommons/ai/protobuf_utils.py +0 -504
- sapiopycommons/ai/request_validation.py +0 -470
- sapiopycommons/ai/server.py +0 -152
- sapiopycommons/ai/test_client.py +0 -446
- sapiopycommons/files/temp_files.py +0 -82
- {sapiopycommons-2025.10.16a785.dist-info → sapiopycommons-2025.10.17a787.dist-info}/WHEEL +0 -0
- {sapiopycommons-2025.10.16a785.dist-info → sapiopycommons-2025.10.17a787.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import io
|
|
3
|
+
import math
|
|
4
|
+
from typing import Final, Mapping, Any
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
from pandas import DataFrame
|
|
8
|
+
from requests import Response
|
|
9
|
+
from sapiopylib.rest.DataMgmtService import DataMgmtServer
|
|
10
|
+
from sapiopylib.rest.DataRecordManagerService import DataRecordManager
|
|
11
|
+
from sapiopylib.rest.DataTypeService import DataTypeManager
|
|
12
|
+
from sapiopylib.rest.ELNService import ElnManager
|
|
13
|
+
from sapiopylib.rest.User import SapioUser
|
|
14
|
+
from sapiopylib.rest.pojo.DataRecord import DataRecord
|
|
15
|
+
from sapiopylib.rest.pojo.Sort import SortDirection
|
|
16
|
+
from sapiopylib.rest.pojo.chartdata.DashboardDefinition import GaugeChartDefinition, DashboardDefinition
|
|
17
|
+
from sapiopylib.rest.pojo.chartdata.DashboardEnums import ChartGroupingType, ChartOperationType, DashboardScope
|
|
18
|
+
from sapiopylib.rest.pojo.chartdata.DashboardSeries import GaugeChartSeries
|
|
19
|
+
from sapiopylib.rest.pojo.datatype.DataType import DataTypeDefinition
|
|
20
|
+
from sapiopylib.rest.pojo.datatype.DataTypeLayout import DataTypeLayout, TableLayout
|
|
21
|
+
from sapiopylib.rest.pojo.datatype.FieldDefinition import AbstractVeloxFieldDefinition, FieldType, \
|
|
22
|
+
VeloxStringFieldDefinition
|
|
23
|
+
from sapiopylib.rest.pojo.eln.ElnEntryPosition import ElnEntryPosition
|
|
24
|
+
from sapiopylib.rest.pojo.eln.ElnExperiment import ElnExperiment
|
|
25
|
+
from sapiopylib.rest.pojo.eln.ExperimentEntry import ExperimentEntry
|
|
26
|
+
from sapiopylib.rest.pojo.eln.ExperimentEntryCriteria import ElnEntryCriteria, ElnFormEntryUpdateCriteria, \
|
|
27
|
+
ElnDashboardEntryUpdateCriteria
|
|
28
|
+
from sapiopylib.rest.pojo.eln.SapioELNEnums import ElnEntryType, ElnBaseDataType, ExperimentEntryStatus
|
|
29
|
+
from sapiopylib.rest.pojo.eln.eln_headings import ElnExperimentTabAddCriteria, ElnExperimentTab
|
|
30
|
+
from sapiopylib.rest.pojo.eln.field_set import ElnFieldSetInfo
|
|
31
|
+
from sapiopylib.rest.utils.ProtocolUtils import ELNStepFactory
|
|
32
|
+
from sapiopylib.rest.utils.Protocols import ElnEntryStep, ElnExperimentProtocol
|
|
33
|
+
|
|
34
|
+
from sapiopycommons.callbacks.field_builder import FieldBuilder
|
|
35
|
+
from sapiopycommons.general.aliases import AliasUtil, SapioRecord
|
|
36
|
+
from sapiopycommons.general.exceptions import SapioException
|
|
37
|
+
from sapiopycommons.general.html_formatter import HtmlFormatter
|
|
38
|
+
from sapiopycommons.general.time_util import TimeUtil
|
|
39
|
+
from sapiopycommons.multimodal.multimodal import MultiModalManager
|
|
40
|
+
from sapiopycommons.multimodal.multimodal_data import ImageDataRequestPojo
|
|
41
|
+
|
|
42
|
+
CREDENTIALS_HEADER: Final[str] = "SAPIO_APP_API_KEY"
|
|
43
|
+
API_URL_HEADER: Final[str] = "SAPIO_APP_API_URL"
|
|
44
|
+
EXP_ID_HEADER: Final[str] = "EXPERIMENT_ID"
|
|
45
|
+
TAB_PREFIX_HEADER: Final[str] = "TAB_PREFIX"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# FR-47422: Create utility classes and methods to assist the tool of tools.
|
|
49
|
+
def create_tot_headers(url: str, username: str, password: str, experiment_id: int, tab_prefix: str) \
|
|
50
|
+
-> dict[str, str]:
|
|
51
|
+
"""
|
|
52
|
+
Create the headers to be passed to a tool of tools endpoint.
|
|
53
|
+
|
|
54
|
+
:param url: The webservice URL of the system to make the changes in.
|
|
55
|
+
:param username: The username of the user making the changes.
|
|
56
|
+
:param password: The password of the user making the changes.
|
|
57
|
+
:param experiment_id: The ID of the experiment to make the changes in.
|
|
58
|
+
:param tab_prefix: The prefix to use for the tab name that will be created by the tool.
|
|
59
|
+
:return: The headers to be passed to the endpoint.
|
|
60
|
+
"""
|
|
61
|
+
# Combine the credentials into the format "username:password"
|
|
62
|
+
credentials: str = f"{username}:{password}"
|
|
63
|
+
# Encode the credentials to bytes, then encode them using base64,
|
|
64
|
+
# and finally convert the result back into a string.
|
|
65
|
+
encoded_credentials: str = base64.b64encode(credentials.encode('utf-8')).decode('utf-8')
|
|
66
|
+
# Remove the trailing slash from the URL if it exists.
|
|
67
|
+
if url.endswith("/"):
|
|
68
|
+
url.rstrip("/")
|
|
69
|
+
headers: dict[str, str] = {
|
|
70
|
+
CREDENTIALS_HEADER: f"Basic {encoded_credentials}",
|
|
71
|
+
API_URL_HEADER: url,
|
|
72
|
+
EXP_ID_HEADER: str(experiment_id),
|
|
73
|
+
TAB_PREFIX_HEADER: tab_prefix
|
|
74
|
+
}
|
|
75
|
+
return headers
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def create_user_from_tot_headers(headers: Mapping[str, str]) -> SapioUser:
|
|
79
|
+
"""
|
|
80
|
+
Create a SapioUser object from the headers passed to a tool of tools endpoint.
|
|
81
|
+
|
|
82
|
+
:param headers: The headers that were passed to the endpoint.
|
|
83
|
+
:return: A SapioUser object created from the headers that can be used to communicate with the Sapio server.
|
|
84
|
+
"""
|
|
85
|
+
headers: dict[str, str] = format_tot_headers(headers)
|
|
86
|
+
credentials = (base64.b64decode(headers[CREDENTIALS_HEADER.lower()].removeprefix("Basic "))
|
|
87
|
+
.decode("utf-8").split(":", 1))
|
|
88
|
+
url: str = headers[API_URL_HEADER.lower()]
|
|
89
|
+
if url.endswith("/"):
|
|
90
|
+
url.rstrip("/")
|
|
91
|
+
return SapioUser(url, username=credentials[0], password=credentials[1])
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def format_tot_headers(headers: Mapping[str, str]) -> dict[str, str]:
|
|
95
|
+
"""
|
|
96
|
+
Format the headers passed to a tool of tools endpoint to guarantee that the keys are lowercase.
|
|
97
|
+
|
|
98
|
+
:param headers: The headers that were passed to the endpoint.
|
|
99
|
+
:return: The headers with all keys converted to lowercase. (Conflicting keys will cause one to overwrite the other,
|
|
100
|
+
but there should not be any conflicting keys in the headers passed to a tool of tools endpoint.)
|
|
101
|
+
"""
|
|
102
|
+
return {k.lower(): v for k, v in headers.items()}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class AiHelper:
|
|
106
|
+
"""
|
|
107
|
+
A class with helper methods for the AI to make use of when creating/updating experiment tabs and entries.
|
|
108
|
+
"""
|
|
109
|
+
# Contextual info.
|
|
110
|
+
user: SapioUser
|
|
111
|
+
exp_id: int
|
|
112
|
+
timeout: int
|
|
113
|
+
|
|
114
|
+
# Managers.
|
|
115
|
+
dr_man: DataRecordManager
|
|
116
|
+
eln_man: ElnManager
|
|
117
|
+
dt_man: DataTypeManager
|
|
118
|
+
|
|
119
|
+
def __init__(self, user: SapioUser, exp_id: int, timeout: int = 120):
|
|
120
|
+
"""
|
|
121
|
+
:param user: The user to send the requests from.
|
|
122
|
+
:param exp_id: The ID of the experiment to create the entries in.
|
|
123
|
+
:param timeout: The timeout in seconds to use for requests.
|
|
124
|
+
"""
|
|
125
|
+
self.user = user
|
|
126
|
+
self.exp_id = exp_id
|
|
127
|
+
self.timeout = timeout
|
|
128
|
+
|
|
129
|
+
self.dr_man = DataRecordManager(self.user)
|
|
130
|
+
self.eln_man = ElnManager(self.user)
|
|
131
|
+
self.dt_man = DataTypeManager(self.user)
|
|
132
|
+
|
|
133
|
+
def call_post_endpoint(self, url: str, payload: Any, tab_prefix: str = "") -> Response:
|
|
134
|
+
"""
|
|
135
|
+
Call a tool endpoint. Constructs the tool headers and checks the response for errors for the caller.
|
|
136
|
+
|
|
137
|
+
:param url: The URL of the endpoint to call.
|
|
138
|
+
:param payload: The payload to send to the endpoint.
|
|
139
|
+
:param tab_prefix: The prefix to use for the tab name that will be created by the tool.
|
|
140
|
+
:return: The Response object returned by the endpoint.
|
|
141
|
+
"""
|
|
142
|
+
headers = create_tot_headers(self.user.url, self.user.username, self.user.password, self.exp_id, tab_prefix)
|
|
143
|
+
response = requests.post(url, json=payload, headers=headers, timeout=self.timeout)
|
|
144
|
+
response.raise_for_status()
|
|
145
|
+
return response
|
|
146
|
+
|
|
147
|
+
def call_get_endpoint(self, url: str, params: Any, tab_prefix: str = "") -> Response:
|
|
148
|
+
"""
|
|
149
|
+
Call a tool endpoint. Constructs the tool headers and checks the response for errors for the caller.
|
|
150
|
+
|
|
151
|
+
:param url: The URL of the endpoint to call.
|
|
152
|
+
:param params: The query parameters to send to the endpoint.
|
|
153
|
+
:param tab_prefix: The prefix to use for the tab name that will be created by the tool.
|
|
154
|
+
:return: The Response object returned by the endpoint.
|
|
155
|
+
"""
|
|
156
|
+
headers = create_tot_headers(self.user.url, self.user.username, self.user.password, self.exp_id, tab_prefix)
|
|
157
|
+
response = requests.get(url, params=params, headers=headers, timeout=self.timeout)
|
|
158
|
+
response.raise_for_status()
|
|
159
|
+
return response
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def protocol(self) -> ElnExperimentProtocol:
|
|
163
|
+
"""
|
|
164
|
+
:return: An experiment protocol object for this helper's experiment. (Recreating a new protocol object every
|
|
165
|
+
time this is called since the protocol's cache could be invalidated by things that the AI is doing.)
|
|
166
|
+
"""
|
|
167
|
+
# The experiment name and record ID aren't necessary to know for our purposes.
|
|
168
|
+
return ElnExperimentProtocol(ElnExperiment(self.exp_id, "", 0), self.user)
|
|
169
|
+
|
|
170
|
+
def create_tab(self, name: str) -> ElnExperimentTab:
|
|
171
|
+
"""
|
|
172
|
+
Create a new tab in the experiment.
|
|
173
|
+
|
|
174
|
+
:param name: The name of the tab to create.
|
|
175
|
+
:return: The newly created tab.
|
|
176
|
+
"""
|
|
177
|
+
tab_crit = ElnExperimentTabAddCriteria(name, [])
|
|
178
|
+
return self.eln_man.add_tab_for_experiment(self.exp_id, tab_crit)
|
|
179
|
+
|
|
180
|
+
def tab_next_entry_order(self, tab: ElnExperimentTab) -> int:
|
|
181
|
+
"""
|
|
182
|
+
:param tab: A tab in this helper's experiment.
|
|
183
|
+
:return: The order that the next entry that gets created in the tab should have.
|
|
184
|
+
"""
|
|
185
|
+
max_order: int = 0
|
|
186
|
+
for step in self.protocol.get_sorted_step_list():
|
|
187
|
+
if step.eln_entry.notebook_experiment_tab_id == tab.tab_id and step.eln_entry.order > max_order:
|
|
188
|
+
max_order = step.eln_entry.order
|
|
189
|
+
return max_order + 1
|
|
190
|
+
|
|
191
|
+
def create_experiment_details_from_data_frame(self,
|
|
192
|
+
tab: ElnExperimentTab,
|
|
193
|
+
entry_name: str,
|
|
194
|
+
df: DataFrame,
|
|
195
|
+
sort_field: str | None = None,
|
|
196
|
+
sort_direction: SortDirection = SortDirection.DESCENDING,
|
|
197
|
+
smiles_column: str | None = None) -> ExperimentEntry | None:
|
|
198
|
+
"""
|
|
199
|
+
Create an experiment detail entry from a DataFrame.
|
|
200
|
+
|
|
201
|
+
:param tab: The tab that the entry should be added to.
|
|
202
|
+
:param entry_name: The name of the entry.
|
|
203
|
+
:param df: The DataFrame to create the entry from.
|
|
204
|
+
:param sort_field: The field to sort the resulting entry rows by, if any.
|
|
205
|
+
:param sort_direction: The direction to sort the resulting entry rows in, if a sort_field is provided.
|
|
206
|
+
:param smiles_column: The column name in the provided DataFrame that corresponds to the SMILES strings of the
|
|
207
|
+
compounds tracked in the DataFrame, if any. If this is provided, then the entry will be created with
|
|
208
|
+
images of the compounds corresponding to the SMILES strings in each row of the table.
|
|
209
|
+
:return: The newly created experiment detail entry.
|
|
210
|
+
"""
|
|
211
|
+
json_list: list[dict[str, Any]] = []
|
|
212
|
+
smiles: list[str] = []
|
|
213
|
+
for _, row in df.iterrows():
|
|
214
|
+
row_dict: dict[str, Any] = row.to_dict()
|
|
215
|
+
if smiles_column is not None:
|
|
216
|
+
smiles.append(row_dict.get(smiles_column))
|
|
217
|
+
json_list.append(row_dict)
|
|
218
|
+
images: list[bytes] | None = None
|
|
219
|
+
if smiles:
|
|
220
|
+
images = self.smiles_to_svg(smiles)
|
|
221
|
+
return self.create_experiment_details_from_json(tab, entry_name, json_list, sort_field, sort_direction, images)
|
|
222
|
+
|
|
223
|
+
def create_experiment_details_from_json(self,
|
|
224
|
+
tab: ElnExperimentTab,
|
|
225
|
+
entry_name: str,
|
|
226
|
+
json_list: list[dict[str, Any]],
|
|
227
|
+
sort_field: str | None = None,
|
|
228
|
+
sort_direction: SortDirection = SortDirection.DESCENDING,
|
|
229
|
+
images: list[bytes] | None = None) -> ExperimentEntry | None:
|
|
230
|
+
"""
|
|
231
|
+
Create an experiment detail entry from a list of JSON dictionaries.
|
|
232
|
+
|
|
233
|
+
:param tab: The tab that the entry should be added to.
|
|
234
|
+
:param entry_name: The name of the entry.
|
|
235
|
+
:param json_list: The list of JSON dictionaries to create the entry from. Each dictionary is expected to have the
|
|
236
|
+
same keys.
|
|
237
|
+
:param sort_field: The field to sort the resulting entry rows by, if any.
|
|
238
|
+
:param sort_direction: The direction to sort the resulting entry rows in, if a sort_field is provided.
|
|
239
|
+
:param images: The images to include in the entry, if any. The images will be added to the rows that they
|
|
240
|
+
correspond to based on the order of the images in the images list and the order of the rows in the
|
|
241
|
+
json list.
|
|
242
|
+
:return: The newly created experiment detail entry.
|
|
243
|
+
"""
|
|
244
|
+
if not json_list:
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
def update_string_field(f: AbstractVeloxFieldDefinition, v: Any) -> None:
|
|
248
|
+
"""
|
|
249
|
+
Update the max length of the string field and whether it is a link-out field depending on the length and
|
|
250
|
+
form of the given value.
|
|
251
|
+
|
|
252
|
+
:param f: The definition of the string field.
|
|
253
|
+
:param v: A field value that will be present for this field.
|
|
254
|
+
"""
|
|
255
|
+
if not isinstance(f, VeloxStringFieldDefinition) or v is None:
|
|
256
|
+
return
|
|
257
|
+
sv = str(v)
|
|
258
|
+
f.max_length = max(f.max_length, len(sv))
|
|
259
|
+
if not f.link_out and sv.startswith("http://") or sv.startswith("https://"):
|
|
260
|
+
link_out, link_out_url = FieldBuilder._convert_link_out({"Link": "[[LINK_OUT]]"})
|
|
261
|
+
f.link_out = link_out
|
|
262
|
+
f.link_out_url = link_out_url
|
|
263
|
+
|
|
264
|
+
# Determine which fields in the JSON can be used to create field definitions.
|
|
265
|
+
fb = FieldBuilder()
|
|
266
|
+
json_key_to_field_def: dict[str, AbstractVeloxFieldDefinition] = {}
|
|
267
|
+
numeric_string_fields: set[str] = set()
|
|
268
|
+
for values in json_list:
|
|
269
|
+
for key, value in values.items():
|
|
270
|
+
# Skip null values, since we can't know what type they're meant to represent.
|
|
271
|
+
if value is None:
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
# The field name is the JSON key name, but with spaces and dashes replaced by underscores and with a
|
|
275
|
+
# leading underscore added if the field name starts with a number.
|
|
276
|
+
field_name: str = key.strip()
|
|
277
|
+
if " " in field_name:
|
|
278
|
+
field_name = field_name.replace(" ", "_")
|
|
279
|
+
if "-" in field_name:
|
|
280
|
+
field_name = field_name.replace("-", "_")
|
|
281
|
+
if field_name[0].isnumeric():
|
|
282
|
+
field_name = "_" + field_name
|
|
283
|
+
|
|
284
|
+
# If this is the first time this key is being encountered, create a field for it.
|
|
285
|
+
if key not in json_key_to_field_def:
|
|
286
|
+
if isinstance(value, str):
|
|
287
|
+
json_key_to_field_def[key] = fb.string_field(field_name, display_name=key)
|
|
288
|
+
update_string_field(json_key_to_field_def[key], value)
|
|
289
|
+
elif isinstance(value, bool):
|
|
290
|
+
json_key_to_field_def[key] = fb.boolean_field(field_name, display_name=key)
|
|
291
|
+
elif isinstance(value, (int, float)):
|
|
292
|
+
json_key_to_field_def[key] = fb.double_field(field_name, display_name=key, precision=3)
|
|
293
|
+
# All other values in the JSON get skipped.
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
# The field definition already exists, but it may not be a valid field type for this value.
|
|
297
|
+
field_type: FieldType = json_key_to_field_def[key].data_field_type
|
|
298
|
+
# Strings can be anything, so we don't need to check the value type.
|
|
299
|
+
if field_type == FieldType.STRING:
|
|
300
|
+
# We still need to make sure the lengths are fine.
|
|
301
|
+
update_string_field(json_key_to_field_def[key], value)
|
|
302
|
+
continue
|
|
303
|
+
# Boolean values can only be booleans.
|
|
304
|
+
if field_type == FieldType.BOOLEAN and isinstance(value, bool):
|
|
305
|
+
continue
|
|
306
|
+
# Integers and floats both fit in DOUBLE fields, but floats can't be NaN or infinity.
|
|
307
|
+
if field_type == FieldType.DOUBLE:
|
|
308
|
+
# Booleans count as ints for isinstance, so make sure that true integers continue but bools don't.
|
|
309
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
310
|
+
continue
|
|
311
|
+
if isinstance(value, float) and not math.isnan(value) and not math.isinf(value):
|
|
312
|
+
continue
|
|
313
|
+
numeric_string_fields.add(key)
|
|
314
|
+
json_key_to_field_def[key] = fb.string_field(field_name, display_name=key)
|
|
315
|
+
update_string_field(json_key_to_field_def[key], value)
|
|
316
|
+
|
|
317
|
+
# Sort the JSON list if requested.
|
|
318
|
+
if sort_field and sort_direction != SortDirection.NONE:
|
|
319
|
+
if images:
|
|
320
|
+
old_order: list[str] = [x[sort_field] for x in json_list]
|
|
321
|
+
json_list.sort(key=lambda x: x.get(sort_field), reverse=sort_direction == SortDirection.DESCENDING)
|
|
322
|
+
# We'll need to resort the images as well.
|
|
323
|
+
if images:
|
|
324
|
+
new_order: list[str] = [x[sort_field] for x in json_list]
|
|
325
|
+
new_images: list[bytes] = []
|
|
326
|
+
for val in new_order:
|
|
327
|
+
# noinspection PyUnboundLocalVariable
|
|
328
|
+
new_images.append(images[old_order.index(val)])
|
|
329
|
+
images = new_images
|
|
330
|
+
|
|
331
|
+
# Extract the valid field values from the JSON.
|
|
332
|
+
field_maps: list[dict[str, Any]] = []
|
|
333
|
+
for json_dict in json_list:
|
|
334
|
+
field_map: dict[str, Any] = {}
|
|
335
|
+
for key, field in json_key_to_field_def.items():
|
|
336
|
+
val: Any = json_dict.get(key)
|
|
337
|
+
if key in numeric_string_fields and val is not None and isinstance(val, (int, float)):
|
|
338
|
+
val: str = f"{val:.3f}"
|
|
339
|
+
field_map[field.data_field_name] = val
|
|
340
|
+
field_maps.append(field_map)
|
|
341
|
+
|
|
342
|
+
# Create the experiment detail entry.
|
|
343
|
+
detail_entry = ElnEntryCriteria(ElnEntryType.Table, entry_name,
|
|
344
|
+
ElnBaseDataType.EXPERIMENT_DETAIL.data_type_name,
|
|
345
|
+
self.tab_next_entry_order(tab),
|
|
346
|
+
notebook_experiment_tab_id=tab.tab_id,
|
|
347
|
+
field_definition_list=[y for x, y in json_key_to_field_def.items()])
|
|
348
|
+
entry = self.eln_man.add_experiment_entry(self.exp_id, detail_entry)
|
|
349
|
+
records: list[DataRecord] = self.dr_man.add_data_records_with_data(entry.data_type_name, field_maps)
|
|
350
|
+
|
|
351
|
+
# If images are provided, update the data type definition of the experiment detail data type to allow
|
|
352
|
+
# record images and add the images to the records.
|
|
353
|
+
if images:
|
|
354
|
+
dt: DataTypeDefinition = self.dt_man.get_data_type_definition(entry.data_type_name)
|
|
355
|
+
dt.is_record_image_assignable = True
|
|
356
|
+
self.eln_man.update_eln_data_type_definition(self.exp_id, entry.entry_id, dt)
|
|
357
|
+
|
|
358
|
+
layout: DataTypeLayout = self.dt_man.get_default_layout(entry.data_type_name)
|
|
359
|
+
layout.table_layout = TableLayout(cell_size=128, record_image_width=128)
|
|
360
|
+
self.eln_man.update_eln_data_type_layout(self.exp_id, entry.entry_id, layout)
|
|
361
|
+
|
|
362
|
+
self.update_record_images(records, images)
|
|
363
|
+
|
|
364
|
+
return entry
|
|
365
|
+
|
|
366
|
+
def create_text_entry(self, tab: ElnExperimentTab, timestamp: str, description: str, auto_format: bool = True) \
|
|
367
|
+
-> ExperimentEntry:
|
|
368
|
+
"""
|
|
369
|
+
Create a new text entry in the experiment.
|
|
370
|
+
|
|
371
|
+
:param tab: The tab to create the text entry in.
|
|
372
|
+
:param timestamp: The timestamp to display at the top of the text entry.
|
|
373
|
+
:param description: The description to display in the text entry.
|
|
374
|
+
:param auto_format: Whether to automatically format the text to be added.
|
|
375
|
+
:return: The newly created text entry.
|
|
376
|
+
"""
|
|
377
|
+
if auto_format:
|
|
378
|
+
description: str = f"<p>{HtmlFormatter.timestamp(timestamp)}<br>{HtmlFormatter.body(description)}</p>"
|
|
379
|
+
else:
|
|
380
|
+
description: str = f"<p>{timestamp}<br>{description}</p>"
|
|
381
|
+
position = ElnEntryPosition(tab.tab_id, self.tab_next_entry_order(tab))
|
|
382
|
+
text_entry: ElnEntryStep = ELNStepFactory.create_text_entry(self.protocol, description, position)
|
|
383
|
+
return text_entry.eln_entry
|
|
384
|
+
|
|
385
|
+
def set_text_entry(self, text_entry: ExperimentEntry, timestamp: str, description: str,
|
|
386
|
+
auto_format: bool = True) -> None:
|
|
387
|
+
"""
|
|
388
|
+
Set the text of a text entry.
|
|
389
|
+
|
|
390
|
+
:param text_entry: The text entry to set the text of.
|
|
391
|
+
:param timestamp: The timestamp to display at the top of the text entry.
|
|
392
|
+
:param description: The description to display in the text entry.
|
|
393
|
+
:param auto_format: Whether to automatically format the text to be added.
|
|
394
|
+
"""
|
|
395
|
+
if auto_format:
|
|
396
|
+
timestamp = HtmlFormatter.timestamp(timestamp)
|
|
397
|
+
description = HtmlFormatter.body(description)
|
|
398
|
+
description: str = f"<p>{timestamp}<br>{description}</p>"
|
|
399
|
+
step = ElnEntryStep(self.protocol, text_entry)
|
|
400
|
+
text_record: DataRecord = step.get_records()[0]
|
|
401
|
+
text_record.set_field_value(ElnBaseDataType.get_text_entry_data_field_name(), description)
|
|
402
|
+
self.dr_man.commit_data_records([text_record])
|
|
403
|
+
|
|
404
|
+
def add_to_text_entry(self, text_entry: ExperimentEntry, description: str, auto_format: bool = True) -> None:
|
|
405
|
+
"""
|
|
406
|
+
Add to the text of a text entry.
|
|
407
|
+
|
|
408
|
+
:param text_entry: The text entry to add the text to.
|
|
409
|
+
:param description: The text to add to the text entry.
|
|
410
|
+
:param auto_format: Whether to automatically format the text to be added.
|
|
411
|
+
"""
|
|
412
|
+
step = ElnEntryStep(self.protocol, text_entry)
|
|
413
|
+
text_record: DataRecord = step.get_records()[0]
|
|
414
|
+
update: str = text_record.get_field_value(ElnBaseDataType.get_text_entry_data_field_name())
|
|
415
|
+
if auto_format:
|
|
416
|
+
description = HtmlFormatter.body(description)
|
|
417
|
+
update += f"<p style=\"padding-top: 10px;\">{description}</p>"
|
|
418
|
+
text_record.set_field_value(ElnBaseDataType.get_text_entry_data_field_name(), update)
|
|
419
|
+
self.dr_man.commit_data_records([text_record])
|
|
420
|
+
|
|
421
|
+
def create_attachment_entry(self, tab: ElnExperimentTab, entry_name: str, file_name: str, file_data: str | bytes) \
|
|
422
|
+
-> ExperimentEntry:
|
|
423
|
+
"""
|
|
424
|
+
Add a new attachment entry to the experiment with the provided attachment data.
|
|
425
|
+
|
|
426
|
+
:param tab: The tab where the attachment entry will be added.
|
|
427
|
+
:param entry_name: Name of the attachment entry to create in the experiment.
|
|
428
|
+
:param file_name: The name of the attachment.
|
|
429
|
+
:param file_data: The data of the attachment. This can be a string or bytes.
|
|
430
|
+
:return: The newly created attachment entry.
|
|
431
|
+
"""
|
|
432
|
+
tab_id: int = tab.tab_id
|
|
433
|
+
|
|
434
|
+
# Encode the file contents in base64.
|
|
435
|
+
if isinstance(file_data, str):
|
|
436
|
+
file_data: bytes = file_data.encode("utf-8")
|
|
437
|
+
base64_encoded: str = base64.b64encode(file_data).decode("utf-8")
|
|
438
|
+
|
|
439
|
+
# Crete an attachment entry with the provided data.
|
|
440
|
+
attachment_entry = self.eln_man.add_experiment_entry(
|
|
441
|
+
self.exp_id,
|
|
442
|
+
ElnEntryCriteria(ElnEntryType.Attachment, entry_name, "Attachment", order=2,
|
|
443
|
+
notebook_experiment_tab_id=tab_id, attachment_file_name=file_name,
|
|
444
|
+
attachment_data_base64=base64_encoded)
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
# Return the entry object for further use.
|
|
448
|
+
return attachment_entry
|
|
449
|
+
|
|
450
|
+
def create_attachment_entry_from_file(self, tab: ElnExperimentTab, entry_name: str, file_path: str) \
|
|
451
|
+
-> ExperimentEntry:
|
|
452
|
+
"""
|
|
453
|
+
Add a new attachment entry to the experiment with the provided file path to a file in the file system.
|
|
454
|
+
|
|
455
|
+
:param tab: The tab where the attachment entry will be added.
|
|
456
|
+
:param entry_name: Name of the attachment entry to create in the experiment.
|
|
457
|
+
:param file_path: The path to a file in the system to attach to the experiment.
|
|
458
|
+
:return: The newly created attachment entry.
|
|
459
|
+
"""
|
|
460
|
+
with open(file_path, 'rb') as f:
|
|
461
|
+
file_contents: bytes = f.read()
|
|
462
|
+
return self.create_attachment_entry(tab, entry_name, file_path, file_contents)
|
|
463
|
+
|
|
464
|
+
def smiles_to_svg(self, smiles: list[str]) -> list[bytes]:
|
|
465
|
+
"""
|
|
466
|
+
Given a list of SMILES strings, return a list of the corresponding images in SVG format.
|
|
467
|
+
|
|
468
|
+
:param smiles: The SMILES strings to retrieve images for.
|
|
469
|
+
:return: The images in SVG format. The indices of the returned list correspond to the indices of the input
|
|
470
|
+
SMILES.
|
|
471
|
+
"""
|
|
472
|
+
if not smiles:
|
|
473
|
+
return []
|
|
474
|
+
reg_man = MultiModalManager(self.user)
|
|
475
|
+
image_list: list[str] = reg_man.load_image_data(ImageDataRequestPojo(smiles, False))
|
|
476
|
+
return [x.encode() for x in image_list]
|
|
477
|
+
|
|
478
|
+
def update_record_images(self, records: list[SapioRecord], images: list[bytes]) -> None:
|
|
479
|
+
"""
|
|
480
|
+
Update the images of the given records with the given images.
|
|
481
|
+
|
|
482
|
+
:param records: The records to update the images of.
|
|
483
|
+
:param images: The images to update the records with. Records will be match with the image in the matching
|
|
484
|
+
index of this list.
|
|
485
|
+
"""
|
|
486
|
+
for record, image in zip(AliasUtil.to_data_records(records), images):
|
|
487
|
+
with io.BytesIO(image) as bytes_io:
|
|
488
|
+
self.dr_man.set_record_image(record, bytes_io)
|
|
489
|
+
|
|
490
|
+
def create_bar_chart(self, entry_name: str, tab: ElnExperimentTab, source_entry: ExperimentEntry,
|
|
491
|
+
x_axis: str, y_axis: str) -> ExperimentEntry:
|
|
492
|
+
"""
|
|
493
|
+
Create a bar chart in the experiment based on the contents of the given source entry.
|
|
494
|
+
|
|
495
|
+
:param entry_name: The name of the bar chart.
|
|
496
|
+
:param tab: The tab to create the bar chart in.
|
|
497
|
+
:param source_entry: The source entry to base the bar chart on.
|
|
498
|
+
:param x_axis: The field to use for the x-axis.
|
|
499
|
+
:param y_axis: The field to use for the y-axis.
|
|
500
|
+
:return: The newly created bar chart entry.
|
|
501
|
+
"""
|
|
502
|
+
protocol = self.protocol
|
|
503
|
+
source_step = ElnEntryStep(protocol, source_entry)
|
|
504
|
+
position = ElnEntryPosition(tab.tab_id, self.tab_next_entry_order(tab))
|
|
505
|
+
return ELNStepFactory.create_bar_chart_step(protocol, source_step, entry_name,
|
|
506
|
+
x_axis, y_axis, position=position)[0].eln_entry
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
class ToolOfToolsHelper:
|
|
510
|
+
"""
|
|
511
|
+
A class with helper methods utilized by the Tool of Tools for the creation and updating of experiment tabs that
|
|
512
|
+
track a tool's progress and results.
|
|
513
|
+
"""
|
|
514
|
+
# Contextual info.
|
|
515
|
+
user: SapioUser
|
|
516
|
+
tab_prefix: str
|
|
517
|
+
exp_id: int
|
|
518
|
+
helper: AiHelper
|
|
519
|
+
|
|
520
|
+
# Tool info.
|
|
521
|
+
name: str
|
|
522
|
+
description: str
|
|
523
|
+
results_data_type: str | None
|
|
524
|
+
|
|
525
|
+
# Managers.
|
|
526
|
+
dr_man: DataRecordManager
|
|
527
|
+
eln_man: ElnManager
|
|
528
|
+
|
|
529
|
+
# Stuff created by this helper.
|
|
530
|
+
_initialized: bool
|
|
531
|
+
"""Whether a tab for this tool has been initialized."""
|
|
532
|
+
_new_tab: bool
|
|
533
|
+
"""Whether a new tab was created for this tool."""
|
|
534
|
+
tab: ElnExperimentTab
|
|
535
|
+
"""The tab that contains the tool's entries."""
|
|
536
|
+
description_entry: ElnEntryStep | None
|
|
537
|
+
"""The text entry that displays the description of the tool."""
|
|
538
|
+
description_record: DataRecord | None
|
|
539
|
+
"""The record that stores the description of the tool."""
|
|
540
|
+
progress_entry: ElnEntryStep | None
|
|
541
|
+
"""A hidden entry for tracking the progress of the tool."""
|
|
542
|
+
progress_record: DataRecord | None
|
|
543
|
+
"""The record that stores the progress of the tool."""
|
|
544
|
+
progress_gauge_entry: ElnEntryStep | None
|
|
545
|
+
"""A chart entry that displays the progress of the tool using the hidden progress entry."""
|
|
546
|
+
results_entry: ElnEntryStep | None
|
|
547
|
+
"""An entry for displaying the results of the tool. If None, the tool does not produce result records."""
|
|
548
|
+
|
|
549
|
+
def __init__(self, headers: Mapping[str, str], name: str, description: str,
|
|
550
|
+
results_data_type: str | None = None):
|
|
551
|
+
"""
|
|
552
|
+
:param headers: The headers that were passed to the endpoint.
|
|
553
|
+
:param name: The name of the tool.
|
|
554
|
+
:param description: A description of the tool.
|
|
555
|
+
:param results_data_type: The data type name for the results of the tool. If None, the tool does not produce
|
|
556
|
+
result records.
|
|
557
|
+
"""
|
|
558
|
+
headers: dict[str, str] = format_tot_headers(headers)
|
|
559
|
+
self.user = create_user_from_tot_headers(headers)
|
|
560
|
+
self.exp_id = int(headers[EXP_ID_HEADER.lower()])
|
|
561
|
+
self.tab_prefix = headers[TAB_PREFIX_HEADER.lower()]
|
|
562
|
+
self.helper = AiHelper(self.user, self.exp_id)
|
|
563
|
+
|
|
564
|
+
self.name = name
|
|
565
|
+
self.description = description
|
|
566
|
+
self.results_data_type = results_data_type
|
|
567
|
+
|
|
568
|
+
self.dr_man = DataRecordManager(self.user)
|
|
569
|
+
self.eln_man = ElnManager(self.user)
|
|
570
|
+
|
|
571
|
+
self._initialized = False
|
|
572
|
+
self._new_tab = False
|
|
573
|
+
|
|
574
|
+
@property
|
|
575
|
+
def is_initialized(self) -> bool:
|
|
576
|
+
return self._initialized
|
|
577
|
+
|
|
578
|
+
@property
|
|
579
|
+
def is_new_tab(self) -> bool:
|
|
580
|
+
return self._new_tab
|
|
581
|
+
|
|
582
|
+
def initialize_tab(self) -> ElnExperimentTab:
|
|
583
|
+
if self._initialized:
|
|
584
|
+
return self.tab
|
|
585
|
+
self._initialized = True
|
|
586
|
+
|
|
587
|
+
# Determine if a previous call to this endpoint already created a tab for these results. If so, grab the entries
|
|
588
|
+
# from that tab.
|
|
589
|
+
tab_name: str = f"{self.tab_prefix.strip()} {self.name.strip()}"
|
|
590
|
+
tabs: list[ElnExperimentTab] = self.eln_man.get_tabs_for_experiment(self.exp_id)
|
|
591
|
+
for tab in tabs:
|
|
592
|
+
if tab.tab_name != tab_name:
|
|
593
|
+
continue
|
|
594
|
+
|
|
595
|
+
for entry in self.helper.protocol.get_sorted_step_list():
|
|
596
|
+
if entry.eln_entry.notebook_experiment_tab_id != tab.tab_id:
|
|
597
|
+
continue
|
|
598
|
+
|
|
599
|
+
dt: str = entry.get_data_type_names()[0] if entry.get_data_type_names() else None
|
|
600
|
+
if (entry.eln_entry.entry_type == ElnEntryType.Form
|
|
601
|
+
and ElnBaseDataType.get_base_type(dt) == ElnBaseDataType.EXPERIMENT_DETAIL
|
|
602
|
+
and not hasattr(self, "progress_entry")):
|
|
603
|
+
self.progress_entry = entry
|
|
604
|
+
self.progress_record = entry.get_records()[0]
|
|
605
|
+
elif (entry.eln_entry.entry_type == ElnEntryType.Dashboard
|
|
606
|
+
and not hasattr(self, "progress_gauge_entry")):
|
|
607
|
+
self.progress_gauge_entry = entry
|
|
608
|
+
elif (entry.eln_entry.entry_type == ElnEntryType.Text
|
|
609
|
+
and not hasattr(self, "description_entry")):
|
|
610
|
+
self.description_entry = entry
|
|
611
|
+
self.description_record = entry.get_records()[0]
|
|
612
|
+
elif (entry.eln_entry.entry_type == ElnEntryType.Table
|
|
613
|
+
and dt == self.results_data_type
|
|
614
|
+
and not hasattr(self, "results_entry")):
|
|
615
|
+
self.results_entry = entry
|
|
616
|
+
|
|
617
|
+
if not hasattr(self, "progress_entry"):
|
|
618
|
+
self.progress_entry = None
|
|
619
|
+
self.progress_record = None
|
|
620
|
+
if not hasattr(self, "progress_gauge_entry"):
|
|
621
|
+
self.progress_gauge_entry = None
|
|
622
|
+
if not hasattr(self, "description_entry"):
|
|
623
|
+
self.description_entry = None
|
|
624
|
+
self.description_record = None
|
|
625
|
+
if not hasattr(self, "results_entry"):
|
|
626
|
+
self.results_entry = None
|
|
627
|
+
|
|
628
|
+
self.tab = tab
|
|
629
|
+
return tab
|
|
630
|
+
|
|
631
|
+
# Otherwise, create the tab for the tool progress and results.
|
|
632
|
+
self.tab = self.helper.create_tab(tab_name)
|
|
633
|
+
self._new_tab = True
|
|
634
|
+
|
|
635
|
+
# Create a hidden entry for tracking the progress of the tool.
|
|
636
|
+
field_sets: list[ElnFieldSetInfo] = self.eln_man.get_field_set_info_list()
|
|
637
|
+
progress_field_set: list[ElnFieldSetInfo] = [x for x in field_sets if
|
|
638
|
+
x.field_set_name == "Tool of Tools Progress"]
|
|
639
|
+
if not progress_field_set:
|
|
640
|
+
raise SapioException("Unable to locate the field set for the Tool of Tools progress.")
|
|
641
|
+
progress_entry_crit = _ElnEntryCriteria(ElnEntryType.Form, f"{tab_name} Progress",
|
|
642
|
+
ElnBaseDataType.EXPERIMENT_DETAIL.data_type_name, 1,
|
|
643
|
+
notebook_experiment_tab_id=self.tab.tab_id,
|
|
644
|
+
enb_field_set_id=progress_field_set[0].field_set_id,
|
|
645
|
+
is_hidden=True)
|
|
646
|
+
progress_entry = ElnEntryStep(self.helper.protocol,
|
|
647
|
+
self.eln_man.add_experiment_entry(self.exp_id, progress_entry_crit))
|
|
648
|
+
self.progress_entry = progress_entry
|
|
649
|
+
self.progress_record = progress_entry.get_records()[0]
|
|
650
|
+
|
|
651
|
+
# Hide the progress entry.
|
|
652
|
+
# TODO: Remove once we get this working on entry creation.
|
|
653
|
+
form_update_crit = ElnFormEntryUpdateCriteria()
|
|
654
|
+
form_update_crit.is_hidden = True
|
|
655
|
+
self.eln_man.update_experiment_entry(self.exp_id, self.progress_entry.get_id(), form_update_crit)
|
|
656
|
+
|
|
657
|
+
# Create the text entry that displays the description of the tool. Include the timestamp of when the
|
|
658
|
+
# tool started and format the description so that the text isn't too small to read.
|
|
659
|
+
# TODO: Get the UTC offset in seconds from the header once that's being sent.
|
|
660
|
+
now: str = TimeUtil.now_in_format("%Y-%m-%d %H:%M:%S UTC", "UTC")
|
|
661
|
+
description: str = f"<p>{HtmlFormatter.timestamp(now)}<br>{HtmlFormatter.body(self.description)}</p>"
|
|
662
|
+
text_entry: ElnEntryStep = _ELNStepFactory.create_text_entry(self.helper.protocol, description,
|
|
663
|
+
column_order=0, column_span=2)
|
|
664
|
+
self.description_entry = text_entry
|
|
665
|
+
self.description_record = text_entry.get_records()[0]
|
|
666
|
+
|
|
667
|
+
# Create a gauge entry to display the progress.
|
|
668
|
+
gauge_entry: ElnEntryStep = _ELNStepFactory._create_gauge_chart(self.helper.protocol, progress_entry,
|
|
669
|
+
f"{self.name} Progress", "Progress", "StatusMsg",
|
|
670
|
+
column_order=2, column_span=2, entry_height=250)
|
|
671
|
+
self.progress_gauge_entry = gauge_entry
|
|
672
|
+
|
|
673
|
+
# Make sure the gauge entry isn't too big and stick it to the right of the text entry.
|
|
674
|
+
# TODO: Remove once we get this working on entry creation.
|
|
675
|
+
dash_update_crit = ElnDashboardEntryUpdateCriteria()
|
|
676
|
+
dash_update_crit.entry_height = 250
|
|
677
|
+
self.eln_man.update_experiment_entry(self.exp_id, self.progress_gauge_entry.get_id(), dash_update_crit)
|
|
678
|
+
|
|
679
|
+
# Create a results entry if this tool produces result records.
|
|
680
|
+
if self.results_data_type:
|
|
681
|
+
results_entry = ELNStepFactory.create_table_step(self.helper.protocol, f"{self.name} Results",
|
|
682
|
+
self.results_data_type)
|
|
683
|
+
self.results_entry = results_entry
|
|
684
|
+
else:
|
|
685
|
+
self.results_entry = None
|
|
686
|
+
|
|
687
|
+
return self.tab
|
|
688
|
+
|
|
689
|
+
def add_to_description(self, description: str, auto_format: bool = True) -> None:
|
|
690
|
+
"""
|
|
691
|
+
Add to the description entry of the tool.
|
|
692
|
+
|
|
693
|
+
:param description: The text to add to the description.
|
|
694
|
+
:param auto_format: Whether to automatically format the text to be added.
|
|
695
|
+
"""
|
|
696
|
+
if not self._initialized:
|
|
697
|
+
raise SapioException("The tab for this tool has not been initialized.")
|
|
698
|
+
field: str = ElnBaseDataType.get_text_entry_data_field_name()
|
|
699
|
+
update: str = self.description_record.get_field_value(field)
|
|
700
|
+
if auto_format:
|
|
701
|
+
description = HtmlFormatter.body(description)
|
|
702
|
+
update += f"<p style=\"padding-top: 10px;\">{description}</p>"
|
|
703
|
+
self.description_record.set_field_value(field, update)
|
|
704
|
+
self.dr_man.commit_data_records([self.description_record])
|
|
705
|
+
|
|
706
|
+
def update_progress(self, progress: float, status_msg: str | None = None) -> None:
|
|
707
|
+
"""
|
|
708
|
+
Updates the progress of the tool.
|
|
709
|
+
|
|
710
|
+
:param progress: A value between 0 and 100 representing the progress of the tool.
|
|
711
|
+
:param status_msg: A status message to display to the user alongside the progress gauge.
|
|
712
|
+
"""
|
|
713
|
+
if not self._initialized:
|
|
714
|
+
raise SapioException("The tab for this tool has not been initialized.")
|
|
715
|
+
self.progress_record.set_field_value("Progress", progress)
|
|
716
|
+
self.progress_record.set_field_value("StatusMsg", status_msg)
|
|
717
|
+
self.dr_man.commit_data_records([self.progress_record])
|
|
718
|
+
|
|
719
|
+
def add_results(self, results: list[SapioRecord]) -> None:
|
|
720
|
+
"""
|
|
721
|
+
Add the results of the tool to the results entry.
|
|
722
|
+
|
|
723
|
+
:param results: The result records to add to the results entry.
|
|
724
|
+
"""
|
|
725
|
+
if not self._initialized:
|
|
726
|
+
raise SapioException("The tab for this tool has not been initialized.")
|
|
727
|
+
self.results_entry.add_records(AliasUtil.to_data_records(results))
|
|
728
|
+
|
|
729
|
+
def add_results_bar_chart(self, x_axis: str, y_axis: str) -> ExperimentEntry:
|
|
730
|
+
"""
|
|
731
|
+
Create a bar chart entry for the results of the tool.
|
|
732
|
+
|
|
733
|
+
:param x_axis: The data field to use for the x-axis of the chart.
|
|
734
|
+
:param y_axis: The data field to use for the y-axis of the chart.
|
|
735
|
+
:return: The newly created chart entry.
|
|
736
|
+
"""
|
|
737
|
+
if not self._initialized:
|
|
738
|
+
raise SapioException("The tab for this tool has not been initialized.")
|
|
739
|
+
if not self.results_entry:
|
|
740
|
+
raise SapioException("This tool does not produce result records.")
|
|
741
|
+
return ELNStepFactory.create_bar_chart_step(self.helper.protocol, self.results_entry,
|
|
742
|
+
f"{self.name} Results Chart", x_axis, y_axis)[0].eln_entry
|
|
743
|
+
|
|
744
|
+
def add_attachment_entry(self, entry_name: str, file_name: str, file_data: str | bytes) -> ExperimentEntry:
|
|
745
|
+
"""
|
|
746
|
+
Add a new attachment entry to the experiment with the provided attachment data.
|
|
747
|
+
|
|
748
|
+
:param entry_name: Name of the attachment entry to create in the experiment.
|
|
749
|
+
:param file_name: The name of the attachment.
|
|
750
|
+
:param file_data: The data of the attachment. This can be a string or bytes.
|
|
751
|
+
:return: The newly created attachment entry.
|
|
752
|
+
"""
|
|
753
|
+
if not self._initialized:
|
|
754
|
+
raise SapioException("The tab for this tool has not been initialized.")
|
|
755
|
+
|
|
756
|
+
return self.helper.create_attachment_entry(self.tab, entry_name, file_name, file_data)
|
|
757
|
+
|
|
758
|
+
def add_attachment_entry_from_file(self, entry_name: str, file_path: str) -> ExperimentEntry:
|
|
759
|
+
"""
|
|
760
|
+
Add a new attachment entry to the experiment with the provided file path to a file in the file system.
|
|
761
|
+
|
|
762
|
+
:param entry_name: Name of the attachment entry to create in the experiment.
|
|
763
|
+
:param file_path: The path to a file in the system to attach to the experiment.
|
|
764
|
+
:return: The newly created attachment entry.
|
|
765
|
+
"""
|
|
766
|
+
if not self._initialized:
|
|
767
|
+
raise SapioException("The tab for this tool has not been initialized.")
|
|
768
|
+
|
|
769
|
+
return self.helper.create_attachment_entry_from_file(self.tab, entry_name, file_path)
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
class _ELNStepFactory:
|
|
773
|
+
"""
|
|
774
|
+
Factory that provides simple functions to create a new ELN step under an ELN protocol.
|
|
775
|
+
"""
|
|
776
|
+
@staticmethod
|
|
777
|
+
def create_text_entry(protocol: ElnExperimentProtocol, text_data: str,
|
|
778
|
+
position: ElnEntryPosition | None = None, **kwargs) -> ElnEntryStep:
|
|
779
|
+
"""
|
|
780
|
+
Create a text entry at the end of the protocol, with a initial text specified in the text entry.
|
|
781
|
+
:param protocol: The protocol to create a new step for.
|
|
782
|
+
:param text_data: Must be non-blank. This is what will be displayed. Some HTML format tags can be inserted.
|
|
783
|
+
:param position: The position of the new step. If not specified, the new step will be added at the end.
|
|
784
|
+
:return: The new text entry step.
|
|
785
|
+
"""
|
|
786
|
+
eln_manager, new_entry = _ELNStepFactory._get_entry_creation_criteria(ElnBaseDataType.TEXT_ENTRY_DETAIL.data_type_name,
|
|
787
|
+
protocol, 'Text Entry', ElnEntryType.Text,
|
|
788
|
+
position, **kwargs)
|
|
789
|
+
record = eln_manager.get_data_records_for_entry(protocol.eln_experiment.notebook_experiment_id,
|
|
790
|
+
new_entry.entry_id).result_list[0]
|
|
791
|
+
record.set_field_value(ElnBaseDataType.get_text_entry_data_field_name(), text_data)
|
|
792
|
+
DataMgmtServer.get_data_record_manager(protocol.user).commit_data_records([record])
|
|
793
|
+
ret = ElnEntryStep(protocol, new_entry)
|
|
794
|
+
protocol.invalidate()
|
|
795
|
+
return ret
|
|
796
|
+
|
|
797
|
+
# TODO: Remove this once pylib's gauge chart definition is up to date.
|
|
798
|
+
@staticmethod
|
|
799
|
+
def _create_gauge_chart(protocol: ElnExperimentProtocol, data_source_step: ElnEntryStep, step_name: str,
|
|
800
|
+
field_name: str, status_field: str, group_by_field_name: str = "DataRecordName",
|
|
801
|
+
**kwargs) \
|
|
802
|
+
-> ElnEntryStep:
|
|
803
|
+
"""
|
|
804
|
+
Create a gauge chart step in the experiment protocol.
|
|
805
|
+
"""
|
|
806
|
+
if not data_source_step.get_data_type_names():
|
|
807
|
+
raise ValueError("The data source step did not declare a data type name.")
|
|
808
|
+
data_type_name: str = data_source_step.get_data_type_names()[0]
|
|
809
|
+
series = GaugeChartSeries(data_type_name, field_name)
|
|
810
|
+
series.operation_type = ChartOperationType.VALUE
|
|
811
|
+
chart = _GaugeChartDefinition()
|
|
812
|
+
chart.main_data_type_name = data_type_name
|
|
813
|
+
chart.status_field = status_field
|
|
814
|
+
chart.minimum_value = 0.
|
|
815
|
+
chart.maximum_value = 100.
|
|
816
|
+
chart.series_list = [series]
|
|
817
|
+
chart.grouping_type = ChartGroupingType.GROUP_BY_FIELD
|
|
818
|
+
chart.grouping_type_data_type_name = data_type_name
|
|
819
|
+
chart.grouping_type_data_field_name = group_by_field_name
|
|
820
|
+
dashboard, step = _ELNStepFactory._create_dashboard_step_from_chart(chart, data_source_step, protocol, step_name,
|
|
821
|
+
None, **kwargs)
|
|
822
|
+
protocol.invalidate()
|
|
823
|
+
return step
|
|
824
|
+
|
|
825
|
+
@staticmethod
|
|
826
|
+
def _create_dashboard_step_from_chart(chart: GaugeChartDefinition, data_source_step: ElnEntryStep,
|
|
827
|
+
protocol: ElnExperimentProtocol, step_name: str,
|
|
828
|
+
position: ElnEntryPosition | None = None, **kwargs) -> \
|
|
829
|
+
tuple[DashboardDefinition, ElnEntryStep]:
|
|
830
|
+
dashboard: DashboardDefinition = DashboardDefinition()
|
|
831
|
+
dashboard.chart_definition_list = [chart]
|
|
832
|
+
dashboard.dashboard_scope = DashboardScope.PRIVATE_ELN
|
|
833
|
+
dashboard = DataMgmtServer.get_dashboard_manager(protocol.user).store_dashboard_definition(dashboard)
|
|
834
|
+
eln_manager, new_entry = _ELNStepFactory._get_entry_creation_criteria("", protocol, step_name,
|
|
835
|
+
ElnEntryType.Dashboard, position,
|
|
836
|
+
**kwargs)
|
|
837
|
+
# noinspection PyTypeChecker
|
|
838
|
+
update_criteria = ElnDashboardEntryUpdateCriteria()
|
|
839
|
+
update_criteria.dashboard_guid = dashboard.dashboard_guid
|
|
840
|
+
update_criteria.data_source_entry_id = data_source_step.get_id()
|
|
841
|
+
update_criteria.entry_height = 500
|
|
842
|
+
eln_manager.update_experiment_entry(protocol.eln_experiment.notebook_experiment_id, new_entry.entry_id,
|
|
843
|
+
update_criteria)
|
|
844
|
+
step = ElnEntryStep(protocol, new_entry)
|
|
845
|
+
return dashboard, step
|
|
846
|
+
|
|
847
|
+
@staticmethod
|
|
848
|
+
def _get_entry_creation_criteria(data_type_name: str | None, protocol: ElnExperimentProtocol,
|
|
849
|
+
step_name: str, entry_type: ElnEntryType, position: ElnEntryPosition | None = None,
|
|
850
|
+
**kwargs):
|
|
851
|
+
tab_id: int | None = None
|
|
852
|
+
order: int | None = None
|
|
853
|
+
if position:
|
|
854
|
+
tab_id = position.tab_id
|
|
855
|
+
order = position.order
|
|
856
|
+
# noinspection PyTypeChecker
|
|
857
|
+
last_step: ElnEntryStep = protocol.get_sorted_step_list()[-1]
|
|
858
|
+
if tab_id is None:
|
|
859
|
+
tab_id = last_step.eln_entry.notebook_experiment_tab_id
|
|
860
|
+
if order is None:
|
|
861
|
+
order = last_step.eln_entry.order + 1
|
|
862
|
+
eln_manager = DataMgmtServer.get_eln_manager(protocol.user)
|
|
863
|
+
entry_criteria = _ElnEntryCriteria(entry_type, step_name, data_type_name, order,
|
|
864
|
+
notebook_experiment_tab_id=tab_id, **kwargs)
|
|
865
|
+
new_entry: ExperimentEntry = eln_manager.add_experiment_entry(protocol.eln_experiment.notebook_experiment_id,
|
|
866
|
+
entry_criteria)
|
|
867
|
+
return eln_manager, new_entry
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
# TODO: Using this to set the new status field setting.
|
|
871
|
+
class _GaugeChartDefinition(GaugeChartDefinition):
|
|
872
|
+
status_field: str
|
|
873
|
+
|
|
874
|
+
def to_json(self) -> dict[str, Any]:
|
|
875
|
+
result = super().to_json()
|
|
876
|
+
result["statusValueField"] = {
|
|
877
|
+
"dataTypeName": self.main_data_type_name,
|
|
878
|
+
"dataFieldName": self.status_field
|
|
879
|
+
}
|
|
880
|
+
return result
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
class _ElnEntryCriteria(ElnEntryCriteria):
|
|
884
|
+
is_hidden: bool | None
|
|
885
|
+
entry_height: int | None
|
|
886
|
+
description: str | None
|
|
887
|
+
is_initialization_required: bool | None
|
|
888
|
+
collapse_entry: bool | None
|
|
889
|
+
entry_status: ExperimentEntryStatus | None
|
|
890
|
+
template_item_fulfilled_timestamp: int | None
|
|
891
|
+
|
|
892
|
+
def __init__(self, entry_type: ElnEntryType, entry_name: str | None, data_type_name: str | None, order: int,
|
|
893
|
+
is_hidden: bool | None = None, entry_height: int | None = None, description: str | None = None,
|
|
894
|
+
is_initialization_required: bool | None = None, collapse_entry: bool | None = None,
|
|
895
|
+
entry_status: ExperimentEntryStatus | None = None, template_item_fulfilled_timestamp: int | None = None,
|
|
896
|
+
**kwargs):
|
|
897
|
+
super().__init__(entry_type, entry_name, data_type_name, order, **kwargs)
|
|
898
|
+
self.is_hidden = is_hidden
|
|
899
|
+
self.entry_height = entry_height
|
|
900
|
+
self.description = description
|
|
901
|
+
self.is_initialization_required = is_initialization_required
|
|
902
|
+
self.collapse_entry = collapse_entry
|
|
903
|
+
self.entry_status = entry_status
|
|
904
|
+
self.template_item_fulfilled_timestamp = template_item_fulfilled_timestamp
|
|
905
|
+
|
|
906
|
+
def to_json(self) -> dict[str, Any]:
|
|
907
|
+
ret: dict[str, Any] = super().to_json()
|
|
908
|
+
ret.update({
|
|
909
|
+
"hidden": self.is_hidden,
|
|
910
|
+
"entryHeight": self.entry_height,
|
|
911
|
+
"description": self.description,
|
|
912
|
+
"initializationRequired": self.is_initialization_required,
|
|
913
|
+
"collapsed": self.collapse_entry,
|
|
914
|
+
"entryStatus": self.entry_status,
|
|
915
|
+
"templateItemFulfilledTimestamp": self.template_item_fulfilled_timestamp
|
|
916
|
+
})
|
|
917
|
+
return ret
|