Header of process and node ports follow each other both ways: a panel edit rewrites only the def line of edited code, a code save reads the header back into input ports and settings (draft and response); Space types in the node editor again

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
This commit is contained in:
2026-10-15 18:19:19 +02:00
co-authored by Claude Opus 5.5
parent 92c562a7d5
commit 762e7fb1fe
15 changed files with 645 additions and 48 deletions
+87
View File
@@ -1837,6 +1837,93 @@ def _signature_gap(
)
def follow_header(node: NodeDef, code: str) -> NodeDef:
"""The node with its input ports and settings read off ``def process(...)``.
The inverse of the canvas's scaffold, which writes the input ports first,
in their order, and the settings after them. A parameter already declared
keeps its kind. A new one is a setting when it has a default, which becomes
its value, or when it follows a setting, and an input port otherwise. One
name swapped for one other is a rename, so the port keeps its message and
the setting its value. A declared name the header no longer has is dropped.
A header this cannot read for certain leaves the node as it is: a syntax
error, no top-level ``def process``, a decorator, ``*args``, ``**kwargs``,
a bare ``*`` or ``/``.
"""
try:
tree = ast.parse(code)
except SyntaxError:
return node
defs = [
entry
for entry in tree.body
if isinstance(entry, ast.FunctionDef) and entry.name == "process"
]
if not defs:
return node
args = defs[-1].args
if (
defs[-1].decorator_list
or args.vararg
or args.kwarg
or args.posonlyargs
or args.kwonlyargs
):
return node
# Defaults belong to the last parameters, so they line up from the end.
defaults: list[ast.expr | None] = [None] * (
len(args.args) - len(args.defaults)
) + list(args.defaults)
header = [
(arg.arg, default)
for arg, default in zip(args.args, defaults, strict=True)
if arg.arg not in RESERVED_SETTINGS
]
def written(name: str) -> bool:
return name.isascii() and name.isidentifier()
ports = {spec.port: spec for spec in node.requires if written(spec.port)}
settings = {
key: value
for key, value in node.params.items()
if key not in RESERVED_SETTINGS and written(key)
}
names = [name for name, _ in header]
gone = [name for name in [*ports, *settings] if name not in names]
new = [name for name in names if name not in ports and name not in settings]
renamed = {new[0]: gone[0]} if len(gone) == len(new) == 1 else {}
requires: list[MessageSpec] = []
params = {key: value for key, value in node.params.items() if key not in settings}
after_setting = False
for name, default in header:
was = renamed.get(name, name)
if was in ports:
requires.append(ports[was].model_copy(update={"port": name}))
elif was in settings or default is not None or after_setting:
params[name] = settings[was] if was in settings else _literal(default)
after_setting = True
else:
requires.append(MessageSpec(port=name))
# Ports the header cannot name, such as a row not named yet, stay.
requires += [spec for spec in node.requires if not written(spec.port)]
return node.model_copy(update={"requires": requires, "params": params})
def _literal(default: ast.expr | None) -> Any:
"""A parameter's default as a setting's value, or empty when it is not JSON."""
if default is None:
return ""
try:
value = ast.literal_eval(default)
json.dumps(value)
except (ValueError, TypeError, SyntaxError, RecursionError):
return ""
return value
def with_settings(
function: Callable[..., Any], params: dict[str, Any]
) -> Callable[..., Any]: