A python node's settings are constants of its own function, so they are passed the way its ports are: by name. The controller binds them to the compiled function, the `params` field is gone from the worker and remote protocols, and a setting sharing a port's name is reported as a node error rather than shadowing it. The panel's scaffold follows suit and keeps the header in step with both ports and settings. The demo's `pace` moves from a flow input to a setting of the training node, which is what it always was. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUb8YpL2s3gmN9WTACTt4q
130 lines
3.8 KiB
Python
130 lines
3.8 KiB
Python
"""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.controller import with_settings
|
|
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():\n print("about to fail")\n'
|
|
' raise RuntimeError("boom")\n',
|
|
"<node demo.broken>",
|
|
"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
|