sapiopycommons 2024.8.29a317__py3-none-any.whl → 2024.8.30a320__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 (33) hide show
  1. sapiopycommons/callbacks/callback_util.py +133 -37
  2. sapiopycommons/customreport/__init__.py +0 -0
  3. sapiopycommons/customreport/column_builder.py +60 -0
  4. sapiopycommons/customreport/custom_report_builder.py +125 -0
  5. sapiopycommons/customreport/term_builder.py +299 -0
  6. sapiopycommons/datatype/attachment_util.py +11 -10
  7. sapiopycommons/eln/experiment_handler.py +209 -48
  8. sapiopycommons/eln/experiment_report_util.py +33 -129
  9. sapiopycommons/files/complex_data_loader.py +5 -4
  10. sapiopycommons/files/file_bridge.py +15 -14
  11. sapiopycommons/files/file_bridge_handler.py +27 -5
  12. sapiopycommons/files/file_data_handler.py +2 -5
  13. sapiopycommons/files/file_util.py +38 -5
  14. sapiopycommons/files/file_validator.py +26 -11
  15. sapiopycommons/files/file_writer.py +44 -15
  16. sapiopycommons/general/aliases.py +147 -3
  17. sapiopycommons/general/audit_log.py +196 -0
  18. sapiopycommons/general/custom_report_util.py +34 -32
  19. sapiopycommons/general/popup_util.py +17 -0
  20. sapiopycommons/general/sapio_links.py +50 -0
  21. sapiopycommons/general/time_util.py +40 -0
  22. sapiopycommons/multimodal/multimodal_data.py +0 -1
  23. sapiopycommons/processtracking/endpoints.py +22 -22
  24. sapiopycommons/recordmodel/record_handler.py +228 -77
  25. sapiopycommons/rules/eln_rule_handler.py +34 -25
  26. sapiopycommons/rules/on_save_rule_handler.py +34 -31
  27. sapiopycommons/webhook/webhook_handlers.py +90 -26
  28. sapiopycommons/webhook/webservice_handlers.py +67 -0
  29. {sapiopycommons-2024.8.29a317.dist-info → sapiopycommons-2024.8.30a320.dist-info}/METADATA +1 -1
  30. sapiopycommons-2024.8.30a320.dist-info/RECORD +50 -0
  31. sapiopycommons-2024.8.29a317.dist-info/RECORD +0 -43
  32. {sapiopycommons-2024.8.29a317.dist-info → sapiopycommons-2024.8.30a320.dist-info}/WHEEL +0 -0
  33. {sapiopycommons-2024.8.29a317.dist-info → sapiopycommons-2024.8.30a320.dist-info}/licenses/LICENSE +0 -0
@@ -1,5 +1,7 @@
1
+ from __future__ import annotations
2
+
1
3
  from collections.abc import Iterable
2
- from typing import Any
4
+ from weakref import WeakValueDictionary
3
5
 
4
6
  from sapiopylib.rest.DataRecordManagerService import DataRecordManager
5
7
  from sapiopylib.rest.User import SapioUser
@@ -7,7 +9,7 @@ from sapiopylib.rest.pojo.CustomReport import CustomReportCriteria, RawReportTer
7
9
  from sapiopylib.rest.pojo.DataRecord import DataRecord
8
10
  from sapiopylib.rest.pojo.DataRecordPaging import DataRecordPojoPageCriteria
9
11
  from sapiopylib.rest.pojo.datatype.FieldDefinition import FieldType
10
- from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
12
+ from sapiopylib.rest.pojo.eln.SapioELNEnums import ElnBaseDataType
11
13
  from sapiopylib.rest.utils.autopaging import QueryDataRecordsAutoPager, QueryDataRecordByIdListAutoPager, \
12
14
  QueryAllRecordsOfTypeAutoPager
13
15
  from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel
@@ -16,8 +18,10 @@ from sapiopylib.rest.utils.recordmodel.RecordModelManager import RecordModelMana
16
18
  from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType, WrappedRecordModel
17
19
  from sapiopylib.rest.utils.recordmodel.RelationshipPath import RelationshipPath, RelationshipNode, \
18
20
  RelationshipNodeType
21
+ from sapiopylib.rest.utils.recordmodel.ancestry import RecordModelAncestorManager
19
22
 
20
- from sapiopycommons.general.aliases import RecordModel, SapioRecord, FieldMap
23
+ from sapiopycommons.general.aliases import RecordModel, SapioRecord, FieldMap, FieldIdentifier, AliasUtil, \
24
+ FieldIdentifierMap, FieldValue, UserIdentifier, FieldIdentifierKey
21
25
  from sapiopycommons.general.custom_report_util import CustomReportUtil
22
26
  from sapiopycommons.general.exceptions import SapioException
23
27
 
@@ -32,16 +36,38 @@ class RecordHandler:
32
36
  rec_man: RecordModelManager
33
37
  inst_man: RecordModelInstanceManager
34
38
  rel_man: RecordModelRelationshipManager
39
+ an_man: RecordModelAncestorManager
40
+
41
+ __instances: WeakValueDictionary[SapioUser, RecordHandler] = WeakValueDictionary()
42
+ __initialized: bool
35
43
 
36
- def __init__(self, context: SapioWebhookContext | SapioUser):
44
+ def __new__(cls, context: UserIdentifier):
37
45
  """
38
46
  :param context: The current webhook context or a user object to send requests from.
39
47
  """
48
+ user = AliasUtil.to_sapio_user(context)
49
+ obj = cls.__instances.get(user)
50
+ if not obj:
51
+ obj = object.__new__(cls)
52
+ obj.__initialized = False
53
+ cls.__instances[user] = obj
54
+ return obj
55
+
56
+ def __init__(self, context: UserIdentifier):
57
+ """
58
+ :param context: The current webhook context or a user object to send requests from.
59
+ """
60
+ self.user = AliasUtil.to_sapio_user(context)
61
+ if self.__initialized:
62
+ return
63
+ self.__initialized = True
64
+
40
65
  self.user = context if isinstance(context, SapioUser) else context.user
41
66
  self.dr_man = DataRecordManager(self.user)
42
67
  self.rec_man = RecordModelManager(self.user)
43
68
  self.inst_man = self.rec_man.instance_manager
44
69
  self.rel_man = self.rec_man.relationship_manager
70
+ self.an_man = RecordModelAncestorManager(self.rec_man)
45
71
 
46
72
  def wrap_model(self, record: DataRecord, wrapper_type: type[WrappedType]) -> WrappedType:
47
73
  """
@@ -51,6 +77,7 @@ class RecordHandler:
51
77
  :param wrapper_type: The record model wrapper to use.
52
78
  :return: The record model for the input.
53
79
  """
80
+ self.__verify_data_type([record], wrapper_type)
54
81
  return self.inst_man.add_existing_record_of_type(record, wrapper_type)
55
82
 
56
83
  def wrap_models(self, records: Iterable[DataRecord], wrapper_type: type[WrappedType]) -> list[WrappedType]:
@@ -61,10 +88,11 @@ class RecordHandler:
61
88
  :param wrapper_type: The record model wrapper to use.
62
89
  :return: The record models for the input.
63
90
  """
91
+ self.__verify_data_type(records, wrapper_type)
64
92
  return self.inst_man.add_existing_records_of_type(list(records), wrapper_type)
65
93
 
66
- def query_models(self, wrapper_type: type[WrappedType], field: str, value_list: Iterable[Any],
67
- page_limit: int | None = None) -> list[WrappedType]:
94
+ def query_models(self, wrapper_type: type[WrappedType], field: FieldIdentifier, value_list: Iterable[FieldValue],
95
+ page_limit: int | None = None, page_size: int | None = None) -> list[WrappedType]:
68
96
  """
69
97
  Shorthand for using the data record manager to query for a list of data records by field value
70
98
  and then converting the results into a list of record models.
@@ -72,14 +100,20 @@ class RecordHandler:
72
100
  :param wrapper_type: The record model wrapper to use.
73
101
  :param field: The field to query on.
74
102
  :param value_list: The values of the field to query on.
75
- :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages.
103
+ :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages. This parameter
104
+ only functions if you set a page size or the platform enforces a page size.
105
+ :param page_size: The size of the pages to query. If None, the page size may be limited by the platform.
76
106
  :return: The record models for the queried records.
77
107
  """
78
- return self.query_models_with_criteria(wrapper_type, field, value_list, None, page_limit)[0]
108
+ criteria: DataRecordPojoPageCriteria | None = None
109
+ if page_size is not None:
110
+ criteria = DataRecordPojoPageCriteria(page_size=page_size)
111
+ return self.query_models_with_criteria(wrapper_type, field, value_list, criteria, page_limit)[0]
79
112
 
80
- def query_and_map_models(self, wrapper_type: type[WrappedType], field: str, value_list: Iterable[Any],
81
- page_limit: int | None = None, *, mapping_field: str | None = None) \
82
- -> dict[Any, list[WrappedType]]:
113
+ def query_and_map_models(self, wrapper_type: type[WrappedType], field: FieldIdentifier,
114
+ value_list: Iterable[FieldValue], page_limit: int | None = None,
115
+ page_size: int | None = None, *, mapping_field: FieldIdentifier | None = None) \
116
+ -> dict[FieldValue, list[WrappedType]]:
83
117
  """
84
118
  Shorthand for using query_models to search for records given values on a specific field and then using
85
119
  map_by_field to turn the returned list into a dictionary mapping field values to records.
@@ -87,17 +121,21 @@ class RecordHandler:
87
121
  :param wrapper_type: The record model wrapper to use.
88
122
  :param field: The field to query and map on.
89
123
  :param value_list: The values of the field to query on.
90
- :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages.
124
+ :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages. This parameter
125
+ only functions if you set a page size or the platform enforces a page size.
126
+ :param page_size: The size of the pages to query. If None, the page size may be limited by the platform.
91
127
  :param mapping_field: If provided, use this field to map against instead of the field that was queried on.
92
128
  :return: The record models for the queried records mapped by field values to the records with that value.
93
129
  """
94
130
  if mapping_field is None:
95
131
  mapping_field = field
96
- return self.map_by_field(self.query_models(wrapper_type, field, value_list, page_limit), mapping_field)
132
+ return self.map_by_field(self.query_models(wrapper_type, field, value_list, page_limit, page_size),
133
+ mapping_field)
97
134
 
98
- def query_and_unique_map_models(self, wrapper_type: type[WrappedType], field: str, value_list: Iterable[Any],
99
- page_limit: int | None = None, *, mapping_field: str | None = None) \
100
- -> dict[Any, WrappedType]:
135
+ def query_and_unique_map_models(self, wrapper_type: type[WrappedType], field: FieldIdentifier,
136
+ value_list: Iterable[FieldValue], page_limit: int | None = None,
137
+ page_size: int | None = None, *, mapping_field: FieldIdentifier | None = None) \
138
+ -> dict[FieldValue, WrappedType]:
101
139
  """
102
140
  Shorthand for using query_models to search for records given values on a specific field and then using
103
141
  map_by_unique_field to turn the returned list into a dictionary mapping field values to records.
@@ -106,15 +144,19 @@ class RecordHandler:
106
144
  :param wrapper_type: The record model wrapper to use.
107
145
  :param field: The field to query and map on.
108
146
  :param value_list: The values of the field to query on.
109
- :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages.
147
+ :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages. This parameter
148
+ only functions if you set a page size or the platform enforces a page size.
149
+ :param page_size: The size of the pages to query. If None, the page size may be limited by the platform.
110
150
  :param mapping_field: If provided, use this field to map against instead of the field that was queried on.
111
151
  :return: The record models for the queried records mapped by field values to the record with that value.
112
152
  """
113
153
  if mapping_field is None:
114
154
  mapping_field = field
115
- return self.map_by_unique_field(self.query_models(wrapper_type, field, value_list, page_limit), mapping_field)
155
+ return self.map_by_unique_field(self.query_models(wrapper_type, field, value_list, page_limit, page_size),
156
+ mapping_field)
116
157
 
117
- def query_models_with_criteria(self, wrapper_type: type[WrappedType], field: str, value_list: Iterable[Any],
158
+ def query_models_with_criteria(self, wrapper_type: type[WrappedType], field: FieldIdentifier,
159
+ value_list: Iterable[FieldValue],
118
160
  paging_criteria: DataRecordPojoPageCriteria | None = None,
119
161
  page_limit: int | None = None) \
120
162
  -> tuple[list[WrappedType], DataRecordPojoPageCriteria]:
@@ -127,26 +169,33 @@ class RecordHandler:
127
169
  :param value_list: The values of the field to query on.
128
170
  :param paging_criteria: The paging criteria to start the query with.
129
171
  :param page_limit: The maximum number of pages to query from the starting criteria. If None, exhausts all
130
- possible pages.
172
+ possible pages. This parameter only functions if you set a page size in the paging criteria or the platform
173
+ enforces a page size.
131
174
  :return: The record models for the queried records and the final paging criteria.
132
175
  """
133
176
  dt: str = wrapper_type.get_wrapper_data_type_name()
177
+ field: str = AliasUtil.to_data_field_name(field)
134
178
  pager = QueryDataRecordsAutoPager(dt, field, list(value_list), self.user, paging_criteria)
135
179
  pager.max_page = page_limit
136
180
  return self.wrap_models(pager.get_all_at_once(), wrapper_type), pager.next_page_criteria
137
181
 
138
182
  def query_models_by_id(self, wrapper_type: type[WrappedType], ids: Iterable[int],
139
- page_limit: int | None = None) -> list[WrappedType]:
183
+ page_limit: int | None = None, page_size: int | None = None) -> list[WrappedType]:
140
184
  """
141
185
  Shorthand for using the data record manager to query for a list of data records by record ID
142
186
  and then converting the results into a list of record models.
143
187
 
144
188
  :param wrapper_type: The record model wrapper to use.
145
189
  :param ids: The list of record IDs to query.
146
- :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages.
190
+ :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages. This parameter
191
+ only functions if you set a page size or the platform enforces a page size.
192
+ :param page_size: The size of the pages to query. If None, the page size may be limited by the platform.
147
193
  :return: The record models for the queried records.
148
194
  """
149
- return self.query_models_by_id_with_criteria(wrapper_type, ids, None, page_limit)[0]
195
+ criteria: DataRecordPojoPageCriteria | None = None
196
+ if page_size is not None:
197
+ criteria = DataRecordPojoPageCriteria(page_size=page_size)
198
+ return self.query_models_by_id_with_criteria(wrapper_type, ids, criteria, page_limit)[0]
150
199
 
151
200
  def query_models_by_id_with_criteria(self, wrapper_type: type[WrappedType], ids: Iterable[int],
152
201
  paging_criteria: DataRecordPojoPageCriteria | None = None,
@@ -160,7 +209,8 @@ class RecordHandler:
160
209
  :param ids: The list of record IDs to query.
161
210
  :param paging_criteria: The paging criteria to start the query with.
162
211
  :param page_limit: The maximum number of pages to query from the starting criteria. If None, exhausts all
163
- possible pages.
212
+ possible pages. This parameter only functions if you set a page size in the paging criteria or the platform
213
+ enforces a page size.
164
214
  :return: The record models for the queried records and the final paging criteria.
165
215
  """
166
216
  dt: str = wrapper_type.get_wrapper_data_type_name()
@@ -168,16 +218,38 @@ class RecordHandler:
168
218
  pager.max_page = page_limit
169
219
  return self.wrap_models(pager.get_all_at_once(), wrapper_type), pager.next_page_criteria
170
220
 
171
- def query_all_models(self, wrapper_type: type[WrappedType], page_limit: int | None = None) -> list[WrappedType]:
221
+ def query_models_by_id_and_map(self, wrapper_type: type[WrappedType], ids: Iterable[int],
222
+ page_limit: int | None = None, page_size: int | None = None) \
223
+ -> dict[int, WrappedType]:
224
+ """
225
+ Shorthand for using the data record manager to query for a list of data records by record ID
226
+ and then converting the results into a dictionary of record ID to the record model for that ID.
227
+
228
+ :param wrapper_type: The record model wrapper to use.
229
+ :param ids: The list of record IDs to query.
230
+ :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages. This parameter
231
+ only functions if you set a page size or the platform enforces a page size.
232
+ :param page_size: The size of the pages to query. If None, the page size may be limited by the platform.
233
+ :return: The record models for the queried records mapped in a dictionary by their record ID.
234
+ """
235
+ return {x.record_id: x for x in self.query_models_by_id(wrapper_type, ids, page_limit, page_size)}
236
+
237
+ def query_all_models(self, wrapper_type: type[WrappedType], page_limit: int | None = None,
238
+ page_size: int | None = None) -> list[WrappedType]:
172
239
  """
173
240
  Shorthand for using the data record manager to query for all data records of a given type
174
241
  and then converting the results into a list of record models.
175
242
 
176
243
  :param wrapper_type: The record model wrapper to use.
177
- :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages.
244
+ :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages. This parameter
245
+ only functions if you set a page size or the platform enforces a page size.
246
+ :param page_size: The size of the pages to query. If None, the page size may be limited by the platform.
178
247
  :return: The record models for the queried records.
179
248
  """
180
- return self.query_all_models_with_criteria(wrapper_type, None, page_limit)[0]
249
+ criteria: DataRecordPojoPageCriteria | None = None
250
+ if page_size is not None:
251
+ criteria = DataRecordPojoPageCriteria(page_size=page_size)
252
+ return self.query_all_models_with_criteria(wrapper_type, criteria, page_limit)[0]
181
253
 
182
254
  def query_all_models_with_criteria(self, wrapper_type: type[WrappedType],
183
255
  paging_criteria: DataRecordPojoPageCriteria | None = None,
@@ -190,7 +262,8 @@ class RecordHandler:
190
262
  :param wrapper_type: The record model wrapper to use.
191
263
  :param paging_criteria: The paging criteria to start the query with.
192
264
  :param page_limit: The maximum number of pages to query from the starting criteria. If None, exhausts all
193
- possible pages.
265
+ possible pages. This parameter only functions if you set a page size in the paging criteria or the platform
266
+ enforces a page size.
194
267
  :return: The record models for the queried records and the final paging criteria.
195
268
  """
196
269
  dt: str = wrapper_type.get_wrapper_data_type_name()
@@ -200,7 +273,7 @@ class RecordHandler:
200
273
 
201
274
  def query_models_by_report(self, wrapper_type: type[WrappedType],
202
275
  report_name: str | RawReportTerm | CustomReportCriteria,
203
- filters: dict[str, Iterable[Any]] | None = None,
276
+ filters: dict[FieldIdentifierKey, Iterable[FieldValue]] | None = None,
204
277
  page_limit: int | None = None,
205
278
  page_size: int | None = None,
206
279
  page_number: int | None = None) -> list[WrappedType]:
@@ -228,11 +301,11 @@ class RecordHandler:
228
301
  :return: The record models for the queried records that matched the given report.
229
302
  """
230
303
  if isinstance(report_name, str):
231
- results: list[dict[str, Any]] = CustomReportUtil.run_system_report(self.user, report_name, filters,
232
- page_limit, page_size, page_number)
304
+ results: list[dict[str, FieldValue]] = CustomReportUtil.run_system_report(self.user, report_name, filters,
305
+ page_limit, page_size, page_number)
233
306
  elif isinstance(report_name, RawReportTerm):
234
- results: list[dict[str, Any]] = CustomReportUtil.run_quick_report(self.user, report_name, filters,
235
- page_limit, page_size, page_number)
307
+ results: list[dict[str, FieldValue]] = CustomReportUtil.run_quick_report(self.user, report_name, filters,
308
+ page_limit, page_size, page_number)
236
309
  elif isinstance(report_name, CustomReportCriteria):
237
310
  dt: str = wrapper_type.get_wrapper_data_type_name()
238
311
  # Ensure that the root data type is the one we're looking for.
@@ -243,8 +316,8 @@ class RecordHandler:
243
316
  # Enforce that the given custom report has a record ID column.
244
317
  if not any([x.data_type_name == dt and x.data_field_name == "RecordId" for x in report_name.column_list]):
245
318
  report_name.column_list.append(ReportColumn(dt, "RecordId", FieldType.LONG))
246
- results: list[dict[str, Any]] = CustomReportUtil.run_custom_report(self.user, report_name, filters,
247
- page_limit, page_size, page_number)
319
+ results: list[dict[str, FieldValue]] = CustomReportUtil.run_custom_report(self.user, report_name, filters,
320
+ page_limit, page_size, page_number)
248
321
  else:
249
322
  raise SapioException("Unrecognized report object.")
250
323
 
@@ -273,7 +346,8 @@ class RecordHandler:
273
346
  """
274
347
  return self.inst_man.add_new_records_of_type(num, wrapper_type)
275
348
 
276
- def add_models_with_data(self, wrapper_type: type[WrappedType], fields: list[FieldMap]) -> list[WrappedType]:
349
+ def add_models_with_data(self, wrapper_type: type[WrappedType], fields: list[FieldIdentifierMap]) \
350
+ -> list[WrappedType]:
277
351
  """
278
352
  Shorthand for using the instance manager to add new models of the given type, and then initializing all those
279
353
  models with the given fields.
@@ -283,13 +357,14 @@ class RecordHandler:
283
357
  :return: The newly added record models with the provided fields set. The records will be in the same order as
284
358
  the fields in the fields list.
285
359
  """
360
+ fields: list[FieldMap] = AliasUtil.to_data_field_names_list_dict(fields)
286
361
  models: list[WrappedType] = self.add_models(wrapper_type, len(fields))
287
362
  for model, field_list in zip(models, fields):
288
363
  model.set_field_values(field_list)
289
364
  return models
290
365
 
291
- def find_or_add_model(self, wrapper_type: type[WrappedType], primary_identifier: str, id_value: Any,
292
- secondary_identifiers: FieldMap | None = None) -> WrappedType:
366
+ def find_or_add_model(self, wrapper_type: type[WrappedType], primary_identifier: FieldIdentifier,
367
+ id_value: FieldValue, secondary_identifiers: FieldIdentifierMap | None = None) -> WrappedType:
293
368
  """
294
369
  Find a unique record that matches the given field values. If no such records exist, add a record model to the
295
370
  cache with the identifying fields set to the desired values. This record will be created in the system when
@@ -312,6 +387,8 @@ class RecordHandler:
312
387
  if secondary_identifiers is None:
313
388
  secondary_identifiers = {}
314
389
 
390
+ primary_identifier: str = AliasUtil.to_data_field_name(primary_identifier)
391
+ secondary_identifiers: FieldMap = AliasUtil.to_data_field_names_dict(secondary_identifiers)
315
392
  unique_record: WrappedType | None = self.__find_model(wrapper_type, primary_identifier, id_value,
316
393
  secondary_identifiers)
317
394
  # If a unique record matched the identifiers, return it.
@@ -338,7 +415,7 @@ class RecordHandler:
338
415
  dt: str = wrapper_type.get_wrapper_data_type_name()
339
416
  return self.wrap_models(self.dr_man.add_data_records(dt, num), wrapper_type)
340
417
 
341
- def create_models_with_data(self, wrapper_type: type[WrappedType], fields: list[FieldMap]) \
418
+ def create_models_with_data(self, wrapper_type: type[WrappedType], fields: list[FieldIdentifierMap]) \
342
419
  -> list[WrappedType]:
343
420
  """
344
421
  Shorthand for creating new records via the data record manager with field data to initialize the records with
@@ -352,10 +429,12 @@ class RecordHandler:
352
429
  :return: The newly created record models.
353
430
  """
354
431
  dt: str = wrapper_type.get_wrapper_data_type_name()
432
+ fields: list[FieldMap] = AliasUtil.to_data_field_names_list_dict(fields)
355
433
  return self.wrap_models(self.dr_man.add_data_records_with_data(dt, fields), wrapper_type)
356
434
 
357
- def find_or_create_model(self, wrapper_type: type[WrappedType], primary_identifier: str, id_value: Any,
358
- secondary_identifiers: FieldMap | None = None) -> WrappedType:
435
+ def find_or_create_model(self, wrapper_type: type[WrappedType], primary_identifier: FieldIdentifier,
436
+ id_value: FieldValue, secondary_identifiers: FieldIdentifierMap | None = None) \
437
+ -> WrappedType:
359
438
  """
360
439
  Find a unique record that matches the given field values. If no such records exist, create one with the
361
440
  identifying fields set to the desired values. If more than one record with the identifying values exists,
@@ -379,6 +458,8 @@ class RecordHandler:
379
458
  if secondary_identifiers is None:
380
459
  secondary_identifiers = {}
381
460
 
461
+ primary_identifier: str = AliasUtil.to_data_field_name(primary_identifier)
462
+ secondary_identifiers: FieldMap = AliasUtil.to_data_field_names_dict(secondary_identifiers)
382
463
  unique_record: WrappedType | None = self.__find_model(wrapper_type, primary_identifier, id_value,
383
464
  secondary_identifiers)
384
465
  # If a unique record matched the identifiers, return it.
@@ -497,7 +578,7 @@ class RecordHandler:
497
578
 
498
579
  @staticmethod
499
580
  def map_by_child(models: Iterable[RecordModel], child_type: type[WrappedType]) \
500
- -> dict[WrappedType, list[RecordModel]]:
581
+ -> dict[WrappedType, RecordModel]:
501
582
  """
502
583
  Take a list of record models and map them by their children. Essentially an inversion of map_to_child.
503
584
  If two records share the same child, an exception will be thrown. The children must already be loaded.
@@ -538,7 +619,7 @@ class RecordHandler:
538
619
  return by_children
539
620
 
540
621
  @staticmethod
541
- def map_to_forward_side_link(models: Iterable[WrappedRecordModel], field_name: str,
622
+ def map_to_forward_side_link(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
542
623
  side_link_type: type[WrappedType]) -> dict[WrappedRecordModel, WrappedType]:
543
624
  """
544
625
  Map a list of record models to their forward side link. The forward side link must already be loaded.
@@ -549,13 +630,14 @@ class RecordHandler:
549
630
  :return: A dict[ModelType, SlideLink]. If an input model doesn't have a forward side link of the given type,
550
631
  then it will map to None.
551
632
  """
633
+ field_name: str = AliasUtil.to_data_field_name(field_name)
552
634
  return_dict: dict[WrappedRecordModel, WrappedType] = {}
553
635
  for model in models:
554
636
  return_dict[model] = model.get_forward_side_link(field_name, side_link_type)
555
637
  return return_dict
556
638
 
557
639
  @staticmethod
558
- def map_by_forward_side_links(models: Iterable[WrappedRecordModel], field_name: str,
640
+ def map_by_forward_side_links(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
559
641
  side_link_type: type[WrappedType]) -> dict[WrappedType, list[WrappedRecordModel]]:
560
642
  """
561
643
  Take a list of record models and map them by their forward side link. Essentially an inversion of
@@ -568,6 +650,7 @@ class RecordHandler:
568
650
  :return: A dict[SideLink, list[ModelType]]. If an input model doesn't have a forward side link of the given type
569
651
  pointing to it, then it will not be in the resulting dictionary.
570
652
  """
653
+ field_name: str = AliasUtil.to_data_field_name(field_name)
571
654
  to_side_link: dict[WrappedRecordModel, WrappedType] = RecordHandler\
572
655
  .map_to_forward_side_link(models, field_name, side_link_type)
573
656
  by_side_link: dict[WrappedType, list[WrappedRecordModel]] = {}
@@ -578,7 +661,7 @@ class RecordHandler:
578
661
  return by_side_link
579
662
 
580
663
  @staticmethod
581
- def map_by_forward_side_link(models: Iterable[WrappedRecordModel], field_name: str,
664
+ def map_by_forward_side_link(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
582
665
  side_link_type: type[WrappedType]) -> dict[WrappedType, WrappedRecordModel]:
583
666
  """
584
667
  Take a list of record models and map them by their forward side link. Essentially an inversion of
@@ -591,6 +674,7 @@ class RecordHandler:
591
674
  :return: A dict[SideLink, ModelType]. If an input model doesn't have a forward side link of the given type
592
675
  pointing to it, then it will not be in the resulting dictionary.
593
676
  """
677
+ field_name: str = AliasUtil.to_data_field_name(field_name)
594
678
  to_side_link: dict[WrappedRecordModel, WrappedType] = RecordHandler\
595
679
  .map_to_forward_side_link(models, field_name, side_link_type)
596
680
  by_side_link: dict[WrappedType, WrappedRecordModel] = {}
@@ -604,7 +688,7 @@ class RecordHandler:
604
688
  return by_side_link
605
689
 
606
690
  @staticmethod
607
- def map_to_reverse_side_links(models: Iterable[WrappedRecordModel], field_name: str,
691
+ def map_to_reverse_side_links(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
608
692
  side_link_type: type[WrappedType]) -> dict[WrappedRecordModel, list[WrappedType]]:
609
693
  """
610
694
  Map a list of record models to a list reverse side links of a given type. The reverse side links must already
@@ -617,13 +701,14 @@ class RecordHandler:
617
701
  :return: A dict[ModelType, list[SideLink]]. If an input model doesn't have reverse side links of the given type,
618
702
  then it will map to an empty list.
619
703
  """
704
+ field_name: str = AliasUtil.to_data_field_name(field_name)
620
705
  return_dict: dict[WrappedRecordModel, list[WrappedType]] = {}
621
706
  for model in models:
622
707
  return_dict[model] = model.get_reverse_side_link(field_name, side_link_type)
623
708
  return return_dict
624
709
 
625
710
  @staticmethod
626
- def map_to_reverse_side_link(models: Iterable[WrappedRecordModel], field_name: str,
711
+ def map_to_reverse_side_link(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
627
712
  side_link_type: type[WrappedType]) -> dict[WrappedRecordModel, WrappedType]:
628
713
  """
629
714
  Map a list of record models to the reverse side link of a given type. If a given record has more than one
@@ -636,6 +721,7 @@ class RecordHandler:
636
721
  :return: A dict[ModelType, SideLink]. If an input model doesn't have reverse side links of the given type,
637
722
  then it will map to None.
638
723
  """
724
+ field_name: str = AliasUtil.to_data_field_name(field_name)
639
725
  return_dict: dict[WrappedRecordModel, WrappedType] = {}
640
726
  for model in models:
641
727
  links: list[WrappedType] = model.get_reverse_side_link(field_name, side_link_type)
@@ -646,7 +732,7 @@ class RecordHandler:
646
732
  return return_dict
647
733
 
648
734
  @staticmethod
649
- def map_by_reverse_side_links(models: Iterable[WrappedRecordModel], field_name: str,
735
+ def map_by_reverse_side_links(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
650
736
  side_link_type: type[WrappedType]) -> dict[WrappedType, list[WrappedRecordModel]]:
651
737
  """
652
738
  Take a list of record models and map them by their reverse side links. Essentially an inversion of
@@ -660,6 +746,7 @@ class RecordHandler:
660
746
  :return: A dict[SideLink, list[ModelType]]. If an input model doesn't have reverse side links of the given type
661
747
  pointing to it, then it will not be in the resulting dictionary.
662
748
  """
749
+ field_name: str = AliasUtil.to_data_field_name(field_name)
663
750
  to_side_links: dict[WrappedRecordModel, list[WrappedType]] = RecordHandler\
664
751
  .map_to_reverse_side_links(models, field_name, side_link_type)
665
752
  by_side_links: dict[WrappedType, list[WrappedRecordModel]] = {}
@@ -669,7 +756,7 @@ class RecordHandler:
669
756
  return by_side_links
670
757
 
671
758
  @staticmethod
672
- def map_by_reverse_side_link(models: Iterable[WrappedRecordModel], field_name: str,
759
+ def map_by_reverse_side_link(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
673
760
  side_link_type: type[WrappedType]) -> dict[WrappedType, WrappedRecordModel]:
674
761
  """
675
762
  Take a list of record models and map them by their reverse side link. Essentially an inversion of
@@ -683,6 +770,7 @@ class RecordHandler:
683
770
  :return: A dict[SideLink, ModelType]. If an input model doesn't have a reverse side link of the given type
684
771
  pointing to it, then it will not be in the resulting dictionary.
685
772
  """
773
+ field_name: str = AliasUtil.to_data_field_name(field_name)
686
774
  to_side_link: dict[WrappedRecordModel, WrappedType] = RecordHandler\
687
775
  .map_to_reverse_side_link(models, field_name, side_link_type)
688
776
  by_side_link: dict[WrappedType, WrappedRecordModel] = {}
@@ -709,7 +797,8 @@ class RecordHandler:
709
797
  return ret_dict
710
798
 
711
799
  @staticmethod
712
- def map_by_field(models: Iterable[SapioRecord], field_name: str) -> dict[Any, list[SapioRecord]]:
800
+ def map_by_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) \
801
+ -> dict[FieldValue, list[SapioRecord]]:
713
802
  """
714
803
  Map the given records by one of their fields. If any two records share the same field value, they'll appear in
715
804
  the same value list.
@@ -718,14 +807,16 @@ class RecordHandler:
718
807
  :param field_name: The field name to map against.
719
808
  :return: A dict mapping field values to the records with that value.
720
809
  """
721
- ret_dict: dict[Any, list[SapioRecord]] = {}
810
+ field_name: str = AliasUtil.to_data_field_name(field_name)
811
+ ret_dict: dict[FieldValue, list[SapioRecord]] = {}
722
812
  for model in models:
723
- val: Any = model.get_field_value(field_name)
813
+ val: FieldValue = model.get_field_value(field_name)
724
814
  ret_dict.setdefault(val, []).append(model)
725
815
  return ret_dict
726
816
 
727
817
  @staticmethod
728
- def map_by_unique_field(models: Iterable[SapioRecord], field_name: str) -> dict[Any, SapioRecord]:
818
+ def map_by_unique_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) \
819
+ -> dict[FieldValue, SapioRecord]:
729
820
  """
730
821
  Uniquely map the given records by one of their fields. If any two records share the same field value, throws
731
822
  an exception.
@@ -734,16 +825,17 @@ class RecordHandler:
734
825
  :param field_name: The field name to map against.
735
826
  :return: A dict mapping field values to the record with that value.
736
827
  """
737
- ret_dict: dict[Any, SapioRecord] = {}
828
+ field_name: str = AliasUtil.to_data_field_name(field_name)
829
+ ret_dict: dict[FieldValue, SapioRecord] = {}
738
830
  for model in models:
739
- val: Any = model.get_field_value(field_name)
831
+ val: FieldValue = model.get_field_value(field_name)
740
832
  if val in ret_dict:
741
833
  raise SapioException(f"Value {val} encountered more than once in models list.")
742
834
  ret_dict.update({val: model})
743
835
  return ret_dict
744
836
 
745
837
  @staticmethod
746
- def sum_of_field(models: Iterable[SapioRecord], field_name: str) -> float:
838
+ def sum_of_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) -> float:
747
839
  """
748
840
  Sum up the numeric value of a given field across all input models. Excepts that all given models have a value.
749
841
  If the field is an integer field, the value will be converted to a float.
@@ -752,13 +844,14 @@ class RecordHandler:
752
844
  :param field_name: The name of the numeric field to sum.
753
845
  :return: The sum of the field values for the collection of models.
754
846
  """
847
+ field_name: str = AliasUtil.to_data_field_name(field_name)
755
848
  field_sum: float = 0
756
849
  for model in models:
757
850
  field_sum += float(model.get_field_value(field_name))
758
851
  return field_sum
759
852
 
760
853
  @staticmethod
761
- def mean_of_field(models: Iterable[SapioRecord], field_name: str) -> float:
854
+ def mean_of_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) -> float:
762
855
  """
763
856
  Calculate the mean of the numeric value of a given field across all input models. Excepts that all given models
764
857
  have a value. If the field is an integer field, the value will be converted to a float.
@@ -799,8 +892,8 @@ class RecordHandler:
799
892
  return oldest
800
893
 
801
894
  @staticmethod
802
- def values_to_field_maps(field_name: str, values: Iterable[Any], existing_fields: list[FieldMap] | None = None) \
803
- -> list[FieldMap]:
895
+ def values_to_field_maps(field_name: FieldIdentifier, values: Iterable[FieldValue],
896
+ existing_fields: list[FieldIdentifier] | None = None) -> list[FieldMap]:
804
897
  """
805
898
  Add a list of values for a specific field to a list of dictionaries pairing each value to that field name.
806
899
 
@@ -811,6 +904,8 @@ class RecordHandler:
811
904
  :return: A fields map list that contains the given values mapped by the given field name.
812
905
  """
813
906
  # Update the existing fields map list if one is given.
907
+ field_name: str = AliasUtil.to_data_field_name(field_name)
908
+ existing_fields: list[FieldMap] = AliasUtil.to_data_field_names_list_dict(existing_fields)
814
909
  if existing_fields:
815
910
  values = list(values)
816
911
  # The number of new values must match the length of the existing fields list.
@@ -831,8 +926,6 @@ class RecordHandler:
831
926
  path, if any. The hierarchy must be linear (1:1 relationship between data types at every step) and the
832
927
  relationship path must already be loaded.
833
928
 
834
- Currently, the relationship path may only contain parent/child nodes.
835
-
836
929
  :param models: A list of record models.
837
930
  :param path: The relationship path to follow.
838
931
  :param wrapper_type: The record model wrapper to use.
@@ -843,15 +936,44 @@ class RecordHandler:
843
936
  # PR-46832: Update path traversal to account for changes to RelationshipPath in Sapiopylib.
844
937
  path: list[RelationshipNode] = path.path
845
938
  for model in models:
846
- current: PyRecordModel = model if isinstance(model, PyRecordModel) else model.backing_model
939
+ current: PyRecordModel | None = model if isinstance(model, PyRecordModel) else model.backing_model
847
940
  for node in path:
848
- direction = node.direction
941
+ data_type: str = node.data_type_name
942
+ direction: RelationshipNodeType = node.direction
849
943
  if current is None:
850
944
  break
851
945
  if direction == RelationshipNodeType.CHILD:
852
- current = current.get_child_of_type(node.data_type_name)
946
+ current = current.get_child_of_type(data_type)
853
947
  elif direction == RelationshipNodeType.PARENT:
854
- current = current.get_parent_of_type(node.data_type_name)
948
+ current = current.get_parent_of_type(data_type)
949
+ elif direction == RelationshipNodeType.ANCESTOR:
950
+ ancestors: list[PyRecordModel] = list(self.an_man.get_ancestors_of_type(current, data_type))
951
+ if not ancestors:
952
+ current = None
953
+ elif len(ancestors) > 1:
954
+ raise SapioException(f"Hierarchy contains multiple ancestors of type {data_type}.")
955
+ else:
956
+ current = ancestors[0]
957
+ elif direction == RelationshipNodeType.DESCENDANT:
958
+ descendants: list[PyRecordModel] = list(self.an_man.get_descendant_of_type(current, data_type))
959
+ if not descendants:
960
+ current = None
961
+ elif len(descendants) > 1:
962
+ raise SapioException(f"Hierarchy contains multiple descendants of type {data_type}.")
963
+ else:
964
+ current = descendants[0]
965
+ elif direction == RelationshipNodeType.FORWARD_SIDE_LINK:
966
+ current = current.get_forward_side_link(node.data_field_name)
967
+ elif direction == RelationshipNodeType.REVERSE_SIDE_LINK:
968
+ field_name: str = node.data_field_name
969
+ reverse_links: list[PyRecordModel] = current.get_reverse_side_link(field_name, data_type)
970
+ if not reverse_links:
971
+ current = None
972
+ elif len(reverse_links) > 1:
973
+ raise SapioException(f"Hierarchy contains multiple reverse links of type {data_type} on field "
974
+ f"{field_name}.")
975
+ else:
976
+ current = reverse_links[0]
855
977
  else:
856
978
  raise SapioException("Unsupported path direction.")
857
979
  ret_dict.update({model: self.inst_man.wrap(current, wrapper_type) if current else None})
@@ -864,8 +986,6 @@ class RecordHandler:
864
986
  path, if any. The hierarchy may be non-linear (1:Many relationships between data types are allowed) and the
865
987
  relationship path must already be loaded.
866
988
 
867
- Currently, the relationship path may only contain parent/child nodes.
868
-
869
989
  :param models: A list of record models.
870
990
  :param path: The relationship path to follow.
871
991
  :param wrapper_type: The record model wrapper to use.
@@ -880,14 +1000,23 @@ class RecordHandler:
880
1000
  next_search: set[PyRecordModel] = set()
881
1001
  # Exhaust the records at each step in the path, then use those records for the next step.
882
1002
  for node in path:
883
- direction = node.direction
1003
+ data_type: str = node.data_type_name
1004
+ direction: RelationshipNodeType = node.direction
884
1005
  if len(current_search) == 0:
885
1006
  break
886
1007
  for search in current_search:
887
1008
  if direction == RelationshipNodeType.CHILD:
888
- next_search.update(search.get_children_of_type(node.data_type_name))
1009
+ next_search.update(search.get_children_of_type(data_type))
889
1010
  elif direction == RelationshipNodeType.PARENT:
890
- next_search.update(search.get_parents_of_type(node.data_type_name))
1011
+ next_search.update(search.get_parents_of_type(data_type))
1012
+ elif direction == RelationshipNodeType.ANCESTOR:
1013
+ next_search.update(self.an_man.get_ancestors_of_type(search, data_type))
1014
+ elif direction == RelationshipNodeType.DESCENDANT:
1015
+ next_search.update(self.an_man.get_descendant_of_type(search, data_type))
1016
+ elif direction == RelationshipNodeType.FORWARD_SIDE_LINK:
1017
+ next_search.add(search.get_forward_side_link(node.data_field_name))
1018
+ elif direction == RelationshipNodeType.REVERSE_SIDE_LINK:
1019
+ next_search.update(search.get_reverse_side_link(node.data_field_name, data_type))
891
1020
  else:
892
1021
  raise SapioException("Unsupported path direction.")
893
1022
  current_search = next_search
@@ -908,8 +1037,6 @@ class RecordHandler:
908
1037
  relationships (e.g. a sample which is aliquoted to a number of samples, then those aliquots are pooled back
909
1038
  together into a single sample).
910
1039
 
911
- Currently, the relationship path may only contain parent/child nodes.
912
-
913
1040
  :param models: A list of record models.
914
1041
  :param path: The relationship path to follow.
915
1042
  :param wrapper_type: The record model wrapper to use.
@@ -922,20 +1049,29 @@ class RecordHandler:
922
1049
  for model in models:
923
1050
  current: list[PyRecordModel] = [model if isinstance(model, PyRecordModel) else model.backing_model]
924
1051
  for node in path:
925
- direction = node.direction
1052
+ data_type: str = node.data_type_name
1053
+ direction: RelationshipNodeType = node.direction
926
1054
  if len(current) == 0:
927
1055
  break
928
1056
  if direction == RelationshipNodeType.CHILD:
929
- current = current[0].get_children_of_type(node.data_type_name)
1057
+ current = current[0].get_children_of_type(data_type)
930
1058
  elif direction == RelationshipNodeType.PARENT:
931
- current = current[0].get_parents_of_type(node.data_type_name)
1059
+ current = current[0].get_parents_of_type(data_type)
1060
+ elif direction == RelationshipNodeType.ANCESTOR:
1061
+ current = list(self.an_man.get_ancestors_of_type(current[0], data_type))
1062
+ elif direction == RelationshipNodeType.DESCENDANT:
1063
+ current = list(self.an_man.get_descendant_of_type(current[0], data_type))
1064
+ elif direction == RelationshipNodeType.FORWARD_SIDE_LINK:
1065
+ current = [current[0].get_forward_side_link(node.data_field_name)]
1066
+ elif direction == RelationshipNodeType.REVERSE_SIDE_LINK:
1067
+ current = current[0].get_reverse_side_link(node.data_field_name, data_type)
932
1068
  else:
933
1069
  raise SapioException("Unsupported path direction.")
934
1070
  ret_dict.update({model: self.inst_man.wrap(current[0], wrapper_type) if current else None})
935
1071
  return ret_dict
936
1072
 
937
- def __find_model(self, wrapper_type: type[WrappedType], primary_identifier: str, id_value: Any,
938
- secondary_identifiers: FieldMap | None = None) -> WrappedType | None:
1073
+ def __find_model(self, wrapper_type: type[WrappedType], primary_identifier: str, id_value: FieldValue,
1074
+ secondary_identifiers: FieldIdentifierMap | None = None) -> WrappedType | None:
939
1075
  """
940
1076
  Find a record from the system that matches the given field values. The primary identifier and value is used
941
1077
  to query for the record, then the secondary identifiers may be optionally provided to further filter the
@@ -959,3 +1095,18 @@ class RecordHandler:
959
1095
  f"encountered in system that matches all provided identifiers.")
960
1096
  unique_record = result
961
1097
  return unique_record
1098
+
1099
+ @staticmethod
1100
+ def __verify_data_type(records: Iterable[DataRecord], wrapper_type: type[WrappedType]) -> None:
1101
+ """
1102
+ Throw an exception if the data type of the given records and wrapper don't match.
1103
+ """
1104
+ model_type: str = wrapper_type.get_wrapper_data_type_name()
1105
+ for record in records:
1106
+ record_type: str = record.data_type_name
1107
+ # Account for ELN data type records.
1108
+ if ElnBaseDataType.is_eln_type(record_type):
1109
+ record_type = ElnBaseDataType.get_base_type(record_type).data_type_name
1110
+ if record_type != model_type:
1111
+ raise SapioException(f"Data record of type {record_type} cannot be wrapped by the record model wrapper "
1112
+ f"of type {model_type}")