diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index 90231fb631..d7ccb68cda 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -996,8 +996,14 @@ class OverrideOutlinerDelete(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): selected_ids_data = self.get_selected_ids_data(context) - with context.temp_override(selected_objects=list(selected_ids_data.objects)): - bpy.ops.bim.override_object_delete(is_batch=self.is_batch) + try: + with context.temp_override(selected_objects=list(selected_ids_data.objects)): + bpy.ops.bim.override_object_delete(is_batch=self.is_batch) + except RuntimeError as e: + error_reports = tool.Blender.extract_error_reports(e) + if not error_reports: + raise + tool.Blender.report_operator_errors(self, error_reports) for collection in selected_ids_data.collections: # Removing an aggregate object would also remove it's collection diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index 0150fc19a4..9a955b379d 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -1716,3 +1716,40 @@ class Blender(bonsai.core.tool.Blender): if len(pos) == 0 or (indices is not None and len(indices) == 0): return False return True + + @classmethod + def extract_error_reports(cls, exception: RuntimeError) -> list[str]: + """Extracts error report lines from a runtime exception during operator execution. + + If operator had any `ERROR` reports, it will always raise a `RuntimeError`, + no matter what status is returned. + And sometimes it's useful to pass those reports to another operator + that called it. That way user won't get to see a scary traceback. + + If empty list is returned, then exception should be reraised, + as it is an actual unhandled runtime error. + """ + error_message = str(exception) + extracted: list[str] = [] + + # If operator was cancelled and had error message, + # it will always start with this (warnings and info msgs are ignored). + if not error_message.startswith("Error: "): + return extracted + + # Ignore actual runtime errors, as they has to be handled separately + # and not just rereported. + if error_message.startswith("Error: Python: Traceback (most recent call last):"): + return extracted + + for report in error_message.strip().split("Error: "): + report = report.strip() + if not report: + continue + extracted.append(report) + return extracted + + @classmethod + def report_operator_errors(cls, operator: bpy.types.Operator, error_reports: list[str]) -> None: + for report in error_reports: + operator.report({"ERROR"}, report) diff --git a/src/bonsai/test/tool/test_blender.py b/src/bonsai/test/tool/test_blender.py index 74778ea2d4..3775bf86c4 100644 --- a/src/bonsai/test/tool/test_blender.py +++ b/src/bonsai/test/tool/test_blender.py @@ -59,3 +59,47 @@ class TestSortPanelsForRegister(NewFile): with pytest.raises(AssertionError): subject.sort_panels_for_register(items, {"J": "A"}) + + +class TestBlenderErrorMessageExtraction(NewFile): + def test_extract_operator_reports(self) -> None: + + ERROR_REPORTS = ["ERROR!!!\nERROR", "ERROR"] + + class OBJECT_OT_test_fail_operator(bpy.types.Operator): + bl_idname = "object.test_fail_operator" + bl_label = "Test Fail Operator" + + def execute(self, context): + self.report({"INFO"}, "Info message.") + subject.report_operator_errors(self, ERROR_REPORTS) + return {"FINISHED"} + + bpy.utils.register_class(OBJECT_OT_test_fail_operator) + + try: + bpy.ops.object.test_fail_operator() + except RuntimeError as e: + error_reports = subject.extract_error_reports(e) + assert error_reports == ERROR_REPORTS + + bpy.utils.unregister_class(OBJECT_OT_test_fail_operator) + + def test_ignore_actual_runtime_errors_from_operators(self) -> None: + + class OBJECT_OT_test_fail_operator(bpy.types.Operator): + bl_idname = "object.test_fail_operator" + bl_label = "Test Fail Operator" + + def execute(self, context): + raise RuntimeError("Intentional runtime error.") + + bpy.utils.register_class(OBJECT_OT_test_fail_operator) + + try: + bpy.ops.object.test_fail_operator() + except RuntimeError as e: + error_reports = subject.extract_error_reports(e) + assert error_reports == [] + + bpy.utils.unregister_class(OBJECT_OT_test_fail_operator)