sapiopycommons 2025.5.13a523__py3-none-any.whl → 2025.5.14a528__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/ai/tool_of_tools.py +809 -0
  2. sapiopycommons/callbacks/callback_util.py +116 -64
  3. sapiopycommons/callbacks/field_builder.py +2 -0
  4. sapiopycommons/customreport/auto_pagers.py +2 -1
  5. sapiopycommons/customreport/term_builder.py +1 -1
  6. sapiopycommons/datatype/pseudo_data_types.py +349 -326
  7. sapiopycommons/eln/experiment_cache.py +188 -0
  8. sapiopycommons/eln/experiment_handler.py +336 -719
  9. sapiopycommons/eln/experiment_step_factory.py +476 -0
  10. sapiopycommons/eln/plate_designer.py +7 -2
  11. sapiopycommons/eln/step_creation.py +236 -0
  12. sapiopycommons/files/file_util.py +4 -4
  13. sapiopycommons/general/accession_service.py +2 -2
  14. sapiopycommons/general/aliases.py +4 -1
  15. sapiopycommons/general/data_structure_util.py +115 -0
  16. sapiopycommons/general/sapio_links.py +4 -12
  17. sapiopycommons/processtracking/custom_workflow_handler.py +2 -1
  18. sapiopycommons/recordmodel/record_handler.py +357 -27
  19. sapiopycommons/rules/eln_rule_handler.py +8 -1
  20. sapiopycommons/rules/on_save_rule_handler.py +8 -1
  21. sapiopycommons/webhook/webhook_handlers.py +3 -0
  22. sapiopycommons/webhook/webservice_handlers.py +2 -2
  23. {sapiopycommons-2025.5.13a523.dist-info → sapiopycommons-2025.5.14a528.dist-info}/METADATA +2 -2
  24. sapiopycommons-2025.5.14a528.dist-info/RECORD +69 -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 -787
  51. sapiopycommons/general/html_formatter.py +0 -456
  52. sapiopycommons-2025.5.13a523.dist-info/RECORD +0 -91
  53. {sapiopycommons-2025.5.13a523.dist-info → sapiopycommons-2025.5.14a528.dist-info}/WHEEL +0 -0
  54. {sapiopycommons-2025.5.13a523.dist-info → sapiopycommons-2025.5.14a528.dist-info}/licenses/LICENSE +0 -0
@@ -4,7 +4,7 @@ import io
4
4
  import re
5
5
  import warnings
6
6
  from copy import copy
7
- from typing import Iterable, TypeAlias, Any, Callable
7
+ from typing import Iterable, TypeAlias, Any, Callable, Container, Collection
8
8
  from weakref import WeakValueDictionary
9
9
 
10
10
  from requests import ReadTimeout
@@ -38,11 +38,18 @@ from sapiopycommons.general.aliases import FieldMap, SapioRecord, AliasUtil, Rec
38
38
  from sapiopycommons.general.custom_report_util import CustomReportUtil
39
39
  from sapiopycommons.general.exceptions import SapioUserCancelledException, SapioException, SapioUserErrorException, \
40
40
  SapioDialogTimeoutException
41
+ from sapiopycommons.general.time_util import TimeUtil
41
42
  from sapiopycommons.recordmodel.record_handler import RecordHandler
42
43
 
43
44
  DataTypeLayoutIdentifier: TypeAlias = DataTypeLayout | str | None
44
45
 
45
46
 
47
+ # CR-47521: Updated various parameter type hints from list or Iterable to more specific type hints.
48
+ # If we need to iterate over the parameter, then it is Iterable.
49
+ # If we need to see if the parameter contains a value, then it is Container.
50
+ # If the length/size of the parameter is needed, then it is Collection.
51
+ # If we need to access the parameter by an index, then it is Sequence. (This excludes sets and dictionaries, so it's
52
+ # probably better to accept a Collection then cast the parameter to a list if you need to get an element from it.)
46
53
  class CallbackUtil:
47
54
  user: SapioUser
48
55
  callback: ClientCallback
@@ -193,7 +200,7 @@ class CallbackUtil:
193
200
  def ok_cancel_dialog(self, title: str, msg: str, default_ok: bool = True) -> bool:
194
201
  """
195
202
  Create an option dialog where the only options are "OK" and "Cancel". Doesn't allow the user to cancel the
196
- dialog using the X at the top right corner.
203
+ dialog using the X in the top right corner.
197
204
 
198
205
  :param title: The title of the dialog.
199
206
  :param msg: The message to display in the dialog. This can be formatted using HTML elements.
@@ -205,7 +212,7 @@ class CallbackUtil:
205
212
  def yes_no_dialog(self, title: str, msg: str, default_yes: bool = True) -> bool:
206
213
  """
207
214
  Create an option dialog where the only options are "Yes" and "No". Doesn't allow the user to cancel the
208
- dialog using the X at the top right corner.
215
+ dialog using the X in the top right corner.
209
216
 
210
217
  :param title: The title of the dialog.
211
218
  :param msg: The message to display in the dialog. This can be formatted using HTML elements.
@@ -258,7 +265,7 @@ class CallbackUtil:
258
265
  def form_dialog(self,
259
266
  title: str,
260
267
  msg: str,
261
- fields: list[AbstractVeloxFieldDefinition],
268
+ fields: Iterable[AbstractVeloxFieldDefinition],
262
269
  values: FieldMap = None,
263
270
  column_positions: dict[str, tuple[int, int]] = None,
264
271
  *,
@@ -296,7 +303,7 @@ class CallbackUtil:
296
303
  def record_form_dialog(self,
297
304
  title: str,
298
305
  msg: str,
299
- fields: list[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
306
+ fields: Iterable[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
300
307
  record: SapioRecord,
301
308
  column_positions: dict[str, tuple[int, int]] | None = None,
302
309
  editable=None,
@@ -369,7 +376,7 @@ class CallbackUtil:
369
376
  def set_record_form_dialog(self,
370
377
  title: str,
371
378
  msg: str,
372
- fields: list[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
379
+ fields: Iterable[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
373
380
  record: SapioRecord,
374
381
  column_positions: dict[str, tuple[int, int]] | None = None,
375
382
  *,
@@ -412,7 +419,7 @@ class CallbackUtil:
412
419
  def create_record_form_dialog(self,
413
420
  title: str,
414
421
  msg: str,
415
- fields: list[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
422
+ fields: Iterable[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
416
423
  wrapper_type: type[WrappedType] | str,
417
424
  column_positions: dict[str, tuple[int, int]] | None = None,
418
425
  *,
@@ -618,14 +625,14 @@ class CallbackUtil:
618
625
  def table_dialog(self,
619
626
  title: str,
620
627
  msg: str,
621
- fields: list[AbstractVeloxFieldDefinition],
622
- values: list[FieldMap],
628
+ fields: Iterable[AbstractVeloxFieldDefinition],
629
+ values: Iterable[FieldMap],
623
630
  *,
624
631
  data_type: DataTypeIdentifier = "Default",
625
632
  display_name: str | None = None,
626
633
  plural_display_name: str | None = None,
627
634
  group_by: FieldIdentifier | None = None,
628
- image_data: list[bytes] | None = None) -> list[FieldMap]:
635
+ image_data: Iterable[bytes] | None = None) -> list[FieldMap]:
629
636
  """
630
637
  Create a table dialog where the user may input data into the fields of the table. Requires that the caller
631
638
  provide the definitions of every field in the table.
@@ -660,7 +667,7 @@ class CallbackUtil:
660
667
  temp_dt.record_image_assignable = bool(image_data)
661
668
 
662
669
  # Send the request to the user.
663
- request = TableEntryDialogRequest(title, msg, temp_dt, values,
670
+ request = TableEntryDialogRequest(title, msg, temp_dt, list(values),
664
671
  record_image_data_list=image_data, group_by_field=group_by,
665
672
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
666
673
  response: list[FieldMap] = self.__handle_dialog_request(request, self.callback.show_table_entry_dialog)
@@ -669,14 +676,15 @@ class CallbackUtil:
669
676
  def record_table_dialog(self,
670
677
  title: str,
671
678
  msg: str,
672
- fields: list[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
673
- records: list[SapioRecord],
679
+ fields: Iterable[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
680
+ records: Iterable[SapioRecord],
674
681
  editable=None,
675
682
  *,
676
683
  default_modifier: FieldModifier | None = None,
677
684
  field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None,
678
685
  group_by: FieldIdentifier | None = None,
679
- image_data: list[bytes] | None = None) -> list[FieldMap]:
686
+ image_data: Iterable[bytes] | None = None,
687
+ index_field: str | None = None) -> list[FieldMap]:
680
688
  """
681
689
  Create a table dialog where the user may input data into the fields of the table. The table is constructed from
682
690
  a given list of records of a singular type.
@@ -706,6 +714,12 @@ class CallbackUtil:
706
714
  The user may remove this grouping if they want to.
707
715
  :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
708
716
  the image data list corresponds to the element at the same index in the records list.
717
+ :param index_field: If provided, the returned field maps will contain a field with this name that is equal to
718
+ the record ID of the record at the same index in the records list. This can be used to map the results
719
+ back to the original records. This is used instead of using a RecordId field, as the RecordId field has
720
+ special behavior in the system that can cause issues if the given records are uncommitted record models
721
+ with negative record IDs, meaning we don't want to have a RecordId field in the field maps provided to the
722
+ system.
709
723
  :return: A list of dictionaries mapping the data field names of the given field definitions to the response
710
724
  value from the user for that field for each row.
711
725
  """
@@ -717,7 +731,10 @@ class CallbackUtil:
717
731
  if not records:
718
732
  raise SapioException("No records provided.")
719
733
  data_type: str = AliasUtil.to_singular_data_type_name(records)
720
- field_map_list: list[FieldMap] = AliasUtil.to_field_map_list(records)
734
+ if index_field is not None:
735
+ field_map_list: list[FieldMap] = self.__get_indexed_field_maps(records, index_field)
736
+ else:
737
+ field_map_list: list[FieldMap] = AliasUtil.to_field_map_list(records)
721
738
 
722
739
  # Convert the group_by parameter to a field name.
723
740
  if group_by is not None:
@@ -750,13 +767,13 @@ class CallbackUtil:
750
767
  def set_record_table_dialog(self,
751
768
  title: str,
752
769
  msg: str,
753
- fields: list[FieldValue] | DataTypeLayoutIdentifier,
754
- records: list[SapioRecord],
770
+ fields: Iterable[FieldValue] | DataTypeLayoutIdentifier,
771
+ records: Iterable[SapioRecord],
755
772
  *,
756
773
  default_modifier: FieldModifier | None = None,
757
774
  field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None,
758
775
  group_by: FieldIdentifier | None = None,
759
- image_data: list[bytes] | None = None):
776
+ image_data: Iterable[bytes] | None = None):
760
777
  """
761
778
  Create a table dialog where the user may input data into the fields of the table. The table is constructed from
762
779
  a given list of records of a singular type. After the user submits this dialog, the values that the user
@@ -787,25 +804,30 @@ class CallbackUtil:
787
804
  :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
788
805
  the image data list corresponds to the element at the same index in the records list.
789
806
  """
807
+ # Index the records with a field name that is the current time in milliseconds. This is done to avoid
808
+ # collisions with any existing field names.
809
+ index_field: str = f"_{TimeUtil.now_in_millis()}"
790
810
  results: list[FieldMap] = self.record_table_dialog(title, msg, fields, records,
791
811
  default_modifier=default_modifier,
792
812
  field_modifiers=field_modifiers,
793
- group_by=group_by, image_data=image_data)
813
+ group_by=group_by, image_data=image_data,
814
+ index_field=index_field)
794
815
  records_by_id: dict[int, SapioRecord] = self.rec_handler.map_by_id(records)
795
816
  for result in results:
796
- records_by_id[result["RecordId"]].set_field_values(result)
817
+ index: int = result.pop(index_field)
818
+ records_by_id[index].set_field_values(result)
797
819
 
798
820
  def create_record_table_dialog(self,
799
821
  title: str,
800
822
  msg: str,
801
- fields: list[FieldValue] | DataTypeLayoutIdentifier,
823
+ fields: Iterable[FieldValue] | DataTypeLayoutIdentifier,
802
824
  wrapper_type: type[WrappedType] | str,
803
825
  count: int | tuple[int, int],
804
826
  *,
805
827
  default_modifier: FieldModifier | None = None,
806
828
  field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None,
807
829
  group_by: FieldIdentifier | None = None,
808
- image_data: list[bytes] | None = None,
830
+ image_data: Iterable[bytes] | None = None,
809
831
  require_input: bool = False,
810
832
  repeat_message: str | None = "Please provide a value to continue.") \
811
833
  -> list[WrappedType] | list[PyRecordModel]:
@@ -860,14 +882,15 @@ class CallbackUtil:
860
882
  def record_adaptive_dialog(self,
861
883
  title: str,
862
884
  msg: str,
863
- fields: list[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
864
- records: list[SapioRecord],
885
+ fields: Iterable[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
886
+ records: Collection[SapioRecord],
865
887
  *,
866
888
  default_modifier: FieldModifier | None = None,
867
889
  field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None,
868
890
  column_positions: dict[str, tuple[int, int]] | None = None,
869
891
  group_by: FieldIdentifier | None = None,
870
- image_data: list[bytes] | None = None) -> list[FieldMap]:
892
+ image_data: Iterable[bytes] | None = None,
893
+ index_field: str | None = None) -> list[FieldMap]:
871
894
  """
872
895
  Create a dialog where the user may input data into the specified fields. The dialog is constructed from
873
896
  a given list of records of a singular type.
@@ -903,6 +926,12 @@ class CallbackUtil:
903
926
  :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
904
927
  the image data list corresponds to the element at the same index in the records list. Only used if the
905
928
  adaptive dialog becomes a table.
929
+ :param index_field: If provided, the returned field maps will contain a field with this name that is equal to
930
+ the record ID of the record at the same index in the records list. This can be used to map the results
931
+ back to the original records. This is used instead of using a RecordId field, as the RecordId field has
932
+ special behavior in the system that can cause issues if the given records are uncommitted record models
933
+ with negative record IDs, meaning we don't want to have a RecordId field in the field maps provided to the
934
+ system. Only used if the adaptive dialog becomes a table.
906
935
  :return: A list of dictionaries mapping the data field names of the given field definitions to the response
907
936
  value from the user for that field for each row. Even if a form was displayed, the field values will still
908
937
  be returned in a list.
@@ -911,23 +940,23 @@ class CallbackUtil:
911
940
  if not count:
912
941
  raise SapioException("No records provided.")
913
942
  if count == 1:
914
- return [self.record_form_dialog(title, msg, fields, records[0], column_positions,
943
+ return [self.record_form_dialog(title, msg, fields, list(records)[0], column_positions,
915
944
  default_modifier=default_modifier, field_modifiers=field_modifiers)]
916
945
  return self.record_table_dialog(title, msg, fields, records,
917
946
  default_modifier=default_modifier, field_modifiers=field_modifiers,
918
- group_by=group_by, image_data=image_data)
947
+ group_by=group_by, image_data=image_data, index_field=index_field)
919
948
 
920
949
  def set_record_adaptive_dialog(self,
921
950
  title: str,
922
951
  msg: str,
923
- fields: list[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
924
- records: list[SapioRecord],
952
+ fields: Iterable[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
953
+ records: Collection[SapioRecord],
925
954
  *,
926
955
  default_modifier: FieldModifier | None = None,
927
956
  field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None,
928
957
  column_positions: dict[str, tuple[int, int]] | None = None,
929
958
  group_by: FieldIdentifier | None = None,
930
- image_data: list[bytes] | None = None) -> None:
959
+ image_data: Iterable[bytes] | None = None) -> None:
931
960
  """
932
961
  Create a dialog where the user may input data into the fields of the dialog. The dialog is constructed from
933
962
  a given list of records of a singular type. After the user submits this dialog, the values that the user
@@ -969,7 +998,7 @@ class CallbackUtil:
969
998
  if not count:
970
999
  raise SapioException("No records provided.")
971
1000
  if count == 1:
972
- self.set_record_form_dialog(title, msg, fields, records[0], column_positions,
1001
+ self.set_record_form_dialog(title, msg, fields, list(records)[0], column_positions,
973
1002
  default_modifier=default_modifier, field_modifiers=field_modifiers)
974
1003
  else:
975
1004
  self.set_record_table_dialog(title, msg, fields, records,
@@ -979,7 +1008,7 @@ class CallbackUtil:
979
1008
  def create_record_adaptive_dialog(self,
980
1009
  title: str,
981
1010
  msg: str,
982
- fields: list[FieldValue] | DataTypeLayoutIdentifier,
1011
+ fields: Iterable[FieldValue] | DataTypeLayoutIdentifier,
983
1012
  wrapper_type: type[WrappedType] | str,
984
1013
  count: int | tuple[int, int],
985
1014
  *,
@@ -987,7 +1016,7 @@ class CallbackUtil:
987
1016
  field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None,
988
1017
  column_positions: dict[str, tuple[int, int]] | None = None,
989
1018
  group_by: FieldIdentifier | None = None,
990
- image_data: list[bytes] | None = None,
1019
+ image_data: Iterable[bytes] | None = None,
991
1020
  require_input: bool = False,
992
1021
  repeat_message: str | None = "Please provide a value to continue.") \
993
1022
  -> list[WrappedType]:
@@ -1050,8 +1079,8 @@ class CallbackUtil:
1050
1079
  def multi_type_table_dialog(self,
1051
1080
  title: str,
1052
1081
  msg: str,
1053
- fields: list[tuple[DataTypeIdentifier, FieldIdentifier] | AbstractVeloxFieldDefinition],
1054
- row_contents: list[list[SapioRecord | FieldMap]],
1082
+ fields: Iterable[tuple[DataTypeIdentifier, FieldIdentifier] | AbstractVeloxFieldDefinition],
1083
+ row_contents: Iterable[Iterable[SapioRecord | FieldMap]],
1055
1084
  *,
1056
1085
  default_modifier: FieldModifier | None = None,
1057
1086
  field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None,
@@ -1187,7 +1216,7 @@ class CallbackUtil:
1187
1216
  if rec is None:
1188
1217
  continue
1189
1218
  # Map records to their data type name. Map field maps to Default.
1190
- dt: str = "Default" if isinstance(rec, dict) else AliasUtil.to_data_type_names(rec)
1219
+ dt: str = "Default" if isinstance(rec, dict) else AliasUtil.to_data_type_name(rec)
1191
1220
  # Warn if the same data type name appears more than once.
1192
1221
  if dt in row_records:
1193
1222
  raise SapioException(f"The data type \"{dt}\" appears more than once in the given row contents.")
@@ -1226,7 +1255,7 @@ class CallbackUtil:
1226
1255
  layout: DataTypeLayoutIdentifier = None,
1227
1256
  minimized: bool = False,
1228
1257
  access_level: FormAccessLevel | None = None,
1229
- plugin_path_list: list[str] | None = None) -> None:
1258
+ plugin_path_list: Iterable[str] | None = None) -> None:
1230
1259
  """
1231
1260
  Create an IDV dialog for the given record. This IDV may use an existing layout already defined in the system,
1232
1261
  and can be created to allow the user to edit the field in the IDV, or to be read-only for the user to review.
@@ -1263,15 +1292,15 @@ class CallbackUtil:
1263
1292
  # CR-47326: Allow the selection dialog functions to preselect rows/records in the table.
1264
1293
  def selection_dialog(self,
1265
1294
  msg: str,
1266
- fields: list[AbstractVeloxFieldDefinition],
1267
- values: list[FieldMap],
1295
+ fields: Iterable[AbstractVeloxFieldDefinition],
1296
+ values: Iterable[FieldMap],
1268
1297
  multi_select: bool = True,
1269
- preselected_rows: list[FieldMap | RecordIdentifier] | None = None,
1298
+ preselected_rows: Iterable[FieldMap | RecordIdentifier] | None = None,
1270
1299
  *,
1271
1300
  data_type: DataTypeIdentifier = "Default",
1272
1301
  display_name: str | None = None,
1273
1302
  plural_display_name: str | None = None,
1274
- image_data: list[bytes] | None = None,
1303
+ image_data: Iterable[bytes] | None = None,
1275
1304
  require_selection: bool = False,
1276
1305
  repeat_message: str | None = "Please provide a selection to continue.") -> list[FieldMap]:
1277
1306
  """
@@ -1329,6 +1358,7 @@ class CallbackUtil:
1329
1358
 
1330
1359
  # Add a RecordId definition to the fields if one is not already present. This is necessary for the
1331
1360
  # pre-selected records parameter to function.
1361
+ fields = list(fields)
1332
1362
  if "RecordId" not in [x.data_field_name for x in fields]:
1333
1363
  builder = FieldBuilder(data_type)
1334
1364
  fields.append(builder.long_field("RecordId", abstract_info=AnyFieldInfo(visible=False)))
@@ -1338,7 +1368,7 @@ class CallbackUtil:
1338
1368
  temp_dt.record_image_assignable = bool(image_data)
1339
1369
 
1340
1370
  # Send the request to the user.
1341
- request = TempTableSelectionRequest(temp_dt, msg, values, image_data, preselected_rows, multi_select)
1371
+ request = TempTableSelectionRequest(temp_dt, msg, list(values), image_data, preselected_rows, multi_select)
1342
1372
  # If require_selection is true, repeat the request if the user didn't make a selection.
1343
1373
  while True:
1344
1374
  response: list[FieldMap] = self.__handle_dialog_request(request,
@@ -1351,12 +1381,12 @@ class CallbackUtil:
1351
1381
 
1352
1382
  def record_selection_dialog(self,
1353
1383
  msg: str,
1354
- fields: list[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
1355
- records: list[SapioRecord],
1384
+ fields: Iterable[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
1385
+ records: Iterable[SapioRecord],
1356
1386
  multi_select: bool = True,
1357
- preselected_records: list[RecordIdentifier] | None = None,
1387
+ preselected_records: Iterable[RecordIdentifier] | None = None,
1358
1388
  *,
1359
- image_data: list[bytes] | None = None,
1389
+ image_data: Iterable[bytes] | None = None,
1360
1390
  require_selection: bool = False,
1361
1391
  repeat_message: str | None = "Please provide a selection to continue.") \
1362
1392
  -> list[SapioRecord]:
@@ -1438,12 +1468,12 @@ class CallbackUtil:
1438
1468
  msg: str,
1439
1469
  multi_select: bool = True,
1440
1470
  only_key_fields: bool = False,
1441
- search_types: list[SearchType] | None = None,
1471
+ search_types: Iterable[SearchType] | None = None,
1442
1472
  scan_criteria: ScanToSelectCriteria | None = None,
1443
1473
  custom_search: CustomReport | CustomReportCriteria | str | None = None,
1444
- preselected_records: list[RecordIdentifier] | None = None,
1445
- record_blacklist: list[RecordIdentifier] | None = None,
1446
- record_whitelist: list[RecordIdentifier] | None = None,
1474
+ preselected_records: Iterable[RecordIdentifier] | None = None,
1475
+ record_blacklist: Iterable[RecordIdentifier] | None = None,
1476
+ record_whitelist: Iterable[RecordIdentifier] | None = None,
1447
1477
  allow_creation: bool = False,
1448
1478
  default_creation_number: int = 1,
1449
1479
  *,
@@ -1533,7 +1563,7 @@ class CallbackUtil:
1533
1563
  title: str,
1534
1564
  msg: str,
1535
1565
  show_comment: bool = True,
1536
- additional_fields: list[AbstractVeloxFieldDefinition] | None = None,
1566
+ additional_fields: Iterable[AbstractVeloxFieldDefinition] | None = None,
1537
1567
  *,
1538
1568
  require_authentication: bool = False) -> ESigningResponsePojo:
1539
1569
  """
@@ -1572,7 +1602,7 @@ class CallbackUtil:
1572
1602
  popup_type=PopupType.Error)
1573
1603
  return response
1574
1604
 
1575
- def request_file(self, title: str, exts: list[str] | None = None,
1605
+ def request_file(self, title: str, exts: Iterable[str] | None = None,
1576
1606
  show_image_editor: bool = False, show_camera_button: bool = False) -> tuple[str, bytes]:
1577
1607
  """
1578
1608
  Request a single file from the user.
@@ -1605,7 +1635,7 @@ class CallbackUtil:
1605
1635
  self.__verify_file(file_path, sink.data, exts)
1606
1636
  return file_path, sink.data
1607
1637
 
1608
- def request_files(self, title: str, exts: list[str] | None = None,
1638
+ def request_files(self, title: str, exts: Iterable[str] | None = None,
1609
1639
  show_image_editor: bool = False, show_camera_button: bool = False) -> dict[str, bytes]:
1610
1640
  """
1611
1641
  Request multiple files from the user.
@@ -1637,7 +1667,7 @@ class CallbackUtil:
1637
1667
  return ret_dict
1638
1668
 
1639
1669
  @staticmethod
1640
- def __verify_file(file_path: str, file_bytes: bytes, allowed_extensions: list[str]) -> None:
1670
+ def __verify_file(file_path: str, file_bytes: bytes, allowed_extensions: Iterable[str]) -> None:
1641
1671
  """
1642
1672
  Verify that the provided file was read (i.e. the file path and file bytes aren't None or empty) and that it
1643
1673
  has the correct file extension. Raises a user error exception if something about the file is incorrect.
@@ -1648,7 +1678,7 @@ class CallbackUtil:
1648
1678
  """
1649
1679
  if file_path is None or len(file_path) == 0 or file_bytes is None or len(file_bytes) == 0:
1650
1680
  raise SapioUserErrorException("Empty file provided or file unable to be read.")
1651
- if len(allowed_extensions) != 0:
1681
+ if allowed_extensions:
1652
1682
  matches: bool = False
1653
1683
  for ext in allowed_extensions:
1654
1684
  if file_path.endswith("." + ext.lstrip(".")):
@@ -1665,8 +1695,8 @@ class CallbackUtil:
1665
1695
  :param file_name: The name of the file.
1666
1696
  :param file_data: The data of the file, provided as either a string or as a bytes array.
1667
1697
  """
1668
- data = io.BytesIO(file_data.encode() if isinstance(file_data, str) else file_data)
1669
- self.callback.send_file(file_name, False, data)
1698
+ with io.BytesIO(file_data.encode() if isinstance(file_data, str) else file_data) as data:
1699
+ self.callback.send_file(file_name, False, data)
1670
1700
 
1671
1701
  def write_zip_file(self, zip_name: str, files: dict[str, str | bytes]) -> None:
1672
1702
  """
@@ -1675,12 +1705,33 @@ class CallbackUtil:
1675
1705
  :param zip_name: The name of the zip file.
1676
1706
  :param files: A dictionary of the files to add to the zip file.
1677
1707
  """
1678
- data = io.BytesIO(FileUtil.zip_files(files))
1679
- self.callback.send_file(zip_name, False, data)
1708
+ with io.BytesIO(FileUtil.zip_files(files)) as data:
1709
+ self.callback.send_file(zip_name, False, data)
1710
+
1711
+ @staticmethod
1712
+ def __get_indexed_field_maps(records: Iterable[SapioRecord], index_field: str) -> list[FieldMap]:
1713
+ """
1714
+ For dialogs that accept multiple records, we may want to be able to match the returned results back to the
1715
+ records that they're for. In this case, we need to add an index to each record so that we can match them back
1716
+ to the original records. We can't use the RecordId field, as new record models have negative record IDs that
1717
+ cause the callback dialogs to bug out if the RecordId field is present and negative.
1718
+
1719
+ :param records: The records to return indexed field maps of.
1720
+ :param index_field: The name of the field to use as the index. Make sure that this field doesn't exist on the
1721
+ records, as then it will overwrite the existing value.
1722
+ :return: A list of field maps for the records, with an index field added to each. The value of the index on
1723
+ each field map is the record's record ID (even if it's a record model with a negative ID).
1724
+ """
1725
+ ret_val: list[FieldMap] = []
1726
+ for record in records:
1727
+ field_map: FieldMap = AliasUtil.to_field_map(record)
1728
+ field_map[index_field] = AliasUtil.to_record_id(record)
1729
+ ret_val.append(field_map)
1730
+ return ret_val
1680
1731
 
1681
1732
  @staticmethod
1682
1733
  def __temp_dt_from_field_defs(data_type: DataTypeIdentifier, display_name: str | None,
1683
- plural_display_name: str | None, fields: list[AbstractVeloxFieldDefinition],
1734
+ plural_display_name: str | None, fields: Iterable[AbstractVeloxFieldDefinition],
1684
1735
  column_positions: dict[str, tuple[int, int]] | None) -> TemporaryDataType:
1685
1736
  """
1686
1737
  Construct a Temporary Data Type definition from a provided list of field definitions for use in a callback.
@@ -1714,7 +1765,7 @@ class CallbackUtil:
1714
1765
  builder.add_field(field_def, column, span)
1715
1766
  return builder.get_temporary_data_type()
1716
1767
 
1717
- def __temp_dt_from_field_names(self, data_type: str, fields: list[FieldIdentifier | FieldFilterCriteria],
1768
+ def __temp_dt_from_field_names(self, data_type: str, fields: Iterable[FieldIdentifier | FieldFilterCriteria],
1718
1769
  column_positions: dict[str, tuple[int, int]] | None,
1719
1770
  default_modifier: FieldModifier, field_modifiers: dict[str, FieldModifier]) \
1720
1771
  -> TemporaryDataType:
@@ -1731,6 +1782,7 @@ class CallbackUtil:
1731
1782
 
1732
1783
  # Determine if any FieldFilterCriteria were provided. If so, remove them from the fields list so that it
1733
1784
  # contains only field identifiers.
1785
+ fields = list(fields)
1734
1786
  filter_criteria: list[FieldFilterCriteria] = [x for x in fields if isinstance(x, FieldFilterCriteria)]
1735
1787
  for criteria in filter_criteria:
1736
1788
  fields.remove(criteria)
@@ -1983,15 +2035,15 @@ class FieldFilterCriteria:
1983
2035
  key_field: bool | None
1984
2036
  identifier: bool | None
1985
2037
  system_field: bool | None
1986
- field_types: list[FieldType] | None
1987
- not_field_types: list[FieldType] | None
2038
+ field_types: Container[FieldType] | None
2039
+ not_field_types: Container[FieldType] | None
1988
2040
  matches_tag: str | None
1989
2041
  contains_tag: str | None
1990
2042
  regex_tag: str | re.Pattern[str] | None
1991
2043
 
1992
2044
  def __init__(self, *, required: bool | None = None, editable: bool | None = None, key_field: bool | None = None,
1993
2045
  identifier: bool | None = None, system_field: bool | None = None,
1994
- field_types: list[FieldType] | None = None, not_field_types: list[FieldType] | None = None,
2046
+ field_types: Container[FieldType] | None = None, not_field_types: Container[FieldType] | None = None,
1995
2047
  matches_tag: str | None = None, contains_tag: str | None = None,
1996
2048
  regex_tag: str | re.Pattern[str] | None = None):
1997
2049
  """
@@ -443,6 +443,8 @@ class FieldBuilder:
443
443
  raise SapioException("Unable to set multiple list modes at once for a selection list.")
444
444
  # Static values don't have a list mode. Evaluate this last so that the multiple list modes check doesn't
445
445
  # need to be more complex.
446
+ # PR-47531: Even though static values don't use an existing list mode, a list mode must still be set.
447
+ list_mode = ListMode.USER
446
448
 
447
449
  if not list_mode and static_values is None:
448
450
  raise SapioException("A list mode must be chosen for selection list fields.")
@@ -6,6 +6,7 @@ from sapiopylib.rest.CustomReportService import CustomReportManager
6
6
  from sapiopylib.rest.DataMgmtService import DataMgmtServer
7
7
  from sapiopylib.rest.pojo.CustomReport import CustomReportCriteria, CustomReport, RawReportTerm, ReportColumn
8
8
  from sapiopylib.rest.pojo.datatype.FieldDefinition import FieldType
9
+ # noinspection PyProtectedMember
9
10
  from sapiopylib.rest.utils.autopaging import SapioPyAutoPager, PagerResultCriteriaType, _default_report_page_size, \
10
11
  _default_record_page_size
11
12
  from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel
@@ -153,7 +154,7 @@ class _RecordReportPagerBase(SapioPyAutoPager[CustomReportCriteria, WrappedType
153
154
  if id_index == -1:
154
155
  raise SapioException(f"This report does not contain a Record ID column for the given record model type "
155
156
  f"{self._data_type}.")
156
- ids: list[int] = [row[id_index] for row in report.result_table]
157
+ ids: set[int] = {row[id_index] for row in report.result_table}
157
158
  for row in self._rec_handler.query_models_by_id(self._query_type, ids, page_size=report.page_size):
158
159
  queue.put(row)
159
160
  if report.has_next_page:
@@ -279,7 +279,7 @@ class TermBuilder:
279
279
 
280
280
  :param a: The first term in the operation.
281
281
  :param b: The second term in the operation.
282
- :param is_negated: Whether the returned term should be negated (i.e. turn this into an xnor operation).
282
+ :param is_negated: Whether the returned term should be negated (i.e. turn this into an XNOR operation).
283
283
  :return: A composite report term for "A xor B".
284
284
  """
285
285
  return TermBuilder.and_terms(TermBuilder.or_terms(a, b),