sapiopycommons 2024.3.19a157__py3-none-any.whl → 2025.1.17a402__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.

Files changed (52) hide show
  1. sapiopycommons/callbacks/__init__.py +0 -0
  2. sapiopycommons/callbacks/callback_util.py +2041 -0
  3. sapiopycommons/callbacks/field_builder.py +545 -0
  4. sapiopycommons/chem/IndigoMolecules.py +46 -1
  5. sapiopycommons/chem/Molecules.py +100 -21
  6. sapiopycommons/customreport/__init__.py +0 -0
  7. sapiopycommons/customreport/column_builder.py +60 -0
  8. sapiopycommons/customreport/custom_report_builder.py +137 -0
  9. sapiopycommons/customreport/term_builder.py +315 -0
  10. sapiopycommons/datatype/attachment_util.py +14 -15
  11. sapiopycommons/datatype/data_fields.py +61 -0
  12. sapiopycommons/datatype/pseudo_data_types.py +440 -0
  13. sapiopycommons/eln/experiment_handler.py +355 -91
  14. sapiopycommons/eln/experiment_report_util.py +649 -0
  15. sapiopycommons/eln/plate_designer.py +152 -0
  16. sapiopycommons/files/complex_data_loader.py +31 -0
  17. sapiopycommons/files/file_bridge.py +149 -25
  18. sapiopycommons/files/file_bridge_handler.py +555 -0
  19. sapiopycommons/files/file_data_handler.py +633 -0
  20. sapiopycommons/files/file_util.py +263 -163
  21. sapiopycommons/files/file_validator.py +569 -0
  22. sapiopycommons/files/file_writer.py +377 -0
  23. sapiopycommons/flowcyto/flow_cyto.py +77 -0
  24. sapiopycommons/flowcyto/flowcyto_data.py +75 -0
  25. sapiopycommons/general/accession_service.py +375 -0
  26. sapiopycommons/general/aliases.py +250 -15
  27. sapiopycommons/general/audit_log.py +185 -0
  28. sapiopycommons/general/custom_report_util.py +251 -31
  29. sapiopycommons/general/directive_util.py +86 -0
  30. sapiopycommons/general/exceptions.py +69 -7
  31. sapiopycommons/general/popup_util.py +59 -7
  32. sapiopycommons/general/sapio_links.py +50 -0
  33. sapiopycommons/general/storage_util.py +148 -0
  34. sapiopycommons/general/time_util.py +91 -7
  35. sapiopycommons/multimodal/multimodal.py +146 -0
  36. sapiopycommons/multimodal/multimodal_data.py +490 -0
  37. sapiopycommons/processtracking/__init__.py +0 -0
  38. sapiopycommons/processtracking/custom_workflow_handler.py +406 -0
  39. sapiopycommons/processtracking/endpoints.py +192 -0
  40. sapiopycommons/recordmodel/record_handler.py +621 -148
  41. sapiopycommons/rules/eln_rule_handler.py +87 -8
  42. sapiopycommons/rules/on_save_rule_handler.py +87 -12
  43. sapiopycommons/sftpconnect/__init__.py +0 -0
  44. sapiopycommons/sftpconnect/sftp_builder.py +70 -0
  45. sapiopycommons/webhook/webhook_context.py +39 -0
  46. sapiopycommons/webhook/webhook_handlers.py +614 -71
  47. sapiopycommons/webhook/webservice_handlers.py +317 -0
  48. {sapiopycommons-2024.3.19a157.dist-info → sapiopycommons-2025.1.17a402.dist-info}/METADATA +5 -4
  49. sapiopycommons-2025.1.17a402.dist-info/RECORD +60 -0
  50. {sapiopycommons-2024.3.19a157.dist-info → sapiopycommons-2025.1.17a402.dist-info}/WHEEL +1 -1
  51. sapiopycommons-2024.3.19a157.dist-info/RECORD +0 -28
  52. {sapiopycommons-2024.3.19a157.dist-info → sapiopycommons-2025.1.17a402.dist-info}/licenses/LICENSE +0 -0
@@ -1,7 +1,13 @@
1
+ from __future__ import annotations
2
+
1
3
  import time
2
4
  from collections.abc import Mapping, Iterable
5
+ from typing import TypeAlias
6
+ from weakref import WeakValueDictionary
3
7
 
8
+ from sapiopylib.rest.DataMgmtService import DataMgmtServer
4
9
  from sapiopylib.rest.ELNService import ElnManager
10
+ from sapiopylib.rest.User import SapioUser
5
11
  from sapiopylib.rest.pojo.DataRecord import DataRecord
6
12
  from sapiopylib.rest.pojo.eln.ElnExperiment import ElnExperiment, TemplateExperimentQueryPojo, ElnTemplate, \
7
13
  InitializeNotebookExperimentPojo, ElnExperimentUpdateCriteria
@@ -16,18 +22,22 @@ from sapiopylib.rest.utils.Protocols import ElnEntryStep, ElnExperimentProtocol
16
22
  from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel
17
23
  from sapiopylib.rest.utils.recordmodel.RecordModelManager import RecordModelInstanceManager, RecordModelManager
18
24
  from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType
25
+ from sapiopylib.rest.utils.recordmodel.properties import Child
19
26
 
20
- from sapiopycommons.general.aliases import AliasUtil, SapioRecord
27
+ from sapiopycommons.eln.experiment_report_util import ExperimentReportUtil
28
+ from sapiopycommons.general.aliases import AliasUtil, SapioRecord, ExperimentIdentifier, UserIdentifier, \
29
+ DataTypeIdentifier, RecordModel
21
30
  from sapiopycommons.general.exceptions import SapioException
22
31
 
23
- Step = str | ElnEntryStep
32
+ Step: TypeAlias = str | ElnEntryStep
24
33
  """An object representing an identifier to an ElnEntryStep. May be either the name of the step or the ElnEntryStep
25
34
  itself."""
26
35
 
27
36
 
28
37
  # FR-46064 - Initial port of PyWebhookUtils to sapiopycommons.
29
38
  class ExperimentHandler:
30
- __context: SapioWebhookContext
39
+ user: SapioUser
40
+ context: SapioWebhookContext | None
31
41
  """The context that this handler is working from."""
32
42
 
33
43
  # Basic experiment info from the context.
@@ -48,9 +58,9 @@ class ExperimentHandler:
48
58
  # additional queries to obtain, but may also be repeatedly accessed. In such cases, cache the information after it
49
59
  # has been requested so that the user doesn't need to worry about caching it themselves.
50
60
  # CR-46341: Replace class variables with instance variables.
51
- __exp_record: DataRecord
61
+ __exp_record: DataRecord | None
52
62
  """The data record for this experiment. Only cached when first accessed."""
53
- __exp_template: ElnTemplate
63
+ __exp_template: ElnTemplate | None
54
64
  """The template for this experiment. Only cached when first accessed."""
55
65
  __exp_options: dict[str, str]
56
66
  """Experiment options for this experiment. Only cached when first accessed."""
@@ -59,60 +69,122 @@ class ExperimentHandler:
59
69
  """Whether this ExperimentHandler has queried the system for all steps in the experiment."""
60
70
  __steps: dict[str, ElnEntryStep]
61
71
  """Steps from this experiment. All steps are cached the first time any individual step is accessed."""
62
- __step_options: dict[ElnEntryStep, dict[str, str]]
63
- """Entry options for each step in this experiment. Only cached for each individual step when they are first accessed.
64
- The cache is updated whenever the entry options for a step are changed by this handler."""
72
+ __step_options: dict[int, dict[str, str]]
73
+ """Entry options for each step in this experiment. All entry options are cached the first time any individual step's
74
+ options are queried. The cache is updated whenever the entry options for a step are changed by this handler."""
65
75
 
66
76
  # Constants
67
- # TODO: sapiopylib is currently storing the status of entries as strings when first queried, requiring us to compare
68
- # against the value of the enums instead of the enums themselves. The ".value"s can be removed once sapiopylib is
69
- # fixed.
70
- __COMPLETE_STATUSES = [ExperimentEntryStatus.Completed.value, ExperimentEntryStatus.CompletedApproved.value]
77
+ __ENTRY_COMPLETE_STATUSES = [ExperimentEntryStatus.Completed, ExperimentEntryStatus.CompletedApproved]
71
78
  """The set of statuses that an ELN entry could have and be considered completed/submitted."""
72
- __LOCKED_STATUSES = [ExperimentEntryStatus.Completed.value, ExperimentEntryStatus.CompletedApproved.value,
73
- ExperimentEntryStatus.Disabled.value, ExperimentEntryStatus.LockedAwaitingApproval.value,
74
- ExperimentEntryStatus.LockedRejected.value]
79
+ __ENTRY_LOCKED_STATUSES = [ExperimentEntryStatus.Completed, ExperimentEntryStatus.CompletedApproved,
80
+ ExperimentEntryStatus.Disabled, ExperimentEntryStatus.LockedAwaitingApproval,
81
+ ExperimentEntryStatus.LockedRejected]
75
82
  """The set of statuses that an ELN entry could have and be considered locked."""
83
+ __EXPERIMENT_COMPLETE_STATUSES = [ElnExperimentStatus.Completed, ElnExperimentStatus.CompletedApproved]
84
+ """The set of statuses that an ELN experiment could have and be considered completed."""
85
+ __EXPERIMENT_LOCKED_STATUSES = [ElnExperimentStatus.Completed, ElnExperimentStatus.CompletedApproved,
86
+ ElnExperimentStatus.LockedRejected, ElnExperimentStatus.LockedAwaitingApproval,
87
+ ElnExperimentStatus.Canceled]
88
+ """The set of statuses that an ELN experiment could have and be considered locked."""
89
+
90
+ __instances: WeakValueDictionary[str, ExperimentHandler] = WeakValueDictionary()
91
+ __initialized: bool
76
92
 
77
- def __init__(self, context: SapioWebhookContext, experiment: ElnExperiment | None = None):
93
+ def __new__(cls, context: UserIdentifier, experiment: ExperimentIdentifier | SapioRecord | None = None):
94
+ """
95
+ :param context: The current webhook context or a user object to send requests from.
96
+ :param experiment: If an experiment is provided that is separate from the experiment that is in the context,
97
+ that experiment will be used by this ExperimentHandler instead. An experiment can be provided in various
98
+ forms, including an ElnExperiment, ElnExperimentProtocol, an experiment record, or a notebook experiment ID.
99
+ """
100
+ param_results = cls.__parse_params(context, experiment)
101
+ user = param_results[0]
102
+ experiment = param_results[2]
103
+ key = f"{user.__hash__()}:{experiment.notebook_experiment_id}"
104
+ obj = cls.__instances.get(key)
105
+ if not obj:
106
+ obj = object.__new__(cls)
107
+ obj.__initialized = False
108
+ cls.__instances[key] = obj
109
+ return obj
110
+
111
+ def __init__(self, context: UserIdentifier, experiment: ExperimentIdentifier | SapioRecord | None = None):
78
112
  """
79
113
  Initialization will throw an exception if there is no ELN Experiment in the provided context and no experiment
80
114
  is provided.
81
115
 
82
- :param context: The current webhook context.
116
+ :param context: The current webhook context or a user object to send requests from.
83
117
  :param experiment: If an experiment is provided that is separate from the experiment that is in the context,
84
- that experiment will be used by this ExperimentHandler instead.
118
+ that experiment will be used by this ExperimentHandler instead. An experiment can be provided in various
119
+ forms, including an ElnExperiment, ElnExperimentProtocol, an experiment record, or a notebook experiment ID.
85
120
  """
86
- # FR-46495 - Allow the init function of ExperimentHandler to take in an ElnExperiment that is separate from the
87
- # context.
88
- if context.eln_experiment is None and experiment is None:
89
- raise SapioException("Cannot initialize ExperimentHandler. No ELN Experiment in the context.")
90
- if context.eln_experiment == experiment:
91
- experiment = None
92
- self.__context = context
121
+ param_results = self.__parse_params(context, experiment)
122
+ self.user = param_results[0]
123
+ self.context = param_results[1]
124
+ experiment = param_results[2]
93
125
 
94
126
  # Get the basic information about this experiment that already exists in the context and is often used.
95
- self.__eln_exp = experiment if experiment else context.eln_experiment
96
- self.__protocol = ElnExperimentProtocol(experiment, context.user) if experiment else context.active_protocol
127
+ self.__eln_exp = experiment
128
+ self.__protocol = ElnExperimentProtocol(experiment, self.user)
97
129
  self.__exp_id = self.__protocol.get_id()
98
130
 
99
131
  # Grab various managers that may be used.
100
- self.__eln_man = context.eln_manager
101
- self.__inst_man = RecordModelManager(context.user).instance_manager
132
+ self.__eln_man = DataMgmtServer.get_eln_manager(self.user)
133
+ self.__inst_man = RecordModelManager(self.user).instance_manager
102
134
 
103
135
  # Create empty caches to fill when necessary.
104
136
  self.__steps = {}
105
137
  self.__step_options = {}
106
138
  # CR-46330: Cache any experiment entry information that might already exist in the context.
107
139
  self.__queried_all_steps = False
108
- # We can only trust the entries in the context if no experiment was given as an input parameter.
109
- if experiment is None:
110
- if context.experiment_entry is not None:
111
- self.__steps.update({context.active_step.get_name(): context.active_step})
112
- if context.experiment_entry_list is not None:
113
- for entry in context.experiment_entry_list:
140
+ # We can only trust the entries in the context if the experiment that this handler is for is the same as the
141
+ # one from the context.
142
+ if self.context is not None and self.context.eln_experiment == experiment:
143
+ if self.context.experiment_entry is not None:
144
+ self.__steps.update({self.context.active_step.get_name(): self.context.active_step})
145
+ if self.context.experiment_entry_list is not None:
146
+ for entry in self.context.experiment_entry_list:
114
147
  self.__steps.update({entry.entry_name: ElnEntryStep(self.__protocol, entry)})
115
148
 
149
+ @staticmethod
150
+ def __parse_params(context: UserIdentifier, experiment: ExperimentIdentifier | SapioRecord | None = None) \
151
+ -> tuple[SapioUser, SapioWebhookContext | None, ElnExperiment]:
152
+ if isinstance(context, SapioWebhookContext):
153
+ user = context.user
154
+ context = context
155
+ else:
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
160
+ # FR-46495 - Allow the init function of ExperimentHandler to take in an ElnExperiment that is separate from the
161
+ # context.
162
+ # CR-37038 - Allow other experiment object types to be provided. Convert them all down to ElnExperiment.
163
+ if (context is None or context.eln_experiment is None) and experiment is not None:
164
+ eln_manager = DataMgmtServer.get_eln_manager(user)
165
+ # If this object is already an ElnExperiment, do nothing.
166
+ if isinstance(experiment, ElnExperiment):
167
+ pass
168
+ # If this object is an ElnExperimentProtocol, then we can get the ElnExperiment from the object.
169
+ elif isinstance(experiment, ElnExperimentProtocol):
170
+ experiment: ElnExperiment = experiment.eln_experiment
171
+ # If this object is an integer, assume it is a notebook ID that we can query the system with.
172
+ elif isinstance(experiment, int):
173
+ notebook_id: int = experiment
174
+ experiment: ElnExperiment = eln_manager.get_eln_experiment_by_id(notebook_id)
175
+ if not experiment:
176
+ raise SapioException(f"No experiment with notebook ID {notebook_id} located in the system.")
177
+ # If this object is a record, assume it is an experiment record that we can query the system with.
178
+ else:
179
+ record_id: int = AliasUtil.to_record_ids([experiment])[0]
180
+ experiment: ElnExperiment = eln_manager.get_eln_experiment_by_record_id(record_id)
181
+ if not experiment:
182
+ raise SapioException(f"No experiment with record ID {record_id} located in the system.")
183
+ if experiment is None:
184
+ raise SapioException("Cannot initialize ExperimentHandler. No ELN Experiment found in the provided parameters.")
185
+
186
+ return user, context, experiment
187
+
116
188
  # FR-46495: Split the creation of the experiment in launch_experiment into a create_experiment function.
117
189
  @staticmethod
118
190
  def create_experiment(context: SapioWebhookContext,
@@ -206,6 +278,7 @@ class ExperimentHandler:
206
278
  """
207
279
  template_id: int | None = self.__eln_exp.template_id
208
280
  if template_id is None:
281
+ self.__exp_template = None
209
282
  if exception_on_none:
210
283
  raise SapioException(f"Experiment with ID {self.__exp_id} has no template ID.")
211
284
  return None
@@ -260,11 +333,7 @@ class ExperimentHandler:
260
333
  :return: The data record for this experiment. None if it has no record.
261
334
  """
262
335
  if not hasattr(self, "_ExperimentHandler__exp_record"):
263
- drm = self.__context.data_record_manager
264
- dt = self.__eln_exp.experiment_data_type_name
265
- results = drm.query_data_records_by_id(dt, [self.__eln_exp.experiment_record_id]).result_list
266
- # PR-46504: Set the exp_record to None if there are no results.
267
- self.__exp_record = results[0] if results else None
336
+ self.__exp_record = self.__protocol.get_record()
268
337
  if self.__exp_record is None and exception_on_none:
269
338
  raise SapioException(f"Experiment record not found for experiment with ID {self.__exp_id}.")
270
339
  return self.__exp_record
@@ -350,6 +419,53 @@ class ExperimentHandler:
350
419
  options.update(mapping)
351
420
  self.update_experiment(experiment_option_map=options)
352
421
 
422
+ def experiment_is_complete(self) -> bool:
423
+ """
424
+ Determine if the experiment has been completed.
425
+
426
+ :return: True if the experiment's status is Completed or CompletedApproved. False otherwise.
427
+ """
428
+ return self.__eln_exp.notebook_experiment_status in self.__EXPERIMENT_COMPLETE_STATUSES
429
+
430
+ def experiment_is_canceled(self) -> bool:
431
+ """
432
+ Determine if the experiment has been canceled.
433
+
434
+ :return: True if the experiment's status is Canceled. False otherwise.
435
+ """
436
+ return self.__eln_exp.notebook_experiment_status == ElnExperimentStatus.Canceled
437
+
438
+ def experiment_is_locked(self) -> bool:
439
+ """
440
+ Determine if the experiment has been locked in any way.
441
+
442
+ :return: True if the experiment's status is Completed, CompletedApproved, Canceled, LockedAwaitingApproval,
443
+ or LockedRejected. False otherwise.
444
+ """
445
+ return self.__eln_exp.notebook_experiment_status in self.__EXPERIMENT_LOCKED_STATUSES
446
+
447
+ def complete_experiment(self) -> None:
448
+ """
449
+ Set the experiment's status to Completed. Makes a webservice call to update the experiment. Checks if the
450
+ experiment is already completed, and does nothing if so.
451
+ """
452
+ if not self.experiment_is_complete():
453
+ self.__protocol.complete_protocol()
454
+ self.__eln_exp.notebook_experiment_status = ElnExperimentStatus.Completed
455
+
456
+ def cancel_experiment(self) -> None:
457
+ """
458
+ Set the experiment's status to Canceled. Makes a webservice call to update the experiment. Checks if the
459
+ experiment is already canceled, and does nothing if so.
460
+
461
+ NOTE: This will not run the usual logic around canceling an experiment that you'd see if canceling the
462
+ experiment using the "Cancel Experiment" toolbar button, such as moving in process samples back to the queue,
463
+ as those changes are tied to the button instead of being on the experiment status change.
464
+ """
465
+ if not self.experiment_is_canceled():
466
+ self.__protocol.cancel_protocol()
467
+ self.__eln_exp.notebook_experiment_status = ElnExperimentStatus.Canceled
468
+
353
469
  def step_exists(self, step_name: str) -> bool:
354
470
  """
355
471
  Determine if a step by the given name exists in the experiment.
@@ -417,6 +533,58 @@ class ExperimentHandler:
417
533
  ret_list.append(step)
418
534
  return ret_list
419
535
 
536
+ def get_all_steps(self, data_type: DataTypeIdentifier | None = None) -> list[ElnEntryStep]:
537
+ """
538
+ Get a list of every entry in the experiment. Optionally filter the returned entries by a data type.
539
+
540
+ Makes a webservice call to retrieve every entry in the experiment if they were not already previously cached.
541
+
542
+ :param data_type: A data type used to filter the returned entries. If None is given, returns all entries. If
543
+ a data type name or wrapper is given, only returns entries that match that data type name or wrapper.
544
+ :return: Every entry in the experiment in order of appearance that match the provided data type, if any.
545
+ """
546
+ if self.__queried_all_steps is False:
547
+ self.__queried_all_steps = True
548
+ self.__steps.update({step.get_name(): step for step in self.__protocol.get_sorted_step_list()})
549
+ all_steps: list[ElnEntryStep] = self.__protocol.get_sorted_step_list()
550
+ if data_type is None:
551
+ return all_steps
552
+ data_type: str = AliasUtil.to_data_type_name(data_type)
553
+ return [x for x in all_steps if data_type in x.get_data_type_names()]
554
+
555
+ def get_step_by_option(self, key: str, value: str | None = None) -> ElnEntryStep:
556
+ """
557
+ Retrieve the step in this experiment that contains an entry option with the provided key and value.
558
+ Throws an exception if no entries or multiple entries in the experiment match.
559
+
560
+ :param key: The key of the entry option to match on.
561
+ :param value: The value of the entry option to match on. If not provided, then only matches on key.
562
+ :return: The entry in this experiment that matches the provided entry option key and value.
563
+ """
564
+ steps: list[ElnEntryStep] = self.get_steps_by_option(key, value)
565
+ count: int = len(steps)
566
+ if count != 1:
567
+ option = key + ("::" + value if value is not None else "")
568
+ raise SapioException(f"{('No' if count == 0 else 'Multiple')} entries in this experiment match the "
569
+ f"provided option: {option}")
570
+ return steps[0]
571
+
572
+ def get_steps_by_option(self, key: str, value: str | None = None) -> list[ElnEntryStep]:
573
+ """
574
+ Retrieve every step in this experiment that contains an entry option with the provided key and value.
575
+
576
+ :param key: The key of the entry option to match on.
577
+ :param value: The value of the entry option to match on. If not provided, then only matches on key.
578
+ :return: The entries in this experiment that match the provided entry option key and value.
579
+ """
580
+ ret_list: list[ElnEntryStep] = []
581
+ for step in self.get_all_steps():
582
+ options: dict[str, str] = self.get_step_options(step)
583
+ if key in options:
584
+ if value is None or options[key] == value:
585
+ ret_list.append(step)
586
+ return ret_list
587
+
420
588
  def get_step_records(self, step: Step) -> list[DataRecord]:
421
589
  """
422
590
  Query for the data records for the given step. The returned records are not cached by the ExperimentHandler.
@@ -466,6 +634,15 @@ class ExperimentHandler:
466
634
  The records may be provided as either DataRecords, PyRecordModels, or WrappedRecordModels.
467
635
  """
468
636
  step = self.__to_eln_step(step)
637
+ if not records:
638
+ return
639
+ dt: str = AliasUtil.to_singular_data_type_name(records)
640
+ if ElnBaseDataType.is_base_data_type(dt):
641
+ raise SapioException(f"{dt} is an ELN data type. This function call has no effect on ELN data types. "
642
+ f"Use add_eln_rows or add_sample_details instead.")
643
+ if dt != step.get_data_type_names()[0]:
644
+ raise SapioException(f"Cannot add {dt} records to entry {step.get_name()} of type "
645
+ f"{step.get_data_type_names()[0]}.")
469
646
  step.add_records(AliasUtil.to_data_records(records))
470
647
 
471
648
  def remove_step_records(self, step: Step, records: Iterable[SapioRecord]) -> None:
@@ -484,6 +661,15 @@ class ExperimentHandler:
484
661
  The records may be provided as either DataRecords, PyRecordModels, or WrappedRecordModels.
485
662
  """
486
663
  step = self.__to_eln_step(step)
664
+ if not records:
665
+ return
666
+ dt: str = AliasUtil.to_singular_data_type_name(records)
667
+ if ElnBaseDataType.is_base_data_type(dt):
668
+ raise SapioException(f"{dt} is an ELN data type. This function call has no effect on ELN data types. "
669
+ f"Use remove_eln_rows or remove_sample_details instead.")
670
+ if dt != step.get_data_type_names()[0]:
671
+ raise SapioException(f"Cannot remove {dt} records from entry {step.get_name()} of type "
672
+ f"{step.get_data_type_names()[0]}.")
487
673
  step.remove_records(AliasUtil.to_data_records(records))
488
674
 
489
675
  def set_step_records(self, step: Step, records: Iterable[SapioRecord]) -> None:
@@ -507,11 +693,17 @@ class ExperimentHandler:
507
693
  The records may be provided as either DataRecords, PyRecordModels, or WrappedRecordModels.
508
694
  """
509
695
  step = self.__to_eln_step(step)
696
+ if records:
697
+ dt: str = AliasUtil.to_singular_data_type_name(records)
698
+ if ElnBaseDataType.is_base_data_type(dt):
699
+ raise SapioException(f"{dt} is an ELN data type. This function call has no effect on ELN data types. "
700
+ f"Use add_eln_rows or add_sample_details instead.")
701
+ if dt != step.get_data_type_names()[0]:
702
+ raise SapioException(f"Cannot set {dt} records for entry {step.get_name()} of type "
703
+ f"{step.get_data_type_names()[0]}.")
510
704
  step.set_records(AliasUtil.to_data_records(records))
511
705
 
512
706
  # FR-46496 - Provide alias of set_step_records for use with form entries.
513
- # TODO: Provide a similar aliased function for attachment entries once sapiopylib allows setting multiple
514
- # attachments to an attachment step.
515
707
  def set_form_record(self, step: Step, record: SapioRecord) -> None:
516
708
  """
517
709
  Sets the record for a form entry.
@@ -529,7 +721,8 @@ class ExperimentHandler:
529
721
  self.set_step_records(step, [record])
530
722
 
531
723
  # FR-46496 - Provide functions for adding and removing rows from an ELN data type entry.
532
- def add_eln_rows(self, step: Step, count: int) -> list[PyRecordModel]:
724
+ def add_eln_rows(self, step: Step, count: int, wrapper_type: type[WrappedType] | None = None) \
725
+ -> list[PyRecordModel | WrappedType]:
533
726
  """
534
727
  Add rows to an ELNExperimentDetail or ELNSampleDetail table entry. The rows will not appear in the system
535
728
  until a record manager store and commit has occurred.
@@ -541,15 +734,37 @@ class ExperimentHandler:
541
734
  The step may be provided as either a string for the name of the step or an ElnEntryStep.
542
735
  If given a name, throws an exception if no step of the given name exists in the experiment.
543
736
  :param count: The number of new rows to add to the entry.
737
+ :param wrapper_type: Optionally wrap the ELN data type in a record model wrapper. If not provided, returns
738
+ an unwrapped PyRecordModel.
544
739
  :return: A list of the newly created rows.
545
740
  """
546
741
  step = self.__to_eln_step(step)
547
742
  if step.eln_entry.entry_type != ElnEntryType.Table:
548
743
  raise SapioException("The provided step is not a table entry.")
549
744
  dt: str = step.get_data_type_names()[0]
550
- if not self.__is_eln_type(dt):
745
+ if not ElnBaseDataType.is_eln_type(dt):
551
746
  raise SapioException("The provided step is not an ELN data type entry.")
552
- return self.__inst_man.add_new_records(dt, count)
747
+ records: list[PyRecordModel] = self.__inst_man.add_new_records(dt, count)
748
+ if wrapper_type:
749
+ return self.__inst_man.wrap_list(records, wrapper_type)
750
+ return records
751
+
752
+ def add_eln_row(self, step: Step, wrapper_type: type[WrappedType] | None = None) -> PyRecordModel | WrappedType:
753
+ """
754
+ Add a row to an ELNExperimentDetail or ELNSampleDetail table entry. The row will not appear in the system
755
+ until a record manager store and commit has occurred.
756
+
757
+ If no step functions have been called before and a step is being searched for by name, queries for the
758
+ list of steps in the experiment and caches them.
759
+
760
+ :param step:
761
+ The step may be provided as either a string for the name of the step or an ElnEntryStep.
762
+ If given a name, throws an exception if no step of the given name exists in the experiment.
763
+ :param wrapper_type: Optionally wrap the ELN data type in a record model wrapper. If not provided, returns
764
+ an unwrapped PyRecordModel.
765
+ :return: The newly created row.
766
+ """
767
+ return self.add_eln_rows(step, 1, wrapper_type)[0]
553
768
 
554
769
  def remove_eln_rows(self, step: Step, records: list[SapioRecord]) -> None:
555
770
  """
@@ -571,10 +786,14 @@ class ExperimentHandler:
571
786
  """
572
787
  step = self.__to_eln_step(step)
573
788
  dt: str = step.get_data_type_names()[0]
574
- if not self.__is_eln_type(dt):
789
+ if not ElnBaseDataType.is_eln_type(dt):
575
790
  raise SapioException("The provided step is not an ELN data type entry.")
576
- if any([x.data_type_name != dt for x in records]):
577
- raise SapioException("Not all of the provided records match the data type of the step.")
791
+ if not records:
792
+ return
793
+ record_dt: str = AliasUtil.to_singular_data_type_name(records, False)
794
+ if record_dt != dt:
795
+ raise SapioException(f"Cannot remove {dt} records from entry {step.get_name()} of type "
796
+ f"{step.get_data_type_names()[0]}.")
578
797
  # If any rows were provided as data records, turn them into record models before deleting them, as otherwise
579
798
  # this function would need to make a webservice call to do the deletion.
580
799
  data_records: list[DataRecord] = []
@@ -588,16 +807,59 @@ class ExperimentHandler:
588
807
  for record in record_models:
589
808
  record.delete()
590
809
 
591
- # TODO: Remove and use the function of the same name in ElnBaseDataType in the future. Currently this function is
592
- # bugged in sapiopylib and is comparing against base_type.name instead of base_type.value.
593
- @staticmethod
594
- def __is_eln_type(data_type: str):
595
- if data_type is None or not data_type:
596
- return False
597
- for base_type in ElnBaseDataType:
598
- if data_type.lower().startswith(base_type.value.lower()):
599
- return True
600
- return False
810
+ def remove_eln_row(self, step: Step, record: SapioRecord) -> None:
811
+ """
812
+ Remove a row from an ELNExperimentDetail or ELNSampleDetail table entry. ELN data type table entries display all
813
+ records in the system that match the entry's data type. This means that removing rows from an ELN data type
814
+ table entry is equivalent to deleting the records for the rows.
815
+
816
+ The row will not be deleted in the system until a record manager store and commit has occurred.
817
+
818
+ If no step functions have been called before and a step is being searched for by name, queries for the
819
+ list of steps in the experiment and caches them.
820
+
821
+ :param step:
822
+ The step may be provided as either a string for the name of the step or an ElnEntryStep.
823
+ If given a name, throws an exception if no step of the given name exists in the experiment.
824
+ :param record:
825
+ The record to remove from the given step.
826
+ The record may be provided as either a DataRecord, PyRecordModel, or WrappedRecordModel.
827
+ """
828
+ self.remove_eln_rows(step, [record])
829
+
830
+ def add_sample_details(self, step: Step, samples: list[RecordModel], wrapper_type: type[WrappedType]) \
831
+ -> list[PyRecordModel | WrappedType]:
832
+ """
833
+ Add sample details to a sample details entry while relating them to the input sample records.
834
+
835
+ :param step:
836
+ The step may be provided as either a string for the name of the step or an ElnEntryStep.
837
+ If given a name, throws an exception if no step of the given name exists in the experiment.
838
+ :param samples: The sample records to add the sample details to.
839
+ :param wrapper_type: Optionally wrap the sample details in a record model wrapper. If not provided, returns
840
+ an unwrapped PyRecordModel.
841
+ :return: The newly created sample details. The indices of the samples in the input list match the index of the
842
+ sample details in this list that they are related to.
843
+ """
844
+ step = self.__to_eln_step(step)
845
+ if step.eln_entry.entry_type != ElnEntryType.Table:
846
+ raise SapioException("The provided step is not a table entry.")
847
+ dt: str = step.get_data_type_names()[0]
848
+ if not ElnBaseDataType.is_eln_type(dt) or ElnBaseDataType.get_base_type(dt) != ElnBaseDataType.SAMPLE_DETAIL:
849
+ raise SapioException("The provided step is not an ELNSampleDetail entry.")
850
+ records: list[PyRecordModel] = []
851
+ for sample in samples:
852
+ if sample.data_type_name != "Sample":
853
+ raise SapioException(f"Received a {sample.data_type_name} record when Sample records were expected.")
854
+ detail: PyRecordModel = sample.add(Child.create_by_name(dt))
855
+ detail.set_field_values({
856
+ "SampleId": sample.get_field_value("SampleId"),
857
+ "OtherSampleId": sample.get_field_value("OtherSampleId")
858
+ })
859
+ records.append(detail)
860
+ if wrapper_type:
861
+ return self.__inst_man.wrap_list(records, wrapper_type)
862
+ return records
601
863
 
602
864
  def update_step(self, step: Step,
603
865
  entry_name: str | None = None,
@@ -713,10 +975,7 @@ class ExperimentHandler:
713
975
  if dependency_set is not None:
714
976
  entry.dependency_set = dependency_set
715
977
  if entry_status is not None:
716
- # TODO: sapiopylib is currently storing the status of entries as strings when first queried. For the sake of not
717
- # breaking comparisons to enums that expect this behavior, also setting the status to the enum's string.
718
- # The ".value" can be removed once sapiopylib is fixed.
719
- entry.entry_status = entry_status.value
978
+ entry.entry_status = entry_status
720
979
  if order is not None:
721
980
  entry.order = order
722
981
  if description is not None:
@@ -752,7 +1011,7 @@ class ExperimentHandler:
752
1011
  if clear_template_item_fulfilled_timestamp is True:
753
1012
  entry.template_item_fulfilled_timestamp = None
754
1013
  if entry_options_map is not None:
755
- self.__step_options.update({step: entry_options_map})
1014
+ self.__step_options.update({step.get_id(): entry_options_map})
756
1015
 
757
1016
  def get_step_option(self, step: Step, option: str) -> str:
758
1017
  """
@@ -782,8 +1041,8 @@ class ExperimentHandler:
782
1041
  list of steps in the experiment and caches them.
783
1042
 
784
1043
  Getting the step options requires a webservice query, which is made the first time any step option
785
- method is called for a specific step. The step options are cached so that subsequent calls of this
786
- method for that step don't make a webservice call.
1044
+ method is called for any step in this experiment. The step options are cached so that subsequent calls of this
1045
+ method don't make a webservice call.
787
1046
 
788
1047
  :param step:
789
1048
  The step to get the options of.
@@ -791,7 +1050,10 @@ class ExperimentHandler:
791
1050
  If given a name, throws an exception if no step of the given name exists in the experiment.
792
1051
  :return: The map of options for the input step.
793
1052
  """
794
- return self.__get_step_options(step)
1053
+ step = self.__to_eln_step(step)
1054
+ if step not in self.__step_options:
1055
+ self.__step_options.update(ExperimentReportUtil.get_experiment_entry_options(self.user, self.get_all_steps()))
1056
+ return self.__step_options[step.get_id()]
795
1057
 
796
1058
  def add_step_options(self, step: Step, mapping: Mapping[str, str]):
797
1059
  """
@@ -867,12 +1129,9 @@ class ExperimentHandler:
867
1129
  If given a name, throws an exception if no step of the given name exists in the experiment.
868
1130
  """
869
1131
  step = self.__to_eln_step(step)
870
- if step.eln_entry.entry_status not in self.__COMPLETE_STATUSES:
1132
+ if step.eln_entry.entry_status not in self.__ENTRY_COMPLETE_STATUSES:
871
1133
  step.complete_step()
872
- # TODO: sapiopylib is currently storing the status of entries as strings when first queried. For the sake of not
873
- # breaking comparisons to enums that expect this behavior, also setting the status to the enum's string.
874
- # The ".value" can be removed once sapiopylib is fixed.
875
- step.eln_entry.entry_status = ExperimentEntryStatus.Completed.value
1134
+ step.eln_entry.entry_status = ExperimentEntryStatus.Completed
876
1135
 
877
1136
  def unlock_step(self, step: Step) -> None:
878
1137
  """
@@ -888,12 +1147,30 @@ class ExperimentHandler:
888
1147
  If given a name, throws an exception if no step of the given name exists in the experiment.
889
1148
  """
890
1149
  step = self.__to_eln_step(step)
891
- if step.eln_entry.entry_status in self.__LOCKED_STATUSES:
1150
+ if step.eln_entry.entry_status in self.__ENTRY_LOCKED_STATUSES:
892
1151
  step.unlock_step()
893
- # TODO: sapiopylib is currently storing the status of entries as strings when first queried. For the sake of not
894
- # breaking comparisons to enums that expect this behavior, also setting the status to the enum's string.
895
- # The ".value" can be removed once sapiopylib is fixed.
896
- step.eln_entry.entry_status = ExperimentEntryStatus.UnlockedChangesRequired.value
1152
+ step.eln_entry.entry_status = ExperimentEntryStatus.UnlockedChangesRequired
1153
+
1154
+ def disable_step(self, step: Step) -> None:
1155
+ """
1156
+ Set the status of the input step to Disabled. This is the state that entries are in when they are waiting for
1157
+ entries that they are dependent upon to be submitted before they can be enabled. If you have unsubmitted an
1158
+ entry and want its dependent entries to be locked again, then you would use this to set their status to
1159
+ disabled.
1160
+
1161
+ Makes a webservice call to update the step. Checks if the step is already unlocked, and does nothing if so.
1162
+
1163
+ If no step functions have been called before and a step is being searched for by name, queries for the
1164
+ list of steps in the experiment and caches them.
1165
+
1166
+ :param step:
1167
+ The step to disable.
1168
+ The step may be provided as either a string for the name of the step or an ElnEntryStep.
1169
+ If given a name, throws an exception if no step of the given name exists in the experiment.
1170
+ """
1171
+ step = self.__to_eln_step(step)
1172
+ if step.eln_entry.entry_status in self.__ENTRY_LOCKED_STATUSES:
1173
+ self.update_step(step, entry_status=ExperimentEntryStatus.Disabled)
897
1174
 
898
1175
  def step_is_submitted(self, step: Step) -> bool:
899
1176
  """
@@ -908,7 +1185,7 @@ class ExperimentHandler:
908
1185
  If given a name, throws an exception if no step of the given name exists in the experiment.
909
1186
  :return: True if the step's status is Completed or CompletedApproved. False otherwise.
910
1187
  """
911
- return self.__to_eln_step(step).eln_entry.entry_status in self.__COMPLETE_STATUSES
1188
+ return self.__to_eln_step(step).eln_entry.entry_status in self.__ENTRY_COMPLETE_STATUSES
912
1189
 
913
1190
  def step_is_locked(self, step: Step) -> bool:
914
1191
  """
@@ -924,7 +1201,7 @@ class ExperimentHandler:
924
1201
  :return: True if the step's status is Completed, CompletedApproved, Disabled, LockedAwaitingApproval,
925
1202
  or LockedRejected. False otherwise.
926
1203
  """
927
- return self.__to_eln_step(step).eln_entry.entry_status in self.__LOCKED_STATUSES
1204
+ return self.__to_eln_step(step).eln_entry.entry_status in self.__ENTRY_LOCKED_STATUSES
928
1205
 
929
1206
  def __to_eln_step(self, step: Step) -> ElnEntryStep:
930
1207
  """
@@ -946,16 +1223,3 @@ class ExperimentHandler:
946
1223
  return self.__exp_options
947
1224
  self.__exp_options = self.__eln_man.get_notebook_experiment_options(self.__exp_id)
948
1225
  return self.__exp_options
949
-
950
- def __get_step_options(self, step: Step) -> dict[str, str]:
951
- """
952
- Cache the options for the input step if they haven't been cached yet.
953
-
954
- :return: The entry options for the input step.
955
- """
956
- step = self.__to_eln_step(step)
957
- if step in self.__step_options:
958
- return self.__step_options.get(step)
959
- options: dict[str, str] = step.get_options()
960
- self.__step_options.update({step: options})
961
- return options