From 0b2d8e587af430e3ce2a601ed3699adfa84fdcc7 Mon Sep 17 00:00:00 2001 From: stroblme Date: Sun, 16 Aug 2026 16:44:06 +0200 Subject: [PATCH] Say what a node returned when it is not a dict of ports Outputs are keyed by port, so a bare value cannot be one. The single mapping point every caller routes through raised a bare AttributeError from retval.items(); it now names the problem, and the message reaches the node the way its other errors do. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A --- backend/app/flow/nodes/base.py | 11 ++++++++++- backend/tests/flow/test_pipeline.py | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/backend/app/flow/nodes/base.py b/backend/app/flow/nodes/base.py index 438ee84..5ef5888 100644 --- a/backend/app/flow/nodes/base.py +++ b/backend/app/flow/nodes/base.py @@ -28,6 +28,10 @@ logger = logging.getLogger(__name__) NodeResult: TypeAlias = "StateBackend | dict[str, Any] | None" +class NodeOutputError(TypeError): + """A node function returned something that cannot be mapped onto ports.""" + + class Node: """ A pipeline node that wraps a function with typed inputs/outputs. @@ -251,10 +255,15 @@ class Node: kwargs[spec.port] = value return kwargs - def _to_messages(self, retval: dict[str, Any] | None) -> dict[str, Any] | None: + def _to_messages(self, retval: Any) -> dict[str, Any] | None: """Map a function's port-keyed return value onto message names.""" if not retval: return None + if not isinstance(retval, dict): + raise NodeOutputError( + f"'{self.local_id}' returned {type(retval).__name__}. Outputs are " + "keyed by port, so return a dict like {'out': value}, or None." + ) by_port = {s.port: s for s in self.output_ports if s.name} outputs = {} for key, value in retval.items(): diff --git a/backend/tests/flow/test_pipeline.py b/backend/tests/flow/test_pipeline.py index 19ed1f6..d39f35a 100644 --- a/backend/tests/flow/test_pipeline.py +++ b/backend/tests/flow/test_pipeline.py @@ -1,5 +1,6 @@ """The wiring fundamentals: name binding, fan-in, namespaces, validation.""" +from app.flow.events import EventBus from app.flow.messages import DType, MessageSpec from app.flow.nodes import Node from app.flow.pipeline import Pipeline @@ -150,6 +151,23 @@ def test_a_failing_node_does_not_stop_its_siblings(): assert pipeline.state["f.ok"] == 1.0 +def test_a_node_returning_something_other_than_a_dict_says_what_is_wrong(): + """Outputs are keyed by port, so a bare value cannot be one of them.""" + events = [] + + def wrong(params): + return 42.0 + + bus = EventBus() + bus.publish = events.append # type: ignore[method-assign] + node = make_node("n", "f", wrong, provides=[spec("out")]) + Pipeline(nodes=[node], events=bus).run() + + (error,) = [e for e in events if e["type"] == "node_error"] + assert "NodeOutputError" in error["error"] + assert "returned float" in error["error"] + + def test_values_carry_timestamps(): node = make_node("n", "f", lambda params: {"out": 1.0}, provides=[spec("out")]) pipeline = Pipeline(nodes=[node])