Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79312a25bb | ||
|
|
e844d508ac | ||
|
|
08ad731038 | ||
|
|
6cdcc235a0 | ||
|
|
9a17000417 | ||
|
|
fbc06463f7 | ||
|
|
77c0bbec9b | ||
|
|
f9163f2375 | ||
|
|
d6c5b110ab | ||
|
|
5d70efdc88 | ||
|
|
228c3a076a | ||
|
|
574d96369c | ||
|
|
ce23e90f59 | ||
|
|
c1cf4e7585 | ||
|
|
42d0526661 | ||
|
|
441dcfebdf | ||
|
|
d751147ed9 | ||
|
|
9e7e474b05 | ||
|
|
1630a1ab79 | ||
|
|
8b56c4971d | ||
|
|
ced63d1a00 | ||
|
|
4370a78728 | ||
|
|
494f7cf79a | ||
|
|
342e1b8749 | ||
|
|
6a9593ee70 | ||
|
|
d87611c035 | ||
|
|
893c76d6cc | ||
|
|
e71f7e207b | ||
|
|
025e58a58e | ||
|
|
6cbc383415 | ||
|
|
d57b2167ca | ||
|
|
b0109aa90f | ||
|
|
0598433783 | ||
|
|
a97311dabe | ||
|
|
68ae24ceac | ||
|
|
1fa2db89e1 | ||
|
|
3ffd985cf7 | ||
|
|
47e5bcc68a | ||
|
|
cb5917eeed | ||
|
|
380e701132 | ||
|
|
557adae33e | ||
|
|
72a87c31b3 | ||
|
|
e5dc94efe3 | ||
|
|
57eddcb76d | ||
|
|
2738113346 | ||
|
|
77b98ad5e1 | ||
|
|
32d42bf472 | ||
|
|
281b8408c9 | ||
|
|
b215c83b76 | ||
|
|
76bc1c343e | ||
|
|
42bc6926ae | ||
|
|
351a68831d | ||
|
|
1c0585fabe | ||
|
|
60d55d904b | ||
|
|
3a5ef069d9 | ||
|
|
77a493f0eb | ||
|
|
2f6a649801 | ||
|
|
4d0de66b0b |
@@ -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 }}
|
||||
|
||||
@@ -1,212 +1,175 @@
|
||||
## ComfyUI-to-Python-Extension
|
||||
# ComfyUI-to-Python-Extension
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
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.
|
||||
This project supports Python 3.12 and newer.
|
||||
|
||||
### Web UI extension (`File -> Save As Script`)
|
||||
|
||||
For ComfyUI to recognize this project as an extension, the repo must be discoverable through ComfyUI's `custom_nodes` search paths.
|
||||
|
||||
Clone directly into `ComfyUI/custom_nodes`:
|
||||
|
||||
```bash
|
||||
cd /path/to/ComfyUI/custom_nodes
|
||||
git clone https://github.com/pydn/ComfyUI-to-Python-Extension.git
|
||||
```
|
||||
|
||||
Or keep the repo elsewhere, then either:
|
||||
- symlink it into `ComfyUI/custom_nodes`
|
||||
- add its parent directory to ComfyUI's `custom_nodes` search paths via `extra_model_paths.yaml`
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Then install this extension into the same Python environment that launches ComfyUI.
|
||||
The `pyproject.toml` file declares the package dependencies, but those dependencies still need to be installed into ComfyUI's runtime Python.
|
||||
|
||||
If you run ComfyUI from a source checkout with `uv`:
|
||||
|
||||
```bash
|
||||
cd /path/to/ComfyUI
|
||||
uv pip install -e ./custom_nodes/ComfyUI-to-Python-Extension
|
||||
uv run python main.py
|
||||
```
|
||||
|
||||
If you use the Windows portable build:
|
||||
|
||||
```
|
||||
import random
|
||||
import torch
|
||||
import sys
|
||||
|
||||
sys.path.append("../")
|
||||
from nodes import (
|
||||
VAEDecode,
|
||||
KSamplerAdvanced,
|
||||
EmptyLatentImage,
|
||||
SaveImage,
|
||||
CheckpointLoaderSimple,
|
||||
CLIPTextEncode,
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
cd C:\path\to\ComfyUI_windows_portable\ComfyUI\custom_nodes\ComfyUI-to-Python-Extension
|
||||
..\..\..\python_embeded\python.exe -m pip install -e .
|
||||
```
|
||||
## 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
|
||||
Running `uv sync` inside `ComfyUI-to-Python-Extension` creates this extension's own `.venv`.
|
||||
ComfyUI does not automatically import dependencies from that `.venv`; it imports custom nodes with the Python interpreter used to launch ComfyUI.
|
||||
|
||||
After installation, restart ComfyUI.
|
||||
|
||||
### CLI exporter / generated scripts
|
||||
|
||||
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.
|
||||
It also does not install ComfyUI runtime dependencies such as `torch` into the current Python environment.
|
||||
|
||||
`COMFYUI_PATH` is checked first. If it is not set, the exporter falls back to searching parent directories for a folder named `ComfyUI`.
|
||||
|
||||
If the CLI fails with `ModuleNotFoundError: No module named 'torch'`, run the command with the same Python environment that launches ComfyUI, or install ComfyUI's runtime dependencies into the environment you are using for the CLI.
|
||||
|
||||
For Windows portable builds, run the CLI with ComfyUI's embedded Python from the extension directory:
|
||||
|
||||
```
|
||||
..\..\..\python_embeded\python.exe -m comfyui_to_python --input_file ".\workflow_api.json" --output_file ".\workflow_api.py"
|
||||
```
|
||||
|
||||
## Web UI Export
|
||||
|
||||
In current ComfyUI builds, `Save As Script` is typically available under:
|
||||
|
||||
`File -> Save As Script`
|
||||
|
||||
The command downloads a generated `.py` file.
|
||||
The current UI export uses the default filename `workflow_api.py` so it works in ComfyUI Desktop without relying on `prompt()`.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
- the Web UI export uses a fixed default filename rather than asking for one interactively
|
||||
|
||||
## 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
|
||||

|
||||
|
||||
2. Load your favorite workflow and click `Save As Script`
|
||||
## Generated Scripts
|
||||
|
||||

|
||||
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!**
|
||||
Generated scripts reuse ComfyUI's runtime argument parser during bootstrap, so common ComfyUI memory flags such as `--highvram`, `--normalvram`, `--lowvram`, `--novram`, `--cpu`, and `--disable-smart-memory` can be passed directly to the exported `.py` file.
|
||||
|
||||

|
||||
Lifecycle notes:
|
||||
- exported scripts are single-shot workflow runners, not long-lived ComfyUI prompt servers
|
||||
- they do not implement Web UI prompt/result caching across repeated service calls
|
||||
- exported `main()` now performs best-effort ComfyUI model/cache cleanup in a `finally` block
|
||||
- set `COMFYUI_TOPYTHON_UNLOAD_MODELS=1` or call `main(unload_models=True)` if an embedded or repeated-call host should aggressively unload models after each run instead of preserving them for reuse
|
||||
|
||||
3. Load up your favorite workflows, then click the newly enabled `Save (API Format)` button under Queue Prompt
|
||||
## Troubleshooting
|
||||
|
||||
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.
|
||||
- unsupported Python version:
|
||||
use Python 3.12 or newer in the environment that runs the extension, then reinstall the extension dependencies there
|
||||
- Web UI import fails after `uv sync`:
|
||||
`uv sync` in this repo installs dependencies into this repo's `.venv`, but ComfyUI loads custom nodes with its own Python environment. Install the extension into the Python interpreter that launches ComfyUI.
|
||||
- Windows portable import fails after `uv sync`:
|
||||
ComfyUI portable uses its bundled `python_embeded` interpreter. From the extension directory, run `..\..\..\python_embeded\python.exe -m pip install -e .`, then restart ComfyUI.
|
||||
- CLI fails with `No module named 'torch'`:
|
||||
the extension `.venv` may not have ComfyUI's runtime dependencies. Either run the CLI from the Python environment that launches ComfyUI, or make sure the target ComfyUI environment is installed and `COMFYUI_PATH` points to it.
|
||||
- `Save As Script` not visible:
|
||||
check your current ComfyUI menu/frontend version and look under `File`
|
||||
- `Save As Script` not visible after restart:
|
||||
make sure this repo is discoverable by ComfyUI through `custom_nodes` by cloning it into `ComfyUI/custom_nodes`, symlinking it there, or adding an external `custom_nodes` path in `extra_model_paths.yaml`
|
||||
- save uses the default filename:
|
||||
rename `workflow_api.py` after download if you want a different local filename
|
||||
- ComfyUI cannot be found:
|
||||
set `COMFYUI_PATH`
|
||||
- models or paths are missing at runtime:
|
||||
verify the target ComfyUI install and its `extra_model_paths.yaml`
|
||||
|
||||
+10
-13
@@ -13,18 +13,10 @@ sys.path.append(ext_dir)
|
||||
try:
|
||||
import black
|
||||
except ImportError:
|
||||
print("Unable to import requirements for ComfyUI-SaveAsScript.")
|
||||
print("Installing...")
|
||||
|
||||
import importlib
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"impact_install", os.path.join(os.path.dirname(__file__), "install.py")
|
||||
)
|
||||
impact_install = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(impact_install)
|
||||
|
||||
print("Successfully installed. Hopefully, at least.")
|
||||
raise ImportError(
|
||||
"ComfyUI-to-Python-Extension requires the project dependencies to be installed. "
|
||||
f"Run 'uv sync' in {ext_dir} with Python 3.12+ before loading this extension."
|
||||
) from None
|
||||
|
||||
# Prevent reimporting of custom nodes
|
||||
os.environ["RUNNING_IN_COMFYUI"] = "TRUE"
|
||||
@@ -45,9 +37,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
@@ -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()
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from typing import TextIO
|
||||
|
||||
from .app import ExportApplication
|
||||
from .cli import DEFAULT_INPUT_FILE, DEFAULT_OUTPUT_FILE, DEFAULT_QUEUE_SIZE, main
|
||||
from .node_runtime import get_node_class_mappings, import_custom_nodes
|
||||
from .runtime_session import WorkflowSession
|
||||
|
||||
|
||||
class ComfyUItoPython:
|
||||
"""Public compatibility facade for the exporter package."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workflow: str = "",
|
||||
frontend_workflow: str | dict | None = None,
|
||||
input_file: str = "",
|
||||
output_file: str | TextIO = "",
|
||||
queue_size: int = 1,
|
||||
node_class_mappings: dict | None = None,
|
||||
needs_init_custom_nodes: bool = False,
|
||||
execution_mode: str = "oneshot",
|
||||
):
|
||||
self._app = ExportApplication(
|
||||
workflow=workflow,
|
||||
frontend_workflow=frontend_workflow,
|
||||
input_file=input_file,
|
||||
output_file=output_file,
|
||||
queue_size=queue_size,
|
||||
node_class_mappings=node_class_mappings,
|
||||
needs_init_custom_nodes=needs_init_custom_nodes,
|
||||
execution_mode=execution_mode,
|
||||
node_mapping_loader=get_node_class_mappings,
|
||||
custom_node_importer=import_custom_nodes,
|
||||
)
|
||||
self._app.execute()
|
||||
|
||||
|
||||
def run(
|
||||
input_file: str = DEFAULT_INPUT_FILE,
|
||||
output_file: str = DEFAULT_OUTPUT_FILE,
|
||||
queue_size: int = DEFAULT_QUEUE_SIZE,
|
||||
) -> None:
|
||||
"""Generate Python code from a ComfyUI workflow_api.json file."""
|
||||
ComfyUItoPython(
|
||||
input_file=input_file,
|
||||
output_file=output_file,
|
||||
queue_size=queue_size,
|
||||
needs_init_custom_nodes=True,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ComfyUItoPython",
|
||||
"WorkflowSession",
|
||||
"run",
|
||||
"main",
|
||||
"get_node_class_mappings",
|
||||
"import_custom_nodes",
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
from .cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,78 @@
|
||||
import copy
|
||||
|
||||
from typing import TextIO
|
||||
|
||||
from .generator.planner import WorkflowPlanner
|
||||
from .generator.render import WorkflowRenderer
|
||||
from .io import write_python_output
|
||||
from .load_order import LoadOrderDeterminer
|
||||
from .workflow_loader import load_frontend_workflow_data, load_workflow_data
|
||||
|
||||
|
||||
class ExportApplication:
|
||||
"""High-level exporter orchestration."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workflow: str = "",
|
||||
frontend_workflow: str | dict | None = None,
|
||||
input_file: str = "",
|
||||
output_file: str | TextIO = "",
|
||||
queue_size: int = 1,
|
||||
node_class_mappings: dict | None = None,
|
||||
needs_init_custom_nodes: bool = False,
|
||||
node_mapping_loader=None,
|
||||
custom_node_importer=None,
|
||||
execution_mode: str = "oneshot",
|
||||
):
|
||||
if input_file and workflow:
|
||||
raise ValueError("Can't provide both input_file and workflow")
|
||||
if not input_file and not workflow:
|
||||
raise ValueError("Needs input_file or workflow")
|
||||
if not output_file:
|
||||
raise ValueError("Needs output_file")
|
||||
|
||||
self.workflow = workflow
|
||||
self.frontend_workflow = frontend_workflow
|
||||
self.input_file = input_file
|
||||
self.output_file = output_file
|
||||
self.queue_size = queue_size
|
||||
self.node_mapping_loader = node_mapping_loader
|
||||
self.custom_node_importer = custom_node_importer
|
||||
self.node_class_mappings = (
|
||||
node_class_mappings
|
||||
if node_class_mappings is not None
|
||||
else self.node_mapping_loader()
|
||||
)
|
||||
self.needs_init_custom_nodes = needs_init_custom_nodes
|
||||
self.execution_mode = execution_mode
|
||||
self.base_node_class_mappings = copy.deepcopy(self.node_class_mappings)
|
||||
|
||||
def execute(self) -> None:
|
||||
data = load_workflow_data(self.workflow, self.input_file)
|
||||
metadata_workflow_data = load_frontend_workflow_data(self.frontend_workflow)
|
||||
|
||||
missing_node_types = {
|
||||
node_data["class_type"]
|
||||
for node_data in data.values()
|
||||
if node_data["class_type"] not in self.node_class_mappings
|
||||
}
|
||||
if self.needs_init_custom_nodes or missing_node_types:
|
||||
self.custom_node_importer()
|
||||
self.base_node_class_mappings = copy.deepcopy(self.node_class_mappings)
|
||||
|
||||
load_order = LoadOrderDeterminer(
|
||||
data, self.node_class_mappings
|
||||
).determine_load_order()
|
||||
plan = WorkflowPlanner(
|
||||
self.node_class_mappings, self.base_node_class_mappings
|
||||
).build_plan(
|
||||
load_order,
|
||||
data,
|
||||
metadata_workflow_data,
|
||||
queue_size=self.queue_size,
|
||||
execution_mode=self.execution_mode,
|
||||
)
|
||||
generated_code = WorkflowRenderer(execution_mode=self.execution_mode).render(plan)
|
||||
write_python_output(self.output_file, generated_code)
|
||||
print(f"Code successfully generated and written to {self.output_file}")
|
||||
@@ -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.")
|
||||
@@ -0,0 +1,4 @@
|
||||
from .planner import WorkflowPlanner
|
||||
from .render import WorkflowRenderer
|
||||
|
||||
__all__ = ["WorkflowPlanner", "WorkflowRenderer"]
|
||||
@@ -0,0 +1,19 @@
|
||||
from ..node_runtime import (
|
||||
add_comfyui_directory_to_sys_path,
|
||||
add_extra_model_paths,
|
||||
bootstrap_comfyui_runtime,
|
||||
cleanup_comfyui_runtime,
|
||||
find_path,
|
||||
get_comfyui_path,
|
||||
get_value_at_index,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"add_comfyui_directory_to_sys_path",
|
||||
"add_extra_model_paths",
|
||||
"bootstrap_comfyui_runtime",
|
||||
"cleanup_comfyui_runtime",
|
||||
"find_path",
|
||||
"get_comfyui_path",
|
||||
"get_value_at_index",
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GenerationPlan:
|
||||
import_statements: dict[str, set[str]]
|
||||
special_functions_code: list[str]
|
||||
loop_code: list[str]
|
||||
workflow_data: dict
|
||||
metadata_workflow_data: dict | None
|
||||
queue_size: int
|
||||
custom_nodes: bool
|
||||
execution_mode: Literal["oneshot", "session"] = field(default="oneshot")
|
||||
executed_variables: dict[str, str] = field(default_factory=dict)
|
||||
@@ -0,0 +1,264 @@
|
||||
import inspect
|
||||
import json
|
||||
import keyword
|
||||
import random
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
from .model import GenerationPlan
|
||||
|
||||
|
||||
class WorkflowPlanner:
|
||||
"""Convert ordered workflow nodes into a renderer-ready generation plan."""
|
||||
|
||||
def __init__(self, node_class_mappings: dict, base_node_class_mappings: dict):
|
||||
self.node_class_mappings = node_class_mappings
|
||||
self.base_node_class_mappings = base_node_class_mappings
|
||||
|
||||
@staticmethod
|
||||
def sanitize_node_id(node_id: str) -> str:
|
||||
sanitized = re.sub(r"[^a-z0-9_]", "_", str(node_id).lower().strip())
|
||||
sanitized = re.sub(r"_+", "_", sanitized).strip("_")
|
||||
if not sanitized:
|
||||
sanitized = "node"
|
||||
return sanitized
|
||||
|
||||
@staticmethod
|
||||
def clean_variable_name(class_type: str) -> str:
|
||||
clean_name = class_type.lower().strip().replace("-", "_").replace(" ", "_")
|
||||
clean_name = re.sub(r"[^a-z0-9_]", "", clean_name)
|
||||
if clean_name[0].isdigit():
|
||||
clean_name = "_" + clean_name
|
||||
return clean_name
|
||||
|
||||
def build_plan(
|
||||
self,
|
||||
load_order: list[tuple[str, dict, bool]],
|
||||
workflow_data: dict,
|
||||
metadata_workflow_data: dict | None = None,
|
||||
queue_size: int = 10,
|
||||
execution_mode: str = "oneshot",
|
||||
) -> GenerationPlan:
|
||||
import_statements = {"nodes": {"NODE_CLASS_MAPPINGS"}}
|
||||
executed_variables = {}
|
||||
special_functions_code = []
|
||||
code = []
|
||||
initialized_objects = {}
|
||||
custom_nodes = False
|
||||
|
||||
for idx, data, is_special_function in load_order:
|
||||
inputs, class_type = data["inputs"], data["class_type"]
|
||||
input_types = self.node_class_mappings[class_type].INPUT_TYPES()
|
||||
input_value_types = self.get_input_value_types(input_types)
|
||||
class_def = self.node_class_mappings[class_type]()
|
||||
|
||||
missing_required_variable = False
|
||||
if "required" in input_types.keys():
|
||||
for required in input_types["required"]:
|
||||
if required not in inputs.keys():
|
||||
missing_required_variable = True
|
||||
if missing_required_variable:
|
||||
continue
|
||||
|
||||
if class_type not in initialized_objects:
|
||||
if class_type == "PreviewImage":
|
||||
continue
|
||||
|
||||
class_type, import_statement, class_code = self.get_class_info(
|
||||
class_type
|
||||
)
|
||||
initialized_objects[class_type] = self.clean_variable_name(class_type)
|
||||
if class_type in self.base_node_class_mappings.keys():
|
||||
module_name, import_name = import_statement
|
||||
import_statements.setdefault(module_name, set()).add(import_name)
|
||||
if 'NODE_CLASS_MAPPINGS["' in class_code:
|
||||
custom_nodes = True
|
||||
special_functions_code.append(class_code)
|
||||
|
||||
class_def_params = self.get_function_parameters(
|
||||
getattr(class_def, class_def.FUNCTION)
|
||||
)
|
||||
no_params = class_def_params is None
|
||||
|
||||
inputs = {
|
||||
key: value
|
||||
for key, value in inputs.items()
|
||||
if no_params or key in class_def_params
|
||||
}
|
||||
|
||||
hidden_inputs = input_types.get("hidden", {})
|
||||
if (
|
||||
"unique_id" in hidden_inputs
|
||||
and (no_params or "unique_id" in class_def_params)
|
||||
):
|
||||
inputs["unique_id"] = random.randint(1, 2**64)
|
||||
if "prompt" in hidden_inputs and (no_params or "prompt" in class_def_params):
|
||||
inputs["prompt"] = {"variable_name": "prompt"}
|
||||
if "extra_pnginfo" in hidden_inputs and (
|
||||
no_params or "extra_pnginfo" in class_def_params
|
||||
):
|
||||
inputs["extra_pnginfo"] = {"variable_name": "extra_pnginfo"}
|
||||
if "hidden" not in input_types and class_def_params is not None:
|
||||
if "unique_id" in class_def_params:
|
||||
inputs["unique_id"] = random.randint(1, 2**64)
|
||||
|
||||
executed_variables[idx] = (
|
||||
f"{self.clean_variable_name(class_type)}_"
|
||||
f"{self.sanitize_node_id(str(idx))}"
|
||||
)
|
||||
inputs = self.update_inputs(inputs, executed_variables)
|
||||
seed_sync_code = self.create_prompt_seed_sync_code(
|
||||
idx, inputs, input_value_types, is_special_function
|
||||
)
|
||||
|
||||
target_lines = special_functions_code if is_special_function else code
|
||||
if seed_sync_code:
|
||||
target_lines.extend(seed_sync_code)
|
||||
target_lines.append(
|
||||
self.create_function_call_code(
|
||||
initialized_objects[class_type],
|
||||
class_def.FUNCTION,
|
||||
executed_variables[idx],
|
||||
is_special_function,
|
||||
input_value_types=input_value_types,
|
||||
**inputs,
|
||||
)
|
||||
)
|
||||
|
||||
return GenerationPlan(
|
||||
import_statements=import_statements,
|
||||
special_functions_code=special_functions_code,
|
||||
loop_code=code,
|
||||
workflow_data=workflow_data,
|
||||
metadata_workflow_data=metadata_workflow_data,
|
||||
queue_size=queue_size,
|
||||
custom_nodes=custom_nodes,
|
||||
execution_mode=execution_mode,
|
||||
executed_variables=executed_variables,
|
||||
)
|
||||
|
||||
def create_function_call_code(
|
||||
self,
|
||||
obj_name: str,
|
||||
func: str,
|
||||
variable_name: str,
|
||||
is_special_function: bool,
|
||||
input_value_types: dict[str, str] | None = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
args = ", ".join(
|
||||
self.format_arg(key, value, (input_value_types or {}).get(key))
|
||||
for key, value in kwargs.items()
|
||||
)
|
||||
code = f"{variable_name} = {obj_name}.{func}({args})\n"
|
||||
if not is_special_function:
|
||||
code = f"\t{code}"
|
||||
return code
|
||||
|
||||
def create_prompt_seed_sync_code(
|
||||
self,
|
||||
node_id: str,
|
||||
inputs: dict,
|
||||
input_value_types: dict[str, str],
|
||||
is_special_function: bool,
|
||||
) -> list[str]:
|
||||
seed_sync_lines = []
|
||||
for key in ("seed", "noise_seed"):
|
||||
if key not in inputs:
|
||||
continue
|
||||
randomized_seed_variable = (
|
||||
f"node_{self.sanitize_node_id(str(node_id))}_{self.clean_variable_name(key)}"
|
||||
)
|
||||
randomized_seed_code = self.get_randomized_seed_code(
|
||||
input_value_types.get(key)
|
||||
)
|
||||
seed_sync_lines.append(
|
||||
f'{randomized_seed_variable} = prompt["{node_id}"]["inputs"]["{key}"] = {randomized_seed_code}'
|
||||
)
|
||||
inputs[key] = {"variable_name": randomized_seed_variable}
|
||||
|
||||
if not seed_sync_lines:
|
||||
return []
|
||||
|
||||
indentation = "" if is_special_function else "\t"
|
||||
return [f"{indentation}{line}\n" for line in seed_sync_lines]
|
||||
|
||||
def format_arg(self, key: str, value: Any, input_value_type: str | None = None) -> str:
|
||||
value_code = self.format_arg_value(key, value, input_value_type)
|
||||
if key.isidentifier() and not keyword.iskeyword(key):
|
||||
return f"{key}={value_code}"
|
||||
return f"**{{{json.dumps(key)}: {value_code}}}"
|
||||
|
||||
@staticmethod
|
||||
def format_arg_value(
|
||||
key: str, value: Any, input_value_type: str | None = None
|
||||
) -> str:
|
||||
if isinstance(value, dict) and "variable_name" in value:
|
||||
return value["variable_name"]
|
||||
if key == "noise_seed" or key == "seed":
|
||||
return WorkflowPlanner.get_randomized_seed_code(input_value_type)
|
||||
if isinstance(value, str):
|
||||
return json.dumps(value)
|
||||
return repr(value)
|
||||
|
||||
@staticmethod
|
||||
def get_input_value_types(input_types: dict) -> dict[str, str]:
|
||||
value_types = {}
|
||||
for section in ("required", "optional", "hidden"):
|
||||
for key, value in input_types.get(section, {}).items():
|
||||
if isinstance(value, tuple) and value:
|
||||
value_types[key] = value[0]
|
||||
elif isinstance(value, str):
|
||||
value_types[key] = value
|
||||
return value_types
|
||||
|
||||
@staticmethod
|
||||
def get_randomized_seed_code(input_value_type: str | None) -> str:
|
||||
randomized_seed_code = "random.randint(1, 2**64)"
|
||||
if input_value_type == "STRING":
|
||||
return f"str({randomized_seed_code})"
|
||||
return randomized_seed_code
|
||||
|
||||
def get_class_info(self, class_type: str) -> tuple[str, tuple[str, str], str]:
|
||||
class_obj = self.base_node_class_mappings.get(class_type)
|
||||
module_name = "nodes"
|
||||
if class_obj is not None:
|
||||
module_name = class_obj.__module__
|
||||
variable_name = self.clean_variable_name(class_type)
|
||||
is_importable_module = bool(
|
||||
module_name
|
||||
and "/" not in module_name
|
||||
and "\\" not in module_name
|
||||
and all(part.isidentifier() for part in module_name.split("."))
|
||||
)
|
||||
if class_type in self.base_node_class_mappings.keys() and is_importable_module:
|
||||
import_statement = (module_name, class_type)
|
||||
class_code = f"{variable_name} = {class_type.strip()}()"
|
||||
else:
|
||||
import_statement = ("nodes", "NODE_CLASS_MAPPINGS")
|
||||
class_code = f'{variable_name} = NODE_CLASS_MAPPINGS["{class_type}"]()'
|
||||
return class_type, import_statement, class_code
|
||||
|
||||
@staticmethod
|
||||
def get_function_parameters(func: Callable) -> list | None:
|
||||
signature = inspect.signature(func)
|
||||
parameters = {
|
||||
name: param.default if param.default != param.empty else None
|
||||
for name, param in signature.parameters.items()
|
||||
}
|
||||
catch_all = any(
|
||||
param.kind == inspect.Parameter.VAR_KEYWORD
|
||||
for param in signature.parameters.values()
|
||||
)
|
||||
return list(parameters.keys()) if not catch_all else None
|
||||
|
||||
def update_inputs(self, inputs: dict, executed_variables: dict) -> dict:
|
||||
for key in inputs.keys():
|
||||
if (
|
||||
isinstance(inputs[key], list)
|
||||
and inputs[key][0] in executed_variables.keys()
|
||||
):
|
||||
inputs[key] = {
|
||||
"variable_name": f"get_value_at_index({executed_variables[inputs[key][0]]}, {inputs[key][1]})"
|
||||
}
|
||||
return inputs
|
||||
@@ -0,0 +1,340 @@
|
||||
import inspect
|
||||
import threading
|
||||
from pprint import pformat
|
||||
from typing import Any
|
||||
|
||||
import black
|
||||
|
||||
from ..node_runtime import import_custom_nodes
|
||||
from .generated_helpers import (
|
||||
add_comfyui_directory_to_sys_path,
|
||||
add_extra_model_paths,
|
||||
bootstrap_comfyui_runtime,
|
||||
cleanup_comfyui_runtime,
|
||||
find_path,
|
||||
get_comfyui_path,
|
||||
get_value_at_index,
|
||||
)
|
||||
from .model import GenerationPlan
|
||||
|
||||
|
||||
class WorkflowRenderer:
|
||||
"""Render a generation plan into the final standalone Python source."""
|
||||
|
||||
def __init__(self, execution_mode: str = "oneshot"):
|
||||
self.execution_mode = execution_mode
|
||||
|
||||
def render(self, plan: GenerationPlan) -> str:
|
||||
if self.execution_mode == "session":
|
||||
return self._render_session_mode(plan)
|
||||
return self._render_oneshot_mode(plan)
|
||||
|
||||
# ── shared sections ──────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _build_entrypoint_section() -> list[str]:
|
||||
return [
|
||||
"# Entrypoint",
|
||||
'if __name__ == "__main__":',
|
||||
" main()",
|
||||
]
|
||||
|
||||
def _build_imports_section(self, plan: GenerationPlan) -> list[str]:
|
||||
func_strings = []
|
||||
for func in [
|
||||
get_value_at_index,
|
||||
get_comfyui_path,
|
||||
find_path,
|
||||
add_comfyui_directory_to_sys_path,
|
||||
add_extra_model_paths,
|
||||
bootstrap_comfyui_runtime,
|
||||
cleanup_comfyui_runtime,
|
||||
]:
|
||||
func_strings.append(f"\n{inspect.getsource(func)}")
|
||||
|
||||
static_imports = [
|
||||
"# Imports",
|
||||
"import json",
|
||||
"import os",
|
||||
"import random",
|
||||
"import sys",
|
||||
"import threading",
|
||||
"from typing import Sequence, Mapping, Any, Union",
|
||||
] + func_strings
|
||||
|
||||
static_imports.append(f"\n{inspect.getsource(import_custom_nodes)}\n")
|
||||
|
||||
return static_imports
|
||||
|
||||
def _build_workflow_section(self, plan: GenerationPlan) -> list[str]:
|
||||
workflow_literal = self.format_python_literal(plan.workflow_data)
|
||||
if plan.metadata_workflow_data is None:
|
||||
extra_pnginfo_literal = "None"
|
||||
else:
|
||||
extra_pnginfo_literal = self.format_python_literal(
|
||||
{"workflow": plan.metadata_workflow_data}
|
||||
)
|
||||
|
||||
return [
|
||||
"# Workflow data",
|
||||
"def build_workflow() -> dict[str, Any]:",
|
||||
f" return {workflow_literal}",
|
||||
"",
|
||||
"def build_extra_pnginfo() -> dict[str, Any] | None:",
|
||||
f" return {extra_pnginfo_literal}",
|
||||
"",
|
||||
"workflow = build_workflow()",
|
||||
"prompt = json.loads(json.dumps(workflow))",
|
||||
"extra_pnginfo = build_extra_pnginfo()",
|
||||
]
|
||||
|
||||
# ── oneshot renderer ─────────────────────────────────────────────
|
||||
|
||||
def _render_oneshot_mode(self, plan: GenerationPlan) -> str:
|
||||
imports_section = self._build_imports_section(plan)
|
||||
workflow_section = self._build_workflow_section(plan)
|
||||
execution_section = self._build_execution_section(plan)
|
||||
entrypoint_section = self._build_entrypoint_section()
|
||||
|
||||
final_code = "\n".join(
|
||||
imports_section
|
||||
+ [""]
|
||||
+ workflow_section
|
||||
+ [""]
|
||||
+ execution_section
|
||||
+ [""]
|
||||
+ entrypoint_section
|
||||
)
|
||||
return black.format_str(final_code, mode=black.Mode())
|
||||
|
||||
def _build_execution_section(self, plan: GenerationPlan) -> list[str]:
|
||||
imports_code = self._build_node_imports(plan.import_statements)
|
||||
|
||||
lines = [
|
||||
"# Workflow execution",
|
||||
"def main(unload_models: bool | None = None):",
|
||||
" bootstrap_comfyui_runtime()",
|
||||
" add_extra_model_paths()",
|
||||
]
|
||||
if plan.custom_nodes:
|
||||
lines.append(" import_custom_nodes()")
|
||||
if imports_code:
|
||||
lines.extend(["", " # Node imports"])
|
||||
lines.extend(f" {line}" for line in imports_code)
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
" import torch",
|
||||
"",
|
||||
" try:",
|
||||
" with torch.inference_mode():",
|
||||
]
|
||||
)
|
||||
lines.extend(
|
||||
self.build_function_body(
|
||||
plan.special_functions_code, "pass", indentation=" "
|
||||
).splitlines()
|
||||
)
|
||||
lines.append(f" for q in range({plan.queue_size}):")
|
||||
lines.extend(
|
||||
self.build_function_body(
|
||||
plan.loop_code, "pass", indentation=" "
|
||||
).splitlines()
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
" finally:",
|
||||
" cleanup_comfyui_runtime(unload_models=unload_models)",
|
||||
]
|
||||
)
|
||||
return lines
|
||||
|
||||
@staticmethod
|
||||
def _build_node_imports(
|
||||
import_statements: dict[str, set[str]],
|
||||
) -> list[str]:
|
||||
imports_code = []
|
||||
for module_name in sorted(import_statements.keys()):
|
||||
class_names = ", ".join(sorted(import_statements[module_name]))
|
||||
imports_code.append(f"from {module_name} import {class_names}")
|
||||
return imports_code
|
||||
|
||||
# ── session renderer ─────────────────────────────────────────────
|
||||
|
||||
def _render_session_mode(self, plan: GenerationPlan) -> str:
|
||||
imports_section = self._build_imports_section(plan)
|
||||
workflow_section = self._build_workflow_section(plan)
|
||||
session_class = self._build_session_class(plan)
|
||||
main_wrapper = self._build_main_wrapper(plan)
|
||||
entrypoint_section = self._build_entrypoint_section()
|
||||
|
||||
final_code = "\n".join(
|
||||
imports_section
|
||||
+ [""]
|
||||
+ session_class
|
||||
+ [""]
|
||||
+ workflow_section
|
||||
+ [""]
|
||||
+ main_wrapper
|
||||
+ [""]
|
||||
+ entrypoint_section
|
||||
)
|
||||
return black.format_str(final_code, mode=black.Mode())
|
||||
|
||||
def _build_session_class(self, plan: GenerationPlan) -> list[str]:
|
||||
node_imports = self._build_node_imports(plan.import_statements)
|
||||
node_import_lines = []
|
||||
if node_imports:
|
||||
node_import_lines.append("")
|
||||
node_import_lines.extend(f" {line}" for line in node_imports)
|
||||
node_import_lines.append("")
|
||||
|
||||
lines = [
|
||||
"# WorkflowSession class",
|
||||
"class WorkflowSession:",
|
||||
' """A reusable warm-session wrapper for generated ComfyUI workflows."""',
|
||||
"",
|
||||
' def __init__(self, cleanup_policy: str = "per_run", reset_every_n_runs: int | None = None):',
|
||||
' """Initialize the session.',
|
||||
"",
|
||||
' Args:',
|
||||
' cleanup_policy: One of "per_run", "session", or "manual".',
|
||||
' reset_every_n_runs: If set, soft-reset every N runs.',
|
||||
' """',
|
||||
" self._bootstrapped = False",
|
||||
" self._custom_nodes_initialized = False",
|
||||
" self._node_instances = {}",
|
||||
" self._lock = threading.Lock()",
|
||||
" self._closed = False",
|
||||
" self._cleanup_policy = cleanup_policy",
|
||||
" self._reset_every_n_runs = reset_every_n_runs",
|
||||
" self._run_count = 0",
|
||||
f" self._queue_size = {plan.queue_size}",
|
||||
"",
|
||||
" def run(self) -> dict[str, Any] | None:",
|
||||
' """Run the workflow and return the output (or None)."""',
|
||||
" with self._lock:",
|
||||
" if self._closed:",
|
||||
" raise RuntimeError('Session is closed')",
|
||||
"",
|
||||
" if not self._bootstrapped:",
|
||||
" self._bootstrapped = True",
|
||||
" bootstrap_comfyui_runtime()",
|
||||
"",
|
||||
]
|
||||
lines.extend(
|
||||
[
|
||||
" if not self._custom_nodes_initialized:",
|
||||
" self._custom_nodes_initialized = True",
|
||||
]
|
||||
)
|
||||
if plan.custom_nodes:
|
||||
lines.append(" import_custom_nodes()")
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
" prompt = json.loads(json.dumps(build_workflow()))",
|
||||
" extra_pnginfo = build_extra_pnginfo()",
|
||||
"",
|
||||
]
|
||||
)
|
||||
lines.extend(node_import_lines)
|
||||
lines.extend(
|
||||
[
|
||||
" import torch",
|
||||
" try:",
|
||||
" with torch.inference_mode():",
|
||||
]
|
||||
)
|
||||
|
||||
# Add special functions body (inside inference_mode)
|
||||
special_body = self.build_function_body(
|
||||
plan.special_functions_code, "pass", indentation=" "
|
||||
)
|
||||
lines.extend(special_body.splitlines())
|
||||
|
||||
lines.append(" for q in range(self._queue_size):")
|
||||
|
||||
# Add loop code (node instantiations + calls)
|
||||
loop_body = self.build_function_body(
|
||||
plan.loop_code, "pass", indentation=" "
|
||||
)
|
||||
lines.extend(loop_body.splitlines())
|
||||
|
||||
# Build outputs collection: outputs = {node_id: var_name, ...}
|
||||
# Inside try, after for loop (same level as for loop), so 16 spaces
|
||||
executed_vars = plan.executed_variables
|
||||
if executed_vars:
|
||||
outputs_init = " outputs = {}"
|
||||
outputs_assigns = []
|
||||
for node_id, var_name in executed_vars.items():
|
||||
outputs_assigns.append(f" outputs[{node_id!r}] = {var_name}")
|
||||
run_increment = " self._run_count += 1"
|
||||
outputs_return = " return outputs"
|
||||
lines.append(outputs_init)
|
||||
lines.extend(outputs_assigns)
|
||||
lines.append(run_increment)
|
||||
lines.append(outputs_return)
|
||||
else:
|
||||
run_increment = " self._run_count += 1"
|
||||
outputs_return = " return None"
|
||||
lines.append(run_increment)
|
||||
lines.append(outputs_return)
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
" finally:",
|
||||
" if self._cleanup_policy == 'per_run':",
|
||||
" cleanup_comfyui_runtime(unload_models=True)",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
# close() method
|
||||
lines.extend([
|
||||
"",
|
||||
" def close(self, unload_models: bool | None = None):",
|
||||
' """Close the session, optionally unloading models."""',
|
||||
" with self._lock:",
|
||||
" if self._closed:",
|
||||
" return",
|
||||
" if self._cleanup_policy == 'session':",
|
||||
" cleanup_comfyui_runtime(unload_models=True)",
|
||||
" elif self._cleanup_policy == 'manual':",
|
||||
" self._bootstrapped = False",
|
||||
" self._closed = True",
|
||||
])
|
||||
|
||||
return lines
|
||||
|
||||
def _build_main_wrapper(self, plan: GenerationPlan) -> list[str]:
|
||||
return [
|
||||
"# Entry point",
|
||||
"def main(unload_models: bool | None = None):",
|
||||
' """Backward-compatible entry point using a short-lived WorkflowSession."""',
|
||||
' session = WorkflowSession(cleanup_policy="per_run")',
|
||||
" try:",
|
||||
" session.run()",
|
||||
" finally:",
|
||||
" session.close(unload_models=unload_models)",
|
||||
]
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def format_python_literal(value: Any) -> str:
|
||||
return pformat(value, sort_dicts=False)
|
||||
|
||||
@staticmethod
|
||||
def build_function_body(
|
||||
code_lines: list[str], empty_fallback: str, indentation: str = " "
|
||||
) -> str:
|
||||
if not code_lines:
|
||||
return f"{indentation}{empty_fallback}"
|
||||
formatted_lines = []
|
||||
for line in code_lines:
|
||||
stripped_line = line.lstrip()
|
||||
if not stripped_line.endswith("\n"):
|
||||
stripped_line += "\n"
|
||||
formatted_lines.append(f"{indentation}{stripped_line}")
|
||||
return "".join(formatted_lines).rstrip()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,172 @@
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
from typing import Any, Mapping, Sequence, Union
|
||||
|
||||
|
||||
def find_path(name: str, path: str = None) -> str:
|
||||
"""Recursively search parent folders until the named entry is found."""
|
||||
if path is None:
|
||||
path = os.getcwd()
|
||||
|
||||
if name in os.listdir(path):
|
||||
path_name = os.path.join(path, name)
|
||||
print(f"{name} found: {path_name}")
|
||||
return path_name
|
||||
|
||||
parent_directory = os.path.dirname(path)
|
||||
if parent_directory == path:
|
||||
return None
|
||||
|
||||
return find_path(name, parent_directory)
|
||||
|
||||
|
||||
def get_comfyui_path() -> str:
|
||||
"""Return the configured ComfyUI path, preferring COMFYUI_PATH when set."""
|
||||
comfyui_path = os.environ.get("COMFYUI_PATH")
|
||||
if comfyui_path:
|
||||
return comfyui_path
|
||||
return find_path("ComfyUI")
|
||||
|
||||
|
||||
def add_comfyui_directory_to_sys_path() -> None:
|
||||
"""Add the ComfyUI checkout to sys.path."""
|
||||
comfyui_path = get_comfyui_path()
|
||||
if comfyui_path is not None and os.path.isdir(comfyui_path):
|
||||
if comfyui_path in sys.path:
|
||||
sys.path.remove(comfyui_path)
|
||||
sys.path.insert(0, comfyui_path)
|
||||
print(f"'{comfyui_path}' added to sys.path")
|
||||
|
||||
|
||||
def add_extra_model_paths() -> None:
|
||||
"""Load ComfyUI extra model paths configuration when available."""
|
||||
try:
|
||||
from main import load_extra_path_config
|
||||
except ImportError:
|
||||
print(
|
||||
"Could not import load_extra_path_config from main.py. Looking in utils.extra_config instead."
|
||||
)
|
||||
from utils.extra_config import load_extra_path_config
|
||||
|
||||
extra_model_paths = find_path("extra_model_paths.yaml")
|
||||
if extra_model_paths is not None:
|
||||
load_extra_path_config(extra_model_paths)
|
||||
else:
|
||||
print("Could not find the extra_model_paths config file.")
|
||||
|
||||
|
||||
def bootstrap_comfyui_runtime() -> None:
|
||||
"""Mirror the allocator-related ComfyUI startup steps before torch import."""
|
||||
add_comfyui_directory_to_sys_path()
|
||||
|
||||
import comfy.options
|
||||
|
||||
comfy.options.enable_args_parsing()
|
||||
|
||||
from comfy.cli_args import args
|
||||
|
||||
if os.name == "nt":
|
||||
os.environ["MIMALLOC_PURGE_DELAY"] = "0"
|
||||
|
||||
if args.default_device is not None:
|
||||
default_dev = args.default_device
|
||||
devices = list(range(32))
|
||||
devices.remove(default_dev)
|
||||
devices.insert(0, default_dev)
|
||||
devices = ",".join(map(str, devices))
|
||||
os.environ["CUDA_VISIBLE_DEVICES"] = str(devices)
|
||||
os.environ["HIP_VISIBLE_DEVICES"] = str(devices)
|
||||
|
||||
if args.cuda_device is not None:
|
||||
os.environ["CUDA_VISIBLE_DEVICES"] = str(args.cuda_device)
|
||||
os.environ["HIP_VISIBLE_DEVICES"] = str(args.cuda_device)
|
||||
os.environ["ASCEND_RT_VISIBLE_DEVICES"] = str(args.cuda_device)
|
||||
|
||||
if args.oneapi_device_selector is not None:
|
||||
os.environ["ONEAPI_DEVICE_SELECTOR"] = args.oneapi_device_selector
|
||||
|
||||
if args.deterministic and "CUBLAS_WORKSPACE_CONFIG" not in os.environ:
|
||||
os.environ["CUBLAS_WORKSPACE_CONFIG"] = ":4096:8"
|
||||
|
||||
import cuda_malloc
|
||||
|
||||
if "rocm" in cuda_malloc.get_torch_version_noimport():
|
||||
os.environ["OCL_SET_SVM_SIZE"] = "262144"
|
||||
|
||||
|
||||
def cleanup_comfyui_runtime(unload_models: bool | None = None) -> None:
|
||||
"""Best-effort cleanup for embedded or repeated generated-script execution."""
|
||||
import gc
|
||||
|
||||
def run_cleanup_hook(name: str, should_run: bool = True) -> None:
|
||||
if not should_run or not hasattr(model_management, name):
|
||||
return
|
||||
cleanup_fn = getattr(model_management, name)
|
||||
try:
|
||||
cleanup_fn()
|
||||
except Exception as exc:
|
||||
warnings.warn(
|
||||
f"ComfyUI cleanup hook {name} failed during teardown: {exc}",
|
||||
RuntimeWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
should_unload = unload_models
|
||||
if should_unload is None:
|
||||
should_unload = os.environ.get("COMFYUI_TOPYTHON_UNLOAD_MODELS", "").lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
|
||||
try:
|
||||
import comfy.model_management as model_management
|
||||
except ModuleNotFoundError:
|
||||
gc.collect()
|
||||
return
|
||||
|
||||
run_cleanup_hook("cleanup_models_gc")
|
||||
run_cleanup_hook("unload_all_models", should_run=should_unload)
|
||||
run_cleanup_hook("soft_empty_cache")
|
||||
gc.collect()
|
||||
|
||||
|
||||
def import_custom_nodes() -> None:
|
||||
"""Initialize ComfyUI custom nodes in the exporter runtime."""
|
||||
comfyui_path = get_comfyui_path()
|
||||
if comfyui_path and comfyui_path not in sys.path:
|
||||
sys.path.insert(0, comfyui_path)
|
||||
|
||||
import asyncio
|
||||
import execution
|
||||
from nodes import init_extra_nodes
|
||||
|
||||
if comfyui_path in sys.path:
|
||||
sys.path.remove(comfyui_path)
|
||||
sys.path.insert(0, comfyui_path)
|
||||
|
||||
import server
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
server_instance = server.PromptServer(loop)
|
||||
execution.PromptQueue(server_instance)
|
||||
asyncio.run(init_extra_nodes())
|
||||
|
||||
|
||||
def get_node_class_mappings() -> dict:
|
||||
"""Load ComfyUI node mappings on demand."""
|
||||
add_comfyui_directory_to_sys_path()
|
||||
from nodes import NODE_CLASS_MAPPINGS
|
||||
|
||||
return NODE_CLASS_MAPPINGS
|
||||
|
||||
|
||||
def get_value_at_index(obj: Union[Sequence, Mapping], index: int) -> Any:
|
||||
"""Return a sequence or mapping result item by index."""
|
||||
try:
|
||||
return obj[index]
|
||||
except KeyError:
|
||||
return obj["result"][index]
|
||||
@@ -0,0 +1,205 @@
|
||||
import gc
|
||||
import json
|
||||
import threading
|
||||
from typing import Any, Literal
|
||||
|
||||
from comfyui_to_python.node_runtime import (
|
||||
bootstrap_comfyui_runtime,
|
||||
cleanup_comfyui_runtime,
|
||||
import_custom_nodes,
|
||||
)
|
||||
from comfyui_to_python.generator.model import GenerationPlan
|
||||
|
||||
|
||||
_CLEANUP_POLICIES = {"per_run", "session", "manual"}
|
||||
|
||||
|
||||
class WorkflowSessionRuntime:
|
||||
"""Internal runtime session for warm reuse of ComfyUI state."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cleanup_policy: Literal["per_run", "session", "manual"] = "session",
|
||||
reset_every_n_runs: int | None = None,
|
||||
):
|
||||
if cleanup_policy not in _CLEANUP_POLICIES:
|
||||
raise ValueError(
|
||||
f"cleanup_policy must be one of {sorted(_CLEANUP_POLICIES)}, "
|
||||
f"got {cleanup_policy!r}"
|
||||
)
|
||||
|
||||
self._cleanup_policy: Literal["per_run", "session", "manual"] = cleanup_policy
|
||||
self._reset_every_n_runs: int | None = reset_every_n_runs
|
||||
|
||||
self.bootstrapped: bool = False
|
||||
self.custom_nodes_initialized: bool = False
|
||||
self.node_instances: dict[str, Any] = {}
|
||||
self._node_classes: dict[str, Any] = {}
|
||||
self.run_count: int = 0
|
||||
self._closed: bool = False
|
||||
self._lock: threading.Lock = threading.Lock()
|
||||
self._workflow_data: dict | None = None
|
||||
self._node_class_mappings: dict | None = None
|
||||
self._extra_pnginfo: dict | None = None
|
||||
|
||||
def _ensure_bootstrapped(self) -> None:
|
||||
if self.bootstrapped:
|
||||
return
|
||||
bootstrap_comfyui_runtime()
|
||||
self.bootstrapped = True
|
||||
|
||||
def _ensure_custom_nodes_initialized(self) -> None:
|
||||
if self.custom_nodes_initialized:
|
||||
return
|
||||
import_custom_nodes()
|
||||
self.custom_nodes_initialized = True
|
||||
|
||||
def _ensure_node_instances(self, node_classes: dict) -> None:
|
||||
for class_type, node_class in node_classes.items():
|
||||
if class_type in self.node_instances:
|
||||
continue
|
||||
self.node_instances[class_type] = node_class()
|
||||
self._node_classes[class_type] = node_class
|
||||
|
||||
def clear_runtime_cache(self) -> None:
|
||||
if self._cleanup_policy == "session":
|
||||
return
|
||||
if self._cleanup_policy == "per_run":
|
||||
cleanup_comfyui_runtime(unload_models=True)
|
||||
|
||||
def close(self, unload_models: bool = True) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._do_close(unload_models=unload_models)
|
||||
self._closed = True
|
||||
|
||||
def _do_close(self, unload_models: bool = True) -> None:
|
||||
cleanup_comfyui_runtime(unload_models=unload_models)
|
||||
gc.collect()
|
||||
|
||||
def _do_run(self) -> Any:
|
||||
if not self._workflow_data or not self._node_class_mappings:
|
||||
return None
|
||||
|
||||
prompt = json.loads(json.dumps(self._workflow_data))
|
||||
extra_pnginfo = self._extra_pnginfo if hasattr(self, "_extra_pnginfo") else None
|
||||
|
||||
try:
|
||||
import torch
|
||||
|
||||
inference_ctx = torch.inference_mode
|
||||
except ImportError:
|
||||
|
||||
def inference_ctx():
|
||||
class _DummyCtx:
|
||||
def __enter__(self):
|
||||
pass
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
return _DummyCtx()
|
||||
|
||||
with inference_ctx():
|
||||
outputs = {}
|
||||
for node_id, node in prompt.items():
|
||||
class_type = node.get("class_type", "")
|
||||
inputs = node.get("inputs", {})
|
||||
if class_type not in self.node_instances:
|
||||
continue
|
||||
node_instance = self.node_instances[class_type]
|
||||
node_class = self._node_classes.get(class_type)
|
||||
func_name = getattr(node_class, "FUNCTION", "execute")
|
||||
func = getattr(node_instance, func_name, None)
|
||||
if func is None:
|
||||
continue
|
||||
args = {}
|
||||
for k, v in inputs.items():
|
||||
args[k] = v
|
||||
result = func(**args)
|
||||
if isinstance(result, tuple) or isinstance(result, list):
|
||||
outputs[node_id] = list(result)
|
||||
else:
|
||||
outputs[node_id] = result
|
||||
|
||||
return outputs
|
||||
|
||||
def run(
|
||||
self,
|
||||
workflow_data: dict | None = None,
|
||||
node_class_mappings: dict | None = None,
|
||||
extra_pnginfo: dict | None = None,
|
||||
) -> Any:
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
raise RuntimeError(
|
||||
"Cannot run() on a closed WorkflowSessionRuntime"
|
||||
)
|
||||
|
||||
self._workflow_data = workflow_data or self._workflow_data
|
||||
self._node_class_mappings = (
|
||||
node_class_mappings or self._node_class_mappings
|
||||
)
|
||||
self._extra_pnginfo = extra_pnginfo or self._extra_pnginfo
|
||||
|
||||
if self._node_class_mappings:
|
||||
self._ensure_node_instances(self._node_class_mappings)
|
||||
|
||||
try:
|
||||
result = self._do_run()
|
||||
except Exception:
|
||||
raise
|
||||
else:
|
||||
if self._cleanup_policy == "per_run":
|
||||
self.clear_runtime_cache()
|
||||
self.run_count += 1
|
||||
|
||||
if (
|
||||
self._reset_every_n_runs
|
||||
and self.run_count % self._reset_every_n_runs == 0
|
||||
):
|
||||
self._do_close(unload_models=False)
|
||||
self._closed = False
|
||||
self.bootstrapped = False
|
||||
self.custom_nodes_initialized = False
|
||||
self.node_instances.clear()
|
||||
self._node_classes.clear()
|
||||
self.run_count = 0
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class WorkflowSession:
|
||||
"""A reusable warm-session wrapper for generated ComfyUI workflows.
|
||||
|
||||
This is the public API class that wraps WorkflowSessionRuntime.
|
||||
It delegates all method calls to the internal runtime instance.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cleanup_policy: Literal["per_run", "session", "manual"] = "session",
|
||||
reset_every_n_runs: int | None = None,
|
||||
):
|
||||
self._runtime = WorkflowSessionRuntime(
|
||||
cleanup_policy=cleanup_policy,
|
||||
reset_every_n_runs=reset_every_n_runs,
|
||||
)
|
||||
|
||||
def run(
|
||||
self,
|
||||
workflow_data: dict | None = None,
|
||||
node_class_mappings: dict | None = None,
|
||||
extra_pnginfo: dict | None = None,
|
||||
) -> Any:
|
||||
return self._runtime.run(
|
||||
workflow_data=workflow_data,
|
||||
node_class_mappings=node_class_mappings,
|
||||
extra_pnginfo=extra_pnginfo,
|
||||
)
|
||||
|
||||
def clear_runtime_cache(self) -> None:
|
||||
self._runtime.clear_runtime_cache()
|
||||
|
||||
def close(self, unload_models: bool = True) -> None:
|
||||
self._runtime.close(unload_models=unload_models)
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Mock the ComfyUI 'server' module so the root __init__.py can be imported by pytest."""
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
sys.modules["server"] = MagicMock()
|
||||
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 |
+64
-98
@@ -1,111 +1,77 @@
|
||||
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({
|
||||
const DEFAULT_SCRIPT_FILENAME = "workflow_api.py";
|
||||
const DEFAULT_WORKFLOW_NAME = "workflow_api.json";
|
||||
|
||||
function $el(tag, options = {}) {
|
||||
const element = document.createElement(tag);
|
||||
const { parent, style, ...props } = options;
|
||||
|
||||
if (style) {
|
||||
Object.assign(element.style, style);
|
||||
}
|
||||
|
||||
Object.assign(element, props);
|
||||
|
||||
if (parent) {
|
||||
parent.appendChild(element);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
const extension = {
|
||||
name: "Comfy.SaveAsScript",
|
||||
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";
|
||||
}
|
||||
savePythonScript() {
|
||||
const filename = DEFAULT_SCRIPT_FILENAME;
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
app.graphToPrompt().then(async (p) => {
|
||||
const frontendWorkflow = p.workflow ?? app.graph.serialize();
|
||||
const json = JSON.stringify({
|
||||
name: DEFAULT_WORKFLOW_NAME,
|
||||
workflow: JSON.stringify(p.output, null, 2),
|
||||
frontend_workflow: JSON.stringify(frontendWorkflow, null, 2),
|
||||
}, null, 2); // convert the data to a JSON string
|
||||
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);
|
||||
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);
|
||||
|
||||
+3
-2
@@ -1,8 +1,9 @@
|
||||
[project]
|
||||
name = "comfyui-to-python-extension"
|
||||
description = "This custom node allows you to generate pure python code from your ComfyUI workflow with the click of a button. Great for rapid experimentation or production deployment."
|
||||
version = "1.3.0"
|
||||
license = "LICENSE"
|
||||
version = "2.1.0"
|
||||
license = { text = "MIT License" }
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["black"]
|
||||
|
||||
[project.urls]
|
||||
|
||||
+126
@@ -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.
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Pytest configuration to handle the ComfyUI extension __init__.py at repo root."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_test_path():
|
||||
"""Ensure the package is importable during tests."""
|
||||
# Add the repo root to sys.path so `from comfyui_to_python import ...` works
|
||||
repo_root = Path(__file__).parent.parent.resolve()
|
||||
if str(repo_root) not in sys.path:
|
||||
sys.path.insert(0, str(repo_root))
|
||||
yield
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"1": {
|
||||
"class_type": "SplitText",
|
||||
"inputs": {
|
||||
"text": "alpha|omega"
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"class_type": "PassthroughText",
|
||||
"inputs": {
|
||||
"text": [
|
||||
"1",
|
||||
1
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
# Imports
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from typing import Sequence, Mapping, Any, Union
|
||||
|
||||
|
||||
def get_value_at_index(obj: Union[Sequence, Mapping], index: int) -> Any:
|
||||
"""Return a sequence or mapping result item by index."""
|
||||
try:
|
||||
return obj[index]
|
||||
except KeyError:
|
||||
return obj["result"][index]
|
||||
|
||||
|
||||
def get_comfyui_path() -> str:
|
||||
"""Return the configured ComfyUI path, preferring COMFYUI_PATH when set."""
|
||||
comfyui_path = os.environ.get("COMFYUI_PATH")
|
||||
if comfyui_path:
|
||||
return comfyui_path
|
||||
return find_path("ComfyUI")
|
||||
|
||||
|
||||
def find_path(name: str, path: str = None) -> str:
|
||||
"""Recursively search parent folders until the named entry is found."""
|
||||
if path is None:
|
||||
path = os.getcwd()
|
||||
|
||||
if name in os.listdir(path):
|
||||
path_name = os.path.join(path, name)
|
||||
print(f"{name} found: {path_name}")
|
||||
return path_name
|
||||
|
||||
parent_directory = os.path.dirname(path)
|
||||
if parent_directory == path:
|
||||
return None
|
||||
|
||||
return find_path(name, parent_directory)
|
||||
|
||||
|
||||
def add_comfyui_directory_to_sys_path() -> None:
|
||||
"""Add the ComfyUI checkout to sys.path."""
|
||||
comfyui_path = get_comfyui_path()
|
||||
if comfyui_path is not None and os.path.isdir(comfyui_path):
|
||||
if comfyui_path in sys.path:
|
||||
sys.path.remove(comfyui_path)
|
||||
sys.path.insert(0, comfyui_path)
|
||||
print(f"'{comfyui_path}' added to sys.path")
|
||||
|
||||
|
||||
def add_extra_model_paths() -> None:
|
||||
"""Load ComfyUI extra model paths configuration when available."""
|
||||
try:
|
||||
from main import load_extra_path_config
|
||||
except ImportError:
|
||||
print(
|
||||
"Could not import load_extra_path_config from main.py. Looking in utils.extra_config instead."
|
||||
)
|
||||
from utils.extra_config import load_extra_path_config
|
||||
|
||||
extra_model_paths = find_path("extra_model_paths.yaml")
|
||||
if extra_model_paths is not None:
|
||||
load_extra_path_config(extra_model_paths)
|
||||
else:
|
||||
print("Could not find the extra_model_paths config file.")
|
||||
|
||||
|
||||
def bootstrap_comfyui_runtime() -> None:
|
||||
"""Mirror the allocator-related ComfyUI startup steps before torch import."""
|
||||
add_comfyui_directory_to_sys_path()
|
||||
|
||||
import comfy.options
|
||||
|
||||
comfy.options.enable_args_parsing()
|
||||
|
||||
from comfy.cli_args import args
|
||||
|
||||
if os.name == "nt":
|
||||
os.environ["MIMALLOC_PURGE_DELAY"] = "0"
|
||||
|
||||
if args.default_device is not None:
|
||||
default_dev = args.default_device
|
||||
devices = list(range(32))
|
||||
devices.remove(default_dev)
|
||||
devices.insert(0, default_dev)
|
||||
devices = ",".join(map(str, devices))
|
||||
os.environ["CUDA_VISIBLE_DEVICES"] = str(devices)
|
||||
os.environ["HIP_VISIBLE_DEVICES"] = str(devices)
|
||||
|
||||
if args.cuda_device is not None:
|
||||
os.environ["CUDA_VISIBLE_DEVICES"] = str(args.cuda_device)
|
||||
os.environ["HIP_VISIBLE_DEVICES"] = str(args.cuda_device)
|
||||
os.environ["ASCEND_RT_VISIBLE_DEVICES"] = str(args.cuda_device)
|
||||
|
||||
if args.oneapi_device_selector is not None:
|
||||
os.environ["ONEAPI_DEVICE_SELECTOR"] = args.oneapi_device_selector
|
||||
|
||||
if args.deterministic and "CUBLAS_WORKSPACE_CONFIG" not in os.environ:
|
||||
os.environ["CUBLAS_WORKSPACE_CONFIG"] = ":4096:8"
|
||||
|
||||
import cuda_malloc
|
||||
|
||||
if "rocm" in cuda_malloc.get_torch_version_noimport():
|
||||
os.environ["OCL_SET_SVM_SIZE"] = "262144"
|
||||
|
||||
|
||||
def cleanup_comfyui_runtime(unload_models: bool | None = None) -> None:
|
||||
"""Best-effort cleanup for embedded or repeated generated-script execution."""
|
||||
import gc
|
||||
|
||||
def run_cleanup_hook(name: str, should_run: bool = True) -> None:
|
||||
if not should_run or not hasattr(model_management, name):
|
||||
return
|
||||
cleanup_fn = getattr(model_management, name)
|
||||
try:
|
||||
cleanup_fn()
|
||||
except Exception as exc:
|
||||
warnings.warn(
|
||||
f"ComfyUI cleanup hook {name} failed during teardown: {exc}",
|
||||
RuntimeWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
should_unload = unload_models
|
||||
if should_unload is None:
|
||||
should_unload = os.environ.get(
|
||||
"COMFYUI_TOPYTHON_UNLOAD_MODELS", ""
|
||||
).lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
|
||||
try:
|
||||
import comfy.model_management as model_management
|
||||
except ModuleNotFoundError:
|
||||
gc.collect()
|
||||
return
|
||||
|
||||
run_cleanup_hook("cleanup_models_gc")
|
||||
run_cleanup_hook("unload_all_models", should_run=should_unload)
|
||||
run_cleanup_hook("soft_empty_cache")
|
||||
gc.collect()
|
||||
|
||||
|
||||
# Workflow data
|
||||
def build_workflow() -> dict[str, Any]:
|
||||
return {
|
||||
"1": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": "v1-5-pruned-emaonly-fp16.safetensors"},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "a small cottage in a meadow, soft daylight",
|
||||
"clip": ["1", 1],
|
||||
},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "blurry, low quality", "clip": ["1", 1]},
|
||||
},
|
||||
"4": {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": 512, "height": 512, "batch_size": 1},
|
||||
},
|
||||
"5": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": 1,
|
||||
"steps": 4,
|
||||
"cfg": 7,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": 1,
|
||||
"model": ["1", 0],
|
||||
"positive": ["2", 0],
|
||||
"negative": ["3", 0],
|
||||
"latent_image": ["4", 0],
|
||||
},
|
||||
},
|
||||
"6": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["5", 0], "vae": ["1", 2]},
|
||||
},
|
||||
"7": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": "E2E_text_to_image", "images": ["6", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_extra_pnginfo() -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
workflow = build_workflow()
|
||||
prompt = json.loads(json.dumps(workflow))
|
||||
extra_pnginfo = build_extra_pnginfo()
|
||||
|
||||
|
||||
# Workflow execution
|
||||
def main(unload_models: bool | None = None):
|
||||
bootstrap_comfyui_runtime()
|
||||
add_extra_model_paths()
|
||||
|
||||
# Node imports
|
||||
from nodes import (
|
||||
CLIPTextEncode,
|
||||
CheckpointLoaderSimple,
|
||||
EmptyLatentImage,
|
||||
KSampler,
|
||||
NODE_CLASS_MAPPINGS,
|
||||
SaveImage,
|
||||
VAEDecode,
|
||||
)
|
||||
|
||||
import torch
|
||||
|
||||
try:
|
||||
with torch.inference_mode():
|
||||
checkpointloadersimple = CheckpointLoaderSimple()
|
||||
checkpointloadersimple_1 = checkpointloadersimple.load_checkpoint(
|
||||
ckpt_name="v1-5-pruned-emaonly-fp16.safetensors"
|
||||
)
|
||||
cliptextencode = CLIPTextEncode()
|
||||
cliptextencode_2 = cliptextencode.encode(
|
||||
text="a small cottage in a meadow, soft daylight",
|
||||
clip=get_value_at_index(checkpointloadersimple_1, 1),
|
||||
)
|
||||
cliptextencode_3 = cliptextencode.encode(
|
||||
text="blurry, low quality",
|
||||
clip=get_value_at_index(checkpointloadersimple_1, 1),
|
||||
)
|
||||
emptylatentimage = EmptyLatentImage()
|
||||
emptylatentimage_4 = emptylatentimage.generate(
|
||||
width=512, height=512, batch_size=1
|
||||
)
|
||||
ksampler = KSampler()
|
||||
vaedecode = VAEDecode()
|
||||
saveimage = SaveImage()
|
||||
for q in range(1):
|
||||
node_5_seed = prompt["5"]["inputs"]["seed"] = random.randint(1, 2**64)
|
||||
ksampler_5 = ksampler.sample(
|
||||
seed=node_5_seed,
|
||||
steps=4,
|
||||
cfg=7,
|
||||
sampler_name="euler",
|
||||
scheduler="normal",
|
||||
denoise=1,
|
||||
model=get_value_at_index(checkpointloadersimple_1, 0),
|
||||
positive=get_value_at_index(cliptextencode_2, 0),
|
||||
negative=get_value_at_index(cliptextencode_3, 0),
|
||||
latent_image=get_value_at_index(emptylatentimage_4, 0),
|
||||
)
|
||||
vaedecode_6 = vaedecode.decode(
|
||||
samples=get_value_at_index(ksampler_5, 0),
|
||||
vae=get_value_at_index(checkpointloadersimple_1, 2),
|
||||
)
|
||||
saveimage_7 = saveimage.save_images(
|
||||
filename_prefix="E2E_text_to_image",
|
||||
images=get_value_at_index(vaedecode_6, 0),
|
||||
prompt=prompt,
|
||||
extra_pnginfo=extra_pnginfo,
|
||||
)
|
||||
finally:
|
||||
cleanup_comfyui_runtime(unload_models=unload_models)
|
||||
|
||||
|
||||
# Entrypoint
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,230 @@
|
||||
# Imports
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from typing import Sequence, Mapping, Any, Union
|
||||
|
||||
|
||||
def get_value_at_index(obj: Union[Sequence, Mapping], index: int) -> Any:
|
||||
"""Return a sequence or mapping result item by index."""
|
||||
try:
|
||||
return obj[index]
|
||||
except KeyError:
|
||||
return obj["result"][index]
|
||||
|
||||
|
||||
def get_comfyui_path() -> str:
|
||||
"""Return the configured ComfyUI path, preferring COMFYUI_PATH when set."""
|
||||
comfyui_path = os.environ.get("COMFYUI_PATH")
|
||||
if comfyui_path:
|
||||
return comfyui_path
|
||||
return find_path("ComfyUI")
|
||||
|
||||
|
||||
def find_path(name: str, path: str = None) -> str:
|
||||
"""Recursively search parent folders until the named entry is found."""
|
||||
if path is None:
|
||||
path = os.getcwd()
|
||||
|
||||
if name in os.listdir(path):
|
||||
path_name = os.path.join(path, name)
|
||||
print(f"{name} found: {path_name}")
|
||||
return path_name
|
||||
|
||||
parent_directory = os.path.dirname(path)
|
||||
if parent_directory == path:
|
||||
return None
|
||||
|
||||
return find_path(name, parent_directory)
|
||||
|
||||
|
||||
def add_comfyui_directory_to_sys_path() -> None:
|
||||
"""Add the ComfyUI checkout to sys.path."""
|
||||
comfyui_path = get_comfyui_path()
|
||||
if comfyui_path is not None and os.path.isdir(comfyui_path):
|
||||
if comfyui_path in sys.path:
|
||||
sys.path.remove(comfyui_path)
|
||||
sys.path.insert(0, comfyui_path)
|
||||
print(f"'{comfyui_path}' added to sys.path")
|
||||
|
||||
|
||||
def add_extra_model_paths() -> None:
|
||||
"""Load ComfyUI extra model paths configuration when available."""
|
||||
try:
|
||||
from main import load_extra_path_config
|
||||
except ImportError:
|
||||
print(
|
||||
"Could not import load_extra_path_config from main.py. Looking in utils.extra_config instead."
|
||||
)
|
||||
from utils.extra_config import load_extra_path_config
|
||||
|
||||
extra_model_paths = find_path("extra_model_paths.yaml")
|
||||
if extra_model_paths is not None:
|
||||
load_extra_path_config(extra_model_paths)
|
||||
else:
|
||||
print("Could not find the extra_model_paths config file.")
|
||||
|
||||
|
||||
def bootstrap_comfyui_runtime() -> None:
|
||||
"""Mirror the allocator-related ComfyUI startup steps before torch import."""
|
||||
add_comfyui_directory_to_sys_path()
|
||||
|
||||
import comfy.options
|
||||
|
||||
comfy.options.enable_args_parsing()
|
||||
|
||||
from comfy.cli_args import args
|
||||
|
||||
if os.name == "nt":
|
||||
os.environ["MIMALLOC_PURGE_DELAY"] = "0"
|
||||
|
||||
if args.default_device is not None:
|
||||
default_dev = args.default_device
|
||||
devices = list(range(32))
|
||||
devices.remove(default_dev)
|
||||
devices.insert(0, default_dev)
|
||||
devices = ",".join(map(str, devices))
|
||||
os.environ["CUDA_VISIBLE_DEVICES"] = str(devices)
|
||||
os.environ["HIP_VISIBLE_DEVICES"] = str(devices)
|
||||
|
||||
if args.cuda_device is not None:
|
||||
os.environ["CUDA_VISIBLE_DEVICES"] = str(args.cuda_device)
|
||||
os.environ["HIP_VISIBLE_DEVICES"] = str(args.cuda_device)
|
||||
os.environ["ASCEND_RT_VISIBLE_DEVICES"] = str(args.cuda_device)
|
||||
|
||||
if args.oneapi_device_selector is not None:
|
||||
os.environ["ONEAPI_DEVICE_SELECTOR"] = args.oneapi_device_selector
|
||||
|
||||
if args.deterministic and "CUBLAS_WORKSPACE_CONFIG" not in os.environ:
|
||||
os.environ["CUBLAS_WORKSPACE_CONFIG"] = ":4096:8"
|
||||
|
||||
import cuda_malloc
|
||||
|
||||
if "rocm" in cuda_malloc.get_torch_version_noimport():
|
||||
os.environ["OCL_SET_SVM_SIZE"] = "262144"
|
||||
|
||||
|
||||
def cleanup_comfyui_runtime(unload_models: bool | None = None) -> None:
|
||||
"""Best-effort cleanup for embedded or repeated generated-script execution."""
|
||||
import gc
|
||||
|
||||
should_unload = unload_models
|
||||
if should_unload is None:
|
||||
should_unload = os.environ.get(
|
||||
"COMFYUI_TOPYTHON_UNLOAD_MODELS", ""
|
||||
).lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
|
||||
try:
|
||||
import comfy.model_management as model_management
|
||||
except ModuleNotFoundError:
|
||||
gc.collect()
|
||||
return
|
||||
|
||||
if hasattr(model_management, "cleanup_models_gc"):
|
||||
model_management.cleanup_models_gc()
|
||||
if should_unload and hasattr(model_management, "unload_all_models"):
|
||||
model_management.unload_all_models()
|
||||
if hasattr(model_management, "soft_empty_cache"):
|
||||
model_management.soft_empty_cache()
|
||||
gc.collect()
|
||||
|
||||
|
||||
def import_custom_nodes() -> None:
|
||||
"""Initialize ComfyUI custom nodes in the exporter runtime."""
|
||||
comfyui_path = get_comfyui_path()
|
||||
if comfyui_path and comfyui_path not in sys.path:
|
||||
sys.path.insert(0, comfyui_path)
|
||||
|
||||
import asyncio
|
||||
import execution
|
||||
from nodes import init_extra_nodes
|
||||
|
||||
if comfyui_path in sys.path:
|
||||
sys.path.remove(comfyui_path)
|
||||
sys.path.insert(0, comfyui_path)
|
||||
|
||||
import server
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
server_instance = server.PromptServer(loop)
|
||||
execution.PromptQueue(server_instance)
|
||||
asyncio.run(init_extra_nodes())
|
||||
|
||||
|
||||
# Workflow data
|
||||
def build_workflow() -> dict[str, Any]:
|
||||
return {
|
||||
"1": {"class_type": "LoadImage", "inputs": {"image": "e2e_upscale_input.png"}},
|
||||
"2": {
|
||||
"class_type": "UpscaleModelLoader",
|
||||
"inputs": {"model_name": "RealESRGAN_x4plus.safetensors"},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "ImageUpscaleWithModel",
|
||||
"inputs": {"upscale_model": ["2", 0], "image": ["1", 0]},
|
||||
},
|
||||
"4": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"filename_prefix": "E2E_upscale_model_loader",
|
||||
"images": ["3", 0],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_extra_pnginfo() -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
workflow = build_workflow()
|
||||
prompt = json.loads(json.dumps(workflow))
|
||||
extra_pnginfo = build_extra_pnginfo()
|
||||
|
||||
|
||||
# Workflow execution
|
||||
def main(unload_models: bool | None = None):
|
||||
bootstrap_comfyui_runtime()
|
||||
add_extra_model_paths()
|
||||
import_custom_nodes()
|
||||
|
||||
# Node imports
|
||||
from nodes import LoadImage, NODE_CLASS_MAPPINGS, SaveImage
|
||||
|
||||
import torch
|
||||
|
||||
try:
|
||||
with torch.inference_mode():
|
||||
loadimage = LoadImage()
|
||||
loadimage_1 = loadimage.load_image(image="e2e_upscale_input.png")
|
||||
upscalemodelloader = NODE_CLASS_MAPPINGS["UpscaleModelLoader"]()
|
||||
upscalemodelloader_2 = upscalemodelloader.EXECUTE_NORMALIZED(
|
||||
model_name="RealESRGAN_x4plus.safetensors"
|
||||
)
|
||||
imageupscalewithmodel = NODE_CLASS_MAPPINGS["ImageUpscaleWithModel"]()
|
||||
saveimage = SaveImage()
|
||||
for q in range(1):
|
||||
imageupscalewithmodel_3 = imageupscalewithmodel.EXECUTE_NORMALIZED(
|
||||
upscale_model=get_value_at_index(upscalemodelloader_2, 0),
|
||||
image=get_value_at_index(loadimage_1, 0),
|
||||
)
|
||||
saveimage_4 = saveimage.save_images(
|
||||
filename_prefix="E2E_upscale_model_loader",
|
||||
images=get_value_at_index(imageupscalewithmodel_3, 0),
|
||||
prompt=prompt,
|
||||
extra_pnginfo=extra_pnginfo,
|
||||
)
|
||||
finally:
|
||||
cleanup_comfyui_runtime(unload_models=unload_models)
|
||||
|
||||
|
||||
# Entrypoint
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,786 @@
|
||||
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,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--execution-mode",
|
||||
default="oneshot",
|
||||
choices=("oneshot", "session"),
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if not args.internal_export and not args.tier:
|
||||
parser.error("--tier is required unless --internal-export is used.")
|
||||
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,
|
||||
execution_mode: str = "oneshot",
|
||||
) -> 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,
|
||||
"execution_mode": execution_mode,
|
||||
}
|
||||
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 export_session_workflow_in_runtime_env(
|
||||
workflow_json: str,
|
||||
execution_mode: str = "session",
|
||||
) -> str:
|
||||
"""Export a session workflow via subprocess in the ComfyUI runtime env.
|
||||
|
||||
Unlike ``export_workflow_in_runtime_env`` this does not require a fixture
|
||||
config – it receives workflow JSON directly and re-enters the runtime
|
||||
interpreter so ``ComfyUItoPython`` can import ComfyUI's nodes.
|
||||
"""
|
||||
runtime_path = os.environ.get("COMFYUI_PATH", "")
|
||||
runtime_python = get_runtime_python(runtime_path)
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".json", mode="w", delete=False, encoding="utf-8"
|
||||
) as wf:
|
||||
wf.write(workflow_json)
|
||||
wf_path = wf.name
|
||||
|
||||
try:
|
||||
temp_fixture = FixtureConfig(
|
||||
name="session-mode-export",
|
||||
path=Path(wf_path),
|
||||
)
|
||||
_, generated = export_workflow(
|
||||
fixture=temp_fixture,
|
||||
tier="runtime",
|
||||
runtime_path=runtime_path,
|
||||
execution_mode=execution_mode,
|
||||
)
|
||||
return generated
|
||||
finally:
|
||||
os.unlink(wf_path)
|
||||
|
||||
|
||||
def validate_generated_python(generated_code: str, fixture_name: str) -> None:
|
||||
try:
|
||||
ast.parse(generated_code)
|
||||
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", ""),
|
||||
execution_mode=args.execution_mode,
|
||||
)
|
||||
output_path.write_text(generated_code, encoding="utf-8")
|
||||
print(generated_code, end="")
|
||||
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,109 @@
|
||||
import json
|
||||
import unittest
|
||||
from io import StringIO
|
||||
|
||||
from comfyui_to_python import ComfyUItoPython
|
||||
|
||||
|
||||
class DummyNode:
|
||||
CATEGORY = "test"
|
||||
FUNCTION = "execute"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {"value": ("STRING",)}}
|
||||
|
||||
def execute(self, value):
|
||||
return (f"result:{value}",)
|
||||
|
||||
|
||||
class ExportSessionModeTest(unittest.TestCase):
|
||||
"""Tests for session mode export pipeline."""
|
||||
|
||||
def test_comfyui_to_python_passes_execution_mode(self):
|
||||
workflow = {
|
||||
"1": {
|
||||
"class_type": "DummyNode",
|
||||
"inputs": {"value": "test"},
|
||||
}
|
||||
}
|
||||
|
||||
output = StringIO()
|
||||
ComfyUItoPython(
|
||||
workflow=json.dumps(workflow),
|
||||
output_file=output,
|
||||
node_class_mappings={"DummyNode": DummyNode},
|
||||
execution_mode="session",
|
||||
)
|
||||
|
||||
generated = output.getvalue()
|
||||
|
||||
self.assertIn("class WorkflowSession:", generated)
|
||||
self.assertIn("def run(self)", generated)
|
||||
self.assertIn("def close(self, unload_models:", generated)
|
||||
|
||||
def test_session_mode_includes_backward_compat_main(self):
|
||||
workflow = {
|
||||
"1": {
|
||||
"class_type": "DummyNode",
|
||||
"inputs": {"value": "test"},
|
||||
}
|
||||
}
|
||||
|
||||
output = StringIO()
|
||||
ComfyUItoPython(
|
||||
workflow=json.dumps(workflow),
|
||||
output_file=output,
|
||||
node_class_mappings={"DummyNode": DummyNode},
|
||||
execution_mode="session",
|
||||
)
|
||||
|
||||
generated = output.getvalue()
|
||||
|
||||
self.assertIn("def main(", generated)
|
||||
self.assertIn("WorkflowSession(", generated)
|
||||
self.assertIn('cleanup_policy="per_run"', generated)
|
||||
self.assertIn("if __name__ == \"__main__\":", generated)
|
||||
|
||||
def test_oneshot_mode_excludes_session_class(self):
|
||||
workflow = {
|
||||
"1": {
|
||||
"class_type": "DummyNode",
|
||||
"inputs": {"value": "test"},
|
||||
}
|
||||
}
|
||||
|
||||
output = StringIO()
|
||||
ComfyUItoPython(
|
||||
workflow=json.dumps(workflow),
|
||||
output_file=output,
|
||||
node_class_mappings={"DummyNode": DummyNode},
|
||||
execution_mode="oneshot",
|
||||
)
|
||||
|
||||
generated = output.getvalue()
|
||||
|
||||
self.assertNotIn("class WorkflowSession:", generated)
|
||||
|
||||
def test_default_mode_is_oneshot(self):
|
||||
workflow = {
|
||||
"1": {
|
||||
"class_type": "DummyNode",
|
||||
"inputs": {"value": "test"},
|
||||
}
|
||||
}
|
||||
|
||||
output = StringIO()
|
||||
ComfyUItoPython(
|
||||
workflow=json.dumps(workflow),
|
||||
output_file=output,
|
||||
node_class_mappings={"DummyNode": DummyNode},
|
||||
)
|
||||
|
||||
generated = output.getvalue()
|
||||
|
||||
self.assertNotIn("class WorkflowSession:", generated)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,312 @@
|
||||
import json
|
||||
import unittest
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
|
||||
from comfyui_to_python import ComfyUItoPython
|
||||
|
||||
|
||||
FIXTURE_DIR = Path(__file__).parent / "fixtures" / "unit" / "generator_codegen"
|
||||
|
||||
|
||||
class AnySwitchRgthree:
|
||||
CATEGORY = "utils"
|
||||
FUNCTION = "switch"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"model": ("MODEL",),
|
||||
}
|
||||
}
|
||||
|
||||
def switch(self, model):
|
||||
return (model,)
|
||||
|
||||
|
||||
class DualClipLoader:
|
||||
CATEGORY = "loaders"
|
||||
FUNCTION = "load_clip"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"clip_name": ("STRING",),
|
||||
}
|
||||
}
|
||||
|
||||
def load_clip(self, clip_name):
|
||||
return (clip_name,)
|
||||
|
||||
|
||||
class PowerLoraLoaderRgthree:
|
||||
CATEGORY = "loaders"
|
||||
FUNCTION = "load_loras"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"PowerLoraLoaderHeaderWidget": ("DICT",),
|
||||
"model": ("MODEL",),
|
||||
"clip": ("CLIP",),
|
||||
}
|
||||
}
|
||||
|
||||
def load_loras(self, **kwargs):
|
||||
return (kwargs,)
|
||||
|
||||
|
||||
class UpscaleModelLoader:
|
||||
CATEGORY = "loaders"
|
||||
FUNCTION = "load_model"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"model_name": ("STRING",),
|
||||
}
|
||||
}
|
||||
|
||||
def load_model(self, model_name):
|
||||
return (model_name,)
|
||||
|
||||
|
||||
class ImageUpscaleWithModel:
|
||||
CATEGORY = "image/upscaling"
|
||||
FUNCTION = "upscale"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"upscale_model": ("UPSCALE_MODEL",),
|
||||
"image": ("IMAGE",),
|
||||
}
|
||||
}
|
||||
|
||||
def upscale(self, upscale_model, image):
|
||||
return (image,)
|
||||
|
||||
|
||||
class VaeDecode:
|
||||
CATEGORY = "latent"
|
||||
FUNCTION = "decode"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"samples": ("LATENT",),
|
||||
}
|
||||
}
|
||||
|
||||
def decode(self, samples):
|
||||
return (samples,)
|
||||
|
||||
|
||||
class WindowsPathNode:
|
||||
CATEGORY = "paths"
|
||||
FUNCTION = "open_path"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"path": ("STRING",),
|
||||
}
|
||||
}
|
||||
|
||||
def open_path(self, path):
|
||||
return (path,)
|
||||
|
||||
|
||||
class TextConcatenateNode:
|
||||
CATEGORY = "text"
|
||||
FUNCTION = "text_concatenate"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"delimiter": ("STRING",),
|
||||
"clean_whitespace": ("STRING",),
|
||||
"text_b": ("STRING",),
|
||||
}
|
||||
}
|
||||
|
||||
def text_concatenate(self, delimiter, clean_whitespace, text_b):
|
||||
return (delimiter, clean_whitespace, text_b)
|
||||
|
||||
|
||||
class StringSeedNode:
|
||||
CATEGORY = "sampling"
|
||||
FUNCTION = "sample"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"seed": ("STRING",),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
},
|
||||
}
|
||||
|
||||
def sample(self, seed, prompt):
|
||||
return (seed, prompt)
|
||||
|
||||
|
||||
def load_fixture(name: str) -> dict:
|
||||
return json.loads((FIXTURE_DIR / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def export_workflow(workflow: dict, node_class_mappings: dict) -> str:
|
||||
output = StringIO()
|
||||
ComfyUItoPython(
|
||||
workflow=json.dumps(workflow),
|
||||
output_file=output,
|
||||
node_class_mappings=node_class_mappings,
|
||||
)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
class GeneratorCodegenIssueRegressionTest(unittest.TestCase):
|
||||
def test_export_uses_dictionary_expansion_for_rgthree_symbol_heavy_input_names(self):
|
||||
generated = export_workflow(
|
||||
load_fixture("unsafe-rgthree-kwargs.json"),
|
||||
{
|
||||
"AnySwitchRgthree": AnySwitchRgthree,
|
||||
"DualClipLoader": DualClipLoader,
|
||||
"PowerLoraLoaderRgthree": PowerLoraLoaderRgthree,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'powerloraloaderrgthree_631 = powerloraloaderrgthree.load_loras(',
|
||||
generated,
|
||||
)
|
||||
self.assertIn(
|
||||
'PowerLoraLoaderHeaderWidget={"type": "PowerLoraLoaderHeaderWidget"}',
|
||||
generated,
|
||||
)
|
||||
self.assertIn('**{"\\u2795 Add Lora": ""}', generated)
|
||||
self.assertNotIn('➕ Add Lora=""', generated)
|
||||
|
||||
def test_export_sanitizes_subgraph_identifiers_for_upscaler_workflows(self):
|
||||
generated = export_workflow(
|
||||
load_fixture("subgraph-upscaler-identifiers.json"),
|
||||
{
|
||||
"VaeDecode": VaeDecode,
|
||||
"UpscaleModelLoader": UpscaleModelLoader,
|
||||
"ImageUpscaleWithModel": ImageUpscaleWithModel,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertIn("upscalemodelloader_42_0 = upscalemodelloader.load_model(", generated)
|
||||
self.assertIn(
|
||||
"imageupscalewithmodel_42_1 = imageupscalewithmodel.upscale(",
|
||||
generated,
|
||||
)
|
||||
self.assertIn(
|
||||
"upscale_model=get_value_at_index(upscalemodelloader_42_0, 0)",
|
||||
generated,
|
||||
)
|
||||
self.assertNotIn("imageupscalewithmodel_42:1", generated)
|
||||
self.assertNotIn("upscalemodelloader_42:0", generated)
|
||||
|
||||
def test_export_preserves_windows_style_model_paths(self):
|
||||
generated = export_workflow(
|
||||
load_fixture("windows-path-string.json"),
|
||||
{
|
||||
"WindowsPathNode": WindowsPathNode,
|
||||
},
|
||||
)
|
||||
|
||||
globals_dict = {"__name__": "generated_workflow_module"}
|
||||
exec(generated, globals_dict)
|
||||
|
||||
self.assertEqual(
|
||||
globals_dict["build_workflow"]()["1"]["inputs"]["path"],
|
||||
r"C:\ComfyUI\models\upscale_models\RealESRGAN_x4plus.safetensors",
|
||||
)
|
||||
|
||||
def test_export_preserves_trailing_backslash_string_literals(self):
|
||||
generated = export_workflow(
|
||||
load_fixture("trailing-backslash-string.json"),
|
||||
{
|
||||
"TextConcatenateNode": TextConcatenateNode,
|
||||
},
|
||||
)
|
||||
|
||||
globals_dict = {"__name__": "generated_workflow_module"}
|
||||
exec(generated, globals_dict)
|
||||
|
||||
self.assertEqual(
|
||||
globals_dict["build_workflow"]()["1"]["inputs"]["text_b"],
|
||||
"\\",
|
||||
)
|
||||
|
||||
def test_export_randomizes_string_seed_inputs_as_strings(self):
|
||||
generated = export_workflow(
|
||||
load_fixture("string-seed-node.json"),
|
||||
{
|
||||
"StringSeedNode": StringSeedNode,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'node_1_seed = prompt["1"]["inputs"]["seed"] = str(random.randint(1, 2**64))',
|
||||
generated,
|
||||
)
|
||||
self.assertIn("seed=node_1_seed", generated)
|
||||
|
||||
def test_issue_cluster_regressions_render_parseable_python(self):
|
||||
workflows = [
|
||||
(
|
||||
load_fixture("unsafe-rgthree-kwargs.json"),
|
||||
{
|
||||
"AnySwitchRgthree": AnySwitchRgthree,
|
||||
"DualClipLoader": DualClipLoader,
|
||||
"PowerLoraLoaderRgthree": PowerLoraLoaderRgthree,
|
||||
},
|
||||
),
|
||||
(
|
||||
load_fixture("subgraph-upscaler-identifiers.json"),
|
||||
{
|
||||
"VaeDecode": VaeDecode,
|
||||
"UpscaleModelLoader": UpscaleModelLoader,
|
||||
"ImageUpscaleWithModel": ImageUpscaleWithModel,
|
||||
},
|
||||
),
|
||||
(
|
||||
load_fixture("trailing-backslash-string.json"),
|
||||
{
|
||||
"TextConcatenateNode": TextConcatenateNode,
|
||||
},
|
||||
),
|
||||
(
|
||||
load_fixture("windows-path-string.json"),
|
||||
{
|
||||
"WindowsPathNode": WindowsPathNode,
|
||||
},
|
||||
),
|
||||
(
|
||||
load_fixture("string-seed-node.json"),
|
||||
{
|
||||
"StringSeedNode": StringSeedNode,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
for workflow, mapping in workflows:
|
||||
generated = export_workflow(workflow, mapping)
|
||||
compile(generated, "<generated_workflow>", "exec")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,116 @@
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
import warnings
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from comfyui_to_python.node_runtime import cleanup_comfyui_runtime
|
||||
|
||||
|
||||
class NodeRuntimeCleanupTest(unittest.TestCase):
|
||||
def test_cleanup_releases_caches_without_forcing_model_unload_by_default(self):
|
||||
comfy_module = types.ModuleType("comfy")
|
||||
comfy_module.__path__ = []
|
||||
model_management = types.ModuleType("comfy.model_management")
|
||||
model_management.cleanup_models_gc = Mock()
|
||||
model_management.unload_all_models = Mock()
|
||||
model_management.soft_empty_cache = Mock()
|
||||
comfy_module.model_management = model_management
|
||||
|
||||
with patch.dict(
|
||||
sys.modules,
|
||||
{
|
||||
"comfy": comfy_module,
|
||||
"comfy.model_management": model_management,
|
||||
},
|
||||
), patch.dict("os.environ", {}, clear=False):
|
||||
cleanup_comfyui_runtime()
|
||||
|
||||
model_management.cleanup_models_gc.assert_called_once_with()
|
||||
model_management.soft_empty_cache.assert_called_once_with()
|
||||
model_management.unload_all_models.assert_not_called()
|
||||
|
||||
def test_cleanup_can_force_model_unload_from_environment(self):
|
||||
comfy_module = types.ModuleType("comfy")
|
||||
comfy_module.__path__ = []
|
||||
model_management = types.ModuleType("comfy.model_management")
|
||||
model_management.cleanup_models_gc = Mock()
|
||||
model_management.unload_all_models = Mock()
|
||||
model_management.soft_empty_cache = Mock()
|
||||
comfy_module.model_management = model_management
|
||||
|
||||
with patch.dict(
|
||||
sys.modules,
|
||||
{
|
||||
"comfy": comfy_module,
|
||||
"comfy.model_management": model_management,
|
||||
},
|
||||
), patch.dict(
|
||||
"os.environ",
|
||||
{"COMFYUI_TOPYTHON_UNLOAD_MODELS": "true"},
|
||||
clear=False,
|
||||
):
|
||||
cleanup_comfyui_runtime()
|
||||
|
||||
model_management.unload_all_models.assert_called_once_with()
|
||||
|
||||
def test_cleanup_suppresses_hook_failures_and_warns(self):
|
||||
comfy_module = types.ModuleType("comfy")
|
||||
comfy_module.__path__ = []
|
||||
model_management = types.ModuleType("comfy.model_management")
|
||||
model_management.cleanup_models_gc = Mock(side_effect=RuntimeError("gc failed"))
|
||||
model_management.unload_all_models = Mock(side_effect=RuntimeError("unload failed"))
|
||||
model_management.soft_empty_cache = Mock(side_effect=RuntimeError("cache failed"))
|
||||
comfy_module.model_management = model_management
|
||||
|
||||
with patch.dict(
|
||||
sys.modules,
|
||||
{
|
||||
"comfy": comfy_module,
|
||||
"comfy.model_management": model_management,
|
||||
},
|
||||
), warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
cleanup_comfyui_runtime(unload_models=True)
|
||||
|
||||
model_management.cleanup_models_gc.assert_called_once_with()
|
||||
model_management.unload_all_models.assert_called_once_with()
|
||||
model_management.soft_empty_cache.assert_called_once_with()
|
||||
self.assertEqual(len(caught), 3)
|
||||
self.assertEqual(
|
||||
[str(warning.message) for warning in caught],
|
||||
[
|
||||
"ComfyUI cleanup hook cleanup_models_gc failed during teardown: gc failed",
|
||||
"ComfyUI cleanup hook unload_all_models failed during teardown: unload failed",
|
||||
"ComfyUI cleanup hook soft_empty_cache failed during teardown: cache failed",
|
||||
],
|
||||
)
|
||||
|
||||
def test_cleanup_does_not_mask_active_workflow_exception(self):
|
||||
comfy_module = types.ModuleType("comfy")
|
||||
comfy_module.__path__ = []
|
||||
model_management = types.ModuleType("comfy.model_management")
|
||||
model_management.cleanup_models_gc = Mock(side_effect=RuntimeError("cleanup failed"))
|
||||
model_management.soft_empty_cache = Mock()
|
||||
comfy_module.model_management = model_management
|
||||
|
||||
with patch.dict(
|
||||
sys.modules,
|
||||
{
|
||||
"comfy": comfy_module,
|
||||
"comfy.model_management": model_management,
|
||||
},
|
||||
), warnings.catch_warnings(record=True):
|
||||
warnings.simplefilter("always")
|
||||
with self.assertRaisesRegex(ValueError, "workflow failed"):
|
||||
try:
|
||||
raise ValueError("workflow failed")
|
||||
finally:
|
||||
cleanup_comfyui_runtime()
|
||||
|
||||
model_management.cleanup_models_gc.assert_called_once_with()
|
||||
model_management.soft_empty_cache.assert_called_once_with()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,33 @@
|
||||
import tomllib
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class ProjectContractsTest(unittest.TestCase):
|
||||
def test_project_declares_supported_python_floor(self):
|
||||
pyproject = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
|
||||
|
||||
self.assertEqual(pyproject["project"]["requires-python"], ">=3.12")
|
||||
|
||||
def test_readme_documents_python_support_and_default_save_filename(self):
|
||||
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("This project supports Python 3.12 and newer.", readme)
|
||||
self.assertIn("default filename `workflow_api.py`", readme)
|
||||
|
||||
def test_extension_import_path_requires_uv_sync_instead_of_running_install_py(self):
|
||||
init_text = (REPO_ROOT / "__init__.py").read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("Run 'uv sync'", init_text)
|
||||
self.assertNotIn("spec_from_file_location", init_text)
|
||||
self.assertNotIn("Successfully installed. Hopefully, at least.", init_text)
|
||||
|
||||
def test_frontend_save_flow_uses_deterministic_filename_without_prompt(self):
|
||||
save_as_script = (REPO_ROOT / "js" / "save-as-script.js").read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn('const DEFAULT_SCRIPT_FILENAME = "workflow_api.py";', save_as_script)
|
||||
self.assertNotIn("prompt(", save_as_script)
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
|
||||
from comfyui_to_python.runtime_session import WorkflowSessionRuntime
|
||||
|
||||
|
||||
class StubNode:
|
||||
FUNCTION = "execute"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {"value": ("STRING",)}}
|
||||
|
||||
def execute(self, value):
|
||||
return (f"result:{value}",)
|
||||
|
||||
|
||||
class TestWorkflowSessionRuntimeInit(unittest.TestCase):
|
||||
"""Tests for WorkflowSessionRuntime initialization."""
|
||||
|
||||
def test_init_default_cleanup_policy_is_session(self):
|
||||
runtime = WorkflowSessionRuntime()
|
||||
self.assertEqual(runtime._cleanup_policy, "session")
|
||||
|
||||
def test_init_accepts_per_run_policy(self):
|
||||
runtime = WorkflowSessionRuntime(cleanup_policy="per_run")
|
||||
self.assertEqual(runtime._cleanup_policy, "per_run")
|
||||
|
||||
def test_init_accepts_manual_policy(self):
|
||||
runtime = WorkflowSessionRuntime(cleanup_policy="manual")
|
||||
self.assertEqual(runtime._cleanup_policy, "manual")
|
||||
|
||||
def test_init_rejects_invalid_policy(self):
|
||||
with self.assertRaises(ValueError):
|
||||
WorkflowSessionRuntime(cleanup_policy="invalid")
|
||||
|
||||
def test_init_sets_default_reset_every_n_runs_none(self):
|
||||
runtime = WorkflowSessionRuntime()
|
||||
self.assertIsNone(runtime._reset_every_n_runs)
|
||||
|
||||
def test_init_accepts_reset_every_n_runs(self):
|
||||
runtime = WorkflowSessionRuntime(reset_every_n_runs=5)
|
||||
self.assertEqual(runtime._reset_every_n_runs, 5)
|
||||
|
||||
def test_init_has_lock(self):
|
||||
runtime = WorkflowSessionRuntime()
|
||||
self.assertIsInstance(runtime._lock, type(threading.Lock()))
|
||||
|
||||
def test_init_bootstrapped_false(self):
|
||||
runtime = WorkflowSessionRuntime()
|
||||
self.assertFalse(runtime.bootstrapped)
|
||||
|
||||
def test_init_custom_nodes_initialized_false(self):
|
||||
runtime = WorkflowSessionRuntime()
|
||||
self.assertFalse(runtime.custom_nodes_initialized)
|
||||
|
||||
def test_init_run_count_zero(self):
|
||||
runtime = WorkflowSessionRuntime()
|
||||
self.assertEqual(runtime.run_count, 0)
|
||||
|
||||
def test_init_closed_false(self):
|
||||
runtime = WorkflowSessionRuntime()
|
||||
self.assertFalse(runtime._closed)
|
||||
|
||||
|
||||
class TestWorkflowSessionRuntimeLifecycle(unittest.TestCase):
|
||||
"""Tests for WorkflowSessionRuntime lifecycle management."""
|
||||
|
||||
def _make_runtime(self):
|
||||
return WorkflowSessionRuntime()
|
||||
|
||||
@patch("comfyui_to_python.runtime_session.bootstrap_comfyui_runtime")
|
||||
def test_ensure_bootstrapped_calls_bootstrap_once(self, mock_bootstrap):
|
||||
runtime = self._make_runtime()
|
||||
runtime._ensure_bootstrapped()
|
||||
mock_bootstrap.assert_called_once()
|
||||
self.assertTrue(runtime.bootstrapped)
|
||||
|
||||
@patch("comfyui_to_python.runtime_session.bootstrap_comfyui_runtime")
|
||||
def test_ensure_bootstrapped_skips_if_already_bootstrapped(self, mock_bootstrap):
|
||||
runtime = self._make_runtime()
|
||||
runtime._ensure_bootstrapped()
|
||||
runtime._ensure_bootstrapped()
|
||||
self.assertEqual(mock_bootstrap.call_count, 1)
|
||||
|
||||
@patch("comfyui_to_python.runtime_session.import_custom_nodes")
|
||||
def test_ensure_custom_nodes_init_calls_import_once(self, mock_import):
|
||||
runtime = self._make_runtime()
|
||||
runtime._ensure_custom_nodes_initialized()
|
||||
mock_import.assert_called_once()
|
||||
self.assertTrue(runtime.custom_nodes_initialized)
|
||||
|
||||
@patch("comfyui_to_python.runtime_session.import_custom_nodes")
|
||||
def test_ensure_custom_nodes_init_skips_if_already_initialized(self, mock_import):
|
||||
runtime = self._make_runtime()
|
||||
runtime._ensure_custom_nodes_initialized()
|
||||
runtime._ensure_custom_nodes_initialized()
|
||||
self.assertEqual(mock_import.call_count, 1)
|
||||
|
||||
def test_close_sets_closed_flag(self):
|
||||
runtime = self._make_runtime()
|
||||
runtime.close(unload_models=True)
|
||||
self.assertTrue(runtime._closed)
|
||||
|
||||
def test_close_is_idempotent(self):
|
||||
runtime = self._make_runtime()
|
||||
with patch.object(runtime, "_do_close") as mock_do_close:
|
||||
runtime.close(unload_models=True)
|
||||
runtime.close(unload_models=True)
|
||||
self.assertEqual(mock_do_close.call_count, 1)
|
||||
|
||||
|
||||
class TestWorkflowSessionRuntimeExceptionSafety(unittest.TestCase):
|
||||
"""Tests that exceptions during run() do not corrupt session state."""
|
||||
|
||||
def _make_runtime(self):
|
||||
return WorkflowSessionRuntime()
|
||||
|
||||
@patch("comfyui_to_python.runtime_session.WorkflowSessionRuntime._do_run")
|
||||
def test_exception_preserves_state_flags(self, mock_do_run):
|
||||
runtime = self._make_runtime()
|
||||
runtime.bootstrapped = True
|
||||
runtime.custom_nodes_initialized = True
|
||||
mock_do_run.side_effect = RuntimeError("simulated failure")
|
||||
with self.assertRaises(RuntimeError):
|
||||
runtime.run()
|
||||
self.assertTrue(runtime.bootstrapped)
|
||||
self.assertTrue(runtime.custom_nodes_initialized)
|
||||
|
||||
@patch("comfyui_to_python.runtime_session.WorkflowSessionRuntime._do_run")
|
||||
def test_run_count_not_incremented_on_exception(self, mock_do_run):
|
||||
runtime = self._make_runtime()
|
||||
mock_do_run.side_effect = RuntimeError("simulated failure")
|
||||
with self.assertRaises(RuntimeError):
|
||||
runtime.run()
|
||||
self.assertEqual(runtime.run_count, 0)
|
||||
|
||||
def test_run_count_increments_after_successful_run(self):
|
||||
runtime = self._make_runtime()
|
||||
workflow_data = {
|
||||
"1": {
|
||||
"class_type": "StubNode",
|
||||
"inputs": {"value": "test"},
|
||||
}
|
||||
}
|
||||
node_mappings = {"StubNode": StubNode}
|
||||
runtime._workflow_data = workflow_data
|
||||
runtime._node_class_mappings = node_mappings
|
||||
runtime.node_instances = {"StubNode": StubNode()}
|
||||
runtime._node_classes = {"StubNode": StubNode}
|
||||
|
||||
runtime.run()
|
||||
|
||||
self.assertEqual(runtime.run_count, 1)
|
||||
|
||||
def test_run_count_resets_after_reset_every_n_runs(self):
|
||||
runtime = WorkflowSessionRuntime(reset_every_n_runs=2)
|
||||
workflow_data = {
|
||||
"1": {
|
||||
"class_type": "StubNode",
|
||||
"inputs": {"value": "test"},
|
||||
}
|
||||
}
|
||||
node_mappings = {"StubNode": StubNode}
|
||||
runtime._workflow_data = workflow_data
|
||||
runtime._node_class_mappings = node_mappings
|
||||
|
||||
runtime.run()
|
||||
self.assertEqual(runtime.run_count, 1)
|
||||
runtime.run()
|
||||
self.assertEqual(runtime.run_count, 0)
|
||||
self.assertFalse(runtime.bootstrapped)
|
||||
self.assertFalse(runtime.custom_nodes_initialized)
|
||||
self.assertEqual(runtime.node_instances, {})
|
||||
|
||||
|
||||
class TestWorkflowSessionRuntimeAlreadyClosed(unittest.TestCase):
|
||||
"""Tests for behavior after close()."""
|
||||
|
||||
def _make_runtime(self):
|
||||
return WorkflowSessionRuntime()
|
||||
|
||||
def test_run_after_close_raises(self):
|
||||
runtime = self._make_runtime()
|
||||
runtime.close()
|
||||
with self.assertRaises(RuntimeError):
|
||||
runtime.run()
|
||||
|
||||
|
||||
class TestWorkflowSessionRuntimeNodeInstances(unittest.TestCase):
|
||||
"""Tests for cached node instance management."""
|
||||
|
||||
def _make_runtime(self):
|
||||
return WorkflowSessionRuntime()
|
||||
|
||||
def test_ensure_node_instances_creates_and_caches_instances(self):
|
||||
runtime = self._make_runtime()
|
||||
node_class = MagicMock()
|
||||
runtime._ensure_node_instances({"TestNode": node_class})
|
||||
node_class.assert_called_once()
|
||||
self.assertIn("TestNode", runtime.node_instances)
|
||||
runtime._ensure_node_instances({"TestNode": node_class})
|
||||
node_class.assert_called_once()
|
||||
|
||||
def test_node_instances_are_cached_across_runs(self):
|
||||
runtime = WorkflowSessionRuntime()
|
||||
workflow_data = {
|
||||
"1": {
|
||||
"class_type": "StubNode",
|
||||
"inputs": {"value": "test"},
|
||||
}
|
||||
}
|
||||
original_stub_init = StubNode.__init__
|
||||
init_calls = []
|
||||
|
||||
def tracking_init(self, *args, **kwargs):
|
||||
init_calls.append(1)
|
||||
original_stub_init(self)
|
||||
|
||||
StubNode.__init__ = tracking_init
|
||||
try:
|
||||
runtime._workflow_data = workflow_data
|
||||
runtime._node_class_mappings = {"StubNode": StubNode}
|
||||
|
||||
runtime.run()
|
||||
runtime.run()
|
||||
|
||||
self.assertEqual(len(init_calls), 1)
|
||||
finally:
|
||||
StubNode.__init__ = original_stub_init
|
||||
|
||||
|
||||
class TestWorkflowSessionRuntimeClearRuntimeCache(unittest.TestCase):
|
||||
"""Tests for clear_runtime_cache behavior."""
|
||||
|
||||
def _make_runtime(self):
|
||||
return WorkflowSessionRuntime()
|
||||
|
||||
@patch("comfyui_to_python.runtime_session.cleanup_comfyui_runtime")
|
||||
def test_clear_runtime_cache_session_policy_skips_unload(self, mock_cleanup):
|
||||
runtime = WorkflowSessionRuntime(cleanup_policy="session")
|
||||
runtime.bootstrapped = True
|
||||
runtime.clear_runtime_cache()
|
||||
# session policy should NOT call unload_all_models
|
||||
mock_cleanup.assert_not_called()
|
||||
|
||||
@patch("comfyui_to_python.runtime_session.cleanup_comfyui_runtime")
|
||||
def test_clear_runtime_cache_per_run_policy_calls_full_cleanup(self, mock_cleanup):
|
||||
runtime = WorkflowSessionRuntime(cleanup_policy="per_run")
|
||||
runtime.bootstrapped = True
|
||||
runtime.clear_runtime_cache()
|
||||
mock_cleanup.assert_called_once_with(unload_models=True)
|
||||
|
||||
@patch("comfyui_to_python.runtime_session.cleanup_comfyui_runtime")
|
||||
def test_clear_runtime_cache_manual_policy_no_cleanup(self, mock_cleanup):
|
||||
runtime = WorkflowSessionRuntime(cleanup_policy="manual")
|
||||
runtime.bootstrapped = True
|
||||
runtime.clear_runtime_cache()
|
||||
mock_cleanup.assert_not_called()
|
||||
|
||||
|
||||
class TestWorkflowSessionRuntimeDoClose(unittest.TestCase):
|
||||
"""Tests for _do_close internal method."""
|
||||
|
||||
def _make_runtime(self):
|
||||
return WorkflowSessionRuntime()
|
||||
|
||||
@patch("comfyui_to_python.runtime_session.cleanup_comfyui_runtime")
|
||||
@patch("comfyui_to_python.runtime_session.gc")
|
||||
def test_do_close_calls_cleanup_and_gc(self, mock_gc, mock_cleanup):
|
||||
runtime = self._make_runtime()
|
||||
runtime._do_close(unload_models=True)
|
||||
mock_cleanup.assert_called_once_with(unload_models=True)
|
||||
mock_gc.collect.assert_called_once()
|
||||
|
||||
mock_cleanup.reset_mock()
|
||||
mock_gc.reset_mock()
|
||||
runtime = self._make_runtime()
|
||||
runtime._do_close(unload_models=False)
|
||||
mock_cleanup.assert_called_once_with(unload_models=False)
|
||||
mock_gc.collect.assert_called_once()
|
||||
|
||||
|
||||
class TestWorkflowSessionRuntimeRun(unittest.TestCase):
|
||||
"""Tests for WorkflowSessionRuntime.run() workflow execution."""
|
||||
|
||||
def _make_runtime(self):
|
||||
return WorkflowSessionRuntime()
|
||||
|
||||
def test_run_executes_workflow_nodes(self):
|
||||
runtime = self._make_runtime()
|
||||
workflow_data = {
|
||||
"1": {
|
||||
"class_type": "StubNode",
|
||||
"inputs": {"value": "test"},
|
||||
}
|
||||
}
|
||||
node_mappings = {"StubNode": StubNode}
|
||||
runtime._workflow_data = workflow_data
|
||||
runtime._node_class_mappings = node_mappings
|
||||
runtime.node_instances = {"StubNode": StubNode()}
|
||||
runtime._node_classes = {"StubNode": StubNode}
|
||||
|
||||
result = runtime.run()
|
||||
|
||||
self.assertIn("1", result)
|
||||
self.assertEqual(result["1"], ["result:test"])
|
||||
|
||||
@patch(
|
||||
"comfyui_to_python.runtime_session.WorkflowSessionRuntime._ensure_node_instances"
|
||||
)
|
||||
def test_run_with_no_workflow_data_returns_none(self, mock_ensure):
|
||||
runtime = self._make_runtime()
|
||||
result = runtime.run()
|
||||
self.assertIsNone(result)
|
||||
|
||||
@patch(
|
||||
"comfyui_to_python.runtime_session.WorkflowSessionRuntime._ensure_node_instances"
|
||||
)
|
||||
def test_run_with_no_node_mappings_returns_none(self, mock_ensure):
|
||||
runtime = self._make_runtime()
|
||||
runtime._workflow_data = {"1": {"class_type": "Test", "inputs": {}}}
|
||||
result = runtime.run()
|
||||
self.assertIsNone(result)
|
||||
|
||||
@patch(
|
||||
"comfyui_to_python.runtime_session.WorkflowSessionRuntime._ensure_node_instances"
|
||||
)
|
||||
def test_run_node_not_in_mappings_is_skipped(self, mock_ensure):
|
||||
workflow_data = {
|
||||
"1": {
|
||||
"class_type": "UnknownNode",
|
||||
"inputs": {"value": "test"},
|
||||
}
|
||||
}
|
||||
node_mappings = {"StubNode": StubNode}
|
||||
runtime = self._make_runtime()
|
||||
runtime._workflow_data = workflow_data
|
||||
runtime._node_class_mappings = node_mappings
|
||||
|
||||
result = runtime.run()
|
||||
|
||||
self.assertEqual(result, {})
|
||||
|
||||
def test_run_node_returns_tuple_is_converted_to_list(self):
|
||||
runtime = self._make_runtime()
|
||||
workflow_data = {
|
||||
"1": {
|
||||
"class_type": "StubNode",
|
||||
"inputs": {"value": "multi"},
|
||||
}
|
||||
}
|
||||
node_mappings = {"StubNode": StubNode}
|
||||
runtime._workflow_data = workflow_data
|
||||
runtime._node_class_mappings = node_mappings
|
||||
runtime.node_instances = {"StubNode": StubNode()}
|
||||
runtime._node_classes = {"StubNode": StubNode}
|
||||
|
||||
result = runtime.run()
|
||||
|
||||
self.assertIsInstance(result["1"], list)
|
||||
self.assertEqual(result["1"][0], "result:multi")
|
||||
|
||||
def test_run_persists_parameters_across_calls(self):
|
||||
runtime = self._make_runtime()
|
||||
workflow_data = {
|
||||
"1": {
|
||||
"class_type": "StubNode",
|
||||
"inputs": {"value": "persist"},
|
||||
}
|
||||
}
|
||||
node_mappings = {"StubNode": StubNode}
|
||||
|
||||
with patch.object(
|
||||
runtime, "_do_run", return_value={"1": ["result:persist"]}
|
||||
) as mock_do_run:
|
||||
runtime.run(
|
||||
workflow_data=workflow_data, node_class_mappings=node_mappings
|
||||
)
|
||||
runtime.run()
|
||||
|
||||
self.assertEqual(mock_do_run.call_count, 2)
|
||||
|
||||
def test_run_does_not_corrupt_session_on_exception(self):
|
||||
runtime = self._make_runtime()
|
||||
workflow_data = {
|
||||
"1": {
|
||||
"class_type": "StubNode",
|
||||
"inputs": {"value": "test"},
|
||||
}
|
||||
}
|
||||
node_mappings = {"StubNode": StubNode}
|
||||
runtime._workflow_data = workflow_data
|
||||
runtime._node_class_mappings = node_mappings
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.execute.side_effect = ValueError("boom")
|
||||
runtime.node_instances = {"StubNode": mock_instance}
|
||||
runtime._node_classes = {"StubNode": StubNode}
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
runtime.run()
|
||||
|
||||
runtime.node_instances = {"StubNode": StubNode()}
|
||||
runtime._node_classes = {"StubNode": StubNode}
|
||||
result = runtime.run()
|
||||
self.assertEqual(result["1"], ["result:test"])
|
||||
|
||||
@patch(
|
||||
"comfyui_to_python.runtime_session.WorkflowSessionRuntime._ensure_node_instances"
|
||||
)
|
||||
def test_session_policy_does_not_clear_cache(self, mock_ensure):
|
||||
runtime = WorkflowSessionRuntime(cleanup_policy="session")
|
||||
workflow_data = {
|
||||
"1": {
|
||||
"class_type": "StubNode",
|
||||
"inputs": {"value": "test"},
|
||||
}
|
||||
}
|
||||
node_mappings = {"StubNode": StubNode}
|
||||
runtime._workflow_data = workflow_data
|
||||
runtime._node_class_mappings = node_mappings
|
||||
|
||||
with patch.object(runtime, "clear_runtime_cache") as mock_clear:
|
||||
runtime.run()
|
||||
mock_clear.assert_not_called()
|
||||
|
||||
@patch(
|
||||
"comfyui_to_python.runtime_session.WorkflowSessionRuntime._ensure_node_instances"
|
||||
)
|
||||
def test_per_run_policy_clears_cache_after_each_run(self, mock_ensure):
|
||||
runtime = WorkflowSessionRuntime(cleanup_policy="per_run")
|
||||
workflow_data = {
|
||||
"1": {
|
||||
"class_type": "StubNode",
|
||||
"inputs": {"value": "test"},
|
||||
}
|
||||
}
|
||||
node_mappings = {"StubNode": StubNode}
|
||||
runtime._workflow_data = workflow_data
|
||||
runtime._node_class_mappings = node_mappings
|
||||
|
||||
with patch.object(runtime, "clear_runtime_cache") as mock_clear:
|
||||
runtime.run()
|
||||
mock_clear.assert_called_once()
|
||||
|
||||
@patch(
|
||||
"comfyui_to_python.runtime_session.WorkflowSessionRuntime._ensure_node_instances"
|
||||
)
|
||||
def test_manual_policy_never_clears_cache(self, mock_ensure):
|
||||
runtime = WorkflowSessionRuntime(cleanup_policy="manual")
|
||||
workflow_data = {
|
||||
"1": {
|
||||
"class_type": "StubNode",
|
||||
"inputs": {"value": "test"},
|
||||
}
|
||||
}
|
||||
node_mappings = {"StubNode": StubNode}
|
||||
runtime._workflow_data = workflow_data
|
||||
runtime._node_class_mappings = node_mappings
|
||||
|
||||
with patch.object(runtime, "clear_runtime_cache") as mock_clear:
|
||||
runtime.run()
|
||||
runtime.run()
|
||||
mock_clear.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,370 @@
|
||||
"""End-to-end tests for session execution mode feature.
|
||||
|
||||
Covers:
|
||||
- Code generation correctness (session vs oneshot)
|
||||
- Runtime execution of generated scripts
|
||||
- Multi-run session behavior
|
||||
"""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
COMFYUI_PATH = os.environ.get("COMFYUI_PATH", str(ROOT.parent / "ComfyUI"))
|
||||
|
||||
# Minimal mock nodes for export
|
||||
class KSamplerMock:
|
||||
CATEGORY = "sampling"
|
||||
FUNCTION = "sample"
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"seed": ("INT", {"default": 0}),
|
||||
"steps": ("INT", {"default": 20}),
|
||||
"cfg": ("FLOAT", {"default": 8.0}),
|
||||
"sampler_name": (["euler", "heun"],),
|
||||
"scheduler": (["normal"],),
|
||||
"denoise": ("FLOAT", {"default": 1.0}),
|
||||
"model": ("MODEL",),
|
||||
"positive": ("CONDITIONING",),
|
||||
"negative": ("CONDITIONING",),
|
||||
"latent_image": ("LATENT",),
|
||||
}
|
||||
}
|
||||
def sample(self, seed, steps, cfg, sampler_name, scheduler, denoise, model, positive, negative, latent_image):
|
||||
return ({"samples": latent_image},)
|
||||
|
||||
class CheckpointLoaderMock:
|
||||
CATEGORY = "loaders"
|
||||
FUNCTION = "load_checkpoint"
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"ckpt_name": ("STRING",),
|
||||
}
|
||||
}
|
||||
def load_checkpoint(self, ckpt_name):
|
||||
return (None, None, None)
|
||||
|
||||
class VAEDecodeMock:
|
||||
CATEGORY = "latent"
|
||||
FUNCTION = "decode"
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"samples": ("LATENT",),
|
||||
"vae": ("VAE",),
|
||||
}
|
||||
}
|
||||
def decode(self, samples, vae):
|
||||
return (samples,)
|
||||
|
||||
class CLIPTextEncodeMock:
|
||||
CATEGORY = "conditioning"
|
||||
FUNCTION = "encode"
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING",),
|
||||
"clip": ("CLIP",),
|
||||
}
|
||||
}
|
||||
def encode(self, text, clip):
|
||||
return ([],)
|
||||
|
||||
class EmptyLatentImageMock:
|
||||
CATEGORY = "latent"
|
||||
FUNCTION = "generate"
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"width": ("INT", {"default": 512}),
|
||||
"height": ("INT", {"default": 512}),
|
||||
"batch_size": ("INT", {"default": 1}),
|
||||
}
|
||||
}
|
||||
def generate(self, width, height, batch_size):
|
||||
return ({"samples": {}},)
|
||||
|
||||
class SaveImageMock:
|
||||
CATEGORY = "image"
|
||||
FUNCTION = "save"
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"filename_prefix": ("STRING",),
|
||||
}
|
||||
}
|
||||
def save(self, images, filename_prefix):
|
||||
return ()
|
||||
|
||||
NODENAMES = {
|
||||
"CheckpointLoaderSimple": CheckpointLoaderMock,
|
||||
"CLIPTextEncode": CLIPTextEncodeMock,
|
||||
"KSampler": KSamplerMock,
|
||||
"VAEDecode": VAEDecodeMock,
|
||||
"EmptyLatentImage": EmptyLatentImageMock,
|
||||
"SaveImage": SaveImageMock,
|
||||
}
|
||||
|
||||
TEXT_TO_IMAGE_WORKFLOW = {
|
||||
"1": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": "v1-5-pruned-emaonly-fp16.safetensors"},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "a small cottage in a meadow, soft daylight",
|
||||
"clip": ["1", 1],
|
||||
},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "blurry, low quality",
|
||||
"clip": ["1", 1],
|
||||
},
|
||||
},
|
||||
"4": {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": 512, "height": 512, "batch_size": 1},
|
||||
},
|
||||
"5": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": 1, "steps": 4, "cfg": 7, "sampler_name": "euler",
|
||||
"scheduler": "normal", "denoise": 1,
|
||||
"model": ["1", 0], "positive": ["2", 0],
|
||||
"negative": ["3", 0], "latent_image": ["4", 0],
|
||||
},
|
||||
},
|
||||
"6": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["5", 0], "vae": ["1", 2]},
|
||||
},
|
||||
"7": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": "E2E_session_mode", "images": ["6", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _get_runtime_python():
|
||||
"""Get the ComfyUI Python interpreter for running generated scripts."""
|
||||
rt_python = Path(COMFYUI_PATH) / ".venv" / "bin" / "python"
|
||||
if rt_python.is_file():
|
||||
return str(rt_python)
|
||||
return sys.executable
|
||||
|
||||
|
||||
def _export_session_workflow_in_runtime_env(
|
||||
workflow_json,
|
||||
execution_mode="session",
|
||||
):
|
||||
"""Export workflow via subprocess in ComfyUI runtime env.
|
||||
|
||||
Re-enters the runtime interpreter so ``ComfyUItoPython`` can import
|
||||
ComfyUI's nodes.py (which requires torch, not available in the test venv).
|
||||
"""
|
||||
runtime_python = _get_runtime_python()
|
||||
env = os.environ.copy()
|
||||
env["COMFYUI_PATH"] = COMFYUI_PATH
|
||||
env["PYTHONPATH"] = os.pathsep.join([str(ROOT), env.get("PYTHONPATH", "")]).rstrip(
|
||||
os.pathsep
|
||||
)
|
||||
|
||||
tmp_path = tempfile.mktemp(suffix=".py")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
runtime_python,
|
||||
str(Path(__file__).resolve().parents[0] / "runtime" / "run_runtime_validation.py"),
|
||||
"--internal-export",
|
||||
"text-to-image",
|
||||
"--execution-mode",
|
||||
execution_mode,
|
||||
"--generated-path",
|
||||
tmp_path,
|
||||
],
|
||||
cwd=ROOT,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
output = (result.stderr or result.stdout or "").strip()
|
||||
if "Missing runtime dependency" not in output and "ModuleNotFoundError" not in output:
|
||||
classification = "repo regression"
|
||||
else:
|
||||
classification = "environment/setup failure"
|
||||
raise RuntimeError(
|
||||
f"Runtime export failed: [{classification}] {output}"
|
||||
)
|
||||
# Read generated code from the temp file (stdout is polluted by
|
||||
# ComfyUI runtime prints such as the sys.path line).
|
||||
return Path(tmp_path).read_text()
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
def _export_workflow(execution_mode="oneshot"):
|
||||
"""Export workflow to a string using ComfyUItoPython (unit tests, mock nodes)."""
|
||||
from comfyui_to_python import ComfyUItoPython
|
||||
output = StringIO()
|
||||
ComfyUItoPython(
|
||||
workflow=json.dumps(TEXT_TO_IMAGE_WORKFLOW),
|
||||
output_file=output,
|
||||
node_class_mappings=NODENAMES,
|
||||
execution_mode=execution_mode,
|
||||
)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
class SessionCodeGenerationTest(unittest.TestCase):
|
||||
"""Unit tests for session mode code generation."""
|
||||
|
||||
def test_oneshot_code_has_bootstrap_helpers_no_session(self):
|
||||
"""Oneshot mode: bootstrap/cleanup helpers present, no WorkflowSession class."""
|
||||
generated = _export_workflow("oneshot")
|
||||
ast.parse(generated)
|
||||
self.assertIn("bootstrap_comfyui_runtime()", generated)
|
||||
self.assertIn("cleanup_comfyui_runtime(", generated)
|
||||
self.assertNotIn("class WorkflowSession", generated)
|
||||
self.assertNotIn("session.run()", generated)
|
||||
|
||||
def test_session_code_has_workflow_session_class_and_session_methods(self):
|
||||
"""Session mode: WorkflowSession class with run() and close() present."""
|
||||
generated = _export_workflow("session")
|
||||
ast.parse(generated)
|
||||
self.assertIn("class WorkflowSession", generated)
|
||||
self.assertIn("def run(self)", generated)
|
||||
self.assertIn("def close(self, unload_models", generated)
|
||||
self.assertIn("session.run()", generated)
|
||||
self.assertIn("session.close(", generated)
|
||||
|
||||
|
||||
class SessionModeExecutionTest(unittest.TestCase):
|
||||
"""E2E tests for session mode script execution."""
|
||||
|
||||
@unittest.skipIf(not Path(COMFYUI_PATH).is_dir(), "ComfyUI checkout not available")
|
||||
def test_oneshot_e2e_text_to_image(self):
|
||||
"""Oneshot mode: generate and run text-to-image workflow, verify PNG output."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
script_path = Path(tmpdir) / "oneshot.py"
|
||||
env = os.environ.copy()
|
||||
env["COMFYUI_PATH"] = COMFYUI_PATH
|
||||
env["PYTHONPATH"] = os.pathsep.join([str(ROOT), env.get("PYTHONPATH", "")])
|
||||
runtime_py = _get_runtime_python()
|
||||
|
||||
# Export inside ComfyUI env so node mappings resolve
|
||||
generated = _export_session_workflow_in_runtime_env(
|
||||
json.dumps(TEXT_TO_IMAGE_WORKFLOW),
|
||||
execution_mode="oneshot",
|
||||
)
|
||||
script_path.write_text(generated)
|
||||
|
||||
# Run
|
||||
result = subprocess.run(
|
||||
[runtime_py, str(script_path), "--cpu"],
|
||||
cwd=ROOT,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, f"stderr: {result.stderr}")
|
||||
|
||||
# Check output
|
||||
output_dir = Path(COMFYUI_PATH) / "output"
|
||||
new_outputs = list(output_dir.glob("E2E_text_to_image*.png"))
|
||||
self.assertTrue(len(new_outputs) > 0, "No PNG output produced")
|
||||
|
||||
@unittest.skipIf(not Path(COMFYUI_PATH).is_dir(), "ComfyUI checkout not available")
|
||||
def test_session_e2e_text_to_image(self):
|
||||
"""Session mode: generate session-mode script, verify WorkflowSession present and run."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
script_path = Path(tmpdir) / "session.py"
|
||||
env = os.environ.copy()
|
||||
env["COMFYUI_PATH"] = COMFYUI_PATH
|
||||
env["PYTHONPATH"] = os.pathsep.join([str(ROOT), env.get("PYTHONPATH", "")])
|
||||
runtime_py = _get_runtime_python()
|
||||
|
||||
# Export inside ComfyUI env
|
||||
generated = _export_session_workflow_in_runtime_env(
|
||||
json.dumps(TEXT_TO_IMAGE_WORKFLOW),
|
||||
execution_mode="session",
|
||||
)
|
||||
script_path.write_text(generated)
|
||||
|
||||
# Verify code structure
|
||||
self.assertIn("class WorkflowSession", generated)
|
||||
|
||||
# Run
|
||||
result = subprocess.run(
|
||||
[runtime_py, str(script_path), "--cpu"],
|
||||
cwd=ROOT,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, f"stderr: {result.stderr}")
|
||||
|
||||
# Check output
|
||||
output_dir = Path(COMFYUI_PATH) / "output"
|
||||
new_outputs = list(output_dir.glob("E2E_text_to_image*.png"))
|
||||
self.assertTrue(len(new_outputs) > 0, "No PNG output produced")
|
||||
|
||||
@unittest.skipIf(not Path(COMFYUI_PATH).is_dir(), "ComfyUI checkout not available")
|
||||
def test_session_e2e_multiple_runs(self):
|
||||
"""Session mode: generate script that calls session.run() 3x in a row, verify no crash."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
script_path = Path(tmpdir) / "session_multi.py"
|
||||
env = os.environ.copy()
|
||||
env["COMFYUI_PATH"] = COMFYUI_PATH
|
||||
env["PYTHONPATH"] = os.pathsep.join([str(ROOT), env.get("PYTHONPATH", "")])
|
||||
runtime_py = _get_runtime_python()
|
||||
|
||||
# Export session mode inside ComfyUI env
|
||||
generated = _export_session_workflow_in_runtime_env(
|
||||
json.dumps(TEXT_TO_IMAGE_WORKFLOW),
|
||||
execution_mode="session",
|
||||
)
|
||||
|
||||
# Modify to run 3x
|
||||
modified = generated.replace(
|
||||
" session.run()",
|
||||
" session.run()\n session.run()\n session.run()",
|
||||
)
|
||||
script_path.write_text(modified)
|
||||
|
||||
# Run
|
||||
result = subprocess.run(
|
||||
[runtime_py, str(script_path), "--cpu"],
|
||||
cwd=ROOT,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, f"stderr: {result.stderr}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,273 @@
|
||||
import json
|
||||
import unittest
|
||||
from io import StringIO
|
||||
from unittest.mock import patch
|
||||
|
||||
from comfyui_to_python import ComfyUItoPython
|
||||
|
||||
|
||||
class LoadImage:
|
||||
CATEGORY = "image"
|
||||
FUNCTION = "load_image"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {"image": ("STRING",)}}
|
||||
|
||||
def load_image(self, image):
|
||||
return (image,)
|
||||
|
||||
|
||||
class SessionRendererTest(unittest.TestCase):
|
||||
"""Tests for session mode code generation in the renderer."""
|
||||
|
||||
def test_session_mode_generates_workflow_session_class(self):
|
||||
workflow = {
|
||||
"1": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": "example.png"},
|
||||
}
|
||||
}
|
||||
|
||||
output = StringIO()
|
||||
ComfyUItoPython(
|
||||
workflow=json.dumps(workflow),
|
||||
output_file=output,
|
||||
node_class_mappings={"LoadImage": LoadImage},
|
||||
execution_mode="session",
|
||||
)
|
||||
|
||||
generated = output.getvalue()
|
||||
self.assertIn("class WorkflowSession", generated)
|
||||
|
||||
def test_session_mode_generates_main_wrapper(self):
|
||||
workflow = {
|
||||
"1": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": "example.png"},
|
||||
}
|
||||
}
|
||||
|
||||
output = StringIO()
|
||||
ComfyUItoPython(
|
||||
workflow=json.dumps(workflow),
|
||||
output_file=output,
|
||||
node_class_mappings={"LoadImage": LoadImage},
|
||||
execution_mode="session",
|
||||
)
|
||||
|
||||
generated = output.getvalue()
|
||||
self.assertIn("def main(unload_models: bool | None = None)", generated)
|
||||
self.assertIn("WorkflowSession(", generated)
|
||||
|
||||
def test_session_mode_main_creates_session_with_per_run_policy(self):
|
||||
workflow = {
|
||||
"1": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": "example.png"},
|
||||
}
|
||||
}
|
||||
|
||||
output = StringIO()
|
||||
ComfyUItoPython(
|
||||
workflow=json.dumps(workflow),
|
||||
output_file=output,
|
||||
node_class_mappings={"LoadImage": LoadImage},
|
||||
execution_mode="session",
|
||||
)
|
||||
|
||||
generated = output.getvalue()
|
||||
self.assertIn('cleanup_policy="per_run"', generated)
|
||||
|
||||
def test_session_mode_main_has_try_finally_close(self):
|
||||
workflow = {
|
||||
"1": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": "example.png"},
|
||||
}
|
||||
}
|
||||
|
||||
output = StringIO()
|
||||
ComfyUItoPython(
|
||||
workflow=json.dumps(workflow),
|
||||
output_file=output,
|
||||
node_class_mappings={"LoadImage": LoadImage},
|
||||
execution_mode="session",
|
||||
)
|
||||
|
||||
generated = output.getvalue()
|
||||
self.assertIn("try:", generated)
|
||||
self.assertIn("session.run()", generated)
|
||||
self.assertIn("finally:", generated)
|
||||
self.assertIn("session.close(", generated)
|
||||
|
||||
def test_session_mode_generates_run_method(self):
|
||||
workflow = {
|
||||
"1": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": "example.png"},
|
||||
}
|
||||
}
|
||||
|
||||
output = StringIO()
|
||||
ComfyUItoPython(
|
||||
workflow=json.dumps(workflow),
|
||||
output_file=output,
|
||||
node_class_mappings={"LoadImage": LoadImage},
|
||||
execution_mode="session",
|
||||
)
|
||||
|
||||
generated = output.getvalue()
|
||||
self.assertIn("def run(self", generated)
|
||||
|
||||
def test_session_mode_generates_close_method(self):
|
||||
workflow = {
|
||||
"1": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": "example.png"},
|
||||
}
|
||||
}
|
||||
|
||||
output = StringIO()
|
||||
ComfyUItoPython(
|
||||
workflow=json.dumps(workflow),
|
||||
output_file=output,
|
||||
node_class_mappings={"LoadImage": LoadImage},
|
||||
execution_mode="session",
|
||||
)
|
||||
|
||||
generated = output.getvalue()
|
||||
self.assertIn("def close(self, unload_models: bool | None = None)", generated)
|
||||
|
||||
def test_session_mode_oneshot_generates_same_code(self):
|
||||
workflow = {
|
||||
"1": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": "example.png"},
|
||||
}
|
||||
}
|
||||
|
||||
output = StringIO()
|
||||
ComfyUItoPython(
|
||||
workflow=json.dumps(workflow),
|
||||
output_file=output,
|
||||
node_class_mappings={"LoadImage": LoadImage},
|
||||
execution_mode="oneshot",
|
||||
)
|
||||
|
||||
generated = output.getvalue()
|
||||
self.assertNotIn("class WorkflowSession", generated)
|
||||
self.assertIn("def main(unload_models: bool | None = None)", generated)
|
||||
self.assertIn("bootstrap_comfyui_runtime()", generated)
|
||||
self.assertIn("cleanup_comfyui_runtime(unload_models=unload_models)", generated)
|
||||
|
||||
def test_session_mode_oneshot_default(self):
|
||||
workflow = {
|
||||
"1": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": "example.png"},
|
||||
}
|
||||
}
|
||||
|
||||
output = StringIO()
|
||||
ComfyUItoPython(
|
||||
workflow=json.dumps(workflow),
|
||||
output_file=output,
|
||||
node_class_mappings={"LoadImage": LoadImage},
|
||||
)
|
||||
|
||||
generated = output.getvalue()
|
||||
self.assertNotIn("class WorkflowSession", generated)
|
||||
self.assertIn("def main(unload_models: bool | None = None)", generated)
|
||||
|
||||
def test_session_mode_script_is_executable(self):
|
||||
workflow = {
|
||||
"1": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": "example.png"},
|
||||
}
|
||||
}
|
||||
|
||||
output = StringIO()
|
||||
ComfyUItoPython(
|
||||
workflow=json.dumps(workflow),
|
||||
output_file=output,
|
||||
node_class_mappings={"LoadImage": LoadImage},
|
||||
execution_mode="session",
|
||||
)
|
||||
|
||||
generated = output.getvalue()
|
||||
|
||||
# Should not raise — checks that generated code has valid syntax
|
||||
compile(generated, "<generated>", "exec")
|
||||
|
||||
# main() should be callable
|
||||
globals_dict = {"__name__": "generated_workflow_module"}
|
||||
with patch("comfyui_to_python.runtime_session.WorkflowSessionRuntime"):
|
||||
exec(generated, globals_dict)
|
||||
self.assertIn("WorkflowSession", globals_dict)
|
||||
self.assertIn("main", globals_dict)
|
||||
self.assertTrue(callable(globals_dict["main"]))
|
||||
|
||||
def test_session_mode_generates_workflow_literal(self):
|
||||
workflow = {
|
||||
"1": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": "example.png"},
|
||||
}
|
||||
}
|
||||
|
||||
output = StringIO()
|
||||
ComfyUItoPython(
|
||||
workflow=json.dumps(workflow),
|
||||
output_file=output,
|
||||
node_class_mappings={"LoadImage": LoadImage},
|
||||
execution_mode="session",
|
||||
)
|
||||
|
||||
generated = output.getvalue()
|
||||
self.assertIn("def build_workflow()", generated)
|
||||
self.assertIn('return', generated)
|
||||
self.assertIn('"class_type": "LoadImage"', generated)
|
||||
|
||||
def test_session_mode_generates_bootstrap_helper(self):
|
||||
workflow = {
|
||||
"1": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": "example.png"},
|
||||
}
|
||||
}
|
||||
|
||||
output = StringIO()
|
||||
ComfyUItoPython(
|
||||
workflow=json.dumps(workflow),
|
||||
output_file=output,
|
||||
node_class_mappings={"LoadImage": LoadImage},
|
||||
execution_mode="session",
|
||||
)
|
||||
|
||||
generated = output.getvalue()
|
||||
self.assertIn("def bootstrap_comfyui_runtime()", generated)
|
||||
|
||||
def test_session_mode_generates_cleanup_helper(self):
|
||||
workflow = {
|
||||
"1": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": "example.png"},
|
||||
}
|
||||
}
|
||||
|
||||
output = StringIO()
|
||||
ComfyUItoPython(
|
||||
workflow=json.dumps(workflow),
|
||||
output_file=output,
|
||||
node_class_mappings={"LoadImage": LoadImage},
|
||||
execution_mode="session",
|
||||
)
|
||||
|
||||
generated = output.getvalue()
|
||||
self.assertIn("def cleanup_comfyui_runtime(", generated)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,522 @@
|
||||
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("def cleanup_comfyui_runtime(", generated)
|
||||
self.assertIn("import comfy.options", generated)
|
||||
self.assertIn("comfy.options.enable_args_parsing()", generated)
|
||||
self.assertIn("import cuda_malloc", generated)
|
||||
self.assertNotIn("\nbootstrap_comfyui_runtime()\n", generated)
|
||||
self.assertIn(
|
||||
"def main(unload_models: bool | None = None):\n"
|
||||
" bootstrap_comfyui_runtime()\n"
|
||||
" add_extra_model_paths()",
|
||||
generated,
|
||||
)
|
||||
self.assertLess(
|
||||
generated.index("def bootstrap_comfyui_runtime()"),
|
||||
generated.index("def main(unload_models: bool | None = None):"),
|
||||
)
|
||||
main_section = generated[
|
||||
generated.index("def main(unload_models: bool | None = None):") :
|
||||
]
|
||||
self.assertIn(
|
||||
"def main(unload_models: bool | None = None):\n"
|
||||
" bootstrap_comfyui_runtime()\n"
|
||||
" add_extra_model_paths()",
|
||||
main_section,
|
||||
)
|
||||
self.assertLess(
|
||||
main_section.index("bootstrap_comfyui_runtime()"),
|
||||
main_section.index("add_extra_model_paths()"),
|
||||
)
|
||||
self.assertLess(
|
||||
main_section.index("add_extra_model_paths()"),
|
||||
main_section.index("import torch"),
|
||||
)
|
||||
self.assertLess(generated.index("import cuda_malloc"), generated.index("import torch"))
|
||||
self.assertIn(
|
||||
" finally:\n cleanup_comfyui_runtime(unload_models=unload_models)",
|
||||
main_section,
|
||||
)
|
||||
|
||||
def test_generated_module_import_does_not_parse_cli_args(self):
|
||||
workflow = {
|
||||
"1": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "example.png",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
output = StringIO()
|
||||
ComfyUItoPython(
|
||||
workflow=json.dumps(workflow),
|
||||
output_file=output,
|
||||
node_class_mappings={
|
||||
"LoadImage": LoadImage,
|
||||
},
|
||||
)
|
||||
|
||||
generated = output.getvalue()
|
||||
|
||||
self.assertNotIn("\nimport torch\n", generated)
|
||||
|
||||
globals_dict = {"__name__": "generated_workflow_module"}
|
||||
with patch.object(sys, "argv", ["generated_workflow.py", "--wrapper-flag"]):
|
||||
exec(generated, globals_dict)
|
||||
|
||||
self.assertTrue(callable(globals_dict["main"]))
|
||||
|
||||
def test_upscale_workflow_uses_direct_upscale_model_loader_init(self):
|
||||
workflow = {
|
||||
"1": {
|
||||
"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(unload_models: bool | None = None)", generated)
|
||||
self.assertIn("bootstrap_comfyui_runtime()", generated)
|
||||
self.assertIn("cleanup_comfyui_runtime(unload_models=unload_models)", generated)
|
||||
self.assertNotIn("def initialize_workflow()", generated)
|
||||
self.assertNotIn("def run_once(", generated)
|
||||
self.assertIn("with torch.inference_mode():", generated)
|
||||
self.assertIn("finally:", generated)
|
||||
self.assertIn("for q in range(1):", generated)
|
||||
self.assertIn("workflow = build_workflow()", generated)
|
||||
self.assertIn("extra_pnginfo = build_extra_pnginfo()", generated)
|
||||
self.assertNotIn('workflow = json.loads("', generated)
|
||||
self.assertLess(
|
||||
generated.index("def build_workflow()"),
|
||||
generated.index("def main(unload_models: bool | None = None)"),
|
||||
)
|
||||
|
||||
def test_hidden_metadata_kwargs_follow_function_signature(self):
|
||||
workflow = {
|
||||
"1": {
|
||||
"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()
|
||||
@@ -0,0 +1,75 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from comfyui_to_python.runtime_session import WorkflowSession
|
||||
|
||||
|
||||
class TestWorkflowSessionInit(unittest.TestCase):
|
||||
"""Tests for WorkflowSession initialization."""
|
||||
|
||||
def test_init_creates_runtime(self):
|
||||
session = WorkflowSession()
|
||||
self.assertIsNotNone(session._runtime)
|
||||
|
||||
def test_init_passes_cleanup_policy(self):
|
||||
session = WorkflowSession(cleanup_policy="per_run")
|
||||
self.assertEqual(session._runtime._cleanup_policy, "per_run")
|
||||
|
||||
def test_init_passes_reset_every_n_runs(self):
|
||||
session = WorkflowSession(reset_every_n_runs=5)
|
||||
self.assertEqual(session._runtime._reset_every_n_runs, 5)
|
||||
|
||||
|
||||
class TestWorkflowSessionDelegation(unittest.TestCase):
|
||||
"""Tests for WorkflowSession public API delegation to internal runtime."""
|
||||
|
||||
def _make_session(self):
|
||||
return WorkflowSession()
|
||||
|
||||
def test_run_delegates_to_runtime(self):
|
||||
session = self._make_session()
|
||||
workflow_data = {
|
||||
"1": {
|
||||
"class_type": "StubNode",
|
||||
"inputs": {"value": "test"},
|
||||
}
|
||||
}
|
||||
node_mappings = {"StubNode": MagicMock()}
|
||||
session._runtime.node_instances = {"StubNode": MagicMock()}
|
||||
session._runtime._node_classes = {"StubNode": MagicMock()}
|
||||
|
||||
with patch.object(
|
||||
session._runtime, "run", return_value={"1": ["result"]}
|
||||
) as mock_run:
|
||||
session.run(workflow_data=workflow_data, node_class_mappings=node_mappings)
|
||||
mock_run.assert_called_once_with(
|
||||
workflow_data=workflow_data,
|
||||
node_class_mappings=node_mappings,
|
||||
extra_pnginfo=None,
|
||||
)
|
||||
|
||||
def test_clear_runtime_cache_delegates_to_runtime(self):
|
||||
session = self._make_session()
|
||||
with patch.object(
|
||||
session._runtime, "clear_runtime_cache"
|
||||
) as mock_clear:
|
||||
session.clear_runtime_cache()
|
||||
mock_clear.assert_called_once()
|
||||
|
||||
def test_close_delegates_to_runtime(self):
|
||||
session = self._make_session()
|
||||
with patch.object(
|
||||
session._runtime, "close"
|
||||
) as mock_close:
|
||||
session.close(unload_models=True)
|
||||
mock_close.assert_called_once_with(unload_models=True)
|
||||
|
||||
def test_run_raises_after_close(self):
|
||||
session = self._make_session()
|
||||
session.close()
|
||||
with self.assertRaises(RuntimeError):
|
||||
session.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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.1.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" },
|
||||
]
|
||||
Reference in New Issue
Block a user