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/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
+