From c9e03684d64e6b0640a379dbf120e6773fee93bd Mon Sep 17 00:00:00 2001 From: "kosinkadink1@gmail.com" Date: Mon, 14 Jul 2025 02:55:07 -0500 Subject: [PATCH] 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 --- comfy_api/v3/io.py | 80 ++++++++++++++++++++++++++++++++--- comfy_extras/nodes_v3_test.py | 5 ++- execution.py | 8 ++-- 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/comfy_api/v3/io.py b/comfy_api/v3/io.py index 8919dcd33..d7f6137f3 100644 --- a/comfy_api/v3/io.py +++ b/comfy_api/v3/io.py @@ -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 diff --git a/comfy_extras/nodes_v3_test.py b/comfy_extras/nodes_v3_test.py index 426a5e3f8..92c120f56 100644 --- a/comfy_extras/nodes_v3_test.py +++ b/comfy_extras/nodes_v3_test.py @@ -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!") - cls.doohickey = "LOLJK" + try: + cls.doohickey = "LOLJK" + except AttributeError: + pass return io.NodeOutput(some_int, image, ui=ui.PreviewImage(image, cls=cls)) diff --git a/execution.py b/execution.py index 8ca134525..8b1792c29 100644 --- a/execution.py +++ b/execution.py @@ -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))