From 4392eb393f0b08b1619ca10168092905469d3f42 Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Wed, 14 Oct 2026 13:54:04 +0300 Subject: [PATCH] verbose errors --- core/nodeslicer/nodes/system.py | 10 +- core/nodeslicer/tree.py | 10 +- infra/blender/blender_slice_nodes_generic.py | 86 ++++++++++++++++ infra/blender/list_utils.py | 22 ++-- infra/debug.py | 7 +- tests/test_debug.py | 101 +++++++++++++++++++ 6 files changed, 221 insertions(+), 15 deletions(-) diff --git a/core/nodeslicer/nodes/system.py b/core/nodeslicer/nodes/system.py index 85aaa9e..b4837d7 100644 --- a/core/nodeslicer/nodes/system.py +++ b/core/nodeslicer/nodes/system.py @@ -215,15 +215,19 @@ class SLICENODE_Executor(ExecSliceNode, SliceNodeJoiner): if operator: operator.report({'INFO'}, f"Executing: {upstream_node.bl_label}") - + try: with execution_cache(): with profile_node_execution(upstream_node): - exec_res = upstream_node.evaluate(operator=operator) + from ....infra.blender.blender_slice_nodes_generic import NodeErrorReportingProxy + wrapped_op = NodeErrorReportingProxy(operator, upstream_node) + exec_res = upstream_node.evaluate(operator=wrapped_op) if exec_res != 'ok' and exec_res is not None: if operator: operator.report({'WARNING'}, f"Node {upstream_node.bl_label} returned {exec_res}") except Exception as e: - if operator: operator.report({'ERROR'}, f"Failed executing {upstream_node.bl_label}: {str(e)}") + from ....infra.blender.blender_slice_nodes_generic import attach_node_failure_info + attach_node_failure_info(upstream_node, e) + if operator: operator.report({'ERROR'}, str(e)) return 'error' return 'ok' diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index 8a113c3..ac3b8a2 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -750,11 +750,15 @@ class SLICENODE_OT_Execute(Operator): try: with execution_cache(): - res = node.evaluate(operator=self) + from ...infra.blender.blender_slice_nodes_generic import NodeErrorReportingProxy + wrapped_op = NodeErrorReportingProxy(self, node) + res = node.evaluate(operator=wrapped_op) if res == 'ok': - self.report({"INFO"}, f"Execution Successful") + self.report({"INFO"}, "Execution Successful") except Exception as e: - self.report({"ERROR"}, f"Execution failed: {str(e)}") + from ...infra.blender.blender_slice_nodes_generic import attach_node_failure_info + attach_node_failure_info(node, e) + self.report({"ERROR"}, str(e)) import traceback traceback.print_exc() diff --git a/infra/blender/blender_slice_nodes_generic.py b/infra/blender/blender_slice_nodes_generic.py index a21dcde..1f01b47 100644 --- a/infra/blender/blender_slice_nodes_generic.py +++ b/infra/blender/blender_slice_nodes_generic.py @@ -646,6 +646,91 @@ def _format_node_description(node: Any) -> str: return str(node) +def get_node_input_names(node: Any, fallback_inputs: list[str] | None = None) -> list[str]: + """Retrieves all input socket names for a node, preserving order without duplicates.""" + names: list[str] = [] + inputs = getattr(node, "inputs", None) + if inputs: + for s in inputs: + s_name = getattr(s, "name", "") + if s_name and s_name not in names: + names.append(s_name) + elif not s_name: + ident = getattr(s, "identifier", "") + if ident and ident not in names: + names.append(ident) + if not names and fallback_inputs: + for s_name in fallback_inputs: + if s_name and s_name not in names: + names.append(s_name) + if not names: + mapping = getattr(node, "list_socket_mapping", None) + if mapping: + for item in mapping: + m_in = item[0] if isinstance(item, (list, tuple)) and item else None + if m_in and m_in not in names: + names.append(m_in) + return names + + +def format_node_failure_message(node: Any, exc_or_msg: Any, fallback_inputs: list[str] | None = None) -> str: + """Formats a concise, informative execution failure message specifying the node and input names.""" + node_str = _format_node_description(node) or "Unknown Node" + input_names = get_node_input_names(node, fallback_inputs=fallback_inputs) + inputs_str = ", ".join(input_names) if input_names else "(None)" + msg = str(exc_or_msg) + if msg.startswith("Execution failed at node "): + return msg + return f"Execution failed at node {node_str} [Inputs: {inputs_str}]: {msg}" + + +def attach_node_failure_info(node: Any, exc: Exception, fallback_inputs: list[str] | None = None) -> Exception: + """Enriches an exception in-place with the failing node and input socket names, preventing duplicate wrapping.""" + if getattr(exc, "_nodeslicer_node", None) is not None: + return exc + try: + setattr(exc, "_nodeslicer_node", node) + input_names = get_node_input_names(node, fallback_inputs=fallback_inputs) + setattr(exc, "_nodeslicer_inputs", input_names) + + node_str = _format_node_description(node) or "Unknown Node" + inputs_str = ", ".join(input_names) if input_names else "(None)" + prefix = f"Execution failed at node {node_str} [Inputs: {inputs_str}]: " + + if exc.args: + orig_msg = str(exc.args[0]) + if not orig_msg.startswith("Execution failed at node "): + exc.args = (prefix + orig_msg, *exc.args[1:]) + else: + exc.args = (prefix + type(exc).__name__,) + + print(f"[NodeSlicer ERROR] {str(exc)}") + except Exception: + pass + return exc + + +class NodeErrorReportingProxy: + """Wraps Blender operator to ensure any operator.report({'ERROR'}, ...) calls + always tell which node failed and the names of its inputs.""" + def __init__(self, operator: Any, node: Any): + self._operator = operator + self._node = node + + def report(self, type_set: set[str], message: str) -> None: + if self._operator and hasattr(self._operator, "report"): + if "ERROR" in type_set: + formatted_msg = format_node_failure_message(self._node, message) + print(f"[NodeSlicer ERROR] {formatted_msg}") + self._operator.report(type_set, formatted_msg) + else: + self._operator.report(type_set, message) + + def __getattr__(self, name: str) -> Any: + return getattr(self._operator, name) + + + def _extract_caller_context() -> tuple[Any, str | None]: try: import sys @@ -888,6 +973,7 @@ def _resolve_output( eval_exc = e import traceback eval_tb = traceback.format_exc() + attach_node_failure_info(socket.node, e) raise finally: try: diff --git a/infra/blender/list_utils.py b/infra/blender/list_utils.py index 1fca24f..4be64e7 100644 --- a/infra/blender/list_utils.py +++ b/infra/blender/list_utils.py @@ -232,14 +232,20 @@ def eval_multivalue( else: vec_flags = is_vector - return eval_element_wise_multivalue( - op_func, - main_data, - *sec_vals, - is_vector=vec_flags, - input_names=secondary_sockets, - output_index=output_index, - ) + try: + return eval_element_wise_multivalue( + op_func, + main_data, + *sec_vals, + is_vector=vec_flags, + input_names=secondary_sockets, + output_index=output_index, + ) + except Exception as e: + from .blender_slice_nodes_generic import attach_node_failure_info + known_inputs = ([main_socket] if main_socket else []) + (secondary_sockets or []) + attach_node_failure_info(node, e, fallback_inputs=known_inputs) + raise def eval_element_wise(op_func: Callable[[Any], Any], data: Any) -> Any: """If data is a list, applies op_func to each element and returns a list. diff --git a/infra/debug.py b/infra/debug.py index 07c1a59..8990bb3 100644 --- a/infra/debug.py +++ b/infra/debug.py @@ -348,6 +348,9 @@ def format_debug_report(dump: dict[str, Any]) -> str: lines.append("") lines.append("!" * 80) lines.append(f" EXCEPTION: {dump.get('exception')}") + lines.append(f" FAILED NODE: {dump.get('node_name', 'Unknown Node')} ({dump.get('node_type', '')})") + input_keys = list(dump.get("inputs", {}).keys()) + lines.append(f" INPUTS: {', '.join(input_keys) if input_keys else '(None)'}") lines.append("!" * 80) if dump.get("traceback"): lines.append(dump["traceback"]) @@ -518,7 +521,9 @@ def dump_node_execution( if log_console: if exception: - print(f"[NodeSlicer DEBUG ERROR] Node '{node_name}' ({node_type}) failed: {exception}") + input_names_list = list(raw_inputs_dict.keys()) + inputs_str = ", ".join(input_names_list) if input_names_list else "(None)" + print(f"[NodeSlicer DEBUG ERROR] Node '{node_name}' ({node_type}) failed: {exception} (Inputs: {inputs_str})") print(f"[NodeSlicer DEBUG] Review dump at: {last_txt_file}") else: print(f"[NodeSlicer DEBUG] Dumped execution of '{node_name}' ({node_type}) -> {last_txt_file}") diff --git a/tests/test_debug.py b/tests/test_debug.py index 68a5469..31cb443 100644 --- a/tests/test_debug.py +++ b/tests/test_debug.py @@ -353,6 +353,107 @@ class TestNodeDebugSystem(unittest.TestCase): err_msg = str(ctx.exception) self.assertIn("non-existent", err_msg) + def test_node_failure_includes_node_and_input_names(self): + from NodeSlicer.infra.blender.blender_slice_nodes_generic import attach_node_failure_info, get_node_input_names + + class FailingNode(SliceNode): + bl_idname = "SLICENODE_FailingOp" + bl_label = "Failing Operation" + + def __init__(self): + self.name = "Custom Failing Node" + self.inputs = DummySocketCollection([ + DummyNodeSocket("Primary", "Primary", "SLICESOCK_Mesh"), + DummyNodeSocket("Offset Val", "Offset Val", "NodeSocketFloat"), + DummyNodeSocket("Rotation Angle", "Rotation Angle", "NodeSocketRotation"), + ], node=self) + + node = FailingNode() + inputs = get_node_input_names(node) + self.assertEqual(inputs, ["Primary", "Offset Val", "Rotation Angle"]) + + exc = ValueError("Invalid operation arguments") + attach_node_failure_info(node, exc) + + err_msg = str(exc) + self.assertIn("Custom Failing Node", err_msg) + self.assertIn("Failing Operation", err_msg) + self.assertIn("Primary, Offset Val, Rotation Angle", err_msg) + self.assertIn("Invalid operation arguments", err_msg) + + def test_node_failure_preserves_innermost_node_on_bubble_up(self): + from NodeSlicer.infra.blender.blender_slice_nodes_generic import attach_node_failure_info + + class InnerNode(SliceNode): + bl_idname = "SLICENODE_Inner" + bl_label = "Inner Worker" + + def __init__(self): + self.name = "My Inner Worker" + self.inputs = DummySocketCollection([ + DummyNodeSocket("InnerInput1", "InnerInput1", "NodeSocketFloat"), + DummyNodeSocket("InnerInput2", "InnerInput2", "NodeSocketString"), + ], node=self) + + class OuterNode(SliceNode): + bl_idname = "SLICENODE_Outer" + bl_label = "Outer Caller" + + def __init__(self): + self.name = "My Outer Caller" + self.inputs = DummySocketCollection([ + DummyNodeSocket("OuterInput", "OuterInput", "NodeSocketFloat") + ], node=self) + + inner = InnerNode() + outer = OuterNode() + + # Inner node fails first + inner_exc = RuntimeError("Hardware buffer failure") + attach_node_failure_info(inner, inner_exc) + + # Bubbles up to outer node + attach_node_failure_info(outer, inner_exc) + + # Must still point to inner node and inner inputs, NOT overwritten by outer node + msg = str(inner_exc) + self.assertIn("My Inner Worker", msg) + self.assertIn("Inner Worker", msg) + self.assertIn("InnerInput1, InnerInput2", msg) + self.assertNotIn("My Outer Caller", msg) + self.assertNotIn("OuterInput", msg) + + def test_node_error_reporting_proxy_formats_operator_errors(self): + from NodeSlicer.infra.blender.blender_slice_nodes_generic import NodeErrorReportingProxy + + class ActionNode(SliceNode): + bl_idname = "SLICENODE_Export" + bl_label = "Export Geometry" + + def __init__(self): + self.name = "Export Step" + self.inputs = DummySocketCollection([ + DummyNodeSocket("Geometry", "Geometry", "SLICESOCK_Mesh"), + DummyNodeSocket("Folder", "Folder", "NodeSocketString"), + DummyNodeSocket("Filename", "Filename", "NodeSocketString"), + ], node=self) + + node = ActionNode() + mock_operator = MagicMock() + proxy = NodeErrorReportingProxy(mock_operator, node) + + # Call error report + proxy.report({"ERROR"}, "Directory does not exist") + + mock_operator.report.assert_called_once() + args = mock_operator.report.call_args[0] + self.assertEqual(args[0], {"ERROR"}) + reported_msg = args[1] + self.assertIn("Export Step", reported_msg) + self.assertIn("Export Geometry", reported_msg) + self.assertIn("Geometry, Folder, Filename", reported_msg) + self.assertIn("Directory does not exist", reported_msg) + if __name__ == '__main__': unittest.main()