sapiopycommons 2025.2.12a433__tar.gz → 2025.2.14a435__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 sapiopycommons might be problematic. Click here for more details.

Files changed (83) hide show
  1. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/PKG-INFO +1 -1
  2. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/pyproject.toml +1 -1
  3. sapiopycommons-2025.2.14a435/src/sapiopycommons/ai/tool_of_tools.py +689 -0
  4. sapiopycommons-2025.2.14a435/tests/_do_not_add_init_py_here +0 -0
  5. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/.gitignore +0 -0
  6. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/LICENSE +0 -0
  7. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/README.md +0 -0
  8. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/__init__.py +0 -0
  9. {sapiopycommons-2025.2.12a433/src/sapiopycommons/callbacks → sapiopycommons-2025.2.14a435/src/sapiopycommons/ai}/__init__.py +0 -0
  10. {sapiopycommons-2025.2.12a433/src/sapiopycommons/chem → sapiopycommons-2025.2.14a435/src/sapiopycommons/callbacks}/__init__.py +0 -0
  11. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/callbacks/callback_util.py +0 -0
  12. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/callbacks/field_builder.py +0 -0
  13. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/chem/IndigoMolecules.py +0 -0
  14. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/chem/Molecules.py +0 -0
  15. {sapiopycommons-2025.2.12a433/src/sapiopycommons/customreport → sapiopycommons-2025.2.14a435/src/sapiopycommons/chem}/__init__.py +0 -0
  16. {sapiopycommons-2025.2.12a433/src/sapiopycommons/datatype → sapiopycommons-2025.2.14a435/src/sapiopycommons/customreport}/__init__.py +0 -0
  17. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/customreport/auto_pagers.py +0 -0
  18. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/customreport/column_builder.py +0 -0
  19. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/customreport/custom_report_builder.py +0 -0
  20. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/customreport/term_builder.py +0 -0
  21. {sapiopycommons-2025.2.12a433/src/sapiopycommons/eln → sapiopycommons-2025.2.14a435/src/sapiopycommons/datatype}/__init__.py +0 -0
  22. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/datatype/attachment_util.py +0 -0
  23. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/datatype/data_fields.py +0 -0
  24. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/datatype/pseudo_data_types.py +0 -0
  25. {sapiopycommons-2025.2.12a433/src/sapiopycommons/files → sapiopycommons-2025.2.14a435/src/sapiopycommons/eln}/__init__.py +0 -0
  26. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/eln/experiment_handler.py +0 -0
  27. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/eln/experiment_report_util.py +0 -0
  28. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/eln/plate_designer.py +0 -0
  29. {sapiopycommons-2025.2.12a433/src/sapiopycommons/general → sapiopycommons-2025.2.14a435/src/sapiopycommons/files}/__init__.py +0 -0
  30. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/files/complex_data_loader.py +0 -0
  31. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/files/file_bridge.py +0 -0
  32. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/files/file_bridge_handler.py +0 -0
  33. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/files/file_data_handler.py +0 -0
  34. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/files/file_util.py +0 -0
  35. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/files/file_validator.py +0 -0
  36. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/files/file_writer.py +0 -0
  37. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/flowcyto/flow_cyto.py +0 -0
  38. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/flowcyto/flowcyto_data.py +0 -0
  39. {sapiopycommons-2025.2.12a433/src/sapiopycommons/processtracking → sapiopycommons-2025.2.14a435/src/sapiopycommons/general}/__init__.py +0 -0
  40. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/general/accession_service.py +0 -0
  41. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/general/aliases.py +0 -0
  42. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/general/audit_log.py +0 -0
  43. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/general/custom_report_util.py +0 -0
  44. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/general/directive_util.py +0 -0
  45. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/general/exceptions.py +0 -0
  46. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/general/popup_util.py +0 -0
  47. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/general/sapio_links.py +0 -0
  48. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/general/storage_util.py +0 -0
  49. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/general/time_util.py +0 -0
  50. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/multimodal/multimodal.py +0 -0
  51. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/multimodal/multimodal_data.py +0 -0
  52. {sapiopycommons-2025.2.12a433/src/sapiopycommons/recordmodel → sapiopycommons-2025.2.14a435/src/sapiopycommons/processtracking}/__init__.py +0 -0
  53. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/processtracking/custom_workflow_handler.py +0 -0
  54. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/processtracking/endpoints.py +0 -0
  55. {sapiopycommons-2025.2.12a433/src/sapiopycommons/rules → sapiopycommons-2025.2.14a435/src/sapiopycommons/recordmodel}/__init__.py +0 -0
  56. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/recordmodel/record_handler.py +0 -0
  57. {sapiopycommons-2025.2.12a433/src/sapiopycommons/sftpconnect → sapiopycommons-2025.2.14a435/src/sapiopycommons/rules}/__init__.py +0 -0
  58. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/rules/eln_rule_handler.py +0 -0
  59. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/rules/on_save_rule_handler.py +0 -0
  60. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/samples/aliquot.py +0 -0
  61. {sapiopycommons-2025.2.12a433/src/sapiopycommons/webhook → sapiopycommons-2025.2.14a435/src/sapiopycommons/sftpconnect}/__init__.py +0 -0
  62. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/sftpconnect/sftp_builder.py +0 -0
  63. /sapiopycommons-2025.2.12a433/tests/_do_not_add_init_py_here → /sapiopycommons-2025.2.14a435/src/sapiopycommons/webhook/__init__.py +0 -0
  64. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/webhook/webhook_context.py +0 -0
  65. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/webhook/webhook_handlers.py +0 -0
  66. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/src/sapiopycommons/webhook/webservice_handlers.py +0 -0
  67. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/tests/AF-A0A009IHW8-F1-model_v4.cif +0 -0
  68. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/tests/accession_test.py +0 -0
  69. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/tests/aliquot_test.py +0 -0
  70. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/tests/bio_reg_test.py +0 -0
  71. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/tests/chem_test.py +0 -0
  72. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/tests/chem_test_curation_queue.py +0 -0
  73. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/tests/curation_queue_test.sdf +0 -0
  74. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/tests/data_type_models.py +0 -0
  75. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/tests/flowcyto/101_DEN084Y5_15_E01_008_clean.fcs +0 -0
  76. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/tests/flowcyto/101_DEN084Y5_15_E03_009_clean.fcs +0 -0
  77. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/tests/flowcyto/101_DEN084Y5_15_E05_010_clean.fcs +0 -0
  78. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/tests/flowcyto/8_color_ICS.wsp +0 -0
  79. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/tests/flowcyto/COVID19_W_001_O.fcs +0 -0
  80. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/tests/flowcyto_test.py +0 -0
  81. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/tests/kappa.chains.fasta +0 -0
  82. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/tests/mafft_test.py +0 -0
  83. {sapiopycommons-2025.2.12a433 → sapiopycommons-2025.2.14a435}/tests/test.gb +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sapiopycommons
3
- Version: 2025.2.12a433
3
+ Version: 2025.2.14a435
4
4
  Summary: Official Sapio Python API Utilities Package
5
5
  Project-URL: Homepage, https://github.com/sapiosciences
6
6
  Author-email: Jonathan Steck <jsteck@sapiosciences.com>, Yechen Qiao <yqiao@sapiosciences.com>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sapiopycommons"
7
- version='2025.02.12a433'
7
+ version='2025.02.14a435'
8
8
  authors = [
9
9
  { name="Jonathan Steck", email="jsteck@sapiosciences.com" },
10
10
  { name="Yechen Qiao", email="yqiao@sapiosciences.com" },
@@ -0,0 +1,689 @@
1
+ import base64
2
+ import math
3
+ import re
4
+ from typing import Final, Mapping, Any
5
+
6
+ from pandas import DataFrame
7
+ from sapiopylib.rest.DataRecordManagerService import DataRecordManager
8
+ from sapiopylib.rest.ELNService import ElnManager
9
+ from sapiopylib.rest.User import SapioUser
10
+ from sapiopylib.rest.pojo.DataRecord import DataRecord
11
+ from sapiopylib.rest.pojo.chartdata.DashboardDefinition import GaugeChartDefinition
12
+ from sapiopylib.rest.pojo.chartdata.DashboardEnums import ChartGroupingType, ChartOperationType
13
+ from sapiopylib.rest.pojo.chartdata.DashboardSeries import GaugeChartSeries
14
+ from sapiopylib.rest.pojo.datatype.FieldDefinition import AbstractVeloxFieldDefinition, FieldType
15
+ from sapiopylib.rest.pojo.eln.ElnEntryPosition import ElnEntryPosition
16
+ from sapiopylib.rest.pojo.eln.ElnExperiment import ElnExperiment
17
+ from sapiopylib.rest.pojo.eln.ExperimentEntry import ExperimentEntry
18
+ from sapiopylib.rest.pojo.eln.ExperimentEntryCriteria import ElnEntryCriteria, ElnFormEntryUpdateCriteria, \
19
+ ElnDashboardEntryUpdateCriteria, ElnTextEntryUpdateCriteria
20
+ from sapiopylib.rest.pojo.eln.SapioELNEnums import ElnEntryType, ElnBaseDataType
21
+ from sapiopylib.rest.pojo.eln.eln_headings import ElnExperimentTabAddCriteria, ElnExperimentTab
22
+ from sapiopylib.rest.pojo.eln.field_set import ElnFieldSetInfo
23
+ from sapiopylib.rest.utils.ProtocolUtils import ELNStepFactory
24
+ from sapiopylib.rest.utils.Protocols import ElnEntryStep, ElnExperimentProtocol
25
+
26
+ from sapiopycommons.callbacks.field_builder import FieldBuilder
27
+ from sapiopycommons.general.aliases import AliasUtil, SapioRecord
28
+ from sapiopycommons.general.exceptions import SapioException
29
+ from sapiopycommons.general.time_util import TimeUtil
30
+
31
+ CREDENTIALS_HEADER: Final[str] = "SAPIO_APP_API_KEY"
32
+ API_URL_HEADER: Final[str] = "SAPIO_APP_API_URL"
33
+ EXP_ID_HEADER: Final[str] = "EXPERIMENT_ID"
34
+ TAB_PREFIX_HEADER: Final[str] = "TAB_PREFIX"
35
+
36
+
37
+ # FR-47422: Create utility classes and methods to assist the tool of tools.
38
+ def create_tot_headers(url: str, username: str, password: str, experiment_id: int, tab_prefix: str) \
39
+ -> dict[str, str]:
40
+ """
41
+ Create the headers to be passed to a tool of tools endpoint.
42
+
43
+ :param url: The webservice URL of the system to make the changes in.
44
+ :param username: The username of the user making the changes.
45
+ :param password: The password of the user making the changes.
46
+ :param experiment_id: The ID of the experiment to make the changes in.
47
+ :param tab_prefix: The prefix to use for the tab name that will be created by the tool.
48
+ :return: The headers to be passed to the endpoint.
49
+ """
50
+ # Combine the credentials into the format "username:password"
51
+ credentials: str = f"{username}:{password}"
52
+ # Encode the credentials to bytes, then encode them using base64,
53
+ # and finally convert the result back into a string.
54
+ encoded_credentials: str = base64.b64encode(credentials.encode('utf-8')).decode('utf-8')
55
+ headers: dict[str, str] = {
56
+ CREDENTIALS_HEADER: f"Basic {encoded_credentials}",
57
+ API_URL_HEADER: url,
58
+ EXP_ID_HEADER: str(experiment_id),
59
+ TAB_PREFIX_HEADER: tab_prefix
60
+ }
61
+ return headers
62
+
63
+
64
+ def create_user_from_tot_headers(headers: Mapping[str, str]) -> SapioUser:
65
+ """
66
+ Create a SapioUser object from the headers passed to a tool of tools endpoint.
67
+
68
+ :param headers: The headers that were passed to the endpoint.
69
+ :return: A SapioUser object created from the headers that can be used to communicate with the Sapio server.
70
+ """
71
+ headers: dict[str, str] = format_tot_headers(headers)
72
+ credentials = (base64.b64decode(headers[CREDENTIALS_HEADER.lower()].removeprefix("Basic "))
73
+ .decode("utf-8").split(":", 1))
74
+ return SapioUser(headers[API_URL_HEADER.lower()], username=credentials[0], password=credentials[1])
75
+
76
+
77
+ def format_tot_headers(headers: Mapping[str, str]) -> dict[str, str]:
78
+ """
79
+ Format the headers passed to a tool of tools endpoint to guarantee that the keys are lowercase.
80
+
81
+ :param headers: The headers that were passed to the endpoint.
82
+ :return: The headers with all keys converted to lowercase. (Conflicting keys will cause one to overwrite the other,
83
+ but there should not be any conflicting keys in the headers passed to a tool of tools endpoint.)
84
+ """
85
+ return {k.lower(): v for k, v in headers.items()}
86
+
87
+
88
+ class HtmlFormatter:
89
+ """
90
+ A class for formatting text in HTML with tag classes supported by the client.
91
+ """
92
+ TIMESTAMP_TEXT__CSS_CLASS_NAME: Final[str] = "timestamp-text"
93
+ HEADER_1_TEXT__CSS_CLASS_NAME: Final[str] = "header1-text"
94
+ HEADER_2_TEXT__CSS_CLASS_NAME: Final[str] = "header2-text"
95
+ HEADER_3_TEXT__CSS_CLASS_NAME: Final[str] = "header3-text"
96
+ BODY_TEXT__CSS_CLASS_NAME: Final[str] = "body-text"
97
+ CAPTION_TEXT__CSS_CLASS_NAME: Final[str] = "caption-text"
98
+
99
+ @staticmethod
100
+ def timestamp(text: str) -> str:
101
+ """
102
+ Given a text string, return that same text string HTML formatted using the timestamp CSS class.
103
+
104
+ :param text: The text to format.
105
+ :return: The HTML formatted text.
106
+ """
107
+ return f"<span class=\"{HtmlFormatter.TIMESTAMP_TEXT__CSS_CLASS_NAME}\">{text}</span>"
108
+
109
+ @staticmethod
110
+ def header_1(text: str) -> str:
111
+ """
112
+ Given a text string, return that same text string HTML formatted using the header 1 CSS class.
113
+
114
+ :param text: The text to format.
115
+ :return: The HTML formatted text.
116
+ """
117
+ return f"<span class=\"{HtmlFormatter.HEADER_1_TEXT__CSS_CLASS_NAME}\">{text}</span>"
118
+
119
+ @staticmethod
120
+ def header_2(text: str) -> str:
121
+ """
122
+ Given a text string, return that same text string HTML formatted using the header 2 CSS class.
123
+
124
+ :param text: The text to format.
125
+ :return: The HTML formatted text.
126
+ """
127
+ return f"<span class=\"{HtmlFormatter.HEADER_2_TEXT__CSS_CLASS_NAME}\">{text}</span>"
128
+
129
+ @staticmethod
130
+ def header_3(text: str) -> str:
131
+ """
132
+ Given a text string, return that same text string HTML formatted using the header 3 CSS class.
133
+
134
+ :param text: The text to format.
135
+ :return: The HTML formatted text.
136
+ """
137
+ return f"<span class=\"{HtmlFormatter.HEADER_3_TEXT__CSS_CLASS_NAME}\">{text}</span>"
138
+
139
+ @staticmethod
140
+ def body(text: str) -> str:
141
+ """
142
+ Given a text string, return that same text string HTML formatted using the body CSS class.
143
+
144
+ :param text: The text to format.
145
+ :return: The HTML formatted text.
146
+ """
147
+ return f"<span class=\"{HtmlFormatter.BODY_TEXT__CSS_CLASS_NAME}\">{text}</span>"
148
+
149
+ @staticmethod
150
+ def caption(text: str) -> str:
151
+ """
152
+ Given a text string, return that same text string HTML formatted using the caption CSS class.
153
+
154
+ :param text: The text to format.
155
+ :return: The HTML formatted text.
156
+ """
157
+ return f"<span class=\"{HtmlFormatter.CAPTION_TEXT__CSS_CLASS_NAME}\">{text}</span>"
158
+
159
+ @staticmethod
160
+ def replace_newlines(text: str) -> str:
161
+ """
162
+ Given a text string, return that same text string HTML formatted with newlines replaced by HTML line breaks.
163
+
164
+ :param text: The text to format.
165
+ :return: The HTML formatted text.
166
+ """
167
+ return re.sub("\r?\n", "<br>", text)
168
+
169
+
170
+ class AiHelper:
171
+ """
172
+ A class with helper methods for the AI to make use of when creating/updating experiment tabs and entries.
173
+ """
174
+ # Contextual info.
175
+ user: SapioUser
176
+ exp_id: int
177
+
178
+ # Managers.
179
+ dr_man: DataRecordManager
180
+ eln_man: ElnManager
181
+
182
+ def __init__(self, user: SapioUser, exp_id: int):
183
+ """
184
+ :param user: The user to send the requests from.
185
+ :param exp_id: The ID of the experiment to create the entries in.
186
+ """
187
+ self.user = user
188
+ self.exp_id = exp_id
189
+
190
+ self.dr_man = DataRecordManager(self.user)
191
+ self.eln_man = ElnManager(self.user)
192
+
193
+ @property
194
+ def protocol(self) -> ElnExperimentProtocol:
195
+ """
196
+ :return: An experiment protocol object for this helper's experiment. (Recreating a new protocol object every
197
+ time this is called since the protocol's cache could be invalidated by things that the AI is doing.)
198
+ """
199
+ # The experiment name and record ID aren't necessary to know for our purposes.
200
+ return ElnExperimentProtocol(ElnExperiment(self.exp_id, "", 0), self.user)
201
+
202
+ def create_tab(self, name: str) -> ElnExperimentTab:
203
+ """
204
+ Create a new tab in the experiment.
205
+
206
+ :param name: The name of the tab to create.
207
+ :return: The newly created tab.
208
+ """
209
+ tab_crit = ElnExperimentTabAddCriteria(name, [])
210
+ return self.eln_man.add_tab_for_experiment(self.exp_id, tab_crit)
211
+
212
+ def tab_next_entry_order(self, tab: ElnExperimentTab) -> int:
213
+ """
214
+ :param tab: A tab in this helper's experiment.
215
+ :return: The order that the next entry that gets created in the tab should have.
216
+ """
217
+ max_order: int = 0
218
+ for step in self.protocol.get_sorted_step_list():
219
+ if step.eln_entry.notebook_experiment_tab_id == tab.tab_id and step.eln_entry.order > max_order:
220
+ max_order = step.eln_entry.order
221
+ return max_order + 1
222
+
223
+ def create_experiment_details_from_data_frame(self,
224
+ tab: ElnExperimentTab,
225
+ entry_name: str,
226
+ df: DataFrame) -> ExperimentEntry | None:
227
+ """
228
+ Create an experiment detail entry from a DataFrame.
229
+
230
+ :param tab: The tab that the entry should be added to.
231
+ :param entry_name: The name of the entry.
232
+ :param df: The DataFrame to create the entry from.
233
+ :return: The newly created experiment detail entry.
234
+ """
235
+ json_list: list[dict[str, Any]] = []
236
+ for _, row in df.iterrows():
237
+ json_list.append(row.to_dict())
238
+ return self.create_experiment_details_from_json(tab, entry_name, json_list)
239
+
240
+ def create_experiment_details_from_json(self,
241
+ tab: ElnExperimentTab,
242
+ entry_name: str,
243
+ json_list: list[dict[str, Any]]) -> ExperimentEntry | None:
244
+ """
245
+ Create an experiment detail entry from a list of JSON dictionaries.
246
+
247
+ :param tab: The tab that the entry should be added to.
248
+ :param entry_name: The name of the entry.
249
+ :param json_list: The list of JSON dictionaries to create the entry from. Each dictionary is expected to have the
250
+ same keys.
251
+ :return: The newly created experiment detail entry.
252
+ """
253
+ if not json_list:
254
+ return None
255
+
256
+ # Determine which fields in the JSON can be used to create field definitions.
257
+ fb = FieldBuilder()
258
+ fields: list[AbstractVeloxFieldDefinition] = []
259
+ fields_by_name: dict[str, AbstractVeloxFieldDefinition] = {}
260
+ for key, value in json_list[0].items():
261
+ field_name: str = key.replace(" ", "_")
262
+ if isinstance(value, str):
263
+ field = fb.string_field(field_name, display_name=key)
264
+ fields.append(field)
265
+ fields_by_name[key] = field
266
+ elif isinstance(value, (int, float)):
267
+ field = fb.double_field(field_name, display_name=key, precision=3)
268
+ fields.append(field)
269
+ fields_by_name[key] = field
270
+
271
+ # Extract the valid field values from the JSON.
272
+ field_maps: list[dict[str, Any]] = []
273
+ for json_dict in json_list:
274
+ field_map: dict[str, Any] = {}
275
+ for key, field in fields_by_name.items():
276
+ # Watch out for NaN values or other special values.
277
+ val: Any = json_dict.get(key)
278
+ if (field.data_field_type == FieldType.DOUBLE
279
+ and (not isinstance(val, (int, float))) or (isinstance(val, float) and math.isnan(val))):
280
+ val = None
281
+ field_map[field.data_field_name] = val
282
+ field_maps.append(field_map)
283
+
284
+ detail_entry = ElnEntryCriteria(ElnEntryType.Table, entry_name,
285
+ ElnBaseDataType.EXPERIMENT_DETAIL.data_type_name,
286
+ self.tab_next_entry_order(tab),
287
+ notebook_experiment_tab_id=tab.tab_id,
288
+ field_definition_list=fields)
289
+ entry = self.eln_man.add_experiment_entry(self.exp_id, detail_entry)
290
+ self.dr_man.add_data_records_with_data(entry.data_type_name, field_maps)
291
+ return entry
292
+
293
+ def create_text_entry(self, tab: ElnExperimentTab, timestamp: str, description: str, auto_format: bool = True) \
294
+ -> ExperimentEntry:
295
+ """
296
+ Create a new text entry in the experiment.
297
+
298
+ :param tab: The tab to create the text entry in.
299
+ :param timestamp: The timestamp to display at the top of the text entry.
300
+ :param description: The description to display in the text entry.
301
+ :param auto_format: Whether to automatically format the text to be added.
302
+ :return: The newly created text entry.
303
+ """
304
+ if auto_format:
305
+ description: str = f"<p>{HtmlFormatter.timestamp(timestamp)}<br>{HtmlFormatter.body(description)}</p>"
306
+ else:
307
+ description: str = f"<p>{timestamp}<br>{description}</p>"
308
+ position = ElnEntryPosition(tab.tab_id, self.tab_next_entry_order(tab))
309
+ text_entry: ElnEntryStep = ELNStepFactory.create_text_entry(self.protocol, description, position)
310
+ return text_entry.eln_entry
311
+
312
+ def set_text_entry(self, text_entry: ExperimentEntry, timestamp: str, description: str,
313
+ auto_format: bool = True) -> None:
314
+ """
315
+ Set the text of a text entry.
316
+
317
+ :param text_entry: The text entry to set the text of.
318
+ :param timestamp: The timestamp to display at the top of the text entry.
319
+ :param description: The description to display in the text entry.
320
+ :param auto_format: Whether to automatically format the text to be added.
321
+ """
322
+ if auto_format:
323
+ timestamp = HtmlFormatter.timestamp(timestamp)
324
+ description = HtmlFormatter.body(description)
325
+ description: str = f"<p>{timestamp}<br>{description}</p>"
326
+ step = ElnEntryStep(self.protocol, text_entry)
327
+ text_record: DataRecord = step.get_records()[0]
328
+ text_record.set_field_value(ElnBaseDataType.get_text_entry_data_field_name(), description)
329
+ self.dr_man.commit_data_records([text_record])
330
+
331
+ def add_to_text_entry(self, text_entry: ExperimentEntry, description: str, auto_format: bool = True) -> None:
332
+ """
333
+ Add to the text of a text entry.
334
+
335
+ :param text_entry: The text entry to add the text to.
336
+ :param description: The text to add to the text entry.
337
+ :param auto_format: Whether to automatically format the text to be added.
338
+ """
339
+ step = ElnEntryStep(self.protocol, text_entry)
340
+ text_record: DataRecord = step.get_records()[0]
341
+ update: str = text_record.get_field_value(ElnBaseDataType.get_text_entry_data_field_name())
342
+ if auto_format:
343
+ description = HtmlFormatter.body(description)
344
+ update += f"<p style=\"padding-top: 10px;\">{description}</p>"
345
+ text_record.set_field_value(ElnBaseDataType.get_text_entry_data_field_name(), update)
346
+ self.dr_man.commit_data_records([text_record])
347
+
348
+ def create_attachment_entry(self, tab: ElnExperimentTab, entry_name: str, file_name: str, file_data: str | bytes) \
349
+ -> ExperimentEntry:
350
+ """
351
+ Add a new attachment entry to the experiment with the provided attachment data.
352
+
353
+ :param tab: The tab where the attachment entry will be added.
354
+ :param entry_name: Name of the attachment entry to create in the experiment.
355
+ :param file_name: The name of the attachment.
356
+ :param file_data: The data of the attachment. This can be a string or bytes.
357
+ :return: The newly created attachment entry.
358
+ """
359
+ tab_id: int = tab.tab_id
360
+
361
+ # Encode the file contents in base64.
362
+ if isinstance(file_data, str):
363
+ file_data: bytes = file_data.encode("utf-8")
364
+ base64_encoded: str = base64.b64encode(file_data).decode("utf-8")
365
+
366
+ # Crete an attachment entry with the provided data.
367
+ attachment_entry = self.eln_man.add_experiment_entry(
368
+ self.exp_id,
369
+ ElnEntryCriteria(ElnEntryType.Attachment, entry_name, "Attachment", order=2,
370
+ notebook_experiment_tab_id=tab_id, attachment_file_name=file_name,
371
+ attachment_data_base64=base64_encoded)
372
+ )
373
+
374
+ # Return the entry object for further use.
375
+ return attachment_entry
376
+
377
+ def create_attachment_entry_from_file(self, tab: ElnExperimentTab, entry_name: str, file_path: str) \
378
+ -> ExperimentEntry:
379
+ """
380
+ Add a new attachment entry to the experiment with the provided file path to a file in the file system.
381
+
382
+ :param tab: The tab where the attachment entry will be added.
383
+ :param entry_name: Name of the attachment entry to create in the experiment.
384
+ :param file_path: The path to a file in the system to attach to the experiment.
385
+ :return: The newly created attachment entry.
386
+ """
387
+ with open(file_path, 'rb') as f:
388
+ file_contents: bytes = f.read()
389
+ return self.create_attachment_entry(tab, entry_name, file_path, file_contents)
390
+
391
+
392
+ class ToolOfToolsHelper:
393
+ """
394
+ A class with helper methods utilized by the Tool of Tools for the creation and updating of experiment tabs that
395
+ track a tool's progress and results.
396
+ """
397
+ # Contextual info.
398
+ user: SapioUser
399
+ tab_prefix: str
400
+ exp_id: int
401
+ helper: AiHelper
402
+
403
+ # Tool info.
404
+ name: str
405
+ description: str
406
+ results_data_type: str | None
407
+
408
+ # Managers.
409
+ dr_man: DataRecordManager
410
+ eln_man: ElnManager
411
+
412
+ # Stuff created by this helper.
413
+ _initialized: bool
414
+ """Whether a tab for this tool has been initialized."""
415
+ tab: ElnExperimentTab
416
+ """The tab that contains the tool's entries."""
417
+ description_entry: ElnEntryStep | None
418
+ """The text entry that displays the description of the tool."""
419
+ description_record: DataRecord | None
420
+ """The record that stores the description of the tool."""
421
+ progress_entry: ElnEntryStep | None
422
+ """A hidden entry for tracking the progress of the tool."""
423
+ progress_record: DataRecord | None
424
+ """The record that stores the progress of the tool."""
425
+ progress_gauge_entry: ElnEntryStep | None
426
+ """A chart entry that displays the progress of the tool using the hidden progress entry."""
427
+ results_entry: ElnEntryStep | None
428
+ """An entry for displaying the results of the tool. If None, the tool does not produce result records."""
429
+
430
+ def __init__(self, headers: Mapping[str, str], name: str, description: str,
431
+ results_data_type: str | None = None):
432
+ """
433
+ :param headers: The headers that were passed to the endpoint.
434
+ :param name: The name of the tool.
435
+ :param description: A description of the tool.
436
+ :param results_data_type: The data type name for the results of the tool. If None, the tool does not produce
437
+ result records.
438
+ """
439
+ headers: dict[str, str] = format_tot_headers(headers)
440
+ self.user = create_user_from_tot_headers(headers)
441
+ self.exp_id = int(headers[EXP_ID_HEADER.lower()])
442
+ self.tab_prefix = headers[TAB_PREFIX_HEADER.lower()]
443
+ self.helper = AiHelper(self.user, self.exp_id)
444
+
445
+ self.name = name
446
+ self.description = description
447
+ self.results_data_type = results_data_type
448
+
449
+ self.dr_man = DataRecordManager(self.user)
450
+ self.eln_man = ElnManager(self.user)
451
+
452
+ self._initialized = False
453
+
454
+ def initialize_tab(self) -> ElnExperimentTab:
455
+ if self._initialized:
456
+ return self.tab
457
+ self._initialized = True
458
+
459
+ # Determine if a previous call to this endpoint already created a tab for these results. If so, grab the entries
460
+ # from that tab.
461
+ tab_name: str = f"{self.tab_prefix.strip()} {self.name.strip()}"
462
+ tabs: list[ElnExperimentTab] = self.eln_man.get_tabs_for_experiment(self.exp_id)
463
+ for tab in tabs:
464
+ if tab.tab_name != tab_name:
465
+ continue
466
+
467
+ for entry in self.helper.protocol.get_sorted_step_list():
468
+ if entry.eln_entry.notebook_experiment_tab_id != tab.tab_id:
469
+ continue
470
+
471
+ dt: str = entry.get_data_type_names()[0] if entry.get_data_type_names() else None
472
+ if (entry.eln_entry.entry_type == ElnEntryType.Form
473
+ and ElnBaseDataType.get_base_type(dt) == ElnBaseDataType.EXPERIMENT_DETAIL
474
+ and not hasattr(self, "progress_entry")):
475
+ self.progress_entry = entry
476
+ self.progress_record = entry.get_records()[0]
477
+ elif (entry.eln_entry.entry_type == ElnEntryType.Dashboard
478
+ and not hasattr(self, "progress_gauge_entry")):
479
+ self.progress_gauge_entry = entry
480
+ elif (entry.eln_entry.entry_type == ElnEntryType.Text
481
+ and not hasattr(self, "description_entry")):
482
+ self.description_entry = entry
483
+ self.description_record = entry.get_records()[0]
484
+ elif (entry.eln_entry.entry_type == ElnEntryType.Table
485
+ and dt == self.results_data_type
486
+ and not hasattr(self, "results_entry")):
487
+ self.results_entry = entry
488
+
489
+ if not hasattr(self, "progress_entry"):
490
+ self.progress_entry = None
491
+ self.progress_record = None
492
+ if not hasattr(self, "progress_gauge_entry"):
493
+ self.progress_gauge_entry = None
494
+ if not hasattr(self, "description_entry"):
495
+ self.description_entry = None
496
+ self.description_record = None
497
+ if not hasattr(self, "results_entry"):
498
+ self.results_entry = None
499
+
500
+ self.tab = tab
501
+ return tab
502
+
503
+ # Otherwise, create the tab for the tool progress and results.
504
+ self.tab = self.helper.create_tab(tab_name)
505
+
506
+ # Create a hidden entry for tracking the progress of the tool.
507
+ field_sets: list[ElnFieldSetInfo] = self.eln_man.get_field_set_info_list()
508
+ progress_field_set: list[ElnFieldSetInfo] = [x for x in field_sets if
509
+ x.field_set_name == "Tool of Tools Progress"]
510
+ if not progress_field_set:
511
+ raise SapioException("Unable to locate the field set for the Tool of Tools progress.")
512
+ progress_entry_crit = ElnEntryCriteria(ElnEntryType.Form, f"ELaiN: {self.name} Progress",
513
+ ElnBaseDataType.EXPERIMENT_DETAIL.data_type_name, 1,
514
+ notebook_experiment_tab_id=self.tab.tab_id,
515
+ enb_field_set_id=progress_field_set[0].field_set_id)
516
+ progress_entry = ElnEntryStep(self.helper.protocol,
517
+ self.eln_man.add_experiment_entry(self.exp_id, progress_entry_crit))
518
+ self.progress_entry = progress_entry
519
+ self.progress_record = progress_entry.get_records()[0]
520
+
521
+ # Hide the progress entry.
522
+ form_update_crit = ElnFormEntryUpdateCriteria()
523
+ form_update_crit.is_hidden = True
524
+ self.eln_man.update_experiment_entry(self.exp_id, self.progress_entry.get_id(), form_update_crit)
525
+
526
+ # Create the text entry that displays the description of the tool. Include the timestamp of when the
527
+ # tool started and format the description so that the text isn't too small to read.
528
+ # TODO: Get the UTC offset in seconds from the header once that's being sent.
529
+ now: str = TimeUtil.now_in_format("%Y-%m-%d %H:%M:%S UTC", "UTC")
530
+ text_entry = ElnEntryStep(self.helper.protocol, self.helper.create_text_entry(self.tab, now, self.description))
531
+ self.description_entry = text_entry
532
+ self.description_record = text_entry.get_records()[0]
533
+
534
+ # Shrink the text entry by one column.
535
+ text_update_crit = ElnTextEntryUpdateCriteria()
536
+ text_update_crit.column_order = 0
537
+ text_update_crit.column_span = 2
538
+ self.eln_man.update_experiment_entry(self.exp_id, self.description_entry.get_id(), text_update_crit)
539
+
540
+ # Create a gauge entry to display the progress.
541
+ gauge_entry: ElnEntryStep = self._create_gauge_chart(self.helper.protocol, progress_entry,
542
+ f"{self.name} Progress", "Progress", "StatusMsg")
543
+ self.progress_gauge_entry = gauge_entry
544
+
545
+ # Make sure the gauge entry isn't too big and stick it to the right of the text entry.
546
+ dash_update_crit = ElnDashboardEntryUpdateCriteria()
547
+ dash_update_crit.entry_height = 250
548
+ dash_update_crit.column_order = 2
549
+ dash_update_crit.column_span = 2
550
+ self.eln_man.update_experiment_entry(self.exp_id, self.progress_gauge_entry.get_id(), dash_update_crit)
551
+
552
+ # TODO: Bulk updates aren't working?
553
+ # self.eln_man.update_experiment_entries(self.exp_id, {
554
+ # self.progress_entry.get_id(): form_update_crit,
555
+ # self.progress_gauge_entry.get_id(): dash_update_crit,
556
+ # self.description_entry.get_id(): text_update_crit
557
+ # })
558
+
559
+ # Create a results entry if this tool produces result records.
560
+ if self.results_data_type:
561
+ results_entry = ELNStepFactory.create_table_step(self.helper.protocol, f"{self.name} Results",
562
+ self.results_data_type)
563
+ self.results_entry = results_entry
564
+ else:
565
+ self.results_entry = None
566
+
567
+ return self.tab
568
+
569
+ def add_to_description(self, description: str, auto_format: bool = True) -> None:
570
+ """
571
+ Add to the description entry of the tool.
572
+
573
+ :param description: The text to add to the description.
574
+ :param auto_format: Whether to automatically format the text to be added.
575
+ """
576
+ if not self._initialized:
577
+ raise SapioException("The tab for this tool has not been initialized.")
578
+ field: str = ElnBaseDataType.get_text_entry_data_field_name()
579
+ update: str = self.description_record.get_field_value(field)
580
+ if auto_format:
581
+ description = HtmlFormatter.body(description)
582
+ update += f"<p style=\"padding-top: 10px;\">{description}</p>"
583
+ self.description_record.set_field_value(field, update)
584
+ self.dr_man.commit_data_records([self.description_record])
585
+
586
+ def update_progress(self, progress: float, status_msg: str | None = None) -> None:
587
+ """
588
+ Updates the progress of the tool.
589
+
590
+ :param progress: A value between 0 and 100 representing the progress of the tool.
591
+ :param status_msg: A status message to display to the user alongside the progress gauge.
592
+ """
593
+ if not self._initialized:
594
+ raise SapioException("The tab for this tool has not been initialized.")
595
+ self.progress_record.set_field_value("Progress", progress)
596
+ self.progress_record.set_field_value("StatusMsg", status_msg)
597
+ self.dr_man.commit_data_records([self.progress_record])
598
+
599
+ def add_results(self, results: list[SapioRecord]) -> None:
600
+ """
601
+ Add the results of the tool to the results entry.
602
+
603
+ :param results: The result records to add to the results entry.
604
+ """
605
+ if not self._initialized:
606
+ raise SapioException("The tab for this tool has not been initialized.")
607
+ self.results_entry.add_records(AliasUtil.to_data_records(results))
608
+
609
+ def add_results_bar_chart(self, x_axis: str, y_axis: str) -> ExperimentEntry:
610
+ """
611
+ Create a bar chart entry for the results of the tool.
612
+
613
+ :param x_axis: The data field to use for the x-axis of the chart.
614
+ :param y_axis: The data field to use for the y-axis of the chart.
615
+ :return: The newly created chart entry.
616
+ """
617
+ if not self._initialized:
618
+ raise SapioException("The tab for this tool has not been initialized.")
619
+ if not self.results_entry:
620
+ raise SapioException("This tool does not produce result records.")
621
+ return ELNStepFactory.create_bar_chart_step(self.helper.protocol, self.results_entry,
622
+ f"{self.name} Results Chart", x_axis, y_axis)[0].eln_entry
623
+
624
+ def add_attachment_entry(self, entry_name: str, file_name: str, file_data: str | bytes) -> ExperimentEntry:
625
+ """
626
+ Add a new attachment entry to the experiment with the provided attachment data.
627
+
628
+ :param entry_name: Name of the attachment entry to create in the experiment.
629
+ :param file_name: The name of the attachment.
630
+ :param file_data: The data of the attachment. This can be a string or bytes.
631
+ :return: The newly created attachment entry.
632
+ """
633
+ if not self._initialized:
634
+ raise SapioException("The tab for this tool has not been initialized.")
635
+
636
+ return self.helper.create_attachment_entry(self.tab, entry_name, file_name, file_data)
637
+
638
+ def add_attachment_entry_from_file(self, entry_name: str, file_path: str) -> ExperimentEntry:
639
+ """
640
+ Add a new attachment entry to the experiment with the provided file path to a file in the file system.
641
+
642
+ :param entry_name: Name of the attachment entry to create in the experiment.
643
+ :param file_path: The path to a file in the system to attach to the experiment.
644
+ :return: The newly created attachment entry.
645
+ """
646
+ if not self._initialized:
647
+ raise SapioException("The tab for this tool has not been initialized.")
648
+
649
+ return self.helper.create_attachment_entry_from_file(self.tab, entry_name, file_path)
650
+
651
+ # TODO: Remove this once pylib's gauge chart definition is up to date.
652
+ @staticmethod
653
+ def _create_gauge_chart(protocol: ElnExperimentProtocol, data_source_step: ElnEntryStep, step_name: str,
654
+ field_name: str, status_field: str, group_by_field_name: str = "DataRecordName") \
655
+ -> ElnEntryStep:
656
+ """
657
+ Create a gauge chart step in the experiment protocol.
658
+ """
659
+ if not data_source_step.get_data_type_names():
660
+ raise ValueError("The data source step did not declare a data type name.")
661
+ data_type_name: str = data_source_step.get_data_type_names()[0]
662
+ series = GaugeChartSeries(data_type_name, field_name)
663
+ series.operation_type = ChartOperationType.VALUE
664
+ chart = _GaugeChartDefinition()
665
+ chart.main_data_type_name = data_type_name
666
+ chart.status_field = status_field
667
+ chart.minimum_value = 0.
668
+ chart.maximum_value = 100.
669
+ chart.series_list = [series]
670
+ chart.grouping_type = ChartGroupingType.GROUP_BY_FIELD
671
+ chart.grouping_type_data_type_name = data_type_name
672
+ chart.grouping_type_data_field_name = group_by_field_name
673
+ dashboard, step = ELNStepFactory._create_dashboard_step_from_chart(chart, data_source_step, protocol, step_name,
674
+ None)
675
+ protocol.invalidate()
676
+ return step
677
+
678
+
679
+ # TODO: Using this to set the new status field setting.
680
+ class _GaugeChartDefinition(GaugeChartDefinition):
681
+ status_field: str
682
+
683
+ def to_json(self) -> dict[str, Any]:
684
+ result = super().to_json()
685
+ result["statusValueField"] = {
686
+ "dataTypeName": self.main_data_type_name,
687
+ "dataFieldName": self.status_field
688
+ }
689
+ return result