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/__init__.py +7 -0
- cdapython/application_utilities.py +54 -0
- cdapython/config/__init__.py +0 -0
- cdapython/config/logger_default_config.yml +19 -0
- cdapython/discover.py +1412 -0
- cdapython/get_data.py +1174 -0
- cdapython/logging_wrappers.py +186 -0
- cdapython/summarize.py +1801 -0
- cdapython/validation.py +622 -0
- cdapython-2.0.0.dist-info/LICENSE +201 -0
- cdapython-2.0.0.dist-info/METADATA +21 -0
- cdapython-2.0.0.dist-info/RECORD +13 -0
- cdapython-2.0.0.dist-info/WHEEL +4 -0
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
|
+
|