Node settings arrive as keyword arguments, not a params dict

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
This commit is contained in:
2026-08-20 17:47:45 +02:00
co-authored by Claude Opus 5
parent 2385e3cf8e
commit 4355c917f8
24 changed files with 225 additions and 126 deletions
+34 -1
View File
@@ -25,6 +25,7 @@ from app.flow.events import EventBus
from app.flow.executor import ExecutionService
from app.flow.messages import MessageSpec, flow_of, qualify
from app.flow.nodes import (
RESERVED_SETTINGS,
ChangeNode,
DelayNode,
ExecNode,
@@ -479,6 +480,17 @@ class FlowController:
params = resolve_params(node_def.params)
if node_type.has_source:
# Settings and ports are both keyword arguments of the same
# function, so one name cannot mean both.
ports = {spec.port for spec in _bound(node_def.requires)}
clash = sorted((set(params) - RESERVED_SETTINGS) & ports)
if clash:
raise ValueError(
f"'{clash[0]}' is both an input and a setting of "
f"'{node_def.id}'. A setting is an argument like a "
"port, so rename one of them."
)
# A shared node runs the library's copy, compiled once under
# the library's own name so every flow using it agrees.
if node_def.source_ref:
@@ -544,7 +556,7 @@ class FlowController:
),
)
node = Node(
f=function,
f=with_settings(function, params),
requires=_bound(node_def.requires),
provides=_bound(node_def.provides),
params=params,
@@ -1019,6 +1031,27 @@ def _bound(specs: list[MessageSpec]) -> list[MessageSpec]:
return [spec for spec in specs if spec.name]
def with_settings(
function: Callable[..., Any], params: dict[str, Any]
) -> Callable[..., Any]:
"""A node's function with its settings bound as keyword arguments.
A setting is a constant of one node's function, so it is passed the way a
port is: by name. The engine's own settings never reach the code, and the
``params`` the pipeline offers is dropped here rather than travelling to a
worker that has nothing to do with it.
"""
settings = {k: v for k, v in params.items() if k not in RESERVED_SETTINGS}
def call(
params: dict[str, Any] | None = None, # noqa: ARG001 - absorbed here
**ports: Any,
) -> Any:
return function(**ports, **settings)
return call
def _collect_issues(
loaded: dict[str, LoadedNode],
pipeline: Pipeline,
+2 -1
View File
@@ -4,7 +4,7 @@ Split by the outside world each one talks to. Importing from
``app.flow.nodes`` keeps working, which is what every caller does.
"""
from app.flow.nodes.base import Node
from app.flow.nodes.base import RESERVED_SETTINGS, Node
from app.flow.nodes.delay import DelayNode
from app.flow.nodes.exec import ExecNode
from app.flow.nodes.file import FileNode
@@ -30,6 +30,7 @@ __all__ = [
"MqttNode",
"Node",
"NtfyNode",
"RESERVED_SETTINGS",
"RbeNode",
"SwitchNode",
"TriggerNode",
+10 -4
View File
@@ -33,6 +33,10 @@ class NodeOutputError(TypeError):
"""A node function returned something that cannot be mapped onto ports."""
#: Settings the engine reads itself rather than handing to the node's function.
RESERVED_SETTINGS = frozenset({"synchronous"})
class Node:
"""
A pipeline node that wraps a function with typed inputs/outputs.
@@ -50,7 +54,9 @@ class Node:
:param provides: Output messages this node produces. Can be a single Message
or list of Messages.
:type provides: MessageSpec | list[MessageSpec]
:param params: Additional parameters passed to the function during execution.
:param params: This node's settings — constants of its function, stored
with the flow. A function node reads them as keyword arguments beside
its ports; a built-in type validates them against its own ``Params``.
:type params: dict
:param name: Optional name for the node. Defaults to function name.
:type name: str | None
@@ -60,14 +66,14 @@ class Node:
:vartype synchronous: bool
:example:
>>> def process_temp(temperature, params):
... return {"celsius": temperature * 0.5 + 32}
>>> def process_temp(temperature, offset):
... return {"celsius": temperature * 0.5 + offset}
>>>
>>> temp_node = Node(
... f=process_temp,
... requires=MessageSpec(name="temperature", dtype=DType.FLOAT),
... provides=MessageSpec(name="celsius", dtype=DType.FLOAT),
... params={},
... params={"offset": 32},
... )
"""
+2 -5
View File
@@ -255,7 +255,6 @@ class RemoteWorkerHub:
node: str,
source: str,
kwargs: dict[str, Any],
params: dict[str, Any] | None,
node_id: str,
timeout: float,
run_id: str = "",
@@ -272,7 +271,6 @@ class RemoteWorkerHub:
"node": node,
"source": source,
"kwargs": kwargs,
"params": params or {},
"run": {"id": run_id} if run_id else None,
"timeout": timeout,
},
@@ -344,16 +342,15 @@ class RemoteWorkerHub:
the label rather than requiring it.
"""
def call(params: dict[str, Any] | None = None, **kwargs: Any) -> Any:
def call(**kwargs: Any) -> Any:
if fallback is not None and self.pick(label) is None:
return fallback(params=params, **kwargs)
return fallback(**kwargs)
return self.run(
label,
flow,
node,
source,
kwargs,
params,
node_id,
timeout,
run_id=run_id,
+5 -2
View File
@@ -35,6 +35,9 @@ class NodeDef(BaseModel):
id: str
type: str = "python"
title: str = ""
#: This node's settings: constants of its function, stored with the flow.
#: A function node reads them as keyword arguments beside its ports, so a
#: setting cannot share a name with one.
params: dict[str, Any] = Field(default_factory=dict)
requires: list[MessageSpec] = Field(default_factory=list)
provides: list[MessageSpec] = Field(default_factory=list)
@@ -264,8 +267,8 @@ class NodeTypeInfo(BaseModel):
params_schema: dict[str, Any] = Field(default_factory=dict)
has_source: bool = False
#: Whether this type takes settings beyond the ones its schema declares.
#: A function node's params are its author's to name, and reach `process`
#: as whatever they put there.
#: A function node's settings are its author's to name, and reach `process`
#: as keyword arguments beside its ports.
free_params: bool = False
#: The package a connector came from; empty for the built-in types.
plugin: str | None = None
+1 -1
View File
@@ -31,7 +31,7 @@ LIB_DIR = "_lib"
DEFAULT_SOURCE = '''"""A new node. Return a dict keyed by your output ports."""
def process(params):
def process():
return {}
'''
+1 -3
View File
@@ -302,9 +302,7 @@ def _handle(request: dict[str, Any], cache: dict[tuple[str, str], Any]) -> Any:
if request["op"] == "compile":
return None
result = function(
**(request.get("kwargs") or {}), params=request.get("params") or {}
)
result = function(**(request.get("kwargs") or {}))
if inspect.isgenerator(result):
result = _drain(result)
try:
+1 -4
View File
@@ -379,7 +379,6 @@ class PythonWorkerPool:
node: str,
source: str,
kwargs: dict[str, Any],
params: dict[str, Any] | None,
node_id: str,
timeout: float,
run_id: str = "",
@@ -393,7 +392,6 @@ class PythonWorkerPool:
"node": node,
"source": source,
"kwargs": kwargs,
"params": params or {},
"run": {"id": run_id} if run_id else None,
},
timeout=timeout,
@@ -432,13 +430,12 @@ class PythonWorkerPool:
node be told apart when one of them is cancelled.
"""
def call(params: dict[str, Any] | None = None, **kwargs: Any) -> Any:
def call(**kwargs: Any) -> Any:
return self.run(
flow,
node,
source,
kwargs,
params,
node_id,
timeout,
run_id=run_id,
+2 -2
View File
@@ -169,8 +169,8 @@ async def save_flow(name: str, definition: dict[str, Any]) -> Any:
async def save_node_source(name: str, node_id: str, code: str) -> Any:
"""Save a node's Python source and report whether it compiles.
A node defines ``process(...)``, taking one argument per input port plus
``params``, and returns a dict keyed by output port.
A node defines ``process(...)``, taking one argument per input port and
one per setting, and returns a dict keyed by output port.
"""
return await _call(
"PUT", f"/flows/{name}/nodes/{node_id}/source", json={"code": code}