From 3109fad29d8860d04372aead6c9a1a190ca52586 Mon Sep 17 00:00:00 2001 From: Samuel Johnson Date: Fri, 10 Jul 2026 15:21:18 -0400 Subject: [PATCH 1/4] report errors --- .../services/reporting/sdtm_report_data.py | 30 +++++++--- .../services/reporting/usdm_report_data.py | 30 +++++++--- .../test_reporting/test_sdtm_report.py | 55 +++++++++++++++++++ .../test_reporting/test_usdm_report.py | 54 ++++++++++++++++++ 4 files changed, 154 insertions(+), 15 deletions(-) diff --git a/cdisc_rules_engine/services/reporting/sdtm_report_data.py b/cdisc_rules_engine/services/reporting/sdtm_report_data.py index ced0b11d9..8f0b4e915 100644 --- a/cdisc_rules_engine/services/reporting/sdtm_report_data.py +++ b/cdisc_rules_engine/services/reporting/sdtm_report_data.py @@ -350,14 +350,28 @@ def _generate_error_details( def _get_csv_rows(self) -> tuple[list[str], list[list[str]]]: header = ["Dataset", "Record", "Variable", "Value"] rows = [] - for issue in self.data_sheets.get("Issue Details", []): - dataset = (issue.get("dataset") or "").removesuffix(".csv") - record = str(issue.get("row", "")) - variables = issue.get("variables") or [] - values = issue.get("values") or [] - for variable, value in zip(variables, values): - csv_value = "" if value in (None, "null") else value - rows.append([dataset, record, variable, csv_value]) + for validation_result in self._results: + for result in validation_result.results or []: + if ( + result.get("executionStatus") + == ExecutionStatus.EXECUTION_ERROR.value + ): + for error in self._error_details(validation_result, result): + dataset_val = ( + error.get("dataset") or result.get("dataset") or "" + ) + dataset = dataset_val.removesuffix(".csv") + csv_value = error.get("values") or error.get("message") or "" + rows.append([dataset, "", "EXECUTION_ERROR", csv_value]) + else: + for issue in self._issue_details(validation_result, result): + dataset = (issue.get("dataset") or "").removesuffix(".csv") + record = str(issue.get("row", "")) + variables = issue.get("variables") or [] + values = issue.get("values") or [] + for variable, value in zip(variables, values): + csv_value = "" if value in (None, "null") else value + rows.append([dataset, record, variable, csv_value]) return header, rows def get_rules_report_data(self) -> list[dict]: diff --git a/cdisc_rules_engine/services/reporting/usdm_report_data.py b/cdisc_rules_engine/services/reporting/usdm_report_data.py index f5a80d3ee..a3931a35c 100644 --- a/cdisc_rules_engine/services/reporting/usdm_report_data.py +++ b/cdisc_rules_engine/services/reporting/usdm_report_data.py @@ -248,13 +248,29 @@ def _generate_error_details( def _get_csv_rows(self) -> tuple[list[str], list[list[str]]]: header = ["path", "attribute", "value"] rows = [] - for issue in self.data_sheets.get("Issue Details", []): - path = issue.get("path") or "" - attributes = issue.get("attributes") or [] - values = issue.get("values") or [] - for attribute, value in zip(attributes, values): - csv_value = "" if value in (None, "null") else value - rows.append([path, attribute, csv_value]) + for validation_result in self._results: + for result in validation_result.results or []: + if ( + result.get("executionStatus") + == ExecutionStatus.EXECUTION_ERROR.value + ): + for error in self._error_details(validation_result, result): + path = ( + error.get("entity") + or result.get("entity") + or result.get("dataset") + or "" + ) + csv_value = error.get("values") or error.get("message") or "" + rows.append([path, "EXECUTION_ERROR", csv_value]) + else: + for issue in self._issue_details(validation_result, result): + path = issue.get("path") or "" + attributes = issue.get("attributes") or [] + values = issue.get("values") or [] + for attribute, value in zip(attributes, values): + csv_value = "" if value in (None, "null") else value + rows.append([path, attribute, csv_value]) return header, rows def get_rules_report_data(self) -> list[dict]: diff --git a/tests/unit/test_services/test_reporting/test_sdtm_report.py b/tests/unit/test_services/test_reporting/test_sdtm_report.py index 0a49cf042..1db4217ce 100644 --- a/tests/unit/test_services/test_reporting/test_sdtm_report.py +++ b/tests/unit/test_services/test_reporting/test_sdtm_report.py @@ -238,3 +238,58 @@ def test_no_errors_when_none_value_in_one_of_the_records(mock_validation_results assert error == summary_data[i] details = report.get_detailed_data() assert len(details) == 3 + + +def test_get_csv_rows_execution_error(mock_validation_results): + mock_validation_results[1].results[0][ + "executionStatus" + ] = ExecutionStatus.EXECUTION_ERROR.value + mock_validation_results[1].results[0]["dataset"] = "TT.csv" + mock_validation_results[1].results[0]["message"] = "TTVARs are wrong" + mock_validation_results[1].results[0]["errors"] = [ + {"error": "Unexpected KeyError in rule execution"} + ] + report = SDTMReportData( + [], + ["test"], + mock_validation_results, + 10.1, + MagicMock(define_xml_path=None, max_errors_per_rule=(None, False)), + ) + _, rows = report.get_csv_rows() + error_rows = [r for r in rows if r[2] == "EXECUTION_ERROR"] + assert len(error_rows) == 1 + dataset, record, variable, value = error_rows[0] + assert dataset == "TT" + assert record == "" + assert value == "TTVARs are wrong - Unexpected KeyError in rule execution" + issue_rows = [r for r in rows if r[2] != "EXECUTION_ERROR"] + assert len(issue_rows) == 4 + + +def test_get_csv_rows_execution_error_detailed_message(mock_validation_results): + mock_validation_results[1].results[0][ + "executionStatus" + ] = ExecutionStatus.EXECUTION_ERROR.value + mock_validation_results[1].results[0]["dataset"] = "json.csv" + mock_validation_results[1].results[0]["message"] = "rule execution error" + detailed_message = ( + "\n Error parsing JSONata Rule for Core Id: CORE-000998\n" + " AttributeError: 'Jsonata' object has no attribute 'lower'" + ) + mock_validation_results[1].results[0]["errors"] = [ + {"error": "Rule format error", "message": detailed_message} + ] + report = SDTMReportData( + [], + ["test"], + mock_validation_results, + 10.1, + MagicMock(define_xml_path=None, max_errors_per_rule=(None, False)), + ) + _, rows = report.get_csv_rows() + error_rows = [r for r in rows if r[2] == "EXECUTION_ERROR"] + assert len(error_rows) == 1 + dataset, record, variable, value = error_rows[0] + assert dataset == "json" + assert value == detailed_message diff --git a/tests/unit/test_services/test_reporting/test_usdm_report.py b/tests/unit/test_services/test_reporting/test_usdm_report.py index 7a5cf4429..43ae926ab 100644 --- a/tests/unit/test_services/test_reporting/test_usdm_report.py +++ b/tests/unit/test_services/test_reporting/test_usdm_report.py @@ -237,3 +237,57 @@ def test_no_errors_when_none_value_in_one_of_the_records(mock_validation_results assert error == summary_data[i] details = report.get_detailed_data() assert len(details) == 3 + + +def test_get_csv_rows_execution_error(mock_validation_results): + mock_validation_results[1].results[0][ + "executionStatus" + ] = ExecutionStatus.EXECUTION_ERROR.value + mock_validation_results[1].results[0]["entity"] = "TT" + mock_validation_results[1].results[0]["message"] = "TTVARs are wrong" + mock_validation_results[1].results[0]["errors"] = [ + {"error": "Unexpected KeyError in rule execution"} + ] + report = USDMReportData( + [], + ["test"], + mock_validation_results, + 10.1, + MagicMock(define_xml_path=None, max_errors_per_rule=(None, False)), + ) + _, rows = report.get_csv_rows() + error_rows = [r for r in rows if r[1] == "EXECUTION_ERROR"] + assert len(error_rows) == 1 + path, attribute, value = error_rows[0] + assert path == "TT" + assert value == "TTVARs are wrong - Unexpected KeyError in rule execution" + issue_rows = [r for r in rows if r[1] != "EXECUTION_ERROR"] + assert len(issue_rows) == 4 + + +def test_get_csv_rows_execution_error_detailed_message(mock_validation_results): + mock_validation_results[1].results[0][ + "executionStatus" + ] = ExecutionStatus.EXECUTION_ERROR.value + mock_validation_results[1].results[0]["entity"] = "json" + mock_validation_results[1].results[0]["message"] = "rule execution error" + detailed_message = ( + "\n Error parsing JSONata Rule for Core Id: CORE-000998\n" + " AttributeError: 'Jsonata' object has no attribute 'lower'" + ) + mock_validation_results[1].results[0]["errors"] = [ + {"error": "Rule format error", "message": detailed_message} + ] + report = USDMReportData( + [], + ["test"], + mock_validation_results, + 10.1, + MagicMock(define_xml_path=None, max_errors_per_rule=(None, False)), + ) + _, rows = report.get_csv_rows() + error_rows = [r for r in rows if r[1] == "EXECUTION_ERROR"] + assert len(error_rows) == 1 + path, attribute, value = error_rows[0] + assert path == "json" + assert value == detailed_message From bed21e3d3dc319d0445f8c32902d360f19d10e63 Mon Sep 17 00:00:00 2001 From: Samuel Johnson Date: Fri, 10 Jul 2026 15:38:37 -0400 Subject: [PATCH 2/4] test --- .../services/reporting/sdtm_report_data.py | 19 ++++++++++--------- .../services/reporting/usdm_report_data.py | 17 +++++++++-------- .../test_reporting/test_sdtm_report.py | 4 ++-- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/cdisc_rules_engine/services/reporting/sdtm_report_data.py b/cdisc_rules_engine/services/reporting/sdtm_report_data.py index 8f0b4e915..b7ad27e75 100644 --- a/cdisc_rules_engine/services/reporting/sdtm_report_data.py +++ b/cdisc_rules_engine/services/reporting/sdtm_report_data.py @@ -363,15 +363,16 @@ def _get_csv_rows(self) -> tuple[list[str], list[list[str]]]: dataset = dataset_val.removesuffix(".csv") csv_value = error.get("values") or error.get("message") or "" rows.append([dataset, "", "EXECUTION_ERROR", csv_value]) - else: - for issue in self._issue_details(validation_result, result): - dataset = (issue.get("dataset") or "").removesuffix(".csv") - record = str(issue.get("row", "")) - variables = issue.get("variables") or [] - values = issue.get("values") or [] - for variable, value in zip(variables, values): - csv_value = "" if value in (None, "null") else value - rows.append([dataset, record, variable, csv_value]) + + for issue in self.data_sheets.get("Issue Details", []): + dataset = (issue.get("dataset") or "").removesuffix(".csv") + record = str(issue.get("row", "")) + variables = issue.get("variables") or [] + values = issue.get("values") or [] + for variable, value in zip(variables, values): + csv_value = "" if value in (None, "null") else value + rows.append([dataset, record, variable, csv_value]) + return header, rows def get_rules_report_data(self) -> list[dict]: diff --git a/cdisc_rules_engine/services/reporting/usdm_report_data.py b/cdisc_rules_engine/services/reporting/usdm_report_data.py index a3931a35c..4e7b95a5b 100644 --- a/cdisc_rules_engine/services/reporting/usdm_report_data.py +++ b/cdisc_rules_engine/services/reporting/usdm_report_data.py @@ -263,14 +263,15 @@ def _get_csv_rows(self) -> tuple[list[str], list[list[str]]]: ) csv_value = error.get("values") or error.get("message") or "" rows.append([path, "EXECUTION_ERROR", csv_value]) - else: - for issue in self._issue_details(validation_result, result): - path = issue.get("path") or "" - attributes = issue.get("attributes") or [] - values = issue.get("values") or [] - for attribute, value in zip(attributes, values): - csv_value = "" if value in (None, "null") else value - rows.append([path, attribute, csv_value]) + + for issue in self.data_sheets.get("Issue Details", []): + path = issue.get("path") or "" + attributes = issue.get("attributes") or [] + values = issue.get("values") or [] + for attribute, value in zip(attributes, values): + csv_value = "" if value in (None, "null") else value + rows.append([path, attribute, csv_value]) + return header, rows def get_rules_report_data(self) -> list[dict]: diff --git a/tests/unit/test_services/test_reporting/test_sdtm_report.py b/tests/unit/test_services/test_reporting/test_sdtm_report.py index 1db4217ce..88e37ff7d 100644 --- a/tests/unit/test_services/test_reporting/test_sdtm_report.py +++ b/tests/unit/test_services/test_reporting/test_sdtm_report.py @@ -271,7 +271,7 @@ def test_get_csv_rows_execution_error_detailed_message(mock_validation_results): mock_validation_results[1].results[0][ "executionStatus" ] = ExecutionStatus.EXECUTION_ERROR.value - mock_validation_results[1].results[0]["dataset"] = "json.csv" + mock_validation_results[1].results[0]["dataset"] = "AE.csv" mock_validation_results[1].results[0]["message"] = "rule execution error" detailed_message = ( "\n Error parsing JSONata Rule for Core Id: CORE-000998\n" @@ -291,5 +291,5 @@ def test_get_csv_rows_execution_error_detailed_message(mock_validation_results): error_rows = [r for r in rows if r[2] == "EXECUTION_ERROR"] assert len(error_rows) == 1 dataset, record, variable, value = error_rows[0] - assert dataset == "json" + assert dataset == "AE" assert value == detailed_message From 5aa29397984809f8e19a0743f2d7d369f52a5cc2 Mon Sep 17 00:00:00 2001 From: Samuel Johnson Date: Tue, 14 Jul 2026 16:34:00 -0400 Subject: [PATCH 3/4] remove _generate_error_details call --- CORE-Report-2026-07-14T16-23-17.csv | 3 ++ cdisc_rules_engine/operations/min_date.py | 10 +++-- .../services/reporting/sdtm_report_data.py | 42 ++++++++++-------- .../services/reporting/usdm_report_data.py | 43 ++++++++++--------- 4 files changed, 54 insertions(+), 44 deletions(-) create mode 100644 CORE-Report-2026-07-14T16-23-17.csv diff --git a/CORE-Report-2026-07-14T16-23-17.csv b/CORE-Report-2026-07-14T16-23-17.csv new file mode 100644 index 000000000..3d2b9eeaf --- /dev/null +++ b/CORE-Report-2026-07-14T16-23-17.csv @@ -0,0 +1,3 @@ +Dataset,Record,Variable,Value +BG,,EXECUTION_ERROR,"Failed to execute rule operation. Operation: min_date, Target: SJSTDTC, Domain: SJ, Error: 'Timestamp' object has no attribute 'lower'" +CL,,EXECUTION_ERROR,"Failed to execute rule operation. Operation: min_date, Target: SJSTDTC, Domain: SJ, Error: 'Timestamp' object has no attribute 'lower'" diff --git a/cdisc_rules_engine/operations/min_date.py b/cdisc_rules_engine/operations/min_date.py index 106a916cc..08eefb066 100644 --- a/cdisc_rules_engine/operations/min_date.py +++ b/cdisc_rules_engine/operations/min_date.py @@ -6,13 +6,15 @@ class MinDate(BaseOperation): def _execute_operation(self): if not self.params.grouping: data = pd.to_datetime(self.params.dataframe[self.params.target]) - min_date = data.min() + min_date = data.min().lower() if isinstance(min_date, pd._libs.tslibs.nattype.NaTType): result = "" else: result = min_date.isoformat() else: - result = self.params.dataframe.groupby( - self.params.grouping, as_index=False - ).min() + result = ( + self.params.dataframe.groupby(self.params.grouping, as_index=False) + .min() + .lower() + ) return result diff --git a/cdisc_rules_engine/services/reporting/sdtm_report_data.py b/cdisc_rules_engine/services/reporting/sdtm_report_data.py index b7ad27e75..cfdcffdc7 100644 --- a/cdisc_rules_engine/services/reporting/sdtm_report_data.py +++ b/cdisc_rules_engine/services/reporting/sdtm_report_data.py @@ -67,6 +67,8 @@ def __init__( self._args.substandard if hasattr(self._args, "substandard") else None ) use_case = self._args.use_case if hasattr(self._args, "use_case") else None + self._issue_execution_status: dict[int, str] = {} + self._issue_csv_fallback_dataset: dict[int, str] = {} self.data_sheets = { "Conformance Details": self.get_conformance_details_data( define_version, @@ -340,31 +342,33 @@ def _generate_error_details( """ errors = [] for result in validation_result.results or []: - errors = ( - errors - + self._issue_details(validation_result, result) - + self._error_details(validation_result, result) - ) + issue_items = self._issue_details(validation_result, result) + error_items = self._error_details(validation_result, result) + for error_item in error_items: + self._issue_execution_status[id(error_item)] = ( + ExecutionStatus.EXECUTION_ERROR.value + ) + self._issue_csv_fallback_dataset[id(error_item)] = ( + result.get("dataset") or "" + ) + errors = errors + issue_items + error_items return errors def _get_csv_rows(self) -> tuple[list[str], list[list[str]]]: header = ["Dataset", "Record", "Variable", "Value"] rows = [] - for validation_result in self._results: - for result in validation_result.results or []: - if ( - result.get("executionStatus") - == ExecutionStatus.EXECUTION_ERROR.value - ): - for error in self._error_details(validation_result, result): - dataset_val = ( - error.get("dataset") or result.get("dataset") or "" - ) - dataset = dataset_val.removesuffix(".csv") - csv_value = error.get("values") or error.get("message") or "" - rows.append([dataset, "", "EXECUTION_ERROR", csv_value]) - for issue in self.data_sheets.get("Issue Details", []): + if ( + self._issue_execution_status.get(id(issue)) + == ExecutionStatus.EXECUTION_ERROR.value + ): + dataset_val = issue.get( + "dataset" + ) or self._issue_csv_fallback_dataset.get(id(issue), "") + dataset = dataset_val.removesuffix(".csv") + csv_value = issue.get("values") or issue.get("message") or "" + rows.append([dataset, "", "EXECUTION_ERROR", csv_value]) + continue dataset = (issue.get("dataset") or "").removesuffix(".csv") record = str(issue.get("row", "")) variables = issue.get("variables") or [] diff --git a/cdisc_rules_engine/services/reporting/usdm_report_data.py b/cdisc_rules_engine/services/reporting/usdm_report_data.py index 4e7b95a5b..7deb35de4 100644 --- a/cdisc_rules_engine/services/reporting/usdm_report_data.py +++ b/cdisc_rules_engine/services/reporting/usdm_report_data.py @@ -41,6 +41,8 @@ def __init__( template, **kwargs, ) + self._issue_execution_status: dict[int, str] = {} + self._issue_csv_fallback_path: dict[int, str] = {} self.data_sheets = { "Conformance Details": self.get_conformance_details_data(), "Entity Details": self.get_entity_details_data(), @@ -238,33 +240,32 @@ def _generate_error_details( """ errors = [] for result in validation_result.results or []: - errors = ( - errors - + self._issue_details(validation_result, result) - + self._error_details(validation_result, result) - ) + issue_items = self._issue_details(validation_result, result) + error_items = self._error_details(validation_result, result) + for error_item in error_items: + self._issue_execution_status[id(error_item)] = ( + ExecutionStatus.EXECUTION_ERROR.value + ) + self._issue_csv_fallback_path[id(error_item)] = ( + result.get("entity") or result.get("dataset") or "" + ) + errors = errors + issue_items + error_items return errors def _get_csv_rows(self) -> tuple[list[str], list[list[str]]]: header = ["path", "attribute", "value"] rows = [] - for validation_result in self._results: - for result in validation_result.results or []: - if ( - result.get("executionStatus") - == ExecutionStatus.EXECUTION_ERROR.value - ): - for error in self._error_details(validation_result, result): - path = ( - error.get("entity") - or result.get("entity") - or result.get("dataset") - or "" - ) - csv_value = error.get("values") or error.get("message") or "" - rows.append([path, "EXECUTION_ERROR", csv_value]) - for issue in self.data_sheets.get("Issue Details", []): + if ( + self._issue_execution_status.get(id(issue)) + == ExecutionStatus.EXECUTION_ERROR.value + ): + path = issue.get("entity") or self._issue_csv_fallback_path.get( + id(issue), "" + ) + csv_value = issue.get("values") or issue.get("message") or "" + rows.append([path, "EXECUTION_ERROR", csv_value]) + continue path = issue.get("path") or "" attributes = issue.get("attributes") or [] values = issue.get("values") or [] From 724b797c149852e5981256e47058e7e308b9ff9e Mon Sep 17 00:00:00 2001 From: Samuel Johnson Date: Wed, 15 Jul 2026 09:01:06 -0400 Subject: [PATCH 4/4] remove lower --- cdisc_rules_engine/operations/min_date.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/cdisc_rules_engine/operations/min_date.py b/cdisc_rules_engine/operations/min_date.py index 08eefb066..106a916cc 100644 --- a/cdisc_rules_engine/operations/min_date.py +++ b/cdisc_rules_engine/operations/min_date.py @@ -6,15 +6,13 @@ class MinDate(BaseOperation): def _execute_operation(self): if not self.params.grouping: data = pd.to_datetime(self.params.dataframe[self.params.target]) - min_date = data.min().lower() + min_date = data.min() if isinstance(min_date, pd._libs.tslibs.nattype.NaTType): result = "" else: result = min_date.isoformat() else: - result = ( - self.params.dataframe.groupby(self.params.grouping, as_index=False) - .min() - .lower() - ) + result = self.params.dataframe.groupby( + self.params.grouping, as_index=False + ).min() return result