sapiopycommons 2024.5.24a240__py3-none-any.whl → 2024.6.6a248__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.

@@ -0,0 +1,318 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import abstractmethod, ABC
4
+
5
+ from sapiopycommons.files.file_bridge import FileBridge
6
+ from sapiopylib.rest.User import SapioUser
7
+ from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
8
+
9
+
10
+ class FileBridgeHandler:
11
+ """
12
+ The FileBridgeHandler provides caching of the results of file bridge endpoint calls while also containing quality
13
+ of life functions for common file bridge actions.
14
+ """
15
+ user: SapioUser
16
+ __bridge: str
17
+ __file_cache: dict[str, bytes]
18
+ """A cache of file paths to file bytes."""
19
+ __files: dict[str, File]
20
+ """A cache of file paths to File objects."""
21
+ __dir_cache: dict[str, list[str]]
22
+ """A cache of directory file paths to the names of the files or nested directories within it."""
23
+ __directories: dict[str, Directory]
24
+ """A cache of directory file paths to Directory objects."""
25
+
26
+ def __init__(self, context: SapioWebhookContext | SapioUser, bridge_name: str):
27
+ """
28
+ :param context: The current webhook context or a user object to send requests from.
29
+ :param bridge_name: The name of the bridge to communicate with. This is the "connection name" in the
30
+ file bridge configurations.
31
+ """
32
+ self.user = context if isinstance(context, SapioUser) else context.user
33
+ self.__bridge = bridge_name
34
+ self.__file_cache = {}
35
+ self.__files = {}
36
+ self.__dir_cache = {}
37
+ self.__directories = {}
38
+
39
+ @property
40
+ def connection_name(self) -> str:
41
+ return self.__bridge
42
+
43
+ def clear_caches(self) -> None:
44
+ """
45
+ Clear the file and directory caches of this handler.
46
+ """
47
+ self.__file_cache.clear()
48
+ self.__files.clear()
49
+ self.__dir_cache.clear()
50
+ self.__directories.clear()
51
+
52
+ def read_file(self, file_path: str, base64_decode: bool = True) -> bytes:
53
+ """
54
+ Read a file from FileBridge. The bytes of the given file will be cached so that any subsequent reads of this
55
+ file will not make an additional webservice call.
56
+
57
+ :param file_path: The path to read the file from.
58
+ :param base64_decode: If true, base64 decode the file. Files are by default base64 encoded when retrieved from
59
+ FileBridge.
60
+ :return: The bytes of the file.
61
+ """
62
+ if file_path in self.__file_cache:
63
+ return self.__file_cache[file_path]
64
+ file_bytes: bytes = FileBridge.read_file(self.user, self.__bridge, file_path, base64_decode)
65
+ self.__file_cache[file_path] = file_bytes
66
+ return file_bytes
67
+
68
+ def write_file(self, file_path: str, file_data: bytes | str) -> None:
69
+ """
70
+ Write a file to FileBridge. The bytes of the given file will be cached so that any subsequent reads of this
71
+ file will not make an additional webservice call.
72
+
73
+ :param file_path: The path to write the file to. If a file already exists at the given path then the file is
74
+ overwritten.
75
+ :param file_data: A string or bytes of the file to be written.
76
+ """
77
+ FileBridge.write_file(self.user, self.__bridge, file_path, file_data)
78
+ self.__file_cache[file_path] = file_data if isinstance(file_data, bytes) else file_data.encode()
79
+
80
+ # Find the directory path to this file and the name of the file. Add the file name to the cached list of
81
+ # files for the directory, assuming we have this directory cached and the file isn't already in it.
82
+ last_slash: int = file_path.rfind("/")
83
+ dir_path: str = file_path[:last_slash]
84
+ file_name: str = file_path[last_slash + 1:]
85
+ if dir_path in self.__dir_cache and file_path not in self.__dir_cache[dir_path]:
86
+ self.__dir_cache[dir_path].append(file_name)
87
+
88
+ def delete_file(self, file_path: str) -> None:
89
+ """
90
+ Delete an existing file in FileBridge. If this file is in the cache, it will also be deleted from the cache.
91
+
92
+ :param file_path: The path to the file to delete.
93
+ """
94
+ FileBridge.delete_file(self.user, self.__bridge, file_path)
95
+ if file_path in self.__file_cache:
96
+ self.__file_cache.pop(file_path)
97
+ if file_path in self.__files:
98
+ self.__files.pop(file_path)
99
+
100
+ def list_directory(self, file_path: str) -> list[str]:
101
+ """
102
+ List the contents of a FileBridge directory. The contents of this directory will be cached so that any
103
+ subsequent lists of this directory will not make an additional webservice call.
104
+
105
+ :param file_path: The path to read the directory from.
106
+ :return: A list of names of files and folders in the directory.
107
+ """
108
+ if file_path in self.__dir_cache:
109
+ return self.__dir_cache[file_path]
110
+ files: list[str] = FileBridge.list_directory(self.user, self.__bridge, file_path)
111
+ self.__dir_cache[file_path] = files
112
+ return files
113
+
114
+ def create_directory(self, file_path: str) -> None:
115
+ """
116
+ Create a new directory in FileBridge. This new directory will be added to the cache as empty so that listing
117
+ the same directory does not make an additional webservice call.
118
+
119
+ :param file_path: The path to create the directory at. If a directory already exists at the given path then an
120
+ exception is raised.
121
+ """
122
+ FileBridge.create_directory(self.user, self.__bridge, file_path)
123
+ # This directory was just created, so we know it's empty.
124
+ self.__dir_cache[file_path] = []
125
+
126
+ def delete_directory(self, file_path: str) -> None:
127
+ """
128
+ Delete an existing directory in FileBridge. If this directory is in the cache, it will also be deleted
129
+ from the cache.
130
+
131
+ :param file_path: The path to the directory to delete.
132
+ """
133
+ FileBridge.delete_directory(self.user, self.__bridge, file_path)
134
+ if file_path in self.__dir_cache:
135
+ self.__dir_cache.pop(file_path)
136
+ if file_path in self.__directories:
137
+ self.__directories.pop(file_path)
138
+
139
+ def is_file(self, file_path: str) -> bool:
140
+ """
141
+ Determine if the given file path points to a file or a directory. This is achieved by trying to call
142
+ list_directory on the given file path. If an exception is thrown, that's because the function was called
143
+ on a file. If no exception is thrown, then we know that this is a directory, and we have now also cached
144
+ the contents of that directory if it wasn't cached already.
145
+
146
+ :param file_path: A file path.
147
+ :return: True if the file path points to a file. False if it points to a directory.
148
+ """
149
+ try:
150
+ self.list_directory(file_path)
151
+ return False
152
+ except Exception:
153
+ return True
154
+
155
+ def move_file(self, move_from: str, move_to: str, old_name: str, new_name: str | None = None) -> None:
156
+ """
157
+ Move a file from one location to another within File Bridge. This is done be reading the file into memory,
158
+ writing a copy of the file in the new location, then deleting the original file.
159
+
160
+ :param move_from: The path to the current location of the file.
161
+ :param move_to: The path to move the file to.
162
+ :param old_name: The current name of the file.
163
+ :param new_name: The name that the file should have after it is moved. if this is not provided, then the new
164
+ name will be the same as the old name.
165
+ """
166
+ if not new_name:
167
+ new_name = old_name
168
+
169
+ # Read the file into memory.
170
+ file_bytes: bytes = self.read_file(move_from + "/" + old_name)
171
+ # Write the file into the new location.
172
+ self.write_file(move_to + "/" + new_name, file_bytes)
173
+ # Delete the file from the old location. We do this last in case the write call fails.
174
+ self.delete_file(move_from + "/" + old_name)
175
+
176
+ def get_file_object(self, file_path: str) -> File:
177
+ """
178
+ Get a File object from a file path. This object can be used to get the contents of the file at this path
179
+ and traverse up the file hierarchy to the directory that the file is contained within.
180
+
181
+ There is no guarantee that this file actually exists within the current file bridge connection when it is
182
+ constructed. If the file doesn't exist, then retrieving its contents will fail.
183
+
184
+ :param file_path: A file path.
185
+ :return: A File object constructed form the given file path.
186
+ """
187
+ if file_path in self.__files:
188
+ return self.__files[file_path]
189
+ file = File(self, file_path)
190
+ self.__files[file_path] = file
191
+ return file
192
+
193
+ def get_directory_object(self, file_path: str) -> Directory | None:
194
+ """
195
+ Get a Directory object from a file path. This object can be used to traverse up and down the file hierarchy
196
+ by going up to the parent directory that this directory is contained within or going down to the contents of
197
+ this directory.
198
+
199
+ There is no guarantee that this directory actually exists within the current file bridge connection when it is
200
+ constructed. If the directory doesn't exist, then retrieving its contents will fail.
201
+
202
+ :param file_path: A file path.
203
+ :return: A Directory object constructed form the given file path.
204
+ """
205
+ if file_path is None:
206
+ return None
207
+ if file_path in self.__directories:
208
+ return self.__directories[file_path]
209
+ directory = Directory(self, file_path)
210
+ self.__directories[file_path] = directory
211
+ return directory
212
+
213
+
214
+ class FileBridgeObject(ABC):
215
+ """
216
+ A FileBridgeObject is either a file or a directory that is contained within file bridge. Every object has a
217
+ name and a parent directory that it is contained within (unless the object is located in the bridge root, in
218
+ which case the parent is None). From the name and the parent, a path can be constructed to that object.
219
+ """
220
+ _handler: FileBridgeHandler
221
+ name: str
222
+ parent: Directory | None
223
+
224
+ def __init__(self, handler: FileBridgeHandler, file_path: str):
225
+ self._handler = handler
226
+
227
+ name, root = split_path(file_path)
228
+ self.name = name
229
+ self.parent = handler.get_directory_object(root)
230
+
231
+ @abstractmethod
232
+ def is_file(self) -> bool:
233
+ """
234
+ :return: True if this object is a file. False if it is a directory.
235
+ """
236
+ pass
237
+
238
+ def get_path(self) -> str:
239
+ """
240
+ :return: The file path that leads to this object.
241
+ """
242
+ if self.parent is None:
243
+ return self.name
244
+ return self.parent.get_path() + "/" + self.name
245
+
246
+
247
+ class File(FileBridgeObject):
248
+ def __init__(self, handler: FileBridgeHandler, file_path: str):
249
+ """
250
+ :param handler: A FileBridgeHandler for the connection that this file came from.
251
+ :param file_path: The path to this file.
252
+ """
253
+ super().__init__(handler, file_path)
254
+
255
+ @property
256
+ def contents(self) -> bytes:
257
+ """
258
+ :return: The bytes of this file.
259
+ This pulls from the cache of this object's related FileBridgeHandler.
260
+ """
261
+ return self._handler.read_file(self.get_path())
262
+
263
+ def is_file(self) -> bool:
264
+ return True
265
+
266
+
267
+ class Directory(FileBridgeObject):
268
+ def __init__(self, handler: FileBridgeHandler, file_path: str):
269
+ """
270
+ :param handler: A FileBridgeHandler for the connection that this directory came from.
271
+ :param file_path: The path to this directory.
272
+ """
273
+ super().__init__(handler, file_path)
274
+
275
+ @property
276
+ def contents(self) -> dict[str, FileBridgeObject]:
277
+ """
278
+ :return: A dictionary of object names to the objects (Files or Directories) contained within this Directory.
279
+ This pulls from the cache of this object's related FileBridgeHandler.
280
+ """
281
+ contents: dict[str, FileBridgeObject] = {}
282
+ path: str = self.get_path()
283
+ for name in self._handler.list_directory(path):
284
+ file_path: str = path + "/" + name
285
+ if self._handler.is_file(file_path):
286
+ contents[name] = self._handler.get_file_object(file_path)
287
+ else:
288
+ contents[name] = self._handler.get_directory_object(file_path)
289
+ return contents
290
+
291
+ def is_file(self) -> bool:
292
+ return False
293
+
294
+ def get_files(self) -> dict[str, File]:
295
+ """
296
+ :return: A mapping of file name to File for every file in this Directory.
297
+ This pulls from the cache of this object's related FileBridgeHandler.
298
+ """
299
+ return {x: y for x, y in self.contents.items() if y.is_file()}
300
+
301
+ def get_directories(self) -> dict[str, Directory]:
302
+ """
303
+ :return: A mapping of directory name to Directory for every directory in this Directory.
304
+ This pulls from the cache of this object's related FileBridgeHandler.
305
+ """
306
+ return {x: y for x, y in self.contents.items() if not y.is_file()}
307
+
308
+
309
+ def split_path(file_path: str) -> (str, str):
310
+ """
311
+ :param file_path: A file path where directories are separated the "/" characters.
312
+ :return: A tuple of two strings that splits the path on its last slash. The first string is the name of the
313
+ file/directory at the given file path and the second string is the location to that file.
314
+ """
315
+ last_slash: int = file_path.rfind("/")
316
+ if last_slash == -1:
317
+ return file_path, None
318
+ return file_path[last_slash + 1:], file_path[:last_slash]
@@ -3,7 +3,7 @@ from typing import Any
3
3
 
4
4
  from sapiopylib.rest.DataMgmtService import DataMgmtServer
5
5
  from sapiopylib.rest.User import SapioUser
6
- from sapiopylib.rest.pojo.CustomReport import ReportColumn, CustomReport
6
+ from sapiopylib.rest.pojo.CustomReport import ReportColumn, CustomReport, CustomReportCriteria, RawReportTerm
7
7
  from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
8
8
 
9
9
 
@@ -13,7 +13,9 @@ class CustomReportUtil:
13
13
  def run_system_report(context: SapioWebhookContext | SapioUser,
14
14
  report_name: str,
15
15
  filters: dict[str, Iterable[Any]] | None = None,
16
- page_limit: int | None = None) -> list[dict[str, Any]]:
16
+ page_limit: int | None = None,
17
+ page_size: int | None = None,
18
+ page_number: int | None = None) -> list[dict[str, Any]]:
17
19
  """
18
20
  Run a system report and return the results of that report as a list of dictionaries for the values of each
19
21
  column in each row.
@@ -27,26 +29,94 @@ class CustomReportUtil:
27
29
  filter on. Only those headers that both the filters and the custom report share will take effect. That is,
28
30
  any filters that have a header name that isn't in the custom report will be ignored.
29
31
  :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages.
32
+ :param page_size: The size of each page of results in the search. If None, the page size is set by the server.
33
+ :param page_number: The page number to start the search from, If None, starts on the first page.
30
34
  :return: The results of the report listed row by row, mapping each cell to the header it is under. The header
31
35
  values in the dicts are the data field names of the columns.
36
+ If two columns in the search have the same data field name but differing data type names, then the
37
+ dictionary key to the value in the column will be "DataTypeName.DataFieldName". For example, if you
38
+ had a Sample column with a data field name of Identifier and a Request column with the same data field name,
39
+ then the dictionary keys for these columns would be Sample.Identifier and Request.Identifier respectively.
32
40
  """
33
- results = CustomReportUtil.__exhaust_system_report(context, report_name, page_limit)
41
+ results: tuple = CustomReportUtil.__exhaust_system_report(context, report_name, page_limit,
42
+ page_size, page_number)
34
43
  columns: list[ReportColumn] = results[0]
35
44
  rows: list[list[Any]] = results[1]
45
+ return CustomReportUtil.__process_results(rows, columns, filters)
36
46
 
37
- ret: list[dict[str, Any]] = []
38
- for row in rows:
39
- row_data: dict[str, Any] = {}
40
- filter_row: bool = False
41
- for value, column in zip(row, columns):
42
- header: str = column.data_field_name
43
- if filters is not None and header in filters and value not in filters.get(header):
44
- filter_row = True
45
- break
46
- row_data.update({header: value})
47
- if filter_row is False:
48
- ret.append(row_data)
49
- return ret
47
+ @staticmethod
48
+ def run_custom_report(context: SapioWebhookContext | SapioUser,
49
+ report_criteria: CustomReportCriteria,
50
+ filters: dict[str, Iterable[Any]] | None = None,
51
+ page_limit: int | None = None,
52
+ page_size: int | None = None,
53
+ page_number: int | None = None) -> list[dict[str, Any]]:
54
+ """
55
+ Run a custom report and return the results of that report as a list of dictionaries for the values of each
56
+ column in each row.
57
+
58
+ Custom reports are constructed by the caller, specifying the report terms and the columns that will be in the
59
+ results. They are like advanced or predefined searches from the system, except they are constructed from
60
+ within the webhook instead of from within the system.
61
+
62
+ :param context: The current webhook context or a user object to send requests from.
63
+ :param report_criteria: The custom report criteria to run.
64
+ :param filters: If provided, filter the results of the report using the given mapping of headers to values to
65
+ filter on. Only those headers that both the filters and the custom report share will take effect. That is,
66
+ any filters that have a header name that isn't in the custom report will be ignored.
67
+ Note that this parameter is only provided for parity with the other run report functions. If you need to
68
+ filter the results of a search, it would likely be more beneficial to have just added a new term to the
69
+ input report criteria that corresponds to the filter.
70
+ :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages.
71
+ :param page_size: The size of each page of results in the search. If None, uses the value from the given report
72
+ criteria. If not None, overwrites the value from the given report criteria.
73
+ :param page_number: The page number to start the search from, If None, uses the value from the given report
74
+ criteria. If not None, overwrites the value from the given report criteria.
75
+ :return: The results of the report listed row by row, mapping each cell to the header it is under. The header
76
+ values in the dicts are the data field names of the columns.
77
+ If two columns in the search have the same data field name but differing data type names, then the
78
+ dictionary key to the value in the column will be "DataTypeName.DataFieldName". For example, if you
79
+ had a Sample column with a data field name of Identifier and a Request column with the same data field name,
80
+ then the dictionary keys for these columns would be Sample.Identifier and Request.Identifier respectively.
81
+ """
82
+ results: tuple = CustomReportUtil.__exhaust_custom_report(context, report_criteria, page_limit,
83
+ page_size, page_number)
84
+ columns: list[ReportColumn] = results[0]
85
+ rows: list[list[Any]] = results[1]
86
+ return CustomReportUtil.__process_results(rows, columns, filters)
87
+
88
+ @staticmethod
89
+ def run_quick_report(context: SapioWebhookContext | SapioUser,
90
+ report_term: RawReportTerm,
91
+ filters: dict[str, Iterable[Any]] | None = None,
92
+ page_limit: int | None = None,
93
+ page_size: int | None = None,
94
+ page_number: int | None = None) -> list[dict[str, Any]]:
95
+ """
96
+ Run a quick report and return the results of that report as a list of dictionaries for the values of each
97
+ column in each row.
98
+
99
+ Quick reports are helpful for cases where you need to query record field values in a more complex manner than
100
+ the data record manager allows, but still simpler than a full-blown custom report. The columns that are returned
101
+ in a quick search are every visible field from the data type that corresponds to the given report term. (Fields
102
+ which are not marked as visible in the data designer will be excluded.)
103
+
104
+ :param context: The current webhook context or a user object to send requests from.
105
+ :param report_term: The raw report term to use for the quick report.
106
+ :param filters: If provided, filter the results of the report using the given mapping of headers to values to
107
+ filter on. Only those headers that both the filters and the custom report share will take effect. That is,
108
+ any filters that have a header name that isn't in the custom report will be ignored.
109
+ :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages.
110
+ :param page_size: The size of each page of results in the search. If None, the page size is set by the server.
111
+ :param page_number: The page number to start the search from, If None, starts on the first page.
112
+ :return: The results of the report listed row by row, mapping each cell to the header it is under. The header
113
+ values in the dicts are the data field names of the columns.
114
+ """
115
+ results: tuple = CustomReportUtil.__exhaust_quick_report(context, report_term, page_limit,
116
+ page_size, page_number)
117
+ columns: list[ReportColumn] = results[0]
118
+ rows: list[list[Any]] = results[1]
119
+ return CustomReportUtil.__process_results(rows, columns, filters)
50
120
 
51
121
  @staticmethod
52
122
  def get_system_report_criteria(context: SapioWebhookContext | SapioUser, report_name: str) -> CustomReport:
@@ -69,22 +139,124 @@ class CustomReportUtil:
69
139
  return report_man.run_system_report_by_name(report_name, 1, 1)
70
140
 
71
141
  @staticmethod
72
- def __exhaust_system_report(context: SapioWebhookContext | SapioUser, report_name: str, page_limit: int | None = None) \
142
+ def __exhaust_system_report(context: SapioWebhookContext | SapioUser,
143
+ report_name: str,
144
+ page_limit: int | None,
145
+ page_size: int | None,
146
+ page_number: int | None) \
147
+ -> tuple[list[ReportColumn], list[list[Any]]]:
148
+ """
149
+ Given a system report, iterate over every page of the report and collect the results
150
+ until there are no remaining pages.
151
+ """
152
+ user: SapioUser = context if isinstance(context, SapioUser) else context.user
153
+ report_man = DataMgmtServer.get_custom_report_manager(user)
154
+
155
+ result = None
156
+ has_next_page: bool = True
157
+ rows: list[list[Any]] = []
158
+ cur_page: int = 1
159
+ while has_next_page and (not page_limit or cur_page < page_limit):
160
+ result = report_man.run_system_report_by_name(report_name, page_size, page_number)
161
+ page_size = result.page_size
162
+ page_number = result.page_number
163
+ has_next_page = result.has_next_page
164
+ rows.extend(result.result_table)
165
+ cur_page += 1
166
+ return result.column_list, rows
167
+
168
+ @staticmethod
169
+ def __exhaust_custom_report(context: SapioWebhookContext | SapioUser,
170
+ report: CustomReportCriteria,
171
+ page_limit: int | None,
172
+ page_size: int | None,
173
+ page_number: int | None) \
174
+ -> tuple[list[ReportColumn], list[list[Any]]]:
175
+ """
176
+ Given a custom report, iterate over every page of the report and collect the results
177
+ until there are no remaining pages.
178
+ """
179
+ user: SapioUser = context if isinstance(context, SapioUser) else context.user
180
+ report_man = DataMgmtServer.get_custom_report_manager(user)
181
+
182
+ result = None
183
+ if page_size is not None:
184
+ report.page_size = page_size
185
+ if page_number is not None:
186
+ report.page_number = page_number
187
+ has_next_page: bool = True
188
+ rows: list[list[Any]] = []
189
+ cur_page: int = 1
190
+ while has_next_page and (not page_limit or cur_page < page_limit):
191
+ result = report_man.run_custom_report(report)
192
+ report.page_size = result.page_size
193
+ report.page_number = result.page_number
194
+ has_next_page = result.has_next_page
195
+ rows.extend(result.result_table)
196
+ cur_page += 1
197
+ return result.column_list, rows
198
+
199
+ @staticmethod
200
+ def __exhaust_quick_report(context: SapioWebhookContext | SapioUser,
201
+ report_term: RawReportTerm,
202
+ page_limit: int | None,
203
+ page_size: int | None,
204
+ page_number: int | None) \
73
205
  -> tuple[list[ReportColumn], list[list[Any]]]:
206
+ """
207
+ Given a quick report, iterate over every page of the report and collect the results
208
+ until there are no remaining pages.
209
+ """
74
210
  user: SapioUser = context if isinstance(context, SapioUser) else context.user
75
211
  report_man = DataMgmtServer.get_custom_report_manager(user)
76
212
 
77
- report = None
78
- page_size: int | None = None
79
- page_number: int | None = None
213
+ result = None
80
214
  has_next_page: bool = True
81
215
  rows: list[list[Any]] = []
82
216
  cur_page: int = 1
83
217
  while has_next_page and (not page_limit or cur_page < page_limit):
84
- report = report_man.run_system_report_by_name(report_name, page_size, page_number)
85
- page_size = report.page_size
86
- page_number = report.page_number
87
- has_next_page = report.has_next_page
88
- rows.extend(report.result_table)
218
+ result = report_man.run_quick_report(report_term, page_size, page_number)
219
+ page_size = result.page_size
220
+ page_number = result.page_number
221
+ has_next_page = result.has_next_page
222
+ rows.extend(result.result_table)
89
223
  cur_page += 1
90
- return report.column_list, rows
224
+ return result.column_list, rows
225
+
226
+ @staticmethod
227
+ def __process_results(rows: list[list[Any]], columns: list[ReportColumn],
228
+ filters: dict[str, Iterable[Any]] | None) -> list[dict[str, Any]]:
229
+ """
230
+ Given the results of a report as a list of row values and the report's columns, combine these lists to
231
+ result in a singular list of dictionaries for each row in the results.
232
+
233
+ If any filter criteria has been provided, also use that to filter the row.
234
+ """
235
+ # It may be the case that two columns have the same data field name but differing data type names.
236
+ # If this occurs, then we need to be able to differentiate these columns in the resulting dictionary.
237
+ prepend_dt: set[str] = set()
238
+ encountered_names: list[str] = []
239
+ for column in columns:
240
+ field_name: str = column.data_field_name
241
+ if field_name in encountered_names:
242
+ prepend_dt.add(field_name)
243
+ else:
244
+ encountered_names.append(field_name)
245
+
246
+ ret: list[dict[str, Any]] = []
247
+ for row in rows:
248
+ row_data: dict[str, Any] = {}
249
+ filter_row: bool = False
250
+ for value, column in zip(row, columns):
251
+ header: str = column.data_field_name
252
+ # If two columns share the same data field name, prepend the data type name of the column to the
253
+ # data field name.
254
+ if header in prepend_dt:
255
+ header = column.data_type_name + "." + header
256
+ if filters is not None and header in filters and value not in filters.get(header):
257
+ filter_row = True
258
+ break
259
+ row_data.update({header: value})
260
+ if filter_row is False:
261
+ ret.append(row_data)
262
+ return ret