cdapython 2.0.2__tar.gz → 2.0.3__tar.gz
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-2.0.2 → cdapython-2.0.3}/CHANGELOG.md +4 -0
- {cdapython-2.0.2 → cdapython-2.0.3}/PKG-INFO +4 -2
- {cdapython-2.0.2 → cdapython-2.0.3}/cdapython/get_data.py +1 -2
- {cdapython-2.0.2 → cdapython-2.0.3}/cdapython/summarize.py +1 -2
- {cdapython-2.0.2 → cdapython-2.0.3}/cdapython/validation.py +112 -98
- {cdapython-2.0.2 → cdapython-2.0.3}/pyproject.toml +2 -2
- {cdapython-2.0.2 → cdapython-2.0.3}/LICENSE +0 -0
- {cdapython-2.0.2 → cdapython-2.0.3}/README.md +0 -0
- {cdapython-2.0.2 → cdapython-2.0.3}/cdapython/__init__.py +0 -0
- {cdapython-2.0.2 → cdapython-2.0.3}/cdapython/application_utilities.py +0 -0
- {cdapython-2.0.2 → cdapython-2.0.3}/cdapython/config/__init__.py +0 -0
- {cdapython-2.0.2 → cdapython-2.0.3}/cdapython/config/logger_default_config.yml +0 -0
- {cdapython-2.0.2 → cdapython-2.0.3}/cdapython/discover.py +0 -0
- {cdapython-2.0.2 → cdapython-2.0.3}/cdapython/logging_wrappers.py +0 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: cdapython
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.3
|
|
4
4
|
Summary: NCI CRDC CDA front-end user interface library
|
|
5
|
+
License-File: LICENSE
|
|
5
6
|
Requires-Python: >=3.9,<4.0
|
|
6
7
|
Classifier: Programming Language :: Python :: 3
|
|
7
8
|
Classifier: Programming Language :: Python :: 3.9
|
|
@@ -9,6 +10,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
9
10
|
Classifier: Programming Language :: Python :: 3.11
|
|
10
11
|
Classifier: Programming Language :: Python :: 3.12
|
|
11
12
|
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
12
14
|
Requires-Dist: cda-client (>=2.0.0)
|
|
13
15
|
Requires-Dist: pandas (>=2.2.3)
|
|
14
16
|
Requires-Dist: pyyaml (>=6.0.0)
|
|
@@ -510,8 +510,7 @@ def get_data(
|
|
|
510
510
|
# Validate and normalize match_all filter strings; save results as a list of statement strings.
|
|
511
511
|
|
|
512
512
|
try:
|
|
513
|
-
|
|
514
|
-
queries_for_match_all = validate_and_transform_match_filter_list( cached_column_metadata, match_all, enforce_column_uniqueness=False )
|
|
513
|
+
queries_for_match_all = validate_and_transform_match_filter_list( cached_column_metadata, match_all, enforce_column_uniqueness=True )
|
|
515
514
|
except Exception as e:
|
|
516
515
|
log.error( e )
|
|
517
516
|
return
|
|
@@ -1056,8 +1056,7 @@ def summarize(
|
|
|
1056
1056
|
# ...and save parse results for each filter expression as a separate Query object (to be combined later).
|
|
1057
1057
|
|
|
1058
1058
|
try:
|
|
1059
|
-
|
|
1060
|
-
queries_for_match_all = validate_and_transform_match_filter_list( cached_column_metadata, match_all, enforce_column_uniqueness=False )
|
|
1059
|
+
queries_for_match_all = validate_and_transform_match_filter_list( cached_column_metadata, match_all, enforce_column_uniqueness=True )
|
|
1061
1060
|
except Exception as e:
|
|
1062
1061
|
log.error( e )
|
|
1063
1062
|
return
|
|
@@ -152,43 +152,47 @@ def validate_and_transform_match_filter_list( cached_column_metadata, match_stat
|
|
|
152
152
|
'f': 'false'
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
#
|
|
156
|
-
#
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
155
|
+
# Enforce column uniqueness (across multiple filters) if directed to do so.
|
|
156
|
+
# Track which columns have been seen in case we need to catch disallowed sets of multiple queries on the same column.
|
|
157
|
+
seen_filter_column_names = set()
|
|
158
|
+
|
|
159
|
+
# Have we seen this before (and do we care)?
|
|
160
|
+
if enforce_column_uniqueness:
|
|
161
|
+
if filter_column_name not in column_names_exempt_from_uniqueness_check and filter_column_name in seen_filter_column_names:
|
|
162
|
+
raise RuntimeError( f"Requested column '{filter_column_name}' cannot be used twice in a 'match_all' list." )
|
|
163
|
+
else:
|
|
164
|
+
seen_filter_column_names.add( filter_column_name )
|
|
160
165
|
|
|
161
166
|
for filter_expression in match_statement_list:
|
|
162
167
|
match_result = re.search( r'^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S.*)$', filter_expression ) if isinstance( filter_expression, str ) else None
|
|
168
|
+
is_ternary = False
|
|
163
169
|
if match_result is not None:
|
|
164
170
|
left_numeric = match_result.group(1)
|
|
165
171
|
left_operator = match_result.group(2)
|
|
166
172
|
column_name = match_result.group(3)
|
|
167
173
|
right_operator = match_result.group(4)
|
|
168
174
|
right_numeric = match_result.group(5)
|
|
169
|
-
if re.search( r'^[-+]?\d+(\.\d+)?$', left_numeric ) is None
|
|
170
|
-
left_operator
|
|
171
|
-
# This is
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
# If we're enforcing column uniqueness,
|
|
175
|
-
#
|
|
176
|
-
if enforce_column_uniqueness and column_name in
|
|
177
|
-
raise RuntimeError( f"Requested column '{
|
|
175
|
+
if re.search( r'^[-+]?\d+(\.\d+)?$', left_numeric ) is not None and re.search( r'^[-+]?\d+(\.\d+)?$', right_numeric ) is not None and \
|
|
176
|
+
left_operator in comparison_operators and right_operator in comparison_operators:
|
|
177
|
+
# This is a numeric comparison of the type we seek.
|
|
178
|
+
is_ternary = True
|
|
179
|
+
|
|
180
|
+
# If we're enforcing column uniqueness, we only want to see each column filtered via
|
|
181
|
+
# at most one filter expression.
|
|
182
|
+
if enforce_column_uniqueness and column_name in seen_filter_column_names:
|
|
183
|
+
raise RuntimeError( f"Requested column '{column_name}' cannot be used twice in a 'match_all' list." )
|
|
178
184
|
else:
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
new_match_statement_list.append( f"{column_name} {right_operator} {right_numeric}" )
|
|
182
|
-
else:
|
|
185
|
+
seen_filter_column_names.add( column_name )
|
|
186
|
+
if enforce_column_uniqueness and not is_ternary:
|
|
183
187
|
new_match_statement_list.append( filter_expression )
|
|
184
188
|
|
|
185
189
|
match_statement_list = new_match_statement_list
|
|
186
190
|
|
|
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
191
|
for filter_expression in match_statement_list:
|
|
191
192
|
|
|
193
|
+
if not isinstance( filter_expression, str ) or len( filter_expression ) == 0:
|
|
194
|
+
raise RuntimeError( f"Match parameters must be nonempty filter strings: you specified '{filter_expression}' (from match list {match_statement_list}), which is not." )
|
|
195
|
+
|
|
192
196
|
#############################################################################################################################
|
|
193
197
|
# Enforce the simplified cdapython query syntax as described in the docs, but quietly allow synonyms if received. Map them
|
|
194
198
|
# first back to the canonical operator in the simplified query syntax, then validate, then normalize for API request syntax
|
|
@@ -196,7 +200,6 @@ def validate_and_transform_match_filter_list( cached_column_metadata, match_stat
|
|
|
196
200
|
|
|
197
201
|
# Take care of two-word operators first: downstream, unmodified, they break simplifying assumptions
|
|
198
202
|
# about filter string tokenization that should safe to make given the spec of the cdapython query syntax.
|
|
199
|
-
|
|
200
203
|
if re.search( r'^(\S+)\s+IS\s+NOT\s+(\S.*)$', filter_expression, flags=re.IGNORECASE ) is not None:
|
|
201
204
|
filter_expression = re.sub( r'^(\S+)\s+IS\s+NOT\s+(\S.*)$', r'\1 != \2', filter_expression, flags=re.IGNORECASE )
|
|
202
205
|
|
|
@@ -204,34 +207,41 @@ def validate_and_transform_match_filter_list( cached_column_metadata, match_stat
|
|
|
204
207
|
filter_expression = re.sub( r'^(\S+)\s+NOT\s+LIKE\s+(\S.*)$', r'\1 != \2', filter_expression, flags=re.IGNORECASE )
|
|
205
208
|
|
|
206
209
|
# Normalize the rest of the known operators (used for null and fuzzy matches) to conform to cdapython query syntax.
|
|
207
|
-
|
|
208
210
|
elif re.search( r'^(\S+)\s+IS\s+(\S.*)$', filter_expression, flags=re.IGNORECASE ) is not None:
|
|
209
211
|
filter_expression = re.sub( r'^(\S+)\s+IS\s+(\S.*)$', r'\1 = \2', filter_expression, flags=re.IGNORECASE )
|
|
210
212
|
|
|
211
213
|
elif re.search( r'^(\S+)\s+LIKE\s+(\S.*)$', filter_expression, flags=re.IGNORECASE ) is not None:
|
|
212
214
|
filter_expression = re.sub( r'^(\S+)\s+LIKE\s+(\S.*)$', r'\1 = \2', filter_expression, flags=re.IGNORECASE )
|
|
213
215
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
+
# Handle ternary comparisons and mark matching filters as such.
|
|
217
|
+
filter_is_ternary = False
|
|
218
|
+
filter_column_name = ''
|
|
219
|
+
match_result = re.search( r'^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S.*)$', filter_expression )
|
|
220
|
+
if match_result is not None:
|
|
221
|
+
left_numeric = match_result.group(1)
|
|
222
|
+
left_operator = match_result.group(2)
|
|
223
|
+
column_name = match_result.group(3)
|
|
224
|
+
right_operator = match_result.group(4)
|
|
225
|
+
right_numeric = match_result.group(5)
|
|
226
|
+
if re.search( r'^[-+]?\d+(\.\d+)?$', left_numeric ) is not None and re.search( r'^[-+]?\d+(\.\d+)?$', right_numeric ) is not None and \
|
|
227
|
+
left_operator in comparison_operators and right_operator in comparison_operators:
|
|
228
|
+
# This is a numeric comparison of the type we seek.
|
|
229
|
+
is_ternary = True
|
|
230
|
+
filter_column_name = column_name.lower()
|
|
216
231
|
|
|
217
|
-
|
|
218
|
-
|
|
232
|
+
#############################################################################################################################
|
|
233
|
+
# Validate (non-ternary) 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)>
|
|
219
234
|
|
|
220
|
-
if re.search( r'^\S+\s+\S+\s+\S.*$', filter_expression ) is None:
|
|
235
|
+
if not filter_is_ternary and re.search( r'^\S+\s+\S+\s+\S.*$', filter_expression ) is None:
|
|
221
236
|
raise RuntimeError( f"Filter string '{filter_expression}' does not conform to 'COLUMN_NAME OP VALUE' format. See the help text for details." )
|
|
222
237
|
|
|
223
238
|
#############################################################################################################################
|
|
224
239
|
# Now parse the filter expression and validate COLUMN and OP tokens.
|
|
225
240
|
|
|
226
|
-
# Try to extract a column name from this filter expression. Don't be case-sensitive.
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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 )
|
|
241
|
+
# Try to extract a column name from this filter expression. Don't be case-sensitive. For
|
|
242
|
+
# ternary filters, we've already extracted `filter_column_name`, above.
|
|
243
|
+
if not filter_is_ternary:
|
|
244
|
+
filter_column_name = re.sub( r'^([\S]+)\s.*', r'\1', filter_expression ).lower()
|
|
235
245
|
|
|
236
246
|
# Let's see if this thing exists.
|
|
237
247
|
filter_column_metadata = cached_column_metadata[ cached_column_metadata['column'] == filter_column_name ]
|
|
@@ -247,92 +257,96 @@ def validate_and_transform_match_filter_list( cached_column_metadata, match_stat
|
|
|
247
257
|
if target_data_type not in operators_by_data_type:
|
|
248
258
|
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
259
|
|
|
250
|
-
|
|
251
|
-
filter_operator = re.sub( r'^\S+\s+(\S+)\s.*', r'\1', filter_expression )
|
|
260
|
+
normalized_filter_expression = filter_expression
|
|
252
261
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
262
|
+
if not filter_is_ternary:
|
|
263
|
+
|
|
264
|
+
# See what the operator is.
|
|
265
|
+
filter_operator = re.sub( r'^\S+\s+(\S+)\s.*', r'\1', filter_expression )
|
|
256
266
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
267
|
+
# Be kind to computer scientists.
|
|
268
|
+
if filter_operator == '==':
|
|
269
|
+
filter_operator = '='
|
|
260
270
|
|
|
261
|
-
|
|
262
|
-
|
|
271
|
+
# Make sure the operator specified is allowed for the data type of the column being filtered.
|
|
272
|
+
if filter_operator not in operators_by_data_type[target_data_type]:
|
|
273
|
+
raise RuntimeError( f"Operator '{filter_operator}' is not usable for values of type '{target_data_type}'." )
|
|
263
274
|
|
|
264
|
-
|
|
265
|
-
|
|
275
|
+
# Extract the filter value/pattern.
|
|
276
|
+
filter_value = re.sub( r'^\S+\s+\S+\s+(\S.*)$', r'\1', filter_expression )
|
|
266
277
|
|
|
267
|
-
|
|
268
|
-
|
|
278
|
+
# We said quotes weren't required for string values. Doesn't technically mean they can't be used. Remove them.
|
|
279
|
+
filter_value = re.sub( r'''^['"]*(.*)['"]*$''', r'\1', filter_value)
|
|
269
280
|
|
|
270
|
-
|
|
281
|
+
#############################################################################################################################
|
|
282
|
+
# Validate VALUE types and process wildcards.
|
|
271
283
|
|
|
272
|
-
|
|
273
|
-
filter_value = filter_value.strip()
|
|
284
|
+
# Ignore leading and trailing whitespace unless we're dealing with strings.
|
|
274
285
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
286
|
+
if target_data_type != 'text':
|
|
287
|
+
filter_value = filter_value.strip()
|
|
288
|
+
|
|
289
|
+
if filter_value.lower() != 'null':
|
|
278
290
|
|
|
279
|
-
|
|
291
|
+
if target_data_type == 'boolean':
|
|
292
|
+
|
|
293
|
+
# 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
294
|
|
|
281
|
-
|
|
295
|
+
filter_value = filter_value.lower()
|
|
282
296
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
297
|
+
if filter_value not in boolean_alias:
|
|
298
|
+
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." )
|
|
299
|
+
else:
|
|
300
|
+
filter_value = boolean_alias[filter_value]
|
|
287
301
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
302
|
+
elif target_data_type in [ 'bigint', 'integer', 'numeric' ]:
|
|
303
|
+
|
|
304
|
+
# If we're supposed to be in a numeric column, make sure we've got a number.
|
|
291
305
|
|
|
292
|
-
|
|
293
|
-
|
|
306
|
+
if re.search( r'^[-+]?\d+(\.\d+)?$', filter_value ) is None:
|
|
307
|
+
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
308
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
309
|
+
elif target_data_type == 'text':
|
|
310
|
+
|
|
311
|
+
# Check for wildcards: if found, adjust operator and
|
|
312
|
+
# wildcard syntax to match API expectations on incoming queries.
|
|
299
313
|
|
|
300
|
-
|
|
314
|
+
original_filter_value = filter_value
|
|
301
315
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
316
|
+
if re.search( r'^\*', filter_value ) is not None or re.search( r'\*$', filter_value ) is not None:
|
|
317
|
+
|
|
318
|
+
# API expects lowercase operators.
|
|
319
|
+
if filter_operator == '!=':
|
|
320
|
+
filter_operator = 'not like'
|
|
321
|
+
else:
|
|
322
|
+
filter_operator = 'like'
|
|
309
323
|
|
|
310
|
-
|
|
311
|
-
|
|
324
|
+
if re.search(r'.\*.', filter_value) is not None:
|
|
325
|
+
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
326
|
|
|
313
|
-
|
|
314
|
-
|
|
327
|
+
# API expects percent signs.
|
|
328
|
+
filter_value = re.sub( r'\*', r'%', filter_value )
|
|
315
329
|
|
|
316
|
-
|
|
317
|
-
|
|
330
|
+
# API needs strings quoted. Minimal replication case: match a string that contains only numbers. Need to distinguish from integer input.
|
|
331
|
+
filter_value = f"'{filter_value}'"
|
|
318
332
|
|
|
319
|
-
|
|
333
|
+
else:
|
|
320
334
|
|
|
321
|
-
|
|
322
|
-
|
|
335
|
+
# Just to be safe. Types change.
|
|
336
|
+
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
337
|
|
|
324
|
-
else:
|
|
325
|
-
|
|
326
|
-
if filter_operator == '=':
|
|
327
|
-
filter_operator = 'is'
|
|
328
|
-
elif filter_operator == '!=':
|
|
329
|
-
filter_operator = 'is not'
|
|
330
338
|
else:
|
|
331
|
-
|
|
339
|
+
|
|
340
|
+
if filter_operator == '=':
|
|
341
|
+
filter_operator = 'is'
|
|
342
|
+
elif filter_operator == '!=':
|
|
343
|
+
filter_operator = 'is not'
|
|
344
|
+
else:
|
|
345
|
+
raise RuntimeError( f"Unexpected operator encountered for NULL: '{filter_operator}' (from '{filter_expression}') -- please use = or != instead." )
|
|
332
346
|
|
|
333
|
-
|
|
347
|
+
filter_value = 'null'
|
|
334
348
|
|
|
335
|
-
|
|
349
|
+
normalized_filter_expression = filter_column_name + ' ' + filter_operator + ' ' + filter_value
|
|
336
350
|
|
|
337
351
|
normalized_match_statement_list.append( normalized_filter_expression )
|
|
338
352
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "cdapython"
|
|
3
|
-
version = "2.0.
|
|
3
|
+
version = "2.0.3"
|
|
4
4
|
description = "NCI CRDC CDA front-end user interface library"
|
|
5
5
|
authors = []
|
|
6
6
|
readme = "README.md"
|
|
@@ -12,9 +12,9 @@ include = ["CHANGELOG.md"]
|
|
|
12
12
|
[tool.poetry.dependencies]
|
|
13
13
|
cda-client = ">=2.0.0"
|
|
14
14
|
pandas = ">=2.2.3"
|
|
15
|
+
python = "^3.9"
|
|
15
16
|
pyyaml = ">= 6.0.0"
|
|
16
17
|
tabulate = ">=0.9.0"
|
|
17
|
-
python = "^3.9"
|
|
18
18
|
|
|
19
19
|
[build-system]
|
|
20
20
|
requires = ["poetry-core>=1.0.0"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|