BREAKING CHANGE: Output order changed to put primary outputs first
- formatted_string now always in output position 0 (was dynamic)
- saved_file_path now always in output position 1 (was dynamic)
- Variable pass-through outputs now in positions 2+ (were 0+)
This change ensures primary outputs are in fixed, predictable positions,
resolving ComfyUI output mapping issues when dynamic outputs are present.
Changes:
- Updated format_string() return statement to reverse output order
- Updated update_widget() to set RETURN_TYPES/RETURN_NAMES in new order
- Updated all 47 tests to expect new output positions
- Updated all docstrings and examples in format_string.py
- Added comprehensive "Output Structure" documentation to README.md
- Added same documentation to docs/index.md for consistency
- All tests passing ✅
795 lines
30 KiB
Python
795 lines
30 KiB
Python
"""
|
|
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, Tuple
|
|
|
|
from aiohttp import web
|
|
from jinja2 import exceptions, sandbox
|
|
from rich import print
|
|
|
|
# Set up logger for this module
|
|
logger = logging.getLogger(__name__)
|
|
logger.setLevel(logging.DEBUG)
|
|
|
|
if "comfy" in sys.modules:
|
|
import folder_paths # from comfyui - gives access to `get_temp_directory()` and `get_output_directory()`
|
|
from server import PromptServer # noqa: from comfyui
|
|
else:
|
|
print(
|
|
"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)
|
|
|
|
# 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'
|
|
```
|
|
|
|
<!-- Example Test:
|
|
>>> from datetime import datetime
|
|
>>> timestamp = FormatString.time_now()
|
|
>>> assert len(timestamp) == 15 # Format YYYYMMDD-HHMMSS is 15 chars
|
|
>>> assert timestamp[8] == '-' # Check format separator
|
|
>>> # Verify it's roughly the current time (allowing some seconds of delay)
|
|
>>> current = datetime.now().strftime("%Y%m%d-%H%M")
|
|
>>> assert timestamp.startswith(current)
|
|
-->
|
|
"""
|
|
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"],)
|
|
```
|
|
|
|
<!-- Example Test:
|
|
>>> input_types = FormatString.INPUT_TYPES()
|
|
>>> assert "required" in input_types
|
|
>>> assert "template_type" in input_types["required"]
|
|
>>> assert "template" in input_types["required"]
|
|
>>> assert "save_path" in input_types["required"]
|
|
>>> assert "hidden" in input_types
|
|
>>> assert "unique_id" in input_types["hidden"]
|
|
>>> assert input_types["required"]["template_type"] == (["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
|
|
```
|
|
|
|
<!-- Example Test:
|
|
>>> # Test with Simple template
|
|
>>> result = FormatString.IS_CHANGED(template="Hello {name}", template_type="Simple")
|
|
>>> assert isinstance(result, dict)
|
|
>>> # Test with Jinja2 template containing datetime function call
|
|
>>> result = FormatString.IS_CHANGED(template="Time: {{ datetime.now() }}", template_type="Jinja2")
|
|
>>> assert isinstance(result, int) # Should return a random int to force recalculation
|
|
-->
|
|
"""
|
|
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']
|
|
```
|
|
|
|
<!-- Example Test:
|
|
>>> # Test simple format
|
|
>>> keys = FormatString._extract_keys("Hello {name}, your age is {age}")
|
|
>>> assert sorted(keys) == ['age', 'name']
|
|
>>> # Test Jinja2 format
|
|
>>> keys = FormatString._extract_keys("Hello {{ name }}, {{ greeting | upper }}")
|
|
>>> assert sorted(keys) == ['greeting', 'name']
|
|
>>> # Test mixed format
|
|
>>> keys = FormatString._extract_keys("Hello {name}, today is {{ date }}")
|
|
>>> assert sorted(keys) == ['date', 'name']
|
|
>>> # Test with additional context (should be excluded)
|
|
>>> keys = FormatString._extract_keys("Time: {{ datetime.now() }}")
|
|
>>> assert keys == []
|
|
-->
|
|
"""
|
|
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,
|
|
) -> Tuple[str, ...]:
|
|
"""
|
|
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.
|
|
|
|
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:
|
|
Tuple[str, ...]: 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
|
|
result = FormatString.format_string(
|
|
template_type="Simple",
|
|
template="Hello {name}, you are {age} years old",
|
|
save_path="",
|
|
unique_id="123",
|
|
name="Alice",
|
|
age="30"
|
|
)
|
|
print(result) # Outputs: ('Hello Alice, you are 30 years old', '', 'Alice', '30')
|
|
|
|
# Jinja2 template example
|
|
result = FormatString.format_string(
|
|
template_type="Jinja2",
|
|
template="Hello {{ name }}, today is {{ datetime.now().strftime('%A') }}",
|
|
save_path="",
|
|
unique_id="124",
|
|
name="Bob"
|
|
)
|
|
print(result) # Outputs: ('Hello Bob, today is Wednesday', '', 'Bob')
|
|
```
|
|
|
|
<!-- Example Test:
|
|
>>> # Test simple format
|
|
>>> result = FormatString.format_string(
|
|
... template_type="Simple",
|
|
... template="Hello {name}, you are {age} years old",
|
|
... save_path="",
|
|
... name="Alice",
|
|
... age="30"
|
|
... )
|
|
>>> assert result[0] == "Hello Alice, you are 30 years old"
|
|
>>> assert result[1] == ""
|
|
>>> assert result[2] == "Alice"
|
|
>>> assert result[3] == "30"
|
|
>>>
|
|
>>> # Test Jinja2 format with datetime (can't test exact output due to time dependency)
|
|
>>> result = FormatString.format_string(
|
|
... template_type="Jinja2",
|
|
... template="Name: {{ name }}",
|
|
... save_path="",
|
|
... name="Bob"
|
|
... )
|
|
>>> assert result[0] == "Name: Bob"
|
|
>>> assert result[1] == ""
|
|
>>> assert result[2] == "Bob"
|
|
-->
|
|
"""
|
|
logger.info(
|
|
f"Formatting string - type: {template_type}, unique_id: {unique_id}"
|
|
)
|
|
logger.debug(f"Template: {template[:100]}...")
|
|
|
|
keys = cls._extract_keys(template)
|
|
logger.debug(f"Extracted variables: {keys}")
|
|
input_vals = ", ".join(f'{k}={kwargs.get(k, "")}' for k in keys)
|
|
logger.debug(f"Input values: {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(
|
|
f"Updated RETURN_TYPES for node {unique_id}: {cls.RETURN_TYPES}"
|
|
)
|
|
|
|
if template_type == "Simple":
|
|
try:
|
|
formatted_string = template.format(**kwargs)
|
|
if logger.level < logging.DEBUG:
|
|
logger.info(
|
|
f"Simple format successful, result length: {len(formatted_string)}"
|
|
)
|
|
elif logger.level == logging.DEBUG:
|
|
logger.debug(f"Simple format successful: {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.info(
|
|
f"Jinja2 format successful, result length: {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")
|
|
|
|
# Log the final formatted string to stdout for visibility
|
|
print(f"\n[FormatString Node {unique_id}] Output:")
|
|
print(f" formatted_string: {formatted_string}")
|
|
print(f" Variables extracted: {keys}")
|
|
print(f" Variable values: {[kwargs.get(key, '') for key in keys]}")
|
|
print(f" Class RETURN_TYPES: {cls.RETURN_TYPES}")
|
|
print(f" Class RETURN_NAMES: {cls.RETURN_NAMES}")
|
|
print(
|
|
f" Expected outputs: {len(keys)} vars + formatted_string + saved_file_path = {len(keys) + 2} total"
|
|
)
|
|
print()
|
|
|
|
# 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)
|
|
|
|
print(f"[FormatString Node {unique_id}] Actual return tuple:")
|
|
for i, (name, value) in enumerate(zip(cls.RETURN_NAMES, result)):
|
|
value_preview = value[:50] if len(value) > 50 else value
|
|
print(f" Output {i}: {name} = {value_preview}")
|
|
print()
|
|
|
|
logger.debug(
|
|
f"Returning {len(result)} outputs: keys={keys}, formatted_string={formatted_string[:50]}..., save_path={actual_save_path}"
|
|
)
|
|
logger.debug(
|
|
f"Full result tuple length: {len(result)}, expected: {len(keys) + 2}"
|
|
)
|
|
return 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
|
|
```
|
|
|
|
<!-- Example Test:
|
|
>>> config = FormatString.update_widget(
|
|
... node_id="test_node",
|
|
... template_type="Simple",
|
|
... template="Hello {name}, you are {age} years old"
|
|
... )
|
|
>>> assert "name" in config["inputs"]
|
|
>>> assert "age" in config["inputs"]
|
|
>>> assert len(config["outputs"]) == 4 # formatted_string, saved_file_path, name, age
|
|
>>> assert config["outputs"][0]["name"] == "formatted_string"
|
|
>>> assert config["outputs"][1]["name"] == "saved_file_path"
|
|
>>> assert config["outputs"][2]["name"] == "name"
|
|
>>> assert config["outputs"][3]["name"] == "age"
|
|
>>> # Check that RETURN_TYPES and RETURN_NAMES are updated
|
|
>>> assert len(FormatString.RETURN_TYPES) == 4
|
|
>>> assert len(FormatString.RETURN_NAMES) == 4
|
|
>>> assert FormatString.RETURN_NAMES[0] == "formatted_string"
|
|
>>> assert FormatString.RETURN_NAMES[1] == "saved_file_path"
|
|
>>> assert FormatString.RETURN_NAMES[2] == "name"
|
|
>>> assert FormatString.RETURN_NAMES[3] == "age"
|
|
>>> # Check that node config is stored
|
|
>>> assert "test_node" in FormatString.node_configs
|
|
>>> assert FormatString.node_configs["test_node"] == config
|
|
-->
|
|
"""
|
|
logger.info(
|
|
f"Updating widget config - node_id: {node_id}, template_type: {template_type}"
|
|
)
|
|
logger.debug(f"Template: {template[:100]}...")
|
|
|
|
keys = cls._extract_keys(template)
|
|
logger.info(f"Extracted {len(keys)} variables from template: {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": ""}) # type: ignore
|
|
config["outputs"].append({"name": key, "type": "STRING"}) # type: ignore
|
|
|
|
# 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(
|
|
f"Updated RETURN_TYPES to {len(cls.RETURN_TYPES)} outputs: {cls.RETURN_NAMES}"
|
|
)
|
|
|
|
# Store the configuration for this specific node
|
|
cls.node_configs[node_id] = config
|
|
logger.debug(f"Stored config for node {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
|
|
```
|
|
|
|
<!-- Example Test:
|
|
>>> # First create a config
|
|
>>> _ = FormatString.update_widget(
|
|
... node_id="test_node_2",
|
|
... template_type="Simple",
|
|
... template="Hello {name}"
|
|
... )
|
|
>>> # Then retrieve it
|
|
>>> config = FormatString.get_node_config("test_node_2")
|
|
>>> assert "inputs" in config
|
|
>>> assert "outputs" in config
|
|
>>> assert "name" in config["inputs"]
|
|
>>> assert len(config["outputs"]) == 3 # name, formatted_string, saved_file_path
|
|
>>> # Test non-existent node
|
|
>>> empty_config = FormatString.get_node_config("non_existent_node")
|
|
>>> assert empty_config == {}
|
|
-->
|
|
"""
|
|
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
|
|
```
|
|
|
|
<!-- Example Test:
|
|
>>> import tempfile
|
|
>>> import json
|
|
>>> import os
|
|
>>> # Create a temporary file with test data
|
|
>>> test_data = {
|
|
... "template_type": "Simple",
|
|
... "template": "Hello {name}",
|
|
... "inputs": {"name": "Alice"}
|
|
... }
|
|
>>> with tempfile.NamedTemporaryFile(delete=False, mode="w") as temp:
|
|
... json.dump(test_data, temp)
|
|
... temp_path = temp.name
|
|
>>> # Test loading the file
|
|
>>> state = FormatString.load_node_state(temp_path)
|
|
>>> assert state["template_type"] == "Simple"
|
|
>>> assert state["template"] == "Hello {name}"
|
|
>>> assert state["inputs"]["name"] == "Alice"
|
|
>>> # Clean up
|
|
>>> os.unlink(temp_path)
|
|
>>> # Test loading non-existent file
|
|
>>> empty_state = FormatString.load_node_state("non_existent_file.json")
|
|
>>> assert empty_state == {}
|
|
-->
|
|
"""
|
|
try:
|
|
with open(file_path, "r") as f:
|
|
load_data = json.load(f)
|
|
return load_data
|
|
except FileNotFoundError:
|
|
return {}
|
|
except Exception as e:
|
|
print(f"Error loading node state: {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.info(
|
|
f"Web API: update_format_string_node - node_id: {node_id}, template_type: {template_type}"
|
|
)
|
|
|
|
try:
|
|
updated_config = FormatString.update_widget(node_id, template_type, template)
|
|
logger.debug(f"Successfully updated config for node {node_id}")
|
|
return web.json_response(updated_config)
|
|
except Exception as e:
|
|
logger.error(f"Error updating node config: {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"}
|