"""What a node prints reaches the editor, attributed to that node.""" import sys from typing import Any from app.flow import logs from app.flow.messages import DType, MessageSpec from app.flow.nodes import Node from app.flow.pipeline import Pipeline def run_with_capture(nodes: list[Node], bus: "RecordingBus") -> None: """Run a graph the way the app does, tee first. The app installs the tee once at startup; pytest replaces ``sys.stdout`` around each test, so it is installed here rather than in a fixture. """ logs.install() Pipeline(nodes=nodes, events=bus).run({}) class RecordingBus: """Stands in for the event bus without an event loop behind it.""" def __init__(self) -> None: self.events: list[dict[str, Any]] = [] def publish(self, event: dict[str, Any]) -> None: self.events.append(event) def make_node(node_id: str, f, provides=()) -> Node: node = Node(f=f, provides=list(provides), name=node_id) node.assign_flow("demo", node_id) return node def logs_of(bus: RecordingBus) -> list[dict[str, Any]]: return [event for event in bus.events if event["type"] == "node_log"] def test_what_a_node_prints_is_reported_against_it(): def talkative(params): print("value looks fine") return {"temp": 20.0} bus = RecordingBus() node = make_node("chatty", talkative, provides=[MessageSpec(name="temp")]) run_with_capture([node], bus) captured = logs_of(bus) assert len(captured) == 1 assert captured[0]["node"] == "demo.chatty" assert captured[0]["level"] == "info" assert "value looks fine" in captured[0]["text"] def test_a_quiet_node_produces_no_log_event(): bus = RecordingBus() node = make_node( "quiet", lambda params: {"temp": 20.0}, provides=[MessageSpec(name="temp", dtype=DType.FLOAT)], ) run_with_capture([node], bus) assert logs_of(bus) == [] def test_a_failing_node_reports_its_traceback(): # Compiled the way the controller compiles node source, because the frame # trimming keys on that filename. namespace: dict[str, Any] = {} exec( compile( 'def process(params):\n print("about to fail")\n' ' raise RuntimeError("boom")\n', "", "exec", ), namespace, ) bus = RecordingBus() run_with_capture([make_node("broken", namespace["process"])], bus) captured = logs_of(bus) assert len(captured) == 1 assert captured[0]["level"] == "error" # Both what it printed and where it broke, which the node bubble has no # room for. assert "about to fail" in captured[0]["text"] assert "RuntimeError: boom" in captured[0]["text"] # The frames above the node belong to the engine that called it. assert "pipeline.py" not in captured[0]["text"] def test_a_flood_is_truncated_rather_than_streamed(): def noisy(params): for index in range(1000): print(f"line {index}") return None bus = RecordingBus() run_with_capture([make_node("noisy", noisy)], bus) captured = logs_of(bus) # One event per execution, whatever the node prints. assert len(captured) == 1 assert captured[0]["truncated"] is True assert len(captured[0]["text"]) <= 8192 def test_printing_outside_a_node_still_reaches_the_real_stream(capsys): logs.install() print("server talking") assert "server talking" in capsys.readouterr().out def test_installing_twice_does_not_stack_tees(): logs.install() once = sys.stdout logs.install() assert sys.stdout is once