sapiopycommons 2024.8.7a303__py3-none-any.whl → 2024.8.19a305__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 +63 -3
- 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 +296 -0
- sapiopycommons/eln/experiment_handler.py +153 -36
- sapiopycommons/files/file_bridge_handler.py +21 -0
- sapiopycommons/files/file_util.py +25 -1
- sapiopycommons/files/file_validator.py +20 -4
- sapiopycommons/files/file_writer.py +44 -15
- sapiopycommons/general/aliases.py +77 -6
- sapiopycommons/general/popup_util.py +17 -0
- sapiopycommons/general/time_util.py +40 -0
- sapiopycommons/recordmodel/record_handler.py +36 -1
- sapiopycommons/rules/eln_rule_handler.py +23 -0
- sapiopycommons/rules/on_save_rule_handler.py +23 -0
- sapiopycommons/webhook/webhook_handlers.py +32 -3
- sapiopycommons/webhook/webservice_handlers.py +67 -0
- {sapiopycommons-2024.8.7a303.dist-info → sapiopycommons-2024.8.19a305.dist-info}/METADATA +1 -1
- {sapiopycommons-2024.8.7a303.dist-info → sapiopycommons-2024.8.19a305.dist-info}/RECORD +22 -17
- {sapiopycommons-2024.8.7a303.dist-info → sapiopycommons-2024.8.19a305.dist-info}/WHEEL +0 -0
- {sapiopycommons-2024.8.7a303.dist-info → sapiopycommons-2024.8.19a305.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import time
|
|
2
4
|
from collections.abc import Mapping, Iterable
|
|
5
|
+
from weakref import WeakValueDictionary
|
|
3
6
|
|
|
4
7
|
from sapiopylib.rest.DataMgmtService import DataMgmtServer
|
|
5
8
|
from sapiopylib.rest.ELNService import ElnManager
|
|
@@ -18,8 +21,9 @@ from sapiopylib.rest.utils.Protocols import ElnEntryStep, ElnExperimentProtocol
|
|
|
18
21
|
from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel
|
|
19
22
|
from sapiopylib.rest.utils.recordmodel.RecordModelManager import RecordModelInstanceManager, RecordModelManager
|
|
20
23
|
from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType
|
|
24
|
+
from sapiopylib.rest.utils.recordmodel.properties import Child
|
|
21
25
|
|
|
22
|
-
from sapiopycommons.general.aliases import AliasUtil, SapioRecord, ExperimentIdentifier
|
|
26
|
+
from sapiopycommons.general.aliases import AliasUtil, SapioRecord, ExperimentIdentifier, RecordModel
|
|
23
27
|
from sapiopycommons.general.exceptions import SapioException
|
|
24
28
|
|
|
25
29
|
Step = str | ElnEntryStep
|
|
@@ -80,7 +84,30 @@ class ExperimentHandler:
|
|
|
80
84
|
ElnExperimentStatus.Canceled]
|
|
81
85
|
"""The set of statuses that an ELN experiment could have and be considered locked."""
|
|
82
86
|
|
|
83
|
-
|
|
87
|
+
__instances: WeakValueDictionary[str, ExperimentHandler] = WeakValueDictionary()
|
|
88
|
+
__initialized: bool
|
|
89
|
+
|
|
90
|
+
def __new__(cls, context: SapioWebhookContext | SapioUser,
|
|
91
|
+
experiment: ExperimentIdentifier | SapioRecord | None = None):
|
|
92
|
+
"""
|
|
93
|
+
:param context: The current webhook context or a user object to send requests from.
|
|
94
|
+
:param experiment: If an experiment is provided that is separate from the experiment that is in the context,
|
|
95
|
+
that experiment will be used by this ExperimentHandler instead. An experiment can be provided in various
|
|
96
|
+
forms, including an ElnExperiment, ElnExperimentProtocol, an experiment record, or a notebook experiment ID.
|
|
97
|
+
"""
|
|
98
|
+
param_results = cls.__parse_params(context, experiment)
|
|
99
|
+
user = param_results[0]
|
|
100
|
+
experiment = param_results[2]
|
|
101
|
+
key = f"{user.__hash__()}:{experiment.notebook_experiment_id}"
|
|
102
|
+
obj = cls.__instances.get(key)
|
|
103
|
+
if not obj:
|
|
104
|
+
obj = object.__new__(cls)
|
|
105
|
+
obj.__initialized = False
|
|
106
|
+
cls.__instances[key] = obj
|
|
107
|
+
return obj
|
|
108
|
+
|
|
109
|
+
def __init__(self, context: SapioWebhookContext | SapioUser,
|
|
110
|
+
experiment: ExperimentIdentifier | SapioRecord | None = None):
|
|
84
111
|
"""
|
|
85
112
|
Initialization will throw an exception if there is no ELN Experiment in the provided context and no experiment
|
|
86
113
|
is provided.
|
|
@@ -90,16 +117,51 @@ class ExperimentHandler:
|
|
|
90
117
|
that experiment will be used by this ExperimentHandler instead. An experiment can be provided in various
|
|
91
118
|
forms, including an ElnExperiment, ElnExperimentProtocol, an experiment record, or a notebook experiment ID.
|
|
92
119
|
"""
|
|
120
|
+
param_results = self.__parse_params(context, experiment)
|
|
121
|
+
self.user = param_results[0]
|
|
122
|
+
self.context = param_results[1]
|
|
123
|
+
experiment = param_results[2]
|
|
124
|
+
|
|
125
|
+
# Get the basic information about this experiment that already exists in the context and is often used.
|
|
126
|
+
self.__eln_exp = experiment
|
|
127
|
+
self.__protocol = ElnExperimentProtocol(experiment, self.user)
|
|
128
|
+
self.__exp_id = self.__protocol.get_id()
|
|
129
|
+
|
|
130
|
+
# Grab various managers that may be used.
|
|
131
|
+
self.__eln_man = DataMgmtServer.get_eln_manager(self.user)
|
|
132
|
+
self.__inst_man = RecordModelManager(self.user).instance_manager
|
|
133
|
+
|
|
134
|
+
# Create empty caches to fill when necessary.
|
|
135
|
+
self.__steps = {}
|
|
136
|
+
self.__step_options = {}
|
|
137
|
+
# CR-46330: Cache any experiment entry information that might already exist in the context.
|
|
138
|
+
self.__queried_all_steps = False
|
|
139
|
+
# We can only trust the entries in the context if the experiment that this handler is for is the same as the
|
|
140
|
+
# one from the context.
|
|
141
|
+
if self.context is not None and self.context.eln_experiment == experiment:
|
|
142
|
+
if self.context.experiment_entry is not None:
|
|
143
|
+
self.__steps.update({self.context.active_step.get_name(): self.context.active_step})
|
|
144
|
+
if self.context.experiment_entry_list is not None:
|
|
145
|
+
for entry in self.context.experiment_entry_list:
|
|
146
|
+
self.__steps.update({entry.entry_name: ElnEntryStep(self.__protocol, entry)})
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def __parse_params(context: SapioWebhookContext | SapioUser,
|
|
150
|
+
experiment: ExperimentIdentifier | SapioRecord | None = None) \
|
|
151
|
+
-> tuple[SapioUser, SapioWebhookContext | None, ElnExperiment]:
|
|
93
152
|
if isinstance(context, SapioWebhookContext):
|
|
94
|
-
|
|
95
|
-
|
|
153
|
+
user = context.user
|
|
154
|
+
context = context
|
|
96
155
|
else:
|
|
97
|
-
|
|
98
|
-
|
|
156
|
+
user = context
|
|
157
|
+
context = None
|
|
158
|
+
if context is not None and context.eln_experiment is not None and experiment is None:
|
|
159
|
+
experiment = context.eln_experiment
|
|
99
160
|
# FR-46495 - Allow the init function of ExperimentHandler to take in an ElnExperiment that is separate from the
|
|
100
161
|
# context.
|
|
101
162
|
# CR-37038 - Allow other experiment object types to be provided. Convert them all down to ElnExperiment.
|
|
102
|
-
if experiment is not None:
|
|
163
|
+
if (context is None or context.eln_experiment is None) and experiment is not None:
|
|
164
|
+
eln_manager = DataMgmtServer.get_eln_manager(user)
|
|
103
165
|
# If this object is already an ElnExperiment, do nothing.
|
|
104
166
|
if isinstance(experiment, ElnExperiment):
|
|
105
167
|
pass
|
|
@@ -109,41 +171,19 @@ class ExperimentHandler:
|
|
|
109
171
|
# If this object is an integer, assume it is a notebook ID that we can query the system with.
|
|
110
172
|
elif isinstance(experiment, int):
|
|
111
173
|
notebook_id: int = experiment
|
|
112
|
-
experiment: ElnExperiment =
|
|
174
|
+
experiment: ElnExperiment = eln_manager.get_eln_experiment_by_id(notebook_id)
|
|
113
175
|
if not experiment:
|
|
114
176
|
raise SapioException(f"No experiment with notebook ID {notebook_id} located in the system.")
|
|
115
177
|
# If this object is a record, assume it is an experiment record that we can query the system with.
|
|
116
178
|
else:
|
|
117
179
|
record_id: int = AliasUtil.to_record_ids([experiment])[0]
|
|
118
|
-
experiment: ElnExperiment =
|
|
180
|
+
experiment: ElnExperiment = eln_manager.get_eln_experiment_by_record_id(record_id)
|
|
119
181
|
if not experiment:
|
|
120
182
|
raise SapioException(f"No experiment with record ID {record_id} located in the system.")
|
|
121
|
-
if (context is None or context.eln_experiment is None) and experiment is None:
|
|
122
|
-
raise SapioException("Cannot initialize ExperimentHandler. No ELN Experiment in the context.")
|
|
123
|
-
if context and context.eln_experiment == experiment:
|
|
124
|
-
experiment: ElnExperiment | None = None
|
|
125
|
-
|
|
126
|
-
# Get the basic information about this experiment that already exists in the context and is often used.
|
|
127
|
-
self.__eln_exp = experiment if experiment else context.eln_experiment
|
|
128
|
-
self.__protocol = ElnExperimentProtocol(experiment, self.user) if experiment else context.active_protocol
|
|
129
|
-
self.__exp_id = self.__protocol.get_id()
|
|
130
|
-
|
|
131
|
-
# Grab various managers that may be used.
|
|
132
|
-
self.__eln_man = DataMgmtServer.get_eln_manager(self.user)
|
|
133
|
-
self.__inst_man = RecordModelManager(self.user).instance_manager
|
|
134
|
-
|
|
135
|
-
# Create empty caches to fill when necessary.
|
|
136
|
-
self.__steps = {}
|
|
137
|
-
self.__step_options = {}
|
|
138
|
-
# CR-46330: Cache any experiment entry information that might already exist in the context.
|
|
139
|
-
self.__queried_all_steps = False
|
|
140
|
-
# We can only trust the entries in the context if no experiment was given as an input parameter.
|
|
141
183
|
if experiment is None:
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
for entry in context.experiment_entry_list:
|
|
146
|
-
self.__steps.update({entry.entry_name: ElnEntryStep(self.__protocol, entry)})
|
|
184
|
+
raise SapioException("Cannot initialize ExperimentHandler. No ELN Experiment found in the provided parameters.")
|
|
185
|
+
|
|
186
|
+
return user, context, experiment
|
|
147
187
|
|
|
148
188
|
# FR-46495: Split the creation of the experiment in launch_experiment into a create_experiment function.
|
|
149
189
|
@staticmethod
|
|
@@ -626,7 +666,8 @@ class ExperimentHandler:
|
|
|
626
666
|
self.set_step_records(step, [record])
|
|
627
667
|
|
|
628
668
|
# FR-46496 - Provide functions for adding and removing rows from an ELN data type entry.
|
|
629
|
-
def add_eln_rows(self, step: Step, count: int
|
|
669
|
+
def add_eln_rows(self, step: Step, count: int, wrapper_type: type[WrappedType] | None = None) \
|
|
670
|
+
-> list[PyRecordModel | WrappedType]:
|
|
630
671
|
"""
|
|
631
672
|
Add rows to an ELNExperimentDetail or ELNSampleDetail table entry. The rows will not appear in the system
|
|
632
673
|
until a record manager store and commit has occurred.
|
|
@@ -638,6 +679,8 @@ class ExperimentHandler:
|
|
|
638
679
|
The step may be provided as either a string for the name of the step or an ElnEntryStep.
|
|
639
680
|
If given a name, throws an exception if no step of the given name exists in the experiment.
|
|
640
681
|
:param count: The number of new rows to add to the entry.
|
|
682
|
+
:param wrapper_type: Optionally wrap the ELN data type in a record model wrapper. If not provided, returns
|
|
683
|
+
an unwrapped PyRecordModel.
|
|
641
684
|
:return: A list of the newly created rows.
|
|
642
685
|
"""
|
|
643
686
|
step = self.__to_eln_step(step)
|
|
@@ -646,7 +689,27 @@ class ExperimentHandler:
|
|
|
646
689
|
dt: str = step.get_data_type_names()[0]
|
|
647
690
|
if not ElnBaseDataType.is_eln_type(dt):
|
|
648
691
|
raise SapioException("The provided step is not an ELN data type entry.")
|
|
649
|
-
|
|
692
|
+
records: list[PyRecordModel] = self.__inst_man.add_new_records(dt, count)
|
|
693
|
+
if wrapper_type:
|
|
694
|
+
return self.__inst_man.wrap_list(records, wrapper_type)
|
|
695
|
+
return records
|
|
696
|
+
|
|
697
|
+
def add_eln_row(self, step: Step, wrapper_type: type[WrappedType] | None = None) -> PyRecordModel | WrappedType:
|
|
698
|
+
"""
|
|
699
|
+
Add a row to an ELNExperimentDetail or ELNSampleDetail table entry. The row will not appear in the system
|
|
700
|
+
until a record manager store and commit has occurred.
|
|
701
|
+
|
|
702
|
+
If no step functions have been called before and a step is being searched for by name, queries for the
|
|
703
|
+
list of steps in the experiment and caches them.
|
|
704
|
+
|
|
705
|
+
:param step:
|
|
706
|
+
The step may be provided as either a string for the name of the step or an ElnEntryStep.
|
|
707
|
+
If given a name, throws an exception if no step of the given name exists in the experiment.
|
|
708
|
+
:param wrapper_type: Optionally wrap the ELN data type in a record model wrapper. If not provided, returns
|
|
709
|
+
an unwrapped PyRecordModel.
|
|
710
|
+
:return: The newly created row.
|
|
711
|
+
"""
|
|
712
|
+
return self.add_eln_rows(step, 1, wrapper_type)[0]
|
|
650
713
|
|
|
651
714
|
def remove_eln_rows(self, step: Step, records: list[SapioRecord]) -> None:
|
|
652
715
|
"""
|
|
@@ -685,6 +748,60 @@ class ExperimentHandler:
|
|
|
685
748
|
for record in record_models:
|
|
686
749
|
record.delete()
|
|
687
750
|
|
|
751
|
+
def remove_eln_row(self, step: Step, record: SapioRecord) -> None:
|
|
752
|
+
"""
|
|
753
|
+
Remove a row from an ELNExperimentDetail or ELNSampleDetail table entry. ELN data type table entries display all
|
|
754
|
+
records in the system that match the entry's data type. This means that removing rows from an ELN data type
|
|
755
|
+
table entry is equivalent to deleting the records for the rows.
|
|
756
|
+
|
|
757
|
+
The row will not be deleted in the system until a record manager store and commit has occurred.
|
|
758
|
+
|
|
759
|
+
If no step functions have been called before and a step is being searched for by name, queries for the
|
|
760
|
+
list of steps in the experiment and caches them.
|
|
761
|
+
|
|
762
|
+
:param step:
|
|
763
|
+
The step may be provided as either a string for the name of the step or an ElnEntryStep.
|
|
764
|
+
If given a name, throws an exception if no step of the given name exists in the experiment.
|
|
765
|
+
:param record:
|
|
766
|
+
The record to remove from the given step.
|
|
767
|
+
The record may be provided as either a DataRecord, PyRecordModel, or WrappedRecordModel.
|
|
768
|
+
"""
|
|
769
|
+
self.remove_eln_row(step, [record])
|
|
770
|
+
|
|
771
|
+
def add_sample_details(self, step: Step, samples: list[RecordModel], wrapper_type: type[WrappedType]) \
|
|
772
|
+
-> list[PyRecordModel | WrappedType]:
|
|
773
|
+
"""
|
|
774
|
+
Add sample details to a sample details entry while relating them to the input sample records.
|
|
775
|
+
|
|
776
|
+
:param step:
|
|
777
|
+
The step may be provided as either a string for the name of the step or an ElnEntryStep.
|
|
778
|
+
If given a name, throws an exception if no step of the given name exists in the experiment.
|
|
779
|
+
:param samples: The sample records to add the sample details to.
|
|
780
|
+
:param wrapper_type: Optionally wrap the sample details in a record model wrapper. If not provided, returns
|
|
781
|
+
an unwrapped PyRecordModel.
|
|
782
|
+
:return: The newly created sample details. The indices of the samples in the input list match the index of the
|
|
783
|
+
sample details in this list that they are related to.
|
|
784
|
+
"""
|
|
785
|
+
step = self.__to_eln_step(step)
|
|
786
|
+
if step.eln_entry.entry_type != ElnEntryType.Table:
|
|
787
|
+
raise SapioException("The provided step is not a table entry.")
|
|
788
|
+
dt: str = step.get_data_type_names()[0]
|
|
789
|
+
if not ElnBaseDataType.is_eln_type(dt) or ElnBaseDataType.get_base_type(dt) != ElnBaseDataType.SAMPLE_DETAIL:
|
|
790
|
+
raise SapioException("The provided step is not an ELNSampleDetail entry.")
|
|
791
|
+
records: list[PyRecordModel] = []
|
|
792
|
+
for sample in samples:
|
|
793
|
+
if sample.data_type_name != "Sample":
|
|
794
|
+
raise SapioException(f"Received a {sample.data_type_name} record when Sample records were expected.")
|
|
795
|
+
detail: PyRecordModel = sample.add(Child.of_type_name(dt))
|
|
796
|
+
detail.set_field_values({
|
|
797
|
+
"SampleId": sample.get_field_value("SampleId"),
|
|
798
|
+
"OtherSampleId": sample.get_field_value("OtherSampleId")
|
|
799
|
+
})
|
|
800
|
+
records.append(detail)
|
|
801
|
+
if wrapper_type:
|
|
802
|
+
return self.__inst_man.wrap_list(records, wrapper_type)
|
|
803
|
+
return records
|
|
804
|
+
|
|
688
805
|
def update_step(self, step: Step,
|
|
689
806
|
entry_name: str | None = None,
|
|
690
807
|
related_entry_set: Iterable[int] | None = None,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from abc import abstractmethod, ABC
|
|
4
|
+
from weakref import WeakValueDictionary
|
|
4
5
|
|
|
5
6
|
from sapiopycommons.files.file_bridge import FileBridge
|
|
6
7
|
from sapiopylib.rest.User import SapioUser
|
|
@@ -23,12 +24,32 @@ class FileBridgeHandler:
|
|
|
23
24
|
__directories: dict[str, Directory]
|
|
24
25
|
"""A cache of directory file paths to Directory objects."""
|
|
25
26
|
|
|
27
|
+
__instances: WeakValueDictionary[str, FileBridgeHandler] = WeakValueDictionary()
|
|
28
|
+
__initialized: bool
|
|
29
|
+
|
|
30
|
+
def __new__(cls, context: SapioWebhookContext | SapioUser, bridge_name: str):
|
|
31
|
+
"""
|
|
32
|
+
:param context: The current webhook context or a user object to send requests from.
|
|
33
|
+
"""
|
|
34
|
+
user = context if isinstance(context, SapioUser) else context.user
|
|
35
|
+
key = f"{user.__hash__()}:{bridge_name}"
|
|
36
|
+
obj = cls.__instances.get(key)
|
|
37
|
+
if not obj:
|
|
38
|
+
obj = object.__new__(cls)
|
|
39
|
+
obj.__initialized = False
|
|
40
|
+
cls.__instances[key] = obj
|
|
41
|
+
return obj
|
|
42
|
+
|
|
26
43
|
def __init__(self, context: SapioWebhookContext | SapioUser, bridge_name: str):
|
|
27
44
|
"""
|
|
28
45
|
:param context: The current webhook context or a user object to send requests from.
|
|
29
46
|
:param bridge_name: The name of the bridge to communicate with. This is the "connection name" in the
|
|
30
47
|
file bridge configurations.
|
|
31
48
|
"""
|
|
49
|
+
if self.__initialized:
|
|
50
|
+
return
|
|
51
|
+
self.__initialized = True
|
|
52
|
+
|
|
32
53
|
self.user = context if isinstance(context, SapioUser) else context.user
|
|
33
54
|
self.__bridge = bridge_name
|
|
34
55
|
self.__file_cache = {}
|
|
@@ -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
|
|
@@ -282,6 +284,20 @@ class FileUtil:
|
|
|
282
284
|
file_bytes: bytes = buffer.getvalue()
|
|
283
285
|
return file_bytes
|
|
284
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
|
+
|
|
285
301
|
# Deprecated functions:
|
|
286
302
|
|
|
287
303
|
# FR-46097 - Add write file request shorthand functions to FileUtil.
|
|
@@ -299,6 +315,8 @@ class FileUtil:
|
|
|
299
315
|
:param request_context: Context that will be returned to the webhook server in the client callback result.
|
|
300
316
|
:return: A SapioWebhookResult with the write request as its client callback request.
|
|
301
317
|
"""
|
|
318
|
+
warnings.warn("FileUtil.write_file is deprecated as of 24.5+. Use CallbackUtil.write_file instead.",
|
|
319
|
+
DeprecationWarning)
|
|
302
320
|
return SapioWebhookResult(True, client_callback_request=WriteFileRequest(file_bytes, file_name,
|
|
303
321
|
request_context))
|
|
304
322
|
|
|
@@ -315,6 +333,8 @@ class FileUtil:
|
|
|
315
333
|
:param request_context: Context that will be returned to the webhook server in the client callback result.
|
|
316
334
|
:return: A SapioWebhookResult with the write request as its client callback request.
|
|
317
335
|
"""
|
|
336
|
+
warnings.warn("FileUtil.write_files is deprecated as of 24.5+. Use CallbackUtil.write_file instead.",
|
|
337
|
+
DeprecationWarning)
|
|
318
338
|
return SapioWebhookResult(True, client_callback_request=MultiFileRequest(files, request_context))
|
|
319
339
|
|
|
320
340
|
@staticmethod
|
|
@@ -342,6 +362,8 @@ class FileUtil:
|
|
|
342
362
|
1 - The file name of the requested file if the user provided one.
|
|
343
363
|
2 - The file bytes of the requested file if the user provided one.
|
|
344
364
|
"""
|
|
365
|
+
warnings.warn("FileUtil.request_file is deprecated as of 24.5+. Use CallbackUtil.request_file instead.",
|
|
366
|
+
DeprecationWarning)
|
|
345
367
|
client_callback = context.client_callback_result
|
|
346
368
|
result_context: str | None = client_callback.callback_context_data if client_callback else None
|
|
347
369
|
# If the user cancels, terminate the interaction.
|
|
@@ -394,6 +416,8 @@ class FileUtil:
|
|
|
394
416
|
May also contain a result that will terminate the client interaction if the user canceled the prompt.
|
|
395
417
|
1 - A dictionary that maps the file names to the file bytes for each provided file.
|
|
396
418
|
"""
|
|
419
|
+
warnings.warn("FileUtil.request_files is deprecated as of 24.5+. Use CallbackUtil.request_files instead.",
|
|
420
|
+
DeprecationWarning)
|
|
397
421
|
client_callback = context.client_callback_result
|
|
398
422
|
result_context: str | None = client_callback.callback_context_data if client_callback else None
|
|
399
423
|
# If the user cancels, terminate the interaction.
|
|
@@ -436,7 +460,7 @@ class FileUtil:
|
|
|
436
460
|
if len(allowed_extensions) != 0:
|
|
437
461
|
matches: bool = False
|
|
438
462
|
for ext in allowed_extensions:
|
|
439
|
-
if file_path.endswith("." + ext):
|
|
463
|
+
if file_path.endswith("." + ext.lstrip(".")):
|
|
440
464
|
matches = True
|
|
441
465
|
break
|
|
442
466
|
if matches is False:
|
|
@@ -12,6 +12,7 @@ from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
|
|
|
12
12
|
from sapiopycommons.callbacks.callback_util import CallbackUtil
|
|
13
13
|
from sapiopycommons.files.file_data_handler import FileDataHandler, FilterList
|
|
14
14
|
from sapiopycommons.general.custom_report_util import CustomReportUtil
|
|
15
|
+
from sapiopycommons.general.exceptions import SapioUserCancelledException
|
|
15
16
|
from sapiopycommons.general.time_util import TimeUtil
|
|
16
17
|
|
|
17
18
|
|
|
@@ -82,7 +83,7 @@ class FileValidator:
|
|
|
82
83
|
def build_violation_report(self, context: SapioWebhookContext | SapioUser,
|
|
83
84
|
rule_violations: dict[int, list[ValidationRule]]) -> None:
|
|
84
85
|
"""
|
|
85
|
-
|
|
86
|
+
Display a simple report of any rule violations in the file to the user as a table dialog.
|
|
86
87
|
|
|
87
88
|
:param context: The current webhook context or a user object to send requests from.
|
|
88
89
|
:param rule_violations: A dict of rule violations generated by a call to validate_file.
|
|
@@ -120,9 +121,24 @@ class FileValidator:
|
|
|
120
121
|
"Reason": violation.reason[:2000]
|
|
121
122
|
})
|
|
122
123
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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: SapioWebhookContext | SapioUser) -> 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()
|
|
126
142
|
|
|
127
143
|
|
|
128
144
|
class ValidationRule:
|
|
@@ -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:
|
|
@@ -2,10 +2,13 @@ from collections.abc import Iterable
|
|
|
2
2
|
from typing import Any
|
|
3
3
|
|
|
4
4
|
from sapiopylib.rest.pojo.DataRecord import DataRecord
|
|
5
|
+
from sapiopylib.rest.pojo.datatype.FieldDefinition import FieldType
|
|
5
6
|
from sapiopylib.rest.pojo.eln.ElnExperiment import ElnExperiment
|
|
6
7
|
from sapiopylib.rest.utils.Protocols import ElnExperimentProtocol
|
|
7
8
|
from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel
|
|
8
|
-
from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedRecordModel, WrappedType
|
|
9
|
+
from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedRecordModel, WrappedType, WrapperField
|
|
10
|
+
|
|
11
|
+
from sapiopycommons.general.exceptions import SapioException
|
|
9
12
|
|
|
10
13
|
RecordModel = PyRecordModel | WrappedRecordModel | WrappedType
|
|
11
14
|
"""Different forms that a record model could take."""
|
|
@@ -13,6 +16,13 @@ SapioRecord = DataRecord | RecordModel
|
|
|
13
16
|
"""A record could be provided as either a DataRecord, PyRecordModel, or WrappedRecordModel (WrappedType)."""
|
|
14
17
|
RecordIdentifier = SapioRecord | int
|
|
15
18
|
"""A RecordIdentifier is either a record type or an integer for the record's record ID."""
|
|
19
|
+
DataTypeIdentifier = SapioRecord | type[WrappedType] | str
|
|
20
|
+
"""A DataTypeIdentifier is either a SapioRecord, a record model wrapper type, or a string."""
|
|
21
|
+
FieldIdentifier = WrapperField | str | tuple[str, FieldType]
|
|
22
|
+
"""A FieldIdentifier is either wrapper field from a record model wrapper, a string, or a tuple of string
|
|
23
|
+
and field type."""
|
|
24
|
+
HasFieldWrappers = type[WrappedType] | WrappedRecordModel
|
|
25
|
+
"""An identifier for classes that have wrapper fields."""
|
|
16
26
|
ExperimentIdentifier = ElnExperimentProtocol | ElnExperiment | int
|
|
17
27
|
"""An ExperimentIdentifier is either an experiment protocol, experiment, or an integer for te experiment's notebook
|
|
18
28
|
ID."""
|
|
@@ -63,14 +73,75 @@ class AliasUtil:
|
|
|
63
73
|
return record if isinstance(record, int) else record.record_id
|
|
64
74
|
|
|
65
75
|
@staticmethod
|
|
66
|
-
def to_data_type_name(
|
|
76
|
+
def to_data_type_name(value: DataTypeIdentifier) -> str:
|
|
77
|
+
"""
|
|
78
|
+
Convert a given value to a data type name.
|
|
79
|
+
|
|
80
|
+
:param value: A value which is a string, record, or record model type.
|
|
81
|
+
:return: A string of the data type name of the input value.
|
|
82
|
+
"""
|
|
83
|
+
if isinstance(value, str):
|
|
84
|
+
return value
|
|
85
|
+
if isinstance(value, SapioRecord):
|
|
86
|
+
return value.data_type_name
|
|
87
|
+
return value.get_wrapper_data_type_name()
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def to_data_type_names(values: Iterable[DataTypeIdentifier], return_set: bool = False) -> list[str] | set[str]:
|
|
91
|
+
"""
|
|
92
|
+
Convert a given iterable of values to a list or set of data type names.
|
|
93
|
+
|
|
94
|
+
:param values: An iterable of values which are strings, records, or record model types.
|
|
95
|
+
:param return_set: If true, return a set instead of a list.
|
|
96
|
+
:return: A list or set of strings of the data type name of the input value.
|
|
97
|
+
"""
|
|
98
|
+
values = [AliasUtil.to_data_type_name(x) for x in values]
|
|
99
|
+
return set(values) if return_set else values
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def to_data_field_name(value: FieldIdentifier) -> str:
|
|
103
|
+
"""
|
|
104
|
+
Convert a string or WrapperField to a data field name string.
|
|
105
|
+
|
|
106
|
+
:param value: A string or WrapperField.
|
|
107
|
+
:return: A string of the data field name of the input value.
|
|
108
|
+
"""
|
|
109
|
+
if isinstance(value, tuple):
|
|
110
|
+
return value[0]
|
|
111
|
+
if isinstance(value, WrapperField):
|
|
112
|
+
return value.field_name
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def to_data_field_names(values: Iterable[FieldIdentifier]) -> list[str]:
|
|
117
|
+
"""
|
|
118
|
+
Convert an iterable of strings or WrapperFields to a list of data field name strings.
|
|
119
|
+
|
|
120
|
+
:param values: An iterable of strings or WrapperFields.
|
|
121
|
+
:return: A list of strings of the data field names of the input values.
|
|
122
|
+
"""
|
|
123
|
+
return [AliasUtil.to_data_field_name(x) for x in values]
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def to_field_type(field: FieldIdentifier, data_type: HasFieldWrappers | None = None) -> FieldType:
|
|
67
127
|
"""
|
|
68
|
-
Convert a
|
|
69
|
-
data type name for that record.
|
|
128
|
+
Convert a given field identifier to the field type for that field.
|
|
70
129
|
|
|
71
|
-
:
|
|
130
|
+
:param field: A string or WrapperField.
|
|
131
|
+
:param data_type: If the field is provided as a string, then a record model wrapper or wrapped record model
|
|
132
|
+
must be provided to determine the field type.
|
|
133
|
+
:return: The field type of the given field.
|
|
72
134
|
"""
|
|
73
|
-
|
|
135
|
+
if isinstance(field, tuple):
|
|
136
|
+
return field[1]
|
|
137
|
+
if isinstance(field, WrapperField):
|
|
138
|
+
return field.field_type
|
|
139
|
+
for var in dir(data_type):
|
|
140
|
+
attr = getattr(data_type, var)
|
|
141
|
+
if isinstance(attr, WrapperField) and attr.field_name == field:
|
|
142
|
+
return attr.field_type
|
|
143
|
+
raise SapioException(f"The wrapper of data type \"{data_type.get_wrapper_data_type_name()}\" doesn't have a "
|
|
144
|
+
f"field with the name \"{field}\",")
|
|
74
145
|
|
|
75
146
|
@staticmethod
|
|
76
147
|
def to_field_map_lists(records: Iterable[SapioRecord]) -> list[FieldMap]:
|