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 abc import ABC, abstractmethod
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from collections import Counter from collections import Counter
from comfy_execution.graph import ExecutionBlocker
from comfy_api.v3.resources import Resources, ResourcesLocal from comfy_api.v3.resources import Resources, ResourcesLocal
import copy import copy
# used for type hinting # used for type hinting
@ -1043,6 +1044,40 @@ class classproperty(object):
return self.f(owner) 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): def add_to_dict_v1(i: InputV3, input: dict):
key = "optional" if i.optional else "required" key = "optional" if i.optional else "required"
input.setdefault(key, {})[i.id] = (i.get_io_type_V1(), i.as_dict_V1()) 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__}.") raise Exception(f"No execute function was defined for node class {cls.__name__}.")
@classmethod @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.""" """Creates clone of real node class to prevent monkey-patching."""
c_type: type[ComfyNodeV3] = cls if is_class(cls) else type(cls) 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__, {}) type_clone: type[ComfyNodeV3] = shallow_clone_class(c_type)
# TODO: what parameters should be carried over? # set hidden
type_clone.SCHEMA = c_type.SCHEMA
type_clone.hidden = HiddenHolder.from_dict(hidden_inputs) type_clone.hidden = HiddenHolder.from_dict(hidden_inputs)
# TODO: add anything we would want to expose inside node's execute function
return type_clone return type_clone
############################################# #############################################
@ -1224,7 +1273,7 @@ class ComfyNodeV3:
cls.GET_SCHEMA() cls.GET_SCHEMA()
return cls._NOT_IDEMPOTENT return cls._NOT_IDEMPOTENT
FUNCTION = "execute" FUNCTION = "EXECUTE_NORMALIZED"
@classmethod @classmethod
def INPUT_TYPES(cls, include_hidden=True, return_schema=False) -> dict[str, dict] | tuple[dict[str, dict], SchemaV3]: 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 # TODO: use kwargs to refer to outputs by id + organize in proper order
return self.args if len(self.args) > 0 else None 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): class _UIOutput(ABC):
def __init__(self): def __init__(self):
pass pass

View File

@ -104,7 +104,10 @@ class V3TestNode(io.ComfyNodeV3):
raise Exception("The 'cls' variable leaked instance state between runs!") raise Exception("The 'cls' variable leaked instance state between runs!")
if hasattr(cls, "doohickey"): if hasattr(cls, "doohickey"):
raise Exception("The 'cls' variable leaked state on class properties between runs!") raise Exception("The 'cls' variable leaked state on class properties between runs!")
cls.doohickey = "LOLJK" try:
cls.doohickey = "LOLJK"
except AttributeError:
pass
return io.NodeOutput(some_int, image, ui=ui.PreviewImage(image, cls=cls)) 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.graph_utils import GraphBuilder, is_link
from comfy_execution.validation import validate_node_input 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): 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 # otherwise, use class instance to populate/reuse some fields
else: else:
type_obj = type(obj) type_obj = type(obj)
type(obj).VALIDATE_CLASS() type_obj.VALIDATE_CLASS()
class_clone = type(obj).prepare_class_clone(hidden_inputs) 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 # NOTE: this is a mock of state management; for local, just stores NodeStateLocal on node instance
if hasattr(obj, "local_state"): if hasattr(obj, "local_state"):
if obj.local_state is None: 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.append(real_inputs.pop(d.id, None))
dynamic_list = [x for x in dynamic_list if x is not None] dynamic_list = [x for x in dynamic_list if x is not None]
inputs = {**real_inputs, add_key: dynamic_list} 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 # V1
else: else:
results.append(getattr(obj, func)(**inputs)) results.append(getattr(obj, func)(**inputs))