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.
@@ -0,0 +1,622 @@
1
+ import re
2
+ import sys
3
+
4
+ from cdapython.discover import tables
5
+
6
+ #############################################################################################################################
7
+ #
8
+ # normalize_to_list( parameter_name, user_supplied_parameter_value, value_type ):
9
+ #
10
+ # Covert single bare values to one-element lists, and convert all values to lowercase, to support
11
+ # strong downstream assumptions about parameter structures.
12
+ #
13
+ # Fail if:
14
+ #
15
+ # * `user_supplied_parameter_value` is neither a list of `value_type` elements nor a single `value_type` value
16
+ #
17
+ #############################################################################################################################
18
+
19
+ def normalize_to_list( parameter_name, user_supplied_parameter_value, value_type ):
20
+ """
21
+ For any user-supplied parameter data that represents a single value
22
+ for a parameter that can in general take multiple concurrent values,
23
+ convert that data into a one-element list, so we don't have to care
24
+ downstream about whether the parameter value was receievd as a
25
+ single bare value or as a list of values. Also convert all values to
26
+ lowercase.
27
+
28
+ Arguments:
29
+ parameter_name ( string; required ):
30
+ The name of the user-facing parameter whose value we're checking.
31
+
32
+ user_supplied_parameter_value ( unknown type; required ):
33
+ The value of `parameter_name` as supplied by the user.
34
+
35
+ value_type ( Python class name; required ):
36
+ The expected data type against which we're going to validate
37
+ `user_supplied_parameter_value`.
38
+ """
39
+
40
+ # Start by assuming everything's fine, and that we have the most general case,
41
+ # namely a list of elements of the expected type.
42
+
43
+ list_to_return = user_supplied_parameter_value
44
+
45
+ if user_supplied_parameter_value is None:
46
+
47
+ # This parameter was not set by the user: make it an empty list.
48
+ list_to_return = []
49
+
50
+ elif isinstance( user_supplied_parameter_value, value_type ):
51
+ # We have a single value of the correct type. Convert it into a one-element list to return.
52
+ if value_type == str:
53
+ list_to_return = [ user_supplied_parameter_value.lower() ]
54
+ else:
55
+ list_to_return = [ user_supplied_parameter_value ]
56
+
57
+ elif not isinstance( user_supplied_parameter_value, list ):
58
+ # We have neither a value of the right type nor a list: can't continue.
59
+ raise RuntimeError( f"User-supplied parameter '{parameter_name}' was assigned a non-list value of unexpected type '{type(user_supplied_parameter_value)}'; should be '{value_type}' or 'list({value_type})'. Please fix." )
60
+
61
+ elif not all( isinstance( element, value_type ) for element in user_supplied_parameter_value ):
62
+ # We have a list, but not all of its elements are of the expected type: can't continue.
63
+ raise RuntimeError( f"User-supplied parameter '{parameter_name}' was assigned a list containing elements of unexpected type '{type(user_supplied_parameter_value)}'; elements should all be '{value_type}'. Please fix." )
64
+
65
+ if value_type == str:
66
+ return [ element.lower() for element in list_to_return ]
67
+ else:
68
+ return list_to_return
69
+
70
+ #############################################################################################################################
71
+ #
72
+ # validate_and_transform_match_filter_list( cached_column_metadata, match_statement ):
73
+ #
74
+ # Fail if:
75
+ #
76
+ # * requested columns don't exist
77
+ # * illegal or type-inappropriate operators are used
78
+ # * filter values don't match the data types of the columns they're paired with
79
+ # * wildcards appear anywhere but at the ends of a filter string
80
+ #
81
+ # ...and recombine elements for use in querying
82
+ #
83
+ #############################################################################################################################
84
+
85
+ def validate_and_transform_match_filter_list( cached_column_metadata, match_statement_list, enforce_column_uniqueness=False ):
86
+ """
87
+ Parse `match_*` filter expressions and transform for syntax validity with the API.
88
+
89
+ Arguments:
90
+ cached_column_metadata ( DataFrame; required ):
91
+ Column metadata from the API, cached by the calling function for downstream reuse without further network disturbance.
92
+ The data structure is a DataFrame with columns [ 'table', 'column', 'data_type', 'nullable', 'description' ].
93
+
94
+ match_statement_list ( list of strings; optional ):
95
+ One or more conditions, expressed as filter strings
96
+
97
+ enforce_column_uniqueness ( boolean; optional ):
98
+ Should we only allow each column to be filtered exactly once? (for match_all)
99
+ Default: False (for match_any)
100
+
101
+ Returns:
102
+ List of transformed and cleaned up match statements, or an empty list if no inputs were given
103
+
104
+ """
105
+ normalized_match_statement_list = []
106
+
107
+ if len( match_statement_list ) == 0:
108
+ return normalized_match_statement_list
109
+
110
+ #############################################################################################################################
111
+ # Define and categorize lists of supported filter-string operators.
112
+
113
+ comparison_operators = {
114
+ '>',
115
+ '>=',
116
+ '<',
117
+ '<='
118
+ }
119
+
120
+ flip_comparison_operator = {
121
+ '>' : '<',
122
+ '>=' : '<=',
123
+ '<' : '>',
124
+ '<=' : '>='
125
+ }
126
+
127
+ equality_operators = {
128
+ '=',
129
+ '!='
130
+ }
131
+
132
+ allowed_operators = comparison_operators | equality_operators
133
+
134
+ #############################################################################################################################
135
+ # Enumerate restrictions on operator use to appropriate data types.
136
+
137
+ operators_by_data_type = {
138
+ 'bigint': allowed_operators,
139
+ 'boolean': equality_operators,
140
+ 'integer': allowed_operators,
141
+ 'numeric': allowed_operators,
142
+ 'text': equality_operators,
143
+ }
144
+
145
+ #############################################################################################################################
146
+ # Enable aliases for various ways to say "True" and "False". (Case will be lowered as soon as each literal is received.)
147
+
148
+ boolean_alias = {
149
+ 'true': 'true',
150
+ 't': 'true',
151
+ 'false': 'false',
152
+ 'f': 'false'
153
+ }
154
+
155
+ # Intercept filter expressions of the form <numeric literal> <comparison operator> <column name> <comparison operator> <numeric literal>
156
+ # and split each into two two-term/one-operator comparisons, marking the field as exempt from potential column uniqueness
157
+ # constraints (unless there are more top-level filters on this column in addition to the first 'X <= column < Y' expression encountered)
158
+ column_names_exempt_from_uniqueness_check = set()
159
+ new_match_statement_list = list()
160
+
161
+ for filter_expression in match_statement_list:
162
+ match_result = re.search( r'^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S.*)$', filter_expression ) if isinstance( filter_expression, str ) else None
163
+ if match_result is not None:
164
+ left_numeric = match_result.group(1)
165
+ left_operator = match_result.group(2)
166
+ column_name = match_result.group(3)
167
+ right_operator = match_result.group(4)
168
+ right_numeric = match_result.group(5)
169
+ if re.search( r'^[-+]?\d+(\.\d+)?$', left_numeric ) is None or re.search( r'^[-+]?\d+(\.\d+)?$', right_numeric ) is None or \
170
+ left_operator not in comparison_operators or right_operator not in comparison_operators:
171
+ # This is not a numeric comparison of the type we seek. Pass it along to downstream validation.
172
+ new_match_statement_list.append( filter_expression )
173
+ else:
174
+ # If we're enforcing column uniqueness, then at this level, whatever happens to a ternary filter downstream, we
175
+ # still only want to see each column filtered via at most one filter expression.
176
+ if enforce_column_uniqueness and column_name in column_names_exempt_from_uniqueness_check:
177
+ raise RuntimeError( f"Requested column '{filter_column_name}' cannot be used twice in a 'match_all' list." )
178
+ else:
179
+ column_names_exempt_from_uniqueness_check.add( column_name )
180
+ new_match_statement_list.append( f"{column_name} {flip_comparison_operator[left_operator]} {left_numeric}" )
181
+ new_match_statement_list.append( f"{column_name} {right_operator} {right_numeric}" )
182
+ else:
183
+ new_match_statement_list.append( filter_expression )
184
+
185
+ match_statement_list = new_match_statement_list
186
+
187
+ # Track which columns have been seen in case we need to catch disallowed sets of multiple queries on the same column.
188
+ seen_filter_column_names = set()
189
+
190
+ for filter_expression in match_statement_list:
191
+
192
+ #############################################################################################################################
193
+ # Enforce the simplified cdapython query syntax as described in the docs, but quietly allow synonyms if received. Map them
194
+ # first back to the canonical operator in the simplified query syntax, then validate, then normalize for API request syntax
195
+ # as if they'd come in as their canonical versions.
196
+
197
+ # Take care of two-word operators first: downstream, unmodified, they break simplifying assumptions
198
+ # about filter string tokenization that should safe to make given the spec of the cdapython query syntax.
199
+
200
+ if re.search( r'^(\S+)\s+IS\s+NOT\s+(\S.*)$', filter_expression, flags=re.IGNORECASE ) is not None:
201
+ filter_expression = re.sub( r'^(\S+)\s+IS\s+NOT\s+(\S.*)$', r'\1 != \2', filter_expression, flags=re.IGNORECASE )
202
+
203
+ elif re.search( r'^(\S+)\s+NOT\s+LIKE\s+(\S.*)$', filter_expression, flags=re.IGNORECASE ) is not None:
204
+ filter_expression = re.sub( r'^(\S+)\s+NOT\s+LIKE\s+(\S.*)$', r'\1 != \2', filter_expression, flags=re.IGNORECASE )
205
+
206
+ # Normalize the rest of the known operators (used for null and fuzzy matches) to conform to cdapython query syntax.
207
+
208
+ elif re.search( r'^(\S+)\s+IS\s+(\S.*)$', filter_expression, flags=re.IGNORECASE ) is not None:
209
+ filter_expression = re.sub( r'^(\S+)\s+IS\s+(\S.*)$', r'\1 = \2', filter_expression, flags=re.IGNORECASE )
210
+
211
+ elif re.search( r'^(\S+)\s+LIKE\s+(\S.*)$', filter_expression, flags=re.IGNORECASE ) is not None:
212
+ filter_expression = re.sub( r'^(\S+)\s+LIKE\s+(\S.*)$', r'\1 = \2', filter_expression, flags=re.IGNORECASE )
213
+
214
+ #############################################################################################################################
215
+ # Validate minimal filter string format: <non-whitespace string (column name)><whitespace><non-whitespace string (operator)><whitespace><non-whitespace string (beginning of value to match)><any mix of whitespace and non-whitespace characters (end of value to match)>
216
+
217
+ if not isinstance( filter_expression, str ) or len( filter_expression ) == 0:
218
+ raise RuntimeError( f"Match parameters must be nonempty filter strings: you specified '{filter_expression}' (from match list {match_statement_list}), which is not." )
219
+
220
+ if re.search( r'^\S+\s+\S+\s+\S.*$', filter_expression ) is None:
221
+ raise RuntimeError( f"Filter string '{filter_expression}' does not conform to 'COLUMN_NAME OP VALUE' format. See the help text for details." )
222
+
223
+ #############################################################################################################################
224
+ # Now parse the filter expression and validate COLUMN and OP tokens.
225
+
226
+ # Try to extract a column name from this filter expression. Don't be case-sensitive.
227
+ filter_column_name = re.sub( r'^([\S]+)\s.*', r'\1', filter_expression ).lower()
228
+
229
+ # Have we seen this before (and do we care)?
230
+ if enforce_column_uniqueness:
231
+ if filter_column_name not in column_names_exempt_from_uniqueness_check and filter_column_name in seen_filter_column_names:
232
+ raise RuntimeError( f"Requested column '{filter_column_name}' cannot be used twice in a 'match_all' list." )
233
+ else:
234
+ seen_filter_column_names.add( filter_column_name )
235
+
236
+ # Let's see if this thing exists.
237
+ filter_column_metadata = cached_column_metadata[ cached_column_metadata['column'] == filter_column_name ]
238
+
239
+ # We should see a one-row DataFrame.
240
+ if filter_column_metadata is None or len( filter_column_metadata ) != 1:
241
+ raise RuntimeError( f"Requested column '{filter_column_name}' is not a searchable CDA column." )
242
+
243
+ # Identify the data type in the column being filtered.
244
+ target_data_type = filter_column_metadata['data_type'].iloc[0]
245
+
246
+ # Ensure that we recognize this data type.
247
+ if target_data_type not in operators_by_data_type:
248
+ raise RuntimeError( f"Requested column '{filter_column_name}' is of unknown data_type '{target_data_type}': cannot continue, please contact the CDA devs with a description of this event." )
249
+
250
+ # See what the operator is.
251
+ filter_operator = re.sub( r'^\S+\s+(\S+)\s.*', r'\1', filter_expression )
252
+
253
+ # Be kind to computer scientists.
254
+ if filter_operator == '==':
255
+ filter_operator = '='
256
+
257
+ # Make sure the operator specified is allowed for the data type of the column being filtered.
258
+ if filter_operator not in operators_by_data_type[target_data_type]:
259
+ raise RuntimeError( f"Operator '{filter_operator}' is not usable for values of type '{target_data_type}'." )
260
+
261
+ # Extract the filter value/pattern.
262
+ filter_value = re.sub( r'^\S+\s+\S+\s+(\S.*)$', r'\1', filter_expression )
263
+
264
+ # We said quotes weren't required for string values. Doesn't technically mean they can't be used. Remove them.
265
+ filter_value = re.sub( r'''^['"]*(.*)['"]*$''', r'\1', filter_value)
266
+
267
+ #############################################################################################################################
268
+ # Validate VALUE types and process wildcards.
269
+
270
+ # Ignore leading and trailing whitespace unless we're dealing with strings.
271
+
272
+ if target_data_type != 'text':
273
+ filter_value = filter_value.strip()
274
+
275
+ if filter_value.lower() != 'null':
276
+
277
+ if target_data_type == 'boolean':
278
+
279
+ # If we're supposed to be in a boolean column, make sure we've got a true/false value. Normalize recognized synonyms for valid values.
280
+
281
+ filter_value = filter_value.lower()
282
+
283
+ if filter_value not in boolean_alias:
284
+ raise RuntimeError( f"Requested column {filter_column_name} has data type 'boolean', requiring a true/false value; you specified '{filter_value}', which is not valid." )
285
+ else:
286
+ filter_value = boolean_alias[filter_value]
287
+
288
+ elif target_data_type in [ 'bigint', 'integer', 'numeric' ]:
289
+
290
+ # If we're supposed to be in a numeric column, make sure we've got a number.
291
+
292
+ if re.search( r'^[-+]?\d+(\.\d+)?$', filter_value ) is None:
293
+ raise RuntimeError( f"Requested column {filter_column_name} has data type '{target_data_type}', requiring a number value; you specified '{filter_value}', which is not." )
294
+
295
+ elif target_data_type == 'text':
296
+
297
+ # Check for wildcards: if found, adjust operator and
298
+ # wildcard syntax to match API expectations on incoming queries.
299
+
300
+ original_filter_value = filter_value
301
+
302
+ if re.search( r'^\*', filter_value ) is not None or re.search( r'\*$', filter_value ) is not None:
303
+
304
+ # API expects lowercase operators.
305
+ if filter_operator == '!=':
306
+ filter_operator = 'not like'
307
+ else:
308
+ filter_operator = 'like'
309
+
310
+ if re.search(r'.\*.', filter_value) is not None:
311
+ raise RuntimeError( f"Wildcards (*) are only allowed at the ends of string values; string '{original_filter_value}' is noncompliant (it has one in the middle). Please fix." )
312
+
313
+ # API expects percent signs.
314
+ filter_value = re.sub( r'\*', r'%', filter_value )
315
+
316
+ # API needs strings quoted. Minimal replication case: match a string that contains only numbers. Need to distinguish from integer input.
317
+ filter_value = f"'{filter_value}'"
318
+
319
+ else:
320
+
321
+ # Just to be safe. Types change.
322
+ raise RuntimeError( f"Unanticipated data_type '{target_data_type}' encountered for filter-target column '{filter_column_name}'; cannot continue. Please report this event to CDA developers." )
323
+
324
+ else:
325
+
326
+ if filter_operator == '=':
327
+ filter_operator = 'is'
328
+ elif filter_operator == '!=':
329
+ filter_operator = 'is not'
330
+ else:
331
+ raise RuntimeError( f"Unexpected operator encountered for NULL: '{filter_operator}' (from '{filter_expression}') -- please use = or != instead." )
332
+
333
+ filter_value = 'null'
334
+
335
+ normalized_filter_expression = filter_column_name + ' ' + filter_operator + ' ' + filter_value
336
+
337
+ normalized_match_statement_list.append( normalized_filter_expression )
338
+
339
+ return normalized_match_statement_list
340
+
341
+ #############################################################################################################################
342
+ #
343
+ # END validate_and_transform_match_filter_list
344
+ #
345
+ #############################################################################################################################
346
+
347
+ #############################################################################################################################
348
+ #
349
+ # validate_and_transform_match_from_file_values( target_data_type, input_values ):
350
+ #
351
+ # Parse `match_from_file` filter values: complain if
352
+ #
353
+ # * filter values don't match the data types of the columns they're paired with
354
+ # * wildcards appear anywhere (they're not compatible with the IN keyword, and we don't currently support the construction of per-value LIKE filters)
355
+ #
356
+ # ...strip apostrophes, and save parse results as a processed set of valid values.
357
+ #
358
+ #############################################################################################################################
359
+
360
+ def validate_and_transform_match_from_file_values( cda_column_to_match, target_data_type, input_values ):
361
+
362
+ processed_values = set()
363
+
364
+ boolean_alias = {
365
+ 'true': 'true',
366
+ 't': 'true',
367
+ 'false': 'false',
368
+ 'f': 'false'
369
+ }
370
+
371
+ for target_value in input_values:
372
+
373
+ # Validate value types and test for wildcards.
374
+
375
+ if target_data_type == 'boolean':
376
+
377
+ # If we're supposed to be in a boolean column, make sure we've got a true/false value.
378
+ if target_value.lower() not in boolean_alias:
379
+ raise RuntimeError( f"match_from_file: requested column {cda_column_to_match} has data type 'boolean', requiring a true/false value; you specified '{target_value}', which is neither." )
380
+
381
+ else:
382
+ target_value = boolean_alias[target_value]
383
+
384
+ elif target_data_type in ['bigint', 'integer', 'numeric']:
385
+
386
+ # If we're supposed to be in a numeric column, make sure we've got a number.
387
+ if re.search( r'^[-+]?\d+(\.\d+)?$', target_value ) is None:
388
+ raise RuntimeError( f"match_from_file: requested column {cda_column_to_match} has data type '{target_data_type}', requiring a number value; you specified '{target_value}', which is not." )
389
+
390
+ elif target_data_type == 'text':
391
+
392
+ # Check for wildcards: if found, vomit.
393
+ if re.search(r'\*', target_value) is not None:
394
+ raise RuntimeError( f"match_from_file: wildcards (*) are disallowed here (only exact matches are supported for this option); value '{target_value}' is noncompliant. Please fix." )
395
+
396
+ else:
397
+
398
+ # Just to be safe. Types change.
399
+ raise RuntimeError( f"match_from_file: unanticipated `target_data_type` '{target_data_type}', cannot continue. Please report this event to CDA developers." )
400
+
401
+ processed_values.add( re.sub( r"'", r'', target_value ) )
402
+
403
+ return processed_values
404
+
405
+ #############################################################################################################################
406
+ #
407
+ # END validate_and_transform_match_from_file_values
408
+ #
409
+ #############################################################################################################################
410
+
411
+ #############################################################################################################################
412
+ #
413
+ # validate_parameter_values( called_function, cached_column_metadata, valid_data_sources, table, match_from_file, data_source, add_columns, exclude_columns, return_data_as, output_file, log ):
414
+ #
415
+ # Validate user-supplied parameters as passed to `called_function`, after first
416
+ # having passed relevant parameters (`data_source`, `add_columns`, `exclude_columns`)
417
+ # through normalize_to_list().
418
+ #
419
+ # Fail if:
420
+ #
421
+ # * `called_function` is not in [ 'column_values', 'get_data', 'summarize' ]
422
+ # * `table` is not a CDA table
423
+ # * `match_from_file` isn't a dict which (if non-null) specifies an accessible input file
424
+ # containing a user-specified column, whose values are to be matched against a CDA column
425
+ # that exists
426
+ # * `data_source` isn't a single valid upstream data source label (for `called_function`=='column_values')
427
+ # or a list of valid upstream data source labels (for `called_function` in [ 'get_data', 'summarize' ])
428
+ # * `add_columns` or `exclude_columns` contain invalid CDA column names ("{table}.*" macros are allowed)
429
+ # * `collate_results` isn't a boolean value or None, depending on `called_function`
430
+ # * `include_external_refs` isn't a boolean value or None, depending on `called_function`
431
+ # * `return_data_as` isn't one of the allowable types for `called_function`
432
+ # * The value of `output_file` isn't consistent with the directive in `return_data_as` for `called_function`
433
+ #
434
+ #############################################################################################################################
435
+
436
+ def validate_parameter_values(
437
+ called_function,
438
+ cached_column_metadata,
439
+ valid_data_sources,
440
+ table,
441
+ match_from_file,
442
+ data_source,
443
+ add_columns,
444
+ exclude_columns,
445
+ collate_results,
446
+ include_external_refs,
447
+ return_data_as,
448
+ output_file,
449
+ log
450
+ ):
451
+ # The data structure coming back from columns() is a DataFrame with columns [ 'table', 'column', 'data_type', 'nullable', 'description' ].
452
+
453
+ # Make sure `table` exists.
454
+
455
+ if table is None or not isinstance( table, str ) or table not in cached_column_metadata['table'].unique():
456
+ raise RuntimeError( f"The required parameter 'table' must be a searchable CDA table; you supplied '{table}', which is not. Please run tables() for a list." )
457
+
458
+ if called_function in [ 'get_data', 'summarize' ]:
459
+
460
+ # Check `match_from_file` data for sanity.
461
+
462
+ # Is `match_from_file` a dict with the expected set of keys?
463
+ if not isinstance( match_from_file, dict ) or set( match_from_file.keys() ) != { 'input_file', 'input_column', 'cda_column_to_match' }:
464
+ raise RuntimeError( f"'match_from_file' must be a 3-element dictionary with keys ['input_file', 'input_column', 'cda_column_to_match']; you specified '{match_from_file}', which is not." )
465
+
466
+ # Does `match_from_file`['cda_column_to_match'] exist?
467
+ if match_from_file['cda_column_to_match'] != '' and match_from_file['cda_column_to_match'] not in cached_column_metadata['column'].unique():
468
+ raise RuntimeError( f"'match_from_file['cda_column_to_match']' must be a valid CDA column; you supplied {match_from_file['cda_column_to_match']}, which is not." )
469
+
470
+ # Does `match_from_file`['input_file'] exist and does it have a column named `match_from_file`['input_column']?
471
+ if match_from_file['input_file'] != '':
472
+
473
+ try:
474
+ with open( match_from_file['input_file'] ) as IN:
475
+ input_file_column_names = next( IN ).rstrip( '\n' ).split( '\t' )
476
+ if match_from_file['input_column'] not in input_file_column_names:
477
+ raise RuntimeError( f"'match_from_file['input_column']' must specify a column that exists in 'match_from_file['input_file']'. You specified '{match_from_file['input_column']}', which is not present in '{match_from_file['input_file']}'." )
478
+
479
+ except Exception as error:
480
+ raise RuntimeError( f"Couldn't read from match_from_file input file '{match_from_file['input_file']}': got error of type '{type(error)}', with error message '{error}'." )
481
+
482
+ # Are the values given in `match_from_file` internally consistent? (Strange results might occur if not.)
483
+ if match_from_file['cda_column_to_match'] == '':
484
+ if match_from_file['input_file'] != '' or match_from_file['input_column'] != '':
485
+ raise RuntimeError( f"If the 'match_from_file' parameter is used, it must be a 3-element dictionary with keys ['input_file', 'input_column', 'cda_column_to_match'] pointing to non-empty values. You specified '{match_from_file}', which is not that." )
486
+ elif match_from_file['input_file'] == '':
487
+ if match_from_file['cda_column_to_match'] != '' or match_from_file['input_column'] != '':
488
+ raise RuntimeError( f"If the 'match_from_file' parameter is used, it must be a 3-element dictionary with keys ['input_file', 'input_column', 'cda_column_to_match'] pointing to non-empty values. You specified '{match_from_file}', which is not that." )
489
+ elif match_from_file['input_column'] == '':
490
+ if match_from_file['cda_column_to_match'] != '' or match_from_file['input_file'] != '':
491
+ raise RuntimeError( f"If the 'match_from_file' parameter is used, it must be a 3-element dictionary with keys ['input_file', 'input_column', 'cda_column_to_match'] pointing to non-empty values. You specified '{match_from_file}', which is not that." )
492
+
493
+ if match_from_file['input_file'] != '' and match_from_file['input_file'] == output_file:
494
+ raise RuntimeError( f"You specified the same file ('{output_file}') as both a source of filter values (via 'match_from_file') and the target output file ( via 'output_file'). Please make sure these two files are different." )
495
+
496
+ # Check that `data_source` is a single valid upstream data source label (for `called_function`=='column_values')
497
+ # or a list of valid upstream data source labels (for `called_function` in [ 'get_data', 'summarize' ]).
498
+ # `valid_data_sources` was normalized to uppercase when it was constructed by the caller.
499
+
500
+ normalized_data_source = list()
501
+
502
+ if called_function == 'column_values' and len( data_source ) > 1:
503
+ raise RuntimeError( f"The 'data_source' parameter must be one valid data source name (e.g. 'GDC'); you specified '{data_source}', which is not." )
504
+
505
+ for ds in data_source:
506
+ if ds.upper() not in valid_data_sources:
507
+ raise RuntimeError( f"The 'data_source' parameter must be a list containing one or more of [ {', '.join( sorted( valid_data_sources ) )} ]. You supplied '{data_source}', which is not that." )
508
+ normalized_data_source.append( ds.upper() )
509
+
510
+ data_source = normalized_data_source
511
+
512
+ # Make sure CDA columns named in `add_columns` exist.
513
+
514
+ for column_name in add_columns:
515
+ match_result = re.search( r'^(.+)\.\*$', column_name )
516
+
517
+ if match_result is not None:
518
+ foreign_table = match_result.group(1)
519
+ if foreign_table not in tables():
520
+ raise RuntimeError( f"'add_columns' can only contain valid CDA column names, or macros for whole tables like 'treatment.*'. You specified '{column_name}', which is neither." )
521
+ elif column_name not in cached_column_metadata['column'].unique():
522
+ raise RuntimeError( f"'add_columns' can only contain valid CDA column names, or macros for whole tables like 'treatment.*'. You specified '{column_name}', which is neither." )
523
+
524
+ # Make sure CDA columns named in `exclude_columns` exist.
525
+
526
+ for column_name in exclude_columns:
527
+ match_result = re.search( r'^(.+)\.\*$', column_name )
528
+
529
+ if match_result is not None:
530
+ foreign_table = match_result.group(1)
531
+ if foreign_table not in tables():
532
+ raise RuntimeError( f"'exclude_columns' can only contain valid CDA column names, or macros for whole tables like 'treatment.*'. You specified '{column_name}', which is neither." )
533
+ elif column_name not in cached_column_metadata['column'].unique():
534
+ raise RuntimeError( f"'exclude_columns' can only contain valid CDA column names. You specified '{column_name}', which is not that." )
535
+
536
+ # Check that `collate_results` is a boolean (for get_data) or None (for column_values, summarize) and that `called_function` has an expected value.
537
+
538
+ if called_function == 'get_data':
539
+ if collate_results != True and collate_results != False:
540
+ raise RuntimeError( f"The `collate_results` parameter must be set to True or False; you specified '{collate_results}', which is neither." )
541
+ if include_external_refs != True and include_external_refs != False:
542
+ raise RuntimeError( f"The `include_external_refs` parameter must be set to True or False; you specified '{include_external_refs}', which is neither." )
543
+ elif called_function == 'summarize' or called_function == 'column_values':
544
+ if collate_results is not None:
545
+ raise RuntimeError( f"Something has gone horribly and unexpectedly wrong with respect to phantom collate_results values in summarize() calls; please notify the CDA devs of this event." )
546
+ if include_external_refs is not None:
547
+ raise RuntimeError( f"Something has gone horribly and unexpectedly wrong with respect to phantom include_external_refs values in summarize() calls; please notify the CDA devs of this event." )
548
+ else:
549
+ raise RuntimeError( f"`called_function` must be one of [ 'column_values', 'get_data', 'summarize' ] -- '{called_function}' is none of those. Please notify the CDA devs of this event." )
550
+
551
+ # Process return-type directives `return_data_as` and `output_file`.
552
+
553
+ # We can't do much validation on filenames. If `output_file` isn't
554
+ # a locally writeable path, it'll fail when we try to open it for
555
+ # writing.
556
+
557
+ allowed_return_types = {
558
+ 'get_data': {
559
+ 'dataframe',
560
+ 'tsv'
561
+ },
562
+ 'summarize': {
563
+ '',
564
+ 'dataframe_list',
565
+ 'dict',
566
+ 'json'
567
+ }
568
+ }
569
+
570
+ if return_data_as is not None:
571
+
572
+ if not isinstance( return_data_as, str ):
573
+ raise RuntimeError( f"Unrecognized 'return_data_as' value '{return_data_as}' requested. Valid values are [ {', '.join( allowed_return_types[called_function] )} ]." )
574
+
575
+ if not isinstance( output_file, str ):
576
+ raise RuntimeError( f"The `output_file` parameter, if not omitted, should be a string containing a path to the desired output file. You supplied '{output_file}', which is not a string, let alone a valid path." )
577
+
578
+ if return_data_as not in allowed_return_types[called_function]:
579
+ # Complain if we receive an unexpected `return_data_as` value.
580
+ raise RuntimeError( f"Unrecognized 'return_data_as' value '{return_data_as}' requested. Valid values are [ {', '.join( allowed_return_types[called_function] )} ]." )
581
+
582
+ elif called_function == 'get_data':
583
+
584
+ if return_data_as == 'tsv' and output_file == '':
585
+ # If the user asks for TSV, they also have to give us a path for the output file. If they didn't, complain.
586
+ raise RuntimeError( 'Return type \'tsv\' was requested, but \'output_file\' was not specified. Please specify output_file=\'some/path/string/to/write/your/tsv/to/your_tsv_output_file.tsv\'.' )
587
+
588
+ elif return_data_as != 'tsv' and output_file != '':
589
+
590
+ # If the user put something in the `output_file` parameter but didn't specify `result_data_as`='tsv',
591
+ # they most likely want their data saved to a file (so ignoring the parameter misconfiguration
592
+ # isn't safe), but ultimately we can't be sure what they meant (so taking an action isn't safe),
593
+ # so we complain and ask them to clarify.
594
+
595
+ raise RuntimeError( f"'output_file' was specified, but this is only meaningful if 'return_data_as' is set to 'tsv'. You requested return_data_as='{return_data_as}'.\n(Note that if you don't specify any value for 'return_data_as', it defaults to 'dataframe'.)." )
596
+
597
+ elif called_function == 'summarize':
598
+
599
+ if return_data_as == 'json' and output_file == '':
600
+ # If the user asks for JSON, they also have to give us a path for the output file. If they didn't, complain.
601
+ raise RuntimeError( "Return type 'json' requested, but 'output_file' not specified. Please specify output_file='some/path/string/to/write/your/json/to'." )
602
+
603
+ elif return_data_as != 'json' and output_file != '':
604
+ # If the user put something in the `output_file` parameter but didn't specify `result_data_as='json'`,
605
+ # they most likely want their data saved to a file (so ignoring the parameter misconfiguration
606
+ # isn't safe), but ultimately we can't be sure what they meant (so taking an action isn't safe),
607
+ # so we complain and ask them to clarify.
608
+
609
+ raise RuntimeError( f"'output_file' was specified, but this is only meaningful if 'return_data_as' is set to 'json'. You requested return_data_as='{return_data_as}'.\n(Note that if you don't specify any value for 'return_data_as', it defaults to printing tables to the standard output stream and not to an output file.)." )
610
+
611
+ else:
612
+ raise RuntimeError( f"Got unpexpectedly non-null 'return_data_as' value '{return_data_as}' from function '{called_function}'. Please report this event to the CDA devs." )
613
+
614
+ return
615
+
616
+ #############################################################################################################################
617
+ #
618
+ # END validate_parameter_values
619
+ #
620
+ #############################################################################################################################
621
+
622
+