From ff090c5ebba334e5056f48159f68e98a26e80896 Mon Sep 17 00:00:00 2001 From: Vito Date: Fri, 6 Mar 2026 12:33:48 -0800 Subject: [PATCH] fix: add OUTPUT_NODE = True to all nodes to prevent caching skip (#120) (#121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ComfyUI's execution engine walks backwards from output nodes. Without OUTPUT_NODE = True, our nodes could be silently skipped when downstream nodes are cached — even when IS_CHANGED returns a new value. Since all three nodes have side effects (database writes, execution tracking), they must be marked as output nodes so ComfyUI always includes them in the execution graph. Adds regression tests for OUTPUT_NODE and IS_CHANGED contracts. Bumps version to 3.1.2. --- prompt_manager.py | 1 + prompt_manager_text.py | 1 + prompt_search_list.py | 1 + pyproject.toml | 2 +- tests/test_node_execution_contract.py | 123 ++++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 tests/test_node_execution_contract.py diff --git a/prompt_manager.py b/prompt_manager.py index 5259d718..171debef 100644 --- a/prompt_manager.py +++ b/prompt_manager.py @@ -102,6 +102,7 @@ class PromptManager(PromptManagerBase, ComfyNodeABC): "The final combined text string (with prepend/append applied) that was encoded.", ) FUNCTION = "encode_prompt" + OUTPUT_NODE = True CATEGORY = "🫶 ComfyAssets/🧠 Prompts" DESCRIPTION = ( "Encodes a text prompt using a CLIP model into an embedding that can be used to guide " diff --git a/prompt_manager_text.py b/prompt_manager_text.py index 7c41051e..af5c4019 100644 --- a/prompt_manager_text.py +++ b/prompt_manager_text.py @@ -101,6 +101,7 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC): "The final combined text string (with prepend/append applied) ready for use in other nodes.", ) FUNCTION = "process_text" + OUTPUT_NODE = True CATEGORY = "🫶 ComfyAssets/🧠 Prompts" DESCRIPTION = ( "Processes and manages text prompts with database storage and search capabilities. " diff --git a/prompt_search_list.py b/prompt_search_list.py index 8a72310e..6910d563 100644 --- a/prompt_search_list.py +++ b/prompt_search_list.py @@ -108,6 +108,7 @@ class PromptSearchList(ComfyNodeABC): OUTPUT_IS_LIST = (True,) OUTPUT_TOOLTIPS = ("List of prompt texts matching the search criteria.",) FUNCTION = "search" + OUTPUT_NODE = True CATEGORY = "🫶 ComfyAssets/🧠 Prompts" DESCRIPTION = ( "Searches the prompt database and outputs matching prompts as a list. " diff --git a/pyproject.toml b/pyproject.toml index 662018a6..b80c54c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "promptmanager" description = "A powerful ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, and an automatic image gallery system using SQLite." -version = "3.1.1" +version = "3.1.2" license = {file = "LICENSE"} dependencies = ["# Core dependencies for PromptManager", "# Note: Most dependencies are already included with ComfyUI", "# Already included with Python standard library:", "# - sqlite3", "# - hashlib", "# - json", "# - datetime", "# - os", "# - typing", "# - threading", "# - uuid", "# Required for gallery functionality:", "watchdog>=2.1.0 # For file system monitoring", "Pillow>=8.0.0 # For image metadata extraction (usually included with ComfyUI)", "# Optional dependencies for enhanced search functionality:", "# fuzzywuzzy[speedup]>=0.18.0 # For fuzzy string matching (optional)", "# sqlalchemy>=1.4.0 # For advanced ORM features (optional)", "# Development dependencies (optional):", "# pytest>=6.0.0 # For running tests", "# black>=22.0.0 # For code formatting", "# flake8>=4.0.0 # For linting", "# mypy>=0.910 # For type checking"] diff --git a/tests/test_node_execution_contract.py b/tests/test_node_execution_contract.py new file mode 100644 index 00000000..e7b5abbe --- /dev/null +++ b/tests/test_node_execution_contract.py @@ -0,0 +1,123 @@ +""" +Tests for ComfyUI node execution contract. + +Ensures all nodes are properly configured for ComfyUI's execution engine: +- OUTPUT_NODE = True so nodes are always included in the execution graph +- IS_CHANGED returns correct values for cache invalidation + +Regression tests for: https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/120 +""" + +import os +import sys +import time +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from prompt_manager import PromptManager +from prompt_manager_text import PromptManagerText +from prompt_search_list import PromptSearchList + +ALL_NODE_CLASSES = [PromptManager, PromptManagerText, PromptSearchList] + + +class TestOutputNode(unittest.TestCase): + """OUTPUT_NODE = True is required so ComfyUI always includes these nodes + in the execution graph. Without it, nodes with side effects (database + writes, execution tracking) can be silently skipped when downstream + nodes are cached.""" + + def test_prompt_manager_is_output_node(self): + self.assertIs(PromptManager.OUTPUT_NODE, True) + + def test_prompt_manager_text_is_output_node(self): + self.assertIs(PromptManagerText.OUTPUT_NODE, True) + + def test_prompt_search_list_is_output_node(self): + self.assertIs(PromptSearchList.OUTPUT_NODE, True) + + +class TestIsChangedPromptManager(unittest.TestCase): + """IS_CHANGED for PromptManager should return a deterministic hash + based on text inputs, so the node re-runs only when inputs change.""" + + def test_same_inputs_return_same_hash(self): + result1 = PromptManager.IS_CHANGED( + clip=None, text="hello", prepend_text="pre", append_text="post" + ) + result2 = PromptManager.IS_CHANGED( + clip=None, text="hello", prepend_text="pre", append_text="post" + ) + self.assertEqual(result1, result2) + + def test_different_text_returns_different_hash(self): + result1 = PromptManager.IS_CHANGED(clip=None, text="hello") + result2 = PromptManager.IS_CHANGED(clip=None, text="world") + self.assertNotEqual(result1, result2) + + def test_different_prepend_returns_different_hash(self): + result1 = PromptManager.IS_CHANGED(clip=None, text="hello", prepend_text="a") + result2 = PromptManager.IS_CHANGED(clip=None, text="hello", prepend_text="b") + self.assertNotEqual(result1, result2) + + def test_different_append_returns_different_hash(self): + result1 = PromptManager.IS_CHANGED(clip=None, text="hello", append_text="a") + result2 = PromptManager.IS_CHANGED(clip=None, text="hello", append_text="b") + self.assertNotEqual(result1, result2) + + def test_returns_string(self): + result = PromptManager.IS_CHANGED(clip=None, text="hello") + self.assertIsInstance(result, str) + + +class TestIsChangedPromptManagerText(unittest.TestCase): + """IS_CHANGED for PromptManagerText should behave identically to + PromptManager — deterministic hash based on text inputs.""" + + def test_same_inputs_return_same_hash(self): + result1 = PromptManagerText.IS_CHANGED( + text="hello", prepend_text="pre", append_text="post" + ) + result2 = PromptManagerText.IS_CHANGED( + text="hello", prepend_text="pre", append_text="post" + ) + self.assertEqual(result1, result2) + + def test_different_text_returns_different_hash(self): + result1 = PromptManagerText.IS_CHANGED(text="hello") + result2 = PromptManagerText.IS_CHANGED(text="world") + self.assertNotEqual(result1, result2) + + def test_different_prepend_returns_different_hash(self): + result1 = PromptManagerText.IS_CHANGED(text="hello", prepend_text="a") + result2 = PromptManagerText.IS_CHANGED(text="hello", prepend_text="b") + self.assertNotEqual(result1, result2) + + def test_different_append_returns_different_hash(self): + result1 = PromptManagerText.IS_CHANGED(text="hello", append_text="a") + result2 = PromptManagerText.IS_CHANGED(text="hello", append_text="b") + self.assertNotEqual(result1, result2) + + def test_returns_string(self): + result = PromptManagerText.IS_CHANGED(text="hello") + self.assertIsInstance(result, str) + + +class TestIsChangedPromptSearchList(unittest.TestCase): + """IS_CHANGED for PromptSearchList should always return a unique value + so the node re-runs every time (database contents may have changed).""" + + def test_returns_different_value_on_successive_calls(self): + result1 = PromptSearchList.IS_CHANGED() + time.sleep(0.01) + result2 = PromptSearchList.IS_CHANGED() + self.assertNotEqual(result1, result2) + + def test_returns_numeric(self): + result = PromptSearchList.IS_CHANGED() + self.assertIsInstance(result, float) + + +if __name__ == "__main__": + unittest.main()