sapiopycommons 2025.7.7a580__py3-none-any.whl → 2025.7.9a582__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 (60) hide show
  1. sapiopycommons/ai/__init__.py +0 -0
  2. sapiopycommons/ai/api/fielddefinitions/proto/fields_pb2.py +43 -0
  3. sapiopycommons/ai/api/fielddefinitions/proto/fields_pb2.pyi +31 -0
  4. sapiopycommons/ai/api/fielddefinitions/proto/fields_pb2_grpc.py +24 -0
  5. sapiopycommons/ai/api/fielddefinitions/proto/velox_field_def_pb2.py +123 -0
  6. sapiopycommons/ai/api/fielddefinitions/proto/velox_field_def_pb2.pyi +598 -0
  7. sapiopycommons/ai/api/fielddefinitions/proto/velox_field_def_pb2_grpc.py +24 -0
  8. sapiopycommons/ai/api/plan/proto/step_output_pb2.py +45 -0
  9. sapiopycommons/ai/api/plan/proto/step_output_pb2.pyi +42 -0
  10. sapiopycommons/ai/api/plan/proto/step_output_pb2_grpc.py +24 -0
  11. sapiopycommons/ai/api/plan/proto/step_pb2.py +43 -0
  12. sapiopycommons/ai/api/plan/proto/step_pb2.pyi +43 -0
  13. sapiopycommons/ai/api/plan/proto/step_pb2_grpc.py +24 -0
  14. sapiopycommons/ai/api/plan/script/proto/script_pb2.py +55 -0
  15. sapiopycommons/ai/api/plan/script/proto/script_pb2.pyi +115 -0
  16. sapiopycommons/ai/api/plan/script/proto/script_pb2_grpc.py +153 -0
  17. sapiopycommons/ai/api/plan/tool/proto/entry_pb2.py +57 -0
  18. sapiopycommons/ai/api/plan/tool/proto/entry_pb2.pyi +96 -0
  19. sapiopycommons/ai/api/plan/tool/proto/entry_pb2_grpc.py +24 -0
  20. sapiopycommons/ai/api/plan/tool/proto/tool_pb2.py +67 -0
  21. sapiopycommons/ai/api/plan/tool/proto/tool_pb2.pyi +220 -0
  22. sapiopycommons/ai/api/plan/tool/proto/tool_pb2_grpc.py +154 -0
  23. sapiopycommons/ai/api/session/proto/sapio_conn_info_pb2.py +39 -0
  24. sapiopycommons/ai/api/session/proto/sapio_conn_info_pb2.pyi +32 -0
  25. sapiopycommons/ai/api/session/proto/sapio_conn_info_pb2_grpc.py +24 -0
  26. sapiopycommons/ai/protobuf_utils.py +508 -0
  27. sapiopycommons/ai/test_client.py +251 -0
  28. sapiopycommons/ai/tool_service_base.py +798 -0
  29. sapiopycommons/callbacks/callback_util.py +332 -665
  30. sapiopycommons/callbacks/field_builder.py +0 -2
  31. sapiopycommons/chem/IndigoMolecules.py +1 -31
  32. sapiopycommons/chem/Molecules.py +3 -3
  33. sapiopycommons/customreport/auto_pagers.py +1 -26
  34. sapiopycommons/customreport/term_builder.py +1 -1
  35. sapiopycommons/datatype/pseudo_data_types.py +326 -349
  36. sapiopycommons/eln/experiment_handler.py +767 -408
  37. sapiopycommons/eln/experiment_report_util.py +6 -11
  38. sapiopycommons/eln/plate_designer.py +2 -7
  39. sapiopycommons/files/file_util.py +5 -7
  40. sapiopycommons/general/accession_service.py +2 -2
  41. sapiopycommons/general/aliases.py +1 -3
  42. sapiopycommons/general/audit_log.py +0 -7
  43. sapiopycommons/general/custom_report_util.py +0 -12
  44. sapiopycommons/processtracking/custom_workflow_handler.py +1 -11
  45. sapiopycommons/processtracking/endpoints.py +0 -27
  46. sapiopycommons/recordmodel/record_handler.py +317 -657
  47. sapiopycommons/rules/eln_rule_handler.py +1 -8
  48. sapiopycommons/rules/on_save_rule_handler.py +1 -8
  49. sapiopycommons/webhook/webhook_handlers.py +0 -3
  50. sapiopycommons/webhook/webservice_handlers.py +2 -2
  51. {sapiopycommons-2025.7.7a580.dist-info → sapiopycommons-2025.7.9a582.dist-info}/METADATA +2 -2
  52. sapiopycommons-2025.7.9a582.dist-info/RECORD +92 -0
  53. sapiopycommons/chem/ps_commons.py +0 -455
  54. sapiopycommons/eln/experiment_cache.py +0 -188
  55. sapiopycommons/eln/experiment_step_factory.py +0 -476
  56. sapiopycommons/eln/step_creation.py +0 -236
  57. sapiopycommons/general/data_structure_util.py +0 -115
  58. sapiopycommons-2025.7.7a580.dist-info/RECORD +0 -69
  59. {sapiopycommons-2025.7.7a580.dist-info → sapiopycommons-2025.7.9a582.dist-info}/WHEEL +0 -0
  60. {sapiopycommons-2025.7.7a580.dist-info → sapiopycommons-2025.7.9a582.dist-info}/licenses/LICENSE +0 -0
@@ -5,46 +5,58 @@ 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
15
8
  from sapiopylib.rest.DataMgmtService import DataMgmtServer
9
+ from sapiopylib.rest.DataRecordManagerService import DataRecordManager
16
10
  from sapiopylib.rest.ELNService import ElnManager
17
11
  from sapiopylib.rest.User import SapioUser
18
12
  from sapiopylib.rest.pojo.DataRecord import DataRecord
13
+ from sapiopylib.rest.pojo.TableColumn import TableColumn
14
+ from sapiopylib.rest.pojo.datatype.FieldDefinition import AbstractVeloxFieldDefinition
19
15
  from sapiopylib.rest.pojo.eln.ElnEntryPosition import ElnEntryPosition
20
16
  from sapiopylib.rest.pojo.eln.ElnExperiment import ElnExperiment, TemplateExperimentQueryPojo, ElnTemplate, \
21
17
  InitializeNotebookExperimentPojo, ElnExperimentUpdateCriteria
22
18
  from sapiopylib.rest.pojo.eln.ExperimentEntry import ExperimentEntry, ExperimentTableEntry, ExperimentFormEntry, \
23
19
  ExperimentAttachmentEntry, ExperimentPluginEntry, ExperimentDashboardEntry, ExperimentTextEntry, \
24
- ExperimentTempDataEntry
20
+ ExperimentTempDataEntry, EntryAttachment, EntryRecordAttachment
25
21
  from sapiopylib.rest.pojo.eln.ExperimentEntryCriteria import AbstractElnEntryUpdateCriteria, \
26
22
  ElnTableEntryUpdateCriteria, ElnFormEntryUpdateCriteria, ElnAttachmentEntryUpdateCriteria, \
27
23
  ElnPluginEntryUpdateCriteria, ElnDashboardEntryUpdateCriteria, ElnTextEntryUpdateCriteria, \
28
- ElnTempDataEntryUpdateCriteria
24
+ ElnTempDataEntryUpdateCriteria, ElnEntryCriteria
29
25
  from sapiopylib.rest.pojo.eln.SapioELNEnums import ExperimentEntryStatus, ElnExperimentStatus, ElnEntryType, \
30
26
  ElnBaseDataType
31
27
  from sapiopylib.rest.pojo.eln.eln_headings import ElnExperimentTab, ElnExperimentTabAddCriteria
32
- from sapiopylib.rest.pojo.eln.protocol_template import ProtocolTemplateInfo
28
+ from sapiopylib.rest.pojo.eln.field_set import ElnFieldSetInfo
33
29
  from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
34
30
  from sapiopylib.rest.pojo.webhook.WebhookDirective import ElnExperimentDirective
35
31
  from sapiopylib.rest.pojo.webhook.WebhookResult import SapioWebhookResult
36
32
  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
- 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."""
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."""
48
60
 
49
61
 
50
62
  # FR-46064 - Initial port of PyWebhookUtils to sapiopycommons.
@@ -65,8 +77,6 @@ class ExperimentHandler:
65
77
  # Managers.
66
78
  _eln_man: ElnManager
67
79
  """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."""
70
80
  _inst_man: RecordModelInstanceManager
71
81
  """The record model instance manager. Used for wrapping the data records of a step as record models."""
72
82
  _rec_handler: RecordHandler
@@ -85,9 +95,7 @@ class ExperimentHandler:
85
95
 
86
96
  _queried_all_steps: bool
87
97
  """Whether this ExperimentHandler has queried the system for all steps in the experiment."""
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]
98
+ _steps: dict[str, ElnEntryStep]
91
99
  """Steps from this experiment by their name. All steps are cached the first time any individual step is accessed."""
92
100
  _steps_by_id: dict[int, ElnEntryStep]
93
101
  """Steps from this experiment by their ID. All steps are cached the first time any individual step is accessed."""
@@ -101,12 +109,13 @@ class ExperimentHandler:
101
109
  _queried_all_tabs: bool
102
110
  """Whether this ExperimentHandler has queried the system for all tabs in the experiment."""
103
111
  _tabs: list[ElnExperimentTab]
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."""
112
+ """The tabs for this experiment. Only cached when first accessed."""
107
113
  _tabs_by_name: dict[str, ElnExperimentTab]
108
114
  """The tabs for this experiment by their name. Only cached when first accessed."""
109
115
 
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
+
110
119
  # Constants
111
120
  _ENTRY_COMPLETE_STATUSES = [ExperimentEntryStatus.Completed, ExperimentEntryStatus.CompletedApproved]
112
121
  """The set of statuses that an ELN entry could have and be considered completed/submitted."""
@@ -164,20 +173,17 @@ class ExperimentHandler:
164
173
 
165
174
  # Grab various managers that may be used.
166
175
  self._eln_man = DataMgmtServer.get_eln_manager(self.user)
167
- self._exp_cache = ExperimentCacheManager(self.user)
168
176
  self._inst_man = RecordModelManager(self.user).instance_manager
169
177
  self._rec_handler = RecordHandler(self.user)
170
178
 
171
179
  # Create empty caches to fill when necessary.
172
180
  self._queried_all_steps = False
173
- self._steps_by_name = {}
181
+ self._steps = {}
174
182
  self._steps_by_id = {}
175
- self._steps = []
176
183
  self._step_options = {}
177
184
  self._step_updates = {}
178
185
 
179
186
  self._tabs = []
180
- self._tabs_by_id = {}
181
187
  self._tabs_by_name = {}
182
188
 
183
189
  self._queried_all_tabs = False
@@ -193,8 +199,7 @@ class ExperimentHandler:
193
199
  for entry in self.context.experiment_entry_list:
194
200
  cache_steps.append(ElnEntryStep(self._protocol, entry))
195
201
  for step in cache_steps:
196
- self._steps.append(step)
197
- self._steps_by_name.update({step.get_name(): step})
202
+ self._steps.update({step.get_name(): step})
198
203
  self._steps_by_id.update({step.get_id(): step})
199
204
 
200
205
  @staticmethod
@@ -259,7 +264,6 @@ class ExperimentHandler:
259
264
  """
260
265
  self._queried_all_steps = False
261
266
  self._steps.clear()
262
- self._steps_by_name.clear()
263
267
  self._steps_by_id.clear()
264
268
  self._step_options.clear()
265
269
  self._step_updates.clear()
@@ -278,46 +282,32 @@ class ExperimentHandler:
278
282
  """
279
283
  self._queried_all_tabs = False
280
284
  self._tabs.clear()
281
- self._tabs_by_id.clear()
282
285
  self._tabs_by_name.clear()
283
286
 
284
- def add_entry_to_caches(self, entry: ExperimentEntry | ElnEntryStep) -> ElnEntryStep:
287
+ def add_entry_to_caches(self, entry: ExperimentEntry | ElnEntryStep) -> None:
285
288
  """
286
289
  Add the given entry to the cache of steps for this experiment. This is necessary in order for certain methods to
287
290
  work. You should only need to do this if you have created a new entry in your code using a method outside
288
291
  of this ExperimentHandler.
289
292
 
290
293
  :param entry: The entry to add to the cache.
291
- :return: The entry that was added to the cache as an ElnEntryStep.
292
294
  """
293
- # ExperimentEntries are stored as ElnEntrySteps in the cache.
294
295
  if isinstance(entry, ExperimentEntry):
295
296
  entry = ElnEntryStep(self._protocol, entry)
296
- # PR-47699: Confirm that this entry is part of the experiment that this handler is for.
297
- if entry.eln_entry.parent_experiment_id != self._exp_id:
298
- raise SapioException(f"Entry with ID {entry.get_id()} is not part of the experiment with ID "
299
- f"{self._exp_id}.")
300
- # PR-47699: Don't add the entry if it is already in the cache.
301
- if entry.get_id() not in self._steps_by_id:
302
- self._steps.append(entry)
303
- self._steps_by_name.update({entry.get_name(): entry})
304
- self._steps_by_id.update({entry.get_id(): entry})
305
- # Skipping the options cache. The get_step_options method will update that cache when necessary.
306
- return entry
307
-
308
- def add_entries_to_caches(self, entries: list[ExperimentEntry | ElnEntryStep]) -> list[ElnEntryStep]:
297
+ self._steps.update({entry.get_name(): entry})
298
+ self._steps_by_id.update({entry.get_id(): entry})
299
+ # Skipping the options cache. The get_step_options method will update the cache when necessary.
300
+
301
+ def add_entries_to_caches(self, entries: list[ExperimentEntry | ElnEntryStep]) -> None:
309
302
  """
310
303
  Add the given entries to the cache of steps for this experiment. This is necessary in order for certain methods
311
304
  to work. You should only need to do this if you have created a new entry in your code using a method outside
312
305
  of this ExperimentHandler.
313
306
 
314
307
  :param entries: The entries to add to the cache.
315
- :return: The entries that were added to the cache as ElnEntrySteps.
316
308
  """
317
- new_entries: list[ElnEntryStep] = []
318
309
  for entry in entries:
319
- new_entries.append(self.add_entry_to_caches(entry))
320
- return new_entries
310
+ self.add_entry_to_caches(entry)
321
311
 
322
312
  def add_tab_to_cache(self, tab: ElnExperimentTab) -> None:
323
313
  """
@@ -329,15 +319,12 @@ class ExperimentHandler:
329
319
  """
330
320
  self._tabs.append(tab)
331
321
  self._tabs.sort(key=lambda t: t.tab_order)
332
- self._tabs_by_id[tab.tab_id] = tab
333
322
  self._tabs_by_name[tab.tab_name] = tab
334
323
 
335
324
  # FR-46495: Split the creation of the experiment in launch_experiment into a create_experiment function.
336
- # CR-47703: Allow create_experiment and launch_experiment to accept None as a template_name to create a blank
337
- # experiment. Also allow a SapioUser object to be provided as context instead of a full SapioWebhookContext object.
338
325
  @staticmethod
339
- def create_experiment(context: UserIdentifier,
340
- template_name: str | None = None,
326
+ def create_experiment(context: SapioWebhookContext,
327
+ template_name: str,
341
328
  experiment_name: str | None = None,
342
329
  parent_record: SapioRecord | None = None, *,
343
330
  template_version: int | None = None, active_templates_only: bool = True) -> ElnExperiment:
@@ -348,9 +335,8 @@ class ExperimentHandler:
348
335
  templates match the same criteria, the first template that is encountered in the query is used. Throws an
349
336
  exception if no template is found. Also makes a webservice request to create the experiment.
350
337
 
351
- :param context: The current webhook context or a user object to send requests from.
352
- :param template_name: The name of the template to create the experiment from. If None, a blank experiment
353
- is created.
338
+ :param context: The current webhook context.
339
+ :param template_name: The name of the template to create the experiment from.
354
340
  :param experiment_name: The name to give to the experiment after it is created. If not provided, defaults to the
355
341
  display name of the template.
356
342
  :param parent_record: The parent record to attach this experiment under. This record must be an eligible
@@ -362,27 +348,32 @@ class ExperimentHandler:
362
348
  :param active_templates_only: Whether only active templates should be queried for.
363
349
  :return: The newly created experiment.
364
350
  """
365
- user = AliasUtil.to_sapio_user(context)
366
- cache = ExperimentCacheManager(user)
367
- eln_manager: ElnManager = DataMgmtServer.get_eln_manager(user)
368
-
369
- template_id: int | None = None
370
- if template_name:
371
- launch_template: ElnTemplate = cache.get_experiment_template(template_name, active_templates_only,
372
- template_version, first_match=True)
373
- template_id = launch_template.template_id
374
- if experiment_name is None:
375
- experiment_name: str = launch_template.display_name
376
- elif experiment_name is None:
377
- experiment_name = f"{user.username}'s Experiment"
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
+
367
+ if experiment_name is None:
368
+ experiment_name: str = launch_template.display_name
378
369
  if parent_record is not None:
379
370
  parent_record: DataRecord = AliasUtil.to_data_record(parent_record)
380
- notebook_init = InitializeNotebookExperimentPojo(experiment_name, template_id, parent_record)
381
- return eln_manager.create_notebook_experiment(notebook_init)
371
+ notebook_init = InitializeNotebookExperimentPojo(experiment_name, launch_template.template_id, parent_record)
372
+ return context.eln_manager.create_notebook_experiment(notebook_init)
382
373
 
383
374
  @staticmethod
384
- def launch_experiment(context: UserIdentifier,
385
- template_name: str | None = None,
375
+ def launch_experiment(context: SapioWebhookContext,
376
+ template_name: str,
386
377
  experiment_name: str | None = None,
387
378
  parent_record: SapioRecord | None = None, *,
388
379
  template_version: int | None = None,
@@ -395,9 +386,8 @@ class ExperimentHandler:
395
386
  templates match the same criteria, the first template that is encountered in the query is used. Throws an
396
387
  exception if no template is found. Also makes a webservice request to create the experiment.
397
388
 
398
- :param context: The current webhook context or a user object to send requests from.
399
- :param template_name: The name of the template to create the experiment from. If None, a blank experiment
400
- is created.
389
+ :param context: The current webhook context.
390
+ :param template_name: The name of the template to create the experiment from.
401
391
  :param experiment_name: The name to give to the experiment after it is created. If not provided, defaults to the
402
392
  display name of the template.
403
393
  :param parent_record: The parent record to attach this experiment under. This record must be an eligible
@@ -600,9 +590,6 @@ class ExperimentHandler:
600
590
  """
601
591
  Set the experiment's status to Completed. Makes a webservice call to update the experiment. Checks if the
602
592
  experiment is already completed, and does nothing if so.
603
-
604
- NOTE: This will cause the usual process tracking logic to run as if you'd clicked the "Complete Experiment"
605
- toolbar button. This includes moving the in process samples forward to the next step in the process.
606
593
  """
607
594
  if not self.experiment_is_complete():
608
595
  self._protocol.complete_protocol()
@@ -613,11 +600,9 @@ class ExperimentHandler:
613
600
  Set the experiment's status to Canceled. Makes a webservice call to update the experiment. Checks if the
614
601
  experiment is already canceled, and does nothing if so.
615
602
 
616
- NOTE: This will cause the usual process tracking logic to run as if you'd clicked the "Cancel Experiment"
617
- toolbar button. This includes moving the in process samples back into the process queue for the current step.
618
-
619
- On version 24.12 and earlier, this was not the case, as the process tracking logic was tied to the button
620
- instead of being on the experiment status change.
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.
621
606
  """
622
607
  if not self.experiment_is_canceled():
623
608
  self._protocol.cancel_protocol()
@@ -647,14 +632,14 @@ class ExperimentHandler:
647
632
  """
648
633
  return all([x is not None for x in self.get_steps(step_names, False)])
649
634
 
650
- def get_step(self, step_name: Step, exception_on_none: bool = True) -> ElnEntryStep | None:
635
+ def get_step(self, step_name: str, exception_on_none: bool = True) -> ElnEntryStep | None:
651
636
  """
652
637
  Get the step of the given name from the experiment.
653
638
 
654
639
  If no step functions have been called before and a step is being searched for by name, queries for the
655
640
  list of steps in the experiment and caches them.
656
641
 
657
- :param step_name: The identifier for the step to return.
642
+ :param step_name: The name for the step to return.
658
643
  :param exception_on_none: If false, returns None if the entry can't be found. If true, raises an exception
659
644
  when the named entry doesn't exist in the experiment.
660
645
  :return: An ElnEntrySteps matching the provided name. If there is no match and no exception is to be thrown,
@@ -662,49 +647,31 @@ class ExperimentHandler:
662
647
  """
663
648
  return self.get_steps([step_name], exception_on_none)[0]
664
649
 
665
- def get_steps(self, step_names: Iterable[Step], exception_on_none: bool = True) -> list[ElnEntryStep | None]:
650
+ def get_steps(self, step_names: Iterable[str], exception_on_none: bool = True) -> list[ElnEntryStep | None]:
666
651
  """
667
652
  Get a list of steps of the given names from the experiment, sorted in the same order as the names are provided.
668
653
 
669
654
  If no step functions have been called before and a step is being searched for by name, queries for the
670
655
  list of steps in the experiment and caches them.
671
656
 
672
- :param step_names: A list of identifiers for the entries to return and the order to return them in.
657
+ :param step_names: A list of names for the entries to return and the order to return them in.
673
658
  :param exception_on_none: If false, returns None for entries that can't be found. If true, raises an exception
674
659
  when the named entry doesn't exist in the experiment.
675
660
  :return: A list of ElnEntrySteps matching the provided names in the order they were provided in. If there is no
676
661
  match for a given step and no exception is to be thrown, returns None for that step.
677
662
  """
678
- # CR-47700: Support getting steps from entry objects.
679
- step_ids: list[str | int] = []
680
- for step in step_names:
681
- # Convert any ElnEntrySteps into ExperimentEntries.
682
- if isinstance(step, ElnEntryStep):
683
- step: ExperimentEntry = step.eln_entry
684
- # If an ExperimentEntry is provided, then add its ID to the list of steps to return.
685
- if isinstance(step, ExperimentEntry):
686
- step_ids.append(step.entry_id)
687
- # Add this entry to the caches so that it doesn't need to be queried for. This will also verify that
688
- # this entry belongs to the experiment that this handler is for.
689
- self.add_entry_to_caches(step)
690
- elif isinstance(step, (str, int)):
691
- step_ids.append(step)
692
-
693
663
  ret_list: list[ElnEntryStep | None] = []
694
- for step_id in step_ids:
664
+ for name in step_names:
695
665
  # If we haven't queried the system for all steps in the experiment yet, then the reason that a step is
696
666
  # missing may be because it wasn't in the webhook context. Therefore, query all steps and then check
697
667
  # if the step name is still missing from the experiment before potentially throwing an exception.
698
- if not self._queried_all_steps:
699
- if ((isinstance(step_id, str) and step_id not in self._steps_by_name)
700
- or (isinstance(step_id, int) and step_id not in self._steps_by_id)):
701
- self._query_all_steps()
702
- if isinstance(step_id, str):
703
- step: ElnEntryStep = self._steps_by_name.get(step_id)
704
- else:
705
- step: ElnEntryStep = self._steps_by_id.get(step_id)
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)
706
673
  if step is None and exception_on_none is True:
707
- raise SapioException(f"ElnEntryStep of name \"{step_id}\" not found in experiment with ID {self._exp_id}.")
674
+ raise SapioException(f"ElnEntryStep of name \"{name}\" not found in experiment with ID {self._exp_id}.")
708
675
  ret_list.append(step)
709
676
  return ret_list
710
677
 
@@ -719,34 +686,14 @@ class ExperimentHandler:
719
686
  :return: Every entry in the experiment in order of appearance that match the provided data type, if any.
720
687
  """
721
688
  if self._queried_all_steps is False:
722
- self._query_all_steps()
723
- else:
724
- # Re-sort the steps in case any new steps were added before the last time that this was called.
725
- def sort_steps(step: ElnEntryStep) -> tuple:
726
- entry = step.eln_entry
727
- tab_order: int = self.get_tab_for_step(step).tab_order
728
- entry_order: int = entry.order
729
- column_order: int = entry.column_order
730
- return tab_order, entry_order, column_order
731
-
732
- self._steps.sort(key=sort_steps)
733
- all_steps: list[ElnEntryStep] = self._steps
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()
734
692
  if data_type is None:
735
693
  return all_steps
736
694
  data_type: str = AliasUtil.to_data_type_name(data_type)
737
695
  return [x for x in all_steps if data_type in x.get_data_type_names()]
738
696
 
739
- def _query_all_steps(self) -> None:
740
- """
741
- Query the system for every step in the experiment and cache them.
742
- """
743
- self._queried_all_steps = True
744
- self._protocol.invalidate()
745
- self._steps = self._protocol.get_sorted_step_list()
746
- for step in self._steps:
747
- self._steps_by_name[step.get_name()] = step
748
- self._steps_by_id[step.get_id()] = step
749
-
750
697
  def get_step_by_option(self, key: str, value: str | None = None) -> ElnEntryStep:
751
698
  """
752
699
  Retrieve the step in this experiment that contains an entry option with the provided key and value.
@@ -793,7 +740,7 @@ class ExperimentHandler:
793
740
  If given a name, throws an exception if no step of the given name exists in the experiment.
794
741
  :return: The data records for the given step.
795
742
  """
796
- return self.get_step(step).get_records()
743
+ return self.__to_eln_step(step).get_records()
797
744
 
798
745
  def get_step_models(self, step: Step, wrapper_type: type[WrappedType] | None = None) \
799
746
  -> list[WrappedType] | list[PyRecordModel]:
@@ -830,14 +777,13 @@ class ExperimentHandler:
830
777
  A list of records to add to the given step.
831
778
  The records may be provided as either DataRecords, PyRecordModels, or WrappedRecordModels.
832
779
  """
833
- step: ElnEntryStep = self.get_step(step)
780
+ step = self.__to_eln_step(step)
834
781
  if not records:
835
782
  return
836
783
  dt: str = AliasUtil.to_singular_data_type_name(records)
837
784
  if ElnBaseDataType.is_base_data_type(dt):
838
- raise SapioException(f"{dt} is an ELN data type. This function call has no effect on ELN data types. ELN "
839
- f"records that are committed to the system will automatically appear in the ELN entry "
840
- f"with the matching data type name.")
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.")
841
787
  if dt != step.get_data_type_names()[0]:
842
788
  raise SapioException(f"Cannot add {dt} records to entry {step.get_name()} of type "
843
789
  f"{step.get_data_type_names()[0]}.")
@@ -845,7 +791,8 @@ class ExperimentHandler:
845
791
 
846
792
  def remove_step_records(self, step: Step, records: Iterable[SapioRecord]) -> None:
847
793
  """
848
- Make a webservice call to remove a list of records from a step.
794
+ Make a webservice call to remove a list of records from a step. Only functions for global data type table
795
+ entries. For removing from an ELN data type table entry, see remove_eln_rows.
849
796
 
850
797
  If no step functions have been called before and a step is being searched for by name, queries for the
851
798
  list of steps in the experiment and caches them.
@@ -857,14 +804,13 @@ class ExperimentHandler:
857
804
  A list of records to remove from the given step.
858
805
  The records may be provided as either DataRecords, PyRecordModels, or WrappedRecordModels.
859
806
  """
860
- step: ElnEntryStep = self.get_step(step)
807
+ step = self.__to_eln_step(step)
861
808
  if not records:
862
809
  return
863
810
  dt: str = AliasUtil.to_singular_data_type_name(records)
864
811
  if ElnBaseDataType.is_base_data_type(dt):
865
- # CR-47532: Add remove_step_records support for Experiment Detail and Sample Detail entries.
866
- self.remove_eln_rows(step, records)
867
- return
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.")
868
814
  if dt != step.get_data_type_names()[0]:
869
815
  raise SapioException(f"Cannot remove {dt} records from entry {step.get_name()} of type "
870
816
  f"{step.get_data_type_names()[0]}.")
@@ -890,17 +836,12 @@ class ExperimentHandler:
890
836
  A list of records to set for the given step,
891
837
  The records may be provided as either DataRecords, PyRecordModels, or WrappedRecordModels.
892
838
  """
893
- step: ElnEntryStep = self.get_step(step)
839
+ step = self.__to_eln_step(step)
894
840
  if records:
895
841
  dt: str = AliasUtil.to_singular_data_type_name(records)
896
- # CR-47532: Add set_step_records support for Experiment Detail and Sample Detail entries.
897
842
  if ElnBaseDataType.is_base_data_type(dt):
898
- remove_rows: list[PyRecordModel] = []
899
- for record in self.get_step_models(step):
900
- if record not in records:
901
- remove_rows.append(record)
902
- self.remove_eln_rows(step, remove_rows)
903
- return
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.")
904
845
  if dt != step.get_data_type_names()[0]:
905
846
  raise SapioException(f"Cannot set {dt} records for entry {step.get_name()} of type "
906
847
  f"{step.get_data_type_names()[0]}.")
@@ -922,28 +863,11 @@ class ExperimentHandler:
922
863
  The record may be provided as either a DataRecord, PyRecordModel, or WrappedRecordModel.
923
864
  """
924
865
  self.set_step_records(step, [record])
925
- step: ElnEntryStep = self.get_step(step)
866
+ step = self.__to_eln_step(step)
926
867
  if isinstance(step.eln_entry, ExperimentFormEntry):
927
868
  step.eln_entry.record_id = AliasUtil.to_data_record(record).record_id
928
869
 
929
870
  # FR-46496 - Provide functions for adding and removing rows from an ELN data type entry.
930
- def add_eln_row(self, step: Step, wrapper_type: type[WrappedType] | None = None) -> WrappedType | PyRecordModel:
931
- """
932
- Add a row to an ELNExperimentDetail or ELNSampleDetail table entry. The row will not appear in the system
933
- until a record manager store and commit has occurred.
934
-
935
- If no step functions have been called before and a step is being searched for by name, queries for the
936
- list of steps in the experiment and caches them.
937
-
938
- :param step:
939
- The step may be provided as either a string for the name of the step or an ElnEntryStep.
940
- If given a name, throws an exception if no step of the given name exists in the experiment.
941
- :param wrapper_type: Optionally wrap the ELN data type in a record model wrapper. If not provided, returns
942
- an unwrapped PyRecordModel.
943
- :return: The newly created row.
944
- """
945
- return self.add_eln_rows(step, 1, wrapper_type)[0]
946
-
947
871
  def add_eln_rows(self, step: Step, count: int, wrapper_type: type[WrappedType] | None = None) \
948
872
  -> list[WrappedType] | list[PyRecordModel]:
949
873
  """
@@ -961,7 +885,7 @@ class ExperimentHandler:
961
885
  an unwrapped PyRecordModel.
962
886
  :return: A list of the newly created rows.
963
887
  """
964
- step: ElnEntryStep = self.get_step(step)
888
+ step = self.__to_eln_step(step)
965
889
  if step.eln_entry.entry_type != ElnEntryType.Table:
966
890
  raise SapioException("The provided step is not a table entry.")
967
891
  dt: str = step.get_data_type_names()[0]
@@ -972,64 +896,10 @@ class ExperimentHandler:
972
896
  return self._inst_man.wrap_list(records, wrapper_type)
973
897
  return records
974
898
 
975
- def add_sample_detail(self, step: Step, sample: RecordModel,
976
- wrapper_type: type[WrappedType] | None = None) \
977
- -> WrappedType | PyRecordModel:
978
- """
979
- Add a sample detail to a sample detail entry while relating it to the input sample record.
980
-
981
- :param step:
982
- The step may be provided as either a string for the name of the step or an ElnEntryStep.
983
- If given a name, throws an exception if no step of the given name exists in the experiment.
984
- :param sample: The sample record to add the sample detail to.
985
- :param wrapper_type: Optionally wrap the sample detail in a record model wrapper. If not provided, returns
986
- an unwrapped PyRecordModel.
987
- :return: The newly created sample detail.
988
- """
989
- return self.add_sample_details(step, [sample], wrapper_type)[0]
990
-
991
- def add_sample_details(self, step: Step, samples: Iterable[RecordModel],
992
- wrapper_type: type[WrappedType] | None = None) \
993
- -> list[WrappedType] | list[PyRecordModel]:
994
- """
995
- Add sample details to a sample details entry while relating them to the input sample records.
996
-
997
- :param step:
998
- The step may be provided as either a string for the name of the step or an ElnEntryStep.
999
- If given a name, throws an exception if no step of the given name exists in the experiment.
1000
- :param samples: The sample records to add the sample details to.
1001
- :param wrapper_type: Optionally wrap the sample details in a record model wrapper. If not provided, returns
1002
- an unwrapped PyRecordModel.
1003
- :return: The newly created sample details. The indices of the samples in the input list match the index of the
1004
- sample details in this list that they are related to.
1005
- """
1006
- step: ElnEntryStep = self.get_step(step)
1007
- if step.eln_entry.entry_type != ElnEntryType.Table:
1008
- raise SapioException("The provided step is not a table entry.")
1009
- dt: str = step.get_data_type_names()[0]
1010
- if not ElnBaseDataType.is_eln_type(dt) or ElnBaseDataType.get_base_type(dt) != ElnBaseDataType.SAMPLE_DETAIL:
1011
- raise SapioException("The provided step is not an ELNSampleDetail entry.")
1012
- records: list[PyRecordModel] = []
1013
- for sample in samples:
1014
- if sample.data_type_name != "Sample":
1015
- raise SapioException(f"Received a {sample.data_type_name} record when Sample records were expected.")
1016
- detail: PyRecordModel = sample.add(Child.create_by_name(dt))
1017
- detail.set_field_values({
1018
- "SampleId": sample.get_field_value("SampleId"),
1019
- "OtherSampleId": sample.get_field_value("OtherSampleId")
1020
- })
1021
- records.append(detail)
1022
- if wrapper_type:
1023
- return self._inst_man.wrap_list(records, wrapper_type)
1024
- return records
1025
-
1026
- def remove_eln_row(self, step: Step, record: SapioRecord) -> None:
899
+ def add_eln_row(self, step: Step, wrapper_type: type[WrappedType] | None = None) -> WrappedType | PyRecordModel:
1027
900
  """
1028
- Remove a row from an ELNExperimentDetail or ELNSampleDetail table entry. ELN data type table entries display all
1029
- records in the system that match the entry's data type. This means that removing rows from an ELN data type
1030
- table entry is equivalent to deleting the records for the rows.
1031
-
1032
- The row will not be deleted in the system until a record manager store and commit has occurred.
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.
1033
903
 
1034
904
  If no step functions have been called before and a step is being searched for by name, queries for the
1035
905
  list of steps in the experiment and caches them.
@@ -1037,13 +907,13 @@ class ExperimentHandler:
1037
907
  :param step:
1038
908
  The step may be provided as either a string for the name of the step or an ElnEntryStep.
1039
909
  If given a name, throws an exception if no step of the given name exists in the experiment.
1040
- :param record:
1041
- The record to remove from the given step.
1042
- The record may be provided as either a DataRecord, PyRecordModel, or WrappedRecordModel.
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.
1043
913
  """
1044
- self.remove_eln_rows(step, [record])
914
+ return self.add_eln_rows(step, 1, wrapper_type)[0]
1045
915
 
1046
- def remove_eln_rows(self, step: Step, records: Iterable[SapioRecord]) -> None:
916
+ def remove_eln_rows(self, step: Step, records: list[SapioRecord]) -> None:
1047
917
  """
1048
918
  Remove rows from an ELNExperimentDetail or ELNSampleDetail table entry. ELN data type table entries display all
1049
919
  records in the system that match the entry's data type. This means that removing rows from an ELN data type
@@ -1061,7 +931,7 @@ class ExperimentHandler:
1061
931
  A list of records to remove from the given step.
1062
932
  The records may be provided as either DataRecords, PyRecordModels, or WrappedRecordModels.
1063
933
  """
1064
- step: ElnEntryStep = self.get_step(step)
934
+ step = self.__to_eln_step(step)
1065
935
  dt: str = step.get_data_type_names()[0]
1066
936
  if not ElnBaseDataType.is_eln_type(dt):
1067
937
  raise SapioException("The provided step is not an ELN data type entry.")
@@ -1084,6 +954,61 @@ class ExperimentHandler:
1084
954
  for record in record_models:
1085
955
  record.delete()
1086
956
 
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
+
1087
1012
  # noinspection PyPep8Naming
1088
1013
  def update_step(self, step: Step,
1089
1014
  entry_name: str | None = None,
@@ -1151,45 +1076,128 @@ class ExperimentHandler:
1151
1076
  If you wish to add options to the existing map of options that an entry has, use the
1152
1077
  add_step_options method.
1153
1078
  """
1154
- # FR-47468: Deprecating this since the entry-specific update criteria should be used instead.
1155
- warnings.warn("Update step is deprecated. Use force_entry_update instead.",
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.",
1156
1082
  DeprecationWarning)
1157
- step: ElnEntryStep = self.get_step(step)
1158
- update = AbstractElnEntryUpdateCriteria(step.eln_entry.entry_type)
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)
1159
1106
 
1160
- # These two variables could be iterables that aren't lists. Convert them to plain
1161
- # lists, since that's what the update criteria is expecting.
1162
- if related_entry_set is not None:
1163
- related_entry_set = list(related_entry_set)
1164
- if dependency_set is not None:
1165
- dependency_set = list(dependency_set)
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.
1166
1134
 
1167
- update.entry_name = entry_name
1168
- update.related_entry_set = related_entry_set
1169
- update.dependency_set = dependency_set
1170
- update.entry_status = entry_status
1171
- update.order = order
1172
- update.description = description
1173
- update.requires_grabber_plugin = requires_grabber_plugin
1174
- update.is_initialization_required = is_initialization_required
1175
- update.notebook_experiment_tab_id = notebook_experiment_tab_id
1176
- update.entry_height = entry_height
1177
- update.column_order = column_order
1178
- update.column_span = column_span
1179
- update.is_removable = is_removable
1180
- update.is_renamable = is_renamable
1181
- update.source_entry_id = source_entry_id
1182
- update.clear_source_entry_id = clear_source_entry_id
1183
- update.is_hidden = is_hidden
1184
- update.is_static_View = is_static_View
1185
- update.is_shown_in_template = is_shown_in_template
1186
- update.template_item_fulfilled_timestamp = template_item_fulfilled_timestamp
1187
- update.clear_template_item_fulfilled_timestamp = clear_template_item_fulfilled_timestamp
1188
- update.entry_options_map = entry_options_map
1135
+ Consider using store_step_update and commit_step_updates instead if the update does not need to be immediate.
1136
+
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.
1189
1139
 
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)
1190
1199
  self.force_step_update(step, update)
1191
1200
 
1192
- # FR-47468: Some functions that can help with entry updates.
1193
1201
  def force_step_update(self, step: Step, update: AbstractElnEntryUpdateCriteria) -> None:
1194
1202
  """
1195
1203
  Immediately sent an update to an entry in this experiment. All changes will be reflected by the ExperimentEntry
@@ -1205,7 +1213,7 @@ class ExperimentHandler:
1205
1213
  If given a name, throws an exception if no step of the given name exists in the experiment.
1206
1214
  :param update: The update to make to the step.
1207
1215
  """
1208
- step: ElnEntryStep = self.get_step(step)
1216
+ step = self.__to_eln_step(step)
1209
1217
  self._eln_man.update_experiment_entry(self._exp_id, step.get_id(), update)
1210
1218
  self._update_entry_details(step, update)
1211
1219
 
@@ -1225,7 +1233,7 @@ class ExperimentHandler:
1225
1233
  If given a name, throws an exception if no step of the given name exists in the experiment.
1226
1234
  :param update: The update to make to the step.
1227
1235
  """
1228
- step: ElnEntryStep = self.get_step(step)
1236
+ step = self.__to_eln_step(step)
1229
1237
  if step.eln_entry.entry_type != update.entry_type:
1230
1238
  raise SapioException(f"The provided step and update criteria are not of the same entry type. "
1231
1239
  f"The step is of type {step.eln_entry.entry_type} and the update criteria is of type "
@@ -1256,13 +1264,72 @@ class ExperimentHandler:
1256
1264
  Commit all the stored updates to the entries in this experiment. The updates are made in the order that they
1257
1265
  were stored.
1258
1266
  """
1259
- if not self._step_updates:
1260
- return
1261
1267
  self._eln_man.update_experiment_entries(self._exp_id, self._step_updates)
1262
1268
  for step_id, criteria in self._step_updates.items():
1263
1269
  self._update_entry_details(self._steps_by_id[step_id], criteria)
1264
1270
  self._step_updates.clear()
1265
1271
 
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
+
1266
1333
  @staticmethod
1267
1334
  def _merge_updates(new_update: AbstractElnEntryUpdateCriteria, old_update: AbstractElnEntryUpdateCriteria) -> None:
1268
1335
  """
@@ -1280,10 +1347,10 @@ class ExperimentHandler:
1280
1347
  entry: ExperimentEntry = step.eln_entry
1281
1348
  if update.entry_name is not None:
1282
1349
  # PR-46477 - Ensure that the previous name of the updated entry already existed in the cache.
1283
- if entry.entry_name in self._steps_by_name:
1284
- self._steps_by_name.pop(entry.entry_name)
1350
+ if entry.entry_name in self._steps:
1351
+ self._steps.pop(entry.entry_name)
1285
1352
  entry.entry_name = update.entry_name
1286
- self._steps_by_name.update({update.entry_name: step})
1353
+ self._steps.update({update.entry_name: step})
1287
1354
  if update.related_entry_set is not None:
1288
1355
  entry.related_entry_id_set = update.related_entry_set
1289
1356
  if update.dependency_set is not None:
@@ -1424,7 +1491,7 @@ class ExperimentHandler:
1424
1491
  If given a name, throws an exception if no step of the given name exists in the experiment.
1425
1492
  :return: The map of options for the input step.
1426
1493
  """
1427
- step: ElnEntryStep = self.get_step(step)
1494
+ step = self.__to_eln_step(step)
1428
1495
  if step not in self._step_options:
1429
1496
  self._step_options.update(ExperimentReportUtil.get_experiment_entry_options(self.user,
1430
1497
  self.get_all_steps()))
@@ -1447,16 +1514,12 @@ class ExperimentHandler:
1447
1514
  The step may be provided as either a string for the name of the step or an ElnEntryStep.
1448
1515
  If given a name, throws an exception if no step of the given name exists in the experiment.
1449
1516
  :param mapping: The new options and values to add to the existing step options, provided as some Mapping
1450
- (e.g. a dictionary). If an option key already exists and is provided in the mapping, overwrites the existing
1451
- value for that key.
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.
1452
1519
  """
1453
- # PR-47698: Convert the given step to an ElnEntryStep if it is not already one.
1454
- step: ElnEntryStep = self.get_step(step)
1455
1520
  options: dict[str, str] = self.get_step_options(step)
1456
1521
  options.update(mapping)
1457
- update = AbstractElnEntryUpdateCriteria(step.eln_entry.entry_type)
1458
- update.entry_options_map = options
1459
- self.force_step_update(step, update)
1522
+ self.force_step_update_params(step, entry_options_map=options)
1460
1523
 
1461
1524
  def initialize_step(self, step: Step) -> None:
1462
1525
  """
@@ -1472,11 +1535,9 @@ class ExperimentHandler:
1472
1535
  If given a name, throws an exception if no step of the given name exists in the experiment.
1473
1536
  """
1474
1537
  # Avoid unnecessary calls if the step is already initialized.
1475
- step: ElnEntryStep = self.get_step(step)
1538
+ step: ElnEntryStep = self.__to_eln_step(step)
1476
1539
  if step.eln_entry.template_item_fulfilled_timestamp is None:
1477
- update = AbstractElnEntryUpdateCriteria(step.eln_entry.entry_type)
1478
- update.template_item_fulfilled_timestamp = TimeUtil.now_in_millis()
1479
- self.force_step_update(step, update)
1540
+ self.force_step_update_params(step, template_item_fulfilled_timestamp=TimeUtil.now_in_millis())
1480
1541
 
1481
1542
  def uninitialize_step(self, step: Step) -> None:
1482
1543
  """
@@ -1492,11 +1553,9 @@ class ExperimentHandler:
1492
1553
  If given a name, throws an exception if no step of the given name exists in the experiment.
1493
1554
  """
1494
1555
  # Avoid unnecessary calls if the step is already uninitialized.
1495
- step: ElnEntryStep = self.get_step(step)
1556
+ step: ElnEntryStep = self.__to_eln_step(step)
1496
1557
  if step.eln_entry.template_item_fulfilled_timestamp is not None:
1497
- update = AbstractElnEntryUpdateCriteria(step.eln_entry.entry_type)
1498
- update.clear_template_item_fulfilled_timestamp = True
1499
- self.force_step_update(step, update)
1558
+ self.force_step_update_params(step, clear_template_item_fulfilled_timestamp=True)
1500
1559
 
1501
1560
  def complete_step(self, step: Step) -> None:
1502
1561
  """
@@ -1511,7 +1570,7 @@ class ExperimentHandler:
1511
1570
  The step may be provided as either a string for the name of the step or an ElnEntryStep.
1512
1571
  If given a name, throws an exception if no step of the given name exists in the experiment.
1513
1572
  """
1514
- step: ElnEntryStep = self.get_step(step)
1573
+ step = self.__to_eln_step(step)
1515
1574
  if step.eln_entry.entry_status not in self._ENTRY_COMPLETE_STATUSES:
1516
1575
  step.complete_step()
1517
1576
  step.eln_entry.entry_status = ExperimentEntryStatus.Completed
@@ -1529,7 +1588,7 @@ class ExperimentHandler:
1529
1588
  The step may be provided as either a string for the name of the step or an ElnEntryStep.
1530
1589
  If given a name, throws an exception if no step of the given name exists in the experiment.
1531
1590
  """
1532
- step: ElnEntryStep = self.get_step(step)
1591
+ step = self.__to_eln_step(step)
1533
1592
  if step.eln_entry.entry_status in self._ENTRY_LOCKED_STATUSES:
1534
1593
  step.unlock_step()
1535
1594
  step.eln_entry.entry_status = ExperimentEntryStatus.UnlockedChangesRequired
@@ -1551,11 +1610,9 @@ class ExperimentHandler:
1551
1610
  The step may be provided as either a string for the name of the step or an ElnEntryStep.
1552
1611
  If given a name, throws an exception if no step of the given name exists in the experiment.
1553
1612
  """
1554
- step: ElnEntryStep = self.get_step(step)
1613
+ step = self.__to_eln_step(step)
1555
1614
  if step.eln_entry.entry_status in self._ENTRY_LOCKED_STATUSES:
1556
- update = AbstractElnEntryUpdateCriteria(step.eln_entry.entry_type)
1557
- update.entry_status = ExperimentEntryStatus.Disabled
1558
- self.force_step_update(step, update)
1615
+ self.force_step_update_params(step, entry_status=ExperimentEntryStatus.Disabled)
1559
1616
 
1560
1617
  def step_is_submitted(self, step: Step) -> bool:
1561
1618
  """
@@ -1570,7 +1627,7 @@ class ExperimentHandler:
1570
1627
  If given a name, throws an exception if no step of the given name exists in the experiment.
1571
1628
  :return: True if the step's status is Completed or CompletedApproved. False otherwise.
1572
1629
  """
1573
- return self.get_step(step).eln_entry.entry_status in self._ENTRY_COMPLETE_STATUSES
1630
+ return self.__to_eln_step(step).eln_entry.entry_status in self._ENTRY_COMPLETE_STATUSES
1574
1631
 
1575
1632
  def step_is_locked(self, step: Step) -> bool:
1576
1633
  """
@@ -1586,7 +1643,7 @@ class ExperimentHandler:
1586
1643
  :return: True if the step's status is Completed, CompletedApproved, Disabled, LockedAwaitingApproval,
1587
1644
  or LockedRejected. False otherwise.
1588
1645
  """
1589
- return self.get_step(step).eln_entry.entry_status in self._ENTRY_LOCKED_STATUSES
1646
+ return self.__to_eln_step(step).eln_entry.entry_status in self._ENTRY_LOCKED_STATUSES
1590
1647
 
1591
1648
  # FR-47464: Some functions that can help with entry placement.
1592
1649
  def get_all_tabs(self) -> list[ElnExperimentTab]:
@@ -1599,7 +1656,6 @@ class ExperimentHandler:
1599
1656
  if not self._queried_all_tabs:
1600
1657
  self._tabs = self._eln_man.get_tabs_for_experiment(self._exp_id)
1601
1658
  self._tabs.sort(key=lambda t: t.tab_order)
1602
- self._tabs_by_id = {tab.tab_id: tab for tab in self._tabs}
1603
1659
  self._tabs_by_name = {tab.tab_name: tab for tab in self._tabs}
1604
1660
  return self._tabs
1605
1661
 
@@ -1633,34 +1689,21 @@ class ExperimentHandler:
1633
1689
  self.add_tab_to_cache(tab)
1634
1690
  return tab
1635
1691
 
1636
- def get_tab(self, tab: str | int, exception_on_none: bool = True) -> ElnExperimentTab:
1692
+ def get_tab(self, tab_name: str) -> ElnExperimentTab:
1637
1693
  """
1638
1694
  Return the tab with the input name.
1639
1695
 
1640
1696
  If no tab functions have been called before and a tab is being searched for by name, queries for the
1641
1697
  list of tabs in the experiment and caches them.
1642
1698
 
1643
- :param tab: The name or order of the tab to get. The order is 1-indexed.
1644
- :param exception_on_none: If True, raises an exception if no tab with the given name exists.
1645
- :return: The tab with the input name, or None if no such tab exists.
1646
- """
1647
- if isinstance(tab, str):
1648
- if tab not in self._tabs_by_name:
1649
- self.get_all_tabs()
1650
- eln_tab = self._tabs_by_name.get(tab)
1651
- elif isinstance(tab, int):
1652
- # The given integer is expected to be 1-indexed, but we read from the list with a 0-index.
1653
- tab -= 1
1654
- tabs = self.get_all_tabs()
1655
- eln_tab = tabs[tab] if len(tabs) > tab else None
1656
- else:
1657
- raise SapioException(f"Tab must be a string or an integer, not {type(tab)}.")
1658
- if eln_tab is None and exception_on_none:
1659
- raise SapioException(f"No tab with the name\\order \"{tab}\" exists in this experiment.")
1660
- return eln_tab
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]
1661
1705
 
1662
- def get_steps_in_tab(self, tab: Tab, data_type: DataTypeIdentifier | None = None) \
1663
- -> list[ElnEntryStep]:
1706
+ def get_steps_in_tab(self, tab: Tab, data_type: DataTypeIdentifier | None = None) -> list[ElnEntryStep]:
1664
1707
  """
1665
1708
  Get all the steps in the input tab sorted in order of appearance.
1666
1709
 
@@ -1670,8 +1713,7 @@ class ExperimentHandler:
1670
1713
  If the steps in the experiment have not been queried before, queries for the list of steps in the experiment
1671
1714
  and caches them.
1672
1715
 
1673
- :param tab: The tab to get the steps of. This can be the tab's order, name, or the tab object itself.
1674
- The order is 1-indexed.
1716
+ :param tab: The tab or tab name to get the steps of.
1675
1717
  :param data_type: The data type to filter the steps by. If None, all steps are returned.
1676
1718
  :return: A list of all the steps in the input tab sorted in order of appearance.
1677
1719
  """
@@ -1680,30 +1722,9 @@ class ExperimentHandler:
1680
1722
  for step in self.get_all_steps(data_type):
1681
1723
  if step.eln_entry.notebook_experiment_tab_id == tab.tab_id:
1682
1724
  steps.append(step)
1725
+ steps.sort(key=lambda s: (s.eln_entry.order, s.eln_entry.column_order))
1683
1726
  return steps
1684
1727
 
1685
- def get_tab_for_step(self, step: Step) -> ElnExperimentTab:
1686
- """
1687
- Get the tab that a particular step is located in.
1688
-
1689
- If no tab functions have been called before and a tab is being searched for by name, queries for the
1690
- list of tabs in the experiment and caches them.
1691
-
1692
- If the steps in the experiment have not been queried before, queries for the list of steps in the experiment
1693
- and caches them.
1694
-
1695
- :param step:
1696
- The step to get the position of.
1697
- The step may be provided as either a string for the name of the step or an ElnEntryStep.
1698
- If given a name, throws an exception if no step of the given name exists in the experiment.
1699
- :return: The tab that the input step is located in.
1700
- """
1701
- step: ElnEntryStep = self.get_step(step)
1702
- tab_id = step.eln_entry.notebook_experiment_tab_id
1703
- if tab_id not in self._tabs_by_id:
1704
- self.get_all_tabs()
1705
- return self._tabs_by_id.get(tab_id)
1706
-
1707
1728
  def get_next_entry_order_in_tab(self, tab: Tab) -> int:
1708
1729
  """
1709
1730
  Get the next available order for a new entry in the input tab.
@@ -1714,81 +1735,419 @@ class ExperimentHandler:
1714
1735
  If the steps in the experiment have not been queried before, queries for the list of steps in the experiment
1715
1736
  and caches them.
1716
1737
 
1717
- :param tab: The tab to get the steps of. This can be the tab's order, name, or the tab object itself.
1718
- The order is 1-indexed.
1738
+ :param tab: The tab or tab name to get the steps of.
1719
1739
  :return: The next available order for a new entry in the input tab.
1720
1740
  """
1721
1741
  steps = self.get_steps_in_tab(tab)
1722
1742
  return steps[-1].eln_entry.order + 1 if steps else 0
1723
1743
 
1724
- # FR-47530: Add functions for dealing with entry positioning.
1725
- def step_to_position(self, step: Step) -> ElnEntryPosition:
1726
- """
1727
- Get the position of the input step in the experiment.
1728
-
1729
- If no step functions have been called before and a step is being searched for by name, queries for the
1730
- list of steps in the experiment and caches them.
1731
-
1732
- :param step:
1733
- The step to get the position of.
1734
- The step may be provided as either a string for the name of the step or an ElnEntryStep.
1735
- If given a name, throws an exception if no step of the given name exists in the experiment.
1736
- :return: The position of the input step in the experiment.
1737
- """
1738
- step: ElnEntryStep = self.get_step(step)
1739
- entry: ExperimentEntry = step.eln_entry
1740
- return ElnEntryPosition(entry.notebook_experiment_tab_id,
1741
- entry.order,
1742
- entry.column_span,
1743
- entry.column_order)
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:
2017
+ """
2018
+ Create a new temp data entry in the experiment.
2019
+
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)
2028
+
2029
+ def create_text_step(self, entry_name: str, text: str | None = None, *,
2030
+ position: ElnEntryPosition | None = None) -> ElnEntryStep:
2031
+ """
2032
+ Create a new text entry in the experiment.
2033
+
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.
2038
+ """
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
2046
+
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
2073
+
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:
2080
+ """
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
1744
2094
 
1745
- def step_at_position(self, position: ElnEntryPosition) -> Step | None:
1746
- """
1747
- Get the step at the input position in the experiment.
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)
1748
2102
 
1749
- If no step functions have been called before and a step is being searched for by name, queries for the
1750
- list of steps in the experiment and caches them.
2103
+ self.add_entry_to_caches(entry)
2104
+ return ElnEntryStep(self._protocol, entry)
1751
2105
 
1752
- :param position: The position to get the step at.
1753
- :return: The step at the input position in the experiment, or None if no step exists at that position.
2106
+ def _to_field_defs(self, fields: list[ElnDataTypeFields], dt: ElnBaseDataType) \
2107
+ -> list[AbstractVeloxFieldDefinition] | None:
1754
2108
  """
1755
- if position.tab_id is None or position.order is None:
1756
- raise SapioException("The provided position must at least have a tab ID and order.")
1757
- for step in self.get_steps_in_tab(position.tab_id):
1758
- entry: ExperimentEntry = step.eln_entry
1759
- if entry.order != position.order:
1760
- continue
1761
- if position.column_span is not None and entry.column_span != position.column_span:
1762
- continue
1763
- if position.column_order is not None and entry.column_order != position.column_order:
1764
- continue
1765
- return step
1766
- return None
1767
-
1768
- # FR-47530: Create a function for adding protocol templates to the experiment.
1769
- def add_protocol(self, protocol: ProtocolTemplateInfo | int, position: ElnEntryPosition) -> list[ElnEntryStep]:
2109
+ Convert a list of ElnDataTypeField aliases to field definitions.
1770
2110
  """
1771
- Add a protocol to the experiment. Updates the handler cache with the newly created entries.
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]
2134
+
2135
+ def __to_eln_step(self, step: Step) -> ElnEntryStep:
2136
+ """
2137
+ Convert a variable that could be either a string or an ElnEntryStep to just an ElnEntryStep.
2138
+ This will query and cache the steps for the experiment if the input step is a name and the steps have not been
2139
+ cached before.
1772
2140
 
1773
- :param protocol: The protocol to add. This can be either a ProtocolTemplateInfo object or the ID of the
1774
- protocol template.
1775
- :param position: The position that the protocol's first entry will be placed at.
1776
- :return: The newly created protocol entries.
2141
+ :return: The input step as an ElnEntryStep.
1777
2142
  """
1778
- protocol = protocol if isinstance(protocol, int) else protocol.template_id
1779
- new_entries: list[ExperimentEntry] = self._eln_man.add_protocol_template(self._exp_id, protocol, position)
1780
- return self.add_entries_to_caches(new_entries)
1781
-
1782
- # CR-47700: Deleted __to_eln_step since it became redundant with the get_step method.
2143
+ return self.get_step(step) if isinstance(step, str) else step
1783
2144
 
1784
2145
  def __to_eln_tab(self, tab: Tab) -> ElnExperimentTab:
1785
2146
  """
1786
- Convert a variable that could be either a tab name, tab order, or ElnExperimentTab to just a tab object.
2147
+ Convert a variable that could be either a string or an ElnExperimentTab to just an ElnExperimentTab.
1787
2148
  This will query and cache the tabs for the experiment if the input tab is a name and the tabs have not been
1788
2149
  cached before.
1789
2150
 
1790
2151
  :return: The input tab as an ElnExperimentTab.
1791
2152
  """
1792
- if not isinstance(tab, ElnExperimentTab):
1793
- return self.get_tab(tab)
1794
- return tab
2153
+ return self.get_tab(tab) if isinstance(tab, str) else tab