Skip to content

Commit 95c9679

Browse files
titlecase operator (#1593)
* titlecase operator, tests * whitespace * changes * warning * exceptions * remove print * whitespace tests --------- Co-authored-by: RamilCDISC <113539111+RamilCDISC@users.noreply.github.com>
1 parent 443b8b9 commit 95c9679

6 files changed

Lines changed: 516 additions & 3 deletions

File tree

cdisc_rules_engine/check_operators/dataframe_operators.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,15 @@
1515
apply_rounding,
1616
is_in,
1717
)
18-
18+
from cdisc_rules_engine.enums.dataset_title_case import DatasetTitleCase
1919
from cdisc_rules_engine.constants import NULL_FLAVORS
2020
from cdisc_rules_engine.utilities.utils import dates_overlap, parse_date
2121
import numpy as np
2222
import dask.dataframe as dd
2323
import pandas as pd
2424
import re
2525
import operator
26+
from titlecase import titlecase
2627
from uuid import uuid4
2728
from cdisc_rules_engine.models.dataset.dask_dataset import DaskDataset
2829
from cdisc_rules_engine.models.dataset.dataset_interface import DatasetInterface
@@ -1073,7 +1074,7 @@ def not_contains_all(self, other_value: dict):
10731074
@log_operator_execution
10741075
@type_operator(FIELD_DATAFRAME)
10751076
def invalid_date(self, other_value):
1076-
target = self.replace_prefix(other_value.get("target"))
1077+
target = other_value.get("target")
10771078
results = ~vectorized_is_valid(self.value[target])
10781079
return self.value.convert_to_series(results)
10791080

@@ -1140,7 +1141,7 @@ def is_incomplete_date(self, other_value):
11401141
@log_operator_execution
11411142
@type_operator(FIELD_DATAFRAME)
11421143
def is_complete_date(self, other_value):
1143-
target = self.replace_prefix(other_value.get("target"))
1144+
target = other_value.get("target")
11441145
results = vectorized_is_complete_date(self.value[target])
11451146
return self.value.convert_to_series(results)
11461147

@@ -1944,3 +1945,39 @@ def check_order(row):
19441945
@type_operator(FIELD_DATAFRAME)
19451946
def is_not_ordered_subset_of(self, other_value: dict):
19461947
return ~self.is_ordered_subset_of(other_value)
1948+
1949+
@log_operator_execution
1950+
@type_operator(FIELD_DATAFRAME)
1951+
def is_title_case(self, other_value: dict):
1952+
"""
1953+
Checks if target column values are in proper title case.
1954+
"""
1955+
target = other_value.get("target")
1956+
acronyms = DatasetTitleCase.Acronyms.value
1957+
lowercase_exceptions = DatasetTitleCase.Lowercase_Exceptions.value
1958+
1959+
def acronym_callback(word, **kwargs):
1960+
if word.lower() in lowercase_exceptions:
1961+
return word.lower()
1962+
if any(word.upper() == acr.upper() for acr in acronyms):
1963+
return word.upper()
1964+
return None
1965+
1966+
def check_title_case(value):
1967+
if pd.isna(value) or value == "" or value in NULL_FLAVORS:
1968+
return True
1969+
str_value = str(value).strip()
1970+
expected = titlecase(str_value, callback=acronym_callback)
1971+
expected = expected[0].upper() + expected[1:]
1972+
return str_value == expected
1973+
1974+
results = self.value[target].apply(check_title_case)
1975+
return self.value.convert_to_series(results)
1976+
1977+
@log_operator_execution
1978+
@type_operator(FIELD_DATAFRAME)
1979+
def is_not_title_case(self, other_value: dict):
1980+
"""
1981+
Checks if target column values are NOT in proper title case.
1982+
"""
1983+
return ~self.is_title_case(other_value)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from cdisc_rules_engine.enums.base_enum import BaseEnum
2+
3+
4+
class DatasetTitleCase(BaseEnum):
5+
Acronyms = ["ID"]
6+
Lowercase_Exceptions = ["per", "and/or", "is", "with"]

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,4 @@ pyyaml==6.0.2
2323
redis==4.5.0
2424
requests~=2.32.3
2525
setuptools~=75.6.0
26+
titlecase==2.4.1

resources/schema/rule/Operator.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,16 @@
525525
"properties": { "operator": { "const": "is_not_ordered_subset_of" } },
526526
"required": ["operator", "value"],
527527
"type": "object"
528+
},
529+
{
530+
"properties": { "operator": { "const": "is_title_case" } },
531+
"required": ["operator"],
532+
"type": "object"
533+
},
534+
{
535+
"properties": { "operator": { "const": "is_not_title_case" } },
536+
"required": ["operator"],
537+
"type": "object"
528538
}
529539
],
530540
"properties": {

resources/schema/rule/Operator.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,29 @@ Complement of `split_parts_have_equal_length`. Returns True when parts have uneq
446446
separator: "/"
447447
```
448448

449+
### is_title_case
450+
451+
Validates that variable labels follow proper title case formatting rules using the titlecase PyPi library. Title case capitalizes the first word and all major words, while keeping articles (a, an, the), conjunctions (and, but, or), and prepositions (in, of, for) in lowercase unless they are the first word.
452+
NOTE: The titlecase library may produce false positives or false negatives in syntactic edge cases (e.g. hyphenated words, slash-separated terms, uncommon prepositions).
453+
454+
> Check that AELABEL values are in proper title case
455+
456+
```yaml
457+
- name: AELABEL
458+
operator: is_title_case
459+
```
460+
461+
### is_not_title_case
462+
463+
Complement of `is_title_case`. Returns True when values are NOT in proper title case.
464+
465+
> Flag AELABEL values that violate title case rules
466+
467+
```yaml
468+
- name: AELABEL
469+
operator: is_not_title_case
470+
```
471+
449472
## Date
450473

451474
Date and time specific operations for comparing dates, validating date completeness, checking date formats, and validating ISO-8601 durations.

0 commit comments

Comments
 (0)