Changed how a node class is cloned and locked for execution, added EXECUTE_NORMALIZED to wrap around execute function so that a NodeOutput is always returned

This commit is contained in:
kosinkadink1@gmail.com 2025-07-14 02:55:07 -05:00
parent 139025f0fd
commit c9e03684d6
3 changed files with 82 additions and 11 deletions

View File

@ -5,6 +5,7 @@ from enum import Enum
from abc import ABC, abstractmethod
from dataclasses import dataclass, asdict
from collections import Counter
from comfy_execution.graph import ExecutionBlocker
from comfy_api.v3.resources import Resources, ResourcesLocal
import copy
# used for type hinting
@ -1043,6 +1044,40 @@ class classproperty(object):
return self.f(owner)
# NOTE: this was ai generated and validated by hand
def shallow_clone_class(cls, new_name=None):
'''
Shallow clone a class.
'''
return type(
new_name or f"{cls.__name__}Clone",
cls.__bases__,
dict(cls.__dict__)
)
# NOTE: this was ai generated and validated by hand
def lock_class(cls):
'''
Lock a class so that its top-levelattributes cannot be modified.
'''
# Locked instance __setattr__
def locked_instance_setattr(self, name, value):
raise AttributeError(
f"Cannot set attribute '{name}' on immutable instance of {type(self).__name__}"
)
# Locked metaclass
class LockedMeta(type(cls)):
def __setattr__(cls_, name, value):
raise AttributeError(
f"Cannot modify class attribute '{name}' on locked class '{cls_.__name__}'"
)
# Rebuild class with locked behavior
locked_dict = dict(cls.__dict__)
locked_dict['__setattr__'] = locked_instance_setattr
return LockedMeta(cls.__name__, cls.__bases__, locked_dict)
def add_to_dict_v1(i: InputV3, input: dict):
key = "optional" if i.optional else "required"
input.setdefault(key, {})[i.id] = (i.get_io_type_V1(), i.as_dict_V1())
@ -1127,14 +1162,28 @@ class ComfyNodeV3:
raise Exception(f"No execute function was defined for node class {cls.__name__}.")
@classmethod
def prepare_class_clone(cls, hidden_inputs: dict, *args, **kwargs) -> type[ComfyNodeV3]:
def EXECUTE_NORMALIZED(cls, *args, **kwargs) -> NodeOutput:
to_return = cls.execute(*args, **kwargs)
if to_return is None:
return NodeOutput()
elif isinstance(to_return, NodeOutput):
return to_return
elif isinstance(to_return, tuple):
return NodeOutput(*to_return)
elif isinstance(to_return, dict):
return NodeOutput.from_dict(to_return)
elif isinstance(to_return, ExecutionBlocker):
return NodeOutput(block_execution=to_return.message)
else:
raise Exception(f"Invalid return type from node: {type(to_return)}")
@classmethod
def prepare_class_clone(cls, hidden_inputs: dict) -> type[ComfyNodeV3]:
"""Creates clone of real node class to prevent monkey-patching."""
c_type: type[ComfyNodeV3] = cls if is_class(cls) else type(cls)
type_clone: type[ComfyNodeV3] = type(f"CLEAN_{c_type.__name__}", c_type.__bases__, {})
# TODO: what parameters should be carried over?
type_clone.SCHEMA = c_type.SCHEMA
type_clone: type[ComfyNodeV3] = shallow_clone_class(c_type)
# set hidden
type_clone.hidden = HiddenHolder.from_dict(hidden_inputs)
# TODO: add anything we would want to expose inside node's execute function
return type_clone
#############################################
@ -1224,7 +1273,7 @@ class ComfyNodeV3:
cls.GET_SCHEMA()
return cls._NOT_IDEMPOTENT
FUNCTION = "execute"
FUNCTION = "EXECUTE_NORMALIZED"
@classmethod
def INPUT_TYPES(cls, include_hidden=True, return_schema=False) -> dict[str, dict] | tuple[dict[str, dict], SchemaV3]:
@ -1353,6 +1402,25 @@ class NodeOutput:
# TODO: use kwargs to refer to outputs by id + organize in proper order
return self.args if len(self.args) > 0 else None
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "NodeOutput":
args = ()
ui = None
expand = None
if "result" in data:
result = data["result"]
if isinstance(result, ExecutionBlocker):
return cls(block_execution=result.message)
args = result
if "ui" in data:
ui = data["ui"]
if "expand" in data:
expand = data["expand"]
return cls(args=args, ui=ui, expand=expand)
def __getitem__(self, index) -> Any:
return self.args[index]
class _UIOutput(ABC):
def __init__(self):
pass

View File

@ -104,7 +104,10 @@ class V3TestNode(io.ComfyNodeV3):
raise Exception("The 'cls' variable leaked instance state between runs!")
if hasattr(cls, "doohickey"):
raise Exception("The 'cls' variable leaked state on class properties between runs!")
try:
cls.doohickey = "LOLJK"
except AttributeError:
pass
return io.NodeOutput(some_int, image, ui=ui.PreviewImage(image, cls=cls))

View File

@ -28,7 +28,7 @@ from comfy_execution.graph import (
)
from comfy_execution.graph_utils import GraphBuilder, is_link
from comfy_execution.validation import validate_node_input
from comfy_api.v3.io import NodeOutput, ComfyNodeV3, Hidden, NodeStateLocal, ResourcesLocal, AutogrowDynamic, is_class
from comfy_api.v3.io import NodeOutput, ComfyNodeV3, Hidden, NodeStateLocal, ResourcesLocal, AutogrowDynamic, is_class, lock_class
class ExecutionResult(Enum):
@ -233,8 +233,8 @@ def _map_node_over_list(obj, input_data_all, func, allow_interrupt=False, execut
# otherwise, use class instance to populate/reuse some fields
else:
type_obj = type(obj)
type(obj).VALIDATE_CLASS()
class_clone = type(obj).prepare_class_clone(hidden_inputs)
type_obj.VALIDATE_CLASS()
class_clone = type_obj.prepare_class_clone(hidden_inputs)
# NOTE: this is a mock of state management; for local, just stores NodeStateLocal on node instance
if hasattr(obj, "local_state"):
if obj.local_state is None:
@ -255,7 +255,7 @@ def _map_node_over_list(obj, input_data_all, func, allow_interrupt=False, execut
dynamic_list.append(real_inputs.pop(d.id, None))
dynamic_list = [x for x in dynamic_list if x is not None]
inputs = {**real_inputs, add_key: dynamic_list}
results.append(getattr(type_obj, func).__func__(class_clone, **inputs))
results.append(getattr(type_obj, func).__func__(lock_class(class_clone), **inputs))
# V1
else:
results.append(getattr(obj, func)(**inputs))