Compare commits

..
Author SHA1 Message Date
Peyton DeNiro 79312a25bb test: add session mode E2E tests and fix close method signature
Add 5 E2E tests for session execution mode: 2 unit tests for code
generation structure and 3 runtime tests that exercise the full
ComfyUI pipeline (oneshot, session single run, session multi-run).

Fix close() method signature in generated session code to use
"bool | None = None" instead of "bool = True" for consistency
with the actual generated output.
2026-04-19 18:03:29 -05:00
Peyton DeNiro e844d508ac test: consolidate and fix session runtime tests 2026-04-19 16:08:15 -05:00
Peyton DeNiro 08ad731038 feat: add session execution mode with WorkflowSession and WorkflowSessionRuntime
Add session-mode code generation that produces a reusable WorkflowSession class
instead of a simple oneshot script. The session mode generates code with an
in-class run/close lifecycle for warm ComfyUI reuse.

Also refactors render.py to extract shared code sections into common methods,
reducing duplication between oneshot and session renderers.

Adds WorkflowSessionRuntime with cleanup policies (per_run, session, manual)
and reset_every_n_runs support, plus 72 new tests covering session rendering,
runtime session, and session export pipeline.
2026-04-19 12:06:46 -05:00
Peyton DeNiro 6cdcc235a0 update README to add additional troubleshooting steps for installation 2026-04-11 19:53:04 -05:00
Peyton DeNiro 9a17000417 Update version. 2026-03-29 19:06:52 -05:00
Peyton DeNiro fbc06463f7 Merge pull request #153 from pydn/runtime-parity-cache-memory-lifecycle
Add generated runtime cleanup after workflow execution
2026-03-29 19:02:51 -05:00
Peyton DeNiro 77c0bbec9b Guard ComfyUI cleanup teardown failures 2026-03-29 19:02:21 -05:00
Peyton DeNiro f9163f2375 Add generated runtime cleanup after workflow execution. Exported scripts now run best-effort ComfyUI cache cleanup in a finally block and support opt-in model unloading for embedded repeated-call use cases. Document the supported VRAM-related runtime flags and lock the behavior in focused exporter and runtime cleanup tests. 2026-03-29 18:18:37 -05:00
Peyton DeNiro d6c5b110ab Merge pull request #152 from pydn/cluster-2-compatibility-78-99-137
Clarify Python support and harden export/install compatibility
2026-03-29 17:55:19 -05:00
Peyton DeNiro 5d70efdc88 Align Cluster 2 compatibility boundaries
Declare Python 3.12 support, replace the hidden pip bootstrap with explicit uv sync guidance, and remove prompt() from the Web UI save flow. Add focused contract coverage for the metadata and frontend defaults.
2026-03-29 17:34:30 -05:00
Peyton DeNiro 228c3a076a Merge pull request #151 from pydn/generator-codegen-correctness
Make generator codegen regressions explicit and fix string seed rand
2026-03-29 17:13:10 -05:00
Peyton DeNiro 574d96369c Make generator codegen regressions explicit and fix string seed randomization 2026-03-29 17:09:30 -05:00
Peyton DeNiro ce23e90f59 Merge pull request #150 from pydn/exporter-readability-refactor
Exporter readability refactor
2026-03-29 15:32:52 -05:00
Peyton DeNiro c1cf4e7585 version update. 2026-03-29 15:31:45 -05:00
Peyton DeNiro 42d0526661 Fix generated workflow torch import order
Move generated torch imports until after add_extra_model_paths runs so exported scripts do not trigger ComfyUI's early-torch warning during startup. Update export assertions and checked-in runtime generated fixtures to match the new main() ordering.
2026-03-29 15:15:52 -05:00
Peyton DeNiro 441dcfebdf Defer generated ComfyUI bootstrap until execution 2026-03-29 14:40:17 -05:00
Peyton DeNiro d751147ed9 Update frontend extension to avoid legacy ui.js import 2026-03-29 14:29:27 -05:00
Peyton DeNiro 9e7e474b05 Refactor the exporter into a package-backed module layout.
Split the previous monolithic implementation into focused modules for CLI handling, workflow loading, runtime bootstrap, load ordering, planning, rendering, and file I/O. Keep the public compatibility surface intact by preserving the legacy top-level wrapper while adding  as the documented entrypoint. This reduces cross-cutting coupling inside the exporter and makes the runtime/bootstrap behavior easier to evolve without carrying unrelated concerns in one file.
2026-03-29 14:09:24 -05:00
Peyton DeNiro 1630a1ab79 Strengthen test suite robustness 2026-03-29 14:08:18 -05:00
Peyton DeNiro 8b56c4971d Bump version to 1.3.2 2026-03-28 18:28:02 -05:00
44 changed files with 4400 additions and 936 deletions
+61 -12
View File
@@ -13,22 +13,20 @@ This project supports:
## Install
Choose the setup that matches how you want to use the project.
This project supports Python 3.12 and newer.
### Web UI extension (`File -> Save As Script`)
For ComfyUI to recognize this project as an extension, the repo must be discoverable through ComfyUI's `custom_nodes` search paths.
Use one of these setups:
Clone directly into `ComfyUI/custom_nodes`:
1. Clone directly into `ComfyUI/custom_nodes`
```bash
cd /path/to/ComfyUI/custom_nodes
git clone https://github.com/pydn/ComfyUI-to-Python-Extension.git
cd ComfyUI-to-Python-Extension
uv sync
```
2. Keep the repo elsewhere, then either:
Or keep the repo elsewhere, then either:
- symlink it into `ComfyUI/custom_nodes`
- add its parent directory to ComfyUI's `custom_nodes` search paths via `extra_model_paths.yaml`
@@ -37,10 +35,29 @@ Example symlink setup:
git clone https://github.com/pydn/ComfyUI-to-Python-Extension.git
cd /path/to/ComfyUI/custom_nodes
ln -s /path/to/ComfyUI-to-Python-Extension ComfyUI-to-Python-Extension
cd /path/to/ComfyUI-to-Python-Extension
uv sync
```
Then install this extension into the same Python environment that launches ComfyUI.
The `pyproject.toml` file declares the package dependencies, but those dependencies still need to be installed into ComfyUI's runtime Python.
If you run ComfyUI from a source checkout with `uv`:
```bash
cd /path/to/ComfyUI
uv pip install -e ./custom_nodes/ComfyUI-to-Python-Extension
uv run python main.py
```
If you use the Windows portable build:
```
cd C:\path\to\ComfyUI_windows_portable\ComfyUI\custom_nodes\ComfyUI-to-Python-Extension
..\..\..\python_embeded\python.exe -m pip install -e .
```
Running `uv sync` inside `ComfyUI-to-Python-Extension` creates this extension's own `.venv`.
ComfyUI does not automatically import dependencies from that `.venv`; it imports custom nodes with the Python interpreter used to launch ComfyUI.
After installation, restart ComfyUI.
### CLI exporter / generated scripts
@@ -55,9 +72,18 @@ export COMFYUI_PATH=/path/to/ComfyUI
```
`COMFYUI_PATH` helps the exporter and generated scripts find the ComfyUI codebase. It does not, by itself, register this repo as a ComfyUI extension for the Web UI.
It also does not install ComfyUI runtime dependencies such as `torch` into the current Python environment.
`COMFYUI_PATH` is checked first. If it is not set, the exporter falls back to searching parent directories for a folder named `ComfyUI`.
If the CLI fails with `ModuleNotFoundError: No module named 'torch'`, run the command with the same Python environment that launches ComfyUI, or install ComfyUI's runtime dependencies into the environment you are using for the CLI.
For Windows portable builds, run the CLI with ComfyUI's embedded Python from the extension directory:
```
..\..\..\python_embeded\python.exe -m comfyui_to_python --input_file ".\workflow_api.json" --output_file ".\workflow_api.py"
```
## Web UI Export
In current ComfyUI builds, `Save As Script` is typically available under:
@@ -65,12 +91,13 @@ In current ComfyUI builds, `Save As Script` is typically available under:
`File -> Save As Script`
The command downloads a generated `.py` file.
The current UI export uses the default filename `workflow_api.py` so it works in ComfyUI Desktop without relying on `prompt()`.
![Save As Script](images/save_as_script.png)
Notes:
- menu placement can differ between frontend versions
- ComfyUI Desktop may fail on the current filename prompt flow; use the CLI flow below if that happens
- the Web UI export uses a fixed default filename rather than asking for one interactively
## CLI Export
@@ -79,18 +106,24 @@ Notes:
3. Run the exporter:
```bash
uv run python comfyui_to_python.py
uv run python -m comfyui_to_python
```
Options:
```bash
uv run python comfyui_to_python.py \
uv run python -m comfyui_to_python \
--input_file workflow_api.json \
--output_file workflow_api.py \
--queue_size 10
```
The legacy wrapper still works if you prefer it:
```bash
uv run python comfyui_to_python.py
```
Flags:
- `--input_file`: input workflow JSON, default `workflow_api.json`
- `--output_file`: output Python file, default `workflow_api.py`
@@ -112,14 +145,30 @@ The generated script is a workflow export. It does not automatically turn workfl
Scripts exported directly from `File -> Save As Script` in the ComfyUI UI already include the frontend workflow metadata needed for drag-and-drop reimport. Images saved by those scripts can be dropped back into ComfyUI and reopen with the original workflow metadata.
Generated scripts reuse ComfyUI's runtime argument parser during bootstrap, so common ComfyUI memory flags such as `--highvram`, `--normalvram`, `--lowvram`, `--novram`, `--cpu`, and `--disable-smart-memory` can be passed directly to the exported `.py` file.
Lifecycle notes:
- exported scripts are single-shot workflow runners, not long-lived ComfyUI prompt servers
- they do not implement Web UI prompt/result caching across repeated service calls
- exported `main()` now performs best-effort ComfyUI model/cache cleanup in a `finally` block
- set `COMFYUI_TOPYTHON_UNLOAD_MODELS=1` or call `main(unload_models=True)` if an embedded or repeated-call host should aggressively unload models after each run instead of preserving them for reuse
## Troubleshooting
- unsupported Python version:
use Python 3.12 or newer in the environment that runs the extension, then reinstall the extension dependencies there
- Web UI import fails after `uv sync`:
`uv sync` in this repo installs dependencies into this repo's `.venv`, but ComfyUI loads custom nodes with its own Python environment. Install the extension into the Python interpreter that launches ComfyUI.
- Windows portable import fails after `uv sync`:
ComfyUI portable uses its bundled `python_embeded` interpreter. From the extension directory, run `..\..\..\python_embeded\python.exe -m pip install -e .`, then restart ComfyUI.
- CLI fails with `No module named 'torch'`:
the extension `.venv` may not have ComfyUI's runtime dependencies. Either run the CLI from the Python environment that launches ComfyUI, or make sure the target ComfyUI environment is installed and `COMFYUI_PATH` points to it.
- `Save As Script` not visible:
check your current ComfyUI menu/frontend version and look under `File`
- `Save As Script` not visible after restart:
make sure this repo is discoverable by ComfyUI through `custom_nodes` by cloning it into `ComfyUI/custom_nodes`, symlinking it there, or adding an external `custom_nodes` path in `extra_model_paths.yaml`
- Desktop says `prompt()` is unsupported:
use the CLI export flow instead
- save uses the default filename:
rename `workflow_api.py` after download if you want a different local filename
- ComfyUI cannot be found:
set `COMFYUI_PATH`
- models or paths are missing at runtime:
+4 -12
View File
@@ -13,18 +13,10 @@ sys.path.append(ext_dir)
try:
import black
except ImportError:
print("Unable to import requirements for ComfyUI-SaveAsScript.")
print("Installing...")
import importlib
spec = importlib.util.spec_from_file_location(
"impact_install", os.path.join(os.path.dirname(__file__), "install.py")
)
impact_install = importlib.util.module_from_spec(spec)
spec.loader.exec_module(impact_install)
print("Successfully installed. Hopefully, at least.")
raise ImportError(
"ComfyUI-to-Python-Extension requires the project dependencies to be installed. "
f"Run 'uv sync' in {ext_dir} with Python 3.12+ before loading this extension."
) from None
# Prevent reimporting of custom nodes
os.environ["RUNNING_IN_COMFYUI"] = "TRUE"
+1 -769
View File
@@ -1,773 +1,5 @@
import copy
import glob
import inspect
import json
import keyword
import os
import random
import sys
import re
from typing import Dict, List, Any, Callable, Tuple, TextIO
from argparse import ArgumentParser
import black
# Add current directory to Python path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from comfyui_to_python_utils import (
import_custom_nodes,
find_path,
get_comfyui_path,
add_comfyui_directory_to_sys_path,
add_extra_model_paths,
get_value_at_index,
)
DEFAULT_INPUT_FILE = "workflow_api.json"
DEFAULT_OUTPUT_FILE = "workflow_api.py"
DEFAULT_QUEUE_SIZE = 10
def get_node_class_mappings() -> Dict:
"""Load ComfyUI node mappings on demand.
Tests that inject explicit node mappings should not need a full ComfyUI runtime
just to import this module.
"""
add_comfyui_directory_to_sys_path()
from nodes import NODE_CLASS_MAPPINGS
return NODE_CLASS_MAPPINGS
class FileHandler:
"""Handles reading and writing files.
This class provides methods to read JSON data from an input file and write code to an output file.
"""
@staticmethod
def read_json_file(file_path: str | TextIO, encoding: str = "utf-8") -> dict:
"""
Reads a JSON file and returns its contents as a dictionary.
Args:
file_path (str): The path to the JSON file.
Returns:
dict: The contents of the JSON file as a dictionary.
Raises:
FileNotFoundError: If the file is not found, it lists all JSON files in the directory of the file path.
ValueError: If the file is not a valid JSON.
"""
if hasattr(file_path, "read"):
return json.load(file_path)
with open(file_path, "r", encoding="utf-8") as file:
data = json.load(file)
return data
@staticmethod
def write_code_to_file(file_path: str | TextIO, code: str) -> None:
"""Write the specified code to a Python file.
Args:
file_path (str): The path to the Python file.
code (str): The code to write to the file.
Returns:
None
"""
if isinstance(file_path, str):
# Extract directory from the filename
directory = os.path.dirname(file_path)
# If the directory does not exist, create it
if directory and not os.path.exists(directory):
os.makedirs(directory)
# Save the code to a .py file
with open(file_path, "w", encoding="utf-8") as file:
file.write(code)
else:
file_path.write(code)
class LoadOrderDeterminer:
"""Determine the load order of each key in the provided dictionary.
This class places the nodes without node dependencies first, then ensures that any node whose
result is used in another node will be added to the list in the order it should be executed.
Attributes:
data (Dict): The dictionary for which to determine the load order.
node_class_mappings (Dict): Mappings of node classes.
"""
def __init__(self, data: Dict, node_class_mappings: Dict):
"""Initialize the LoadOrderDeterminer with the given data and node class mappings.
Args:
data (Dict): The dictionary for which to determine the load order.
node_class_mappings (Dict): Mappings of node classes.
"""
self.data = data
self.node_class_mappings = node_class_mappings
self.visited = {}
self.load_order = []
self.is_special_function = False
def determine_load_order(self) -> List[Tuple[str, Dict, bool]]:
"""Determine the load order for the given data.
Returns:
List[Tuple[str, Dict, bool]]: A list of tuples representing the load order.
"""
self._load_special_functions_first()
self.is_special_function = False
for key in self.data:
if key not in self.visited:
self._dfs(key)
return self.load_order
def _dfs(self, key: str) -> None:
"""Depth-First Search function to determine the load order.
Args:
key (str): The key from which to start the DFS.
Returns:
None
"""
# Mark the node as visited.
self.visited[key] = True
inputs = self.data[key]["inputs"]
# Loop over each input key.
for input_key, val in inputs.items():
# If the value is a list and the first item in the list has not been visited yet,
# then recursively apply DFS on the dependency.
if isinstance(val, list) and val[0] not in self.visited:
self._dfs(val[0])
# Add the key and its corresponding data to the load order list.
self.load_order.append((key, self.data[key], self.is_special_function))
def _load_special_functions_first(self) -> None:
"""Load functions without dependencies, loaderes, and encoders first.
Returns:
None
"""
# Iterate over each key in the data to check for loader keys.
for key in self.data:
class_def = self.node_class_mappings[self.data[key]["class_type"]]()
# Check if the class is a loader class or meets specific conditions.
if (
class_def.CATEGORY == "loaders"
or class_def.FUNCTION in ["encode"]
or not any(
isinstance(val, list) for val in self.data[key]["inputs"].values()
)
):
self.is_special_function = True
# If the key has not been visited, perform a DFS from that key.
if key not in self.visited:
self._dfs(key)
class CodeGenerator:
"""Generates Python code for a workflow based on the load order.
Attributes:
node_class_mappings (Dict): Mappings of node classes.
base_node_class_mappings (Dict): Base mappings of node classes.
"""
def __init__(self, node_class_mappings: Dict, base_node_class_mappings: Dict):
"""Initialize the CodeGenerator with given node class mappings.
Args:
node_class_mappings (Dict): Mappings of node classes.
base_node_class_mappings (Dict): Base mappings of node classes.
"""
self.node_class_mappings = node_class_mappings
self.base_node_class_mappings = base_node_class_mappings
@staticmethod
def sanitize_node_id(node_id: str) -> str:
"""Convert node IDs into variable-safe tokens without collapsing separators."""
sanitized = re.sub(r"[^a-z0-9_]", "_", str(node_id).lower().strip())
sanitized = re.sub(r"_+", "_", sanitized).strip("_")
if not sanitized:
sanitized = "node"
return sanitized
def generate_workflow(
self,
load_order: List,
workflow_data: Dict,
metadata_workflow_data: Dict | None = None,
queue_size: int = 10,
) -> str:
"""Generate the execution code based on the load order.
Args:
load_order (List): A list of tuples representing the load order.
queue_size (int): The number of photos that will be created by the script.
Returns:
str: Generated execution code as a string.
"""
# Create the necessary data structures to hold imports and generated code
import_statements, executed_variables, special_functions_code, code = (
{"nodes": {"NODE_CLASS_MAPPINGS"}},
{},
[],
[],
)
# This dictionary will store the names of the objects that we have already initialized
initialized_objects = {}
custom_nodes = False
# Loop over each dictionary in the load order list
for idx, data, is_special_function in load_order:
# Generate class definition and inputs from the data
inputs, class_type = data["inputs"], data["class_type"]
input_types = self.node_class_mappings[class_type].INPUT_TYPES()
class_def = self.node_class_mappings[class_type]()
# If required inputs are not present, skip the node as it will break the code if passed through to the script
missing_required_variable = False
if "required" in input_types.keys():
for required in input_types["required"]:
if required not in inputs.keys():
missing_required_variable = True
if missing_required_variable:
continue
# If the class hasn't been initialized yet, initialize it and generate the import statements
if class_type not in initialized_objects:
# No need to use preview image nodes since we are executing the script in a terminal
if class_type == "PreviewImage":
continue
class_type, import_statement, class_code = self.get_class_info(
class_type
)
initialized_objects[class_type] = self.clean_variable_name(class_type)
if class_type in self.base_node_class_mappings.keys():
module_name, import_name = import_statement
import_statements.setdefault(module_name, set()).add(import_name)
if 'NODE_CLASS_MAPPINGS["' in class_code:
custom_nodes = True
special_functions_code.append(class_code)
# Get all possible parameters for class_def
class_def_params = self.get_function_parameters(
getattr(class_def, class_def.FUNCTION)
)
no_params = class_def_params is None
# Remove any keyword arguments from **inputs if they are not in class_def_params
inputs = {
key: value
for key, value in inputs.items()
if no_params or key in class_def_params
}
# Deal with hidden variables
hidden_inputs = input_types.get("hidden", {})
if (
"unique_id" in hidden_inputs
and (no_params or "unique_id" in class_def_params)
):
inputs["unique_id"] = random.randint(1, 2**64)
if "prompt" in hidden_inputs and (no_params or "prompt" in class_def_params):
inputs["prompt"] = {"variable_name": "prompt"}
if "extra_pnginfo" in hidden_inputs and (
no_params or "extra_pnginfo" in class_def_params
):
inputs["extra_pnginfo"] = {"variable_name": "extra_pnginfo"}
if "hidden" not in input_types and class_def_params is not None:
if "unique_id" in class_def_params:
inputs["unique_id"] = random.randint(1, 2**64)
# Create executed variable and generate code
executed_variables[idx] = (
f"{self.clean_variable_name(class_type)}_"
f"{self.sanitize_node_id(str(idx))}"
)
inputs = self.update_inputs(inputs, executed_variables)
seed_sync_code = self.create_prompt_seed_sync_code(
idx, inputs, is_special_function
)
if is_special_function:
if seed_sync_code:
special_functions_code.extend(seed_sync_code)
special_functions_code.append(
self.create_function_call_code(
initialized_objects[class_type],
class_def.FUNCTION,
executed_variables[idx],
is_special_function,
**inputs,
)
)
else:
if seed_sync_code:
code.extend(seed_sync_code)
code.append(
self.create_function_call_code(
initialized_objects[class_type],
class_def.FUNCTION,
executed_variables[idx],
is_special_function,
**inputs,
)
)
# Generate final code by combining imports and code, and wrap them in a main function
final_code = self.assemble_python_code(
import_statements,
special_functions_code,
code,
workflow_data,
metadata_workflow_data,
queue_size,
custom_nodes,
)
return final_code
def create_function_call_code(
self,
obj_name: str,
func: str,
variable_name: str,
is_special_function: bool,
**kwargs,
) -> str:
"""Generate Python code for a function call.
Args:
obj_name (str): The name of the initialized object.
func (str): The function to be called.
variable_name (str): The name of the variable that the function result should be assigned to.
is_special_function (bool): Determines the code indentation.
**kwargs: The keyword arguments for the function.
Returns:
str: The generated Python code.
"""
args = ", ".join(self.format_arg(key, value) for key, value in kwargs.items())
# Generate the Python code
code = f"{variable_name} = {obj_name}.{func}({args})\n"
# If the code contains dependencies and is not a loader or encoder, indent the code because it will be placed inside
# of a for loop
if not is_special_function:
code = f"\t{code}"
return code
def create_prompt_seed_sync_code(
self, node_id: str, inputs: Dict, is_special_function: bool
) -> List[str]:
"""Generate code that keeps prompt metadata aligned with randomized seeds."""
seed_sync_lines = []
for key in ("seed", "noise_seed"):
if key not in inputs:
continue
randomized_seed_variable = (
f"node_{self.sanitize_node_id(str(node_id))}_{self.clean_variable_name(key)}"
)
seed_sync_lines.append(
f'{randomized_seed_variable} = prompt["{node_id}"]["inputs"]["{key}"] = random.randint(1, 2**64)'
)
inputs[key] = {"variable_name": randomized_seed_variable}
if not seed_sync_lines:
return []
indentation = "" if is_special_function else "\t"
return [f"{indentation}{line}\n" for line in seed_sync_lines]
def format_arg(self, key: str, value: any) -> str:
"""Formats arguments based on key and value.
Args:
key (str): Argument key.
value (any): Argument value.
Returns:
str: Formatted argument as a string.
"""
value_code = self.format_arg_value(key, value)
if key.isidentifier() and not keyword.iskeyword(key):
return f"{key}={value_code}"
return f"**{{{json.dumps(key)}: {value_code}}}"
@staticmethod
def format_arg_value(key: str, value: any) -> str:
"""Formats an argument value as Python source."""
if isinstance(value, dict) and "variable_name" in value:
return value["variable_name"]
if key == "noise_seed" or key == "seed":
return "random.randint(1, 2**64)"
if isinstance(value, str):
return json.dumps(value)
return repr(value)
def assemble_python_code(
self,
import_statements: Dict[str, set],
speical_functions_code: List[str],
code: List[str],
workflow_data: Dict,
metadata_workflow_data: Dict | None,
queue_size: int,
custom_nodes=False,
) -> str:
"""Generates the final code string.
Args:
import_statements (Dict[str, set]): Import statements grouped by module.
speical_functions_code (List[str]): A list of special functions code strings.
code (List[str]): A list of code strings.
workflow_data (Dict): The API workflow data used for runtime prompt execution.
metadata_workflow_data (Dict | None): The workflow metadata to embed into saved outputs.
queue_size (int): Number of photos that will be generated by the script.
custom_nodes (bool): Whether to include custom nodes in the code.
Returns:
str: Generated final code as a string.
"""
if metadata_workflow_data is None:
extra_pnginfo_code = "extra_pnginfo = None"
else:
extra_pnginfo_code = (
"extra_pnginfo = "
'{"workflow": json.loads('
+ json.dumps(json.dumps(metadata_workflow_data))
+ ")}"
)
# Get the source code of the utils functions as a string
func_strings = []
for func in [
get_value_at_index,
get_comfyui_path,
find_path,
add_comfyui_directory_to_sys_path,
add_extra_model_paths,
]:
func_strings.append(f"\n{inspect.getsource(func)}")
# Define static import statements required for the script
static_imports = (
[
"import json",
"import os",
"import random",
"import sys",
"from typing import Sequence, Mapping, Any, Union",
"import torch",
]
+ func_strings
+ [
"\n\nadd_comfyui_directory_to_sys_path()\nadd_extra_model_paths()\n",
f"workflow = json.loads({json.dumps(json.dumps(workflow_data))})",
"prompt = json.loads(json.dumps(workflow))",
extra_pnginfo_code,
]
)
# Check if custom nodes should be included
if custom_nodes:
static_imports.append(f"\n{inspect.getsource(import_custom_nodes)}\n")
custom_nodes = "import_custom_nodes()\n\t"
else:
custom_nodes = ""
# Create import statements for node classes
imports_code = []
for module_name in sorted(import_statements.keys()):
class_names = ", ".join(sorted(import_statements[module_name]))
imports_code.append(f"from {module_name} import {class_names}")
special_functions_body = "\n\t\t".join(speical_functions_code) or "pass"
loop_body = "\n\t\t".join(code) or "\tpass"
# Assemble the main function code, including custom nodes if applicable
main_function_code = (
"def main():\n\t"
+ f"{custom_nodes}with torch.inference_mode():\n\t\t"
+ special_functions_body
+ f"\n\n\t\tfor q in range({queue_size}):\n\t\t"
+ loop_body
)
# Concatenate all parts to form the final code
final_code = "\n".join(
static_imports
+ imports_code
+ ["", main_function_code, "", 'if __name__ == "__main__":', "\tmain()"]
)
# Format the final code according to PEP 8 using the Black library
final_code = black.format_str(final_code, mode=black.Mode())
return final_code
def get_class_info(self, class_type: str) -> Tuple[str, Tuple[str, str], str]:
"""Generates and returns necessary information about class type.
Args:
class_type (str): Class type.
Returns:
Tuple[str, Tuple[str, str], str]: Updated class type, import statement and initialization code.
"""
class_obj = self.base_node_class_mappings.get(class_type)
module_name = "nodes"
if class_obj is not None:
module_name = class_obj.__module__
variable_name = self.clean_variable_name(class_type)
is_importable_module = bool(
module_name
and "/" not in module_name
and "\\" not in module_name
and all(part.isidentifier() for part in module_name.split("."))
)
if class_type in self.base_node_class_mappings.keys() and is_importable_module:
import_statement = (module_name, class_type)
class_code = f"{variable_name} = {class_type.strip()}()"
else:
import_statement = ("nodes", "NODE_CLASS_MAPPINGS")
class_code = f'{variable_name} = NODE_CLASS_MAPPINGS["{class_type}"]()'
return class_type, import_statement, class_code
@staticmethod
def clean_variable_name(class_type: str) -> str:
"""
Remove any characters from variable name that could cause errors running the Python script.
Args:
class_type (str): Class type.
Returns:
str: Cleaned variable name with no special characters or spaces
"""
# Convert to lowercase and replace spaces with underscores
clean_name = class_type.lower().strip().replace("-", "_").replace(" ", "_")
# Remove characters that are not letters, numbers, or underscores
clean_name = re.sub(r"[^a-z0-9_]", "", clean_name)
# Ensure that it doesn't start with a number
if clean_name[0].isdigit():
clean_name = "_" + clean_name
return clean_name
def get_function_parameters(self, func: Callable) -> List:
"""Get the names of a function's parameters.
Args:
func (Callable): The function whose parameters we want to inspect.
Returns:
List: A list containing the names of the function's parameters.
"""
signature = inspect.signature(func)
parameters = {
name: param.default if param.default != param.empty else None
for name, param in signature.parameters.items()
}
catch_all = any(
param.kind == inspect.Parameter.VAR_KEYWORD
for param in signature.parameters.values()
)
return list(parameters.keys()) if not catch_all else None
def update_inputs(self, inputs: Dict, executed_variables: Dict) -> Dict:
"""Update inputs based on the executed variables.
Args:
inputs (Dict): Inputs dictionary to update.
executed_variables (Dict): Dictionary storing executed variable names.
Returns:
Dict: Updated inputs dictionary.
"""
for key in inputs.keys():
if (
isinstance(inputs[key], list)
and inputs[key][0] in executed_variables.keys()
):
inputs[key] = {
"variable_name": f"get_value_at_index({executed_variables[inputs[key][0]]}, {inputs[key][1]})"
}
return inputs
class ComfyUItoPython:
"""Main workflow to generate Python code from a workflow_api.json file.
Attributes:
input_file (str): Path to the input JSON file.
output_file (str): Path to the output Python file.
queue_size (int): The number of photos that will be created by the script.
node_class_mappings (Dict): Mappings of node classes.
base_node_class_mappings (Dict): Base mappings of node classes.
"""
def __init__(
self,
workflow: str = "",
frontend_workflow: str | Dict | None = None,
input_file: str = "",
output_file: str | TextIO = "",
queue_size: int = 1,
node_class_mappings: Dict | None = None,
needs_init_custom_nodes: bool = False,
):
"""Initialize the ComfyUItoPython class with the given parameters. Exactly one of workflow or input_file must be specified.
Args:
workflow (str): The workflow's JSON.
input_file (str): Path to the input JSON file.
output_file (str | TextIO): Path to the output file or a file-like object.
queue_size (int): The number of times a workflow will be executed by the script. Defaults to 1.
node_class_mappings (Dict | None): Mappings of node classes. Defaults to the current
ComfyUI NODE_CLASS_MAPPINGS when not provided.
needs_init_custom_nodes (bool): Whether to initialize custom nodes. Defaults to False.
"""
if input_file and workflow:
raise ValueError("Can't provide both input_file and workflow")
elif not input_file and not workflow:
raise ValueError("Needs input_file or workflow")
if not output_file:
raise ValueError("Needs output_file")
self.workflow = workflow
self.frontend_workflow = frontend_workflow
self.input_file = input_file
self.output_file = output_file
self.queue_size = queue_size
self.node_class_mappings = (
node_class_mappings
if node_class_mappings is not None
else get_node_class_mappings()
)
self.needs_init_custom_nodes = needs_init_custom_nodes
self.base_node_class_mappings = copy.deepcopy(self.node_class_mappings)
self.execute()
def execute(self):
"""Execute the main workflow to generate Python code.
Returns:
None
"""
# Step 1: Read JSON data from the input file
if self.input_file:
data = FileHandler.read_json_file(self.input_file)
else:
data = json.loads(self.workflow)
metadata_workflow_data = None
if self.frontend_workflow:
if isinstance(self.frontend_workflow, str):
metadata_workflow_data = json.loads(self.frontend_workflow)
else:
metadata_workflow_data = self.frontend_workflow
# Step 2: Initialize extra/custom nodes when requested or when the workflow references
# a node class that is not currently loaded in the runtime.
missing_node_types = {
node_data["class_type"]
for node_data in data.values()
if node_data["class_type"] not in self.node_class_mappings
}
if self.needs_init_custom_nodes or missing_node_types:
import_custom_nodes()
self.base_node_class_mappings = copy.deepcopy(self.node_class_mappings)
# Step 3: Determine the load order
load_order_determiner = LoadOrderDeterminer(data, self.node_class_mappings)
load_order = load_order_determiner.determine_load_order()
# Step 4: Generate the workflow code
code_generator = CodeGenerator(
self.node_class_mappings, self.base_node_class_mappings
)
generated_code = code_generator.generate_workflow(
load_order,
data,
metadata_workflow_data,
queue_size=self.queue_size,
)
# Step 5: Write the generated code to a file
FileHandler.write_code_to_file(self.output_file, generated_code)
print(f"Code successfully generated and written to {self.output_file}")
def run(
input_file: str = DEFAULT_INPUT_FILE,
output_file: str = DEFAULT_OUTPUT_FILE,
queue_size: int = DEFAULT_QUEUE_SIZE,
) -> None:
"""Generate Python code from a ComfyUI workflow_api.json file.
Args:
input_file (str): Path to the input JSON file. Defaults to "workflow_api.json".
output_file (str): Path to the output Python file.
Defaults to "workflow_api.py".
queue_size (int): The number of times a workflow will be executed by the script.
Defaults to 1.
Returns:
None
"""
ComfyUItoPython(
input_file=input_file,
output_file=output_file,
queue_size=queue_size,
needs_init_custom_nodes=True,
)
def main() -> None:
"""Main function to generate Python code from a ComfyUI workflow_api.json file."""
parser = ArgumentParser(
description="Generate Python code from a ComfyUI workflow_api.json file."
)
parser.add_argument(
"-f",
"--input_file",
type=str,
help="path to the input JSON file",
default=DEFAULT_INPUT_FILE,
)
parser.add_argument(
"-o",
"--output_file",
type=str,
help="path to the output Python file",
default=DEFAULT_OUTPUT_FILE,
)
parser.add_argument(
"-q",
"--queue_size",
type=int,
help="number of times the workflow will be executed by default",
default=DEFAULT_QUEUE_SIZE,
)
pargs = parser.parse_args()
run(**vars(pargs))
print("Done.")
from comfyui_to_python.cli import main
if __name__ == "__main__":
"""Run the main function."""
main()
+59
View File
@@ -0,0 +1,59 @@
from typing import TextIO
from .app import ExportApplication
from .cli import DEFAULT_INPUT_FILE, DEFAULT_OUTPUT_FILE, DEFAULT_QUEUE_SIZE, main
from .node_runtime import get_node_class_mappings, import_custom_nodes
from .runtime_session import WorkflowSession
class ComfyUItoPython:
"""Public compatibility facade for the exporter package."""
def __init__(
self,
workflow: str = "",
frontend_workflow: str | dict | None = None,
input_file: str = "",
output_file: str | TextIO = "",
queue_size: int = 1,
node_class_mappings: dict | None = None,
needs_init_custom_nodes: bool = False,
execution_mode: str = "oneshot",
):
self._app = ExportApplication(
workflow=workflow,
frontend_workflow=frontend_workflow,
input_file=input_file,
output_file=output_file,
queue_size=queue_size,
node_class_mappings=node_class_mappings,
needs_init_custom_nodes=needs_init_custom_nodes,
execution_mode=execution_mode,
node_mapping_loader=get_node_class_mappings,
custom_node_importer=import_custom_nodes,
)
self._app.execute()
def run(
input_file: str = DEFAULT_INPUT_FILE,
output_file: str = DEFAULT_OUTPUT_FILE,
queue_size: int = DEFAULT_QUEUE_SIZE,
) -> None:
"""Generate Python code from a ComfyUI workflow_api.json file."""
ComfyUItoPython(
input_file=input_file,
output_file=output_file,
queue_size=queue_size,
needs_init_custom_nodes=True,
)
__all__ = [
"ComfyUItoPython",
"WorkflowSession",
"run",
"main",
"get_node_class_mappings",
"import_custom_nodes",
]
+5
View File
@@ -0,0 +1,5 @@
from .cli import main
if __name__ == "__main__":
main()
+78
View File
@@ -0,0 +1,78 @@
import copy
from typing import TextIO
from .generator.planner import WorkflowPlanner
from .generator.render import WorkflowRenderer
from .io import write_python_output
from .load_order import LoadOrderDeterminer
from .workflow_loader import load_frontend_workflow_data, load_workflow_data
class ExportApplication:
"""High-level exporter orchestration."""
def __init__(
self,
workflow: str = "",
frontend_workflow: str | dict | None = None,
input_file: str = "",
output_file: str | TextIO = "",
queue_size: int = 1,
node_class_mappings: dict | None = None,
needs_init_custom_nodes: bool = False,
node_mapping_loader=None,
custom_node_importer=None,
execution_mode: str = "oneshot",
):
if input_file and workflow:
raise ValueError("Can't provide both input_file and workflow")
if not input_file and not workflow:
raise ValueError("Needs input_file or workflow")
if not output_file:
raise ValueError("Needs output_file")
self.workflow = workflow
self.frontend_workflow = frontend_workflow
self.input_file = input_file
self.output_file = output_file
self.queue_size = queue_size
self.node_mapping_loader = node_mapping_loader
self.custom_node_importer = custom_node_importer
self.node_class_mappings = (
node_class_mappings
if node_class_mappings is not None
else self.node_mapping_loader()
)
self.needs_init_custom_nodes = needs_init_custom_nodes
self.execution_mode = execution_mode
self.base_node_class_mappings = copy.deepcopy(self.node_class_mappings)
def execute(self) -> None:
data = load_workflow_data(self.workflow, self.input_file)
metadata_workflow_data = load_frontend_workflow_data(self.frontend_workflow)
missing_node_types = {
node_data["class_type"]
for node_data in data.values()
if node_data["class_type"] not in self.node_class_mappings
}
if self.needs_init_custom_nodes or missing_node_types:
self.custom_node_importer()
self.base_node_class_mappings = copy.deepcopy(self.node_class_mappings)
load_order = LoadOrderDeterminer(
data, self.node_class_mappings
).determine_load_order()
plan = WorkflowPlanner(
self.node_class_mappings, self.base_node_class_mappings
).build_plan(
load_order,
data,
metadata_workflow_data,
queue_size=self.queue_size,
execution_mode=self.execution_mode,
)
generated_code = WorkflowRenderer(execution_mode=self.execution_mode).render(plan)
write_python_output(self.output_file, generated_code)
print(f"Code successfully generated and written to {self.output_file}")
+42
View File
@@ -0,0 +1,42 @@
from argparse import ArgumentParser
DEFAULT_INPUT_FILE = "workflow_api.json"
DEFAULT_OUTPUT_FILE = "workflow_api.py"
DEFAULT_QUEUE_SIZE = 10
def build_argument_parser() -> ArgumentParser:
parser = ArgumentParser(
description="Generate Python code from a ComfyUI workflow_api.json file."
)
parser.add_argument(
"-f",
"--input_file",
type=str,
help="path to the input JSON file",
default=DEFAULT_INPUT_FILE,
)
parser.add_argument(
"-o",
"--output_file",
type=str,
help="path to the output Python file",
default=DEFAULT_OUTPUT_FILE,
)
parser.add_argument(
"-q",
"--queue_size",
type=int,
help="number of times the workflow will be executed by default",
default=DEFAULT_QUEUE_SIZE,
)
return parser
def main() -> None:
from . import run
parser = build_argument_parser()
pargs = parser.parse_args()
run(**vars(pargs))
print("Done.")
+4
View File
@@ -0,0 +1,4 @@
from .planner import WorkflowPlanner
from .render import WorkflowRenderer
__all__ = ["WorkflowPlanner", "WorkflowRenderer"]
@@ -0,0 +1,19 @@
from ..node_runtime import (
add_comfyui_directory_to_sys_path,
add_extra_model_paths,
bootstrap_comfyui_runtime,
cleanup_comfyui_runtime,
find_path,
get_comfyui_path,
get_value_at_index,
)
__all__ = [
"add_comfyui_directory_to_sys_path",
"add_extra_model_paths",
"bootstrap_comfyui_runtime",
"cleanup_comfyui_runtime",
"find_path",
"get_comfyui_path",
"get_value_at_index",
]
+15
View File
@@ -0,0 +1,15 @@
from dataclasses import dataclass, field
from typing import Literal
@dataclass(frozen=True)
class GenerationPlan:
import_statements: dict[str, set[str]]
special_functions_code: list[str]
loop_code: list[str]
workflow_data: dict
metadata_workflow_data: dict | None
queue_size: int
custom_nodes: bool
execution_mode: Literal["oneshot", "session"] = field(default="oneshot")
executed_variables: dict[str, str] = field(default_factory=dict)
+264
View File
@@ -0,0 +1,264 @@
import inspect
import json
import keyword
import random
import re
from typing import Any, Callable
from .model import GenerationPlan
class WorkflowPlanner:
"""Convert ordered workflow nodes into a renderer-ready generation plan."""
def __init__(self, node_class_mappings: dict, base_node_class_mappings: dict):
self.node_class_mappings = node_class_mappings
self.base_node_class_mappings = base_node_class_mappings
@staticmethod
def sanitize_node_id(node_id: str) -> str:
sanitized = re.sub(r"[^a-z0-9_]", "_", str(node_id).lower().strip())
sanitized = re.sub(r"_+", "_", sanitized).strip("_")
if not sanitized:
sanitized = "node"
return sanitized
@staticmethod
def clean_variable_name(class_type: str) -> str:
clean_name = class_type.lower().strip().replace("-", "_").replace(" ", "_")
clean_name = re.sub(r"[^a-z0-9_]", "", clean_name)
if clean_name[0].isdigit():
clean_name = "_" + clean_name
return clean_name
def build_plan(
self,
load_order: list[tuple[str, dict, bool]],
workflow_data: dict,
metadata_workflow_data: dict | None = None,
queue_size: int = 10,
execution_mode: str = "oneshot",
) -> GenerationPlan:
import_statements = {"nodes": {"NODE_CLASS_MAPPINGS"}}
executed_variables = {}
special_functions_code = []
code = []
initialized_objects = {}
custom_nodes = False
for idx, data, is_special_function in load_order:
inputs, class_type = data["inputs"], data["class_type"]
input_types = self.node_class_mappings[class_type].INPUT_TYPES()
input_value_types = self.get_input_value_types(input_types)
class_def = self.node_class_mappings[class_type]()
missing_required_variable = False
if "required" in input_types.keys():
for required in input_types["required"]:
if required not in inputs.keys():
missing_required_variable = True
if missing_required_variable:
continue
if class_type not in initialized_objects:
if class_type == "PreviewImage":
continue
class_type, import_statement, class_code = self.get_class_info(
class_type
)
initialized_objects[class_type] = self.clean_variable_name(class_type)
if class_type in self.base_node_class_mappings.keys():
module_name, import_name = import_statement
import_statements.setdefault(module_name, set()).add(import_name)
if 'NODE_CLASS_MAPPINGS["' in class_code:
custom_nodes = True
special_functions_code.append(class_code)
class_def_params = self.get_function_parameters(
getattr(class_def, class_def.FUNCTION)
)
no_params = class_def_params is None
inputs = {
key: value
for key, value in inputs.items()
if no_params or key in class_def_params
}
hidden_inputs = input_types.get("hidden", {})
if (
"unique_id" in hidden_inputs
and (no_params or "unique_id" in class_def_params)
):
inputs["unique_id"] = random.randint(1, 2**64)
if "prompt" in hidden_inputs and (no_params or "prompt" in class_def_params):
inputs["prompt"] = {"variable_name": "prompt"}
if "extra_pnginfo" in hidden_inputs and (
no_params or "extra_pnginfo" in class_def_params
):
inputs["extra_pnginfo"] = {"variable_name": "extra_pnginfo"}
if "hidden" not in input_types and class_def_params is not None:
if "unique_id" in class_def_params:
inputs["unique_id"] = random.randint(1, 2**64)
executed_variables[idx] = (
f"{self.clean_variable_name(class_type)}_"
f"{self.sanitize_node_id(str(idx))}"
)
inputs = self.update_inputs(inputs, executed_variables)
seed_sync_code = self.create_prompt_seed_sync_code(
idx, inputs, input_value_types, is_special_function
)
target_lines = special_functions_code if is_special_function else code
if seed_sync_code:
target_lines.extend(seed_sync_code)
target_lines.append(
self.create_function_call_code(
initialized_objects[class_type],
class_def.FUNCTION,
executed_variables[idx],
is_special_function,
input_value_types=input_value_types,
**inputs,
)
)
return GenerationPlan(
import_statements=import_statements,
special_functions_code=special_functions_code,
loop_code=code,
workflow_data=workflow_data,
metadata_workflow_data=metadata_workflow_data,
queue_size=queue_size,
custom_nodes=custom_nodes,
execution_mode=execution_mode,
executed_variables=executed_variables,
)
def create_function_call_code(
self,
obj_name: str,
func: str,
variable_name: str,
is_special_function: bool,
input_value_types: dict[str, str] | None = None,
**kwargs,
) -> str:
args = ", ".join(
self.format_arg(key, value, (input_value_types or {}).get(key))
for key, value in kwargs.items()
)
code = f"{variable_name} = {obj_name}.{func}({args})\n"
if not is_special_function:
code = f"\t{code}"
return code
def create_prompt_seed_sync_code(
self,
node_id: str,
inputs: dict,
input_value_types: dict[str, str],
is_special_function: bool,
) -> list[str]:
seed_sync_lines = []
for key in ("seed", "noise_seed"):
if key not in inputs:
continue
randomized_seed_variable = (
f"node_{self.sanitize_node_id(str(node_id))}_{self.clean_variable_name(key)}"
)
randomized_seed_code = self.get_randomized_seed_code(
input_value_types.get(key)
)
seed_sync_lines.append(
f'{randomized_seed_variable} = prompt["{node_id}"]["inputs"]["{key}"] = {randomized_seed_code}'
)
inputs[key] = {"variable_name": randomized_seed_variable}
if not seed_sync_lines:
return []
indentation = "" if is_special_function else "\t"
return [f"{indentation}{line}\n" for line in seed_sync_lines]
def format_arg(self, key: str, value: Any, input_value_type: str | None = None) -> str:
value_code = self.format_arg_value(key, value, input_value_type)
if key.isidentifier() and not keyword.iskeyword(key):
return f"{key}={value_code}"
return f"**{{{json.dumps(key)}: {value_code}}}"
@staticmethod
def format_arg_value(
key: str, value: Any, input_value_type: str | None = None
) -> str:
if isinstance(value, dict) and "variable_name" in value:
return value["variable_name"]
if key == "noise_seed" or key == "seed":
return WorkflowPlanner.get_randomized_seed_code(input_value_type)
if isinstance(value, str):
return json.dumps(value)
return repr(value)
@staticmethod
def get_input_value_types(input_types: dict) -> dict[str, str]:
value_types = {}
for section in ("required", "optional", "hidden"):
for key, value in input_types.get(section, {}).items():
if isinstance(value, tuple) and value:
value_types[key] = value[0]
elif isinstance(value, str):
value_types[key] = value
return value_types
@staticmethod
def get_randomized_seed_code(input_value_type: str | None) -> str:
randomized_seed_code = "random.randint(1, 2**64)"
if input_value_type == "STRING":
return f"str({randomized_seed_code})"
return randomized_seed_code
def get_class_info(self, class_type: str) -> tuple[str, tuple[str, str], str]:
class_obj = self.base_node_class_mappings.get(class_type)
module_name = "nodes"
if class_obj is not None:
module_name = class_obj.__module__
variable_name = self.clean_variable_name(class_type)
is_importable_module = bool(
module_name
and "/" not in module_name
and "\\" not in module_name
and all(part.isidentifier() for part in module_name.split("."))
)
if class_type in self.base_node_class_mappings.keys() and is_importable_module:
import_statement = (module_name, class_type)
class_code = f"{variable_name} = {class_type.strip()}()"
else:
import_statement = ("nodes", "NODE_CLASS_MAPPINGS")
class_code = f'{variable_name} = NODE_CLASS_MAPPINGS["{class_type}"]()'
return class_type, import_statement, class_code
@staticmethod
def get_function_parameters(func: Callable) -> list | None:
signature = inspect.signature(func)
parameters = {
name: param.default if param.default != param.empty else None
for name, param in signature.parameters.items()
}
catch_all = any(
param.kind == inspect.Parameter.VAR_KEYWORD
for param in signature.parameters.values()
)
return list(parameters.keys()) if not catch_all else None
def update_inputs(self, inputs: dict, executed_variables: dict) -> dict:
for key in inputs.keys():
if (
isinstance(inputs[key], list)
and inputs[key][0] in executed_variables.keys()
):
inputs[key] = {
"variable_name": f"get_value_at_index({executed_variables[inputs[key][0]]}, {inputs[key][1]})"
}
return inputs
+340
View File
@@ -0,0 +1,340 @@
import inspect
import threading
from pprint import pformat
from typing import Any
import black
from ..node_runtime import import_custom_nodes
from .generated_helpers import (
add_comfyui_directory_to_sys_path,
add_extra_model_paths,
bootstrap_comfyui_runtime,
cleanup_comfyui_runtime,
find_path,
get_comfyui_path,
get_value_at_index,
)
from .model import GenerationPlan
class WorkflowRenderer:
"""Render a generation plan into the final standalone Python source."""
def __init__(self, execution_mode: str = "oneshot"):
self.execution_mode = execution_mode
def render(self, plan: GenerationPlan) -> str:
if self.execution_mode == "session":
return self._render_session_mode(plan)
return self._render_oneshot_mode(plan)
# ── shared sections ──────────────────────────────────────────────
@staticmethod
def _build_entrypoint_section() -> list[str]:
return [
"# Entrypoint",
'if __name__ == "__main__":',
" main()",
]
def _build_imports_section(self, plan: GenerationPlan) -> list[str]:
func_strings = []
for func in [
get_value_at_index,
get_comfyui_path,
find_path,
add_comfyui_directory_to_sys_path,
add_extra_model_paths,
bootstrap_comfyui_runtime,
cleanup_comfyui_runtime,
]:
func_strings.append(f"\n{inspect.getsource(func)}")
static_imports = [
"# Imports",
"import json",
"import os",
"import random",
"import sys",
"import threading",
"from typing import Sequence, Mapping, Any, Union",
] + func_strings
static_imports.append(f"\n{inspect.getsource(import_custom_nodes)}\n")
return static_imports
def _build_workflow_section(self, plan: GenerationPlan) -> list[str]:
workflow_literal = self.format_python_literal(plan.workflow_data)
if plan.metadata_workflow_data is None:
extra_pnginfo_literal = "None"
else:
extra_pnginfo_literal = self.format_python_literal(
{"workflow": plan.metadata_workflow_data}
)
return [
"# Workflow data",
"def build_workflow() -> dict[str, Any]:",
f" return {workflow_literal}",
"",
"def build_extra_pnginfo() -> dict[str, Any] | None:",
f" return {extra_pnginfo_literal}",
"",
"workflow = build_workflow()",
"prompt = json.loads(json.dumps(workflow))",
"extra_pnginfo = build_extra_pnginfo()",
]
# ── oneshot renderer ─────────────────────────────────────────────
def _render_oneshot_mode(self, plan: GenerationPlan) -> str:
imports_section = self._build_imports_section(plan)
workflow_section = self._build_workflow_section(plan)
execution_section = self._build_execution_section(plan)
entrypoint_section = self._build_entrypoint_section()
final_code = "\n".join(
imports_section
+ [""]
+ workflow_section
+ [""]
+ execution_section
+ [""]
+ entrypoint_section
)
return black.format_str(final_code, mode=black.Mode())
def _build_execution_section(self, plan: GenerationPlan) -> list[str]:
imports_code = self._build_node_imports(plan.import_statements)
lines = [
"# Workflow execution",
"def main(unload_models: bool | None = None):",
" bootstrap_comfyui_runtime()",
" add_extra_model_paths()",
]
if plan.custom_nodes:
lines.append(" import_custom_nodes()")
if imports_code:
lines.extend(["", " # Node imports"])
lines.extend(f" {line}" for line in imports_code)
lines.extend(
[
"",
" import torch",
"",
" try:",
" with torch.inference_mode():",
]
)
lines.extend(
self.build_function_body(
plan.special_functions_code, "pass", indentation=" "
).splitlines()
)
lines.append(f" for q in range({plan.queue_size}):")
lines.extend(
self.build_function_body(
plan.loop_code, "pass", indentation=" "
).splitlines()
)
lines.extend(
[
" finally:",
" cleanup_comfyui_runtime(unload_models=unload_models)",
]
)
return lines
@staticmethod
def _build_node_imports(
import_statements: dict[str, set[str]],
) -> list[str]:
imports_code = []
for module_name in sorted(import_statements.keys()):
class_names = ", ".join(sorted(import_statements[module_name]))
imports_code.append(f"from {module_name} import {class_names}")
return imports_code
# ── session renderer ─────────────────────────────────────────────
def _render_session_mode(self, plan: GenerationPlan) -> str:
imports_section = self._build_imports_section(plan)
workflow_section = self._build_workflow_section(plan)
session_class = self._build_session_class(plan)
main_wrapper = self._build_main_wrapper(plan)
entrypoint_section = self._build_entrypoint_section()
final_code = "\n".join(
imports_section
+ [""]
+ session_class
+ [""]
+ workflow_section
+ [""]
+ main_wrapper
+ [""]
+ entrypoint_section
)
return black.format_str(final_code, mode=black.Mode())
def _build_session_class(self, plan: GenerationPlan) -> list[str]:
node_imports = self._build_node_imports(plan.import_statements)
node_import_lines = []
if node_imports:
node_import_lines.append("")
node_import_lines.extend(f" {line}" for line in node_imports)
node_import_lines.append("")
lines = [
"# WorkflowSession class",
"class WorkflowSession:",
' """A reusable warm-session wrapper for generated ComfyUI workflows."""',
"",
' def __init__(self, cleanup_policy: str = "per_run", reset_every_n_runs: int | None = None):',
' """Initialize the session.',
"",
' Args:',
' cleanup_policy: One of "per_run", "session", or "manual".',
' reset_every_n_runs: If set, soft-reset every N runs.',
' """',
" self._bootstrapped = False",
" self._custom_nodes_initialized = False",
" self._node_instances = {}",
" self._lock = threading.Lock()",
" self._closed = False",
" self._cleanup_policy = cleanup_policy",
" self._reset_every_n_runs = reset_every_n_runs",
" self._run_count = 0",
f" self._queue_size = {plan.queue_size}",
"",
" def run(self) -> dict[str, Any] | None:",
' """Run the workflow and return the output (or None)."""',
" with self._lock:",
" if self._closed:",
" raise RuntimeError('Session is closed')",
"",
" if not self._bootstrapped:",
" self._bootstrapped = True",
" bootstrap_comfyui_runtime()",
"",
]
lines.extend(
[
" if not self._custom_nodes_initialized:",
" self._custom_nodes_initialized = True",
]
)
if plan.custom_nodes:
lines.append(" import_custom_nodes()")
lines.extend(
[
"",
" prompt = json.loads(json.dumps(build_workflow()))",
" extra_pnginfo = build_extra_pnginfo()",
"",
]
)
lines.extend(node_import_lines)
lines.extend(
[
" import torch",
" try:",
" with torch.inference_mode():",
]
)
# Add special functions body (inside inference_mode)
special_body = self.build_function_body(
plan.special_functions_code, "pass", indentation=" "
)
lines.extend(special_body.splitlines())
lines.append(" for q in range(self._queue_size):")
# Add loop code (node instantiations + calls)
loop_body = self.build_function_body(
plan.loop_code, "pass", indentation=" "
)
lines.extend(loop_body.splitlines())
# Build outputs collection: outputs = {node_id: var_name, ...}
# Inside try, after for loop (same level as for loop), so 16 spaces
executed_vars = plan.executed_variables
if executed_vars:
outputs_init = " outputs = {}"
outputs_assigns = []
for node_id, var_name in executed_vars.items():
outputs_assigns.append(f" outputs[{node_id!r}] = {var_name}")
run_increment = " self._run_count += 1"
outputs_return = " return outputs"
lines.append(outputs_init)
lines.extend(outputs_assigns)
lines.append(run_increment)
lines.append(outputs_return)
else:
run_increment = " self._run_count += 1"
outputs_return = " return None"
lines.append(run_increment)
lines.append(outputs_return)
lines.extend(
[
" finally:",
" if self._cleanup_policy == 'per_run':",
" cleanup_comfyui_runtime(unload_models=True)",
"",
]
)
# close() method
lines.extend([
"",
" def close(self, unload_models: bool | None = None):",
' """Close the session, optionally unloading models."""',
" with self._lock:",
" if self._closed:",
" return",
" if self._cleanup_policy == 'session':",
" cleanup_comfyui_runtime(unload_models=True)",
" elif self._cleanup_policy == 'manual':",
" self._bootstrapped = False",
" self._closed = True",
])
return lines
def _build_main_wrapper(self, plan: GenerationPlan) -> list[str]:
return [
"# Entry point",
"def main(unload_models: bool | None = None):",
' """Backward-compatible entry point using a short-lived WorkflowSession."""',
' session = WorkflowSession(cleanup_policy="per_run")',
" try:",
" session.run()",
" finally:",
" session.close(unload_models=unload_models)",
]
# ── helpers ──────────────────────────────────────────────────────
@staticmethod
def format_python_literal(value: Any) -> str:
return pformat(value, sort_dicts=False)
@staticmethod
def build_function_body(
code_lines: list[str], empty_fallback: str, indentation: str = " "
) -> str:
if not code_lines:
return f"{indentation}{empty_fallback}"
formatted_lines = []
for line in code_lines:
stripped_line = line.lstrip()
if not stripped_line.endswith("\n"):
stripped_line += "\n"
formatted_lines.append(f"{indentation}{stripped_line}")
return "".join(formatted_lines).rstrip()
+23
View File
@@ -0,0 +1,23 @@
import json
import os
from typing import TextIO
def load_json_input(file_path: str | TextIO, encoding: str = "utf-8") -> dict:
"""Read workflow JSON from a file path or file-like object."""
if hasattr(file_path, "read"):
return json.load(file_path)
with open(file_path, "r", encoding=encoding) as file:
return json.load(file)
def write_python_output(file_path: str | TextIO, code: str) -> None:
"""Write generated Python to a file path or file-like object."""
if isinstance(file_path, str):
directory = os.path.dirname(file_path)
if directory and not os.path.exists(directory):
os.makedirs(directory)
with open(file_path, "w", encoding="utf-8") as file:
file.write(code)
return
file_path.write(code)
+43
View File
@@ -0,0 +1,43 @@
from typing import Dict
class LoadOrderDeterminer:
"""Determine workflow execution order with loader-like nodes prioritized."""
def __init__(self, data: Dict, node_class_mappings: Dict):
self.data = data
self.node_class_mappings = node_class_mappings
self.visited = {}
self.load_order = []
self.is_special_function = False
def determine_load_order(self) -> list[tuple[str, Dict, bool]]:
self._load_special_functions_first()
self.is_special_function = False
for key in self.data:
if key not in self.visited:
self._dfs(key)
return self.load_order
def _dfs(self, key: str) -> None:
self.visited[key] = True
inputs = self.data[key]["inputs"]
for value in inputs.values():
if isinstance(value, list) and value[0] not in self.visited:
self._dfs(value[0])
self.load_order.append((key, self.data[key], self.is_special_function))
def _load_special_functions_first(self) -> None:
for key in self.data:
class_def = self.node_class_mappings[self.data[key]["class_type"]]()
if (
class_def.CATEGORY == "loaders"
or class_def.FUNCTION in ["encode"]
or not any(
isinstance(value, list)
for value in self.data[key]["inputs"].values()
)
):
self.is_special_function = True
if key not in self.visited:
self._dfs(key)
+172
View File
@@ -0,0 +1,172 @@
import os
import sys
import warnings
from typing import Any, Mapping, Sequence, Union
def find_path(name: str, path: str = None) -> str:
"""Recursively search parent folders until the named entry is found."""
if path is None:
path = os.getcwd()
if name in os.listdir(path):
path_name = os.path.join(path, name)
print(f"{name} found: {path_name}")
return path_name
parent_directory = os.path.dirname(path)
if parent_directory == path:
return None
return find_path(name, parent_directory)
def get_comfyui_path() -> str:
"""Return the configured ComfyUI path, preferring COMFYUI_PATH when set."""
comfyui_path = os.environ.get("COMFYUI_PATH")
if comfyui_path:
return comfyui_path
return find_path("ComfyUI")
def add_comfyui_directory_to_sys_path() -> None:
"""Add the ComfyUI checkout to sys.path."""
comfyui_path = get_comfyui_path()
if comfyui_path is not None and os.path.isdir(comfyui_path):
if comfyui_path in sys.path:
sys.path.remove(comfyui_path)
sys.path.insert(0, comfyui_path)
print(f"'{comfyui_path}' added to sys.path")
def add_extra_model_paths() -> None:
"""Load ComfyUI extra model paths configuration when available."""
try:
from main import load_extra_path_config
except ImportError:
print(
"Could not import load_extra_path_config from main.py. Looking in utils.extra_config instead."
)
from utils.extra_config import load_extra_path_config
extra_model_paths = find_path("extra_model_paths.yaml")
if extra_model_paths is not None:
load_extra_path_config(extra_model_paths)
else:
print("Could not find the extra_model_paths config file.")
def bootstrap_comfyui_runtime() -> None:
"""Mirror the allocator-related ComfyUI startup steps before torch import."""
add_comfyui_directory_to_sys_path()
import comfy.options
comfy.options.enable_args_parsing()
from comfy.cli_args import args
if os.name == "nt":
os.environ["MIMALLOC_PURGE_DELAY"] = "0"
if args.default_device is not None:
default_dev = args.default_device
devices = list(range(32))
devices.remove(default_dev)
devices.insert(0, default_dev)
devices = ",".join(map(str, devices))
os.environ["CUDA_VISIBLE_DEVICES"] = str(devices)
os.environ["HIP_VISIBLE_DEVICES"] = str(devices)
if args.cuda_device is not None:
os.environ["CUDA_VISIBLE_DEVICES"] = str(args.cuda_device)
os.environ["HIP_VISIBLE_DEVICES"] = str(args.cuda_device)
os.environ["ASCEND_RT_VISIBLE_DEVICES"] = str(args.cuda_device)
if args.oneapi_device_selector is not None:
os.environ["ONEAPI_DEVICE_SELECTOR"] = args.oneapi_device_selector
if args.deterministic and "CUBLAS_WORKSPACE_CONFIG" not in os.environ:
os.environ["CUBLAS_WORKSPACE_CONFIG"] = ":4096:8"
import cuda_malloc
if "rocm" in cuda_malloc.get_torch_version_noimport():
os.environ["OCL_SET_SVM_SIZE"] = "262144"
def cleanup_comfyui_runtime(unload_models: bool | None = None) -> None:
"""Best-effort cleanup for embedded or repeated generated-script execution."""
import gc
def run_cleanup_hook(name: str, should_run: bool = True) -> None:
if not should_run or not hasattr(model_management, name):
return
cleanup_fn = getattr(model_management, name)
try:
cleanup_fn()
except Exception as exc:
warnings.warn(
f"ComfyUI cleanup hook {name} failed during teardown: {exc}",
RuntimeWarning,
stacklevel=2,
)
should_unload = unload_models
if should_unload is None:
should_unload = os.environ.get("COMFYUI_TOPYTHON_UNLOAD_MODELS", "").lower() in {
"1",
"true",
"yes",
"on",
}
try:
import comfy.model_management as model_management
except ModuleNotFoundError:
gc.collect()
return
run_cleanup_hook("cleanup_models_gc")
run_cleanup_hook("unload_all_models", should_run=should_unload)
run_cleanup_hook("soft_empty_cache")
gc.collect()
def import_custom_nodes() -> None:
"""Initialize ComfyUI custom nodes in the exporter runtime."""
comfyui_path = get_comfyui_path()
if comfyui_path and comfyui_path not in sys.path:
sys.path.insert(0, comfyui_path)
import asyncio
import execution
from nodes import init_extra_nodes
if comfyui_path in sys.path:
sys.path.remove(comfyui_path)
sys.path.insert(0, comfyui_path)
import server
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
server_instance = server.PromptServer(loop)
execution.PromptQueue(server_instance)
asyncio.run(init_extra_nodes())
def get_node_class_mappings() -> dict:
"""Load ComfyUI node mappings on demand."""
add_comfyui_directory_to_sys_path()
from nodes import NODE_CLASS_MAPPINGS
return NODE_CLASS_MAPPINGS
def get_value_at_index(obj: Union[Sequence, Mapping], index: int) -> Any:
"""Return a sequence or mapping result item by index."""
try:
return obj[index]
except KeyError:
return obj["result"][index]
+205
View File
@@ -0,0 +1,205 @@
import gc
import json
import threading
from typing import Any, Literal
from comfyui_to_python.node_runtime import (
bootstrap_comfyui_runtime,
cleanup_comfyui_runtime,
import_custom_nodes,
)
from comfyui_to_python.generator.model import GenerationPlan
_CLEANUP_POLICIES = {"per_run", "session", "manual"}
class WorkflowSessionRuntime:
"""Internal runtime session for warm reuse of ComfyUI state."""
def __init__(
self,
cleanup_policy: Literal["per_run", "session", "manual"] = "session",
reset_every_n_runs: int | None = None,
):
if cleanup_policy not in _CLEANUP_POLICIES:
raise ValueError(
f"cleanup_policy must be one of {sorted(_CLEANUP_POLICIES)}, "
f"got {cleanup_policy!r}"
)
self._cleanup_policy: Literal["per_run", "session", "manual"] = cleanup_policy
self._reset_every_n_runs: int | None = reset_every_n_runs
self.bootstrapped: bool = False
self.custom_nodes_initialized: bool = False
self.node_instances: dict[str, Any] = {}
self._node_classes: dict[str, Any] = {}
self.run_count: int = 0
self._closed: bool = False
self._lock: threading.Lock = threading.Lock()
self._workflow_data: dict | None = None
self._node_class_mappings: dict | None = None
self._extra_pnginfo: dict | None = None
def _ensure_bootstrapped(self) -> None:
if self.bootstrapped:
return
bootstrap_comfyui_runtime()
self.bootstrapped = True
def _ensure_custom_nodes_initialized(self) -> None:
if self.custom_nodes_initialized:
return
import_custom_nodes()
self.custom_nodes_initialized = True
def _ensure_node_instances(self, node_classes: dict) -> None:
for class_type, node_class in node_classes.items():
if class_type in self.node_instances:
continue
self.node_instances[class_type] = node_class()
self._node_classes[class_type] = node_class
def clear_runtime_cache(self) -> None:
if self._cleanup_policy == "session":
return
if self._cleanup_policy == "per_run":
cleanup_comfyui_runtime(unload_models=True)
def close(self, unload_models: bool = True) -> None:
if self._closed:
return
self._do_close(unload_models=unload_models)
self._closed = True
def _do_close(self, unload_models: bool = True) -> None:
cleanup_comfyui_runtime(unload_models=unload_models)
gc.collect()
def _do_run(self) -> Any:
if not self._workflow_data or not self._node_class_mappings:
return None
prompt = json.loads(json.dumps(self._workflow_data))
extra_pnginfo = self._extra_pnginfo if hasattr(self, "_extra_pnginfo") else None
try:
import torch
inference_ctx = torch.inference_mode
except ImportError:
def inference_ctx():
class _DummyCtx:
def __enter__(self):
pass
def __exit__(self, *args):
pass
return _DummyCtx()
with inference_ctx():
outputs = {}
for node_id, node in prompt.items():
class_type = node.get("class_type", "")
inputs = node.get("inputs", {})
if class_type not in self.node_instances:
continue
node_instance = self.node_instances[class_type]
node_class = self._node_classes.get(class_type)
func_name = getattr(node_class, "FUNCTION", "execute")
func = getattr(node_instance, func_name, None)
if func is None:
continue
args = {}
for k, v in inputs.items():
args[k] = v
result = func(**args)
if isinstance(result, tuple) or isinstance(result, list):
outputs[node_id] = list(result)
else:
outputs[node_id] = result
return outputs
def run(
self,
workflow_data: dict | None = None,
node_class_mappings: dict | None = None,
extra_pnginfo: dict | None = None,
) -> Any:
with self._lock:
if self._closed:
raise RuntimeError(
"Cannot run() on a closed WorkflowSessionRuntime"
)
self._workflow_data = workflow_data or self._workflow_data
self._node_class_mappings = (
node_class_mappings or self._node_class_mappings
)
self._extra_pnginfo = extra_pnginfo or self._extra_pnginfo
if self._node_class_mappings:
self._ensure_node_instances(self._node_class_mappings)
try:
result = self._do_run()
except Exception:
raise
else:
if self._cleanup_policy == "per_run":
self.clear_runtime_cache()
self.run_count += 1
if (
self._reset_every_n_runs
and self.run_count % self._reset_every_n_runs == 0
):
self._do_close(unload_models=False)
self._closed = False
self.bootstrapped = False
self.custom_nodes_initialized = False
self.node_instances.clear()
self._node_classes.clear()
self.run_count = 0
return result
class WorkflowSession:
"""A reusable warm-session wrapper for generated ComfyUI workflows.
This is the public API class that wraps WorkflowSessionRuntime.
It delegates all method calls to the internal runtime instance.
"""
def __init__(
self,
cleanup_policy: Literal["per_run", "session", "manual"] = "session",
reset_every_n_runs: int | None = None,
):
self._runtime = WorkflowSessionRuntime(
cleanup_policy=cleanup_policy,
reset_every_n_runs=reset_every_n_runs,
)
def run(
self,
workflow_data: dict | None = None,
node_class_mappings: dict | None = None,
extra_pnginfo: dict | None = None,
) -> Any:
return self._runtime.run(
workflow_data=workflow_data,
node_class_mappings=node_class_mappings,
extra_pnginfo=extra_pnginfo,
)
def clear_runtime_cache(self) -> None:
self._runtime.clear_runtime_cache()
def close(self, unload_models: bool = True) -> None:
self._runtime.close(unload_models=unload_models)
+19
View File
@@ -0,0 +1,19 @@
import json
from .io import load_json_input
def load_workflow_data(workflow: str, input_file: str):
"""Load workflow data from inline JSON or an input file."""
if input_file:
return load_json_input(input_file)
return json.loads(workflow)
def load_frontend_workflow_data(frontend_workflow: str | dict | None):
"""Load optional frontend workflow metadata."""
if not frontend_workflow:
return None
if isinstance(frontend_workflow, str):
return json.loads(frontend_workflow)
return frontend_workflow
+18 -122
View File
@@ -1,123 +1,19 @@
import os
from typing import Sequence, Mapping, Any, Union
import sys
from comfyui_to_python.node_runtime import (
add_comfyui_directory_to_sys_path,
add_extra_model_paths,
bootstrap_comfyui_runtime,
find_path,
get_comfyui_path,
get_value_at_index,
import_custom_nodes,
)
def get_comfyui_path() -> str:
"""Return the configured ComfyUI path, preferring COMFYUI_PATH when set."""
comfyui_path = os.environ.get("COMFYUI_PATH")
if comfyui_path:
return comfyui_path
return find_path("ComfyUI")
def import_custom_nodes() -> None:
"""Find all custom nodes in the custom_nodes folder and add those node objects to NODE_CLASS_MAPPINGS
This function sets up a new asyncio event loop, initializes the PromptServer,
creates a PromptQueue, and initializes the custom nodes.
"""
comfyui_path = get_comfyui_path()
if comfyui_path and comfyui_path not in sys.path:
sys.path.insert(0, comfyui_path)
import asyncio
import execution
from nodes import init_extra_nodes
if comfyui_path in sys.path:
sys.path.remove(comfyui_path)
sys.path.insert(0, comfyui_path)
import server
# Creating a new event loop and setting it as the default loop
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Creating an instance of PromptServer with the loop
server_instance = server.PromptServer(loop)
execution.PromptQueue(server_instance)
# Initializing custom nodes
asyncio.run(init_extra_nodes())
def find_path(name: str, path: str = None) -> str:
"""
Recursively looks at parent folders starting from the given path until it finds the given name.
Returns the path as a Path object if found, or None otherwise.
"""
# If no path is given, use the current working directory
if path is None:
path = os.getcwd()
# Check if the current directory contains the name
if name in os.listdir(path):
path_name = os.path.join(path, name)
print(f"{name} found: {path_name}")
return path_name
# Get the parent directory
parent_directory = os.path.dirname(path)
# If the parent directory is the same as the current directory, we've reached the root and stop the search
if parent_directory == path:
return None
# Recursively call the function with the parent directory
return find_path(name, parent_directory)
def add_comfyui_directory_to_sys_path() -> None:
"""
Add 'ComfyUI' to the sys.path
"""
comfyui_path = get_comfyui_path()
if comfyui_path is not None and os.path.isdir(comfyui_path):
if comfyui_path in sys.path:
sys.path.remove(comfyui_path)
sys.path.insert(0, comfyui_path)
print(f"'{comfyui_path}' added to sys.path")
def add_extra_model_paths() -> None:
"""
Parse the optional extra_model_paths.yaml file and add the parsed paths to the sys.path.
"""
try:
from main import load_extra_path_config
except ImportError:
print(
"Could not import load_extra_path_config from main.py. Looking in utils.extra_config instead."
)
from utils.extra_config import load_extra_path_config
extra_model_paths = find_path("extra_model_paths.yaml")
if extra_model_paths is not None:
load_extra_path_config(extra_model_paths)
else:
print("Could not find the extra_model_paths config file.")
def get_value_at_index(obj: Union[Sequence, Mapping], index: int) -> Any:
"""Returns the value at the given index of a sequence or mapping.
If the object is a sequence (like list or string), returns the value at the given index.
If the object is a mapping (like a dictionary), returns the value at the index-th key.
Some return a dictionary, in these cases, we look for the "results" key
Args:
obj (Union[Sequence, Mapping]): The object to retrieve the value from.
index (int): The index of the value to retrieve.
Returns:
Any: The value at the given index.
Raises:
IndexError: If the index is out of bounds for the object and the object is not a mapping.
"""
try:
return obj[index]
except KeyError:
return obj["result"][index]
__all__ = [
"add_comfyui_directory_to_sys_path",
"add_extra_model_paths",
"bootstrap_comfyui_runtime",
"find_path",
"get_comfyui_path",
"get_value_at_index",
"import_custom_nodes",
]
+6
View File
@@ -0,0 +1,6 @@
"""Mock the ComfyUI 'server' module so the root __init__.py can be imported by pytest."""
import sys
from unittest.mock import MagicMock
sys.modules["server"] = MagicMock()
+23 -11
View File
@@ -1,6 +1,25 @@
import { api } from "../../scripts/api.js";
import { app } from "../../scripts/app.js";
import { $el } from "../../scripts/ui.js";
const DEFAULT_SCRIPT_FILENAME = "workflow_api.py";
const DEFAULT_WORKFLOW_NAME = "workflow_api.json";
function $el(tag, options = {}) {
const element = document.createElement(tag);
const { parent, style, ...props } = options;
if (style) {
Object.assign(element.style, style);
}
Object.assign(element, props);
if (parent) {
parent.appendChild(element);
}
return element;
}
const extension = {
name: "Comfy.SaveAsScript",
@@ -23,15 +42,12 @@ const extension = {
});
},
savePythonScript() {
var filename = prompt("Save script as:");
if(filename === undefined || filename === null || filename === "") {
return
}
const filename = DEFAULT_SCRIPT_FILENAME;
app.graphToPrompt().then(async (p) => {
const frontendWorkflow = p.workflow ?? app.graph.serialize();
const json = JSON.stringify({
name: filename + ".json",
name: DEFAULT_WORKFLOW_NAME,
workflow: JSON.stringify(p.output, null, 2),
frontend_workflow: JSON.stringify(frontendWorkflow, null, 2),
}, null, 2); // convert the data to a JSON string
@@ -39,10 +55,6 @@ const extension = {
if(response.status == 200) {
const blob = new Blob([await response.text()], {type: "text/python;charset=utf-8"});
const url = URL.createObjectURL(blob);
if(!filename.endsWith(".py")) {
filename += ".py";
}
const a = $el("a", {
href: url,
download: filename,
+2 -1
View File
@@ -1,8 +1,9 @@
[project]
name = "comfyui-to-python-extension"
description = "This custom node allows you to generate pure python code from your ComfyUI workflow with the click of a button. Great for rapid experimentation or production deployment."
version = "1.3.1"
version = "2.1.0"
license = { text = "MIT License" }
requires-python = ">=3.12"
dependencies = ["black"]
[project.urls]
+15 -1
View File
@@ -5,6 +5,8 @@ This directory contains two test paths:
- unit tests for exporter behavior
- runtime validation for committed workflow fixtures
Use the lightweight path for routine contributor validation, and use the runtime path when you need end-to-end confidence against a real ComfyUI checkout.
## Prerequisites
- Run commands from the repo root.
@@ -30,12 +32,18 @@ The current runtime-capable fixtures require these model files in the target Com
## Unit Tests
Run the current unit test module:
Run the exporter-focused unit test module:
```bash
uv run python -m unittest tests.test_upscale_model_loader_export
```
Run the runtime-harness unit test module:
```bash
uv run python -m unittest tests.test_runtime_validation_harness
```
Run all `unittest`-discoverable tests under `tests`:
```bash
@@ -49,6 +57,7 @@ The runtime harness lives at `tests/runtime/run_runtime_validation.py`.
### Fast Tier
Fast tier validates export behavior against committed fixtures without requiring a full ComfyUI runtime for every fixture.
This is the recommended default validation lane for routine changes.
Run all fast-tier compatible fixtures:
@@ -65,6 +74,7 @@ uv run python tests/runtime/run_runtime_validation.py --tier fast --fixture unsa
### Runtime Tier
Runtime tier exports inside a real ComfyUI checkout and executes generated Python for runtime-capable fixtures.
This is the heavier validation lane for changes that need end-to-end runtime confidence.
Run all runtime-capable fixtures:
@@ -92,11 +102,15 @@ Current committed fixtures:
- `text-to-image`
- `unsafe-kwargs`
- `subgraph-identifiers`
- `reused-node-class-branches`
- `secondary-output-selection`
Notes:
- `--tier runtime` only runs fixtures marked runtime-capable.
- `--tier fast` only runs fixtures with local test mappings.
- `reused-node-class-branches` protects repeated node-class usage and branch wiring in the fast tier.
- `secondary-output-selection` protects non-zero output index wiring in the fast tier.
## Troubleshooting
+16
View File
@@ -0,0 +1,16 @@
"""Pytest configuration to handle the ComfyUI extension __init__.py at repo root."""
import sys
from pathlib import Path
import pytest
@pytest.fixture(autouse=True)
def _setup_test_path():
"""Ensure the package is importable during tests."""
# Add the repo root to sys.path so `from comfyui_to_python import ...` works
repo_root = Path(__file__).parent.parent.resolve()
if str(repo_root) not in sys.path:
sys.path.insert(0, str(repo_root))
yield
+27
View File
@@ -0,0 +1,27 @@
{
"1:10": {
"class_type": "PassthroughText",
"inputs": {
"text": "left branch"
}
},
"1:20": {
"class_type": "PassthroughText",
"inputs": {
"text": "right branch"
}
},
"2": {
"class_type": "JoinText",
"inputs": {
"left": [
"1:10",
0
],
"right": [
"1:20",
0
]
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"1": {
"class_type": "SplitText",
"inputs": {
"text": "alpha|omega"
}
},
"2": {
"class_type": "PassthroughText",
"inputs": {
"text": [
"1",
1
]
}
}
}
@@ -0,0 +1,8 @@
{
"1": {
"class_type": "StringSeedNode",
"inputs": {
"seed": "seed-placeholder"
}
}
}
@@ -0,0 +1,27 @@
{
"33": {
"class_type": "VaeDecode",
"inputs": {
"samples": "latent-placeholder"
}
},
"42:0": {
"class_type": "UpscaleModelLoader",
"inputs": {
"model_name": "4x-ultrasharp.safetensors"
}
},
"42:1": {
"class_type": "ImageUpscaleWithModel",
"inputs": {
"upscale_model": [
"42:0",
0
],
"image": [
"33",
0
]
}
}
}
@@ -0,0 +1,10 @@
{
"1": {
"class_type": "TextConcatenateNode",
"inputs": {
"delimiter": "",
"clean_whitespace": "true",
"text_b": "\\"
}
}
}
@@ -0,0 +1,41 @@
{
"11": {
"class_type": "DualClipLoader",
"inputs": {
"clip_name": "clip.safetensors"
}
},
"633": {
"class_type": "AnySwitchRgthree",
"inputs": {
"model": "model-placeholder"
}
},
"631": {
"class_type": "PowerLoraLoaderRgthree",
"inputs": {
"PowerLoraLoaderHeaderWidget": {
"type": "PowerLoraLoaderHeaderWidget"
},
"lora_1": {
"on": false,
"lora": "lora.safetensors",
"strength": 1.2
},
"lora_2": {
"on": false,
"lora": "lora2.safetensors",
"strength": 0.7
},
"\u2795 Add Lora": "",
"model": [
"633",
0
],
"clip": [
"11",
0
]
}
}
}
@@ -0,0 +1,8 @@
{
"1": {
"class_type": "WindowsPathNode",
"inputs": {
"path": "C:\\ComfyUI\\models\\upscale_models\\RealESRGAN_x4plus.safetensors"
}
}
}
+275
View File
@@ -0,0 +1,275 @@
# Imports
import json
import os
import random
import sys
from typing import Sequence, Mapping, Any, Union
def get_value_at_index(obj: Union[Sequence, Mapping], index: int) -> Any:
"""Return a sequence or mapping result item by index."""
try:
return obj[index]
except KeyError:
return obj["result"][index]
def get_comfyui_path() -> str:
"""Return the configured ComfyUI path, preferring COMFYUI_PATH when set."""
comfyui_path = os.environ.get("COMFYUI_PATH")
if comfyui_path:
return comfyui_path
return find_path("ComfyUI")
def find_path(name: str, path: str = None) -> str:
"""Recursively search parent folders until the named entry is found."""
if path is None:
path = os.getcwd()
if name in os.listdir(path):
path_name = os.path.join(path, name)
print(f"{name} found: {path_name}")
return path_name
parent_directory = os.path.dirname(path)
if parent_directory == path:
return None
return find_path(name, parent_directory)
def add_comfyui_directory_to_sys_path() -> None:
"""Add the ComfyUI checkout to sys.path."""
comfyui_path = get_comfyui_path()
if comfyui_path is not None and os.path.isdir(comfyui_path):
if comfyui_path in sys.path:
sys.path.remove(comfyui_path)
sys.path.insert(0, comfyui_path)
print(f"'{comfyui_path}' added to sys.path")
def add_extra_model_paths() -> None:
"""Load ComfyUI extra model paths configuration when available."""
try:
from main import load_extra_path_config
except ImportError:
print(
"Could not import load_extra_path_config from main.py. Looking in utils.extra_config instead."
)
from utils.extra_config import load_extra_path_config
extra_model_paths = find_path("extra_model_paths.yaml")
if extra_model_paths is not None:
load_extra_path_config(extra_model_paths)
else:
print("Could not find the extra_model_paths config file.")
def bootstrap_comfyui_runtime() -> None:
"""Mirror the allocator-related ComfyUI startup steps before torch import."""
add_comfyui_directory_to_sys_path()
import comfy.options
comfy.options.enable_args_parsing()
from comfy.cli_args import args
if os.name == "nt":
os.environ["MIMALLOC_PURGE_DELAY"] = "0"
if args.default_device is not None:
default_dev = args.default_device
devices = list(range(32))
devices.remove(default_dev)
devices.insert(0, default_dev)
devices = ",".join(map(str, devices))
os.environ["CUDA_VISIBLE_DEVICES"] = str(devices)
os.environ["HIP_VISIBLE_DEVICES"] = str(devices)
if args.cuda_device is not None:
os.environ["CUDA_VISIBLE_DEVICES"] = str(args.cuda_device)
os.environ["HIP_VISIBLE_DEVICES"] = str(args.cuda_device)
os.environ["ASCEND_RT_VISIBLE_DEVICES"] = str(args.cuda_device)
if args.oneapi_device_selector is not None:
os.environ["ONEAPI_DEVICE_SELECTOR"] = args.oneapi_device_selector
if args.deterministic and "CUBLAS_WORKSPACE_CONFIG" not in os.environ:
os.environ["CUBLAS_WORKSPACE_CONFIG"] = ":4096:8"
import cuda_malloc
if "rocm" in cuda_malloc.get_torch_version_noimport():
os.environ["OCL_SET_SVM_SIZE"] = "262144"
def cleanup_comfyui_runtime(unload_models: bool | None = None) -> None:
"""Best-effort cleanup for embedded or repeated generated-script execution."""
import gc
def run_cleanup_hook(name: str, should_run: bool = True) -> None:
if not should_run or not hasattr(model_management, name):
return
cleanup_fn = getattr(model_management, name)
try:
cleanup_fn()
except Exception as exc:
warnings.warn(
f"ComfyUI cleanup hook {name} failed during teardown: {exc}",
RuntimeWarning,
stacklevel=2,
)
should_unload = unload_models
if should_unload is None:
should_unload = os.environ.get(
"COMFYUI_TOPYTHON_UNLOAD_MODELS", ""
).lower() in {
"1",
"true",
"yes",
"on",
}
try:
import comfy.model_management as model_management
except ModuleNotFoundError:
gc.collect()
return
run_cleanup_hook("cleanup_models_gc")
run_cleanup_hook("unload_all_models", should_run=should_unload)
run_cleanup_hook("soft_empty_cache")
gc.collect()
# Workflow data
def build_workflow() -> dict[str, Any]:
return {
"1": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "v1-5-pruned-emaonly-fp16.safetensors"},
},
"2": {
"class_type": "CLIPTextEncode",
"inputs": {
"text": "a small cottage in a meadow, soft daylight",
"clip": ["1", 1],
},
},
"3": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "blurry, low quality", "clip": ["1", 1]},
},
"4": {
"class_type": "EmptyLatentImage",
"inputs": {"width": 512, "height": 512, "batch_size": 1},
},
"5": {
"class_type": "KSampler",
"inputs": {
"seed": 1,
"steps": 4,
"cfg": 7,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1,
"model": ["1", 0],
"positive": ["2", 0],
"negative": ["3", 0],
"latent_image": ["4", 0],
},
},
"6": {
"class_type": "VAEDecode",
"inputs": {"samples": ["5", 0], "vae": ["1", 2]},
},
"7": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "E2E_text_to_image", "images": ["6", 0]},
},
}
def build_extra_pnginfo() -> dict[str, Any] | None:
return None
workflow = build_workflow()
prompt = json.loads(json.dumps(workflow))
extra_pnginfo = build_extra_pnginfo()
# Workflow execution
def main(unload_models: bool | None = None):
bootstrap_comfyui_runtime()
add_extra_model_paths()
# Node imports
from nodes import (
CLIPTextEncode,
CheckpointLoaderSimple,
EmptyLatentImage,
KSampler,
NODE_CLASS_MAPPINGS,
SaveImage,
VAEDecode,
)
import torch
try:
with torch.inference_mode():
checkpointloadersimple = CheckpointLoaderSimple()
checkpointloadersimple_1 = checkpointloadersimple.load_checkpoint(
ckpt_name="v1-5-pruned-emaonly-fp16.safetensors"
)
cliptextencode = CLIPTextEncode()
cliptextencode_2 = cliptextencode.encode(
text="a small cottage in a meadow, soft daylight",
clip=get_value_at_index(checkpointloadersimple_1, 1),
)
cliptextencode_3 = cliptextencode.encode(
text="blurry, low quality",
clip=get_value_at_index(checkpointloadersimple_1, 1),
)
emptylatentimage = EmptyLatentImage()
emptylatentimage_4 = emptylatentimage.generate(
width=512, height=512, batch_size=1
)
ksampler = KSampler()
vaedecode = VAEDecode()
saveimage = SaveImage()
for q in range(1):
node_5_seed = prompt["5"]["inputs"]["seed"] = random.randint(1, 2**64)
ksampler_5 = ksampler.sample(
seed=node_5_seed,
steps=4,
cfg=7,
sampler_name="euler",
scheduler="normal",
denoise=1,
model=get_value_at_index(checkpointloadersimple_1, 0),
positive=get_value_at_index(cliptextencode_2, 0),
negative=get_value_at_index(cliptextencode_3, 0),
latent_image=get_value_at_index(emptylatentimage_4, 0),
)
vaedecode_6 = vaedecode.decode(
samples=get_value_at_index(ksampler_5, 0),
vae=get_value_at_index(checkpointloadersimple_1, 2),
)
saveimage_7 = saveimage.save_images(
filename_prefix="E2E_text_to_image",
images=get_value_at_index(vaedecode_6, 0),
prompt=prompt,
extra_pnginfo=extra_pnginfo,
)
finally:
cleanup_comfyui_runtime(unload_models=unload_models)
# Entrypoint
if __name__ == "__main__":
main()
@@ -0,0 +1,230 @@
# Imports
import json
import os
import random
import sys
from typing import Sequence, Mapping, Any, Union
def get_value_at_index(obj: Union[Sequence, Mapping], index: int) -> Any:
"""Return a sequence or mapping result item by index."""
try:
return obj[index]
except KeyError:
return obj["result"][index]
def get_comfyui_path() -> str:
"""Return the configured ComfyUI path, preferring COMFYUI_PATH when set."""
comfyui_path = os.environ.get("COMFYUI_PATH")
if comfyui_path:
return comfyui_path
return find_path("ComfyUI")
def find_path(name: str, path: str = None) -> str:
"""Recursively search parent folders until the named entry is found."""
if path is None:
path = os.getcwd()
if name in os.listdir(path):
path_name = os.path.join(path, name)
print(f"{name} found: {path_name}")
return path_name
parent_directory = os.path.dirname(path)
if parent_directory == path:
return None
return find_path(name, parent_directory)
def add_comfyui_directory_to_sys_path() -> None:
"""Add the ComfyUI checkout to sys.path."""
comfyui_path = get_comfyui_path()
if comfyui_path is not None and os.path.isdir(comfyui_path):
if comfyui_path in sys.path:
sys.path.remove(comfyui_path)
sys.path.insert(0, comfyui_path)
print(f"'{comfyui_path}' added to sys.path")
def add_extra_model_paths() -> None:
"""Load ComfyUI extra model paths configuration when available."""
try:
from main import load_extra_path_config
except ImportError:
print(
"Could not import load_extra_path_config from main.py. Looking in utils.extra_config instead."
)
from utils.extra_config import load_extra_path_config
extra_model_paths = find_path("extra_model_paths.yaml")
if extra_model_paths is not None:
load_extra_path_config(extra_model_paths)
else:
print("Could not find the extra_model_paths config file.")
def bootstrap_comfyui_runtime() -> None:
"""Mirror the allocator-related ComfyUI startup steps before torch import."""
add_comfyui_directory_to_sys_path()
import comfy.options
comfy.options.enable_args_parsing()
from comfy.cli_args import args
if os.name == "nt":
os.environ["MIMALLOC_PURGE_DELAY"] = "0"
if args.default_device is not None:
default_dev = args.default_device
devices = list(range(32))
devices.remove(default_dev)
devices.insert(0, default_dev)
devices = ",".join(map(str, devices))
os.environ["CUDA_VISIBLE_DEVICES"] = str(devices)
os.environ["HIP_VISIBLE_DEVICES"] = str(devices)
if args.cuda_device is not None:
os.environ["CUDA_VISIBLE_DEVICES"] = str(args.cuda_device)
os.environ["HIP_VISIBLE_DEVICES"] = str(args.cuda_device)
os.environ["ASCEND_RT_VISIBLE_DEVICES"] = str(args.cuda_device)
if args.oneapi_device_selector is not None:
os.environ["ONEAPI_DEVICE_SELECTOR"] = args.oneapi_device_selector
if args.deterministic and "CUBLAS_WORKSPACE_CONFIG" not in os.environ:
os.environ["CUBLAS_WORKSPACE_CONFIG"] = ":4096:8"
import cuda_malloc
if "rocm" in cuda_malloc.get_torch_version_noimport():
os.environ["OCL_SET_SVM_SIZE"] = "262144"
def cleanup_comfyui_runtime(unload_models: bool | None = None) -> None:
"""Best-effort cleanup for embedded or repeated generated-script execution."""
import gc
should_unload = unload_models
if should_unload is None:
should_unload = os.environ.get(
"COMFYUI_TOPYTHON_UNLOAD_MODELS", ""
).lower() in {
"1",
"true",
"yes",
"on",
}
try:
import comfy.model_management as model_management
except ModuleNotFoundError:
gc.collect()
return
if hasattr(model_management, "cleanup_models_gc"):
model_management.cleanup_models_gc()
if should_unload and hasattr(model_management, "unload_all_models"):
model_management.unload_all_models()
if hasattr(model_management, "soft_empty_cache"):
model_management.soft_empty_cache()
gc.collect()
def import_custom_nodes() -> None:
"""Initialize ComfyUI custom nodes in the exporter runtime."""
comfyui_path = get_comfyui_path()
if comfyui_path and comfyui_path not in sys.path:
sys.path.insert(0, comfyui_path)
import asyncio
import execution
from nodes import init_extra_nodes
if comfyui_path in sys.path:
sys.path.remove(comfyui_path)
sys.path.insert(0, comfyui_path)
import server
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
server_instance = server.PromptServer(loop)
execution.PromptQueue(server_instance)
asyncio.run(init_extra_nodes())
# Workflow data
def build_workflow() -> dict[str, Any]:
return {
"1": {"class_type": "LoadImage", "inputs": {"image": "e2e_upscale_input.png"}},
"2": {
"class_type": "UpscaleModelLoader",
"inputs": {"model_name": "RealESRGAN_x4plus.safetensors"},
},
"3": {
"class_type": "ImageUpscaleWithModel",
"inputs": {"upscale_model": ["2", 0], "image": ["1", 0]},
},
"4": {
"class_type": "SaveImage",
"inputs": {
"filename_prefix": "E2E_upscale_model_loader",
"images": ["3", 0],
},
},
}
def build_extra_pnginfo() -> dict[str, Any] | None:
return None
workflow = build_workflow()
prompt = json.loads(json.dumps(workflow))
extra_pnginfo = build_extra_pnginfo()
# Workflow execution
def main(unload_models: bool | None = None):
bootstrap_comfyui_runtime()
add_extra_model_paths()
import_custom_nodes()
# Node imports
from nodes import LoadImage, NODE_CLASS_MAPPINGS, SaveImage
import torch
try:
with torch.inference_mode():
loadimage = LoadImage()
loadimage_1 = loadimage.load_image(image="e2e_upscale_input.png")
upscalemodelloader = NODE_CLASS_MAPPINGS["UpscaleModelLoader"]()
upscalemodelloader_2 = upscalemodelloader.EXECUTE_NORMALIZED(
model_name="RealESRGAN_x4plus.safetensors"
)
imageupscalewithmodel = NODE_CLASS_MAPPINGS["ImageUpscaleWithModel"]()
saveimage = SaveImage()
for q in range(1):
imageupscalewithmodel_3 = imageupscalewithmodel.EXECUTE_NORMALIZED(
upscale_model=get_value_at_index(upscalemodelloader_2, 0),
image=get_value_at_index(loadimage_1, 0),
)
saveimage_4 = saveimage.save_images(
filename_prefix="E2E_upscale_model_loader",
images=get_value_at_index(imageupscalewithmodel_3, 0),
prompt=prompt,
extra_pnginfo=extra_pnginfo,
)
finally:
cleanup_comfyui_runtime(unload_models=unload_models)
# Entrypoint
if __name__ == "__main__":
main()
+95
View File
@@ -109,6 +109,40 @@ class PassthroughText:
return (text,)
class SplitText:
CATEGORY = "utils"
FUNCTION = "split"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING",),
}
}
def split(self, text):
left, right = text.split("|", 1)
return (left, right)
class JoinText:
CATEGORY = "utils"
FUNCTION = "join"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"left": ("STRING",),
"right": ("STRING",),
}
}
def join(self, left, right):
return (f"{left}::{right}",)
class UpscaleModelLoader:
CATEGORY = "loaders"
FUNCTION = "load_model"
@@ -234,6 +268,22 @@ FIXTURES = {
"PassthroughText": PassthroughText,
},
),
"reused-node-class-branches": FixtureConfig(
name="reused-node-class-branches",
path=FIXTURE_DIR / "reused-node-class-branches.json",
mapping_factory=lambda: {
"PassthroughText": PassthroughText,
"JoinText": JoinText,
},
),
"secondary-output-selection": FixtureConfig(
name="secondary-output-selection",
path=FIXTURE_DIR / "secondary-output-selection.json",
mapping_factory=lambda: {
"SplitText": SplitText,
"PassthroughText": PassthroughText,
},
),
}
@@ -270,6 +320,12 @@ def parse_args() -> argparse.Namespace:
"--generated-path",
help=argparse.SUPPRESS,
)
parser.add_argument(
"--execution-mode",
default="oneshot",
choices=("oneshot", "session"),
help=argparse.SUPPRESS,
)
args = parser.parse_args()
if not args.internal_export and not args.tier:
parser.error("--tier is required unless --internal-export is used.")
@@ -351,6 +407,7 @@ def export_workflow(
fixture: FixtureConfig,
tier: str,
runtime_path: str,
execution_mode: str = "oneshot",
) -> tuple[str, str]:
from comfyui_to_python import ComfyUItoPython
@@ -359,6 +416,7 @@ def export_workflow(
kwargs = {
"workflow": workflow,
"output_file": output,
"execution_mode": execution_mode,
}
if tier == "fast" and fixture.fast_mapping_factory is not None:
kwargs["node_class_mappings"] = fixture.fast_mapping_factory()
@@ -429,6 +487,41 @@ def export_workflow_in_runtime_env(fixture: FixtureConfig, runtime_path: str) ->
return generated_path.read_text(encoding="utf-8")
def export_session_workflow_in_runtime_env(
workflow_json: str,
execution_mode: str = "session",
) -> str:
"""Export a session workflow via subprocess in the ComfyUI runtime env.
Unlike ``export_workflow_in_runtime_env`` this does not require a fixture
config – it receives workflow JSON directly and re-enters the runtime
interpreter so ``ComfyUItoPython`` can import ComfyUI's nodes.
"""
runtime_path = os.environ.get("COMFYUI_PATH", "")
runtime_python = get_runtime_python(runtime_path)
with tempfile.NamedTemporaryFile(
suffix=".json", mode="w", delete=False, encoding="utf-8"
) as wf:
wf.write(workflow_json)
wf_path = wf.name
try:
temp_fixture = FixtureConfig(
name="session-mode-export",
path=Path(wf_path),
)
_, generated = export_workflow(
fixture=temp_fixture,
tier="runtime",
runtime_path=runtime_path,
execution_mode=execution_mode,
)
return generated
finally:
os.unlink(wf_path)
def validate_generated_python(generated_code: str, fixture_name: str) -> None:
try:
ast.parse(generated_code)
@@ -621,8 +714,10 @@ def main() -> int:
fixture=fixture,
tier="runtime",
runtime_path=os.environ.get("COMFYUI_PATH", ""),
execution_mode=args.execution_mode,
)
output_path.write_text(generated_code, encoding="utf-8")
print(generated_code, end="")
return 0
try:
+109
View File
@@ -0,0 +1,109 @@
import json
import unittest
from io import StringIO
from comfyui_to_python import ComfyUItoPython
class DummyNode:
CATEGORY = "test"
FUNCTION = "execute"
@classmethod
def INPUT_TYPES(cls):
return {"required": {"value": ("STRING",)}}
def execute(self, value):
return (f"result:{value}",)
class ExportSessionModeTest(unittest.TestCase):
"""Tests for session mode export pipeline."""
def test_comfyui_to_python_passes_execution_mode(self):
workflow = {
"1": {
"class_type": "DummyNode",
"inputs": {"value": "test"},
}
}
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
output_file=output,
node_class_mappings={"DummyNode": DummyNode},
execution_mode="session",
)
generated = output.getvalue()
self.assertIn("class WorkflowSession:", generated)
self.assertIn("def run(self)", generated)
self.assertIn("def close(self, unload_models:", generated)
def test_session_mode_includes_backward_compat_main(self):
workflow = {
"1": {
"class_type": "DummyNode",
"inputs": {"value": "test"},
}
}
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
output_file=output,
node_class_mappings={"DummyNode": DummyNode},
execution_mode="session",
)
generated = output.getvalue()
self.assertIn("def main(", generated)
self.assertIn("WorkflowSession(", generated)
self.assertIn('cleanup_policy="per_run"', generated)
self.assertIn("if __name__ == \"__main__\":", generated)
def test_oneshot_mode_excludes_session_class(self):
workflow = {
"1": {
"class_type": "DummyNode",
"inputs": {"value": "test"},
}
}
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
output_file=output,
node_class_mappings={"DummyNode": DummyNode},
execution_mode="oneshot",
)
generated = output.getvalue()
self.assertNotIn("class WorkflowSession:", generated)
def test_default_mode_is_oneshot(self):
workflow = {
"1": {
"class_type": "DummyNode",
"inputs": {"value": "test"},
}
}
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
output_file=output,
node_class_mappings={"DummyNode": DummyNode},
)
generated = output.getvalue()
self.assertNotIn("class WorkflowSession:", generated)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,312 @@
import json
import unittest
from io import StringIO
from pathlib import Path
from comfyui_to_python import ComfyUItoPython
FIXTURE_DIR = Path(__file__).parent / "fixtures" / "unit" / "generator_codegen"
class AnySwitchRgthree:
CATEGORY = "utils"
FUNCTION = "switch"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
}
}
def switch(self, model):
return (model,)
class DualClipLoader:
CATEGORY = "loaders"
FUNCTION = "load_clip"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"clip_name": ("STRING",),
}
}
def load_clip(self, clip_name):
return (clip_name,)
class PowerLoraLoaderRgthree:
CATEGORY = "loaders"
FUNCTION = "load_loras"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"PowerLoraLoaderHeaderWidget": ("DICT",),
"model": ("MODEL",),
"clip": ("CLIP",),
}
}
def load_loras(self, **kwargs):
return (kwargs,)
class UpscaleModelLoader:
CATEGORY = "loaders"
FUNCTION = "load_model"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model_name": ("STRING",),
}
}
def load_model(self, model_name):
return (model_name,)
class ImageUpscaleWithModel:
CATEGORY = "image/upscaling"
FUNCTION = "upscale"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"upscale_model": ("UPSCALE_MODEL",),
"image": ("IMAGE",),
}
}
def upscale(self, upscale_model, image):
return (image,)
class VaeDecode:
CATEGORY = "latent"
FUNCTION = "decode"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"samples": ("LATENT",),
}
}
def decode(self, samples):
return (samples,)
class WindowsPathNode:
CATEGORY = "paths"
FUNCTION = "open_path"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"path": ("STRING",),
}
}
def open_path(self, path):
return (path,)
class TextConcatenateNode:
CATEGORY = "text"
FUNCTION = "text_concatenate"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"delimiter": ("STRING",),
"clean_whitespace": ("STRING",),
"text_b": ("STRING",),
}
}
def text_concatenate(self, delimiter, clean_whitespace, text_b):
return (delimiter, clean_whitespace, text_b)
class StringSeedNode:
CATEGORY = "sampling"
FUNCTION = "sample"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"seed": ("STRING",),
},
"hidden": {
"prompt": "PROMPT",
},
}
def sample(self, seed, prompt):
return (seed, prompt)
def load_fixture(name: str) -> dict:
return json.loads((FIXTURE_DIR / name).read_text(encoding="utf-8"))
def export_workflow(workflow: dict, node_class_mappings: dict) -> str:
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
output_file=output,
node_class_mappings=node_class_mappings,
)
return output.getvalue()
class GeneratorCodegenIssueRegressionTest(unittest.TestCase):
def test_export_uses_dictionary_expansion_for_rgthree_symbol_heavy_input_names(self):
generated = export_workflow(
load_fixture("unsafe-rgthree-kwargs.json"),
{
"AnySwitchRgthree": AnySwitchRgthree,
"DualClipLoader": DualClipLoader,
"PowerLoraLoaderRgthree": PowerLoraLoaderRgthree,
},
)
self.assertIn(
'powerloraloaderrgthree_631 = powerloraloaderrgthree.load_loras(',
generated,
)
self.assertIn(
'PowerLoraLoaderHeaderWidget={"type": "PowerLoraLoaderHeaderWidget"}',
generated,
)
self.assertIn('**{"\\u2795 Add Lora": ""}', generated)
self.assertNotIn('➕ Add Lora=""', generated)
def test_export_sanitizes_subgraph_identifiers_for_upscaler_workflows(self):
generated = export_workflow(
load_fixture("subgraph-upscaler-identifiers.json"),
{
"VaeDecode": VaeDecode,
"UpscaleModelLoader": UpscaleModelLoader,
"ImageUpscaleWithModel": ImageUpscaleWithModel,
},
)
self.assertIn("upscalemodelloader_42_0 = upscalemodelloader.load_model(", generated)
self.assertIn(
"imageupscalewithmodel_42_1 = imageupscalewithmodel.upscale(",
generated,
)
self.assertIn(
"upscale_model=get_value_at_index(upscalemodelloader_42_0, 0)",
generated,
)
self.assertNotIn("imageupscalewithmodel_42:1", generated)
self.assertNotIn("upscalemodelloader_42:0", generated)
def test_export_preserves_windows_style_model_paths(self):
generated = export_workflow(
load_fixture("windows-path-string.json"),
{
"WindowsPathNode": WindowsPathNode,
},
)
globals_dict = {"__name__": "generated_workflow_module"}
exec(generated, globals_dict)
self.assertEqual(
globals_dict["build_workflow"]()["1"]["inputs"]["path"],
r"C:\ComfyUI\models\upscale_models\RealESRGAN_x4plus.safetensors",
)
def test_export_preserves_trailing_backslash_string_literals(self):
generated = export_workflow(
load_fixture("trailing-backslash-string.json"),
{
"TextConcatenateNode": TextConcatenateNode,
},
)
globals_dict = {"__name__": "generated_workflow_module"}
exec(generated, globals_dict)
self.assertEqual(
globals_dict["build_workflow"]()["1"]["inputs"]["text_b"],
"\\",
)
def test_export_randomizes_string_seed_inputs_as_strings(self):
generated = export_workflow(
load_fixture("string-seed-node.json"),
{
"StringSeedNode": StringSeedNode,
},
)
self.assertIn(
'node_1_seed = prompt["1"]["inputs"]["seed"] = str(random.randint(1, 2**64))',
generated,
)
self.assertIn("seed=node_1_seed", generated)
def test_issue_cluster_regressions_render_parseable_python(self):
workflows = [
(
load_fixture("unsafe-rgthree-kwargs.json"),
{
"AnySwitchRgthree": AnySwitchRgthree,
"DualClipLoader": DualClipLoader,
"PowerLoraLoaderRgthree": PowerLoraLoaderRgthree,
},
),
(
load_fixture("subgraph-upscaler-identifiers.json"),
{
"VaeDecode": VaeDecode,
"UpscaleModelLoader": UpscaleModelLoader,
"ImageUpscaleWithModel": ImageUpscaleWithModel,
},
),
(
load_fixture("trailing-backslash-string.json"),
{
"TextConcatenateNode": TextConcatenateNode,
},
),
(
load_fixture("windows-path-string.json"),
{
"WindowsPathNode": WindowsPathNode,
},
),
(
load_fixture("string-seed-node.json"),
{
"StringSeedNode": StringSeedNode,
},
),
]
for workflow, mapping in workflows:
generated = export_workflow(workflow, mapping)
compile(generated, "<generated_workflow>", "exec")
if __name__ == "__main__":
unittest.main()
+116
View File
@@ -0,0 +1,116 @@
import sys
import types
import unittest
import warnings
from unittest.mock import Mock, patch
from comfyui_to_python.node_runtime import cleanup_comfyui_runtime
class NodeRuntimeCleanupTest(unittest.TestCase):
def test_cleanup_releases_caches_without_forcing_model_unload_by_default(self):
comfy_module = types.ModuleType("comfy")
comfy_module.__path__ = []
model_management = types.ModuleType("comfy.model_management")
model_management.cleanup_models_gc = Mock()
model_management.unload_all_models = Mock()
model_management.soft_empty_cache = Mock()
comfy_module.model_management = model_management
with patch.dict(
sys.modules,
{
"comfy": comfy_module,
"comfy.model_management": model_management,
},
), patch.dict("os.environ", {}, clear=False):
cleanup_comfyui_runtime()
model_management.cleanup_models_gc.assert_called_once_with()
model_management.soft_empty_cache.assert_called_once_with()
model_management.unload_all_models.assert_not_called()
def test_cleanup_can_force_model_unload_from_environment(self):
comfy_module = types.ModuleType("comfy")
comfy_module.__path__ = []
model_management = types.ModuleType("comfy.model_management")
model_management.cleanup_models_gc = Mock()
model_management.unload_all_models = Mock()
model_management.soft_empty_cache = Mock()
comfy_module.model_management = model_management
with patch.dict(
sys.modules,
{
"comfy": comfy_module,
"comfy.model_management": model_management,
},
), patch.dict(
"os.environ",
{"COMFYUI_TOPYTHON_UNLOAD_MODELS": "true"},
clear=False,
):
cleanup_comfyui_runtime()
model_management.unload_all_models.assert_called_once_with()
def test_cleanup_suppresses_hook_failures_and_warns(self):
comfy_module = types.ModuleType("comfy")
comfy_module.__path__ = []
model_management = types.ModuleType("comfy.model_management")
model_management.cleanup_models_gc = Mock(side_effect=RuntimeError("gc failed"))
model_management.unload_all_models = Mock(side_effect=RuntimeError("unload failed"))
model_management.soft_empty_cache = Mock(side_effect=RuntimeError("cache failed"))
comfy_module.model_management = model_management
with patch.dict(
sys.modules,
{
"comfy": comfy_module,
"comfy.model_management": model_management,
},
), warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
cleanup_comfyui_runtime(unload_models=True)
model_management.cleanup_models_gc.assert_called_once_with()
model_management.unload_all_models.assert_called_once_with()
model_management.soft_empty_cache.assert_called_once_with()
self.assertEqual(len(caught), 3)
self.assertEqual(
[str(warning.message) for warning in caught],
[
"ComfyUI cleanup hook cleanup_models_gc failed during teardown: gc failed",
"ComfyUI cleanup hook unload_all_models failed during teardown: unload failed",
"ComfyUI cleanup hook soft_empty_cache failed during teardown: cache failed",
],
)
def test_cleanup_does_not_mask_active_workflow_exception(self):
comfy_module = types.ModuleType("comfy")
comfy_module.__path__ = []
model_management = types.ModuleType("comfy.model_management")
model_management.cleanup_models_gc = Mock(side_effect=RuntimeError("cleanup failed"))
model_management.soft_empty_cache = Mock()
comfy_module.model_management = model_management
with patch.dict(
sys.modules,
{
"comfy": comfy_module,
"comfy.model_management": model_management,
},
), warnings.catch_warnings(record=True):
warnings.simplefilter("always")
with self.assertRaisesRegex(ValueError, "workflow failed"):
try:
raise ValueError("workflow failed")
finally:
cleanup_comfyui_runtime()
model_management.cleanup_models_gc.assert_called_once_with()
model_management.soft_empty_cache.assert_called_once_with()
if __name__ == "__main__":
unittest.main()
+33
View File
@@ -0,0 +1,33 @@
import tomllib
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
class ProjectContractsTest(unittest.TestCase):
def test_project_declares_supported_python_floor(self):
pyproject = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
self.assertEqual(pyproject["project"]["requires-python"], ">=3.12")
def test_readme_documents_python_support_and_default_save_filename(self):
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
self.assertIn("This project supports Python 3.12 and newer.", readme)
self.assertIn("default filename `workflow_api.py`", readme)
def test_extension_import_path_requires_uv_sync_instead_of_running_install_py(self):
init_text = (REPO_ROOT / "__init__.py").read_text(encoding="utf-8")
self.assertIn("Run 'uv sync'", init_text)
self.assertNotIn("spec_from_file_location", init_text)
self.assertNotIn("Successfully installed. Hopefully, at least.", init_text)
def test_frontend_save_flow_uses_deterministic_filename_without_prompt(self):
save_as_script = (REPO_ROOT / "js" / "save-as-script.js").read_text(encoding="utf-8")
self.assertIn('const DEFAULT_SCRIPT_FILENAME = "workflow_api.py";', save_as_script)
self.assertNotIn("prompt(", save_as_script)
+469
View File
@@ -0,0 +1,469 @@
import threading
import unittest
from unittest.mock import MagicMock, patch, call
from comfyui_to_python.runtime_session import WorkflowSessionRuntime
class StubNode:
FUNCTION = "execute"
@classmethod
def INPUT_TYPES(cls):
return {"required": {"value": ("STRING",)}}
def execute(self, value):
return (f"result:{value}",)
class TestWorkflowSessionRuntimeInit(unittest.TestCase):
"""Tests for WorkflowSessionRuntime initialization."""
def test_init_default_cleanup_policy_is_session(self):
runtime = WorkflowSessionRuntime()
self.assertEqual(runtime._cleanup_policy, "session")
def test_init_accepts_per_run_policy(self):
runtime = WorkflowSessionRuntime(cleanup_policy="per_run")
self.assertEqual(runtime._cleanup_policy, "per_run")
def test_init_accepts_manual_policy(self):
runtime = WorkflowSessionRuntime(cleanup_policy="manual")
self.assertEqual(runtime._cleanup_policy, "manual")
def test_init_rejects_invalid_policy(self):
with self.assertRaises(ValueError):
WorkflowSessionRuntime(cleanup_policy="invalid")
def test_init_sets_default_reset_every_n_runs_none(self):
runtime = WorkflowSessionRuntime()
self.assertIsNone(runtime._reset_every_n_runs)
def test_init_accepts_reset_every_n_runs(self):
runtime = WorkflowSessionRuntime(reset_every_n_runs=5)
self.assertEqual(runtime._reset_every_n_runs, 5)
def test_init_has_lock(self):
runtime = WorkflowSessionRuntime()
self.assertIsInstance(runtime._lock, type(threading.Lock()))
def test_init_bootstrapped_false(self):
runtime = WorkflowSessionRuntime()
self.assertFalse(runtime.bootstrapped)
def test_init_custom_nodes_initialized_false(self):
runtime = WorkflowSessionRuntime()
self.assertFalse(runtime.custom_nodes_initialized)
def test_init_run_count_zero(self):
runtime = WorkflowSessionRuntime()
self.assertEqual(runtime.run_count, 0)
def test_init_closed_false(self):
runtime = WorkflowSessionRuntime()
self.assertFalse(runtime._closed)
class TestWorkflowSessionRuntimeLifecycle(unittest.TestCase):
"""Tests for WorkflowSessionRuntime lifecycle management."""
def _make_runtime(self):
return WorkflowSessionRuntime()
@patch("comfyui_to_python.runtime_session.bootstrap_comfyui_runtime")
def test_ensure_bootstrapped_calls_bootstrap_once(self, mock_bootstrap):
runtime = self._make_runtime()
runtime._ensure_bootstrapped()
mock_bootstrap.assert_called_once()
self.assertTrue(runtime.bootstrapped)
@patch("comfyui_to_python.runtime_session.bootstrap_comfyui_runtime")
def test_ensure_bootstrapped_skips_if_already_bootstrapped(self, mock_bootstrap):
runtime = self._make_runtime()
runtime._ensure_bootstrapped()
runtime._ensure_bootstrapped()
self.assertEqual(mock_bootstrap.call_count, 1)
@patch("comfyui_to_python.runtime_session.import_custom_nodes")
def test_ensure_custom_nodes_init_calls_import_once(self, mock_import):
runtime = self._make_runtime()
runtime._ensure_custom_nodes_initialized()
mock_import.assert_called_once()
self.assertTrue(runtime.custom_nodes_initialized)
@patch("comfyui_to_python.runtime_session.import_custom_nodes")
def test_ensure_custom_nodes_init_skips_if_already_initialized(self, mock_import):
runtime = self._make_runtime()
runtime._ensure_custom_nodes_initialized()
runtime._ensure_custom_nodes_initialized()
self.assertEqual(mock_import.call_count, 1)
def test_close_sets_closed_flag(self):
runtime = self._make_runtime()
runtime.close(unload_models=True)
self.assertTrue(runtime._closed)
def test_close_is_idempotent(self):
runtime = self._make_runtime()
with patch.object(runtime, "_do_close") as mock_do_close:
runtime.close(unload_models=True)
runtime.close(unload_models=True)
self.assertEqual(mock_do_close.call_count, 1)
class TestWorkflowSessionRuntimeExceptionSafety(unittest.TestCase):
"""Tests that exceptions during run() do not corrupt session state."""
def _make_runtime(self):
return WorkflowSessionRuntime()
@patch("comfyui_to_python.runtime_session.WorkflowSessionRuntime._do_run")
def test_exception_preserves_state_flags(self, mock_do_run):
runtime = self._make_runtime()
runtime.bootstrapped = True
runtime.custom_nodes_initialized = True
mock_do_run.side_effect = RuntimeError("simulated failure")
with self.assertRaises(RuntimeError):
runtime.run()
self.assertTrue(runtime.bootstrapped)
self.assertTrue(runtime.custom_nodes_initialized)
@patch("comfyui_to_python.runtime_session.WorkflowSessionRuntime._do_run")
def test_run_count_not_incremented_on_exception(self, mock_do_run):
runtime = self._make_runtime()
mock_do_run.side_effect = RuntimeError("simulated failure")
with self.assertRaises(RuntimeError):
runtime.run()
self.assertEqual(runtime.run_count, 0)
def test_run_count_increments_after_successful_run(self):
runtime = self._make_runtime()
workflow_data = {
"1": {
"class_type": "StubNode",
"inputs": {"value": "test"},
}
}
node_mappings = {"StubNode": StubNode}
runtime._workflow_data = workflow_data
runtime._node_class_mappings = node_mappings
runtime.node_instances = {"StubNode": StubNode()}
runtime._node_classes = {"StubNode": StubNode}
runtime.run()
self.assertEqual(runtime.run_count, 1)
def test_run_count_resets_after_reset_every_n_runs(self):
runtime = WorkflowSessionRuntime(reset_every_n_runs=2)
workflow_data = {
"1": {
"class_type": "StubNode",
"inputs": {"value": "test"},
}
}
node_mappings = {"StubNode": StubNode}
runtime._workflow_data = workflow_data
runtime._node_class_mappings = node_mappings
runtime.run()
self.assertEqual(runtime.run_count, 1)
runtime.run()
self.assertEqual(runtime.run_count, 0)
self.assertFalse(runtime.bootstrapped)
self.assertFalse(runtime.custom_nodes_initialized)
self.assertEqual(runtime.node_instances, {})
class TestWorkflowSessionRuntimeAlreadyClosed(unittest.TestCase):
"""Tests for behavior after close()."""
def _make_runtime(self):
return WorkflowSessionRuntime()
def test_run_after_close_raises(self):
runtime = self._make_runtime()
runtime.close()
with self.assertRaises(RuntimeError):
runtime.run()
class TestWorkflowSessionRuntimeNodeInstances(unittest.TestCase):
"""Tests for cached node instance management."""
def _make_runtime(self):
return WorkflowSessionRuntime()
def test_ensure_node_instances_creates_and_caches_instances(self):
runtime = self._make_runtime()
node_class = MagicMock()
runtime._ensure_node_instances({"TestNode": node_class})
node_class.assert_called_once()
self.assertIn("TestNode", runtime.node_instances)
runtime._ensure_node_instances({"TestNode": node_class})
node_class.assert_called_once()
def test_node_instances_are_cached_across_runs(self):
runtime = WorkflowSessionRuntime()
workflow_data = {
"1": {
"class_type": "StubNode",
"inputs": {"value": "test"},
}
}
original_stub_init = StubNode.__init__
init_calls = []
def tracking_init(self, *args, **kwargs):
init_calls.append(1)
original_stub_init(self)
StubNode.__init__ = tracking_init
try:
runtime._workflow_data = workflow_data
runtime._node_class_mappings = {"StubNode": StubNode}
runtime.run()
runtime.run()
self.assertEqual(len(init_calls), 1)
finally:
StubNode.__init__ = original_stub_init
class TestWorkflowSessionRuntimeClearRuntimeCache(unittest.TestCase):
"""Tests for clear_runtime_cache behavior."""
def _make_runtime(self):
return WorkflowSessionRuntime()
@patch("comfyui_to_python.runtime_session.cleanup_comfyui_runtime")
def test_clear_runtime_cache_session_policy_skips_unload(self, mock_cleanup):
runtime = WorkflowSessionRuntime(cleanup_policy="session")
runtime.bootstrapped = True
runtime.clear_runtime_cache()
# session policy should NOT call unload_all_models
mock_cleanup.assert_not_called()
@patch("comfyui_to_python.runtime_session.cleanup_comfyui_runtime")
def test_clear_runtime_cache_per_run_policy_calls_full_cleanup(self, mock_cleanup):
runtime = WorkflowSessionRuntime(cleanup_policy="per_run")
runtime.bootstrapped = True
runtime.clear_runtime_cache()
mock_cleanup.assert_called_once_with(unload_models=True)
@patch("comfyui_to_python.runtime_session.cleanup_comfyui_runtime")
def test_clear_runtime_cache_manual_policy_no_cleanup(self, mock_cleanup):
runtime = WorkflowSessionRuntime(cleanup_policy="manual")
runtime.bootstrapped = True
runtime.clear_runtime_cache()
mock_cleanup.assert_not_called()
class TestWorkflowSessionRuntimeDoClose(unittest.TestCase):
"""Tests for _do_close internal method."""
def _make_runtime(self):
return WorkflowSessionRuntime()
@patch("comfyui_to_python.runtime_session.cleanup_comfyui_runtime")
@patch("comfyui_to_python.runtime_session.gc")
def test_do_close_calls_cleanup_and_gc(self, mock_gc, mock_cleanup):
runtime = self._make_runtime()
runtime._do_close(unload_models=True)
mock_cleanup.assert_called_once_with(unload_models=True)
mock_gc.collect.assert_called_once()
mock_cleanup.reset_mock()
mock_gc.reset_mock()
runtime = self._make_runtime()
runtime._do_close(unload_models=False)
mock_cleanup.assert_called_once_with(unload_models=False)
mock_gc.collect.assert_called_once()
class TestWorkflowSessionRuntimeRun(unittest.TestCase):
"""Tests for WorkflowSessionRuntime.run() workflow execution."""
def _make_runtime(self):
return WorkflowSessionRuntime()
def test_run_executes_workflow_nodes(self):
runtime = self._make_runtime()
workflow_data = {
"1": {
"class_type": "StubNode",
"inputs": {"value": "test"},
}
}
node_mappings = {"StubNode": StubNode}
runtime._workflow_data = workflow_data
runtime._node_class_mappings = node_mappings
runtime.node_instances = {"StubNode": StubNode()}
runtime._node_classes = {"StubNode": StubNode}
result = runtime.run()
self.assertIn("1", result)
self.assertEqual(result["1"], ["result:test"])
@patch(
"comfyui_to_python.runtime_session.WorkflowSessionRuntime._ensure_node_instances"
)
def test_run_with_no_workflow_data_returns_none(self, mock_ensure):
runtime = self._make_runtime()
result = runtime.run()
self.assertIsNone(result)
@patch(
"comfyui_to_python.runtime_session.WorkflowSessionRuntime._ensure_node_instances"
)
def test_run_with_no_node_mappings_returns_none(self, mock_ensure):
runtime = self._make_runtime()
runtime._workflow_data = {"1": {"class_type": "Test", "inputs": {}}}
result = runtime.run()
self.assertIsNone(result)
@patch(
"comfyui_to_python.runtime_session.WorkflowSessionRuntime._ensure_node_instances"
)
def test_run_node_not_in_mappings_is_skipped(self, mock_ensure):
workflow_data = {
"1": {
"class_type": "UnknownNode",
"inputs": {"value": "test"},
}
}
node_mappings = {"StubNode": StubNode}
runtime = self._make_runtime()
runtime._workflow_data = workflow_data
runtime._node_class_mappings = node_mappings
result = runtime.run()
self.assertEqual(result, {})
def test_run_node_returns_tuple_is_converted_to_list(self):
runtime = self._make_runtime()
workflow_data = {
"1": {
"class_type": "StubNode",
"inputs": {"value": "multi"},
}
}
node_mappings = {"StubNode": StubNode}
runtime._workflow_data = workflow_data
runtime._node_class_mappings = node_mappings
runtime.node_instances = {"StubNode": StubNode()}
runtime._node_classes = {"StubNode": StubNode}
result = runtime.run()
self.assertIsInstance(result["1"], list)
self.assertEqual(result["1"][0], "result:multi")
def test_run_persists_parameters_across_calls(self):
runtime = self._make_runtime()
workflow_data = {
"1": {
"class_type": "StubNode",
"inputs": {"value": "persist"},
}
}
node_mappings = {"StubNode": StubNode}
with patch.object(
runtime, "_do_run", return_value={"1": ["result:persist"]}
) as mock_do_run:
runtime.run(
workflow_data=workflow_data, node_class_mappings=node_mappings
)
runtime.run()
self.assertEqual(mock_do_run.call_count, 2)
def test_run_does_not_corrupt_session_on_exception(self):
runtime = self._make_runtime()
workflow_data = {
"1": {
"class_type": "StubNode",
"inputs": {"value": "test"},
}
}
node_mappings = {"StubNode": StubNode}
runtime._workflow_data = workflow_data
runtime._node_class_mappings = node_mappings
mock_instance = MagicMock()
mock_instance.execute.side_effect = ValueError("boom")
runtime.node_instances = {"StubNode": mock_instance}
runtime._node_classes = {"StubNode": StubNode}
with self.assertRaises(ValueError):
runtime.run()
runtime.node_instances = {"StubNode": StubNode()}
runtime._node_classes = {"StubNode": StubNode}
result = runtime.run()
self.assertEqual(result["1"], ["result:test"])
@patch(
"comfyui_to_python.runtime_session.WorkflowSessionRuntime._ensure_node_instances"
)
def test_session_policy_does_not_clear_cache(self, mock_ensure):
runtime = WorkflowSessionRuntime(cleanup_policy="session")
workflow_data = {
"1": {
"class_type": "StubNode",
"inputs": {"value": "test"},
}
}
node_mappings = {"StubNode": StubNode}
runtime._workflow_data = workflow_data
runtime._node_class_mappings = node_mappings
with patch.object(runtime, "clear_runtime_cache") as mock_clear:
runtime.run()
mock_clear.assert_not_called()
@patch(
"comfyui_to_python.runtime_session.WorkflowSessionRuntime._ensure_node_instances"
)
def test_per_run_policy_clears_cache_after_each_run(self, mock_ensure):
runtime = WorkflowSessionRuntime(cleanup_policy="per_run")
workflow_data = {
"1": {
"class_type": "StubNode",
"inputs": {"value": "test"},
}
}
node_mappings = {"StubNode": StubNode}
runtime._workflow_data = workflow_data
runtime._node_class_mappings = node_mappings
with patch.object(runtime, "clear_runtime_cache") as mock_clear:
runtime.run()
mock_clear.assert_called_once()
@patch(
"comfyui_to_python.runtime_session.WorkflowSessionRuntime._ensure_node_instances"
)
def test_manual_policy_never_clears_cache(self, mock_ensure):
runtime = WorkflowSessionRuntime(cleanup_policy="manual")
workflow_data = {
"1": {
"class_type": "StubNode",
"inputs": {"value": "test"},
}
}
node_mappings = {"StubNode": StubNode}
runtime._workflow_data = workflow_data
runtime._node_class_mappings = node_mappings
with patch.object(runtime, "clear_runtime_cache") as mock_clear:
runtime.run()
runtime.run()
mock_clear.assert_not_called()
if __name__ == "__main__":
unittest.main()
+305
View File
@@ -0,0 +1,305 @@
import struct
import tempfile
import unittest
import zlib
from pathlib import Path
from unittest.mock import patch
from tests.runtime.run_runtime_validation import (
FixtureConfig,
ModelRequirement,
ValidationFailure,
check_models,
ensure_runtime_path,
execute_generated_python,
load_fixture_names,
parse_png_info,
validate_generated_python,
)
def make_png_bytes(
width: int,
height: int,
text_chunks: list[tuple[bytes, bytes]] | None = None,
) -> bytes:
def chunk(chunk_type: bytes, data: bytes) -> bytes:
crc = zlib.crc32(chunk_type + data) & 0xFFFFFFFF
return (
struct.pack(">I", len(data))
+ chunk_type
+ data
+ struct.pack(">I", crc)
)
ihdr = chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
text_chunks = text_chunks or []
idat = chunk(
b"IDAT",
zlib.compress(b"\x00" + (b"\x00\x00\x00" * width)),
)
return b"".join(
[
b"\x89PNG\r\n\x1a\n",
ihdr,
*[chunk(chunk_type, payload) for chunk_type, payload in text_chunks],
idat,
chunk(b"IEND", b""),
]
)
class RuntimeValidationHarnessTest(unittest.TestCase):
def test_load_fixture_names_all_returns_registered_names(self):
fixture_names = load_fixture_names("all")
self.assertGreaterEqual(len(fixture_names), 4)
self.assertEqual(fixture_names[0], "upscale-model-loader")
self.assertIn("unsafe-kwargs", fixture_names)
def test_load_fixture_names_rejects_unknown_fixture(self):
with self.assertRaises(ValidationFailure) as context:
load_fixture_names("missing-fixture")
self.assertEqual(context.exception.classification, "fixture bug")
self.assertIn("Unknown fixture", context.exception.message)
def test_ensure_runtime_path_fast_tier_returns_env_without_validation(self):
with patch.dict("os.environ", {"COMFYUI_PATH": "/does/not/exist"}, clear=False):
runtime_path = ensure_runtime_path("fast")
self.assertEqual(runtime_path, "/does/not/exist")
@patch("tests.runtime.run_runtime_validation.get_comfyui_path", return_value="")
def test_ensure_runtime_path_runtime_tier_requires_valid_checkout(self, _mock_path):
with self.assertRaises(ValidationFailure) as context:
ensure_runtime_path("runtime")
self.assertEqual(context.exception.classification, "environment/setup failure")
self.assertIn("Could not find a valid ComfyUI checkout", context.exception.message)
def test_check_models_returns_only_missing_requirements(self):
with tempfile.TemporaryDirectory() as tmpdir:
runtime_path = Path(tmpdir)
present_dir = runtime_path / "models" / "checkpoints"
present_dir.mkdir(parents=True)
(present_dir / "present.safetensors").write_text("ok", encoding="utf-8")
fixture = FixtureConfig(
name="model-checks",
path=Path("unused.json"),
model_requirements=(
ModelRequirement(
filename="present.safetensors",
relative_dir="models/checkpoints",
source_url="https://example.invalid/present",
),
ModelRequirement(
filename="missing.safetensors",
relative_dir="models/checkpoints",
source_url="https://example.invalid/missing",
),
),
)
missing = check_models(fixture, str(runtime_path))
self.assertEqual([item.filename for item in missing], ["missing.safetensors"])
def test_validate_generated_python_accepts_valid_python(self):
validate_generated_python("value = 1\n", "valid-fixture")
def test_validate_generated_python_reports_syntax_regression(self):
with self.assertRaises(ValidationFailure) as context:
validate_generated_python("def broken(:\n", "broken-fixture")
self.assertEqual(context.exception.classification, "repo regression")
self.assertIn("broken-fixture", context.exception.message)
def test_parse_png_info_reads_dimensions_and_text_chunks(self):
compressed_text = zlib.compress(b"workflow data")
png_bytes = make_png_bytes(
width=3,
height=2,
text_chunks=[
(b"tEXt", b"prompt\x00hello"),
(b"zTXt", b"workflow\x00\x00" + compressed_text),
(b"iTXt", b"comment\x00\x00\x00\x00\x00unicode text"),
],
)
with tempfile.TemporaryDirectory() as tmpdir:
image_path = Path(tmpdir) / "artifact.png"
image_path.write_bytes(png_bytes)
width, height, metadata = parse_png_info(image_path)
self.assertEqual((width, height), (3, 2))
self.assertEqual(metadata["prompt"], "hello")
self.assertEqual(metadata["workflow"], "workflow data")
self.assertEqual(metadata["comment"], "unicode text")
def test_parse_png_info_rejects_non_png_files(self):
with tempfile.TemporaryDirectory() as tmpdir:
image_path = Path(tmpdir) / "artifact.bin"
image_path.write_bytes(b"not-a-png")
with self.assertRaises(ValidationFailure) as context:
parse_png_info(image_path)
self.assertEqual(context.exception.classification, "environment/setup failure")
self.assertIn("Expected PNG output", context.exception.message)
def test_parse_png_info_requires_dimensions(self):
with tempfile.TemporaryDirectory() as tmpdir:
image_path = Path(tmpdir) / "artifact.png"
image_path.write_bytes(
b"\x89PNG\r\n\x1a\n"
+ struct.pack(">I", 0)
+ b"IEND"
+ struct.pack(">I", 0)
)
with self.assertRaises(ValidationFailure) as context:
parse_png_info(image_path)
self.assertEqual(context.exception.classification, "environment/setup failure")
self.assertIn("Could not read PNG dimensions", context.exception.message)
@patch("tests.runtime.run_runtime_validation.get_runtime_python", return_value="/usr/bin/python")
@patch("tests.runtime.run_runtime_validation.subprocess.run")
def test_execute_generated_python_classifies_missing_torch_as_environment_failure(
self,
mock_run,
_mock_runtime_python,
):
mock_run.return_value.returncode = 1
mock_run.return_value.stderr = "ModuleNotFoundError: No module named 'torch'"
mock_run.return_value.stdout = ""
fixture = FixtureConfig(
name="runtime-fixture",
path=Path("unused.json"),
runtime_capable=True,
)
with tempfile.TemporaryDirectory() as tmpdir:
(Path(tmpdir) / "output").mkdir()
with self.assertRaises(ValidationFailure) as context:
execute_generated_python("print('hello')\n", fixture, tmpdir)
self.assertEqual(context.exception.classification, "environment/setup failure")
@patch("tests.runtime.run_runtime_validation.get_runtime_python", return_value="/usr/bin/python")
@patch("tests.runtime.run_runtime_validation.subprocess.run")
def test_execute_generated_python_classifies_missing_files_as_environment_failure(
self,
mock_run,
_mock_runtime_python,
):
mock_run.return_value.returncode = 1
mock_run.return_value.stderr = "No such file or directory: missing.png"
mock_run.return_value.stdout = ""
fixture = FixtureConfig(
name="runtime-fixture",
path=Path("unused.json"),
runtime_capable=True,
)
with tempfile.TemporaryDirectory() as tmpdir:
(Path(tmpdir) / "output").mkdir()
with self.assertRaises(ValidationFailure) as context:
execute_generated_python("print('hello')\n", fixture, tmpdir)
self.assertEqual(context.exception.classification, "environment/setup failure")
@patch("tests.runtime.run_runtime_validation.get_runtime_python", return_value="/usr/bin/python")
@patch("tests.runtime.run_runtime_validation.subprocess.run")
def test_execute_generated_python_classifies_other_failures_as_repo_regression(
self,
mock_run,
_mock_runtime_python,
):
mock_run.return_value.returncode = 1
mock_run.return_value.stderr = "ValueError: broken workflow"
mock_run.return_value.stdout = ""
fixture = FixtureConfig(
name="runtime-fixture",
path=Path("unused.json"),
runtime_capable=True,
)
with tempfile.TemporaryDirectory() as tmpdir:
(Path(tmpdir) / "output").mkdir()
with self.assertRaises(ValidationFailure) as context:
execute_generated_python("print('hello')\n", fixture, tmpdir)
self.assertEqual(context.exception.classification, "repo regression")
@patch("tests.runtime.run_runtime_validation.get_runtime_python", return_value="/usr/bin/python")
@patch("tests.runtime.run_runtime_validation.subprocess.run")
def test_execute_generated_python_requires_fresh_matching_artifact(
self,
mock_run,
_mock_runtime_python,
):
mock_run.return_value.returncode = 0
mock_run.return_value.stderr = ""
mock_run.return_value.stdout = ""
fixture = FixtureConfig(
name="runtime-fixture",
path=Path("unused.json"),
runtime_capable=True,
filename_prefix="expected_prefix",
)
with tempfile.TemporaryDirectory() as tmpdir:
(Path(tmpdir) / "output").mkdir()
with self.assertRaises(ValidationFailure) as context:
execute_generated_python("print('hello')\n", fixture, tmpdir)
self.assertEqual(context.exception.classification, "repo regression")
self.assertIn("did not produce a new output file", context.exception.message)
@patch("tests.runtime.run_runtime_validation.get_runtime_python", return_value="/usr/bin/python")
@patch("tests.runtime.run_runtime_validation.validate_output_artifact")
@patch("tests.runtime.run_runtime_validation.subprocess.run")
def test_execute_generated_python_validates_newest_matching_artifact(
self,
mock_run,
mock_validate_output,
_mock_runtime_python,
):
mock_run.return_value.returncode = 0
mock_run.return_value.stderr = ""
mock_run.return_value.stdout = ""
fixture = FixtureConfig(
name="runtime-fixture",
path=Path("unused.json"),
runtime_capable=True,
filename_prefix="expected_prefix",
)
with tempfile.TemporaryDirectory() as tmpdir:
output_dir = Path(tmpdir) / "output"
output_dir.mkdir()
older = output_dir / "expected_prefix_00001_.png"
older.write_bytes(make_png_bytes(1, 1))
with patch(
"tests.runtime.run_runtime_validation.subprocess.run",
side_effect=self._write_runtime_artifact(mock_run.return_value, output_dir),
):
execute_generated_python("print('hello')\n", fixture, tmpdir)
validated_path = mock_validate_output.call_args[0][1]
self.assertEqual(validated_path.name, "expected_prefix_00002_.png")
@staticmethod
def _write_runtime_artifact(result, output_dir: Path):
def side_effect(*_args, **_kwargs):
(output_dir / "expected_prefix_00002_.png").write_bytes(make_png_bytes(2, 2))
return result
return side_effect
if __name__ == "__main__":
unittest.main()
+370
View File
@@ -0,0 +1,370 @@
"""End-to-end tests for session execution mode feature.
Covers:
- Code generation correctness (session vs oneshot)
- Runtime execution of generated scripts
- Multi-run session behavior
"""
import ast
import json
import os
import subprocess
import sys
import tempfile
import unittest
from io import StringIO
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
COMFYUI_PATH = os.environ.get("COMFYUI_PATH", str(ROOT.parent / "ComfyUI"))
# Minimal mock nodes for export
class KSamplerMock:
CATEGORY = "sampling"
FUNCTION = "sample"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"seed": ("INT", {"default": 0}),
"steps": ("INT", {"default": 20}),
"cfg": ("FLOAT", {"default": 8.0}),
"sampler_name": (["euler", "heun"],),
"scheduler": (["normal"],),
"denoise": ("FLOAT", {"default": 1.0}),
"model": ("MODEL",),
"positive": ("CONDITIONING",),
"negative": ("CONDITIONING",),
"latent_image": ("LATENT",),
}
}
def sample(self, seed, steps, cfg, sampler_name, scheduler, denoise, model, positive, negative, latent_image):
return ({"samples": latent_image},)
class CheckpointLoaderMock:
CATEGORY = "loaders"
FUNCTION = "load_checkpoint"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"ckpt_name": ("STRING",),
}
}
def load_checkpoint(self, ckpt_name):
return (None, None, None)
class VAEDecodeMock:
CATEGORY = "latent"
FUNCTION = "decode"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"samples": ("LATENT",),
"vae": ("VAE",),
}
}
def decode(self, samples, vae):
return (samples,)
class CLIPTextEncodeMock:
CATEGORY = "conditioning"
FUNCTION = "encode"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING",),
"clip": ("CLIP",),
}
}
def encode(self, text, clip):
return ([],)
class EmptyLatentImageMock:
CATEGORY = "latent"
FUNCTION = "generate"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"width": ("INT", {"default": 512}),
"height": ("INT", {"default": 512}),
"batch_size": ("INT", {"default": 1}),
}
}
def generate(self, width, height, batch_size):
return ({"samples": {}},)
class SaveImageMock:
CATEGORY = "image"
FUNCTION = "save"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"filename_prefix": ("STRING",),
}
}
def save(self, images, filename_prefix):
return ()
NODENAMES = {
"CheckpointLoaderSimple": CheckpointLoaderMock,
"CLIPTextEncode": CLIPTextEncodeMock,
"KSampler": KSamplerMock,
"VAEDecode": VAEDecodeMock,
"EmptyLatentImage": EmptyLatentImageMock,
"SaveImage": SaveImageMock,
}
TEXT_TO_IMAGE_WORKFLOW = {
"1": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "v1-5-pruned-emaonly-fp16.safetensors"},
},
"2": {
"class_type": "CLIPTextEncode",
"inputs": {
"text": "a small cottage in a meadow, soft daylight",
"clip": ["1", 1],
},
},
"3": {
"class_type": "CLIPTextEncode",
"inputs": {
"text": "blurry, low quality",
"clip": ["1", 1],
},
},
"4": {
"class_type": "EmptyLatentImage",
"inputs": {"width": 512, "height": 512, "batch_size": 1},
},
"5": {
"class_type": "KSampler",
"inputs": {
"seed": 1, "steps": 4, "cfg": 7, "sampler_name": "euler",
"scheduler": "normal", "denoise": 1,
"model": ["1", 0], "positive": ["2", 0],
"negative": ["3", 0], "latent_image": ["4", 0],
},
},
"6": {
"class_type": "VAEDecode",
"inputs": {"samples": ["5", 0], "vae": ["1", 2]},
},
"7": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "E2E_session_mode", "images": ["6", 0]},
},
}
def _get_runtime_python():
"""Get the ComfyUI Python interpreter for running generated scripts."""
rt_python = Path(COMFYUI_PATH) / ".venv" / "bin" / "python"
if rt_python.is_file():
return str(rt_python)
return sys.executable
def _export_session_workflow_in_runtime_env(
workflow_json,
execution_mode="session",
):
"""Export workflow via subprocess in ComfyUI runtime env.
Re-enters the runtime interpreter so ``ComfyUItoPython`` can import
ComfyUI's nodes.py (which requires torch, not available in the test venv).
"""
runtime_python = _get_runtime_python()
env = os.environ.copy()
env["COMFYUI_PATH"] = COMFYUI_PATH
env["PYTHONPATH"] = os.pathsep.join([str(ROOT), env.get("PYTHONPATH", "")]).rstrip(
os.pathsep
)
tmp_path = tempfile.mktemp(suffix=".py")
try:
result = subprocess.run(
[
runtime_python,
str(Path(__file__).resolve().parents[0] / "runtime" / "run_runtime_validation.py"),
"--internal-export",
"text-to-image",
"--execution-mode",
execution_mode,
"--generated-path",
tmp_path,
],
cwd=ROOT,
env=env,
capture_output=True,
text=True,
)
if result.returncode != 0:
output = (result.stderr or result.stdout or "").strip()
if "Missing runtime dependency" not in output and "ModuleNotFoundError" not in output:
classification = "repo regression"
else:
classification = "environment/setup failure"
raise RuntimeError(
f"Runtime export failed: [{classification}] {output}"
)
# Read generated code from the temp file (stdout is polluted by
# ComfyUI runtime prints such as the sys.path line).
return Path(tmp_path).read_text()
finally:
os.unlink(tmp_path)
def _export_workflow(execution_mode="oneshot"):
"""Export workflow to a string using ComfyUItoPython (unit tests, mock nodes)."""
from comfyui_to_python import ComfyUItoPython
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(TEXT_TO_IMAGE_WORKFLOW),
output_file=output,
node_class_mappings=NODENAMES,
execution_mode=execution_mode,
)
return output.getvalue()
class SessionCodeGenerationTest(unittest.TestCase):
"""Unit tests for session mode code generation."""
def test_oneshot_code_has_bootstrap_helpers_no_session(self):
"""Oneshot mode: bootstrap/cleanup helpers present, no WorkflowSession class."""
generated = _export_workflow("oneshot")
ast.parse(generated)
self.assertIn("bootstrap_comfyui_runtime()", generated)
self.assertIn("cleanup_comfyui_runtime(", generated)
self.assertNotIn("class WorkflowSession", generated)
self.assertNotIn("session.run()", generated)
def test_session_code_has_workflow_session_class_and_session_methods(self):
"""Session mode: WorkflowSession class with run() and close() present."""
generated = _export_workflow("session")
ast.parse(generated)
self.assertIn("class WorkflowSession", generated)
self.assertIn("def run(self)", generated)
self.assertIn("def close(self, unload_models", generated)
self.assertIn("session.run()", generated)
self.assertIn("session.close(", generated)
class SessionModeExecutionTest(unittest.TestCase):
"""E2E tests for session mode script execution."""
@unittest.skipIf(not Path(COMFYUI_PATH).is_dir(), "ComfyUI checkout not available")
def test_oneshot_e2e_text_to_image(self):
"""Oneshot mode: generate and run text-to-image workflow, verify PNG output."""
with tempfile.TemporaryDirectory() as tmpdir:
script_path = Path(tmpdir) / "oneshot.py"
env = os.environ.copy()
env["COMFYUI_PATH"] = COMFYUI_PATH
env["PYTHONPATH"] = os.pathsep.join([str(ROOT), env.get("PYTHONPATH", "")])
runtime_py = _get_runtime_python()
# Export inside ComfyUI env so node mappings resolve
generated = _export_session_workflow_in_runtime_env(
json.dumps(TEXT_TO_IMAGE_WORKFLOW),
execution_mode="oneshot",
)
script_path.write_text(generated)
# Run
result = subprocess.run(
[runtime_py, str(script_path), "--cpu"],
cwd=ROOT,
env=env,
capture_output=True,
text=True,
timeout=300,
)
self.assertEqual(result.returncode, 0, f"stderr: {result.stderr}")
# Check output
output_dir = Path(COMFYUI_PATH) / "output"
new_outputs = list(output_dir.glob("E2E_text_to_image*.png"))
self.assertTrue(len(new_outputs) > 0, "No PNG output produced")
@unittest.skipIf(not Path(COMFYUI_PATH).is_dir(), "ComfyUI checkout not available")
def test_session_e2e_text_to_image(self):
"""Session mode: generate session-mode script, verify WorkflowSession present and run."""
with tempfile.TemporaryDirectory() as tmpdir:
script_path = Path(tmpdir) / "session.py"
env = os.environ.copy()
env["COMFYUI_PATH"] = COMFYUI_PATH
env["PYTHONPATH"] = os.pathsep.join([str(ROOT), env.get("PYTHONPATH", "")])
runtime_py = _get_runtime_python()
# Export inside ComfyUI env
generated = _export_session_workflow_in_runtime_env(
json.dumps(TEXT_TO_IMAGE_WORKFLOW),
execution_mode="session",
)
script_path.write_text(generated)
# Verify code structure
self.assertIn("class WorkflowSession", generated)
# Run
result = subprocess.run(
[runtime_py, str(script_path), "--cpu"],
cwd=ROOT,
env=env,
capture_output=True,
text=True,
timeout=300,
)
self.assertEqual(result.returncode, 0, f"stderr: {result.stderr}")
# Check output
output_dir = Path(COMFYUI_PATH) / "output"
new_outputs = list(output_dir.glob("E2E_text_to_image*.png"))
self.assertTrue(len(new_outputs) > 0, "No PNG output produced")
@unittest.skipIf(not Path(COMFYUI_PATH).is_dir(), "ComfyUI checkout not available")
def test_session_e2e_multiple_runs(self):
"""Session mode: generate script that calls session.run() 3x in a row, verify no crash."""
with tempfile.TemporaryDirectory() as tmpdir:
script_path = Path(tmpdir) / "session_multi.py"
env = os.environ.copy()
env["COMFYUI_PATH"] = COMFYUI_PATH
env["PYTHONPATH"] = os.pathsep.join([str(ROOT), env.get("PYTHONPATH", "")])
runtime_py = _get_runtime_python()
# Export session mode inside ComfyUI env
generated = _export_session_workflow_in_runtime_env(
json.dumps(TEXT_TO_IMAGE_WORKFLOW),
execution_mode="session",
)
# Modify to run 3x
modified = generated.replace(
" session.run()",
" session.run()\n session.run()\n session.run()",
)
script_path.write_text(modified)
# Run
result = subprocess.run(
[runtime_py, str(script_path), "--cpu"],
cwd=ROOT,
env=env,
capture_output=True,
text=True,
timeout=600,
)
self.assertEqual(result.returncode, 0, f"stderr: {result.stderr}")
if __name__ == "__main__":
unittest.main()
+273
View File
@@ -0,0 +1,273 @@
import json
import unittest
from io import StringIO
from unittest.mock import patch
from comfyui_to_python import ComfyUItoPython
class LoadImage:
CATEGORY = "image"
FUNCTION = "load_image"
@classmethod
def INPUT_TYPES(cls):
return {"required": {"image": ("STRING",)}}
def load_image(self, image):
return (image,)
class SessionRendererTest(unittest.TestCase):
"""Tests for session mode code generation in the renderer."""
def test_session_mode_generates_workflow_session_class(self):
workflow = {
"1": {
"class_type": "LoadImage",
"inputs": {"image": "example.png"},
}
}
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
output_file=output,
node_class_mappings={"LoadImage": LoadImage},
execution_mode="session",
)
generated = output.getvalue()
self.assertIn("class WorkflowSession", generated)
def test_session_mode_generates_main_wrapper(self):
workflow = {
"1": {
"class_type": "LoadImage",
"inputs": {"image": "example.png"},
}
}
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
output_file=output,
node_class_mappings={"LoadImage": LoadImage},
execution_mode="session",
)
generated = output.getvalue()
self.assertIn("def main(unload_models: bool | None = None)", generated)
self.assertIn("WorkflowSession(", generated)
def test_session_mode_main_creates_session_with_per_run_policy(self):
workflow = {
"1": {
"class_type": "LoadImage",
"inputs": {"image": "example.png"},
}
}
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
output_file=output,
node_class_mappings={"LoadImage": LoadImage},
execution_mode="session",
)
generated = output.getvalue()
self.assertIn('cleanup_policy="per_run"', generated)
def test_session_mode_main_has_try_finally_close(self):
workflow = {
"1": {
"class_type": "LoadImage",
"inputs": {"image": "example.png"},
}
}
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
output_file=output,
node_class_mappings={"LoadImage": LoadImage},
execution_mode="session",
)
generated = output.getvalue()
self.assertIn("try:", generated)
self.assertIn("session.run()", generated)
self.assertIn("finally:", generated)
self.assertIn("session.close(", generated)
def test_session_mode_generates_run_method(self):
workflow = {
"1": {
"class_type": "LoadImage",
"inputs": {"image": "example.png"},
}
}
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
output_file=output,
node_class_mappings={"LoadImage": LoadImage},
execution_mode="session",
)
generated = output.getvalue()
self.assertIn("def run(self", generated)
def test_session_mode_generates_close_method(self):
workflow = {
"1": {
"class_type": "LoadImage",
"inputs": {"image": "example.png"},
}
}
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
output_file=output,
node_class_mappings={"LoadImage": LoadImage},
execution_mode="session",
)
generated = output.getvalue()
self.assertIn("def close(self, unload_models: bool | None = None)", generated)
def test_session_mode_oneshot_generates_same_code(self):
workflow = {
"1": {
"class_type": "LoadImage",
"inputs": {"image": "example.png"},
}
}
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
output_file=output,
node_class_mappings={"LoadImage": LoadImage},
execution_mode="oneshot",
)
generated = output.getvalue()
self.assertNotIn("class WorkflowSession", generated)
self.assertIn("def main(unload_models: bool | None = None)", generated)
self.assertIn("bootstrap_comfyui_runtime()", generated)
self.assertIn("cleanup_comfyui_runtime(unload_models=unload_models)", generated)
def test_session_mode_oneshot_default(self):
workflow = {
"1": {
"class_type": "LoadImage",
"inputs": {"image": "example.png"},
}
}
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
output_file=output,
node_class_mappings={"LoadImage": LoadImage},
)
generated = output.getvalue()
self.assertNotIn("class WorkflowSession", generated)
self.assertIn("def main(unload_models: bool | None = None)", generated)
def test_session_mode_script_is_executable(self):
workflow = {
"1": {
"class_type": "LoadImage",
"inputs": {"image": "example.png"},
}
}
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
output_file=output,
node_class_mappings={"LoadImage": LoadImage},
execution_mode="session",
)
generated = output.getvalue()
# Should not raise — checks that generated code has valid syntax
compile(generated, "<generated>", "exec")
# main() should be callable
globals_dict = {"__name__": "generated_workflow_module"}
with patch("comfyui_to_python.runtime_session.WorkflowSessionRuntime"):
exec(generated, globals_dict)
self.assertIn("WorkflowSession", globals_dict)
self.assertIn("main", globals_dict)
self.assertTrue(callable(globals_dict["main"]))
def test_session_mode_generates_workflow_literal(self):
workflow = {
"1": {
"class_type": "LoadImage",
"inputs": {"image": "example.png"},
}
}
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
output_file=output,
node_class_mappings={"LoadImage": LoadImage},
execution_mode="session",
)
generated = output.getvalue()
self.assertIn("def build_workflow()", generated)
self.assertIn('return', generated)
self.assertIn('"class_type": "LoadImage"', generated)
def test_session_mode_generates_bootstrap_helper(self):
workflow = {
"1": {
"class_type": "LoadImage",
"inputs": {"image": "example.png"},
}
}
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
output_file=output,
node_class_mappings={"LoadImage": LoadImage},
execution_mode="session",
)
generated = output.getvalue()
self.assertIn("def bootstrap_comfyui_runtime()", generated)
def test_session_mode_generates_cleanup_helper(self):
workflow = {
"1": {
"class_type": "LoadImage",
"inputs": {"image": "example.png"},
}
}
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
output_file=output,
node_class_mappings={"LoadImage": LoadImage},
execution_mode="session",
)
generated = output.getvalue()
self.assertIn("def cleanup_comfyui_runtime(", generated)
if __name__ == "__main__":
unittest.main()
+164 -6
View File
@@ -1,4 +1,5 @@
import json
import sys
import tempfile
import unittest
from io import StringIO
@@ -115,6 +116,102 @@ class HiddenPromptSeedNode:
class UpscaleModelLoaderExportTest(unittest.TestCase):
def test_top_level_module_preserves_exporter_entrypoints(self):
from comfyui_to_python import main
self.assertTrue(callable(ComfyUItoPython))
self.assertTrue(callable(run))
self.assertTrue(callable(main))
def test_export_defers_comfyui_bootstrap_until_main(self):
workflow = {
"1": {
"class_type": "LoadImage",
"inputs": {
"image": "example.png",
},
}
}
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
output_file=output,
node_class_mappings={
"LoadImage": LoadImage,
},
)
generated = output.getvalue()
self.assertIn("def bootstrap_comfyui_runtime()", generated)
self.assertIn("def cleanup_comfyui_runtime(", generated)
self.assertIn("import comfy.options", generated)
self.assertIn("comfy.options.enable_args_parsing()", generated)
self.assertIn("import cuda_malloc", generated)
self.assertNotIn("\nbootstrap_comfyui_runtime()\n", generated)
self.assertIn(
"def main(unload_models: bool | None = None):\n"
" bootstrap_comfyui_runtime()\n"
" add_extra_model_paths()",
generated,
)
self.assertLess(
generated.index("def bootstrap_comfyui_runtime()"),
generated.index("def main(unload_models: bool | None = None):"),
)
main_section = generated[
generated.index("def main(unload_models: bool | None = None):") :
]
self.assertIn(
"def main(unload_models: bool | None = None):\n"
" bootstrap_comfyui_runtime()\n"
" add_extra_model_paths()",
main_section,
)
self.assertLess(
main_section.index("bootstrap_comfyui_runtime()"),
main_section.index("add_extra_model_paths()"),
)
self.assertLess(
main_section.index("add_extra_model_paths()"),
main_section.index("import torch"),
)
self.assertLess(generated.index("import cuda_malloc"), generated.index("import torch"))
self.assertIn(
" finally:\n cleanup_comfyui_runtime(unload_models=unload_models)",
main_section,
)
def test_generated_module_import_does_not_parse_cli_args(self):
workflow = {
"1": {
"class_type": "LoadImage",
"inputs": {
"image": "example.png",
},
}
}
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
output_file=output,
node_class_mappings={
"LoadImage": LoadImage,
},
)
generated = output.getvalue()
self.assertNotIn("\nimport torch\n", generated)
globals_dict = {"__name__": "generated_workflow_module"}
with patch.object(sys, "argv", ["generated_workflow.py", "--wrapper-flag"]):
exec(generated, globals_dict)
self.assertTrue(callable(globals_dict["main"]))
def test_upscale_workflow_uses_direct_upscale_model_loader_init(self):
workflow = {
"1": {
@@ -151,8 +248,8 @@ class UpscaleModelLoaderExportTest(unittest.TestCase):
generated = output.getvalue()
self.assertIn("from nodes import NODE_CLASS_MAPPINGS", generated)
self.assertIn(f"from {LoadImage.__module__} import (", generated)
self.assertIn(" from nodes import NODE_CLASS_MAPPINGS", generated)
self.assertIn(f" from {LoadImage.__module__} import (", generated)
self.assertIn("LoadImage,", generated)
self.assertIn("UpscaleModelLoader,", generated)
self.assertIn("ImageUpscaleWithModel,", generated)
@@ -196,10 +293,11 @@ class UpscaleModelLoaderExportTest(unittest.TestCase):
generated = output.getvalue()
self.assertIn('extra_pnginfo = {', generated)
self.assertIn('"workflow": json.loads(', generated)
self.assertIn("def build_extra_pnginfo()", generated)
self.assertIn('"workflow": {', generated)
self.assertIn('"version": 0.4', generated)
self.assertIn('"nodes": []', generated)
self.assertNotIn('"workflow": json.loads(', generated)
self.assertNotIn('"source": "workflow_api"', generated)
def test_export_without_frontend_workflow_leaves_png_workflow_metadata_absent(self):
@@ -223,7 +321,8 @@ class UpscaleModelLoaderExportTest(unittest.TestCase):
generated = output.getvalue()
self.assertIn("extra_pnginfo = None", generated)
self.assertIn("def build_extra_pnginfo()", generated)
self.assertIn("return None", generated)
self.assertNotIn('"workflow": json.loads(', generated)
def test_export_preserves_unique_variable_names_for_subgraph_node_ids(self):
@@ -296,9 +395,68 @@ class UpscaleModelLoaderExportTest(unittest.TestCase):
generated = output_file.read_text(encoding="utf-8")
self.assertIn("extra_pnginfo = None", generated)
self.assertIn("def build_extra_pnginfo()", generated)
self.assertIn("return None", generated)
self.assertNotIn('"workflow": json.loads(', generated)
def test_export_structures_generated_script_into_readable_sections(self):
workflow = {
"1": {
"class_type": "LoadImage",
"inputs": {
"image": "example.png",
},
},
"2": {
"class_type": "UpscaleModelLoader",
"inputs": {
"model_name": "RealESRGAN_x4plus.safetensors",
},
},
"3": {
"class_type": "ImageUpscaleWithModel",
"inputs": {
"upscale_model": ["2", 0],
"image": ["1", 0],
},
},
}
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
output_file=output,
node_class_mappings={
"LoadImage": LoadImage,
"UpscaleModelLoader": UpscaleModelLoader,
"ImageUpscaleWithModel": ImageUpscaleWithModel,
},
)
generated = output.getvalue()
self.assertIn("# Imports", generated)
self.assertIn("# Workflow data", generated)
self.assertIn("# Workflow execution", generated)
self.assertIn("# Entrypoint", generated)
self.assertIn("def build_workflow()", generated)
self.assertIn("def build_extra_pnginfo()", generated)
self.assertIn("def main(unload_models: bool | None = None)", generated)
self.assertIn("bootstrap_comfyui_runtime()", generated)
self.assertIn("cleanup_comfyui_runtime(unload_models=unload_models)", generated)
self.assertNotIn("def initialize_workflow()", generated)
self.assertNotIn("def run_once(", generated)
self.assertIn("with torch.inference_mode():", generated)
self.assertIn("finally:", generated)
self.assertIn("for q in range(1):", generated)
self.assertIn("workflow = build_workflow()", generated)
self.assertIn("extra_pnginfo = build_extra_pnginfo()", generated)
self.assertNotIn('workflow = json.loads("', generated)
self.assertLess(
generated.index("def build_workflow()"),
generated.index("def main(unload_models: bool | None = None)"),
)
def test_hidden_metadata_kwargs_follow_function_signature(self):
workflow = {
"1": {
+75
View File
@@ -0,0 +1,75 @@
import unittest
from unittest.mock import MagicMock, patch
from comfyui_to_python.runtime_session import WorkflowSession
class TestWorkflowSessionInit(unittest.TestCase):
"""Tests for WorkflowSession initialization."""
def test_init_creates_runtime(self):
session = WorkflowSession()
self.assertIsNotNone(session._runtime)
def test_init_passes_cleanup_policy(self):
session = WorkflowSession(cleanup_policy="per_run")
self.assertEqual(session._runtime._cleanup_policy, "per_run")
def test_init_passes_reset_every_n_runs(self):
session = WorkflowSession(reset_every_n_runs=5)
self.assertEqual(session._runtime._reset_every_n_runs, 5)
class TestWorkflowSessionDelegation(unittest.TestCase):
"""Tests for WorkflowSession public API delegation to internal runtime."""
def _make_session(self):
return WorkflowSession()
def test_run_delegates_to_runtime(self):
session = self._make_session()
workflow_data = {
"1": {
"class_type": "StubNode",
"inputs": {"value": "test"},
}
}
node_mappings = {"StubNode": MagicMock()}
session._runtime.node_instances = {"StubNode": MagicMock()}
session._runtime._node_classes = {"StubNode": MagicMock()}
with patch.object(
session._runtime, "run", return_value={"1": ["result"]}
) as mock_run:
session.run(workflow_data=workflow_data, node_class_mappings=node_mappings)
mock_run.assert_called_once_with(
workflow_data=workflow_data,
node_class_mappings=node_mappings,
extra_pnginfo=None,
)
def test_clear_runtime_cache_delegates_to_runtime(self):
session = self._make_session()
with patch.object(
session._runtime, "clear_runtime_cache"
) as mock_clear:
session.clear_runtime_cache()
mock_clear.assert_called_once()
def test_close_delegates_to_runtime(self):
session = self._make_session()
with patch.object(
session._runtime, "close"
) as mock_close:
session.close(unload_models=True)
mock_close.assert_called_once_with(unload_models=True)
def test_run_raises_after_close(self):
session = self._make_session()
session.close()
with self.assertRaises(RuntimeError):
session.run()
if __name__ == "__main__":
unittest.main()
Generated
+2 -2
View File
@@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 2
requires-python = ">=3.12"
[[package]]
@@ -57,7 +57,7 @@ wheels = [
[[package]]
name = "comfyui-to-python-extension"
version = "1.3.1"
version = "2.1.0"
source = { virtual = "." }
dependencies = [
{ name = "black" },