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
This commit is contained in:
Andrej730
2025-05-06 16:22:34 +05:00
parent 0ed964daa2
commit 3ab1c2e0f9
3 changed files with 89 additions and 2 deletions
@@ -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
+37
View File
@@ -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)
+44
View File
@@ -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)