diff --git a/execution.py b/execution.py index c45317593..00e4c2aae 100644 --- a/execution.py +++ b/execution.py @@ -1113,6 +1113,32 @@ def full_type_name(klass): return klass.__qualname__ return module + '.' + klass.__qualname__ +def node_not_executable_reason(class_def, class_type): + """Return a human-readable reason the node cannot be executed, or None if it's fine. + + Catches a node whose declared entry point doesn't resolve to a real method + (e.g. a V1 ``FUNCTION = "invert"`` where the method is misspelled, or a V3 node + missing its ``execute`` override). Running this during validation surfaces the + problem before execution starts, instead of after upstream nodes have run. + + Only the class is inspected; the node is never instantiated here, so a node's + ``__init__`` side effects cannot run (or fail) during validation. + """ + try: + if issubclass(class_def, _ComfyNodeInternal): + # V3: validates that execute()/define_schema() overrides exist. + class_def.VALIDATE_CLASS() + return None + # V1: FUNCTION names the method to call; it must exist on the class. + function_name = getattr(class_def, "FUNCTION", None) + if function_name is None: + return f"'{class_type}' does not define FUNCTION" + if not callable(getattr(class_def, function_name, None)): + return f"'{class_type}' has no method '{function_name}' (declared in FUNCTION)" + return None + except Exception as ex: + return str(ex) + async def validate_prompt(prompt_id, prompt, partial_execution_list: Union[list[str], None]): outputs = set() for x in prompt: @@ -1148,6 +1174,35 @@ async def validate_prompt(prompt_id, prompt, partial_execution_list: Union[list[ } return (False, error, [], {}) + # Make sure the node is actually executable (its FUNCTION/execute entry + # point resolves to a real method) before we touch any schema-derived + # attributes below or start execution. Catches code typos up front and + # attributes the error to the offending node. + not_executable = node_not_executable_reason(class_, class_type) + if not_executable is not None: + node_title = prompt[x].get('_meta', {}).get('title', class_type) + error = { + "type": "invalid_node_definition", + "message": "Node is not executable", + "details": f"{not_executable} (Node ID '#{x}')", + "extra_info": { + "node_id": x, + "class_type": class_type, + "node_title": node_title, + } + } + node_errors = {x: { + "errors": [{ + "type": "invalid_node_definition", + "message": "Node is not executable", + "details": not_executable, + "extra_info": {}, + }], + "dependent_outputs": [], + "class_type": class_type, + }} + return (False, error, [], node_errors) + if hasattr(class_, 'OUTPUT_NODE') and class_.OUTPUT_NODE is True: if partial_execution_list is None or x in partial_execution_list: outputs.add(x) diff --git a/tests-unit/execution_test/validate_node_executable_test.py b/tests-unit/execution_test/validate_node_executable_test.py new file mode 100644 index 000000000..694163b58 --- /dev/null +++ b/tests-unit/execution_test/validate_node_executable_test.py @@ -0,0 +1,137 @@ +"""Tests for pre-execution validation that a node is actually executable. + +validate_prompt rejects a node whose declared entry point does not resolve to a +real method (a V1 FUNCTION typo, or a V3 node missing its execute override) before +any node runs, attributing the error to the offending node. +""" +import asyncio + +import nodes +from comfy_api.latest import io +from execution import node_not_executable_reason, validate_prompt + + +class _GoodV1Node: + @classmethod + def INPUT_TYPES(cls): + return {"required": {}} + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "run" + OUTPUT_NODE = True + CATEGORY = "Test" + + def run(self): + return (None,) + + +class _TypoV1Node: + @classmethod + def INPUT_TYPES(cls): + return {"required": {}} + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "invert" # method below is misspelled + OUTPUT_NODE = True + CATEGORY = "Test" + + def invvert(self): + return (None,) + + +class _SideEffectInitV1Node: + """Valid class-level method, but a constructor that must never run in validation.""" + @classmethod + def INPUT_TYPES(cls): + return {"required": {}} + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "run" + OUTPUT_NODE = True + CATEGORY = "Test" + + def __init__(self): + raise RuntimeError("__init__ must not run during validation") + + def run(self): + return (None,) + + +def _v3_schema(node_id): + return io.Schema( + node_id=node_id, + display_name=node_id, + category="Test", + inputs=[], + outputs=[io.Image.Output()], + is_output_node=True, + ) + + +class _GoodV3Node(io.ComfyNode): + @classmethod + def define_schema(cls): + return _v3_schema("GoodV3Node") + + @classmethod + def execute(cls): + return io.NodeOutput(None) + + +class _TypoV3Node(io.ComfyNode): + @classmethod + def define_schema(cls): + return _v3_schema("TypoV3Node") + + @classmethod + def exicute(cls): # typo: should be "execute" + return io.NodeOutput(None) + + +def _register(class_type, class_def): + nodes.NODE_CLASS_MAPPINGS[class_type] = class_def + + +def _validate(class_type): + prompt = {"1": {"class_type": class_type, "inputs": {}}} + return asyncio.run(validate_prompt("pid", prompt, None)) + + +def test_good_node_passes(): + _register("GoodV1Node", _GoodV1Node) + assert node_not_executable_reason(_GoodV1Node, "GoodV1Node") is None + valid, _, _, _ = _validate("GoodV1Node") + assert valid is True + + +def test_typo_node_rejected_with_node_error(): + _register("TypoV1Node", _TypoV1Node) + valid, error, _, node_errors = _validate("TypoV1Node") + assert valid is False + assert error["type"] == "invalid_node_definition" + assert node_errors["1"]["class_type"] == "TypoV1Node" + assert node_errors["1"]["errors"][0]["type"] == "invalid_node_definition" + assert "invert" in node_errors["1"]["errors"][0]["details"] + + +def test_validation_does_not_instantiate_node(): + """A valid node is not constructed during validation, so __init__ never runs.""" + _register("SideEffectInitV1Node", _SideEffectInitV1Node) + assert node_not_executable_reason(_SideEffectInitV1Node, "SideEffectInitV1Node") is None + valid, _, _, _ = _validate("SideEffectInitV1Node") + assert valid is True + + +def test_good_v3_node_passes(): + _register("GoodV3Node", _GoodV3Node) + assert node_not_executable_reason(_GoodV3Node, "GoodV3Node") is None + valid, _, _, _ = _validate("GoodV3Node") + assert valid is True + + +def test_typo_v3_node_rejected_with_node_error(): + _register("TypoV3Node", _TypoV3Node) + valid, error, _, node_errors = _validate("TypoV3Node") + assert valid is False + assert error["type"] == "invalid_node_definition" + assert node_errors["1"]["errors"][0]["type"] == "invalid_node_definition"