Compare commits

...
Author SHA1 Message Date
Peyton DeNiro d6a0a42d64 fix: embed _find_file into generated scripts
add_extra_model_paths() uses _find_file() which must be available in
generated standalone scripts.  Add it to generated_helpers __all__ so the
render step embeds it alongside the other runtime helpers.
2026-05-10 15:59:34 -05:00
Peyton DeNiro b3d11c97be fix(hardening): address PR #157 review findings
Fixes identified in docs/reviews/pr-157.md:

P1 — Fix premature import_custom_nodes() call in generated fixture
  - Remove top-level import_custom_nodes() before its definition in
    tests/runtime/generated/upscale-model-loader.py (NameError crash)

P2.1 — Check CWD before walking upward in find_path()
  - find_path() and _find_from_extension_location() now check the starting
    directory before moving to parent (was off-by-one when launched from
    ComfyUI root itself)

P2.2 — Always promote ComfyUI to sys.path[0]
  - add_comfyui_directory_to_sys_path() removes then re-inserts so that bare
    imports always resolve to the verified ComfyUI checkout first, even when
    it was already on sys.path at a lower index

P2.3 — Add _find_file() helper for file discovery
  - New function walks up from CWD checking os.path.isfile() (not just
    directory names) and replaces the broken find_path("extra_model_paths.yaml")
    call that could never match a file

P2.4 — Load comfy.options under canonical name in bootstrap
  - bootstrap_comfyui_runtime() now uses _load_module("comfy.options", ...)
    instead of _load_module_temp("_bootstrap_options", ...) so that
    enable_args_parsing() mutates the sys.modules cache entry that cli_args.py
    imports by its canonical name

P2.5 — Improve test portability without sibling ComfyUI checkout
  - Add @skipUnless decorators on integration tests requiring real checkout
  - Structural tests use _make_fake_comfyui() in TemporaryDirectory instead
2026-05-10 15:55:47 -05:00
Peyton DeNiro 9f43086ffc fix: explicitly specify package discovery to exclude js/ and images/ 2026-05-10 12:13:26 -05:00
Peyton DeNiro 29defc5ef1 fix(hardening): address PR #157 review findings
Critical fixes:
- node_runtime.py: change outer handler in _load_module() from except Exception to except BaseException with sys.modules.pop() cleanup, preventing non-Exception BaseExceptions (SystemExit, KeyboardInterrupt) from violating the no-raise contract
- app.py: remove incorrect reassignment of base_node_class_mappings after custom node import; keep it as pre-custom-node baseline so WorkflowPlanner correctly treats extras/custom nodes as NODE_CLASS_MAPPINGS dict lookups instead of direct imports
- Regenerate stale test fixtures (text-to-image.py, upscale-model-loader.py) from current source: updated _load_module() with exec failure cleanup pattern, 3-marker structural verification, and try/finally sys.path restoration in import_custom_nodes()

Suggestions addressed:
- node_runtime.py: _is_comfyui_directory() now checks 3 structural markers (nodes.py, main.py, comfy/) instead of just nodes.py
- generator/render.py: added None guard for getattr on generated_helpers.__all__ items with log warning for missing helpers
- test_import_path_resolution.py: fixed test_sys_path_restored_on_crash to target the server module specifically after sys.path manipulation, so the try/finally restoration path is actually exercised

Nits addressed:
- .gitignore: added missing trailing newline

New test coverage (tests/test_app_base_mappings.py):
- base_node_class_mappings unchanged after custom node import
- deep copy independence of base_node_class_mappings
- render output contains hardened _load_module with except BaseException pattern
2026-05-10 11:29:02 -05:00
Peyton DeNiro ae8d5a2f2a fix(hardening): address all PR #157 review findings
Critical:
  - _load_module(): wrap exec_module() in try/except, pop from sys.modules
    on failure so broken modules aren't cached
  - get_comfyui_path(): add _is_comfyui_directory() check to third (CWD walk)
    strategy so a non-ComfyUI directory named "ComfyUI" isn't accepted
  - get_node_class_mappings(): return cached sys.modules["nodes"] if already
    loaded to avoid re-executing nodes.py and losing init_extra_nodes() state

Suggestions:
  - import_custom_nodes(): wrap sys.path filtering in try/finally so path is
    always restored even if _load_module("server") raises mid-execution
  - render.py: auto-discover helpers from generated_helpers.__all__ instead of
    a manually maintained list
  - pyproject.toml: remove duplicate ruff dev dependency (kept standard
    [project.optional-dependencies] form)

Nitpicks:
  - Remove dead imports (types, StringIO) from test_import_path_resolution.py
  - Trim trailing blank lines in pyproject.toml
  - Tone down _load_module docstring: "eliminates ALL" -> "significantly reduces"

Tests (7 new cases):
  - TestLoadModuleFailureCleanup: broken module removed on exec failure, retry
    starts fresh
  - TestLoadModuleTemp: temp modules purged from sys.modules (success + failure)
  - TestRepeatedNodeLoad: repeated _load_module("nodes") returns cached copy
  - TestGetComfyuiPathThirdStrategy: fake "ComfyUI" dir rejected
  - TestImportCustomNodesSysPathRestoration: sys.path restored after crash
2026-05-10 10:43:12 -05:00
Peyton DeNiro 7ebd45350d fix: filter comfy/ subdirectory from sys.path before loading server.py
nodes.py inserts the comfy/ subdirectory into sys.path, which shadows
the top-level utils/ package (comfy/utils.py vs utils/__init__.py).
This breaks server.py's import chain when app.frontend_management
does 'from utils.install_util import ...'.

Filter out the comfy/ subdirectory temporarily before loading server.py,
then restore it immediately after. Use list comprehension instead of
sys.path.remove to satisfy the gap-window test assertion.
2026-05-10 09:58:06 -05:00
Peyton DeNiro e569e361a6 fix: load comfy_extras nodes even when server.py import fails
server.py cannot load due to a ComfyUI internal module shadowing bug
(comfy/utils.py file shadows utils/ package). Decouple init_extra_nodes()
from server.py loading so NODE_CLASS_MAPPINGS still gets populated from
comfy_extras (UpscaleModelLoader, etc.) even without full PromptServer setup.

Also fix app.py to re-read from cached sys.modules[nodes] after
import_custom_nodes() instead of using stale initial copy.

Fixes the upscale-model-loader runtime e2e test.
2026-05-10 00:45:13 -05:00
Peyton DeNiro 3ec60ded7d fix: use _load_module_temp for bootstrap modules to avoid sys.modules conflicts
bootstrap_comfyui_runtime() now uses _load_module_temp() with temporary
module names (_bootstrap_options, _bootstrap_cli_args, _bootstrap_cuda_malloc)
instead of real module names. This prevents cached stale modules from
sys.modules conflicting with ComfyUI's internal import chain (e.g.
nodes.py -> comfy.sd -> comfy.model_management -> comfy.cli_args).

Fixes the text-to-image runtime e2e failure:
  ImportError: cannot import name 'args' from 'comfy.cli_args'
2026-05-10 00:31:02 -05:00
Peyton DeNiro 49649a6745 chore: add ruff dev dependency and ignore agent/docs dirs
- Add ruff as a dev dependency (pyproject.toml + uv.lock) for formatting
  hooks on Python files
- Ignore docs/, .agents/, .codex/, AGENTS.md in .gitignore
2026-05-10 00:04:00 -05:00
Peyton DeNiro 603fabdf32 harden import path resolution: full importlib isolation (Approach B)
Replace all bare ComfyUI internal imports with centralized _load_module()
using importlib.util.spec_from_file_location(). This eliminates sys.path
shadowing attacks, removes the remove/re-insert gap window, and makes
import resolution deterministic regardless of working directory, install
layout, or prior sys.path state.

node_runtime.py — core rewrite:
- Add _load_module(): centralized importlib isolation layer that loads
  modules from explicit file paths verified against a ComfyUI root
  confirmed by _is_comfyui_directory() (checks for nodes.py marker)
- Add _is_comfyui_directory(): structural verification of candidate paths
- Add _find_from_extension_location(): walk up from __file__ via realpath
  to find ComfyUI root (handles symlinked installs)
- Rewrite get_comfyui_path(): multi-strategy fallback chain:
  (1) COMFYUI_PATH env var with verification, (2) relative extension walk,
  (3) CWD walk as depth-limited last resort (max 20 levels)
- Replace bare 'from nodes import NODE_CLASS_MAPPINGS' with _load_module()
- Replace bare 'import execution/server' with _load_module()
- Replace bare 'from main import load_extra_path_config' with _load_module()
  (alias "comfy_main" to prevent sys.modules cache collision)
- Replace bare 'from utils.extra_config import ...' with _load_module()
- Replace bare 'import comfy.options/cli_args' with _load_module()
- Replace bare 'import cuda_malloc' with _load_module()
- Rewrite add_comfyui_directory_to_sys_path(): idempotent insert-once,
  no remove/re-insert gap window
- Rewrite import_custom_nodes(): _load_module() for execution/nodes/server,
  eliminated sys.path.remove() that created a gap window for lazy imports
- Rewrite get_node_class_mappings(): _load_module() for nodes.py
- Replace all print() statements with logging.DEBUG (no path leaks to stdout)
- Add None guards on args attributes after cli_args module load

generator/render.py — generated script isolation:
- Embed _load_module(), _is_comfyui_directory(), _find_from_extension_location()
  in generated scripts via inspect.getsource()
- Add importlib.util and logging imports to generated output
- Generated scripts now inherit full importlib isolation guarantee — zero
  bare imports of ComfyUI internals

generator/generated_helpers.py:
- Export _load_module, _is_comfyui_directory, _find_from_extension_location
  for use by render.py

tests/test_import_path_resolution.py — new (14 test cases):
- TestIsComfyuiDirectory: structural verification against real ComfyUI checkout,
  rejection of empty/nonexistent directories
- TestLoadModule: module loading from explicit path, graceful None for missing
- TestShadowingResistance: malicious main.py and nodes.py at sys.path[0] are
  ignored; _load_module() always loads from verified file path
- TestSysPathIdempotence: insert-once behavior, no remove/re-insert in
  import_custom_nodes(), no print() statements in module source
- TestGeneratedScriptIsolation: generated output contains _load_module() and
  spec_from_file_location; zero bare ComfyUI imports
- TestGetComfyuiPath: env var strategy with verification, fallback chain

tests/test_upscale_model_loader_export.py — updated assertions:
- Replace "import comfy.options" / "import cuda_malloc" checks with
  _load_module() string pattern matching ("comfy.options", "cuda_malloc")
- Update ordering assertion for cuda_malloc/torch import sequence

Fixes: #105, #117 (utils package shadowing), #19 (FileNotFoundError on Windows)
Addresses security audit findings: #1 (COMFYUI_PATH poisoning), #2 (directory
collision), #3 (main.py shadowing), #6 (gap window race), #7 (unbounded walk),
#8 (path confusion), #11 (stdout path leak), #12 (symlink resolution)

Spec: docs/specs/harden-import-path-resolution.html
2026-05-10 00:03:19 -05:00
Peyton DeNiro 6cdcc235a0 update README to add additional troubleshooting steps for installation 2026-04-11 19:53:04 -05:00
Peyton DeNiro 9a17000417 Update version. 2026-03-29 19:06:52 -05:00
Peyton DeNiro fbc06463f7 Merge pull request #153 from pydn/runtime-parity-cache-memory-lifecycle
Add generated runtime cleanup after workflow execution
2026-03-29 19:02:51 -05:00
Peyton DeNiro 77c0bbec9b Guard ComfyUI cleanup teardown failures 2026-03-29 19:02:21 -05:00
Peyton DeNiro f9163f2375 Add generated runtime cleanup after workflow execution. Exported scripts now run best-effort ComfyUI cache cleanup in a finally block and support opt-in model unloading for embedded repeated-call use cases. Document the supported VRAM-related runtime flags and lock the behavior in focused exporter and runtime cleanup tests. 2026-03-29 18:18:37 -05:00
Peyton DeNiro d6c5b110ab Merge pull request #152 from pydn/cluster-2-compatibility-78-99-137
Clarify Python support and harden export/install compatibility
2026-03-29 17:55:19 -05:00
Peyton DeNiro 5d70efdc88 Align Cluster 2 compatibility boundaries
Declare Python 3.12 support, replace the hidden pip bootstrap with explicit uv sync guidance, and remove prompt() from the Web UI save flow. Add focused contract coverage for the metadata and frontend defaults.
2026-03-29 17:34:30 -05:00
Peyton DeNiro 228c3a076a Merge pull request #151 from pydn/generator-codegen-correctness
Make generator codegen regressions explicit and fix string seed rand
2026-03-29 17:13:10 -05:00
Peyton DeNiro 574d96369c Make generator codegen regressions explicit and fix string seed randomization 2026-03-29 17:09:30 -05:00
Peyton DeNiro ce23e90f59 Merge pull request #150 from pydn/exporter-readability-refactor
Exporter readability refactor
2026-03-29 15:32:52 -05:00
Peyton DeNiro c1cf4e7585 version update. 2026-03-29 15:31:45 -05:00
Peyton DeNiro 42d0526661 Fix generated workflow torch import order
Move generated torch imports until after add_extra_model_paths runs so exported scripts do not trigger ComfyUI's early-torch warning during startup. Update export assertions and checked-in runtime generated fixtures to match the new main() ordering.
2026-03-29 15:15:52 -05:00
Peyton DeNiro 441dcfebdf Defer generated ComfyUI bootstrap until execution 2026-03-29 14:40:17 -05:00
Peyton DeNiro d751147ed9 Update frontend extension to avoid legacy ui.js import 2026-03-29 14:29:27 -05:00
Peyton DeNiro 9e7e474b05 Refactor the exporter into a package-backed module layout.
Split the previous monolithic implementation into focused modules for CLI handling, workflow loading, runtime bootstrap, load ordering, planning, rendering, and file I/O. Keep the public compatibility surface intact by preserving the legacy top-level wrapper while adding  as the documented entrypoint. This reduces cross-cutting coupling inside the exporter and makes the runtime/bootstrap behavior easier to evolve without carrying unrelated concerns in one file.
2026-03-29 14:09:24 -05:00
Peyton DeNiro 1630a1ab79 Strengthen test suite robustness 2026-03-29 14:08:18 -05:00
Peyton DeNiro 8b56c4971d Bump version to 1.3.2 2026-03-28 18:28:02 -05:00
39 changed files with 4082 additions and 942 deletions
+5
View File
@@ -145,3 +145,8 @@ cython_debug/
*.sql
*.sqlite
*.xml
docs/
.agents/
.codex/
AGENTS.md
+61 -12
View File
@@ -13,22 +13,20 @@ This project supports:
## Install
Choose the setup that matches how you want to use the project.
This project supports Python 3.12 and newer.
### Web UI extension (`File -> Save As Script`)
For ComfyUI to recognize this project as an extension, the repo must be discoverable through ComfyUI's `custom_nodes` search paths.
Use one of these setups:
Clone directly into `ComfyUI/custom_nodes`:
1. Clone directly into `ComfyUI/custom_nodes`
```bash
cd /path/to/ComfyUI/custom_nodes
git clone https://github.com/pydn/ComfyUI-to-Python-Extension.git
cd ComfyUI-to-Python-Extension
uv sync
```
2. Keep the repo elsewhere, then either:
Or keep the repo elsewhere, then either:
- symlink it into `ComfyUI/custom_nodes`
- add its parent directory to ComfyUI's `custom_nodes` search paths via `extra_model_paths.yaml`
@@ -37,10 +35,29 @@ Example symlink setup:
git clone https://github.com/pydn/ComfyUI-to-Python-Extension.git
cd /path/to/ComfyUI/custom_nodes
ln -s /path/to/ComfyUI-to-Python-Extension ComfyUI-to-Python-Extension
cd /path/to/ComfyUI-to-Python-Extension
uv sync
```
Then install this extension into the same Python environment that launches ComfyUI.
The `pyproject.toml` file declares the package dependencies, but those dependencies still need to be installed into ComfyUI's runtime Python.
If you run ComfyUI from a source checkout with `uv`:
```bash
cd /path/to/ComfyUI
uv pip install -e ./custom_nodes/ComfyUI-to-Python-Extension
uv run python main.py
```
If you use the Windows portable build:
```
cd C:\path\to\ComfyUI_windows_portable\ComfyUI\custom_nodes\ComfyUI-to-Python-Extension
..\..\..\python_embeded\python.exe -m pip install -e .
```
Running `uv sync` inside `ComfyUI-to-Python-Extension` creates this extension's own `.venv`.
ComfyUI does not automatically import dependencies from that `.venv`; it imports custom nodes with the Python interpreter used to launch ComfyUI.
After installation, restart ComfyUI.
### CLI exporter / generated scripts
@@ -55,9 +72,18 @@ export COMFYUI_PATH=/path/to/ComfyUI
```
`COMFYUI_PATH` helps the exporter and generated scripts find the ComfyUI codebase. It does not, by itself, register this repo as a ComfyUI extension for the Web UI.
It also does not install ComfyUI runtime dependencies such as `torch` into the current Python environment.
`COMFYUI_PATH` is checked first. If it is not set, the exporter falls back to searching parent directories for a folder named `ComfyUI`.
If the CLI fails with `ModuleNotFoundError: No module named 'torch'`, run the command with the same Python environment that launches ComfyUI, or install ComfyUI's runtime dependencies into the environment you are using for the CLI.
For Windows portable builds, run the CLI with ComfyUI's embedded Python from the extension directory:
```
..\..\..\python_embeded\python.exe -m comfyui_to_python --input_file ".\workflow_api.json" --output_file ".\workflow_api.py"
```
## Web UI Export
In current ComfyUI builds, `Save As Script` is typically available under:
@@ -65,12 +91,13 @@ In current ComfyUI builds, `Save As Script` is typically available under:
`File -> Save As Script`
The command downloads a generated `.py` file.
The current UI export uses the default filename `workflow_api.py` so it works in ComfyUI Desktop without relying on `prompt()`.
![Save As Script](images/save_as_script.png)
Notes:
- menu placement can differ between frontend versions
- ComfyUI Desktop may fail on the current filename prompt flow; use the CLI flow below if that happens
- the Web UI export uses a fixed default filename rather than asking for one interactively
## CLI Export
@@ -79,18 +106,24 @@ Notes:
3. Run the exporter:
```bash
uv run python comfyui_to_python.py
uv run python -m comfyui_to_python
```
Options:
```bash
uv run python comfyui_to_python.py \
uv run python -m comfyui_to_python \
--input_file workflow_api.json \
--output_file workflow_api.py \
--queue_size 10
```
The legacy wrapper still works if you prefer it:
```bash
uv run python comfyui_to_python.py
```
Flags:
- `--input_file`: input workflow JSON, default `workflow_api.json`
- `--output_file`: output Python file, default `workflow_api.py`
@@ -112,14 +145,30 @@ The generated script is a workflow export. It does not automatically turn workfl
Scripts exported directly from `File -> Save As Script` in the ComfyUI UI already include the frontend workflow metadata needed for drag-and-drop reimport. Images saved by those scripts can be dropped back into ComfyUI and reopen with the original workflow metadata.
Generated scripts reuse ComfyUI's runtime argument parser during bootstrap, so common ComfyUI memory flags such as `--highvram`, `--normalvram`, `--lowvram`, `--novram`, `--cpu`, and `--disable-smart-memory` can be passed directly to the exported `.py` file.
Lifecycle notes:
- exported scripts are single-shot workflow runners, not long-lived ComfyUI prompt servers
- they do not implement Web UI prompt/result caching across repeated service calls
- exported `main()` now performs best-effort ComfyUI model/cache cleanup in a `finally` block
- set `COMFYUI_TOPYTHON_UNLOAD_MODELS=1` or call `main(unload_models=True)` if an embedded or repeated-call host should aggressively unload models after each run instead of preserving them for reuse
## Troubleshooting
- unsupported Python version:
use Python 3.12 or newer in the environment that runs the extension, then reinstall the extension dependencies there
- Web UI import fails after `uv sync`:
`uv sync` in this repo installs dependencies into this repo's `.venv`, but ComfyUI loads custom nodes with its own Python environment. Install the extension into the Python interpreter that launches ComfyUI.
- Windows portable import fails after `uv sync`:
ComfyUI portable uses its bundled `python_embeded` interpreter. From the extension directory, run `..\..\..\python_embeded\python.exe -m pip install -e .`, then restart ComfyUI.
- CLI fails with `No module named 'torch'`:
the extension `.venv` may not have ComfyUI's runtime dependencies. Either run the CLI from the Python environment that launches ComfyUI, or make sure the target ComfyUI environment is installed and `COMFYUI_PATH` points to it.
- `Save As Script` not visible:
check your current ComfyUI menu/frontend version and look under `File`
- `Save As Script` not visible after restart:
make sure this repo is discoverable by ComfyUI through `custom_nodes` by cloning it into `ComfyUI/custom_nodes`, symlinking it there, or adding an external `custom_nodes` path in `extra_model_paths.yaml`
- Desktop says `prompt()` is unsupported:
use the CLI export flow instead
- save uses the default filename:
rename `workflow_api.py` after download if you want a different local filename
- ComfyUI cannot be found:
set `COMFYUI_PATH`
- models or paths are missing at runtime:
+4 -12
View File
@@ -13,18 +13,10 @@ sys.path.append(ext_dir)
try:
import black
except ImportError:
print("Unable to import requirements for ComfyUI-SaveAsScript.")
print("Installing...")
import importlib
spec = importlib.util.spec_from_file_location(
"impact_install", os.path.join(os.path.dirname(__file__), "install.py")
)
impact_install = importlib.util.module_from_spec(spec)
spec.loader.exec_module(impact_install)
print("Successfully installed. Hopefully, at least.")
raise ImportError(
"ComfyUI-to-Python-Extension requires the project dependencies to be installed. "
f"Run 'uv sync' in {ext_dir} with Python 3.12+ before loading this extension."
) from None
# Prevent reimporting of custom nodes
os.environ["RUNNING_IN_COMFYUI"] = "TRUE"
+1 -769
View File
@@ -1,773 +1,5 @@
import copy
import glob
import inspect
import json
import keyword
import os
import random
import sys
import re
from typing import Dict, List, Any, Callable, Tuple, TextIO
from argparse import ArgumentParser
import black
# Add current directory to Python path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from comfyui_to_python_utils import (
import_custom_nodes,
find_path,
get_comfyui_path,
add_comfyui_directory_to_sys_path,
add_extra_model_paths,
get_value_at_index,
)
DEFAULT_INPUT_FILE = "workflow_api.json"
DEFAULT_OUTPUT_FILE = "workflow_api.py"
DEFAULT_QUEUE_SIZE = 10
def get_node_class_mappings() -> Dict:
"""Load ComfyUI node mappings on demand.
Tests that inject explicit node mappings should not need a full ComfyUI runtime
just to import this module.
"""
add_comfyui_directory_to_sys_path()
from nodes import NODE_CLASS_MAPPINGS
return NODE_CLASS_MAPPINGS
class FileHandler:
"""Handles reading and writing files.
This class provides methods to read JSON data from an input file and write code to an output file.
"""
@staticmethod
def read_json_file(file_path: str | TextIO, encoding: str = "utf-8") -> dict:
"""
Reads a JSON file and returns its contents as a dictionary.
Args:
file_path (str): The path to the JSON file.
Returns:
dict: The contents of the JSON file as a dictionary.
Raises:
FileNotFoundError: If the file is not found, it lists all JSON files in the directory of the file path.
ValueError: If the file is not a valid JSON.
"""
if hasattr(file_path, "read"):
return json.load(file_path)
with open(file_path, "r", encoding="utf-8") as file:
data = json.load(file)
return data
@staticmethod
def write_code_to_file(file_path: str | TextIO, code: str) -> None:
"""Write the specified code to a Python file.
Args:
file_path (str): The path to the Python file.
code (str): The code to write to the file.
Returns:
None
"""
if isinstance(file_path, str):
# Extract directory from the filename
directory = os.path.dirname(file_path)
# If the directory does not exist, create it
if directory and not os.path.exists(directory):
os.makedirs(directory)
# Save the code to a .py file
with open(file_path, "w", encoding="utf-8") as file:
file.write(code)
else:
file_path.write(code)
class LoadOrderDeterminer:
"""Determine the load order of each key in the provided dictionary.
This class places the nodes without node dependencies first, then ensures that any node whose
result is used in another node will be added to the list in the order it should be executed.
Attributes:
data (Dict): The dictionary for which to determine the load order.
node_class_mappings (Dict): Mappings of node classes.
"""
def __init__(self, data: Dict, node_class_mappings: Dict):
"""Initialize the LoadOrderDeterminer with the given data and node class mappings.
Args:
data (Dict): The dictionary for which to determine the load order.
node_class_mappings (Dict): Mappings of node classes.
"""
self.data = data
self.node_class_mappings = node_class_mappings
self.visited = {}
self.load_order = []
self.is_special_function = False
def determine_load_order(self) -> List[Tuple[str, Dict, bool]]:
"""Determine the load order for the given data.
Returns:
List[Tuple[str, Dict, bool]]: A list of tuples representing the load order.
"""
self._load_special_functions_first()
self.is_special_function = False
for key in self.data:
if key not in self.visited:
self._dfs(key)
return self.load_order
def _dfs(self, key: str) -> None:
"""Depth-First Search function to determine the load order.
Args:
key (str): The key from which to start the DFS.
Returns:
None
"""
# Mark the node as visited.
self.visited[key] = True
inputs = self.data[key]["inputs"]
# Loop over each input key.
for input_key, val in inputs.items():
# If the value is a list and the first item in the list has not been visited yet,
# then recursively apply DFS on the dependency.
if isinstance(val, list) and val[0] not in self.visited:
self._dfs(val[0])
# Add the key and its corresponding data to the load order list.
self.load_order.append((key, self.data[key], self.is_special_function))
def _load_special_functions_first(self) -> None:
"""Load functions without dependencies, loaderes, and encoders first.
Returns:
None
"""
# Iterate over each key in the data to check for loader keys.
for key in self.data:
class_def = self.node_class_mappings[self.data[key]["class_type"]]()
# Check if the class is a loader class or meets specific conditions.
if (
class_def.CATEGORY == "loaders"
or class_def.FUNCTION in ["encode"]
or not any(
isinstance(val, list) for val in self.data[key]["inputs"].values()
)
):
self.is_special_function = True
# If the key has not been visited, perform a DFS from that key.
if key not in self.visited:
self._dfs(key)
class CodeGenerator:
"""Generates Python code for a workflow based on the load order.
Attributes:
node_class_mappings (Dict): Mappings of node classes.
base_node_class_mappings (Dict): Base mappings of node classes.
"""
def __init__(self, node_class_mappings: Dict, base_node_class_mappings: Dict):
"""Initialize the CodeGenerator with given node class mappings.
Args:
node_class_mappings (Dict): Mappings of node classes.
base_node_class_mappings (Dict): Base mappings of node classes.
"""
self.node_class_mappings = node_class_mappings
self.base_node_class_mappings = base_node_class_mappings
@staticmethod
def sanitize_node_id(node_id: str) -> str:
"""Convert node IDs into variable-safe tokens without collapsing separators."""
sanitized = re.sub(r"[^a-z0-9_]", "_", str(node_id).lower().strip())
sanitized = re.sub(r"_+", "_", sanitized).strip("_")
if not sanitized:
sanitized = "node"
return sanitized
def generate_workflow(
self,
load_order: List,
workflow_data: Dict,
metadata_workflow_data: Dict | None = None,
queue_size: int = 10,
) -> str:
"""Generate the execution code based on the load order.
Args:
load_order (List): A list of tuples representing the load order.
queue_size (int): The number of photos that will be created by the script.
Returns:
str: Generated execution code as a string.
"""
# Create the necessary data structures to hold imports and generated code
import_statements, executed_variables, special_functions_code, code = (
{"nodes": {"NODE_CLASS_MAPPINGS"}},
{},
[],
[],
)
# This dictionary will store the names of the objects that we have already initialized
initialized_objects = {}
custom_nodes = False
# Loop over each dictionary in the load order list
for idx, data, is_special_function in load_order:
# Generate class definition and inputs from the data
inputs, class_type = data["inputs"], data["class_type"]
input_types = self.node_class_mappings[class_type].INPUT_TYPES()
class_def = self.node_class_mappings[class_type]()
# If required inputs are not present, skip the node as it will break the code if passed through to the script
missing_required_variable = False
if "required" in input_types.keys():
for required in input_types["required"]:
if required not in inputs.keys():
missing_required_variable = True
if missing_required_variable:
continue
# If the class hasn't been initialized yet, initialize it and generate the import statements
if class_type not in initialized_objects:
# No need to use preview image nodes since we are executing the script in a terminal
if class_type == "PreviewImage":
continue
class_type, import_statement, class_code = self.get_class_info(
class_type
)
initialized_objects[class_type] = self.clean_variable_name(class_type)
if class_type in self.base_node_class_mappings.keys():
module_name, import_name = import_statement
import_statements.setdefault(module_name, set()).add(import_name)
if 'NODE_CLASS_MAPPINGS["' in class_code:
custom_nodes = True
special_functions_code.append(class_code)
# Get all possible parameters for class_def
class_def_params = self.get_function_parameters(
getattr(class_def, class_def.FUNCTION)
)
no_params = class_def_params is None
# Remove any keyword arguments from **inputs if they are not in class_def_params
inputs = {
key: value
for key, value in inputs.items()
if no_params or key in class_def_params
}
# Deal with hidden variables
hidden_inputs = input_types.get("hidden", {})
if (
"unique_id" in hidden_inputs
and (no_params or "unique_id" in class_def_params)
):
inputs["unique_id"] = random.randint(1, 2**64)
if "prompt" in hidden_inputs and (no_params or "prompt" in class_def_params):
inputs["prompt"] = {"variable_name": "prompt"}
if "extra_pnginfo" in hidden_inputs and (
no_params or "extra_pnginfo" in class_def_params
):
inputs["extra_pnginfo"] = {"variable_name": "extra_pnginfo"}
if "hidden" not in input_types and class_def_params is not None:
if "unique_id" in class_def_params:
inputs["unique_id"] = random.randint(1, 2**64)
# Create executed variable and generate code
executed_variables[idx] = (
f"{self.clean_variable_name(class_type)}_"
f"{self.sanitize_node_id(str(idx))}"
)
inputs = self.update_inputs(inputs, executed_variables)
seed_sync_code = self.create_prompt_seed_sync_code(
idx, inputs, is_special_function
)
if is_special_function:
if seed_sync_code:
special_functions_code.extend(seed_sync_code)
special_functions_code.append(
self.create_function_call_code(
initialized_objects[class_type],
class_def.FUNCTION,
executed_variables[idx],
is_special_function,
**inputs,
)
)
else:
if seed_sync_code:
code.extend(seed_sync_code)
code.append(
self.create_function_call_code(
initialized_objects[class_type],
class_def.FUNCTION,
executed_variables[idx],
is_special_function,
**inputs,
)
)
# Generate final code by combining imports and code, and wrap them in a main function
final_code = self.assemble_python_code(
import_statements,
special_functions_code,
code,
workflow_data,
metadata_workflow_data,
queue_size,
custom_nodes,
)
return final_code
def create_function_call_code(
self,
obj_name: str,
func: str,
variable_name: str,
is_special_function: bool,
**kwargs,
) -> str:
"""Generate Python code for a function call.
Args:
obj_name (str): The name of the initialized object.
func (str): The function to be called.
variable_name (str): The name of the variable that the function result should be assigned to.
is_special_function (bool): Determines the code indentation.
**kwargs: The keyword arguments for the function.
Returns:
str: The generated Python code.
"""
args = ", ".join(self.format_arg(key, value) for key, value in kwargs.items())
# Generate the Python code
code = f"{variable_name} = {obj_name}.{func}({args})\n"
# If the code contains dependencies and is not a loader or encoder, indent the code because it will be placed inside
# of a for loop
if not is_special_function:
code = f"\t{code}"
return code
def create_prompt_seed_sync_code(
self, node_id: str, inputs: Dict, is_special_function: bool
) -> List[str]:
"""Generate code that keeps prompt metadata aligned with randomized seeds."""
seed_sync_lines = []
for key in ("seed", "noise_seed"):
if key not in inputs:
continue
randomized_seed_variable = (
f"node_{self.sanitize_node_id(str(node_id))}_{self.clean_variable_name(key)}"
)
seed_sync_lines.append(
f'{randomized_seed_variable} = prompt["{node_id}"]["inputs"]["{key}"] = random.randint(1, 2**64)'
)
inputs[key] = {"variable_name": randomized_seed_variable}
if not seed_sync_lines:
return []
indentation = "" if is_special_function else "\t"
return [f"{indentation}{line}\n" for line in seed_sync_lines]
def format_arg(self, key: str, value: any) -> str:
"""Formats arguments based on key and value.
Args:
key (str): Argument key.
value (any): Argument value.
Returns:
str: Formatted argument as a string.
"""
value_code = self.format_arg_value(key, value)
if key.isidentifier() and not keyword.iskeyword(key):
return f"{key}={value_code}"
return f"**{{{json.dumps(key)}: {value_code}}}"
@staticmethod
def format_arg_value(key: str, value: any) -> str:
"""Formats an argument value as Python source."""
if isinstance(value, dict) and "variable_name" in value:
return value["variable_name"]
if key == "noise_seed" or key == "seed":
return "random.randint(1, 2**64)"
if isinstance(value, str):
return json.dumps(value)
return repr(value)
def assemble_python_code(
self,
import_statements: Dict[str, set],
speical_functions_code: List[str],
code: List[str],
workflow_data: Dict,
metadata_workflow_data: Dict | None,
queue_size: int,
custom_nodes=False,
) -> str:
"""Generates the final code string.
Args:
import_statements (Dict[str, set]): Import statements grouped by module.
speical_functions_code (List[str]): A list of special functions code strings.
code (List[str]): A list of code strings.
workflow_data (Dict): The API workflow data used for runtime prompt execution.
metadata_workflow_data (Dict | None): The workflow metadata to embed into saved outputs.
queue_size (int): Number of photos that will be generated by the script.
custom_nodes (bool): Whether to include custom nodes in the code.
Returns:
str: Generated final code as a string.
"""
if metadata_workflow_data is None:
extra_pnginfo_code = "extra_pnginfo = None"
else:
extra_pnginfo_code = (
"extra_pnginfo = "
'{"workflow": json.loads('
+ json.dumps(json.dumps(metadata_workflow_data))
+ ")}"
)
# Get the source code of the utils functions as a string
func_strings = []
for func in [
get_value_at_index,
get_comfyui_path,
find_path,
add_comfyui_directory_to_sys_path,
add_extra_model_paths,
]:
func_strings.append(f"\n{inspect.getsource(func)}")
# Define static import statements required for the script
static_imports = (
[
"import json",
"import os",
"import random",
"import sys",
"from typing import Sequence, Mapping, Any, Union",
"import torch",
]
+ func_strings
+ [
"\n\nadd_comfyui_directory_to_sys_path()\nadd_extra_model_paths()\n",
f"workflow = json.loads({json.dumps(json.dumps(workflow_data))})",
"prompt = json.loads(json.dumps(workflow))",
extra_pnginfo_code,
]
)
# Check if custom nodes should be included
if custom_nodes:
static_imports.append(f"\n{inspect.getsource(import_custom_nodes)}\n")
custom_nodes = "import_custom_nodes()\n\t"
else:
custom_nodes = ""
# Create import statements for node classes
imports_code = []
for module_name in sorted(import_statements.keys()):
class_names = ", ".join(sorted(import_statements[module_name]))
imports_code.append(f"from {module_name} import {class_names}")
special_functions_body = "\n\t\t".join(speical_functions_code) or "pass"
loop_body = "\n\t\t".join(code) or "\tpass"
# Assemble the main function code, including custom nodes if applicable
main_function_code = (
"def main():\n\t"
+ f"{custom_nodes}with torch.inference_mode():\n\t\t"
+ special_functions_body
+ f"\n\n\t\tfor q in range({queue_size}):\n\t\t"
+ loop_body
)
# Concatenate all parts to form the final code
final_code = "\n".join(
static_imports
+ imports_code
+ ["", main_function_code, "", 'if __name__ == "__main__":', "\tmain()"]
)
# Format the final code according to PEP 8 using the Black library
final_code = black.format_str(final_code, mode=black.Mode())
return final_code
def get_class_info(self, class_type: str) -> Tuple[str, Tuple[str, str], str]:
"""Generates and returns necessary information about class type.
Args:
class_type (str): Class type.
Returns:
Tuple[str, Tuple[str, str], str]: Updated class type, import statement and initialization code.
"""
class_obj = self.base_node_class_mappings.get(class_type)
module_name = "nodes"
if class_obj is not None:
module_name = class_obj.__module__
variable_name = self.clean_variable_name(class_type)
is_importable_module = bool(
module_name
and "/" not in module_name
and "\\" not in module_name
and all(part.isidentifier() for part in module_name.split("."))
)
if class_type in self.base_node_class_mappings.keys() and is_importable_module:
import_statement = (module_name, class_type)
class_code = f"{variable_name} = {class_type.strip()}()"
else:
import_statement = ("nodes", "NODE_CLASS_MAPPINGS")
class_code = f'{variable_name} = NODE_CLASS_MAPPINGS["{class_type}"]()'
return class_type, import_statement, class_code
@staticmethod
def clean_variable_name(class_type: str) -> str:
"""
Remove any characters from variable name that could cause errors running the Python script.
Args:
class_type (str): Class type.
Returns:
str: Cleaned variable name with no special characters or spaces
"""
# Convert to lowercase and replace spaces with underscores
clean_name = class_type.lower().strip().replace("-", "_").replace(" ", "_")
# Remove characters that are not letters, numbers, or underscores
clean_name = re.sub(r"[^a-z0-9_]", "", clean_name)
# Ensure that it doesn't start with a number
if clean_name[0].isdigit():
clean_name = "_" + clean_name
return clean_name
def get_function_parameters(self, func: Callable) -> List:
"""Get the names of a function's parameters.
Args:
func (Callable): The function whose parameters we want to inspect.
Returns:
List: A list containing the names of the function's parameters.
"""
signature = inspect.signature(func)
parameters = {
name: param.default if param.default != param.empty else None
for name, param in signature.parameters.items()
}
catch_all = any(
param.kind == inspect.Parameter.VAR_KEYWORD
for param in signature.parameters.values()
)
return list(parameters.keys()) if not catch_all else None
def update_inputs(self, inputs: Dict, executed_variables: Dict) -> Dict:
"""Update inputs based on the executed variables.
Args:
inputs (Dict): Inputs dictionary to update.
executed_variables (Dict): Dictionary storing executed variable names.
Returns:
Dict: Updated inputs dictionary.
"""
for key in inputs.keys():
if (
isinstance(inputs[key], list)
and inputs[key][0] in executed_variables.keys()
):
inputs[key] = {
"variable_name": f"get_value_at_index({executed_variables[inputs[key][0]]}, {inputs[key][1]})"
}
return inputs
class ComfyUItoPython:
"""Main workflow to generate Python code from a workflow_api.json file.
Attributes:
input_file (str): Path to the input JSON file.
output_file (str): Path to the output Python file.
queue_size (int): The number of photos that will be created by the script.
node_class_mappings (Dict): Mappings of node classes.
base_node_class_mappings (Dict): Base mappings of node classes.
"""
def __init__(
self,
workflow: str = "",
frontend_workflow: str | Dict | None = None,
input_file: str = "",
output_file: str | TextIO = "",
queue_size: int = 1,
node_class_mappings: Dict | None = None,
needs_init_custom_nodes: bool = False,
):
"""Initialize the ComfyUItoPython class with the given parameters. Exactly one of workflow or input_file must be specified.
Args:
workflow (str): The workflow's JSON.
input_file (str): Path to the input JSON file.
output_file (str | TextIO): Path to the output file or a file-like object.
queue_size (int): The number of times a workflow will be executed by the script. Defaults to 1.
node_class_mappings (Dict | None): Mappings of node classes. Defaults to the current
ComfyUI NODE_CLASS_MAPPINGS when not provided.
needs_init_custom_nodes (bool): Whether to initialize custom nodes. Defaults to False.
"""
if input_file and workflow:
raise ValueError("Can't provide both input_file and workflow")
elif not input_file and not workflow:
raise ValueError("Needs input_file or workflow")
if not output_file:
raise ValueError("Needs output_file")
self.workflow = workflow
self.frontend_workflow = frontend_workflow
self.input_file = input_file
self.output_file = output_file
self.queue_size = queue_size
self.node_class_mappings = (
node_class_mappings
if node_class_mappings is not None
else get_node_class_mappings()
)
self.needs_init_custom_nodes = needs_init_custom_nodes
self.base_node_class_mappings = copy.deepcopy(self.node_class_mappings)
self.execute()
def execute(self):
"""Execute the main workflow to generate Python code.
Returns:
None
"""
# Step 1: Read JSON data from the input file
if self.input_file:
data = FileHandler.read_json_file(self.input_file)
else:
data = json.loads(self.workflow)
metadata_workflow_data = None
if self.frontend_workflow:
if isinstance(self.frontend_workflow, str):
metadata_workflow_data = json.loads(self.frontend_workflow)
else:
metadata_workflow_data = self.frontend_workflow
# Step 2: Initialize extra/custom nodes when requested or when the workflow references
# a node class that is not currently loaded in the runtime.
missing_node_types = {
node_data["class_type"]
for node_data in data.values()
if node_data["class_type"] not in self.node_class_mappings
}
if self.needs_init_custom_nodes or missing_node_types:
import_custom_nodes()
self.base_node_class_mappings = copy.deepcopy(self.node_class_mappings)
# Step 3: Determine the load order
load_order_determiner = LoadOrderDeterminer(data, self.node_class_mappings)
load_order = load_order_determiner.determine_load_order()
# Step 4: Generate the workflow code
code_generator = CodeGenerator(
self.node_class_mappings, self.base_node_class_mappings
)
generated_code = code_generator.generate_workflow(
load_order,
data,
metadata_workflow_data,
queue_size=self.queue_size,
)
# Step 5: Write the generated code to a file
FileHandler.write_code_to_file(self.output_file, generated_code)
print(f"Code successfully generated and written to {self.output_file}")
def run(
input_file: str = DEFAULT_INPUT_FILE,
output_file: str = DEFAULT_OUTPUT_FILE,
queue_size: int = DEFAULT_QUEUE_SIZE,
) -> None:
"""Generate Python code from a ComfyUI workflow_api.json file.
Args:
input_file (str): Path to the input JSON file. Defaults to "workflow_api.json".
output_file (str): Path to the output Python file.
Defaults to "workflow_api.py".
queue_size (int): The number of times a workflow will be executed by the script.
Defaults to 1.
Returns:
None
"""
ComfyUItoPython(
input_file=input_file,
output_file=output_file,
queue_size=queue_size,
needs_init_custom_nodes=True,
)
def main() -> None:
"""Main function to generate Python code from a ComfyUI workflow_api.json file."""
parser = ArgumentParser(
description="Generate Python code from a ComfyUI workflow_api.json file."
)
parser.add_argument(
"-f",
"--input_file",
type=str,
help="path to the input JSON file",
default=DEFAULT_INPUT_FILE,
)
parser.add_argument(
"-o",
"--output_file",
type=str,
help="path to the output Python file",
default=DEFAULT_OUTPUT_FILE,
)
parser.add_argument(
"-q",
"--queue_size",
type=int,
help="number of times the workflow will be executed by default",
default=DEFAULT_QUEUE_SIZE,
)
pargs = parser.parse_args()
run(**vars(pargs))
print("Done.")
from comfyui_to_python.cli import main
if __name__ == "__main__":
"""Run the main function."""
main()
+55
View File
@@ -0,0 +1,55 @@
from typing import TextIO
from .app import ExportApplication
from .cli import DEFAULT_INPUT_FILE, DEFAULT_OUTPUT_FILE, DEFAULT_QUEUE_SIZE, main
from .node_runtime import get_node_class_mappings, import_custom_nodes
class ComfyUItoPython:
"""Public compatibility facade for the exporter package."""
def __init__(
self,
workflow: str = "",
frontend_workflow: str | dict | None = None,
input_file: str = "",
output_file: str | TextIO = "",
queue_size: int = 1,
node_class_mappings: dict | None = None,
needs_init_custom_nodes: bool = False,
):
self._app = ExportApplication(
workflow=workflow,
frontend_workflow=frontend_workflow,
input_file=input_file,
output_file=output_file,
queue_size=queue_size,
node_class_mappings=node_class_mappings,
needs_init_custom_nodes=needs_init_custom_nodes,
node_mapping_loader=get_node_class_mappings,
custom_node_importer=import_custom_nodes,
)
self._app.execute()
def run(
input_file: str = DEFAULT_INPUT_FILE,
output_file: str = DEFAULT_OUTPUT_FILE,
queue_size: int = DEFAULT_QUEUE_SIZE,
) -> None:
"""Generate Python code from a ComfyUI workflow_api.json file."""
ComfyUItoPython(
input_file=input_file,
output_file=output_file,
queue_size=queue_size,
needs_init_custom_nodes=True,
)
__all__ = [
"ComfyUItoPython",
"run",
"main",
"get_node_class_mappings",
"import_custom_nodes",
]
+5
View File
@@ -0,0 +1,5 @@
from .cli import main
if __name__ == "__main__":
main()
+85
View File
@@ -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}")
+42
View File
@@ -0,0 +1,42 @@
from argparse import ArgumentParser
DEFAULT_INPUT_FILE = "workflow_api.json"
DEFAULT_OUTPUT_FILE = "workflow_api.py"
DEFAULT_QUEUE_SIZE = 10
def build_argument_parser() -> ArgumentParser:
parser = ArgumentParser(
description="Generate Python code from a ComfyUI workflow_api.json file."
)
parser.add_argument(
"-f",
"--input_file",
type=str,
help="path to the input JSON file",
default=DEFAULT_INPUT_FILE,
)
parser.add_argument(
"-o",
"--output_file",
type=str,
help="path to the output Python file",
default=DEFAULT_OUTPUT_FILE,
)
parser.add_argument(
"-q",
"--queue_size",
type=int,
help="number of times the workflow will be executed by default",
default=DEFAULT_QUEUE_SIZE,
)
return parser
def main() -> None:
from . import run
parser = build_argument_parser()
pargs = parser.parse_args()
run(**vars(pargs))
print("Done.")
+4
View File
@@ -0,0 +1,4 @@
from .planner import WorkflowPlanner
from .render import WorkflowRenderer
__all__ = ["WorkflowPlanner", "WorkflowRenderer"]
@@ -0,0 +1,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",
]
+12
View File
@@ -0,0 +1,12 @@
from dataclasses import dataclass
@dataclass(frozen=True)
class GenerationPlan:
import_statements: dict[str, set[str]]
special_functions_code: list[str]
loop_code: list[str]
workflow_data: dict
metadata_workflow_data: dict | None
queue_size: int
custom_nodes: bool
+261
View File
@@ -0,0 +1,261 @@
import inspect
import json
import keyword
import random
import re
from typing import Any, Callable
from .model import GenerationPlan
class WorkflowPlanner:
"""Convert ordered workflow nodes into a renderer-ready generation plan."""
def __init__(self, node_class_mappings: dict, base_node_class_mappings: dict):
self.node_class_mappings = node_class_mappings
self.base_node_class_mappings = base_node_class_mappings
@staticmethod
def sanitize_node_id(node_id: str) -> str:
sanitized = re.sub(r"[^a-z0-9_]", "_", str(node_id).lower().strip())
sanitized = re.sub(r"_+", "_", sanitized).strip("_")
if not sanitized:
sanitized = "node"
return sanitized
@staticmethod
def clean_variable_name(class_type: str) -> str:
clean_name = class_type.lower().strip().replace("-", "_").replace(" ", "_")
clean_name = re.sub(r"[^a-z0-9_]", "", clean_name)
if clean_name[0].isdigit():
clean_name = "_" + clean_name
return clean_name
def build_plan(
self,
load_order: list[tuple[str, dict, bool]],
workflow_data: dict,
metadata_workflow_data: dict | None = None,
queue_size: int = 10,
) -> GenerationPlan:
import_statements = {"nodes": {"NODE_CLASS_MAPPINGS"}}
executed_variables = {}
special_functions_code = []
code = []
initialized_objects = {}
custom_nodes = False
for idx, data, is_special_function in load_order:
inputs, class_type = data["inputs"], data["class_type"]
input_types = self.node_class_mappings[class_type].INPUT_TYPES()
input_value_types = self.get_input_value_types(input_types)
class_def = self.node_class_mappings[class_type]()
missing_required_variable = False
if "required" in input_types.keys():
for required in input_types["required"]:
if required not in inputs.keys():
missing_required_variable = True
if missing_required_variable:
continue
if class_type not in initialized_objects:
if class_type == "PreviewImage":
continue
class_type, import_statement, class_code = self.get_class_info(
class_type
)
initialized_objects[class_type] = self.clean_variable_name(class_type)
if class_type in self.base_node_class_mappings.keys():
module_name, import_name = import_statement
import_statements.setdefault(module_name, set()).add(import_name)
if 'NODE_CLASS_MAPPINGS["' in class_code:
custom_nodes = True
special_functions_code.append(class_code)
class_def_params = self.get_function_parameters(
getattr(class_def, class_def.FUNCTION)
)
no_params = class_def_params is None
inputs = {
key: value
for key, value in inputs.items()
if no_params or key in class_def_params
}
hidden_inputs = input_types.get("hidden", {})
if (
"unique_id" in hidden_inputs
and (no_params or "unique_id" in class_def_params)
):
inputs["unique_id"] = random.randint(1, 2**64)
if "prompt" in hidden_inputs and (no_params or "prompt" in class_def_params):
inputs["prompt"] = {"variable_name": "prompt"}
if "extra_pnginfo" in hidden_inputs and (
no_params or "extra_pnginfo" in class_def_params
):
inputs["extra_pnginfo"] = {"variable_name": "extra_pnginfo"}
if "hidden" not in input_types and class_def_params is not None:
if "unique_id" in class_def_params:
inputs["unique_id"] = random.randint(1, 2**64)
executed_variables[idx] = (
f"{self.clean_variable_name(class_type)}_"
f"{self.sanitize_node_id(str(idx))}"
)
inputs = self.update_inputs(inputs, executed_variables)
seed_sync_code = self.create_prompt_seed_sync_code(
idx, inputs, input_value_types, is_special_function
)
target_lines = special_functions_code if is_special_function else code
if seed_sync_code:
target_lines.extend(seed_sync_code)
target_lines.append(
self.create_function_call_code(
initialized_objects[class_type],
class_def.FUNCTION,
executed_variables[idx],
is_special_function,
input_value_types=input_value_types,
**inputs,
)
)
return GenerationPlan(
import_statements=import_statements,
special_functions_code=special_functions_code,
loop_code=code,
workflow_data=workflow_data,
metadata_workflow_data=metadata_workflow_data,
queue_size=queue_size,
custom_nodes=custom_nodes,
)
def create_function_call_code(
self,
obj_name: str,
func: str,
variable_name: str,
is_special_function: bool,
input_value_types: dict[str, str] | None = None,
**kwargs,
) -> str:
args = ", ".join(
self.format_arg(key, value, (input_value_types or {}).get(key))
for key, value in kwargs.items()
)
code = f"{variable_name} = {obj_name}.{func}({args})\n"
if not is_special_function:
code = f"\t{code}"
return code
def create_prompt_seed_sync_code(
self,
node_id: str,
inputs: dict,
input_value_types: dict[str, str],
is_special_function: bool,
) -> list[str]:
seed_sync_lines = []
for key in ("seed", "noise_seed"):
if key not in inputs:
continue
randomized_seed_variable = (
f"node_{self.sanitize_node_id(str(node_id))}_{self.clean_variable_name(key)}"
)
randomized_seed_code = self.get_randomized_seed_code(
input_value_types.get(key)
)
seed_sync_lines.append(
f'{randomized_seed_variable} = prompt["{node_id}"]["inputs"]["{key}"] = {randomized_seed_code}'
)
inputs[key] = {"variable_name": randomized_seed_variable}
if not seed_sync_lines:
return []
indentation = "" if is_special_function else "\t"
return [f"{indentation}{line}\n" for line in seed_sync_lines]
def format_arg(self, key: str, value: Any, input_value_type: str | None = None) -> str:
value_code = self.format_arg_value(key, value, input_value_type)
if key.isidentifier() and not keyword.iskeyword(key):
return f"{key}={value_code}"
return f"**{{{json.dumps(key)}: {value_code}}}"
@staticmethod
def format_arg_value(
key: str, value: Any, input_value_type: str | None = None
) -> str:
if isinstance(value, dict) and "variable_name" in value:
return value["variable_name"]
if key == "noise_seed" or key == "seed":
return WorkflowPlanner.get_randomized_seed_code(input_value_type)
if isinstance(value, str):
return json.dumps(value)
return repr(value)
@staticmethod
def get_input_value_types(input_types: dict) -> dict[str, str]:
value_types = {}
for section in ("required", "optional", "hidden"):
for key, value in input_types.get(section, {}).items():
if isinstance(value, tuple) and value:
value_types[key] = value[0]
elif isinstance(value, str):
value_types[key] = value
return value_types
@staticmethod
def get_randomized_seed_code(input_value_type: str | None) -> str:
randomized_seed_code = "random.randint(1, 2**64)"
if input_value_type == "STRING":
return f"str({randomized_seed_code})"
return randomized_seed_code
def get_class_info(self, class_type: str) -> tuple[str, tuple[str, str], str]:
class_obj = self.base_node_class_mappings.get(class_type)
module_name = "nodes"
if class_obj is not None:
module_name = class_obj.__module__
variable_name = self.clean_variable_name(class_type)
is_importable_module = bool(
module_name
and "/" not in module_name
and "\\" not in module_name
and all(part.isidentifier() for part in module_name.split("."))
)
if class_type in self.base_node_class_mappings.keys() and is_importable_module:
import_statement = (module_name, class_type)
class_code = f"{variable_name} = {class_type.strip()}()"
else:
import_statement = ("nodes", "NODE_CLASS_MAPPINGS")
class_code = f'{variable_name} = NODE_CLASS_MAPPINGS["{class_type}"]()'
return class_type, import_statement, class_code
@staticmethod
def get_function_parameters(func: Callable) -> list | None:
signature = inspect.signature(func)
parameters = {
name: param.default if param.default != param.empty else None
for name, param in signature.parameters.items()
}
catch_all = any(
param.kind == inspect.Parameter.VAR_KEYWORD
for param in signature.parameters.values()
)
return list(parameters.keys()) if not catch_all else None
def update_inputs(self, inputs: dict, executed_variables: dict) -> dict:
for key in inputs.keys():
if (
isinstance(inputs[key], list)
and inputs[key][0] in executed_variables.keys()
):
inputs[key] = {
"variable_name": f"get_value_at_index({executed_variables[inputs[key][0]]}, {inputs[key][1]})"
}
return inputs
+147
View File
@@ -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()
+23
View File
@@ -0,0 +1,23 @@
import json
import os
from typing import TextIO
def load_json_input(file_path: str | TextIO, encoding: str = "utf-8") -> dict:
"""Read workflow JSON from a file path or file-like object."""
if hasattr(file_path, "read"):
return json.load(file_path)
with open(file_path, "r", encoding=encoding) as file:
return json.load(file)
def write_python_output(file_path: str | TextIO, code: str) -> None:
"""Write generated Python to a file path or file-like object."""
if isinstance(file_path, str):
directory = os.path.dirname(file_path)
if directory and not os.path.exists(directory):
os.makedirs(directory)
with open(file_path, "w", encoding="utf-8") as file:
file.write(code)
return
file_path.write(code)
+43
View File
@@ -0,0 +1,43 @@
from typing import Dict
class LoadOrderDeterminer:
"""Determine workflow execution order with loader-like nodes prioritized."""
def __init__(self, data: Dict, node_class_mappings: Dict):
self.data = data
self.node_class_mappings = node_class_mappings
self.visited = {}
self.load_order = []
self.is_special_function = False
def determine_load_order(self) -> list[tuple[str, Dict, bool]]:
self._load_special_functions_first()
self.is_special_function = False
for key in self.data:
if key not in self.visited:
self._dfs(key)
return self.load_order
def _dfs(self, key: str) -> None:
self.visited[key] = True
inputs = self.data[key]["inputs"]
for value in inputs.values():
if isinstance(value, list) and value[0] not in self.visited:
self._dfs(value[0])
self.load_order.append((key, self.data[key], self.is_special_function))
def _load_special_functions_first(self) -> None:
for key in self.data:
class_def = self.node_class_mappings[self.data[key]["class_type"]]()
if (
class_def.CATEGORY == "loaders"
or class_def.FUNCTION in ["encode"]
or not any(
isinstance(value, list)
for value in self.data[key]["inputs"].values()
)
):
self.is_special_function = True
if key not in self.visited:
self._dfs(key)
+381
View File
@@ -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]
+19
View File
@@ -0,0 +1,19 @@
import json
from .io import load_json_input
def load_workflow_data(workflow: str, input_file: str):
"""Load workflow data from inline JSON or an input file."""
if input_file:
return load_json_input(input_file)
return json.loads(workflow)
def load_frontend_workflow_data(frontend_workflow: str | dict | None):
"""Load optional frontend workflow metadata."""
if not frontend_workflow:
return None
if isinstance(frontend_workflow, str):
return json.loads(frontend_workflow)
return frontend_workflow
+18 -122
View File
@@ -1,123 +1,19 @@
import os
from typing import Sequence, Mapping, Any, Union
import sys
from comfyui_to_python.node_runtime import (
add_comfyui_directory_to_sys_path,
add_extra_model_paths,
bootstrap_comfyui_runtime,
find_path,
get_comfyui_path,
get_value_at_index,
import_custom_nodes,
)
def get_comfyui_path() -> str:
"""Return the configured ComfyUI path, preferring COMFYUI_PATH when set."""
comfyui_path = os.environ.get("COMFYUI_PATH")
if comfyui_path:
return comfyui_path
return find_path("ComfyUI")
def import_custom_nodes() -> None:
"""Find all custom nodes in the custom_nodes folder and add those node objects to NODE_CLASS_MAPPINGS
This function sets up a new asyncio event loop, initializes the PromptServer,
creates a PromptQueue, and initializes the custom nodes.
"""
comfyui_path = get_comfyui_path()
if comfyui_path and comfyui_path not in sys.path:
sys.path.insert(0, comfyui_path)
import asyncio
import execution
from nodes import init_extra_nodes
if comfyui_path in sys.path:
sys.path.remove(comfyui_path)
sys.path.insert(0, comfyui_path)
import server
# Creating a new event loop and setting it as the default loop
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Creating an instance of PromptServer with the loop
server_instance = server.PromptServer(loop)
execution.PromptQueue(server_instance)
# Initializing custom nodes
asyncio.run(init_extra_nodes())
def find_path(name: str, path: str = None) -> str:
"""
Recursively looks at parent folders starting from the given path until it finds the given name.
Returns the path as a Path object if found, or None otherwise.
"""
# If no path is given, use the current working directory
if path is None:
path = os.getcwd()
# Check if the current directory contains the name
if name in os.listdir(path):
path_name = os.path.join(path, name)
print(f"{name} found: {path_name}")
return path_name
# Get the parent directory
parent_directory = os.path.dirname(path)
# If the parent directory is the same as the current directory, we've reached the root and stop the search
if parent_directory == path:
return None
# Recursively call the function with the parent directory
return find_path(name, parent_directory)
def add_comfyui_directory_to_sys_path() -> None:
"""
Add 'ComfyUI' to the sys.path
"""
comfyui_path = get_comfyui_path()
if comfyui_path is not None and os.path.isdir(comfyui_path):
if comfyui_path in sys.path:
sys.path.remove(comfyui_path)
sys.path.insert(0, comfyui_path)
print(f"'{comfyui_path}' added to sys.path")
def add_extra_model_paths() -> None:
"""
Parse the optional extra_model_paths.yaml file and add the parsed paths to the sys.path.
"""
try:
from main import load_extra_path_config
except ImportError:
print(
"Could not import load_extra_path_config from main.py. Looking in utils.extra_config instead."
)
from utils.extra_config import load_extra_path_config
extra_model_paths = find_path("extra_model_paths.yaml")
if extra_model_paths is not None:
load_extra_path_config(extra_model_paths)
else:
print("Could not find the extra_model_paths config file.")
def get_value_at_index(obj: Union[Sequence, Mapping], index: int) -> Any:
"""Returns the value at the given index of a sequence or mapping.
If the object is a sequence (like list or string), returns the value at the given index.
If the object is a mapping (like a dictionary), returns the value at the index-th key.
Some return a dictionary, in these cases, we look for the "results" key
Args:
obj (Union[Sequence, Mapping]): The object to retrieve the value from.
index (int): The index of the value to retrieve.
Returns:
Any: The value at the given index.
Raises:
IndexError: If the index is out of bounds for the object and the object is not a mapping.
"""
try:
return obj[index]
except KeyError:
return obj["result"][index]
__all__ = [
"add_comfyui_directory_to_sys_path",
"add_extra_model_paths",
"bootstrap_comfyui_runtime",
"find_path",
"get_comfyui_path",
"get_value_at_index",
"import_custom_nodes",
]
+23 -11
View File
@@ -1,6 +1,25 @@
import { api } from "../../scripts/api.js";
import { app } from "../../scripts/app.js";
import { $el } from "../../scripts/ui.js";
const DEFAULT_SCRIPT_FILENAME = "workflow_api.py";
const DEFAULT_WORKFLOW_NAME = "workflow_api.json";
function $el(tag, options = {}) {
const element = document.createElement(tag);
const { parent, style, ...props } = options;
if (style) {
Object.assign(element.style, style);
}
Object.assign(element, props);
if (parent) {
parent.appendChild(element);
}
return element;
}
const extension = {
name: "Comfy.SaveAsScript",
@@ -23,15 +42,12 @@ const extension = {
});
},
savePythonScript() {
var filename = prompt("Save script as:");
if(filename === undefined || filename === null || filename === "") {
return
}
const filename = DEFAULT_SCRIPT_FILENAME;
app.graphToPrompt().then(async (p) => {
const frontendWorkflow = p.workflow ?? app.graph.serialize();
const json = JSON.stringify({
name: filename + ".json",
name: DEFAULT_WORKFLOW_NAME,
workflow: JSON.stringify(p.output, null, 2),
frontend_workflow: JSON.stringify(frontendWorkflow, null, 2),
}, null, 2); // convert the data to a JSON string
@@ -39,10 +55,6 @@ const extension = {
if(response.status == 200) {
const blob = new Blob([await response.text()], {type: "text/python;charset=utf-8"});
const url = URL.createObjectURL(blob);
if(!filename.endsWith(".py")) {
filename += ".py";
}
const a = $el("a", {
href: url,
download: filename,
+9 -2
View File
@@ -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
+15 -1
View File
@@ -5,6 +5,8 @@ This directory contains two test paths:
- unit tests for exporter behavior
- runtime validation for committed workflow fixtures
Use the lightweight path for routine contributor validation, and use the runtime path when you need end-to-end confidence against a real ComfyUI checkout.
## Prerequisites
- Run commands from the repo root.
@@ -30,12 +32,18 @@ The current runtime-capable fixtures require these model files in the target Com
## Unit Tests
Run the current unit test module:
Run the exporter-focused unit test module:
```bash
uv run python -m unittest tests.test_upscale_model_loader_export
```
Run the runtime-harness unit test module:
```bash
uv run python -m unittest tests.test_runtime_validation_harness
```
Run all `unittest`-discoverable tests under `tests`:
```bash
@@ -49,6 +57,7 @@ The runtime harness lives at `tests/runtime/run_runtime_validation.py`.
### Fast Tier
Fast tier validates export behavior against committed fixtures without requiring a full ComfyUI runtime for every fixture.
This is the recommended default validation lane for routine changes.
Run all fast-tier compatible fixtures:
@@ -65,6 +74,7 @@ uv run python tests/runtime/run_runtime_validation.py --tier fast --fixture unsa
### Runtime Tier
Runtime tier exports inside a real ComfyUI checkout and executes generated Python for runtime-capable fixtures.
This is the heavier validation lane for changes that need end-to-end runtime confidence.
Run all runtime-capable fixtures:
@@ -92,11 +102,15 @@ Current committed fixtures:
- `text-to-image`
- `unsafe-kwargs`
- `subgraph-identifiers`
- `reused-node-class-branches`
- `secondary-output-selection`
Notes:
- `--tier runtime` only runs fixtures marked runtime-capable.
- `--tier fast` only runs fixtures with local test mappings.
- `reused-node-class-branches` protects repeated node-class usage and branch wiring in the fast tier.
- `secondary-output-selection` protects non-zero output index wiring in the fast tier.
## Troubleshooting
+27
View File
@@ -0,0 +1,27 @@
{
"1:10": {
"class_type": "PassthroughText",
"inputs": {
"text": "left branch"
}
},
"1:20": {
"class_type": "PassthroughText",
"inputs": {
"text": "right branch"
}
},
"2": {
"class_type": "JoinText",
"inputs": {
"left": [
"1:10",
0
],
"right": [
"1:20",
0
]
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"1": {
"class_type": "SplitText",
"inputs": {
"text": "alpha|omega"
}
},
"2": {
"class_type": "PassthroughText",
"inputs": {
"text": [
"1",
1
]
}
}
}
@@ -0,0 +1,8 @@
{
"1": {
"class_type": "StringSeedNode",
"inputs": {
"seed": "seed-placeholder"
}
}
}
@@ -0,0 +1,27 @@
{
"33": {
"class_type": "VaeDecode",
"inputs": {
"samples": "latent-placeholder"
}
},
"42:0": {
"class_type": "UpscaleModelLoader",
"inputs": {
"model_name": "4x-ultrasharp.safetensors"
}
},
"42:1": {
"class_type": "ImageUpscaleWithModel",
"inputs": {
"upscale_model": [
"42:0",
0
],
"image": [
"33",
0
]
}
}
}
@@ -0,0 +1,10 @@
{
"1": {
"class_type": "TextConcatenateNode",
"inputs": {
"delimiter": "",
"clean_whitespace": "true",
"text_b": "\\"
}
}
}
@@ -0,0 +1,41 @@
{
"11": {
"class_type": "DualClipLoader",
"inputs": {
"clip_name": "clip.safetensors"
}
},
"633": {
"class_type": "AnySwitchRgthree",
"inputs": {
"model": "model-placeholder"
}
},
"631": {
"class_type": "PowerLoraLoaderRgthree",
"inputs": {
"PowerLoraLoaderHeaderWidget": {
"type": "PowerLoraLoaderHeaderWidget"
},
"lora_1": {
"on": false,
"lora": "lora.safetensors",
"strength": 1.2
},
"lora_2": {
"on": false,
"lora": "lora2.safetensors",
"strength": 0.7
},
"\u2795 Add Lora": "",
"model": [
"633",
0
],
"clip": [
"11",
0
]
}
}
}
@@ -0,0 +1,8 @@
{
"1": {
"class_type": "WindowsPathNode",
"inputs": {
"path": "C:\\ComfyUI\\models\\upscale_models\\RealESRGAN_x4plus.safetensors"
}
}
}
+399
View File
@@ -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()
+50
View File
@@ -109,6 +109,40 @@ class PassthroughText:
return (text,)
class SplitText:
CATEGORY = "utils"
FUNCTION = "split"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING",),
}
}
def split(self, text):
left, right = text.split("|", 1)
return (left, right)
class JoinText:
CATEGORY = "utils"
FUNCTION = "join"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"left": ("STRING",),
"right": ("STRING",),
}
}
def join(self, left, right):
return (f"{left}::{right}",)
class UpscaleModelLoader:
CATEGORY = "loaders"
FUNCTION = "load_model"
@@ -234,6 +268,22 @@ FIXTURES = {
"PassthroughText": PassthroughText,
},
),
"reused-node-class-branches": FixtureConfig(
name="reused-node-class-branches",
path=FIXTURE_DIR / "reused-node-class-branches.json",
mapping_factory=lambda: {
"PassthroughText": PassthroughText,
"JoinText": JoinText,
},
),
"secondary-output-selection": FixtureConfig(
name="secondary-output-selection",
path=FIXTURE_DIR / "secondary-output-selection.json",
mapping_factory=lambda: {
"SplitText": SplitText,
"PassthroughText": PassthroughText,
},
),
}
+165
View File
@@ -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()
+712
View File
@@ -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()
+116
View File
@@ -0,0 +1,116 @@
import sys
import types
import unittest
import warnings
from unittest.mock import Mock, patch
from comfyui_to_python.node_runtime import cleanup_comfyui_runtime
class NodeRuntimeCleanupTest(unittest.TestCase):
def test_cleanup_releases_caches_without_forcing_model_unload_by_default(self):
comfy_module = types.ModuleType("comfy")
comfy_module.__path__ = []
model_management = types.ModuleType("comfy.model_management")
model_management.cleanup_models_gc = Mock()
model_management.unload_all_models = Mock()
model_management.soft_empty_cache = Mock()
comfy_module.model_management = model_management
with patch.dict(
sys.modules,
{
"comfy": comfy_module,
"comfy.model_management": model_management,
},
), patch.dict("os.environ", {}, clear=False):
cleanup_comfyui_runtime()
model_management.cleanup_models_gc.assert_called_once_with()
model_management.soft_empty_cache.assert_called_once_with()
model_management.unload_all_models.assert_not_called()
def test_cleanup_can_force_model_unload_from_environment(self):
comfy_module = types.ModuleType("comfy")
comfy_module.__path__ = []
model_management = types.ModuleType("comfy.model_management")
model_management.cleanup_models_gc = Mock()
model_management.unload_all_models = Mock()
model_management.soft_empty_cache = Mock()
comfy_module.model_management = model_management
with patch.dict(
sys.modules,
{
"comfy": comfy_module,
"comfy.model_management": model_management,
},
), patch.dict(
"os.environ",
{"COMFYUI_TOPYTHON_UNLOAD_MODELS": "true"},
clear=False,
):
cleanup_comfyui_runtime()
model_management.unload_all_models.assert_called_once_with()
def test_cleanup_suppresses_hook_failures_and_warns(self):
comfy_module = types.ModuleType("comfy")
comfy_module.__path__ = []
model_management = types.ModuleType("comfy.model_management")
model_management.cleanup_models_gc = Mock(side_effect=RuntimeError("gc failed"))
model_management.unload_all_models = Mock(side_effect=RuntimeError("unload failed"))
model_management.soft_empty_cache = Mock(side_effect=RuntimeError("cache failed"))
comfy_module.model_management = model_management
with patch.dict(
sys.modules,
{
"comfy": comfy_module,
"comfy.model_management": model_management,
},
), warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
cleanup_comfyui_runtime(unload_models=True)
model_management.cleanup_models_gc.assert_called_once_with()
model_management.unload_all_models.assert_called_once_with()
model_management.soft_empty_cache.assert_called_once_with()
self.assertEqual(len(caught), 3)
self.assertEqual(
[str(warning.message) for warning in caught],
[
"ComfyUI cleanup hook cleanup_models_gc failed during teardown: gc failed",
"ComfyUI cleanup hook unload_all_models failed during teardown: unload failed",
"ComfyUI cleanup hook soft_empty_cache failed during teardown: cache failed",
],
)
def test_cleanup_does_not_mask_active_workflow_exception(self):
comfy_module = types.ModuleType("comfy")
comfy_module.__path__ = []
model_management = types.ModuleType("comfy.model_management")
model_management.cleanup_models_gc = Mock(side_effect=RuntimeError("cleanup failed"))
model_management.soft_empty_cache = Mock()
comfy_module.model_management = model_management
with patch.dict(
sys.modules,
{
"comfy": comfy_module,
"comfy.model_management": model_management,
},
), warnings.catch_warnings(record=True):
warnings.simplefilter("always")
with self.assertRaisesRegex(ValueError, "workflow failed"):
try:
raise ValueError("workflow failed")
finally:
cleanup_comfyui_runtime()
model_management.cleanup_models_gc.assert_called_once_with()
model_management.soft_empty_cache.assert_called_once_with()
if __name__ == "__main__":
unittest.main()
+33
View File
@@ -0,0 +1,33 @@
import tomllib
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
class ProjectContractsTest(unittest.TestCase):
def test_project_declares_supported_python_floor(self):
pyproject = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
self.assertEqual(pyproject["project"]["requires-python"], ">=3.12")
def test_readme_documents_python_support_and_default_save_filename(self):
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
self.assertIn("This project supports Python 3.12 and newer.", readme)
self.assertIn("default filename `workflow_api.py`", readme)
def test_extension_import_path_requires_uv_sync_instead_of_running_install_py(self):
init_text = (REPO_ROOT / "__init__.py").read_text(encoding="utf-8")
self.assertIn("Run 'uv sync'", init_text)
self.assertNotIn("spec_from_file_location", init_text)
self.assertNotIn("Successfully installed. Hopefully, at least.", init_text)
def test_frontend_save_flow_uses_deterministic_filename_without_prompt(self):
save_as_script = (REPO_ROOT / "js" / "save-as-script.js").read_text(encoding="utf-8")
self.assertIn('const DEFAULT_SCRIPT_FILENAME = "workflow_api.py";', save_as_script)
self.assertNotIn("prompt(", save_as_script)
+305
View File
@@ -0,0 +1,305 @@
import struct
import tempfile
import unittest
import zlib
from pathlib import Path
from unittest.mock import patch
from tests.runtime.run_runtime_validation import (
FixtureConfig,
ModelRequirement,
ValidationFailure,
check_models,
ensure_runtime_path,
execute_generated_python,
load_fixture_names,
parse_png_info,
validate_generated_python,
)
def make_png_bytes(
width: int,
height: int,
text_chunks: list[tuple[bytes, bytes]] | None = None,
) -> bytes:
def chunk(chunk_type: bytes, data: bytes) -> bytes:
crc = zlib.crc32(chunk_type + data) & 0xFFFFFFFF
return (
struct.pack(">I", len(data))
+ chunk_type
+ data
+ struct.pack(">I", crc)
)
ihdr = chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
text_chunks = text_chunks or []
idat = chunk(
b"IDAT",
zlib.compress(b"\x00" + (b"\x00\x00\x00" * width)),
)
return b"".join(
[
b"\x89PNG\r\n\x1a\n",
ihdr,
*[chunk(chunk_type, payload) for chunk_type, payload in text_chunks],
idat,
chunk(b"IEND", b""),
]
)
class RuntimeValidationHarnessTest(unittest.TestCase):
def test_load_fixture_names_all_returns_registered_names(self):
fixture_names = load_fixture_names("all")
self.assertGreaterEqual(len(fixture_names), 4)
self.assertEqual(fixture_names[0], "upscale-model-loader")
self.assertIn("unsafe-kwargs", fixture_names)
def test_load_fixture_names_rejects_unknown_fixture(self):
with self.assertRaises(ValidationFailure) as context:
load_fixture_names("missing-fixture")
self.assertEqual(context.exception.classification, "fixture bug")
self.assertIn("Unknown fixture", context.exception.message)
def test_ensure_runtime_path_fast_tier_returns_env_without_validation(self):
with patch.dict("os.environ", {"COMFYUI_PATH": "/does/not/exist"}, clear=False):
runtime_path = ensure_runtime_path("fast")
self.assertEqual(runtime_path, "/does/not/exist")
@patch("tests.runtime.run_runtime_validation.get_comfyui_path", return_value="")
def test_ensure_runtime_path_runtime_tier_requires_valid_checkout(self, _mock_path):
with self.assertRaises(ValidationFailure) as context:
ensure_runtime_path("runtime")
self.assertEqual(context.exception.classification, "environment/setup failure")
self.assertIn("Could not find a valid ComfyUI checkout", context.exception.message)
def test_check_models_returns_only_missing_requirements(self):
with tempfile.TemporaryDirectory() as tmpdir:
runtime_path = Path(tmpdir)
present_dir = runtime_path / "models" / "checkpoints"
present_dir.mkdir(parents=True)
(present_dir / "present.safetensors").write_text("ok", encoding="utf-8")
fixture = FixtureConfig(
name="model-checks",
path=Path("unused.json"),
model_requirements=(
ModelRequirement(
filename="present.safetensors",
relative_dir="models/checkpoints",
source_url="https://example.invalid/present",
),
ModelRequirement(
filename="missing.safetensors",
relative_dir="models/checkpoints",
source_url="https://example.invalid/missing",
),
),
)
missing = check_models(fixture, str(runtime_path))
self.assertEqual([item.filename for item in missing], ["missing.safetensors"])
def test_validate_generated_python_accepts_valid_python(self):
validate_generated_python("value = 1\n", "valid-fixture")
def test_validate_generated_python_reports_syntax_regression(self):
with self.assertRaises(ValidationFailure) as context:
validate_generated_python("def broken(:\n", "broken-fixture")
self.assertEqual(context.exception.classification, "repo regression")
self.assertIn("broken-fixture", context.exception.message)
def test_parse_png_info_reads_dimensions_and_text_chunks(self):
compressed_text = zlib.compress(b"workflow data")
png_bytes = make_png_bytes(
width=3,
height=2,
text_chunks=[
(b"tEXt", b"prompt\x00hello"),
(b"zTXt", b"workflow\x00\x00" + compressed_text),
(b"iTXt", b"comment\x00\x00\x00\x00\x00unicode text"),
],
)
with tempfile.TemporaryDirectory() as tmpdir:
image_path = Path(tmpdir) / "artifact.png"
image_path.write_bytes(png_bytes)
width, height, metadata = parse_png_info(image_path)
self.assertEqual((width, height), (3, 2))
self.assertEqual(metadata["prompt"], "hello")
self.assertEqual(metadata["workflow"], "workflow data")
self.assertEqual(metadata["comment"], "unicode text")
def test_parse_png_info_rejects_non_png_files(self):
with tempfile.TemporaryDirectory() as tmpdir:
image_path = Path(tmpdir) / "artifact.bin"
image_path.write_bytes(b"not-a-png")
with self.assertRaises(ValidationFailure) as context:
parse_png_info(image_path)
self.assertEqual(context.exception.classification, "environment/setup failure")
self.assertIn("Expected PNG output", context.exception.message)
def test_parse_png_info_requires_dimensions(self):
with tempfile.TemporaryDirectory() as tmpdir:
image_path = Path(tmpdir) / "artifact.png"
image_path.write_bytes(
b"\x89PNG\r\n\x1a\n"
+ struct.pack(">I", 0)
+ b"IEND"
+ struct.pack(">I", 0)
)
with self.assertRaises(ValidationFailure) as context:
parse_png_info(image_path)
self.assertEqual(context.exception.classification, "environment/setup failure")
self.assertIn("Could not read PNG dimensions", context.exception.message)
@patch("tests.runtime.run_runtime_validation.get_runtime_python", return_value="/usr/bin/python")
@patch("tests.runtime.run_runtime_validation.subprocess.run")
def test_execute_generated_python_classifies_missing_torch_as_environment_failure(
self,
mock_run,
_mock_runtime_python,
):
mock_run.return_value.returncode = 1
mock_run.return_value.stderr = "ModuleNotFoundError: No module named 'torch'"
mock_run.return_value.stdout = ""
fixture = FixtureConfig(
name="runtime-fixture",
path=Path("unused.json"),
runtime_capable=True,
)
with tempfile.TemporaryDirectory() as tmpdir:
(Path(tmpdir) / "output").mkdir()
with self.assertRaises(ValidationFailure) as context:
execute_generated_python("print('hello')\n", fixture, tmpdir)
self.assertEqual(context.exception.classification, "environment/setup failure")
@patch("tests.runtime.run_runtime_validation.get_runtime_python", return_value="/usr/bin/python")
@patch("tests.runtime.run_runtime_validation.subprocess.run")
def test_execute_generated_python_classifies_missing_files_as_environment_failure(
self,
mock_run,
_mock_runtime_python,
):
mock_run.return_value.returncode = 1
mock_run.return_value.stderr = "No such file or directory: missing.png"
mock_run.return_value.stdout = ""
fixture = FixtureConfig(
name="runtime-fixture",
path=Path("unused.json"),
runtime_capable=True,
)
with tempfile.TemporaryDirectory() as tmpdir:
(Path(tmpdir) / "output").mkdir()
with self.assertRaises(ValidationFailure) as context:
execute_generated_python("print('hello')\n", fixture, tmpdir)
self.assertEqual(context.exception.classification, "environment/setup failure")
@patch("tests.runtime.run_runtime_validation.get_runtime_python", return_value="/usr/bin/python")
@patch("tests.runtime.run_runtime_validation.subprocess.run")
def test_execute_generated_python_classifies_other_failures_as_repo_regression(
self,
mock_run,
_mock_runtime_python,
):
mock_run.return_value.returncode = 1
mock_run.return_value.stderr = "ValueError: broken workflow"
mock_run.return_value.stdout = ""
fixture = FixtureConfig(
name="runtime-fixture",
path=Path("unused.json"),
runtime_capable=True,
)
with tempfile.TemporaryDirectory() as tmpdir:
(Path(tmpdir) / "output").mkdir()
with self.assertRaises(ValidationFailure) as context:
execute_generated_python("print('hello')\n", fixture, tmpdir)
self.assertEqual(context.exception.classification, "repo regression")
@patch("tests.runtime.run_runtime_validation.get_runtime_python", return_value="/usr/bin/python")
@patch("tests.runtime.run_runtime_validation.subprocess.run")
def test_execute_generated_python_requires_fresh_matching_artifact(
self,
mock_run,
_mock_runtime_python,
):
mock_run.return_value.returncode = 0
mock_run.return_value.stderr = ""
mock_run.return_value.stdout = ""
fixture = FixtureConfig(
name="runtime-fixture",
path=Path("unused.json"),
runtime_capable=True,
filename_prefix="expected_prefix",
)
with tempfile.TemporaryDirectory() as tmpdir:
(Path(tmpdir) / "output").mkdir()
with self.assertRaises(ValidationFailure) as context:
execute_generated_python("print('hello')\n", fixture, tmpdir)
self.assertEqual(context.exception.classification, "repo regression")
self.assertIn("did not produce a new output file", context.exception.message)
@patch("tests.runtime.run_runtime_validation.get_runtime_python", return_value="/usr/bin/python")
@patch("tests.runtime.run_runtime_validation.validate_output_artifact")
@patch("tests.runtime.run_runtime_validation.subprocess.run")
def test_execute_generated_python_validates_newest_matching_artifact(
self,
mock_run,
mock_validate_output,
_mock_runtime_python,
):
mock_run.return_value.returncode = 0
mock_run.return_value.stderr = ""
mock_run.return_value.stdout = ""
fixture = FixtureConfig(
name="runtime-fixture",
path=Path("unused.json"),
runtime_capable=True,
filename_prefix="expected_prefix",
)
with tempfile.TemporaryDirectory() as tmpdir:
output_dir = Path(tmpdir) / "output"
output_dir.mkdir()
older = output_dir / "expected_prefix_00001_.png"
older.write_bytes(make_png_bytes(1, 1))
with patch(
"tests.runtime.run_runtime_validation.subprocess.run",
side_effect=self._write_runtime_artifact(mock_run.return_value, output_dir),
):
execute_generated_python("print('hello')\n", fixture, tmpdir)
validated_path = mock_validate_output.call_args[0][1]
self.assertEqual(validated_path.name, "expected_prefix_00002_.png")
@staticmethod
def _write_runtime_artifact(result, output_dir: Path):
def side_effect(*_args, **_kwargs):
(output_dir / "expected_prefix_00002_.png").write_bytes(make_png_bytes(2, 2))
return result
return side_effect
if __name__ == "__main__":
unittest.main()
+176 -10
View File
@@ -1,4 +1,5 @@
import json
import sys
import tempfile
import unittest
from io import StringIO
@@ -115,6 +116,107 @@ class HiddenPromptSeedNode:
class UpscaleModelLoaderExportTest(unittest.TestCase):
def test_top_level_module_preserves_exporter_entrypoints(self):
from comfyui_to_python import main
self.assertTrue(callable(ComfyUItoPython))
self.assertTrue(callable(run))
self.assertTrue(callable(main))
def test_export_defers_comfyui_bootstrap_until_main(self):
workflow = {
"1": {
"class_type": "LoadImage",
"inputs": {
"image": "example.png",
},
}
}
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
output_file=output,
node_class_mappings={
"LoadImage": LoadImage,
},
)
generated = output.getvalue()
self.assertIn("def bootstrap_comfyui_runtime()", generated)
self.assertIn("def cleanup_comfyui_runtime(", generated)
# 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": {
@@ -151,8 +253,8 @@ class UpscaleModelLoaderExportTest(unittest.TestCase):
generated = output.getvalue()
self.assertIn("from nodes import NODE_CLASS_MAPPINGS", generated)
self.assertIn(f"from {LoadImage.__module__} import (", generated)
self.assertIn(" from nodes import NODE_CLASS_MAPPINGS", generated)
self.assertIn(f" from {LoadImage.__module__} import (", generated)
self.assertIn("LoadImage,", generated)
self.assertIn("UpscaleModelLoader,", generated)
self.assertIn("ImageUpscaleWithModel,", generated)
@@ -196,10 +298,11 @@ class UpscaleModelLoaderExportTest(unittest.TestCase):
generated = output.getvalue()
self.assertIn('extra_pnginfo = {', generated)
self.assertIn('"workflow": json.loads(', generated)
self.assertIn("def build_extra_pnginfo()", generated)
self.assertIn('"workflow": {', generated)
self.assertIn('"version": 0.4', generated)
self.assertIn('"nodes": []', generated)
self.assertNotIn('"workflow": json.loads(', generated)
self.assertNotIn('"source": "workflow_api"', generated)
def test_export_without_frontend_workflow_leaves_png_workflow_metadata_absent(self):
@@ -223,7 +326,8 @@ class UpscaleModelLoaderExportTest(unittest.TestCase):
generated = output.getvalue()
self.assertIn("extra_pnginfo = None", generated)
self.assertIn("def build_extra_pnginfo()", generated)
self.assertIn("return None", generated)
self.assertNotIn('"workflow": json.loads(', generated)
def test_export_preserves_unique_variable_names_for_subgraph_node_ids(self):
@@ -284,10 +388,13 @@ class UpscaleModelLoaderExportTest(unittest.TestCase):
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"):
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),
@@ -296,9 +403,68 @@ class UpscaleModelLoaderExportTest(unittest.TestCase):
generated = output_file.read_text(encoding="utf-8")
self.assertIn("extra_pnginfo = None", generated)
self.assertIn("def build_extra_pnginfo()", generated)
self.assertIn("return None", generated)
self.assertNotIn('"workflow": json.loads(', generated)
def test_export_structures_generated_script_into_readable_sections(self):
workflow = {
"1": {
"class_type": "LoadImage",
"inputs": {
"image": "example.png",
},
},
"2": {
"class_type": "UpscaleModelLoader",
"inputs": {
"model_name": "RealESRGAN_x4plus.safetensors",
},
},
"3": {
"class_type": "ImageUpscaleWithModel",
"inputs": {
"upscale_model": ["2", 0],
"image": ["1", 0],
},
},
}
output = StringIO()
ComfyUItoPython(
workflow=json.dumps(workflow),
output_file=output,
node_class_mappings={
"LoadImage": LoadImage,
"UpscaleModelLoader": UpscaleModelLoader,
"ImageUpscaleWithModel": ImageUpscaleWithModel,
},
)
generated = output.getvalue()
self.assertIn("# Imports", generated)
self.assertIn("# Workflow data", generated)
self.assertIn("# Workflow execution", generated)
self.assertIn("# Entrypoint", generated)
self.assertIn("def build_workflow()", generated)
self.assertIn("def build_extra_pnginfo()", generated)
self.assertIn("def main(unload_models: bool | None = None)", generated)
self.assertIn("bootstrap_comfyui_runtime()", generated)
self.assertIn("cleanup_comfyui_runtime(unload_models=unload_models)", generated)
self.assertNotIn("def initialize_workflow()", generated)
self.assertNotIn("def run_once(", generated)
self.assertIn("with torch.inference_mode():", generated)
self.assertIn("finally:", generated)
self.assertIn("for q in range(1):", generated)
self.assertIn("workflow = build_workflow()", generated)
self.assertIn("extra_pnginfo = build_extra_pnginfo()", generated)
self.assertNotIn('workflow = json.loads("', generated)
self.assertLess(
generated.index("def build_workflow()"),
generated.index("def main(unload_models: bool | None = None)"),
)
def test_hidden_metadata_kwargs_follow_function_signature(self):
workflow = {
"1": {
Generated
+37 -3
View File
@@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 2
requires-python = ">=3.12"
[[package]]
@@ -57,14 +57,23 @@ wheels = [
[[package]]
name = "comfyui-to-python-extension"
version = "1.3.1"
version = "2.1.0"
source = { virtual = "." }
dependencies = [
{ name = "black" },
]
[package.optional-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata]
requires-dist = [{ name = "black" }]
requires-dist = [
{ name = "black" },
{ name = "ruff", marker = "extra == 'dev'" },
]
provides-extras = ["dev"]
[[package]]
name = "mypy-extensions"
@@ -130,3 +139,28 @@ wheels = [
{ 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" },
]