sapiopycommons 2025.5.6a512__py3-none-any.whl → 2025.5.7a514__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of sapiopycommons might be problematic. Click here for more details.

Files changed (54) hide show
  1. sapiopycommons/callbacks/callback_util.py +116 -64
  2. sapiopycommons/callbacks/field_builder.py +2 -0
  3. sapiopycommons/customreport/auto_pagers.py +2 -1
  4. sapiopycommons/customreport/term_builder.py +1 -1
  5. sapiopycommons/datatype/pseudo_data_types.py +349 -326
  6. sapiopycommons/eln/experiment_cache.py +188 -0
  7. sapiopycommons/eln/experiment_handler.py +336 -719
  8. sapiopycommons/eln/experiment_step_factory.py +476 -0
  9. sapiopycommons/eln/plate_designer.py +7 -2
  10. sapiopycommons/eln/step_creation.py +236 -0
  11. sapiopycommons/files/file_util.py +4 -4
  12. sapiopycommons/general/accession_service.py +2 -2
  13. sapiopycommons/general/aliases.py +4 -1
  14. sapiopycommons/general/data_structure_util.py +115 -0
  15. sapiopycommons/general/sapio_links.py +4 -12
  16. sapiopycommons/processtracking/custom_workflow_handler.py +2 -1
  17. sapiopycommons/recordmodel/record_handler.py +357 -27
  18. sapiopycommons/rules/eln_rule_handler.py +8 -1
  19. sapiopycommons/rules/on_save_rule_handler.py +8 -1
  20. sapiopycommons/webhook/webhook_handlers.py +3 -0
  21. sapiopycommons/webhook/webservice_handlers.py +2 -2
  22. {sapiopycommons-2025.5.6a512.dist-info → sapiopycommons-2025.5.7a514.dist-info}/METADATA +2 -2
  23. sapiopycommons-2025.5.7a514.dist-info/RECORD +67 -0
  24. sapiopycommons/ai/__init__.py +0 -0
  25. sapiopycommons/ai/api/fielddefinitions/proto/fields_pb2.py +0 -43
  26. sapiopycommons/ai/api/fielddefinitions/proto/fields_pb2.pyi +0 -31
  27. sapiopycommons/ai/api/fielddefinitions/proto/fields_pb2_grpc.py +0 -24
  28. sapiopycommons/ai/api/fielddefinitions/proto/velox_field_def_pb2.py +0 -123
  29. sapiopycommons/ai/api/fielddefinitions/proto/velox_field_def_pb2.pyi +0 -598
  30. sapiopycommons/ai/api/fielddefinitions/proto/velox_field_def_pb2_grpc.py +0 -24
  31. sapiopycommons/ai/api/plan/proto/step_output_pb2.py +0 -45
  32. sapiopycommons/ai/api/plan/proto/step_output_pb2.pyi +0 -42
  33. sapiopycommons/ai/api/plan/proto/step_output_pb2_grpc.py +0 -24
  34. sapiopycommons/ai/api/plan/proto/step_pb2.py +0 -43
  35. sapiopycommons/ai/api/plan/proto/step_pb2.pyi +0 -43
  36. sapiopycommons/ai/api/plan/proto/step_pb2_grpc.py +0 -24
  37. sapiopycommons/ai/api/plan/script/proto/script_pb2.py +0 -53
  38. sapiopycommons/ai/api/plan/script/proto/script_pb2.pyi +0 -99
  39. sapiopycommons/ai/api/plan/script/proto/script_pb2_grpc.py +0 -153
  40. sapiopycommons/ai/api/plan/tool/proto/entry_pb2.py +0 -57
  41. sapiopycommons/ai/api/plan/tool/proto/entry_pb2.pyi +0 -96
  42. sapiopycommons/ai/api/plan/tool/proto/entry_pb2_grpc.py +0 -24
  43. sapiopycommons/ai/api/plan/tool/proto/tool_pb2.py +0 -67
  44. sapiopycommons/ai/api/plan/tool/proto/tool_pb2.pyi +0 -220
  45. sapiopycommons/ai/api/plan/tool/proto/tool_pb2_grpc.py +0 -154
  46. sapiopycommons/ai/api/session/proto/sapio_conn_info_pb2.py +0 -39
  47. sapiopycommons/ai/api/session/proto/sapio_conn_info_pb2.pyi +0 -32
  48. sapiopycommons/ai/api/session/proto/sapio_conn_info_pb2_grpc.py +0 -24
  49. sapiopycommons/ai/protobuf_utils.py +0 -454
  50. sapiopycommons/ai/tool_service_base.py +0 -708
  51. sapiopycommons/general/html_formatter.py +0 -456
  52. sapiopycommons-2025.5.6a512.dist-info/RECORD +0 -91
  53. {sapiopycommons-2025.5.6a512.dist-info → sapiopycommons-2025.5.7a514.dist-info}/WHEEL +0 -0
  54. {sapiopycommons-2025.5.6a512.dist-info → sapiopycommons-2025.5.7a514.dist-info}/licenses/LICENSE +0 -0
@@ -1,7 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import io
3
4
  import warnings
4
5
  from collections.abc import Iterable
6
+ from typing import Collection
5
7
  from weakref import WeakValueDictionary
6
8
 
7
9
  from sapiopylib.rest.DataRecordManagerService import DataRecordManager
@@ -13,19 +15,28 @@ from sapiopylib.rest.pojo.datatype.FieldDefinition import FieldType
13
15
  from sapiopylib.rest.pojo.eln.SapioELNEnums import ElnBaseDataType
14
16
  from sapiopylib.rest.utils.autopaging import QueryDataRecordsAutoPager, QueryDataRecordByIdListAutoPager, \
15
17
  QueryAllRecordsOfTypeAutoPager
16
- from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel
18
+ from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel, AbstractRecordModelPropertyGetter, \
19
+ RecordModelPropertyType, AbstractRecordModelPropertyAdder, AbstractRecordModelPropertySetter, \
20
+ AbstractRecordModelPropertyRemover
17
21
  from sapiopylib.rest.utils.recordmodel.RecordModelManager import RecordModelManager, RecordModelInstanceManager, \
18
22
  RecordModelRelationshipManager
19
23
  from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType, WrappedRecordModel
20
24
  from sapiopylib.rest.utils.recordmodel.RelationshipPath import RelationshipPath, RelationshipNode, \
21
25
  RelationshipNodeType
22
26
  from sapiopylib.rest.utils.recordmodel.ancestry import RecordModelAncestorManager
27
+ from sapiopylib.rest.utils.recordmodel.properties import Parents, Parent, Children, Child, ForwardSideLink
23
28
 
24
29
  from sapiopycommons.general.aliases import RecordModel, SapioRecord, FieldMap, FieldIdentifier, AliasUtil, \
25
- FieldIdentifierMap, FieldValue, UserIdentifier, FieldIdentifierKey
30
+ FieldIdentifierMap, FieldValue, UserIdentifier, FieldIdentifierKey, DataTypeIdentifier
26
31
  from sapiopycommons.general.custom_report_util import CustomReportUtil
27
32
  from sapiopycommons.general.exceptions import SapioException
28
33
 
34
+ # Aliases for longer name.
35
+ _PropertyGetter = AbstractRecordModelPropertyGetter
36
+ _PropertyAdder = AbstractRecordModelPropertyAdder
37
+ _PropertyRemover = AbstractRecordModelPropertyRemover
38
+ _PropertySetter = AbstractRecordModelPropertySetter
39
+ _PropertyType = RecordModelPropertyType
29
40
 
30
41
  # FR-46064 - Initial port of PyWebhookUtils to sapiopycommons.
31
42
  class RecordHandler:
@@ -58,12 +69,11 @@ class RecordHandler:
58
69
  """
59
70
  :param context: The current webhook context or a user object to send requests from.
60
71
  """
61
- self.user = AliasUtil.to_sapio_user(context)
62
72
  if self.__initialized:
63
73
  return
64
74
  self.__initialized = True
65
75
 
66
- self.user = context if isinstance(context, SapioUser) else context.user
76
+ self.user = AliasUtil.to_sapio_user(context)
67
77
  self.dr_man = DataRecordManager(self.user)
68
78
  self.rec_man = RecordModelManager(self.user)
69
79
  self.inst_man = self.rec_man.instance_manager
@@ -104,8 +114,10 @@ class RecordHandler:
104
114
  return [self.wrap_model(x, wrapper_type) for x in records]
105
115
 
106
116
  # CR-47491: Support providing a data type name string to receive PyRecordModels instead of requiring a WrapperType.
117
+ # CR-47523: Support a singular field value being provided for the value_list parameter.
107
118
  def query_models(self, wrapper_type: type[WrappedType] | str, field: FieldIdentifier,
108
- value_list: Iterable[FieldValue], page_limit: int | None = None, page_size: int | None = None) \
119
+ value_list: Iterable[FieldValue] | FieldValue,
120
+ page_limit: int | None = None, page_size: int | None = None) \
109
121
  -> list[WrappedType] | list[PyRecordModel]:
110
122
  """
111
123
  Shorthand for using the data record manager to query for a list of data records by field value
@@ -113,7 +125,9 @@ class RecordHandler:
113
125
 
114
126
  :param wrapper_type: The record model wrapper to use, or the data type name of the records.
115
127
  :param field: The field to query on.
116
- :param value_list: The values of the field to query on.
128
+ :param value_list: The values of the field to query on, or a singular field value that will be automatically
129
+ converted to a singleton list. Note that field values of None are not supported by this method and will be
130
+ ignored. If you need to query for records with a null field value, use a custom report.
117
131
  :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages. This parameter
118
132
  only functions if you set a page size or the platform enforces a page size.
119
133
  :param page_size: The size of the pages to query. If None, the page size may be limited by the platform.
@@ -126,8 +140,10 @@ class RecordHandler:
126
140
  return self.query_models_with_criteria(wrapper_type, field, value_list, criteria, page_limit)[0]
127
141
 
128
142
  def query_and_map_models(self, wrapper_type: type[WrappedType] | str, field: FieldIdentifier,
129
- value_list: Iterable[FieldValue], page_limit: int | None = None,
130
- page_size: int | None = None, *, mapping_field: FieldIdentifier | None = None) \
143
+ value_list: Iterable[FieldValue] | FieldValue,
144
+ page_limit: int | None = None, page_size: int | None = None,
145
+ *,
146
+ mapping_field: FieldIdentifier | None = None) \
131
147
  -> dict[FieldValue, list[WrappedType] | list[PyRecordModel]]:
132
148
  """
133
149
  Shorthand for using query_models to search for records given values on a specific field and then using
@@ -135,7 +151,9 @@ class RecordHandler:
135
151
 
136
152
  :param wrapper_type: The record model wrapper to use, or the data type name of the records.
137
153
  :param field: The field to query and map on.
138
- :param value_list: The values of the field to query on.
154
+ :param value_list: The values of the field to query on, or a singular field value that will be automatically
155
+ converted to a singleton list. Note that field values of None are not supported by this method and will be
156
+ ignored. If you need to query for records with a null field value, use a custom report.
139
157
  :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages. This parameter
140
158
  only functions if you set a page size or the platform enforces a page size.
141
159
  :param page_size: The size of the pages to query. If None, the page size may be limited by the platform.
@@ -150,8 +168,10 @@ class RecordHandler:
150
168
  mapping_field)
151
169
 
152
170
  def query_and_unique_map_models(self, wrapper_type: type[WrappedType] | str, field: FieldIdentifier,
153
- value_list: Iterable[FieldValue], page_limit: int | None = None,
154
- page_size: int | None = None, *, mapping_field: FieldIdentifier | None = None) \
171
+ value_list: Iterable[FieldValue] | FieldValue,
172
+ page_limit: int | None = None, page_size: int | None = None,
173
+ *,
174
+ mapping_field: FieldIdentifier | None = None) \
155
175
  -> dict[FieldValue, WrappedType | PyRecordModel]:
156
176
  """
157
177
  Shorthand for using query_models to search for records given values on a specific field and then using
@@ -160,7 +180,9 @@ class RecordHandler:
160
180
 
161
181
  :param wrapper_type: The record model wrapper to use, or the data type name of the records.
162
182
  :param field: The field to query and map on.
163
- :param value_list: The values of the field to query on.
183
+ :param value_list: The values of the field to query on, or a singular field value that will be automatically
184
+ converted to a singleton list. Note that field values of None are not supported by this method and will be
185
+ ignored. If you need to query for records with a null field value, use a custom report.
164
186
  :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages. This parameter
165
187
  only functions if you set a page size or the platform enforces a page size.
166
188
  :param page_size: The size of the pages to query. If None, the page size may be limited by the platform.
@@ -175,7 +197,7 @@ class RecordHandler:
175
197
  mapping_field)
176
198
 
177
199
  def query_models_with_criteria(self, wrapper_type: type[WrappedType] | str, field: FieldIdentifier,
178
- value_list: Iterable[FieldValue],
200
+ value_list: Iterable[FieldValue] | FieldValue,
179
201
  paging_criteria: DataRecordPojoPageCriteria | None = None,
180
202
  page_limit: int | None = None) \
181
203
  -> tuple[list[WrappedType] | list[PyRecordModel], DataRecordPojoPageCriteria]:
@@ -185,7 +207,9 @@ class RecordHandler:
185
207
 
186
208
  :param wrapper_type: The record model wrapper to use, or the data type name of the records.
187
209
  :param field: The field to query on.
188
- :param value_list: The values of the field to query on.
210
+ :param value_list: The values of the field to query on, or a singular field value that will be automatically
211
+ converted to a singleton list. Note that field values of None are not supported by this method and will be
212
+ ignored. If you need to query for records with a null field value, use a custom report.
189
213
  :param paging_criteria: The paging criteria to start the query with.
190
214
  :param page_limit: The maximum number of pages to query from the starting criteria. If None, exhausts all
191
215
  possible pages. This parameter only functions if you set a page size in the paging criteria or the platform
@@ -197,6 +221,8 @@ class RecordHandler:
197
221
  if isinstance(wrapper_type, str):
198
222
  wrapper_type = None
199
223
  field: str = AliasUtil.to_data_field_name(field)
224
+ if isinstance(value_list, FieldValue):
225
+ value_list: list[FieldValue] = [value_list]
200
226
  pager = QueryDataRecordsAutoPager(dt, field, list(value_list), self.user, paging_criteria)
201
227
  pager.max_page = page_limit
202
228
  return self.wrap_models(pager.get_all_at_once(), wrapper_type), pager.next_page_criteria
@@ -357,7 +383,7 @@ class RecordHandler:
357
383
  raise SapioException("Unrecognized report object.")
358
384
 
359
385
  # Using the bracket accessor because we want to throw an exception if RecordId doesn't exist in the report.
360
- # This should only possibly be the case with system reports, as quick reports will include the record ID and
386
+ # This should only possibly be the case with system reports, as quick reports will include the record ID, and
361
387
  # we forced any given custom report to have a record ID column.
362
388
  ids: list[int] = [row["RecordId"] for row in results]
363
389
  return self.query_models_by_id(wrapper_type, ids)
@@ -523,6 +549,279 @@ class RecordHandler:
523
549
  secondary_identifiers.update({primary_identifier: id_value})
524
550
  return self.create_models_with_data(wrapper_type, [secondary_identifiers])[0]
525
551
 
552
+ # FR-47525: Add functions for getting and setting record image bytes.
553
+ def get_record_image(self, record: SapioRecord) -> bytes:
554
+ """
555
+ Retrieve the record image for a given record.
556
+
557
+ :param record: The record model to retrieve the image of.
558
+ :return: The file bytes of the given record's image.
559
+ """
560
+ record: DataRecord = AliasUtil.to_data_record(record)
561
+ with io.BytesIO() as data_sink:
562
+ def consume_data(chunk: bytes):
563
+ data_sink.write(chunk)
564
+
565
+ self.dr_man.get_record_image(record, consume_data)
566
+ data_sink.flush()
567
+ data_sink.seek(0)
568
+ file_bytes = data_sink.read()
569
+ return file_bytes
570
+
571
+ def set_record_image(self, record: SapioRecord, file_data: str | bytes) -> None:
572
+ """
573
+ Set the record image for a given record.
574
+
575
+ :param record: The record model to set the image of.
576
+ :param file_data: The file data of the image to set on the record.
577
+ """
578
+ record: DataRecord = AliasUtil.to_data_record(record)
579
+ with io.BytesIO(file_data.encode() if isinstance(file_data, str) else file_data) as stream:
580
+ self.dr_man.set_record_image(record, stream)
581
+
582
+ # FR-47522: Add RecordHandler functions that copy from the RecordModelUtil class in our Java utilities.
583
+ @staticmethod
584
+ def get_values_list(records: list[RecordModel], field: FieldIdentifier) -> list[FieldValue]:
585
+ """
586
+ Get a list of field values from a list of record models.
587
+
588
+ :param records: The list of record models to get the field values from.
589
+ :param field: The field to get the values of.
590
+ :return: A list of field values from the input record models. The values are in the same order as the input
591
+ record models.
592
+ """
593
+ field: str = AliasUtil.to_data_field_name(field)
594
+ return [x.get_field_value(field) for x in records]
595
+
596
+ @staticmethod
597
+ def get_values_set(records: list[RecordModel], field: FieldIdentifier) -> set[FieldValue]:
598
+ """
599
+ Get a set of field values from a list of record models.
600
+
601
+ :param records: The list of record models to get the field values from.
602
+ :param field: The field to get the values of.
603
+ :return: A set of field values from the input record models.
604
+ """
605
+ field: str = AliasUtil.to_data_field_name(field)
606
+ return {x.get_field_value(field) for x in records}
607
+
608
+ @staticmethod
609
+ def set_values(records: list[RecordModel], field: FieldIdentifier, value: FieldValue) -> None:
610
+ """
611
+ Set the value of a field on a list of record models.
612
+
613
+ :param records: The list of record models to set the field value on.
614
+ :param field: The field to set the value of.
615
+ :param value: The value to set the field to for all input records.
616
+ """
617
+ field: str = AliasUtil.to_data_field_name(field)
618
+ for record in records:
619
+ record.set_field_value(field, value)
620
+
621
+ @staticmethod
622
+ def get_min_record(records: list[RecordModel], field: FieldIdentifier) -> RecordModel:
623
+ """
624
+ Get the record model with the minimum value of a given field from a list of record models.
625
+
626
+ :param records: The list of record models to search through.
627
+ :param field: The field to find the minimum value of.
628
+ :return: The record model with the minimum value of the given field.
629
+ """
630
+ field: str = AliasUtil.to_data_field_name(field)
631
+ return min(records, key=lambda x: x.get_field_value(field))
632
+
633
+ @staticmethod
634
+ def get_max_record(records: list[RecordModel], field: FieldIdentifier) -> RecordModel:
635
+ """
636
+ Get the record model with the maximum value of a given field from a list of record models.
637
+
638
+ :param records: The list of record models to search through.
639
+ :param field: The field to find the maximum value of.
640
+ :return: The record model with the maximum value of the given field.
641
+ """
642
+ field: str = AliasUtil.to_data_field_name(field)
643
+ return max(records, key=lambda x: x.get_field_value(field))
644
+
645
+ @staticmethod
646
+ def get_from_all(records: Iterable[RecordModel], getter: _PropertyGetter[_PropertyType]) -> list[_PropertyType]:
647
+ """
648
+ Use a getter property on all records in a list of record models. For example, you can iterate over a list of
649
+ record models using a getter of Ancestors.of_type(SampleModel) to get all the SampleModel ancestors from each
650
+ record.
651
+
652
+ :param records: The list of record models to get the property from.
653
+ :param getter: The getter to use to get the property from each record.
654
+ :return: A list of the property values from the input record models. The value at the matching index of the
655
+ input records is the results of using the getter on that record.
656
+ """
657
+ return [x.get(getter) for x in records]
658
+
659
+ @staticmethod
660
+ def set_on_all(records: Iterable[RecordModel], setter: _PropertySetter[_PropertyType]) -> list[_PropertyType]:
661
+ """
662
+ Use a setter property on all records in a list of record models. For example, you can iterate over a list of
663
+ record models user a setter of ForwardSideLink.ref(field_name, record) to set a forward side link on each
664
+ record.
665
+
666
+ :param records: The list of record models to set the property on.
667
+ :param setter: The setter to use to set the property on each record.
668
+ :return: A list of the property values that were set on the input record models. The value at the matching index
669
+ of the input records is the results of using the setter on that record.
670
+ """
671
+ return [x.set(setter) for x in records]
672
+
673
+ @staticmethod
674
+ def add_to_all(records: Iterable[RecordModel], adder: _PropertyAdder[_PropertyType]) -> list[_PropertyType]:
675
+ """
676
+ Use an adder property on all records in a list of record models. For example, you can iterate over a list of
677
+ record models using an adder of Child.create(SampleModel) to create a new SampleModel child on each record.
678
+
679
+ :param records: The list of record models to add the property to.
680
+ :param adder: The adder to use to add the property to each record.
681
+ :return: A list of the property values that were added to the input record models. The value at the matching
682
+ index of the input records is the results of using the adder on that record.
683
+ """
684
+ return [x.add(adder) for x in records]
685
+
686
+ @staticmethod
687
+ def remove_from_all(records: Iterable[RecordModel], remover: _PropertyRemover[_PropertyType]) -> list[_PropertyType]:
688
+ """
689
+ Use a remover property on all records in a list of record models. For example, you can iterate over a list of
690
+ record models using a remover of Parents.ref(records) to remove a list of parents from each record.
691
+
692
+ :param records: The list of record models to remove the property from.
693
+ :param remover: The remover to use to remove the property from each record.
694
+ :return: A list of the property values that were removed from the input record models. The value at the matching
695
+ index of the input records is the results of using the remover on that record.
696
+ """
697
+ return [x.remove(remover) for x in records]
698
+
699
+ # FR-47527: Created functions for manipulating relationships between records,
700
+ def get_extension(self, model: RecordModel, wrapper_type: type[WrappedType] | str) \
701
+ -> WrappedType | PyRecordModel | None:
702
+ """
703
+ Given a record with an extension record related to it, return the extension record as a record model.
704
+ This will retrieve an extension record without doing a webservice request to the server. The input record and
705
+ extension record will be considered related to one another if you later use load_child or load_parent on the
706
+ input record or extension record respectively.
707
+
708
+ :param model: The record model to get the extension for.
709
+ :param wrapper_type: The record model wrapper to use, or the data type name of the extension record. If a data
710
+ type name is provided, the returned record will be a PyRecordModel instead of a WrappedRecordModel.
711
+ :return: The extension record model for the input record model, or None if no extension record exists.
712
+ """
713
+ ext_dt: str = AliasUtil.to_data_type_name(wrapper_type)
714
+ ext_fields: FieldMap = {}
715
+ for field, value in AliasUtil.to_field_map(model).items():
716
+ if field.startswith(ext_dt + "."):
717
+ ext_fields[field.removeprefix(ext_dt + ".")] = value
718
+ if not ext_fields or ext_fields.get("RecordId") is None:
719
+ return None
720
+ ext_rec: DataRecord = DataRecord(ext_dt, ext_fields.get("RecordId"), ext_fields)
721
+ ext_model: WrappedType | PyRecordModel = self.wrap_model(ext_rec, wrapper_type)
722
+ self._spoof_child_load(model, ext_model)
723
+ self._spoof_parent_load(ext_model, model)
724
+ return ext_model
725
+
726
+ def get_or_add_parent(self, record: RecordModel, parent_type: type[WrappedType] | str) \
727
+ -> WrappedType | PyRecordModel:
728
+ """
729
+ Given a record model, retrieve the singular parent record model of a given type. If a parent of the given type
730
+ does not exist, a new one will be created. The parents of the given data type must already be loaded.
731
+
732
+ :param record: The record model to get the parent of.
733
+ :param parent_type: The record model wrapper of the parent, or the data type name of the parent. If a data type
734
+ name is provided, the returned record will be a PyRecordModel instead of a WrappedRecordModel.
735
+ :return: The parent record model of the given type.
736
+ """
737
+ parent_dt: str = AliasUtil.to_data_type_name(parent_type)
738
+ wrapper: type[WrappedType] | None = parent_type if isinstance(parent_type, type) else None
739
+ record: PyRecordModel = RecordModelInstanceManager.unwrap(record)
740
+ parent: PyRecordModel | None = record.get_parent_of_type(parent_dt)
741
+ if parent is not None:
742
+ return self.wrap_model(parent, wrapper) if wrapper else parent
743
+ return record.add(Parent.create(wrapper)) if wrapper else record.add(Parent.create_by_name(parent_dt))
744
+
745
+ def get_or_add_child(self, record: RecordModel, child_type: type[WrappedType] | str) -> WrappedType | PyRecordModel:
746
+ """
747
+ Given a record model, retrieve the singular child record model of a given type. If a child of the given type
748
+ does not exist, a new one will be created. The children of the given data type must already be loaded.
749
+
750
+ :param record: The record model to get the child of.
751
+ :param child_type: The record model wrapper of the child, or the data type name of the child. If a data type
752
+ name is provided, the returned record will be a PyRecordModel instead of a WrappedRecordModel.
753
+ :return: The child record model of the given type.
754
+ """
755
+ child_dt: str = AliasUtil.to_data_type_name(child_type)
756
+ wrapper: type[WrappedType] | None = child_type if isinstance(child_type, type) else None
757
+ record: PyRecordModel = RecordModelInstanceManager.unwrap(record)
758
+ child: PyRecordModel | None = record.get_child_of_type(child_dt)
759
+ if child is not None:
760
+ return self.wrap_model(child, wrapper) if wrapper else child
761
+ return record.add(Child.create(wrapper)) if wrapper else record.add(Child.create_by_name(child_dt))
762
+
763
+ def get_or_add_side_link(self, record: RecordModel, side_link_field: FieldIdentifier,
764
+ side_link_type: type[WrappedType] | str) -> WrappedType | PyRecordModel:
765
+ """
766
+ Given a record model, retrieve the singular side link record model of a given type. If a side link of the given
767
+ type does not exist, a new one will be created. The side links of the given data type must already be loaded.
768
+
769
+ :param record: The record model to get the side link of.
770
+ :param side_link_field: The field name of the side link to get.
771
+ :param side_link_type: The record model wrapper of the side link, or the data type name of the side link. If a
772
+ data type name is provided, the returned record will be a PyRecordModel instead of a WrappedRecordModel.
773
+ :return: The side link record model of the given type.
774
+ """
775
+ side_link_field: str = AliasUtil.to_data_field_name(side_link_field)
776
+ wrapper: type[WrappedType] | None = side_link_type if isinstance(side_link_type, type) else None
777
+ record: PyRecordModel = RecordModelInstanceManager.unwrap(record)
778
+ side_link: PyRecordModel | None = record.get_forward_side_link(side_link_field)
779
+ if side_link is not None:
780
+ return self.wrap_model(side_link, wrapper) if wrapper else side_link
781
+ side_link: WrappedType | PyRecordModel = self.add_model(side_link_type)
782
+ record.set(ForwardSideLink.ref(side_link_field, side_link))
783
+ return side_link
784
+
785
+ @staticmethod
786
+ def set_parents(record: RecordModel, parents: Iterable[RecordModel], parent_type: DataTypeIdentifier) -> None:
787
+ """
788
+ Set the parents of a record model to a list of parent record models of a given type. The parents of the given
789
+ data type must already be loaded. This method will add the parents to the record model if they are not already
790
+ parents, and remove any existing parents that are not in the input list.
791
+
792
+ :param record: The record model to set the parents of.
793
+ :param parents: The list of parent record models to set as the parents of the input record model.
794
+ :param parent_type: The data type identifier of the parent record models.
795
+ """
796
+ parent_dt: str = AliasUtil.to_data_type_name(parent_type)
797
+ existing_parents: list[PyRecordModel] = record.get(Parents.of_type_name(parent_dt))
798
+ for parent in parents:
799
+ if parent not in existing_parents:
800
+ record.add(Parent.ref(parent))
801
+ for parent in existing_parents:
802
+ if parent not in parents:
803
+ record.remove(Parent.ref(parent))
804
+
805
+ @staticmethod
806
+ def set_children(record: RecordModel, children: Iterable[RecordModel], child_type: DataTypeIdentifier) -> None:
807
+ """
808
+ Set the children of a record model to a list of child record models of a given type. The children of the given
809
+ data type must already be loaded. This method will add the children to the record model if they are not already
810
+ children, and remove any existing children that are not in the input list.
811
+
812
+ :param record: The record model to set the children of.
813
+ :param children: The list of child record models to set as the children of the input record model.
814
+ :param child_type: The data type identifier of the child record models.
815
+ """
816
+ child_dt: str = AliasUtil.to_data_type_name(child_type)
817
+ existing_children: list[PyRecordModel] = record.get(Children.of_type_name(child_dt))
818
+ for child in children:
819
+ if child not in existing_children:
820
+ record.add(Child.ref(child))
821
+ for child in existing_children:
822
+ if child not in children:
823
+ record.remove(Child.ref(child))
824
+
526
825
  @staticmethod
527
826
  def map_to_parent(models: Iterable[WrappedRecordModel], parent_type: type[WrappedType])\
528
827
  -> dict[WrappedRecordModel, WrappedType]:
@@ -904,7 +1203,7 @@ class RecordHandler:
904
1203
  return field_sum
905
1204
 
906
1205
  @staticmethod
907
- def mean_of_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) -> float:
1206
+ def mean_of_field(models: Collection[SapioRecord], field_name: FieldIdentifier) -> float:
908
1207
  """
909
1208
  Calculate the mean of the numeric value of a given field across all input models. Excepts that all given models
910
1209
  have a value. If the field is an integer field, the value will be converted to a float.
@@ -913,7 +1212,7 @@ class RecordHandler:
913
1212
  :param field_name: The name of the numeric field to mean.
914
1213
  :return: The mean of the field values for the collection of models.
915
1214
  """
916
- return RecordHandler.sum_of_field(models, field_name) / len(list(models))
1215
+ return RecordHandler.sum_of_field(models, field_name) / len(models)
917
1216
 
918
1217
  @staticmethod
919
1218
  def get_newest_record(records: Iterable[SapioRecord]) -> SapioRecord:
@@ -923,11 +1222,7 @@ class RecordHandler:
923
1222
  :param records: The list of records.
924
1223
  :return: The input record with the highest record ID. None if the input list is empty.
925
1224
  """
926
- newest: SapioRecord | None = None
927
- for record in records:
928
- if newest is None or record.record_id > newest.record_id:
929
- newest = record
930
- return newest
1225
+ return max(records, key=lambda x: x.record_id)
931
1226
 
932
1227
  # FR-46696: Add a function for getting the oldest record in a list, just like we have one for the newest record.
933
1228
  @staticmethod
@@ -938,11 +1233,7 @@ class RecordHandler:
938
1233
  :param records: The list of records.
939
1234
  :return: The input record with the lowest record ID. None if the input list is empty.
940
1235
  """
941
- oldest: SapioRecord | None = None
942
- for record in records:
943
- if oldest is None or record.record_id < oldest.record_id:
944
- oldest = record
945
- return oldest
1236
+ return min(records, key=lambda x: x.record_id)
946
1237
 
947
1238
  @staticmethod
948
1239
  def values_to_field_maps(field_name: FieldIdentifier, values: Iterable[FieldValue],
@@ -1169,3 +1460,42 @@ class RecordHandler:
1169
1460
  if record_type != model_type:
1170
1461
  raise SapioException(f"Data record of type {record_type} cannot be wrapped by the record model wrapper "
1171
1462
  f"of type {model_type}")
1463
+
1464
+ @staticmethod
1465
+ def _spoof_child_load(model: RecordModel, child: RecordModel) -> None:
1466
+ """
1467
+ Spoof the loading of a child record on a record model. This is useful for when you have records that you know
1468
+ are related but didn't use the relationship manager to load the relationship, which would make a webservice
1469
+ call.
1470
+ """
1471
+ RecordHandler._spoof_children_load(model, [child])
1472
+
1473
+ @staticmethod
1474
+ def _spoof_children_load(model: RecordModel, children: list[RecordModel]) -> None:
1475
+ """
1476
+ Spoof the loading of child records on a record model. This is useful for when you have records that you know
1477
+ are related but didn't use the relationship manager to load the relationship, which would make a webservice
1478
+ """
1479
+ model: PyRecordModel = RecordModelInstanceManager.unwrap(model)
1480
+ child_dt: str = AliasUtil.to_singular_data_type_name(children)
1481
+ # noinspection PyProtectedMember
1482
+ model._mark_children_loaded(child_dt, RecordModelInstanceManager.unwrap_list(children))
1483
+
1484
+ @staticmethod
1485
+ def _spoof_parent_load(model: RecordModel, parent: RecordModel) -> None:
1486
+ """
1487
+ Spoof the loading of a parent record on a record model. This is useful for when you have records that you know
1488
+ are related but didn't use the relationship manager to load the relationship, which would make a webservice
1489
+ """
1490
+ RecordHandler._spoof_parents_load(model, [parent])
1491
+
1492
+ @staticmethod
1493
+ def _spoof_parents_load(model: RecordModel, parents: list[RecordModel]) -> None:
1494
+ """
1495
+ Spoof the loading of parent records on a record model. This is useful for when you have records that you know
1496
+ are related but didn't use the relationship manager to load the relationship, which would make a webservice
1497
+ """
1498
+ model: PyRecordModel = RecordModelInstanceManager.unwrap(model)
1499
+ parent_dt: str = AliasUtil.to_singular_data_type_name(parents)
1500
+ # noinspection PyProtectedMember
1501
+ model._mark_children_loaded(parent_dt, RecordModelInstanceManager.unwrap_list(parents))
@@ -126,12 +126,16 @@ class ElnRuleHandler:
126
126
  """
127
127
  return list(self._entry_to_field_maps.keys())
128
128
 
129
+ # CR-47529: Add info about HVDT behavior to the docstring of these functions.
129
130
  def get_records(self, data_type: DataTypeIdentifier, entry: str | None = None) -> list[DataRecord]:
130
131
  """
131
132
  Get records from the cached context with the given data type. Capable of being filtered to searching within
132
133
  the context of an entry name. If the given data type or entry does not exist in the context,
133
134
  returns an empty list.
134
135
 
136
+ Note that if you are attempting to retrieve record that are high volume data types and are receiving nothing,
137
+ the HVDTs may have been sent as field maps. Consider using the get_field_maps function if this occurs.
138
+
135
139
  :param data_type: The data type of the records to return.
136
140
  :param entry: The name of the entry to grab the records from. If None, returns the records that match the data
137
141
  type from every entry. If an entry is provided, but it does not exist in the context, returns an empty list.
@@ -150,7 +154,7 @@ class ElnRuleHandler:
150
154
 
151
155
  Field maps will only exist in the context if the data record that the fields are from is no longer accessible
152
156
  to the user. This can occur because the data record was deleted, or because the user does not have access to the
153
- record due to ACL.
157
+ record due to ACL. This can also occur under certain circumstances if the records are HVDTs.
154
158
 
155
159
  :param data_type: The data type of the field maps to return.
156
160
  :param entry: The name of the entry to grab the field maps from. If None, returns the field maps that match the
@@ -170,6 +174,9 @@ class ElnRuleHandler:
170
174
  within the context of an entry name. If the given data type or entry does not exist in the context,
171
175
  returns an empty list.
172
176
 
177
+ Note that if you are attempting to retrieve record that are high volume data types and are receiving nothing,
178
+ the HVDTs may have been sent as field maps. Consider using the get_field_maps function if this occurs.
179
+
173
180
  :param wrapper_type: The record model wrapper or data type name of the record to get from the context.
174
181
  :param entry: The name of the entry to grab the records from. If None, returns the records that match the data
175
182
  type from every entry. If an entry is provided, but it does not exist in the context, returns an empty list.
@@ -122,12 +122,16 @@ class OnSaveRuleHandler:
122
122
  """
123
123
  return list(self._base_id_to_field_maps.keys())
124
124
 
125
+ # CR-47529: Add info about HVDT behavior to the docstring of these functions.
125
126
  def get_records(self, data_type: DataTypeIdentifier, record_id: int | None = None) -> list[DataRecord]:
126
127
  """
127
128
  Get records from the cached context with the given data type. Capable of being filtered to searching within
128
129
  the context of a record ID. If the given data type or record ID does not exist in the context,
129
130
  returns an empty list.
130
131
 
132
+ Note that if you are attempting to retrieve record that are high volume data types and are receiving nothing,
133
+ the HVDTs may have been sent as field maps. Consider using the get_field_maps function if this occurs.
134
+
131
135
  :param data_type: The data type of the records to return.
132
136
  :param record_id: The record ID of the base record to search from. If None, returns the records that match the
133
137
  data type from every ID. If an ID is provided, but it does not exist in the context, returns an empty list.
@@ -146,7 +150,7 @@ class OnSaveRuleHandler:
146
150
 
147
151
  Field maps will only exist in the context if the data record that the fields are from is no longer accessible
148
152
  to the user. This can occur because the data record was deleted, or because the user does not have access to the
149
- record due to ACL.
153
+ record due to ACL. This can also occur under certain circumstances if the records are HVDTs.
150
154
 
151
155
  :param data_type: The data type of the field maps to return.
152
156
  :param record_id: The record ID of the base record to search from. If None, returns the field maps that match
@@ -166,6 +170,9 @@ class OnSaveRuleHandler:
166
170
  the context of a record ID. If the given data type or record ID does not exist in the context,
167
171
  returns an empty list.
168
172
 
173
+ Note that if you are attempting to retrieve record that are high volume data types and are receiving nothing,
174
+ the HVDTs may have been sent as field maps. Consider using the get_field_maps function if this occurs.
175
+
169
176
  :param wrapper_type: The record model wrapper or data type name of the record to get from the context.
170
177
  :param record_id: The record ID of the base record to search from. If None, returns the records that match the
171
178
  data type from ID. If an ID is provided, but it does not exist in the context, returns an empty list.
@@ -220,6 +220,9 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
220
220
  else:
221
221
  self.custom_context = None
222
222
 
223
+ # CR-47526: Set the dialog timeout to 1 hour by default. This can be overridden by the webhook.
224
+ self.callback.set_dialog_timeout(3600)
225
+
223
226
  # Set the default display types, titles, and messages for each type of exception that can display a message.
224
227
  self.default_user_error_display_type = MessageDisplayType.TOASTER_WARNING
225
228
  self.default_critical_error_display_type = MessageDisplayType.DISPLAY_ERROR
@@ -3,7 +3,7 @@ import traceback
3
3
  from abc import abstractmethod, ABC
4
4
  from base64 import b64decode
5
5
  from logging import Logger
6
- from typing import Any
6
+ from typing import Any, Mapping
7
7
 
8
8
  from flask import request, Response, Request
9
9
  from sapiopylib.rest.DataRecordManagerService import DataRecordManager
@@ -122,7 +122,7 @@ class AbstractWebserviceHandler(AbstractWebhookHandler):
122
122
  """
123
123
  pass
124
124
 
125
- def authenticate_user(self, headers: dict[str, str]) -> SapioUser:
125
+ def authenticate_user(self, headers: Mapping[str, str]) -> SapioUser:
126
126
  """
127
127
  Authenticate a user for making requests to a Sapio server using the provided headers. If no user can be
128
128
  authenticated, then an exception will be thrown.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sapiopycommons
3
- Version: 2025.5.6a512
3
+ Version: 2025.5.7a514
4
4
  Summary: Official Sapio Python API Utilities Package
5
5
  Project-URL: Homepage, https://github.com/sapiosciences
6
6
  Author-email: Jonathan Steck <jsteck@sapiosciences.com>, Yechen Qiao <yqiao@sapiosciences.com>
@@ -17,7 +17,7 @@ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
17
17
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
18
  Requires-Python: >=3.10
19
19
  Requires-Dist: databind>=4.5
20
- Requires-Dist: sapiopylib>=2024.5.24.210
20
+ Requires-Dist: sapiopylib>=2025.4.17.264
21
21
  Description-Content-Type: text/markdown
22
22
 
23
23