sapiopycommons 2025.7.31a664__tar.gz → 2025.8.1a670__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 (93) hide show
  1. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/PKG-INFO +2 -2
  2. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/pyproject.toml +2 -2
  3. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/ai/tool_of_tools.py +235 -127
  4. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/callbacks/callback_util.py +21 -14
  5. sapiopycommons-2025.8.1a670/src/sapiopycommons/files/assay_plate_reader.py +93 -0
  6. sapiopycommons-2025.8.1a670/src/sapiopycommons/files/file_text_converter.py +207 -0
  7. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/flowcyto/flow_cyto.py +2 -24
  8. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/general/accession_service.py +2 -28
  9. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/multimodal/multimodal.py +2 -24
  10. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/tests/accession_test.py +1 -1
  11. sapiopycommons-2025.8.1a670/tests/assay_plate_reader/BMGLabtech96.txt +28 -0
  12. sapiopycommons-2025.8.1a670/tests/assay_plate_reader/assay_plate_processing_test.py +43 -0
  13. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/tests/mafft_test.py +1 -1
  14. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/.gitignore +0 -0
  15. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/LICENSE +0 -0
  16. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/README.md +0 -0
  17. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/__init__.py +0 -0
  18. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/ai/__init__.py +0 -0
  19. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/callbacks/__init__.py +0 -0
  20. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/callbacks/field_builder.py +0 -0
  21. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/chem/IndigoMolecules.py +0 -0
  22. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/chem/Molecules.py +0 -0
  23. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/chem/__init__.py +0 -0
  24. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/customreport/__init__.py +0 -0
  25. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/customreport/auto_pagers.py +0 -0
  26. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/customreport/column_builder.py +0 -0
  27. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/customreport/custom_report_builder.py +0 -0
  28. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/customreport/term_builder.py +0 -0
  29. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/datatype/__init__.py +0 -0
  30. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/datatype/attachment_util.py +0 -0
  31. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/datatype/data_fields.py +0 -0
  32. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/datatype/pseudo_data_types.py +0 -0
  33. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/eln/__init__.py +0 -0
  34. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/eln/experiment_cache.py +0 -0
  35. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/eln/experiment_handler.py +0 -0
  36. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/eln/experiment_report_util.py +0 -0
  37. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/eln/experiment_step_factory.py +0 -0
  38. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/eln/experiment_tags.py +0 -0
  39. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/eln/plate_designer.py +0 -0
  40. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/eln/step_creation.py +0 -0
  41. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/files/__init__.py +0 -0
  42. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/files/complex_data_loader.py +0 -0
  43. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/files/file_bridge.py +0 -0
  44. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/files/file_bridge_handler.py +0 -0
  45. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/files/file_data_handler.py +0 -0
  46. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/files/file_util.py +0 -0
  47. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/files/file_validator.py +0 -0
  48. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/files/file_writer.py +0 -0
  49. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/flowcyto/flowcyto_data.py +0 -0
  50. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/general/__init__.py +0 -0
  51. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/general/aliases.py +0 -0
  52. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/general/audit_log.py +0 -0
  53. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/general/custom_report_util.py +0 -0
  54. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/general/data_structure_util.py +0 -0
  55. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/general/directive_util.py +0 -0
  56. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/general/exceptions.py +0 -0
  57. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/general/html_formatter.py +0 -0
  58. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/general/popup_util.py +0 -0
  59. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/general/sapio_links.py +0 -0
  60. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/general/storage_util.py +0 -0
  61. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/general/time_util.py +0 -0
  62. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/multimodal/multimodal_data.py +0 -0
  63. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/processtracking/__init__.py +0 -0
  64. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/processtracking/custom_workflow_handler.py +0 -0
  65. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/processtracking/endpoints.py +0 -0
  66. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/recordmodel/__init__.py +0 -0
  67. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/recordmodel/record_handler.py +0 -0
  68. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/rules/__init__.py +0 -0
  69. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/rules/eln_rule_handler.py +0 -0
  70. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/rules/on_save_rule_handler.py +0 -0
  71. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/samples/aliquot.py +0 -0
  72. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/sftpconnect/__init__.py +0 -0
  73. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/sftpconnect/sftp_builder.py +0 -0
  74. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/webhook/__init__.py +0 -0
  75. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/webhook/webhook_context.py +0 -0
  76. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/webhook/webhook_handlers.py +0 -0
  77. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/src/sapiopycommons/webhook/webservice_handlers.py +0 -0
  78. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/tests/AF-A0A009IHW8-F1-model_v4.cif +0 -0
  79. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/tests/_do_not_add_init_py_here +0 -0
  80. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/tests/aliquot_test.py +0 -0
  81. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/tests/bio_reg_test.py +0 -0
  82. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/tests/chem_test.py +0 -0
  83. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/tests/chem_test_curation_queue.py +0 -0
  84. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/tests/curation_queue_test.sdf +0 -0
  85. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/tests/data_type_models.py +0 -0
  86. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/tests/flowcyto/101_DEN084Y5_15_E01_008_clean.fcs +0 -0
  87. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/tests/flowcyto/101_DEN084Y5_15_E03_009_clean.fcs +0 -0
  88. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/tests/flowcyto/101_DEN084Y5_15_E05_010_clean.fcs +0 -0
  89. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/tests/flowcyto/8_color_ICS.wsp +0 -0
  90. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/tests/flowcyto/COVID19_W_001_O.fcs +0 -0
  91. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/tests/flowcyto_test.py +0 -0
  92. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/tests/kappa.chains.fasta +0 -0
  93. {sapiopycommons-2025.7.31a664 → sapiopycommons-2025.8.1a670}/tests/test.gb +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sapiopycommons
3
- Version: 2025.7.31a664
3
+ Version: 2025.8.1a670
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>
@@ -17,7 +17,7 @@ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
17
17
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
18
  Requires-Python: >=3.10
19
19
  Requires-Dist: databind>=4.5
20
- Requires-Dist: sapiopylib>=2025.4.17.264
20
+ Requires-Dist: sapiopylib>=2025.7.31a279
21
21
  Description-Content-Type: text/markdown
22
22
 
23
23
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sapiopycommons"
7
- version='2025.07.31a664'
7
+ version='2025.08.01a670'
8
8
  authors = [
9
9
  { name="Jonathan Steck", email="jsteck@sapiosciences.com" },
10
10
  { name="Yechen Qiao", email="yqiao@sapiosciences.com" },
@@ -14,7 +14,7 @@ license = "MPL-2.0"
14
14
  readme = "README.md"
15
15
  requires-python = ">=3.10"
16
16
  dependencies = [
17
- 'sapiopylib>=2025.4.17.264', 'databind>=4.5'
17
+ 'sapiopylib>=2025.7.31a279', 'databind>=4.5'
18
18
  ]
19
19
  classifiers = [
20
20
  "Intended Audience :: Developers",
@@ -1,30 +1,31 @@
1
1
  import base64
2
2
  import io
3
3
  import math
4
- import re
5
4
  from typing import Final, Mapping, Any
6
5
 
7
6
  import requests
8
7
  from pandas import DataFrame
9
8
  from requests import Response
9
+ from sapiopylib.rest.DataMgmtService import DataMgmtServer
10
10
  from sapiopylib.rest.DataRecordManagerService import DataRecordManager
11
11
  from sapiopylib.rest.DataTypeService import DataTypeManager
12
12
  from sapiopylib.rest.ELNService import ElnManager
13
13
  from sapiopylib.rest.User import SapioUser
14
14
  from sapiopylib.rest.pojo.DataRecord import DataRecord
15
15
  from sapiopylib.rest.pojo.Sort import SortDirection
16
- from sapiopylib.rest.pojo.chartdata.DashboardDefinition import GaugeChartDefinition
17
- from sapiopylib.rest.pojo.chartdata.DashboardEnums import ChartGroupingType, ChartOperationType
16
+ from sapiopylib.rest.pojo.chartdata.DashboardDefinition import GaugeChartDefinition, DashboardDefinition
17
+ from sapiopylib.rest.pojo.chartdata.DashboardEnums import ChartGroupingType, ChartOperationType, DashboardScope
18
18
  from sapiopylib.rest.pojo.chartdata.DashboardSeries import GaugeChartSeries
19
19
  from sapiopylib.rest.pojo.datatype.DataType import DataTypeDefinition
20
20
  from sapiopylib.rest.pojo.datatype.DataTypeLayout import DataTypeLayout, TableLayout
21
- from sapiopylib.rest.pojo.datatype.FieldDefinition import AbstractVeloxFieldDefinition, FieldType
21
+ from sapiopylib.rest.pojo.datatype.FieldDefinition import AbstractVeloxFieldDefinition, FieldType, \
22
+ VeloxStringFieldDefinition
22
23
  from sapiopylib.rest.pojo.eln.ElnEntryPosition import ElnEntryPosition
23
24
  from sapiopylib.rest.pojo.eln.ElnExperiment import ElnExperiment
24
25
  from sapiopylib.rest.pojo.eln.ExperimentEntry import ExperimentEntry
25
26
  from sapiopylib.rest.pojo.eln.ExperimentEntryCriteria import ElnEntryCriteria, ElnFormEntryUpdateCriteria, \
26
- ElnDashboardEntryUpdateCriteria, ElnTextEntryUpdateCriteria
27
- from sapiopylib.rest.pojo.eln.SapioELNEnums import ElnEntryType, ElnBaseDataType
27
+ ElnDashboardEntryUpdateCriteria
28
+ from sapiopylib.rest.pojo.eln.SapioELNEnums import ElnEntryType, ElnBaseDataType, ExperimentEntryStatus
28
29
  from sapiopylib.rest.pojo.eln.eln_headings import ElnExperimentTabAddCriteria, ElnExperimentTab
29
30
  from sapiopylib.rest.pojo.eln.field_set import ElnFieldSetInfo
30
31
  from sapiopylib.rest.utils.ProtocolUtils import ELNStepFactory
@@ -33,6 +34,7 @@ from sapiopylib.rest.utils.Protocols import ElnEntryStep, ElnExperimentProtocol
33
34
  from sapiopycommons.callbacks.field_builder import FieldBuilder
34
35
  from sapiopycommons.general.aliases import AliasUtil, SapioRecord
35
36
  from sapiopycommons.general.exceptions import SapioException
37
+ from sapiopycommons.general.html_formatter import HtmlFormatter
36
38
  from sapiopycommons.general.time_util import TimeUtil
37
39
  from sapiopycommons.multimodal.multimodal import MultiModalManager
38
40
  from sapiopycommons.multimodal.multimodal_data import ImageDataRequestPojo
@@ -100,88 +102,6 @@ def format_tot_headers(headers: Mapping[str, str]) -> dict[str, str]:
100
102
  return {k.lower(): v for k, v in headers.items()}
101
103
 
102
104
 
103
- class HtmlFormatter:
104
- """
105
- A class for formatting text in HTML with tag classes supported by the client.
106
- """
107
- TIMESTAMP_TEXT__CSS_CLASS_NAME: Final[str] = "timestamp-text"
108
- HEADER_1_TEXT__CSS_CLASS_NAME: Final[str] = "header1-text"
109
- HEADER_2_TEXT__CSS_CLASS_NAME: Final[str] = "header2-text"
110
- HEADER_3_TEXT__CSS_CLASS_NAME: Final[str] = "header3-text"
111
- BODY_TEXT__CSS_CLASS_NAME: Final[str] = "body-text"
112
- CAPTION_TEXT__CSS_CLASS_NAME: Final[str] = "caption-text"
113
-
114
- @staticmethod
115
- def timestamp(text: str) -> str:
116
- """
117
- Given a text string, return that same text string HTML formatted using the timestamp CSS class.
118
-
119
- :param text: The text to format.
120
- :return: The HTML formatted text.
121
- """
122
- return f"<span class=\"{HtmlFormatter.TIMESTAMP_TEXT__CSS_CLASS_NAME}\">{text}</span>"
123
-
124
- @staticmethod
125
- def header_1(text: str) -> str:
126
- """
127
- Given a text string, return that same text string HTML formatted using the header 1 CSS class.
128
-
129
- :param text: The text to format.
130
- :return: The HTML formatted text.
131
- """
132
- return f"<span class=\"{HtmlFormatter.HEADER_1_TEXT__CSS_CLASS_NAME}\">{text}</span>"
133
-
134
- @staticmethod
135
- def header_2(text: str) -> str:
136
- """
137
- Given a text string, return that same text string HTML formatted using the header 2 CSS class.
138
-
139
- :param text: The text to format.
140
- :return: The HTML formatted text.
141
- """
142
- return f"<span class=\"{HtmlFormatter.HEADER_2_TEXT__CSS_CLASS_NAME}\">{text}</span>"
143
-
144
- @staticmethod
145
- def header_3(text: str) -> str:
146
- """
147
- Given a text string, return that same text string HTML formatted using the header 3 CSS class.
148
-
149
- :param text: The text to format.
150
- :return: The HTML formatted text.
151
- """
152
- return f"<span class=\"{HtmlFormatter.HEADER_3_TEXT__CSS_CLASS_NAME}\">{text}</span>"
153
-
154
- @staticmethod
155
- def body(text: str) -> str:
156
- """
157
- Given a text string, return that same text string HTML formatted using the body CSS class.
158
-
159
- :param text: The text to format.
160
- :return: The HTML formatted text.
161
- """
162
- return f"<span class=\"{HtmlFormatter.BODY_TEXT__CSS_CLASS_NAME}\">{text}</span>"
163
-
164
- @staticmethod
165
- def caption(text: str) -> str:
166
- """
167
- Given a text string, return that same text string HTML formatted using the caption CSS class.
168
-
169
- :param text: The text to format.
170
- :return: The HTML formatted text.
171
- """
172
- return f"<span class=\"{HtmlFormatter.CAPTION_TEXT__CSS_CLASS_NAME}\">{text}</span>"
173
-
174
- @staticmethod
175
- def replace_newlines(text: str) -> str:
176
- """
177
- Given a text string, return that same text string HTML formatted with newlines replaced by HTML line breaks.
178
-
179
- :param text: The text to format.
180
- :return: The HTML formatted text.
181
- """
182
- return re.sub("\r?\n", "<br>", text)
183
-
184
-
185
105
  class AiHelper:
186
106
  """
187
107
  A class with helper methods for the AI to make use of when creating/updating experiment tabs and entries.
@@ -189,25 +109,28 @@ class AiHelper:
189
109
  # Contextual info.
190
110
  user: SapioUser
191
111
  exp_id: int
112
+ timeout: int
192
113
 
193
114
  # Managers.
194
115
  dr_man: DataRecordManager
195
116
  eln_man: ElnManager
196
117
  dt_man: DataTypeManager
197
118
 
198
- def __init__(self, user: SapioUser, exp_id: int):
119
+ def __init__(self, user: SapioUser, exp_id: int, timeout: int = 120):
199
120
  """
200
121
  :param user: The user to send the requests from.
201
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.
202
124
  """
203
125
  self.user = user
204
126
  self.exp_id = exp_id
127
+ self.timeout = timeout
205
128
 
206
129
  self.dr_man = DataRecordManager(self.user)
207
130
  self.eln_man = ElnManager(self.user)
208
131
  self.dt_man = DataTypeManager(self.user)
209
132
 
210
- def call_endpoint(self, url: str, payload: Any, tab_prefix: str = "") -> Response:
133
+ def call_post_endpoint(self, url: str, payload: Any, tab_prefix: str = "") -> Response:
211
134
  """
212
135
  Call a tool endpoint. Constructs the tool headers and checks the response for errors for the caller.
213
136
 
@@ -217,7 +140,21 @@ class AiHelper:
217
140
  :return: The Response object returned by the endpoint.
218
141
  """
219
142
  headers = create_tot_headers(self.user.url, self.user.username, self.user.password, self.exp_id, tab_prefix)
220
- response = requests.post(url, json=payload, headers=headers)
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)
221
158
  response.raise_for_status()
222
159
  return response
223
160
 
@@ -307,20 +244,75 @@ class AiHelper:
307
244
  if not json_list:
308
245
  return None
309
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
+
310
264
  # Determine which fields in the JSON can be used to create field definitions.
311
265
  fb = FieldBuilder()
312
- fields: list[AbstractVeloxFieldDefinition] = []
313
- fields_by_name: dict[str, AbstractVeloxFieldDefinition] = {}
314
- for key, value in json_list[0].items():
315
- field_name: str = key.replace(" ", "_")
316
- if isinstance(value, str):
317
- field = fb.string_field(field_name, display_name=key)
318
- fields.append(field)
319
- fields_by_name[key] = field
320
- elif isinstance(value, (int, float)):
321
- field = fb.double_field(field_name, display_name=key, precision=3)
322
- fields.append(field)
323
- fields_by_name[key] = field
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)
324
316
 
325
317
  # Sort the JSON list if requested.
326
318
  if sort_field and sort_direction != SortDirection.NONE:
@@ -340,12 +332,10 @@ class AiHelper:
340
332
  field_maps: list[dict[str, Any]] = []
341
333
  for json_dict in json_list:
342
334
  field_map: dict[str, Any] = {}
343
- for key, field in fields_by_name.items():
344
- # Watch out for NaN values or other special values in numeric columns.
335
+ for key, field in json_key_to_field_def.items():
345
336
  val: Any = json_dict.get(key)
346
- if (field.data_field_type == FieldType.DOUBLE
347
- and (not isinstance(val, (int, float))) or (isinstance(val, float) and math.isnan(val))):
348
- val = None
337
+ if key in numeric_string_fields and val is not None and isinstance(val, (int, float)):
338
+ val: str = f"{val:.3f}"
349
339
  field_map[field.data_field_name] = val
350
340
  field_maps.append(field_map)
351
341
 
@@ -354,7 +344,7 @@ class AiHelper:
354
344
  ElnBaseDataType.EXPERIMENT_DETAIL.data_type_name,
355
345
  self.tab_next_entry_order(tab),
356
346
  notebook_experiment_tab_id=tab.tab_id,
357
- field_definition_list=fields)
347
+ field_definition_list=[y for x, y in json_key_to_field_def.items()])
358
348
  entry = self.eln_man.add_experiment_entry(self.exp_id, detail_entry)
359
349
  records: list[DataRecord] = self.dr_man.add_data_records_with_data(entry.data_type_name, field_maps)
360
350
 
@@ -539,6 +529,8 @@ class ToolOfToolsHelper:
539
529
  # Stuff created by this helper.
540
530
  _initialized: bool
541
531
  """Whether a tab for this tool has been initialized."""
532
+ _new_tab: bool
533
+ """Whether a new tab was created for this tool."""
542
534
  tab: ElnExperimentTab
543
535
  """The tab that contains the tool's entries."""
544
536
  description_entry: ElnEntryStep | None
@@ -577,6 +569,15 @@ class ToolOfToolsHelper:
577
569
  self.eln_man = ElnManager(self.user)
578
570
 
579
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
580
581
 
581
582
  def initialize_tab(self) -> ElnExperimentTab:
582
583
  if self._initialized:
@@ -629,6 +630,7 @@ class ToolOfToolsHelper:
629
630
 
630
631
  # Otherwise, create the tab for the tool progress and results.
631
632
  self.tab = self.helper.create_tab(tab_name)
633
+ self._new_tab = True
632
634
 
633
635
  # Create a hidden entry for tracking the progress of the tool.
634
636
  field_sets: list[ElnFieldSetInfo] = self.eln_man.get_field_set_info_list()
@@ -636,16 +638,18 @@ class ToolOfToolsHelper:
636
638
  x.field_set_name == "Tool of Tools Progress"]
637
639
  if not progress_field_set:
638
640
  raise SapioException("Unable to locate the field set for the Tool of Tools progress.")
639
- progress_entry_crit = ElnEntryCriteria(ElnEntryType.Form, f"{tab_name} Progress",
640
- ElnBaseDataType.EXPERIMENT_DETAIL.data_type_name, 1,
641
- notebook_experiment_tab_id=self.tab.tab_id,
642
- enb_field_set_id=progress_field_set[0].field_set_id)
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)
643
646
  progress_entry = ElnEntryStep(self.helper.protocol,
644
647
  self.eln_man.add_experiment_entry(self.exp_id, progress_entry_crit))
645
648
  self.progress_entry = progress_entry
646
649
  self.progress_record = progress_entry.get_records()[0]
647
650
 
648
651
  # Hide the progress entry.
652
+ # TODO: Remove once we get this working on entry creation.
649
653
  form_update_crit = ElnFormEntryUpdateCriteria()
650
654
  form_update_crit.is_hidden = True
651
655
  self.eln_man.update_experiment_entry(self.exp_id, self.progress_entry.get_id(), form_update_crit)
@@ -654,26 +658,22 @@ class ToolOfToolsHelper:
654
658
  # tool started and format the description so that the text isn't too small to read.
655
659
  # TODO: Get the UTC offset in seconds from the header once that's being sent.
656
660
  now: str = TimeUtil.now_in_format("%Y-%m-%d %H:%M:%S UTC", "UTC")
657
- text_entry = ElnEntryStep(self.helper.protocol, self.helper.create_text_entry(self.tab, now, self.description))
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)
658
664
  self.description_entry = text_entry
659
665
  self.description_record = text_entry.get_records()[0]
660
666
 
661
- # Shrink the text entry by one column.
662
- text_update_crit = ElnTextEntryUpdateCriteria()
663
- text_update_crit.column_order = 0
664
- text_update_crit.column_span = 2
665
- self.eln_man.update_experiment_entry(self.exp_id, self.description_entry.get_id(), text_update_crit)
666
-
667
667
  # Create a gauge entry to display the progress.
668
- gauge_entry: ElnEntryStep = self._create_gauge_chart(self.helper.protocol, progress_entry,
669
- f"{self.name} Progress", "Progress", "StatusMsg")
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)
670
671
  self.progress_gauge_entry = gauge_entry
671
672
 
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.
673
675
  dash_update_crit = ElnDashboardEntryUpdateCriteria()
674
676
  dash_update_crit.entry_height = 250
675
- dash_update_crit.column_order = 2
676
- dash_update_crit.column_span = 2
677
677
  self.eln_man.update_experiment_entry(self.exp_id, self.progress_gauge_entry.get_id(), dash_update_crit)
678
678
 
679
679
  # Create a results entry if this tool produces result records.
@@ -768,10 +768,37 @@ class ToolOfToolsHelper:
768
768
 
769
769
  return self.helper.create_attachment_entry_from_file(self.tab, entry_name, file_path)
770
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
+
771
797
  # TODO: Remove this once pylib's gauge chart definition is up to date.
772
798
  @staticmethod
773
799
  def _create_gauge_chart(protocol: ElnExperimentProtocol, data_source_step: ElnEntryStep, step_name: str,
774
- field_name: str, status_field: str, group_by_field_name: str = "DataRecordName") \
800
+ field_name: str, status_field: str, group_by_field_name: str = "DataRecordName",
801
+ **kwargs) \
775
802
  -> ElnEntryStep:
776
803
  """
777
804
  Create a gauge chart step in the experiment protocol.
@@ -790,11 +817,55 @@ class ToolOfToolsHelper:
790
817
  chart.grouping_type = ChartGroupingType.GROUP_BY_FIELD
791
818
  chart.grouping_type_data_type_name = data_type_name
792
819
  chart.grouping_type_data_field_name = group_by_field_name
793
- dashboard, step = ELNStepFactory._create_dashboard_step_from_chart(chart, data_source_step, protocol, step_name,
794
- None)
820
+ dashboard, step = _ELNStepFactory._create_dashboard_step_from_chart(chart, data_source_step, protocol, step_name,
821
+ None, **kwargs)
795
822
  protocol.invalidate()
796
823
  return step
797
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
+
798
869
 
799
870
  # TODO: Using this to set the new status field setting.
800
871
  class _GaugeChartDefinition(GaugeChartDefinition):
@@ -807,3 +878,40 @@ class _GaugeChartDefinition(GaugeChartDefinition):
807
878
  "dataFieldName": self.status_field
808
879
  }
809
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
@@ -1815,7 +1815,8 @@ class CallbackUtil:
1815
1815
  return response
1816
1816
 
1817
1817
  def request_file(self, title: str, exts: Iterable[str] | None = None,
1818
- show_image_editor: bool = False, show_camera_button: bool = False) -> tuple[str, bytes]:
1818
+ show_image_editor: bool = False, show_camera_button: bool = False,
1819
+ *, enforce_file_extensions: bool = True) -> tuple[str, bytes]:
1819
1820
  """
1820
1821
  Request a single file from the user.
1821
1822
 
@@ -1825,6 +1826,8 @@ class CallbackUtil:
1825
1826
  :param show_image_editor: Whether the user will see an image editor when image is uploaded in this file prompt.
1826
1827
  :param show_camera_button: Whether the user will be able to use camera to take a picture as an upload request,
1827
1828
  rather than selecting an existing file.
1829
+ :param enforce_file_extensions: If true, then the file extensions provided in the exts parameter will be
1830
+ enforced. If false, then the user may upload any file type.
1828
1831
  :return: The file name and bytes of the uploaded file.
1829
1832
  """
1830
1833
  # If no extensions were provided, use an empty list for the extensions instead.
@@ -1844,11 +1847,12 @@ class CallbackUtil:
1844
1847
  file_path: str = self.__send_dialog(request, self.callback.show_file_dialog, data_sink=do_consume)
1845
1848
 
1846
1849
  # Verify that each of the file given matches the expected extension(s).
1847
- self.__verify_file(file_path, sink.data, exts)
1850
+ self.__verify_file(file_path, sink.data, exts if enforce_file_extensions else None)
1848
1851
  return file_path, sink.data
1849
1852
 
1850
1853
  def request_files(self, title: str, exts: Iterable[str] | None = None,
1851
- show_image_editor: bool = False, show_camera_button: bool = False) -> dict[str, bytes]:
1854
+ show_image_editor: bool = False, show_camera_button: bool = False,
1855
+ *, enforce_file_extensions: bool = True) -> dict[str, bytes]:
1852
1856
  """
1853
1857
  Request multiple files from the user.
1854
1858
 
@@ -1858,6 +1862,8 @@ class CallbackUtil:
1858
1862
  :param show_image_editor: Whether the user will see an image editor when image is uploaded in this file prompt.
1859
1863
  :param show_camera_button: Whether the user will be able to use camera to take a picture as an upload request,
1860
1864
  rather than selecting an existing file.
1865
+ :param enforce_file_extensions: If true, then the file extensions provided in the exts parameter will be
1866
+ enforced. If false, then the user may upload any file type.
1861
1867
  :return: A dictionary of file name to file bytes for each file the user uploaded.
1862
1868
  """
1863
1869
  # If no extensions were provided, use an empty list for the extensions instead.
@@ -1873,7 +1879,7 @@ class CallbackUtil:
1873
1879
  for file_path in file_paths:
1874
1880
  sink = InMemoryRecordDataSink(self.user)
1875
1881
  sink.consume_client_callback_file_path_data(file_path)
1876
- self.__verify_file(file_path, sink.data, exts)
1882
+ self.__verify_file(file_path, sink.data, exts if enforce_file_extensions else None)
1877
1883
  ret_dict.update({file_path: sink.data})
1878
1884
 
1879
1885
  return ret_dict
@@ -1890,16 +1896,17 @@ class CallbackUtil:
1890
1896
  """
1891
1897
  if file_path is None or len(file_path) == 0 or file_bytes is None or len(file_bytes) == 0:
1892
1898
  raise SapioUserErrorException("Empty file provided or file unable to be read.")
1893
- if allowed_extensions:
1894
- matches: bool = False
1895
- for ext in allowed_extensions:
1896
- # FR-47690: Changed to a case-insensitive match.
1897
- if file_path.casefold().endswith("." + ext.lstrip(".").casefold()):
1898
- matches = True
1899
- break
1900
- if matches is False:
1901
- raise SapioUserErrorException("Unsupported file type. Expecting the following extension(s): "
1902
- + (",".join(allowed_extensions)))
1899
+ if not allowed_extensions:
1900
+ return
1901
+ matches: bool = False
1902
+ for ext in allowed_extensions:
1903
+ # FR-47690: Changed to a case-insensitive match.
1904
+ if file_path.casefold().endswith("." + ext.lstrip(".").casefold()):
1905
+ matches = True
1906
+ break
1907
+ if not matches:
1908
+ raise SapioUserErrorException("Unsupported file type. Expecting the following extension(s): "
1909
+ + (",".join(allowed_extensions)))
1903
1910
 
1904
1911
  def write_file(self, file_name: str, file_data: str | bytes) -> None:
1905
1912
  """