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.
@@ -1,3 +1,7 @@
1
+ 2025-09-17: 2.0.3: update ternary numeric comparison handling for latest API ternary operator syntax support
2
+
1
3
  2025-08-18: 2.0.2: update PyYAML build dependency
4
+
2
5
  2025-08-18: 2.0.1: update default API URL
6
+
3
7
  2025-08-18: 2.0.0: initial public release to PyPI
@@ -1,7 +1,8 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: cdapython
3
- Version: 2.0.2
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
- # 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 )
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
- # TO DO: RE-ENABLE enforce_column_uniqueness AFTER FIGURING OUT NUMERIC HANDLING AND COMPATIBILITY BETWEEN GET_DATA AND SUMMARIZE
1060
- queries_for_match_all = validate_and_transform_match_filter_list( cached_column_metadata, match_all, enforce_column_uniqueness=False )
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
- # 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()
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 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." )
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
- 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:
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
- # 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
+ # 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
- 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." )
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
- 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 )
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
- # See what the operator is.
251
- filter_operator = re.sub( r'^\S+\s+(\S+)\s.*', r'\1', filter_expression )
260
+ normalized_filter_expression = filter_expression
252
261
 
253
- # Be kind to computer scientists.
254
- if filter_operator == '==':
255
- filter_operator = '='
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
- # 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}'." )
267
+ # Be kind to computer scientists.
268
+ if filter_operator == '==':
269
+ filter_operator = '='
260
270
 
261
- # Extract the filter value/pattern.
262
- filter_value = re.sub( r'^\S+\s+\S+\s+(\S.*)$', r'\1', filter_expression )
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
- # 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)
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
- # Validate VALUE types and process wildcards.
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
- # Ignore leading and trailing whitespace unless we're dealing with strings.
281
+ #############################################################################################################################
282
+ # Validate VALUE types and process wildcards.
271
283
 
272
- if target_data_type != 'text':
273
- filter_value = filter_value.strip()
284
+ # Ignore leading and trailing whitespace unless we're dealing with strings.
274
285
 
275
- if filter_value.lower() != 'null':
276
-
277
- if target_data_type == 'boolean':
286
+ if target_data_type != 'text':
287
+ filter_value = filter_value.strip()
288
+
289
+ if filter_value.lower() != 'null':
278
290
 
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.
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
- filter_value = filter_value.lower()
295
+ filter_value = filter_value.lower()
282
296
 
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]
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
- 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.
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
- 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." )
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
- 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.
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
- original_filter_value = filter_value
314
+ original_filter_value = filter_value
301
315
 
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'
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
- 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." )
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
- # API expects percent signs.
314
- filter_value = re.sub( r'\*', r'%', filter_value )
327
+ # API expects percent signs.
328
+ filter_value = re.sub( r'\*', r'%', filter_value )
315
329
 
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}'"
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
- else:
333
+ else:
320
334
 
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." )
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
- raise RuntimeError( f"Unexpected operator encountered for NULL: '{filter_operator}' (from '{filter_expression}') -- please use = or != instead." )
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
- filter_value = 'null'
347
+ filter_value = 'null'
334
348
 
335
- normalized_filter_expression = filter_column_name + ' ' + filter_operator + ' ' + filter_value
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.2"
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