sapiopycommons 2025.5.2a504__py3-none-any.whl → 2025.5.6a510__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 (54) hide show
  1. sapiopycommons/callbacks/callback_util.py +116 -64
  2. sapiopycommons/callbacks/field_builder.py +2 -0
  3. sapiopycommons/customreport/auto_pagers.py +2 -1
  4. sapiopycommons/customreport/term_builder.py +1 -1
  5. sapiopycommons/datatype/pseudo_data_types.py +349 -326
  6. sapiopycommons/eln/experiment_cache.py +188 -0
  7. sapiopycommons/eln/experiment_handler.py +336 -719
  8. sapiopycommons/eln/experiment_step_factory.py +476 -0
  9. sapiopycommons/eln/plate_designer.py +7 -2
  10. sapiopycommons/eln/step_creation.py +236 -0
  11. sapiopycommons/files/file_util.py +4 -4
  12. sapiopycommons/general/accession_service.py +2 -2
  13. sapiopycommons/general/aliases.py +4 -1
  14. sapiopycommons/general/data_structure_util.py +115 -0
  15. sapiopycommons/general/sapio_links.py +4 -12
  16. sapiopycommons/processtracking/custom_workflow_handler.py +2 -1
  17. sapiopycommons/recordmodel/record_handler.py +357 -27
  18. sapiopycommons/rules/eln_rule_handler.py +8 -1
  19. sapiopycommons/rules/on_save_rule_handler.py +8 -1
  20. sapiopycommons/webhook/webhook_handlers.py +3 -0
  21. sapiopycommons/webhook/webservice_handlers.py +2 -2
  22. {sapiopycommons-2025.5.2a504.dist-info → sapiopycommons-2025.5.6a510.dist-info}/METADATA +2 -2
  23. sapiopycommons-2025.5.6a510.dist-info/RECORD +67 -0
  24. sapiopycommons/ai/__init__.py +0 -0
  25. sapiopycommons/ai/api/fielddefinitions/proto/fields_pb2.py +0 -43
  26. sapiopycommons/ai/api/fielddefinitions/proto/fields_pb2.pyi +0 -31
  27. sapiopycommons/ai/api/fielddefinitions/proto/fields_pb2_grpc.py +0 -24
  28. sapiopycommons/ai/api/fielddefinitions/proto/velox_field_def_pb2.py +0 -123
  29. sapiopycommons/ai/api/fielddefinitions/proto/velox_field_def_pb2.pyi +0 -598
  30. sapiopycommons/ai/api/fielddefinitions/proto/velox_field_def_pb2_grpc.py +0 -24
  31. sapiopycommons/ai/api/plan/proto/step_output_pb2.py +0 -45
  32. sapiopycommons/ai/api/plan/proto/step_output_pb2.pyi +0 -42
  33. sapiopycommons/ai/api/plan/proto/step_output_pb2_grpc.py +0 -24
  34. sapiopycommons/ai/api/plan/proto/step_pb2.py +0 -43
  35. sapiopycommons/ai/api/plan/proto/step_pb2.pyi +0 -43
  36. sapiopycommons/ai/api/plan/proto/step_pb2_grpc.py +0 -24
  37. sapiopycommons/ai/api/plan/script/proto/script_pb2.py +0 -53
  38. sapiopycommons/ai/api/plan/script/proto/script_pb2.pyi +0 -99
  39. sapiopycommons/ai/api/plan/script/proto/script_pb2_grpc.py +0 -153
  40. sapiopycommons/ai/api/plan/tool/proto/entry_pb2.py +0 -57
  41. sapiopycommons/ai/api/plan/tool/proto/entry_pb2.pyi +0 -96
  42. sapiopycommons/ai/api/plan/tool/proto/entry_pb2_grpc.py +0 -24
  43. sapiopycommons/ai/api/plan/tool/proto/tool_pb2.py +0 -67
  44. sapiopycommons/ai/api/plan/tool/proto/tool_pb2.pyi +0 -220
  45. sapiopycommons/ai/api/plan/tool/proto/tool_pb2_grpc.py +0 -154
  46. sapiopycommons/ai/api/session/proto/sapio_conn_info_pb2.py +0 -39
  47. sapiopycommons/ai/api/session/proto/sapio_conn_info_pb2.pyi +0 -32
  48. sapiopycommons/ai/api/session/proto/sapio_conn_info_pb2_grpc.py +0 -24
  49. sapiopycommons/ai/protobuf_utils.py +0 -454
  50. sapiopycommons/ai/tool_service_base.py +0 -708
  51. sapiopycommons/general/html_formatter.py +0 -456
  52. sapiopycommons-2025.5.2a504.dist-info/RECORD +0 -91
  53. {sapiopycommons-2025.5.2a504.dist-info → sapiopycommons-2025.5.6a510.dist-info}/WHEEL +0 -0
  54. {sapiopycommons-2025.5.2a504.dist-info → sapiopycommons-2025.5.6a510.dist-info}/licenses/LICENSE +0 -0
@@ -5,58 +5,46 @@ from collections.abc import Mapping, Iterable
5
5
  from typing import TypeAlias
6
6
  from weakref import WeakValueDictionary
7
7
 
8
+ from sapiopycommons.eln.experiment_cache import ExperimentCacheManager
9
+ from sapiopycommons.eln.experiment_report_util import ExperimentReportUtil
10
+ from sapiopycommons.general.aliases import AliasUtil, SapioRecord, ExperimentIdentifier, UserIdentifier, \
11
+ DataTypeIdentifier, RecordModel, ExperimentEntryIdentifier
12
+ from sapiopycommons.general.exceptions import SapioException
13
+ from sapiopycommons.general.time_util import TimeUtil
14
+ from sapiopycommons.recordmodel.record_handler import RecordHandler
8
15
  from sapiopylib.rest.DataMgmtService import DataMgmtServer
9
- from sapiopylib.rest.DataRecordManagerService import DataRecordManager
10
16
  from sapiopylib.rest.ELNService import ElnManager
11
17
  from sapiopylib.rest.User import SapioUser
12
18
  from sapiopylib.rest.pojo.DataRecord import DataRecord
13
- from sapiopylib.rest.pojo.TableColumn import TableColumn
14
- from sapiopylib.rest.pojo.datatype.FieldDefinition import AbstractVeloxFieldDefinition
15
19
  from sapiopylib.rest.pojo.eln.ElnEntryPosition import ElnEntryPosition
16
20
  from sapiopylib.rest.pojo.eln.ElnExperiment import ElnExperiment, TemplateExperimentQueryPojo, ElnTemplate, \
17
21
  InitializeNotebookExperimentPojo, ElnExperimentUpdateCriteria
18
22
  from sapiopylib.rest.pojo.eln.ExperimentEntry import ExperimentEntry, ExperimentTableEntry, ExperimentFormEntry, \
19
23
  ExperimentAttachmentEntry, ExperimentPluginEntry, ExperimentDashboardEntry, ExperimentTextEntry, \
20
- ExperimentTempDataEntry, EntryAttachment, EntryRecordAttachment
24
+ ExperimentTempDataEntry
21
25
  from sapiopylib.rest.pojo.eln.ExperimentEntryCriteria import AbstractElnEntryUpdateCriteria, \
22
26
  ElnTableEntryUpdateCriteria, ElnFormEntryUpdateCriteria, ElnAttachmentEntryUpdateCriteria, \
23
27
  ElnPluginEntryUpdateCriteria, ElnDashboardEntryUpdateCriteria, ElnTextEntryUpdateCriteria, \
24
- ElnTempDataEntryUpdateCriteria, ElnEntryCriteria
28
+ ElnTempDataEntryUpdateCriteria
25
29
  from sapiopylib.rest.pojo.eln.SapioELNEnums import ExperimentEntryStatus, ElnExperimentStatus, ElnEntryType, \
26
30
  ElnBaseDataType
27
31
  from sapiopylib.rest.pojo.eln.eln_headings import ElnExperimentTab, ElnExperimentTabAddCriteria
28
- from sapiopylib.rest.pojo.eln.field_set import ElnFieldSetInfo
32
+ from sapiopylib.rest.pojo.eln.protocol_template import ProtocolTemplateInfo
29
33
  from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
30
34
  from sapiopylib.rest.pojo.webhook.WebhookDirective import ElnExperimentDirective
31
35
  from sapiopylib.rest.pojo.webhook.WebhookResult import SapioWebhookResult
32
36
  from sapiopylib.rest.utils.Protocols import ElnEntryStep, ElnExperimentProtocol
33
- from sapiopylib.rest.utils.plates.MultiLayerPlating import MultiLayerPlateConfig, MultiLayerPlateLayer, \
34
- MultiLayerDataTypeConfig, MultiLayerReplicateConfig, MultiLayerDilutionConfig
35
- from sapiopylib.rest.utils.plates.MultiLayerPlatingUtils import MultiLayerPlatingManager
36
- from sapiopylib.rest.utils.plates.PlatingUtils import PlatingOrder
37
37
  from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel
38
38
  from sapiopylib.rest.utils.recordmodel.RecordModelManager import RecordModelInstanceManager, RecordModelManager
39
39
  from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType
40
40
  from sapiopylib.rest.utils.recordmodel.properties import Child
41
41
 
42
- from sapiopycommons.datatype.data_fields import SystemFields
43
- from sapiopycommons.eln.experiment_report_util import ExperimentReportUtil
44
- from sapiopycommons.eln.experiment_tags import PLATE_DESIGNER_PLUGIN
45
- from sapiopycommons.general.aliases import AliasUtil, SapioRecord, ExperimentIdentifier, UserIdentifier, \
46
- DataTypeIdentifier, RecordModel, FieldMap, FieldIdentifier
47
- from sapiopycommons.general.exceptions import SapioException
48
- from sapiopycommons.general.time_util import TimeUtil
49
- from sapiopycommons.recordmodel.record_handler import RecordHandler
50
-
51
- Step: TypeAlias = str | ElnEntryStep
52
- """An object representing an identifier to an ElnEntryStep. May be either the name of the step or the ElnEntryStep
53
- 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
- ElnExperimentTab itself."""
57
- ElnDataTypeFields: TypeAlias = AbstractVeloxFieldDefinition | ElnFieldSetInfo | str | int
58
- """An object representing an identifier to an ElnDataType field. These can be field definitions for ad hoc fields,
59
- predefined field set info objects, predefined field names, or integers for predefined field set IDs."""
42
+ Step: TypeAlias = str | ExperimentEntryIdentifier
43
+ """An object representing an identifier to an entry in a particular experiment. This may be the name of the experiment,
44
+ or a typical experiment entry identifier."""
45
+ Tab: TypeAlias = int | str | ElnExperimentTab
46
+ """An object representing an identifier to a tab in a particular experiment. This may be the tab's order, its name,
47
+ or the tab object itself."""
60
48
 
61
49
 
62
50
  # FR-46064 - Initial port of PyWebhookUtils to sapiopycommons.
@@ -77,6 +65,8 @@ class ExperimentHandler:
77
65
  # Managers.
78
66
  _eln_man: ElnManager
79
67
  """The ELN manager. Used for updating the experiment and its steps."""
68
+ _exp_cache: ExperimentCacheManager
69
+ """The experiment cache manager. Used for caching experiment-related information."""
80
70
  _inst_man: RecordModelInstanceManager
81
71
  """The record model instance manager. Used for wrapping the data records of a step as record models."""
82
72
  _rec_handler: RecordHandler
@@ -95,7 +85,9 @@ class ExperimentHandler:
95
85
 
96
86
  _queried_all_steps: bool
97
87
  """Whether this ExperimentHandler has queried the system for all steps in the experiment."""
98
- _steps: dict[str, ElnEntryStep]
88
+ _steps: list[ElnEntryStep]
89
+ """The sorted list of steps for this experiment. All steps are cached the first time any individual step is accessed."""
90
+ _steps_by_name: dict[str, ElnEntryStep]
99
91
  """Steps from this experiment by their name. All steps are cached the first time any individual step is accessed."""
100
92
  _steps_by_id: dict[int, ElnEntryStep]
101
93
  """Steps from this experiment by their ID. All steps are cached the first time any individual step is accessed."""
@@ -109,13 +101,12 @@ class ExperimentHandler:
109
101
  _queried_all_tabs: bool
110
102
  """Whether this ExperimentHandler has queried the system for all tabs in the experiment."""
111
103
  _tabs: list[ElnExperimentTab]
112
- """The tabs for this experiment. Only cached when first accessed."""
104
+ """The sorted tabs for this experiment. Only cached when first accessed."""
105
+ _tabs_by_id: dict[int, ElnExperimentTab]
106
+ """The tabs for this experiment by their ID. Only cached when first accessed."""
113
107
  _tabs_by_name: dict[str, ElnExperimentTab]
114
108
  """The tabs for this experiment by their name. Only cached when first accessed."""
115
109
 
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
110
  # Constants
120
111
  _ENTRY_COMPLETE_STATUSES = [ExperimentEntryStatus.Completed, ExperimentEntryStatus.CompletedApproved]
121
112
  """The set of statuses that an ELN entry could have and be considered completed/submitted."""
@@ -173,17 +164,20 @@ class ExperimentHandler:
173
164
 
174
165
  # Grab various managers that may be used.
175
166
  self._eln_man = DataMgmtServer.get_eln_manager(self.user)
167
+ self._exp_cache = ExperimentCacheManager(self.user)
176
168
  self._inst_man = RecordModelManager(self.user).instance_manager
177
169
  self._rec_handler = RecordHandler(self.user)
178
170
 
179
171
  # Create empty caches to fill when necessary.
180
172
  self._queried_all_steps = False
181
- self._steps = {}
173
+ self._steps_by_name = {}
182
174
  self._steps_by_id = {}
175
+ self._steps = []
183
176
  self._step_options = {}
184
177
  self._step_updates = {}
185
178
 
186
179
  self._tabs = []
180
+ self._tabs_by_id = {}
187
181
  self._tabs_by_name = {}
188
182
 
189
183
  self._queried_all_tabs = False
@@ -199,7 +193,8 @@ class ExperimentHandler:
199
193
  for entry in self.context.experiment_entry_list:
200
194
  cache_steps.append(ElnEntryStep(self._protocol, entry))
201
195
  for step in cache_steps:
202
- self._steps.update({step.get_name(): step})
196
+ self._steps.append(step)
197
+ self._steps_by_name.update({step.get_name(): step})
203
198
  self._steps_by_id.update({step.get_id(): step})
204
199
 
205
200
  @staticmethod
@@ -264,6 +259,7 @@ class ExperimentHandler:
264
259
  """
265
260
  self._queried_all_steps = False
266
261
  self._steps.clear()
262
+ self._steps_by_name.clear()
267
263
  self._steps_by_id.clear()
268
264
  self._step_options.clear()
269
265
  self._step_updates.clear()
@@ -282,32 +278,39 @@ class ExperimentHandler:
282
278
  """
283
279
  self._queried_all_tabs = False
284
280
  self._tabs.clear()
281
+ self._tabs_by_id.clear()
285
282
  self._tabs_by_name.clear()
286
283
 
287
- def add_entry_to_caches(self, entry: ExperimentEntry | ElnEntryStep) -> None:
284
+ def add_entry_to_caches(self, entry: ExperimentEntry | ElnEntryStep) -> ElnEntryStep:
288
285
  """
289
286
  Add the given entry to the cache of steps for this experiment. This is necessary in order for certain methods to
290
287
  work. You should only need to do this if you have created a new entry in your code using a method outside
291
288
  of this ExperimentHandler.
292
289
 
293
290
  :param entry: The entry to add to the cache.
291
+ :return: The entry that was added to the cache as an ElnEntryStep.
294
292
  """
295
293
  if isinstance(entry, ExperimentEntry):
296
294
  entry = ElnEntryStep(self._protocol, entry)
297
- self._steps.update({entry.get_name(): entry})
295
+ self._steps.append(entry)
296
+ self._steps_by_name.update({entry.get_name(): entry})
298
297
  self._steps_by_id.update({entry.get_id(): entry})
299
298
  # Skipping the options cache. The get_step_options method will update the cache when necessary.
299
+ return entry
300
300
 
301
- def add_entries_to_caches(self, entries: list[ExperimentEntry | ElnEntryStep]) -> None:
301
+ def add_entries_to_caches(self, entries: list[ExperimentEntry | ElnEntryStep]) -> list[ElnEntryStep]:
302
302
  """
303
303
  Add the given entries to the cache of steps for this experiment. This is necessary in order for certain methods
304
304
  to work. You should only need to do this if you have created a new entry in your code using a method outside
305
305
  of this ExperimentHandler.
306
306
 
307
307
  :param entries: The entries to add to the cache.
308
+ :return: The entries that were added to the cache as ElnEntrySteps.
308
309
  """
310
+ new_entries: list[ElnEntryStep] = []
309
311
  for entry in entries:
310
- self.add_entry_to_caches(entry)
312
+ new_entries.append(self.add_entry_to_caches(entry))
313
+ return new_entries
311
314
 
312
315
  def add_tab_to_cache(self, tab: ElnExperimentTab) -> None:
313
316
  """
@@ -319,6 +322,7 @@ class ExperimentHandler:
319
322
  """
320
323
  self._tabs.append(tab)
321
324
  self._tabs.sort(key=lambda t: t.tab_order)
325
+ self._tabs_by_id[tab.tab_id] = tab
322
326
  self._tabs_by_name[tab.tab_name] = tab
323
327
 
324
328
  # FR-46495: Split the creation of the experiment in launch_experiment into a create_experiment function.
@@ -348,22 +352,10 @@ class ExperimentHandler:
348
352
  :param active_templates_only: Whether only active templates should be queried for.
349
353
  :return: The newly created experiment.
350
354
  """
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
-
355
+ launch_template: ElnTemplate = ExperimentCacheManager(context).get_experiment_template(template_name,
356
+ active_templates_only,
357
+ template_version,
358
+ first_match=True)
367
359
  if experiment_name is None:
368
360
  experiment_name: str = launch_template.display_name
369
361
  if parent_record is not None:
@@ -590,6 +582,9 @@ class ExperimentHandler:
590
582
  """
591
583
  Set the experiment's status to Completed. Makes a webservice call to update the experiment. Checks if the
592
584
  experiment is already completed, and does nothing if so.
585
+
586
+ NOTE: This will cause the usual process tracking logic to run as if you'd clicked the "Complete Experiment"
587
+ toolbar button. This includes moving the in process samples forward to the next step in the process.
593
588
  """
594
589
  if not self.experiment_is_complete():
595
590
  self._protocol.complete_protocol()
@@ -600,9 +595,11 @@ class ExperimentHandler:
600
595
  Set the experiment's status to Canceled. Makes a webservice call to update the experiment. Checks if the
601
596
  experiment is already canceled, and does nothing if so.
602
597
 
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.
598
+ NOTE: This will cause the usual process tracking logic to run as if you'd clicked the "Cancel Experiment"
599
+ toolbar button. This includes moving the in process samples back into the process queue for the current step.
600
+
601
+ On version 24.12 and earlier, this was not the case, as the process tracking logic was tied to the button
602
+ instead of being on the experiment status change.
606
603
  """
607
604
  if not self.experiment_is_canceled():
608
605
  self._protocol.cancel_protocol()
@@ -632,14 +629,14 @@ class ExperimentHandler:
632
629
  """
633
630
  return all([x is not None for x in self.get_steps(step_names, False)])
634
631
 
635
- def get_step(self, step_name: str, exception_on_none: bool = True) -> ElnEntryStep | None:
632
+ def get_step(self, step_name: str | int, exception_on_none: bool = True) -> ElnEntryStep | None:
636
633
  """
637
634
  Get the step of the given name from the experiment.
638
635
 
639
636
  If no step functions have been called before and a step is being searched for by name, queries for the
640
637
  list of steps in the experiment and caches them.
641
638
 
642
- :param step_name: The name for the step to return.
639
+ :param step_name: The name or ID for the step to return.
643
640
  :param exception_on_none: If false, returns None if the entry can't be found. If true, raises an exception
644
641
  when the named entry doesn't exist in the experiment.
645
642
  :return: An ElnEntrySteps matching the provided name. If there is no match and no exception is to be thrown,
@@ -647,31 +644,32 @@ class ExperimentHandler:
647
644
  """
648
645
  return self.get_steps([step_name], exception_on_none)[0]
649
646
 
650
- def get_steps(self, step_names: Iterable[str], exception_on_none: bool = True) -> list[ElnEntryStep | None]:
647
+ def get_steps(self, step_names: Iterable[str | int], exception_on_none: bool = True) -> list[ElnEntryStep | None]:
651
648
  """
652
649
  Get a list of steps of the given names from the experiment, sorted in the same order as the names are provided.
653
650
 
654
651
  If no step functions have been called before and a step is being searched for by name, queries for the
655
652
  list of steps in the experiment and caches them.
656
653
 
657
- :param step_names: A list of names for the entries to return and the order to return them in.
654
+ :param step_names: A list of names or IDs for the entries to return and the order to return them in.
658
655
  :param exception_on_none: If false, returns None for entries that can't be found. If true, raises an exception
659
656
  when the named entry doesn't exist in the experiment.
660
657
  :return: A list of ElnEntrySteps matching the provided names in the order they were provided in. If there is no
661
658
  match for a given step and no exception is to be thrown, returns None for that step.
662
659
  """
663
660
  ret_list: list[ElnEntryStep | None] = []
664
- for name in step_names:
661
+ for step_id in step_names:
665
662
  # If we haven't queried the system for all steps in the experiment yet, then the reason that a step is
666
663
  # missing may be because it wasn't in the webhook context. Therefore, query all steps and then check
667
664
  # if the step name is still missing from the experiment before potentially throwing an exception.
668
- if self._queried_all_steps is False and name not in self._steps:
669
- self._queried_all_steps = True
670
- self._steps.update({step.get_name(): step for step in self._protocol.get_sorted_step_list()})
671
-
672
- step: ElnEntryStep = self._steps.get(name)
665
+ if self._queried_all_steps is False and step_id not in self._steps_by_name and step_id not in self._steps_by_id:
666
+ self._query_all_steps()
667
+ if isinstance(step_id, str):
668
+ step: ElnEntryStep = self._steps_by_name.get(step_id)
669
+ else:
670
+ step: ElnEntryStep = self._steps_by_id.get(step_id)
673
671
  if step is None and exception_on_none is True:
674
- raise SapioException(f"ElnEntryStep of name \"{name}\" not found in experiment with ID {self._exp_id}.")
672
+ raise SapioException(f"ElnEntryStep of name \"{step_id}\" not found in experiment with ID {self._exp_id}.")
675
673
  ret_list.append(step)
676
674
  return ret_list
677
675
 
@@ -686,14 +684,34 @@ class ExperimentHandler:
686
684
  :return: Every entry in the experiment in order of appearance that match the provided data type, if any.
687
685
  """
688
686
  if self._queried_all_steps is False:
689
- self._queried_all_steps = True
690
- self._steps.update({step.get_name(): step for step in self._protocol.get_sorted_step_list()})
691
- all_steps: list[ElnEntryStep] = self._protocol.get_sorted_step_list()
687
+ self._query_all_steps()
688
+ else:
689
+ # Re-sort the steps in case any new steps were added before the last time that this was called.
690
+ def sort_steps(step: ElnEntryStep) -> tuple:
691
+ entry = step.eln_entry
692
+ tab_order: int = self.get_tab_for_step(step).tab_order
693
+ entry_order: int = entry.order
694
+ column_order: int = entry.column_order
695
+ return tab_order, entry_order, column_order
696
+
697
+ self._steps.sort(key=sort_steps)
698
+ all_steps: list[ElnEntryStep] = self._steps
692
699
  if data_type is None:
693
700
  return all_steps
694
701
  data_type: str = AliasUtil.to_data_type_name(data_type)
695
702
  return [x for x in all_steps if data_type in x.get_data_type_names()]
696
703
 
704
+ def _query_all_steps(self) -> None:
705
+ """
706
+ Query the system for every step in the experiment and cache them.
707
+ """
708
+ self._queried_all_steps = True
709
+ self._protocol.invalidate()
710
+ self._steps = self._protocol.get_sorted_step_list()
711
+ for step in self._steps:
712
+ self._steps_by_name[step.get_name()] = step
713
+ self._steps_by_id[step.get_id()] = step
714
+
697
715
  def get_step_by_option(self, key: str, value: str | None = None) -> ElnEntryStep:
698
716
  """
699
717
  Retrieve the step in this experiment that contains an entry option with the provided key and value.
@@ -782,8 +800,9 @@ class ExperimentHandler:
782
800
  return
783
801
  dt: str = AliasUtil.to_singular_data_type_name(records)
784
802
  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.")
803
+ raise SapioException(f"{dt} is an ELN data type. This function call has no effect on ELN data types. ELN "
804
+ f"records that are committed to the system will automatically appear in the ELN entry "
805
+ f"with the matching data type name.")
787
806
  if dt != step.get_data_type_names()[0]:
788
807
  raise SapioException(f"Cannot add {dt} records to entry {step.get_name()} of type "
789
808
  f"{step.get_data_type_names()[0]}.")
@@ -809,8 +828,9 @@ class ExperimentHandler:
809
828
  return
810
829
  dt: str = AliasUtil.to_singular_data_type_name(records)
811
830
  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.")
831
+ # CR-47532: Add remove_step_records support for Experiment Detail and Sample Detail entries.
832
+ self.remove_eln_rows(step, records)
833
+ return
814
834
  if dt != step.get_data_type_names()[0]:
815
835
  raise SapioException(f"Cannot remove {dt} records from entry {step.get_name()} of type "
816
836
  f"{step.get_data_type_names()[0]}.")
@@ -839,9 +859,14 @@ class ExperimentHandler:
839
859
  step = self.__to_eln_step(step)
840
860
  if records:
841
861
  dt: str = AliasUtil.to_singular_data_type_name(records)
862
+ # CR-47532: Add set_step_records support for Experiment Detail and Sample Detail entries.
842
863
  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.")
864
+ remove_rows: list[PyRecordModel] = []
865
+ for record in self.get_step_models(step):
866
+ if record not in records:
867
+ remove_rows.append(record)
868
+ self.remove_eln_rows(step, remove_rows)
869
+ return
845
870
  if dt != step.get_data_type_names()[0]:
846
871
  raise SapioException(f"Cannot set {dt} records for entry {step.get_name()} of type "
847
872
  f"{step.get_data_type_names()[0]}.")
@@ -868,6 +893,23 @@ class ExperimentHandler:
868
893
  step.eln_entry.record_id = AliasUtil.to_data_record(record).record_id
869
894
 
870
895
  # FR-46496 - Provide functions for adding and removing rows from an ELN data type entry.
896
+ def add_eln_row(self, step: Step, wrapper_type: type[WrappedType] | None = None) -> WrappedType | PyRecordModel:
897
+ """
898
+ Add a row to an ELNExperimentDetail or ELNSampleDetail table entry. The row will not appear in the system
899
+ until a record manager store and commit has occurred.
900
+
901
+ If no step functions have been called before and a step is being searched for by name, queries for the
902
+ list of steps in the experiment and caches them.
903
+
904
+ :param step:
905
+ The step may be provided as either a string for the name of the step or an ElnEntryStep.
906
+ If given a name, throws an exception if no step of the given name exists in the experiment.
907
+ :param wrapper_type: Optionally wrap the ELN data type in a record model wrapper. If not provided, returns
908
+ an unwrapped PyRecordModel.
909
+ :return: The newly created row.
910
+ """
911
+ return self.add_eln_rows(step, 1, wrapper_type)[0]
912
+
871
913
  def add_eln_rows(self, step: Step, count: int, wrapper_type: type[WrappedType] | None = None) \
872
914
  -> list[WrappedType] | list[PyRecordModel]:
873
915
  """
@@ -896,10 +938,64 @@ class ExperimentHandler:
896
938
  return self._inst_man.wrap_list(records, wrapper_type)
897
939
  return records
898
940
 
899
- def add_eln_row(self, step: Step, wrapper_type: type[WrappedType] | None = None) -> WrappedType | PyRecordModel:
941
+ def add_sample_detail(self, step: Step, sample: RecordModel,
942
+ wrapper_type: type[WrappedType] | None = None) \
943
+ -> WrappedType | PyRecordModel:
900
944
  """
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.
945
+ Add a sample detail to a sample detail entry while relating it to the input sample record.
946
+
947
+ :param step:
948
+ The step may be provided as either a string for the name of the step or an ElnEntryStep.
949
+ If given a name, throws an exception if no step of the given name exists in the experiment.
950
+ :param sample: The sample record to add the sample detail to.
951
+ :param wrapper_type: Optionally wrap the sample detail in a record model wrapper. If not provided, returns
952
+ an unwrapped PyRecordModel.
953
+ :return: The newly created sample detail.
954
+ """
955
+ return self.add_sample_details(step, [sample], wrapper_type)[0]
956
+
957
+ def add_sample_details(self, step: Step, samples: Iterable[RecordModel],
958
+ wrapper_type: type[WrappedType] | None = None) \
959
+ -> list[WrappedType] | list[PyRecordModel]:
960
+ """
961
+ Add sample details to a sample details entry while relating them to the input sample records.
962
+
963
+ :param step:
964
+ The step may be provided as either a string for the name of the step or an ElnEntryStep.
965
+ If given a name, throws an exception if no step of the given name exists in the experiment.
966
+ :param samples: The sample records to add the sample details to.
967
+ :param wrapper_type: Optionally wrap the sample details in a record model wrapper. If not provided, returns
968
+ an unwrapped PyRecordModel.
969
+ :return: The newly created sample details. The indices of the samples in the input list match the index of the
970
+ sample details in this list that they are related to.
971
+ """
972
+ step = self.__to_eln_step(step)
973
+ if step.eln_entry.entry_type != ElnEntryType.Table:
974
+ raise SapioException("The provided step is not a table entry.")
975
+ dt: str = step.get_data_type_names()[0]
976
+ if not ElnBaseDataType.is_eln_type(dt) or ElnBaseDataType.get_base_type(dt) != ElnBaseDataType.SAMPLE_DETAIL:
977
+ raise SapioException("The provided step is not an ELNSampleDetail entry.")
978
+ records: list[PyRecordModel] = []
979
+ for sample in samples:
980
+ if sample.data_type_name != "Sample":
981
+ raise SapioException(f"Received a {sample.data_type_name} record when Sample records were expected.")
982
+ detail: PyRecordModel = sample.add(Child.create_by_name(dt))
983
+ detail.set_field_values({
984
+ "SampleId": sample.get_field_value("SampleId"),
985
+ "OtherSampleId": sample.get_field_value("OtherSampleId")
986
+ })
987
+ records.append(detail)
988
+ if wrapper_type:
989
+ return self._inst_man.wrap_list(records, wrapper_type)
990
+ return records
991
+
992
+ def remove_eln_row(self, step: Step, record: SapioRecord) -> None:
993
+ """
994
+ Remove a row from an ELNExperimentDetail or ELNSampleDetail table entry. ELN data type table entries display all
995
+ records in the system that match the entry's data type. This means that removing rows from an ELN data type
996
+ table entry is equivalent to deleting the records for the rows.
997
+
998
+ The row will not be deleted in the system until a record manager store and commit has occurred.
903
999
 
904
1000
  If no step functions have been called before and a step is being searched for by name, queries for the
905
1001
  list of steps in the experiment and caches them.
@@ -907,13 +1003,13 @@ class ExperimentHandler:
907
1003
  :param step:
908
1004
  The step may be provided as either a string for the name of the step or an ElnEntryStep.
909
1005
  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.
1006
+ :param record:
1007
+ The record to remove from the given step.
1008
+ The record may be provided as either a DataRecord, PyRecordModel, or WrappedRecordModel.
913
1009
  """
914
- return self.add_eln_rows(step, 1, wrapper_type)[0]
1010
+ self.remove_eln_rows(step, [record])
915
1011
 
916
- def remove_eln_rows(self, step: Step, records: list[SapioRecord]) -> None:
1012
+ def remove_eln_rows(self, step: Step, records: Iterable[SapioRecord]) -> None:
917
1013
  """
918
1014
  Remove rows from an ELNExperimentDetail or ELNSampleDetail table entry. ELN data type table entries display all
919
1015
  records in the system that match the entry's data type. This means that removing rows from an ELN data type
@@ -954,61 +1050,6 @@ class ExperimentHandler:
954
1050
  for record in record_models:
955
1051
  record.delete()
956
1052
 
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
1053
  # noinspection PyPep8Naming
1013
1054
  def update_step(self, step: Step,
1014
1055
  entry_name: str | None = None,
@@ -1076,128 +1117,45 @@ class ExperimentHandler:
1076
1117
  If you wish to add options to the existing map of options that an entry has, use the
1077
1118
  add_step_options method.
1078
1119
  """
1079
- # FR-47468: Deprecating this since the parameters are ordered. The new method requires keyword parameters, so
1080
- # that we can add new parameters wherever we want without breaking existing code.
1081
- warnings.warn("Update step is deprecated. Use force_entry_update_params instead.",
1120
+ # FR-47468: Deprecating this since the entry-specific update criteria should be used instead.
1121
+ warnings.warn("Update step is deprecated. Use force_entry_update instead.",
1082
1122
  DeprecationWarning)
1083
- self.force_step_update_params(step,
1084
- entry_name=entry_name,
1085
- related_entry_set=related_entry_set,
1086
- dependency_set=dependency_set,
1087
- entry_status=entry_status,
1088
- order=order,
1089
- description=description,
1090
- requires_grabber_plugin=requires_grabber_plugin,
1091
- is_initialization_required=is_initialization_required,
1092
- notebook_experiment_tab_id=notebook_experiment_tab_id,
1093
- entry_height=entry_height,
1094
- column_order=column_order,
1095
- column_span=column_span,
1096
- is_removable=is_removable,
1097
- is_renamable=is_renamable,
1098
- source_entry_id=source_entry_id,
1099
- clear_source_entry_id=clear_source_entry_id,
1100
- is_hidden=is_hidden,
1101
- is_static_view=is_static_View,
1102
- is_shown_in_template=is_shown_in_template,
1103
- template_item_fulfilled_timestamp=template_item_fulfilled_timestamp,
1104
- clear_template_item_fulfilled_timestamp=clear_template_item_fulfilled_timestamp,
1105
- entry_options_map=entry_options_map)
1106
-
1107
- # FR-47468: Some functions that can help with entry updates.
1108
- def force_step_update_params(self, step: Step, *,
1109
- entry_name: str | None = None,
1110
- related_entry_set: Iterable[int] | None = None,
1111
- dependency_set: Iterable[int] | None = None,
1112
- entry_status: ExperimentEntryStatus | None = None,
1113
- order: int | None = None,
1114
- description: str | None = None,
1115
- requires_grabber_plugin: bool | None = None,
1116
- is_initialization_required: bool | None = None,
1117
- notebook_experiment_tab_id: int | None = None,
1118
- entry_height: int | None = None,
1119
- column_order: int | None = None,
1120
- column_span: int | None = None,
1121
- is_removable: bool | None = None,
1122
- is_renamable: bool | None = None,
1123
- source_entry_id: int | None = None,
1124
- clear_source_entry_id: bool | None = None,
1125
- is_hidden: bool | None = None,
1126
- is_static_view: bool | None = None,
1127
- is_shown_in_template: bool | None = None,
1128
- template_item_fulfilled_timestamp: int | None = None,
1129
- clear_template_item_fulfilled_timestamp: bool | None = None,
1130
- entry_options_map: dict[str, str] | None = None) -> None:
1131
- """
1132
- Immediately sent an update to an entry in this experiment. All changes will be reflected by the ExperimentEntry
1133
- of the Step that is being updated.
1123
+ step: ElnEntryStep = self.__to_eln_step(step)
1124
+ update = AbstractElnEntryUpdateCriteria(step.eln_entry.entry_type)
1134
1125
 
1135
- Consider using store_step_update and commit_step_updates instead if the update does not need to be immediate.
1126
+ # These two variables could be iterables that aren't lists. Convert them to plain
1127
+ # lists, since that's what the update criteria is expecting.
1128
+ if related_entry_set is not None:
1129
+ related_entry_set = list(related_entry_set)
1130
+ if dependency_set is not None:
1131
+ dependency_set = list(dependency_set)
1136
1132
 
1137
- If no step functions have been called before and a step is being searched for by name, queries for the
1138
- list of steps in the experiment and caches them.
1133
+ update.entry_name = entry_name
1134
+ update.related_entry_set = related_entry_set
1135
+ update.dependency_set = dependency_set
1136
+ update.entry_status = entry_status
1137
+ update.order = order
1138
+ update.description = description
1139
+ update.requires_grabber_plugin = requires_grabber_plugin
1140
+ update.is_initialization_required = is_initialization_required
1141
+ update.notebook_experiment_tab_id = notebook_experiment_tab_id
1142
+ update.entry_height = entry_height
1143
+ update.column_order = column_order
1144
+ update.column_span = column_span
1145
+ update.is_removable = is_removable
1146
+ update.is_renamable = is_renamable
1147
+ update.source_entry_id = source_entry_id
1148
+ update.clear_source_entry_id = clear_source_entry_id
1149
+ update.is_hidden = is_hidden
1150
+ update.is_static_View = is_static_View
1151
+ update.is_shown_in_template = is_shown_in_template
1152
+ update.template_item_fulfilled_timestamp = template_item_fulfilled_timestamp
1153
+ update.clear_template_item_fulfilled_timestamp = clear_template_item_fulfilled_timestamp
1154
+ update.entry_options_map = entry_options_map
1139
1155
 
1140
- :param step:
1141
- The entry step to update.
1142
- The step may be provided as either a string for the name of the step or an ElnEntryStep.
1143
- If given a name, throws an exception if no step of the given name exists in the experiment.
1144
- :param entry_name: The new name of this entry.
1145
- :param related_entry_set: The new set of entry IDs for the entries that are related (implicitly dependent) to
1146
- this entry. Completely overwrites the existing related entries.
1147
- :param dependency_set: The new set of entry IDs for the entries that are dependent (explicitly dependent) on
1148
- this entry. Completely overwrites the existing dependent entries.
1149
- :param entry_status: The new status of this entry.
1150
- :param order: The row order of this entry in its tab.
1151
- :param description: The new description of this entry.
1152
- :param requires_grabber_plugin: Whether this entry's initialization is handled by a grabber plugin. If true,
1153
- then is_initialization_required is forced to true by the server.
1154
- :param is_initialization_required: Whether the user is required to manually initialize this entry.
1155
- :param notebook_experiment_tab_id: The ID of the tab that this entry should appear on.
1156
- :param entry_height: The height of this entry.
1157
- :param column_order: The column order of this entry.
1158
- :param column_span: How many columns this entry spans.
1159
- :param is_removable: Whether this entry can be removed by the user.
1160
- :param is_renamable: Whether this entry can be renamed by the user.
1161
- :param source_entry_id: The ID of this entry from its template.
1162
- :param clear_source_entry_id: True if the source entry ID should be cleared.
1163
- :param is_hidden: Whether this entry is hidden from the user.
1164
- :param is_static_view: Whether this entry is static. Static entries are uneditable and shared across all
1165
- experiments of the same template.
1166
- :param is_shown_in_template: Whether this entry is saved to and shown in the experiment's template.
1167
- :param template_item_fulfilled_timestamp: A timestamp in milliseconds for when this entry was initialized.
1168
- :param clear_template_item_fulfilled_timestamp: True if the template item fulfilled timestamp should be cleared,
1169
- uninitializing the entry.
1170
- :param entry_options_map:
1171
- The new map of options for this entry. Completely overwrites the existing options map.
1172
- Any changes to the entry options will update this ExperimentHandler's cache of entry options.
1173
- If you wish to add options to the existing map of options that an entry has, use the
1174
- add_step_options method.
1175
- """
1176
- update = self._criteria_from_params(step,
1177
- entry_name=entry_name,
1178
- related_entry_set=related_entry_set,
1179
- dependency_set=dependency_set,
1180
- entry_status=entry_status,
1181
- order=order,
1182
- description=description,
1183
- requires_grabber_plugin=requires_grabber_plugin,
1184
- is_initialization_required=is_initialization_required,
1185
- notebook_experiment_tab_id=notebook_experiment_tab_id,
1186
- entry_height=entry_height,
1187
- column_order=column_order,
1188
- column_span=column_span,
1189
- is_removable=is_removable,
1190
- is_renamable=is_renamable,
1191
- source_entry_id=source_entry_id,
1192
- clear_source_entry_id=clear_source_entry_id,
1193
- is_hidden=is_hidden,
1194
- is_static_view=is_static_view,
1195
- is_shown_in_template=is_shown_in_template,
1196
- template_item_fulfilled_timestamp=template_item_fulfilled_timestamp,
1197
- clear_template_item_fulfilled_timestamp=clear_template_item_fulfilled_timestamp,
1198
- entry_options_map=entry_options_map)
1199
1156
  self.force_step_update(step, update)
1200
1157
 
1158
+ # FR-47468: Some functions that can help with entry updates.
1201
1159
  def force_step_update(self, step: Step, update: AbstractElnEntryUpdateCriteria) -> None:
1202
1160
  """
1203
1161
  Immediately sent an update to an entry in this experiment. All changes will be reflected by the ExperimentEntry
@@ -1269,67 +1227,6 @@ class ExperimentHandler:
1269
1227
  self._update_entry_details(self._steps_by_id[step_id], criteria)
1270
1228
  self._step_updates.clear()
1271
1229
 
1272
- def _criteria_from_params(self, step: Step, *,
1273
- entry_name: str | None = None,
1274
- related_entry_set: Iterable[int] | None = None,
1275
- dependency_set: Iterable[int] | None = None,
1276
- entry_status: ExperimentEntryStatus | None = None,
1277
- order: int | None = None,
1278
- description: str | None = None,
1279
- requires_grabber_plugin: bool | None = None,
1280
- is_initialization_required: bool | None = None,
1281
- notebook_experiment_tab_id: int | None = None,
1282
- entry_height: int | None = None,
1283
- column_order: int | None = None,
1284
- column_span: int | None = None,
1285
- is_removable: bool | None = None,
1286
- is_renamable: bool | None = None,
1287
- source_entry_id: int | None = None,
1288
- clear_source_entry_id: bool | None = None,
1289
- is_hidden: bool | None = None,
1290
- is_static_view: bool | None = None,
1291
- is_shown_in_template: bool | None = None,
1292
- template_item_fulfilled_timestamp: int | None = None,
1293
- clear_template_item_fulfilled_timestamp: bool | None = None,
1294
- entry_options_map: dict[str, str] | None = None) -> AbstractElnEntryUpdateCriteria:
1295
- """
1296
- Create an abstract update criteria object from the provided parameters for the given step.
1297
- """
1298
- step: ElnEntryStep = self.__to_eln_step(step)
1299
- update = AbstractElnEntryUpdateCriteria(step.eln_entry.entry_type)
1300
-
1301
- # These two variables could be iterables that aren't lists. Convert them to plain
1302
- # lists, since that's what the update criteria is expecting.
1303
- if related_entry_set is not None:
1304
- related_entry_set = list(related_entry_set)
1305
- if dependency_set is not None:
1306
- dependency_set = list(dependency_set)
1307
-
1308
- update.entry_name = entry_name
1309
- update.related_entry_set = related_entry_set
1310
- update.dependency_set = dependency_set
1311
- update.entry_status = entry_status
1312
- update.order = order
1313
- update.description = description
1314
- update.requires_grabber_plugin = requires_grabber_plugin
1315
- update.is_initialization_required = is_initialization_required
1316
- update.notebook_experiment_tab_id = notebook_experiment_tab_id
1317
- update.entry_height = entry_height
1318
- update.column_order = column_order
1319
- update.column_span = column_span
1320
- update.is_removable = is_removable
1321
- update.is_renamable = is_renamable
1322
- update.source_entry_id = source_entry_id
1323
- update.clear_source_entry_id = clear_source_entry_id
1324
- update.is_hidden = is_hidden
1325
- update.is_static_View = is_static_view
1326
- update.is_shown_in_template = is_shown_in_template
1327
- update.template_item_fulfilled_timestamp = template_item_fulfilled_timestamp
1328
- update.clear_template_item_fulfilled_timestamp = clear_template_item_fulfilled_timestamp
1329
- update.entry_options_map = entry_options_map
1330
-
1331
- return update
1332
-
1333
1230
  @staticmethod
1334
1231
  def _merge_updates(new_update: AbstractElnEntryUpdateCriteria, old_update: AbstractElnEntryUpdateCriteria) -> None:
1335
1232
  """
@@ -1347,10 +1244,10 @@ class ExperimentHandler:
1347
1244
  entry: ExperimentEntry = step.eln_entry
1348
1245
  if update.entry_name is not None:
1349
1246
  # PR-46477 - Ensure that the previous name of the updated entry already existed in the cache.
1350
- if entry.entry_name in self._steps:
1351
- self._steps.pop(entry.entry_name)
1247
+ if entry.entry_name in self._steps_by_name:
1248
+ self._steps_by_name.pop(entry.entry_name)
1352
1249
  entry.entry_name = update.entry_name
1353
- self._steps.update({update.entry_name: step})
1250
+ self._steps_by_name.update({update.entry_name: step})
1354
1251
  if update.related_entry_set is not None:
1355
1252
  entry.related_entry_id_set = update.related_entry_set
1356
1253
  if update.dependency_set is not None:
@@ -1514,12 +1411,14 @@ class ExperimentHandler:
1514
1411
  The step may be provided as either a string for the name of the step or an ElnEntryStep.
1515
1412
  If given a name, throws an exception if no step of the given name exists in the experiment.
1516
1413
  :param mapping: The new options and values to add to the existing step options, provided as some Mapping
1517
- (e.g. a Dict). If an option key already exists and is provided in the mapping, overwrites the existing value
1518
- for that key.
1414
+ (e.g. a dictionary). If an option key already exists and is provided in the mapping, overwrites the existing
1415
+ value for that key.
1519
1416
  """
1520
1417
  options: dict[str, str] = self.get_step_options(step)
1521
1418
  options.update(mapping)
1522
- self.force_step_update_params(step, entry_options_map=options)
1419
+ update = AbstractElnEntryUpdateCriteria(step.eln_entry.entry_type)
1420
+ update.entry_options_map = options
1421
+ self.force_step_update(step, update)
1523
1422
 
1524
1423
  def initialize_step(self, step: Step) -> None:
1525
1424
  """
@@ -1537,7 +1436,9 @@ class ExperimentHandler:
1537
1436
  # Avoid unnecessary calls if the step is already initialized.
1538
1437
  step: ElnEntryStep = self.__to_eln_step(step)
1539
1438
  if step.eln_entry.template_item_fulfilled_timestamp is None:
1540
- self.force_step_update_params(step, template_item_fulfilled_timestamp=TimeUtil.now_in_millis())
1439
+ update = AbstractElnEntryUpdateCriteria(step.eln_entry.entry_type)
1440
+ update.template_item_fulfilled_timestamp = TimeUtil.now_in_millis()
1441
+ self.force_step_update(step, update)
1541
1442
 
1542
1443
  def uninitialize_step(self, step: Step) -> None:
1543
1444
  """
@@ -1555,7 +1456,9 @@ class ExperimentHandler:
1555
1456
  # Avoid unnecessary calls if the step is already uninitialized.
1556
1457
  step: ElnEntryStep = self.__to_eln_step(step)
1557
1458
  if step.eln_entry.template_item_fulfilled_timestamp is not None:
1558
- self.force_step_update_params(step, clear_template_item_fulfilled_timestamp=True)
1459
+ update = AbstractElnEntryUpdateCriteria(step.eln_entry.entry_type)
1460
+ update.clear_template_item_fulfilled_timestamp = True
1461
+ self.force_step_update(step, update)
1559
1462
 
1560
1463
  def complete_step(self, step: Step) -> None:
1561
1464
  """
@@ -1612,7 +1515,9 @@ class ExperimentHandler:
1612
1515
  """
1613
1516
  step = self.__to_eln_step(step)
1614
1517
  if step.eln_entry.entry_status in self._ENTRY_LOCKED_STATUSES:
1615
- self.force_step_update_params(step, entry_status=ExperimentEntryStatus.Disabled)
1518
+ update = AbstractElnEntryUpdateCriteria(step.eln_entry.entry_type)
1519
+ update.entry_status = ExperimentEntryStatus.Disabled
1520
+ self.force_step_update(step, update)
1616
1521
 
1617
1522
  def step_is_submitted(self, step: Step) -> bool:
1618
1523
  """
@@ -1656,6 +1561,7 @@ class ExperimentHandler:
1656
1561
  if not self._queried_all_tabs:
1657
1562
  self._tabs = self._eln_man.get_tabs_for_experiment(self._exp_id)
1658
1563
  self._tabs.sort(key=lambda t: t.tab_order)
1564
+ self._tabs_by_id = {tab.tab_id: tab for tab in self._tabs}
1659
1565
  self._tabs_by_name = {tab.tab_name: tab for tab in self._tabs}
1660
1566
  return self._tabs
1661
1567
 
@@ -1689,21 +1595,34 @@ class ExperimentHandler:
1689
1595
  self.add_tab_to_cache(tab)
1690
1596
  return tab
1691
1597
 
1692
- def get_tab(self, tab_name: str) -> ElnExperimentTab:
1598
+ def get_tab(self, tab: str | int, exception_on_none: bool = True) -> ElnExperimentTab:
1693
1599
  """
1694
1600
  Return the tab with the input name.
1695
1601
 
1696
1602
  If no tab functions have been called before and a tab is being searched for by name, queries for the
1697
1603
  list of tabs in the experiment and caches them.
1698
1604
 
1699
- :param tab_name: The name of the tab to get.
1700
- :return: The tab with the input name.
1701
- """
1702
- if tab_name not in self._tabs_by_name:
1703
- self.get_all_tabs()
1704
- return self._tabs_by_name[tab_name]
1605
+ :param tab: The name or order of the tab to get. The order is 1-indexed.
1606
+ :param exception_on_none: If True, raises an exception if no tab with the given name exists.
1607
+ :return: The tab with the input name, or None if no such tab exists.
1608
+ """
1609
+ if isinstance(tab, str):
1610
+ if tab not in self._tabs_by_name:
1611
+ self.get_all_tabs()
1612
+ eln_tab = self._tabs_by_name.get(tab)
1613
+ elif isinstance(tab, int):
1614
+ # The given integer is expected to be 1-indexed, but we read from the list with a 0-index.
1615
+ tab -= 1
1616
+ tabs = self.get_all_tabs()
1617
+ eln_tab = tabs[tab] if len(tabs) > tab else None
1618
+ else:
1619
+ raise SapioException(f"Tab must be a string or an integer, not {type(tab)}.")
1620
+ if eln_tab is None and exception_on_none:
1621
+ raise SapioException(f"No tab with the name\\order \"{tab}\" exists in this experiment.")
1622
+ return eln_tab
1705
1623
 
1706
- def get_steps_in_tab(self, tab: Tab, data_type: DataTypeIdentifier | None = None) -> list[ElnEntryStep]:
1624
+ def get_steps_in_tab(self, tab: Tab, data_type: DataTypeIdentifier | None = None) \
1625
+ -> list[ElnEntryStep]:
1707
1626
  """
1708
1627
  Get all the steps in the input tab sorted in order of appearance.
1709
1628
 
@@ -1713,7 +1632,8 @@ class ExperimentHandler:
1713
1632
  If the steps in the experiment have not been queried before, queries for the list of steps in the experiment
1714
1633
  and caches them.
1715
1634
 
1716
- :param tab: The tab or tab name to get the steps of.
1635
+ :param tab: The tab to get the steps of. This can be the tab's order, name, or the tab object itself.
1636
+ The order is 1-indexed.
1717
1637
  :param data_type: The data type to filter the steps by. If None, all steps are returned.
1718
1638
  :return: A list of all the steps in the input tab sorted in order of appearance.
1719
1639
  """
@@ -1722,9 +1642,30 @@ class ExperimentHandler:
1722
1642
  for step in self.get_all_steps(data_type):
1723
1643
  if step.eln_entry.notebook_experiment_tab_id == tab.tab_id:
1724
1644
  steps.append(step)
1725
- steps.sort(key=lambda s: (s.eln_entry.order, s.eln_entry.column_order))
1726
1645
  return steps
1727
1646
 
1647
+ def get_tab_for_step(self, step: Step) -> ElnExperimentTab:
1648
+ """
1649
+ Get the tab that a particular step is located in.
1650
+
1651
+ If no tab functions have been called before and a tab is being searched for by name, queries for the
1652
+ list of tabs in the experiment and caches them.
1653
+
1654
+ If the steps in the experiment have not been queried before, queries for the list of steps in the experiment
1655
+ and caches them.
1656
+
1657
+ :param step:
1658
+ The step to get the position of.
1659
+ The step may be provided as either a string for the name of the step or an ElnEntryStep.
1660
+ If given a name, throws an exception if no step of the given name exists in the experiment.
1661
+ :return: The tab that the input step is located in.
1662
+ """
1663
+ step = self.__to_eln_step(step)
1664
+ tab_id = step.eln_entry.notebook_experiment_tab_id
1665
+ if tab_id not in self._tabs_by_id:
1666
+ self.get_all_tabs()
1667
+ return self._tabs_by_id.get(tab_id)
1668
+
1728
1669
  def get_next_entry_order_in_tab(self, tab: Tab) -> int:
1729
1670
  """
1730
1671
  Get the next available order for a new entry in the input tab.
@@ -1735,402 +1676,70 @@ class ExperimentHandler:
1735
1676
  If the steps in the experiment have not been queried before, queries for the list of steps in the experiment
1736
1677
  and caches them.
1737
1678
 
1738
- :param tab: The tab or tab name to get the steps of.
1679
+ :param tab: The tab to get the steps of. This can be the tab's order, name, or the tab object itself.
1680
+ The order is 1-indexed.
1739
1681
  :return: The next available order for a new entry in the input tab.
1740
1682
  """
1741
1683
  steps = self.get_steps_in_tab(tab)
1742
1684
  return steps[-1].eln_entry.order + 1 if steps else 0
1743
1685
 
1744
- # FR-47468: Add functions for creating new entries in the experiment.
1745
- def create_attachment_step(self, entry_name: str, data_type: DataTypeIdentifier,
1746
- *,
1747
- position: ElnEntryPosition | None = None,
1748
- attachments: list[EntryAttachment] | list[SapioRecord] | None = None) -> ElnEntryStep:
1749
- """
1750
- Create a new attachment entry in the experiment.
1751
-
1752
- :param entry_name: The name of the entry.
1753
- :param data_type: The data type of the entry.
1754
- :param position: Information about where to place the entry in the experiment.
1755
- :param attachments: The list of attachments to initially populate the entry with.
1756
- :return: The newly created attachment entry.
1757
- """
1758
- step = self._create_step(ElnEntryType.Attachment, entry_name, data_type, position)
1759
- if attachments:
1760
- entry_attachments: list[EntryAttachment] = []
1761
- for entry in attachments:
1762
- if isinstance(entry, EntryAttachment):
1763
- entry_attachments.append(entry)
1764
- elif isinstance(entry, SapioRecord):
1765
- entry: SapioRecord
1766
- file_name: str = entry.get_field_value("FilePath")
1767
- if not file_name:
1768
- file_name = entry.get_field_value(SystemFields.DATA_RECORD_NAME__FIELD.field_name)
1769
- rec_id: int = AliasUtil.to_record_id(entry)
1770
- entry_attachments.append(EntryRecordAttachment(file_name, rec_id))
1771
- else:
1772
- raise SapioException("Attachments must be of type EntryAttachment or SapioRecord.")
1773
- update = ElnAttachmentEntryUpdateCriteria()
1774
- update.entry_attachment_list = attachments
1775
- self.force_step_update(step, update)
1776
- return step
1777
-
1778
- def create_form_step(self, entry_name: str,
1779
- data_type: DataTypeIdentifier,
1780
- fields: list[FieldIdentifier] | None = None,
1781
- layout_name: str | None = None,
1782
- form_names: list[str] | None = None,
1783
- *,
1784
- position: ElnEntryPosition | None = None,
1785
- record: SapioRecord | None = None) -> ElnEntryStep:
1786
- """
1787
- Create a new form entry in the experiment.
1788
-
1789
- :param entry_name: The name of the entry.
1790
- :param data_type: The data type of the entry.
1791
- :param fields: The list of data field names for the given data type that should appear on the
1792
- form. Fields will appear in the order they are provided. If not provided and a layout name is not provided,
1793
- the entry will be created with the data type's default layout.
1794
- :param layout_name: The name of the layout to use for the entry. If not provided and field names are not
1795
- provided, the entry will be created with the data type's default layout.
1796
- :param form_names: The list of layout component form names to use from the specified layout name. If not
1797
- provided, then all available forms from the specified layout will be used.
1798
- :param position: Information about where to place the entry in the experiment.
1799
- :param record: The record to initially populate the entry with.
1800
- :return: The newly created form entry.
1801
- """
1802
- if record:
1803
- rdt: str = AliasUtil.to_data_type_name(record)
1804
- sdt: str = AliasUtil.to_data_type_name(data_type)
1805
- if rdt != sdt:
1806
- raise SapioException(f"Cannot set {rdt} records for entry {entry_name} of type "
1807
- f"{sdt}.")
1808
-
1809
- step = self._create_step(ElnEntryType.Form, entry_name, data_type, position)
1810
- if fields or layout_name or form_names or record:
1811
- update = ElnFormEntryUpdateCriteria()
1812
- if fields:
1813
- update.data_field_name_list = AliasUtil.to_data_field_names(fields)
1814
- update.data_type_layout_name = layout_name
1815
- update.form_name_list = form_names
1816
- if record:
1817
- update.record_id = AliasUtil.to_record_id(record)
1818
- self.force_step_update(step, update)
1819
- return step
1820
-
1821
- def create_experiment_detail_form_step(self, entry_name: str,
1822
- fields: list[ElnDataTypeFields] | None = None,
1823
- field_map: FieldMap | None = None,
1824
- *,
1825
- position: ElnEntryPosition | None = None,
1826
- is_field_addable: bool | None = None,
1827
- is_existing_field_removable: bool | None = None) -> ElnEntryStep:
1828
- """
1829
- Create a new ELN experiment details form entry in the experiment.
1830
-
1831
- :param entry_name: The name of the entry.
1832
- :param fields: A list of objects representing the data fields that should appear on the entry. Fields
1833
- will appear in the order they are provided.
1834
- :param field_map: A field map that will be used to populate the entry. The data field names in
1835
- the map must match the field names of the provided field definitions.
1836
- :param position: Information about where to place the entry in the experiment.
1837
- :param is_field_addable: Whether users are able to add additional fields to this entry in the UI.
1838
- :param is_existing_field_removable: Whether users are able to remove existing fields from this entry in the UI.
1839
- :return: The newly created form entry.
1840
- """
1841
- return self._create_eln_dt_form_step(entry_name, ElnBaseDataType.EXPERIMENT_DETAIL, fields, field_map,
1842
- position=position,
1843
- is_field_addable=is_field_addable,
1844
- is_existing_field_removable=is_existing_field_removable)
1845
-
1846
- def create_sample_detail_form_step(self, entry_name: str,
1847
- fields: list[ElnDataTypeFields] | None = None,
1848
- field_map: FieldMap | None = None,
1849
- *,
1850
- position: ElnEntryPosition | None = None,
1851
- is_field_addable: bool | None = None,
1852
- is_existing_field_removable: bool | None = None) -> ElnEntryStep:
1853
- """
1854
- Create a new ELN sample details form entry in the experiment.
1855
-
1856
- :param entry_name: The name of the entry.
1857
- :param fields: A list of objects representing the data fields that should appear on the entry. Fields
1858
- will appear in the order they are provided.
1859
- :param field_map: A field map that will be used to populate the entry. The data field names in
1860
- the map must match the field names of the provided field definitions.
1861
- :param position: Information about where to place the entry in the experiment.
1862
- :param is_field_addable: Whether users are able to add additional fields to this entry in the UI.
1863
- :param is_existing_field_removable: Whether users are able to remove existing fields from this entry in the UI.
1864
- :return: The newly created form entry.
1865
- """
1866
- return self._create_eln_dt_form_step(entry_name, ElnBaseDataType.SAMPLE_DETAIL, fields, field_map,
1867
- position=position,
1868
- is_field_addable=is_field_addable,
1869
- is_existing_field_removable=is_existing_field_removable)
1870
-
1871
- def _create_eln_dt_form_step(self, entry_name: str,
1872
- dt: ElnBaseDataType,
1873
- fields: list[ElnDataTypeFields] | None = None,
1874
- field_map: FieldMap | None = None,
1875
- *,
1876
- position: ElnEntryPosition | None = None,
1877
- is_field_addable: bool | None = None,
1878
- is_existing_field_removable: bool | None = None) -> ElnEntryStep:
1879
- fields: list[AbstractVeloxFieldDefinition] | None = self._to_field_defs(fields, dt)
1880
- step = self._create_step(ElnEntryType.Form, entry_name, dt.data_type_name,
1881
- position,
1882
- field_definition_list=fields,
1883
- field_map_list=[field_map] if field_map else None)
1884
- if is_field_addable is not None or is_existing_field_removable is not None:
1885
- update = ElnFormEntryUpdateCriteria()
1886
- update.is_field_addable = is_field_addable
1887
- update.is_existing_field_removable = is_existing_field_removable
1888
- self.force_step_update(step, update)
1889
- return step
1890
-
1891
- def create_plugin_step(self, entry_name: str, data_type: DataTypeIdentifier, plugin_path: str, *,
1892
- position: ElnEntryPosition | None = None) -> ElnEntryStep:
1893
- """
1894
- Create a new plugin entry in the experiment.
1895
-
1896
- :param entry_name: The name of the entry.
1897
- :param data_type: The data type of the entry.
1898
- :param plugin_path: The plugin path to the CSP that determines this entry's functionality.
1899
- :param position: Information about where to place the entry in the experiment.
1900
- :return: The newly created plugin entry.
1901
- """
1902
- return self._create_step(ElnEntryType.Plugin, entry_name, data_type, position,
1903
- csp_plugin_name=plugin_path)
1904
-
1905
- def create_table_step(self, entry_name: str, data_type: DataTypeIdentifier,
1906
- fields: list[FieldIdentifier] | list[TableColumn] | None = None,
1907
- layout_name: str | None = None,
1908
- show_key_fields: bool | None = None,
1909
- *,
1910
- position: ElnEntryPosition | None = None,
1911
- records: list[SapioRecord] | None = None) -> ElnEntryStep:
1912
- """
1913
- Create a new table entry in the experiment.
1914
-
1915
- :param entry_name: The name of the entry.
1916
- :param data_type: The data type of the entry.
1917
- :param fields: The list of data field names for the given data type that should appear on the
1918
- table. You may also provide a list of TableColumns instead of a list of field names for when you want to
1919
- set the sort order and direction of the columns in the table. Fields will appear in the order they are
1920
- provided. If not provided and a layout name is not provided, the entry will be created with the data type's
1921
- default layout.
1922
- :param layout_name: The name of the layout to use for the entry. If not provided and field names are not
1923
- provided, the entry will be created with the data type's default layout.
1924
- :param show_key_fields: Whether the table should only show key fields of the data type.
1925
- :param position: Information about where to place the entry in the experiment.
1926
- :param records: The list of records to initially populate the entry with.
1927
- :return: The newly created table entry.
1928
- """
1929
- step = self._create_step(ElnEntryType.Table, entry_name, data_type, position)
1930
- if fields or layout_name:
1931
- update = ElnTableEntryUpdateCriteria()
1932
- if fields:
1933
- dt: str = AliasUtil.to_data_type_name(data_type)
1934
- columns: list[TableColumn] = []
1935
- for field in fields:
1936
- if isinstance(field, TableColumn):
1937
- columns.append(field)
1938
- else:
1939
- columns.append(TableColumn(dt, AliasUtil.to_data_field_name(field)))
1940
- update.table_column_list = columns
1941
- update.data_type_layout_name = layout_name
1942
- update.show_key_fields = show_key_fields
1943
- self.force_step_update(step, update)
1944
- if records:
1945
- self.set_step_records(step, records)
1946
- return step
1947
-
1948
- def create_experiment_detail_table_step(self, entry_name: str,
1949
- fields: list[ElnDataTypeFields] | None = None,
1950
- field_maps: list[FieldMap] | None = None, *,
1951
- position: ElnEntryPosition | None = None,
1952
- is_field_addable: bool | None = None,
1953
- is_existing_field_removable: bool | None = None) -> ElnEntryStep:
1954
- """
1955
- Create a new ELN experiment details table entry in the experiment.
1956
-
1957
- :param entry_name: The name of the entry.
1958
- :param fields: A list of objects representing the data fields that should appear on the entry. Fields
1959
- will appear in the order they are provided.
1960
- :param field_maps: A field maps list that will be used to populate the entry. The data field names in
1961
- the maps must match the field names of the provided field definitions.
1962
- :param position: Information about where to place the entry in the experiment.
1963
- :param is_field_addable: Whether users are able to add additional fields to this entry in the UI.
1964
- :param is_existing_field_removable: Whether users are able to remove existing fields from this entry in the UI.
1965
- :return: The newly created table entry.
1966
- """
1967
- return self._create_eln_dt_table_step(entry_name, ElnBaseDataType.EXPERIMENT_DETAIL, fields, field_maps,
1968
- position=position,
1969
- is_field_addable=is_field_addable,
1970
- is_existing_field_removable=is_existing_field_removable)
1971
-
1972
- def create_sample_detail_table_step(self, entry_name: str,
1973
- fields: list[ElnDataTypeFields] | None = None,
1974
- field_maps: list[FieldMap] | None = None, *,
1975
- position: ElnEntryPosition | None = None,
1976
- is_field_addable: bool | None = None,
1977
- is_existing_field_removable: bool | None = None) -> ElnEntryStep:
1978
- """
1979
- Create a new ELN sample details table entry in the experiment.
1980
-
1981
- :param entry_name: The name of the entry.
1982
- :param fields: A list of objects representing the data fields that should appear on the entry. Fields
1983
- will appear in the order they are provided.
1984
- :param field_maps: A field maps list that will be used to populate the entry. The data field names in
1985
- the maps must match the field names of the provided field definitions.
1986
- :param position: Information about where to place the entry in the experiment.
1987
- :param is_field_addable: Whether users are able to add additional fields to this entry in the UI.
1988
- :param is_existing_field_removable: Whether users are able to remove existing fields from this entry in the UI.
1989
- :return: The newly created table entry.
1990
- """
1991
- return self._create_eln_dt_table_step(entry_name, ElnBaseDataType.SAMPLE_DETAIL, fields, field_maps,
1992
- position=position,
1993
- is_field_addable=is_field_addable,
1994
- is_existing_field_removable=is_existing_field_removable)
1995
-
1996
- def _create_eln_dt_table_step(self, entry_name: str,
1997
- dt: ElnBaseDataType,
1998
- fields: list[ElnDataTypeFields] | None = None,
1999
- field_maps: list[FieldMap] | None = None, *,
2000
- position: ElnEntryPosition | None = None,
2001
- is_field_addable: bool | None = None,
2002
- is_existing_field_removable: bool | None = None) -> ElnEntryStep:
2003
- fields: list[AbstractVeloxFieldDefinition] | None = self._to_field_defs(fields, dt)
2004
- step = self._create_step(ElnEntryType.Table, entry_name, dt.data_type_name,
2005
- position,
2006
- field_definition_list=fields,
2007
- field_map_list=field_maps)
2008
- if is_field_addable is not None or is_existing_field_removable is not None:
2009
- update = ElnTableEntryUpdateCriteria()
2010
- update.is_field_addable = is_field_addable
2011
- update.is_existing_field_removable = is_existing_field_removable
2012
- self.force_step_update(step, update)
2013
- return step
2014
-
2015
- def create_temp_data_step(self, entry_name: str, data_type: DataTypeIdentifier, plugin_path: str, *,
2016
- position: ElnEntryPosition | None = None) -> ElnEntryStep:
1686
+ # FR-47530: Add functions for dealing with entry positioning.
1687
+ def step_to_position(self, step: Step) -> ElnEntryPosition:
2017
1688
  """
2018
- Create a new temp data entry in the experiment.
1689
+ Get the position of the input step in the experiment.
2019
1690
 
2020
- :param entry_name: The name of the entry.
2021
- :param data_type: The data type of the entry.
2022
- :param plugin_path: The plugin path to the plugin that populates the entry.
2023
- :param position: Information about where to place the entry in the experiment.
2024
- :return: The newly created temp data entry.
2025
- """
2026
- return self._create_step(ElnEntryType.TempData, entry_name, data_type, position,
2027
- temp_data_plugin_path=plugin_path)
1691
+ If no step functions have been called before and a step is being searched for by name, queries for the
1692
+ list of steps in the experiment and caches them.
2028
1693
 
2029
- def create_text_step(self, entry_name: str, text: str | None = None, *,
2030
- position: ElnEntryPosition | None = None) -> ElnEntryStep:
1694
+ :param step:
1695
+ The step to get the position of.
1696
+ The step may be provided as either a string for the name of the step or an ElnEntryStep.
1697
+ If given a name, throws an exception if no step of the given name exists in the experiment.
1698
+ :return: The position of the input step in the experiment.
2031
1699
  """
2032
- Create a new text entry in the experiment.
1700
+ step: ElnEntryStep = self.__to_eln_step(step)
1701
+ entry: ExperimentEntry = step.eln_entry
1702
+ return ElnEntryPosition(entry.notebook_experiment_tab_id,
1703
+ entry.order,
1704
+ entry.column_span,
1705
+ entry.column_order)
2033
1706
 
2034
- :param entry_name: The name of the entry.
2035
- :param text: The text to populate the entry with.
2036
- :param position: Information about where to place the entry in the experiment.
2037
- :return: The newly created text entry.
1707
+ def step_at_position(self, position: ElnEntryPosition) -> Step | None:
2038
1708
  """
2039
- step: ElnEntryStep = self._create_step(ElnEntryType.Text, entry_name,
2040
- ElnBaseDataType.TEXT_ENTRY_DETAIL.data_type_name, position)
2041
- if text:
2042
- text_record: DataRecord = self.get_step_records(entry_name)[0]
2043
- text_record.set_field_value(ElnBaseDataType.get_text_entry_data_field_name(), text)
2044
- DataRecordManager(self.user).commit_data_records([text_record])
2045
- return step
1709
+ Get the step at the input position in the experiment.
2046
1710
 
2047
- def create_plate_designer_step(self, entry_name: str, source_entry: Step, *,
2048
- position: ElnEntryPosition | None = None) -> ElnEntryStep:
2049
- """
2050
- Create a new 3D plate designer entry in the experiment.
2051
-
2052
- :param entry_name: The name of the entry.
2053
- :param source_entry: The entry that the plate designer will source its samples from.
2054
- :param position: Information about where to place the entry in the experiment.
2055
- :return: The newly created plate designer entry.
2056
- """
2057
- step = self.create_plugin_step(entry_name, "Sample", PLATE_DESIGNER_PLUGIN, position=position)
2058
- default_layer = MultiLayerPlateLayer(
2059
- MultiLayerDataTypeConfig("Sample"),
2060
- PlatingOrder.FillBy.BY_COLUMN,
2061
- MultiLayerReplicateConfig(),
2062
- MultiLayerDilutionConfig()
2063
- )
2064
- initial_step_options: dict[str, str] = {
2065
- "MultiLayerPlating_Plate_RecordIdList": "",
2066
- "MultiLayerPlating_Entry_Prefs": MultiLayerPlatingManager.get_entry_prefs_json([default_layer]),
2067
- "MultiLayerPlating_Entry_PrePlating_Prefs": MultiLayerPlatingManager.get_plate_configs_json(MultiLayerPlateConfig())
2068
- }
2069
- source_entry = self.__to_eln_step(source_entry)
2070
- self.force_step_update_params(step, entry_height=600, entry_options_map=initial_step_options,
2071
- related_entry_set=[source_entry.get_id()])
2072
- return step
1711
+ If no step functions have been called before and a step is being searched for by name, queries for the
1712
+ list of steps in the experiment and caches them.
2073
1713
 
2074
- # TODO: Update these functions to use entry type-specific creation criteria once Sapiopylib supports that.
2075
- # This should take in an entry creation criteria object that handles all the abstract attributes that
2076
- # every entry type shares.
2077
- def _create_step(self, entry_type: ElnEntryType, entry_name: str, data_type: DataTypeIdentifier,
2078
- position: ElnEntryPosition | None = None, **kwargs) \
2079
- -> ElnEntryStep:
1714
+ :param position: The position to get the step at.
1715
+ :return: The step at the input position in the experiment, or None if no step exists at that position.
2080
1716
  """
2081
- Create a new entry in the experiment of the given type.
2082
- """
2083
- if position is not None:
2084
- order: int = position.order
2085
- tab_id: int = position.tab_id
2086
- column_order: int = position.column_order
2087
- column_span: int = position.column_span
2088
- else:
2089
- last_tab: ElnExperimentTab = self.get_last_tab()
2090
- order: int = self.get_next_entry_order_in_tab(last_tab)
2091
- tab_id: int = last_tab.tab_id
2092
- column_order: int = 0
2093
- column_span: int = last_tab.max_number_of_columns
2094
-
2095
- data_type: str = AliasUtil.to_data_type_name(data_type)
2096
- crit = ElnEntryCriteria(entry_type, entry_name, data_type, order,
2097
- notebook_experiment_tab_id=tab_id,
2098
- column_order=column_order,
2099
- column_span=column_span,
2100
- **kwargs)
2101
- entry: ExperimentEntry = self._eln_man.add_experiment_entry(self._exp_id, crit)
2102
-
2103
- self.add_entry_to_caches(entry)
2104
- return ElnEntryStep(self._protocol, entry)
1717
+ if position.tab_id is None or position.order is None:
1718
+ raise SapioException("The provided position must at least have a tab ID and order.")
1719
+ for step in self.get_steps_in_tab(position.tab_id):
1720
+ entry: ExperimentEntry = step.eln_entry
1721
+ if entry.order != position.order:
1722
+ continue
1723
+ if position.column_span is not None and entry.column_span != position.column_span:
1724
+ continue
1725
+ if position.column_order is not None and entry.column_order != position.column_order:
1726
+ continue
1727
+ return step
1728
+ return None
2105
1729
 
2106
- def _to_field_defs(self, fields: list[ElnDataTypeFields], dt: ElnBaseDataType) \
2107
- -> list[AbstractVeloxFieldDefinition] | None:
1730
+ # FR-47530: Create a function for adding protocol templates to the experiment.
1731
+ def add_protocol(self, protocol: ProtocolTemplateInfo | int, position: ElnEntryPosition) -> list[ElnEntryStep]:
2108
1732
  """
2109
- Convert a list of ElnDataTypeField aliases to field definitions.
1733
+ Add a protocol to the experiment. Updates the handler cache with the newly created entries.
1734
+
1735
+ :param protocol: The protocol to add. This can be either a ProtocolTemplateInfo object or the ID of the
1736
+ protocol template.
1737
+ :param position: The position that the protocol's first entry will be placed at.
1738
+ :return: The newly created protocol entries.
2110
1739
  """
2111
- if not fields:
2112
- return None
2113
- field_defs: list[AbstractVeloxFieldDefinition] = []
2114
- for field in fields:
2115
- if isinstance(field, AbstractVeloxFieldDefinition):
2116
- 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))
2119
- 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))
2123
- return field_defs
2124
-
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]
1740
+ protocol = protocol if isinstance(protocol, int) else protocol.template_id
1741
+ new_entries: list[ExperimentEntry] = self._eln_man.add_protocol_template(self._exp_id, protocol, position)
1742
+ return self.add_entries_to_caches(new_entries)
2134
1743
 
2135
1744
  def __to_eln_step(self, step: Step) -> ElnEntryStep:
2136
1745
  """
@@ -2140,14 +1749,22 @@ class ExperimentHandler:
2140
1749
 
2141
1750
  :return: The input step as an ElnEntryStep.
2142
1751
  """
2143
- return self.get_step(step) if isinstance(step, str) else step
1752
+ if isinstance(step, str):
1753
+ return self.get_step(step)
1754
+ if isinstance(step, int):
1755
+ return self._steps_by_id.get(step)
1756
+ if isinstance(step, ExperimentEntry):
1757
+ return self.add_entry_to_caches(step)
1758
+ return step
2144
1759
 
2145
1760
  def __to_eln_tab(self, tab: Tab) -> ElnExperimentTab:
2146
1761
  """
2147
- Convert a variable that could be either a string or an ElnExperimentTab to just an ElnExperimentTab.
1762
+ Convert a variable that could be either a tab name, tab order, or ElnExperimentTab to just a tab object.
2148
1763
  This will query and cache the tabs for the experiment if the input tab is a name and the tabs have not been
2149
1764
  cached before.
2150
1765
 
2151
1766
  :return: The input tab as an ElnExperimentTab.
2152
1767
  """
2153
- return self.get_tab(tab) if isinstance(tab, str) else tab
1768
+ if not isinstance(tab, ElnExperimentTab):
1769
+ return self.get_tab(tab)
1770
+ return tab