Compare commits

...
Author SHA1 Message Date
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
Peyton DeNiro ced63d1a00 Merge pull request #149 from pydn/codex/runtime-e2e-validation
Add runtime E2E validation harness and fixtures
2026-03-28 18:08:24 -05:00
Peyton DeNiro 4370a78728 Fix exporter metadata kwarg and seed sync handling 2026-03-28 17:27:03 -05:00
Peyton DeNiro 494f7cf79a Remove CLI frontend workflow metadata path 2026-03-28 17:07:59 -05:00
Peyton DeNiro 342e1b8749 Fix workflow metadata and subgraph export names 2026-03-28 16:58:47 -05:00
Peyton DeNiro 6a9593ee70 Trigger Codex review 2026-03-28 16:40:34 -05:00
Peyton DeNiro d87611c035 Fix runtime validation entrypoint and export test 2026-03-28 16:33:11 -05:00
Peyton DeNiro 893c76d6cc Remove PR_DESCRIPTION.md 2026-03-28 16:33:00 -05:00
Peyton DeNiro e71f7e207b Clarify runtime test model prerequisites 2026-03-28 16:27:43 -05:00
Peyton DeNiro 025e58a58e Document test commands and remove hard-coded runtime path 2026-03-28 16:22:12 -05:00
Peyton DeNiro 6cbc383415 remove .md 2026-03-28 16:12:49 -05:00
Peyton DeNiro d57b2167ca Remove tracked runtime spec docs from branch 2026-03-28 16:12:05 -05:00
Peyton DeNiro b0109aa90f Clarify runtime validation harness behavior 2026-03-28 16:09:13 -05:00
Peyton DeNiro 0598433783 Remove upstream smoke validation tier 2026-03-28 16:02:40 -05:00
Peyton DeNiro a97311dabe Add CLI support for frontend workflow metadata 2026-03-28 13:58:56 -05:00
Peyton DeNiro 68ae24ceac Use frontend workflow from graphToPrompt 2026-03-28 13:49:35 -05:00
Peyton DeNiro 1fa2db89e1 Preserve reimportable ComfyUI workflow metadata 2026-03-28 13:42:44 -05:00
Peyton DeNiro 3ffd985cf7 Fix runtime validation exporter edge cases 2026-03-28 13:33:08 -05:00
Peyton DeNiro 47e5bcc68a Add runtime E2E validation harness 2026-03-28 13:24:13 -05:00
Peyton DeNiro cb5917eeed Merge pull request #148 from pydn/codex/issue-143-upscale-model-loader
Fix upscale model loader export bootstrap
2026-03-28 13:10:12 -05:00
Peyton DeNiro 380e701132 Narrow runtime compatibility fix 2026-03-28 13:08:34 -05:00
Peyton DeNiro 557adae33e Fix upscale export runtime compatibility 2026-03-28 13:08:34 -05:00
Peyton DeNiro 72a87c31b3 Added clarity on extension placement requirements. 2026-03-28 13:08:34 -05:00
Peyton DeNiro e5dc94efe3 Update README intro 2026-03-28 13:08:34 -05:00
Peyton DeNiro 57eddcb76d Updated readme images. 2026-03-28 13:08:34 -05:00
Peyton DeNiro 2738113346 Refresh README for current setup and usage 2026-03-28 13:08:34 -05:00
Peyton DeNiro 77b98ad5e1 Fix upscale model loader export bootstrap 2026-03-28 13:08:34 -05:00
Peyton DeNiro 32d42bf472 Merge pull request #133 from xiaoxidashen/main
Fix comfyui_to_python_utils import error, Fix conflict between comfy/utils.py and utils,Fix not await for load extra nodes
2025-09-26 18:53:00 -05:00
Peyton DeNiro 281b8408c9 Merge pull request #132 from nuaimat/main
Fix "Save as Script" option not available in ComfyUI v0.3.50
2025-09-26 18:23:32 -05:00
chengxi b215c83b76 fix not await for load extra nodes 2025-09-07 14:33:48 +08:00
chengxi 76bc1c343e Fix conflict between comfy/utils.py and utils 2025-09-07 14:33:27 +08:00
chengxi 42bc6926ae fix not found comfyui_to_python_utils 2025-09-07 14:32:28 +08:00
Mo Nuaimat 351a68831d Fix "Save as Script" option not available in ComfyUI v0.3.50 2025-08-27 21:36:29 -07:00
pydn 1c0585fabe Update version number 2025-07-27 19:09:46 -05:00
Peyton DeNiro 60d55d904b Merge pull request #125 from pydn/licence-update
Licence update
2025-07-27 19:06:38 -05:00
pydn 3a5ef069d9 Update .toml file to have MIT license 2025-07-27 18:56:13 -05:00
Peyton DeNiro 77a493f0eb Merge pull request #113 from ComfyNodePRs/update-publish-yaml
Update Github Action for Publishing to Comfy Registry
2025-07-27 18:32:41 -05:00
snomiao 2f6a649801 chore(publish): update GitHub Actions workflow for node publishing
- Added permissions for issue writing
- Updated action version to v1 for publish-node-action
- Added condition to run job only for 'pydn' repository owner
2025-01-25 07:27:37 +00:00
snomiao 4d0de66b0b chore(licence-update): Update PyProject Toml - License 2025-01-21 01:31:31 +00:00
43 changed files with 3874 additions and 1024 deletions
+6 -2
View File
@@ -7,15 +7,19 @@ on:
paths:
- "pyproject.toml"
permissions:
issues: write
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'pydn' }}
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@main
uses: Comfy-Org/publish-node-action@v1
with:
## Add your own personal access token to your Github Repository secrets and reference it here.
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
+100 -180
View File
@@ -1,212 +1,132 @@
## ComfyUI-to-Python-Extension
# ComfyUI-to-Python-Extension
![banner](images/comfyui_to_python_banner.png)
The `ComfyUI-to-Python-Extension` is a powerful tool that translates [ComfyUI](https://github.com/comfyanonymous/ComfyUI) workflows into executable Python code. Designed to bridge the gap between ComfyUI's visual interface and Python's programming environment, this script facilitates the seamless transition from design to code execution. Whether you're a data scientist, a software developer, or an AI enthusiast, this tool streamlines the process of implementing ComfyUI workflows in Python.
Build a workflow in ComfyUI, then walk away with runnable Python.
**Convert this:**
`ComfyUI-to-Python-Extension` turns visual workflows into executable scripts so you can move from node graphs to automation, experiments, and repeatable generation without rebuilding everything by hand.
![SDXL UI Example](images/SDXL-UI-Example.jpg)
This project supports:
- exporting from the ComfyUI UI with `Save As Script`
- converting saved API-format workflows with the CLI
## Install
**To this:**
Choose the setup that matches how you want to use the project.
### 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:
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
```
import random
import torch
import sys
sys.path.append("../")
from nodes import (
VAEDecode,
KSamplerAdvanced,
EmptyLatentImage,
SaveImage,
CheckpointLoaderSimple,
CLIPTextEncode,
)
2. 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`
def main():
with torch.inference_mode():
checkpointloadersimple = CheckpointLoaderSimple()
checkpointloadersimple_4 = checkpointloadersimple.load_checkpoint(
ckpt_name="sd_xl_base_1.0.safetensors"
)
emptylatentimage = EmptyLatentImage()
emptylatentimage_5 = emptylatentimage.generate(
width=1024, height=1024, batch_size=1
)
cliptextencode = CLIPTextEncode()
cliptextencode_6 = cliptextencode.encode(
text="evening sunset scenery blue sky nature, glass bottle with a galaxy in it",
clip=checkpointloadersimple_4[1],
)
cliptextencode_7 = cliptextencode.encode(
text="text, watermark", clip=checkpointloadersimple_4[1]
)
checkpointloadersimple_12 = checkpointloadersimple.load_checkpoint(
ckpt_name="sd_xl_refiner_1.0.safetensors"
)
cliptextencode_15 = cliptextencode.encode(
text="evening sunset scenery blue sky nature, glass bottle with a galaxy in it",
clip=checkpointloadersimple_12[1],
)
cliptextencode_16 = cliptextencode.encode(
text="text, watermark", clip=checkpointloadersimple_12[1]
)
ksampleradvanced = KSamplerAdvanced()
vaedecode = VAEDecode()
saveimage = SaveImage()
for q in range(10):
ksampleradvanced_10 = ksampleradvanced.sample(
add_noise="enable",
noise_seed=random.randint(1, 2**64),
steps=25,
cfg=8,
sampler_name="euler",
scheduler="normal",
start_at_step=0,
end_at_step=20,
return_with_leftover_noise="enable",
model=checkpointloadersimple_4[0],
positive=cliptextencode_6[0],
negative=cliptextencode_7[0],
latent_image=emptylatentimage_5[0],
)
ksampleradvanced_11 = ksampleradvanced.sample(
add_noise="disable",
noise_seed=random.randint(1, 2**64),
steps=25,
cfg=8,
sampler_name="euler",
scheduler="normal",
start_at_step=20,
end_at_step=10000,
return_with_leftover_noise="disable",
model=checkpointloadersimple_12[0],
positive=cliptextencode_15[0],
negative=cliptextencode_16[0],
latent_image=ksampleradvanced_10[0],
)
vaedecode_17 = vaedecode.decode(
samples=ksampleradvanced_11[0], vae=checkpointloadersimple_12[2]
)
saveimage_19 = saveimage.save_images(
filename_prefix="ComfyUI", images=vaedecode_17[0]
)
if __name__ == "__main__":
main()
Example symlink setup:
```bash
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
```
## Potential Use Cases
- Streamlining the process for creating a lean app or pipeline deployment that uses a ComfyUI workflow
- Creating programmatic experiments for various prompt/parameter values
- Creating large queues for image generation (For example, you could adjust the script to generate 1000 images without clicking ctrl+enter 1000 times)
- Easily expanding or iterating on your architecture in Python once a foundational workflow is in place in the GUI
## V1.3.0 Release Notes
- Generate .py file directly from the ComfyUI Web App
After installation, restart ComfyUI.
### CLI exporter / generated scripts
You can keep the repo anywhere for CLI usage and generated-script execution.
```bash
git clone https://github.com/pydn/ComfyUI-to-Python-Extension.git
cd ComfyUI-to-Python-Extension
uv sync
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.
`COMFYUI_PATH` is checked first. If it is not set, the exporter falls back to searching parent directories for a folder named `ComfyUI`.
## Web UI Export
In current ComfyUI builds, `Save As Script` is typically available under:
`File -> Save As Script`
The command downloads a generated `.py` file.
![Save As Script](images/save_as_script.png)
## V1.2.1 Release Notes
- Dynamically change `comfyui_to_python.py` parameters with CLI arguments
- Hotfix to handle nodes that accept kwargs.
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
## V1.2.0 Release Notes
- Updates to adhere to latest changes from `ComfyUI`
## CLI Export
## V1.0.0 Release Notes
- **Use all the custom nodes!**
- Custom nodes are now supported. If you run into any issues with code execution, first ensure that the each node works as expected in the GUI. If it works in the GUI, but not in the generated script, please submit an issue.
1. In ComfyUI, enable dev mode options if needed.
2. Save the workflow in API format: `File -> Export (API)`.
3. Run the exporter:
```bash
uv run python -m comfyui_to_python
```
## Installation
Options:
```bash
uv run python -m comfyui_to_python \
--input_file workflow_api.json \
--output_file workflow_api.py \
--queue_size 10
```
1. Navigate to your `ComfyUI/custom_nodes` directory
The legacy wrapper still works if you prefer it:
2. Clone this repo
```bash
git clone https://github.com/pydn/ComfyUI-to-Python-Extension.git
```
```bash
uv run python comfyui_to_python.py
```
After cloning the repo, your `ComfyUI` directory should look like this:
```
/comfy
/comfy_extras
/custom_nodes
--/ComfyUI-to-Python-Extension
/input
/models
/output
/script_examples
/web
.gitignore
LICENSE
README.md
comfyui_screenshot.png
cuda_mollac.py
execution.py
extra_model_paths.yaml.example
folder_paths.py
latent_preview.py
main.py
nodes.py
requirements.txt
server.py
```
Flags:
- `--input_file`: input workflow JSON, default `workflow_api.json`
- `--output_file`: output Python file, default `workflow_api.py`
- `--queue_size`: default execution count in the generated script, default `10`
## Web App Use
1. Launch ComfyUI
![Dev Mode Options](images/dev_mode_options.PNG)
2. Load your favorite workflow and click `Save As Script`
## Generated Scripts
![Save As Script](images/save_as_script.png)
Generated scripts depend on a working ComfyUI runtime.
3. Type your desired file name into the pop up screen.
If the repo is not inside ComfyUI, set:
4. Move .py file from your downloads folder to your `ComfyUI` directory.
```bash
export COMFYUI_PATH=/path/to/ComfyUI
```
5. Now you can execute the newly created .py file to generate images without launching a server.
The generated script is a workflow export. It does not automatically turn workflow inputs into command-line arguments.
## CLI Usage
1. Navigate to the `ComfyUI-to-Python-Extension` folder and install requirements
```bash
pip install -r requirements.txt
```
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.
2. Launch ComfyUI, click the gear icon over `Queue Prompt`, then check `Enable Dev mode Options`. **THE SCRIPT WILL NOT WORK IF YOU DO NOT ENABLE THIS OPTION!**
## Troubleshooting
![Enable Dev Mode Options](images/dev_mode_options.jpg)
3. Load up your favorite workflows, then click the newly enabled `Save (API Format)` button under Queue Prompt
4. Move the downloaded .json workflow file to your `ComfyUI/ComfyUI-to-Python-Extension` folder
5. If needed, add arguments when executing `comfyui_to_python.py` to update the default `input_file` and `output_file` to match your .json workflow file and desired .py file name. By default, the script will look for a file called `workflow_api.json`. You can also update the `queue_size` variable to your desired number of images that you want to generate in a single script execution. By default, the scripts will generate 10 images. Run `python comfyui_to_python.py --help` for more details.
6a. Run the script with default arguments:
```bash
python comfyui_to_python.py
```
6b. Run the script with optional arguments:
```bash
python comfyui_to_python.py --input_file "workflow_api (2).json" --output_file my_workflow.py --queue_size 100
```
7. After running `comfyui_to_python.py`, a new .py file will be created in the current working directory. If you made no changes, look for `workflow_api.py`.
8. Now you can execute the newly created .py file to generate images without launching a server.
- `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
- ComfyUI cannot be found:
set `COMFYUI_PATH`
- models or paths are missing at runtime:
verify the target ComfyUI install and its `extra_model_paths.yaml`
+6 -1
View File
@@ -45,9 +45,14 @@ async def save_as_script(request):
data = await request.json()
name = data["name"]
workflow = data["workflow"]
frontend_workflow = data.get("frontend_workflow")
sio = StringIO()
ComfyUItoPython(workflow=workflow, output_file=sio)
ComfyUItoPython(
workflow=workflow,
frontend_workflow=frontend_workflow,
output_file=sio,
)
sio.seek(0)
data = sio.read()
+1 -637
View File
@@ -1,641 +1,5 @@
import copy
import glob
import inspect
import json
import os
import random
import sys
import re
from typing import Dict, List, Any, Callable, Tuple, TextIO
from argparse import ArgumentParser
import black
from comfyui_to_python_utils import (
import_custom_nodes,
find_path,
add_comfyui_directory_to_sys_path,
add_extra_model_paths,
get_value_at_index,
)
add_comfyui_directory_to_sys_path()
from nodes import NODE_CLASS_MAPPINGS
DEFAULT_INPUT_FILE = "workflow_api.json"
DEFAULT_OUTPUT_FILE = "workflow_api.py"
DEFAULT_QUEUE_SIZE = 10
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
def generate_workflow(
self,
load_order: List,
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 = (
set(["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():
import_statements.add(import_statement)
if class_type not in self.base_node_class_mappings.keys():
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
if (
"hidden" in input_types.keys()
and "unique_id" in input_types["hidden"].keys()
):
inputs["unique_id"] = random.randint(1, 2**64)
elif 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)}_{idx}"
inputs = self.update_inputs(inputs, executed_variables)
if is_special_function:
special_functions_code.append(
self.create_function_call_code(
initialized_objects[class_type],
class_def.FUNCTION,
executed_variables[idx],
is_special_function,
**inputs,
)
)
else:
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, 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 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.
"""
if key == "noise_seed" or key == "seed":
return f"{key}=random.randint(1, 2**64)"
elif isinstance(value, str):
value = value.replace("\n", "\\n").replace('"', "'")
return f'{key}="{value}"'
elif isinstance(value, dict) and "variable_name" in value:
return f'{key}={value["variable_name"]}'
return f"{key}={value}"
def assemble_python_code(
self,
import_statements: set,
speical_functions_code: List[str],
code: List[str],
queue_size: int,
custom_nodes=False,
) -> str:
"""Generates the final code string.
Args:
import_statements (set): A set of unique import statements.
speical_functions_code (List[str]): A list of special functions code strings.
code (List[str]): A list of code strings.
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.
"""
# Get the source code of the utils functions as a string
func_strings = []
for func in [
get_value_at_index,
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 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"]
)
# 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 = [
f"from nodes import {', '.join([class_name for class_name in import_statements])}"
]
# 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"
+ "\n\t\t".join(speical_functions_code)
+ f"\n\n\t\tfor q in range({queue_size}):\n\t\t"
+ "\n\t\t".join(code)
)
# 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, str, str]:
"""Generates and returns necessary information about class type.
Args:
class_type (str): Class type.
Returns:
Tuple[str, str, str]: Updated class type, import statement string, class initialization code.
"""
import_statement = class_type
variable_name = self.clean_variable_name(class_type)
if class_type in self.base_node_class_mappings.keys():
class_code = f"{variable_name} = {class_type.strip()}()"
else:
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 = "",
input_file: str = "",
output_file: str | TextIO = "",
queue_size: int = 1,
node_class_mappings: Dict = NODE_CLASS_MAPPINGS,
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): Mappings of node classes. Defaults to NODE_CLASS_MAPPINGS.
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.input_file = input_file
self.output_file = output_file
self.queue_size = queue_size
self.node_class_mappings = 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: Import all custom nodes if we need to
if self.needs_init_custom_nodes:
import_custom_nodes()
else:
# If they're already imported, we don't know which nodes are custom nodes, so we need to import all of them
self.base_node_class_mappings = {}
# Step 2: 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)
# 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, 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()
+55
View File
@@ -0,0 +1,55 @@
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
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,
):
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,
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",
"run",
"main",
"get_node_class_mappings",
"import_custom_nodes",
]
+5
View File
@@ -0,0 +1,5 @@
from .cli import main
if __name__ == "__main__":
main()
+75
View File
@@ -0,0 +1,75 @@
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,
):
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.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,
)
generated_code = WorkflowRenderer().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,17 @@
from ..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,
)
__all__ = [
"add_comfyui_directory_to_sys_path",
"add_extra_model_paths",
"bootstrap_comfyui_runtime",
"find_path",
"get_comfyui_path",
"get_value_at_index",
]
+12
View File
@@ -0,0 +1,12 @@
from dataclasses import dataclass
@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
+261
View File
@@ -0,0 +1,261 @@
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,
) -> 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,
)
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
+139
View File
@@ -0,0 +1,139 @@
import inspect
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,
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 render(self, plan: GenerationPlan) -> 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}
)
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,
]:
func_strings.append(f"\n{inspect.getsource(func)}")
static_imports = [
"# Imports",
"import json",
"import os",
"import random",
"import sys",
"from typing import Sequence, Mapping, Any, Union",
] + func_strings
if plan.custom_nodes:
static_imports.append(f"\n{inspect.getsource(import_custom_nodes)}\n")
custom_nodes_call = "import_custom_nodes()"
else:
custom_nodes_call = None
imports_code = []
for module_name in sorted(plan.import_statements.keys()):
class_names = ", ".join(sorted(plan.import_statements[module_name]))
imports_code.append(f"from {module_name} import {class_names}")
workflow_section = [
"# 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()",
]
execution_section = [
"# Workflow execution",
"def main():",
" bootstrap_comfyui_runtime()",
" add_extra_model_paths()",
]
if custom_nodes_call:
execution_section.append(f" {custom_nodes_call}")
if imports_code:
execution_section.extend(["", " # Node imports"])
execution_section.extend(f" {line}" for line in imports_code)
execution_section.extend(
[
"",
" import torch",
"",
" with torch.inference_mode():",
]
)
execution_section.extend(
self.build_function_body(
plan.special_functions_code, "pass", indentation=" "
).splitlines()
)
execution_section.append(f" for q in range({plan.queue_size}):")
execution_section.extend(
self.build_function_body(
plan.loop_code, "pass", indentation=" "
).splitlines()
)
entrypoint_section = [
"# Entrypoint",
'if __name__ == "__main__":',
" main()",
]
final_code = "\n".join(
static_imports
+ [""]
+ workflow_section
+ [""]
+ execution_section
+ [""]
+ entrypoint_section
)
return black.format_str(final_code, mode=black.Mode())
@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)
+133
View File
@@ -0,0 +1,133 @@
import os
import sys
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 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]
+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 -105
View File
@@ -1,106 +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 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.
"""
import asyncio
import execution
from nodes import init_extra_nodes
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
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 = find_path("ComfyUI")
if comfyui_path is not None and os.path.isdir(comfyui_path):
sys.path.append(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",
]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 129 KiB

+67 -97
View File
@@ -1,111 +1,81 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { $el } from "../../scripts/ui.js";
import { app } from "../../scripts/app.js";
app.registerExtension({
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",
commands: [
{
id: "triggerSaveAsScript",
label: "Save As Script",
function: () => { extension.savePythonScript(); }
}
],
menuCommands: [
{
path: ["File"],
commands: ["triggerSaveAsScript"]
}
],
init() {
$el("style", {
parent: document.head,
});
},
async setup() {
function savePythonScript() {
var filename = prompt("Save script as:");
if(filename === undefined || filename === null || filename === "") {
return
}
app.graphToPrompt().then(async (p) => {
const json = JSON.stringify({name: filename + ".json", workflow: JSON.stringify(p.output, null, 2)}, null, 2); // convert the data to a JSON string
var response = await api.fetchApi(`/saveasscript`, { method: "POST", body: json });
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,
style: {display: "none"},
parent: document.body,
});
a.click();
setTimeout(function () {
a.remove();
window.URL.revokeObjectURL(url);
}, 0);
}
});
savePythonScript() {
var filename = prompt("Save script as:");
if(filename === undefined || filename === null || filename === "") {
return
}
app.graphToPrompt().then(async (p) => {
const frontendWorkflow = p.workflow ?? app.graph.serialize();
const json = JSON.stringify({
name: filename + ".json",
workflow: JSON.stringify(p.output, null, 2),
frontend_workflow: JSON.stringify(frontendWorkflow, null, 2),
}, null, 2); // convert the data to a JSON string
var response = await api.fetchApi(`/saveasscript`, { method: "POST", body: json });
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 menu = document.querySelector(".comfy-menu");
const separator = document.createElement("hr");
separator.style.margin = "20px 0";
separator.style.width = "100%";
menu.append(separator);
const saveButton = document.createElement("button");
saveButton.textContent = "Save as Script";
saveButton.onclick = () => savePythonScript();
menu.append(saveButton);
// Also load to new style menu
const dropdownMenu = document.querySelectorAll(".p-menubar-submenu ")[0];
// Get submenu items
const listItems = dropdownMenu.querySelectorAll("li");
let newSetsize = listItems.length;
const separatorMenu = document.createElement("li");
separatorMenu.setAttribute("id", "pv_id_8_0_" + (newSetsize - 1).toString());
separatorMenu.setAttribute("class", "p-menubar-separator");
separatorMenu.setAttribute("role", "separator");
separatorMenu.setAttribute("data-pc-section", "separator");
dropdownMenu.append(separatorMenu);
// Adjust list items within to increase setsize
listItems.forEach((item) => {
// First check if it's a separator
if(item.getAttribute("data-pc-section") !== "separator") {
item.setAttribute("aria-setsize", newSetsize);
const a = $el("a", {
href: url,
download: filename,
style: {display: "none"},
parent: document.body,
});
a.click();
setTimeout(function () {
a.remove();
window.URL.revokeObjectURL(url);
}, 0);
}
});
console.log(newSetsize);
// Here's the format of list items
const saveButtonText = document.createElement("li");
saveButtonText.setAttribute("id", "pv_id_8_0_" + newSetsize.toString());
saveButtonText.setAttribute("class", "p-menubar-item relative");
saveButtonText.setAttribute("role", "menuitem");
saveButtonText.setAttribute("aria-label", "Save as Script");
saveButtonText.setAttribute("aria-level", "2");
saveButtonText.setAttribute("aria-setsize", newSetsize.toString());
saveButtonText.setAttribute("aria-posinset", newSetsize.toString());
saveButtonText.setAttribute("data-pc-section", "item");
saveButtonText.setAttribute("data-p-active", "false");
saveButtonText.setAttribute("data-p-focused", "false");
saveButtonText.innerHTML = `
<div class="p-menubar-item-content" data-pc-section="itemcontent">
<a class="p-menubar-item-link" tabindex="-1" aria-hidden="true" data-pc-section="itemlink" target="_blank">
<span class="p-menubar-item-icon pi pi-book"></span>
<span class="p-menubar-item-label">Save as Script</span>
</a>
</div>
`
saveButtonText.onclick = () => savePythonScript();
dropdownMenu.append(saveButtonText);
},
async setup() {
console.log("SaveAsScript loaded");
}
});
};
app.registerExtension(extension);
+2 -2
View File
@@ -1,8 +1,8 @@
[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.0"
license = "LICENSE"
version = "2.0.0"
license = { text = "MIT License" }
dependencies = ["black"]
[project.urls]
+126
View File
@@ -0,0 +1,126 @@
# Tests
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.
- Use `uv` for all project commands.
- Install the repo environment first:
```bash
uv sync
```
- Runtime validation also needs a working ComfyUI checkout.
Set `COMFYUI_PATH` to that checkout, or run the tests from a location where a parent directory contains `ComfyUI`.
- Runtime-tier tests do not download missing models automatically.
If a required model is missing, the fixture fails with `model provisioning failure`.
Use `--print-download-plan` to print the manual download commands.
### Runtime Model Requirements
The current runtime-capable fixtures require these model files in the target ComfyUI checkout:
- `text-to-image`: `models/checkpoints/v1-5-pruned-emaonly-fp16.safetensors`
- `upscale-model-loader`: `models/upscale_models/RealESRGAN_x4plus.safetensors`
## Unit Tests
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
uv run python -m unittest discover -s tests
```
## Runtime Tests
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:
```bash
uv run python tests/runtime/run_runtime_validation.py --tier fast --fixture all
```
Run one fixture:
```bash
uv run python tests/runtime/run_runtime_validation.py --tier fast --fixture unsafe-kwargs
```
### 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:
```bash
uv run python tests/runtime/run_runtime_validation.py --tier runtime --fixture all
```
Run one runtime-capable fixture:
```bash
uv run python tests/runtime/run_runtime_validation.py --tier runtime --fixture text-to-image
```
Print download commands for missing models instead of failing immediately:
```bash
uv run python tests/runtime/run_runtime_validation.py --tier runtime --fixture all --print-download-plan
```
## Runtime Fixture Names
Current committed fixtures:
- `upscale-model-loader`
- `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
- `No module named ...`:
run `uv sync` in this repo, and for runtime-tier failures also make sure the target ComfyUI checkout has its own dependencies installed.
- `Could not find a valid ComfyUI checkout for runtime validation.`:
set `COMFYUI_PATH` to your ComfyUI checkout, or run the tests from a directory layout where a parent folder contains `ComfyUI`.
- `Missing models for ...`:
runtime-tier tests do not fetch models for you. Rerun with `--print-download-plan` to print the expected `curl` commands and target model directories, then install the files manually.
- `No selected fixtures are runtime-capable for this tier.`:
choose a runtime-capable fixture such as `text-to-image` or `upscale-model-loader`.
- Generated script execution fails because files or models are missing:
confirm the required models exist under the target ComfyUI checkout and that any staged inputs can be copied into its `input/` directory.
+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
]
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"173:132": {
"class_type": "RegexReplace",
"inputs": {
"text": "abc123",
"pattern": "\\d+",
"replace": "X"
}
},
"174:133": {
"class_type": "PassthroughText",
"inputs": {
"text": [
"173:132",
0
]
}
}
}
+86
View File
@@ -0,0 +1,86 @@
{
"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
]
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"1": {
"class_type": "FlexibleNode",
"inputs": {
"safe_name": "kept-readable",
"class": "reserved-word",
"\u2795 Add Lora": "symbol-heavy-key",
"spaces and-hyphens": "still-unsafe"
}
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"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
]
}
}
}
@@ -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"
}
}
}
+232
View File
@@ -0,0 +1,232 @@
# 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"
# 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():
bootstrap_comfyui_runtime()
add_extra_model_paths()
# Node imports
from nodes import (
CLIPTextEncode,
CheckpointLoaderSimple,
EmptyLatentImage,
KSampler,
NODE_CLASS_MAPPINGS,
SaveImage,
VAEDecode,
)
import torch
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,
)
# Entrypoint
if __name__ == "__main__":
main()
@@ -0,0 +1,197 @@
# 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 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():
bootstrap_comfyui_runtime()
add_extra_model_paths()
import_custom_nodes()
# Node imports
from nodes import LoadImage, NODE_CLASS_MAPPINGS, SaveImage
import torch
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,
)
# Entrypoint
if __name__ == "__main__":
main()
+741
View File
@@ -0,0 +1,741 @@
import argparse
import ast
import json
import os
import shutil
import struct
import subprocess
import sys
import tempfile
import zlib
from dataclasses import dataclass
from io import StringIO
from pathlib import Path
from typing import Callable
ROOT = Path(__file__).resolve().parents[2]
FIXTURE_DIR = ROOT / "tests" / "fixtures" / "runtime"
GENERATED_DIR = ROOT / "tests" / "runtime" / "generated"
COMFYUI_OUTPUT_DIRNAME = "output"
COMFYUI_INPUT_DIRNAME = "input"
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from comfyui_to_python_utils import get_comfyui_path
class ValidationFailure(RuntimeError):
def __init__(self, classification: str, message: str):
super().__init__(message)
self.classification = classification
self.message = message
@dataclass(frozen=True)
class FixtureConfig:
name: str
path: Path
mapping_factory: Callable[[], dict] | None = None
fast_mapping_factory: Callable[[], dict] | None = None
runtime_capable: bool = False
filename_prefix: str | None = None
expected_min_dimensions: tuple[int, int] | None = None
metadata_markers: tuple[str, ...] = ()
model_requirements: tuple["ModelRequirement", ...] = ()
staged_inputs: tuple["StagedInput", ...] = ()
@dataclass(frozen=True)
class ModelRequirement:
filename: str
relative_dir: str
source_url: str
@dataclass(frozen=True)
class StagedInput:
source_path: Path
destination_name: str
class FlexibleNode:
CATEGORY = "utils"
FUNCTION = "run"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"safe_name": ("STRING",),
}
}
def run(self, **kwargs):
return (kwargs,)
class RegexReplace:
CATEGORY = "utils"
FUNCTION = "replace"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING",),
"pattern": ("STRING",),
"replace": ("STRING",),
}
}
def replace(self, text, pattern, replace):
return (text,)
class PassthroughText:
CATEGORY = "utils"
FUNCTION = "run"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING",),
}
}
def run(self, text):
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"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model_name": ("STRING",),
}
}
def load_model(self, model_name):
return (model_name,)
class LoadImage:
CATEGORY = "image"
FUNCTION = "load_image"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("STRING",),
}
}
def load_image(self, image):
return (image,)
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 SaveImageNode:
CATEGORY = "image"
FUNCTION = "save_images"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"filename_prefix": ("STRING",),
}
}
def save_images(self, images, filename_prefix):
return ()
FIXTURES = {
# Runtime-capable fixtures are exported inside a real ComfyUI checkout and
# then executed end-to-end. Fast fixtures use local stub mappings so the
# exporter can be validated without importing the full runtime.
"upscale-model-loader": FixtureConfig(
name="upscale-model-loader",
path=FIXTURE_DIR / "upscale-model-loader.json",
fast_mapping_factory=lambda: {
"LoadImage": LoadImage,
"UpscaleModelLoader": UpscaleModelLoader,
"ImageUpscaleWithModel": ImageUpscaleWithModel,
"SaveImage": SaveImageNode,
},
runtime_capable=True,
filename_prefix="E2E_upscale_model_loader",
expected_min_dimensions=(1000, 1000),
metadata_markers=("UpscaleModelLoader", "E2E_upscale_model_loader"),
model_requirements=(
ModelRequirement(
filename="RealESRGAN_x4plus.safetensors",
relative_dir="models/upscale_models",
source_url="https://huggingface.co/Comfy-Org/Real-ESRGAN_repackaged/resolve/main/RealESRGAN_x4plus.safetensors",
),
),
staged_inputs=(
StagedInput(
source_path=ROOT / "images" / "save_as_script.png",
destination_name="e2e_upscale_input.png",
),
),
),
"text-to-image": FixtureConfig(
name="text-to-image",
path=FIXTURE_DIR / "text-to-image.json",
runtime_capable=True,
filename_prefix="E2E_text_to_image",
expected_min_dimensions=(512, 512),
metadata_markers=("a small cottage in a meadow", "CheckpointLoaderSimple"),
model_requirements=(
ModelRequirement(
filename="v1-5-pruned-emaonly-fp16.safetensors",
relative_dir="models/checkpoints",
source_url="https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors",
),
),
),
"unsafe-kwargs": FixtureConfig(
name="unsafe-kwargs",
path=FIXTURE_DIR / "unsafe-kwargs.json",
mapping_factory=lambda: {"FlexibleNode": FlexibleNode},
),
"subgraph-identifiers": FixtureConfig(
name="subgraph-identifiers",
path=FIXTURE_DIR / "subgraph-identifiers.json",
mapping_factory=lambda: {
"RegexReplace": RegexReplace,
"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,
},
),
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Run fast or runtime validation for committed fixtures."
)
parser.add_argument(
"--tier",
choices=("fast", "runtime"),
help="Validation tier to run.",
)
parser.add_argument(
"--fixture",
default="all",
help="Fixture name or 'all'.",
)
parser.add_argument(
"--execute",
action="store_true",
help="Execute generated Python for runtime-capable fixtures.",
)
parser.add_argument(
"--print-download-plan",
action="store_true",
help="Print model download commands for missing models.",
)
parser.add_argument(
"--internal-export",
choices=tuple(FIXTURES.keys()),
help=argparse.SUPPRESS,
)
parser.add_argument(
"--generated-path",
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.")
return args
def load_fixture_names(selection: str) -> list[str]:
if selection == "all":
return list(FIXTURES.keys())
if selection not in FIXTURES:
raise ValidationFailure("fixture bug", f"Unknown fixture '{selection}'.")
return [selection]
def ensure_runtime_path(tier: str) -> str:
if tier == "runtime":
runtime_path = get_comfyui_path()
else:
return os.environ.get("COMFYUI_PATH", "")
if not runtime_path or not Path(runtime_path).is_dir():
raise ValidationFailure(
"environment/setup failure",
"Could not find a valid ComfyUI checkout for runtime validation. "
"Set COMFYUI_PATH or run the tests from a location where a "
"parent directory contains ComfyUI.",
)
return str(runtime_path)
def get_runtime_python(runtime_path: str) -> str:
runtime_python = Path(runtime_path) / ".venv" / "bin" / "python"
if runtime_python.is_file():
return str(runtime_python)
return sys.executable
def get_fixture(fixture_name: str) -> FixtureConfig:
return FIXTURES[fixture_name]
def check_models(fixture: FixtureConfig, runtime_path: str) -> list[ModelRequirement]:
runtime_root = Path(runtime_path)
missing = []
for requirement in fixture.model_requirements:
target = runtime_root / requirement.relative_dir / requirement.filename
if not target.is_file():
missing.append(requirement)
return missing
def print_download_plan(fixture: FixtureConfig, runtime_path: str) -> None:
runtime_root = Path(runtime_path)
for requirement in check_models(fixture, runtime_path):
target_dir = runtime_root / requirement.relative_dir
print(
"download:",
f"mkdir -p {target_dir} && curl -L {requirement.source_url} -o {target_dir / requirement.filename}",
)
def stage_inputs(fixture: FixtureConfig, runtime_path: str) -> None:
runtime_input_dir = Path(runtime_path) / COMFYUI_INPUT_DIRNAME
runtime_input_dir.mkdir(parents=True, exist_ok=True)
for staged_input in fixture.staged_inputs:
if not staged_input.source_path.is_file():
raise ValidationFailure(
"fixture bug",
f"Missing staged input source {staged_input.source_path} for {fixture.name}.",
)
shutil.copyfile(
staged_input.source_path,
runtime_input_dir / staged_input.destination_name,
)
def export_workflow(
fixture: FixtureConfig,
tier: str,
runtime_path: str,
) -> tuple[str, str]:
from comfyui_to_python import ComfyUItoPython
workflow = fixture.path.read_text(encoding="utf-8")
output = StringIO()
kwargs = {
"workflow": workflow,
"output_file": output,
}
if tier == "fast" and fixture.fast_mapping_factory is not None:
kwargs["node_class_mappings"] = fixture.fast_mapping_factory()
elif fixture.mapping_factory is not None:
kwargs["node_class_mappings"] = fixture.mapping_factory()
else:
os.environ["COMFYUI_PATH"] = runtime_path
try:
ComfyUItoPython(**kwargs)
except ModuleNotFoundError as exc:
missing_module = exc.name or "unknown"
raise ValidationFailure(
"environment/setup failure",
f"Missing runtime dependency '{missing_module}' while exporting {fixture.name}.",
) from exc
except KeyError as exc:
raise ValidationFailure(
"repo regression",
f"Exporter failed to resolve workflow data for {fixture.name}: {exc}",
) from exc
except Exception as exc:
raise ValidationFailure(
"repo regression",
f"Exporter failed for {fixture.name}: {exc}",
) from exc
return workflow, output.getvalue()
def export_workflow_in_runtime_env(fixture: FixtureConfig, runtime_path: str) -> str:
runtime_python = get_runtime_python(runtime_path)
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
generated_path = GENERATED_DIR / f"{fixture.name}.py"
env = os.environ.copy()
env["COMFYUI_PATH"] = runtime_path
env["PYTHONPATH"] = os.pathsep.join([str(ROOT), env.get("PYTHONPATH", "")]).rstrip(
os.pathsep
)
# Re-enter this script under the runtime interpreter so export happens with
# the target ComfyUI checkout on sys.path, not just the repo's current venv.
result = subprocess.run(
[
runtime_python,
str(Path(__file__).resolve()),
"--internal-export",
fixture.name,
"--generated-path",
str(generated_path),
],
cwd=ROOT,
env=env,
capture_output=True,
text=True,
)
if result.returncode != 0:
output = (result.stderr or result.stdout or "").strip()
classification = "environment/setup failure"
if (
"Missing runtime dependency" not in output
and "ModuleNotFoundError" not in output
):
classification = "repo regression"
raise ValidationFailure(
classification,
f"Runtime export failed for {fixture.name}: {output}",
)
return generated_path.read_text(encoding="utf-8")
def validate_generated_python(generated_code: str, fixture_name: str) -> None:
try:
ast.parse(generated_code)
except SyntaxError as exc:
raise ValidationFailure(
"repo regression",
f"Generated Python is not valid for {fixture_name}: {exc}",
) from exc
def parse_png_info(image_path: Path) -> tuple[int, int, dict[str, str]]:
with image_path.open("rb") as handle:
signature = handle.read(8)
if signature != b"\x89PNG\r\n\x1a\n":
raise ValidationFailure(
"environment/setup failure",
f"Expected PNG output for {image_path.name}, got a different file format.",
)
width = height = None
text_data: dict[str, str] = {}
while True:
length_bytes = handle.read(4)
if not length_bytes:
break
length = struct.unpack(">I", length_bytes)[0]
chunk_type = handle.read(4)
chunk_data = handle.read(length)
handle.read(4)
if chunk_type == b"IHDR":
width, height = struct.unpack(">II", chunk_data[:8])
elif chunk_type == b"tEXt":
key, value = chunk_data.split(b"\x00", 1)
text_data[key.decode("latin-1")] = value.decode("latin-1")
elif chunk_type == b"zTXt":
key, compressed = chunk_data.split(b"\x00", 1)
text_data[key.decode("latin-1")] = zlib.decompress(
compressed[1:]
).decode("latin-1")
elif chunk_type == b"iTXt":
parts = chunk_data.split(b"\x00", 5)
if len(parts) == 6:
key = parts[0].decode("utf-8")
compressed_flag = parts[1]
value = parts[5]
if compressed_flag == b"\x01":
value = zlib.decompress(value)
text_data[key] = value.decode("utf-8")
elif chunk_type == b"IEND":
break
if width is None or height is None:
raise ValidationFailure(
"environment/setup failure",
f"Could not read PNG dimensions from {image_path.name}.",
)
return width, height, text_data
def validate_output_artifact(
fixture: FixtureConfig,
output_path: Path,
) -> None:
# Read PNG metadata directly so artifact validation does not depend on
# optional imaging libraries inside the runtime environment.
width, height, metadata = parse_png_info(output_path)
if fixture.expected_min_dimensions is not None:
min_width, min_height = fixture.expected_min_dimensions
if width < min_width or height < min_height:
raise ValidationFailure(
"repo regression",
f"Output dimensions for {fixture.name} were {width}x{height}, expected at least {min_width}x{min_height}.",
)
metadata_blob = "\n".join(
[output_path.name] + [f"{key}={value}" for key, value in metadata.items()]
)
for marker in fixture.metadata_markers:
if marker not in metadata_blob:
raise ValidationFailure(
"repo regression",
f"Output metadata for {fixture.name} did not contain expected marker '{marker}'.",
)
def execute_generated_python(
generated_code: str,
fixture: FixtureConfig,
runtime_path: str,
) -> None:
if not fixture.runtime_capable:
raise ValidationFailure(
"fixture bug",
f"Fixture {fixture.name} is not marked runtime-capable.",
)
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir) / f"{fixture.name}.py"
tmp_path.write_text(generated_code, encoding="utf-8")
output_dir = Path(runtime_path) / COMFYUI_OUTPUT_DIRNAME
# Compare against the pre-run snapshot so validation can prove this
# execution created a fresh artifact instead of reusing an old output.
existing_outputs = set(output_dir.glob("*.png"))
env = os.environ.copy()
env["COMFYUI_PATH"] = runtime_path
env["PYTHONPATH"] = os.pathsep.join(
[str(ROOT), env.get("PYTHONPATH", "")]
).rstrip(os.pathsep)
runtime_python = get_runtime_python(runtime_path)
result = subprocess.run(
[runtime_python, str(tmp_path)],
cwd=ROOT,
env=env,
capture_output=True,
text=True,
)
if result.returncode == 0:
if fixture.filename_prefix is None:
return
new_outputs = [
path
for path in output_dir.glob(f"{fixture.filename_prefix}*.png")
if path not in existing_outputs
]
if not new_outputs:
raise ValidationFailure(
"repo regression",
f"Generated script for {fixture.name} did not produce a new output file with prefix {fixture.filename_prefix}.",
)
newest_output = max(new_outputs, key=lambda path: path.stat().st_mtime)
validate_output_artifact(fixture, newest_output)
return
stderr = (result.stderr or "").strip()
stdout = (result.stdout or "").strip()
output = stderr or stdout or "generated script exited with a non-zero status"
lower_output = output.lower()
if "no module named 'torch'" in lower_output:
classification = "environment/setup failure"
elif "no such file or directory" in lower_output or "not found" in lower_output:
classification = "environment/setup failure"
else:
classification = "repo regression"
raise ValidationFailure(
classification,
f"Generated script execution failed for {fixture.name}: {output}",
)
def run_fixture(fixture: FixtureConfig, tier: str, execute: bool, runtime_path: str) -> str:
if tier == "fast":
_, generated_code = export_workflow(fixture, tier, runtime_path)
else:
missing_models = check_models(fixture, runtime_path)
if missing_models:
raise ValidationFailure(
"model provisioning failure",
"Missing models for "
f"{fixture.name}: "
+ ", ".join(
f"{item.relative_dir}/{item.filename}" for item in missing_models
),
)
stage_inputs(fixture, runtime_path)
generated_code = export_workflow_in_runtime_env(fixture, runtime_path)
validate_generated_python(generated_code, fixture.name)
# Fast keeps execution opt-in because its stub node mappings are intended for
# export coverage only. Runtime always executes the generated script.
should_execute = execute or tier == "runtime"
if should_execute and tier == "runtime":
execute_generated_python(generated_code, fixture, runtime_path)
return "pass"
def main() -> int:
args = parse_args()
if args.internal_export:
fixture = get_fixture(args.internal_export)
output_path = Path(args.generated_path)
_, generated_code = export_workflow(
fixture=fixture,
tier="runtime",
runtime_path=os.environ.get("COMFYUI_PATH", ""),
)
output_path.write_text(generated_code, encoding="utf-8")
return 0
try:
runtime_path = ensure_runtime_path(args.tier)
fixture_names = load_fixture_names(args.fixture)
requested = [FIXTURES[name] for name in fixture_names]
if args.tier == "fast":
requested = [
fixture
for fixture in requested
if fixture.fast_mapping_factory is not None
or fixture.mapping_factory is not None
]
if not requested:
raise ValidationFailure(
"fixture bug",
"No selected fixtures are fast-tier compatible.",
)
elif args.tier == "runtime":
requested = [fixture for fixture in requested if fixture.runtime_capable]
if not requested:
raise ValidationFailure(
"fixture bug",
"No selected fixtures are runtime-capable for this tier.",
)
failures: list[tuple[str, str, str]] = []
for fixture in requested:
try:
if args.print_download_plan and args.tier == "runtime":
missing_models = check_models(fixture, runtime_path)
for requirement in missing_models:
target_dir = Path(runtime_path) / requirement.relative_dir
print(
"download:",
f"mkdir -p {target_dir} && curl -L {requirement.source_url} -o {target_dir / requirement.filename}",
)
if missing_models:
print(f"{fixture.name}: download-plan")
continue
status = run_fixture(fixture, args.tier, args.execute, runtime_path)
print(f"{fixture.name}: {status}")
except ValidationFailure as exc:
failures.append((fixture.name, exc.classification, exc.message))
print(
f"{fixture.name}: fail ({exc.classification})",
file=sys.stderr,
)
print(exc.message, file=sys.stderr)
if failures:
classifications = ", ".join(
f"{name}={classification}" for name, classification, _ in failures
)
print(f"classification: {classifications}", file=sys.stderr)
return 1
except ValidationFailure as exc:
print(f"classification: {exc.classification}", file=sys.stderr)
print(exc.message, file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(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()
+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()
+510
View File
@@ -0,0 +1,510 @@
import json
import sys
import tempfile
import unittest
from io import StringIO
from pathlib import Path
from unittest.mock import patch
from comfyui_to_python import ComfyUItoPython, run
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 LoadImage:
CATEGORY = "image"
FUNCTION = "load_image"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("STRING",),
}
}
def load_image(self, image):
return (image,)
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 MergeImages:
CATEGORY = "image"
FUNCTION = "merge"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"left": ("IMAGE",),
"right": ("IMAGE",),
}
}
def merge(self, left, right):
return (left, right)
class HiddenMetadataFilteredNode:
CATEGORY = "image"
FUNCTION = "save"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
},
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
},
}
def save(self, image):
return (image,)
class HiddenPromptSeedNode:
CATEGORY = "sampling"
FUNCTION = "sample"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"seed": ("INT",),
"noise_seed": ("INT",),
},
"hidden": {
"prompt": "PROMPT",
},
}
def sample(self, seed, noise_seed, prompt):
return (seed, noise_seed, prompt)
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("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():\n bootstrap_comfyui_runtime()\n add_extra_model_paths()",
generated,
)
self.assertLess(
generated.index("def bootstrap_comfyui_runtime()"), generated.index("def main():")
)
main_section = generated[generated.index("def main():") :]
self.assertIn(
"def main():\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"))
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": {
"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(" 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)
self.assertIn("loadimage = LoadImage()", generated)
self.assertIn("upscalemodelloader = UpscaleModelLoader()", generated)
self.assertIn(
"imageupscalewithmodel_3 = imageupscalewithmodel.upscale(",
generated,
)
self.assertNotIn('NODE_CLASS_MAPPINGS["UpscaleModelLoader"]()', generated)
def test_frontend_workflow_metadata_is_embedded_for_reimport(self):
workflow = {
"1": {
"class_type": "LoadImage",
"inputs": {
"image": "example.png",
},
}
}
frontend_workflow = {
"version": 0.4,
"last_node_id": 1,
"last_link_id": 0,
"nodes": [],
"links": [],
"groups": [],
"config": {},
"extra": {},
}
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
frontend_workflow=json.dumps(frontend_workflow),
output_file=output,
node_class_mappings={
"LoadImage": LoadImage,
},
)
generated = output.getvalue()
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):
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 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):
workflow = {
"1:23": {
"class_type": "LoadImage",
"inputs": {
"image": "left.png",
},
},
"12:3": {
"class_type": "LoadImage",
"inputs": {
"image": "right.png",
},
},
"20": {
"class_type": "MergeImages",
"inputs": {
"left": ["1:23", 0],
"right": ["12:3", 0],
},
},
}
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
output_file=output,
node_class_mappings={
"LoadImage": LoadImage,
"MergeImages": MergeImages,
},
)
generated = output.getvalue()
self.assertIn("loadimage_1_23 = loadimage.load_image(", generated)
self.assertIn("loadimage_12_3 = loadimage.load_image(", generated)
self.assertIn("mergeimages_20 = mergeimages.merge(", generated)
self.assertIn("left=get_value_at_index(loadimage_1_23, 0)", generated)
self.assertIn("right=get_value_at_index(loadimage_12_3, 0)", generated)
def test_run_cli_export_leaves_png_workflow_metadata_absent(self):
workflow = {
"1": {
"class_type": "LoadImage",
"inputs": {
"image": "example.png",
},
}
}
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
input_file = tmpdir_path / "workflow_api.json"
output_file = tmpdir_path / "workflow_api.py"
input_file.write_text(json.dumps(workflow), encoding="utf-8")
with patch(
"comfyui_to_python.get_node_class_mappings",
return_value={"LoadImage": LoadImage},
), patch("comfyui_to_python.import_custom_nodes"):
run(
input_file=str(input_file),
output_file=str(output_file),
queue_size=1,
)
generated = output_file.read_text(encoding="utf-8")
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()", generated)
self.assertIn("bootstrap_comfyui_runtime()", generated)
self.assertNotIn("def initialize_workflow()", generated)
self.assertNotIn("def run_once(", generated)
self.assertIn("with torch.inference_mode():", 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()"),
)
def test_hidden_metadata_kwargs_follow_function_signature(self):
workflow = {
"1": {
"class_type": "HiddenMetadataFilteredNode",
"inputs": {
"image": "example.png",
},
}
}
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
output_file=output,
node_class_mappings={
"HiddenMetadataFilteredNode": HiddenMetadataFilteredNode,
},
)
generated = output.getvalue()
self.assertIn("hiddenmetadatafilterednode_1 =", generated)
self.assertNotIn("prompt=prompt", generated)
self.assertNotIn("extra_pnginfo=extra_pnginfo", generated)
def test_randomized_seed_inputs_update_prompt_metadata_before_execution(self):
workflow = {
"1": {
"class_type": "HiddenPromptSeedNode",
"inputs": {
"seed": 1,
"noise_seed": 2,
},
}
}
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
output_file=output,
node_class_mappings={
"HiddenPromptSeedNode": HiddenPromptSeedNode,
},
)
generated = output.getvalue()
self.assertIn(
'node_1_seed = prompt["1"]["inputs"]["seed"] = random.randint(1, 2**64)',
generated,
)
self.assertIn(
'node_1_noise_seed = prompt["1"]["inputs"]["noise_seed"] = random.randint(',
generated,
)
self.assertIn("1, 2**64", generated)
self.assertIn("seed=node_1_seed", generated)
self.assertIn("noise_seed=node_1_noise_seed", generated)
self.assertIn("prompt=prompt", generated)
if __name__ == "__main__":
unittest.main()
Generated
+132
View File
@@ -0,0 +1,132 @@
version = 1
revision = 2
requires-python = ">=3.12"
[[package]]
name = "black"
version = "26.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "mypy-extensions" },
{ name = "packaging" },
{ name = "pathspec" },
{ name = "platformdirs" },
{ name = "pytokens" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" },
{ url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" },
{ url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" },
{ url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" },
{ url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" },
{ url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" },
{ url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" },
{ url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" },
{ url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" },
{ url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" },
{ url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" },
{ url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" },
{ url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" },
{ url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" },
{ url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" },
{ url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "comfyui-to-python-extension"
version = "2.0.0"
source = { virtual = "." }
dependencies = [
{ name = "black" },
]
[package.metadata]
requires-dist = [{ name = "black" }]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pathspec"
version = "1.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
]
[[package]]
name = "pytokens"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" },
{ url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" },
{ url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" },
{ url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" },
{ url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" },
{ url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" },
{ url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" },
{ url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" },
{ url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" },
{ url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" },
{ url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" },
{ url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" },
{ url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" },
{ url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" },
{ url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" },
{ url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" },
{ url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" },
{ url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" },
{ url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" },
{ url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" },
{ url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" },
]