sapiopycommons 2025.3.25a459__py3-none-any.whl → 2025.3.31a466__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.

@@ -26,6 +26,7 @@ from sapiopylib.rest.pojo.eln.SapioELNEnums import ExperimentEntryStatus, ElnExp
26
26
  ElnBaseDataType
27
27
  from sapiopylib.rest.pojo.eln.eln_headings import ElnExperimentTab, ElnExperimentTabAddCriteria
28
28
  from sapiopylib.rest.pojo.eln.field_set import ElnFieldSetInfo
29
+ from sapiopylib.rest.pojo.eln.protocol_template import ProtocolTemplateInfo
29
30
  from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
30
31
  from sapiopylib.rest.pojo.webhook.WebhookDirective import ElnExperimentDirective
31
32
  from sapiopylib.rest.pojo.webhook.WebhookResult import SapioWebhookResult
@@ -40,6 +41,7 @@ from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType
40
41
  from sapiopylib.rest.utils.recordmodel.properties import Child
41
42
 
42
43
  from sapiopycommons.datatype.data_fields import SystemFields
44
+ from sapiopycommons.datatype.experiment_cache import ExperimentCacheManager
43
45
  from sapiopycommons.eln.experiment_report_util import ExperimentReportUtil
44
46
  from sapiopycommons.eln.experiment_tags import PLATE_DESIGNER_PLUGIN
45
47
  from sapiopycommons.general.aliases import AliasUtil, SapioRecord, ExperimentIdentifier, UserIdentifier, \
@@ -51,8 +53,8 @@ from sapiopycommons.recordmodel.record_handler import RecordHandler
51
53
  Step: TypeAlias = str | ElnEntryStep
52
54
  """An object representing an identifier to an ElnEntryStep. May be either the name of the step or the ElnEntryStep
53
55
  itself."""
54
- Tab: TypeAlias = str | ElnExperimentTab
55
- """An object representing an identifier to an ElnExperimentTab. May be either the name of the tab or the
56
+ TabIdentifier: TypeAlias = int | str | ElnExperimentTab
57
+ """An object representing an identifier to an ElnExperimentTab. May be either the ID or name of the tab, or the
56
58
  ElnExperimentTab itself."""
57
59
  ElnDataTypeFields: TypeAlias = AbstractVeloxFieldDefinition | ElnFieldSetInfo | str | int
58
60
  """An object representing an identifier to an ElnDataType field. These can be field definitions for ad hoc fields,
@@ -77,6 +79,8 @@ class ExperimentHandler:
77
79
  # Managers.
78
80
  _eln_man: ElnManager
79
81
  """The ELN manager. Used for updating the experiment and its steps."""
82
+ _exp_cache: ExperimentCacheManager
83
+ """The experiment cache manager. Used for caching experiment-related information."""
80
84
  _inst_man: RecordModelInstanceManager
81
85
  """The record model instance manager. Used for wrapping the data records of a step as record models."""
82
86
  _rec_handler: RecordHandler
@@ -113,9 +117,6 @@ class ExperimentHandler:
113
117
  _tabs_by_name: dict[str, ElnExperimentTab]
114
118
  """The tabs for this experiment by their name. Only cached when first accessed."""
115
119
 
116
- _predefined_fields: dict[str, dict[str, AbstractVeloxFieldDefinition]]
117
- """A dictionary of predefined fields for each ELN data type. Only cached when first accessed."""
118
-
119
120
  # Constants
120
121
  _ENTRY_COMPLETE_STATUSES = [ExperimentEntryStatus.Completed, ExperimentEntryStatus.CompletedApproved]
121
122
  """The set of statuses that an ELN entry could have and be considered completed/submitted."""
@@ -173,6 +174,7 @@ class ExperimentHandler:
173
174
 
174
175
  # Grab various managers that may be used.
175
176
  self._eln_man = DataMgmtServer.get_eln_manager(self.user)
177
+ self._exp_cache = ExperimentCacheManager(self.user)
176
178
  self._inst_man = RecordModelManager(self.user).instance_manager
177
179
  self._rec_handler = RecordHandler(self.user)
178
180
 
@@ -284,30 +286,35 @@ class ExperimentHandler:
284
286
  self._tabs.clear()
285
287
  self._tabs_by_name.clear()
286
288
 
287
- def add_entry_to_caches(self, entry: ExperimentEntry | ElnEntryStep) -> None:
289
+ def add_entry_to_caches(self, entry: ExperimentEntry | ElnEntryStep) -> ElnEntryStep:
288
290
  """
289
291
  Add the given entry to the cache of steps for this experiment. This is necessary in order for certain methods to
290
292
  work. You should only need to do this if you have created a new entry in your code using a method outside
291
293
  of this ExperimentHandler.
292
294
 
293
295
  :param entry: The entry to add to the cache.
296
+ :return: The entry that was added to the cache as an ElnEntryStep.
294
297
  """
295
298
  if isinstance(entry, ExperimentEntry):
296
299
  entry = ElnEntryStep(self._protocol, entry)
297
300
  self._steps.update({entry.get_name(): entry})
298
301
  self._steps_by_id.update({entry.get_id(): entry})
299
302
  # Skipping the options cache. The get_step_options method will update the cache when necessary.
303
+ return entry
300
304
 
301
- def add_entries_to_caches(self, entries: list[ExperimentEntry | ElnEntryStep]) -> None:
305
+ def add_entries_to_caches(self, entries: list[ExperimentEntry | ElnEntryStep]) -> list[ElnEntryStep]:
302
306
  """
303
307
  Add the given entries to the cache of steps for this experiment. This is necessary in order for certain methods
304
308
  to work. You should only need to do this if you have created a new entry in your code using a method outside
305
309
  of this ExperimentHandler.
306
310
 
307
311
  :param entries: The entries to add to the cache.
312
+ :return: The entries that were added to the cache as ElnEntrySteps.
308
313
  """
314
+ new_entries: list[ElnEntryStep] = []
309
315
  for entry in entries:
310
- self.add_entry_to_caches(entry)
316
+ new_entries.append(self.add_entry_to_caches(entry))
317
+ return new_entries
311
318
 
312
319
  def add_tab_to_cache(self, tab: ElnExperimentTab) -> None:
313
320
  """
@@ -348,22 +355,10 @@ class ExperimentHandler:
348
355
  :param active_templates_only: Whether only active templates should be queried for.
349
356
  :return: The newly created experiment.
350
357
  """
351
- template_query = TemplateExperimentQueryPojo(latest_version_only=(template_version is None),
352
- active_templates_only=active_templates_only)
353
- templates: list[ElnTemplate] = context.eln_manager.get_template_experiment_list(template_query)
354
- launch_template: ElnTemplate | None = None
355
- for template in templates:
356
- if template.template_name != template_name:
357
- continue
358
- if template_version is not None and template.template_version != template_version:
359
- continue
360
- launch_template = template
361
- break
362
- if launch_template is None:
363
- raise SapioException(f"No template with the name \"{template_name}\"" +
364
- ("" if template_version is None else f" and the version {template_version}") +
365
- f" found.")
366
-
358
+ launch_template: ElnTemplate = ExperimentCacheManager(context).get_experiment_template(template_name,
359
+ active_templates_only,
360
+ template_version,
361
+ first_match=True)
367
362
  if experiment_name is None:
368
363
  experiment_name: str = launch_template.display_name
369
364
  if parent_record is not None:
@@ -590,6 +585,9 @@ class ExperimentHandler:
590
585
  """
591
586
  Set the experiment's status to Completed. Makes a webservice call to update the experiment. Checks if the
592
587
  experiment is already completed, and does nothing if so.
588
+
589
+ NOTE: This will cause the usual process tracking logic to run as if you'd clicked the "Complete Experiment"
590
+ toolbar button. This includes moving the in process samples forward to the next step in the process.
593
591
  """
594
592
  if not self.experiment_is_complete():
595
593
  self._protocol.complete_protocol()
@@ -600,9 +598,11 @@ class ExperimentHandler:
600
598
  Set the experiment's status to Canceled. Makes a webservice call to update the experiment. Checks if the
601
599
  experiment is already canceled, and does nothing if so.
602
600
 
603
- NOTE: This will not run the usual logic around canceling an experiment that you'd see if canceling the
604
- experiment using the "Cancel Experiment" toolbar button, such as moving in process samples back to the queue,
605
- as those changes are tied to the button instead of being on the experiment status change.
601
+ NOTE: This will cause the usual process tracking logic to run as if you'd clicked the "Cancel Experiment"
602
+ toolbar button. This includes moving the in process samples back into the process queue for the current step.
603
+
604
+ On version 24.12 and earlier, this was not the case, as the process tracking logic was tied to the button
605
+ instead of being on the experiment status change.
606
606
  """
607
607
  if not self.experiment_is_canceled():
608
608
  self._protocol.cancel_protocol()
@@ -782,8 +782,9 @@ class ExperimentHandler:
782
782
  return
783
783
  dt: str = AliasUtil.to_singular_data_type_name(records)
784
784
  if ElnBaseDataType.is_base_data_type(dt):
785
- raise SapioException(f"{dt} is an ELN data type. This function call has no effect on ELN data types. "
786
- f"Use add_eln_rows or add_sample_details instead.")
785
+ raise SapioException(f"{dt} is an ELN data type. This function call has no effect on ELN data types. ELN "
786
+ f"records that are committed to the system will automatically appear in the ELN entry "
787
+ f"with the matching data type name.")
787
788
  if dt != step.get_data_type_names()[0]:
788
789
  raise SapioException(f"Cannot add {dt} records to entry {step.get_name()} of type "
789
790
  f"{step.get_data_type_names()[0]}.")
@@ -809,8 +810,9 @@ class ExperimentHandler:
809
810
  return
810
811
  dt: str = AliasUtil.to_singular_data_type_name(records)
811
812
  if ElnBaseDataType.is_base_data_type(dt):
812
- raise SapioException(f"{dt} is an ELN data type. This function call has no effect on ELN data types. "
813
- f"Use remove_eln_rows or remove_sample_details instead.")
813
+ # CR-47532: Add remove_step_records support for Experiment Detail and Sample Detail entries.
814
+ self.remove_eln_rows(step, records)
815
+ return
814
816
  if dt != step.get_data_type_names()[0]:
815
817
  raise SapioException(f"Cannot remove {dt} records from entry {step.get_name()} of type "
816
818
  f"{step.get_data_type_names()[0]}.")
@@ -839,9 +841,14 @@ class ExperimentHandler:
839
841
  step = self.__to_eln_step(step)
840
842
  if records:
841
843
  dt: str = AliasUtil.to_singular_data_type_name(records)
844
+ # CR-47532: Add set_step_records support for Experiment Detail and Sample Detail entries.
842
845
  if ElnBaseDataType.is_base_data_type(dt):
843
- raise SapioException(f"{dt} is an ELN data type. This function call has no effect on ELN data types. "
844
- f"Use add_eln_rows or add_sample_details instead.")
846
+ remove_rows: list[PyRecordModel] = []
847
+ for record in self.get_step_models(step):
848
+ if record not in records:
849
+ remove_rows.append(record)
850
+ self.remove_eln_rows(step, remove_rows)
851
+ return
845
852
  if dt != step.get_data_type_names()[0]:
846
853
  raise SapioException(f"Cannot set {dt} records for entry {step.get_name()} of type "
847
854
  f"{step.get_data_type_names()[0]}.")
@@ -868,6 +875,23 @@ class ExperimentHandler:
868
875
  step.eln_entry.record_id = AliasUtil.to_data_record(record).record_id
869
876
 
870
877
  # FR-46496 - Provide functions for adding and removing rows from an ELN data type entry.
878
+ def add_eln_row(self, step: Step, wrapper_type: type[WrappedType] | None = None) -> WrappedType | PyRecordModel:
879
+ """
880
+ Add a row to an ELNExperimentDetail or ELNSampleDetail table entry. The row will not appear in the system
881
+ until a record manager store and commit has occurred.
882
+
883
+ If no step functions have been called before and a step is being searched for by name, queries for the
884
+ list of steps in the experiment and caches them.
885
+
886
+ :param step:
887
+ The step may be provided as either a string for the name of the step or an ElnEntryStep.
888
+ If given a name, throws an exception if no step of the given name exists in the experiment.
889
+ :param wrapper_type: Optionally wrap the ELN data type in a record model wrapper. If not provided, returns
890
+ an unwrapped PyRecordModel.
891
+ :return: The newly created row.
892
+ """
893
+ return self.add_eln_rows(step, 1, wrapper_type)[0]
894
+
871
895
  def add_eln_rows(self, step: Step, count: int, wrapper_type: type[WrappedType] | None = None) \
872
896
  -> list[WrappedType] | list[PyRecordModel]:
873
897
  """
@@ -896,10 +920,64 @@ class ExperimentHandler:
896
920
  return self._inst_man.wrap_list(records, wrapper_type)
897
921
  return records
898
922
 
899
- def add_eln_row(self, step: Step, wrapper_type: type[WrappedType] | None = None) -> WrappedType | PyRecordModel:
923
+ def add_sample_detail(self, step: Step, sample: RecordModel,
924
+ wrapper_type: type[WrappedType] | None = None) \
925
+ -> WrappedType | PyRecordModel:
900
926
  """
901
- Add a row to an ELNExperimentDetail or ELNSampleDetail table entry. The row will not appear in the system
902
- until a record manager store and commit has occurred.
927
+ Add a sample detail to a sample detail entry while relating it to the input sample record.
928
+
929
+ :param step:
930
+ The step may be provided as either a string for the name of the step or an ElnEntryStep.
931
+ If given a name, throws an exception if no step of the given name exists in the experiment.
932
+ :param sample: The sample record to add the sample detail to.
933
+ :param wrapper_type: Optionally wrap the sample detail in a record model wrapper. If not provided, returns
934
+ an unwrapped PyRecordModel.
935
+ :return: The newly created sample detail.
936
+ """
937
+ return self.add_sample_details(step, [sample], wrapper_type)[0]
938
+
939
+ def add_sample_details(self, step: Step, samples: list[RecordModel],
940
+ wrapper_type: type[WrappedType] | None = None) \
941
+ -> list[WrappedType] | list[PyRecordModel]:
942
+ """
943
+ Add sample details to a sample details entry while relating them to the input sample records.
944
+
945
+ :param step:
946
+ The step may be provided as either a string for the name of the step or an ElnEntryStep.
947
+ If given a name, throws an exception if no step of the given name exists in the experiment.
948
+ :param samples: The sample records to add the sample details to.
949
+ :param wrapper_type: Optionally wrap the sample details in a record model wrapper. If not provided, returns
950
+ an unwrapped PyRecordModel.
951
+ :return: The newly created sample details. The indices of the samples in the input list match the index of the
952
+ sample details in this list that they are related to.
953
+ """
954
+ step = self.__to_eln_step(step)
955
+ if step.eln_entry.entry_type != ElnEntryType.Table:
956
+ raise SapioException("The provided step is not a table entry.")
957
+ dt: str = step.get_data_type_names()[0]
958
+ if not ElnBaseDataType.is_eln_type(dt) or ElnBaseDataType.get_base_type(dt) != ElnBaseDataType.SAMPLE_DETAIL:
959
+ raise SapioException("The provided step is not an ELNSampleDetail entry.")
960
+ records: list[PyRecordModel] = []
961
+ for sample in samples:
962
+ if sample.data_type_name != "Sample":
963
+ raise SapioException(f"Received a {sample.data_type_name} record when Sample records were expected.")
964
+ detail: PyRecordModel = sample.add(Child.create_by_name(dt))
965
+ detail.set_field_values({
966
+ "SampleId": sample.get_field_value("SampleId"),
967
+ "OtherSampleId": sample.get_field_value("OtherSampleId")
968
+ })
969
+ records.append(detail)
970
+ if wrapper_type:
971
+ return self._inst_man.wrap_list(records, wrapper_type)
972
+ return records
973
+
974
+ def remove_eln_row(self, step: Step, record: SapioRecord) -> None:
975
+ """
976
+ Remove a row from an ELNExperimentDetail or ELNSampleDetail table entry. ELN data type table entries display all
977
+ records in the system that match the entry's data type. This means that removing rows from an ELN data type
978
+ table entry is equivalent to deleting the records for the rows.
979
+
980
+ The row will not be deleted in the system until a record manager store and commit has occurred.
903
981
 
904
982
  If no step functions have been called before and a step is being searched for by name, queries for the
905
983
  list of steps in the experiment and caches them.
@@ -907,11 +985,11 @@ class ExperimentHandler:
907
985
  :param step:
908
986
  The step may be provided as either a string for the name of the step or an ElnEntryStep.
909
987
  If given a name, throws an exception if no step of the given name exists in the experiment.
910
- :param wrapper_type: Optionally wrap the ELN data type in a record model wrapper. If not provided, returns
911
- an unwrapped PyRecordModel.
912
- :return: The newly created row.
988
+ :param record:
989
+ The record to remove from the given step.
990
+ The record may be provided as either a DataRecord, PyRecordModel, or WrappedRecordModel.
913
991
  """
914
- return self.add_eln_rows(step, 1, wrapper_type)[0]
992
+ self.remove_eln_rows(step, [record])
915
993
 
916
994
  def remove_eln_rows(self, step: Step, records: list[SapioRecord]) -> None:
917
995
  """
@@ -954,61 +1032,6 @@ class ExperimentHandler:
954
1032
  for record in record_models:
955
1033
  record.delete()
956
1034
 
957
- def remove_eln_row(self, step: Step, record: SapioRecord) -> None:
958
- """
959
- Remove a row from an ELNExperimentDetail or ELNSampleDetail table entry. ELN data type table entries display all
960
- records in the system that match the entry's data type. This means that removing rows from an ELN data type
961
- table entry is equivalent to deleting the records for the rows.
962
-
963
- The row will not be deleted in the system until a record manager store and commit has occurred.
964
-
965
- If no step functions have been called before and a step is being searched for by name, queries for the
966
- list of steps in the experiment and caches them.
967
-
968
- :param step:
969
- The step may be provided as either a string for the name of the step or an ElnEntryStep.
970
- If given a name, throws an exception if no step of the given name exists in the experiment.
971
- :param record:
972
- The record to remove from the given step.
973
- The record may be provided as either a DataRecord, PyRecordModel, or WrappedRecordModel.
974
- """
975
- self.remove_eln_rows(step, [record])
976
-
977
- def add_sample_details(self, step: Step, samples: list[RecordModel],
978
- wrapper_type: type[WrappedType] | None = None) \
979
- -> list[WrappedType] | list[PyRecordModel]:
980
- """
981
- Add sample details to a sample details entry while relating them to the input sample records.
982
-
983
- :param step:
984
- The step may be provided as either a string for the name of the step or an ElnEntryStep.
985
- If given a name, throws an exception if no step of the given name exists in the experiment.
986
- :param samples: The sample records to add the sample details to.
987
- :param wrapper_type: Optionally wrap the sample details in a record model wrapper. If not provided, returns
988
- an unwrapped PyRecordModel.
989
- :return: The newly created sample details. The indices of the samples in the input list match the index of the
990
- sample details in this list that they are related to.
991
- """
992
- step = self.__to_eln_step(step)
993
- if step.eln_entry.entry_type != ElnEntryType.Table:
994
- raise SapioException("The provided step is not a table entry.")
995
- dt: str = step.get_data_type_names()[0]
996
- if not ElnBaseDataType.is_eln_type(dt) or ElnBaseDataType.get_base_type(dt) != ElnBaseDataType.SAMPLE_DETAIL:
997
- raise SapioException("The provided step is not an ELNSampleDetail entry.")
998
- records: list[PyRecordModel] = []
999
- for sample in samples:
1000
- if sample.data_type_name != "Sample":
1001
- raise SapioException(f"Received a {sample.data_type_name} record when Sample records were expected.")
1002
- detail: PyRecordModel = sample.add(Child.create_by_name(dt))
1003
- detail.set_field_values({
1004
- "SampleId": sample.get_field_value("SampleId"),
1005
- "OtherSampleId": sample.get_field_value("OtherSampleId")
1006
- })
1007
- records.append(detail)
1008
- if wrapper_type:
1009
- return self._inst_man.wrap_list(records, wrapper_type)
1010
- return records
1011
-
1012
1035
  # noinspection PyPep8Naming
1013
1036
  def update_step(self, step: Step,
1014
1037
  entry_name: str | None = None,
@@ -1703,7 +1726,7 @@ class ExperimentHandler:
1703
1726
  self.get_all_tabs()
1704
1727
  return self._tabs_by_name[tab_name]
1705
1728
 
1706
- def get_steps_in_tab(self, tab: Tab, data_type: DataTypeIdentifier | None = None) -> list[ElnEntryStep]:
1729
+ def get_steps_in_tab(self, tab: TabIdentifier, data_type: DataTypeIdentifier | None = None) -> list[ElnEntryStep]:
1707
1730
  """
1708
1731
  Get all the steps in the input tab sorted in order of appearance.
1709
1732
 
@@ -1713,7 +1736,8 @@ class ExperimentHandler:
1713
1736
  If the steps in the experiment have not been queried before, queries for the list of steps in the experiment
1714
1737
  and caches them.
1715
1738
 
1716
- :param tab: The tab or tab name to get the steps of.
1739
+ :param tab: The tab to get the steps of. This can be either an ElnExperimentTab object, or the name or ID of
1740
+ the tab.
1717
1741
  :param data_type: The data type to filter the steps by. If None, all steps are returned.
1718
1742
  :return: A list of all the steps in the input tab sorted in order of appearance.
1719
1743
  """
@@ -1725,7 +1749,7 @@ class ExperimentHandler:
1725
1749
  steps.sort(key=lambda s: (s.eln_entry.order, s.eln_entry.column_order))
1726
1750
  return steps
1727
1751
 
1728
- def get_next_entry_order_in_tab(self, tab: Tab) -> int:
1752
+ def get_next_entry_order_in_tab(self, tab: TabIdentifier) -> int:
1729
1753
  """
1730
1754
  Get the next available order for a new entry in the input tab.
1731
1755
 
@@ -1735,12 +1759,71 @@ class ExperimentHandler:
1735
1759
  If the steps in the experiment have not been queried before, queries for the list of steps in the experiment
1736
1760
  and caches them.
1737
1761
 
1738
- :param tab: The tab or tab name to get the steps of.
1762
+ :param tab: The tab to get the next entry order of. This can be either an ElnExperimentTab object, or the name
1763
+ or ID of the tab.
1739
1764
  :return: The next available order for a new entry in the input tab.
1740
1765
  """
1741
1766
  steps = self.get_steps_in_tab(tab)
1742
1767
  return steps[-1].eln_entry.order + 1 if steps else 0
1743
1768
 
1769
+ # FR-47530: Add functions for dealing with entry positioning.
1770
+ def step_to_position(self, step: Step) -> ElnEntryPosition:
1771
+ """
1772
+ Get the position of the input step in the experiment.
1773
+
1774
+ If no step functions have been called before and a step is being searched for by name, queries for the
1775
+ list of steps in the experiment and caches them.
1776
+
1777
+ :param step:
1778
+ The step to get the position of.
1779
+ The step may be provided as either a string for the name of the step or an ElnEntryStep.
1780
+ If given a name, throws an exception if no step of the given name exists in the experiment.
1781
+ :return: The position of the input step in the experiment.
1782
+ """
1783
+ step: ElnEntryStep = self.__to_eln_step(step)
1784
+ entry: ExperimentEntry = step.eln_entry
1785
+ return ElnEntryPosition(entry.notebook_experiment_tab_id,
1786
+ entry.order,
1787
+ entry.column_span,
1788
+ entry.column_order)
1789
+
1790
+ def step_at_position(self, position: ElnEntryPosition) -> Step | None:
1791
+ """
1792
+ Get the step at the input position in the experiment.
1793
+
1794
+ If no step functions have been called before and a step is being searched for by name, queries for the
1795
+ list of steps in the experiment and caches them.
1796
+
1797
+ :param position: The position to get the step at.
1798
+ :return: The step at the input position in the experiment, or None if no step exists at that position.
1799
+ """
1800
+ if position.tab_id is None or position.order is None:
1801
+ raise SapioException("The provided position must at least have a tab ID and order.")
1802
+ for step in self.get_steps_in_tab(position.tab_id):
1803
+ entry: ExperimentEntry = step.eln_entry
1804
+ if entry.order != position.order:
1805
+ continue
1806
+ if position.column_span is not None and entry.column_span != position.column_span:
1807
+ continue
1808
+ if position.column_order is not None and entry.column_order != position.column_order:
1809
+ continue
1810
+ return step
1811
+ return None
1812
+
1813
+ # FR-47530: Create a function for adding protocol templates to the experiment.
1814
+ def add_protocol(self, protocol: ProtocolTemplateInfo | int, position: ElnEntryPosition) -> list[ElnEntryStep]:
1815
+ """
1816
+ Add a protocol to the experiment. Updates the handler cache with the newly created entries.
1817
+
1818
+ :param protocol: The protocol to add. This can be either a ProtocolTemplateInfo object or the ID of the
1819
+ protocol template.
1820
+ :param position: The position that the protocol's first entry will be placed at.
1821
+ :return: The newly created protocol entries.
1822
+ """
1823
+ protocol = protocol if isinstance(protocol, int) else protocol.template_id
1824
+ new_entries: list[ExperimentEntry] = self._eln_man.add_protocol_template(self._exp_id, protocol, position)
1825
+ return self.add_entries_to_caches(new_entries)
1826
+
1744
1827
  # FR-47468: Add functions for creating new entries in the experiment.
1745
1828
  def create_attachment_step(self, entry_name: str, data_type: DataTypeIdentifier,
1746
1829
  *,
@@ -2114,24 +2197,12 @@ class ExperimentHandler:
2114
2197
  for field in fields:
2115
2198
  if isinstance(field, AbstractVeloxFieldDefinition):
2116
2199
  field_defs.append(field)
2117
- elif isinstance(field, ElnFieldSetInfo):
2118
- field_defs.extend(self._eln_man.get_predefined_fields_from_field_set_id(field.field_set_id))
2200
+ elif isinstance(field, (int, ElnFieldSetInfo)):
2201
+ field_defs.extend(self._exp_cache.get_field_set_fields(field))
2119
2202
  elif isinstance(field, str):
2120
- field_defs.append(self._predefined_field(field, dt))
2121
- elif isinstance(field, int):
2122
- field_defs.extend(self._eln_man.get_predefined_fields_from_field_set_id(field))
2203
+ field_defs.append(self._exp_cache.get_predefined_field(field, dt))
2123
2204
  return field_defs
2124
2205
 
2125
- def _predefined_field(self, field_name: str, data_type: ElnBaseDataType) -> AbstractVeloxFieldDefinition:
2126
- """
2127
- Get the predefined field of the given name for the given ELN data type.
2128
- """
2129
- # TODO: Should this be in some sort of DataTypeCacheManager?
2130
- if data_type not in self._predefined_fields:
2131
- fields: list[AbstractVeloxFieldDefinition] = self._eln_man.get_predefined_fields(data_type)
2132
- self._predefined_fields[data_type.data_type_name] = {x.data_field_name: x for x in fields}
2133
- return self._predefined_fields[data_type.data_type_name][field_name]
2134
-
2135
2206
  def __to_eln_step(self, step: Step) -> ElnEntryStep:
2136
2207
  """
2137
2208
  Convert a variable that could be either a string or an ElnEntryStep to just an ElnEntryStep.
@@ -2142,7 +2213,7 @@ class ExperimentHandler:
2142
2213
  """
2143
2214
  return self.get_step(step) if isinstance(step, str) else step
2144
2215
 
2145
- def __to_eln_tab(self, tab: Tab) -> ElnExperimentTab:
2216
+ def __to_eln_tab(self, tab: TabIdentifier) -> ElnExperimentTab:
2146
2217
  """
2147
2218
  Convert a variable that could be either a string or an ElnExperimentTab to just an ElnExperimentTab.
2148
2219
  This will query and cache the tabs for the experiment if the input tab is a name and the tabs have not been
@@ -2150,4 +2221,8 @@ class ExperimentHandler:
2150
2221
 
2151
2222
  :return: The input tab as an ElnExperimentTab.
2152
2223
  """
2153
- return self.get_tab(tab) if isinstance(tab, str) else tab
2224
+ if isinstance(tab, str):
2225
+ return self.get_tab(tab)
2226
+ if isinstance(tab, int):
2227
+ return [x for x in self._tabs if x.tab_id == tab][0]
2228
+ return tab
@@ -327,10 +327,10 @@ class FileUtil:
327
327
  :param files: A dictionary of file name to file data as a string or bytes.
328
328
  :return: The bytes for a zip file containing the input files.
329
329
  """
330
- zip_buffer: io.BytesIO = io.BytesIO()
331
- with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
332
- for file_name, file_data in files.items():
333
- zip_file.writestr(file_name, file_data)
330
+ with io.BytesIO() as zip_buffer:
331
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
332
+ for file_name, file_data in files.items():
333
+ zip_file.writestr(file_name, file_data)
334
334
  return zip_buffer.getvalue()
335
335
 
336
336
  # Deprecated functions:
@@ -199,7 +199,7 @@ class AccessionRequestId(AbstractAccessionServiceOperator):
199
199
 
200
200
  Properties:
201
201
  numberOfCharacters: Number of characters maximum in the request ID.
202
- accessorName: This is a legacy variable from drum.getNextIdListByMapName(), which allows setting different "accessorName" from old system. We need this for compability patch for converting these to the new preference format.
202
+ accessorName: This is a legacy variable from drum.getNextIdListByMapName(), which allows setting different "accessorName" from old system. We need this for compatibility patch for converting these to the new preference format.
203
203
  """
204
204
  _num_of_characters: int
205
205
  _accessor_name: str
@@ -341,7 +341,7 @@ class AccessionService:
341
341
  def get_affixed_id_in_batch(self, data_type_name: str, data_field_name: str, num_ids: int, prefix: str | None,
342
342
  suffix: str | None, num_digits: int | None, start_num: int = 1) -> list[str]:
343
343
  """
344
- Get the batch affixed IDs that are maximal in cache and contiguious for a particular datatype.datafield under a given format.
344
+ Get the batch affixed IDs that are maximal in cache and contiguous for a particular datatype.datafield under a given format.
345
345
  :param data_type_name: The datatype name to look for max ID
346
346
  :param data_field_name: The datafield name to look for max ID
347
347
  :param num_ids: The number of IDs to accession.
@@ -0,0 +1,115 @@
1
+ from enum import Enum
2
+ from typing import Iterable, Any, Collection
3
+
4
+ from sapiopycommons.general.exceptions import SapioException
5
+
6
+
7
+ class ArrayTransformation(Enum):
8
+ """
9
+ An enumeration of the different transformations that can be applied to a 2D array.
10
+ """
11
+ ROTATE_CLOCKWISE = 0
12
+ ROTATE_COUNTER_CLOCKWISE = 1
13
+ ROTATE_180_DEGREES = 2
14
+ MIRROR_HORIZONTAL = 3
15
+ MIRROR_VERTICAL = 4
16
+
17
+
18
+ # FR-47524: Create a DataStructureUtils class that implements various collection utility functions from our Java
19
+ # libraries.
20
+ class DataStructureUtil:
21
+ """
22
+ Utility class for working with data structures. Copies from ListUtil, SetUtil, and various other classes in
23
+ our Java library.
24
+ """
25
+ @staticmethod
26
+ def find_first_or_none(values: Iterable[Any]) -> Any | None:
27
+ """
28
+ Get the first value from an iterable, or None if the iterable is empty.
29
+
30
+ :param values: An iterable of values.
31
+ :return: The first value from the input, or None if the input is empty.
32
+ """
33
+ return next(iter(values), None)
34
+
35
+ @staticmethod
36
+ def remove_null_values(values: Iterable[Any]) -> list[Any]:
37
+ """
38
+ Remove null values from a list.
39
+
40
+ :param values: An iterable of values.
41
+ :return: A list containing all the non-null values from the input.
42
+ """
43
+ return [value for value in values if value is not None]
44
+
45
+ @staticmethod
46
+ def transform_2d_array(values: Collection[Collection[Any]], transformation: ArrayTransformation) \
47
+ -> Collection[Collection[Any]]:
48
+ """
49
+ Perform a transformation on a 2D list.
50
+
51
+ :param values: An iterable of iterables. The iterables should all be of the same size.
52
+ :param transformation: The transformation to apply to the input.
53
+ :return: A new 2D list containing the input transformed according to the specified transformation.
54
+ """
55
+ x: int = len(values)
56
+ for row in values:
57
+ y = len(row)
58
+ if y != x:
59
+ raise SapioException(f"Input must be a square 2D array. The provided array has a length of {x} but "
60
+ f"at least one row has a length of {y}.")
61
+
62
+ match transformation:
63
+ case ArrayTransformation.ROTATE_CLOCKWISE:
64
+ return [list(row) for row in zip(*values[::-1])]
65
+ case ArrayTransformation.ROTATE_COUNTER_CLOCKWISE:
66
+ return [list(row) for row in zip(*values)][::-1]
67
+ case ArrayTransformation.ROTATE_180_DEGREES:
68
+ return [row[::-1] for row in values[::-1]]
69
+ case ArrayTransformation.MIRROR_HORIZONTAL:
70
+ return [list(row[::-1]) for row in values]
71
+ case ArrayTransformation.MIRROR_VERTICAL:
72
+ return values[::-1]
73
+
74
+ raise SapioException(f"Invalid transformation: {transformation}")
75
+
76
+ @staticmethod
77
+ def flatten_to_list(values: Iterable[Iterable[Any]]) -> list[Any]:
78
+ """
79
+ Flatten a list of lists into a single list.
80
+
81
+ :param values: An iterable of iterables.
82
+ :return: A single list containing all the values from the input. Elements are in the order they appear in the
83
+ input.
84
+ """
85
+ return [item for sublist in values for item in sublist]
86
+
87
+ @staticmethod
88
+ def flatten_to_set(values: Iterable[Iterable[Any]]) -> set[Any]:
89
+ """
90
+ Flatten a list of lists into a single set.
91
+
92
+ :param values: An iterable of iterables.
93
+ :return: A single set containing all the values from the input. Elements are in the order they appear in the
94
+ input.
95
+ """
96
+ return {item for subset in values for item in subset}
97
+
98
+ @staticmethod
99
+ def invert_dictionary(dictionary: dict[Any, Any], list_values: bool = False) \
100
+ -> dict[Any, Any] | dict[Any, list[Any]]:
101
+ """
102
+ Invert a dictionary, swapping keys and values. Note that the values of the input dictionary must be hashable.
103
+
104
+ :param dictionary: A dictionary to invert.
105
+ :param list_values: If false, keys that share the same value in the input dictionary will be overwritten in
106
+ the output dictionary so that only the last key remains. If true, the values of the output dictionary will
107
+ be lists where input keys that share the same value will be stored together.
108
+ :return: A new dictionary with the keys and values swapped.
109
+ """
110
+ if list_values:
111
+ inverted = {}
112
+ for key, value in dictionary.items():
113
+ inverted.setdefault(value, []).append(key)
114
+ return inverted
115
+ return {value: key for key, value in dictionary.items()}