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.
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
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
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)
if base_attr is None:
return None

View File

@ -6,11 +6,12 @@ from collections import Counter
from dataclasses import asdict, dataclass
from enum import Enum
from typing import Any, Callable, Literal, TypedDict, TypeVar
from comfy_api.v3.helpers import first_real_override
# used for type hinting
import torch
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 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())
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
SCHEMA = None
@ -1264,6 +1266,7 @@ class ComfyNodeV3(ComfyNodeInternal):
@classmethod
@abstractmethod
def execute(cls, **kwargs) -> NodeOutput:
"""Override this function with one that performs node's actions."""
raise NotImplementedError
@classmethod
@ -1292,28 +1295,28 @@ class ComfyNodeV3(ComfyNodeInternal):
"""
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):
self.local_state: NodeStateLocal = None
self.local_resources: ResourcesLocal = None
self.__class__.VALIDATE_CLASS()
@classmethod
def GET_SERIALIZERS(cls) -> list[Serializer]:
return []
@classmethod
def GET_BASE_CLASS(cls):
return _ComfyNodeBaseInternal
@final
@classmethod
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__}.")
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__}.")
@final
@classmethod
def EXECUTE_NORMALIZED(cls, *args, **kwargs) -> NodeOutput:
to_return = cls.execute(*args, **kwargs)
@ -1330,6 +1333,7 @@ class ComfyNodeV3(ComfyNodeInternal):
else:
raise Exception(f"Invalid return type from node: {type(to_return)}")
@final
@classmethod
def PREPARE_CLASS_CLONE(cls, hidden_inputs: dict) -> type[ComfyNodeV3]:
"""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)
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
#--------------------------------------------
@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
@final
@classproperty
def DESCRIPTION(cls): # noqa
if cls._DESCRIPTION is None:
@ -1350,6 +1368,7 @@ class ComfyNodeV3(ComfyNodeInternal):
return cls._DESCRIPTION
_CATEGORY = None
@final
@classproperty
def CATEGORY(cls): # noqa
if cls._CATEGORY is None:
@ -1357,6 +1376,7 @@ class ComfyNodeV3(ComfyNodeInternal):
return cls._CATEGORY
_EXPERIMENTAL = None
@final
@classproperty
def EXPERIMENTAL(cls): # noqa
if cls._EXPERIMENTAL is None:
@ -1364,6 +1384,7 @@ class ComfyNodeV3(ComfyNodeInternal):
return cls._EXPERIMENTAL
_DEPRECATED = None
@final
@classproperty
def DEPRECATED(cls): # noqa
if cls._DEPRECATED is None:
@ -1371,6 +1392,7 @@ class ComfyNodeV3(ComfyNodeInternal):
return cls._DEPRECATED
_API_NODE = None
@final
@classproperty
def API_NODE(cls): # noqa
if cls._API_NODE is None:
@ -1378,6 +1400,7 @@ class ComfyNodeV3(ComfyNodeInternal):
return cls._API_NODE
_OUTPUT_NODE = None
@final
@classproperty
def OUTPUT_NODE(cls): # noqa
if cls._OUTPUT_NODE is None:
@ -1385,6 +1408,7 @@ class ComfyNodeV3(ComfyNodeInternal):
return cls._OUTPUT_NODE
_INPUT_IS_LIST = None
@final
@classproperty
def INPUT_IS_LIST(cls): # noqa
if cls._INPUT_IS_LIST is None:
@ -1392,6 +1416,7 @@ class ComfyNodeV3(ComfyNodeInternal):
return cls._INPUT_IS_LIST
_OUTPUT_IS_LIST = None
@final
@classproperty
def OUTPUT_IS_LIST(cls): # noqa
if cls._OUTPUT_IS_LIST is None:
@ -1399,6 +1424,7 @@ class ComfyNodeV3(ComfyNodeInternal):
return cls._OUTPUT_IS_LIST
_RETURN_TYPES = None
@final
@classproperty
def RETURN_TYPES(cls): # noqa
if cls._RETURN_TYPES is None:
@ -1406,6 +1432,7 @@ class ComfyNodeV3(ComfyNodeInternal):
return cls._RETURN_TYPES
_RETURN_NAMES = None
@final
@classproperty
def RETURN_NAMES(cls): # noqa
if cls._RETURN_NAMES is None:
@ -1413,6 +1440,7 @@ class ComfyNodeV3(ComfyNodeInternal):
return cls._RETURN_NAMES
_OUTPUT_TOOLTIPS = None
@final
@classproperty
def OUTPUT_TOOLTIPS(cls): # noqa
if cls._OUTPUT_TOOLTIPS is None:
@ -1420,6 +1448,7 @@ class ComfyNodeV3(ComfyNodeInternal):
return cls._OUTPUT_TOOLTIPS
_NOT_IDEMPOTENT = None
@final
@classproperty
def NOT_IDEMPOTENT(cls): # noqa
if cls._NOT_IDEMPOTENT is None:
@ -1428,6 +1457,7 @@ class ComfyNodeV3(ComfyNodeInternal):
FUNCTION = "EXECUTE_NORMALIZED"
@final
@classmethod
def INPUT_TYPES(cls, include_hidden=True, return_schema=False) -> dict[str, dict] | tuple[dict[str, dict], SchemaV3]:
schema = cls.FINALIZE_SCHEMA()
@ -1439,6 +1469,7 @@ class ComfyNodeV3(ComfyNodeInternal):
return input, schema
return input
@final
@classmethod
def FINALIZE_SCHEMA(cls):
"""Call define_schema and finalize it."""
@ -1446,6 +1477,7 @@ class ComfyNodeV3(ComfyNodeInternal):
schema.finalize()
return schema
@final
@classmethod
def GET_SCHEMA(cls) -> SchemaV3:
"""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.SCHEMA = 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:
'''
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.validation import validate_node_input
from comfy_api.internal import ComfyNodeInternal
from comfy_api.v3 import io, helpers
@ -54,7 +55,7 @@ class IsChangedCache:
class_def = nodes.NODE_CLASS_MAPPINGS[class_type]
has_is_changed = False
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
is_changed_name = "fingerprint_inputs"
elif hasattr(class_def, "IS_CHANGED"):
@ -127,7 +128,7 @@ class CacheSet:
return result
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:
valid_inputs, schema = class_def.INPUT_TYPES(include_hidden=False, return_schema=True)
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:
pre_execute_cb(index)
# 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 io.is_class(obj):
type_obj = obj
@ -411,8 +412,8 @@ def execute(server, dynprompt, caches, current_item, extra_data, executed, promp
obj = class_def()
caches.objects.set(unique_id, obj)
if issubclass(class_def, io.ComfyNodeV3):
lazy_status_present = helpers.first_real_override(class_def, "check_lazy_status", base=io.ComfyNodeV3) is not None
if issubclass(class_def, ComfyNodeInternal):
lazy_status_present = helpers.first_real_override(class_def, "check_lazy_status") is not None
else:
lazy_status_present = getattr(obj, "check_lazy_status", None) is not None
if lazy_status_present:
@ -674,9 +675,9 @@ def validate_inputs(prompt, item, validated):
validate_function_inputs = []
validate_has_kwargs = False
if issubclass(obj_class, io.ComfyNodeV3):
if issubclass(obj_class, ComfyNodeInternal):
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:
validate_function_name = "VALIDATE_INPUTS"
validate_function = getattr(obj_class, validate_function_name, None)

View File

@ -29,7 +29,7 @@ import comfy.model_management
import node_helpers
from comfyui_version import __version__
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.model_manager import ModelFileManager
@ -555,7 +555,7 @@ class PromptServer():
def node_info(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()
info = {}
info['input'] = obj_class.INPUT_TYPES()