sapiopycommons 2025.7.31a664__py3-none-any.whl → 2025.8.1a670__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 +235 -127
- sapiopycommons/callbacks/callback_util.py +21 -14
- sapiopycommons/files/assay_plate_reader.py +93 -0
- sapiopycommons/files/file_text_converter.py +207 -0
- sapiopycommons/flowcyto/flow_cyto.py +2 -24
- sapiopycommons/general/accession_service.py +2 -28
- sapiopycommons/multimodal/multimodal.py +2 -24
- {sapiopycommons-2025.7.31a664.dist-info → sapiopycommons-2025.8.1a670.dist-info}/METADATA +2 -2
- {sapiopycommons-2025.7.31a664.dist-info → sapiopycommons-2025.8.1a670.dist-info}/RECORD +11 -9
- {sapiopycommons-2025.7.31a664.dist-info → sapiopycommons-2025.8.1a670.dist-info}/WHEEL +0 -0
- {sapiopycommons-2025.7.31a664.dist-info → sapiopycommons-2025.8.1a670.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
|
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
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
for
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
field
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
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 (
|
|
347
|
-
|
|
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=
|
|
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 =
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
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 =
|
|
669
|
-
|
|
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 =
|
|
794
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
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
|
"""
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import dataclasses
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from databind.core.dataclasses import dataclass
|
|
6
|
+
from databind.json import loads
|
|
7
|
+
from sapiopylib.rest.utils.singletons import SapioContextManager
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclasses.dataclass
|
|
11
|
+
class ProcessAssayPlateRequest:
|
|
12
|
+
"""
|
|
13
|
+
A request to process the results of assay plate reader with a configuration set in Sapio.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
num_rows (int): The number of rows in the plate.
|
|
17
|
+
num_columns (int): The number of columns in the plate.
|
|
18
|
+
plate_ids_in_context (list[str]): List of plate IDs that are in context for this request.
|
|
19
|
+
filename (str): The name of the file containing the assay data.
|
|
20
|
+
file_data (bytes): The binary content of the file.
|
|
21
|
+
plate_reader_config_name (str): The name of the plate reader configuration to use.
|
|
22
|
+
"""
|
|
23
|
+
num_rows: int
|
|
24
|
+
num_columns: int
|
|
25
|
+
plate_ids_in_context: list[str] | None
|
|
26
|
+
filename: str
|
|
27
|
+
file_data: bytes
|
|
28
|
+
plate_reader_config_name: str
|
|
29
|
+
|
|
30
|
+
def to_json(self) -> dict[str, Any]:
|
|
31
|
+
return {
|
|
32
|
+
"numRows": self.num_rows,
|
|
33
|
+
"numCols": self.num_columns,
|
|
34
|
+
"plateIdsInContext": self.plate_ids_in_context,
|
|
35
|
+
"fileName": self.filename,
|
|
36
|
+
"fileDataBase64": base64.b64encode(self.file_data).decode('utf-8'),
|
|
37
|
+
"plateReaderName": self.plate_reader_config_name
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class AssayPlateResultIdent:
|
|
43
|
+
plateId: str
|
|
44
|
+
channelIdOrBlock: str
|
|
45
|
+
kineticAssaySeconds: float | None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class AssayResultDatum:
|
|
50
|
+
"""
|
|
51
|
+
Describes the data received from an assay plate reader.
|
|
52
|
+
Most of the time, the data is a single value, but sometimes it can be multiple values, especially for kinetic data.
|
|
53
|
+
"""
|
|
54
|
+
DEFAULT_PROPERTY_NAME: str = "read"
|
|
55
|
+
rowPosition: str
|
|
56
|
+
columnPosition: str
|
|
57
|
+
valueByPropertyName: dict[str, float]
|
|
58
|
+
textValueByPropertyName: dict[str, str]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class AssayPlateResult:
|
|
63
|
+
"""
|
|
64
|
+
Assay plate load result for a single plate in a file. A file can have more than one of this result if it has multiple plate of data in a single file.
|
|
65
|
+
"""
|
|
66
|
+
resultIdent: AssayPlateResultIdent
|
|
67
|
+
numRows: int
|
|
68
|
+
numColumns: int
|
|
69
|
+
resultDatum: list[AssayResultDatum]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class AssayFileLoadResult:
|
|
74
|
+
"""
|
|
75
|
+
The entire top-level file loading result for an assay plate reader file.
|
|
76
|
+
"""
|
|
77
|
+
filename: str
|
|
78
|
+
plateResultList: list[AssayPlateResult]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class AssayPlateReader(SapioContextManager):
|
|
82
|
+
"""
|
|
83
|
+
This class contains services for Sapio Assay Plate Reader.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def process_plate_reader_data(self, request: ProcessAssayPlateRequest) -> AssayFileLoadResult:
|
|
87
|
+
"""
|
|
88
|
+
Processes the assay plate reader data using provided request into a structured result using configuration defined in Sapio.
|
|
89
|
+
"""
|
|
90
|
+
payload = request.to_json()
|
|
91
|
+
response = self.user.plugin_post("assayplatereader/process", payload=payload)
|
|
92
|
+
self.user.raise_for_status(response)
|
|
93
|
+
return loads(response.text, AssayFileLoadResult)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
from enum import Enum, auto
|
|
5
|
+
|
|
6
|
+
class FileType(Enum):
|
|
7
|
+
"""Supported file types for conversion."""
|
|
8
|
+
TXT = auto()
|
|
9
|
+
MD = auto()
|
|
10
|
+
CSV = auto()
|
|
11
|
+
DOC = auto()
|
|
12
|
+
DOCX = auto()
|
|
13
|
+
XLS = auto()
|
|
14
|
+
XLSX = auto()
|
|
15
|
+
PPT = auto()
|
|
16
|
+
PPTX = auto()
|
|
17
|
+
PDF = auto()
|
|
18
|
+
UNKNOWN = auto()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FileToTextConverter:
|
|
22
|
+
"""
|
|
23
|
+
A class for converting various file types to raw text.
|
|
24
|
+
"""
|
|
25
|
+
@staticmethod
|
|
26
|
+
def mime_type_to_enum(mime_type: str) -> FileType:
|
|
27
|
+
"""
|
|
28
|
+
Converts a MIME type to a FileType enum.
|
|
29
|
+
|
|
30
|
+
:param mime_type: The MIME type string to convert.
|
|
31
|
+
:return: The corresponding FileType enum, or UNKNOWN if not recognized.
|
|
32
|
+
"""
|
|
33
|
+
if not mime_type or not mime_type.strip():
|
|
34
|
+
return FileType.UNKNOWN
|
|
35
|
+
|
|
36
|
+
mime_map = {
|
|
37
|
+
"text/plain": FileType.TXT,
|
|
38
|
+
"text/markdown": FileType.MD,
|
|
39
|
+
"text/csv": FileType.CSV,
|
|
40
|
+
"application/msword": FileType.DOC,
|
|
41
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": FileType.DOCX,
|
|
42
|
+
"application/vnd.ms-excel": FileType.XLS,
|
|
43
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": FileType.XLSX,
|
|
44
|
+
"application/vnd.ms-powerpoint": FileType.PPT,
|
|
45
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": FileType.PPTX,
|
|
46
|
+
"application/pdf": FileType.PDF,
|
|
47
|
+
}
|
|
48
|
+
return mime_map.get(mime_type, FileType.UNKNOWN)
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def file_extension_to_enum(file_path: str) -> FileType:
|
|
52
|
+
"""
|
|
53
|
+
Converts a file path or extension to a FileType enum.
|
|
54
|
+
|
|
55
|
+
:param file_path: The file path or extension to convert.
|
|
56
|
+
:return: The corresponding FileType enum, or UNKNOWN if not recognized.
|
|
57
|
+
"""
|
|
58
|
+
if not file_path or not file_path.strip():
|
|
59
|
+
return FileType.UNKNOWN
|
|
60
|
+
|
|
61
|
+
# Extract the file extension, removing the leading dot and making it lowercase
|
|
62
|
+
file_extension = os.path.splitext(file_path)[1].lstrip('.').lower()
|
|
63
|
+
|
|
64
|
+
ext_map = {
|
|
65
|
+
"txt": FileType.TXT,
|
|
66
|
+
"md": FileType.MD,
|
|
67
|
+
"csv": FileType.CSV,
|
|
68
|
+
"doc": FileType.DOC,
|
|
69
|
+
"docx": FileType.DOCX,
|
|
70
|
+
"xls": FileType.XLS,
|
|
71
|
+
"xlsx": FileType.XLSX,
|
|
72
|
+
"ppt": FileType.PPT,
|
|
73
|
+
"pptx": FileType.PPTX,
|
|
74
|
+
"pdf": FileType.PDF,
|
|
75
|
+
}
|
|
76
|
+
return ext_map.get(file_extension, FileType.UNKNOWN)
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def parse_file(cls, file_type: FileType, file_bytes: bytes) -> str | None:
|
|
80
|
+
"""
|
|
81
|
+
Parses file bytes based on the FileType and returns the text content.
|
|
82
|
+
|
|
83
|
+
:param file_type: The type of the file to parse.
|
|
84
|
+
:param file_bytes: The raw bytes of the file to parse.
|
|
85
|
+
:return: The text content of the file, or None if the file type is not supported or parsing fails.
|
|
86
|
+
"""
|
|
87
|
+
if file_type is None or file_bytes is None:
|
|
88
|
+
return None
|
|
89
|
+
if not file_bytes:
|
|
90
|
+
return ""
|
|
91
|
+
|
|
92
|
+
# Dispatch to the correct parser method
|
|
93
|
+
parser_map = {
|
|
94
|
+
FileType.TXT: cls._parse_plain_text,
|
|
95
|
+
FileType.MD: cls._parse_plain_text,
|
|
96
|
+
FileType.CSV: cls._parse_plain_text,
|
|
97
|
+
FileType.DOC: cls._parse_doc,
|
|
98
|
+
FileType.DOCX: cls._parse_docx,
|
|
99
|
+
FileType.XLS: cls._parse_xls,
|
|
100
|
+
FileType.XLSX: cls._parse_xlsx,
|
|
101
|
+
FileType.PPT: cls._parse_ppt,
|
|
102
|
+
FileType.PPTX: cls._parse_pptx,
|
|
103
|
+
FileType.PDF: cls._parse_pdf,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
parser_func = parser_map.get(file_type)
|
|
107
|
+
|
|
108
|
+
if parser_func:
|
|
109
|
+
return parser_func(file_bytes)
|
|
110
|
+
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _parse_plain_text(file_bytes: bytes) -> str:
|
|
115
|
+
return file_bytes.decode('utf-8')
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _run_textract(file_bytes: bytes, extension: str) -> str:
|
|
119
|
+
"""
|
|
120
|
+
Helper to run textract on in-memory bytes by writing to a temp file.
|
|
121
|
+
Note: textract may require external system dependencies.
|
|
122
|
+
"""
|
|
123
|
+
import textract
|
|
124
|
+
with tempfile.NamedTemporaryFile(suffix=f".{extension}", delete=True) as temp_file:
|
|
125
|
+
temp_file.write(file_bytes)
|
|
126
|
+
temp_file.flush() # Ensure all bytes are written to disk
|
|
127
|
+
text = textract.process(temp_file.name).decode('utf-8')
|
|
128
|
+
return text
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def _parse_doc(cls, file_bytes: bytes) -> str:
|
|
132
|
+
return cls._run_textract(file_bytes, 'doc')
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _parse_docx(file_bytes: bytes) -> str:
|
|
136
|
+
import docx
|
|
137
|
+
with io.BytesIO(file_bytes) as stream:
|
|
138
|
+
document = docx.Document(stream)
|
|
139
|
+
return "\n".join(para.text for para in document.paragraphs if para.text.strip())
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _parse_xls(file_bytes: bytes) -> str:
|
|
143
|
+
import xlrd
|
|
144
|
+
workbook = xlrd.open_workbook(file_contents=file_bytes)
|
|
145
|
+
text_parts = []
|
|
146
|
+
for sheet in workbook.sheets():
|
|
147
|
+
text_parts.append(f"Sheet: {sheet.name}\n")
|
|
148
|
+
for row_idx in range(sheet.nrows):
|
|
149
|
+
row_cells = []
|
|
150
|
+
for col_idx in range(sheet.ncols):
|
|
151
|
+
cell_text = str(sheet.cell_value(row_idx, col_idx))
|
|
152
|
+
if cell_text.strip():
|
|
153
|
+
row_cells.append(cell_text + "\t")
|
|
154
|
+
if row_cells:
|
|
155
|
+
text_parts.append("".join(row_cells))
|
|
156
|
+
text_parts.append("\n")
|
|
157
|
+
text_parts.append("\n")
|
|
158
|
+
return "".join(text_parts)
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def _parse_xlsx(file_bytes: bytes) -> str:
|
|
162
|
+
import openpyxl
|
|
163
|
+
with io.BytesIO(file_bytes) as stream:
|
|
164
|
+
workbook = openpyxl.load_workbook(stream, read_only=True)
|
|
165
|
+
text_parts = []
|
|
166
|
+
for sheet in workbook.worksheets:
|
|
167
|
+
text_parts.append(f"Sheet: {sheet.title}\n")
|
|
168
|
+
for row in sheet.iter_rows():
|
|
169
|
+
row_cells = []
|
|
170
|
+
for cell in row:
|
|
171
|
+
cell_text = str(cell.value) if cell.value is not None else ""
|
|
172
|
+
if cell_text.strip():
|
|
173
|
+
row_cells.append(cell_text + "\t")
|
|
174
|
+
if row_cells:
|
|
175
|
+
text_parts.append("".join(row_cells))
|
|
176
|
+
text_parts.append("\n")
|
|
177
|
+
text_parts.append("\n")
|
|
178
|
+
return "".join(text_parts)
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def _parse_ppt(cls, file_bytes: bytes) -> str:
|
|
182
|
+
return cls._run_textract(file_bytes, 'ppt')
|
|
183
|
+
|
|
184
|
+
@staticmethod
|
|
185
|
+
def _parse_pptx(file_bytes: bytes) -> str:
|
|
186
|
+
import pptx
|
|
187
|
+
with io.BytesIO(file_bytes) as stream:
|
|
188
|
+
presentation = pptx.Presentation(stream)
|
|
189
|
+
text_parts = []
|
|
190
|
+
for slide in presentation.slides:
|
|
191
|
+
for shape in slide.shapes:
|
|
192
|
+
if shape.has_text_frame:
|
|
193
|
+
text = shape.text_frame.text
|
|
194
|
+
if text and text.strip():
|
|
195
|
+
text_parts.append(text)
|
|
196
|
+
return "\n".join(text_parts)
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def _parse_pdf(file_bytes: bytes) -> str:
|
|
200
|
+
"""Parses a PDF file's bytes and extracts text using PyMuPDF."""
|
|
201
|
+
import pymupdf
|
|
202
|
+
text_parts = []
|
|
203
|
+
with io.BytesIO(file_bytes) as stream:
|
|
204
|
+
with pymupdf.open(stream=stream) as doc:
|
|
205
|
+
for page in doc:
|
|
206
|
+
text_parts.append(page.get_text())
|
|
207
|
+
return "\n".join(text_parts)
|
|
@@ -4,38 +4,16 @@ from weakref import WeakValueDictionary
|
|
|
4
4
|
|
|
5
5
|
from databind.json import dumps
|
|
6
6
|
from sapiopylib.rest.User import SapioUser
|
|
7
|
+
from sapiopylib.rest.utils.singletons import SapioContextManager
|
|
7
8
|
|
|
8
9
|
from sapiopycommons.flowcyto.flowcyto_data import FlowJoWorkspaceInputJson, UploadFCSInputJson, \
|
|
9
10
|
ComputeFlowStatisticsInputJson
|
|
10
11
|
|
|
11
12
|
|
|
12
|
-
class FlowCytoManager:
|
|
13
|
+
class FlowCytoManager(SapioContextManager):
|
|
13
14
|
"""
|
|
14
15
|
This manager includes flow cytometry analysis tools that would require FlowCyto license to use.
|
|
15
16
|
"""
|
|
16
|
-
_user: SapioUser
|
|
17
|
-
|
|
18
|
-
__instances: WeakValueDictionary[SapioUser, FlowCytoManager] = WeakValueDictionary()
|
|
19
|
-
__initialized: bool
|
|
20
|
-
|
|
21
|
-
def __new__(cls, user: SapioUser):
|
|
22
|
-
"""
|
|
23
|
-
Observes singleton pattern per record model manager object.
|
|
24
|
-
|
|
25
|
-
:param user: The user that will make the webservice request to the application.
|
|
26
|
-
"""
|
|
27
|
-
obj = cls.__instances.get(user)
|
|
28
|
-
if not obj:
|
|
29
|
-
obj = object.__new__(cls)
|
|
30
|
-
obj.__initialized = False
|
|
31
|
-
cls.__instances[user] = obj
|
|
32
|
-
return obj
|
|
33
|
-
|
|
34
|
-
def __init__(self, user: SapioUser):
|
|
35
|
-
if self.__initialized:
|
|
36
|
-
return
|
|
37
|
-
self._user = user
|
|
38
|
-
self.__initialized = True
|
|
39
17
|
|
|
40
18
|
def create_flowjo_workspace(self, workspace_input: FlowJoWorkspaceInputJson) -> int:
|
|
41
19
|
"""
|
|
@@ -5,6 +5,7 @@ from typing import Any
|
|
|
5
5
|
from weakref import WeakValueDictionary
|
|
6
6
|
|
|
7
7
|
from sapiopylib.rest.User import SapioUser
|
|
8
|
+
from sapiopylib.rest.utils.singletons import SapioContextManager
|
|
8
9
|
|
|
9
10
|
_STR_JAVA_TYPE = "java.lang.String"
|
|
10
11
|
_INT_JAVA_TYPE = "java.lang.Integer"
|
|
@@ -274,37 +275,10 @@ class AccessionServiceDescriptor:
|
|
|
274
275
|
}
|
|
275
276
|
|
|
276
277
|
|
|
277
|
-
class AccessionService:
|
|
278
|
+
class AccessionService(SapioContextManager):
|
|
278
279
|
"""
|
|
279
280
|
Provides Sapio Foundations Accession Service functionalities.
|
|
280
281
|
"""
|
|
281
|
-
_user: SapioUser
|
|
282
|
-
|
|
283
|
-
__instances: WeakValueDictionary[SapioUser, AccessionService] = WeakValueDictionary()
|
|
284
|
-
__initialized: bool
|
|
285
|
-
|
|
286
|
-
@property
|
|
287
|
-
def user(self) -> SapioUser:
|
|
288
|
-
return self._user
|
|
289
|
-
|
|
290
|
-
def __new__(cls, user: SapioUser):
|
|
291
|
-
"""
|
|
292
|
-
Observes singleton pattern per record model manager object.
|
|
293
|
-
|
|
294
|
-
:param user: The user that will make the webservice request to the application.
|
|
295
|
-
"""
|
|
296
|
-
obj = cls.__instances.get(user)
|
|
297
|
-
if not obj:
|
|
298
|
-
obj = object.__new__(cls)
|
|
299
|
-
obj.__initialized = False
|
|
300
|
-
cls.__instances[user] = obj
|
|
301
|
-
return obj
|
|
302
|
-
|
|
303
|
-
def __init__(self, user: SapioUser):
|
|
304
|
-
if self.__initialized:
|
|
305
|
-
return
|
|
306
|
-
self._user = user
|
|
307
|
-
self.__initialized = True
|
|
308
282
|
|
|
309
283
|
def accession_with_config(self, data_type_name: str, data_field_name: str, num_ids: int) -> list[str]:
|
|
310
284
|
"""
|
|
@@ -7,35 +7,13 @@ from weakref import WeakValueDictionary
|
|
|
7
7
|
from databind.json import dumps, loads
|
|
8
8
|
from sapiopylib.rest.User import SapioUser
|
|
9
9
|
from sapiopylib.rest.pojo.DataRecord import DataRecord
|
|
10
|
+
from sapiopylib.rest.utils.singletons import SapioContextManager
|
|
10
11
|
|
|
11
12
|
from sapiopycommons.general.exceptions import SapioException
|
|
12
13
|
from sapiopycommons.multimodal.multimodal_data import *
|
|
13
14
|
|
|
14
15
|
|
|
15
|
-
class MultiModalManager:
|
|
16
|
-
_user: SapioUser
|
|
17
|
-
|
|
18
|
-
__instances: WeakValueDictionary[SapioUser, MultiModalManager] = WeakValueDictionary()
|
|
19
|
-
__initialized: bool
|
|
20
|
-
|
|
21
|
-
def __new__(cls, user: SapioUser):
|
|
22
|
-
"""
|
|
23
|
-
Observes singleton pattern per record model manager object.
|
|
24
|
-
|
|
25
|
-
:param user: The user that will make the webservice request to the application.
|
|
26
|
-
"""
|
|
27
|
-
obj = cls.__instances.get(user)
|
|
28
|
-
if not obj:
|
|
29
|
-
obj = object.__new__(cls)
|
|
30
|
-
obj.__initialized = False
|
|
31
|
-
cls.__instances[user] = obj
|
|
32
|
-
return obj
|
|
33
|
-
|
|
34
|
-
def __init__(self, user:SapioUser):
|
|
35
|
-
if self.__initialized:
|
|
36
|
-
return
|
|
37
|
-
self._user = user
|
|
38
|
-
self.__initialized = True
|
|
16
|
+
class MultiModalManager(SapioContextManager):
|
|
39
17
|
|
|
40
18
|
def load_image_data(self, request: ImageDataRequestPojo) -> list[str]:
|
|
41
19
|
"""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: sapiopycommons
|
|
3
|
-
Version: 2025.
|
|
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.
|
|
20
|
+
Requires-Dist: sapiopylib>=2025.7.31a279
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
|
|
23
23
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
sapiopycommons/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
2
|
sapiopycommons/ai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
-
sapiopycommons/ai/tool_of_tools.py,sha256=
|
|
3
|
+
sapiopycommons/ai/tool_of_tools.py,sha256=zYmQ4rNX-qYQnc-vNDnYZjtv9JgmQAmVVuHfVOdBF3w,46984
|
|
4
4
|
sapiopycommons/callbacks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
-
sapiopycommons/callbacks/callback_util.py,sha256=
|
|
5
|
+
sapiopycommons/callbacks/callback_util.py,sha256=Z1LcXnRRjXyhmcSDUwh4NzcA6ICtcbFUMKcvAqQcS8E,153811
|
|
6
6
|
sapiopycommons/callbacks/field_builder.py,sha256=rnIP-RJafk3mZlAx1eJ8a0eSW9Ps_L6_WadCmusnENw,38772
|
|
7
7
|
sapiopycommons/chem/IndigoMolecules.py,sha256=7ucCaRMLu1zfH2uPIvXwRTSdpNcS03O1P9p_O-5B4xQ,5110
|
|
8
8
|
sapiopycommons/chem/Molecules.py,sha256=mVqPn32MPMjF0iZas-5MFkS-upIdoW5OB72KKZmJRJA,12523
|
|
@@ -25,17 +25,19 @@ sapiopycommons/eln/experiment_tags.py,sha256=7-fpOiSqrjbXmWIJhEhaxMgLsVCPAtKqH8x
|
|
|
25
25
|
sapiopycommons/eln/plate_designer.py,sha256=XFazSvhTbSy47t80-jc2tyx_-fQ_IUjKd18JQKEFcsY,13939
|
|
26
26
|
sapiopycommons/eln/step_creation.py,sha256=CFkGC-SxwAQpQlcs_obqLAVgmsNxKSGMqMtO_E6IVmw,10171
|
|
27
27
|
sapiopycommons/files/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
28
|
+
sapiopycommons/files/assay_plate_reader.py,sha256=3c2PQiiAbc2QJU9ZfNLzcTmvJrUwsbkIHO7R6R52xGU,3020
|
|
28
29
|
sapiopycommons/files/complex_data_loader.py,sha256=T39veNhvYl6j_uZjIIJ8Mk5Aa7otR5RB-g8XlAdkksA,1421
|
|
29
30
|
sapiopycommons/files/file_bridge.py,sha256=vKbqxPexi15epr_-_qLrEfYoxNxB031mXN92iVtOMqE,9511
|
|
30
31
|
sapiopycommons/files/file_bridge_handler.py,sha256=SEYDIQhSCmjI6qyLdDJE8JVKSd0WYvF7JvAq_Ahp9Do,25503
|
|
31
32
|
sapiopycommons/files/file_data_handler.py,sha256=f96MlkMuQhUCi4oLnzJK5AiuElCp5jLI8_sJkZVwpws,36779
|
|
33
|
+
sapiopycommons/files/file_text_converter.py,sha256=Gaj_divTiKXWd6flDOgrxNXpcn9fDWqxX6LUG0joePk,7516
|
|
32
34
|
sapiopycommons/files/file_util.py,sha256=djouyGjsYgWzjz2OBRnSeMDgj6NrsJUm1a2J93J8Wco,31915
|
|
33
35
|
sapiopycommons/files/file_validator.py,sha256=ryg22-93csmRO_Pv0ZpWphNkB74xWZnHyJ23K56qLj0,28761
|
|
34
36
|
sapiopycommons/files/file_writer.py,sha256=hACVl0duCjP28gJ1NPljkjagNCLod0ygUlPbvUmRDNM,17605
|
|
35
|
-
sapiopycommons/flowcyto/flow_cyto.py,sha256=
|
|
37
|
+
sapiopycommons/flowcyto/flow_cyto.py,sha256=B6DFquLi-gcWfJWyP4vYfwTXXJKl6O9W5-k8FzkM0Oo,2610
|
|
36
38
|
sapiopycommons/flowcyto/flowcyto_data.py,sha256=mYKFuLbtpJ-EsQxLGtu4tNHVlygTxKixgJxJqD68F58,2596
|
|
37
39
|
sapiopycommons/general/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
38
|
-
sapiopycommons/general/accession_service.py,sha256=
|
|
40
|
+
sapiopycommons/general/accession_service.py,sha256=ZvtvZg7d_siMJUedjrF14mcqo5ZqVA5IJxDa5enlB-8,12792
|
|
39
41
|
sapiopycommons/general/aliases.py,sha256=VwnWf_P803pcteoAIs0DkLScVChCS5XNgryTp8FzaNc,14696
|
|
40
42
|
sapiopycommons/general/audit_log.py,sha256=sQAMcJx0cNkgZm7nTZSaGPxWvHG0_x6dBtU0jESavb4,9131
|
|
41
43
|
sapiopycommons/general/custom_report_util.py,sha256=9elLEUSgfM0gli8nRPz1uYkhaXN4Vnx3piSiNHv5IBs,19156
|
|
@@ -47,7 +49,7 @@ sapiopycommons/general/popup_util.py,sha256=HKILegU1uCL_6abNlNL0Wn3xgX2JNa_kJeq7
|
|
|
47
49
|
sapiopycommons/general/sapio_links.py,sha256=YkcVKNLrSGoM7tCCXBAsIbIxylctwdcEyhePrRMODe0,2859
|
|
48
50
|
sapiopycommons/general/storage_util.py,sha256=ovmK_jN7v09BoX07XxwShpBUC5WYQOM7dbKV_VeLXJU,8892
|
|
49
51
|
sapiopycommons/general/time_util.py,sha256=jU1urPoZRv6evNucR0-288EyT4PrsDpCr-H1-7BKq9A,12363
|
|
50
|
-
sapiopycommons/multimodal/multimodal.py,sha256=
|
|
52
|
+
sapiopycommons/multimodal/multimodal.py,sha256=EP9WYzx1CvidmEBlvzO6tiF4HJwsPB1FgxpnbWzxnpA,6161
|
|
51
53
|
sapiopycommons/multimodal/multimodal_data.py,sha256=0BeVPr9HaC0hNTF1v1phTIKGruvNnwerHsD994qJKBg,15099
|
|
52
54
|
sapiopycommons/processtracking/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
53
55
|
sapiopycommons/processtracking/custom_workflow_handler.py,sha256=eYKdYlwo8xx-6AkB_iPUBNV9yDoNvW2h_Sm3i8JpmRU,25844
|
|
@@ -64,7 +66,7 @@ sapiopycommons/webhook/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3
|
|
|
64
66
|
sapiopycommons/webhook/webhook_context.py,sha256=D793uLsb1691SalaPnBUk3rOSxn_hYLhdvkaIxjNXss,1909
|
|
65
67
|
sapiopycommons/webhook/webhook_handlers.py,sha256=7o_wXOruhT9auNh8OfhJAh4WhhiPKij67FMBSpGPICc,39939
|
|
66
68
|
sapiopycommons/webhook/webservice_handlers.py,sha256=tyaYGG1-v_JJrJHZ6cy5mGCxX9z1foLw7pM4MDJlFxs,14297
|
|
67
|
-
sapiopycommons-2025.
|
|
68
|
-
sapiopycommons-2025.
|
|
69
|
-
sapiopycommons-2025.
|
|
70
|
-
sapiopycommons-2025.
|
|
69
|
+
sapiopycommons-2025.8.1a670.dist-info/METADATA,sha256=uNBFJ8fUgwnMnOn17DSe4SJpNPZhxIex4q9S3_LSSmo,3142
|
|
70
|
+
sapiopycommons-2025.8.1a670.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
|
|
71
|
+
sapiopycommons-2025.8.1a670.dist-info/licenses/LICENSE,sha256=HyVuytGSiAUQ6ErWBHTqt1iSGHhLmlC8fO7jTCuR8dU,16725
|
|
72
|
+
sapiopycommons-2025.8.1a670.dist-info/RECORD,,
|
|
File without changes
|
{sapiopycommons-2025.7.31a664.dist-info → sapiopycommons-2025.8.1a670.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|