sapiopycommons 2024.8.27a310__py3-none-any.whl → 2024.8.28a313__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/callbacks/callback_util.py +69 -407
- sapiopycommons/chem/IndigoMolecules.py +0 -1
- sapiopycommons/chem/Molecules.py +0 -1
- sapiopycommons/datatype/attachment_util.py +10 -11
- sapiopycommons/eln/experiment_handler.py +48 -209
- sapiopycommons/files/complex_data_loader.py +4 -5
- sapiopycommons/files/file_bridge.py +24 -31
- sapiopycommons/files/file_data_handler.py +5 -2
- sapiopycommons/files/file_util.py +10 -50
- sapiopycommons/files/file_validator.py +6 -92
- sapiopycommons/files/file_writer.py +15 -44
- sapiopycommons/general/aliases.py +3 -147
- sapiopycommons/general/custom_report_util.py +37 -211
- sapiopycommons/general/popup_util.py +0 -17
- sapiopycommons/general/time_util.py +0 -40
- sapiopycommons/processtracking/endpoints.py +22 -22
- sapiopycommons/recordmodel/record_handler.py +97 -481
- sapiopycommons/rules/eln_rule_handler.py +25 -34
- sapiopycommons/rules/on_save_rule_handler.py +31 -34
- sapiopycommons/webhook/webhook_handlers.py +26 -147
- {sapiopycommons-2024.8.27a310.dist-info → sapiopycommons-2024.8.28a313.dist-info}/METADATA +2 -4
- sapiopycommons-2024.8.28a313.dist-info/RECORD +38 -0
- sapiopycommons/customreport/__init__.py +0 -0
- sapiopycommons/customreport/column_builder.py +0 -60
- sapiopycommons/customreport/custom_report_builder.py +0 -125
- sapiopycommons/customreport/term_builder.py +0 -299
- sapiopycommons/eln/experiment_report_util.py +0 -118
- sapiopycommons/files/file_bridge_handler.py +0 -340
- sapiopycommons/general/accession_service.py +0 -375
- sapiopycommons/general/audit_log.py +0 -196
- sapiopycommons/general/sapio_links.py +0 -50
- sapiopycommons/multimodal/multimodal.py +0 -146
- sapiopycommons/multimodal/multimodal_data.py +0 -486
- sapiopycommons/webhook/webservice_handlers.py +0 -67
- sapiopycommons-2024.8.27a310.dist-info/RECORD +0 -50
- {sapiopycommons-2024.8.27a310.dist-info → sapiopycommons-2024.8.28a313.dist-info}/WHEEL +0 -0
- {sapiopycommons-2024.8.27a310.dist-info → sapiopycommons-2024.8.28a313.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import re
|
|
2
2
|
from typing import Any, Callable, Iterable
|
|
3
3
|
|
|
4
|
-
from sapiopycommons.general.aliases import SapioRecord
|
|
5
4
|
from sapiopycommons.general.exceptions import SapioException
|
|
6
|
-
|
|
5
|
+
|
|
7
6
|
from sapiopycommons.recordmodel.record_handler import RecordHandler
|
|
8
7
|
|
|
8
|
+
from sapiopycommons.general.aliases import SapioRecord
|
|
9
|
+
|
|
10
|
+
from sapiopycommons.general.time_util import TimeUtil
|
|
11
|
+
|
|
9
12
|
FilterList = Iterable[int] | range | Callable[[int, dict[str, Any]], bool] | None
|
|
10
13
|
"""A FilterList is an object used to determine if a row in the file data should be skipped over. This can take the
|
|
11
14
|
form of am iterable (e.g. list, set) of its or a range where row indices in the list or range are skipped, or it can be
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import io
|
|
2
|
-
import warnings
|
|
3
|
-
import zipfile
|
|
4
2
|
|
|
5
3
|
import pandas
|
|
6
4
|
from numpy import dtype
|
|
@@ -23,8 +21,7 @@ class FileUtil:
|
|
|
23
21
|
"""
|
|
24
22
|
@staticmethod
|
|
25
23
|
def tokenize_csv(file_bytes: bytes, required_headers: list[str] | None = None, header_row_index: int | None = 0,
|
|
26
|
-
seperator: str = ","
|
|
27
|
-
-> tuple[list[dict[str, str]], list[list[str]]]:
|
|
24
|
+
seperator: str = ",") -> tuple[list[dict[str, str]], list[list[str]]]:
|
|
28
25
|
"""
|
|
29
26
|
Tokenize a CSV file. The provided file must be uniform. That is, if row 1 has 10 cells, all the rows in the file
|
|
30
27
|
must have 10 cells. Otherwise, the Pandas parser throws a tokenizer exception.
|
|
@@ -37,30 +34,22 @@ class FileUtil:
|
|
|
37
34
|
meaning that required headers are also ignored if any are provided. By default, the first row (0th index)
|
|
38
35
|
is assumed to be the header row.
|
|
39
36
|
:param seperator: The character that separates cells in the table.
|
|
40
|
-
:param encoding: The encoding used to read the given file bytes. If not provided, uses utf-8. If your file
|
|
41
|
-
contains a non-utf-8 character, then a UnicodeDecodeError will be thrown. If this happens, consider using
|
|
42
|
-
ISO-8859-1 as the encoding.
|
|
43
|
-
:param exception_on_empty: Throw a user error exception if the provided file bytes result in an empty list in
|
|
44
|
-
the first element of the returned tuple.
|
|
45
37
|
:return: The CSV parsed into a list of dicts where each dict is a row, mapping the headers to the cells for
|
|
46
38
|
that row. Also returns a list of each row above the headers (the metadata), parsed into a list of each cell.
|
|
47
39
|
If the header row index is 0 or None, this list will be empty.
|
|
48
40
|
"""
|
|
49
41
|
# Parse the file bytes into two DataFrames. The first is metadata of the file located above the header row,
|
|
50
42
|
# while the second is the body of the file below the header row.
|
|
51
|
-
file_body, file_metadata = FileUtil.csv_to_data_frames(file_bytes, header_row_index, seperator
|
|
52
|
-
encoding=encoding)
|
|
43
|
+
file_body, file_metadata = FileUtil.csv_to_data_frames(file_bytes, header_row_index, seperator)
|
|
53
44
|
# Parse the metadata from above the header row index into a list of lists.
|
|
54
45
|
metadata: list[list[str]] = FileUtil.data_frame_to_lists(file_metadata)
|
|
55
46
|
# Parse the data from the file body into a list of dicts.
|
|
56
47
|
rows: list[dict[str, str]] = FileUtil.data_frame_to_dicts(file_body, required_headers, header_row_index)
|
|
57
|
-
if exception_on_empty and not rows:
|
|
58
|
-
raise SapioUserErrorException("The provided file contains no rows of information below the headers.")
|
|
59
48
|
return rows, metadata
|
|
60
49
|
|
|
61
50
|
@staticmethod
|
|
62
|
-
def tokenize_xlsx(file_bytes: bytes, required_headers: list[str] | None = None, header_row_index: int | None = 0
|
|
63
|
-
|
|
51
|
+
def tokenize_xlsx(file_bytes: bytes, required_headers: list[str] | None = None, header_row_index: int | None = 0) \
|
|
52
|
+
-> tuple[list[dict[str, str]], list[list[str]]]:
|
|
64
53
|
"""
|
|
65
54
|
Tokenize an XLSX file row by row.
|
|
66
55
|
|
|
@@ -71,8 +60,6 @@ class FileUtil:
|
|
|
71
60
|
row is returned in the metadata list. If input is None, then no row is considered to be the header row,
|
|
72
61
|
meaning that required headers are also ignored if any are provided. By default, the first row (0th index)
|
|
73
62
|
is assumed to be the header row.
|
|
74
|
-
:param exception_on_empty: Throw a user error exception if the provided file bytes result in an empty list in
|
|
75
|
-
the first element of the returned tuple.
|
|
76
63
|
:return: The XLSX parsed into a list of dicts where each dict is a row, mapping the headers to the cells for
|
|
77
64
|
that row. Also returns a list of each row above the headers (the metadata), parsed into a list of each cell.
|
|
78
65
|
If the header row index is 0 or None, this list will be empty.
|
|
@@ -84,13 +71,11 @@ class FileUtil:
|
|
|
84
71
|
metadata: list[list[str]] = FileUtil.data_frame_to_lists(file_metadata)
|
|
85
72
|
# Parse the data from the file body into a list of dicts.
|
|
86
73
|
rows: list[dict[str, str]] = FileUtil.data_frame_to_dicts(file_body, required_headers, header_row_index)
|
|
87
|
-
if exception_on_empty and not rows:
|
|
88
|
-
raise SapioUserErrorException("The provided file contains no rows of information below the headers.")
|
|
89
74
|
return rows, metadata
|
|
90
75
|
|
|
91
76
|
@staticmethod
|
|
92
|
-
def csv_to_data_frames(file_bytes: bytes, header_row_index: int | None = 0, seperator: str = ","
|
|
93
|
-
|
|
77
|
+
def csv_to_data_frames(file_bytes: bytes, header_row_index: int | None = 0, seperator: str = ",") \
|
|
78
|
+
-> tuple[DataFrame, DataFrame | None]:
|
|
94
79
|
"""
|
|
95
80
|
Parse the file bytes for a CSV into DataFrames. The provided file must be uniform. That is, if row 1 has 10
|
|
96
81
|
cells, all the rows in the file must have 10 cells. Otherwise, the Pandas parser throws a tokenizer exception.
|
|
@@ -101,9 +86,6 @@ class FileUtil:
|
|
|
101
86
|
meaning that required headers are also ignored if any are provided. By default, the first row (0th index)
|
|
102
87
|
is assumed to be the header row.
|
|
103
88
|
:param seperator: The character that separates cells in the table.
|
|
104
|
-
:param encoding: The encoding used to read the given file bytes. If not provided, uses utf-8. If your file
|
|
105
|
-
contains a non-utf-8 character, then a UnicodeDecodeError will be thrown. If this happens, consider using
|
|
106
|
-
ISO-8859-1 as the encoding.
|
|
107
89
|
:return: A tuple of two DataFrames. The first is the frame for the CSV table body, while the second is for the
|
|
108
90
|
metadata from above the header row, or None if there is no metadata.
|
|
109
91
|
"""
|
|
@@ -115,13 +97,13 @@ class FileUtil:
|
|
|
115
97
|
# can throw off the header row index.
|
|
116
98
|
file_metadata = pandas.read_csv(file_io, header=None, dtype=dtype(str),
|
|
117
99
|
skiprows=lambda x: x >= header_row_index,
|
|
118
|
-
skip_blank_lines=False, sep=seperator
|
|
100
|
+
skip_blank_lines=False, sep=seperator)
|
|
119
101
|
with io.BytesIO(file_bytes) as file_io:
|
|
120
102
|
# The use of the dtype argument is to ensure that everything from the file gets read as a string. Added
|
|
121
103
|
# because some numerical values would get ".0" appended to them, even when casting the DataFrame cell to a
|
|
122
104
|
# string.
|
|
123
105
|
file_body: DataFrame = pandas.read_csv(file_io, header=header_row_index, dtype=dtype(str),
|
|
124
|
-
skip_blank_lines=False, sep=seperator
|
|
106
|
+
skip_blank_lines=False, sep=seperator)
|
|
125
107
|
|
|
126
108
|
return file_body, file_metadata
|
|
127
109
|
|
|
@@ -240,7 +222,7 @@ class FileUtil:
|
|
|
240
222
|
:param file_data: The CSV file to be converted.
|
|
241
223
|
:return: The bytes of the CSV file converted to an XLSX file.
|
|
242
224
|
"""
|
|
243
|
-
with (io.BytesIO(file_data
|
|
225
|
+
with (io.BytesIO(file_data) if isinstance(file_data, bytes) else io.StringIO(file_data)) as csv:
|
|
244
226
|
# Setting header to false makes pandas read the CSV as-is.
|
|
245
227
|
data_frame = pandas.read_csv(csv, sep=",", header=None)
|
|
246
228
|
|
|
@@ -284,20 +266,6 @@ class FileUtil:
|
|
|
284
266
|
file_bytes: bytes = buffer.getvalue()
|
|
285
267
|
return file_bytes
|
|
286
268
|
|
|
287
|
-
@staticmethod
|
|
288
|
-
def zip_files(files: dict[str, str | bytes]) -> bytes:
|
|
289
|
-
"""
|
|
290
|
-
Create a zip file for a collection of files.
|
|
291
|
-
|
|
292
|
-
:param files: A dictionary of file name to file data as a string or bytes.
|
|
293
|
-
:return: The bytes for a zip file containing the input files.
|
|
294
|
-
"""
|
|
295
|
-
zip_buffer: io.BytesIO = io.BytesIO()
|
|
296
|
-
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
|
297
|
-
for file_name, file_data in files.items():
|
|
298
|
-
zip_file.writestr(file_name, file_data)
|
|
299
|
-
return zip_buffer.getvalue()
|
|
300
|
-
|
|
301
269
|
# Deprecated functions:
|
|
302
270
|
|
|
303
271
|
# FR-46097 - Add write file request shorthand functions to FileUtil.
|
|
@@ -315,8 +283,6 @@ class FileUtil:
|
|
|
315
283
|
:param request_context: Context that will be returned to the webhook server in the client callback result.
|
|
316
284
|
:return: A SapioWebhookResult with the write request as its client callback request.
|
|
317
285
|
"""
|
|
318
|
-
warnings.warn("FileUtil.write_file is deprecated as of 24.5+. Use CallbackUtil.write_file instead.",
|
|
319
|
-
DeprecationWarning)
|
|
320
286
|
return SapioWebhookResult(True, client_callback_request=WriteFileRequest(file_bytes, file_name,
|
|
321
287
|
request_context))
|
|
322
288
|
|
|
@@ -333,8 +299,6 @@ class FileUtil:
|
|
|
333
299
|
:param request_context: Context that will be returned to the webhook server in the client callback result.
|
|
334
300
|
:return: A SapioWebhookResult with the write request as its client callback request.
|
|
335
301
|
"""
|
|
336
|
-
warnings.warn("FileUtil.write_files is deprecated as of 24.5+. Use CallbackUtil.write_file instead.",
|
|
337
|
-
DeprecationWarning)
|
|
338
302
|
return SapioWebhookResult(True, client_callback_request=MultiFileRequest(files, request_context))
|
|
339
303
|
|
|
340
304
|
@staticmethod
|
|
@@ -362,8 +326,6 @@ class FileUtil:
|
|
|
362
326
|
1 - The file name of the requested file if the user provided one.
|
|
363
327
|
2 - The file bytes of the requested file if the user provided one.
|
|
364
328
|
"""
|
|
365
|
-
warnings.warn("FileUtil.request_file is deprecated as of 24.5+. Use CallbackUtil.request_file instead.",
|
|
366
|
-
DeprecationWarning)
|
|
367
329
|
client_callback = context.client_callback_result
|
|
368
330
|
result_context: str | None = client_callback.callback_context_data if client_callback else None
|
|
369
331
|
# If the user cancels, terminate the interaction.
|
|
@@ -416,8 +378,6 @@ class FileUtil:
|
|
|
416
378
|
May also contain a result that will terminate the client interaction if the user canceled the prompt.
|
|
417
379
|
1 - A dictionary that maps the file names to the file bytes for each provided file.
|
|
418
380
|
"""
|
|
419
|
-
warnings.warn("FileUtil.request_files is deprecated as of 24.5+. Use CallbackUtil.request_files instead.",
|
|
420
|
-
DeprecationWarning)
|
|
421
381
|
client_callback = context.client_callback_result
|
|
422
382
|
result_context: str | None = client_callback.callback_context_data if client_callback else None
|
|
423
383
|
# If the user cancels, terminate the interaction.
|
|
@@ -460,7 +420,7 @@ class FileUtil:
|
|
|
460
420
|
if len(allowed_extensions) != 0:
|
|
461
421
|
matches: bool = False
|
|
462
422
|
for ext in allowed_extensions:
|
|
463
|
-
if file_path.endswith("." + ext
|
|
423
|
+
if file_path.endswith("." + ext):
|
|
464
424
|
matches = True
|
|
465
425
|
break
|
|
466
426
|
if matches is False:
|
|
@@ -4,15 +4,12 @@ from abc import abstractmethod
|
|
|
4
4
|
from typing import Any
|
|
5
5
|
|
|
6
6
|
from sapiopylib.rest.User import SapioUser
|
|
7
|
-
from sapiopylib.rest.pojo.CustomReport import RawReportTerm, RawTermOperation
|
|
8
7
|
from sapiopylib.rest.pojo.datatype.FieldDefinition import VeloxIntegerFieldDefinition, VeloxStringFieldDefinition, \
|
|
9
8
|
AbstractVeloxFieldDefinition
|
|
9
|
+
from sapiopylib.rest.pojo.webhook.WebhookResult import SapioWebhookResult
|
|
10
10
|
|
|
11
11
|
from sapiopycommons.callbacks.callback_util import CallbackUtil
|
|
12
12
|
from sapiopycommons.files.file_data_handler import FileDataHandler, FilterList
|
|
13
|
-
from sapiopycommons.general.aliases import UserIdentifier, AliasUtil
|
|
14
|
-
from sapiopycommons.general.custom_report_util import CustomReportUtil
|
|
15
|
-
from sapiopycommons.general.exceptions import SapioUserCancelledException
|
|
16
13
|
from sapiopycommons.general.time_util import TimeUtil
|
|
17
14
|
|
|
18
15
|
|
|
@@ -80,10 +77,10 @@ class FileValidator:
|
|
|
80
77
|
|
|
81
78
|
return failed_rows
|
|
82
79
|
|
|
83
|
-
def build_violation_report(self, context:
|
|
80
|
+
def build_violation_report(self, context: SapioWebhookResult | SapioUser,
|
|
84
81
|
rule_violations: dict[int, list[ValidationRule]]) -> None:
|
|
85
82
|
"""
|
|
86
|
-
|
|
83
|
+
Build a simple report of any rule violations in the file to display to the user as a table dialog.
|
|
87
84
|
|
|
88
85
|
:param context: The current webhook context or a user object to send requests from.
|
|
89
86
|
:param rule_violations: A dict of rule violations generated by a call to validate_file.
|
|
@@ -121,24 +118,9 @@ class FileValidator:
|
|
|
121
118
|
"Reason": violation.reason[:2000]
|
|
122
119
|
})
|
|
123
120
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def validate_and_report_errors(self, context: UserIdentifier) -> None:
|
|
129
|
-
"""
|
|
130
|
-
Validate the file. If any rule violations are found, display a simple report of any rule violations in the file
|
|
131
|
-
to the user as a table dialog and throw a SapioUserCancelled exception after the user acknowledges the dialog
|
|
132
|
-
to end the webhook interaction.
|
|
133
|
-
|
|
134
|
-
Shorthand for calling validate_file() and then build_violation_report() if there are any errors.
|
|
135
|
-
|
|
136
|
-
:param context: The current webhook context or a user object to send requests from.
|
|
137
|
-
"""
|
|
138
|
-
violations = self.validate_file()
|
|
139
|
-
if violations:
|
|
140
|
-
self.build_violation_report(context, violations)
|
|
141
|
-
raise SapioUserCancelledException()
|
|
121
|
+
callback_util = CallbackUtil(context)
|
|
122
|
+
callback_util.table_dialog("Errors", "The following rule violations were encountered in the provided file.",
|
|
123
|
+
columns, rows)
|
|
142
124
|
|
|
143
125
|
|
|
144
126
|
class ValidationRule:
|
|
@@ -498,71 +480,3 @@ class ContainsSubstringFromCellRule(RowRule):
|
|
|
498
480
|
|
|
499
481
|
def validate(self, row: dict[str, Any]) -> bool:
|
|
500
482
|
return row.get(self.second) in row.get(self.first)
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
class UniqueSystemValueRule(ColumnRule):
|
|
504
|
-
"""
|
|
505
|
-
Requires that every cell in the column has a value that is not already in use in the system for a given data type
|
|
506
|
-
and field name.
|
|
507
|
-
"""
|
|
508
|
-
user: SapioUser
|
|
509
|
-
data_type_name: str
|
|
510
|
-
data_field_name: str
|
|
511
|
-
|
|
512
|
-
def __init__(self, context: UserIdentifier, header: str, data_type_name: str,
|
|
513
|
-
data_field_name: str):
|
|
514
|
-
"""
|
|
515
|
-
:param context: The current webhook context or a user object to send requests from.
|
|
516
|
-
:param header: The header that this rule acts upon.
|
|
517
|
-
:param data_type_name: The data type name to search on.
|
|
518
|
-
:param data_field_name: The data field name to search on. This is expected to be a string field.
|
|
519
|
-
"""
|
|
520
|
-
self.user = AliasUtil.to_sapio_user(context)
|
|
521
|
-
self.data_type_name = data_type_name
|
|
522
|
-
self.data_field_name = data_field_name
|
|
523
|
-
super().__init__(header, f"This value already exists in the system.")
|
|
524
|
-
|
|
525
|
-
def validate(self, rows: list[dict[str, Any]]) -> list[int]:
|
|
526
|
-
file_handler = FileDataHandler(rows)
|
|
527
|
-
values: list[str] = file_handler.get_values_list(self.header)
|
|
528
|
-
|
|
529
|
-
# Run a quick report for all records of this type that match these field values.
|
|
530
|
-
term = RawReportTerm(self.data_type_name, self.data_field_name, RawTermOperation.EQUAL_TO_OPERATOR,
|
|
531
|
-
"{" + ",".join(values) + "}")
|
|
532
|
-
results: list[dict[str, Any]] = CustomReportUtil.run_quick_report(self.user, term)
|
|
533
|
-
existing_values: list[Any] = [x.get(self.data_field_name) for x in results]
|
|
534
|
-
return file_handler.get_in_list(self.header, existing_values)
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
class ExistingSystemValueRule(ColumnRule):
|
|
538
|
-
"""
|
|
539
|
-
Requires that every cell in the column has a value that is already in use in the system for a given data type
|
|
540
|
-
and field name.
|
|
541
|
-
"""
|
|
542
|
-
user: SapioUser
|
|
543
|
-
data_type_name: str
|
|
544
|
-
data_field_name: str
|
|
545
|
-
|
|
546
|
-
def __init__(self, context: UserIdentifier, header: str, data_type_name: str,
|
|
547
|
-
data_field_name: str):
|
|
548
|
-
"""
|
|
549
|
-
:param context: The current webhook context or a user object to send requests from.
|
|
550
|
-
:param header: The header that this rule acts upon.
|
|
551
|
-
:param data_type_name: The data type name to search on.
|
|
552
|
-
:param data_field_name: The data field name to search on. This is expected to be a string field.
|
|
553
|
-
"""
|
|
554
|
-
self.user = AliasUtil.to_sapio_user(context)
|
|
555
|
-
self.data_type_name = data_type_name
|
|
556
|
-
self.data_field_name = data_field_name
|
|
557
|
-
super().__init__(header, f"This value doesn't exist in the system.")
|
|
558
|
-
|
|
559
|
-
def validate(self, rows: list[dict[str, Any]]) -> list[int]:
|
|
560
|
-
file_handler = FileDataHandler(rows)
|
|
561
|
-
values: list[str] = file_handler.get_values_list(self.header)
|
|
562
|
-
|
|
563
|
-
# Run a quick report for all records of this type that match these field values.
|
|
564
|
-
term = RawReportTerm(self.data_type_name, self.data_field_name, RawTermOperation.EQUAL_TO_OPERATOR,
|
|
565
|
-
"{" + ",".join(values) + "}")
|
|
566
|
-
results: list[dict[str, Any]] = CustomReportUtil.run_quick_report(self.user, term)
|
|
567
|
-
existing_values: list[Any] = [x.get(self.data_field_name) for x in results]
|
|
568
|
-
return file_handler.get_not_in_list(self.header, existing_values)
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import warnings
|
|
4
3
|
from abc import abstractmethod
|
|
5
4
|
from enum import Enum
|
|
6
5
|
from typing import Any
|
|
@@ -19,7 +18,7 @@ class FileWriter:
|
|
|
19
18
|
body: list[list[Any]]
|
|
20
19
|
delimiter: str
|
|
21
20
|
line_break: str
|
|
22
|
-
column_definitions:
|
|
21
|
+
column_definitions: list[ColumnDef]
|
|
23
22
|
|
|
24
23
|
def __init__(self, headers: list[str], delimiter: str = ",", line_break: str = "\r\n"):
|
|
25
24
|
"""
|
|
@@ -31,7 +30,7 @@ class FileWriter:
|
|
|
31
30
|
self.delimiter = delimiter
|
|
32
31
|
self.line_break = line_break
|
|
33
32
|
self.body = []
|
|
34
|
-
self.column_definitions =
|
|
33
|
+
self.column_definitions = []
|
|
35
34
|
|
|
36
35
|
def add_row_list(self, row: list[Any]) -> None:
|
|
37
36
|
"""
|
|
@@ -66,49 +65,21 @@ class FileWriter:
|
|
|
66
65
|
new_row.append(row.get(header, ""))
|
|
67
66
|
self.body.append(new_row)
|
|
68
67
|
|
|
69
|
-
def
|
|
68
|
+
def add_column_definitions(self, column_defs: list[ColumnDef]) -> None:
|
|
70
69
|
"""
|
|
71
|
-
Add
|
|
70
|
+
Add new column definitions to this FileWriter. Column definitions are evaluated in the order they are added,
|
|
71
|
+
meaning that they map to the header with the equivalent index. Before the file is built, the number of column
|
|
72
|
+
definitions must equal the number of headers if any column definition is provided.
|
|
72
73
|
|
|
73
|
-
ColumnDefs are only used if the build_file function is provided with a list of RowBundles.
|
|
74
|
-
have a column definition if this is the case.
|
|
74
|
+
ColumnDefs are only used if the build_file function is provided with a list of RowBundles.
|
|
75
75
|
|
|
76
76
|
Custom column definitions can be created by defining a class that extends ColumnDef and implements the print
|
|
77
77
|
method.
|
|
78
78
|
|
|
79
|
-
:param
|
|
79
|
+
:param column_defs: A list of column definitions to be used to construct the file when build_file is
|
|
80
80
|
called.
|
|
81
|
-
:param header: The header that this column definition is for. If a header is provided that isn't in the headers
|
|
82
|
-
list, the header is appended to the end of the list.
|
|
83
81
|
"""
|
|
84
|
-
|
|
85
|
-
self.headers.append(header)
|
|
86
|
-
self.column_definitions[header] = column_def
|
|
87
|
-
|
|
88
|
-
def add_column_definitions(self, column_defs: dict[str, ColumnDef]) -> None:
|
|
89
|
-
"""
|
|
90
|
-
Add new column definitions to this FileWriter.
|
|
91
|
-
|
|
92
|
-
ColumnDefs are only used if the build_file function is provided with a list of RowBundles. Every header must
|
|
93
|
-
have a column definition if this is the case.
|
|
94
|
-
|
|
95
|
-
Custom column definitions can be created by defining a class that extends ColumnDef and implements the print
|
|
96
|
-
method.
|
|
97
|
-
|
|
98
|
-
:param column_defs: A dictionary of header names to column definitions to be used to construct the file when
|
|
99
|
-
build_file is called.
|
|
100
|
-
"""
|
|
101
|
-
# For backwards compatibility purposes, if column definitions are provided as a list,
|
|
102
|
-
# add them in order of appearance of the headers. This will only work if the headers are defined first, though.
|
|
103
|
-
if isinstance(column_defs, list):
|
|
104
|
-
warnings.warn("Adding column definitions is no longer expected as a list. Continuing to provide a list to "
|
|
105
|
-
"this function may result in undesirable behavior.", UserWarning)
|
|
106
|
-
if not self.headers:
|
|
107
|
-
raise SapioException("No headers provided to FileWriter before the column definitions were added.")
|
|
108
|
-
for header, column_def in zip(self.headers, column_defs):
|
|
109
|
-
self.column_definitions[header] = column_def
|
|
110
|
-
for header, column_def in column_defs.items():
|
|
111
|
-
self.add_column_definition(header, column_def)
|
|
82
|
+
self.column_definitions.extend(column_defs)
|
|
112
83
|
|
|
113
84
|
def build_file(self, rows: list[RowBundle] | None = None, sorter=None, reverse: bool = False) -> str:
|
|
114
85
|
"""
|
|
@@ -129,10 +100,11 @@ class FileWriter:
|
|
|
129
100
|
"""
|
|
130
101
|
# If any column definitions have been provided, the number of column definitions and headers must be equal.
|
|
131
102
|
if self.column_definitions:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
103
|
+
def_count: int = len(self.column_definitions)
|
|
104
|
+
header_count: int = len(self.headers)
|
|
105
|
+
if def_count != header_count:
|
|
106
|
+
raise SapioException(f"FileWriter has {def_count} column definitions defined but {header_count} "
|
|
107
|
+
f"headers. The number of column definitions must equal the number of headers.")
|
|
136
108
|
# If any RowBundles have been provided, there must be column definitions for mapping them to the file.
|
|
137
109
|
elif rows:
|
|
138
110
|
raise SapioException(f"FileWriter was given RowBundles but contains no column definitions for mapping "
|
|
@@ -158,8 +130,7 @@ class FileWriter:
|
|
|
158
130
|
rows.sort(key=lambda x: x.index)
|
|
159
131
|
for row in rows:
|
|
160
132
|
new_row: list[Any] = []
|
|
161
|
-
for
|
|
162
|
-
column = self.column_definitions[header]
|
|
133
|
+
for column in self.column_definitions:
|
|
163
134
|
if column.may_skip and row.may_skip:
|
|
164
135
|
new_row.append("")
|
|
165
136
|
else:
|
|
@@ -1,47 +1,24 @@
|
|
|
1
1
|
from collections.abc import Iterable
|
|
2
2
|
from typing import Any
|
|
3
3
|
|
|
4
|
-
from sapiopylib.rest.User import SapioUser
|
|
5
4
|
from sapiopylib.rest.pojo.DataRecord import DataRecord
|
|
6
|
-
from sapiopylib.rest.pojo.datatype.FieldDefinition import FieldType
|
|
7
5
|
from sapiopylib.rest.pojo.eln.ElnExperiment import ElnExperiment
|
|
8
|
-
from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
|
|
9
6
|
from sapiopylib.rest.utils.Protocols import ElnExperimentProtocol
|
|
10
7
|
from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel
|
|
11
|
-
from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedRecordModel, WrappedType
|
|
8
|
+
from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedRecordModel, WrappedType
|
|
12
9
|
|
|
13
|
-
from sapiopycommons.general.exceptions import SapioException
|
|
14
|
-
|
|
15
|
-
FieldValue = int | float | str | bool | None
|
|
16
|
-
"""Allowable values for fields in the system."""
|
|
17
10
|
RecordModel = PyRecordModel | WrappedRecordModel | WrappedType
|
|
18
11
|
"""Different forms that a record model could take."""
|
|
19
12
|
SapioRecord = DataRecord | RecordModel
|
|
20
13
|
"""A record could be provided as either a DataRecord, PyRecordModel, or WrappedRecordModel (WrappedType)."""
|
|
21
14
|
RecordIdentifier = SapioRecord | int
|
|
22
15
|
"""A RecordIdentifier is either a record type or an integer for the record's record ID."""
|
|
23
|
-
DataTypeIdentifier = SapioRecord | type[WrappedType] | str
|
|
24
|
-
"""A DataTypeIdentifier is either a SapioRecord, a record model wrapper type, or a string."""
|
|
25
|
-
FieldIdentifier = WrapperField | str | tuple[str, FieldType]
|
|
26
|
-
"""A FieldIdentifier is either wrapper field from a record model wrapper, a string, or a tuple of string
|
|
27
|
-
and field type."""
|
|
28
|
-
FieldIdentifierKey = WrapperField | str
|
|
29
|
-
"""A FieldIdentifierKey is a FieldIdentifier, except it can't be a tuple, s tuples can't be used as keys in
|
|
30
|
-
dictionaries.."""
|
|
31
|
-
HasFieldWrappers = type[WrappedType] | WrappedRecordModel
|
|
32
|
-
"""An identifier for classes that have wrapper fields."""
|
|
33
16
|
ExperimentIdentifier = ElnExperimentProtocol | ElnExperiment | int
|
|
34
17
|
"""An ExperimentIdentifier is either an experiment protocol, experiment, or an integer for te experiment's notebook
|
|
35
18
|
ID."""
|
|
36
|
-
FieldMap = dict[str,
|
|
19
|
+
FieldMap = dict[str, Any]
|
|
37
20
|
"""A field map is simply a dict of data field names to values. The purpose of aliasing this is to help distinguish
|
|
38
21
|
any random dict in a webhook from one which is explicitly used for record fields."""
|
|
39
|
-
FieldIdentifierMap = dict[FieldIdentifierKey, FieldValue]
|
|
40
|
-
"""A field identifier map is the same thing as a field map, except the keys can be field identifiers instead
|
|
41
|
-
of just strings. Note that although one of the allowed field identifiers is a tuple, you can't use tuples as
|
|
42
|
-
keys in a dictionary."""
|
|
43
|
-
UserIdentifier = SapioWebhookContext | SapioUser
|
|
44
|
-
"""An identifier for classes from which a user object can be used for sending requests."""
|
|
45
22
|
|
|
46
23
|
|
|
47
24
|
# FR-46064 - Initial port of PyWebhookUtils to sapiopycommons.
|
|
@@ -73,123 +50,7 @@ class AliasUtil:
|
|
|
73
50
|
|
|
74
51
|
:return: A list of record IDs for the input records.
|
|
75
52
|
"""
|
|
76
|
-
return [(
|
|
77
|
-
|
|
78
|
-
@staticmethod
|
|
79
|
-
def to_record_id(record: RecordIdentifier):
|
|
80
|
-
"""
|
|
81
|
-
Convert a single variable that could be either an integer, DataRecord, PyRecordModel,
|
|
82
|
-
or WrappedRecordModel to just an integer (taking the record ID from the record).
|
|
83
|
-
|
|
84
|
-
:return: A record ID for the input record.
|
|
85
|
-
"""
|
|
86
|
-
return record if isinstance(record, int) else record.record_id
|
|
87
|
-
|
|
88
|
-
@staticmethod
|
|
89
|
-
def to_data_type_name(value: DataTypeIdentifier) -> str:
|
|
90
|
-
"""
|
|
91
|
-
Convert a given value to a data type name.
|
|
92
|
-
|
|
93
|
-
:param value: A value which is a string, record, or record model type.
|
|
94
|
-
:return: A string of the data type name of the input value.
|
|
95
|
-
"""
|
|
96
|
-
if isinstance(value, str):
|
|
97
|
-
return value
|
|
98
|
-
if isinstance(value, SapioRecord):
|
|
99
|
-
return value.data_type_name
|
|
100
|
-
return value.get_wrapper_data_type_name()
|
|
101
|
-
|
|
102
|
-
@staticmethod
|
|
103
|
-
def to_data_type_names(values: Iterable[DataTypeIdentifier], return_set: bool = False) -> list[str] | set[str]:
|
|
104
|
-
"""
|
|
105
|
-
Convert a given iterable of values to a list or set of data type names.
|
|
106
|
-
|
|
107
|
-
:param values: An iterable of values which are strings, records, or record model types.
|
|
108
|
-
:param return_set: If true, return a set instead of a list.
|
|
109
|
-
:return: A list or set of strings of the data type name of the input value.
|
|
110
|
-
"""
|
|
111
|
-
values = [AliasUtil.to_data_type_name(x) for x in values]
|
|
112
|
-
return set(values) if return_set else values
|
|
113
|
-
|
|
114
|
-
@staticmethod
|
|
115
|
-
def to_singular_data_type_name(values: Iterable[DataTypeIdentifier]) -> str:
|
|
116
|
-
"""
|
|
117
|
-
Convert a given iterable of values to a singular data type name that they share. Throws an exception if more
|
|
118
|
-
than one data type name exists in the provided list of identifiers.
|
|
119
|
-
|
|
120
|
-
:param values: An iterable of values which are strings, records, or record model types.
|
|
121
|
-
:return: The single data type name that the input vales share.
|
|
122
|
-
"""
|
|
123
|
-
data_types: set[str] = AliasUtil.to_data_type_names(values, True)
|
|
124
|
-
if len(data_types) > 1:
|
|
125
|
-
raise SapioException(f"Provided values contain multiple data types: {data_types}. "
|
|
126
|
-
f"Only expecting a single data type.")
|
|
127
|
-
return data_types.pop()
|
|
128
|
-
|
|
129
|
-
@staticmethod
|
|
130
|
-
def to_data_field_name(value: FieldIdentifier) -> str:
|
|
131
|
-
"""
|
|
132
|
-
Convert a string or WrapperField to a data field name string.
|
|
133
|
-
|
|
134
|
-
:param value: A string or WrapperField.
|
|
135
|
-
:return: A string of the data field name of the input value.
|
|
136
|
-
"""
|
|
137
|
-
if isinstance(value, tuple):
|
|
138
|
-
return value[0]
|
|
139
|
-
if isinstance(value, WrapperField):
|
|
140
|
-
return value.field_name
|
|
141
|
-
return value
|
|
142
|
-
|
|
143
|
-
@staticmethod
|
|
144
|
-
def to_data_field_names(values: Iterable[FieldIdentifier]) -> list[str]:
|
|
145
|
-
"""
|
|
146
|
-
Convert an iterable of strings or WrapperFields to a list of data field name strings.
|
|
147
|
-
|
|
148
|
-
:param values: An iterable of strings or WrapperFields.
|
|
149
|
-
:return: A list of strings of the data field names of the input values.
|
|
150
|
-
"""
|
|
151
|
-
return [AliasUtil.to_data_field_name(x) for x in values]
|
|
152
|
-
|
|
153
|
-
@staticmethod
|
|
154
|
-
def to_data_field_names_dict(values: dict[FieldIdentifierKey, Any]) -> dict[str, Any]:
|
|
155
|
-
"""
|
|
156
|
-
Take a dictionary whose keys are field identifiers and convert them all to strings for the data field name.
|
|
157
|
-
|
|
158
|
-
:param values: A dictionary of field identifiers to field values.
|
|
159
|
-
:return: A dictionary of strings of the data field names to field values for the input values.
|
|
160
|
-
"""
|
|
161
|
-
ret_dict: dict[str, FieldValue] = {}
|
|
162
|
-
for field, value in values.items():
|
|
163
|
-
ret_dict[AliasUtil.to_data_field_name(field)] = value
|
|
164
|
-
return ret_dict
|
|
165
|
-
|
|
166
|
-
@staticmethod
|
|
167
|
-
def to_data_field_names_list_dict(values: list[dict[FieldIdentifierKey, Any]]) -> list[dict[str, Any]]:
|
|
168
|
-
ret_list: list[dict[str, Any]] = []
|
|
169
|
-
for field_map in values:
|
|
170
|
-
ret_list.append(AliasUtil.to_data_field_names_dict(field_map))
|
|
171
|
-
return ret_list
|
|
172
|
-
|
|
173
|
-
@staticmethod
|
|
174
|
-
def to_field_type(field: FieldIdentifier, data_type: HasFieldWrappers | None = None) -> FieldType:
|
|
175
|
-
"""
|
|
176
|
-
Convert a given field identifier to the field type for that field.
|
|
177
|
-
|
|
178
|
-
:param field: A string or WrapperField.
|
|
179
|
-
:param data_type: If the field is provided as a string, then a record model wrapper or wrapped record model
|
|
180
|
-
must be provided to determine the field type.
|
|
181
|
-
:return: The field type of the given field.
|
|
182
|
-
"""
|
|
183
|
-
if isinstance(field, tuple):
|
|
184
|
-
return field[1]
|
|
185
|
-
if isinstance(field, WrapperField):
|
|
186
|
-
return field.field_type
|
|
187
|
-
for var in dir(data_type):
|
|
188
|
-
attr = getattr(data_type, var)
|
|
189
|
-
if isinstance(attr, WrapperField) and attr.field_name == field:
|
|
190
|
-
return attr.field_type
|
|
191
|
-
raise SapioException(f"The wrapper of data type \"{data_type.get_wrapper_data_type_name()}\" doesn't have a "
|
|
192
|
-
f"field with the name \"{field}\",")
|
|
53
|
+
return [(x if isinstance(x, int) else x.record_id) for x in records]
|
|
193
54
|
|
|
194
55
|
@staticmethod
|
|
195
56
|
def to_field_map_lists(records: Iterable[SapioRecord]) -> list[FieldMap]:
|
|
@@ -202,7 +63,6 @@ class AliasUtil:
|
|
|
202
63
|
field_map_list: list[FieldMap] = []
|
|
203
64
|
for record in records:
|
|
204
65
|
if isinstance(record, DataRecord):
|
|
205
|
-
# noinspection PyTypeChecker
|
|
206
66
|
field_map_list.append(record.get_fields())
|
|
207
67
|
else:
|
|
208
68
|
field_map_list.append(record.fields.copy_to_dict())
|
|
@@ -220,7 +80,3 @@ class AliasUtil:
|
|
|
220
80
|
if isinstance(experiment, ElnExperiment):
|
|
221
81
|
return experiment.notebook_experiment_id
|
|
222
82
|
return experiment.get_id()
|
|
223
|
-
|
|
224
|
-
@staticmethod
|
|
225
|
-
def to_sapio_user(context: UserIdentifier) -> SapioUser:
|
|
226
|
-
return context if isinstance(context, SapioUser) else context.user
|