From 3ab1c2e0f91b00c3348bf5d14b28f482cc3c4d50 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 6 May 2025 16:22:34 +0500 Subject: [PATCH] outliner.delete - make errors from attempt to delete locked object less scary Traceback errors give users an impression that some broke and maybe project now is in some invalid state and they should ctrl-z immediately, error reports are much more friendly. Before - https://i.imgur.com/N4fUU1M.png After - https://i.imgur.com/6On1Be0.png --- .../bonsai/bim/module/geometry/operator.py | 10 ++++- src/bonsai/bonsai/tool/blender.py | 37 ++++++++++++++++ src/bonsai/test/tool/test_blender.py | 44 +++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) 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)