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/get_data.py
ADDED
|
@@ -0,0 +1,1174 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import numpy
|
|
3
|
+
import pandas as pd
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
import cda_client
|
|
7
|
+
|
|
8
|
+
from cdapython.application_utilities import get_api_url
|
|
9
|
+
from cdapython.discover import columns, release_metadata
|
|
10
|
+
from cdapython.logging_wrappers import get_logger
|
|
11
|
+
from cdapython.validation import normalize_to_list, validate_and_transform_match_filter_list, validate_and_transform_match_from_file_values, validate_parameter_values
|
|
12
|
+
|
|
13
|
+
from cda_client.api.data import file_fetch_rows_endpoint_data_file_post as file_data_endpoint
|
|
14
|
+
from cda_client.api.data import subject_fetch_rows_endpoint_data_subject_post as subject_data_endpoint
|
|
15
|
+
from cda_client.errors import UnexpectedStatus
|
|
16
|
+
from cda_client.models.client_error import ClientError
|
|
17
|
+
from cda_client.models.internal_error import InternalError
|
|
18
|
+
from cda_client.models.data_request_body import DataRequestBody
|
|
19
|
+
|
|
20
|
+
#############################################################################################################################
|
|
21
|
+
#############################################################################################################################
|
|
22
|
+
# Nomenclature notes:
|
|
23
|
+
#
|
|
24
|
+
# * try to standardize all potential user-facing synonyms for basic database data structures
|
|
25
|
+
# (field, entity, endpoint, cell, value, term, etc.) to 'table', 'column', 'row' and 'value'.
|
|
26
|
+
#############################################################################################################################
|
|
27
|
+
#############################################################################################################################
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
#############################################################################################################################
|
|
31
|
+
#
|
|
32
|
+
# get_file_data( ): Get CDA file data rows ('result rows') that match user-specified criteria.
|
|
33
|
+
#
|
|
34
|
+
#############################################################################################################################
|
|
35
|
+
|
|
36
|
+
def get_file_data(
|
|
37
|
+
*,
|
|
38
|
+
match_all=None,
|
|
39
|
+
match_any=None,
|
|
40
|
+
match_from_file={ 'input_file': '', 'input_column': '', 'cda_column_to_match': '' },
|
|
41
|
+
data_source=None,
|
|
42
|
+
add_columns=None,
|
|
43
|
+
exclude_columns=None,
|
|
44
|
+
collate_results=False,
|
|
45
|
+
return_data_as='dataframe',
|
|
46
|
+
output_file=''
|
|
47
|
+
):
|
|
48
|
+
"""
|
|
49
|
+
Get CDA file rows ('result rows') that match user-specified criteria.
|
|
50
|
+
|
|
51
|
+
Arguments:
|
|
52
|
+
match_all ( string or list of strings; optional ):
|
|
53
|
+
One or more conditions, expressed as filter strings (see below),
|
|
54
|
+
ALL of which must be met by all result rows.
|
|
55
|
+
|
|
56
|
+
match_any ( string or list of strings; optional ):
|
|
57
|
+
One or more conditions, expressed as filter strings (see below),
|
|
58
|
+
AT LEAST ONE of which must be met by all result rows.
|
|
59
|
+
|
|
60
|
+
match_from_file ( 3-element dictionary of strings; optional ):
|
|
61
|
+
A dictionary containing 3 named elements:
|
|
62
|
+
1. 'input_file': The name of a (local) TSV file (with column names in its first row)
|
|
63
|
+
2. 'input_column': The name of a column in that TSV
|
|
64
|
+
3. 'cda_column_to_match': The name of a CDA column
|
|
65
|
+
Restrict result rows to those where the value of the given CDA
|
|
66
|
+
column matches at least one value from the given column
|
|
67
|
+
in the given TSV file.
|
|
68
|
+
|
|
69
|
+
data_source ( string or list of strings; optional ):
|
|
70
|
+
Restrict results to those deriving from the given upstream
|
|
71
|
+
data source(s). Current valid values are 'GDC', 'IDC', 'PDC',
|
|
72
|
+
'CDS' and 'ICDC'. (Default: no filter.)
|
|
73
|
+
|
|
74
|
+
add_columns ( string or list of strings; optional ):
|
|
75
|
+
One or more columns from a second table to add to result data.
|
|
76
|
+
|
|
77
|
+
exclude_columns ( string or list of strings; optional ):
|
|
78
|
+
One or more columns to remove from result data.
|
|
79
|
+
|
|
80
|
+
collate_results ( boolean; optional ):
|
|
81
|
+
If True: for each result file, include a DataFrame collating
|
|
82
|
+
results linked to that file from each non-file table that was
|
|
83
|
+
queried. Otherwise, for each result file, include a list of
|
|
84
|
+
unique values associated with that file from each non-file
|
|
85
|
+
column that was queried.
|
|
86
|
+
|
|
87
|
+
return_data_as ( string; optional: 'dataframe' or 'tsv' ):
|
|
88
|
+
Specify how to return results: as a pandas DataFrame,
|
|
89
|
+
or as output written to a TSV file named by the user. If this
|
|
90
|
+
argument is omitted, the default is to return results as a DataFrame.
|
|
91
|
+
|
|
92
|
+
output_file ( string; optional ):
|
|
93
|
+
If return_data_as='tsv' is specified, `output_file` should contain a
|
|
94
|
+
resolvable path to a file into which tab-delimited results will be
|
|
95
|
+
written.
|
|
96
|
+
|
|
97
|
+
Filter strings:
|
|
98
|
+
Filter strings are expressions of the form 'COLUMN_NAME OP VALUE'
|
|
99
|
+
(note in particular that the whitespace surrounding OP is required),
|
|
100
|
+
where
|
|
101
|
+
|
|
102
|
+
COLUMN_NAME is a searchable CDA column (see the columns() function
|
|
103
|
+
for details)
|
|
104
|
+
|
|
105
|
+
OP is one of: < <= > >= = !=
|
|
106
|
+
|
|
107
|
+
VALUE is a particular value of whatever data type is stored
|
|
108
|
+
in COLUMN_NAME (see the columns() function for details), or
|
|
109
|
+
the special keyword NULL, indicating the filter should match
|
|
110
|
+
missing (null) values in COLUMN_NAME.
|
|
111
|
+
|
|
112
|
+
Operators = and != will work on numeric, boolean and string VALUEs.
|
|
113
|
+
|
|
114
|
+
Operators < <= > >= will only work on numeric VALUEs.
|
|
115
|
+
|
|
116
|
+
Users can require partial matches to string VALUEs by adding * to either or
|
|
117
|
+
both ends. For example:
|
|
118
|
+
|
|
119
|
+
diagnosis = *duct*
|
|
120
|
+
sex = F*
|
|
121
|
+
|
|
122
|
+
String VALUEs need not be quoted inside of filter strings. For example, to include
|
|
123
|
+
the filters specified just above in the `match_all` argument, we can write:
|
|
124
|
+
|
|
125
|
+
get_file_data( match_all=[ 'diagnosis = *duct*', 'sex = F*' ] )
|
|
126
|
+
|
|
127
|
+
NULL is a special VALUE which can be used to match missing data. For
|
|
128
|
+
example, to get CDA file data for which the `cause_of_death` field
|
|
129
|
+
is missing data in associated subject rows, we can write:
|
|
130
|
+
|
|
131
|
+
get_file_data( match_all=[ 'cause_of_death = NULL' ] )
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
(Default) A pandas.DataFrame containing CDA file data matching the user-specified
|
|
135
|
+
filter criteria. The DataFrame's named columns will match columns in the `file` table
|
|
136
|
+
plus any optional user-added columns from other tables, and each row in the DataFrame
|
|
137
|
+
will represent one CDA `file` row (possibly with related data from other tables
|
|
138
|
+
appended to it, according to user directives).
|
|
139
|
+
|
|
140
|
+
OR returns nothing, but writes results to a user-specified TSV file.
|
|
141
|
+
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
return get_data( 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, collate_results=collate_results, include_external_refs=False, return_data_as=return_data_as, output_file=output_file )
|
|
145
|
+
|
|
146
|
+
#############################################################################################################################
|
|
147
|
+
#
|
|
148
|
+
# get_subject_data( ): Get CDA subject data rows ('result rows') that match user-specified criteria.
|
|
149
|
+
#
|
|
150
|
+
#############################################################################################################################
|
|
151
|
+
|
|
152
|
+
def get_subject_data(
|
|
153
|
+
*,
|
|
154
|
+
match_all=None,
|
|
155
|
+
match_any=None,
|
|
156
|
+
match_from_file={ 'input_file': '', 'input_column': '', 'cda_column_to_match': '' },
|
|
157
|
+
data_source=None,
|
|
158
|
+
add_columns=None,
|
|
159
|
+
exclude_columns=None,
|
|
160
|
+
collate_results=False,
|
|
161
|
+
include_external_refs=False,
|
|
162
|
+
return_data_as='dataframe',
|
|
163
|
+
output_file=''
|
|
164
|
+
):
|
|
165
|
+
"""
|
|
166
|
+
Get CDA subject rows ('result rows') that match user-specified criteria.
|
|
167
|
+
|
|
168
|
+
Arguments:
|
|
169
|
+
match_all ( string or list of strings; optional ):
|
|
170
|
+
One or more conditions, expressed as filter strings (see below),
|
|
171
|
+
ALL of which must be met by all result rows.
|
|
172
|
+
|
|
173
|
+
match_any ( string or list of strings; optional ):
|
|
174
|
+
One or more conditions, expressed as filter strings (see below),
|
|
175
|
+
AT LEAST ONE of which must be met by all result rows.
|
|
176
|
+
|
|
177
|
+
match_from_file ( 3-element dictionary of strings; optional ):
|
|
178
|
+
A dictionary containing 3 named elements:
|
|
179
|
+
1. 'input_file': The name of a (local) TSV file (with column names in its first row)
|
|
180
|
+
2. 'input_column': The name of a column in that TSV
|
|
181
|
+
3. 'cda_column_to_match': The name of a CDA column
|
|
182
|
+
Restrict result rows to those where the value of the given CDA
|
|
183
|
+
column matches at least one value from the given column
|
|
184
|
+
in the given TSV file.
|
|
185
|
+
|
|
186
|
+
data_source ( string or list of strings; optional ):
|
|
187
|
+
Restrict results to those deriving from the given upstream
|
|
188
|
+
data source(s). Current valid values are 'GDC', 'IDC', 'PDC',
|
|
189
|
+
'CDS' and 'ICDC'. (Default: no filter.)
|
|
190
|
+
|
|
191
|
+
add_columns ( string or list of strings; optional ):
|
|
192
|
+
One or more columns from a second table to add to result data.
|
|
193
|
+
|
|
194
|
+
exclude_columns ( string or list of strings; optional ):
|
|
195
|
+
One or more columns to remove from result data.
|
|
196
|
+
|
|
197
|
+
collate_results ( boolean; optional ):
|
|
198
|
+
If True: for each result subject, include a DataFrame collating
|
|
199
|
+
results linked to that subject from each non-subject table that was
|
|
200
|
+
queried. Otherwise, for each result subject, include a list of
|
|
201
|
+
unique values associated with that subject from each non-subject
|
|
202
|
+
column that was queried. Defaults to False.
|
|
203
|
+
|
|
204
|
+
include_external_refs ( boolean; optional ):
|
|
205
|
+
If True: for each result subject, include a DataFrame called
|
|
206
|
+
'external_reference_data' that collates references to external
|
|
207
|
+
resources containing data describing that subject. Defaults to False.
|
|
208
|
+
|
|
209
|
+
return_data_as ( string; optional: 'dataframe' or 'tsv' ):
|
|
210
|
+
Specify how to return results: as a pandas DataFrame,
|
|
211
|
+
or as output written to a TSV file named by the user. If this
|
|
212
|
+
argument is omitted, the default is to return results as a DataFrame.
|
|
213
|
+
|
|
214
|
+
output_file ( string; optional ):
|
|
215
|
+
If return_data_as='tsv' is specified, `output_file` should contain a
|
|
216
|
+
resolvable path to a file into which tab-delimited results will be
|
|
217
|
+
written.
|
|
218
|
+
|
|
219
|
+
Filter strings:
|
|
220
|
+
Filter strings are expressions of the form 'COLUMN_NAME OP VALUE'
|
|
221
|
+
(note in particular that the whitespace surrounding OP is required),
|
|
222
|
+
where
|
|
223
|
+
|
|
224
|
+
COLUMN_NAME is a searchable CDA column (see the columns() function
|
|
225
|
+
for details)
|
|
226
|
+
|
|
227
|
+
OP is one of: < <= > >= = !=
|
|
228
|
+
|
|
229
|
+
VALUE is a particular value of whatever data type is stored
|
|
230
|
+
in COLUMN_NAME (see the columns() function for details), or
|
|
231
|
+
the special keyword NULL, indicating the filter should match
|
|
232
|
+
missing (null) values in COLUMN_NAME.
|
|
233
|
+
|
|
234
|
+
Operators = and != will work on numeric, boolean and string VALUEs.
|
|
235
|
+
|
|
236
|
+
Operators < <= > >= will only work on numeric VALUEs.
|
|
237
|
+
|
|
238
|
+
Users can require partial matches to string VALUEs by adding * to either or
|
|
239
|
+
both ends. For example:
|
|
240
|
+
|
|
241
|
+
diagnosis = *duct*
|
|
242
|
+
sex = F*
|
|
243
|
+
|
|
244
|
+
String VALUEs need not be quoted inside of filter strings. For example, to include
|
|
245
|
+
the filters specified just above in the `match_all` argument, we can write:
|
|
246
|
+
|
|
247
|
+
get_subject_data( match_all=[ 'diagnosis = *duct*', 'sex = F*' ] )
|
|
248
|
+
|
|
249
|
+
NULL is a special VALUE which can be used to match missing data. For
|
|
250
|
+
example, to get CDA subject data for which the `cause_of_death` field
|
|
251
|
+
is missing data, we can write:
|
|
252
|
+
|
|
253
|
+
get_subject_data( match_all=[ 'cause_of_death = NULL' ] )
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
(Default) A pandas.DataFrame containing CDA subject data matching the user-specified
|
|
257
|
+
filter criteria. The DataFrame's named columns will match columns in the `subject` table
|
|
258
|
+
plus any optional user-added columns from other tables, and each row in the DataFrame
|
|
259
|
+
will represent one CDA `subject` row (possibly with related data from other tables
|
|
260
|
+
appended to it, according to user directives).
|
|
261
|
+
|
|
262
|
+
OR returns nothing, but writes results to a user-specified TSV file.
|
|
263
|
+
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
return get_data( 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, collate_results=collate_results, include_external_refs=include_external_refs, return_data_as=return_data_as, output_file=output_file )
|
|
267
|
+
|
|
268
|
+
#############################################################################################################################
|
|
269
|
+
#
|
|
270
|
+
# get_data( table=`table` ): Get CDA data rows ('result rows') from `table` that match user-specified criteria.
|
|
271
|
+
#
|
|
272
|
+
#############################################################################################################################
|
|
273
|
+
|
|
274
|
+
def get_data(
|
|
275
|
+
table=None,
|
|
276
|
+
*,
|
|
277
|
+
match_all=None,
|
|
278
|
+
match_any=None,
|
|
279
|
+
match_from_file={ 'input_file': '', 'input_column': '', 'cda_column_to_match': '' },
|
|
280
|
+
data_source=None,
|
|
281
|
+
add_columns=None,
|
|
282
|
+
exclude_columns=None,
|
|
283
|
+
collate_results=False,
|
|
284
|
+
include_external_refs=False,
|
|
285
|
+
return_data_as='dataframe',
|
|
286
|
+
output_file=''
|
|
287
|
+
):
|
|
288
|
+
"""
|
|
289
|
+
Get CDA data rows ('result rows') from `table` that match user-specified criteria.
|
|
290
|
+
|
|
291
|
+
Arguments:
|
|
292
|
+
table ( string; required: 'file' or 'subject' ):
|
|
293
|
+
The CDA table whose rows are to be filtered and retrieved.
|
|
294
|
+
|
|
295
|
+
match_all ( string or list of strings; optional ):
|
|
296
|
+
One or more conditions, expressed as filter strings (see below),
|
|
297
|
+
ALL of which must be met by all result rows.
|
|
298
|
+
|
|
299
|
+
match_any ( string or list of strings; optional ):
|
|
300
|
+
One or more conditions, expressed as filter strings (see below),
|
|
301
|
+
AT LEAST ONE of which must be met by all result rows.
|
|
302
|
+
|
|
303
|
+
match_from_file ( 3-element dictionary of strings; optional ):
|
|
304
|
+
A dictionary containing 3 named elements:
|
|
305
|
+
1. 'input_file': The name of a (local) TSV file (with column names in its first row)
|
|
306
|
+
2. 'input_column': The name of a column in that TSV
|
|
307
|
+
3. 'cda_column_to_match': The name of a CDA column
|
|
308
|
+
Restrict result rows to those where the value of the given CDA
|
|
309
|
+
column matches at least one value from the given column
|
|
310
|
+
in the given TSV file.
|
|
311
|
+
|
|
312
|
+
data_source ( string or list of strings; optional ):
|
|
313
|
+
Restrict results to those deriving from the given upstream
|
|
314
|
+
data source(s). Current valid values are 'GDC', 'IDC', 'PDC',
|
|
315
|
+
'CDS' and 'ICDC'. (Default: no filter.)
|
|
316
|
+
|
|
317
|
+
add_columns ( string or list of strings; optional ):
|
|
318
|
+
One or more columns from a second table to add to result data from `table`.
|
|
319
|
+
|
|
320
|
+
exclude_columns ( string or list of strings; optional ):
|
|
321
|
+
One or more columns to remove from result data.
|
|
322
|
+
|
|
323
|
+
collate_results ( boolean; optional ):
|
|
324
|
+
If True: for each result row, include a DataFrame collating
|
|
325
|
+
results linked to that row from each foreign table that was
|
|
326
|
+
queried. Otherwise, for each result row, include a list of
|
|
327
|
+
unique values associated with that row from each foreign
|
|
328
|
+
column that was queried.
|
|
329
|
+
|
|
330
|
+
include_external_refs ( boolean; optional ):
|
|
331
|
+
If True: for each result row, include a DataFrame called
|
|
332
|
+
'external_reference_data' that collates references to external
|
|
333
|
+
resources containing data directly associated with that
|
|
334
|
+
row. Defaults to False.
|
|
335
|
+
|
|
336
|
+
return_data_as ( string; optional: 'dataframe' or 'tsv' ):
|
|
337
|
+
Specify how get_data() should return results: as a pandas DataFrame,
|
|
338
|
+
or as output written to a TSV file named by the user. If this
|
|
339
|
+
argument is omitted, get_data() will default to returning
|
|
340
|
+
results as a DataFrame.
|
|
341
|
+
|
|
342
|
+
output_file ( string; optional ):
|
|
343
|
+
If return_data_as='tsv' is specified, `output_file` should contain a
|
|
344
|
+
resolvable path to a file into which get_data() will write
|
|
345
|
+
tab-delimited results.
|
|
346
|
+
|
|
347
|
+
Filter strings:
|
|
348
|
+
Filter strings are expressions of the form 'COLUMN_NAME OP VALUE'
|
|
349
|
+
(note in particular that the whitespace surrounding OP is required),
|
|
350
|
+
where
|
|
351
|
+
|
|
352
|
+
COLUMN_NAME is a searchable CDA column (see the columns() function
|
|
353
|
+
for details)
|
|
354
|
+
|
|
355
|
+
OP is one of: < <= > >= = !=
|
|
356
|
+
|
|
357
|
+
VALUE is a particular value of whatever data type is stored
|
|
358
|
+
in COLUMN_NAME (see the columns() function for details), or
|
|
359
|
+
the special keyword NULL, indicating the filter should match
|
|
360
|
+
missing (null) values in COLUMN_NAME.
|
|
361
|
+
|
|
362
|
+
Operators = and != will work on numeric, boolean and string VALUEs.
|
|
363
|
+
|
|
364
|
+
Operators < <= > >= will only work on numeric VALUEs.
|
|
365
|
+
|
|
366
|
+
Users can require partial matches to string VALUEs by adding * to either or
|
|
367
|
+
both ends. For example:
|
|
368
|
+
|
|
369
|
+
diagnosis = *duct*
|
|
370
|
+
sex = F*
|
|
371
|
+
|
|
372
|
+
String VALUEs need not be quoted inside of filter strings. For example, to include
|
|
373
|
+
the filters specified just above in the `match_all` argument, when querying
|
|
374
|
+
the `subject` table, we can write:
|
|
375
|
+
|
|
376
|
+
get_data( table='subject', match_all=[ 'diagnosis = *duct*', 'sex = F*' ] )
|
|
377
|
+
|
|
378
|
+
NULL is a special VALUE which can be used to match missing data. For
|
|
379
|
+
example, to get `subject` rows where the `cause_of_death` field
|
|
380
|
+
is missing data, we can write:
|
|
381
|
+
|
|
382
|
+
get_data( table='subject', match_all=[ 'cause_of_death = NULL' ] )
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
(Default) A pandas.DataFrame containing CDA `table` rows matching the user-specified
|
|
386
|
+
filter criteria. The DataFrame's named columns will match columns in `table` plus
|
|
387
|
+
any optional user-added columns from other tables, and each row in the DataFrame
|
|
388
|
+
will represent one CDA `table` row (possibly with related data from other tables
|
|
389
|
+
appended to it, according to user directives).
|
|
390
|
+
|
|
391
|
+
OR returns nothing, but writes results to a user-specified TSV file.
|
|
392
|
+
|
|
393
|
+
"""
|
|
394
|
+
|
|
395
|
+
log = get_logger()
|
|
396
|
+
|
|
397
|
+
#############################################################################################################################
|
|
398
|
+
# Validate parameter inputs.
|
|
399
|
+
#############################################################################################################################
|
|
400
|
+
|
|
401
|
+
# Normalize user-supplied parameter data so we can assume from here on out that these are always lists of values:
|
|
402
|
+
# convert any of the following that come in as single values (instead of lists of values) into one-element lists,
|
|
403
|
+
# and leave the rest unmodified. Also convert everything to lowercase.
|
|
404
|
+
|
|
405
|
+
# If someone can devise a way to do this with a control loop, I'm all ears. I gave up after 20 minutes
|
|
406
|
+
# of fiddling with `locals()`.
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
match_all = normalize_to_list( 'match_all', match_all, str )
|
|
410
|
+
match_any = normalize_to_list( 'match_any', match_any, str )
|
|
411
|
+
data_source = normalize_to_list( 'data_source', data_source, str )
|
|
412
|
+
add_columns = normalize_to_list( 'add_columns', add_columns, str )
|
|
413
|
+
exclude_columns = normalize_to_list( 'exclude_columns', exclude_columns, str )
|
|
414
|
+
except Exception as e:
|
|
415
|
+
log.error( e )
|
|
416
|
+
return
|
|
417
|
+
|
|
418
|
+
# Cache CDA table and column metadata from the API for downstream reuse without further
|
|
419
|
+
# network disturbance. The data structure coming back from columns() is a DataFrame
|
|
420
|
+
# with columns [ 'table', 'column', 'data_type', 'nullable', 'description' ].
|
|
421
|
+
|
|
422
|
+
cached_column_metadata = columns()
|
|
423
|
+
|
|
424
|
+
# Cache valid labels for upstream data sources. The data structure coming back from
|
|
425
|
+
# release_metadata() is a list of dicts, with each dict looking like
|
|
426
|
+
#
|
|
427
|
+
# {
|
|
428
|
+
# 'cda_table': 'file',
|
|
429
|
+
# 'cda_column': 'access',
|
|
430
|
+
# 'data_source': 'GDC',
|
|
431
|
+
# 'data_source_version': 'March 2025',
|
|
432
|
+
# 'data_source_extraction_date': '2025-03-21',
|
|
433
|
+
# 'data_source_row_count': 3025352,
|
|
434
|
+
# 'data_source_unique_value_count': 4,
|
|
435
|
+
# 'data_source_null_count': 407714
|
|
436
|
+
# }
|
|
437
|
+
|
|
438
|
+
try:
|
|
439
|
+
cached_release_metadata = release_metadata()
|
|
440
|
+
except Exception as e:
|
|
441
|
+
log.critical( e )
|
|
442
|
+
return
|
|
443
|
+
|
|
444
|
+
valid_data_sources = set()
|
|
445
|
+
|
|
446
|
+
for column_record in cached_release_metadata:
|
|
447
|
+
record_data_source = column_record['data_source']
|
|
448
|
+
# Let's not care about case.
|
|
449
|
+
if record_data_source.upper() != 'CDA':
|
|
450
|
+
valid_data_sources.add( record_data_source.upper() )
|
|
451
|
+
|
|
452
|
+
# Validate user-supplied parameter data.
|
|
453
|
+
|
|
454
|
+
try:
|
|
455
|
+
validate_parameter_values(
|
|
456
|
+
called_function='get_data',
|
|
457
|
+
cached_column_metadata=cached_column_metadata,
|
|
458
|
+
valid_data_sources=valid_data_sources,
|
|
459
|
+
table=table,
|
|
460
|
+
match_from_file=match_from_file,
|
|
461
|
+
data_source=data_source,
|
|
462
|
+
add_columns=add_columns,
|
|
463
|
+
exclude_columns=exclude_columns,
|
|
464
|
+
collate_results=collate_results,
|
|
465
|
+
include_external_refs=include_external_refs,
|
|
466
|
+
return_data_as=return_data_as,
|
|
467
|
+
output_file=output_file,
|
|
468
|
+
log=log
|
|
469
|
+
)
|
|
470
|
+
except Exception as e:
|
|
471
|
+
log.error( e )
|
|
472
|
+
return
|
|
473
|
+
|
|
474
|
+
#############################################################################################################################
|
|
475
|
+
# Preprocess CDA table metadata, to enable consistent processing (and reporting) throughout.
|
|
476
|
+
#############################################################################################################################
|
|
477
|
+
|
|
478
|
+
# Track the data type present in each CDA column, so we can
|
|
479
|
+
# format results properly downstream. Among other things we need to
|
|
480
|
+
# know details of numeric types, when constructing DataFrames to return
|
|
481
|
+
# to the user, so we can compensate for pandas' inconsistent handling
|
|
482
|
+
# of numeric null values.
|
|
483
|
+
|
|
484
|
+
column_data_types = dict()
|
|
485
|
+
|
|
486
|
+
# Store the default column ordering as provided by the columns() function,
|
|
487
|
+
# so all cdapython interfaces always display the same data in the same way
|
|
488
|
+
# by default.
|
|
489
|
+
|
|
490
|
+
source_table_columns_in_order = list()
|
|
491
|
+
|
|
492
|
+
for row_index, column_record in cached_column_metadata.iterrows():
|
|
493
|
+
|
|
494
|
+
# Save the data_type of each CDA column.
|
|
495
|
+
column_data_types[ column_record['column'] ] = column_record['data_type']
|
|
496
|
+
|
|
497
|
+
if column_record['table'] == table:
|
|
498
|
+
|
|
499
|
+
# Remember the order in which columns() delivered the source table's columns.
|
|
500
|
+
source_table_columns_in_order.append( column_record['column'] )
|
|
501
|
+
|
|
502
|
+
#############################################################################################################################
|
|
503
|
+
# Construct query substructures according to user directives.
|
|
504
|
+
#############################################################################################################################
|
|
505
|
+
|
|
506
|
+
#############################################################################################################################
|
|
507
|
+
# Manage basic validation for the `match_all` parameter, which enumerates user-specified requirements that returned
|
|
508
|
+
# rows must all simultaneously satisfy (AND; intersection; 'all of these must apply').
|
|
509
|
+
#
|
|
510
|
+
# Validate and normalize match_all filter strings; save results as a list of statement strings.
|
|
511
|
+
|
|
512
|
+
try:
|
|
513
|
+
# TO DO: RE-ENABLE enforce_column_uniqueness AFTER FIGURING OUT NUMERIC HANDLING AND COMPATIBILITY BETWEEN GET_DATA AND SUMMARIZE
|
|
514
|
+
queries_for_match_all = validate_and_transform_match_filter_list( cached_column_metadata, match_all, enforce_column_uniqueness=False )
|
|
515
|
+
except Exception as e:
|
|
516
|
+
log.error( e )
|
|
517
|
+
return
|
|
518
|
+
|
|
519
|
+
# Update `queries_for_match_all` to restrict results to optionally-specified `data_source` values.
|
|
520
|
+
|
|
521
|
+
for upstream_data_source in data_source:
|
|
522
|
+
queries_for_match_all.append( f"{table}_data_at_{upstream_data_source.lower()} = true" )
|
|
523
|
+
|
|
524
|
+
#############################################################################################################################
|
|
525
|
+
# Manage basic validation for the `match_any` parameter, which enumerates user-specified requirements for which
|
|
526
|
+
# returned rows must satisfy at least one (OR; union; 'at least one of these must apply').
|
|
527
|
+
#
|
|
528
|
+
# Validate and normalize match_any filter strings; save results as a list of statement strings.
|
|
529
|
+
|
|
530
|
+
try:
|
|
531
|
+
queries_for_match_any = validate_and_transform_match_filter_list( cached_column_metadata, match_any, enforce_column_uniqueness=False )
|
|
532
|
+
except Exception as e:
|
|
533
|
+
log.error( e )
|
|
534
|
+
return
|
|
535
|
+
|
|
536
|
+
#############################################################################################################################
|
|
537
|
+
# If not null, process match_from_file query information: load target values to match and check to see if records with
|
|
538
|
+
# missing values in the target column should be included.
|
|
539
|
+
|
|
540
|
+
if match_from_file['input_file'] != '':
|
|
541
|
+
|
|
542
|
+
match_from_file_target_column = match_from_file['cda_column_to_match']
|
|
543
|
+
match_from_file_target_data_type = column_data_types[ match_from_file_target_column ]
|
|
544
|
+
|
|
545
|
+
# Load target values to search against.
|
|
546
|
+
#
|
|
547
|
+
# Interpret missing data as 'empty values allowed' -- if we don't do this, we're setting our users up to (a) create a TSV
|
|
548
|
+
# from fetched results and then (b) filter downstream queries based on those results subject to a hidden condition that
|
|
549
|
+
# any results fetched in (a) that have missing values will be ignored when filtering, which seems to me like a recipe for
|
|
550
|
+
# anger and confusion when results don't match the input set along the given column.
|
|
551
|
+
|
|
552
|
+
match_from_file_target_values = set()
|
|
553
|
+
|
|
554
|
+
match_from_file_nulls_allowed = False
|
|
555
|
+
|
|
556
|
+
try:
|
|
557
|
+
with open( match_from_file['input_file'] ) as IN:
|
|
558
|
+
|
|
559
|
+
column_names = next( IN ).rstrip( '\n' ).split( '\t' )
|
|
560
|
+
|
|
561
|
+
for next_line in IN:
|
|
562
|
+
|
|
563
|
+
record = dict( zip( column_names, next_line.rstrip( '\n' ).split( '\t' ) ) )
|
|
564
|
+
target_value = record[match_from_file['input_column']]
|
|
565
|
+
|
|
566
|
+
if target_value is None or target_value == '' or target_value == '<NA>':
|
|
567
|
+
match_from_file_nulls_allowed = True
|
|
568
|
+
else:
|
|
569
|
+
match_from_file_target_values.add( target_value )
|
|
570
|
+
|
|
571
|
+
except Exception as error:
|
|
572
|
+
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}'.")
|
|
573
|
+
return
|
|
574
|
+
|
|
575
|
+
# Parse target values: complain if
|
|
576
|
+
#
|
|
577
|
+
# * values don't match the data types of the columns they're paired with
|
|
578
|
+
# * wildcards appear anywhere (they're not compatible with the IN keyword, and we don't currently support the construction of per-value LIKE filters)
|
|
579
|
+
#
|
|
580
|
+
# ...strip apostrophes, and save parse results as a processed set of valid values.
|
|
581
|
+
|
|
582
|
+
try:
|
|
583
|
+
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 )
|
|
584
|
+
except Exception as e:
|
|
585
|
+
log.error( e )
|
|
586
|
+
return
|
|
587
|
+
|
|
588
|
+
# Create API filter strings from processed `match_from_file` input data.
|
|
589
|
+
|
|
590
|
+
match_from_file_filter_strings = set()
|
|
591
|
+
|
|
592
|
+
if match_from_file_nulls_allowed:
|
|
593
|
+
match_from_file_filter_strings.add( f"{match_from_file_target_column} is null" )
|
|
594
|
+
|
|
595
|
+
if match_from_file_target_data_type == 'text' and len( processed_target_values ) > 0:
|
|
596
|
+
match_from_file_filter_strings.add( f"{match_from_file_target_column} in [ '" + "', '".join( processed_target_values ) + "' ]" )
|
|
597
|
+
|
|
598
|
+
# Add results to the queries_for_match_any list.
|
|
599
|
+
|
|
600
|
+
initial_match_any_filter_strings = set( queries_for_match_any )
|
|
601
|
+
|
|
602
|
+
queries_for_match_any = list( initial_match_any_filter_strings | match_from_file_filter_strings )
|
|
603
|
+
|
|
604
|
+
#############################################################################################################################
|
|
605
|
+
# Parse `add_columns` and `exclude_columns` lists.
|
|
606
|
+
|
|
607
|
+
columns_to_add = list()
|
|
608
|
+
|
|
609
|
+
for column_to_add in add_columns:
|
|
610
|
+
# Ignore requests for columns that are already present by default, and don't add columns twice.
|
|
611
|
+
if column_to_add not in source_table_columns_in_order and column_to_add not in columns_to_add:
|
|
612
|
+
# If collate == True, we'll need to ask for provenance information from this column's
|
|
613
|
+
# home table, if we haven't yet done so.
|
|
614
|
+
if collate_results:
|
|
615
|
+
# Identify this column's home table.
|
|
616
|
+
foreign_table_name = ''
|
|
617
|
+
if re.search( r'\.\*$', column_to_add ) is not None:
|
|
618
|
+
foreign_table_name = re.sub( r'\.\*$', r'', column_to_add )
|
|
619
|
+
else:
|
|
620
|
+
foreign_table_name = cached_column_metadata.query( f"column == '{column_to_add}'" )['table'].iloc[0]
|
|
621
|
+
# If we're asking for upstream_identifiers info, then skip this bit -- it has its own closer-bound data source info.
|
|
622
|
+
if foreign_table_name != 'upstream_identifiers':
|
|
623
|
+
# Check to see if we're already asking for provenance info from that table: if not, do, unless we're asking for 'upstream_identifiers.*'.
|
|
624
|
+
for data_source in [ source_label.lower() for source_label in sorted( valid_data_sources ) ]:
|
|
625
|
+
provenance_field = f"{foreign_table_name}_data_at_{data_source}"
|
|
626
|
+
if provenance_field not in columns_to_add:
|
|
627
|
+
columns_to_add.append( provenance_field )
|
|
628
|
+
|
|
629
|
+
columns_to_add.append( column_to_add )
|
|
630
|
+
|
|
631
|
+
if collate_results:
|
|
632
|
+
# Be sure to include requests for provenance information about foreign tables
|
|
633
|
+
# included only implicitly (by filters, not by add_columns).
|
|
634
|
+
|
|
635
|
+
for filter_query in set( queries_for_match_all ) | set( queries_for_match_any ):
|
|
636
|
+
column_to_add = re.search( r'^(\S+)\s', filter_query ).group(1)
|
|
637
|
+
foreign_table_name = cached_column_metadata.query( f"column == '{column_to_add}'" )['table'].iloc[0]
|
|
638
|
+
# Check to see if we're already asking for provenance info from that table: if not, do, unless {table} is 'upstream_identifiers'.
|
|
639
|
+
if foreign_table_name != 'upstream_identifiers':
|
|
640
|
+
for data_source in [ source_label.lower() for source_label in sorted( valid_data_sources ) ]:
|
|
641
|
+
provenance_field = f"{foreign_table_name}_data_at_{data_source}"
|
|
642
|
+
if provenance_field not in columns_to_add:
|
|
643
|
+
columns_to_add.append( provenance_field )
|
|
644
|
+
|
|
645
|
+
columns_to_exclude = list()
|
|
646
|
+
|
|
647
|
+
suppress_data_source_results = False
|
|
648
|
+
|
|
649
|
+
for column_to_exclude in exclude_columns:
|
|
650
|
+
|
|
651
|
+
# Handle 'data_source' explicitly; it's a user-facing summary column the API neither knows
|
|
652
|
+
# nor needs to care about.
|
|
653
|
+
|
|
654
|
+
if column_to_exclude.lower() == 'data_source':
|
|
655
|
+
suppress_data_source_results = True
|
|
656
|
+
|
|
657
|
+
# Ignore requests to exclude columns that are already excluded. Let the API sort out
|
|
658
|
+
# what to do if a user requests to both add and exclude a column.
|
|
659
|
+
|
|
660
|
+
if column_to_exclude not in columns_to_exclude:
|
|
661
|
+
columns_to_exclude.append( column_to_exclude )
|
|
662
|
+
# If we're excluding {home_table}.*, automatically suppress 'data_source' results.
|
|
663
|
+
if column_to_exclude == f"{table}.*":
|
|
664
|
+
suppress_data_source_results = True
|
|
665
|
+
|
|
666
|
+
#############################################################################################################################
|
|
667
|
+
# Build an object to represent our upcoming API query.
|
|
668
|
+
|
|
669
|
+
query_object = DataRequestBody()
|
|
670
|
+
query_object.match_all = queries_for_match_all
|
|
671
|
+
query_object.match_some = queries_for_match_any
|
|
672
|
+
query_object.add_columns = columns_to_add
|
|
673
|
+
query_object.exclude_columns = columns_to_exclude
|
|
674
|
+
query_object.collate_results = collate_results
|
|
675
|
+
query_object.external_reference = include_external_refs
|
|
676
|
+
|
|
677
|
+
#############################################################################################################################
|
|
678
|
+
# Fetch data from the API.
|
|
679
|
+
#############################################################################################################################
|
|
680
|
+
|
|
681
|
+
# Support selection of the appropriate endpoint based on the value of `table`.
|
|
682
|
+
|
|
683
|
+
query_selector = {
|
|
684
|
+
'file': file_data_endpoint,
|
|
685
|
+
'subject': subject_data_endpoint
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
# We return all results to users at once. Paging can occur internally, but is made
|
|
689
|
+
# transparent to the user. Track offset and page size in case we have to handle paged
|
|
690
|
+
# results.
|
|
691
|
+
|
|
692
|
+
starting_offset = 0
|
|
693
|
+
rows_per_page = 500000
|
|
694
|
+
|
|
695
|
+
# Try to get data from the REST API.
|
|
696
|
+
|
|
697
|
+
log.debug( f"Sending query to API '/data/{table}' endpoint:\n{json.dumps( query_object.to_dict(), indent=4 )}\n" )
|
|
698
|
+
|
|
699
|
+
query_api_instance = cda_client.Client( base_url=get_api_url(), raise_on_unexpected_status=True )
|
|
700
|
+
|
|
701
|
+
try:
|
|
702
|
+
api_response_object = query_selector[table].sync(
|
|
703
|
+
client=query_api_instance,
|
|
704
|
+
body=query_object,
|
|
705
|
+
limit=rows_per_page,
|
|
706
|
+
offset=starting_offset
|
|
707
|
+
)
|
|
708
|
+
except UnexpectedStatus as error:
|
|
709
|
+
log.error( f"UnexpectedStatus error from API, status code {error.status_code}: {json.loads( error.content )['message']}" )
|
|
710
|
+
return
|
|
711
|
+
except Exception as error:
|
|
712
|
+
log.error( f"{type(error)}: {error}" )
|
|
713
|
+
return
|
|
714
|
+
|
|
715
|
+
# Forward error types known to be returned by the API.
|
|
716
|
+
if isinstance( api_response_object, ClientError ) or isinstance( api_response_object, InternalError ):
|
|
717
|
+
log.error( f"{api_response_object.error_type}: {api_response_object.message}" )
|
|
718
|
+
return
|
|
719
|
+
|
|
720
|
+
# Make a Pandas DataFrame out of the first batch of results.
|
|
721
|
+
#
|
|
722
|
+
# The API returns responses in JSON format: convert that JSON into a DataFrame
|
|
723
|
+
# using pandas' json_normalize() function. Example JSON responses ( note that
|
|
724
|
+
# not all of these columns are returned by default: some were requested; others
|
|
725
|
+
# induced by a non-null `data_source` parameter; note also that this is
|
|
726
|
+
# a cut/paste job from several responses, don't check it too hard for internal
|
|
727
|
+
# consistency -- it's just meant to let readers know what to expect in terms of
|
|
728
|
+
# field names and nesting structures):
|
|
729
|
+
#
|
|
730
|
+
# {
|
|
731
|
+
# "result": [
|
|
732
|
+
# {
|
|
733
|
+
# "subject_id": "TCGA.TCGA-AA-A022",
|
|
734
|
+
# "subject_crdc_id": null,
|
|
735
|
+
# "species": "human",
|
|
736
|
+
# "year_of_birth": 1917,
|
|
737
|
+
# "year_of_death": null,
|
|
738
|
+
# "cause_of_death": null,
|
|
739
|
+
# "race": null,
|
|
740
|
+
# "ethnicity": null,
|
|
741
|
+
# "subject_data_at_gdc": true,
|
|
742
|
+
# "subject_data_at_idc": true,
|
|
743
|
+
# "subject_data_at_cds": false,
|
|
744
|
+
# "subject_data_at_pdc": true,
|
|
745
|
+
# "subject_data_at_icdc": false,
|
|
746
|
+
# "subject_data_source_count": 3,
|
|
747
|
+
# "sex": [
|
|
748
|
+
# "female"
|
|
749
|
+
# ]
|
|
750
|
+
# },
|
|
751
|
+
#
|
|
752
|
+
# ...
|
|
753
|
+
#
|
|
754
|
+
# ],
|
|
755
|
+
# "query_sql": "WITH subject_preselect AS ( ... ) AS json_result",
|
|
756
|
+
# "total_row_count": 9,
|
|
757
|
+
# "next_url": ""
|
|
758
|
+
# }
|
|
759
|
+
|
|
760
|
+
# Report some metadata about the results we got back.
|
|
761
|
+
|
|
762
|
+
log.debug( f"/data/{table} endpoint query SQL:\n{api_response_object.to_dict()['query_sql']}" )
|
|
763
|
+
|
|
764
|
+
# This is stupidly verbose.
|
|
765
|
+
|
|
766
|
+
# log.debug( f"Page one results:\n{json.dumps( api_response_object.to_dict()['result'], indent=4 )}\n" )
|
|
767
|
+
|
|
768
|
+
# Convert response JSON into a DataFrame using pandas' json_normalize() function.
|
|
769
|
+
|
|
770
|
+
result_dataframe = pd.json_normalize( api_response_object.to_dict()['result'] )
|
|
771
|
+
|
|
772
|
+
# The data we've fetched so far might be just the first page (if the total number
|
|
773
|
+
# of results is greater than `rows_per_page`).
|
|
774
|
+
#
|
|
775
|
+
# Get the rest of the result pages, if there are any, and add each page's data
|
|
776
|
+
# onto the end of our results DataFrame.
|
|
777
|
+
|
|
778
|
+
incremented_offset = starting_offset + rows_per_page
|
|
779
|
+
|
|
780
|
+
while api_response_object.next_url is not None and len( api_response_object.next_url ) > 0:
|
|
781
|
+
|
|
782
|
+
log.debug( f"Pulling next paged result from API with an offset of {incremented_offset} and a max page size of {rows_per_page}")
|
|
783
|
+
|
|
784
|
+
try:
|
|
785
|
+
api_response_object = query_selector[table].sync(
|
|
786
|
+
client=query_api_instance,
|
|
787
|
+
body=query_object,
|
|
788
|
+
offset=incremented_offset,
|
|
789
|
+
limit=rows_per_page
|
|
790
|
+
)
|
|
791
|
+
except UnexpectedStatus as error:
|
|
792
|
+
log.error( f"UnexpectedStatus error from API, status code {error.status_code}: {json.loads( error.content )['message']}" )
|
|
793
|
+
return
|
|
794
|
+
except Exception as error:
|
|
795
|
+
log.error( f"{type(error)}: {error}" )
|
|
796
|
+
return
|
|
797
|
+
|
|
798
|
+
# Forward error types known to be returned by the API.
|
|
799
|
+
if isinstance( api_response_object, ClientError ) or isinstance( api_response_object, InternalError ):
|
|
800
|
+
log.error( f"{api_response_object.error_type}: {api_response_object.message}" )
|
|
801
|
+
|
|
802
|
+
# Convert response JSON into a DataFrame using pandas' json_normalize() function.
|
|
803
|
+
|
|
804
|
+
next_result_batch = pd.json_normalize( api_response_object.to_dict()['result'] )
|
|
805
|
+
|
|
806
|
+
# Add data from this page to our full result set.
|
|
807
|
+
|
|
808
|
+
if not result_dataframe.empty and not next_result_batch.empty:
|
|
809
|
+
|
|
810
|
+
# Silence a future deprecation warning about pd.concat and empty DataFrame columns.
|
|
811
|
+
#
|
|
812
|
+
# Possiby relevant note: never fill in missing numeric values with 0!
|
|
813
|
+
|
|
814
|
+
next_result_batch = next_result_batch.astype( result_dataframe.dtypes )
|
|
815
|
+
result_dataframe = pd.concat( [result_dataframe, next_result_batch] )
|
|
816
|
+
|
|
817
|
+
incremented_offset = incremented_offset + rows_per_page
|
|
818
|
+
|
|
819
|
+
#############################################################################################################################
|
|
820
|
+
# Postprocess API result data.
|
|
821
|
+
#############################################################################################################################
|
|
822
|
+
|
|
823
|
+
log.debug( 'Organizing result data...' )
|
|
824
|
+
|
|
825
|
+
# Collect data source information and populate our user-facing `data_source` result column summary,
|
|
826
|
+
# unless it's been suppressed via exclude_columns=['data_source'].
|
|
827
|
+
|
|
828
|
+
if not suppress_data_source_results:
|
|
829
|
+
|
|
830
|
+
# Make a new column called 'data_source', populated with empty lists.
|
|
831
|
+
result_dataframe['data_source'] = [ [] for _ in range( len( result_dataframe ) ) ]
|
|
832
|
+
|
|
833
|
+
for row_index, result_record in result_dataframe.iterrows():
|
|
834
|
+
for upstream_data_source in sorted( valid_data_sources ):
|
|
835
|
+
if result_record[ f"{table}_data_at_{upstream_data_source.lower()}" ] == True:
|
|
836
|
+
result_dataframe['data_source'].iloc[row_index].append( upstream_data_source )
|
|
837
|
+
|
|
838
|
+
# Ensure the contents and ordering of the set of default columns for this endpoint
|
|
839
|
+
# is the same whether or not additional column data (e.g. from other tables) has
|
|
840
|
+
# been requested. Also make sure non-user-facing columns (e.g. `subject_data_at_gdc`)
|
|
841
|
+
# are not passed through to the user unprocessed.
|
|
842
|
+
|
|
843
|
+
columns_to_suppress = list()
|
|
844
|
+
added_columns = list()
|
|
845
|
+
|
|
846
|
+
df_columns_to_add = dict()
|
|
847
|
+
|
|
848
|
+
for column in result_dataframe:
|
|
849
|
+
|
|
850
|
+
if column not in { 'data_source' }:
|
|
851
|
+
|
|
852
|
+
# TO DO: This is a terrible way to exclude columns. See similar comment on banned_columns in summarize.py. Also see below in this block for more explicit filters.
|
|
853
|
+
|
|
854
|
+
if re.search( r'^[^_]+_data_at_[^_]+$', column ) is not None or re.search( r'^[^_]+_data_source_count$', column ) is not None or re.search( r'_id_alias$', column ) is not None or re.search( r'crdc_id$', column ) is not None:
|
|
855
|
+
columns_to_suppress.append( column )
|
|
856
|
+
|
|
857
|
+
# Remove raw versions of aggregated result sets from foreign tables
|
|
858
|
+
# and replace them with DataFrames.
|
|
859
|
+
|
|
860
|
+
elif re.search( r'_columns$', column ) is not None:
|
|
861
|
+
|
|
862
|
+
# Note: collate_results will always be True in this case: there is no other way to get 'X_columns' lists back from the API.
|
|
863
|
+
|
|
864
|
+
columns_to_suppress.append( column )
|
|
865
|
+
|
|
866
|
+
foreign_table_name = re.search( r'^(.*)_columns$', column ).group(1)
|
|
867
|
+
|
|
868
|
+
# Our result DataFrame's cells in a column named for `foreign_table_name` will
|
|
869
|
+
# contain DataFrames with linked values, row-wise, from `foreign_table_name`, describing
|
|
870
|
+
# all data from that table associated with each top-level row's main entity record.
|
|
871
|
+
|
|
872
|
+
foreign_df_list = list()
|
|
873
|
+
|
|
874
|
+
for row_index, result_record in result_dataframe.iterrows():
|
|
875
|
+
|
|
876
|
+
foreign_table_data_by_column = dict()
|
|
877
|
+
|
|
878
|
+
# Summarize (row-wise) 'data_source' values as we do for top-level result rows, unless we're processing external_reference or upstream_identifiers, which encode this data differently or not at all.
|
|
879
|
+
|
|
880
|
+
foreign_table_data_by_column['data_source'] = list()
|
|
881
|
+
|
|
882
|
+
if result_record[column] is not None:
|
|
883
|
+
|
|
884
|
+
for foreign_table_record in result_record[column]:
|
|
885
|
+
|
|
886
|
+
upstream_data_source = ''
|
|
887
|
+
|
|
888
|
+
if foreign_table_name in [ 'project', 'subject' ]:
|
|
889
|
+
# project and subject records can have multiple upstream data sources.
|
|
890
|
+
upstream_data_source = set()
|
|
891
|
+
|
|
892
|
+
for foreign_table_column in foreign_table_record:
|
|
893
|
+
|
|
894
|
+
match_result = re.search( r'^' + re.escape( foreign_table_name ) + r'_data_at_(.+)$', foreign_table_column )
|
|
895
|
+
|
|
896
|
+
if match_result is not None:
|
|
897
|
+
|
|
898
|
+
if foreign_table_record[foreign_table_column] == True:
|
|
899
|
+
|
|
900
|
+
detected_data_source = match_result.group(1).upper()
|
|
901
|
+
|
|
902
|
+
if foreign_table_name in [ 'project', 'subject' ]:
|
|
903
|
+
upstream_data_source.add( detected_data_source )
|
|
904
|
+
|
|
905
|
+
elif upstream_data_source == '':
|
|
906
|
+
upstream_data_source = detected_data_source
|
|
907
|
+
|
|
908
|
+
elif upstream_data_source != detected_data_source:
|
|
909
|
+
# There should only ever be one of these for non-subject records.
|
|
910
|
+
log.error( f"Upstream data source clash: {detected_data_source} != {upstream_data_source}; {foreign_table_name} (partial) record: \"{foreign_table_record}\"; please notify the CDA devs of this event." )
|
|
911
|
+
return
|
|
912
|
+
|
|
913
|
+
# TO DO: This is a terrible way to exclude columns. See similar comment on banned_columns in summarize.py. Also see above and below in this general block for more explicit filters.
|
|
914
|
+
|
|
915
|
+
elif re.search( r'^' + re.escape( foreign_table_name ) + r'_data_source_count$', foreign_table_column ) is None \
|
|
916
|
+
and re.search( r'^' + re.escape( foreign_table_name ) + r'_id_alias$', foreign_table_column ) is None \
|
|
917
|
+
and re.search( r'crdc_id$', foreign_table_column ) is None:
|
|
918
|
+
|
|
919
|
+
if foreign_table_column not in foreign_table_data_by_column:
|
|
920
|
+
foreign_table_data_by_column[foreign_table_column] = list()
|
|
921
|
+
|
|
922
|
+
# Encode nulls as '<NA>'.
|
|
923
|
+
# (float) NaN != NaN
|
|
924
|
+
# Testing values for None will miss NaN values, so we use the above truth to test for those too.
|
|
925
|
+
# Empty list values [] will be passed along unmodified.
|
|
926
|
+
|
|
927
|
+
if foreign_table_record[foreign_table_column] is None or foreign_table_record[foreign_table_column] != foreign_table_record[foreign_table_column]:
|
|
928
|
+
|
|
929
|
+
foreign_table_data_by_column[foreign_table_column].append( '<NA>' )
|
|
930
|
+
|
|
931
|
+
else:
|
|
932
|
+
|
|
933
|
+
foreign_table_data_by_column[foreign_table_column].append( foreign_table_record[foreign_table_column] )
|
|
934
|
+
|
|
935
|
+
if foreign_table_name in ['project', 'subject' ]:
|
|
936
|
+
upstream_data_source = sorted( upstream_data_source )
|
|
937
|
+
|
|
938
|
+
foreign_table_data_by_column['data_source'].append( upstream_data_source )
|
|
939
|
+
|
|
940
|
+
if len( foreign_table_data_by_column.keys() ) > 1:
|
|
941
|
+
|
|
942
|
+
foreign_table_column_ordering = [ 'data_source' ]
|
|
943
|
+
|
|
944
|
+
# Summarize (row-wise) 'data_source' values as we do for top-level result rows, unless we're processing external_reference or upstream_identifiers, which encode this data differently or not at all.
|
|
945
|
+
if foreign_table_name in { 'external_reference', 'upstream_identifiers' }:
|
|
946
|
+
foreign_table_column_ordering = []
|
|
947
|
+
|
|
948
|
+
foreign_table_column_list = [ 'external_reference_type', 'external_reference_name', 'external_reference_short_name', 'last_updated', 'uri', 'external_reference_description', 'source_short_name', 'source_url' ]
|
|
949
|
+
|
|
950
|
+
if foreign_table_name != 'external_reference':
|
|
951
|
+
foreign_table_column_list = cached_column_metadata.query( f"table == '{foreign_table_name}'" ).column.to_list()
|
|
952
|
+
|
|
953
|
+
for foreign_table_column in foreign_table_column_list:
|
|
954
|
+
if foreign_table_column in foreign_table_data_by_column:
|
|
955
|
+
foreign_table_column_ordering.append( foreign_table_column )
|
|
956
|
+
|
|
957
|
+
foreign_df_list.append( pd.DataFrame.from_dict( { re.sub( r'^external_reference_', r'', foreign_table_column ) : foreign_table_data_by_column[foreign_table_column] for foreign_table_column in foreign_table_column_ordering }, orient='columns' ) )
|
|
958
|
+
|
|
959
|
+
else:
|
|
960
|
+
|
|
961
|
+
foreign_df_list.append( pd.DataFrame.from_dict( {} ) )
|
|
962
|
+
|
|
963
|
+
# Make a new column called '`foreign_table_name`_data', populated with DataFrames.
|
|
964
|
+
df_columns_to_add[f"{foreign_table_name}_data"] = foreign_df_list
|
|
965
|
+
|
|
966
|
+
elif column not in source_table_columns_in_order:
|
|
967
|
+
added_columns.append( column )
|
|
968
|
+
|
|
969
|
+
for column in df_columns_to_add:
|
|
970
|
+
result_dataframe[column] = df_columns_to_add[column]
|
|
971
|
+
|
|
972
|
+
if len( columns_to_suppress ) > 0:
|
|
973
|
+
log.debug( f"Filtering API columns: {columns_to_suppress}" )
|
|
974
|
+
result_dataframe = result_dataframe.drop( columns=columns_to_suppress )
|
|
975
|
+
|
|
976
|
+
# Resequence the output columns according to the sequence given by the columns() function.
|
|
977
|
+
final_column_order = list()
|
|
978
|
+
|
|
979
|
+
# First, order all the native fields from this endpoint that weren't explicitly excluded by the user, in the default (relative) order.
|
|
980
|
+
for column in source_table_columns_in_order:
|
|
981
|
+
if column in result_dataframe:
|
|
982
|
+
final_column_order.append( column )
|
|
983
|
+
|
|
984
|
+
# Then our `data_source` result summary, if it wasn't suppressed.
|
|
985
|
+
if not suppress_data_source_results:
|
|
986
|
+
final_column_order.append( 'data_source' )
|
|
987
|
+
|
|
988
|
+
# Then the fields from other tables that the user added.
|
|
989
|
+
for added_column in added_columns:
|
|
990
|
+
final_column_order.append( added_column )
|
|
991
|
+
|
|
992
|
+
for added_column in df_columns_to_add:
|
|
993
|
+
final_column_order.append( added_column )
|
|
994
|
+
|
|
995
|
+
if len( result_dataframe.columns ) > 0:
|
|
996
|
+
|
|
997
|
+
result_dataframe = result_dataframe[ final_column_order ]
|
|
998
|
+
|
|
999
|
+
log.debug( 'Handling missing values...' )
|
|
1000
|
+
|
|
1001
|
+
result_column_names = result_dataframe.columns.to_list()
|
|
1002
|
+
|
|
1003
|
+
for column in result_column_names:
|
|
1004
|
+
|
|
1005
|
+
if column != 'data_source' and column not in df_columns_to_add and column not in added_columns:
|
|
1006
|
+
|
|
1007
|
+
# Home-table columns.
|
|
1008
|
+
|
|
1009
|
+
# CDA has no float values. Cast all numeric data to integers.
|
|
1010
|
+
|
|
1011
|
+
if column_data_types[column] in { 'integer', 'bigint' }:
|
|
1012
|
+
|
|
1013
|
+
# Columns of type `float64` can contain NaN (missing) values, which cannot (for some reason)
|
|
1014
|
+
# be stored in Pandas Series objects (i.e., DataFrame columns) of type `int` or `int64`.
|
|
1015
|
+
# Pandas workaround: use extension type 'Int64' (note initial capital) -- itself an alias for numpy.int64 --
|
|
1016
|
+
# which supports the storage of missing values. These will print as '<NA>'.
|
|
1017
|
+
|
|
1018
|
+
if result_dataframe[column].dtype == 'float64':
|
|
1019
|
+
|
|
1020
|
+
result_dataframe[column] = pd.to_numeric( result_dataframe[column] ).round().astype( 'Int64' )
|
|
1021
|
+
|
|
1022
|
+
# (float) NaN != NaN
|
|
1023
|
+
# Testing cell values for None will miss NaN values, which will then generate an error if uncaught before trying to round them.
|
|
1024
|
+
|
|
1025
|
+
result_dataframe[column] = result_dataframe[column].apply( lambda cell_val: [ numpy.int64( round( element_val ) ) if ( element_val is not None and element_val == element_val ) else '<NA>' for element_val in cell_val ] if isinstance( cell_val, list ) else numpy.int64( round( cell_val ) ) if ( cell_val is not None and cell_val == cell_val ) else '<NA>' )
|
|
1026
|
+
|
|
1027
|
+
elif column_data_types[column] in { 'text', 'boolean' }:
|
|
1028
|
+
|
|
1029
|
+
# Replace values that are None (== null) with '<NA>' (to match what we['re forced to] use
|
|
1030
|
+
# for null numeric values). Values that are empty lists [] will be passed along unmodified.
|
|
1031
|
+
|
|
1032
|
+
result_dataframe[column] = result_dataframe[column].fillna( '<NA>' )
|
|
1033
|
+
|
|
1034
|
+
else:
|
|
1035
|
+
|
|
1036
|
+
# This isn't anticipated. Yell if we get something unexpected.
|
|
1037
|
+
log.critical( f"Unexpected data type `{column_data_types[column]}` received; aborting. Please report this event to the CDA development team." )
|
|
1038
|
+
return
|
|
1039
|
+
|
|
1040
|
+
elif column in added_columns:
|
|
1041
|
+
|
|
1042
|
+
# * this column is from a foreign table: if it were a native column, it would never have been added to `added_columns`
|
|
1043
|
+
#
|
|
1044
|
+
# * `collate_results` is False: if it were True, this data would've been kept in the context of its containing
|
|
1045
|
+
# aggregated "X_data" structure and not added to `added_columns`
|
|
1046
|
+
#
|
|
1047
|
+
# * THEREFORE, each cell's data is (by design) a (possibly empty) list of unique observed values
|
|
1048
|
+
|
|
1049
|
+
# Handle missing values atom-wise, building a new column as we go, then swap the result into `result_dataframe`.
|
|
1050
|
+
|
|
1051
|
+
processed_column_data = list()
|
|
1052
|
+
|
|
1053
|
+
for row_index, result_record in result_dataframe.iterrows():
|
|
1054
|
+
|
|
1055
|
+
current_cell_value = result_record[column]
|
|
1056
|
+
|
|
1057
|
+
if current_cell_value == '<NA>':
|
|
1058
|
+
log.critical( f"Unexpected data modification of value in result column {column} (to '<NA>'). Cannot continue: please notify the CDA developers of this event and include any information needed to replicate this message." )
|
|
1059
|
+
return
|
|
1060
|
+
|
|
1061
|
+
elif len( current_cell_value ) == 0:
|
|
1062
|
+
# An empty list.
|
|
1063
|
+
processed_column_data.append( list() )
|
|
1064
|
+
|
|
1065
|
+
else:
|
|
1066
|
+
# We have a nonzero-length list of non-null data values.
|
|
1067
|
+
processed_cell_value = list()
|
|
1068
|
+
|
|
1069
|
+
for list_element in current_cell_value:
|
|
1070
|
+
|
|
1071
|
+
processed_list_element = list_element
|
|
1072
|
+
|
|
1073
|
+
if column_data_types[column] in { 'integer', 'bigint' }:
|
|
1074
|
+
# CDA has no float values. Cast all numeric data to integers.
|
|
1075
|
+
processed_list_element = round( processed_list_element )
|
|
1076
|
+
|
|
1077
|
+
elif column_data_types[column] not in { 'text', 'boolean' }:
|
|
1078
|
+
# This isn't anticipated. Yell if we get something unexpected.
|
|
1079
|
+
log.critical( f"Unexpected data type `{column_data_types[column]}` received; aborting. Please report this event to the CDA development team." )
|
|
1080
|
+
return
|
|
1081
|
+
|
|
1082
|
+
processed_cell_value.append( processed_list_element )
|
|
1083
|
+
|
|
1084
|
+
processed_column_data.append( processed_cell_value )
|
|
1085
|
+
|
|
1086
|
+
result_dataframe[column] = processed_column_data
|
|
1087
|
+
|
|
1088
|
+
#############################################################################################################################
|
|
1089
|
+
# Return our response to the user.
|
|
1090
|
+
#############################################################################################################################
|
|
1091
|
+
|
|
1092
|
+
if return_data_as == '' or return_data_as == 'dataframe':
|
|
1093
|
+
|
|
1094
|
+
# Right now, the default is the same as if the user had specified return_data_as='dataframe'.
|
|
1095
|
+
return result_dataframe
|
|
1096
|
+
|
|
1097
|
+
elif return_data_as == 'tsv':
|
|
1098
|
+
|
|
1099
|
+
log.debug( f"Printing results to TSV file '{output_file}'" )
|
|
1100
|
+
|
|
1101
|
+
# Write results to a user-specified TSV.
|
|
1102
|
+
|
|
1103
|
+
try:
|
|
1104
|
+
|
|
1105
|
+
# We can't use DataFrame.to_csv() because it doesn't handle nested DataFrames the way we want.
|
|
1106
|
+
|
|
1107
|
+
with open( output_file, 'w' ) as OUT:
|
|
1108
|
+
|
|
1109
|
+
print( *result_dataframe.columns.to_list(), sep='\t', file=OUT )
|
|
1110
|
+
|
|
1111
|
+
for row_index, result_record in result_dataframe.iterrows():
|
|
1112
|
+
|
|
1113
|
+
row_data = list()
|
|
1114
|
+
|
|
1115
|
+
for column in result_dataframe.columns.to_list():
|
|
1116
|
+
|
|
1117
|
+
if isinstance( result_record[column], pd.DataFrame ):
|
|
1118
|
+
|
|
1119
|
+
list_of_dicts_with_na_nulls = result_record[column].to_dict( orient='records' )
|
|
1120
|
+
|
|
1121
|
+
list_of_dicts_with_empty_string_nulls = list()
|
|
1122
|
+
|
|
1123
|
+
# This assumes 2D DataFrames, which is safe at time of writing (2025-05-07).
|
|
1124
|
+
|
|
1125
|
+
for dict_with_na_nulls in list_of_dicts_with_na_nulls:
|
|
1126
|
+
|
|
1127
|
+
dict_with_empty_string_nulls = dict()
|
|
1128
|
+
|
|
1129
|
+
for key in dict_with_na_nulls:
|
|
1130
|
+
|
|
1131
|
+
if dict_with_na_nulls[key] == '<NA>':
|
|
1132
|
+
|
|
1133
|
+
dict_with_empty_string_nulls[key] = ''
|
|
1134
|
+
|
|
1135
|
+
else:
|
|
1136
|
+
|
|
1137
|
+
dict_with_empty_string_nulls[key] = dict_with_na_nulls[key]
|
|
1138
|
+
|
|
1139
|
+
list_of_dicts_with_empty_string_nulls.append( dict_with_empty_string_nulls )
|
|
1140
|
+
|
|
1141
|
+
if len( list_of_dicts_with_empty_string_nulls ) > 0:
|
|
1142
|
+
|
|
1143
|
+
row_data.append( list_of_dicts_with_empty_string_nulls )
|
|
1144
|
+
|
|
1145
|
+
else:
|
|
1146
|
+
|
|
1147
|
+
row_data.append( '' )
|
|
1148
|
+
|
|
1149
|
+
elif result_record[column] is None or ( isinstance( result_record[column], str ) and result_record[column] == '<NA>' ):
|
|
1150
|
+
|
|
1151
|
+
row_data.append( '' )
|
|
1152
|
+
|
|
1153
|
+
else:
|
|
1154
|
+
|
|
1155
|
+
row_data.append( result_record[column] )
|
|
1156
|
+
|
|
1157
|
+
print( *row_data, sep='\t', file=OUT )
|
|
1158
|
+
|
|
1159
|
+
return
|
|
1160
|
+
|
|
1161
|
+
except Exception as error:
|
|
1162
|
+
log.error( f"Couldn't write to requested output file '{output_file}': got error of type '{type(error)}', with error message '{error}'." )
|
|
1163
|
+
return
|
|
1164
|
+
|
|
1165
|
+
log.critical( 'Something has gone unexpectedly and disastrously wrong with result-data postprocessing. Please alert the CDA devs to this event and include details of how to reproduce this error.' )
|
|
1166
|
+
return
|
|
1167
|
+
|
|
1168
|
+
#############################################################################################################################
|
|
1169
|
+
#
|
|
1170
|
+
# END get_data
|
|
1171
|
+
#
|
|
1172
|
+
#############################################################################################################################
|
|
1173
|
+
|
|
1174
|
+
|