sapiopycommons 2025.7.8a581__py3-none-any.whl → 2025.7.9a583__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.

@@ -780,7 +780,7 @@ class CallbackUtil:
780
780
  # FR-47690: Set default values for fields that aren't present.
781
781
  for row in values:
782
782
  for field in fields:
783
- if field.data_field_name not in values:
783
+ if field.data_field_name not in row:
784
784
  row[field.data_field_name] = field.default_value
785
785
 
786
786
  # Convert the group_by parameter to a field name.
@@ -6,8 +6,6 @@ indigo = Indigo()
6
6
  renderer = IndigoRenderer(indigo)
7
7
  indigo.setOption("render-output-format", "svg")
8
8
  indigo.setOption("ignore-stereochemistry-errors", True)
9
- # Ignore only if loading as non-query object. That is the meaning of this flag. Does nothing if it's query molecule.
10
- indigo.setOption("ignore-noncritical-query-features", True)
11
9
  indigo.setOption("render-stereo-style", "ext")
12
10
  indigo.setOption("aromaticity-model", "generic")
13
11
  indigo.setOption("render-coloring", True)
@@ -3,9 +3,13 @@ from __future__ import annotations
3
3
  import io
4
4
  import warnings
5
5
  from collections.abc import Iterable
6
- from typing import Collection
6
+ from typing import Collection, TypeVar, TypeAlias
7
7
  from weakref import WeakValueDictionary
8
8
 
9
+ from sapiopycommons.general.aliases import RecordModel, SapioRecord, FieldMap, FieldIdentifier, AliasUtil, \
10
+ FieldIdentifierMap, FieldValue, UserIdentifier, FieldIdentifierKey, DataTypeIdentifier
11
+ from sapiopycommons.general.custom_report_util import CustomReportUtil
12
+ from sapiopycommons.general.exceptions import SapioException
9
13
  from sapiopylib.rest.DataRecordManagerService import DataRecordManager
10
14
  from sapiopylib.rest.User import SapioUser
11
15
  from sapiopylib.rest.pojo.CustomReport import CustomReportCriteria, RawReportTerm, ReportColumn
@@ -24,19 +28,23 @@ from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType, Wr
24
28
  from sapiopylib.rest.utils.recordmodel.RelationshipPath import RelationshipPath, RelationshipNode, \
25
29
  RelationshipNodeType
26
30
  from sapiopylib.rest.utils.recordmodel.ancestry import RecordModelAncestorManager
27
- from sapiopylib.rest.utils.recordmodel.properties import Parents, Parent, Children, Child, ForwardSideLink
28
-
29
- from sapiopycommons.general.aliases import RecordModel, SapioRecord, FieldMap, FieldIdentifier, AliasUtil, \
30
- FieldIdentifierMap, FieldValue, UserIdentifier, FieldIdentifierKey, DataTypeIdentifier
31
- from sapiopycommons.general.custom_report_util import CustomReportUtil
32
- from sapiopycommons.general.exceptions import SapioException
31
+ from sapiopylib.rest.utils.recordmodel.properties import Parents, Parent, Children, Child, ForwardSideLink, \
32
+ ReverseSideLink
33
33
 
34
34
  # Aliases for longer name.
35
- _PropertyGetter = AbstractRecordModelPropertyGetter
36
- _PropertyAdder = AbstractRecordModelPropertyAdder
37
- _PropertyRemover = AbstractRecordModelPropertyRemover
38
- _PropertySetter = AbstractRecordModelPropertySetter
39
- _PropertyType = RecordModelPropertyType
35
+ _PropertyGetter: TypeAlias = AbstractRecordModelPropertyGetter
36
+ _PropertyAdder: TypeAlias = AbstractRecordModelPropertyAdder
37
+ _PropertyRemover: TypeAlias = AbstractRecordModelPropertyRemover
38
+ _PropertySetter: TypeAlias = AbstractRecordModelPropertySetter
39
+ _PropertyType: TypeAlias = RecordModelPropertyType
40
+
41
+ # CR-47717: Use TypeVars in the type hints of certain functions to prevent PyCharm from erroneously flagging certain
42
+ # return type hints as incorrect.
43
+ IsRecordModel = TypeVar('IsRecordModel', bound=RecordModel)
44
+ """A PyRecordModel or AbstractRecordModel."""
45
+ IsSapioRecord = TypeVar('IsSapioRecord', bound=SapioRecord)
46
+ """A DataRecord, PyRecordModel, or AbstractRecordModel."""
47
+
40
48
 
41
49
  # FR-46064 - Initial port of PyWebhookUtils to sapiopycommons.
42
50
  # FR-47575 - Reordered functions so that the Java and Python versions are as close to each other as possible.
@@ -524,9 +532,11 @@ class RecordHandler:
524
532
  """
525
533
  warnings.warn("Deprecated in favor of the [System/Custom/Quick]ReportRecordAutoPager classes.", DeprecationWarning)
526
534
  if isinstance(report_name, str):
535
+ # noinspection PyDeprecation
527
536
  results: list[dict[str, FieldValue]] = CustomReportUtil.run_system_report(self.user, report_name, filters,
528
537
  page_limit, page_size, page_number)
529
538
  elif isinstance(report_name, RawReportTerm):
539
+ # noinspection PyDeprecation
530
540
  results: list[dict[str, FieldValue]] = CustomReportUtil.run_quick_report(self.user, report_name, filters,
531
541
  page_limit, page_size, page_number)
532
542
  elif isinstance(report_name, CustomReportCriteria):
@@ -539,6 +549,7 @@ class RecordHandler:
539
549
  # Enforce that the given custom report has a record ID column.
540
550
  if not any([x.data_type_name == dt and x.data_field_name == "RecordId" for x in report_name.column_list]):
541
551
  report_name.column_list.append(ReportColumn(dt, "RecordId", FieldType.LONG))
552
+ # noinspection PyDeprecation
542
553
  results: list[dict[str, FieldValue]] = CustomReportUtil.run_custom_report(self.user, report_name, filters,
543
554
  page_limit, page_size, page_number)
544
555
  else:
@@ -551,7 +562,7 @@ class RecordHandler:
551
562
  return self.query_models_by_id(wrapper_type, ids)
552
563
 
553
564
  @staticmethod
554
- def map_by_id(models: Iterable[SapioRecord]) -> dict[int, SapioRecord]:
565
+ def map_by_id(models: Iterable[IsSapioRecord]) -> dict[int, IsSapioRecord]:
555
566
  """
556
567
  Map the given records their record IDs.
557
568
 
@@ -560,12 +571,12 @@ class RecordHandler:
560
571
  """
561
572
  ret_dict: dict[int, SapioRecord] = {}
562
573
  for model in models:
563
- ret_dict.update({model.record_id: model})
574
+ ret_dict.update({AliasUtil.to_record_id(model): model})
564
575
  return ret_dict
565
576
 
566
577
  @staticmethod
567
- def map_by_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) \
568
- -> dict[FieldValue, list[SapioRecord]]:
578
+ def map_by_field(models: Iterable[IsSapioRecord], field_name: FieldIdentifier) \
579
+ -> dict[FieldValue, list[IsSapioRecord]]:
569
580
  """
570
581
  Map the given records by one of their fields. If any two records share the same field value, they'll appear in
571
582
  the same value list.
@@ -582,8 +593,8 @@ class RecordHandler:
582
593
  return ret_dict
583
594
 
584
595
  @staticmethod
585
- def map_by_unique_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) \
586
- -> dict[FieldValue, SapioRecord]:
596
+ def map_by_unique_field(models: Iterable[IsSapioRecord], field_name: FieldIdentifier) \
597
+ -> dict[FieldValue, IsSapioRecord]:
587
598
  """
588
599
  Uniquely map the given records by one of their fields. If any two records share the same field value, throws
589
600
  an exception.
@@ -662,7 +673,7 @@ class RecordHandler:
662
673
  return RecordHandler.sum_of_field(models, field_name) / len(models)
663
674
 
664
675
  @staticmethod
665
- def get_newest_record(records: Iterable[SapioRecord]) -> SapioRecord:
676
+ def get_newest_record(records: Iterable[IsSapioRecord]) -> IsSapioRecord:
666
677
  """
667
678
  Get the newest record from a list of records.
668
679
 
@@ -673,7 +684,7 @@ class RecordHandler:
673
684
 
674
685
  # FR-46696: Add a function for getting the oldest record in a list, just like we have one for the newest record.
675
686
  @staticmethod
676
- def get_oldest_record(records: Iterable[SapioRecord]) -> SapioRecord:
687
+ def get_oldest_record(records: Iterable[IsSapioRecord]) -> IsSapioRecord:
677
688
  """
678
689
  Get the oldest record from a list of records.
679
690
 
@@ -683,7 +694,7 @@ class RecordHandler:
683
694
  return min(records, key=lambda x: x.record_id)
684
695
 
685
696
  @staticmethod
686
- def get_min_record(records: list[RecordModel], field: FieldIdentifier) -> RecordModel:
697
+ def get_min_record(records: list[IsSapioRecord], field: FieldIdentifier) -> IsSapioRecord:
687
698
  """
688
699
  Get the record model with the minimum value of a given field from a list of record models.
689
700
 
@@ -695,7 +706,7 @@ class RecordHandler:
695
706
  return min(records, key=lambda x: x.get_field_value(field))
696
707
 
697
708
  @staticmethod
698
- def get_max_record(records: list[RecordModel], field: FieldIdentifier) -> RecordModel:
709
+ def get_max_record(records: list[IsSapioRecord], field: FieldIdentifier) -> IsSapioRecord:
699
710
  """
700
711
  Get the record model with the maximum value of a given field from a list of record models.
701
712
 
@@ -870,7 +881,7 @@ class RecordHandler:
870
881
  parent_dt: str = AliasUtil.to_data_type_name(parent_type)
871
882
  wrapper: type[WrappedType] | None = parent_type if isinstance(parent_type, type) else None
872
883
  record: PyRecordModel = RecordModelInstanceManager.unwrap(record)
873
- parent: PyRecordModel | None = record.get_parent_of_type(parent_dt)
884
+ parent: PyRecordModel | None = record.get(Parent.of_type_name(parent_dt))
874
885
  if parent is not None:
875
886
  return self.wrap_model(parent, wrapper) if wrapper else parent
876
887
  return record.add(Parent.create(wrapper)) if wrapper else record.add(Parent.create_by_name(parent_dt))
@@ -888,7 +899,7 @@ class RecordHandler:
888
899
  child_dt: str = AliasUtil.to_data_type_name(child_type)
889
900
  wrapper: type[WrappedType] | None = child_type if isinstance(child_type, type) else None
890
901
  record: PyRecordModel = RecordModelInstanceManager.unwrap(record)
891
- child: PyRecordModel | None = record.get_child_of_type(child_dt)
902
+ child: PyRecordModel | None = record.get(Child.of_type_name(child_dt))
892
903
  if child is not None:
893
904
  return self.wrap_model(child, wrapper) if wrapper else child
894
905
  return record.add(Child.create(wrapper)) if wrapper else record.add(Child.create_by_name(child_dt))
@@ -908,7 +919,7 @@ class RecordHandler:
908
919
  side_link_field: str = AliasUtil.to_data_field_name(side_link_field)
909
920
  wrapper: type[WrappedType] | None = side_link_type if isinstance(side_link_type, type) else None
910
921
  record: PyRecordModel = RecordModelInstanceManager.unwrap(record)
911
- side_link: PyRecordModel | None = record.get_forward_side_link(side_link_field)
922
+ side_link: PyRecordModel | None = record.get(ForwardSideLink.of(side_link_field))
912
923
  if side_link is not None:
913
924
  return self.wrap_model(side_link, wrapper) if wrapper else side_link
914
925
  side_link: WrappedType | PyRecordModel = self.add_model(side_link_type)
@@ -955,52 +966,63 @@ class RecordHandler:
955
966
  if child not in children:
956
967
  record.remove(Child.ref(child))
957
968
 
969
+ # CR-47717: Update the map_[to/by]_[relationship] functions to allow PyRecordModels to be provided and returned
970
+ # instead of only using WrappedRecordModels and wrapper types.
958
971
  @staticmethod
959
- def map_to_parent(models: Iterable[WrappedRecordModel], parent_type: type[WrappedType])\
960
- -> dict[WrappedRecordModel, WrappedType]:
972
+ def map_to_parent(models: Iterable[IsRecordModel], parent_type: type[WrappedType] | str) \
973
+ -> dict[IsRecordModel, WrappedType | PyRecordModel]:
961
974
  """
962
975
  Map a list of record models to a single parent of a given type. The parents must already be loaded.
963
976
 
964
977
  :param models: A list of record models.
965
- :param parent_type: The record model wrapper of the parent.
978
+ :param parent_type: The record model wrapper or data type name of the parents. If a data type name is
979
+ provided, the returned records will be PyRecordModels instead of WrappedRecordModels.
966
980
  :return: A dict[ModelType, ParentType]. If an input model doesn't have a parent of the given parent type, then
967
981
  it will map to None.
968
982
  """
969
- return_dict: dict[WrappedRecordModel, WrappedType] = {}
983
+ return_dict: dict[RecordModel, WrappedType | PyRecordModel] = {}
970
984
  for model in models:
971
- return_dict[model] = model.get_parent_of_type(parent_type)
985
+ if isinstance(parent_type, str):
986
+ return_dict[model] = model.get(Parent.of_type_name(parent_type))
987
+ else:
988
+ return_dict[model] = model.get(Parent.of_type(parent_type))
972
989
  return return_dict
973
990
 
974
991
  @staticmethod
975
- def map_to_parents(models: Iterable[WrappedRecordModel], parent_type: type[WrappedType]) \
976
- -> dict[WrappedRecordModel, list[WrappedType]]:
992
+ def map_to_parents(models: Iterable[IsRecordModel], parent_type: type[WrappedType] | str) \
993
+ -> dict[IsRecordModel, list[WrappedType] | list[PyRecordModel]]:
977
994
  """
978
995
  Map a list of record models to a list parents of a given type. The parents must already be loaded.
979
996
 
980
997
  :param models: A list of record models.
981
- :param parent_type: The record model wrapper of the parents.
998
+ :param parent_type: The record model wrapper or data type name of the parents. If a data type name is
999
+ provided, the returned records will be PyRecordModels instead of WrappedRecordModels.
982
1000
  :return: A dict[ModelType, list[ParentType]]. If an input model doesn't have a parent of the given parent type,
983
1001
  then it will map to an empty list.
984
1002
  """
985
- return_dict: dict[WrappedRecordModel, list[WrappedType]] = {}
1003
+ return_dict: dict[WrappedRecordModel, list[WrappedType] | list[PyRecordModel]] = {}
986
1004
  for model in models:
987
- return_dict[model] = model.get_parents_of_type(parent_type)
1005
+ if isinstance(parent_type, str):
1006
+ return_dict[model] = model.get(Parents.of_type_name(parent_type))
1007
+ else:
1008
+ return_dict[model] = model.get(Parents.of_type(parent_type))
988
1009
  return return_dict
989
1010
 
990
1011
  @staticmethod
991
- def map_by_parent(models: Iterable[WrappedRecordModel], parent_type: type[WrappedType]) \
992
- -> dict[WrappedType, WrappedRecordModel]:
1012
+ def map_by_parent(models: Iterable[IsRecordModel], parent_type: type[WrappedType] | str) \
1013
+ -> dict[WrappedType | PyRecordModel, IsRecordModel]:
993
1014
  """
994
1015
  Take a list of record models and map them by their parent. Essentially an inversion of map_to_parent.
995
1016
  If two records share the same parent, an exception will be thrown. The parents must already be loaded.
996
1017
 
997
1018
  :param models: A list of record models.
998
- :param parent_type: The record model wrapper of the parents.
1019
+ :param parent_type: The record model wrapper or data type name of the parents. If a data type name is
1020
+ provided, the returned records will be PyRecordModels instead of WrappedRecordModels.
999
1021
  :return: A dict[ParentType, ModelType]. If an input model doesn't have a parent of the given parent type,
1000
1022
  then it will not be in the resulting dictionary.
1001
1023
  """
1002
- to_parent: dict[WrappedRecordModel, WrappedType] = RecordHandler.map_to_parent(models, parent_type)
1003
- by_parent: dict[WrappedType, WrappedRecordModel] = {}
1024
+ to_parent: dict[RecordModel, WrappedType | PyRecordModel] = RecordHandler.map_to_parent(models, parent_type)
1025
+ by_parent: dict[WrappedType | PyRecordModel, RecordModel] = {}
1004
1026
  for record, parent in to_parent.items():
1005
1027
  if parent is None:
1006
1028
  continue
@@ -1011,70 +1033,81 @@ class RecordHandler:
1011
1033
  return by_parent
1012
1034
 
1013
1035
  @staticmethod
1014
- def map_by_parents(models: Iterable[WrappedRecordModel], parent_type: type[WrappedType]) \
1015
- -> dict[WrappedType, list[WrappedRecordModel]]:
1036
+ def map_by_parents(models: Iterable[IsRecordModel], parent_type: type[WrappedType] | str) \
1037
+ -> dict[WrappedType | PyRecordModel, list[IsRecordModel]]:
1016
1038
  """
1017
1039
  Take a list of record models and map them by their parents. Essentially an inversion of map_to_parents. Input
1018
1040
  models that share a parent will end up in the same list. The parents must already be loaded.
1019
1041
 
1020
1042
  :param models: A list of record models.
1021
- :param parent_type: The record model wrapper of the parents.
1043
+ :param parent_type: The record model wrapper or data type name of the parents. If a data type name is
1044
+ provided, the returned records will be PyRecordModels instead of WrappedRecordModels.
1022
1045
  :return: A dict[ParentType, list[ModelType]]. If an input model doesn't have a parent of the given parent type,
1023
1046
  then it will not be in the resulting dictionary.
1024
1047
  """
1025
- to_parents: dict[WrappedRecordModel, list[WrappedType]] = RecordHandler.map_to_parents(models, parent_type)
1026
- by_parents: dict[WrappedType, list[WrappedRecordModel]] = {}
1048
+ to_parents: dict[RecordModel, list[WrappedType] | list[PyRecordModel]] = RecordHandler\
1049
+ .map_to_parents(models, parent_type)
1050
+ by_parents: dict[WrappedType | PyRecordModel, list[RecordModel]] = {}
1027
1051
  for record, parents in to_parents.items():
1028
1052
  for parent in parents:
1029
1053
  by_parents.setdefault(parent, []).append(record)
1030
1054
  return by_parents
1031
1055
 
1032
1056
  @staticmethod
1033
- def map_to_child(models: Iterable[WrappedRecordModel], child_type: type[WrappedType])\
1034
- -> dict[WrappedRecordModel, WrappedType]:
1057
+ def map_to_child(models: Iterable[IsRecordModel], child_type: type[WrappedType] | str) \
1058
+ -> dict[IsRecordModel, WrappedType | PyRecordModel]:
1035
1059
  """
1036
1060
  Map a list of record models to a single child of a given type. The children must already be loaded.
1037
1061
 
1038
1062
  :param models: A list of record models.
1039
- :param child_type: The record model wrapper of the child.
1063
+ :param child_type: The record model wrapper or data type name of the children. If a data type name is
1064
+ provided, the returned records will be PyRecordModels instead of WrappedRecordModels.
1040
1065
  :return: A dict[ModelType, ChildType]. If an input model doesn't have a child of the given child type, then
1041
1066
  it will map to None.
1042
1067
  """
1043
- return_dict: dict[WrappedRecordModel, WrappedType] = {}
1068
+ return_dict: dict[RecordModel, WrappedType | PyRecordModel] = {}
1044
1069
  for model in models:
1045
- return_dict[model] = model.get_child_of_type(child_type)
1070
+ if isinstance(child_type, str):
1071
+ return_dict[model] = model.get(Child.of_type_name(child_type))
1072
+ else:
1073
+ return_dict[model] = model.get(Child.of_type(child_type))
1046
1074
  return return_dict
1047
1075
 
1048
1076
  @staticmethod
1049
- def map_to_children(models: Iterable[WrappedRecordModel], child_type: type[WrappedType]) \
1050
- -> dict[WrappedRecordModel, list[WrappedType]]:
1077
+ def map_to_children(models: Iterable[IsRecordModel], child_type: type[WrappedType] | str) \
1078
+ -> dict[IsRecordModel, list[WrappedType] | PyRecordModel]:
1051
1079
  """
1052
1080
  Map a list of record models to a list children of a given type. The children must already be loaded.
1053
1081
 
1054
1082
  :param models: A list of record models.
1055
- :param child_type: The record model wrapper of the children.
1083
+ :param child_type: The record model wrapper or data type name of the children. If a data type name is
1084
+ provided, the returned records will be PyRecordModels instead of WrappedRecordModels.
1056
1085
  :return: A dict[ModelType, list[ChildType]]. If an input model doesn't have children of the given child type,
1057
1086
  then it will map to an empty list.
1058
1087
  """
1059
- return_dict: dict[WrappedRecordModel, list[WrappedType]] = {}
1088
+ return_dict: dict[RecordModel, list[WrappedType] | list[PyRecordModel]] = {}
1060
1089
  for model in models:
1061
- return_dict[model] = model.get_children_of_type(child_type)
1090
+ if isinstance(child_type, str):
1091
+ return_dict[model] = model.get(Children.of_type_name(child_type))
1092
+ else:
1093
+ return_dict[model] = model.get(Children.of_type(child_type))
1062
1094
  return return_dict
1063
1095
 
1064
1096
  @staticmethod
1065
- def map_by_child(models: Iterable[WrappedRecordModel], child_type: type[WrappedType]) \
1066
- -> dict[WrappedType, WrappedRecordModel]:
1097
+ def map_by_child(models: Iterable[IsRecordModel], child_type: type[WrappedType] | str) \
1098
+ -> dict[WrappedType | str, IsRecordModel]:
1067
1099
  """
1068
1100
  Take a list of record models and map them by their children. Essentially an inversion of map_to_child.
1069
1101
  If two records share the same child, an exception will be thrown. The children must already be loaded.
1070
1102
 
1071
1103
  :param models: A list of record models.
1072
- :param child_type: The record model wrapper of the children.
1104
+ :param child_type: The record model wrapper or data type name of the children. If a data type name is
1105
+ provided, the returned records will be PyRecordModels instead of WrappedRecordModels.
1073
1106
  :return: A dict[ChildType, ModelType]. If an input model doesn't have a child of the given child type,
1074
1107
  then it will not be in the resulting dictionary.
1075
1108
  """
1076
- to_child: dict[WrappedRecordModel, WrappedType] = RecordHandler.map_to_child(models, child_type)
1077
- by_child: dict[WrappedType, WrappedRecordModel] = {}
1109
+ to_child: dict[RecordModel, WrappedType | PyRecordModel] = RecordHandler.map_to_child(models, child_type)
1110
+ by_child: dict[WrappedType | PyRecordModel, RecordModel] = {}
1078
1111
  for record, child in to_child.items():
1079
1112
  if child is None:
1080
1113
  continue
@@ -1085,45 +1118,50 @@ class RecordHandler:
1085
1118
  return by_child
1086
1119
 
1087
1120
  @staticmethod
1088
- def map_by_children(models: Iterable[WrappedRecordModel], child_type: type[WrappedType]) \
1089
- -> dict[WrappedType, list[WrappedRecordModel]]:
1121
+ def map_by_children(models: Iterable[IsRecordModel], child_type: type[WrappedType] | str) \
1122
+ -> dict[WrappedType | PyRecordModel, list[IsRecordModel]]:
1090
1123
  """
1091
1124
  Take a list of record models and map them by their children. Essentially an inversion of map_to_children. Input
1092
1125
  models that share a child will end up in the same list. The children must already be loaded.
1093
1126
 
1094
1127
  :param models: A list of record models.
1095
- :param child_type: The record model wrapper of the children.
1128
+ :param child_type: The record model wrapper or data type name of the children. If a data type name is
1129
+ provided, the returned records will be PyRecordModels instead of WrappedRecordModels.
1096
1130
  :return: A dict[ChildType, list[ModelType]]. If an input model doesn't have children of the given child type,
1097
1131
  then it will not be in the resulting dictionary.
1098
1132
  """
1099
- to_children: dict[WrappedRecordModel, list[WrappedType]] = RecordHandler.map_to_children(models, child_type)
1100
- by_children: dict[WrappedType, list[WrappedRecordModel]] = {}
1133
+ to_children: dict[RecordModel, list[WrappedType] | list[PyRecordModel]] = RecordHandler\
1134
+ .map_to_children(models, child_type)
1135
+ by_children: dict[WrappedType | PyRecordModel, list[RecordModel]] = {}
1101
1136
  for record, children in to_children.items():
1102
1137
  for child in children:
1103
1138
  by_children.setdefault(child, []).append(record)
1104
1139
  return by_children
1105
1140
 
1106
1141
  @staticmethod
1107
- def map_to_forward_side_link(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
1108
- side_link_type: type[WrappedType]) -> dict[WrappedRecordModel, WrappedType]:
1142
+ def map_to_forward_side_link(models: Iterable[IsRecordModel], field_name: FieldIdentifier,
1143
+ side_link_type: type[WrappedType] | None) \
1144
+ -> dict[IsRecordModel, WrappedType | PyRecordModel]:
1109
1145
  """
1110
1146
  Map a list of record models to their forward side link. The forward side link must already be loaded.
1111
1147
 
1112
1148
  :param models: A list of record models.
1113
1149
  :param field_name: The field name on the record models where the side link is located.
1114
- :param side_link_type: The record model wrapper of the forward side link.
1150
+ :param side_link_type: The record model wrapper of the forward side link. If None, the side links will
1151
+ be returned as PyRecordModels instead of WrappedRecordModels.
1115
1152
  :return: A dict[ModelType, SlideLink]. If an input model doesn't have a forward side link of the given type,
1116
1153
  then it will map to None.
1117
1154
  """
1118
1155
  field_name: str = AliasUtil.to_data_field_name(field_name)
1119
- return_dict: dict[WrappedRecordModel, WrappedType] = {}
1156
+ return_dict: dict[RecordModel, WrappedType | PyRecordModel] = {}
1120
1157
  for model in models:
1121
- return_dict[model] = model.get_forward_side_link(field_name, side_link_type)
1158
+ return_dict[model] = model.get(ForwardSideLink.of(field_name, side_link_type))
1122
1159
  return return_dict
1123
1160
 
1124
1161
  @staticmethod
1125
- def map_by_forward_side_link(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
1126
- side_link_type: type[WrappedType]) -> dict[WrappedType, WrappedRecordModel]:
1162
+ def map_by_forward_side_link(models: Iterable[IsRecordModel], field_name: FieldIdentifier,
1163
+ side_link_type: type[WrappedType] | None) \
1164
+ -> dict[WrappedType | PyRecordModel, IsRecordModel]:
1127
1165
  """
1128
1166
  Take a list of record models and map them by their forward side link. Essentially an inversion of
1129
1167
  map_to_forward_side_link, but if two records share the same forward link, an exception is thrown.
@@ -1131,14 +1169,15 @@ class RecordHandler:
1131
1169
 
1132
1170
  :param models: A list of record models.
1133
1171
  :param field_name: The field name on the record models where the side link is located.
1134
- :param side_link_type: The record model wrapper of the forward side links.
1172
+ :param side_link_type: The record model wrapper of the forward side links. If None, the side links will
1173
+ be returned as PyRecordModels instead of WrappedRecordModels.
1135
1174
  :return: A dict[SideLink, ModelType]. If an input model doesn't have a forward side link of the given type
1136
1175
  pointing to it, then it will not be in the resulting dictionary.
1137
1176
  """
1138
1177
  field_name: str = AliasUtil.to_data_field_name(field_name)
1139
- to_side_link: dict[WrappedRecordModel, WrappedType] = RecordHandler\
1178
+ to_side_link: dict[RecordModel, WrappedType | PyRecordModel] = RecordHandler\
1140
1179
  .map_to_forward_side_link(models, field_name, side_link_type)
1141
- by_side_link: dict[WrappedType, WrappedRecordModel] = {}
1180
+ by_side_link: dict[WrappedType | PyRecordModel, RecordModel] = {}
1142
1181
  for record, side_link in to_side_link.items():
1143
1182
  if side_link is None:
1144
1183
  continue
@@ -1149,8 +1188,9 @@ class RecordHandler:
1149
1188
  return by_side_link
1150
1189
 
1151
1190
  @staticmethod
1152
- def map_by_forward_side_links(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
1153
- side_link_type: type[WrappedType]) -> dict[WrappedType, list[WrappedRecordModel]]:
1191
+ def map_by_forward_side_links(models: Iterable[IsRecordModel], field_name: FieldIdentifier,
1192
+ side_link_type: type[WrappedType] | None) \
1193
+ -> dict[WrappedType | PyRecordModel, list[IsRecordModel]]:
1154
1194
  """
1155
1195
  Take a list of record models and map them by their forward side link. Essentially an inversion of
1156
1196
  map_to_forward_side_link. Input models that share a forward side link will end up in the same list.
@@ -1158,14 +1198,15 @@ class RecordHandler:
1158
1198
 
1159
1199
  :param models: A list of record models.
1160
1200
  :param field_name: The field name on the record models where the side link is located.
1161
- :param side_link_type: The record model wrapper of the forward side links.
1201
+ :param side_link_type: The record model wrapper of the forward side links. If None, the side links will
1202
+ be returned as PyRecordModels instead of WrappedRecordModels.
1162
1203
  :return: A dict[SideLink, list[ModelType]]. If an input model doesn't have a forward side link of the given type
1163
1204
  pointing to it, then it will not be in the resulting dictionary.
1164
1205
  """
1165
1206
  field_name: str = AliasUtil.to_data_field_name(field_name)
1166
- to_side_link: dict[WrappedRecordModel, WrappedType] = RecordHandler\
1207
+ to_side_link: dict[RecordModel, WrappedType | PyRecordModel] = RecordHandler\
1167
1208
  .map_to_forward_side_link(models, field_name, side_link_type)
1168
- by_side_link: dict[WrappedType, list[WrappedRecordModel]] = {}
1209
+ by_side_link: dict[WrappedType | PyRecordModel, list[RecordModel]] = {}
1169
1210
  for record, side_link in to_side_link.items():
1170
1211
  if side_link is None:
1171
1212
  continue
@@ -1173,8 +1214,9 @@ class RecordHandler:
1173
1214
  return by_side_link
1174
1215
 
1175
1216
  @staticmethod
1176
- def map_to_reverse_side_link(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
1177
- side_link_type: type[WrappedType]) -> dict[WrappedRecordModel, WrappedType]:
1217
+ def map_to_reverse_side_link(models: Iterable[IsRecordModel], field_name: FieldIdentifier,
1218
+ side_link_type: type[WrappedType] | str) \
1219
+ -> dict[IsRecordModel, WrappedType | PyRecordModel]:
1178
1220
  """
1179
1221
  Map a list of record models to the reverse side link of a given type. If a given record has more than one
1180
1222
  reverse side link of this type, an exception is thrown. The reverse side links must already be loaded.
@@ -1182,14 +1224,18 @@ class RecordHandler:
1182
1224
  :param models: A list of record models.
1183
1225
  :param field_name: The field name on the side linked model where the side link to the given record models is
1184
1226
  located.
1185
- :param side_link_type: The record model wrapper of the reverse side links.
1227
+ :param side_link_type: The record model wrapper or data type name of the reverse side links. If a data type
1228
+ name is provided, the returned records will be PyRecordModels instead of WrappedRecordModels.
1186
1229
  :return: A dict[ModelType, SideLink]. If an input model doesn't have reverse side links of the given type,
1187
1230
  then it will map to None.
1188
1231
  """
1189
1232
  field_name: str = AliasUtil.to_data_field_name(field_name)
1190
- return_dict: dict[WrappedRecordModel, WrappedType] = {}
1233
+ return_dict: dict[RecordModel, WrappedType | PyRecordModel] = {}
1191
1234
  for model in models:
1192
- links: list[WrappedType] = model.get_reverse_side_link(field_name, side_link_type)
1235
+ if isinstance(side_link_type, str):
1236
+ links: list[WrappedType] = model.get(ReverseSideLink.of(side_link_type, field_name))
1237
+ else:
1238
+ links: list[WrappedType] = model.get(ReverseSideLink.of_type(side_link_type, field_name))
1193
1239
  if len(links) > 1:
1194
1240
  raise SapioException(f"Model {model.data_type_name} {model.record_id} has more than one reverse link "
1195
1241
  f"of type {side_link_type.get_wrapper_data_type_name()}.")
@@ -1197,8 +1243,9 @@ class RecordHandler:
1197
1243
  return return_dict
1198
1244
 
1199
1245
  @staticmethod
1200
- def map_to_reverse_side_links(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
1201
- side_link_type: type[WrappedType]) -> dict[WrappedRecordModel, list[WrappedType]]:
1246
+ def map_to_reverse_side_links(models: Iterable[IsRecordModel], field_name: FieldIdentifier,
1247
+ side_link_type: type[WrappedType] | str) \
1248
+ -> dict[IsRecordModel, list[WrappedType] | list[PyRecordModel]]:
1202
1249
  """
1203
1250
  Map a list of record models to a list reverse side links of a given type. The reverse side links must already
1204
1251
  be loaded.
@@ -1206,19 +1253,24 @@ class RecordHandler:
1206
1253
  :param models: A list of record models.
1207
1254
  :param field_name: The field name on the side linked model where the side link to the given record models is
1208
1255
  located.
1209
- :param side_link_type: The record model wrapper of the reverse side links.
1256
+ :param side_link_type: The record model wrapper or data type name of the reverse side links. If a data type
1257
+ name is provided, the returned records will be PyRecordModels instead of WrappedRecordModels.
1210
1258
  :return: A dict[ModelType, list[SideLink]]. If an input model doesn't have reverse side links of the given type,
1211
1259
  then it will map to an empty list.
1212
1260
  """
1213
1261
  field_name: str = AliasUtil.to_data_field_name(field_name)
1214
- return_dict: dict[WrappedRecordModel, list[WrappedType]] = {}
1262
+ return_dict: dict[RecordModel, list[WrappedType] | list[PyRecordModel]] = {}
1215
1263
  for model in models:
1216
- return_dict[model] = model.get_reverse_side_link(field_name, side_link_type)
1264
+ if isinstance(side_link_type, str):
1265
+ return_dict[model] = model.get(ReverseSideLink.of(side_link_type, field_name))
1266
+ else:
1267
+ return_dict[model] = model.get(ReverseSideLink.of_type(side_link_type, field_name))
1217
1268
  return return_dict
1218
1269
 
1219
1270
  @staticmethod
1220
- def map_by_reverse_side_link(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
1221
- side_link_type: type[WrappedType]) -> dict[WrappedType, WrappedRecordModel]:
1271
+ def map_by_reverse_side_link(models: Iterable[IsRecordModel], field_name: FieldIdentifier,
1272
+ side_link_type: type[WrappedType] | str) \
1273
+ -> dict[WrappedType | PyRecordModel, IsRecordModel]:
1222
1274
  """
1223
1275
  Take a list of record models and map them by their reverse side link. Essentially an inversion of
1224
1276
  map_to_reverse_side_link. If two records share the same reverse side link, an exception is thrown.
@@ -1227,14 +1279,15 @@ class RecordHandler:
1227
1279
  :param models: A list of record models.
1228
1280
  :param field_name: The field name on the side linked model where the side link to the given record models is
1229
1281
  located.
1230
- :param side_link_type: The record model wrapper of the reverse side links.
1282
+ :param side_link_type: The record model wrapper or data type name of the reverse side links. If a data type
1283
+ name is provided, the returned records will be PyRecordModels instead of WrappedRecordModels.
1231
1284
  :return: A dict[SideLink, ModelType]. If an input model doesn't have a reverse side link of the given type
1232
1285
  pointing to it, then it will not be in the resulting dictionary.
1233
1286
  """
1234
1287
  field_name: str = AliasUtil.to_data_field_name(field_name)
1235
- to_side_link: dict[WrappedRecordModel, WrappedType] = RecordHandler\
1288
+ to_side_link: dict[RecordModel, WrappedType | PyRecordModel] = RecordHandler\
1236
1289
  .map_to_reverse_side_link(models, field_name, side_link_type)
1237
- by_side_link: dict[WrappedType, WrappedRecordModel] = {}
1290
+ by_side_link: dict[WrappedType | PyRecordModel, RecordModel] = {}
1238
1291
  for record, side_link in to_side_link.items():
1239
1292
  if side_link is None:
1240
1293
  continue
@@ -1245,8 +1298,8 @@ class RecordHandler:
1245
1298
  return by_side_link
1246
1299
 
1247
1300
  @staticmethod
1248
- def map_by_reverse_side_links(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
1249
- side_link_type: type[WrappedType]) -> dict[WrappedType, list[WrappedRecordModel]]:
1301
+ def map_by_reverse_side_links(models: Iterable[IsRecordModel], field_name: FieldIdentifier,
1302
+ side_link_type: type[WrappedType] | str) -> dict[WrappedType | PyRecordModel, list[IsRecordModel]]:
1250
1303
  """
1251
1304
  Take a list of record models and map them by their reverse side links. Essentially an inversion of
1252
1305
  map_to_reverse_side_links. Input models that share a reverse side link will end up in the same list.
@@ -1255,7 +1308,8 @@ class RecordHandler:
1255
1308
  :param models: A list of record models.
1256
1309
  :param field_name: The field name on the side linked model where the side link to the given record models is
1257
1310
  located.
1258
- :param side_link_type: The record model wrapper of the reverse side links.
1311
+ :param side_link_type: The record model wrapper or data type name of the reverse side links. If a data type
1312
+ name is provided, the returned records will be PyRecordModels instead of WrappedRecordModels.
1259
1313
  :return: A dict[SideLink, list[ModelType]]. If an input model doesn't have reverse side links of the given type
1260
1314
  pointing to it, then it will not be in the resulting dictionary.
1261
1315
  """
@@ -1270,9 +1324,9 @@ class RecordHandler:
1270
1324
 
1271
1325
  # FR-46155: Update relationship path traversing functions to be non-static and take in a wrapper type so that the
1272
1326
  # output can be wrapped instead of requiring the user to wrap the output.
1273
- def get_linear_path(self, models: Iterable[RecordModel], path: RelationshipPath,
1327
+ def get_linear_path(self, models: Iterable[IsRecordModel], path: RelationshipPath,
1274
1328
  wrapper_type: type[WrappedType] | None = None) \
1275
- -> dict[RecordModel, WrappedType | PyRecordModel | None]:
1329
+ -> dict[IsRecordModel, WrappedType | PyRecordModel | None]:
1276
1330
  """
1277
1331
  Given a relationship path, travel the path starting from the input models. Returns the record at the end of the
1278
1332
  path, if any. The hierarchy must be linear (1:1 relationship between data types at every step) and the
@@ -1285,7 +1339,7 @@ class RecordHandler:
1285
1339
  :return: Each record model mapped to the record at the end of the path starting from itself. If the end of the
1286
1340
  path couldn't be reached, the record will map to None.
1287
1341
  """
1288
- ret_dict: dict[RecordModel, WrappedType | None] = {}
1342
+ ret_dict: dict[RecordModel, WrappedType | PyRecordModel | None] = {}
1289
1343
  # PR-46832: Update path traversal to account for changes to RelationshipPath in Sapiopylib.
1290
1344
  path: list[RelationshipNode] = path.path
1291
1345
  for model in models:
@@ -1332,9 +1386,9 @@ class RecordHandler:
1332
1386
  ret_dict.update({model: self.wrap_model(current, wrapper_type) if current else None})
1333
1387
  return ret_dict
1334
1388
 
1335
- def get_branching_path(self, models: Iterable[RecordModel], path: RelationshipPath,
1389
+ def get_branching_path(self, models: Iterable[IsRecordModel], path: RelationshipPath,
1336
1390
  wrapper_type: type[WrappedType] | None = None)\
1337
- -> dict[RecordModel, list[WrappedType] | list[PyRecordModel]]:
1391
+ -> dict[IsRecordModel, list[WrappedType] | list[PyRecordModel]]:
1338
1392
  """
1339
1393
  Given a relationship path, travel the path starting from the input models. Returns the record at the end of the
1340
1394
  path, if any. The hierarchy may be non-linear (1:Many relationships between data types are allowed) and the
@@ -1347,7 +1401,7 @@ class RecordHandler:
1347
1401
  :return: Each record model mapped to the records at the end of the path starting from itself. If the end of the
1348
1402
  path couldn't be reached, the record will map to an empty list.
1349
1403
  """
1350
- ret_dict: dict[RecordModel, list[WrappedType]] = {}
1404
+ ret_dict: dict[RecordModel, list[WrappedType] | list[PyRecordModel]] = {}
1351
1405
  # PR-46832: Update path traversal to account for changes to RelationshipPath in Sapiopylib.
1352
1406
  path: list[RelationshipNode] = path.path
1353
1407
  for model in models:
@@ -1383,9 +1437,9 @@ class RecordHandler:
1383
1437
 
1384
1438
  # FR-46155: Create a relationship traversing function that returns a single function at the end of the path like
1385
1439
  # get_linear_path but can handle branching paths in the middle of the search like get_branching_path.
1386
- def get_flat_path(self, models: Iterable[RecordModel], path: RelationshipPath,
1440
+ def get_flat_path(self, models: Iterable[IsRecordModel], path: RelationshipPath,
1387
1441
  wrapper_type: type[WrappedType] | None = None) \
1388
- -> dict[RecordModel, WrappedType | PyRecordModel | None]:
1442
+ -> dict[IsRecordModel, WrappedType | PyRecordModel | None]:
1389
1443
  """
1390
1444
  Given a relationship path, travel the path starting from the input models. Returns the record at the end of the
1391
1445
  path, if any. The hierarchy may be non-linear (1:Many relationships between data types are allowed) and the
@@ -1402,7 +1456,7 @@ class RecordHandler:
1402
1456
  :return: Each record model mapped to the record at the end of the path starting from itself. If the end of the
1403
1457
  path couldn't be reached, the record will map to None.
1404
1458
  """
1405
- ret_dict: dict[RecordModel, WrappedType | None] = {}
1459
+ ret_dict: dict[RecordModel, WrappedType | PyRecordModel | None] = {}
1406
1460
  # PR-46832: Update path traversal to account for changes to RelationshipPath in Sapiopylib.
1407
1461
  path: list[RelationshipNode] = path.path
1408
1462
  for model in models:
@@ -7,7 +7,6 @@ import traceback
7
7
  from abc import abstractmethod
8
8
  from logging import Logger
9
9
 
10
- from sapiopylib.rest import UserManagerService, GroupManagerService, MessengerService
11
10
  from sapiopylib.rest.AccessionService import AccessionManager
12
11
  from sapiopylib.rest.CustomReportService import CustomReportManager
13
12
  from sapiopylib.rest.DashboardManager import DashboardManager
@@ -16,10 +15,13 @@ from sapiopylib.rest.DataRecordManagerService import DataRecordManager
16
15
  from sapiopylib.rest.DataService import DataManager
17
16
  from sapiopylib.rest.DataTypeService import DataTypeManager
18
17
  from sapiopylib.rest.ELNService import ElnManager
18
+ from sapiopylib.rest.GroupManagerService import VeloxGroupManager
19
+ from sapiopylib.rest.MessengerService import SapioMessenger
19
20
  from sapiopylib.rest.PicklistService import PickListManager
20
21
  from sapiopylib.rest.ReportManager import ReportManager
21
22
  from sapiopylib.rest.SesssionManagerService import SessionManager
22
23
  from sapiopylib.rest.User import SapioUser
24
+ from sapiopylib.rest.UserManagerService import VeloxUserManager
23
25
  from sapiopylib.rest.WebhookService import AbstractWebhookHandler
24
26
  from sapiopylib.rest.pojo.Message import VeloxLogMessage, VeloxLogLevel
25
27
  from sapiopylib.rest.pojo.webhook.ClientCallbackRequest import PopupType
@@ -85,9 +87,9 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
85
87
  """A class for making requests to the data type webservice endpoints."""
86
88
  eln_man: ElnManager
87
89
  """A class for making requests to the ELN management webservice endpoints."""
88
- group_man: GroupManagerService
90
+ group_man: VeloxGroupManager
89
91
  """A class for making requests to the group management webservice endpoints."""
90
- messenger: MessengerService
92
+ messenger: SapioMessenger
91
93
  """A class for making requests to the message webservice endpoints."""
92
94
  list_man: PickListManager
93
95
  """A class for making requests to the pick list webservice endpoints."""
@@ -95,7 +97,7 @@ class CommonsWebhookHandler(AbstractWebhookHandler):
95
97
  """A class for making requests to the report webservice endpoints."""
96
98
  session_man: SessionManager
97
99
  """A class for making requests to the session management webservice endpoints."""
98
- user_man: UserManagerService
100
+ user_man: VeloxUserManager
99
101
  """A class for making requests to the user management webservice endpoints."""
100
102
 
101
103
  rec_man: RecordModelManager
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sapiopycommons
3
- Version: 2025.7.8a581
3
+ Version: 2025.7.9a583
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>
@@ -1,11 +1,10 @@
1
1
  sapiopycommons/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  sapiopycommons/callbacks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
- sapiopycommons/callbacks/callback_util.py,sha256=rps6RA6lmzCOwiBqPQAe2Mkf0CIF4RjHPQTYgduMAgE,153011
3
+ sapiopycommons/callbacks/callback_util.py,sha256=OuPJ1o6jcDQ7qV-dxrjAkJerGbVI9_9P-xu0r3ODaMM,153008
4
4
  sapiopycommons/callbacks/field_builder.py,sha256=rnIP-RJafk3mZlAx1eJ8a0eSW9Ps_L6_WadCmusnENw,38772
5
- sapiopycommons/chem/IndigoMolecules.py,sha256=30bsnZ2o4fJXUV6kUTI-I6fDa7bQj7zfE3rOQQ7WD5M,5287
5
+ sapiopycommons/chem/IndigoMolecules.py,sha256=7ucCaRMLu1zfH2uPIvXwRTSdpNcS03O1P9p_O-5B4xQ,5110
6
6
  sapiopycommons/chem/Molecules.py,sha256=mVqPn32MPMjF0iZas-5MFkS-upIdoW5OB72KKZmJRJA,12523
7
7
  sapiopycommons/chem/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- sapiopycommons/chem/ps_commons.py,sha256=ngM6wAo_UMCkb--rtjLMyABysMopHmCc0C2LzYdDK7Y,19848
9
8
  sapiopycommons/customreport/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
9
  sapiopycommons/customreport/auto_pagers.py,sha256=89p-tik0MhsOplYje6LbAW4WClldpAmb8YXFDoXhIlY,17144
11
10
  sapiopycommons/customreport/column_builder.py,sha256=0RO53e9rKPZ07C--KcepN6_tpRw_FxF3O9vdG0ilKG8,3014
@@ -52,7 +51,7 @@ sapiopycommons/processtracking/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5
52
51
  sapiopycommons/processtracking/custom_workflow_handler.py,sha256=eYKdYlwo8xx-6AkB_iPUBNV9yDoNvW2h_Sm3i8JpmRU,25844
53
52
  sapiopycommons/processtracking/endpoints.py,sha256=5AJLbhRKQsOeeOdQa888xcCJZD5aavxD-DHZ36Qob_M,12548
54
53
  sapiopycommons/recordmodel/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
55
- sapiopycommons/recordmodel/record_handler.py,sha256=HfYOl_dDHFd0SEQS3g48_a4zsm36ODWkvZunwzCFDos,90666
54
+ sapiopycommons/recordmodel/record_handler.py,sha256=WxmgrWQ3nX3eVZSHJY7e8fj7CI7azSyEyovmYcy9098,95021
56
55
  sapiopycommons/rules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
57
56
  sapiopycommons/rules/eln_rule_handler.py,sha256=MnE-eSl1kNfaXWFi9elTOC9V2fdUzrwWTvCHUprC8_I,11388
58
57
  sapiopycommons/rules/on_save_rule_handler.py,sha256=fkNIlslAZZ0BUrRiwecyvf42JBR8FpCCQ6DBNKXP2jE,11155
@@ -61,9 +60,9 @@ sapiopycommons/sftpconnect/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJ
61
60
  sapiopycommons/sftpconnect/sftp_builder.py,sha256=lFK3FeXk-sFLefW0hqY8WGUQDeYiGaT6yDACzT_zFgQ,3015
62
61
  sapiopycommons/webhook/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
63
62
  sapiopycommons/webhook/webhook_context.py,sha256=D793uLsb1691SalaPnBUk3rOSxn_hYLhdvkaIxjNXss,1909
64
- sapiopycommons/webhook/webhook_handlers.py,sha256=tUVNCw05CDGu1gFDm2g558hX_O203WVm_n__ojjoRRM,39841
63
+ sapiopycommons/webhook/webhook_handlers.py,sha256=7o_wXOruhT9auNh8OfhJAh4WhhiPKij67FMBSpGPICc,39939
65
64
  sapiopycommons/webhook/webservice_handlers.py,sha256=tyaYGG1-v_JJrJHZ6cy5mGCxX9z1foLw7pM4MDJlFxs,14297
66
- sapiopycommons-2025.7.8a581.dist-info/METADATA,sha256=pqCJd-E4Z9GWbcI1jTNMzzBbdz3VRIACPNNHIjN0utM,3142
67
- sapiopycommons-2025.7.8a581.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
68
- sapiopycommons-2025.7.8a581.dist-info/licenses/LICENSE,sha256=HyVuytGSiAUQ6ErWBHTqt1iSGHhLmlC8fO7jTCuR8dU,16725
69
- sapiopycommons-2025.7.8a581.dist-info/RECORD,,
65
+ sapiopycommons-2025.7.9a583.dist-info/METADATA,sha256=zOjGEYY42rhigetoYC87X-jQ8rLzPNqEuP3stxwqarM,3142
66
+ sapiopycommons-2025.7.9a583.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
67
+ sapiopycommons-2025.7.9a583.dist-info/licenses/LICENSE,sha256=HyVuytGSiAUQ6ErWBHTqt1iSGHhLmlC8fO7jTCuR8dU,16725
68
+ sapiopycommons-2025.7.9a583.dist-info/RECORD,,
@@ -1,455 +0,0 @@
1
- """
2
- Parallel Synthesis Commons
3
- Author: Yechen Qiao
4
- """
5
- import json
6
- from dataclasses import dataclass
7
- from typing import Any
8
-
9
- from indigo import IndigoObject
10
- from sapiopycommons.chem.IndigoMolecules import indigo, get_aromatic_dearomatic_forms, renderer
11
-
12
-
13
- class SerializableQueryMolecule:
14
- mol_block: str
15
- smarts: str
16
- render_svg: str
17
-
18
- @staticmethod
19
- def create(query_molecule: IndigoObject):
20
- aromatic, dearomatic = get_aromatic_dearomatic_forms(query_molecule)
21
- ret: SerializableQueryMolecule = SerializableQueryMolecule()
22
- ret.mol_block = aromatic.molfile()
23
- ret.smarts = aromatic.smarts()
24
- ret.render_svg = renderer.renderToString(dearomatic)
25
- return ret
26
-
27
- def to_json(self) -> dict[str, Any]:
28
- """
29
- Save the SerializableQueryMolecule to a JSON string.
30
- :return: A JSON string representation of the query molecule.
31
- """
32
- return {
33
- "mol_block": self.mol_block,
34
- "smarts": self.smarts,
35
- "render_svg": self.render_svg
36
- }
37
-
38
-
39
- class SerializableMoleculeMatch:
40
- """
41
- A serializable match that stores and loads a match that can be serialized to JSON.
42
- """
43
- _query_atom_to_atom: dict[int, int]
44
- _query_bond_to_bond: dict[int, int]
45
- _query_molecule_file: str
46
- _matching_molecule_file: str
47
- _query_molecule: IndigoObject
48
- _matching_molecule: IndigoObject
49
- _record_id: int # Only when received from Sapio.
50
-
51
- @property
52
- def record_id(self) -> int:
53
- """
54
- Get the record ID of the match.
55
- :return: The record ID.
56
- """
57
- return self._record_id
58
-
59
- def __str__(self):
60
- return json.dumps(self.to_json())
61
-
62
- def __hash__(self):
63
- return hash(self._query_molecule.smarts())
64
-
65
- def __eq__(self, other):
66
- if not isinstance(other, SerializableMoleculeMatch):
67
- return False
68
- if self._query_atom_to_atom == other._query_atom_to_atom and \
69
- self._query_bond_to_bond == other._query_bond_to_bond and \
70
- self._query_molecule_file == other._query_molecule_file and \
71
- self._matching_molecule_file == other._matching_molecule_file and \
72
- self._record_id == other._record_id:
73
- return True
74
- if self._query_molecule.smarts() != other._query_molecule.smarts():
75
- return False
76
- return are_symmetrical_subs(self, other)
77
-
78
- def mapAtom(self, atom: IndigoObject) -> IndigoObject | None:
79
- if not self._query_atom_to_atom or atom.index() not in self._query_atom_to_atom:
80
- return None
81
- index = self._query_atom_to_atom[atom.index()]
82
- return self._matching_molecule.getAtom(index)
83
-
84
- def mapBond(self, bond: IndigoObject) -> IndigoObject | None:
85
- if not self._query_bond_to_bond or bond.index() not in self._query_bond_to_bond:
86
- return None
87
- index = self._query_bond_to_bond[bond.index()]
88
- return self._matching_molecule.getBond(index)
89
-
90
- def to_json(self) -> dict[str, Any]:
91
- """
92
- Save the SerializableMoleculeMatch to a JSON string.
93
- :return: A JSON string representation of the match.
94
- """
95
- return {
96
- "query_molecule_file": self._query_molecule_file,
97
- "matching_molecule_file": self._matching_molecule_file,
98
- "query_atom_to_atom": self._query_atom_to_atom,
99
- "query_bond_to_bond": self._query_bond_to_bond,
100
- "record_id": self._record_id
101
- }
102
-
103
- @staticmethod
104
- def from_json(json_dct: dict[str, Any]) -> 'SerializableMoleculeMatch':
105
- """
106
- Load a SerializableMoleculeMatch from a JSON string.
107
- :param json_dct: A JSON string representation of the match.
108
- :return: A new SerializableMoleculeMatch instance.
109
- """
110
- smm = SerializableMoleculeMatch()
111
- smm._query_atom_to_atom = {}
112
- for key, value in json_dct.get("query_atom_to_atom", {}).items():
113
- smm._query_atom_to_atom[int(key)] = int(value)
114
- smm._query_bond_to_bond = {}
115
- for key, value in json_dct.get("query_bond_to_bond", {}).items():
116
- smm._query_bond_to_bond[int(key)] = int(value)
117
- smm._query_molecule_file = json_dct.get("query_molecule_file")
118
- smm._matching_molecule_file = json_dct.get("matching_molecule_file")
119
- smm._query_molecule = indigo.loadQueryMolecule(smm._query_molecule_file)
120
- smm._matching_molecule = indigo.loadMolecule(smm._matching_molecule_file)
121
- smm._record_id = json_dct.get("record_id", 0) # Default to 0 if not present
122
- return smm
123
-
124
- @staticmethod
125
- def create(query_molecule: IndigoObject, matching_molecule: IndigoObject,
126
- match: IndigoObject) -> 'SerializableMoleculeMatch':
127
- """
128
- Create a SerializableMoleculeMatch from a query molecule, matching molecule, and match.
129
- :param query_molecule: The query molecule.
130
- :param matching_molecule: The matching molecule.
131
- :param match: The match object containing atom mappings.
132
- :return: A new SerializableMoleculeMatch instance.
133
- """
134
- smm = SerializableMoleculeMatch()
135
- smm._query_atom_to_atom = {}
136
- smm._query_bond_to_bond = {}
137
- smm._query_molecule = query_molecule.clone()
138
- smm._matching_molecule = matching_molecule.clone()
139
- smm._query_molecule_file = query_molecule.molfile()
140
- smm._matching_molecule_file = matching_molecule.molfile()
141
- smm._record_id = 0
142
-
143
- for qatom in query_molecule.iterateAtoms():
144
- concrete_atom = match.mapAtom(qatom)
145
- if concrete_atom is None:
146
- continue
147
- smm._query_atom_to_atom[qatom.index()] = concrete_atom.index()
148
-
149
- for qbond in query_molecule.iterateBonds():
150
- concrete_bond = match.mapBond(qbond)
151
- if concrete_bond is None:
152
- continue
153
- smm._query_bond_to_bond[qbond.index()] = concrete_bond.index()
154
- return smm
155
-
156
- def get_matched_molecule_copy(self):
157
- return self._matching_molecule.clone()
158
-
159
-
160
- @dataclass
161
- class ReplacementReaction:
162
- """
163
- A replacement reaction stores reactio template with 1 reactant replaced by specific user match.
164
- """
165
- reaction: IndigoObject
166
- reaction_reactant: IndigoObject
167
- replacement_reactant: IndigoObject
168
- replacement_query_reaction_match: SerializableMoleculeMatch
169
-
170
-
171
- # noinspection PyProtectedMember
172
- def highlight_mol_substructure_serial_match(molecule: IndigoObject, serializable_match: SerializableMoleculeMatch):
173
- """
174
- Highlight the substructure in the molecule based on the SerializableMoleculeMatch.
175
- :param molecule: The molecule to highlight.
176
- :param serializable_match: The SerializableMoleculeMatch containing atom mappings.
177
- """
178
- for qatom in serializable_match._query_molecule.iterateAtoms():
179
- atom = serializable_match.mapAtom(qatom)
180
- if atom is None:
181
- continue
182
- atom.highlight()
183
-
184
- for nei in atom.iterateNeighbors():
185
- if not nei.isPseudoatom() and not nei.isRSite() and nei.atomicNumber() == 1:
186
- nei.highlight()
187
- nei.bond().highlight()
188
-
189
- for bond in serializable_match._query_molecule.iterateBonds():
190
- bond = serializable_match.mapBond(bond)
191
- if bond is None:
192
- continue
193
- bond.highlight()
194
-
195
-
196
- def clear_highlights(molecule: IndigoObject):
197
- """
198
- Clear all highlights in the molecule.
199
- :param molecule: The molecule to clear highlights from.
200
- """
201
- for atom in molecule.iterateAtoms():
202
- atom.unhighlight()
203
- for bond in molecule.iterateBonds():
204
- bond.unhighlight()
205
-
206
-
207
- def clear_reaction_highlights(reaction: IndigoObject):
208
- """
209
- Clear all highlights in the reaction.
210
- :param reaction: The reaction to clear highlights from.
211
- """
212
- for reactant in reaction.iterateReactants():
213
- clear_highlights(reactant)
214
- for product in reaction.iterateProducts():
215
- clear_highlights(product)
216
-
217
-
218
- def reserve_atom_mapping_number_of_search_result(q_reaction: IndigoObject, q_reactant: IndigoObject,
219
- new_reaction_reactant: IndigoObject, new_reaction: IndigoObject,
220
- sub_match: SerializableMoleculeMatch) -> None:
221
- """
222
- Set the atom mapping number on the query molecule based on the atom mapping number of the sub_match molecule, if it exists.
223
- :param new_reaction: The new reaction where the new reaction's reactant is found. This will be the target reaciton to write AAM to.
224
- :param new_reaction_reactant: The new reaction's reactant where the AAM will be written to.
225
- :param q_reactant: The query reactant from the query reaction that is being matched.
226
- :param q_reaction: The query reaction that contains the query reactant for the sub_match.
227
- :param sub_match: The substructure search match obtained from indigo.substructureMatcher(mol).match(query).
228
- """
229
- for query_atom in q_reactant.iterateAtoms():
230
- concrete_atom = sub_match.mapAtom(query_atom)
231
- if concrete_atom is None:
232
- continue
233
- reaction_atom = q_reactant.getAtom(query_atom.index())
234
- map_num = q_reaction.atomMappingNumber(reaction_atom)
235
- if map_num:
236
- concrete_atom = new_reaction_reactant.getAtom(concrete_atom.index())
237
- new_reaction.setAtomMappingNumber(concrete_atom, map_num)
238
-
239
-
240
- def clean_product_aam(reaction: IndigoObject):
241
- """
242
- Remove atom mappings from product that are not present in the reactants.
243
- """
244
- existing_mapping_numbers = set()
245
- for reactant in reaction.iterateReactants():
246
- for atom in reactant.iterateAtoms():
247
- map_num = reaction.atomMappingNumber(atom)
248
- if map_num:
249
- existing_mapping_numbers.add(map_num)
250
-
251
- for product in reaction.iterateProducts():
252
- for atom in product.iterateAtoms():
253
- map_num = reaction.atomMappingNumber(atom)
254
- if map_num and map_num not in existing_mapping_numbers:
255
- reaction.setAtomMappingNumber(atom, 0) # YQ: atom number 0 means no mapping number in Indigo
256
-
257
-
258
- def make_concrete_reaction(reactants: list[IndigoObject], products: list[IndigoObject], replacement: IndigoObject,
259
- replacement_index: int) -> tuple[IndigoObject, IndigoObject]:
260
- """
261
- Create a concrete reaction from the given reactants and products, replacing the specified reactant with the replacement molecule.
262
- :param reactants: List of reactant molecules.
263
- :param products: List of product molecules.
264
- :param replacement: The molecule to replace in the reactants.
265
- :param replacement_index: The index of the reactant to replace.
266
- :return: A new IndigoObject representing the concrete reaction.
267
- """
268
- concrete_reaction = indigo.createQueryReaction()
269
- for i, reactant in enumerate(reactants):
270
- if i == replacement_index:
271
- concrete_reaction.addReactant(indigo.loadQueryMolecule(replacement.molfile()))
272
- else:
273
- concrete_reaction.addReactant(reactant.clone())
274
- for product in products:
275
- concrete_reaction.addProduct(product.clone())
276
- return concrete_reaction, concrete_reaction.getMolecule(replacement_index)
277
-
278
-
279
- def is_ambiguous_atom(atom: IndigoObject) -> bool:
280
- """
281
- Test whether the symbol is an adjacent matching wildcard.
282
- """
283
- if atom.isPseudoatom() or atom.isRSite():
284
- return True
285
- symbol = atom.symbol()
286
- if symbol in {'A', 'Q', 'X', 'M', 'AH', 'QH', 'XH', 'MH', 'NOT', 'R', '*'}:
287
- return True
288
- return "[" in symbol and "]" in symbol
289
-
290
-
291
- def get_react_site_highlights(product, ignored_atom_indexes):
292
- """
293
- Get the highlights for the reaction site in the product, ignoring the atoms that are not part of the reaction site.
294
- :param product: The product molecule.
295
- :param ignored_atom_indexes: A set of atom indexes to ignore.
296
- :return: An IndigoObject with highlighted atoms and bonds that are part of the reaction site.
297
- """
298
- highlight = product.clone()
299
- for atom in highlight.iterateAtoms():
300
- if atom.index() not in ignored_atom_indexes:
301
- atom.highlight()
302
- for nei in atom.iterateNeighbors():
303
- if nei.index() not in ignored_atom_indexes:
304
- nei.highlight()
305
- nei.bond().highlight()
306
- return highlight
307
-
308
-
309
- def get_used_reactants_for_match(
310
- reaction: IndigoObject, q_reaction: IndigoObject, reaction_match: IndigoObject,
311
- kept_replacement_reaction_list_list: list[list[ReplacementReaction]]) -> list[ReplacementReaction]:
312
- """
313
- Find the replacement reactions that correspond to the reactants in reaction that also matches the query reaction.
314
- Return None if any of the reactants do not have a corresponding replacement reaction, even though reaction may have matches directly to the query reaction.
315
- Otherwise, return a list of ReplacementReaction objects that correspond to the reactants in the reaction ordered by the reactants in the query reaction.
316
- """
317
- q_reactants = []
318
- for q_reactant in q_reaction.iterateReactants():
319
- q_reactants.append(q_reactant)
320
- q_products = []
321
- for q_product in q_reaction.iterateProducts():
322
- q_products.append(q_product)
323
- reactants = []
324
- for enum_r in reaction.iterateReactants():
325
- reactants.append(enum_r)
326
- products = []
327
- for enum_p in reaction.iterateProducts():
328
- products.append(enum_p)
329
- ret: list[ReplacementReaction] = []
330
- for i, q_reactant in enumerate(q_reactants):
331
- replacement_list = kept_replacement_reaction_list_list[i]
332
- enum_r = reactants[i]
333
- useful_enumr_atom_indexes = set()
334
- for rr_atom in q_reactant.iterateAtoms():
335
- enum_atom = reaction_match.mapAtom(rr_atom)
336
- if enum_atom:
337
- useful_enumr_atom_indexes.add(enum_atom.index())
338
- found: ReplacementReaction | None = None
339
- for rr in replacement_list:
340
- if found:
341
- break
342
- exact_match = indigo.exactMatch(enum_r, rr.replacement_reactant)
343
- if not exact_match:
344
- # YQ Skip if this enumeration is not meant to be the same reactant as replacement we are iterating.
345
- continue
346
- # YQ these are atoms in replacement reactant that are actually used in the query reactant
347
- useful_rr_atom_indexes = set(rr.replacement_query_reaction_match._query_atom_to_atom.values())
348
- useful_rr_neighbor_indexes = set()
349
- # YQ furthermore, an atom is also considered useful for purpose of matching, if it neighbors a useful index, and is an ambiguous atom in query.
350
- q_reactant: IndigoObject
351
- for q_atom in q_reactant.iterateAtoms():
352
- if is_ambiguous_atom(q_atom):
353
- for q_neighbor in q_atom.iterateNeighbors():
354
- mapped_atom = rr.replacement_query_reaction_match.mapAtom(q_neighbor)
355
- if mapped_atom:
356
- rr_atom_index = mapped_atom.index()
357
- useful_rr_neighbor_indexes.add(rr_atom_index)
358
-
359
- useful_enum_r_mapping_numbers = set()
360
- all_enum_r_mapping_numbers = set()
361
- for enum_atom in enum_r.iterateAtoms():
362
- enum_atom_index = enum_atom.index()
363
- mapping_num = reaction.atomMappingNumber(enum_atom)
364
- if mapping_num > 0:
365
- all_enum_r_mapping_numbers.add(mapping_num)
366
- # Assuming rr atom indexes are exact match to enum r.
367
- if enum_atom_index in useful_rr_atom_indexes:
368
- useful_enum_r_mapping_numbers.add(mapping_num)
369
- ignoring_enum_r_mapping_numbers = all_enum_r_mapping_numbers - useful_enum_r_mapping_numbers - useful_rr_neighbor_indexes
370
- all_products_match = True
371
-
372
- rr_products = []
373
- for rr_product in rr.reaction.iterateProducts():
374
- rr_products.append(rr_product)
375
- for j, product in enumerate(products):
376
- q_product = rr_products[j]
377
- product_matcher = indigo.substructureMatcher(product)
378
- ignored_atom_indexes = set()
379
- for enum_product_atom in product.iterateAtoms():
380
- enum_mapping_number = reaction.atomMappingNumber(enum_product_atom)
381
- # YQ For each atom in product: either it is not related to this reactant, or it must be inside the reactant site of the reactant.
382
- if enum_mapping_number in ignoring_enum_r_mapping_numbers:
383
- product_matcher.ignoreAtom(enum_product_atom)
384
- ignored_atom_indexes.add(enum_product_atom.index())
385
- match = product_matcher.match(q_product)
386
- if not match:
387
- all_products_match = False
388
- else:
389
- found = rr
390
-
391
- if all_products_match:
392
- break
393
- if found:
394
- ret.append(found)
395
- else:
396
- return []
397
- return ret
398
-
399
-
400
- def are_symmetrical_subs(match1: SerializableMoleculeMatch, match2: SerializableMoleculeMatch) -> bool:
401
- """
402
- Check if two SerializableMoleculeMatch objects are symmetrical.
403
- That is, if we only get the atoms and bonds in the mapping, the two molecules are identical.
404
- :param match1: The first SerializableMoleculeMatch object.
405
- :param match2: The second SerializableMoleculeMatch object.
406
- :return: True if the matches are symmetrical, False otherwise.
407
- """
408
- match1_test = match1.get_matched_molecule_copy()
409
- match1_atom_indexes = set(match1._query_atom_to_atom.values())
410
- match1_bond_indexes = set(match1._query_bond_to_bond.values())
411
- atom_delete_list: list[int] = []
412
- atom_mirror_list: list[int] = []
413
- bond_delete_list: list[int] = []
414
- bond_mirror_list: list[int] = []
415
- for atom in match1_test.iterateAtoms():
416
- if atom.index() not in match1_atom_indexes:
417
- atom_delete_list.append(atom.index())
418
- else:
419
- atom_mirror_list.append(atom.index())
420
- for bond in match1_test.iterateBonds():
421
- if bond.index() not in match1_bond_indexes:
422
- bond_delete_list.append(bond.index())
423
- else:
424
- bond_mirror_list.append(bond.index())
425
- match1_test.removeBonds(bond_delete_list)
426
- match1_test.removeAtoms(atom_delete_list)
427
- match1_mirror_test = match1.get_matched_molecule_copy()
428
- match1_mirror_test.removeBonds(bond_mirror_list)
429
- match1_mirror_test.removeAtoms(atom_mirror_list)
430
-
431
- match2_test = match2.get_matched_molecule_copy()
432
- match2_atom_indexes = set(match2._query_atom_to_atom.values())
433
- match2_bond_indexes = set(match2._query_bond_to_bond.values())
434
- atom_delete_list = []
435
- bond_delete_list = []
436
- atom_mirror_list = []
437
- bond_mirror_list = []
438
- for atom in match2_test.iterateAtoms():
439
- if atom.index() not in match2_atom_indexes:
440
- atom_delete_list.append(atom.index())
441
- else:
442
- atom_mirror_list.append(atom.index())
443
- for bond in match2_test.iterateBonds():
444
- if bond.index() not in match2_bond_indexes:
445
- bond_delete_list.append(bond.index())
446
- else:
447
- bond_mirror_list.append(bond.index())
448
- match2_test.removeBonds(bond_delete_list)
449
- match2_test.removeAtoms(atom_delete_list)
450
- match2_mirror_test = match2.get_matched_molecule_copy()
451
- match2_mirror_test.removeBonds(bond_mirror_list)
452
- match2_mirror_test.removeAtoms(atom_mirror_list)
453
-
454
- return match1_test.canonicalSmiles() == match2_test.canonicalSmiles() and \
455
- match1_mirror_test.canonicalSmiles() == match2_mirror_test.canonicalSmiles()