""" FormatString module for ComfyUI. This module provides a custom node for ComfyUI that allows formatting strings using either simple Python formatting or Jinja2 templates. It can also save formatted templates to disk for later reuse. The node dynamically updates its inputs and outputs based on the variables detected in the template, making it highly flexible for various text generation and parameter formatting needs in ComfyUI workflows. """ import datetime import json import logging import math import os import random import re import sys from typing import Any, Dict, List from aiohttp import web from jinja2 import exceptions, sandbox # Set up logger for this module logger = logging.getLogger(__name__) if "comfy" in sys.modules: import folder_paths # from comfyui - gives access to `get_temp_directory()` and `get_output_directory()` from server import PromptServer else: logger.warning( "ComfyUI not detected, FormatString node will not function properly outside of ComfyUI." ) class FormatString: """ A ComfyUI node for string formatting using Python's format syntax or Jinja2 templates. This node dynamically adapts its inputs and outputs based on the variables detected in the provided template. It supports saving template state to disk and loading it back. The node can operate in two modes: 1. Simple: Uses Python's str.format() method 2. Jinja2: Uses Jinja2 templating engine with sandbox protection Additional context variables like datetime, random, and math functions are available in Jinja2 mode. Outputs: The node always returns formatted_string and saved_file_path as the first two outputs (positions 0 and 1), followed by any variable values extracted from the template in subsequent positions. This ensures the primary outputs are in fixed, predictable positions. Attributes: CATEGORY (str): The category of the node in ComfyUI's node menu. FUNCTION (str): The main function to be called when the node is executed. RETURN_TYPES (tuple): Types of the returned outputs (dynamically updated). RETURN_NAMES (tuple): Names of the returned outputs (dynamically updated). node_configs (dict): Storage for configurations of node instances. jinja_env (SandboxedEnvironment): Sandboxed Jinja2 environment for secure template rendering. additional_context (dict): Extra context variables available in Jinja2 templates. """ CATEGORY = "dv/string_operations" FUNCTION = "format_string" RETURN_TYPES = ("STRING", "STRING") RETURN_NAMES = ("formatted_string", "saved_file_path") OUTPUT_IS_LIST = (False, False) OUTPUT_NODE = True # Store configurations for each node instance node_configs: Dict[str, Dict[str, Any]] = {} # Create a sandboxed Jinja2 environment for security jinja_env = sandbox.SandboxedEnvironment() # Define additional context @staticmethod def time_now() -> str: """ Get the current time in a formatted string. Returns: str: Current time formatted as 'YYYYMMDD-HHMMSS'. Example: ```python from format_string import FormatString timestamp = FormatString.time_now() print(timestamp) # Outputs something like: '20240327-153045' ``` """ return datetime.datetime.now().strftime("%Y%m%d-%H%M%S") additional_context = { "datetime": datetime, "now": time_now, # we name our custom function `time_now` as `now` so inside jinja it's `{{ now() }}` "random": random, "math": math, # Add more modules or functions as needed } @classmethod def INPUT_TYPES(cls) -> Dict[str, Any]: """ Define the input types for the FormatString node. This method is called by ComfyUI to determine what inputs the node should have. Returns: Dict[str, Any]: Dictionary defining the node's inputs, including template_type, template, save_path, and a hidden unique_id. Example: ```python from format_string import FormatString input_types = FormatString.INPUT_TYPES() print(input_types["required"]["template_type"]) # Outputs: (["Simple", "Jinja2"],) ``` """ return { "required": { "template_type": (["Simple", "Jinja2"],), "template": ("STRING", {"multiline": True}), "save_path": ("STRING", {"default": ""}), }, "hidden": {"unique_id": "UNIQUE_ID"}, } @classmethod def IS_CHANGED(cls, **kwargs): """ Determine if the node should be re-executed based on input changes. This method is called by ComfyUI to check if the node needs to be re-calculated due to changes in its inputs. It forces recalculation when Jinja2 templates contain time-dependent function calls (e.g., datetime.now(), time_now()). Args: **kwargs: Keyword arguments containing the node's current inputs. Returns: Any: A hash of inputs for caching, or a random number to force recalculation when time-dependent functions are detected. Example: ```python from format_string import FormatString # This would typically be called by ComfyUI result = FormatString.IS_CHANGED(template="Hello {name}", template_type="Simple") # Returns hash of inputs for proper caching ``` """ template = kwargs.get("template", "") template_type = kwargs.get("template_type", "Simple") if not template: logger.debug("Empty template, returning kwargs for caching") return kwargs template_preview = template[:50] if len(template) > 50 else template logger.debug( f"IS_CHANGED called - template_type: {template_type}, template: {template_preview}..." ) # Check for time-dependent function calls in Jinja2 templates if template_type == "Jinja2": # Look for actual function calls like datetime.now(), now(), or time_now() # These are time-dependent and should force recalculation each time time_function_pattern = r"\b(datetime\.now|now|time_now)\s*\(" if re.search(time_function_pattern, template): # Force recalculation for time-dependent templates logger.debug( "Time-dependent function detected in Jinja2 template, forcing recalculation" ) return random.randrange(sys.maxsize) # Return kwargs for proper caching - ComfyUI will hash this logger.debug( "No time-dependent functions, using cached result if inputs unchanged" ) return kwargs @staticmethod def _extract_keys(template: str) -> List[str]: """ Extract variable names from a template string. This method parses a template string to find all variable names used in it, supporting both Python's format style {var} and Jinja2's {{ var }} syntax. Args: template (str): The template string to parse. Returns: List[str]: A list of unique variable names found in the template. Example: ```python from format_string import FormatString template = "Hello {name}, today is {{ datetime.now() }}" keys = FormatString._extract_keys(template) print(keys) # Outputs: ['name'] ``` """ variables = [] seen = set() def add_var(var): var = var.split("|")[0].split(".")[0].strip() if var not in seen and var not in FormatString.additional_context: seen.add(var) variables.append(var) # Extract variables from Jinja2 expressions {{ }} for match in re.finditer( r"\{\{\s*([\w.]+)(?:\s*\|[\w\s]+)?(?:\.[^\(\)]+\(\))?\s*\}\}", template ): add_var(match.group(1)) # Extract variables from f-string style { } for match in re.finditer(r"\{(\w+)\}", template): add_var(match.group(1)) # Extract variables from Jinja2 control structures {% %} for structure in re.finditer(r"\{%.*?%\}", template): for var in re.findall(r"\b(\w+)\|\b", structure.group(0)): if not var.startswith("end") and var not in { "if", "else", "elif", "for", "in", }: add_var(var) return variables @classmethod def format_string( cls, template_type: str, template: str, save_path: str, unique_id: str = "", **kwargs, ) -> Dict[str, Any]: """ Format a string using the specified template type and variables. This is the main method executed by the node. It formats the template using either Python's str.format() or Jinja2 templating, and optionally saves the state to disk. The node is OUTPUT_NODE=True so the formatted string also renders as a read-only text display directly on the node (no separate Show Text node needed). Args: template_type (str): Either "Simple" or "Jinja2" to specify the template engine. template (str): The template string to format. save_path (str): Optional path to save the node state. If empty, state is not saved. unique_id (str): The unique identifier for this node instance (passed by ComfyUI). **kwargs: Variable keyword arguments that provide values for template variables. Returns: Dict[str, Any]: ``{"ui": {"text": [formatted_string]}, "result": result}`` where ``result`` is a tuple containing the formatted string, the save path, followed by the values of input variables (in order). Example: ```python from format_string import FormatString # Simple template example ret = FormatString.format_string( template_type="Simple", template="Hello {name}, you are {age} years old", save_path="", unique_id="123", name="Alice", age="30" ) print(ret["result"]) # Outputs: ('Hello Alice, you are 30 years old', '', 'Alice', '30') # Jinja2 template example ret = FormatString.format_string( template_type="Jinja2", template="Hello {{ name }}, today is {{ datetime.now().strftime('%A') }}", save_path="", unique_id="124", name="Bob" ) print(ret["result"]) # Outputs: ('Hello Bob, today is Wednesday', '', 'Bob') ``` """ logger.debug( "Formatting string - type: %s, unique_id: %s", template_type, unique_id ) logger.debug("Template: %.100s...", template) keys = cls._extract_keys(template) logger.debug("Extracted variables: %s", keys) input_vals = ", ".join(f"{k}={kwargs.get(k, '')}" for k in keys) logger.debug("Input values: %s", input_vals) # CRITICAL: Update RETURN_TYPES/RETURN_NAMES before execution to ensure they match our return tuple # This is necessary because update_widget might not have been called yet (e.g., on workflow load) if unique_id: cls.update_widget(unique_id, template_type, template) logger.debug( "Updated RETURN_TYPES for node %s: %s", unique_id, cls.RETURN_TYPES ) if template_type == "Simple": try: formatted_string = template.format(**kwargs) logger.debug("Simple format successful: %.50s", formatted_string) except KeyError as e: error_msg = f"Missing variable in Simple template: {str(e)}" logger.error(error_msg) raise # Re-raise for proper error handling except Exception as e: error_msg = f"Error in Simple template: {str(e)}" logger.error(error_msg) formatted_string = f"Error: {error_msg}" else: # Jinja2 try: jinja_template = cls.jinja_env.from_string(template) # Combine user-provided kwargs with additional_context context = {**cls.additional_context, **kwargs} formatted_string = jinja_template.render(**context) logger.debug( "Jinja2 format successful, result length: %d", len(formatted_string) ) except exceptions.TemplateSyntaxError as e: error_msg = f"Error in Jinja2 template: {str(e)}" logger.error(error_msg) formatted_string = error_msg except Exception as e: error_msg = f"Error rendering Jinja2 template: {str(e)}" logger.error(error_msg) formatted_string = error_msg # Save the state save_data = { "template_type": template_type, "template": template, "inputs": {k: kwargs.get(k, "") for k in keys}, } actual_save_path = "" if save_path: actual_save_path = os.path.join( folder_paths.get_output_directory(), save_path ) try: os.makedirs(os.path.dirname(actual_save_path), exist_ok=True) with open(actual_save_path, "w") as f: json.dump(save_data, f, indent=2, sort_keys=True) logger.info(f"Node state saved to: {actual_save_path}") except Exception as e: logger.error(f"Error saving node state: {str(e)}") actual_save_path = "" # Reset save_path if saving failed else: logger.debug("No save_path provided, skipping state save") # Return formatted_string and saved_file_path FIRST (fixed positions 0,1), # then all input values (for chaining) # The order must match what was set in update_widget's RETURN_TYPES/RETURN_NAMES result = ( formatted_string, actual_save_path, ) + tuple(str(kwargs.get(key, "")) for key in keys) logger.debug( "Returning %d outputs: keys=%s, save_path=%s", len(result), keys, actual_save_path, ) logger.debug( "Full result tuple length: %d, expected: %d", len(result), len(keys) + 2 ) return { "ui": {"text": [formatted_string]}, "result": result, } @classmethod def update_widget( cls, node_id: str, template_type: str, template: str ) -> Dict[str, Any]: """ Update a node's widget configuration based on the template. This method is called when a template is changed to dynamically update the node's inputs and outputs based on the variables detected in the template. Args: node_id (str): The unique identifier of the node instance. template_type (str): The template type ("Simple" or "Jinja2"). template (str): The template string. Returns: Dict[str, Any]: Updated configuration for the node. Example: ```python from format_string import FormatString # Called via ComfyUI's web API when template changes config = FormatString.update_widget( node_id="node_123", template_type="Simple", template="Hello {name}, you are {age} years old" ) print(config["inputs"]) # Shows inputs including 'name' and 'age' print(config["outputs"]) # Shows outputs including extracted variables ``` """ logger.debug( "Updating widget config - node_id: %s, template_type: %s", node_id, template_type, ) logger.debug("Template: %.100s...", template) keys = cls._extract_keys(template) logger.debug("Extracted %d variables from template: %s", len(keys), keys) config: Dict[str, Any] = { "inputs": { "template_type": (["Simple", "Jinja2"],), "template": ("STRING", {"multiline": True}), "save_path": ("STRING", {"default": ""}), }, "outputs": [], } for key in keys: config["inputs"][key] = ("STRING", {"default": ""}) config["outputs"].append({"name": key, "type": "STRING"}) # Add formatted_string and saved_file_path at the START of outputs (fixed positions) config["outputs"] = [ {"name": "formatted_string", "type": "STRING"}, {"name": "saved_file_path", "type": "STRING"}, ] + config["outputs"] # Update RETURN_TYPES and RETURN_NAMES dynamically # formatted_string and saved_file_path are ALWAYS first two outputs (positions 0,1) # This allows passing through variable values for chaining cls.RETURN_TYPES = ("STRING", "STRING") + ("STRING",) * len(keys) cls.RETURN_NAMES = ("formatted_string", "saved_file_path") + tuple(keys) cls.OUTPUT_IS_LIST = (False,) * (len(keys) + 2) logger.debug( "Updated RETURN_TYPES to %d outputs: %s", len(cls.RETURN_TYPES), cls.RETURN_NAMES, ) # Store the configuration for this specific node cls.node_configs[node_id] = config logger.debug("Stored config for node %s", node_id) return config @classmethod def get_node_config(cls, node_id: str) -> Dict[str, Any]: """ Get the configuration for a specific node instance. Args: node_id (str): The unique identifier of the node instance. Returns: Dict[str, Any]: The configuration for the specified node, or an empty dict if not found. Example: ```python from format_string import FormatString # After updating a node's configuration config = FormatString.get_node_config("node_123") print(config) # Shows the stored configuration for node_123 ``` """ return cls.node_configs.get(node_id, {}) @classmethod def load_node_state(cls, file_path: str) -> Dict[str, Any]: """ Load a previously saved node state from disk. Args: file_path (str): Path to the saved node state JSON file. Returns: Dict[str, Any]: The loaded node state, or an empty dict if loading failed. Example: ```python from format_string import FormatString # Load a previously saved state state = FormatString.load_node_state("/path/to/saved_state.json") print(state["template"]) # Shows the saved template print(state["inputs"]) # Shows the saved input values ``` """ try: with open(file_path, "r") as f: load_data = json.load(f) return load_data except FileNotFoundError: return {} except Exception as e: logger.error("Failed to load node state from %s: %s", file_path, e) return {} # Custom route for updating node configuration @PromptServer.instance.routes.post("/update_format_string_node") async def update_format_string_node(request): """ AIOHTTP route handler for updating a FormatString node's configuration. This endpoint receives JSON data containing a node ID, template type, and template, then updates the node's configuration based on the template. Args: request (web.Request): The HTTP request object containing JSON data. Returns: web.Response: JSON response with the updated node configuration. Example: This would typically be called by the frontend JavaScript via a POST request: ```javascript // In ComfyUI frontend fetch("/update_format_string_node", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ nodeId: "node_123", template_type: "Simple", template: "Hello {name}" }) }).then(response => response.json()) .then(data => console.log(data)); ``` """ data = await request.json() node_id = data.get("nodeId", "") template_type = data.get("template_type", "") template = data.get("template", "") logger.debug( "Web API: update_format_string_node - node_id: %s, template_type: %s", node_id, template_type, ) try: updated_config = FormatString.update_widget(node_id, template_type, template) logger.debug("Successfully updated config for node %s", node_id) return web.json_response(updated_config) except Exception as e: logger.error("Error updating node config: %s", str(e), exc_info=True) return web.json_response({"error": str(e)}, status=500) # Custom route for loading node state @PromptServer.instance.routes.post("/load_format_string_node") async def load_format_string_node(request): """ AIOHTTP route handler for loading a FormatString node's state from disk. This endpoint receives JSON data containing a file path, then loads and returns the node state from that file. Args: request (web.Request): The HTTP request object containing JSON data. Returns: web.Response: JSON response with the loaded node state. Example: This would typically be called by the frontend JavaScript via a POST request: ```javascript // In ComfyUI frontend fetch("/load_format_string_node", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ file_path: "/path/to/saved_state.json" }) }).then(response => response.json()) .then(data => console.log(data)); ``` """ data = await request.json() file_path = data.get("file_path", "") state = FormatString.load_node_state(file_path) return web.json_response(state) # Custom route for getting node-specific configuration @PromptServer.instance.routes.get("/get_format_string_node_config/{node_id}") async def get_format_string_node_config(request): """ AIOHTTP route handler for retrieving a FormatString node's configuration. This endpoint retrieves the stored configuration for a specific node instance. Args: request (web.Request): The HTTP request object containing the node ID in the URL. Returns: web.Response: JSON response with the node's configuration. Example: This would typically be called by the frontend JavaScript via a GET request: ```javascript // In ComfyUI frontend fetch("/get_format_string_node_config/node_123") .then(response => response.json()) .then(data => console.log(data)); ``` """ node_id = request.match_info["node_id"] config = FormatString.get_node_config(node_id) return web.json_response(config) # Node registration for ComfyUI NODE_CLASS_MAPPINGS = {"FormatString": FormatString} NODE_DISPLAY_NAME_MAPPINGS = {"FormatString": "Format String"}