From 8dad4b5894f64beec523bdccbb374d5a81cd28af Mon Sep 17 00:00:00 2001 From: Samuel Johnson Date: Wed, 6 May 2026 15:28:56 -0400 Subject: [PATCH 1/5] use case --- README.md | 4 +- cdisc_rules_engine/constants/use_cases.py | 108 ++++++++++++++++++ cdisc_rules_engine/rules_engine.py | 1 + .../utilities/rule_processor.py | 44 ++++++- core.py | 8 +- .../test_utilities/test_rule_processor.py | 88 +++++++++----- 6 files changed, 212 insertions(+), 41 deletions(-) create mode 100644 cdisc_rules_engine/constants/use_cases.py diff --git a/README.md b/README.md index 4340249dc..febe8e9ae 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,9 @@ This will show the list of validation options. -ss, --substandard TEXT Substandard to validate against. SUBSTANDARD environment variable can be used to pass value. "SDTM", "SEND", "ADaM", or "CDASH" [required for TIG] - -uc, --use-case TEXT Use Case for TIG Validation + -uc, --use-case TEXT Use Case for TIG Custom Domains + When performing a TIG validation with custom domain(s), this must be given to identify the custom domains' use case + in order to determine what rules to validate against them "INDH", "PROD", "NONCLIN", or "ANALYSIS" [required for TIG] USE_CASE environment variable can be used to pass value. diff --git a/cdisc_rules_engine/constants/use_cases.py b/cdisc_rules_engine/constants/use_cases.py new file mode 100644 index 000000000..fdd8488c0 --- /dev/null +++ b/cdisc_rules_engine/constants/use_cases.py @@ -0,0 +1,108 @@ +""" +Constants for use cases and their allowed domains. +""" + +SDTM = "SDTM" +SEND = "SEND" +ADAM = "ADAM" +CDASH = "CDASH" + +INDH = "INDH" +PROD = "PROD" +NONCLIN = "NONCLIN" +ANALYSIS = "ANALYSIS" + +# NOTE: this may need to be expanded after the pilot re: custom domains, other applicable domains, etc. The +USE_CASE_DOMAINS = { + SDTM: { # only prod and individual health are allowed for sdtm + INDH: [ + "AE", + "CO", + "CM", + "DM", + "DI", + "DU", + "DO", + "DS", + "EG", + "EX", + "EC", + "FA", + "IE", + "LB", + "MH", + "PC", + "PP", + "DA", + "DV", + "QS", + "RELREC", + "RE", + "SC", + "SE", + "SV", + "SU", + "EM", + "TA", + "TE", + "TI", + "TS", + "TV", + "VS", + ], + PROD: ["TO", "PD", "PT", "IT", "IN", "IQ", "ES"], + NONCLIN: [], + ANALYSIS: [], + }, + SEND: { # only nonclin allowed for send + INDH: [], + PROD: [], + NONCLIN: [ + "BW", + "CV", + "CL", + "CO", + "DD", + "DM", + "DI", + "DU", + "DS", + "EG", + "EX", + "FW", + "GT", + "LB", + "MA", + "MI", + "OM", + "PM", + "PK", + "PP", + "POOLDEF", + "RELREC", + "RELREF", + "RE", + "SC", + "SE", + "TA", + "TE", + "TF", + "TX", + "TS", + "VS", + ], + ANALYSIS: [], + }, + ADAM: { # only analysis allowed for adam, ADAM AD-- prefix check is done elsewhere. This is here for completeness. + INDH: [], + PROD: [], + NONCLIN: [], + ANALYSIS: [], + }, + CDASH: { # no conformance rules for CDASH Presently + INDH: [], + PROD: [], + NONCLIN: [], + ANALYSIS: [], + }, +} diff --git a/cdisc_rules_engine/rules_engine.py b/cdisc_rules_engine/rules_engine.py index bc677ef04..9ff27c034 100644 --- a/cdisc_rules_engine/rules_engine.py +++ b/cdisc_rules_engine/rules_engine.py @@ -222,6 +222,7 @@ def validate_single_dataset( rule, dataset_metadata, self.standard, + self.standard_substandard, self.use_case, ) if is_suitable: diff --git a/cdisc_rules_engine/utilities/rule_processor.py b/cdisc_rules_engine/utilities/rule_processor.py index 1395947b2..acdf25fff 100644 --- a/cdisc_rules_engine/utilities/rule_processor.py +++ b/cdisc_rules_engine/utilities/rule_processor.py @@ -25,6 +25,7 @@ SUPPLEMENTARY_DOMAINS, ) from cdisc_rules_engine.constants.rule_constants import ALL_KEYWORD +from cdisc_rules_engine.constants.use_cases import USE_CASE_DOMAINS from cdisc_rules_engine.interfaces import ConditionInterface from cdisc_rules_engine.models.operation_params import OperationParams from cdisc_rules_engine.models.rule_conditions import AllowedConditionsKeys @@ -267,15 +268,43 @@ def rule_applies_to_use_case( self, rule: dict, standard: str, - use_case: str, + standard_substandard: str, + dataset_metadata, + custom_domain_use_case: str, ) -> bool: if standard.lower() != "tig": return True - use_cases = rule.get("use_case") or [] - if not use_cases: + use_cases = ( + [uc.strip() for uc in rule.get("use_case", "").split(",")] + if rule.get("use_case") + else [] + ) + substandard = standard_substandard.upper() + if substandard not in USE_CASE_DOMAINS: + return False + domain_to_check = dataset_metadata.domain + if dataset_metadata.is_supp and dataset_metadata.rdomain: + domain_to_check = dataset_metadata.rdomain + # Handle ADaM datasets with AD prefix + if substandard == "ADAM" and domain_to_check.startswith("AD"): + return "ANALYSIS" in use_cases + + # Standard domain check + allowed_domains = set() + for use in use_cases: + if use in USE_CASE_DOMAINS[substandard]: + allowed_domains.update(USE_CASE_DOMAINS[substandard][use]) + if domain_to_check in allowed_domains: return True - use_cases = [uc.strip() for uc in use_cases.split(",")] - return use_case in use_cases + + is_custom_domain = domain_to_check[0].upper() in ("X", "Y", "Z") + if not is_custom_domain: + return False + if not custom_domain_use_case: + raise ValueError( + f"Custom domain '{domain_to_check}' requires a use case -uc in validation command but none was provided." + ) + return custom_domain_use_case in use_cases @classmethod def rule_applies_to_entity( @@ -635,7 +664,8 @@ def is_suitable_for_validation( self, rule: dict, dataset_metadata: SDTMDatasetMetadata, - standard, + standard: str, + substandard: str, use_case: str, ) -> Tuple[bool, str]: """Check if rule is suitable and return reason if not""" @@ -653,6 +683,8 @@ def is_suitable_for_validation( if not self.rule_applies_to_use_case( rule, standard, + substandard, + dataset_metadata, use_case, ): reason = ( diff --git a/core.py b/core.py index de6cc7ce3..7c0dd4aed 100644 --- a/core.py +++ b/core.py @@ -384,7 +384,7 @@ def load_custom_dotenv_from_data_options(ctx, param, value): default=None, type=click.Choice(["INDH", "PROD", "NONCLIN", "ANALYSIS"], case_sensitive=True), help=( - "CDISC TIG Use Case for scoping a TIG Validation." + "CDISC TIG Use Case for scoping a TIG Custom Domains." "Any of INDH, PROD, NONCLIN, or ANALYSIS." ), envvar="USE_CASE", @@ -626,10 +626,8 @@ def validate( # noqa cache_path: str = os.path.join(os.path.dirname(__file__), cache) if standard == "tig": - if not substandard or not use_case: - logger.error( - "Standard 'tig' requires both --substandard and --use-case to be specified." - ) + if not substandard: + logger.error("Standard 'tig' requires --substandard to be specified.") ctx.exit(2) # Construct ExternalDictionariesContainer: external_dictionaries = ExternalDictionariesContainer( diff --git a/tests/unit/test_utilities/test_rule_processor.py b/tests/unit/test_utilities/test_rule_processor.py index e0f0ed23a..ad8ad8762 100644 --- a/tests/unit/test_utilities/test_rule_processor.py +++ b/tests/unit/test_utilities/test_rule_processor.py @@ -377,47 +377,77 @@ def test_rule_applies_to_class( @pytest.mark.parametrize( - "rule_use_case, use_case, standard, outcome", + "dataset_name, domain, rdomain, rule_use_case, use_case, standard, standard_substandard, outcome", [ - # Basic use case tests - user provides "INDH" or "PROD" - ("INDH, PROD", "INDH", "tig", True), - ("INDH, PROD", "PROD", "tig", True), - ("INDH", "INDH", "tig", True), - ("INDH", "INDH", "tig", True), - ("PROD", "PROD", "tig", True), - ("PROD", "INDH", "tig", False), - ("NONCLIN", "NONCLIN", "tig", True), - ("NONCLIN", "INDH", "tig", False), - # Tests for ADaM datasets - ("ANALYSIS", "ANALYSIS", "tig", True), - ("ANALYSIS", "ANALYSIS", "tig", True), - ("ANALYSIS", "INDH", "tig", False), - # Tests for supplementary datasets - ("INDH", "INDH", "tig", True), - ("INDH", "INDH", "tig", True), - ("INDH", "INDH", "tig", True), - ("INDH", "INDH", "tig", True), - ("PROD", "PROD", "tig", True), - # Tests for empty/None use cases in rule (should always return True) - ("", "INDH", "tig", True), - (None, "INDH", "tig", True), + # Basic use case tests - custom_domain_use_case is irrelevant for standard domains + ("AE", "AE", None, ["INDH", "PROD"], None, "tig", "SDTM", True), + ("CM", "CM", None, ["INDH"], None, "tig", "SDTM", True), + ("TS", "TS", None, ["INDH"], None, "tig", "SDTM", True), + ("ES", "ES", None, ["PROD"], None, "tig", "SDTM", True), + ("BW", "BW", None, ["NONCLIN"], None, "tig", "SEND", True), + # Domain not in rule's use case domains + ("ES", "ES", None, ["INDH"], None, "tig", "SDTM", False), + ("BW", "BW", None, ["INDH"], None, "tig", "SEND", False), + # command line use_case is ignored for standard domains + ("ES", "ES", None, ["PROD"], "INDH", "tig", "SDTM", True), + # ADAM tests - custom_domain_use_case irrelevant, only rule's use_case matters + ("ADAE", "ADAE", None, ["ANALYSIS"], None, "tig", "ADAM", True), + ("ADAE", "ADAE", None, ["INDH"], None, "tig", "ADAM", False), + # Supp tests - rdomain is checked, custom_domain_use_case irrelevant + ("SUPPAE", None, "AE", ["INDH"], None, "tig", "SDTM", True), + ("SUPPQS", None, "QS", ["INDH"], None, "tig", "SDTM", True), + ("SUPPEC", None, "EC", ["INDH"], None, "tig", "SDTM", True), + ("SUPP--", None, "AE", ["INDH"], None, "tig", "SDTM", True), + ("SUPPPT", None, "PT", ["PROD"], None, "tig", "SDTM", True), + # Tests for empty/None use cases in rule - standard domain returns False, custom would raise + ("AE", "AE", None, [], None, "tig", "SDTM", False), + ("AE", "AE", None, None, None, "tig", "SDTM", False), # Tests for non-TIG standard (should always return True) - ("INDH", "INDH", "sdtmig", True), - ("NONCLIN", "NONCLIN", "sendct", True), - # Test case mismatch - ("INDH, PROD", "SAFETY", "tig", False), + ("AE", "AE", None, ["INDH"], None, "sdtmig", "SDTM", True), + ("BW", "BW", None, ["NONCLIN"], None, "sendct", "SEND", True), + # command line use_case is ignored - AE is in INDH domains + ("AE", "AE", None, ["INDH", "PROD"], "SAFETY", "tig", "SDTM", True), + # Tests for custom domains (XYZ-prefixed) + ("XY", "XY", None, ["INDH"], "INDH", "tig", "SDTM", True), + ("XY", "XY", None, ["INDH"], "PROD", "tig", "SDTM", False), + ("ZZ", "ZZ", None, ["PROD"], "PROD", "tig", "SDTM", True), ], ) def test_rule_applies_to_use_case( mock_data_service, + dataset_name, + domain, + rdomain, rule_use_case, - standard, use_case, + standard, + standard_substandard, outcome, ): processor = RuleProcessor(mock_data_service, InMemoryCacheService()) rule = {"use_case": rule_use_case} - assert processor.rule_applies_to_use_case(rule, standard, use_case) == outcome + dataset_metadata = SDTMDatasetMetadata( + name=dataset_name, + first_record=( + {"DOMAIN": domain, "RDOMAIN": rdomain} if domain or rdomain else {} + ), + ) + assert ( + processor.rule_applies_to_use_case( + rule, standard, standard_substandard, dataset_metadata, use_case + ) + == outcome + ) + + +def test_rule_applies_to_use_case_custom_domain_no_use_case_argument_raises( + mock_data_service, +): + processor = RuleProcessor(mock_data_service, InMemoryCacheService()) + rule = {"use_case": ["INDH"]} + dataset_metadata = SDTMDatasetMetadata(name="XY", first_record={"DOMAIN": "XY"}) + with pytest.raises(ValueError, match="requires a use case"): + processor.rule_applies_to_use_case(rule, "tig", "SDTM", dataset_metadata, None) @pytest.mark.parametrize("dataset_implementation", [PandasDataset, DaskDataset]) From 24e9854ee609767178d35dc83ba80ce623ede75e Mon Sep 17 00:00:00 2001 From: Samuel Johnson Date: Wed, 6 May 2026 15:31:10 -0400 Subject: [PATCH 2/5] template --- resources/templates/report-template.xlsx | Bin 20462 -> 20470 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/resources/templates/report-template.xlsx b/resources/templates/report-template.xlsx index b27e0fabdc3dc66a855afa9b2f0415b508240045..274aef9550c2e50e1073ccc39c3315562d5aa798 100644 GIT binary patch delta 11119 zcmb7qWn5L=6E2-1-6h?f(%_-Hr5gn4?oFqFa6m%3JEgm%5d}n~OS-%7L4E)AyZ6K8 z1Drkc%rno-EEb!+*FhHaMHY1R0Rph+Gq@-Y3k6ky1OhX<&=0C$^jg6BS!YhGWdUmw^U z3FK!|4GHrI&sQg`5bJ~zmmeXIx-7-~3an?W0=-xy{u+eFM~)WzD=~iLXA2%{lo3B) zhUuW`>uo1F9@*PBK|m6gJX4TurCcf z$Rl%gwA!IFTRYU)fq4Bu;IkMILXz#%5I)j;%eGAINL!BRpNW`qjgW#XY_WeigexX7 zM!FgPs@AU5ZEmT^J{S>ih4ZP%UUZ+(8HxzfR7EP$iyZZ~i$Y<{;_b;dS9&Qs)`FO- zn^*suPyX}i_*h67xIeBRBbiDx(Xfi?%lowVR|b zE1o$hA=cJUPVZEP+9W87G7@sT^Uby-Db^lK(*!Rzfa_bY9dPH;B+zp>hDtt-_Md&O z__=U5NpJ@bX6M7Hb4O8uLGg}zslosU1@#I8oI^|qaQy!D(w?(lcw8`LuWvyN- zr-w0wQYzm0^H?|I>+l&_Q^N2}p}&lz4s2f-zJAs-`r~14uURdLZ;H^nB4K7}-qOgO zn0NhFL@xJHCF>sbzrfz1AJc3ZjbA=cm4%jo*_`V!Vpu)ZL6v~IbM#$}#<1`6%#^vv*MFa*`K@IEz0lHEN^uly%@Ef+rbSt5#w z6~SZ@Nx{R2r?E+fWFS2zU@`g<$iX?&=glRq<;Q{5r6g>olIXJX=RtZ*v-}U^@5H)B?LwP1Z`l* z4QA>$KDTc)=+tmrzJK+2|Ep}-%o+Q>LF;qnc>@P|!d!e;Ljes~Z?b`j4HrP;oxway zSeW+LU}s%1%%axOt{D%3|` zL~oPc0L5b#%`!>F#yxb##x`~}!)|9TeKAdWK|eSu&nn3nD#VK|;v5r4e&ApY{;&3q^RKJjK9al^-YUQ2Xa2#zu#1 zx`Z_*d)jfhJF6B^#l@Nc8bU5D$H$OwHuaBwv7|C~S>vUs;w)i^r#CTv>A_!CC*?H} zzSLTyYO=k;qA_`~t6vfA-TLhD%3yvzTzt;rT!2ZFC3&UHwpY0<3(zIeJe@ko--A&p zp0jA$ZMhc$V&{D6%F1J=48I=@N%3fY@1C-lSVs zei}Qn0C0rsOXDr0GpsMI_FnvyxrBMdf*#2>urr3fjpXx`kx-mN87lVivO5dkY|fs) z`hbyn&4OEXdm%fz!24gj+O%^dlmrN|-%7N`G?5WjUKXRY<5IxX;ZKr9yEO)DgWpIc zgwGKce_AMu9hN0n4{uPPHY#GJ%Q1xcFd-x610*!H*LB{|≠joB@WrRtX8;D05Eg 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 delta 11105 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 Date: Wed, 6 May 2026 15:38:48 -0400 Subject: [PATCH 3/5] test --- .../test_utilities/test_rule_processor.py | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/tests/unit/test_utilities/test_rule_processor.py b/tests/unit/test_utilities/test_rule_processor.py index ad8ad8762..ef30380a0 100644 --- a/tests/unit/test_utilities/test_rule_processor.py +++ b/tests/unit/test_utilities/test_rule_processor.py @@ -380,37 +380,37 @@ def test_rule_applies_to_class( "dataset_name, domain, rdomain, rule_use_case, use_case, standard, standard_substandard, outcome", [ # Basic use case tests - custom_domain_use_case is irrelevant for standard domains - ("AE", "AE", None, ["INDH", "PROD"], None, "tig", "SDTM", True), - ("CM", "CM", None, ["INDH"], None, "tig", "SDTM", True), - ("TS", "TS", None, ["INDH"], None, "tig", "SDTM", True), - ("ES", "ES", None, ["PROD"], None, "tig", "SDTM", True), - ("BW", "BW", None, ["NONCLIN"], None, "tig", "SEND", True), + ("AE", "AE", None, "INDH, PROD", None, "tig", "SDTM", True), + ("CM", "CM", None, "INDH", None, "tig", "SDTM", True), + ("TS", "TS", None, "INDH", None, "tig", "SDTM", True), + ("ES", "ES", None, "PROD", None, "tig", "SDTM", True), + ("BW", "BW", None, "NONCLIN", None, "tig", "SEND", True), # Domain not in rule's use case domains - ("ES", "ES", None, ["INDH"], None, "tig", "SDTM", False), - ("BW", "BW", None, ["INDH"], None, "tig", "SEND", False), + ("ES", "ES", None, "INDH", None, "tig", "SDTM", False), + ("BW", "BW", None, "INDH", None, "tig", "SEND", False), # command line use_case is ignored for standard domains - ("ES", "ES", None, ["PROD"], "INDH", "tig", "SDTM", True), - # ADAM tests - custom_domain_use_case irrelevant, only rule's use_case matters - ("ADAE", "ADAE", None, ["ANALYSIS"], None, "tig", "ADAM", True), - ("ADAE", "ADAE", None, ["INDH"], None, "tig", "ADAM", False), - # Supp tests - rdomain is checked, custom_domain_use_case irrelevant - ("SUPPAE", None, "AE", ["INDH"], None, "tig", "SDTM", True), - ("SUPPQS", None, "QS", ["INDH"], None, "tig", "SDTM", True), - ("SUPPEC", None, "EC", ["INDH"], None, "tig", "SDTM", True), - ("SUPP--", None, "AE", ["INDH"], None, "tig", "SDTM", True), - ("SUPPPT", None, "PT", ["PROD"], None, "tig", "SDTM", True), - # Tests for empty/None use cases in rule - standard domain returns False, custom would raise - ("AE", "AE", None, [], None, "tig", "SDTM", False), + ("ES", "ES", None, "PROD", "INDH", "tig", "SDTM", True), + # ADAM tests + ("ADAE", "ADAE", None, "ANALYSIS", None, "tig", "ADAM", True), + ("ADAE", "ADAE", None, "INDH", None, "tig", "ADAM", False), + # Supp tests + ("SUPPAE", None, "AE", "INDH", None, "tig", "SDTM", True), + ("SUPPQS", None, "QS", "INDH", None, "tig", "SDTM", True), + ("SUPPEC", None, "EC", "INDH", None, "tig", "SDTM", True), + ("SUPP--", None, "AE", "INDH", None, "tig", "SDTM", True), + ("SUPPPT", None, "PT", "PROD", None, "tig", "SDTM", True), + # Empty/None use cases in rule + ("AE", "AE", None, "", None, "tig", "SDTM", False), ("AE", "AE", None, None, None, "tig", "SDTM", False), - # Tests for non-TIG standard (should always return True) - ("AE", "AE", None, ["INDH"], None, "sdtmig", "SDTM", True), - ("BW", "BW", None, ["NONCLIN"], None, "sendct", "SEND", True), - # command line use_case is ignored - AE is in INDH domains - ("AE", "AE", None, ["INDH", "PROD"], "SAFETY", "tig", "SDTM", True), - # Tests for custom domains (XYZ-prefixed) - ("XY", "XY", None, ["INDH"], "INDH", "tig", "SDTM", True), - ("XY", "XY", None, ["INDH"], "PROD", "tig", "SDTM", False), - ("ZZ", "ZZ", None, ["PROD"], "PROD", "tig", "SDTM", True), + # Non-TIG standard + ("AE", "AE", None, "INDH", None, "sdtmig", "SDTM", True), + ("BW", "BW", None, "NONCLIN", None, "sendct", "SEND", True), + # command line use_case ignored - AE is in INDH domains + ("AE", "AE", None, "INDH, PROD", "SAFETY", "tig", "SDTM", True), + # Custom domains (XYZ-prefixed) + ("XY", "XY", None, "INDH", "INDH", "tig", "SDTM", True), + ("XY", "XY", None, "INDH", "PROD", "tig", "SDTM", False), + ("ZZ", "ZZ", None, "PROD", "PROD", "tig", "SDTM", True), ], ) def test_rule_applies_to_use_case( From a5843afd81279f5faf89f621f60a815d32752188 Mon Sep 17 00:00:00 2001 From: Samuel Johnson Date: Wed, 6 May 2026 15:40:16 -0400 Subject: [PATCH 4/5] test --- tests/unit/test_utilities/test_rule_processor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_utilities/test_rule_processor.py b/tests/unit/test_utilities/test_rule_processor.py index ef30380a0..00a0938fb 100644 --- a/tests/unit/test_utilities/test_rule_processor.py +++ b/tests/unit/test_utilities/test_rule_processor.py @@ -444,7 +444,7 @@ def test_rule_applies_to_use_case_custom_domain_no_use_case_argument_raises( mock_data_service, ): processor = RuleProcessor(mock_data_service, InMemoryCacheService()) - rule = {"use_case": ["INDH"]} + rule = {"use_case": "INDH"} dataset_metadata = SDTMDatasetMetadata(name="XY", first_record={"DOMAIN": "XY"}) with pytest.raises(ValueError, match="requires a use case"): processor.rule_applies_to_use_case(rule, "tig", "SDTM", dataset_metadata, None) From 1a5c8680c8beccca264aa1c9951e1c77d1f6806d Mon Sep 17 00:00:00 2001 From: Samuel Johnson Date: Fri, 8 May 2026 15:02:57 -0400 Subject: [PATCH 5/5] tests, changes --- cdisc_rules_engine/constants/use_cases.py | 2 +- .../utilities/rule_processor.py | 5 +- core.py | 2 +- .../test_utilities/test_rule_processor.py | 74 +++++++++++-------- 4 files changed, 48 insertions(+), 35 deletions(-) diff --git a/cdisc_rules_engine/constants/use_cases.py b/cdisc_rules_engine/constants/use_cases.py index fdd8488c0..f8eb35d37 100644 --- a/cdisc_rules_engine/constants/use_cases.py +++ b/cdisc_rules_engine/constants/use_cases.py @@ -12,7 +12,7 @@ NONCLIN = "NONCLIN" ANALYSIS = "ANALYSIS" -# NOTE: this may need to be expanded after the pilot re: custom domains, other applicable domains, etc. The +# NOTE: this may need to be expanded after the pilot re: custom domains, other applicable domains, etc. USE_CASE_DOMAINS = { SDTM: { # only prod and individual health are allowed for sdtm INDH: [ diff --git a/cdisc_rules_engine/utilities/rule_processor.py b/cdisc_rules_engine/utilities/rule_processor.py index acdf25fff..119be5724 100644 --- a/cdisc_rules_engine/utilities/rule_processor.py +++ b/cdisc_rules_engine/utilities/rule_processor.py @@ -48,6 +48,7 @@ from cdisc_rules_engine.interfaces.data_service_interface import ( DataServiceInterface, ) +from cdisc_rules_engine.utilities.sdtm_utilities import is_custom_domain class RuleProcessor: @@ -297,8 +298,8 @@ def rule_applies_to_use_case( if domain_to_check in allowed_domains: return True - is_custom_domain = domain_to_check[0].upper() in ("X", "Y", "Z") - if not is_custom_domain: + domain_is_custom = is_custom_domain(self.library_metadata, domain_to_check) + if not domain_is_custom: return False if not custom_domain_use_case: raise ValueError( diff --git a/core.py b/core.py index 7c0dd4aed..f6bad6a99 100644 --- a/core.py +++ b/core.py @@ -384,7 +384,7 @@ def load_custom_dotenv_from_data_options(ctx, param, value): default=None, type=click.Choice(["INDH", "PROD", "NONCLIN", "ANALYSIS"], case_sensitive=True), help=( - "CDISC TIG Use Case for scoping a TIG Custom Domains." + "Specifies the CDISC TIG use case for all custom domains in a validation run" "Any of INDH, PROD, NONCLIN, or ANALYSIS." ), envvar="USE_CASE", diff --git a/tests/unit/test_utilities/test_rule_processor.py b/tests/unit/test_utilities/test_rule_processor.py index 2911ced63..24ae8492c 100644 --- a/tests/unit/test_utilities/test_rule_processor.py +++ b/tests/unit/test_utilities/test_rule_processor.py @@ -377,40 +377,40 @@ def test_rule_applies_to_class( @pytest.mark.parametrize( - "dataset_name, domain, rdomain, rule_use_case, use_case, standard, standard_substandard, outcome", + "dataset_name, domain, rdomain, rule_use_case, use_case, standard, standard_substandard, outcome, is_custom", [ # Basic use case tests - custom_domain_use_case is irrelevant for standard domains - ("AE", "AE", None, "INDH, PROD", None, "tig", "SDTM", True), - ("CM", "CM", None, "INDH", None, "tig", "SDTM", True), - ("TS", "TS", None, "INDH", None, "tig", "SDTM", True), - ("ES", "ES", None, "PROD", None, "tig", "SDTM", True), - ("BW", "BW", None, "NONCLIN", None, "tig", "SEND", True), + ("AE", "AE", None, "INDH, PROD", None, "tig", "SDTM", True, False), + ("CM", "CM", None, "INDH", None, "tig", "SDTM", True, False), + ("TS", "TS", None, "INDH", None, "tig", "SDTM", True, False), + ("ES", "ES", None, "PROD", None, "tig", "SDTM", True, False), + ("BW", "BW", None, "NONCLIN", None, "tig", "SEND", True, False), # Domain not in rule's use case domains - ("ES", "ES", None, "INDH", None, "tig", "SDTM", False), - ("BW", "BW", None, "INDH", None, "tig", "SEND", False), + ("ES", "ES", None, "INDH", None, "tig", "SDTM", False, False), + ("BW", "BW", None, "INDH", None, "tig", "SEND", False, False), # command line use_case is ignored for standard domains - ("ES", "ES", None, "PROD", "INDH", "tig", "SDTM", True), + ("ES", "ES", None, "PROD", "INDH", "tig", "SDTM", True, False), # ADAM tests - ("ADAE", "ADAE", None, "ANALYSIS", None, "tig", "ADAM", True), - ("ADAE", "ADAE", None, "INDH", None, "tig", "ADAM", False), + ("ADAE", "ADAE", None, "ANALYSIS", None, "tig", "ADAM", True, False), + ("ADAE", "ADAE", None, "INDH", None, "tig", "ADAM", False, False), # Supp tests - ("SUPPAE", None, "AE", "INDH", None, "tig", "SDTM", True), - ("SUPPQS", None, "QS", "INDH", None, "tig", "SDTM", True), - ("SUPPEC", None, "EC", "INDH", None, "tig", "SDTM", True), - ("SUPP--", None, "AE", "INDH", None, "tig", "SDTM", True), - ("SUPPPT", None, "PT", "PROD", None, "tig", "SDTM", True), + ("SUPPAE", None, "AE", "INDH", None, "tig", "SDTM", True, False), + ("SUPPQS", None, "QS", "INDH", None, "tig", "SDTM", True, False), + ("SUPPEC", None, "EC", "INDH", None, "tig", "SDTM", True, False), + ("SUPP--", None, "AE", "INDH", None, "tig", "SDTM", True, False), + ("SUPPPT", None, "PT", "PROD", None, "tig", "SDTM", True, False), # Empty/None use cases in rule - ("AE", "AE", None, "", None, "tig", "SDTM", False), - ("AE", "AE", None, None, None, "tig", "SDTM", False), + ("AE", "AE", None, "", None, "tig", "SDTM", False, False), + ("AE", "AE", None, None, None, "tig", "SDTM", False, False), # Non-TIG standard - ("AE", "AE", None, "INDH", None, "sdtmig", "SDTM", True), - ("BW", "BW", None, "NONCLIN", None, "sendct", "SEND", True), + ("AE", "AE", None, "INDH", None, "sdtmig", "SDTM", True, False), + ("BW", "BW", None, "NONCLIN", None, "sendct", "SEND", True, False), # command line use_case ignored - AE is in INDH domains - ("AE", "AE", None, "INDH, PROD", "SAFETY", "tig", "SDTM", True), + ("AE", "AE", None, "INDH, PROD", "SAFETY", "tig", "SDTM", True, False), # Custom domains (XYZ-prefixed) - ("XY", "XY", None, "INDH", "INDH", "tig", "SDTM", True), - ("XY", "XY", None, "INDH", "PROD", "tig", "SDTM", False), - ("ZZ", "ZZ", None, "PROD", "PROD", "tig", "SDTM", True), + ("XY", "XY", None, "INDH", "INDH", "tig", "SDTM", True, True), + ("XY", "XY", None, "INDH", "PROD", "tig", "SDTM", False, True), + ("ZZ", "ZZ", None, "PROD", "PROD", "tig", "SDTM", True, True), ], ) def test_rule_applies_to_use_case( @@ -423,6 +423,7 @@ def test_rule_applies_to_use_case( standard, standard_substandard, outcome, + is_custom, ): processor = RuleProcessor(mock_data_service, InMemoryCacheService()) rule = {"use_case": rule_use_case} @@ -432,12 +433,17 @@ def test_rule_applies_to_use_case( {"DOMAIN": domain, "RDOMAIN": rdomain} if domain or rdomain else {} ), ) - assert ( - processor.rule_applies_to_use_case( - rule, standard, standard_substandard, dataset_metadata, use_case + + with patch( + "cdisc_rules_engine.utilities.rule_processor.is_custom_domain", + return_value=is_custom, + ): + assert ( + processor.rule_applies_to_use_case( + rule, standard, standard_substandard, dataset_metadata, use_case + ) + == outcome ) - == outcome - ) def test_rule_applies_to_use_case_custom_domain_no_use_case_argument_raises( @@ -446,8 +452,14 @@ def test_rule_applies_to_use_case_custom_domain_no_use_case_argument_raises( processor = RuleProcessor(mock_data_service, InMemoryCacheService()) rule = {"use_case": "INDH"} dataset_metadata = SDTMDatasetMetadata(name="XY", first_record={"DOMAIN": "XY"}) - with pytest.raises(ValueError, match="requires a use case"): - processor.rule_applies_to_use_case(rule, "tig", "SDTM", dataset_metadata, None) + with patch( + "cdisc_rules_engine.utilities.rule_processor.is_custom_domain", + return_value=True, + ): + with pytest.raises(ValueError, match="requires a use case"): + processor.rule_applies_to_use_case( + rule, "tig", "SDTM", dataset_metadata, None + ) @pytest.mark.parametrize("dataset_implementation", [PandasDataset, DaskDataset])