Separate ComfyNodeV3 into an internal base class and one that only has the functions defined that a developer cares about overriding, reference ComfyNodeInternal in execution.py/server.py instead of ComfyNodeV3 to make the code not bound to a particular version of v3 schema (once placed on api)

This commit is contained in:
Jedrzej Kosinski 2025-07-17 16:09:18 -07:00
parent b99e3d1336
commit ab98b65226
5 changed files with 119 additions and 34 deletions

View File

@ -1,6 +1,10 @@
class ComfyNodeInternal: from abc import ABC, abstractmethod
class ComfyNodeInternal(ABC):
"""Class that all V3-based APIs inherit from for ComfyNode. """Class that all V3-based APIs inherit from for ComfyNode.
This is intended to only be referenced within execution.py, as it has to handle all V3 APIs going forward.""" This is intended to only be referenced within execution.py, as it has to handle all V3 APIs going forward."""
@classmethod
@abstractmethod
def GET_NODE_INFO_V1(cls):
... ...

View File

@ -1,10 +1,16 @@
from typing import Callable, Optional from typing import Callable, Optional
def first_real_override(cls: type, name: str, *, base: type) -> Optional[Callable]: def first_real_override(cls: type, name: str, *, base: type=None) -> Optional[Callable]:
"""Return the *callable* override of `name` visible on `cls`, or None if every """Return the *callable* override of `name` visible on `cls`, or None if every
implementation up to (and including) `base` is the placeholder defined on `base`. implementation up to (and including) `base` is the placeholder defined on `base`.
If base is not provided, it will assume cls has a GET_BASE_CLASS
""" """
if base is None:
if not hasattr(cls, "GET_BASE_CLASS"):
raise ValueError("base is required if cls does not have a GET_BASE_CLASS; is this a valid ComfyNode subclass?")
base = cls.GET_BASE_CLASS()
base_attr = getattr(base, name, None) base_attr = getattr(base, name, None)
if base_attr is None: if base_attr is None:
return None return None

View File

@ -6,11 +6,12 @@ from collections import Counter
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from enum import Enum from enum import Enum
from typing import Any, Callable, Literal, TypedDict, TypeVar from typing import Any, Callable, Literal, TypedDict, TypeVar
from comfy_api.v3.helpers import first_real_override
# used for type hinting # used for type hinting
import torch import torch
from spandrel import ImageModelDescriptor from spandrel import ImageModelDescriptor
from typing_extensions import NotRequired from typing_extensions import NotRequired, final
from comfy.clip_vision import ClipVisionModel from comfy.clip_vision import ClipVisionModel
from comfy.clip_vision import Output as ClipVisionOutput_ from comfy.clip_vision import Output as ClipVisionOutput_
@ -1244,8 +1245,9 @@ def add_to_dict_v3(io: InputV3 | OutputV3, d: dict):
d[io.id] = (io.get_io_type(), io.as_dict()) d[io.id] = (io.get_io_type(), io.as_dict())
class ComfyNodeV3(ComfyNodeInternal):
"""Common base class for all V3 nodes.""" class _ComfyNodeBaseInternal(ComfyNodeInternal):
"""Common base class for storing internal methods and properties; DO NOT USE for defining nodes."""
RELATIVE_PYTHON_MODULE = None RELATIVE_PYTHON_MODULE = None
SCHEMA = None SCHEMA = None
@ -1264,6 +1266,7 @@ class ComfyNodeV3(ComfyNodeInternal):
@classmethod @classmethod
@abstractmethod @abstractmethod
def execute(cls, **kwargs) -> NodeOutput: def execute(cls, **kwargs) -> NodeOutput:
"""Override this function with one that performs node's actions."""
raise NotImplementedError raise NotImplementedError
@classmethod @classmethod
@ -1292,28 +1295,28 @@ class ComfyNodeV3(ComfyNodeInternal):
""" """
return [name for name in kwargs if kwargs[name] is None] return [name for name in kwargs if kwargs[name] is None]
@classmethod
def GET_SERIALIZERS(cls) -> list[Serializer]:
return []
@classmethod
def GET_NODE_INFO_V3(cls) -> dict[str, Any]:
schema = cls.GET_SCHEMA()
info = schema.get_v3_info(cls)
return asdict(info)
def __init__(self): def __init__(self):
self.local_state: NodeStateLocal = None self.local_state: NodeStateLocal = None
self.local_resources: ResourcesLocal = None self.local_resources: ResourcesLocal = None
self.__class__.VALIDATE_CLASS() self.__class__.VALIDATE_CLASS()
@classmethod
def GET_SERIALIZERS(cls) -> list[Serializer]:
return []
@classmethod
def GET_BASE_CLASS(cls):
return _ComfyNodeBaseInternal
@final
@classmethod @classmethod
def VALIDATE_CLASS(cls): def VALIDATE_CLASS(cls):
if not callable(cls.define_schema): if first_real_override(cls, "define_schema") is None:
raise Exception(f"No define_schema function was defined for node class {cls.__name__}.") raise Exception(f"No define_schema function was defined for node class {cls.__name__}.")
if not callable(cls.execute): if first_real_override(cls, "execute") is None:
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__}.")
@final
@classmethod @classmethod
def EXECUTE_NORMALIZED(cls, *args, **kwargs) -> NodeOutput: def EXECUTE_NORMALIZED(cls, *args, **kwargs) -> NodeOutput:
to_return = cls.execute(*args, **kwargs) to_return = cls.execute(*args, **kwargs)
@ -1330,6 +1333,7 @@ class ComfyNodeV3(ComfyNodeInternal):
else: else:
raise Exception(f"Invalid return type from node: {type(to_return)}") raise Exception(f"Invalid return type from node: {type(to_return)}")
@final
@classmethod @classmethod
def PREPARE_CLASS_CLONE(cls, hidden_inputs: dict) -> type[ComfyNodeV3]: 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."""
@ -1339,10 +1343,24 @@ class ComfyNodeV3(ComfyNodeInternal):
type_clone.hidden = HiddenHolder.from_dict(hidden_inputs) type_clone.hidden = HiddenHolder.from_dict(hidden_inputs)
return type_clone return type_clone
@final
@classmethod
def GET_NODE_INFO_V3(cls) -> dict[str, Any]:
schema = cls.GET_SCHEMA()
info = schema.get_v3_info(cls)
return asdict(info)
############################################# #############################################
# V1 Backwards Compatibility code # V1 Backwards Compatibility code
#-------------------------------------------- #--------------------------------------------
@final
@classmethod
def GET_NODE_INFO_V1(cls) -> dict[str, Any]:
schema = cls.GET_SCHEMA()
info = schema.get_v1_info(cls)
return asdict(info)
_DESCRIPTION = None _DESCRIPTION = None
@final
@classproperty @classproperty
def DESCRIPTION(cls): # noqa def DESCRIPTION(cls): # noqa
if cls._DESCRIPTION is None: if cls._DESCRIPTION is None:
@ -1350,6 +1368,7 @@ class ComfyNodeV3(ComfyNodeInternal):
return cls._DESCRIPTION return cls._DESCRIPTION
_CATEGORY = None _CATEGORY = None
@final
@classproperty @classproperty
def CATEGORY(cls): # noqa def CATEGORY(cls): # noqa
if cls._CATEGORY is None: if cls._CATEGORY is None:
@ -1357,6 +1376,7 @@ class ComfyNodeV3(ComfyNodeInternal):
return cls._CATEGORY return cls._CATEGORY
_EXPERIMENTAL = None _EXPERIMENTAL = None
@final
@classproperty @classproperty
def EXPERIMENTAL(cls): # noqa def EXPERIMENTAL(cls): # noqa
if cls._EXPERIMENTAL is None: if cls._EXPERIMENTAL is None:
@ -1364,6 +1384,7 @@ class ComfyNodeV3(ComfyNodeInternal):
return cls._EXPERIMENTAL return cls._EXPERIMENTAL
_DEPRECATED = None _DEPRECATED = None
@final
@classproperty @classproperty
def DEPRECATED(cls): # noqa def DEPRECATED(cls): # noqa
if cls._DEPRECATED is None: if cls._DEPRECATED is None:
@ -1371,6 +1392,7 @@ class ComfyNodeV3(ComfyNodeInternal):
return cls._DEPRECATED return cls._DEPRECATED
_API_NODE = None _API_NODE = None
@final
@classproperty @classproperty
def API_NODE(cls): # noqa def API_NODE(cls): # noqa
if cls._API_NODE is None: if cls._API_NODE is None:
@ -1378,6 +1400,7 @@ class ComfyNodeV3(ComfyNodeInternal):
return cls._API_NODE return cls._API_NODE
_OUTPUT_NODE = None _OUTPUT_NODE = None
@final
@classproperty @classproperty
def OUTPUT_NODE(cls): # noqa def OUTPUT_NODE(cls): # noqa
if cls._OUTPUT_NODE is None: if cls._OUTPUT_NODE is None:
@ -1385,6 +1408,7 @@ class ComfyNodeV3(ComfyNodeInternal):
return cls._OUTPUT_NODE return cls._OUTPUT_NODE
_INPUT_IS_LIST = None _INPUT_IS_LIST = None
@final
@classproperty @classproperty
def INPUT_IS_LIST(cls): # noqa def INPUT_IS_LIST(cls): # noqa
if cls._INPUT_IS_LIST is None: if cls._INPUT_IS_LIST is None:
@ -1392,6 +1416,7 @@ class ComfyNodeV3(ComfyNodeInternal):
return cls._INPUT_IS_LIST return cls._INPUT_IS_LIST
_OUTPUT_IS_LIST = None _OUTPUT_IS_LIST = None
@final
@classproperty @classproperty
def OUTPUT_IS_LIST(cls): # noqa def OUTPUT_IS_LIST(cls): # noqa
if cls._OUTPUT_IS_LIST is None: if cls._OUTPUT_IS_LIST is None:
@ -1399,6 +1424,7 @@ class ComfyNodeV3(ComfyNodeInternal):
return cls._OUTPUT_IS_LIST return cls._OUTPUT_IS_LIST
_RETURN_TYPES = None _RETURN_TYPES = None
@final
@classproperty @classproperty
def RETURN_TYPES(cls): # noqa def RETURN_TYPES(cls): # noqa
if cls._RETURN_TYPES is None: if cls._RETURN_TYPES is None:
@ -1406,6 +1432,7 @@ class ComfyNodeV3(ComfyNodeInternal):
return cls._RETURN_TYPES return cls._RETURN_TYPES
_RETURN_NAMES = None _RETURN_NAMES = None
@final
@classproperty @classproperty
def RETURN_NAMES(cls): # noqa def RETURN_NAMES(cls): # noqa
if cls._RETURN_NAMES is None: if cls._RETURN_NAMES is None:
@ -1413,6 +1440,7 @@ class ComfyNodeV3(ComfyNodeInternal):
return cls._RETURN_NAMES return cls._RETURN_NAMES
_OUTPUT_TOOLTIPS = None _OUTPUT_TOOLTIPS = None
@final
@classproperty @classproperty
def OUTPUT_TOOLTIPS(cls): # noqa def OUTPUT_TOOLTIPS(cls): # noqa
if cls._OUTPUT_TOOLTIPS is None: if cls._OUTPUT_TOOLTIPS is None:
@ -1420,6 +1448,7 @@ class ComfyNodeV3(ComfyNodeInternal):
return cls._OUTPUT_TOOLTIPS return cls._OUTPUT_TOOLTIPS
_NOT_IDEMPOTENT = None _NOT_IDEMPOTENT = None
@final
@classproperty @classproperty
def NOT_IDEMPOTENT(cls): # noqa def NOT_IDEMPOTENT(cls): # noqa
if cls._NOT_IDEMPOTENT is None: if cls._NOT_IDEMPOTENT is None:
@ -1428,6 +1457,7 @@ class ComfyNodeV3(ComfyNodeInternal):
FUNCTION = "EXECUTE_NORMALIZED" FUNCTION = "EXECUTE_NORMALIZED"
@final
@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]:
schema = cls.FINALIZE_SCHEMA() schema = cls.FINALIZE_SCHEMA()
@ -1439,6 +1469,7 @@ class ComfyNodeV3(ComfyNodeInternal):
return input, schema return input, schema
return input return input
@final
@classmethod @classmethod
def FINALIZE_SCHEMA(cls): def FINALIZE_SCHEMA(cls):
"""Call define_schema and finalize it.""" """Call define_schema and finalize it."""
@ -1446,6 +1477,7 @@ class ComfyNodeV3(ComfyNodeInternal):
schema.finalize() schema.finalize()
return schema return schema
@final
@classmethod @classmethod
def GET_SCHEMA(cls) -> SchemaV3: def GET_SCHEMA(cls) -> SchemaV3:
"""Validate node class, finalize schema, validate schema, and set expected class properties.""" """Validate node class, finalize schema, validate schema, and set expected class properties."""
@ -1487,16 +1519,58 @@ class ComfyNodeV3(ComfyNodeInternal):
cls._OUTPUT_TOOLTIPS = output_tooltips cls._OUTPUT_TOOLTIPS = output_tooltips
cls.SCHEMA = schema cls.SCHEMA = schema
return schema return schema
@classmethod
def GET_NODE_INFO_V1(cls) -> dict[str, Any]:
schema = cls.GET_SCHEMA()
info = schema.get_v1_info(cls)
return asdict(info)
#-------------------------------------------- #--------------------------------------------
############################################# #############################################
class ComfyNodeV3(_ComfyNodeBaseInternal):
"""Common base class for all V3 nodes."""
@classmethod
@abstractmethod
def define_schema(cls) -> SchemaV3:
"""Override this function with one that returns a SchemaV3 instance."""
raise NotImplementedError
@classmethod
@abstractmethod
def execute(cls, **kwargs) -> NodeOutput:
"""Override this function with one that performs node's actions."""
raise NotImplementedError
@classmethod
def validate_inputs(cls, **kwargs) -> bool:
"""Optionally, define this function to validate inputs; equivalent to V1's VALIDATE_INPUTS."""
raise NotImplementedError
@classmethod
def fingerprint_inputs(cls, **kwargs) -> Any:
"""Optionally, define this function to fingerprint inputs; equivalent to V1's IS_CHANGED."""
raise NotImplementedError
@classmethod
def check_lazy_status(cls, **kwargs) -> list[str]:
"""Optionally, define this function to return a list of input names that should be evaluated.
This basic mixin impl. requires all inputs.
:kwargs: All node inputs will be included here. If the input is ``None``, it should be assumed that it has not yet been evaluated. \
When using ``INPUT_IS_LIST = True``, unevaluated will instead be ``(None,)``.
Params should match the nodes execution ``FUNCTION`` (self, and all inputs by name).
Will be executed repeatedly until it returns an empty list, or all requested items were already evaluated (and sent as params).
Comfy Docs: https://docs.comfy.org/custom-nodes/backend/lazy_evaluation#defining-check-lazy-status
"""
return [name for name in kwargs if kwargs[name] is None]
@final
@classmethod
def GET_BASE_CLASS(cls):
"""DO NOT override this class. Will break things in execution.py."""
return ComfyNodeV3
class NodeOutput: class NodeOutput:
''' '''
Standardized output of a node; can pass in any number of args and/or a UIOutput into 'ui' kwarg. Standardized output of a node; can pass in any number of args and/or a UIOutput into 'ui' kwarg.

View File

@ -28,6 +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.internal import ComfyNodeInternal
from comfy_api.v3 import io, helpers from comfy_api.v3 import io, helpers
@ -54,7 +55,7 @@ class IsChangedCache:
class_def = nodes.NODE_CLASS_MAPPINGS[class_type] class_def = nodes.NODE_CLASS_MAPPINGS[class_type]
has_is_changed = False has_is_changed = False
is_changed_name = None is_changed_name = None
if issubclass(class_def, io.ComfyNodeV3) and helpers.first_real_override(class_def, "fingerprint_inputs", base=io.ComfyNodeV3) is not None: if issubclass(class_def, ComfyNodeInternal) and helpers.first_real_override(class_def, "fingerprint_inputs") is not None:
has_is_changed = True has_is_changed = True
is_changed_name = "fingerprint_inputs" is_changed_name = "fingerprint_inputs"
elif hasattr(class_def, "IS_CHANGED"): elif hasattr(class_def, "IS_CHANGED"):
@ -127,7 +128,7 @@ class CacheSet:
return result return result
def get_input_data(inputs, class_def, unique_id, outputs=None, dynprompt=None, extra_data={}): def get_input_data(inputs, class_def, unique_id, outputs=None, dynprompt=None, extra_data={}):
is_v3 = issubclass(class_def, io.ComfyNodeV3) is_v3 = issubclass(class_def, ComfyNodeInternal)
if is_v3: if is_v3:
valid_inputs, schema = class_def.INPUT_TYPES(include_hidden=False, return_schema=True) valid_inputs, schema = class_def.INPUT_TYPES(include_hidden=False, return_schema=True)
else: else:
@ -224,7 +225,7 @@ def _map_node_over_list(obj, input_data_all, func, allow_interrupt=False, execut
if pre_execute_cb is not None and index is not None: if pre_execute_cb is not None and index is not None:
pre_execute_cb(index) pre_execute_cb(index)
# V3 # V3
if isinstance(obj, io.ComfyNodeV3) or (io.is_class(obj) and issubclass(obj, io.ComfyNodeV3)): if isinstance(obj, ComfyNodeInternal) or (io.is_class(obj) and issubclass(obj, ComfyNodeInternal)):
# if is just a class, then assign no resources or state, just create clone # if is just a class, then assign no resources or state, just create clone
if io.is_class(obj): if io.is_class(obj):
type_obj = obj type_obj = obj
@ -411,8 +412,8 @@ def execute(server, dynprompt, caches, current_item, extra_data, executed, promp
obj = class_def() obj = class_def()
caches.objects.set(unique_id, obj) caches.objects.set(unique_id, obj)
if issubclass(class_def, io.ComfyNodeV3): if issubclass(class_def, ComfyNodeInternal):
lazy_status_present = helpers.first_real_override(class_def, "check_lazy_status", base=io.ComfyNodeV3) is not None lazy_status_present = helpers.first_real_override(class_def, "check_lazy_status") is not None
else: else:
lazy_status_present = getattr(obj, "check_lazy_status", None) is not None lazy_status_present = getattr(obj, "check_lazy_status", None) is not None
if lazy_status_present: if lazy_status_present:
@ -674,9 +675,9 @@ def validate_inputs(prompt, item, validated):
validate_function_inputs = [] validate_function_inputs = []
validate_has_kwargs = False validate_has_kwargs = False
if issubclass(obj_class, io.ComfyNodeV3): if issubclass(obj_class, ComfyNodeInternal):
validate_function_name = "validate_inputs" validate_function_name = "validate_inputs"
validate_function = helpers.first_real_override(obj_class, validate_function_name, base=io.ComfyNodeV3) validate_function = helpers.first_real_override(obj_class, validate_function_name)
else: else:
validate_function_name = "VALIDATE_INPUTS" validate_function_name = "VALIDATE_INPUTS"
validate_function = getattr(obj_class, validate_function_name, None) validate_function = getattr(obj_class, validate_function_name, None)

View File

@ -29,7 +29,7 @@ import comfy.model_management
import node_helpers import node_helpers
from comfyui_version import __version__ from comfyui_version import __version__
from app.frontend_management import FrontendManager from app.frontend_management import FrontendManager
from comfy_api.v3.io import ComfyNodeV3 from comfy_api.internal import ComfyNodeInternal
from app.user_manager import UserManager from app.user_manager import UserManager
from app.model_manager import ModelFileManager from app.model_manager import ModelFileManager
@ -555,7 +555,7 @@ class PromptServer():
def node_info(node_class): def node_info(node_class):
obj_class = nodes.NODE_CLASS_MAPPINGS[node_class] obj_class = nodes.NODE_CLASS_MAPPINGS[node_class]
if issubclass(obj_class, ComfyNodeV3): if issubclass(obj_class, ComfyNodeInternal):
return obj_class.GET_NODE_INFO_V1() return obj_class.GET_NODE_INFO_V1()
info = {} info = {}
info['input'] = obj_class.INPUT_TYPES() info['input'] = obj_class.INPUT_TYPES()