Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6a0a42d64 | ||
|
|
b3d11c97be | ||
|
|
9f43086ffc | ||
|
|
29defc5ef1 | ||
|
|
ae8d5a2f2a | ||
|
|
7ebd45350d | ||
|
|
e569e361a6 | ||
|
|
3ec60ded7d | ||
|
|
49649a6745 | ||
|
|
603fabdf32 | ||
|
|
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 |
@@ -145,3 +145,8 @@ cython_debug/
|
||||
*.sql
|
||||
*.sqlite
|
||||
*.xml
|
||||
|
||||
docs/
|
||||
.agents/
|
||||
.codex/
|
||||
AGENTS.md
|
||||
|
||||
@@ -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
-638
@@ -1,642 +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
|
||||
|
||||
# Add current directory to Python path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from comfyui_to_python_utils import (
|
||||
import_custom_nodes,
|
||||
find_path,
|
||||
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,55 @@
|
||||
from typing import TextIO
|
||||
|
||||
from .app import ExportApplication
|
||||
from .cli import DEFAULT_INPUT_FILE, DEFAULT_OUTPUT_FILE, DEFAULT_QUEUE_SIZE, main
|
||||
from .node_runtime import get_node_class_mappings, import_custom_nodes
|
||||
|
||||
|
||||
class ComfyUItoPython:
|
||||
"""Public compatibility facade for the exporter package."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workflow: str = "",
|
||||
frontend_workflow: str | dict | None = None,
|
||||
input_file: str = "",
|
||||
output_file: str | TextIO = "",
|
||||
queue_size: int = 1,
|
||||
node_class_mappings: dict | None = None,
|
||||
needs_init_custom_nodes: bool = False,
|
||||
):
|
||||
self._app = ExportApplication(
|
||||
workflow=workflow,
|
||||
frontend_workflow=frontend_workflow,
|
||||
input_file=input_file,
|
||||
output_file=output_file,
|
||||
queue_size=queue_size,
|
||||
node_class_mappings=node_class_mappings,
|
||||
needs_init_custom_nodes=needs_init_custom_nodes,
|
||||
node_mapping_loader=get_node_class_mappings,
|
||||
custom_node_importer=import_custom_nodes,
|
||||
)
|
||||
self._app.execute()
|
||||
|
||||
|
||||
def run(
|
||||
input_file: str = DEFAULT_INPUT_FILE,
|
||||
output_file: str = DEFAULT_OUTPUT_FILE,
|
||||
queue_size: int = DEFAULT_QUEUE_SIZE,
|
||||
) -> None:
|
||||
"""Generate Python code from a ComfyUI workflow_api.json file."""
|
||||
ComfyUItoPython(
|
||||
input_file=input_file,
|
||||
output_file=output_file,
|
||||
queue_size=queue_size,
|
||||
needs_init_custom_nodes=True,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ComfyUItoPython",
|
||||
"run",
|
||||
"main",
|
||||
"get_node_class_mappings",
|
||||
"import_custom_nodes",
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
from .cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,85 @@
|
||||
import copy
|
||||
import sys
|
||||
|
||||
from typing import TextIO
|
||||
|
||||
from .generator.planner import WorkflowPlanner
|
||||
from .generator.render import WorkflowRenderer
|
||||
from .io import write_python_output
|
||||
from .load_order import LoadOrderDeterminer
|
||||
from .workflow_loader import load_frontend_workflow_data, load_workflow_data
|
||||
|
||||
|
||||
class ExportApplication:
|
||||
"""High-level exporter orchestration."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workflow: str = "",
|
||||
frontend_workflow: str | dict | None = None,
|
||||
input_file: str = "",
|
||||
output_file: str | TextIO = "",
|
||||
queue_size: int = 1,
|
||||
node_class_mappings: dict | None = None,
|
||||
needs_init_custom_nodes: bool = False,
|
||||
node_mapping_loader=None,
|
||||
custom_node_importer=None,
|
||||
):
|
||||
if input_file and workflow:
|
||||
raise ValueError("Can't provide both input_file and workflow")
|
||||
if not input_file and not workflow:
|
||||
raise ValueError("Needs input_file or workflow")
|
||||
if not output_file:
|
||||
raise ValueError("Needs output_file")
|
||||
|
||||
self.workflow = workflow
|
||||
self.frontend_workflow = frontend_workflow
|
||||
self.input_file = input_file
|
||||
self.output_file = output_file
|
||||
self.queue_size = queue_size
|
||||
self.node_mapping_loader = node_mapping_loader
|
||||
self.custom_node_importer = custom_node_importer
|
||||
self.node_class_mappings = (
|
||||
node_class_mappings
|
||||
if node_class_mappings is not None
|
||||
else self.node_mapping_loader()
|
||||
)
|
||||
self.needs_init_custom_nodes = needs_init_custom_nodes
|
||||
self.base_node_class_mappings = copy.deepcopy(self.node_class_mappings)
|
||||
|
||||
def execute(self) -> None:
|
||||
data = load_workflow_data(self.workflow, self.input_file)
|
||||
metadata_workflow_data = load_frontend_workflow_data(self.frontend_workflow)
|
||||
|
||||
missing_node_types = {
|
||||
node_data["class_type"]
|
||||
for node_data in data.values()
|
||||
if node_data["class_type"] not in self.node_class_mappings
|
||||
}
|
||||
if self.needs_init_custom_nodes or missing_node_types:
|
||||
self.custom_node_importer()
|
||||
# Re-read from the cached "nodes" module in sys.modules after
|
||||
# import_custom_nodes() populates it with extras (comfy_extras,
|
||||
# custom node directories, etc.). The original dict is a stale copy.
|
||||
nodes_mod = sys.modules.get("nodes")
|
||||
if nodes_mod is not None:
|
||||
fresh_mappings = getattr(nodes_mod, "NODE_CLASS_MAPPINGS", {})
|
||||
self.node_class_mappings = fresh_mappings
|
||||
# Leave self.base_node_class_mappings unchanged — it represents the
|
||||
# pre-custom-node baseline used by WorkflowPlanner to decide whether
|
||||
# a node gets a direct import or a NODE_CLASS_MAPPINGS dict lookup.
|
||||
|
||||
load_order = LoadOrderDeterminer(
|
||||
data, self.node_class_mappings
|
||||
).determine_load_order()
|
||||
plan = WorkflowPlanner(
|
||||
self.node_class_mappings, self.base_node_class_mappings
|
||||
).build_plan(
|
||||
load_order,
|
||||
data,
|
||||
metadata_workflow_data,
|
||||
queue_size=self.queue_size,
|
||||
)
|
||||
generated_code = WorkflowRenderer().render(plan)
|
||||
write_python_output(self.output_file, generated_code)
|
||||
print(f"Code successfully generated and written to {self.output_file}")
|
||||
@@ -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,29 @@
|
||||
from ..node_runtime import (
|
||||
_find_file,
|
||||
_find_from_extension_location,
|
||||
_is_comfyui_directory,
|
||||
_load_module,
|
||||
_load_module_temp,
|
||||
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__ = [
|
||||
"_find_file",
|
||||
"_find_from_extension_location",
|
||||
"_is_comfyui_directory",
|
||||
"_load_module",
|
||||
"_load_module_temp",
|
||||
"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,12 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GenerationPlan:
|
||||
import_statements: dict[str, set[str]]
|
||||
special_functions_code: list[str]
|
||||
loop_code: list[str]
|
||||
workflow_data: dict
|
||||
metadata_workflow_data: dict | None
|
||||
queue_size: int
|
||||
custom_nodes: bool
|
||||
@@ -0,0 +1,261 @@
|
||||
import inspect
|
||||
import json
|
||||
import keyword
|
||||
import random
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
from .model import GenerationPlan
|
||||
|
||||
|
||||
class WorkflowPlanner:
|
||||
"""Convert ordered workflow nodes into a renderer-ready generation plan."""
|
||||
|
||||
def __init__(self, node_class_mappings: dict, base_node_class_mappings: dict):
|
||||
self.node_class_mappings = node_class_mappings
|
||||
self.base_node_class_mappings = base_node_class_mappings
|
||||
|
||||
@staticmethod
|
||||
def sanitize_node_id(node_id: str) -> str:
|
||||
sanitized = re.sub(r"[^a-z0-9_]", "_", str(node_id).lower().strip())
|
||||
sanitized = re.sub(r"_+", "_", sanitized).strip("_")
|
||||
if not sanitized:
|
||||
sanitized = "node"
|
||||
return sanitized
|
||||
|
||||
@staticmethod
|
||||
def clean_variable_name(class_type: str) -> str:
|
||||
clean_name = class_type.lower().strip().replace("-", "_").replace(" ", "_")
|
||||
clean_name = re.sub(r"[^a-z0-9_]", "", clean_name)
|
||||
if clean_name[0].isdigit():
|
||||
clean_name = "_" + clean_name
|
||||
return clean_name
|
||||
|
||||
def build_plan(
|
||||
self,
|
||||
load_order: list[tuple[str, dict, bool]],
|
||||
workflow_data: dict,
|
||||
metadata_workflow_data: dict | None = None,
|
||||
queue_size: int = 10,
|
||||
) -> GenerationPlan:
|
||||
import_statements = {"nodes": {"NODE_CLASS_MAPPINGS"}}
|
||||
executed_variables = {}
|
||||
special_functions_code = []
|
||||
code = []
|
||||
initialized_objects = {}
|
||||
custom_nodes = False
|
||||
|
||||
for idx, data, is_special_function in load_order:
|
||||
inputs, class_type = data["inputs"], data["class_type"]
|
||||
input_types = self.node_class_mappings[class_type].INPUT_TYPES()
|
||||
input_value_types = self.get_input_value_types(input_types)
|
||||
class_def = self.node_class_mappings[class_type]()
|
||||
|
||||
missing_required_variable = False
|
||||
if "required" in input_types.keys():
|
||||
for required in input_types["required"]:
|
||||
if required not in inputs.keys():
|
||||
missing_required_variable = True
|
||||
if missing_required_variable:
|
||||
continue
|
||||
|
||||
if class_type not in initialized_objects:
|
||||
if class_type == "PreviewImage":
|
||||
continue
|
||||
|
||||
class_type, import_statement, class_code = self.get_class_info(
|
||||
class_type
|
||||
)
|
||||
initialized_objects[class_type] = self.clean_variable_name(class_type)
|
||||
if class_type in self.base_node_class_mappings.keys():
|
||||
module_name, import_name = import_statement
|
||||
import_statements.setdefault(module_name, set()).add(import_name)
|
||||
if 'NODE_CLASS_MAPPINGS["' in class_code:
|
||||
custom_nodes = True
|
||||
special_functions_code.append(class_code)
|
||||
|
||||
class_def_params = self.get_function_parameters(
|
||||
getattr(class_def, class_def.FUNCTION)
|
||||
)
|
||||
no_params = class_def_params is None
|
||||
|
||||
inputs = {
|
||||
key: value
|
||||
for key, value in inputs.items()
|
||||
if no_params or key in class_def_params
|
||||
}
|
||||
|
||||
hidden_inputs = input_types.get("hidden", {})
|
||||
if (
|
||||
"unique_id" in hidden_inputs
|
||||
and (no_params or "unique_id" in class_def_params)
|
||||
):
|
||||
inputs["unique_id"] = random.randint(1, 2**64)
|
||||
if "prompt" in hidden_inputs and (no_params or "prompt" in class_def_params):
|
||||
inputs["prompt"] = {"variable_name": "prompt"}
|
||||
if "extra_pnginfo" in hidden_inputs and (
|
||||
no_params or "extra_pnginfo" in class_def_params
|
||||
):
|
||||
inputs["extra_pnginfo"] = {"variable_name": "extra_pnginfo"}
|
||||
if "hidden" not in input_types and class_def_params is not None:
|
||||
if "unique_id" in class_def_params:
|
||||
inputs["unique_id"] = random.randint(1, 2**64)
|
||||
|
||||
executed_variables[idx] = (
|
||||
f"{self.clean_variable_name(class_type)}_"
|
||||
f"{self.sanitize_node_id(str(idx))}"
|
||||
)
|
||||
inputs = self.update_inputs(inputs, executed_variables)
|
||||
seed_sync_code = self.create_prompt_seed_sync_code(
|
||||
idx, inputs, input_value_types, is_special_function
|
||||
)
|
||||
|
||||
target_lines = special_functions_code if is_special_function else code
|
||||
if seed_sync_code:
|
||||
target_lines.extend(seed_sync_code)
|
||||
target_lines.append(
|
||||
self.create_function_call_code(
|
||||
initialized_objects[class_type],
|
||||
class_def.FUNCTION,
|
||||
executed_variables[idx],
|
||||
is_special_function,
|
||||
input_value_types=input_value_types,
|
||||
**inputs,
|
||||
)
|
||||
)
|
||||
|
||||
return GenerationPlan(
|
||||
import_statements=import_statements,
|
||||
special_functions_code=special_functions_code,
|
||||
loop_code=code,
|
||||
workflow_data=workflow_data,
|
||||
metadata_workflow_data=metadata_workflow_data,
|
||||
queue_size=queue_size,
|
||||
custom_nodes=custom_nodes,
|
||||
)
|
||||
|
||||
def create_function_call_code(
|
||||
self,
|
||||
obj_name: str,
|
||||
func: str,
|
||||
variable_name: str,
|
||||
is_special_function: bool,
|
||||
input_value_types: dict[str, str] | None = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
args = ", ".join(
|
||||
self.format_arg(key, value, (input_value_types or {}).get(key))
|
||||
for key, value in kwargs.items()
|
||||
)
|
||||
code = f"{variable_name} = {obj_name}.{func}({args})\n"
|
||||
if not is_special_function:
|
||||
code = f"\t{code}"
|
||||
return code
|
||||
|
||||
def create_prompt_seed_sync_code(
|
||||
self,
|
||||
node_id: str,
|
||||
inputs: dict,
|
||||
input_value_types: dict[str, str],
|
||||
is_special_function: bool,
|
||||
) -> list[str]:
|
||||
seed_sync_lines = []
|
||||
for key in ("seed", "noise_seed"):
|
||||
if key not in inputs:
|
||||
continue
|
||||
randomized_seed_variable = (
|
||||
f"node_{self.sanitize_node_id(str(node_id))}_{self.clean_variable_name(key)}"
|
||||
)
|
||||
randomized_seed_code = self.get_randomized_seed_code(
|
||||
input_value_types.get(key)
|
||||
)
|
||||
seed_sync_lines.append(
|
||||
f'{randomized_seed_variable} = prompt["{node_id}"]["inputs"]["{key}"] = {randomized_seed_code}'
|
||||
)
|
||||
inputs[key] = {"variable_name": randomized_seed_variable}
|
||||
|
||||
if not seed_sync_lines:
|
||||
return []
|
||||
|
||||
indentation = "" if is_special_function else "\t"
|
||||
return [f"{indentation}{line}\n" for line in seed_sync_lines]
|
||||
|
||||
def format_arg(self, key: str, value: Any, input_value_type: str | None = None) -> str:
|
||||
value_code = self.format_arg_value(key, value, input_value_type)
|
||||
if key.isidentifier() and not keyword.iskeyword(key):
|
||||
return f"{key}={value_code}"
|
||||
return f"**{{{json.dumps(key)}: {value_code}}}"
|
||||
|
||||
@staticmethod
|
||||
def format_arg_value(
|
||||
key: str, value: Any, input_value_type: str | None = None
|
||||
) -> str:
|
||||
if isinstance(value, dict) and "variable_name" in value:
|
||||
return value["variable_name"]
|
||||
if key == "noise_seed" or key == "seed":
|
||||
return WorkflowPlanner.get_randomized_seed_code(input_value_type)
|
||||
if isinstance(value, str):
|
||||
return json.dumps(value)
|
||||
return repr(value)
|
||||
|
||||
@staticmethod
|
||||
def get_input_value_types(input_types: dict) -> dict[str, str]:
|
||||
value_types = {}
|
||||
for section in ("required", "optional", "hidden"):
|
||||
for key, value in input_types.get(section, {}).items():
|
||||
if isinstance(value, tuple) and value:
|
||||
value_types[key] = value[0]
|
||||
elif isinstance(value, str):
|
||||
value_types[key] = value
|
||||
return value_types
|
||||
|
||||
@staticmethod
|
||||
def get_randomized_seed_code(input_value_type: str | None) -> str:
|
||||
randomized_seed_code = "random.randint(1, 2**64)"
|
||||
if input_value_type == "STRING":
|
||||
return f"str({randomized_seed_code})"
|
||||
return randomized_seed_code
|
||||
|
||||
def get_class_info(self, class_type: str) -> tuple[str, tuple[str, str], str]:
|
||||
class_obj = self.base_node_class_mappings.get(class_type)
|
||||
module_name = "nodes"
|
||||
if class_obj is not None:
|
||||
module_name = class_obj.__module__
|
||||
variable_name = self.clean_variable_name(class_type)
|
||||
is_importable_module = bool(
|
||||
module_name
|
||||
and "/" not in module_name
|
||||
and "\\" not in module_name
|
||||
and all(part.isidentifier() for part in module_name.split("."))
|
||||
)
|
||||
if class_type in self.base_node_class_mappings.keys() and is_importable_module:
|
||||
import_statement = (module_name, class_type)
|
||||
class_code = f"{variable_name} = {class_type.strip()}()"
|
||||
else:
|
||||
import_statement = ("nodes", "NODE_CLASS_MAPPINGS")
|
||||
class_code = f'{variable_name} = NODE_CLASS_MAPPINGS["{class_type}"]()'
|
||||
return class_type, import_statement, class_code
|
||||
|
||||
@staticmethod
|
||||
def get_function_parameters(func: Callable) -> list | None:
|
||||
signature = inspect.signature(func)
|
||||
parameters = {
|
||||
name: param.default if param.default != param.empty else None
|
||||
for name, param in signature.parameters.items()
|
||||
}
|
||||
catch_all = any(
|
||||
param.kind == inspect.Parameter.VAR_KEYWORD
|
||||
for param in signature.parameters.values()
|
||||
)
|
||||
return list(parameters.keys()) if not catch_all else None
|
||||
|
||||
def update_inputs(self, inputs: dict, executed_variables: dict) -> dict:
|
||||
for key in inputs.keys():
|
||||
if (
|
||||
isinstance(inputs[key], list)
|
||||
and inputs[key][0] in executed_variables.keys()
|
||||
):
|
||||
inputs[key] = {
|
||||
"variable_name": f"get_value_at_index({executed_variables[inputs[key][0]]}, {inputs[key][1]})"
|
||||
}
|
||||
return inputs
|
||||
@@ -0,0 +1,147 @@
|
||||
import inspect
|
||||
import logging
|
||||
from pprint import pformat
|
||||
from typing import Any
|
||||
|
||||
import black
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
from ..node_runtime import import_custom_nodes
|
||||
from . import generated_helpers
|
||||
from .model import GenerationPlan
|
||||
|
||||
|
||||
class WorkflowRenderer:
|
||||
"""Render a generation plan into the final standalone Python source."""
|
||||
|
||||
def render(self, plan: GenerationPlan) -> str:
|
||||
workflow_literal = self.format_python_literal(plan.workflow_data)
|
||||
if plan.metadata_workflow_data is None:
|
||||
extra_pnginfo_literal = "None"
|
||||
else:
|
||||
extra_pnginfo_literal = self.format_python_literal(
|
||||
{"workflow": plan.metadata_workflow_data}
|
||||
)
|
||||
|
||||
# Auto-discover helpers from generated_helpers.__all__ so new helpers
|
||||
# are picked up without manually updating this list.
|
||||
func_strings = []
|
||||
for name in generated_helpers.__all__:
|
||||
func = getattr(generated_helpers, name, None)
|
||||
if func is None:
|
||||
log.warning(
|
||||
"Helper '%s' missing from generated_helpers — skipping", name
|
||||
)
|
||||
continue
|
||||
func_strings.append(f"\n{inspect.getsource(func)}")
|
||||
|
||||
static_imports = [
|
||||
"# Imports",
|
||||
"import importlib.util",
|
||||
"import json",
|
||||
"import logging",
|
||||
"import os",
|
||||
"import random",
|
||||
"import sys",
|
||||
"from typing import Sequence, Mapping, Any, Union",
|
||||
"",
|
||||
"log = logging.getLogger(__name__)",
|
||||
] + func_strings
|
||||
|
||||
if plan.custom_nodes:
|
||||
static_imports.append(f"\n{inspect.getsource(import_custom_nodes)}\n")
|
||||
custom_nodes_call = "import_custom_nodes()"
|
||||
else:
|
||||
custom_nodes_call = None
|
||||
|
||||
imports_code = []
|
||||
for module_name in sorted(plan.import_statements.keys()):
|
||||
class_names = ", ".join(sorted(plan.import_statements[module_name]))
|
||||
imports_code.append(f"from {module_name} import {class_names}")
|
||||
|
||||
workflow_section = [
|
||||
"# Workflow data",
|
||||
"def build_workflow() -> dict[str, Any]:",
|
||||
f" return {workflow_literal}",
|
||||
"",
|
||||
"def build_extra_pnginfo() -> dict[str, Any] | None:",
|
||||
f" return {extra_pnginfo_literal}",
|
||||
"",
|
||||
"workflow = build_workflow()",
|
||||
"prompt = json.loads(json.dumps(workflow))",
|
||||
"extra_pnginfo = build_extra_pnginfo()",
|
||||
]
|
||||
|
||||
execution_section = [
|
||||
"# Workflow execution",
|
||||
"def main(unload_models: bool | None = None):",
|
||||
" bootstrap_comfyui_runtime()",
|
||||
" add_extra_model_paths()",
|
||||
]
|
||||
if custom_nodes_call:
|
||||
execution_section.append(f" {custom_nodes_call}")
|
||||
if imports_code:
|
||||
execution_section.extend(["", " # Node imports"])
|
||||
execution_section.extend(f" {line}" for line in imports_code)
|
||||
execution_section.extend(
|
||||
[
|
||||
"",
|
||||
" import torch",
|
||||
"",
|
||||
" try:",
|
||||
" with torch.inference_mode():",
|
||||
]
|
||||
)
|
||||
execution_section.extend(
|
||||
self.build_function_body(
|
||||
plan.special_functions_code, "pass", indentation=" "
|
||||
).splitlines()
|
||||
)
|
||||
execution_section.append(f" for q in range({plan.queue_size}):")
|
||||
execution_section.extend(
|
||||
self.build_function_body(
|
||||
plan.loop_code, "pass", indentation=" "
|
||||
).splitlines()
|
||||
)
|
||||
execution_section.extend(
|
||||
[
|
||||
" finally:",
|
||||
" cleanup_comfyui_runtime(unload_models=unload_models)",
|
||||
]
|
||||
)
|
||||
|
||||
entrypoint_section = [
|
||||
"# Entrypoint",
|
||||
'if __name__ == "__main__":',
|
||||
" main()",
|
||||
]
|
||||
|
||||
final_code = "\n".join(
|
||||
static_imports
|
||||
+ [""]
|
||||
+ workflow_section
|
||||
+ [""]
|
||||
+ execution_section
|
||||
+ [""]
|
||||
+ entrypoint_section
|
||||
)
|
||||
return black.format_str(final_code, mode=black.Mode())
|
||||
|
||||
@staticmethod
|
||||
def format_python_literal(value: Any) -> str:
|
||||
return pformat(value, sort_dicts=False)
|
||||
|
||||
@staticmethod
|
||||
def build_function_body(
|
||||
code_lines: list[str], empty_fallback: str, indentation: str = " "
|
||||
) -> str:
|
||||
if not code_lines:
|
||||
return f"{indentation}{empty_fallback}"
|
||||
formatted_lines = []
|
||||
for line in code_lines:
|
||||
stripped_line = line.lstrip()
|
||||
if not stripped_line.endswith("\n"):
|
||||
stripped_line += "\n"
|
||||
formatted_lines.append(f"{indentation}{stripped_line}")
|
||||
return "".join(formatted_lines).rstrip()
|
||||
@@ -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,381 @@
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
from typing import Any, Mapping, Sequence, Union
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_comfyui_directory(path: str) -> bool:
|
||||
"""Verify a directory has ComfyUI structural markers.
|
||||
|
||||
Checks for nodes.py, main.py, and the comfy/ subdirectory to raise
|
||||
the bar against spoofing via a directory with only a single marker file.
|
||||
"""
|
||||
if not os.path.isdir(path):
|
||||
return False
|
||||
return (
|
||||
os.path.isfile(os.path.join(path, "nodes.py"))
|
||||
and os.path.isfile(os.path.join(path, "main.py"))
|
||||
and os.path.isdir(os.path.join(path, "comfy"))
|
||||
)
|
||||
|
||||
|
||||
def _load_module(module_name: str, filepath: str) -> Any:
|
||||
"""Load a Python module from an explicit file path, bypassing sys.path.
|
||||
|
||||
Significantly reduces bare import shadowing risk by loading modules
|
||||
from verified file paths instead of relying on sys.path resolution.
|
||||
If exec_module() raises, the partially-loaded module is removed from
|
||||
sys.modules so subsequent calls start fresh.
|
||||
"""
|
||||
# Return cached module if already loaded — prevents re-execution
|
||||
# that would reset state (e.g. NODE_CLASS_MAPPINGS after init_extra_nodes).
|
||||
if module_name in sys.modules:
|
||||
return sys.modules[module_name]
|
||||
if not os.path.isfile(filepath):
|
||||
log.debug("Module file not found: %s (%s)", module_name, filepath)
|
||||
return None
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(module_name, filepath)
|
||||
if spec is None or spec.loader is None:
|
||||
log.debug("Could not create spec for %s at %s", module_name, filepath)
|
||||
return None
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = mod
|
||||
try:
|
||||
spec.loader.exec_module(mod)
|
||||
except BaseException:
|
||||
sys.modules.pop(module_name, None)
|
||||
raise
|
||||
return mod
|
||||
except BaseException as e:
|
||||
log.debug("Failed to load %s from %s: %s", module_name, filepath, e)
|
||||
sys.modules.pop(module_name, None) # Also clean up on edge-case exceptions
|
||||
return None
|
||||
|
||||
|
||||
def _find_from_extension_location() -> str | None:
|
||||
"""Walk up from this file's location to find ComfyUI root.
|
||||
|
||||
Checks the starting directory first before walking upward.
|
||||
"""
|
||||
ext_dir = os.path.dirname(os.path.realpath(__file__))
|
||||
candidate = ext_dir
|
||||
for _ in range(10):
|
||||
if os.path.basename(candidate) == "ComfyUI":
|
||||
if _is_comfyui_directory(candidate):
|
||||
return candidate
|
||||
parent = os.path.dirname(candidate)
|
||||
if parent == candidate:
|
||||
break
|
||||
candidate = parent
|
||||
return None
|
||||
|
||||
|
||||
def _find_file(name: str, max_depth: int = 20) -> str | None:
|
||||
"""Walk up from CWD to find a file by name.
|
||||
|
||||
Unlike find_path() which searches for directories, this checks
|
||||
os.path.isfile() at each level. Returns full path to the file or None.
|
||||
|
||||
Checks CWD first before walking upward.
|
||||
"""
|
||||
candidate = os.getcwd()
|
||||
for _ in range(max_depth):
|
||||
filepath = os.path.join(candidate, name)
|
||||
if os.path.isfile(filepath):
|
||||
return filepath
|
||||
parent = os.path.dirname(candidate)
|
||||
if parent == candidate:
|
||||
break
|
||||
candidate = parent
|
||||
return None
|
||||
|
||||
|
||||
def find_path(name: str, max_depth: int = 20) -> str | None:
|
||||
"""Iteratively walk up from CWD to find a directory by name.
|
||||
|
||||
Checks CWD first before walking upward. Depth-limited to prevent slow
|
||||
startup on deep trees. Each candidate verified by _is_comfyui_directory()
|
||||
at the caller.
|
||||
"""
|
||||
candidate = os.getcwd()
|
||||
for _ in range(max_depth):
|
||||
if os.path.basename(candidate) == name:
|
||||
return candidate
|
||||
parent = os.path.dirname(candidate)
|
||||
if parent == candidate:
|
||||
break
|
||||
candidate = parent
|
||||
return None
|
||||
|
||||
|
||||
def get_comfyui_path() -> str | None:
|
||||
"""Resolve ComfyUI path via prioritized multi-strategy fallback.
|
||||
|
||||
Strategy order:
|
||||
1. COMFYUI_PATH env var (verified with _is_comfyui_directory)
|
||||
2. Relative walk from extension location (realpath + verified)
|
||||
3. CWD walk (legacy fallback, depth-limited)
|
||||
"""
|
||||
p = os.environ.get("COMFYUI_PATH")
|
||||
if p and _is_comfyui_directory(p):
|
||||
return p
|
||||
p = _find_from_extension_location()
|
||||
if p:
|
||||
return p
|
||||
p = find_path("ComfyUI", max_depth=20)
|
||||
if p and _is_comfyui_directory(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def add_comfyui_directory_to_sys_path() -> None:
|
||||
"""Add the ComfyUI checkout to sys.path (idempotent — always at index 0).
|
||||
|
||||
If already present but lower in sys.path, removes and re-inserts at front
|
||||
so bare imports always resolve to this copy first.
|
||||
"""
|
||||
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)
|
||||
log.debug("Added %s to sys.path[0]", comfyui_path)
|
||||
|
||||
|
||||
def add_extra_model_paths() -> None:
|
||||
"""Load ComfyUI extra model paths configuration when available."""
|
||||
comfyui_path = get_comfyui_path()
|
||||
if comfyui_path is None:
|
||||
log.debug("Cannot load extra model paths: ComfyUI path not found")
|
||||
return
|
||||
|
||||
# Try main.py first, then utils/extra_config.py — both via _load_module()
|
||||
main_mod = _load_module("comfy_main", os.path.join(comfyui_path, "main.py"))
|
||||
if main_mod is not None and hasattr(main_mod, "load_extra_path_config"):
|
||||
load_extra_path_config = getattr(main_mod, "load_extra_path_config")
|
||||
else:
|
||||
log.debug("main.py not available, trying utils/extra_config.py")
|
||||
extra_config_mod = _load_module(
|
||||
"extra_config", os.path.join(comfyui_path, "utils", "extra_config.py")
|
||||
)
|
||||
if extra_config_mod is None or not hasattr(
|
||||
extra_config_mod, "load_extra_path_config"
|
||||
):
|
||||
log.debug("Could not find load_extra_path_config in either path")
|
||||
return
|
||||
load_extra_path_config = getattr(extra_config_mod, "load_extra_path_config")
|
||||
|
||||
extra_model_paths = _find_file("extra_model_paths.yaml")
|
||||
if extra_model_paths is not None:
|
||||
load_extra_path_config(extra_model_paths)
|
||||
else:
|
||||
log.debug("Could not find the extra_model_paths config file.")
|
||||
|
||||
|
||||
def _load_module_temp(module_name: str, filepath: str) -> Any:
|
||||
"""Load a module via _load_module() then remove it from sys.modules.
|
||||
|
||||
Used during bootstrap for modules that ComfyUI's import chain also loads
|
||||
normally — prevents the cached copy from conflicting with later imports.
|
||||
"""
|
||||
mod = _load_module(module_name, filepath)
|
||||
sys.modules.pop(module_name, None)
|
||||
return mod
|
||||
|
||||
|
||||
def bootstrap_comfyui_runtime() -> None:
|
||||
"""Mirror the allocator-related ComfyUI startup steps before torch import."""
|
||||
add_comfyui_directory_to_sys_path()
|
||||
comfyui_path = get_comfyui_path()
|
||||
if comfyui_path is None:
|
||||
log.debug("bootstrap_comfyui_runtime: ComfyUI path not found")
|
||||
return
|
||||
|
||||
# Load comfy.options under its canonical name so that enable_args_parsing()
|
||||
# mutates the sys.modules["comfy.options"] cache entry. When cli_args.py
|
||||
# later imports comfy.options by canonical name, it sees the mutation.
|
||||
options_mod = _load_module(
|
||||
"comfy.options", os.path.join(comfyui_path, "comfy", "options.py")
|
||||
)
|
||||
if options_mod is not None:
|
||||
options_mod.enable_args_parsing()
|
||||
|
||||
# cli_args imports comfy.options — so it must be cached under canonical name
|
||||
# at this point. We remove both from sys.modules only after cli_args has
|
||||
# loaded and consumed the args value, to prevent conflicts with later normal
|
||||
# import chains (e.g. nodes.py -> comfy.cli_args).
|
||||
cli_args_mod = _load_module(
|
||||
"comfy.cli_args", os.path.join(comfyui_path, "comfy", "cli_args.py")
|
||||
)
|
||||
|
||||
args = getattr(cli_args_mod, "args", None) if cli_args_mod else None
|
||||
|
||||
# Now safe to remove bootstrap copies — args value already captured.
|
||||
sys.modules.pop("comfy.options", None)
|
||||
sys.modules.pop("comfy.cli_args", None)
|
||||
|
||||
if args is None:
|
||||
return
|
||||
|
||||
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"
|
||||
|
||||
cuda_malloc_mod = _load_module_temp(
|
||||
"_bootstrap_cuda_malloc", os.path.join(comfyui_path, "cuda_malloc.py")
|
||||
)
|
||||
if (
|
||||
cuda_malloc_mod is not None
|
||||
and hasattr(cuda_malloc_mod, "get_torch_version_noimport")
|
||||
and "rocm" in cuda_malloc_mod.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.
|
||||
|
||||
Uses _load_module() for all ComfyUI imports — no bare imports,
|
||||
no sys.path remove/re-insert gap.
|
||||
"""
|
||||
comfyui_path = get_comfyui_path()
|
||||
if comfyui_path is None:
|
||||
log.debug("import_custom_nodes: ComfyUI path not found")
|
||||
return
|
||||
|
||||
# Idempotent insert-once — never removes from sys.path
|
||||
if comfyui_path not in sys.path:
|
||||
sys.path.insert(0, comfyui_path)
|
||||
|
||||
import asyncio
|
||||
|
||||
execution_mod = _load_module(
|
||||
"execution", os.path.join(comfyui_path, "execution.py")
|
||||
)
|
||||
nodes_mod = _load_module("nodes", os.path.join(comfyui_path, "nodes.py"))
|
||||
|
||||
# nodes.py inserts comfy/ subdirectory into sys.path, which shadows the
|
||||
# top-level utils/ package (comfy/utils.py vs utils/). This breaks
|
||||
# server.py → app.frontend_management → from utils.install_util import ...
|
||||
# Filter it out temporarily so server.py loads cleanly.
|
||||
comfy_subdir = os.path.join(comfyui_path, "comfy")
|
||||
original_sys_path = list(sys.path)
|
||||
sys.path[:] = [p for p in sys.path if p != comfy_subdir]
|
||||
|
||||
try:
|
||||
server_mod = _load_module("server", os.path.join(comfyui_path, "server.py"))
|
||||
finally:
|
||||
# Restore sys.path so nodes and other modules that need comfy/ still work.
|
||||
# Guaranteed even if _load_module raises mid-execution.
|
||||
sys.path[:] = original_sys_path
|
||||
|
||||
if execution_mod is None or server_mod is None:
|
||||
log.debug(
|
||||
"import_custom_nodes: could not load execution/server modules. "
|
||||
"Proceeding without full PromptServer/PromptQueue setup."
|
||||
)
|
||||
# Even without server, we can still populate NODE_CLASS_MAPPINGS from
|
||||
# comfy_extras and built-in extra nodes.
|
||||
if nodes_mod is not None and hasattr(nodes_mod, "init_extra_nodes"):
|
||||
asyncio.run(nodes_mod.init_extra_nodes())
|
||||
return
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
server_instance = server_mod.PromptServer(loop)
|
||||
execution_mod.PromptQueue(server_instance)
|
||||
if nodes_mod is not None and hasattr(nodes_mod, "init_extra_nodes"):
|
||||
asyncio.run(nodes_mod.init_extra_nodes())
|
||||
|
||||
|
||||
def get_node_class_mappings() -> dict:
|
||||
"""Load ComfyUI node mappings on demand via _load_module().
|
||||
|
||||
Reuses the cached "nodes" module from sys.modules if already loaded
|
||||
(e.g. by import_custom_nodes) to avoid resetting NODE_CLASS_MAPPINGS.
|
||||
"""
|
||||
add_comfyui_directory_to_sys_path()
|
||||
comfyui_path = get_comfyui_path()
|
||||
if comfyui_path is None:
|
||||
log.debug("get_node_class_mappings: ComfyUI path not found")
|
||||
return {}
|
||||
|
||||
# Reuse cached module if already loaded to avoid resetting mappings.
|
||||
nodes_mod = sys.modules.get("nodes")
|
||||
if nodes_mod is None:
|
||||
nodes_mod = _load_module("nodes", os.path.join(comfyui_path, "nodes.py"))
|
||||
if nodes_mod is None:
|
||||
return {}
|
||||
return getattr(nodes_mod, "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,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
-106
@@ -1,107 +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
|
||||
sys.path.insert(0, find_path("ComfyUI"))
|
||||
import server
|
||||
|
||||
# Creating a new event loop and setting it as the default loop
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
# Creating an instance of PromptServer with the loop
|
||||
server_instance = server.PromptServer(loop)
|
||||
execution.PromptQueue(server_instance)
|
||||
|
||||
# Initializing custom nodes
|
||||
asyncio.run(init_extra_nodes())
|
||||
|
||||
|
||||
def find_path(name: str, path: str = None) -> str:
|
||||
"""
|
||||
Recursively looks at parent folders starting from the given path until it finds the given name.
|
||||
Returns the path as a Path object if found, or None otherwise.
|
||||
"""
|
||||
# If no path is given, use the current working directory
|
||||
if path is None:
|
||||
path = os.getcwd()
|
||||
|
||||
# Check if the current directory contains the name
|
||||
if name in os.listdir(path):
|
||||
path_name = os.path.join(path, name)
|
||||
print(f"{name} found: {path_name}")
|
||||
return path_name
|
||||
|
||||
# Get the parent directory
|
||||
parent_directory = os.path.dirname(path)
|
||||
|
||||
# If the parent directory is the same as the current directory, we've reached the root and stop the search
|
||||
if parent_directory == path:
|
||||
return None
|
||||
|
||||
# Recursively call the function with the parent directory
|
||||
return find_path(name, parent_directory)
|
||||
|
||||
|
||||
def add_comfyui_directory_to_sys_path() -> None:
|
||||
"""
|
||||
Add 'ComfyUI' to the sys.path
|
||||
"""
|
||||
comfyui_path = find_path("ComfyUI")
|
||||
if comfyui_path is not None and os.path.isdir(comfyui_path):
|
||||
sys.path.append(comfyui_path)
|
||||
print(f"'{comfyui_path}' added to sys.path")
|
||||
|
||||
|
||||
def add_extra_model_paths() -> None:
|
||||
"""
|
||||
Parse the optional extra_model_paths.yaml file and add the parsed paths to the sys.path.
|
||||
"""
|
||||
try:
|
||||
from main import load_extra_path_config
|
||||
except ImportError:
|
||||
print(
|
||||
"Could not import load_extra_path_config from main.py. Looking in utils.extra_config instead."
|
||||
)
|
||||
from utils.extra_config import load_extra_path_config
|
||||
|
||||
extra_model_paths = find_path("extra_model_paths.yaml")
|
||||
|
||||
if extra_model_paths is not None:
|
||||
load_extra_path_config(extra_model_paths)
|
||||
else:
|
||||
print("Could not find the extra_model_paths config file.")
|
||||
|
||||
|
||||
def get_value_at_index(obj: Union[Sequence, Mapping], index: int) -> Any:
|
||||
"""Returns the value at the given index of a sequence or mapping.
|
||||
|
||||
If the object is a sequence (like list or string), returns the value at the given index.
|
||||
If the object is a mapping (like a dictionary), returns the value at the index-th key.
|
||||
|
||||
Some return a dictionary, in these cases, we look for the "results" key
|
||||
|
||||
Args:
|
||||
obj (Union[Sequence, Mapping]): The object to retrieve the value from.
|
||||
index (int): The index of the value to retrieve.
|
||||
|
||||
Returns:
|
||||
Any: The value at the given index.
|
||||
|
||||
Raises:
|
||||
IndexError: If the index is out of bounds for the object and the object is not a mapping.
|
||||
"""
|
||||
try:
|
||||
return obj[index]
|
||||
except KeyError:
|
||||
return obj["result"][index]
|
||||
__all__ = [
|
||||
"add_comfyui_directory_to_sys_path",
|
||||
"add_extra_model_paths",
|
||||
"bootstrap_comfyui_runtime",
|
||||
"find_path",
|
||||
"get_comfyui_path",
|
||||
"get_value_at_index",
|
||||
"import_custom_nodes",
|
||||
]
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 312 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 129 KiB |
+28
-11
@@ -1,6 +1,25 @@
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { $el } from "../../scripts/ui.js";
|
||||
|
||||
const DEFAULT_SCRIPT_FILENAME = "workflow_api.py";
|
||||
const DEFAULT_WORKFLOW_NAME = "workflow_api.json";
|
||||
|
||||
function $el(tag, options = {}) {
|
||||
const element = document.createElement(tag);
|
||||
const { parent, style, ...props } = options;
|
||||
|
||||
if (style) {
|
||||
Object.assign(element.style, style);
|
||||
}
|
||||
|
||||
Object.assign(element, props);
|
||||
|
||||
if (parent) {
|
||||
parent.appendChild(element);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
const extension = {
|
||||
name: "Comfy.SaveAsScript",
|
||||
@@ -23,21 +42,19 @@ const extension = {
|
||||
});
|
||||
},
|
||||
savePythonScript() {
|
||||
var filename = prompt("Save script as:");
|
||||
if(filename === undefined || filename === null || filename === "") {
|
||||
return
|
||||
}
|
||||
|
||||
const filename = DEFAULT_SCRIPT_FILENAME;
|
||||
|
||||
app.graphToPrompt().then(async (p) => {
|
||||
const json = JSON.stringify({name: filename + ".json", workflow: JSON.stringify(p.output, null, 2)}, null, 2); // convert the data to a JSON string
|
||||
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);
|
||||
if(!filename.endsWith(".py")) {
|
||||
filename += ".py";
|
||||
}
|
||||
|
||||
const a = $el("a", {
|
||||
href: url,
|
||||
download: filename,
|
||||
|
||||
+9
-2
@@ -1,10 +1,17 @@
|
||||
[project]
|
||||
name = "comfyui-to-python-extension"
|
||||
description = "This custom node allows you to generate pure python code from your ComfyUI workflow with the click of a button. Great for rapid experimentation or production deployment."
|
||||
version = "1.3.1"
|
||||
license = { text = "MIT License" }
|
||||
version = "2.1.0"
|
||||
license = "MIT"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["black"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["comfyui_to_python*"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["ruff"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/pydn/ComfyUI-to-Python-Extension"
|
||||
# Used by Comfy Registry https://comfyregistry.org
|
||||
|
||||
+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,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,399 @@
|
||||
# Imports
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from typing import Sequence, Mapping, Any, Union
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _find_from_extension_location() -> str | None:
|
||||
"""Walk up from this file's location to find ComfyUI root."""
|
||||
ext_dir = os.path.dirname(os.path.realpath(__file__))
|
||||
candidate = ext_dir
|
||||
for _ in range(10):
|
||||
parent = os.path.dirname(candidate)
|
||||
if parent == candidate:
|
||||
break
|
||||
candidate = parent
|
||||
if os.path.basename(candidate) == "ComfyUI":
|
||||
if _is_comfyui_directory(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _is_comfyui_directory(path: str) -> bool:
|
||||
"""Verify a directory has ComfyUI structural markers.
|
||||
|
||||
Checks for nodes.py, main.py, and the comfy/ subdirectory to raise
|
||||
the bar against spoofing via a directory with only a single marker file.
|
||||
"""
|
||||
if not os.path.isdir(path):
|
||||
return False
|
||||
return (
|
||||
os.path.isfile(os.path.join(path, "nodes.py"))
|
||||
and os.path.isfile(os.path.join(path, "main.py"))
|
||||
and os.path.isdir(os.path.join(path, "comfy"))
|
||||
)
|
||||
|
||||
|
||||
def _load_module(module_name: str, filepath: str) -> Any:
|
||||
"""Load a Python module from an explicit file path, bypassing sys.path.
|
||||
|
||||
Significantly reduces bare import shadowing risk by loading modules
|
||||
from verified file paths instead of relying on sys.path resolution.
|
||||
If exec_module() raises, the partially-loaded module is removed from
|
||||
sys.modules so subsequent calls start fresh.
|
||||
"""
|
||||
# Return cached module if already loaded — prevents re-execution
|
||||
# that would reset state (e.g. NODE_CLASS_MAPPINGS after init_extra_nodes).
|
||||
if module_name in sys.modules:
|
||||
return sys.modules[module_name]
|
||||
if not os.path.isfile(filepath):
|
||||
log.debug("Module file not found: %s (%s)", module_name, filepath)
|
||||
return None
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(module_name, filepath)
|
||||
if spec is None or spec.loader is None:
|
||||
log.debug("Could not create spec for %s at %s", module_name, filepath)
|
||||
return None
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = mod
|
||||
try:
|
||||
spec.loader.exec_module(mod)
|
||||
except BaseException:
|
||||
sys.modules.pop(module_name, None)
|
||||
raise
|
||||
return mod
|
||||
except BaseException as e:
|
||||
log.debug("Failed to load %s from %s: %s", module_name, filepath, e)
|
||||
sys.modules.pop(module_name, None) # Also clean up on edge-case exceptions
|
||||
return None
|
||||
|
||||
|
||||
def _load_module_temp(module_name: str, filepath: str) -> Any:
|
||||
"""Load a module via _load_module() then remove it from sys.modules.
|
||||
|
||||
Used during bootstrap for modules that ComfyUI's import chain also loads
|
||||
normally — prevents the cached copy from conflicting with later imports.
|
||||
"""
|
||||
mod = _load_module(module_name, filepath)
|
||||
sys.modules.pop(module_name, None)
|
||||
return mod
|
||||
|
||||
|
||||
def add_comfyui_directory_to_sys_path() -> None:
|
||||
"""Add the ComfyUI checkout to sys.path (idempotent — insert-once, no gap)."""
|
||||
comfyui_path = get_comfyui_path()
|
||||
if comfyui_path is not None and os.path.isdir(comfyui_path):
|
||||
if comfyui_path not in sys.path:
|
||||
sys.path.insert(0, comfyui_path)
|
||||
log.debug("Added %s to sys.path", comfyui_path)
|
||||
|
||||
|
||||
def add_extra_model_paths() -> None:
|
||||
"""Load ComfyUI extra model paths configuration when available."""
|
||||
comfyui_path = get_comfyui_path()
|
||||
if comfyui_path is None:
|
||||
log.debug("Cannot load extra model paths: ComfyUI path not found")
|
||||
return
|
||||
|
||||
# Try main.py first, then utils/extra_config.py — both via _load_module()
|
||||
main_mod = _load_module("comfy_main", os.path.join(comfyui_path, "main.py"))
|
||||
if main_mod is not None and hasattr(main_mod, "load_extra_path_config"):
|
||||
load_extra_path_config = getattr(main_mod, "load_extra_path_config")
|
||||
else:
|
||||
log.debug("main.py not available, trying utils/extra_config.py")
|
||||
extra_config_mod = _load_module(
|
||||
"extra_config", os.path.join(comfyui_path, "utils", "extra_config.py")
|
||||
)
|
||||
if extra_config_mod is None or not hasattr(
|
||||
extra_config_mod, "load_extra_path_config"
|
||||
):
|
||||
log.debug("Could not find load_extra_path_config in either path")
|
||||
return
|
||||
load_extra_path_config = getattr(extra_config_mod, "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:
|
||||
log.debug("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()
|
||||
comfyui_path = get_comfyui_path()
|
||||
if comfyui_path is None:
|
||||
log.debug("bootstrap_comfyui_runtime: ComfyUI path not found")
|
||||
return
|
||||
|
||||
# Use _load_module for file-based isolation, but with temporary names so
|
||||
# the modules are removed from sys.modules after reading values.
|
||||
# This prevents conflicts when ComfyUI's internal import chain (e.g.
|
||||
# nodes.py -> comfy.cli_args) later loads these modules normally.
|
||||
options_mod = _load_module_temp(
|
||||
"_bootstrap_options", os.path.join(comfyui_path, "comfy", "options.py")
|
||||
)
|
||||
if options_mod is not None:
|
||||
options_mod.enable_args_parsing()
|
||||
|
||||
cli_args_mod = _load_module_temp(
|
||||
"_bootstrap_cli_args", os.path.join(comfyui_path, "comfy", "cli_args.py")
|
||||
)
|
||||
args = getattr(cli_args_mod, "args", None) if cli_args_mod else None
|
||||
|
||||
if args is None:
|
||||
return
|
||||
|
||||
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"
|
||||
|
||||
cuda_malloc_mod = _load_module_temp(
|
||||
"_bootstrap_cuda_malloc", os.path.join(comfyui_path, "cuda_malloc.py")
|
||||
)
|
||||
if (
|
||||
cuda_malloc_mod is not None
|
||||
and hasattr(cuda_malloc_mod, "get_torch_version_noimport")
|
||||
and "rocm" in cuda_malloc_mod.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 find_path(name: str, max_depth: int = 20) -> str | None:
|
||||
"""Iteratively walk up from CWD to find a directory by name.
|
||||
|
||||
Depth-limited to prevent slow startup on deep trees.
|
||||
Each candidate verified by _is_comfyui_directory() at the caller.
|
||||
"""
|
||||
candidate = os.getcwd()
|
||||
for _ in range(max_depth):
|
||||
parent = os.path.dirname(candidate)
|
||||
if parent == candidate:
|
||||
break
|
||||
candidate = parent
|
||||
if os.path.basename(candidate) == name:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def get_comfyui_path() -> str | None:
|
||||
"""Resolve ComfyUI path via prioritized multi-strategy fallback.
|
||||
|
||||
Strategy order:
|
||||
1. COMFYUI_PATH env var (verified with _is_comfyui_directory)
|
||||
2. Relative walk from extension location (realpath + verified)
|
||||
3. CWD walk (legacy fallback, depth-limited)
|
||||
"""
|
||||
p = os.environ.get("COMFYUI_PATH")
|
||||
if p and _is_comfyui_directory(p):
|
||||
return p
|
||||
p = _find_from_extension_location()
|
||||
if p:
|
||||
return p
|
||||
p = find_path("ComfyUI", max_depth=20)
|
||||
if p and _is_comfyui_directory(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
# 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,397 @@
|
||||
# Imports
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from typing import Sequence, Mapping, Any, Union
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _find_from_extension_location() -> str | None:
|
||||
"""Walk up from this file's location to find ComfyUI root."""
|
||||
ext_dir = os.path.dirname(os.path.realpath(__file__))
|
||||
candidate = ext_dir
|
||||
for _ in range(10):
|
||||
parent = os.path.dirname(candidate)
|
||||
if parent == candidate:
|
||||
break
|
||||
candidate = parent
|
||||
if os.path.basename(candidate) == "ComfyUI":
|
||||
if _is_comfyui_directory(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _is_comfyui_directory(path: str) -> bool:
|
||||
"""Verify a directory has ComfyUI structural markers.
|
||||
|
||||
Checks for nodes.py, main.py, and the comfy/ subdirectory to raise
|
||||
the bar against spoofing via a directory with only a single marker file.
|
||||
"""
|
||||
if not os.path.isdir(path):
|
||||
return False
|
||||
return (
|
||||
os.path.isfile(os.path.join(path, "nodes.py"))
|
||||
and os.path.isfile(os.path.join(path, "main.py"))
|
||||
and os.path.isdir(os.path.join(path, "comfy"))
|
||||
)
|
||||
|
||||
|
||||
def _load_module(module_name: str, filepath: str) -> Any:
|
||||
"""Load a Python module from an explicit file path, bypassing sys.path.
|
||||
|
||||
Significantly reduces bare import shadowing risk by loading modules
|
||||
from verified file paths instead of relying on sys.path resolution.
|
||||
If exec_module() raises, the partially-loaded module is removed from
|
||||
sys.modules so subsequent calls start fresh.
|
||||
"""
|
||||
# Return cached module if already loaded — prevents re-execution
|
||||
# that would reset state (e.g. NODE_CLASS_MAPPINGS after init_extra_nodes).
|
||||
if module_name in sys.modules:
|
||||
return sys.modules[module_name]
|
||||
if not os.path.isfile(filepath):
|
||||
log.debug("Module file not found: %s (%s)", module_name, filepath)
|
||||
return None
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(module_name, filepath)
|
||||
if spec is None or spec.loader is None:
|
||||
log.debug("Could not create spec for %s at %s", module_name, filepath)
|
||||
return None
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = mod
|
||||
try:
|
||||
spec.loader.exec_module(mod)
|
||||
except BaseException:
|
||||
sys.modules.pop(module_name, None)
|
||||
raise
|
||||
return mod
|
||||
except BaseException as e:
|
||||
log.debug("Failed to load %s from %s: %s", module_name, filepath, e)
|
||||
sys.modules.pop(module_name, None) # Also clean up on edge-case exceptions
|
||||
return None
|
||||
|
||||
|
||||
def _load_module_temp(module_name: str, filepath: str) -> Any:
|
||||
"""Load a module via _load_module() then remove it from sys.modules.
|
||||
|
||||
Used during bootstrap for modules that ComfyUI's import chain also loads
|
||||
normally — prevents the cached copy from conflicting with later imports.
|
||||
"""
|
||||
mod = _load_module(module_name, filepath)
|
||||
sys.modules.pop(module_name, None)
|
||||
return mod
|
||||
|
||||
|
||||
def add_comfyui_directory_to_sys_path() -> None:
|
||||
"""Add the ComfyUI checkout to sys.path (idempotent — insert-once, no gap)."""
|
||||
comfyui_path = get_comfyui_path()
|
||||
if comfyui_path is not None and os.path.isdir(comfyui_path):
|
||||
if comfyui_path not in sys.path:
|
||||
sys.path.insert(0, comfyui_path)
|
||||
log.debug("Added %s to sys.path", comfyui_path)
|
||||
|
||||
|
||||
def add_extra_model_paths() -> None:
|
||||
"""Load ComfyUI extra model paths configuration when available."""
|
||||
comfyui_path = get_comfyui_path()
|
||||
if comfyui_path is None:
|
||||
log.debug("Cannot load extra model paths: ComfyUI path not found")
|
||||
return
|
||||
|
||||
# Try main.py first, then utils/extra_config.py — both via _load_module()
|
||||
main_mod = _load_module("comfy_main", os.path.join(comfyui_path, "main.py"))
|
||||
if main_mod is not None and hasattr(main_mod, "load_extra_path_config"):
|
||||
load_extra_path_config = getattr(main_mod, "load_extra_path_config")
|
||||
else:
|
||||
log.debug("main.py not available, trying utils/extra_config.py")
|
||||
extra_config_mod = _load_module(
|
||||
"extra_config", os.path.join(comfyui_path, "utils", "extra_config.py")
|
||||
)
|
||||
if extra_config_mod is None or not hasattr(
|
||||
extra_config_mod, "load_extra_path_config"
|
||||
):
|
||||
log.debug("Could not find load_extra_path_config in either path")
|
||||
return
|
||||
load_extra_path_config = getattr(extra_config_mod, "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:
|
||||
log.debug("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()
|
||||
comfyui_path = get_comfyui_path()
|
||||
if comfyui_path is None:
|
||||
log.debug("bootstrap_comfyui_runtime: ComfyUI path not found")
|
||||
return
|
||||
|
||||
# Use _load_module for file-based isolation, but with temporary names so
|
||||
# the modules are removed from sys.modules after reading values.
|
||||
# This prevents conflicts when ComfyUI's internal import chain (e.g.
|
||||
# nodes.py -> comfy.cli_args) later loads these modules normally.
|
||||
options_mod = _load_module_temp(
|
||||
"_bootstrap_options", os.path.join(comfyui_path, "comfy", "options.py")
|
||||
)
|
||||
if options_mod is not None:
|
||||
options_mod.enable_args_parsing()
|
||||
|
||||
cli_args_mod = _load_module_temp(
|
||||
"_bootstrap_cli_args", os.path.join(comfyui_path, "comfy", "cli_args.py")
|
||||
)
|
||||
args = getattr(cli_args_mod, "args", None) if cli_args_mod else None
|
||||
|
||||
if args is None:
|
||||
return
|
||||
|
||||
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"
|
||||
|
||||
cuda_malloc_mod = _load_module_temp(
|
||||
"_bootstrap_cuda_malloc", os.path.join(comfyui_path, "cuda_malloc.py")
|
||||
)
|
||||
if (
|
||||
cuda_malloc_mod is not None
|
||||
and hasattr(cuda_malloc_mod, "get_torch_version_noimport")
|
||||
and "rocm" in cuda_malloc_mod.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 find_path(name: str, max_depth: int = 20) -> str | None:
|
||||
"""Iteratively walk up from CWD to find a directory by name.
|
||||
|
||||
Depth-limited to prevent slow startup on deep trees.
|
||||
Each candidate verified by _is_comfyui_directory() at the caller.
|
||||
"""
|
||||
candidate = os.getcwd()
|
||||
for _ in range(max_depth):
|
||||
parent = os.path.dirname(candidate)
|
||||
if parent == candidate:
|
||||
break
|
||||
candidate = parent
|
||||
if os.path.basename(candidate) == name:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def get_comfyui_path() -> str | None:
|
||||
"""Resolve ComfyUI path via prioritized multi-strategy fallback.
|
||||
|
||||
Strategy order:
|
||||
1. COMFYUI_PATH env var (verified with _is_comfyui_directory)
|
||||
2. Relative walk from extension location (realpath + verified)
|
||||
3. CWD walk (legacy fallback, depth-limited)
|
||||
"""
|
||||
p = os.environ.get("COMFYUI_PATH")
|
||||
if p and _is_comfyui_directory(p):
|
||||
return p
|
||||
p = _find_from_extension_location()
|
||||
if p:
|
||||
return p
|
||||
p = find_path("ComfyUI", max_depth=20)
|
||||
if p and _is_comfyui_directory(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
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 import_custom_nodes() -> None:
|
||||
"""Initialize ComfyUI custom nodes in the exporter runtime.
|
||||
|
||||
Uses _load_module() for all ComfyUI imports — no bare imports,
|
||||
no sys.path remove/re-insert gap.
|
||||
"""
|
||||
comfyui_path = get_comfyui_path()
|
||||
if comfyui_path is None:
|
||||
log.debug("import_custom_nodes: ComfyUI path not found")
|
||||
return
|
||||
|
||||
# Idempotent insert-once — never removes from sys.path
|
||||
if comfyui_path not in sys.path:
|
||||
sys.path.insert(0, comfyui_path)
|
||||
|
||||
import asyncio
|
||||
|
||||
execution_mod = _load_module(
|
||||
"execution", os.path.join(comfyui_path, "execution.py")
|
||||
)
|
||||
nodes_mod = _load_module("nodes", os.path.join(comfyui_path, "nodes.py"))
|
||||
|
||||
# nodes.py inserts comfy/ subdirectory into sys.path, which shadows the
|
||||
# top-level utils/ package (comfy/utils.py vs utils/). This breaks
|
||||
# server.py → app.frontend_management → from utils.install_util import ...
|
||||
# Filter it out temporarily so server.py loads cleanly.
|
||||
comfy_subdir = os.path.join(comfyui_path, "comfy")
|
||||
original_sys_path = list(sys.path)
|
||||
sys.path[:] = [p for p in sys.path if p != comfy_subdir]
|
||||
|
||||
try:
|
||||
server_mod = _load_module("server", os.path.join(comfyui_path, "server.py"))
|
||||
finally:
|
||||
# Restore sys.path so nodes and other modules that need comfy/ still work.
|
||||
# Guaranteed even if _load_module raises mid-execution.
|
||||
sys.path[:] = original_sys_path
|
||||
|
||||
if execution_mod is None or server_mod is None:
|
||||
log.debug(
|
||||
"import_custom_nodes: could not load execution/server modules. "
|
||||
"Proceeding without full PromptServer/PromptQueue setup."
|
||||
)
|
||||
# Even without server, we can still populate NODE_CLASS_MAPPINGS from
|
||||
# comfy_extras and built-in extra nodes.
|
||||
if nodes_mod is not None and hasattr(nodes_mod, "init_extra_nodes"):
|
||||
asyncio.run(nodes_mod.init_extra_nodes())
|
||||
return
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
server_instance = server_mod.PromptServer(loop)
|
||||
execution_mod.PromptQueue(server_instance)
|
||||
if nodes_mod is not None and hasattr(nodes_mod, "init_extra_nodes"):
|
||||
asyncio.run(nodes_mod.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,741 @@
|
||||
import argparse
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zlib
|
||||
from dataclasses import dataclass
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FIXTURE_DIR = ROOT / "tests" / "fixtures" / "runtime"
|
||||
GENERATED_DIR = ROOT / "tests" / "runtime" / "generated"
|
||||
COMFYUI_OUTPUT_DIRNAME = "output"
|
||||
COMFYUI_INPUT_DIRNAME = "input"
|
||||
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from comfyui_to_python_utils import get_comfyui_path
|
||||
|
||||
|
||||
class ValidationFailure(RuntimeError):
|
||||
def __init__(self, classification: str, message: str):
|
||||
super().__init__(message)
|
||||
self.classification = classification
|
||||
self.message = message
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FixtureConfig:
|
||||
name: str
|
||||
path: Path
|
||||
mapping_factory: Callable[[], dict] | None = None
|
||||
fast_mapping_factory: Callable[[], dict] | None = None
|
||||
runtime_capable: bool = False
|
||||
filename_prefix: str | None = None
|
||||
expected_min_dimensions: tuple[int, int] | None = None
|
||||
metadata_markers: tuple[str, ...] = ()
|
||||
model_requirements: tuple["ModelRequirement", ...] = ()
|
||||
staged_inputs: tuple["StagedInput", ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelRequirement:
|
||||
filename: str
|
||||
relative_dir: str
|
||||
source_url: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StagedInput:
|
||||
source_path: Path
|
||||
destination_name: str
|
||||
|
||||
|
||||
class FlexibleNode:
|
||||
CATEGORY = "utils"
|
||||
FUNCTION = "run"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"safe_name": ("STRING",),
|
||||
}
|
||||
}
|
||||
|
||||
def run(self, **kwargs):
|
||||
return (kwargs,)
|
||||
|
||||
|
||||
class RegexReplace:
|
||||
CATEGORY = "utils"
|
||||
FUNCTION = "replace"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING",),
|
||||
"pattern": ("STRING",),
|
||||
"replace": ("STRING",),
|
||||
}
|
||||
}
|
||||
|
||||
def replace(self, text, pattern, replace):
|
||||
return (text,)
|
||||
|
||||
|
||||
class PassthroughText:
|
||||
CATEGORY = "utils"
|
||||
FUNCTION = "run"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING",),
|
||||
}
|
||||
}
|
||||
|
||||
def run(self, text):
|
||||
return (text,)
|
||||
|
||||
|
||||
class SplitText:
|
||||
CATEGORY = "utils"
|
||||
FUNCTION = "split"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING",),
|
||||
}
|
||||
}
|
||||
|
||||
def split(self, text):
|
||||
left, right = text.split("|", 1)
|
||||
return (left, right)
|
||||
|
||||
|
||||
class JoinText:
|
||||
CATEGORY = "utils"
|
||||
FUNCTION = "join"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"left": ("STRING",),
|
||||
"right": ("STRING",),
|
||||
}
|
||||
}
|
||||
|
||||
def join(self, left, right):
|
||||
return (f"{left}::{right}",)
|
||||
|
||||
|
||||
class UpscaleModelLoader:
|
||||
CATEGORY = "loaders"
|
||||
FUNCTION = "load_model"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"model_name": ("STRING",),
|
||||
}
|
||||
}
|
||||
|
||||
def load_model(self, model_name):
|
||||
return (model_name,)
|
||||
|
||||
|
||||
class LoadImage:
|
||||
CATEGORY = "image"
|
||||
FUNCTION = "load_image"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("STRING",),
|
||||
}
|
||||
}
|
||||
|
||||
def load_image(self, image):
|
||||
return (image,)
|
||||
|
||||
|
||||
class ImageUpscaleWithModel:
|
||||
CATEGORY = "image/upscaling"
|
||||
FUNCTION = "upscale"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"upscale_model": ("UPSCALE_MODEL",),
|
||||
"image": ("IMAGE",),
|
||||
}
|
||||
}
|
||||
|
||||
def upscale(self, upscale_model, image):
|
||||
return (image,)
|
||||
|
||||
|
||||
class SaveImageNode:
|
||||
CATEGORY = "image"
|
||||
FUNCTION = "save_images"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"filename_prefix": ("STRING",),
|
||||
}
|
||||
}
|
||||
|
||||
def save_images(self, images, filename_prefix):
|
||||
return ()
|
||||
|
||||
|
||||
FIXTURES = {
|
||||
# Runtime-capable fixtures are exported inside a real ComfyUI checkout and
|
||||
# then executed end-to-end. Fast fixtures use local stub mappings so the
|
||||
# exporter can be validated without importing the full runtime.
|
||||
"upscale-model-loader": FixtureConfig(
|
||||
name="upscale-model-loader",
|
||||
path=FIXTURE_DIR / "upscale-model-loader.json",
|
||||
fast_mapping_factory=lambda: {
|
||||
"LoadImage": LoadImage,
|
||||
"UpscaleModelLoader": UpscaleModelLoader,
|
||||
"ImageUpscaleWithModel": ImageUpscaleWithModel,
|
||||
"SaveImage": SaveImageNode,
|
||||
},
|
||||
runtime_capable=True,
|
||||
filename_prefix="E2E_upscale_model_loader",
|
||||
expected_min_dimensions=(1000, 1000),
|
||||
metadata_markers=("UpscaleModelLoader", "E2E_upscale_model_loader"),
|
||||
model_requirements=(
|
||||
ModelRequirement(
|
||||
filename="RealESRGAN_x4plus.safetensors",
|
||||
relative_dir="models/upscale_models",
|
||||
source_url="https://huggingface.co/Comfy-Org/Real-ESRGAN_repackaged/resolve/main/RealESRGAN_x4plus.safetensors",
|
||||
),
|
||||
),
|
||||
staged_inputs=(
|
||||
StagedInput(
|
||||
source_path=ROOT / "images" / "save_as_script.png",
|
||||
destination_name="e2e_upscale_input.png",
|
||||
),
|
||||
),
|
||||
),
|
||||
"text-to-image": FixtureConfig(
|
||||
name="text-to-image",
|
||||
path=FIXTURE_DIR / "text-to-image.json",
|
||||
runtime_capable=True,
|
||||
filename_prefix="E2E_text_to_image",
|
||||
expected_min_dimensions=(512, 512),
|
||||
metadata_markers=("a small cottage in a meadow", "CheckpointLoaderSimple"),
|
||||
model_requirements=(
|
||||
ModelRequirement(
|
||||
filename="v1-5-pruned-emaonly-fp16.safetensors",
|
||||
relative_dir="models/checkpoints",
|
||||
source_url="https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors",
|
||||
),
|
||||
),
|
||||
),
|
||||
"unsafe-kwargs": FixtureConfig(
|
||||
name="unsafe-kwargs",
|
||||
path=FIXTURE_DIR / "unsafe-kwargs.json",
|
||||
mapping_factory=lambda: {"FlexibleNode": FlexibleNode},
|
||||
),
|
||||
"subgraph-identifiers": FixtureConfig(
|
||||
name="subgraph-identifiers",
|
||||
path=FIXTURE_DIR / "subgraph-identifiers.json",
|
||||
mapping_factory=lambda: {
|
||||
"RegexReplace": RegexReplace,
|
||||
"PassthroughText": PassthroughText,
|
||||
},
|
||||
),
|
||||
"reused-node-class-branches": FixtureConfig(
|
||||
name="reused-node-class-branches",
|
||||
path=FIXTURE_DIR / "reused-node-class-branches.json",
|
||||
mapping_factory=lambda: {
|
||||
"PassthroughText": PassthroughText,
|
||||
"JoinText": JoinText,
|
||||
},
|
||||
),
|
||||
"secondary-output-selection": FixtureConfig(
|
||||
name="secondary-output-selection",
|
||||
path=FIXTURE_DIR / "secondary-output-selection.json",
|
||||
mapping_factory=lambda: {
|
||||
"SplitText": SplitText,
|
||||
"PassthroughText": PassthroughText,
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run fast or runtime validation for committed fixtures."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tier",
|
||||
choices=("fast", "runtime"),
|
||||
help="Validation tier to run.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fixture",
|
||||
default="all",
|
||||
help="Fixture name or 'all'.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--execute",
|
||||
action="store_true",
|
||||
help="Execute generated Python for runtime-capable fixtures.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--print-download-plan",
|
||||
action="store_true",
|
||||
help="Print model download commands for missing models.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--internal-export",
|
||||
choices=tuple(FIXTURES.keys()),
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--generated-path",
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if not args.internal_export and not args.tier:
|
||||
parser.error("--tier is required unless --internal-export is used.")
|
||||
return args
|
||||
|
||||
|
||||
def load_fixture_names(selection: str) -> list[str]:
|
||||
if selection == "all":
|
||||
return list(FIXTURES.keys())
|
||||
if selection not in FIXTURES:
|
||||
raise ValidationFailure("fixture bug", f"Unknown fixture '{selection}'.")
|
||||
return [selection]
|
||||
|
||||
|
||||
def ensure_runtime_path(tier: str) -> str:
|
||||
if tier == "runtime":
|
||||
runtime_path = get_comfyui_path()
|
||||
else:
|
||||
return os.environ.get("COMFYUI_PATH", "")
|
||||
|
||||
if not runtime_path or not Path(runtime_path).is_dir():
|
||||
raise ValidationFailure(
|
||||
"environment/setup failure",
|
||||
"Could not find a valid ComfyUI checkout for runtime validation. "
|
||||
"Set COMFYUI_PATH or run the tests from a location where a "
|
||||
"parent directory contains ComfyUI.",
|
||||
)
|
||||
|
||||
return str(runtime_path)
|
||||
|
||||
|
||||
def get_runtime_python(runtime_path: str) -> str:
|
||||
runtime_python = Path(runtime_path) / ".venv" / "bin" / "python"
|
||||
if runtime_python.is_file():
|
||||
return str(runtime_python)
|
||||
return sys.executable
|
||||
|
||||
|
||||
def get_fixture(fixture_name: str) -> FixtureConfig:
|
||||
return FIXTURES[fixture_name]
|
||||
|
||||
|
||||
def check_models(fixture: FixtureConfig, runtime_path: str) -> list[ModelRequirement]:
|
||||
runtime_root = Path(runtime_path)
|
||||
missing = []
|
||||
for requirement in fixture.model_requirements:
|
||||
target = runtime_root / requirement.relative_dir / requirement.filename
|
||||
if not target.is_file():
|
||||
missing.append(requirement)
|
||||
return missing
|
||||
|
||||
|
||||
def print_download_plan(fixture: FixtureConfig, runtime_path: str) -> None:
|
||||
runtime_root = Path(runtime_path)
|
||||
for requirement in check_models(fixture, runtime_path):
|
||||
target_dir = runtime_root / requirement.relative_dir
|
||||
print(
|
||||
"download:",
|
||||
f"mkdir -p {target_dir} && curl -L {requirement.source_url} -o {target_dir / requirement.filename}",
|
||||
)
|
||||
|
||||
|
||||
def stage_inputs(fixture: FixtureConfig, runtime_path: str) -> None:
|
||||
runtime_input_dir = Path(runtime_path) / COMFYUI_INPUT_DIRNAME
|
||||
runtime_input_dir.mkdir(parents=True, exist_ok=True)
|
||||
for staged_input in fixture.staged_inputs:
|
||||
if not staged_input.source_path.is_file():
|
||||
raise ValidationFailure(
|
||||
"fixture bug",
|
||||
f"Missing staged input source {staged_input.source_path} for {fixture.name}.",
|
||||
)
|
||||
shutil.copyfile(
|
||||
staged_input.source_path,
|
||||
runtime_input_dir / staged_input.destination_name,
|
||||
)
|
||||
|
||||
|
||||
def export_workflow(
|
||||
fixture: FixtureConfig,
|
||||
tier: str,
|
||||
runtime_path: str,
|
||||
) -> tuple[str, str]:
|
||||
from comfyui_to_python import ComfyUItoPython
|
||||
|
||||
workflow = fixture.path.read_text(encoding="utf-8")
|
||||
output = StringIO()
|
||||
kwargs = {
|
||||
"workflow": workflow,
|
||||
"output_file": output,
|
||||
}
|
||||
if tier == "fast" and fixture.fast_mapping_factory is not None:
|
||||
kwargs["node_class_mappings"] = fixture.fast_mapping_factory()
|
||||
elif fixture.mapping_factory is not None:
|
||||
kwargs["node_class_mappings"] = fixture.mapping_factory()
|
||||
else:
|
||||
os.environ["COMFYUI_PATH"] = runtime_path
|
||||
|
||||
try:
|
||||
ComfyUItoPython(**kwargs)
|
||||
except ModuleNotFoundError as exc:
|
||||
missing_module = exc.name or "unknown"
|
||||
raise ValidationFailure(
|
||||
"environment/setup failure",
|
||||
f"Missing runtime dependency '{missing_module}' while exporting {fixture.name}.",
|
||||
) from exc
|
||||
except KeyError as exc:
|
||||
raise ValidationFailure(
|
||||
"repo regression",
|
||||
f"Exporter failed to resolve workflow data for {fixture.name}: {exc}",
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise ValidationFailure(
|
||||
"repo regression",
|
||||
f"Exporter failed for {fixture.name}: {exc}",
|
||||
) from exc
|
||||
|
||||
return workflow, output.getvalue()
|
||||
|
||||
|
||||
def export_workflow_in_runtime_env(fixture: FixtureConfig, runtime_path: str) -> str:
|
||||
runtime_python = get_runtime_python(runtime_path)
|
||||
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
|
||||
generated_path = GENERATED_DIR / f"{fixture.name}.py"
|
||||
env = os.environ.copy()
|
||||
env["COMFYUI_PATH"] = runtime_path
|
||||
env["PYTHONPATH"] = os.pathsep.join([str(ROOT), env.get("PYTHONPATH", "")]).rstrip(
|
||||
os.pathsep
|
||||
)
|
||||
# Re-enter this script under the runtime interpreter so export happens with
|
||||
# the target ComfyUI checkout on sys.path, not just the repo's current venv.
|
||||
result = subprocess.run(
|
||||
[
|
||||
runtime_python,
|
||||
str(Path(__file__).resolve()),
|
||||
"--internal-export",
|
||||
fixture.name,
|
||||
"--generated-path",
|
||||
str(generated_path),
|
||||
],
|
||||
cwd=ROOT,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
output = (result.stderr or result.stdout or "").strip()
|
||||
classification = "environment/setup failure"
|
||||
if (
|
||||
"Missing runtime dependency" not in output
|
||||
and "ModuleNotFoundError" not in output
|
||||
):
|
||||
classification = "repo regression"
|
||||
raise ValidationFailure(
|
||||
classification,
|
||||
f"Runtime export failed for {fixture.name}: {output}",
|
||||
)
|
||||
return generated_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def validate_generated_python(generated_code: str, fixture_name: str) -> None:
|
||||
try:
|
||||
ast.parse(generated_code)
|
||||
except SyntaxError as exc:
|
||||
raise ValidationFailure(
|
||||
"repo regression",
|
||||
f"Generated Python is not valid for {fixture_name}: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
def parse_png_info(image_path: Path) -> tuple[int, int, dict[str, str]]:
|
||||
with image_path.open("rb") as handle:
|
||||
signature = handle.read(8)
|
||||
if signature != b"\x89PNG\r\n\x1a\n":
|
||||
raise ValidationFailure(
|
||||
"environment/setup failure",
|
||||
f"Expected PNG output for {image_path.name}, got a different file format.",
|
||||
)
|
||||
|
||||
width = height = None
|
||||
text_data: dict[str, str] = {}
|
||||
while True:
|
||||
length_bytes = handle.read(4)
|
||||
if not length_bytes:
|
||||
break
|
||||
length = struct.unpack(">I", length_bytes)[0]
|
||||
chunk_type = handle.read(4)
|
||||
chunk_data = handle.read(length)
|
||||
handle.read(4)
|
||||
|
||||
if chunk_type == b"IHDR":
|
||||
width, height = struct.unpack(">II", chunk_data[:8])
|
||||
elif chunk_type == b"tEXt":
|
||||
key, value = chunk_data.split(b"\x00", 1)
|
||||
text_data[key.decode("latin-1")] = value.decode("latin-1")
|
||||
elif chunk_type == b"zTXt":
|
||||
key, compressed = chunk_data.split(b"\x00", 1)
|
||||
text_data[key.decode("latin-1")] = zlib.decompress(
|
||||
compressed[1:]
|
||||
).decode("latin-1")
|
||||
elif chunk_type == b"iTXt":
|
||||
parts = chunk_data.split(b"\x00", 5)
|
||||
if len(parts) == 6:
|
||||
key = parts[0].decode("utf-8")
|
||||
compressed_flag = parts[1]
|
||||
value = parts[5]
|
||||
if compressed_flag == b"\x01":
|
||||
value = zlib.decompress(value)
|
||||
text_data[key] = value.decode("utf-8")
|
||||
elif chunk_type == b"IEND":
|
||||
break
|
||||
|
||||
if width is None or height is None:
|
||||
raise ValidationFailure(
|
||||
"environment/setup failure",
|
||||
f"Could not read PNG dimensions from {image_path.name}.",
|
||||
)
|
||||
|
||||
return width, height, text_data
|
||||
|
||||
|
||||
def validate_output_artifact(
|
||||
fixture: FixtureConfig,
|
||||
output_path: Path,
|
||||
) -> None:
|
||||
# Read PNG metadata directly so artifact validation does not depend on
|
||||
# optional imaging libraries inside the runtime environment.
|
||||
width, height, metadata = parse_png_info(output_path)
|
||||
|
||||
if fixture.expected_min_dimensions is not None:
|
||||
min_width, min_height = fixture.expected_min_dimensions
|
||||
if width < min_width or height < min_height:
|
||||
raise ValidationFailure(
|
||||
"repo regression",
|
||||
f"Output dimensions for {fixture.name} were {width}x{height}, expected at least {min_width}x{min_height}.",
|
||||
)
|
||||
|
||||
metadata_blob = "\n".join(
|
||||
[output_path.name] + [f"{key}={value}" for key, value in metadata.items()]
|
||||
)
|
||||
for marker in fixture.metadata_markers:
|
||||
if marker not in metadata_blob:
|
||||
raise ValidationFailure(
|
||||
"repo regression",
|
||||
f"Output metadata for {fixture.name} did not contain expected marker '{marker}'.",
|
||||
)
|
||||
|
||||
|
||||
def execute_generated_python(
|
||||
generated_code: str,
|
||||
fixture: FixtureConfig,
|
||||
runtime_path: str,
|
||||
) -> None:
|
||||
if not fixture.runtime_capable:
|
||||
raise ValidationFailure(
|
||||
"fixture bug",
|
||||
f"Fixture {fixture.name} is not marked runtime-capable.",
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir) / f"{fixture.name}.py"
|
||||
tmp_path.write_text(generated_code, encoding="utf-8")
|
||||
output_dir = Path(runtime_path) / COMFYUI_OUTPUT_DIRNAME
|
||||
# Compare against the pre-run snapshot so validation can prove this
|
||||
# execution created a fresh artifact instead of reusing an old output.
|
||||
existing_outputs = set(output_dir.glob("*.png"))
|
||||
env = os.environ.copy()
|
||||
env["COMFYUI_PATH"] = runtime_path
|
||||
env["PYTHONPATH"] = os.pathsep.join(
|
||||
[str(ROOT), env.get("PYTHONPATH", "")]
|
||||
).rstrip(os.pathsep)
|
||||
runtime_python = get_runtime_python(runtime_path)
|
||||
|
||||
result = subprocess.run(
|
||||
[runtime_python, str(tmp_path)],
|
||||
cwd=ROOT,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
if fixture.filename_prefix is None:
|
||||
return
|
||||
new_outputs = [
|
||||
path
|
||||
for path in output_dir.glob(f"{fixture.filename_prefix}*.png")
|
||||
if path not in existing_outputs
|
||||
]
|
||||
if not new_outputs:
|
||||
raise ValidationFailure(
|
||||
"repo regression",
|
||||
f"Generated script for {fixture.name} did not produce a new output file with prefix {fixture.filename_prefix}.",
|
||||
)
|
||||
newest_output = max(new_outputs, key=lambda path: path.stat().st_mtime)
|
||||
validate_output_artifact(fixture, newest_output)
|
||||
return
|
||||
|
||||
stderr = (result.stderr or "").strip()
|
||||
stdout = (result.stdout or "").strip()
|
||||
output = stderr or stdout or "generated script exited with a non-zero status"
|
||||
lower_output = output.lower()
|
||||
|
||||
if "no module named 'torch'" in lower_output:
|
||||
classification = "environment/setup failure"
|
||||
elif "no such file or directory" in lower_output or "not found" in lower_output:
|
||||
classification = "environment/setup failure"
|
||||
else:
|
||||
classification = "repo regression"
|
||||
|
||||
raise ValidationFailure(
|
||||
classification,
|
||||
f"Generated script execution failed for {fixture.name}: {output}",
|
||||
)
|
||||
|
||||
|
||||
def run_fixture(fixture: FixtureConfig, tier: str, execute: bool, runtime_path: str) -> str:
|
||||
if tier == "fast":
|
||||
_, generated_code = export_workflow(fixture, tier, runtime_path)
|
||||
else:
|
||||
missing_models = check_models(fixture, runtime_path)
|
||||
if missing_models:
|
||||
raise ValidationFailure(
|
||||
"model provisioning failure",
|
||||
"Missing models for "
|
||||
f"{fixture.name}: "
|
||||
+ ", ".join(
|
||||
f"{item.relative_dir}/{item.filename}" for item in missing_models
|
||||
),
|
||||
)
|
||||
stage_inputs(fixture, runtime_path)
|
||||
generated_code = export_workflow_in_runtime_env(fixture, runtime_path)
|
||||
validate_generated_python(generated_code, fixture.name)
|
||||
|
||||
# Fast keeps execution opt-in because its stub node mappings are intended for
|
||||
# export coverage only. Runtime always executes the generated script.
|
||||
should_execute = execute or tier == "runtime"
|
||||
if should_execute and tier == "runtime":
|
||||
execute_generated_python(generated_code, fixture, runtime_path)
|
||||
|
||||
return "pass"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
|
||||
if args.internal_export:
|
||||
fixture = get_fixture(args.internal_export)
|
||||
output_path = Path(args.generated_path)
|
||||
_, generated_code = export_workflow(
|
||||
fixture=fixture,
|
||||
tier="runtime",
|
||||
runtime_path=os.environ.get("COMFYUI_PATH", ""),
|
||||
)
|
||||
output_path.write_text(generated_code, encoding="utf-8")
|
||||
return 0
|
||||
|
||||
try:
|
||||
runtime_path = ensure_runtime_path(args.tier)
|
||||
fixture_names = load_fixture_names(args.fixture)
|
||||
requested = [FIXTURES[name] for name in fixture_names]
|
||||
|
||||
if args.tier == "fast":
|
||||
requested = [
|
||||
fixture
|
||||
for fixture in requested
|
||||
if fixture.fast_mapping_factory is not None
|
||||
or fixture.mapping_factory is not None
|
||||
]
|
||||
if not requested:
|
||||
raise ValidationFailure(
|
||||
"fixture bug",
|
||||
"No selected fixtures are fast-tier compatible.",
|
||||
)
|
||||
elif args.tier == "runtime":
|
||||
requested = [fixture for fixture in requested if fixture.runtime_capable]
|
||||
if not requested:
|
||||
raise ValidationFailure(
|
||||
"fixture bug",
|
||||
"No selected fixtures are runtime-capable for this tier.",
|
||||
)
|
||||
|
||||
failures: list[tuple[str, str, str]] = []
|
||||
for fixture in requested:
|
||||
try:
|
||||
if args.print_download_plan and args.tier == "runtime":
|
||||
missing_models = check_models(fixture, runtime_path)
|
||||
for requirement in missing_models:
|
||||
target_dir = Path(runtime_path) / requirement.relative_dir
|
||||
print(
|
||||
"download:",
|
||||
f"mkdir -p {target_dir} && curl -L {requirement.source_url} -o {target_dir / requirement.filename}",
|
||||
)
|
||||
if missing_models:
|
||||
print(f"{fixture.name}: download-plan")
|
||||
continue
|
||||
status = run_fixture(fixture, args.tier, args.execute, runtime_path)
|
||||
print(f"{fixture.name}: {status}")
|
||||
except ValidationFailure as exc:
|
||||
failures.append((fixture.name, exc.classification, exc.message))
|
||||
print(
|
||||
f"{fixture.name}: fail ({exc.classification})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(exc.message, file=sys.stderr)
|
||||
if failures:
|
||||
classifications = ", ".join(
|
||||
f"{name}={classification}" for name, classification, _ in failures
|
||||
)
|
||||
print(f"classification: {classifications}", file=sys.stderr)
|
||||
return 1
|
||||
except ValidationFailure as exc:
|
||||
print(f"classification: {exc.classification}", file=sys.stderr)
|
||||
print(exc.message, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Tests for ExportApplication base_node_class_mappings behavior.
|
||||
|
||||
Verifies that after custom node import, base_node_class_mappings retains the
|
||||
pre-custom-node baseline so WorkflowPlanner correctly distinguishes between
|
||||
built-in nodes (direct import) and custom/extras nodes (NODE_CLASS_MAPPINGS lookup).
|
||||
"""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from io import StringIO
|
||||
|
||||
|
||||
def _make_workflow():
|
||||
"""Minimal workflow JSON string for testing."""
|
||||
return '{"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "test.safetensors"}}}'
|
||||
|
||||
|
||||
class TestBaseNodeClassMappings(unittest.TestCase):
|
||||
"""Tests for base_node_class_mappings stability across custom node reloads."""
|
||||
|
||||
def tearDown(self):
|
||||
# Clean up any sys.modules pollution to avoid cross-test contamination
|
||||
if "nodes" in sys.modules:
|
||||
del sys.modules["nodes"]
|
||||
|
||||
def test_base_mappings_unchanged_after_custom_node_import(self):
|
||||
"""Given custom nodes are loaded, base_node_class_mappings stays at original baseline."""
|
||||
from comfyui_to_python.app import ExportApplication
|
||||
|
||||
class FakeNode:
|
||||
CATEGORY = "loaders"
|
||||
FUNCTION = "execute"
|
||||
|
||||
@staticmethod
|
||||
def INPUT_TYPES():
|
||||
return {"required": {"ckpt_name": ("STRING",)}}
|
||||
|
||||
def execute(self, ckpt_name: str):
|
||||
return ({},)
|
||||
|
||||
class FakeCustomNode:
|
||||
CATEGORY = "custom"
|
||||
FUNCTION = "execute"
|
||||
|
||||
@staticmethod
|
||||
def INPUT_TYPES():
|
||||
return {"required": {"ckpt_name": ("STRING",)}}
|
||||
|
||||
def execute(self, ckpt_name: str):
|
||||
return ({},)
|
||||
|
||||
initial_mappings = {"CheckpointLoaderSimple": FakeNode}
|
||||
after_import_mappings = dict(initial_mappings)
|
||||
# Simulate custom nodes adding a new class
|
||||
after_import_mappings["CustomNode"] = FakeCustomNode
|
||||
|
||||
call_count = [0]
|
||||
|
||||
def mock_node_loader():
|
||||
return initial_mappings
|
||||
|
||||
def mock_custom_node_importer():
|
||||
call_count[0] += 1
|
||||
# Simulate populating sys.modules["nodes"].NODE_CLASS_MAPPINGS
|
||||
nodes_mod_type = type("NodesModule", (), {})()
|
||||
nodes_mod_type.NODE_CLASS_MAPPINGS = after_import_mappings
|
||||
sys.modules["nodes"] = nodes_mod_type
|
||||
|
||||
output = StringIO()
|
||||
app = ExportApplication(
|
||||
workflow=_make_workflow(),
|
||||
output_file=output,
|
||||
node_mapping_loader=mock_node_loader,
|
||||
custom_node_importer=mock_custom_node_importer,
|
||||
needs_init_custom_nodes=True,
|
||||
)
|
||||
|
||||
# base_node_class_mappings should be a deep copy of initial (pre-custom-node) state
|
||||
self.assertEqual(app.base_node_class_mappings, initial_mappings)
|
||||
app.execute()
|
||||
|
||||
# After execute(), base_node_class_mappings must still be the original baseline
|
||||
# (not overwritten with after_import_mappings which includes custom nodes)
|
||||
self.assertEqual(
|
||||
app.base_node_class_mappings,
|
||||
initial_mappings,
|
||||
"base_node_class_mappings should remain at pre-custom-node baseline",
|
||||
)
|
||||
|
||||
def test_base_mappings_is_deep_copy_not_shared_reference(self):
|
||||
"""Given node mappings change in-place, base_node_class_mappings is independent."""
|
||||
from comfyui_to_python.app import ExportApplication
|
||||
|
||||
class FakeNode2:
|
||||
CATEGORY = "loaders"
|
||||
FUNCTION = "execute"
|
||||
|
||||
@staticmethod
|
||||
def INPUT_TYPES():
|
||||
return {"required": {"ckpt_name": ("STRING",)}}
|
||||
|
||||
def execute(self, ckpt_name: str):
|
||||
return ({},)
|
||||
|
||||
initial_mappings = {"CheckpointLoaderSimple": FakeNode2}
|
||||
|
||||
def mock_node_loader():
|
||||
return dict(initial_mappings)
|
||||
|
||||
output = StringIO()
|
||||
app = ExportApplication(
|
||||
workflow=_make_workflow(),
|
||||
output_file=output,
|
||||
node_mapping_loader=mock_node_loader,
|
||||
custom_node_importer=lambda: None,
|
||||
)
|
||||
|
||||
# Mutate the live mappings
|
||||
app.node_class_mappings["NewKey"] = "value"
|
||||
|
||||
# base_node_class_mappings must not reflect the mutation (deep copy)
|
||||
self.assertNotIn(
|
||||
"NewKey",
|
||||
app.base_node_class_mappings,
|
||||
"base_node_class_mappings must be a deep copy, not shared reference",
|
||||
)
|
||||
|
||||
|
||||
class TestRenderHardenedImports(unittest.TestCase):
|
||||
"""Integration test: render output contains hardened _load_module pattern."""
|
||||
|
||||
def test_generated_output_contains_exec_failure_cleanup(self):
|
||||
"""Given a GenerationPlan, rendered output includes try/except BaseException around exec_module()."""
|
||||
from comfyui_to_python.generator.render import WorkflowRenderer
|
||||
from comfyui_to_python.generator.model import GenerationPlan
|
||||
|
||||
plan = GenerationPlan(
|
||||
workflow_data={"1": {"class_type": "CheckpointLoaderSimple"}},
|
||||
metadata_workflow_data=None,
|
||||
custom_nodes=False,
|
||||
import_statements={},
|
||||
special_functions_code=[],
|
||||
loop_code=["result = some_node()"],
|
||||
queue_size=1,
|
||||
)
|
||||
renderer = WorkflowRenderer()
|
||||
generated = renderer.render(plan)
|
||||
|
||||
# The _load_module in rendered output must have the exec failure cleanup pattern
|
||||
self.assertIn("try:", generated)
|
||||
self.assertIn("spec.loader.exec_module(mod)", generated)
|
||||
self.assertIn(
|
||||
"except BaseException:",
|
||||
generated,
|
||||
"Must catch BaseException for edge-case safety",
|
||||
)
|
||||
self.assertIn(
|
||||
"sys.modules.pop(module_name, None)",
|
||||
generated,
|
||||
"Must clean up sys.modules on exec failure",
|
||||
)
|
||||
|
||||
|
||||
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,712 @@
|
||||
"""Tests for hardened import path resolution and importlib isolation layer.
|
||||
|
||||
Covers Approach B from docs/specs/harden-import-path-resolution.html:
|
||||
- _is_comfyui_directory() structural verification
|
||||
- _load_module() centralized importlib isolation
|
||||
- get_comfyui_path() multi-strategy resolution
|
||||
- sys.path idempotence (no remove/re-insert gap)
|
||||
- Security: shadowing attack resistance
|
||||
- Logging hygiene (no print() path leaks)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
# Real ComfyUI checkout for integration-style unit tests (parent of this repo)
|
||||
_REAL_COMFYUI = os.path.realpath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "ComfyUI")
|
||||
)
|
||||
|
||||
|
||||
def _make_fake_comfyui(tmpdir: str) -> str:
|
||||
"""Create a minimal fake ComfyUI directory in tmpdir with structural markers."""
|
||||
comfyui_dir = os.path.join(tmpdir, "ComfyUI")
|
||||
os.makedirs(comfyui_dir)
|
||||
# Structural markers checked by _is_comfyui_directory()
|
||||
with open(os.path.join(comfyui_dir, "nodes.py"), "w") as f:
|
||||
f.write("# fake nodes.py\n")
|
||||
with open(os.path.join(comfyui_dir, "main.py"), "w") as f:
|
||||
f.write("# fake main.py\n")
|
||||
os.makedirs(os.path.join(comfyui_dir, "comfy"))
|
||||
return comfyui_dir
|
||||
|
||||
|
||||
class TestLoadModule(unittest.TestCase):
|
||||
"""Tests for _load_module() centralized importlib isolation."""
|
||||
|
||||
def tearDown(self):
|
||||
# Clean up sys.modules cache between tests to avoid cross-test pollution
|
||||
mod_names_to_clean = [
|
||||
name for name in sys.modules if name.startswith("_test_mod_")
|
||||
]
|
||||
for name in mod_names_to_clean:
|
||||
del sys.modules[name]
|
||||
|
||||
def test_loads_module_from_explicit_file_path(self):
|
||||
"""Given a valid .py file, loads the module and returns it with expected attributes."""
|
||||
from comfyui_to_python.node_runtime import _load_module
|
||||
|
||||
# Use a simple self-contained module (nodes.py requires torch)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
mod_file = os.path.join(tmpdir, "mymodule.py")
|
||||
with open(mod_file, "w") as f:
|
||||
f.write("VALUE = 42\nNAME = 'test_module'\n")
|
||||
|
||||
mod = _load_module("_test_mod_simple", mod_file)
|
||||
self.assertIsNotNone(mod)
|
||||
self.assertEqual(mod.VALUE, 42)
|
||||
self.assertEqual(mod.NAME, "test_module")
|
||||
|
||||
def test_returns_none_for_missing_file(self):
|
||||
"""Given a nonexistent file path, returns None without raising."""
|
||||
from comfyui_to_python.node_runtime import _load_module
|
||||
|
||||
result = _load_module("_test_mod_missing", "/nonexistent/path/module.py")
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
class TestShadowingResistance(unittest.TestCase):
|
||||
"""Security: _load_module() must ignore shadowed modules at sys.path[0]."""
|
||||
|
||||
def tearDown(self):
|
||||
# Clean up sys.modules cache between tests
|
||||
mod_names_to_clean = [
|
||||
name for name in sys.modules if name.startswith("_sec_test_")
|
||||
]
|
||||
for name in mod_names_to_clean:
|
||||
del sys.modules[name]
|
||||
|
||||
def test_ignores_shadowed_main_at_syspath(self):
|
||||
"""Given a malicious main.py at sys.path[0], _load_module() loads from verified path."""
|
||||
from comfyui_to_python.node_runtime import _load_module
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Legitimate module
|
||||
legit_file = os.path.join(tmpdir, "legit_main.py")
|
||||
with open(legit_file, "w") as f:
|
||||
f.write("ORIGIN = 'legitimate'\n")
|
||||
|
||||
# Malicious shadow at sys.path[0]
|
||||
attack_dir = os.path.join(tmpdir, "attack")
|
||||
os.makedirs(attack_dir)
|
||||
attack_file = os.path.join(attack_dir, "main.py")
|
||||
with open(attack_file, "w") as f:
|
||||
f.write("ORIGIN = 'malicious'\n")
|
||||
|
||||
# Insert malicious path at sys.path[0]
|
||||
old_path = sys.path[:]
|
||||
try:
|
||||
sys.path.insert(0, attack_dir)
|
||||
# _load_module with explicit legit path must return legitimate module
|
||||
mod = _load_module("_sec_test_main", legit_file)
|
||||
finally:
|
||||
sys.path[:] = old_path
|
||||
|
||||
self.assertIsNotNone(mod)
|
||||
self.assertEqual(mod.ORIGIN, "legitimate")
|
||||
|
||||
def test_ignores_shadowed_nodes_at_syspath(self):
|
||||
"""Given a malicious nodes.py at sys.path[0], _load_module() loads from verified path."""
|
||||
from comfyui_to_python.node_runtime import _load_module
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Legitimate module
|
||||
legit_file = os.path.join(tmpdir, "legit_nodes.py")
|
||||
with open(legit_file, "w") as f:
|
||||
f.write("NODE_CLASS_MAPPINGS = {'RealNode': 'real'}\n")
|
||||
|
||||
# Malicious shadow at sys.path[0]
|
||||
attack_dir = os.path.join(tmpdir, "attack")
|
||||
os.makedirs(attack_dir)
|
||||
attack_file = os.path.join(attack_dir, "nodes.py")
|
||||
with open(attack_file, "w") as f:
|
||||
f.write("NODE_CLASS_MAPPINGS = {'FakeNode': 'malicious'}\n")
|
||||
|
||||
old_path = sys.path[:]
|
||||
try:
|
||||
sys.path.insert(0, attack_dir)
|
||||
mod = _load_module("_sec_test_nodes", legit_file)
|
||||
finally:
|
||||
sys.path[:] = old_path
|
||||
|
||||
self.assertIsNotNone(mod)
|
||||
self.assertEqual(mod.NODE_CLASS_MAPPINGS, {"RealNode": "real"})
|
||||
|
||||
|
||||
@unittest.skipUnless(os.path.isdir(_REAL_COMFYUI), "requires ../ComfyUI checkout")
|
||||
class TestSysPathIdempotence(unittest.TestCase):
|
||||
"""Tests for sys.path idempotence — no remove/re-insert gap."""
|
||||
|
||||
def test_add_comfyui_idempotent_no_remove_insert(self):
|
||||
"""Repeated calls to add_comfyui_directory_to_sys_path() are insert-once only."""
|
||||
from comfyui_to_python.node_runtime import (
|
||||
add_comfyui_directory_to_sys_path,
|
||||
)
|
||||
|
||||
with patch.dict("os.environ", {"COMFYUI_PATH": _REAL_COMFYUI}, clear=False):
|
||||
before_length = len(sys.path)
|
||||
add_comfyui_directory_to_sys_path()
|
||||
after_first = len(sys.path)
|
||||
|
||||
# Second call — must not change sys.path at all (no remove+re-insert)
|
||||
add_comfyui_directory_to_sys_path()
|
||||
after_second = len(sys.path)
|
||||
|
||||
self.assertEqual(after_first, after_second, "sys.path changed on second call")
|
||||
# ComfyUI should be on sys.path
|
||||
self.assertIn(_REAL_COMFYUI, sys.path)
|
||||
|
||||
def test_already_at_index_zero_is_noop(self):
|
||||
"""Given ComfyUI already at sys.path[0], add_comfyui_directory_to_sys_path() is a no-op."""
|
||||
from comfyui_to_python.node_runtime import (
|
||||
add_comfyui_directory_to_sys_path,
|
||||
)
|
||||
|
||||
original_path = sys.path[:]
|
||||
try:
|
||||
with patch.dict("os.environ", {"COMFYUI_PATH": _REAL_COMFYUI}, clear=False):
|
||||
# Put ComfyUI at index 0 manually
|
||||
if _REAL_COMFYUI in sys.path:
|
||||
sys.path.remove(_REAL_COMFYUI)
|
||||
sys.path.insert(0, _REAL_COMFYUI)
|
||||
original_index = sys.path.index(_REAL_COMFYUI)
|
||||
|
||||
add_comfyui_directory_to_sys_path()
|
||||
new_index = sys.path.index(_REAL_COMFYUI)
|
||||
finally:
|
||||
sys.path[:] = original_path
|
||||
|
||||
self.assertEqual(
|
||||
original_index, new_index, "Index should not change when already at [0]"
|
||||
)
|
||||
self.assertEqual(new_index, 0)
|
||||
|
||||
def test_promotes_comfyui_to_sys_path_zero(self):
|
||||
"""Given ComfyUI at sys.path[5], add_comfyui_directory_to_sys_path() moves it to index 0."""
|
||||
from comfyui_to_python.node_runtime import (
|
||||
add_comfyui_directory_to_sys_path,
|
||||
)
|
||||
|
||||
original_path = sys.path[:]
|
||||
try:
|
||||
with patch.dict("os.environ", {"COMFYUI_PATH": _REAL_COMFYUI}, clear=False):
|
||||
# Insert ComfyUI deep in sys.path (simulating PYTHONPATH placement)
|
||||
if _REAL_COMFYUI in sys.path:
|
||||
sys.path.remove(_REAL_COMFYUI)
|
||||
sys.path.insert(5, _REAL_COMFYUI)
|
||||
|
||||
add_comfyui_directory_to_sys_path()
|
||||
self.assertEqual(
|
||||
sys.path[0],
|
||||
_REAL_COMFYUI,
|
||||
"ComfyUI must be promoted to sys.path[0] when already present lower down",
|
||||
)
|
||||
finally:
|
||||
sys.path[:] = original_path
|
||||
|
||||
def test_import_custom_nodes_no_gap_window(self):
|
||||
"""import_custom_nodes() must never remove ComfyUI from sys.path during execution."""
|
||||
import inspect
|
||||
from comfyui_to_python.node_runtime import import_custom_nodes
|
||||
|
||||
source = inspect.getsource(import_custom_nodes)
|
||||
self.assertNotIn(
|
||||
"sys.path.remove",
|
||||
source,
|
||||
"import_custom_nodes() must not remove ComfyUI from sys.path (gap window)",
|
||||
)
|
||||
|
||||
def test_no_print_statements_in_node_runtime(self):
|
||||
"""node_runtime.py must use logging, not print(), to avoid path leaks to stdout."""
|
||||
import inspect
|
||||
from comfyui_to_python import node_runtime
|
||||
|
||||
source = inspect.getsource(node_runtime)
|
||||
self.assertNotIn(
|
||||
"print(",
|
||||
source,
|
||||
"node_runtime.py must not use print() — use logging.DEBUG instead",
|
||||
)
|
||||
|
||||
|
||||
class TestGeneratedScriptIsolation(unittest.TestCase):
|
||||
"""Tests for generated script bootstrap (Phase 3)."""
|
||||
|
||||
def test_generated_script_embeds_load_module(self):
|
||||
"""render.py must embed _load_module() in generated scripts."""
|
||||
from comfyui_to_python.generator.render import WorkflowRenderer
|
||||
from comfyui_to_python.generator.model import GenerationPlan
|
||||
|
||||
plan = GenerationPlan(
|
||||
workflow_data={"1": {"class_type": "CheckpointLoaderSimple"}},
|
||||
metadata_workflow_data=None,
|
||||
custom_nodes=False,
|
||||
import_statements={},
|
||||
special_functions_code=[],
|
||||
loop_code=["result = some_node()"],
|
||||
queue_size=1,
|
||||
)
|
||||
renderer = WorkflowRenderer()
|
||||
generated = renderer.render(plan)
|
||||
|
||||
self.assertIn(
|
||||
"_load_module", generated, "Generated script must embed _load_module()"
|
||||
)
|
||||
self.assertIn(
|
||||
"spec_from_file_location",
|
||||
generated,
|
||||
"Generated script must use importlib isolation",
|
||||
)
|
||||
|
||||
def test_generated_script_no_bare_comfyui_imports(self):
|
||||
"""Generated scripts must have zero bare imports of ComfyUI internals."""
|
||||
from comfyui_to_python.generator.render import WorkflowRenderer
|
||||
from comfyui_to_python.generator.model import GenerationPlan
|
||||
|
||||
plan = GenerationPlan(
|
||||
workflow_data={"1": {"class_type": "CheckpointLoaderSimple"}},
|
||||
metadata_workflow_data=None,
|
||||
custom_nodes=True,
|
||||
import_statements={},
|
||||
special_functions_code=[],
|
||||
loop_code=["result = some_node()"],
|
||||
queue_size=1,
|
||||
)
|
||||
renderer = WorkflowRenderer()
|
||||
generated = renderer.render(plan)
|
||||
|
||||
# These bare imports must NOT appear in generated output
|
||||
bare_imports = [
|
||||
"from main import",
|
||||
"from utils.extra_config import",
|
||||
"import execution",
|
||||
"import server",
|
||||
"from nodes import",
|
||||
"import comfy.options",
|
||||
"from comfy.cli_args import",
|
||||
]
|
||||
for bare in bare_imports:
|
||||
self.assertNotIn(
|
||||
bare,
|
||||
generated,
|
||||
f"Generated script must not contain bare import: {bare}",
|
||||
)
|
||||
|
||||
|
||||
class TestGetComfyuiPath(unittest.TestCase):
|
||||
"""Tests for get_comfyui_path() multi-strategy resolution."""
|
||||
|
||||
def test_resolves_via_valid_comfyui_path_env(self):
|
||||
"""Given COMFYUI_PATH env var set to valid ComfyUI dir, returns that path."""
|
||||
from comfyui_to_python.node_runtime import get_comfyui_path
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
fake_comfyui = _make_fake_comfyui(tmpdir)
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(fake_comfyui) # strategy 2 would find it, but env wins first
|
||||
finally:
|
||||
pass
|
||||
with patch.dict("os.environ", {"COMFYUI_PATH": fake_comfyui}, clear=False):
|
||||
result = get_comfyui_path()
|
||||
os.chdir(original_cwd)
|
||||
self.assertEqual(result, fake_comfyui)
|
||||
|
||||
def test_falls_through_to_next_strategy_when_env_invalid(self):
|
||||
"""Given invalid COMFYUI_PATH (no nodes.py), rejects and falls through."""
|
||||
from comfyui_to_python.node_runtime import get_comfyui_path
|
||||
|
||||
with patch.dict(
|
||||
"os.environ", {"COMFYUI_PATH": "/tmp/not-comfyui"}, clear=False
|
||||
):
|
||||
result = get_comfyui_path()
|
||||
# The invalid path must be rejected (not returned as-is)
|
||||
self.assertNotEqual(result, "/tmp/not-comfyui")
|
||||
# In standalone/dev setups, no strategy may succeed -> None is valid
|
||||
# In ComfyUI/custom_nodes/ install, relative strategy would find it
|
||||
|
||||
|
||||
class TestIsComfyuiDirectory(unittest.TestCase):
|
||||
"""Tests for _is_comfyui_directory() structural verification."""
|
||||
|
||||
def test_returns_true_for_real_comfyui_checkout(self):
|
||||
"""Given a valid ComfyUI directory with structural markers, returns True."""
|
||||
from comfyui_to_python.node_runtime import _is_comfyui_directory
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
fake_comfyui = _make_fake_comfyui(tmpdir)
|
||||
self.assertTrue(_is_comfyui_directory(fake_comfyui))
|
||||
|
||||
def test_rejects_directory_lacking_markers(self):
|
||||
"""Given a directory without nodes.py, returns False."""
|
||||
from comfyui_to_python.node_runtime import _is_comfyui_directory
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Empty directory has no ComfyUI markers
|
||||
self.assertFalse(_is_comfyui_directory(tmpdir))
|
||||
|
||||
def test_rejects_nonexistent_path(self):
|
||||
"""Given a nonexistent path, returns False."""
|
||||
from comfyui_to_python.node_runtime import _is_comfyui_directory
|
||||
|
||||
self.assertFalse(_is_comfyui_directory("/nonexistent/path/to/comfyui"))
|
||||
|
||||
|
||||
class TestLoadModuleFailureCleanup(unittest.TestCase):
|
||||
"""Tests for _load_module() behavior when exec_module() raises."""
|
||||
|
||||
def tearDown(self):
|
||||
mod_names_to_clean = [
|
||||
name for name in sys.modules if name.startswith("_test_fail_")
|
||||
]
|
||||
for name in mod_names_to_clean:
|
||||
del sys.modules[name]
|
||||
|
||||
def test_broken_module_removed_from_sys_modules_on_failure(self):
|
||||
"""Given a module that raises during exec, it is removed from sys.modules."""
|
||||
from comfyui_to_python.node_runtime import _load_module
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
mod_file = os.path.join(tmpdir, "badmodule.py")
|
||||
with open(mod_file, "w") as f:
|
||||
f.write("raise RuntimeError('mid-execution failure')\n")
|
||||
|
||||
result = _load_module("_test_fail_bad", mod_file)
|
||||
self.assertIsNone(result)
|
||||
self.assertNotIn(
|
||||
"_test_fail_bad",
|
||||
sys.modules,
|
||||
"Failed module must not remain cached in sys.modules",
|
||||
)
|
||||
|
||||
def test_broken_module_retried_cleanly(self):
|
||||
"""Given a failed load, a subsequent load attempt starts fresh (not from cache)."""
|
||||
from comfyui_to_python.node_runtime import _load_module
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
bad_file = os.path.join(tmpdir, "bad.py")
|
||||
good_file = os.path.join(tmpdir, "good.py")
|
||||
|
||||
# First load: bad module that raises
|
||||
with open(bad_file, "w") as f:
|
||||
f.write("raise RuntimeError('fail')\n")
|
||||
result_bad = _load_module("_test_fail_retry", bad_file)
|
||||
self.assertIsNone(result_bad)
|
||||
|
||||
# Overwrite with good module
|
||||
os.remove(bad_file)
|
||||
with open(good_file, "w") as f:
|
||||
f.write("VALUE = 99\n")
|
||||
|
||||
# Reload same name from a different (working) file
|
||||
result_good = _load_module("_test_fail_retry", good_file)
|
||||
self.assertIsNotNone(result_good)
|
||||
self.assertEqual(result_good.VALUE, 99)
|
||||
|
||||
|
||||
class TestCanonicalOptionsLoading(unittest.TestCase):
|
||||
"""Tests for canonical module loading of comfy.options in bootstrap."""
|
||||
|
||||
def tearDown(self):
|
||||
# Clean up sys.modules cache
|
||||
mod_names_to_clean = [
|
||||
name for name in sys.modules if name.startswith("_test_opts_")
|
||||
]
|
||||
for name in mod_names_to_clean:
|
||||
del sys.modules[name]
|
||||
if "comfy.options" in sys.modules:
|
||||
del sys.modules["comfy.options"]
|
||||
|
||||
def test_load_under_canonical_name_caches_in_sys_modules(self):
|
||||
"""When _load_module() uses canonical name, the module is cached in sys.modules."""
|
||||
from comfyui_to_python.node_runtime import _load_module
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
mod_file = os.path.join(tmpdir, "options.py")
|
||||
with open(mod_file, "w") as f:
|
||||
f.write("args_parsing = False\n")
|
||||
|
||||
# Load under canonical name
|
||||
mod = _load_module("comfy.options", mod_file)
|
||||
self.assertIsNotNone(mod)
|
||||
# The canonical key must be in sys.modules so later imports see mutations
|
||||
self.assertIn(
|
||||
"comfy.options",
|
||||
sys.modules,
|
||||
"Canonical module load must cache under canonical name in sys.modules",
|
||||
)
|
||||
|
||||
|
||||
class TestLoadModuleTemp(unittest.TestCase):
|
||||
"""Tests for _load_module_temp() removal from sys.modules."""
|
||||
|
||||
def tearDown(self):
|
||||
mod_names_to_clean = [
|
||||
name for name in sys.modules if name.startswith("_test_temp_")
|
||||
]
|
||||
for name in mod_names_to_clean:
|
||||
del sys.modules[name]
|
||||
|
||||
def test_module_removed_from_sys_modules_after_temp_load(self):
|
||||
"""After _load_module_temp(), the module key is absent from sys.modules."""
|
||||
from comfyui_to_python.node_runtime import _load_module_temp
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
mod_file = os.path.join(tmpdir, "tempmod.py")
|
||||
with open(mod_file, "w") as f:
|
||||
f.write("VALUE = 123\n")
|
||||
|
||||
mod = _load_module_temp("_test_temp_mod", mod_file)
|
||||
self.assertIsNotNone(mod)
|
||||
self.assertEqual(mod.VALUE, 123)
|
||||
self.assertNotIn(
|
||||
"_test_temp_mod",
|
||||
sys.modules,
|
||||
"Temp module must be removed from sys.modules after load",
|
||||
)
|
||||
|
||||
def test_module_removed_on_temp_load_failure(self):
|
||||
"""After _load_module_temp() fails, the module key is absent from sys.modules."""
|
||||
from comfyui_to_python.node_runtime import _load_module_temp
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
mod_file = os.path.join(tmpdir, "badtemp.py")
|
||||
with open(mod_file, "w") as f:
|
||||
f.write("raise RuntimeError('fail')\n")
|
||||
|
||||
result = _load_module_temp("_test_temp_bad", mod_file)
|
||||
self.assertIsNone(result)
|
||||
self.assertNotIn(
|
||||
"_test_temp_bad",
|
||||
sys.modules,
|
||||
"Failed temp module must be removed from sys.modules",
|
||||
)
|
||||
|
||||
|
||||
class TestRepeatedNodeLoad(unittest.TestCase):
|
||||
"""Tests that repeated _load_module('nodes', ...) calls respect the cache."""
|
||||
|
||||
def tearDown(self):
|
||||
if "_test_nodes" in sys.modules:
|
||||
del sys.modules["_test_nodes"]
|
||||
|
||||
def test_repeated_load_preserves_cached_attributes(self):
|
||||
"""Second _load_module() for same name returns cached module, not a fresh copy."""
|
||||
from comfyui_to_python.node_runtime import _load_module
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
mod_file = os.path.join(tmpdir, "mynodes.py")
|
||||
with open(mod_file, "w") as f:
|
||||
f.write("NODE_CLASS_MAPPINGS = {'A': 1}\n")
|
||||
|
||||
first = _load_module("_test_nodes", mod_file)
|
||||
self.assertIsNotNone(first)
|
||||
|
||||
# Mutate the cached module to simulate init_extra_nodes()
|
||||
first.NODE_CLASS_MAPPINGS["B"] = 2
|
||||
|
||||
# Second load with same name must return cached copy, preserving mutation
|
||||
second = _load_module("_test_nodes", mod_file)
|
||||
self.assertIs(first, second)
|
||||
self.assertEqual(second.NODE_CLASS_MAPPINGS.get("B"), 2)
|
||||
|
||||
|
||||
class TestFindFile(unittest.TestCase):
|
||||
"""Tests for _find_file() — file discovery via directory walk."""
|
||||
|
||||
def test_finds_file_at_cwd_level(self):
|
||||
"""Given a file at CWD, _find_file() returns the full path to it."""
|
||||
from comfyui_to_python.node_runtime import _find_file
|
||||
|
||||
original_cwd = os.getcwd()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
target_file = os.path.join(tmpdir, "extra_model_paths.yaml")
|
||||
with open(target_file, "w") as f:
|
||||
f.write("config: value\n")
|
||||
|
||||
try:
|
||||
os.chdir(tmpdir)
|
||||
result = _find_file("extra_model_paths.yaml")
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
self.assertEqual(result, target_file)
|
||||
|
||||
def test_finds_file_in_parent_directory(self):
|
||||
"""Given a file in a parent directory, _find_file() walks up and finds it."""
|
||||
from comfyui_to_python.node_runtime import _find_file
|
||||
|
||||
original_cwd = os.getcwd()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
target_file = os.path.join(tmpdir, "extra_model_paths.yaml")
|
||||
with open(target_file, "w") as f:
|
||||
f.write("config: value\n")
|
||||
|
||||
# Create a subdirectory and chdir into it
|
||||
subdir = os.path.join(tmpdir, "subdir", "deeper")
|
||||
os.makedirs(subdir)
|
||||
|
||||
try:
|
||||
os.chdir(subdir)
|
||||
result = _find_file("extra_model_paths.yaml")
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
self.assertEqual(result, target_file)
|
||||
|
||||
def test_returns_none_when_file_not_found(self):
|
||||
"""Given a filename that doesn't exist within max_depth, returns None."""
|
||||
from comfyui_to_python.node_runtime import _find_file
|
||||
|
||||
original_cwd = os.getcwd()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
try:
|
||||
os.chdir(tmpdir)
|
||||
result = _find_file("nonexistent_config.yaml")
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_none_for_directory_not_file(self):
|
||||
"""Given a directory with matching name, returns None (wants a file)."""
|
||||
from comfyui_to_python.node_runtime import _find_file
|
||||
|
||||
original_cwd = os.getcwd()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create a directory (not file) with the target name
|
||||
fake_dir = os.path.join(tmpdir, "extra_model_paths.yaml")
|
||||
os.makedirs(fake_dir)
|
||||
|
||||
try:
|
||||
os.chdir(tmpdir)
|
||||
result = _find_file("extra_model_paths.yaml")
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
class TestFindPathCwdFirst(unittest.TestCase):
|
||||
"""Tests for find_path() checking CWD before walking to parent."""
|
||||
|
||||
def test_find_path_returns_cwd_when_cwd_is_match(self):
|
||||
"""Given CWD is named 'ComfyUI', find_path('ComfyUI') returns CWD itself."""
|
||||
from comfyui_to_python.node_runtime import find_path
|
||||
|
||||
original_cwd = os.getcwd()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
comfyui_dir = os.path.join(tmpdir, "ComfyUI")
|
||||
os.makedirs(comfyui_dir)
|
||||
try:
|
||||
os.chdir(comfyui_dir)
|
||||
result = find_path("ComfyUI")
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
self.assertEqual(result, comfyui_dir)
|
||||
|
||||
def test_find_path_checks_cwd_before_parent(self):
|
||||
"""When CWD matches but parent also matches, CWD is returned (check-first)."""
|
||||
from comfyui_to_python.node_runtime import find_path
|
||||
|
||||
original_cwd = os.getcwd()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create nested dirs: outer/ComfyUI/inner where inner is named ComfyUI too
|
||||
outer_comfy = os.path.join(tmpdir, "ComfyUI")
|
||||
os.makedirs(outer_comfy)
|
||||
# Rename so we can create an inner dir also named ComfyUI
|
||||
os.rename(outer_comfy, os.path.join(tmpdir, "ParentComfy"))
|
||||
parent_comfy = os.path.join(tmpdir, "ParentComfy")
|
||||
child_dir = os.path.join(parent_comfy, "subdir")
|
||||
os.makedirs(child_dir)
|
||||
|
||||
try:
|
||||
os.chdir(child_dir)
|
||||
# When searching for ParentComfy, should find it at parent level
|
||||
result = find_path("ParentComfy")
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
self.assertEqual(result, parent_comfy)
|
||||
|
||||
|
||||
class TestGetComfyuiPathThirdStrategy(unittest.TestCase):
|
||||
"""Tests for get_comfyui_path() third-strategy verification."""
|
||||
|
||||
def test_fallback_rejects_non_comfyui_directory(self):
|
||||
"""Given a directory named ComfyUI without nodes.py, third strategy rejects it."""
|
||||
from comfyui_to_python.node_runtime import get_comfyui_path
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create a fake "ComfyUI" directory (no nodes.py)
|
||||
fake_dir = os.path.join(tmpdir, "ComfyUI")
|
||||
os.makedirs(fake_dir)
|
||||
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmpdir)
|
||||
with patch.dict("os.environ", {}, clear=False):
|
||||
# Strip COMFYUI_PATH so fallback strategies activate
|
||||
env_copy = dict(os.environ)
|
||||
env_copy.pop("COMFYUI_PATH", None)
|
||||
with patch.dict("os.environ", env_copy, clear=True):
|
||||
result = get_comfyui_path()
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
# Must NOT return the fake directory
|
||||
self.assertNotEqual(result, fake_dir)
|
||||
|
||||
|
||||
@unittest.skipUnless(os.path.isdir(_REAL_COMFYUI), "requires ../ComfyUI checkout")
|
||||
class TestImportCustomNodesSysPathRestoration(unittest.TestCase):
|
||||
"""Tests for sys.path restoration in import_custom_nodes()."""
|
||||
|
||||
def test_sys_path_restored_on_crash(self):
|
||||
"""Given a crash during import_custom_nodes(), sys.path is still restored.
|
||||
|
||||
The crash fires on the "server" module load (after comfy/ filtering)
|
||||
so that the try/finally sys.path restoration path is actually exercised.
|
||||
"""
|
||||
from comfyui_to_python.node_runtime import import_custom_nodes
|
||||
|
||||
original_path = sys.path[:]
|
||||
call_order = []
|
||||
|
||||
def fake_load(name, path):
|
||||
call_order.append(name)
|
||||
if name == "server":
|
||||
raise RuntimeError("simulated crash during server load")
|
||||
return None # Let execution/nodes return None (falls through gracefully)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"comfyui_to_python.node_runtime._load_module",
|
||||
side_effect=fake_load,
|
||||
),
|
||||
patch(
|
||||
"comfyui_to_python.node_runtime.get_comfyui_path",
|
||||
return_value=_REAL_COMFYUI if os.path.isdir(_REAL_COMFYUI) else None,
|
||||
),
|
||||
):
|
||||
try:
|
||||
import_custom_nodes()
|
||||
except RuntimeError:
|
||||
pass # Expected crash
|
||||
|
||||
# When ComfyUI path is found, "execution" and "nodes" should be called
|
||||
# before reaching the server module load inside try/finally.
|
||||
self.assertIn("execution", call_order, "Expected 'execution' to be loaded")
|
||||
self.assertIn("nodes", call_order, "Expected 'nodes' to be loaded")
|
||||
self.assertIn("server", call_order, "Expected 'server' to trigger the crash")
|
||||
|
||||
# sys.path should still contain all original entries (no dangling state)
|
||||
for entry in original_path:
|
||||
self.assertIn(entry, sys.path)
|
||||
|
||||
|
||||
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,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,530 @@
|
||||
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)
|
||||
# Hardened imports use _load_module_temp() with temporary names,
|
||||
# not bare 'import comfy.options' — avoids sys.modules pollution
|
||||
self.assertIn("_load_module_temp", generated)
|
||||
self.assertIn("options.py", generated)
|
||||
self.assertIn("enable_args_parsing()", generated)
|
||||
self.assertIn("cuda_malloc.py", 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"),
|
||||
)
|
||||
# cuda_malloc is loaded via _load_module() inside bootstrap_comfyui_runtime()
|
||||
# which runs before 'import torch' in main() — ordering preserved
|
||||
self.assertIn("cuda_malloc", generated)
|
||||
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,166 @@
|
||||
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.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "black" },
|
||||
{ name = "ruff", marker = "extra == 'dev'" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user