cdapython 2.0.0__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.
cdapython/summarize.py ADDED
@@ -0,0 +1,1801 @@
1
+ import json
2
+ import os
3
+ import pandas as pd
4
+ import re
5
+ import tabulate
6
+
7
+ import cda_client
8
+
9
+ from multiprocessing.pool import ApplyResult
10
+
11
+ from cdapython.application_utilities import get_api_url
12
+ from cdapython.discover import columns, release_metadata, tables
13
+ from cdapython.logging_wrappers import get_logger
14
+ from cdapython.validation import normalize_to_list, validate_and_transform_match_filter_list, validate_and_transform_match_from_file_values, validate_parameter_values
15
+
16
+ from cda_client.api.summary import file_summary_endpoint_summary_file_post as summary_file_endpoint
17
+ from cda_client.api.summary import subject_summary_endpoint_summary_subject_post as summary_subject_endpoint
18
+ from cda_client.errors import UnexpectedStatus
19
+ from cda_client.models.client_error import ClientError
20
+ from cda_client.models.internal_error import InternalError
21
+ from cda_client.models.summary_request_body import SummaryRequestBody
22
+
23
+ #############################################################################################################################
24
+ #############################################################################################################################
25
+ # Nomenclature notes:
26
+ #
27
+ # * try to standardize all potential user-facing synonyms for basic database data structures
28
+ # (field, entity, endpoint, cell, value, term, etc.) to "table", "column", "row" and "value".
29
+ #############################################################################################################################
30
+ #############################################################################################################################
31
+
32
+ #############################################################################################################################
33
+ #
34
+ # expand_file_results(): Expand the information from one embedded DataFrame column in a result DataFrame returned by
35
+ # get_file_data() into a 2D table with one row per embdedded DataFrame row, with a `file_id` column
36
+ # prepended to each to identify the parent file record.
37
+ #
38
+ #############################################################################################################################
39
+
40
+ def expand_file_results(
41
+ results_dataframe,
42
+ column_to_expand
43
+ ):
44
+ """
45
+ Given a result DataFrame R returned by get_file_data(), and a column C in R
46
+ that contains DataFrames, return a version of the information in C
47
+ expanded into a 2-dimensional table T, with one row in T for every row in every
48
+ DataFrame in C, and with each row in T also containing the file_id in R that goes with
49
+ that row.
50
+ """
51
+
52
+ log = get_logger()
53
+ id_column_name = 'file_id'
54
+
55
+ if not isinstance( results_dataframe, pd.DataFrame ):
56
+ log.error( f"The 'results_dataframe' parameter must be a pandas DataFrame; you passed in an object of type { type( results_dataframe ) }, which is not that." )
57
+ return
58
+ elif id_column_name not in results_dataframe.columns:
59
+ if 'subject_id' in results_dataframe.columns:
60
+ log.error( f"The results dataframe you passed in does not have the expected '{id_column_name}' column: did you mean to use expand_subject_results() instead?" )
61
+ else:
62
+ log.error( f"The results dataframe you passed in does not have the expected '{id_column_name}' column: cannot collate sub-DataFrame results by file without it." )
63
+ return
64
+ elif not isinstance( column_to_expand, str ):
65
+ log.error( f"The 'column_to_expand' parameter must be a string naming a column that exists in results_dataframe and contains (sub-)DataFrames as values. You passed in a value for 'column_to_expand' which wasn't a string (type {type(column_to_expand)})." )
66
+ return
67
+ elif column_to_expand not in results_dataframe.columns:
68
+ log.error( f"The 'column_to_expand' parameter must be a string naming a column that exists in results_dataframe and contains (sub-)DataFrames as values. You passed in column_to_expand='{column_to_expand}', which isn't the name of a column in the result DataFrame you specified." )
69
+ return
70
+
71
+ return expand_results( results_dataframe=results_dataframe, column_to_expand=column_to_expand, table='file' )
72
+
73
+ #############################################################################################################################
74
+ #
75
+ # expand_subject_results(): Expand the information from one embedded DataFrame column in a result DataFrame returned by
76
+ # get_subject_data() into a 2D table with one row per embdedded DataFrame row, with a `subject_id` column
77
+ # prepended to each to identify the parent subject record.
78
+ #
79
+ #############################################################################################################################
80
+
81
+ def expand_subject_results(
82
+ results_dataframe,
83
+ column_to_expand
84
+ ):
85
+ """
86
+ Given a result DataFrame R returned by get_subject_data(), and a column C in R
87
+ that contains DataFrames, return a version of the information in C
88
+ expanded into a 2-dimensional table T, with one row in T for every row in every
89
+ DataFrame in C, and with each row in T also containing the subject_id in R that goes with
90
+ that row.
91
+ """
92
+
93
+ log = get_logger()
94
+ id_column_name = 'subject_id'
95
+
96
+ if not isinstance( results_dataframe, pd.DataFrame ):
97
+ log.error( f"The 'results_dataframe' parameter must be a pandas DataFrame; you passed in an object of type { type( results_dataframe ) }, which is not that." )
98
+ return
99
+ elif id_column_name not in results_dataframe.columns:
100
+ if 'file_id' in results_dataframe.columns:
101
+ log.error( f"The results dataframe you passed in does not have the expected '{id_column_name}' column: did you mean to use expand_file_results() instead?" )
102
+ else:
103
+ log.error( f"The results dataframe you passed in does not have the expected '{id_column_name}' column: cannot collate sub-DataFrame results by subject without it." )
104
+ return
105
+ elif not isinstance( column_to_expand, str ):
106
+ log.error( f"The 'column_to_expand' parameter must be a string naming a column that exists in results_dataframe and contains (sub-)DataFrames as values. You passed in a value for 'column_to_expand' which wasn't a string (type {type(column_to_expand)})." )
107
+ return
108
+ elif column_to_expand not in results_dataframe.columns:
109
+ log.error( f"The 'column_to_expand' parameter must be a string naming a column that exists in results_dataframe and contains (sub-)DataFrames as values. You passed in column_to_expand='{column_to_expand}', which isn't the name of a column in the result DataFrame you specified." )
110
+ return
111
+
112
+ return expand_results( results_dataframe=results_dataframe, column_to_expand=column_to_expand, table='subject' )
113
+
114
+ #############################################################################################################################
115
+ #
116
+ # expand_results(): Expand the information from one embedded DataFrame column in a result DataFrame returned by
117
+ # get_X_data() into a 2D table with one row per embdedded DataFrame row, with an `X_id` column
118
+ # prepended to each to identify the parent result record.
119
+ #
120
+ #############################################################################################################################
121
+
122
+ def expand_results(
123
+ results_dataframe,
124
+ column_to_expand,
125
+ table
126
+ ):
127
+ """
128
+ Given a result DataFrame R returned by get_data(), and a column C in R
129
+ that contains DataFrames, return a version of the information in C
130
+ expanded into a 2-dimensional table T, with one row in T for every row in every
131
+ DataFrame in C, and with each row in T also containing the ID in R that goes with
132
+ that row.
133
+ """
134
+
135
+ log = get_logger()
136
+ id_column_name = f"{table}_id"
137
+
138
+ expanded_column_data = []
139
+
140
+ for _, result in results_dataframe.iterrows():
141
+ if not isinstance( result[column_to_expand], pd.DataFrame ):
142
+ log.error( f"The 'column_to_expand' parameter must be a string naming a column that exists in results_dataframe and contains (sub-)DataFrames as values. You passed in column_to_expand='{column_to_expand}', which isn't a column that contains DataFrames as values." )
143
+ return
144
+ else:
145
+ for _, dataframe_row in result[column_to_expand].iterrows():
146
+ row_data = {}
147
+ row_data[id_column_name] = result[id_column_name]
148
+ row_data.update( dataframe_row.to_dict() )
149
+ expanded_column_data.append( row_data )
150
+
151
+ return pd.DataFrame( expanded_column_data )
152
+
153
+ #############################################################################################################################
154
+ #
155
+ # intersect_file_results(): Combine DataFrames produced by get_file_data() into one DataFrame describing all the file rows
156
+ # that are present in all input DataFrames.
157
+ #
158
+ #############################################################################################################################
159
+
160
+ def intersect_file_results(
161
+ *result_dfs_to_merge,
162
+ ignore_added_columns=False
163
+ ):
164
+ """
165
+ Combine two or more DataFrames produced by get_file_data() via intersection: merge result data for
166
+ all files present in all input DataFrames.
167
+
168
+ Arguments:
169
+ two or more DataFrames returned by get_file_data()
170
+
171
+ ignore_added_columns ( boolean; optional ):
172
+ Merge only columns from the file table: avoids breakages in
173
+ cases where added extra (non-file) columns can't be merged due
174
+ to differences in how similar but different upstream queries produced
175
+ the results we're trying to merge.
176
+ (Default: False: try to merge file data plus all extra data appearing in
177
+ all input DataFrames.)
178
+
179
+ Returns:
180
+ A pandas.DataFrame containing combined metadata about all file rows that
181
+ appear in all input DataFrames, including by default all associated non-file data
182
+ present in all input DataFrames.
183
+ """
184
+
185
+ return intersect_results( *result_dfs_to_merge, ignore_added_columns=ignore_added_columns, table='file' )
186
+
187
+ #############################################################################################################################
188
+ #
189
+ # intersect_subject_results(): Combine DataFrames produced by get_subject_data() into one DataFrame describing all the subject rows
190
+ # that are present in all input DataFrames.
191
+ #
192
+ #############################################################################################################################
193
+
194
+ def intersect_subject_results(
195
+ *result_dfs_to_merge,
196
+ ignore_added_columns=False
197
+ ):
198
+ """
199
+ Combine two or more DataFrames produced by get_subject_data() via intersection: merge result data for
200
+ all subjects present in all input DataFrames.
201
+
202
+ Arguments:
203
+ two or more DataFrames returned by get_subject_data()
204
+
205
+ ignore_added_columns ( boolean; optional ):
206
+ Merge only columns from the subject table: avoids breakages in
207
+ cases where added extra (non-subject) columns can't be merged due
208
+ to differences in how similar but different upstream queries produced
209
+ the results we're trying to merge.
210
+ (Default: False: try to merge subject data plus all extra data appearing in
211
+ all input DataFrames.)
212
+
213
+ Returns:
214
+ A pandas.DataFrame containing combined metadata about all subject rows that
215
+ appear in all input DataFrames, including by default all associated non-subject data
216
+ present in all input DataFrames.
217
+ """
218
+
219
+ return intersect_results( *result_dfs_to_merge, ignore_added_columns=ignore_added_columns, table='subject' )
220
+
221
+ #############################################################################################################################
222
+ #
223
+ # intersect_results(): Compute the intersection of two or more result DataFrames returned by get_data() from the same endpoint.
224
+ #
225
+ # when merging each (top-level) column for each result row:
226
+ #
227
+ # merging str, int, bool values :
228
+ # * preserve first-seen values
229
+ # --> complain (should never happen) if clashes are ever observed
230
+ # home-table columns need not be present in all of the DataFrames to be merged since
231
+ # all target values are guaranteed inferrable from at least one of the
232
+ # other DataFrames to merge because this function computes an _intersection_
233
+ # of home-table records: every value in any home-table column included in any of the
234
+ # constitutent DataFrames is guaranteed to be available somewhere
235
+ # /for all target records/ because all target records are present in all
236
+ # input DataFrames by definition, including in particular the DataFrame
237
+ # containing values for that column
238
+ #
239
+ # merging list values :
240
+ # * some (foreign column value summary) list contents can be filtered by upstream
241
+ # queries and so may no longer be representative of the merged data if just
242
+ # copied into merged results via union heedless of context
243
+ # --> error if any list describing one foreign file, subject or project column
244
+ # that is present in one result set is missing from any of the other result sets
245
+ # potential gaps cannot be filled in the above case
246
+ # * if no error, then iteratively and pairwise: make lists into sets, take union, re-listify
247
+ #
248
+ # merging (sub-)DataFrames :
249
+ # * some sub-DataFrame contents (project_data, subject_data, file_data, external_reference_data)
250
+ # can be row-filtered by upstream queries and so may no longer be representative of the merged
251
+ # data if just copied into merged results via union heedless of context
252
+ # --> error if any sub-DataFrame in { file_data, project_data, subject_data, external_reference_data }
253
+ # that is present in one result set is missing from any of the other result sets
254
+ # --> error if the list of columns for ANY NONEMPTY sub-DataFrame in one result set
255
+ # does not match the list of columns for NONEMPTY DataFrame values in any
256
+ # same-named sibling column in any other result set
257
+ # potential gaps cannot be filled in either of the above cases
258
+ # * error for file_data, subject_data, project_data sub-DataFrames if file_id,
259
+ # subject_id, project_id, resp., not present in NONEMPTY DataFrame cell values:
260
+ # cannot unambiguously reconstitute results given the potential presence of row
261
+ # filtering and/or possibly identical sets of file/subject/project records
262
+ # distinguishable from one another only by ID
263
+ # * if no error, then deduplicate row records across all input DataFrames and return unioned result
264
+ #
265
+ # ----------------------------------------------------------------------------------------
266
+ #
267
+ # non-list, non-DataFrame result columns can have mixed types -- e.g. ints (or any non-null
268
+ # values that aren't lists or DataFrames) mixed with str ('<NA>' null codes for display)
269
+ #
270
+ # make it an error to filter twice on the same column via match_all
271
+ #
272
+ # any number (>= 2) of dataframes can be combined in one call
273
+ #
274
+ # add ignore_added_columns flag to merge just the core-table columns
275
+ #
276
+ # Result column ordering:
277
+ #
278
+ # {table} default, minus any excluded columns
279
+ # 'data_source' if present
280
+ # foreign list columns in first-seen order
281
+ # all foreign sub-DataFrame columns in first-seen order except the last two:
282
+ # upstream_identifiers_data if present
283
+ # external_reference_data if present
284
+ #
285
+ #############################################################################################################################
286
+
287
+ def intersect_results(
288
+ *result_dfs_to_merge,
289
+ ignore_added_columns=False,
290
+ table=None
291
+ ):
292
+ """
293
+ Help me help you. Help text.
294
+ """
295
+
296
+ log = get_logger()
297
+
298
+ # To do: move validation to validation.py
299
+
300
+ if table is None:
301
+ log.error( "'table' parameter cannot be omitted." )
302
+ return
303
+ elif table not in { 'file', 'subject' }:
304
+ log.error( f"'table' parameter must be one of {{ 'file', 'subject' }}. You specified '{table}', which is neither." )
305
+ return
306
+ elif len( result_dfs_to_merge ) < 2:
307
+ log.error( 'You need to specify at least two result DataFrames to be merged.' )
308
+ return
309
+
310
+ # Cache CDA table and column metadata from the API for downstream reuse without further
311
+ # network disturbance. The data structure coming back from columns() is a DataFrame
312
+ # with columns [ 'table', 'column', 'data_type', 'nullable', 'description' ].
313
+
314
+ cached_column_metadata = columns()
315
+
316
+ # Result column ordering:
317
+ #
318
+ # {table} default, minus any excluded columns
319
+ # 'data_source' if present
320
+ # foreign list columns in first-seen order
321
+ # all foreign sub-DataFrame columns in first-seen order except the last two:
322
+ # upstream_identifiers_data if present
323
+ # external_reference_data if present
324
+
325
+ # Store the default column ordering for {table} as provided by the columns() function,
326
+ # so all cdapython interfaces always display the same data in the same way
327
+ # by default. In this case, we'll use this ordering to guide construction of
328
+ # the {table} portion of the output we return.
329
+
330
+ source_table_columns_in_order = list()
331
+
332
+ for row_index, column_record in cached_column_metadata.iterrows():
333
+ if column_record['table'] == table:
334
+ # Remember the order in which columns() delivered the {table}'s columns.
335
+ source_table_columns_in_order.append( column_record['column'] )
336
+ # Gracefully handle the virtual 'data_source' column generated by cdapython during
337
+ # its initial data fetches.
338
+ source_table_columns_in_order.append( 'data_source' )
339
+ # Track which of {table}'s columns we actually see.
340
+ seen_source_table_columns = set()
341
+
342
+ # For the rest of the columns we might encounter in our input DataFrames:
343
+
344
+ # * if the column name exists in cached_column_metadata, we process it
345
+ # as a list of values from a foreign table (as generated according to the
346
+ # user's upstream query).
347
+ foreign_table_value_list_order = list()
348
+ # Save value-list data as we go.
349
+ foreign_table_value_list_data_by_column_and_id = dict()
350
+
351
+ # * If the column name is unknown to cached_column_metadata, we assume it's a
352
+ # DataFrame column created by cdapython containing covariant-grouped tabular
353
+ # results (again, as generated according to the user's upstream query).
354
+ foreign_table_df_order = list()
355
+ # Track column lists for (nonempty) sub-DataFrame values.
356
+ foreign_table_df_columns = dict()
357
+ # Save DataFrame-row data as we go.
358
+ foreign_table_df_row_data_by_column_and_id = dict()
359
+
360
+ # We make sure these are always at the end of our returned results, if present.
361
+ seen_upstream_identifiers_data = False
362
+ seen_external_reference_data = False
363
+
364
+ # Make sure {table}_id is present, or we can't merge.
365
+ main_id_column = f"{table}_id"
366
+ if main_id_column not in result_dfs_to_merge[0].columns:
367
+ log.error( f"Column '{main_id_column}' must be present in all input DataFrames." )
368
+ return
369
+
370
+ # Track all result columns which, if present anywhere, must be present everywhere.
371
+ must_see_everywhere = { main_id_column }
372
+
373
+ # Scan the list of columns in the first DataFrame the user sent, classify each,
374
+ # and initialize relevant trackers.
375
+ source_table_data_by_column_and_id = dict()
376
+ for column_name in result_dfs_to_merge[0].columns:
377
+ if column_name in source_table_columns_in_order:
378
+ # This is a column (or virtual column a la 'data_source') from {table}.
379
+ seen_source_table_columns.add( column_name )
380
+ source_table_data_by_column_and_id[column_name] = dict()
381
+ elif not ignore_added_columns:
382
+ if column_name in cached_column_metadata['column'].unique():
383
+ # This is a foreign-table value list.
384
+ must_see_everywhere.add( column_name )
385
+ foreign_table_value_list_order.append( column_name )
386
+ foreign_table_value_list_data_by_column_and_id[column_name] = dict()
387
+ else:
388
+ # This is a DataFrame column created by cdapython containing covariant-grouped tabular results.
389
+ foreign_table_df_columns[column_name] = list()
390
+ foreign_table_df_row_data_by_column_and_id[column_name] = dict()
391
+ if column_name == 'upstream_identifiers_data':
392
+ seen_upstream_identifiers_data = True
393
+ elif column_name == 'external_reference_data':
394
+ must_see_everywhere.add( column_name )
395
+ seen_external_reference_data = True
396
+ else:
397
+ foreign_table_df_order.append( column_name )
398
+ if column_name in { 'external_reference_data', 'file_data', 'project_data', 'subject_data' }:
399
+ must_see_everywhere.add( column_name )
400
+
401
+ target_record_ids = set( result_dfs_to_merge[0][main_id_column].unique() )
402
+
403
+ for result_df in result_dfs_to_merge:
404
+ target_record_ids = target_record_ids & set( result_df[main_id_column].unique() )
405
+
406
+ # Save result data as we go.
407
+
408
+ for result_df in result_dfs_to_merge:
409
+ for column_name in must_see_everywhere:
410
+ # Make sure every DataFrame has the columns we need to produce a merged result set.
411
+ if column_name not in result_df.columns:
412
+ if column_name == main_id_column:
413
+ log.error( f"Column '{main_id_column}' must be present in all input DataFrames." )
414
+ else:
415
+ log.error( f"If the optional 'ignore_added_columns' flag is not set to True, then all input DataFrames must have compatible columns. '{column_name}' is present in some but not all of your input DataFrames: cannot continue." )
416
+ return
417
+ for row_index, result_record in result_df.iterrows():
418
+ main_id = result_record[main_id_column]
419
+ if main_id in target_record_ids:
420
+ for column_name in list( result_record.index ):
421
+ if column_name in source_table_columns_in_order:
422
+ # We might encounter some {table} columns that aren't in all input DataFrames.
423
+ # This is fine. See the discussion before this function's defline.
424
+ if column_name not in seen_source_table_columns:
425
+ seen_source_table_columns.add( column_name )
426
+ source_table_data_by_column_and_id[column_name] = dict()
427
+ if main_id in source_table_data_by_column_and_id[column_name]:
428
+ new_value = result_record[column_name]
429
+ if new_value != source_table_data_by_column_and_id[column_name][main_id]:
430
+ log.error( f"Unexpectedly encountered different clashing values across different input DataFrames for {table} record '{main_id}', column '{column_name}': '{source_table_data_by_column_and_id[column_name][main_id]}' vs. {new_value}'. Cannot continue." )
431
+ return
432
+ else:
433
+ source_table_data_by_column_and_id[column_name][main_id] = result_record[column_name]
434
+ elif not ignore_added_columns:
435
+ if column_name in cached_column_metadata['column'].unique():
436
+ # This is a foreign-table value list.
437
+ if column_name not in must_see_everywhere:
438
+ # We need these to be in all input DataFrames.
439
+ log.error( f"If the optional 'ignore_added_columns' flag is not set to True, then all input DataFrames must have compatible columns. '{column_name}' is present in some but not all of your input DataFrames: cannot continue." )
440
+ return
441
+ if main_id not in foreign_table_value_list_data_by_column_and_id[column_name]:
442
+ foreign_table_value_list_data_by_column_and_id[column_name][main_id] = set()
443
+ foreign_table_value_list_data_by_column_and_id[column_name][main_id] = foreign_table_value_list_data_by_column_and_id[column_name][main_id] | set( result_record[column_name] )
444
+ else:
445
+ # This is a DataFrame column created by cdapython containing covariant-grouped tabular results.
446
+ if column_name in { 'external_reference_data', 'file_data', 'project_data', 'subject_data' } and column_name not in must_see_everywhere:
447
+ # We need these to be in all input DataFrames, or none.
448
+ log.error( f"If the optional 'ignore_added_columns' flag is not set to True, then all input DataFrames must have compatible columns. '{column_name}' is present in some but not all of your input DataFrames: cannot continue." )
449
+ return
450
+ elif column_name not in foreign_table_df_columns:
451
+ foreign_table_df_order.append( column_name )
452
+ foreign_table_df_columns[column_name] = list()
453
+ foreign_table_df_row_data_by_column_and_id[column_name] = dict()
454
+ if len( foreign_table_df_columns[column_name] ) == 0 and len( result_record[column_name].columns ) > 0:
455
+ for sub_column_name in list( result_record[column_name].columns ):
456
+ foreign_table_df_columns[column_name].append( sub_column_name )
457
+ if column_name in { 'file_data', 'project_data', 'subject_data' }:
458
+ target_id_column = re.sub( r'_data$', r'', column_name ) + '_id'
459
+ if target_id_column not in foreign_table_df_columns[column_name]:
460
+ log.error( f"If the optional 'ignore_added_columns' flag is not set to True and your input DataFrames contain 'file_data', 'project_data' or 'subject_data' columns, the DataFrames in those columns must have 'file_id', 'project_id' and 'subject_id' columns, respectively, or we cannot merge the results." )
461
+ return
462
+ elif len( result_record[column_name].columns ) > 0:
463
+ if list( result_record[column_name].columns ) != foreign_table_df_columns[column_name]:
464
+ log.error( f"If the optional 'ignore_added_columns' flag is not set to True, then all input DataFrames must have compatible columns. '{column_name}' is present in multiple input DataFrames, but the columns present in '{column_name}' (sub-)DataFrames do not match across all (top-level) input DataFrames." )
465
+ return
466
+ if main_id not in foreign_table_df_row_data_by_column_and_id[column_name]:
467
+ foreign_table_df_row_data_by_column_and_id[column_name][main_id] = pd.DataFrame()
468
+ if len( result_record[column_name].columns ) > 0:
469
+ if len( foreign_table_df_row_data_by_column_and_id[column_name][main_id].columns ) == 0:
470
+ foreign_table_df_row_data_by_column_and_id[column_name][main_id] = result_record[column_name].copy()
471
+ else:
472
+ results_with_dupes = pd.concat( [ foreign_table_df_row_data_by_column_and_id[column_name][main_id], result_record[column_name] ], ignore_index = True )
473
+ # Evades 'unhashable type: list' error when trying to deduplicate rows whose cells contain list values.
474
+ foreign_table_df_row_data_by_column_and_id[column_name][main_id] = results_with_dupes.loc[ results_with_dupes.astype(str).drop_duplicates().index ]
475
+
476
+ if not ignore_added_columns:
477
+ if seen_upstream_identifiers_data:
478
+ foreign_table_df_order.append( 'upstream_identifiers_data' )
479
+ if seen_external_reference_data:
480
+ foreign_table_df_order.append( 'external_reference_data' )
481
+
482
+ # Build result DataFrame.
483
+
484
+ merged_result_df = pd.DataFrame()
485
+
486
+ main_id_list = sorted( target_record_ids )
487
+
488
+ for column_name in source_table_columns_in_order:
489
+ if column_name in seen_source_table_columns:
490
+ if column_name == main_id_column:
491
+ merged_result_df[column_name] = main_id_list
492
+ else:
493
+ merged_result_df[column_name] = [ source_table_data_by_column_and_id[column_name][main_id] for main_id in main_id_list ]
494
+
495
+ if not ignore_added_columns:
496
+ for column_name in foreign_table_value_list_order:
497
+ merged_result_df[column_name] = [ sorted( foreign_table_value_list_data_by_column_and_id[column_name][main_id] ) for main_id in main_id_list ]
498
+ for column_name in foreign_table_df_order:
499
+ merged_result_df[column_name] = [ foreign_table_df_row_data_by_column_and_id[column_name][main_id] for main_id in main_id_list ]
500
+
501
+ return merged_result_df
502
+
503
+ #############################################################################################################################
504
+ #
505
+ # summarize_files(): Get a report describing columns of interest in the CDA file table, summarizing column values over
506
+ # all files matching user-supplied query filters. Optionally add columns from other tables, which are
507
+ # summarized across all of their own rows that are directly related to files matching the given filters.
508
+ #
509
+ #############################################################################################################################
510
+
511
+ def summarize_files(
512
+ *,
513
+ match_all=None,
514
+ match_any=None,
515
+ match_from_file={ 'input_file': '', 'input_column': '', 'cda_column_to_match': '' },
516
+ data_source=None,
517
+ add_columns=None,
518
+ exclude_columns=None,
519
+ return_data_as='',
520
+ output_file=''
521
+ ):
522
+ """
523
+ For a set of CDA file rows that all match a user-specified set of filters --
524
+ "result rows" -- get a report showing counts of values present in that
525
+ set of rows, profiled across (user-modifiable) columns of interest.
526
+
527
+ Arguments:
528
+ match_all ( string or list of strings; optional ):
529
+ One or more conditions, expressed as filter strings (see below),
530
+ ALL of which must be met by all result rows.
531
+
532
+ match_any ( string or list of strings; optional ):
533
+ One or more conditions, expressed as filter strings (see below),
534
+ AT LEAST ONE of which must be met by all result rows.
535
+
536
+ match_from_file ( 3-element dictionary of strings; optional ):
537
+ A dictionary containing 3 named elements:
538
+ 1. 'input_file': The name of a (local) TSV file (with column names in its first row)
539
+ 2. 'input_column': The name of a column in that TSV
540
+ 3. 'cda_column_to_match': The name of a CDA column
541
+ Restrict result rows to those where the value of the given CDA
542
+ column matches at least one value from the given column
543
+ in the given TSV file.
544
+
545
+ data_source ( string or list of strings; optional ):
546
+ Restrict results to those deriving from the given upstream
547
+ data source(s). Current valid values are 'GDC', 'IDC', 'PDC',
548
+ 'CDS' and 'ICDC'. (Default: no filter.)
549
+
550
+ add_columns ( string or list of strings; optional ):
551
+ One or more columns from a second table to add to summary output.
552
+
553
+ exclude_columns ( string or list of strings; optional ):
554
+ One or more columns to remove from summary output.
555
+
556
+ return_data_as ( string; optional: 'dataframe_list' or 'dict' or 'json' ):
557
+ Specify how to return results: as a list of pandas DataFrames, as a
558
+ Python dictionary, or as output written to a JSON file named by the user.
559
+ If this argument is omitted, then for each DataFrame that would have
560
+ been returned by the 'dataframe_list' option, a table will be
561
+ pretty-printed to the standard output stream (and nothing will be returned).
562
+
563
+ output_file( string; optional ):
564
+ If return_data_as='json' is specified, output_file should contain a
565
+ resolvable path to a file into which JSON-formatted results will be
566
+ written.
567
+
568
+ Filter strings:
569
+ Filter strings are expressions of the form "COLUMN_NAME OP VALUE"
570
+ (note in particular that the whitespace surrounding OP is required),
571
+ where
572
+
573
+ COLUMN_NAME is a searchable CDA column (see the columns() function
574
+ for details)
575
+
576
+ OP is one of: < <= > >= = !=
577
+
578
+ VALUE is a particular value of whatever data type is stored
579
+ in COLUMN_NAME (see the columns() function for details), or
580
+ the special keyword NULL, indicating the filter should match
581
+ missing (null) values in COLUMN_NAME.
582
+
583
+ Operators = and != will work on numeric, boolean and string VALUEs.
584
+
585
+ Operators < <= > >= will only work on numeric VALUEs.
586
+
587
+ Users can require partial matches to string VALUEs by adding * to either or
588
+ both ends. For example:
589
+
590
+ primary_disease_type = *duct*
591
+ sex = F*
592
+ size < 100
593
+
594
+ String VALUEs need not be quoted inside of filter strings. For example, to include
595
+ the filters specified just above in the `match_all` argument, we can write:
596
+
597
+ summarize_files( match_all=[ 'primary_disease_type = *duct*', 'sex = F*', 'size < 100' ] )
598
+
599
+ NULL is a special VALUE which can be used to match missing data. For
600
+ example, to get a summary report for CDA files where the `access` field
601
+ is missing data, we can write:
602
+
603
+ summarize_files( match_all=[ 'access = NULL' ] )
604
+
605
+ Returns:
606
+
607
+ list of pandas DataFrames, with one DataFrame for each summarized column,
608
+ enumerating counts (or statistically summarizing unbounded numeric values) over all
609
+ of that column's data values appearing in any CDA file rows that match the
610
+ user-specified filter criteria (the 'result rows'). Two DataFrames in this list --
611
+ 'number_of_matching_files' and 'number_of_subjects_related_to_matching_files' --
612
+ will contain integers representing the total number of result file rows and the
613
+ total number of related subjects, respectively. Every other DataFrame in the list
614
+ will be titled with a CDA column name and will contain value counts or statistical
615
+ summaries for that column as filtered by the result row set.
616
+
617
+ OR Python dictionary enumerating counts of all data values for each summarized column
618
+ (or a statistical summary of those data values, in the case of unbounded numeric data)
619
+ across all CDA file rows that match the user-specified filter criteria (the 'result rows').
620
+ Two summary keys in this dictionary -- 'number_of_matching_files' and
621
+ 'number_of_subjects_related_to_matching_files' -- will point to integers representing
622
+ the total number of result file rows and the total number of associated subject rows,
623
+ respectively. Every other key in the dictionary will contain a CDA column name; every
624
+ dictionary value will itself be a dictionary either enumerating observed counts of all
625
+ values appearing in that column as filtered by the result row set, or encoding a
626
+ statistical summary of those values in the case of unbounded numeric data.
627
+
628
+ OR JSON-formatted text representing the same structure as the `return_data_as='dict'`
629
+ option, written to `output_file`.
630
+
631
+ OR returns nothing, but displays a series of tables to standard output
632
+ describing the same data returned by the other `return_data_as` options.
633
+
634
+ And yes, we know how those first two paragraphs look. We apologize to the entire English language.
635
+ """
636
+
637
+ return summarize( table='file', match_all=match_all, match_any=match_any, match_from_file=match_from_file, data_source=data_source, add_columns=add_columns, exclude_columns=exclude_columns, return_data_as=return_data_as, output_file=output_file )
638
+
639
+ #############################################################################################################################
640
+ #
641
+ # END summarize_files()
642
+ #
643
+ #############################################################################################################################
644
+
645
+ #############################################################################################################################
646
+ #
647
+ # summarize_subjects(): Get a report describing columns of interest in the CDA subject table, summarizing column values over
648
+ # all subjects matching user-supplied query filters. Optionally add columns from other tables, which are
649
+ # summarized across all of their own rows that are directly related to subjects matching the given filters.
650
+ #
651
+ #############################################################################################################################
652
+
653
+ def summarize_subjects(
654
+ *,
655
+ match_all=None,
656
+ match_any=None,
657
+ match_from_file={ 'input_file': '', 'input_column': '', 'cda_column_to_match': '' },
658
+ data_source=None,
659
+ add_columns=None,
660
+ exclude_columns=None,
661
+ return_data_as='',
662
+ output_file=''
663
+ ):
664
+ """
665
+ For a set of CDA subject rows that all match a user-specified set of filters --
666
+ "result rows" -- get a report showing counts of values present in that
667
+ set of rows, profiled across (user-modifiable) columns of interest.
668
+
669
+ Arguments:
670
+ match_all ( string or list of strings; optional ):
671
+ One or more conditions, expressed as filter strings (see below),
672
+ ALL of which must be met by all result rows.
673
+
674
+ match_any ( string or list of strings; optional ):
675
+ One or more conditions, expressed as filter strings (see below),
676
+ AT LEAST ONE of which must be met by all result rows.
677
+
678
+ match_from_file ( 3-element dictionary of strings; optional ):
679
+ A dictionary containing 3 named elements:
680
+ 1. 'input_file': The name of a (local) TSV file (with column names in its first row)
681
+ 2. 'input_column': The name of a column in that TSV
682
+ 3. 'cda_column_to_match': The name of a CDA column
683
+ Restrict result rows to those where the value of the given CDA
684
+ column matches at least one value from the given column
685
+ in the given TSV file.
686
+
687
+ data_source ( string or list of strings; optional ):
688
+ Restrict results to those deriving from the given upstream
689
+ data source(s). Current valid values are 'GDC', 'IDC', 'PDC',
690
+ 'CDS' and 'ICDC'. (Default: no filter.)
691
+
692
+ add_columns ( string or list of strings; optional ):
693
+ One or more columns from a second table to add to summary output.
694
+
695
+ exclude_columns ( string or list of strings; optional ):
696
+ One or more columns to remove from summary output.
697
+
698
+ return_data_as ( string; optional: 'dataframe_list' or 'dict' or 'json' ):
699
+ Specify how to return results: as a list of pandas DataFrames, as a
700
+ Python dictionary, or as output written to a JSON file named by the user.
701
+ If this argument is omitted, then for each DataFrame that would have
702
+ been returned by the 'dataframe_list' option, a table will be
703
+ pretty-printed to the standard output stream (and nothing will be returned).
704
+
705
+ output_file( string; optional ):
706
+ If return_data_as='json' is specified, output_file should contain a
707
+ resolvable path to a file into which JSON-formatted results will be written.
708
+
709
+ Filter strings:
710
+ Filter strings are expressions of the form "COLUMN_NAME OP VALUE"
711
+ (note in particular that the whitespace surrounding OP is required),
712
+ where
713
+
714
+ COLUMN_NAME is a searchable CDA column (see the columns() function
715
+ for details)
716
+
717
+ OP is one of: < <= > >= = !=
718
+
719
+ VALUE is a particular value of whatever data type is stored
720
+ in COLUMN_NAME (see the columns() function for details), or
721
+ the special keyword NULL, indicating the filter should match
722
+ missing (null) values in COLUMN_NAME.
723
+
724
+ Operators = and != will work on numeric, boolean and string VALUEs.
725
+
726
+ Operators < <= > >= will only work on numeric VALUEs.
727
+
728
+ Users can require partial matches to string VALUEs by adding * to either or
729
+ both ends. For example:
730
+
731
+ primary_disease_type = *duct*
732
+ sex = F*
733
+ size < 100
734
+
735
+ String VALUEs need not be quoted inside of filter strings. For example, to include
736
+ the filters specified just above in the `match_all` argument, we can write:
737
+
738
+ summarize_subjects( match_all=[ 'primary_disease_type = *duct*', 'sex = F*' ] )
739
+
740
+ NULL is a special VALUE which can be used to match missing data. For
741
+ example, to get a summary report for CDA subjects where the `year_of_birth` field
742
+ is missing data, we can write:
743
+
744
+ summarize_subjects( match_all=[ 'year_of_birth = NULL' ] )
745
+
746
+ Returns:
747
+
748
+ list of pandas DataFrames, with one DataFrame for each summarized column,
749
+ enumerating counts (or statistically summarizing unbounded numeric values) over all
750
+ of that column's data values appearing in any CDA subject rows that match the
751
+ user-specified filter criteria (the 'result rows'). Two DataFrames in this list --
752
+ 'number_of_matching_subjects' and 'number_of_files_related_to_matching_subjects' --
753
+ will contain integers representing the total number of result subject rows and the
754
+ total number of related files, respectively. Every other DataFrame in the list
755
+ will be titled with a CDA column name and will contain value counts or statistical
756
+ summaries for that column as filtered by the result row set.
757
+
758
+ OR Python dictionary enumerating counts of all data values for each summarized column
759
+ (or a statistical summary of those data values, in the case of unbounded numeric data)
760
+ across all CDA subject rows that match the user-specified filter criteria (the 'result rows').
761
+ Two summary keys in this dictionary -- 'number_of_matching_subjects' and
762
+ 'number_of_files_related_to_matching_subjects' -- will point to integers representing
763
+ the total number of result subject rows and the total number of associated file rows,
764
+ respectively. Every other key in the dictionary will contain a CDA column name; every
765
+ dictionary value will itself be a dictionary either enumerating observed counts of all
766
+ values appearing in that column as filtered by the result row set, or encoding a
767
+ statistical summary of those values in the case of unbounded numeric data.
768
+
769
+ OR JSON-formatted text representing the same structure as the `return_data_as='dict'`
770
+ option, written to `output_file`.
771
+
772
+ OR returns nothing, but displays a series of tables to standard output
773
+ describing the same data returned by the other `return_data_as` options.
774
+
775
+ And yes, we know how those first two paragraphs look. We apologize to the entire English language.
776
+ """
777
+
778
+ return summarize( table='subject', match_all=match_all, match_any=match_any, match_from_file=match_from_file, data_source=data_source, add_columns=add_columns, exclude_columns=exclude_columns, return_data_as=return_data_as, output_file=output_file )
779
+
780
+ #############################################################################################################################
781
+ #
782
+ # END summarize_subjects()
783
+ #
784
+ #############################################################################################################################
785
+
786
+ #############################################################################################################################
787
+ #
788
+ # summarize(): Get a report describing columns of interest in a user-specified CDA table, summarizing column values over
789
+ # all rows matching user-supplied query filters. Optionally add columns from other tables, which are
790
+ # summarized across all of their own rows that are directly related to rows from the main table that
791
+ # match the given filters.
792
+ #
793
+ #############################################################################################################################
794
+
795
+ def summarize(
796
+ table='',
797
+ *,
798
+ match_all=None,
799
+ match_any=None,
800
+ match_from_file={ 'input_file': '', 'input_column': '', 'cda_column_to_match': '' },
801
+ data_source=None,
802
+ add_columns=None,
803
+ exclude_columns=None,
804
+ return_data_as='',
805
+ output_file=''
806
+ ):
807
+ """
808
+ For a set of rows in a user-specified table that all match a user-specified set of
809
+ filters -- "result rows" -- get a report showing counts of values present in that
810
+ set of rows, profiled across (user-modifiable) columns of interest.
811
+
812
+ Arguments:
813
+ table ( string; required: 'file' or 'subject' ):
814
+ The CDA table to be queried and summarized.
815
+
816
+ match_all ( string or list of strings; optional ):
817
+ One or more conditions, expressed as filter strings (see below),
818
+ ALL of which must be met by all result rows.
819
+
820
+ match_any ( string or list of strings; optional ):
821
+ One or more conditions, expressed as filter strings (see below),
822
+ AT LEAST ONE of which must be met by all result rows.
823
+
824
+ match_from_file ( 3-element dictionary of strings; optional ):
825
+ A dictionary containing 3 named elements:
826
+ 1. 'input_file': The name of a (local) TSV file (with column names in its first row)
827
+ 2. 'input_column': The name of a column in that TSV
828
+ 3. 'cda_column_to_match': The name of a CDA column
829
+ Restrict result rows to those where the value of the given CDA
830
+ column matches at least one value from the given column
831
+ in the given TSV file.
832
+
833
+ data_source ( string or list of strings; optional ):
834
+ Restrict results to those deriving from the given upstream
835
+ data source(s). Current valid values are 'GDC', 'IDC', 'PDC',
836
+ 'CDS' and 'ICDC'. (Default: no filter.)
837
+
838
+ add_columns ( string or list of strings; optional ):
839
+ One or more columns from a second table to add to summary output for `table`.
840
+
841
+ exclude_columns ( string or list of strings; optional ):
842
+ One or more columns to remove from summary output.
843
+
844
+ return_data_as ( string; optional: 'dataframe_list' or 'dict' or 'json' ):
845
+ Specify how summarize() should return results: as a list
846
+ of pandas DataFrames, as a Python dictionary, or as output written to a
847
+ JSON file named by the user. If this argument is omitted,
848
+ summarize() will, for each DataFrame that would have been returned
849
+ by the 'dataframe_list' option, print a table to the standard output
850
+ stream (and nothing will be returned).
851
+
852
+ output_file( string; optional ):
853
+ If return_data_as='json' is specified, output_file should contain a
854
+ resolvable path to a file into which summarize() will write
855
+ JSON-formatted results.
856
+
857
+ Filter strings:
858
+ Filter strings are expressions of the form "COLUMN_NAME OP VALUE"
859
+ (note in particular that the whitespace surrounding OP is required),
860
+ where
861
+
862
+ COLUMN_NAME is a searchable CDA column (see the columns() function
863
+ for details)
864
+
865
+ OP is one of: < <= > >= = !=
866
+
867
+ VALUE is a particular value of whatever data type is stored
868
+ in COLUMN_NAME (see the columns() function for details), or
869
+ the special keyword NULL, indicating the filter should match
870
+ missing (null) values in COLUMN_NAME.
871
+
872
+ Operators = and != will work on numeric, boolean and string VALUEs.
873
+
874
+ Operators < <= > >= will only work on numeric VALUEs.
875
+
876
+ Users can require partial matches to string VALUEs by adding * to either or
877
+ both ends. For example:
878
+
879
+ primary_disease_type = *duct*
880
+ sex = F*
881
+
882
+ String VALUEs need not be quoted inside of filter strings. For example, to include
883
+ the filters specified just above in the `match_all` argument, we can write:
884
+
885
+ summarize( table='subject', match_all=[ 'primary_disease_type = *duct*', 'sex = F*' ] )
886
+
887
+ NULL is a special VALUE which can be used to match missing data. For
888
+ example, to get a count summary for rows where the `sex` field is missing data,
889
+ we can write:
890
+
891
+ summarize( table='subject', match_all=[ 'sex = NULL' ] )
892
+
893
+ Returns:
894
+
895
+ list of pandas DataFrames, with one DataFrame for each summarized column,
896
+ enumerating counts (or statistically summarizing unbounded numeric values) over all
897
+ of that column's data values appearing in any rows that match the
898
+ user-specified filter criteria (the 'result rows'). Two of four possible special
899
+ DataFrames in this list ('number_of_matching_files', 'number_of_matching_subjects',
900
+ 'number_of_subjects_related_to_matching_files', 'number_of_files_related_to_matching_subjects')
901
+ will contain integers representing the total number of result rows and the
902
+ total number of result-related rows in another table, as appropriate. Summaries for
903
+ table='subject' will include a count of all related files; summaries for table='file'
904
+ will include a count of all related subjects. Every other DataFrame in the list
905
+ will be titled with a CDA column name and will contain value counts or statistical
906
+ summaries for that column as filtered by the result row set.
907
+
908
+ OR Python dictionary enumerating counts of all data values for each summarized column
909
+ (or a statistical summary of those data values, in the case of unbounded numeric data)
910
+ across all rows that match the user-specified filter criteria (the 'result rows').
911
+ Two of four possible special summary keys in this dictionary ('number_of_matching_files',
912
+ 'number_of_matching_subjects', 'number_of_subjects_related_to_matching_files',
913
+ 'number_of_files_related_to_matching_subjects') will point to integers representing
914
+ the total number of result rows and the total number of result-related rows in another
915
+ table, as appropriate. Summaries for table='subject' will include a count of all related
916
+ files; summaries for table='file' will include a count of all related subjects. Every
917
+ other key in the dictionary will contain a CDA column name; every dictionary value will
918
+ itself be a dictionary either enumerating observed counts of all values appearing in
919
+ that column as filtered by the result row set, or encoding a statistical summary of
920
+ those values in the case of unbounded numeric data.
921
+
922
+ OR JSON-formatted text representing the same structure as the `return_data_as='dict'`
923
+ option, written to `output_file`.
924
+
925
+ OR returns nothing, but displays a series of tables to standard output
926
+ describing the same data returned by the other `return_data_as` options.
927
+
928
+ And yes, we know how those first two paragraphs look. We apologize to the entire English language.
929
+ """
930
+
931
+ log = get_logger()
932
+
933
+ #############################################################################################################################
934
+ # Validate parameter inputs.
935
+ #############################################################################################################################
936
+
937
+ # Normalize user-supplied parameter data so we can assume from here on out that these are always lists of values:
938
+ # convert any of the following that come in as single values (instead of lists of values) into one-element lists,
939
+ # and leave the rest unmodified.
940
+
941
+ # If someone can devise a way to do this with a control loop, I'm all ears. I gave up after 20 minutes
942
+ # of fiddling with `locals()`.
943
+
944
+ try:
945
+ match_all = normalize_to_list( 'match_all', match_all, str )
946
+ match_any = normalize_to_list( 'match_any', match_any, str )
947
+ data_source = normalize_to_list( 'data_source', data_source, str )
948
+ add_columns = normalize_to_list( 'add_columns', add_columns, str )
949
+ exclude_columns = normalize_to_list( 'exclude_columns', exclude_columns, str )
950
+ except Exception as e:
951
+ log.error( e )
952
+ return
953
+
954
+ # Cache CDA table and column metadata from the API for downstream reuse without further
955
+ # network disturbance. The data structure coming back from columns() is a DataFrame
956
+ # with columns [ 'table', 'column', 'data_type', 'nullable', 'description' ].
957
+
958
+ cached_column_metadata = columns()
959
+
960
+ # Cache valid labels for upstream data sources. The data structure coming back from
961
+ # release_metadata() is a list of dicts, with each dict looking like
962
+ #
963
+ # {
964
+ # 'cda_table': 'file',
965
+ # 'cda_column': 'access',
966
+ # 'data_source': 'GDC',
967
+ # 'data_source_version': 'March 2025',
968
+ # 'data_source_extraction_date': '2025-03-21',
969
+ # 'data_source_row_count': 3025352,
970
+ # 'data_source_unique_value_count': 4,
971
+ # 'data_source_null_count': 407714
972
+ # }
973
+
974
+ try:
975
+ cached_release_metadata = release_metadata()
976
+ except Exception as e:
977
+ log.critical( e )
978
+ return
979
+
980
+ valid_data_sources = set()
981
+
982
+ for column_record in cached_release_metadata:
983
+ record_data_source = column_record['data_source']
984
+ if record_data_source != 'CDA':
985
+ # Let's not care about case.
986
+ valid_data_sources.add( record_data_source.upper() )
987
+
988
+ # Validate user-supplied parameter data.
989
+
990
+ try:
991
+ validate_parameter_values(
992
+ called_function='summarize',
993
+ cached_column_metadata=cached_column_metadata,
994
+ valid_data_sources=valid_data_sources,
995
+ table=table,
996
+ match_from_file=match_from_file,
997
+ data_source=data_source,
998
+ add_columns=add_columns,
999
+ exclude_columns=exclude_columns,
1000
+ collate_results=None,
1001
+ include_external_refs=None,
1002
+ return_data_as=return_data_as,
1003
+ output_file=output_file,
1004
+ log=log
1005
+ )
1006
+ except Exception as e:
1007
+ # As of right now (2025-04), we return rather than halting the system via sys.exit() to avoid
1008
+ # completely crashing interactive sessions when errors are encountered. This behavior may be
1009
+ # updated in future to a more robust response.
1010
+ log.error( e )
1011
+ return
1012
+
1013
+ #############################################################################################################################
1014
+ # Preprocess table metadata, to enable consistent processing (and reporting) throughout.
1015
+ #############################################################################################################################
1016
+
1017
+ # Track the data type present in each CDA column, so we can
1018
+ # format results properly downstream. Among other things we need to
1019
+ # know details of numeric types, when constructing DataFrames to return
1020
+ # to the user, so we can compensate for pandas' inconsistent handling
1021
+ # of numeric null values.
1022
+
1023
+ column_data_types = dict()
1024
+
1025
+ # Store the default column ordering as provided by the columns() function,
1026
+ # so all cdapython interfaces always display the same data in the same way
1027
+ # by default.
1028
+
1029
+ source_table_columns_in_order = list()
1030
+
1031
+ for row_index, column_record in cached_column_metadata.iterrows():
1032
+
1033
+ # Save the data_type of each CDA column.
1034
+ column_data_types[ column_record['column'] ] = column_record['data_type']
1035
+
1036
+ if column_record['table'] == table:
1037
+
1038
+ # Remember the order in which columns() delivered the source table's columns.
1039
+ source_table_columns_in_order.append( column_record['column'] )
1040
+
1041
+ #############################################################################################################################
1042
+ # Construct query substructures according to user directives.
1043
+ #############################################################################################################################
1044
+
1045
+ #############################################################################################################################
1046
+ # Manage basic validation for the `match_all` parameter, which enumerates user-specified requirements that returned
1047
+ # rows must all simultaneously satisfy (AND; intersection; 'all of these must apply').
1048
+ #
1049
+ # Parse (and normalize: resolving various operator aliases, etc.) `match_all` filter expressions: complain if
1050
+ #
1051
+ # * requested columns don't exist
1052
+ # * illegal or type-inappropriate operators are used
1053
+ # * filter values don't match the data types of the columns they're paired with
1054
+ # * wildcards appear anywhere but at the ends of a filter string
1055
+ #
1056
+ # ...and save parse results for each filter expression as a separate Query object (to be combined later).
1057
+
1058
+ try:
1059
+ # TO DO: RE-ENABLE enforce_column_uniqueness AFTER FIGURING OUT NUMERIC HANDLING AND COMPATIBILITY BETWEEN GET_DATA AND SUMMARIZE
1060
+ queries_for_match_all = validate_and_transform_match_filter_list( cached_column_metadata, match_all, enforce_column_uniqueness=False )
1061
+ except Exception as e:
1062
+ log.error( e )
1063
+ return
1064
+
1065
+ #############################################################################################################################
1066
+ # Update `queries_for_match_all` to restrict results to optionally-specified `data_source` values.
1067
+
1068
+ for upstream_data_source in data_source:
1069
+ queries_for_match_all.append( f"{table}_data_at_{upstream_data_source.lower()} = true" )
1070
+
1071
+ #############################################################################################################################
1072
+ # Manage basic validation for the `match_any` parameter, which enumerates user-specified requirements for which
1073
+ # returned rows must satisfy at least one (OR; union; 'at least one of these must apply').
1074
+ #
1075
+ # Parse (and normalize: resolving various operator aliases, etc.) `match_any` filter expressions: complain if
1076
+ #
1077
+ # * requested columns don't exist
1078
+ # * illegal or type-inappropriate operators are used
1079
+ # * filter values don't match the data types of the columns they're paired with
1080
+ # * wildcards appear anywhere but at the ends of a filter string
1081
+ #
1082
+ # ...and save parse results for each filter expression as a separate Query object (to be combined later).
1083
+
1084
+ try:
1085
+ queries_for_match_any = validate_and_transform_match_filter_list( cached_column_metadata, match_any, enforce_column_uniqueness=False )
1086
+ except Exception as e:
1087
+ log.error( e )
1088
+ return
1089
+
1090
+ #############################################################################################################################
1091
+ # If not null, process match_from_file query information: load target values to match and check to see if records with
1092
+ # missing values in the target column should be included.
1093
+
1094
+ if match_from_file['input_file'] != '':
1095
+
1096
+ match_from_file_target_column = match_from_file['cda_column_to_match']
1097
+ match_from_file_target_data_type = column_data_types[ match_from_file_target_column ]
1098
+
1099
+ # Load target values to search against.
1100
+ #
1101
+ # Interpret missing data as 'empty values allowed' -- if we don't do this, we're setting our users up to (a) create a TSV
1102
+ # from fetched results and then (b) filter downstream queries based on those results subject to a hidden condition that
1103
+ # any results fetched in (a) that have missing values will be ignored when filtering, which seems to me like a recipe for
1104
+ # anger and confusion when results don't match the input set along the given column.
1105
+
1106
+ match_from_file_target_values = set()
1107
+
1108
+ match_from_file_nulls_allowed = False
1109
+
1110
+ try:
1111
+
1112
+ with open( match_from_file['input_file'] ) as IN:
1113
+
1114
+ column_names = next( IN ).rstrip( '\n' ).split( '\t' )
1115
+
1116
+ for next_line in IN:
1117
+
1118
+ record = dict( zip( column_names, next_line.rstrip( '\n' ).split( '\t' ) ) )
1119
+ target_value = record[match_from_file['input_column']]
1120
+
1121
+ if target_value is None or target_value == '' or target_value == '<NA>':
1122
+ match_from_file_nulls_allowed = True
1123
+ else:
1124
+ match_from_file_target_values.add( target_value )
1125
+
1126
+ except Exception as error:
1127
+
1128
+ log.error( f"Couldn't load data from requested column '{match_from_file['input_column']}' from requested TSV file '{match_from_file['input_file']}': got error of type '{type( error )}', with error message '{error}'.")
1129
+ return
1130
+
1131
+ # Parse target values: complain if
1132
+ #
1133
+ # * values don't match the data types of the columns they're paired with
1134
+ # * wildcards appear anywhere (they're not compatible with the IN keyword, and we don't currently support the construction of per-value LIKE filters)
1135
+ #
1136
+ # ...strip apostrophes, and save parse results as a processed set of valid values.
1137
+
1138
+ try:
1139
+ processed_target_values = validate_and_transform_match_from_file_values( match_from_file_target_column, match_from_file_target_data_type, match_from_file_target_values )
1140
+ except Exception as e:
1141
+ log.error( e )
1142
+ return
1143
+
1144
+ # Create API filter strings from processed `match_from_file` input data.
1145
+
1146
+ match_from_file_filter_strings = set()
1147
+
1148
+ if match_from_file_nulls_allowed:
1149
+ match_from_file_filter_strings.add( f"{match_from_file_target_column} is null" )
1150
+
1151
+ if match_from_file_target_data_type == 'text' and len( processed_target_values ) > 0:
1152
+ match_from_file_filter_strings.add( f"{match_from_file_target_column} in [ '" + "', '".join( processed_target_values ) + "' ]" )
1153
+
1154
+ # Add results to the queries_for_match_any list.
1155
+
1156
+ initial_match_any_filter_strings = set( queries_for_match_any )
1157
+
1158
+ queries_for_match_any = list( initial_match_any_filter_strings | match_from_file_filter_strings )
1159
+
1160
+ #############################################################################################################################
1161
+ # Parse `add_columns` and `exclude_columns` lists.
1162
+
1163
+ columns_to_add = list()
1164
+
1165
+ for column_to_add in add_columns:
1166
+
1167
+ # Ignore requests for columns that are already present by default, and don't add columns twice.
1168
+ if column_to_add not in source_table_columns_in_order and column_to_add not in columns_to_add:
1169
+ columns_to_add.append( column_to_add )
1170
+
1171
+ columns_to_exclude = list()
1172
+
1173
+ suppress_data_source_results = False
1174
+
1175
+ for column_to_exclude in exclude_columns:
1176
+
1177
+ # Handle 'data_source' explicitly; it's a user-facing summary column the API neither knows
1178
+ # nor needs to care about.
1179
+
1180
+ if column_to_exclude.lower() == 'data_source':
1181
+ suppress_data_source_results = True
1182
+
1183
+ # Ignore requests to exclude columns that are already excluded. Let the API sort out
1184
+ # what to do if a user requests to both add and exclude a column.
1185
+
1186
+ if column_to_exclude not in columns_to_exclude:
1187
+ columns_to_exclude.append( column_to_exclude )
1188
+
1189
+ #############################################################################################################################
1190
+ # Build an object to represent our upcoming API query.
1191
+
1192
+ query_object = SummaryRequestBody()
1193
+ query_object.match_all = queries_for_match_all
1194
+ query_object.match_some = queries_for_match_any
1195
+ query_object.add_columns = columns_to_add
1196
+ query_object.exclude_columns = columns_to_exclude
1197
+
1198
+ #############################################################################################################################
1199
+ # Fetch data from the API.
1200
+ #############################################################################################################################
1201
+
1202
+ # Support selection of the appropriate endpoint based on the value of `table`.
1203
+
1204
+ query_selector = {
1205
+ 'file': summary_file_endpoint,
1206
+ 'subject': summary_subject_endpoint
1207
+ }
1208
+
1209
+ # Try to get data from the REST API.
1210
+
1211
+ log.debug( f"Sending query to API '/summary/{table}' endpoint:\n{json.dumps( query_object.to_dict(), indent=4 )}\n" )
1212
+
1213
+ query_api_instance = cda_client.Client( base_url=get_api_url(), raise_on_unexpected_status=True )
1214
+
1215
+ try:
1216
+ api_response_object = query_selector[table].sync(
1217
+ client=query_api_instance,
1218
+ body=query_object
1219
+ )
1220
+ except UnexpectedStatus as error:
1221
+ log.error( f"UnexpectedStatus error from API, status code {error.status_code}: {json.loads( error.content )['message']}" )
1222
+ return
1223
+ except Exception as error:
1224
+ log.error( f"{type(error)}: {error}" )
1225
+ return
1226
+
1227
+ # Forward error types known to be returned by the API.
1228
+ if isinstance( api_response_object, ClientError ) or isinstance( api_response_object, InternalError ):
1229
+ log.error( f"{api_response_object.error_type}: {api_response_object.message}" )
1230
+ return
1231
+
1232
+ # Sample JSON response from a /summary endpoint:
1233
+ #
1234
+ # {
1235
+ # "result": [
1236
+ # {
1237
+ # "total_count": 6904,
1238
+ # "file_count": 584670,
1239
+ # "species_summary": [
1240
+ # {
1241
+ # "species": "human",
1242
+ # "count_result": 6904
1243
+ # }
1244
+ # ],
1245
+ # "year_of_birth_summary": [
1246
+ # {
1247
+ # "min": 1908,
1248
+ # "max": 2010,
1249
+ # "mean": 1958,
1250
+ # "median": 1956,
1251
+ # "lower_quartile": 1942,
1252
+ # "upper_quartile": 1971
1253
+ # }
1254
+ # ],
1255
+ # "year_of_death_summary": [
1256
+ # {
1257
+ # "min": 1996,
1258
+ # "max": 2022,
1259
+ # "mean": 2009,
1260
+ # "median": 2007,
1261
+ # "lower_quartile": 2002,
1262
+ # "upper_quartile": 2018
1263
+ # }
1264
+ # ],
1265
+ # "cause_of_death_summary": [
1266
+ # {
1267
+ # "cause_of_death": null,
1268
+ # "count_result": 6683
1269
+ # },
1270
+ # {
1271
+ # "cause_of_death": "Non-Cancer Related Death",
1272
+ # "count_result": 28
1273
+ # },
1274
+ # {
1275
+ # "cause_of_death": "Surgical Complication",
1276
+ # "count_result": 4
1277
+ # },
1278
+ # {
1279
+ # "cause_of_death": "Cancer-Related Death",
1280
+ # "count_result": 186
1281
+ # },
1282
+ # {
1283
+ # "cause_of_death": "Cardiovascular Disorder",
1284
+ # "count_result": 3
1285
+ # }
1286
+ # ],
1287
+ # "race_summary": [
1288
+ # {
1289
+ # "race": null,
1290
+ # "count_result": 902
1291
+ # },
1292
+ # {
1293
+ # "race": "Black or African American",
1294
+ # "count_result": 596
1295
+ # },
1296
+ # {
1297
+ # "race": "Asian",
1298
+ # "count_result": 431
1299
+ # },
1300
+ # {
1301
+ # "race": "More than one race",
1302
+ # "count_result": 14
1303
+ # },
1304
+ # {
1305
+ # "race": "White",
1306
+ # "count_result": 4922
1307
+ # },
1308
+ # {
1309
+ # "race": "American Indian or Alaska Native",
1310
+ # "count_result": 24
1311
+ # },
1312
+ # {
1313
+ # "race": "Native Hawaiian or Other Pacific Islander",
1314
+ # "count_result": 15
1315
+ # }
1316
+ # ],
1317
+ # "ethnicity_summary": [
1318
+ # {
1319
+ # "ethnicity": null,
1320
+ # "count_result": 1595
1321
+ # },
1322
+ # {
1323
+ # "ethnicity": "Non-Hispanic",
1324
+ # "count_result": 4903
1325
+ # },
1326
+ # {
1327
+ # "ethnicity": "Hispanic or Latino",
1328
+ # "count_result": 406
1329
+ # }
1330
+ # ],
1331
+ # "subject_data_source_count_summary": [
1332
+ # {
1333
+ # "min": 1,
1334
+ # "max": 4,
1335
+ # "mean": 2,
1336
+ # "median": 2,
1337
+ # "lower_quartile": 2,
1338
+ # "upper_quartile": 2
1339
+ # }
1340
+ # ],
1341
+ # "data_source": {
1342
+ # "cds_exclusive": 235,
1343
+ # "gdc_exclusive": 351,
1344
+ # "icdc_exclusive": 0,
1345
+ # "idc_exclusive": 0,
1346
+ # "pdc_exclusive": 6,
1347
+ # "cds_gdc_exclusive": 0,
1348
+ # "cds_icdc_exclusive": 0,
1349
+ # "cds_idc_exclusive": 898,
1350
+ # "cds_pdc_exclusive": 0,
1351
+ # "gdc_icdc_exclusive": 0,
1352
+ # "gdc_idc_exclusive": 4544,
1353
+ # "gdc_pdc_exclusive": 7,
1354
+ # "icdc_idc_exclusive": 0,
1355
+ # "icdc_pdc_exclusive": 0,
1356
+ # "idc_pdc_exclusive": 0,
1357
+ # "cds_gdc_icdc_exclusive": 0,
1358
+ # "cds_gdc_idc_exclusive": 574,
1359
+ # "cds_gdc_pdc_exclusive": 1,
1360
+ # "cds_icdc_idc_exclusive": 0,
1361
+ # "cds_icdc_pdc_exclusive": 0,
1362
+ # "cds_idc_pdc_exclusive": 0,
1363
+ # "gdc_icdc_idc_exclusive": 0,
1364
+ # "gdc_icdc_pdc_exclusive": 0,
1365
+ # "gdc_idc_pdc_exclusive": 205,
1366
+ # "icdc_idc_pdc_exclusive": 0,
1367
+ # "cds_gdc_icdc_idc_exclusive": 0,
1368
+ # "cds_gdc_icdc_pdc_exclusive": 0,
1369
+ # "cds_gdc_idc_pdc_exclusive": 83,
1370
+ # "cds_icdc_idc_pdc_exclusive": 0,
1371
+ # "gdc_icdc_idc_pdc_exclusive": 0,
1372
+ # "cds_gdc_icdc_idc_pdc": 0
1373
+ # },
1374
+ # "sex": [
1375
+ # {
1376
+ # "sex": null,
1377
+ # "count_result": 6
1378
+ # },
1379
+ # {
1380
+ # "sex": "female",
1381
+ # "count_result": 3591
1382
+ # },
1383
+ # {
1384
+ # "sex": "male",
1385
+ # "count_result": 3309
1386
+ # }
1387
+ # ]
1388
+ # }
1389
+ # ],
1390
+ # "query_sql": "WITH subject_preselect AS (SELECT subject.id AS subject_id, subject.id_alias AS subject_id_alias, [...these are very long!...] AS cds_gdc_icdc_idc_pdc) AS subquery) AS data_source, (SELECT observation_columns.sex FROM observation_columns) AS sex) AS json_result"
1391
+ # }
1392
+
1393
+ # Report some metadata about the results we got back.
1394
+
1395
+ log.debug( f"/summary/{table} endpoint query SQL:\n{api_response_object.to_dict()['query_sql']}" )
1396
+
1397
+ # This is immensely verbose, sometimes.
1398
+
1399
+ log.debug( f"/summary/{table} endpoint results:\n{json.dumps( api_response_object.to_dict()['result'], indent=4 )}\n" )
1400
+
1401
+ #############################################################################################################################
1402
+ # Postprocess API result data.
1403
+ #############################################################################################################################
1404
+
1405
+ log.debug( "Organizing result data..." )
1406
+
1407
+ # Make a dict out of the results so we can restructure a bit before DataFrame conversion.
1408
+
1409
+ api_response_dict = api_response_object.to_dict()['result'][0]
1410
+
1411
+ # Wrap the 'data_source' response element in a list to avoid splitting the entries into individual columns
1412
+ # when converting into a DataFrame.
1413
+
1414
+ api_response_dict['data_source'] = [api_response_dict['data_source']]
1415
+
1416
+ # Convert response JSON into a DataFrame using pandas' json_normalize() function.
1417
+
1418
+ result_dataframe = pd.json_normalize( [api_response_dict] )
1419
+
1420
+ # For some reason, the highest-level summary counts come through as floats. Fix that
1421
+ # (and rename them while we're at it).
1422
+
1423
+ toplevel_columns_to_fix = {
1424
+
1425
+ 'total_count': 'number_of_matching_files' if table == 'file' else 'number_of_matching_subjects' if table == 'subject' else 'number_of_matching_rows',
1426
+ 'file_count': 'number_of_files_related_to_matching_subjects',
1427
+ 'subject_count': 'number_of_subjects_related_to_matching_files'
1428
+ }
1429
+
1430
+ for result_column in toplevel_columns_to_fix:
1431
+
1432
+ if result_column in result_dataframe:
1433
+
1434
+ result_dataframe[result_column] = result_dataframe[result_column].round().astype( int )
1435
+
1436
+ result_dataframe = result_dataframe.rename( columns={ result_column: toplevel_columns_to_fix[result_column] } )
1437
+
1438
+ # Remove '_summary' from ordinary result column names before returning.
1439
+
1440
+ skip_rename = {
1441
+ 'data_source',
1442
+ 'file_data_source_count_summary',
1443
+ 'subject_data_source_count_summary',
1444
+ 'number_of_matching_files',
1445
+ 'number_of_matching_subjects',
1446
+ 'number_of_matching_rows',
1447
+ 'number_of_files_related_to_matching_subjects',
1448
+ 'number_of_subjects_related_to_matching_files'
1449
+ }
1450
+
1451
+ result_column_names = result_dataframe.columns.values
1452
+
1453
+ for result_column in result_column_names:
1454
+
1455
+ if result_column not in skip_rename:
1456
+
1457
+ new_column_name = re.sub( r'_summary$', r'', result_column )
1458
+
1459
+ if new_column_name != result_column:
1460
+
1461
+ result_dataframe = result_dataframe.rename( columns={ result_column: new_column_name } )
1462
+
1463
+ #############################################################################################################################
1464
+ # Build a response for the user according to the directives we got, and send the result back to them.
1465
+ #############################################################################################################################
1466
+
1467
+ if return_data_as == '' or return_data_as == 'dataframe_list':
1468
+
1469
+ # Right now, the default is to print one table to standard output
1470
+ # for each DataFrame that would be returned had the user requested
1471
+ # `return_data_as='dataframe_list'`.
1472
+
1473
+ result_list = list()
1474
+
1475
+ # Return overall result summary counts first.
1476
+
1477
+ for toplevel_column in [ 'number_of_matching_files', 'number_of_matching_subjects', 'number_of_matching_rows', 'number_of_files_related_to_matching_subjects', 'number_of_subjects_related_to_matching_files' ]:
1478
+
1479
+ if toplevel_column in result_dataframe:
1480
+
1481
+ # Copy the column into a new DataFrame, then append the new DataFrame to the result list.
1482
+
1483
+ result_list.append( pd.DataFrame( result_dataframe[toplevel_column], columns=[toplevel_column] ) )
1484
+
1485
+ # Next, summarize data sources unless `exclude_columns='data_source'` was specified by the user.
1486
+
1487
+ if not suppress_data_source_results:
1488
+
1489
+ output_data_source_dict = {
1490
+ 'data_source': list(),
1491
+ f"{table}s": list()
1492
+ }
1493
+
1494
+ if result_dataframe['data_source'] is not None:
1495
+
1496
+ input_data_source_dict = result_dataframe['data_source'][0][0]
1497
+
1498
+ # This cell should be a Python dict pairing some combination of valid data sources with a count of matching results.
1499
+
1500
+ for data_source_combo in input_data_source_dict:
1501
+
1502
+ current_count = input_data_source_dict[data_source_combo]
1503
+
1504
+ if current_count is not None and current_count != 0:
1505
+
1506
+ data_source_combo = re.sub( r'_exclusive$', r'', data_source_combo )
1507
+
1508
+ if re.search( r'_', data_source_combo ) is None:
1509
+
1510
+ data_source_combo = f"{data_source_combo.upper()} only"
1511
+
1512
+ else:
1513
+
1514
+ data_source_combo = " + ".join( data_source_combo.upper().split( '_' ) )
1515
+
1516
+ output_data_source_dict[f"{table}s"].append( current_count )
1517
+ output_data_source_dict['data_source'].append( data_source_combo )
1518
+
1519
+ result_list.append( pd.DataFrame.from_dict( output_data_source_dict ).sort_values( by=f"{table}s", ascending=False ).reset_index( drop=True ) )
1520
+
1521
+ # Put the numeric summaries at the end of the displayed block of results.
1522
+
1523
+ result_list_tail = list()
1524
+
1525
+ for result_column in result_dataframe.columns:
1526
+
1527
+ if result_column not in skip_rename and result_dataframe[result_column].dtype == 'object' and isinstance( result_dataframe[result_column][0], list ) and isinstance( result_dataframe[result_column][0][0], dict ) and 'median' in result_dataframe[result_column][0][0]:
1528
+
1529
+ # These are one-element arrays, with the element being a key/value dictionary containing summary stats.
1530
+ #
1531
+ # They come back with null values if there are no results. In such a case, we don't want to include this structure in our output.
1532
+
1533
+ if result_dataframe[result_column][0][0]['median'] is not None:
1534
+
1535
+ result_column_dict = dict()
1536
+
1537
+ result_column_dict['cda_column_name'] = [result_column]
1538
+
1539
+ # Hard-coding this is fragile, but safe for now and there's a lot to do.
1540
+
1541
+ for key in [ 'mean', 'min', 'lower_quartile', 'median', 'upper_quartile', 'max' ]:
1542
+
1543
+ result_column_dict[key] = [result_dataframe[result_column][0][0][key]]
1544
+
1545
+ result_list_tail.append( pd.DataFrame.from_dict( result_column_dict ).reset_index( drop=True ) )
1546
+
1547
+ elif result_column not in skip_rename:
1548
+
1549
+ # Copy the column into a new DataFrame, then append the new DataFrame to the result list.
1550
+
1551
+ if result_dataframe[result_column].dtype == 'int64':
1552
+
1553
+ result_dataframe[result_column] = int( result_dataframe[result_column][0] )
1554
+
1555
+ elif result_dataframe[result_column].dtype == 'object':
1556
+
1557
+ result_column_dict = {
1558
+ result_column: list(),
1559
+ 'count_result': list()
1560
+ }
1561
+
1562
+ if result_dataframe[result_column][0] is not None:
1563
+
1564
+ # This cell should contain an array of Python dicts, with each dict containing two entries:
1565
+ #
1566
+ # data column label and value:
1567
+ # keyword: `result_column`, e.g. 'cause_of_death'
1568
+ # value: one allowable value for `result_column`, e.g. 'Cancer-Related Death'
1569
+ #
1570
+ # observed count of the given value:
1571
+ # keyword: 'count_result'
1572
+ # value: (int) number of times the given data value (described in the previous dictionary entry) was observed in this set of result data
1573
+
1574
+ for dict_pair in result_dataframe[result_column][0]:
1575
+
1576
+ print_value = '<NA>'
1577
+
1578
+ actual_value = dict_pair[result_column]
1579
+
1580
+ if actual_value is not None and actual_value != '':
1581
+
1582
+ print_value = actual_value
1583
+
1584
+ result_column_dict[result_column].append( print_value )
1585
+
1586
+ result_column_dict['count_result'].append( dict_pair['count_result'] )
1587
+
1588
+ result_list.append( pd.DataFrame.from_dict( result_column_dict ).sort_values( by=[ 'count_result', result_column ], ascending=[ False, True ] ).reset_index( drop=True ) )
1589
+
1590
+ else:
1591
+
1592
+ log.critical( f"Unexpected return type '{result_dataframe[result_column].dtype}' observed in result column '{result_column}'; please inform the CDA devs of this event." )
1593
+ return
1594
+
1595
+ result_list = result_list + result_list_tail
1596
+
1597
+ if return_data_as == '':
1598
+
1599
+ log.debug( 'Returning results in default form (printing list of tables to standard output)' )
1600
+
1601
+ with pd.option_context( 'display.max_rows', None, 'display.max_columns', None, 'display.max_colwidth', 65 ):
1602
+
1603
+ for result_list_df in result_list:
1604
+
1605
+ print_df = result_list_df
1606
+
1607
+ max_col_width = 80
1608
+
1609
+ maxcolwidths_list = [ None ]
1610
+
1611
+ colalign_list = [ 'left' ]
1612
+
1613
+ if print_df is not None and len( print_df ) > 0:
1614
+
1615
+ if len( print_df.columns ) == 1:
1616
+
1617
+ colalign_list = [ 'left' ]
1618
+
1619
+ elif 'count_result' in print_df.columns.values or f"{table}s" in print_df.columns.values:
1620
+
1621
+ maxcolwidths_list = [ None, max_col_width ]
1622
+
1623
+ colalign_list = [ 'right', 'right' ]
1624
+
1625
+ # Truncate displayed text values manually and add ellipses. The `tabulate` library doesn't do this on its own (as Pandas does).
1626
+
1627
+ print_df[print_df.columns[0]] = print_df[print_df.columns[0]].apply( lambda x: re.sub( f"^(.{{{max_col_width-3}}}).*", r"\1...", x ) if ( x is not None and not isinstance( x, bool ) and len( x ) > max_col_width ) else x )
1628
+
1629
+ # Put the count values first in the display.
1630
+
1631
+ new_column_ordering = list( reversed( print_df.columns.tolist() ) )
1632
+
1633
+ print_df = print_df[new_column_ordering]
1634
+
1635
+ elif 'median' in print_df.columns.values:
1636
+
1637
+ colalign_list = [ 'right' ]
1638
+
1639
+ result_name = print_df['cda_column_name'][0]
1640
+
1641
+ result_dict = {
1642
+
1643
+ '': list(),
1644
+ result_name: list()
1645
+ }
1646
+
1647
+ # Hard-coding this is fragile, but safe for now and there's a lot to do.
1648
+
1649
+ for key in [ 'mean', 'min', 'lower_quartile', 'median', 'upper_quartile', 'max' ]:
1650
+
1651
+ result_dict[''].append( f"{re.sub( r'_', r' ', key )}" )
1652
+
1653
+ result_dict[result_name].append( f"{print_df[key][0]:>15}" )
1654
+
1655
+ print_df = pd.DataFrame.from_dict( result_dict ).reset_index( drop=True )
1656
+
1657
+ # Suppress output of confusing row-index column when displaying DataFrame contents and get some control over cell alignment.
1658
+
1659
+ print(
1660
+ tabulate.tabulate(
1661
+ print_df,
1662
+ showindex=False,
1663
+ headers=print_df.columns,
1664
+ tablefmt='double_outline',
1665
+ colalign=colalign_list,
1666
+ maxcolwidths=maxcolwidths_list,
1667
+ disable_numparse=True,
1668
+ )
1669
+ )
1670
+
1671
+ return
1672
+
1673
+ elif return_data_as == 'dataframe_list':
1674
+
1675
+ log.debug( 'Returning results as a list of pandas.DataFrame objects' )
1676
+
1677
+ return result_list
1678
+
1679
+ elif return_data_as == 'dict' or return_data_as == 'json':
1680
+
1681
+ # Build a Python dictionary to shape returned results.
1682
+
1683
+ result_dict = dict()
1684
+
1685
+ for result_column in result_dataframe.columns:
1686
+
1687
+ if ( result_column not in skip_rename or re.search( r'_data_source_count_summary$', result_column ) is not None ) and result_dataframe[result_column].dtype == 'object' and isinstance( result_dataframe[result_column][0], list ) and isinstance( result_dataframe[result_column][0][0], dict ) and 'median' in result_dataframe[result_column][0][0]:
1688
+
1689
+ # These are one-element arrays, with the element being a key/value dictionary containing summary stats.
1690
+ #
1691
+ # They come back with null values if there are no results. In such a case, we don't want to include this structure in our output.
1692
+
1693
+ if result_dataframe[result_column][0][0]['median'] is not None:
1694
+
1695
+ result_dict[result_column] = dict()
1696
+
1697
+ # Hard-coding this is fragile, but safe for now and there's a lot to do.
1698
+
1699
+ for key in [ 'mean', 'min', 'lower_quartile', 'median', 'upper_quartile', 'max' ]:
1700
+
1701
+ result_dict[result_column][key] = result_dataframe[result_column][0][0][key]
1702
+
1703
+ elif result_column == 'data_source':
1704
+
1705
+ if result_dataframe['data_source'] is not None:
1706
+
1707
+ input_data_source_dict = result_dataframe['data_source'][0][0]
1708
+
1709
+ # This cell should be a Python dict pairing some combination of valid data sources with a count of matching results.
1710
+
1711
+ for data_source_combo in input_data_source_dict:
1712
+
1713
+ current_count = input_data_source_dict[data_source_combo]
1714
+
1715
+ if current_count is not None and current_count != 0:
1716
+
1717
+ data_source_combo = re.sub( r'_exclusive$', r'', data_source_combo )
1718
+
1719
+ if re.search( r'_', data_source_combo ) is None:
1720
+
1721
+ data_source_combo = f"{data_source_combo.upper()} only"
1722
+
1723
+ else:
1724
+
1725
+ data_source_combo = " and ".join( data_source_combo.upper().split( '_' ) )
1726
+
1727
+ if 'data_source' not in result_dict:
1728
+
1729
+ result_dict['data_source'] = dict()
1730
+
1731
+ result_dict['data_source'][data_source_combo] = current_count
1732
+
1733
+ else:
1734
+
1735
+ if result_dataframe[result_column].dtype == 'int64':
1736
+
1737
+ result_dict[result_column] = int( result_dataframe[result_column][0] )
1738
+
1739
+ elif result_dataframe[result_column].dtype == 'object':
1740
+
1741
+ result_dict[result_column] = None
1742
+
1743
+ if result_dataframe[result_column][0] is not None:
1744
+
1745
+ # This cell should contain an array of Python dicts, with each dict containing two entries:
1746
+ #
1747
+ # data column label and value:
1748
+ # keyword: `result_column`, e.g. 'cause_of_death'
1749
+ # value: one allowable value for `result_column`, e.g. 'Cancer-Related Death'
1750
+ #
1751
+ # observed count of the given value:
1752
+ # keyword: 'count_result'
1753
+ # value: (int) number of times the given data value (described in the previous dictionary entry) was observed in this set of result data
1754
+
1755
+ result_dict[result_column] = dict()
1756
+
1757
+ for dict_pair in result_dataframe[result_column][0]:
1758
+
1759
+ result_dict[result_column][dict_pair[result_column]] = dict_pair['count_result']
1760
+
1761
+ else:
1762
+
1763
+ log.critical( f"Unexpected return type '{result_dataframe[result_column].dtype}' observed in result column '{result_column}'; please inform the CDA devs of this event." )
1764
+ return
1765
+
1766
+ if return_data_as == 'dict':
1767
+
1768
+ log.debug( 'Returning results as a Python dictionary' )
1769
+
1770
+ return result_dict
1771
+
1772
+ elif return_data_as == 'json':
1773
+
1774
+ # Write the results to a user-specified JSON file.
1775
+
1776
+ log.debug( f"Printing results to JSON file '{output_file}'" )
1777
+
1778
+ try:
1779
+
1780
+ with open( output_file, 'w' ) as OUT:
1781
+
1782
+ json.dump( result_dict, OUT, indent=4, ensure_ascii=True )
1783
+
1784
+ return
1785
+
1786
+ except Exception as error:
1787
+
1788
+ log.error( f"Couldn't write to requested output file '{output_file}': got error of type '{type(error)}', with error message '{error}'." )
1789
+
1790
+ return
1791
+
1792
+ log.critical( 'Something has gone unexpectedly and disastrously wrong with return-data postprocessing. Please alert the CDA devs to this event and include details of how to reproduce this error.' )
1793
+ return
1794
+
1795
+ #############################################################################################################################
1796
+ #
1797
+ # END summarize()
1798
+ #
1799
+ #############################################################################################################################
1800
+
1801
+