"""What a node prints reaches the editor, attributed to that node.""" import sys from typing import Any from fluksio.flow import logs from fluksio.flow.controller import with_settings from fluksio.flow.messages import DType, MessageSpec from fluksio.flow.nodes import Node from fluksio.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():\n print("about to fail")\n' ' raise RuntimeError("boom")\n', "", "exec", ), namespace, ) bus = RecordingBus() # Wrapped the way the controller wraps it, so the settings a node declares # arrive as keyword arguments and the frames match the real call. run_with_capture( [make_node("broken", with_settings(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