sapiopycommons 2024.8.15a304__py3-none-any.whl → 2024.8.20a306__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 +130 -34
- sapiopycommons/customreport/__init__.py +0 -0
- sapiopycommons/customreport/column_builder.py +60 -0
- sapiopycommons/customreport/custom_report_builder.py +125 -0
- sapiopycommons/customreport/term_builder.py +299 -0
- sapiopycommons/datatype/attachment_util.py +11 -10
- sapiopycommons/eln/experiment_handler.py +209 -44
- sapiopycommons/eln/experiment_report_util.py +33 -129
- sapiopycommons/files/complex_data_loader.py +5 -4
- sapiopycommons/files/file_bridge.py +15 -14
- sapiopycommons/files/file_bridge_handler.py +26 -4
- sapiopycommons/files/file_data_handler.py +2 -5
- sapiopycommons/files/file_util.py +38 -5
- sapiopycommons/files/file_validator.py +26 -11
- sapiopycommons/files/file_writer.py +44 -15
- sapiopycommons/general/aliases.py +147 -3
- sapiopycommons/general/audit_log.py +196 -0
- sapiopycommons/general/custom_report_util.py +34 -32
- sapiopycommons/general/popup_util.py +17 -0
- sapiopycommons/general/sapio_links.py +50 -0
- sapiopycommons/general/time_util.py +40 -0
- sapiopycommons/multimodal/multimodal_data.py +0 -1
- sapiopycommons/processtracking/endpoints.py +22 -22
- sapiopycommons/recordmodel/record_handler.py +183 -61
- sapiopycommons/rules/eln_rule_handler.py +34 -25
- sapiopycommons/rules/on_save_rule_handler.py +34 -31
- sapiopycommons/webhook/webhook_handlers.py +90 -26
- sapiopycommons/webhook/webservice_handlers.py +67 -0
- {sapiopycommons-2024.8.15a304.dist-info → sapiopycommons-2024.8.20a306.dist-info}/METADATA +1 -1
- sapiopycommons-2024.8.20a306.dist-info/RECORD +50 -0
- sapiopycommons-2024.8.15a304.dist-info/RECORD +0 -43
- {sapiopycommons-2024.8.15a304.dist-info → sapiopycommons-2024.8.20a306.dist-info}/WHEEL +0 -0
- {sapiopycommons-2024.8.15a304.dist-info → sapiopycommons-2024.8.20a306.dist-info}/licenses/LICENSE +0 -0
|
@@ -4,13 +4,14 @@ import urllib.parse
|
|
|
4
4
|
|
|
5
5
|
from requests import Response
|
|
6
6
|
from sapiopylib.rest.User import SapioUser
|
|
7
|
-
|
|
7
|
+
|
|
8
|
+
from sapiopycommons.general.aliases import UserIdentifier, AliasUtil
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
# FR-46064 - Initial port of PyWebhookUtils to sapiopycommons.
|
|
11
12
|
class FileBridge:
|
|
12
13
|
@staticmethod
|
|
13
|
-
def read_file(context:
|
|
14
|
+
def read_file(context: UserIdentifier, bridge_name: str, file_path: str,
|
|
14
15
|
base64_decode: bool = True) -> bytes:
|
|
15
16
|
"""
|
|
16
17
|
Read a file from FileBridge.
|
|
@@ -27,7 +28,7 @@ class FileBridge:
|
|
|
27
28
|
params = {
|
|
28
29
|
'Filepath': f"bridge://{bridge_name}/{file_path}"
|
|
29
30
|
}
|
|
30
|
-
user: SapioUser =
|
|
31
|
+
user: SapioUser = AliasUtil.to_sapio_user(context)
|
|
31
32
|
response = user.get(sub_path, params)
|
|
32
33
|
user.raise_for_status(response)
|
|
33
34
|
|
|
@@ -37,7 +38,7 @@ class FileBridge:
|
|
|
37
38
|
return ret_val
|
|
38
39
|
|
|
39
40
|
@staticmethod
|
|
40
|
-
def write_file(context:
|
|
41
|
+
def write_file(context: UserIdentifier, bridge_name: str, file_path: str,
|
|
41
42
|
file_data: bytes | str) -> None:
|
|
42
43
|
"""
|
|
43
44
|
Write a file to FileBridge.
|
|
@@ -53,13 +54,13 @@ class FileBridge:
|
|
|
53
54
|
params = {
|
|
54
55
|
'Filepath': f"bridge://{bridge_name}/{file_path}"
|
|
55
56
|
}
|
|
56
|
-
user: SapioUser =
|
|
57
|
-
with io.
|
|
57
|
+
user: SapioUser = AliasUtil.to_sapio_user(context)
|
|
58
|
+
with io.BytesIO(file_data.encode() if isinstance(file_data, str) else file_data) as data_stream:
|
|
58
59
|
response = user.post_data_stream(sub_path, params=params, data_stream=data_stream)
|
|
59
60
|
user.raise_for_status(response)
|
|
60
61
|
|
|
61
62
|
@staticmethod
|
|
62
|
-
def list_directory(context:
|
|
63
|
+
def list_directory(context: UserIdentifier, bridge_name: str,
|
|
63
64
|
file_path: str | None = "") -> list[str]:
|
|
64
65
|
"""
|
|
65
66
|
List the contents of a FileBridge directory.
|
|
@@ -74,7 +75,7 @@ class FileBridge:
|
|
|
74
75
|
params = {
|
|
75
76
|
'Filepath': f"bridge://{bridge_name}/{file_path}"
|
|
76
77
|
}
|
|
77
|
-
user: SapioUser =
|
|
78
|
+
user: SapioUser = AliasUtil.to_sapio_user(context)
|
|
78
79
|
response: Response = user.get(sub_path, params=params)
|
|
79
80
|
user.raise_for_status(response)
|
|
80
81
|
|
|
@@ -83,7 +84,7 @@ class FileBridge:
|
|
|
83
84
|
return [urllib.parse.unquote(value)[path_length:] for value in response_body]
|
|
84
85
|
|
|
85
86
|
@staticmethod
|
|
86
|
-
def create_directory(context:
|
|
87
|
+
def create_directory(context: UserIdentifier, bridge_name: str, file_path: str) -> None:
|
|
87
88
|
"""
|
|
88
89
|
Create a new directory in FileBridge.
|
|
89
90
|
|
|
@@ -97,12 +98,12 @@ class FileBridge:
|
|
|
97
98
|
params = {
|
|
98
99
|
'Filepath': f"bridge://{bridge_name}/{file_path}"
|
|
99
100
|
}
|
|
100
|
-
user: SapioUser =
|
|
101
|
+
user: SapioUser = AliasUtil.to_sapio_user(context)
|
|
101
102
|
response = user.post(sub_path, params=params)
|
|
102
103
|
user.raise_for_status(response)
|
|
103
104
|
|
|
104
105
|
@staticmethod
|
|
105
|
-
def delete_file(context:
|
|
106
|
+
def delete_file(context: UserIdentifier, bridge_name: str, file_path: str) -> None:
|
|
106
107
|
"""
|
|
107
108
|
Delete an existing file in FileBridge.
|
|
108
109
|
|
|
@@ -115,12 +116,12 @@ class FileBridge:
|
|
|
115
116
|
params = {
|
|
116
117
|
'Filepath': f"bridge://{bridge_name}/{file_path}"
|
|
117
118
|
}
|
|
118
|
-
user: SapioUser =
|
|
119
|
+
user: SapioUser = AliasUtil.to_sapio_user(context)
|
|
119
120
|
response = user.delete(sub_path, params=params)
|
|
120
121
|
user.raise_for_status(response)
|
|
121
122
|
|
|
122
123
|
@staticmethod
|
|
123
|
-
def delete_directory(context:
|
|
124
|
+
def delete_directory(context: UserIdentifier, bridge_name: str, file_path: str) -> None:
|
|
124
125
|
"""
|
|
125
126
|
Delete an existing directory in FileBridge.
|
|
126
127
|
|
|
@@ -133,6 +134,6 @@ class FileBridge:
|
|
|
133
134
|
params = {
|
|
134
135
|
'Filepath': f"bridge://{bridge_name}/{file_path}"
|
|
135
136
|
}
|
|
136
|
-
user: SapioUser =
|
|
137
|
+
user: SapioUser = AliasUtil.to_sapio_user(context)
|
|
137
138
|
response = user.delete(sub_path, params=params)
|
|
138
139
|
user.raise_for_status(response)
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from abc import abstractmethod, ABC
|
|
4
|
+
from weakref import WeakValueDictionary
|
|
4
5
|
|
|
5
|
-
from sapiopycommons.files.file_bridge import FileBridge
|
|
6
6
|
from sapiopylib.rest.User import SapioUser
|
|
7
|
-
|
|
7
|
+
|
|
8
|
+
from sapiopycommons.files.file_bridge import FileBridge
|
|
9
|
+
from sapiopycommons.general.aliases import AliasUtil, UserIdentifier
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
class FileBridgeHandler:
|
|
@@ -23,13 +25,33 @@ class FileBridgeHandler:
|
|
|
23
25
|
__directories: dict[str, Directory]
|
|
24
26
|
"""A cache of directory file paths to Directory objects."""
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
__instances: WeakValueDictionary[str, FileBridgeHandler] = WeakValueDictionary()
|
|
29
|
+
__initialized: bool
|
|
30
|
+
|
|
31
|
+
def __new__(cls, context: UserIdentifier, bridge_name: str):
|
|
32
|
+
"""
|
|
33
|
+
:param context: The current webhook context or a user object to send requests from.
|
|
34
|
+
"""
|
|
35
|
+
user = AliasUtil.to_sapio_user(context)
|
|
36
|
+
key = f"{user.__hash__()}:{bridge_name}"
|
|
37
|
+
obj = cls.__instances.get(key)
|
|
38
|
+
if not obj:
|
|
39
|
+
obj = object.__new__(cls)
|
|
40
|
+
obj.__initialized = False
|
|
41
|
+
cls.__instances[key] = obj
|
|
42
|
+
return obj
|
|
43
|
+
|
|
44
|
+
def __init__(self, context: UserIdentifier, bridge_name: str):
|
|
27
45
|
"""
|
|
28
46
|
:param context: The current webhook context or a user object to send requests from.
|
|
29
47
|
:param bridge_name: The name of the bridge to communicate with. This is the "connection name" in the
|
|
30
48
|
file bridge configurations.
|
|
31
49
|
"""
|
|
32
|
-
|
|
50
|
+
if self.__initialized:
|
|
51
|
+
return
|
|
52
|
+
self.__initialized = True
|
|
53
|
+
|
|
54
|
+
self.user = AliasUtil.to_sapio_user(context)
|
|
33
55
|
self.__bridge = bridge_name
|
|
34
56
|
self.__file_cache = {}
|
|
35
57
|
self.__files = {}
|
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import re
|
|
2
2
|
from typing import Any, Callable, Iterable
|
|
3
3
|
|
|
4
|
-
from sapiopycommons.general.exceptions import SapioException
|
|
5
|
-
|
|
6
|
-
from sapiopycommons.recordmodel.record_handler import RecordHandler
|
|
7
|
-
|
|
8
4
|
from sapiopycommons.general.aliases import SapioRecord
|
|
9
|
-
|
|
5
|
+
from sapiopycommons.general.exceptions import SapioException
|
|
10
6
|
from sapiopycommons.general.time_util import TimeUtil
|
|
7
|
+
from sapiopycommons.recordmodel.record_handler import RecordHandler
|
|
11
8
|
|
|
12
9
|
FilterList = Iterable[int] | range | Callable[[int, dict[str, Any]], bool] | None
|
|
13
10
|
"""A FilterList is an object used to determine if a row in the file data should be skipped over. This can take the
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import io
|
|
2
|
+
import warnings
|
|
3
|
+
import zipfile
|
|
2
4
|
|
|
3
5
|
import pandas
|
|
4
6
|
from numpy import dtype
|
|
@@ -21,7 +23,8 @@ class FileUtil:
|
|
|
21
23
|
"""
|
|
22
24
|
@staticmethod
|
|
23
25
|
def tokenize_csv(file_bytes: bytes, required_headers: list[str] | None = None, header_row_index: int | None = 0,
|
|
24
|
-
seperator: str = ",", *, encoding: str | None = None
|
|
26
|
+
seperator: str = ",", *, encoding: str | None = None, exception_on_empty: bool = True) \
|
|
27
|
+
-> tuple[list[dict[str, str]], list[list[str]]]:
|
|
25
28
|
"""
|
|
26
29
|
Tokenize a CSV file. The provided file must be uniform. That is, if row 1 has 10 cells, all the rows in the file
|
|
27
30
|
must have 10 cells. Otherwise, the Pandas parser throws a tokenizer exception.
|
|
@@ -37,6 +40,8 @@ class FileUtil:
|
|
|
37
40
|
:param encoding: The encoding used to read the given file bytes. If not provided, uses utf-8. If your file
|
|
38
41
|
contains a non-utf-8 character, then a UnicodeDecodeError will be thrown. If this happens, consider using
|
|
39
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.
|
|
40
45
|
:return: The CSV parsed into a list of dicts where each dict is a row, mapping the headers to the cells for
|
|
41
46
|
that row. Also returns a list of each row above the headers (the metadata), parsed into a list of each cell.
|
|
42
47
|
If the header row index is 0 or None, this list will be empty.
|
|
@@ -49,11 +54,13 @@ class FileUtil:
|
|
|
49
54
|
metadata: list[list[str]] = FileUtil.data_frame_to_lists(file_metadata)
|
|
50
55
|
# Parse the data from the file body into a list of dicts.
|
|
51
56
|
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.")
|
|
52
59
|
return rows, metadata
|
|
53
60
|
|
|
54
61
|
@staticmethod
|
|
55
|
-
def tokenize_xlsx(file_bytes: bytes, required_headers: list[str] | None = None, header_row_index: int | None = 0
|
|
56
|
-
|
|
62
|
+
def tokenize_xlsx(file_bytes: bytes, required_headers: list[str] | None = None, header_row_index: int | None = 0,
|
|
63
|
+
*, exception_on_empty: bool = True) -> tuple[list[dict[str, str]], list[list[str]]]:
|
|
57
64
|
"""
|
|
58
65
|
Tokenize an XLSX file row by row.
|
|
59
66
|
|
|
@@ -64,6 +71,8 @@ class FileUtil:
|
|
|
64
71
|
row is returned in the metadata list. If input is None, then no row is considered to be the header row,
|
|
65
72
|
meaning that required headers are also ignored if any are provided. By default, the first row (0th index)
|
|
66
73
|
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.
|
|
67
76
|
:return: The XLSX parsed into a list of dicts where each dict is a row, mapping the headers to the cells for
|
|
68
77
|
that row. Also returns a list of each row above the headers (the metadata), parsed into a list of each cell.
|
|
69
78
|
If the header row index is 0 or None, this list will be empty.
|
|
@@ -75,6 +84,8 @@ class FileUtil:
|
|
|
75
84
|
metadata: list[list[str]] = FileUtil.data_frame_to_lists(file_metadata)
|
|
76
85
|
# Parse the data from the file body into a list of dicts.
|
|
77
86
|
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.")
|
|
78
89
|
return rows, metadata
|
|
79
90
|
|
|
80
91
|
@staticmethod
|
|
@@ -229,7 +240,7 @@ class FileUtil:
|
|
|
229
240
|
:param file_data: The CSV file to be converted.
|
|
230
241
|
:return: The bytes of the CSV file converted to an XLSX file.
|
|
231
242
|
"""
|
|
232
|
-
with (io.BytesIO(file_data) if isinstance(file_data,
|
|
243
|
+
with (io.BytesIO(file_data.encode() if isinstance(file_data, str) else file_data)) as csv:
|
|
233
244
|
# Setting header to false makes pandas read the CSV as-is.
|
|
234
245
|
data_frame = pandas.read_csv(csv, sep=",", header=None)
|
|
235
246
|
|
|
@@ -273,6 +284,20 @@ class FileUtil:
|
|
|
273
284
|
file_bytes: bytes = buffer.getvalue()
|
|
274
285
|
return file_bytes
|
|
275
286
|
|
|
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
|
+
|
|
276
301
|
# Deprecated functions:
|
|
277
302
|
|
|
278
303
|
# FR-46097 - Add write file request shorthand functions to FileUtil.
|
|
@@ -290,6 +315,8 @@ class FileUtil:
|
|
|
290
315
|
:param request_context: Context that will be returned to the webhook server in the client callback result.
|
|
291
316
|
:return: A SapioWebhookResult with the write request as its client callback request.
|
|
292
317
|
"""
|
|
318
|
+
warnings.warn("FileUtil.write_file is deprecated as of 24.5+. Use CallbackUtil.write_file instead.",
|
|
319
|
+
DeprecationWarning)
|
|
293
320
|
return SapioWebhookResult(True, client_callback_request=WriteFileRequest(file_bytes, file_name,
|
|
294
321
|
request_context))
|
|
295
322
|
|
|
@@ -306,6 +333,8 @@ class FileUtil:
|
|
|
306
333
|
:param request_context: Context that will be returned to the webhook server in the client callback result.
|
|
307
334
|
:return: A SapioWebhookResult with the write request as its client callback request.
|
|
308
335
|
"""
|
|
336
|
+
warnings.warn("FileUtil.write_files is deprecated as of 24.5+. Use CallbackUtil.write_file instead.",
|
|
337
|
+
DeprecationWarning)
|
|
309
338
|
return SapioWebhookResult(True, client_callback_request=MultiFileRequest(files, request_context))
|
|
310
339
|
|
|
311
340
|
@staticmethod
|
|
@@ -333,6 +362,8 @@ class FileUtil:
|
|
|
333
362
|
1 - The file name of the requested file if the user provided one.
|
|
334
363
|
2 - The file bytes of the requested file if the user provided one.
|
|
335
364
|
"""
|
|
365
|
+
warnings.warn("FileUtil.request_file is deprecated as of 24.5+. Use CallbackUtil.request_file instead.",
|
|
366
|
+
DeprecationWarning)
|
|
336
367
|
client_callback = context.client_callback_result
|
|
337
368
|
result_context: str | None = client_callback.callback_context_data if client_callback else None
|
|
338
369
|
# If the user cancels, terminate the interaction.
|
|
@@ -385,6 +416,8 @@ class FileUtil:
|
|
|
385
416
|
May also contain a result that will terminate the client interaction if the user canceled the prompt.
|
|
386
417
|
1 - A dictionary that maps the file names to the file bytes for each provided file.
|
|
387
418
|
"""
|
|
419
|
+
warnings.warn("FileUtil.request_files is deprecated as of 24.5+. Use CallbackUtil.request_files instead.",
|
|
420
|
+
DeprecationWarning)
|
|
388
421
|
client_callback = context.client_callback_result
|
|
389
422
|
result_context: str | None = client_callback.callback_context_data if client_callback else None
|
|
390
423
|
# If the user cancels, terminate the interaction.
|
|
@@ -427,7 +460,7 @@ class FileUtil:
|
|
|
427
460
|
if len(allowed_extensions) != 0:
|
|
428
461
|
matches: bool = False
|
|
429
462
|
for ext in allowed_extensions:
|
|
430
|
-
if file_path.endswith("." + ext):
|
|
463
|
+
if file_path.endswith("." + ext.lstrip(".")):
|
|
431
464
|
matches = True
|
|
432
465
|
break
|
|
433
466
|
if matches is False:
|
|
@@ -7,12 +7,12 @@ from sapiopylib.rest.User import SapioUser
|
|
|
7
7
|
from sapiopylib.rest.pojo.CustomReport import RawReportTerm, RawTermOperation
|
|
8
8
|
from sapiopylib.rest.pojo.datatype.FieldDefinition import VeloxIntegerFieldDefinition, VeloxStringFieldDefinition, \
|
|
9
9
|
AbstractVeloxFieldDefinition
|
|
10
|
-
from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
|
|
11
|
-
from sapiopylib.rest.pojo.webhook.WebhookResult import SapioWebhookResult
|
|
12
10
|
|
|
13
11
|
from sapiopycommons.callbacks.callback_util import CallbackUtil
|
|
14
12
|
from sapiopycommons.files.file_data_handler import FileDataHandler, FilterList
|
|
13
|
+
from sapiopycommons.general.aliases import UserIdentifier, AliasUtil
|
|
15
14
|
from sapiopycommons.general.custom_report_util import CustomReportUtil
|
|
15
|
+
from sapiopycommons.general.exceptions import SapioUserCancelledException
|
|
16
16
|
from sapiopycommons.general.time_util import TimeUtil
|
|
17
17
|
|
|
18
18
|
|
|
@@ -80,10 +80,10 @@ class FileValidator:
|
|
|
80
80
|
|
|
81
81
|
return failed_rows
|
|
82
82
|
|
|
83
|
-
def build_violation_report(self, context:
|
|
83
|
+
def build_violation_report(self, context: UserIdentifier,
|
|
84
84
|
rule_violations: dict[int, list[ValidationRule]]) -> None:
|
|
85
85
|
"""
|
|
86
|
-
|
|
86
|
+
Display a simple report of any rule violations in the file to the user as a table dialog.
|
|
87
87
|
|
|
88
88
|
:param context: The current webhook context or a user object to send requests from.
|
|
89
89
|
:param rule_violations: A dict of rule violations generated by a call to validate_file.
|
|
@@ -121,9 +121,24 @@ class FileValidator:
|
|
|
121
121
|
"Reason": violation.reason[:2000]
|
|
122
122
|
})
|
|
123
123
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
callback = CallbackUtil(context)
|
|
125
|
+
callback.table_dialog("Errors", "The following rule violations were encountered in the provided file.",
|
|
126
|
+
columns, rows)
|
|
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()
|
|
127
142
|
|
|
128
143
|
|
|
129
144
|
class ValidationRule:
|
|
@@ -494,7 +509,7 @@ class UniqueSystemValueRule(ColumnRule):
|
|
|
494
509
|
data_type_name: str
|
|
495
510
|
data_field_name: str
|
|
496
511
|
|
|
497
|
-
def __init__(self, context:
|
|
512
|
+
def __init__(self, context: UserIdentifier, header: str, data_type_name: str,
|
|
498
513
|
data_field_name: str):
|
|
499
514
|
"""
|
|
500
515
|
:param context: The current webhook context or a user object to send requests from.
|
|
@@ -502,7 +517,7 @@ class UniqueSystemValueRule(ColumnRule):
|
|
|
502
517
|
:param data_type_name: The data type name to search on.
|
|
503
518
|
:param data_field_name: The data field name to search on. This is expected to be a string field.
|
|
504
519
|
"""
|
|
505
|
-
self.user =
|
|
520
|
+
self.user = AliasUtil.to_sapio_user(context)
|
|
506
521
|
self.data_type_name = data_type_name
|
|
507
522
|
self.data_field_name = data_field_name
|
|
508
523
|
super().__init__(header, f"This value already exists in the system.")
|
|
@@ -528,7 +543,7 @@ class ExistingSystemValueRule(ColumnRule):
|
|
|
528
543
|
data_type_name: str
|
|
529
544
|
data_field_name: str
|
|
530
545
|
|
|
531
|
-
def __init__(self, context:
|
|
546
|
+
def __init__(self, context: UserIdentifier, header: str, data_type_name: str,
|
|
532
547
|
data_field_name: str):
|
|
533
548
|
"""
|
|
534
549
|
:param context: The current webhook context or a user object to send requests from.
|
|
@@ -536,7 +551,7 @@ class ExistingSystemValueRule(ColumnRule):
|
|
|
536
551
|
:param data_type_name: The data type name to search on.
|
|
537
552
|
:param data_field_name: The data field name to search on. This is expected to be a string field.
|
|
538
553
|
"""
|
|
539
|
-
self.user =
|
|
554
|
+
self.user = AliasUtil.to_sapio_user(context)
|
|
540
555
|
self.data_type_name = data_type_name
|
|
541
556
|
self.data_field_name = data_field_name
|
|
542
557
|
super().__init__(header, f"This value doesn't exist in the system.")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import warnings
|
|
3
4
|
from abc import abstractmethod
|
|
4
5
|
from enum import Enum
|
|
5
6
|
from typing import Any
|
|
@@ -18,7 +19,7 @@ class FileWriter:
|
|
|
18
19
|
body: list[list[Any]]
|
|
19
20
|
delimiter: str
|
|
20
21
|
line_break: str
|
|
21
|
-
column_definitions:
|
|
22
|
+
column_definitions: dict[str, ColumnDef]
|
|
22
23
|
|
|
23
24
|
def __init__(self, headers: list[str], delimiter: str = ",", line_break: str = "\r\n"):
|
|
24
25
|
"""
|
|
@@ -30,7 +31,7 @@ class FileWriter:
|
|
|
30
31
|
self.delimiter = delimiter
|
|
31
32
|
self.line_break = line_break
|
|
32
33
|
self.body = []
|
|
33
|
-
self.column_definitions =
|
|
34
|
+
self.column_definitions = {}
|
|
34
35
|
|
|
35
36
|
def add_row_list(self, row: list[Any]) -> None:
|
|
36
37
|
"""
|
|
@@ -65,21 +66,49 @@ class FileWriter:
|
|
|
65
66
|
new_row.append(row.get(header, ""))
|
|
66
67
|
self.body.append(new_row)
|
|
67
68
|
|
|
68
|
-
def
|
|
69
|
+
def add_column_definition(self, header: str, column_def: ColumnDef) -> None:
|
|
69
70
|
"""
|
|
70
|
-
Add new column
|
|
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.
|
|
71
|
+
Add a new column definition to this FileWriter for a specific header.
|
|
73
72
|
|
|
74
|
-
ColumnDefs are only used if the build_file function is provided with a list of RowBundles.
|
|
73
|
+
ColumnDefs are only used if the build_file function is provided with a list of RowBundles. Every header must
|
|
74
|
+
have a column definition if this is the case.
|
|
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_def: A 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.
|
|
81
83
|
"""
|
|
82
|
-
self.
|
|
84
|
+
if header not in self.headers:
|
|
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)
|
|
83
112
|
|
|
84
113
|
def build_file(self, rows: list[RowBundle] | None = None, sorter=None, reverse: bool = False) -> str:
|
|
85
114
|
"""
|
|
@@ -100,11 +129,10 @@ class FileWriter:
|
|
|
100
129
|
"""
|
|
101
130
|
# If any column definitions have been provided, the number of column definitions and headers must be equal.
|
|
102
131
|
if self.column_definitions:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
f"headers. The number of column definitions must equal the number of headers.")
|
|
132
|
+
for header in self.headers:
|
|
133
|
+
if header not in self.column_definitions:
|
|
134
|
+
raise SapioException(f"FileWriter has no column definition for the header {header}. If any column "
|
|
135
|
+
f"definitions are provided, then all headers must have a column definition.")
|
|
108
136
|
# If any RowBundles have been provided, there must be column definitions for mapping them to the file.
|
|
109
137
|
elif rows:
|
|
110
138
|
raise SapioException(f"FileWriter was given RowBundles but contains no column definitions for mapping "
|
|
@@ -130,7 +158,8 @@ class FileWriter:
|
|
|
130
158
|
rows.sort(key=lambda x: x.index)
|
|
131
159
|
for row in rows:
|
|
132
160
|
new_row: list[Any] = []
|
|
133
|
-
for
|
|
161
|
+
for header in self.headers:
|
|
162
|
+
column = self.column_definitions[header]
|
|
134
163
|
if column.may_skip and row.may_skip:
|
|
135
164
|
new_row.append("")
|
|
136
165
|
else:
|