sapiopycommons 2025.4.9a150__py3-none-any.whl → 2025.4.9a476__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 (42) hide show
  1. sapiopycommons/callbacks/callback_util.py +1262 -392
  2. sapiopycommons/callbacks/field_builder.py +2 -0
  3. sapiopycommons/chem/Molecules.py +0 -2
  4. sapiopycommons/customreport/auto_pagers.py +281 -0
  5. sapiopycommons/customreport/term_builder.py +1 -1
  6. sapiopycommons/datatype/attachment_util.py +4 -2
  7. sapiopycommons/datatype/data_fields.py +23 -1
  8. sapiopycommons/eln/experiment_cache.py +173 -0
  9. sapiopycommons/eln/experiment_handler.py +933 -279
  10. sapiopycommons/eln/experiment_report_util.py +15 -10
  11. sapiopycommons/eln/experiment_step_factory.py +474 -0
  12. sapiopycommons/eln/experiment_tags.py +7 -0
  13. sapiopycommons/eln/plate_designer.py +159 -59
  14. sapiopycommons/eln/step_creation.py +235 -0
  15. sapiopycommons/files/file_bridge.py +76 -0
  16. sapiopycommons/files/file_bridge_handler.py +325 -110
  17. sapiopycommons/files/file_data_handler.py +2 -2
  18. sapiopycommons/files/file_util.py +40 -15
  19. sapiopycommons/files/file_validator.py +6 -5
  20. sapiopycommons/files/file_writer.py +1 -1
  21. sapiopycommons/flowcyto/flow_cyto.py +1 -1
  22. sapiopycommons/general/accession_service.py +3 -3
  23. sapiopycommons/general/aliases.py +51 -28
  24. sapiopycommons/general/audit_log.py +2 -2
  25. sapiopycommons/general/custom_report_util.py +24 -1
  26. sapiopycommons/general/data_structure_util.py +115 -0
  27. sapiopycommons/general/directive_util.py +86 -0
  28. sapiopycommons/general/exceptions.py +41 -2
  29. sapiopycommons/general/popup_util.py +2 -2
  30. sapiopycommons/multimodal/multimodal.py +1 -0
  31. sapiopycommons/processtracking/custom_workflow_handler.py +46 -30
  32. sapiopycommons/recordmodel/record_handler.py +547 -159
  33. sapiopycommons/rules/eln_rule_handler.py +41 -30
  34. sapiopycommons/rules/on_save_rule_handler.py +41 -30
  35. sapiopycommons/samples/aliquot.py +48 -0
  36. sapiopycommons/webhook/webhook_handlers.py +448 -55
  37. sapiopycommons/webhook/webservice_handlers.py +2 -2
  38. {sapiopycommons-2025.4.9a150.dist-info → sapiopycommons-2025.4.9a476.dist-info}/METADATA +1 -1
  39. sapiopycommons-2025.4.9a476.dist-info/RECORD +67 -0
  40. sapiopycommons-2025.4.9a150.dist-info/RECORD +0 -59
  41. {sapiopycommons-2025.4.9a150.dist-info → sapiopycommons-2025.4.9a476.dist-info}/WHEEL +0 -0
  42. {sapiopycommons-2025.4.9a150.dist-info → sapiopycommons-2025.4.9a476.dist-info}/licenses/LICENSE +0 -0
@@ -21,10 +21,14 @@ class FileUtil:
21
21
  Utilities for the handling of files, including the requesting of files from the user and the parsing of files into
22
22
  tokenized lists. Makes use of Pandas DataFrames for any file parsing purposes.
23
23
  """
24
+ # PR-47433: Add a keep_default_na argument to FileUtil.tokenize_csv and FileUtil.tokenize_xlsx so that N/A values
25
+ # don't get returned as NoneType, and add **kwargs in case any other Pandas input parameters need changed by the
26
+ # caller.
24
27
  @staticmethod
25
28
  def tokenize_csv(file_bytes: bytes, required_headers: list[str] | None = None, header_row_index: int | None = 0,
26
29
  seperator: str = ",", *, encoding: str | None = None, encoding_error: str | None = "strict",
27
- exception_on_empty: bool = True) -> tuple[list[dict[str, str]], list[list[str]]]:
30
+ exception_on_empty: bool = True, keep_default_na: bool = False, **kwargs) \
31
+ -> tuple[list[dict[str, str]], list[list[str]]]:
28
32
  """
29
33
  Tokenize a CSV file. The provided file must be uniform. That is, if row 1 has 10 cells, all the rows in the file
30
34
  must have 10 cells. Otherwise, the Pandas parser throws a tokenizer exception.
@@ -46,6 +50,9 @@ class FileUtil:
46
50
  https://docs.python.org/3/library/codecs.html#error-handlers
47
51
  :param exception_on_empty: Throw a user error exception if the provided file bytes result in an empty list in
48
52
  the first element of the returned tuple.
53
+ :param keep_default_na: If False, values that are recognized as NaN (e.g. N/A, NA, NaN) will remain as strings.
54
+ If True, these values will be converted to a NoneType value.
55
+ :param kwargs: Additional arguments to be passed to the pandas read_csv function.
49
56
  :return: The CSV parsed into a list of dicts where each dict is a row, mapping the headers to the cells for
50
57
  that row. Also returns a list of each row above the headers (the metadata), parsed into a list of each cell.
51
58
  If the header row index is 0 or None, this list will be empty.
@@ -53,7 +60,8 @@ class FileUtil:
53
60
  # Parse the file bytes into two DataFrames. The first is metadata of the file located above the header row,
54
61
  # while the second is the body of the file below the header row.
55
62
  file_body, file_metadata = FileUtil.csv_to_data_frames(file_bytes, header_row_index, seperator,
56
- encoding=encoding, encoding_error=encoding_error)
63
+ encoding=encoding, encoding_error=encoding_error,
64
+ keep_default_na=keep_default_na, **kwargs)
57
65
  # Parse the metadata from above the header row index into a list of lists.
58
66
  metadata: list[list[str]] = FileUtil.data_frame_to_lists(file_metadata)
59
67
  # Parse the data from the file body into a list of dicts.
@@ -64,7 +72,8 @@ class FileUtil:
64
72
 
65
73
  @staticmethod
66
74
  def tokenize_xlsx(file_bytes: bytes, required_headers: list[str] | None = None, header_row_index: int | None = 0,
67
- *, exception_on_empty: bool = True) -> tuple[list[dict[str, str]], list[list[str]]]:
75
+ *, exception_on_empty: bool = True, keep_default_na: bool = False, **kwargs) \
76
+ -> tuple[list[dict[str, str]], list[list[str]]]:
68
77
  """
69
78
  Tokenize an XLSX file row by row.
70
79
 
@@ -77,13 +86,17 @@ class FileUtil:
77
86
  is assumed to be the header row.
78
87
  :param exception_on_empty: Throw a user error exception if the provided file bytes result in an empty list in
79
88
  the first element of the returned tuple.
89
+ :param keep_default_na: If False, values that are recognized as NaN (e.g. N/A, NA, NaN) will remain as strings.
90
+ If True, these values will be converted to a NoneType value.
91
+ :param kwargs: Additional arguments to be passed to the pandas read_excel function.
80
92
  :return: The XLSX parsed into a list of dicts where each dict is a row, mapping the headers to the cells for
81
93
  that row. Also returns a list of each row above the headers (the metadata), parsed into a list of each cell.
82
94
  If the header row index is 0 or None, this list will be empty.
83
95
  """
84
96
  # Parse the file bytes into two DataFrames. The first is metadata of the file located above the header row,
85
97
  # while the second is the body of the file below the header row.
86
- file_body, file_metadata = FileUtil.xlsx_to_data_frames(file_bytes, header_row_index)
98
+ file_body, file_metadata = FileUtil.xlsx_to_data_frames(file_bytes, header_row_index,
99
+ keep_default_na=keep_default_na, **kwargs)
87
100
  # Parse the metadata from above the header row index into a list of lists.
88
101
  metadata: list[list[str]] = FileUtil.data_frame_to_lists(file_metadata)
89
102
  # Parse the data from the file body into a list of dicts.
@@ -94,7 +107,8 @@ class FileUtil:
94
107
 
95
108
  @staticmethod
96
109
  def csv_to_data_frames(file_bytes: bytes, header_row_index: int | None = 0, seperator: str = ",",
97
- *, encoding: str | None = None, encoding_error: str | None = "strict") \
110
+ *, encoding: str | None = None, encoding_error: str | None = "strict",
111
+ keep_default_na: bool = False, **kwargs) \
98
112
  -> tuple[DataFrame, DataFrame | None]:
99
113
  """
100
114
  Parse the file bytes for a CSV into DataFrames. The provided file must be uniform. That is, if row 1 has 10
@@ -113,6 +127,9 @@ class FileUtil:
113
127
  is "strict", meaning that encoding errors raise an exception. Change this to "ignore" to skip over invalid
114
128
  characters or "replace" to replace invalid characters with a ? character. For a full list of options, see
115
129
  https://docs.python.org/3/library/codecs.html#error-handlers
130
+ :param keep_default_na: If False, values that are recognized as NaN (e.g. N/A, NA, NaN) will remain as strings.
131
+ If True, these values will be converted to a NoneType value.
132
+ :param kwargs: Additional arguments to be passed to the pandas read_csv function.
116
133
  :return: A tuple of two DataFrames. The first is the frame for the CSV table body, while the second is for the
117
134
  metadata from above the header row, or None if there is no metadata.
118
135
  """
@@ -125,19 +142,21 @@ class FileUtil:
125
142
  file_metadata = pandas.read_csv(file_io, header=None, dtype=dtype(str),
126
143
  skiprows=lambda x: x >= header_row_index,
127
144
  skip_blank_lines=False, sep=seperator, encoding=encoding,
128
- encoding_errors=encoding_error)
145
+ encoding_errors=encoding_error, keep_default_na=keep_default_na,
146
+ **kwargs)
129
147
  with io.BytesIO(file_bytes) as file_io:
130
148
  # The use of the dtype argument is to ensure that everything from the file gets read as a string. Added
131
149
  # because some numerical values would get ".0" appended to them, even when casting the DataFrame cell to a
132
150
  # string.
133
151
  file_body: DataFrame = pandas.read_csv(file_io, header=header_row_index, dtype=dtype(str),
134
- skip_blank_lines=False, sep=seperator, encoding=encoding)
152
+ skip_blank_lines=False, sep=seperator, encoding=encoding,
153
+ keep_default_na=keep_default_na, **kwargs)
135
154
 
136
155
  return file_body, file_metadata
137
156
 
138
157
  @staticmethod
139
- def xlsx_to_data_frames(file_bytes: bytes, header_row_index: int | None = 0) \
140
- -> tuple[DataFrame, DataFrame | None]:
158
+ def xlsx_to_data_frames(file_bytes: bytes, header_row_index: int | None = 0, *, keep_default_na: bool = False,
159
+ **kwargs) -> tuple[DataFrame, DataFrame | None]:
141
160
  """
142
161
  Parse the file bytes for an XLSX into DataFrames.
143
162
 
@@ -146,6 +165,9 @@ class FileUtil:
146
165
  row is returned in the metadata list. If input is None, then no row is considered to be the header row,
147
166
  meaning that required headers are also ignored if any are provided. By default, the first row (0th index)
148
167
  is assumed to be the header row.
168
+ :param keep_default_na: If False, values that are recognized as NaN (e.g. N/A, NA, NaN) will remain as strings.
169
+ If True, these values will be converted to a NoneType value.
170
+ :param kwargs: Additional arguments to be passed to the pandas read_excel function.
149
171
  :return: A tuple of two DataFrames. The first is the frame for the XLSX table body, while the second is for the
150
172
  metadata from above the header row, or None if there is no metadata.
151
173
  """
@@ -155,12 +177,14 @@ class FileUtil:
155
177
  # The metadata DataFrame has no headers and only consists of the rows above the header row index.
156
178
  # Therefore, we skip every row including and past the header.
157
179
  file_metadata = pandas.read_excel(file_io, header=None, dtype=dtype(str),
158
- skiprows=lambda x: x >= header_row_index)
180
+ skiprows=lambda x: x >= header_row_index,
181
+ keep_default_na=keep_default_na, **kwargs)
159
182
  with io.BytesIO(file_bytes) as file_io:
160
183
  # The use of the dtype argument is to ensure that everything from the file gets read as a string. Added
161
184
  # because some numerical values would get ".0" appended to them, even when casting the DataFrame cell to a
162
185
  # string.
163
- file_body: DataFrame = pandas.read_excel(file_io, header=header_row_index, dtype=dtype(str))
186
+ file_body: DataFrame = pandas.read_excel(file_io, header=header_row_index, dtype=dtype(str),
187
+ keep_default_na=keep_default_na, **kwargs)
164
188
 
165
189
  return file_body, file_metadata
166
190
 
@@ -255,6 +279,7 @@ class FileUtil:
255
279
  data_frame = pandas.read_csv(csv, sep=",", header=None)
256
280
 
257
281
  with io.BytesIO() as output:
282
+ # noinspection PyTypeChecker
258
283
  with pandas.ExcelWriter(output, engine='xlsxwriter') as writer:
259
284
  # Setting header and index to false makes the CSV convert to an XLSX as-is.
260
285
  data_frame.to_excel(writer, sheet_name='Sheet1', header=False, index=False)
@@ -302,10 +327,10 @@ class FileUtil:
302
327
  :param files: A dictionary of file name to file data as a string or bytes.
303
328
  :return: The bytes for a zip file containing the input files.
304
329
  """
305
- zip_buffer: io.BytesIO = io.BytesIO()
306
- with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
307
- for file_name, file_data in files.items():
308
- zip_file.writestr(file_name, file_data)
330
+ with io.BytesIO() as zip_buffer:
331
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
332
+ for file_name, file_data in files.items():
333
+ zip_file.writestr(file_name, file_data)
309
334
  return zip_buffer.getvalue()
310
335
 
311
336
  # Deprecated functions:
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import re
3
4
  from abc import abstractmethod
4
5
  from typing import Any
5
6
 
@@ -9,9 +10,9 @@ from sapiopylib.rest.pojo.datatype.FieldDefinition import VeloxIntegerFieldDefin
9
10
  AbstractVeloxFieldDefinition
10
11
 
11
12
  from sapiopycommons.callbacks.callback_util import CallbackUtil
13
+ from sapiopycommons.customreport.auto_pagers import QuickReportDictAutoPager
12
14
  from sapiopycommons.files.file_data_handler import FileDataHandler, FilterList
13
15
  from sapiopycommons.general.aliases import UserIdentifier, AliasUtil
14
- from sapiopycommons.general.custom_report_util import CustomReportUtil
15
16
  from sapiopycommons.general.exceptions import SapioUserCancelledException
16
17
  from sapiopycommons.general.time_util import TimeUtil
17
18
 
@@ -311,8 +312,8 @@ class MatchesPatternRule(ColumnRule):
311
312
  """
312
313
  pattern: str
313
314
 
314
- def __init__(self, header: str, pattern: str, *, reason: str | None = None, whitelist: FilterList = None,
315
- blacklist: FilterList = None):
315
+ def __init__(self, header: str, pattern: str | re.Pattern[str], *, reason: str | None = None,
316
+ whitelist: FilterList = None, blacklist: FilterList = None):
316
317
  """
317
318
  :param header: The header that this rule acts upon.
318
319
  :param pattern: A regex pattern.
@@ -529,7 +530,7 @@ class UniqueSystemValueRule(ColumnRule):
529
530
  # Run a quick report for all records of this type that match these field values.
530
531
  term = RawReportTerm(self.data_type_name, self.data_field_name, RawTermOperation.EQUAL_TO_OPERATOR,
531
532
  "{" + ",".join(values) + "}")
532
- results: list[dict[str, Any]] = CustomReportUtil.run_quick_report(self.user, term)
533
+ results: list[dict[str, Any]] = QuickReportDictAutoPager(self.user, term).get_all_at_once()
533
534
  existing_values: list[Any] = [x.get(self.data_field_name) for x in results]
534
535
  return file_handler.get_in_list(self.header, existing_values)
535
536
 
@@ -563,6 +564,6 @@ class ExistingSystemValueRule(ColumnRule):
563
564
  # Run a quick report for all records of this type that match these field values.
564
565
  term = RawReportTerm(self.data_type_name, self.data_field_name, RawTermOperation.EQUAL_TO_OPERATOR,
565
566
  "{" + ",".join(values) + "}")
566
- results: list[dict[str, Any]] = CustomReportUtil.run_quick_report(self.user, term)
567
+ results: list[dict[str, Any]] = QuickReportDictAutoPager(self.user, term).get_all_at_once()
567
568
  existing_values: list[Any] = [x.get(self.data_field_name) for x in results]
568
569
  return file_handler.get_not_in_list(self.header, existing_values)
@@ -307,7 +307,7 @@ class FieldColumn(ColumnDef):
307
307
  elif self.search_order == FieldSearchOrder.BUNDLE_ONLY:
308
308
  return row.fields.get(self.field_name)
309
309
  elif self.search_order == FieldSearchOrder.RECORD_FIRST:
310
- fields: dict[str, Any] = AliasUtil.to_field_map_lists([record])[0] if record else {}
310
+ fields: dict[str, Any] = AliasUtil.to_field_map(record) if record else {}
311
311
  if self.field_name not in fields or (self.skip_none_values and fields.get(self.field_name) is None):
312
312
  return row.fields.get(self.field_name)
313
313
  return fields.get(self.field_name)
@@ -2,8 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  from weakref import WeakValueDictionary
4
4
 
5
- from sapiopylib.rest.User import SapioUser
6
5
  from databind.json import dumps
6
+ from sapiopylib.rest.User import SapioUser
7
7
 
8
8
  from sapiopycommons.flowcyto.flowcyto_data import FlowJoWorkspaceInputJson, UploadFCSInputJson, \
9
9
  ComputeFlowStatisticsInputJson
@@ -95,7 +95,7 @@ class AccessionWithPrefixSuffix(AbstractAccessionServiceOperator):
95
95
 
96
96
  @property
97
97
  def default_accessor_name(self):
98
- return "PREFIX_AND_SUFFIX" + "(" + self.prefix + "," + self.suffix + ")";
98
+ return "PREFIX_AND_SUFFIX" + "(" + self.prefix + "," + self.suffix + ")"
99
99
 
100
100
 
101
101
  class AccessionGlobalPrefixSuffix(AbstractAccessionServiceOperator):
@@ -199,7 +199,7 @@ class AccessionRequestId(AbstractAccessionServiceOperator):
199
199
 
200
200
  Properties:
201
201
  numberOfCharacters: Number of characters maximum in the request ID.
202
- accessorName: This is a legacy variable from drum.getNextIdListByMapName(), which allows setting different "accessorName" from old system. We need this for compability patch for converting these to the new preference format.
202
+ accessorName: This is a legacy variable from drum.getNextIdListByMapName(), which allows setting different "accessorName" from old system. We need this for compatibility patch for converting these to the new preference format.
203
203
  """
204
204
  _num_of_characters: int
205
205
  _accessor_name: str
@@ -341,7 +341,7 @@ class AccessionService:
341
341
  def get_affixed_id_in_batch(self, data_type_name: str, data_field_name: str, num_ids: int, prefix: str | None,
342
342
  suffix: str | None, num_digits: int | None, start_num: int = 1) -> list[str]:
343
343
  """
344
- Get the batch affixed IDs that are maximal in cache and contiguious for a particular datatype.datafield under a given format.
344
+ Get the batch affixed IDs that are maximal in cache and contiguous for a particular datatype.datafield under a given format.
345
345
  :param data_type_name: The datatype name to look for max ID
346
346
  :param data_field_name: The datafield name to look for max ID
347
347
  :param num_ids: The number of IDs to accession.
@@ -1,50 +1,53 @@
1
1
  from collections.abc import Iterable
2
- from typing import Any
2
+ from typing import Any, TypeAlias
3
3
 
4
4
  from sapiopylib.rest.User import SapioUser
5
5
  from sapiopylib.rest.pojo.DataRecord import DataRecord
6
- from sapiopylib.rest.pojo.datatype.FieldDefinition import FieldType
6
+ from sapiopylib.rest.pojo.datatype.FieldDefinition import FieldType, AbstractVeloxFieldDefinition
7
7
  from sapiopylib.rest.pojo.eln.ElnExperiment import ElnExperiment
8
8
  from sapiopylib.rest.pojo.eln.ExperimentEntry import ExperimentEntry
9
9
  from sapiopylib.rest.pojo.eln.SapioELNEnums import ElnBaseDataType
10
+ from sapiopylib.rest.pojo.eln.eln_headings import ElnExperimentTab
10
11
  from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
11
12
  from sapiopylib.rest.utils.Protocols import ElnExperimentProtocol, ElnEntryStep
12
- from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel
13
+ from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel, AbstractRecordModel
13
14
  from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedRecordModel, WrappedType, WrapperField
14
15
 
15
16
  from sapiopycommons.general.exceptions import SapioException
16
17
 
17
- FieldValue = int | float | str | bool | None
18
+ FieldValue: TypeAlias = int | float | str | bool | None
18
19
  """Allowable values for fields in the system."""
19
- RecordModel = PyRecordModel | WrappedRecordModel
20
+ RecordModel: TypeAlias = PyRecordModel | AbstractRecordModel | WrappedRecordModel
20
21
  """Different forms that a record model could take."""
21
- SapioRecord = DataRecord | RecordModel
22
+ SapioRecord: TypeAlias = DataRecord | RecordModel
22
23
  """A record could be provided as either a DataRecord, PyRecordModel, or WrappedRecordModel (WrappedType)."""
23
- RecordIdentifier = SapioRecord | int
24
+ RecordIdentifier: TypeAlias = SapioRecord | int
24
25
  """A RecordIdentifier is either a record type or an integer for the record's record ID."""
25
- DataTypeIdentifier = SapioRecord | type[WrappedType] | str
26
+ DataTypeIdentifier: TypeAlias = SapioRecord | type[WrappedType] | str
26
27
  """A DataTypeIdentifier is either a SapioRecord, a record model wrapper type, or a string."""
27
- FieldIdentifier = WrapperField | str | tuple[str, FieldType]
28
+ FieldIdentifier: TypeAlias = AbstractVeloxFieldDefinition | WrapperField | str | tuple[str, FieldType]
28
29
  """A FieldIdentifier is either wrapper field from a record model wrapper, a string, or a tuple of string
29
30
  and field type."""
30
- FieldIdentifierKey = WrapperField | str
31
+ FieldIdentifierKey: TypeAlias = WrapperField | str
31
32
  """A FieldIdentifierKey is a FieldIdentifier, except it can't be a tuple, s tuples can't be used as keys in
32
33
  dictionaries.."""
33
- HasFieldWrappers = type[WrappedType] | WrappedRecordModel
34
+ HasFieldWrappers: TypeAlias = type[WrappedType] | WrappedRecordModel
34
35
  """An identifier for classes that have wrapper fields."""
35
- ExperimentIdentifier = ElnExperimentProtocol | ElnExperiment | int
36
+ ExperimentIdentifier: TypeAlias = ElnExperimentProtocol | ElnExperiment | int
36
37
  """An ExperimentIdentifier is either an experiment protocol, experiment, or an integer for the experiment's notebook
37
38
  ID."""
38
- ExperimentEntryIdentifier = ElnEntryStep | ExperimentEntry | int
39
+ ExperimentEntryIdentifier: TypeAlias = ElnEntryStep | ExperimentEntry | int
39
40
  """An ExperimentEntryIdentifier is either an ELN entry step, experiment entry, or an integer for the entry's ID."""
40
- FieldMap = dict[str, FieldValue]
41
+ TabIdentifier: TypeAlias = int | ElnExperimentTab
42
+ """A TabIdentifier is either an integer for the tab's ID or an ElnExperimentTab object."""
43
+ FieldMap: TypeAlias = dict[str, FieldValue]
41
44
  """A field map is simply a dict of data field names to values. The purpose of aliasing this is to help distinguish
42
45
  any random dict in a webhook from one which is explicitly used for record fields."""
43
- FieldIdentifierMap = dict[FieldIdentifierKey, FieldValue]
46
+ FieldIdentifierMap: TypeAlias = dict[FieldIdentifierKey, FieldValue]
44
47
  """A field identifier map is the same thing as a field map, except the keys can be field identifiers instead
45
48
  of just strings. Note that although one of the allowed field identifiers is a tuple, you can't use tuples as
46
49
  keys in a dictionary."""
47
- UserIdentifier = SapioWebhookContext | SapioUser
50
+ UserIdentifier: TypeAlias = SapioWebhookContext | SapioUser
48
51
  """An identifier for classes from which a user object can be used for sending requests."""
49
52
 
50
53
 
@@ -142,23 +145,25 @@ class AliasUtil:
142
145
  @staticmethod
143
146
  def to_data_field_name(value: FieldIdentifier) -> str:
144
147
  """
145
- Convert a string or WrapperField to a data field name string.
148
+ Convert an object that can be used to identify a data field to a data field name string.
146
149
 
147
- :param value: A string or WrapperField.
150
+ :param value: An object that can be used to identify a data field.
148
151
  :return: A string of the data field name of the input value.
149
152
  """
150
153
  if isinstance(value, tuple):
151
154
  return value[0]
152
155
  if isinstance(value, WrapperField):
153
156
  return value.field_name
157
+ if isinstance(value, AbstractVeloxFieldDefinition):
158
+ return value.data_field_name
154
159
  return value
155
160
 
156
161
  @staticmethod
157
162
  def to_data_field_names(values: Iterable[FieldIdentifier]) -> list[str]:
158
163
  """
159
- Convert an iterable of strings or WrapperFields to a list of data field name strings.
164
+ Convert an iterable of objects that can be used to identify data fields to a list of data field name strings.
160
165
 
161
- :param values: An iterable of strings or WrapperFields.
166
+ :param values: An iterable of objects that can be used to identify a data field.
162
167
  :return: A list of strings of the data field names of the input values.
163
168
  """
164
169
  return [AliasUtil.to_data_field_name(x) for x in values]
@@ -205,20 +210,38 @@ class AliasUtil:
205
210
  f"field with the name \"{field}\",")
206
211
 
207
212
  @staticmethod
208
- def to_field_map_lists(records: Iterable[SapioRecord]) -> list[FieldMap]:
213
+ def to_field_map(record: SapioRecord, include_record_id: bool = False) -> FieldMap:
209
214
  """
210
- Convert a list of variables that could either be DataRecords, PyRecordModels,
211
- or WrappedRecordModels to a list of their field maps.
215
+ Convert a given record value to a field map.
216
+
217
+ :param record: A record which is a DataRecord, PyRecordModel, or WrappedRecordModel.
218
+ :param include_record_id: If true, include the record ID of the record in the field map using the RecordId key.
219
+ :return: The field map for the input record.
220
+ """
221
+ if isinstance(record, DataRecord):
222
+ # noinspection PyTypeChecker
223
+ fields: FieldMap = record.get_fields()
224
+ else:
225
+ fields: FieldMap = record.fields.copy_to_dict()
226
+ # PR-47457: Only include the record ID if the caller requests it, since including the record ID can break
227
+ # callbacks in certain circumstances if the record ID is negative.
228
+ if include_record_id:
229
+ fields["RecordId"] = AliasUtil.to_record_id(record)
230
+ return fields
231
+
232
+ @staticmethod
233
+ def to_field_map_list(records: Iterable[SapioRecord], include_record_id: bool = False) -> list[FieldMap]:
234
+ """
235
+ Convert a list of variables that could either be DataRecords, PyRecordModels, or WrappedRecordModels
236
+ to a list of their field maps. This includes the given RecordId of the given records.
212
237
 
238
+ :param records: An iterable of records which are DataRecords, PyRecordModels, or WrappedRecordModels.
239
+ :param include_record_id: If true, include the record ID of the records in the field map using the RecordId key.
213
240
  :return: A list of field maps for the input records.
214
241
  """
215
242
  field_map_list: list[FieldMap] = []
216
243
  for record in records:
217
- if isinstance(record, DataRecord):
218
- # noinspection PyTypeChecker
219
- field_map_list.append(record.get_fields())
220
- else:
221
- field_map_list.append(record.fields.copy_to_dict())
244
+ field_map_list.append(AliasUtil.to_field_map(record, include_record_id))
222
245
  return field_map_list
223
246
 
224
247
  @staticmethod
@@ -3,11 +3,11 @@ from enum import Enum
3
3
  from sapiopylib.rest.User import SapioUser
4
4
  from sapiopylib.rest.pojo.CustomReport import ReportColumn, CustomReportCriteria
5
5
 
6
+ from sapiopycommons.customreport.auto_pagers import CustomReportDictAutoPager
6
7
  from sapiopycommons.customreport.column_builder import ColumnBuilder
7
8
  from sapiopycommons.customreport.term_builder import TermBuilder
8
9
  from sapiopycommons.datatype.pseudo_data_types import AuditLogPseudoDef
9
10
  from sapiopycommons.general.aliases import RecordIdentifier, AliasUtil, UserIdentifier, FieldIdentifier, FieldValue
10
- from sapiopycommons.general.custom_report_util import CustomReportUtil
11
11
 
12
12
 
13
13
  class EventType(Enum):
@@ -164,7 +164,7 @@ class AuditLogUtil:
164
164
  criteria = AuditLogUtil.create_data_record_audit_log_report(records, fields)
165
165
 
166
166
  # Then we must run the custom report using that criteria.
167
- raw_report_data: list[dict[str, FieldValue]] = CustomReportUtil.run_custom_report(self.user, criteria)
167
+ raw_report_data: list[dict[str, FieldValue]] = CustomReportDictAutoPager(self.user, criteria).get_all_at_once()
168
168
 
169
169
  # This section will prepare a map matching the original RecordIdentifier by record id.
170
170
  # This is because the audit log entries will have record ids, but we want the keys in our result map
@@ -1,3 +1,4 @@
1
+ import warnings
1
2
  from collections.abc import Iterable
2
3
 
3
4
  from sapiopylib.rest.DataMgmtService import DataMgmtServer
@@ -40,6 +41,7 @@ class CustomReportUtil:
40
41
  had a Sample column with a data field name of Identifier and a Request column with the same data field name,
41
42
  then the dictionary keys for these columns would be Sample.Identifier and Request.Identifier respectively.
42
43
  """
44
+ warnings.warn("Deprecated in favor of the SystemReportDictAutoPager class.", DeprecationWarning)
43
45
  results: tuple = CustomReportUtil._exhaust_system_report(context, report_name, page_limit,
44
46
  page_size, page_number)
45
47
  columns: list[ReportColumn] = results[0]
@@ -82,6 +84,7 @@ class CustomReportUtil:
82
84
  had a Sample column with a data field name of Identifier and a Request column with the same data field name,
83
85
  then the dictionary keys for these columns would be Sample.Identifier and Request.Identifier respectively.
84
86
  """
87
+ warnings.warn("Deprecated in favor of the CustomReportDictAutoPager class.", DeprecationWarning)
85
88
  results: tuple = CustomReportUtil._exhaust_custom_report(context, report_criteria, page_limit,
86
89
  page_size, page_number)
87
90
  columns: list[ReportColumn] = results[0]
@@ -117,6 +120,7 @@ class CustomReportUtil:
117
120
  :return: The results of the report listed row by row, mapping each cell to the header it is under. The header
118
121
  values in the dicts are the data field names of the columns.
119
122
  """
123
+ warnings.warn("Deprecated in favor of the QuickReportDictAutoPager class.", DeprecationWarning)
120
124
  results: tuple = CustomReportUtil._exhaust_quick_report(context, report_term, page_limit,
121
125
  page_size, page_number)
122
126
  columns: list[ReportColumn] = results[0]
@@ -127,7 +131,8 @@ class CustomReportUtil:
127
131
  def get_system_report_criteria(context: UserIdentifier, report_name: str) -> CustomReport:
128
132
  """
129
133
  Retrieve a custom report from the system given the name of the report. This works by querying the system report
130
- with a page number and size of 1 to minimize the amount of data transfer needed to retrieve the report's config.
134
+ with a page number of 0 and page size of 1 to minimize the amount of data transfer needed to retrieve the
135
+ report's config.
131
136
 
132
137
  System reports are also known as predefined searches in the system and must be defined in the data designer for
133
138
  a specific data type. That is, saved searches created by users cannot be run using this function.
@@ -143,6 +148,24 @@ class CustomReportUtil:
143
148
  report_man = DataMgmtServer.get_custom_report_manager(user)
144
149
  return report_man.run_system_report_by_name(report_name, 1, 0)
145
150
 
151
+ @staticmethod
152
+ def get_quick_report_criteria(context: UserIdentifier, report_term: RawReportTerm) -> CustomReport:
153
+ """
154
+ Retrieve a quick report from the system given a report term. This works by making a quick report query
155
+ with a page number of 0 and page size of 1 to minimize the amount of data transfer needed to retrieve the
156
+ report's config.
157
+
158
+ Using this, you can add to the root term of the search to then run a new search, or provide it to client
159
+ callbacks or directives that take CustomReports.
160
+
161
+ :param context: The current webhook context or a user object to send requests from.
162
+ :param report_term: The raw report term to use for the quick report.
163
+ :return: The CustomReport object for the given report term.
164
+ """
165
+ user: SapioUser = AliasUtil.to_sapio_user(context)
166
+ report_man = DataMgmtServer.get_custom_report_manager(user)
167
+ return report_man.run_quick_report(report_term, 1, 0)
168
+
146
169
  @staticmethod
147
170
  def _exhaust_system_report(context: UserIdentifier,
148
171
  report_name: str,
@@ -0,0 +1,115 @@
1
+ from enum import Enum
2
+ from typing import Iterable, Any, Collection
3
+
4
+ from sapiopycommons.general.exceptions import SapioException
5
+
6
+
7
+ class ArrayTransformation(Enum):
8
+ """
9
+ An enumeration of the different transformations that can be applied to a 2D array.
10
+ """
11
+ ROTATE_CLOCKWISE = 0
12
+ ROTATE_COUNTER_CLOCKWISE = 1
13
+ ROTATE_180_DEGREES = 2
14
+ MIRROR_HORIZONTAL = 3
15
+ MIRROR_VERTICAL = 4
16
+
17
+
18
+ # FR-47524: Create a DataStructureUtils class that implements various collection utility functions from our Java
19
+ # libraries.
20
+ class DataStructureUtil:
21
+ """
22
+ Utility class for working with data structures. Copies from ListUtil, SetUtil, and various other classes in
23
+ our Java library.
24
+ """
25
+ @staticmethod
26
+ def find_first_or_none(values: Iterable[Any]) -> Any | None:
27
+ """
28
+ Get the first value from an iterable, or None if the iterable is empty.
29
+
30
+ :param values: An iterable of values.
31
+ :return: The first value from the input, or None if the input is empty.
32
+ """
33
+ return next(iter(values), None)
34
+
35
+ @staticmethod
36
+ def remove_null_values(values: Iterable[Any]) -> list[Any]:
37
+ """
38
+ Remove null values from a list.
39
+
40
+ :param values: An iterable of values.
41
+ :return: A list containing all the non-null values from the input.
42
+ """
43
+ return [value for value in values if value is not None]
44
+
45
+ @staticmethod
46
+ def transform_2d_array(values: Collection[Collection[Any]], transformation: ArrayTransformation) \
47
+ -> Collection[Collection[Any]]:
48
+ """
49
+ Perform a transformation on a 2D list.
50
+
51
+ :param values: An iterable of iterables. The iterables should all be of the same size.
52
+ :param transformation: The transformation to apply to the input.
53
+ :return: A new 2D list containing the input transformed according to the specified transformation.
54
+ """
55
+ x: int = len(values)
56
+ for row in values:
57
+ y = len(row)
58
+ if y != x:
59
+ raise SapioException(f"Input must be a square 2D array. The provided array has a length of {x} but "
60
+ f"at least one row has a length of {y}.")
61
+
62
+ match transformation:
63
+ case ArrayTransformation.ROTATE_CLOCKWISE:
64
+ return [list(row) for row in zip(*values[::-1])]
65
+ case ArrayTransformation.ROTATE_COUNTER_CLOCKWISE:
66
+ return [list(row) for row in zip(*values)][::-1]
67
+ case ArrayTransformation.ROTATE_180_DEGREES:
68
+ return [row[::-1] for row in values[::-1]]
69
+ case ArrayTransformation.MIRROR_HORIZONTAL:
70
+ return [list(row[::-1]) for row in values]
71
+ case ArrayTransformation.MIRROR_VERTICAL:
72
+ return values[::-1]
73
+
74
+ raise SapioException(f"Invalid transformation: {transformation}")
75
+
76
+ @staticmethod
77
+ def flatten_to_list(values: Iterable[Iterable[Any]]) -> list[Any]:
78
+ """
79
+ Flatten a list of lists into a single list.
80
+
81
+ :param values: An iterable of iterables.
82
+ :return: A single list containing all the values from the input. Elements are in the order they appear in the
83
+ input.
84
+ """
85
+ return [item for sublist in values for item in sublist]
86
+
87
+ @staticmethod
88
+ def flatten_to_set(values: Iterable[Iterable[Any]]) -> set[Any]:
89
+ """
90
+ Flatten a list of lists into a single set.
91
+
92
+ :param values: An iterable of iterables.
93
+ :return: A single set containing all the values from the input. Elements are in the order they appear in the
94
+ input.
95
+ """
96
+ return {item for subset in values for item in subset}
97
+
98
+ @staticmethod
99
+ def invert_dictionary(dictionary: dict[Any, Any], list_values: bool = False) \
100
+ -> dict[Any, Any] | dict[Any, list[Any]]:
101
+ """
102
+ Invert a dictionary, swapping keys and values. Note that the values of the input dictionary must be hashable.
103
+
104
+ :param dictionary: A dictionary to invert.
105
+ :param list_values: If false, keys that share the same value in the input dictionary will be overwritten in
106
+ the output dictionary so that only the last key remains. If true, the values of the output dictionary will
107
+ be lists where input keys that share the same value will be stored together.
108
+ :return: A new dictionary with the keys and values swapped.
109
+ """
110
+ if list_values:
111
+ inverted = {}
112
+ for key, value in dictionary.items():
113
+ inverted.setdefault(value, []).append(key)
114
+ return inverted
115
+ return {value: key for key, value in dictionary.items()}