diff --git a/.gitignore b/.gitignore
index ace17ff..96272cc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -145,3 +145,9 @@ cython_debug/
*.sql
*.sqlite
*.xml
+
+docs/
+.agents/
+.codex/
+AGENTS.md
+.ralph/
diff --git a/README.md b/README.md
index d4e0445..2246733 100644
--- a/README.md
+++ b/README.md
@@ -40,15 +40,19 @@ ln -s /path/to/ComfyUI-to-Python-Extension ComfyUI-to-Python-Extension
Then install this extension into the same Python environment that launches ComfyUI.
The `pyproject.toml` file declares the package dependencies, but those dependencies still need to be installed into ComfyUI's runtime Python.
-If you run ComfyUI from a source checkout with `uv`:
+#### From source checkout with `uv`
+
+If you run ComfyUI from a source checkout with `uv`, install its runtime dependencies and this extension in one step:
```bash
cd /path/to/ComfyUI
-uv pip install -e ./custom_nodes/ComfyUI-to-Python-Extension
+uv pip install -r requirements.txt -e ./custom_nodes/ComfyUI-to-Python-Extension
uv run python main.py
```
-If you use the Windows portable build:
+The `-r requirements.txt` installs ComfyUI's runtime dependencies (torch, etc.) and `-e ./custom_nodes/ComfyUI-to-Python-Extension` installs this extension in editable mode so code changes take effect on restart.
+
+#### Windows portable build
```
cd C:\path\to\ComfyUI_windows_portable\ComfyUI\custom_nodes\ComfyUI-to-Python-Extension
diff --git a/comfyui_to_python/app.py b/comfyui_to_python/app.py
index 334d06f..c05f27f 100644
--- a/comfyui_to_python/app.py
+++ b/comfyui_to_python/app.py
@@ -1,4 +1,5 @@
import copy
+import sys
from typing import TextIO
@@ -57,7 +58,16 @@ def execute(self) -> None:
}
if self.needs_init_custom_nodes or missing_node_types:
self.custom_node_importer()
- self.base_node_class_mappings = copy.deepcopy(self.node_class_mappings)
+ # 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
diff --git a/comfyui_to_python/generator/embedded_modules.py b/comfyui_to_python/generator/embedded_modules.py
new file mode 100644
index 0000000..c8e9d79
--- /dev/null
+++ b/comfyui_to_python/generator/embedded_modules.py
@@ -0,0 +1,299 @@
+"""Auto-discover and embed all runtime helpers as a single block.
+
+Instead of manually curating a __all__ list per function (which breaks when
+new internal helpers are added), this module reads contributing source files,
+strips their import statements and duplicate module logger setup via AST, and
+returns the clean definitions ready for embedding in generated standalone scripts.
+
+This guarantees that ALL functions, classes, and constants from the
+contributing modules are embedded together — so internal cross-calls always
+resolve without NameError.
+"""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+# Modules whose top-level definitions should be embedded in generated scripts.
+# Order matters: dependencies first, so functions are defined before callers use them.
+_SOURCE_FILES: list[str] = [
+ "runtime/module_loader.py", # _load_module, _bootstrap_import — no internal deps
+ "runtime/path_discovery.py", # get_comfyui_path, find_path, etc.
+ "runtime/bootstrap.py", # CLI filtering; depends on module_loader
+ "node_runtime.py", # public API facade + bootstrap/cleanup
+]
+
+
+APPROVED_EMBEDDED_NAMES: frozenset[str] = frozenset(
+ {
+ "_apply_device_settings",
+ "_apply_directory_overrides",
+ "_bootstrap_import",
+ "_discover_comfyui_cli_options",
+ "_filter_comfyui_args",
+ "_find_file",
+ "_find_from_extension_location",
+ "_get_base_option",
+ "_init_extra_nodes",
+ "_is_comfyui_directory",
+ "_load_custom_node_modules",
+ "_load_module",
+ "_load_module_temp",
+ "_parse_parser_actions",
+ "add_comfyui_directory_to_sys_path",
+ "add_extra_model_paths",
+ "bootstrap_comfyui_runtime",
+ "cleanup_comfyui_runtime",
+ "find_path",
+ "get_comfyui_path",
+ "get_node_class_mappings",
+ "get_value_at_index",
+ "import_custom_nodes",
+ }
+)
+
+
+def _strip_imports(source: str) -> str:
+ """Remove non-embeddable top-level statements from Python source code.
+
+ Uses AST to find import nodes and module logger assignments, then rebuilds
+ the source with those lines removed while preserving everything else
+ (functions, classes, constants, docstrings, comments).
+
+ Args:
+ source: Full Python source code of a module.
+
+ Returns:
+ Source code with all top-level import statements removed.
+ """
+ tree = ast.parse(source)
+ lines = source.splitlines(keepends=True)
+
+ # Collect line numbers (1-indexed) of top-level statements to remove.
+ skip_lines: set[int] = set()
+ for node in ast.iter_child_nodes(tree):
+ if isinstance(node, (ast.Import, ast.ImportFrom)) or (
+ isinstance(node, (ast.Assign, ast.AnnAssign))
+ and _is_module_logger_assignment(node)
+ ):
+ # Handle multi-line imports (from x import (a,\n b))
+ start = node.lineno or 1
+ end = getattr(node, "end_lineno", start) or start
+ for ln in range(start, end + 1):
+ skip_lines.add(ln)
+
+ # Also strip blank lines that immediately follow removed imports
+ # to avoid excessive whitespace gaps
+ result_lines: list[str] = []
+ prev_was_import = False
+ for i, line in enumerate(lines, start=1):
+ if i in skip_lines:
+ prev_was_import = True
+ continue
+ # Skip a single blank line after imports (but keep meaningful spacing)
+ if prev_was_import and line.strip() == "":
+ prev_was_import = False
+ continue
+ prev_was_import = False
+ result_lines.append(line)
+
+ return "".join(result_lines).strip() + "\n"
+
+
+def _is_module_logger_assignment(node: ast.Assign | ast.AnnAssign) -> bool:
+ """Return True when a top-level assignment only initializes `log`."""
+ if isinstance(node, ast.Assign):
+ targets = node.targets
+ value = node.value
+ else:
+ targets = [node.target]
+ value = node.value
+
+ if value is None:
+ return False
+ if not targets or any(
+ not isinstance(target, ast.Name) or target.id != "log" for target in targets
+ ):
+ return False
+ return (
+ isinstance(value, ast.Call)
+ and isinstance(value.func, ast.Attribute)
+ and value.func.attr == "getLogger"
+ and isinstance(value.func.value, ast.Name)
+ and value.func.value.id == "logging"
+ )
+
+
+def get_embedded_helpers() -> str:
+ """Return the full embedded helper block for generated scripts.
+
+ Reads each source file listed in _SOURCE_FILES, strips its import
+ statements, and concatenates the results into a single embeddable
+ code block.
+
+ Returns:
+ Python source code containing all function/class/constant definitions
+ from contributing modules, ready to paste into a generated script.
+ """
+ package_root = Path(__file__).resolve().parent.parent # comfyui_to_python/
+ parts: list[str] = []
+
+ for rel_path in _SOURCE_FILES:
+ filepath = package_root / rel_path
+ if not filepath.exists():
+ raise FileNotFoundError(
+ f"Embedded source file not found: {filepath}\n"
+ f"If you added a new contributing module, update "
+ f"_SOURCE_FILES in {__file__}"
+ )
+ parts.append(f"# --- Embedded from {rel_path} ---\n")
+ parts.append(_strip_imports(filepath.read_text()))
+
+ return "\n".join(parts)
+
+
+def verify_embedded_surface_matches_manifest() -> list[str]:
+ """Return embedded helper names that differ from the approved surface."""
+ actual = list_embedded_names()
+ differences = [
+ *(
+ f"missing approved embedded name: {name}"
+ for name in sorted(APPROVED_EMBEDDED_NAMES - actual)
+ ),
+ *(
+ f"unexpected embedded name: {name}"
+ for name in sorted(actual - APPROVED_EMBEDDED_NAMES)
+ ),
+ ]
+ return differences
+
+
+def list_embedded_names() -> set[str]:
+ """Return the set of all top-level names that will be embedded.
+
+ Useful for testing / verification to ensure no unexpected names are
+ included and to check for missing dependencies.
+
+ Returns:
+ Set of function/class/constant names from contributing modules.
+ """
+ package_root = Path(__file__).resolve().parent.parent
+ names: set[str] = set()
+
+ for rel_path in _SOURCE_FILES:
+ filepath = package_root / rel_path
+ tree = ast.parse(filepath.read_text())
+ for node in ast.iter_child_nodes(tree):
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
+ names.add(node.name)
+ elif isinstance(node, ast.Assign):
+ for target in node.targets:
+ if isinstance(target, ast.Name):
+ # Skip common non-embeddable names like 'log'
+ if target.id not in ("log",):
+ names.add(target.id)
+
+ return names
+
+
+def verify_no_missing_cross_calls() -> list[str]:
+ """Check that all function calls within embedded code resolve to embedded names.
+
+ Scans each contributing module for calls to names defined at module level
+ in other modules, and reports any that are NOT in the embedded set.
+
+ Skips builtins, exceptions, type hints, and local/nested function references
+ that are created dynamically (e.g., via getattr on runtime-loaded modules).
+
+ Returns:
+ List of unresolved call names (empty if everything resolves).
+ """
+ import builtins as _builtins
+
+ embedded = list_embedded_names()
+ package_root = Path(__file__).resolve().parent.parent
+ builtin_names = set(dir(_builtins))
+
+ # Exception classes that are common in error handling
+ exception_names = {
+ "Exception",
+ "BaseException",
+ "ValueError",
+ "TypeError",
+ "KeyError",
+ "AttributeError",
+ "ModuleNotFoundError",
+ "ImportError",
+ "FileNotFoundError",
+ "RuntimeError",
+ "StopIteration",
+ "IndexError",
+ "OSError",
+ }
+
+ # Type hint names from typing module
+ typing_names = {
+ "Any",
+ "Sequence",
+ "Mapping",
+ "Union",
+ "Optional",
+ "List",
+ "Dict",
+ "Set",
+ "Tuple",
+ "FrozenSet",
+ "Callable",
+ }
+
+ all_known = builtin_names | exception_names | typing_names | embedded
+ unresolved: list[str] = []
+
+ for rel_path in _SOURCE_FILES:
+ filepath = package_root / rel_path
+ tree = ast.parse(filepath.read_text())
+
+ # Find all Name nodes used as function calls
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
+ caller_name = node.func.id
+ if caller_name not in all_known:
+ # Could be a local/nested function or dynamic lookup — check if
+ # it's defined as a nested def anywhere in the same file
+ is_nested = _is_nested_or_local_def(filepath, caller_name)
+ if not is_nested:
+ unresolved.append(
+ f"{rel_path}: calls '{caller_name}' "
+ "(not embedded, not builtin)"
+ )
+
+ return unresolved
+
+
+def _is_nested_or_local_def(filepath: Path, name: str) -> bool:
+ """Check if a name is defined as a nested function or local variable in a file.
+
+ Catches patterns like:
+ def outer():
+ if cond:
+ x = getattr(mod, "x") # 'x' is a local var (nested inside if)
+ return x()
+ """
+ tree = ast.parse(filepath.read_text())
+ nested_defs: set[str] = set()
+ local_assigns: set[str] = set()
+
+ for node in ast.walk(tree):
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
+ # Walk ALL descendants of the function body to find nested defs
+ # and local variable assignments (including those inside if/else blocks)
+ for child in ast.walk(node):
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
+ nested_defs.add(child.name)
+ elif isinstance(child, ast.Assign):
+ for target in child.targets:
+ if isinstance(target, ast.Name):
+ local_assigns.add(target.id)
+
+ return name in nested_defs or name in local_assigns
diff --git a/comfyui_to_python/generator/generated_helpers.py b/comfyui_to_python/generator/generated_helpers.py
index 896ec37..c8fb914 100644
--- a/comfyui_to_python/generator/generated_helpers.py
+++ b/comfyui_to_python/generator/generated_helpers.py
@@ -1,4 +1,12 @@
from ..node_runtime import (
+ _bootstrap_import,
+ _discover_comfyui_cli_options,
+ _filter_comfyui_args,
+ _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,
@@ -9,6 +17,14 @@
)
__all__ = [
+ "_bootstrap_import",
+ "_discover_comfyui_cli_options",
+ "_filter_comfyui_args",
+ "_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",
diff --git a/comfyui_to_python/generator/planner.py b/comfyui_to_python/generator/planner.py
index cfc5090..01b490a 100644
--- a/comfyui_to_python/generator/planner.py
+++ b/comfyui_to_python/generator/planner.py
@@ -51,12 +51,7 @@ def build_plan(
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:
+ if self._node_has_missing_required_inputs(input_types, inputs):
continue
if class_type not in initialized_objects:
@@ -85,21 +80,7 @@ def build_plan(
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)
+ self._apply_hidden_inputs(inputs, input_types, class_def_params)
executed_variables[idx] = (
f"{self.clean_variable_name(class_type)}_"
@@ -134,6 +115,34 @@ def build_plan(
custom_nodes=custom_nodes,
)
+ @staticmethod
+ def _node_has_missing_required_inputs(input_types: dict, inputs: dict) -> bool:
+ """Return True when a node is missing any required input."""
+ return any(
+ required not in inputs for required in input_types.get("required", {})
+ )
+
+ @staticmethod
+ def _apply_hidden_inputs(
+ inputs: dict, input_types: dict, class_def_params: list | None
+ ) -> None:
+ """Inject ComfyUI hidden runtime inputs accepted by the node function."""
+ hidden_inputs = input_types.get("hidden", {})
+ no_params = class_def_params is None
+ 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)
+
def create_function_call_code(
self,
obj_name: str,
@@ -164,13 +173,15 @@ def create_prompt_seed_sync_code(
if key not in inputs:
continue
randomized_seed_variable = (
- f"node_{self.sanitize_node_id(str(node_id))}_{self.clean_variable_name(key)}"
+ f"node_{self.sanitize_node_id(str(node_id))}_"
+ f"{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}'
+ f'{randomized_seed_variable} = prompt["{node_id}"]["inputs"]'
+ f'["{key}"] = {randomized_seed_code}'
)
inputs[key] = {"variable_name": randomized_seed_variable}
@@ -180,7 +191,9 @@ def create_prompt_seed_sync_code(
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:
+ 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}"
@@ -255,7 +268,9 @@ def update_inputs(self, inputs: dict, executed_variables: dict) -> dict:
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]})"
- }
+ variable_name = (
+ f"get_value_at_index({executed_variables[inputs[key][0]]}, "
+ f"{inputs[key][1]})"
+ )
+ inputs[key] = {"variable_name": variable_name}
return inputs
diff --git a/comfyui_to_python/generator/render.py b/comfyui_to_python/generator/render.py
index 1acd4a3..cfc9ee2 100644
--- a/comfyui_to_python/generator/render.py
+++ b/comfyui_to_python/generator/render.py
@@ -1,67 +1,67 @@
import inspect
+import logging
from pprint import pformat
from typing import Any
import black
from ..node_runtime import import_custom_nodes
-from .generated_helpers import (
- add_comfyui_directory_to_sys_path,
- add_extra_model_paths,
- bootstrap_comfyui_runtime,
- cleanup_comfyui_runtime,
- find_path,
- get_comfyui_path,
- get_value_at_index,
-)
+from .embedded_modules import get_embedded_helpers
from .model import GenerationPlan
+log = logging.getLogger(__name__)
+
class WorkflowRenderer:
"""Render a generation plan into the final standalone Python source."""
def render(self, plan: GenerationPlan) -> str:
- workflow_literal = self.format_python_literal(plan.workflow_data)
- if plan.metadata_workflow_data is None:
- extra_pnginfo_literal = "None"
- else:
- extra_pnginfo_literal = self.format_python_literal(
- {"workflow": plan.metadata_workflow_data}
- )
-
- func_strings = []
- for func in [
- get_value_at_index,
- get_comfyui_path,
- find_path,
- add_comfyui_directory_to_sys_path,
- add_extra_model_paths,
- bootstrap_comfyui_runtime,
- cleanup_comfyui_runtime,
- ]:
- func_strings.append(f"\n{inspect.getsource(func)}")
+ final_code = "\n".join(
+ self._build_static_imports(plan)
+ + [""]
+ + self._build_workflow_section(plan)
+ + [""]
+ + self._build_execution_section(plan)
+ + [""]
+ + self._build_entrypoint_section()
+ )
+ return black.format_str(final_code, mode=black.Mode())
+ def _build_static_imports(self, plan: GenerationPlan) -> list[str]:
+ """Build imports and embedded helper definitions for standalone scripts."""
+ # Auto-discover all helpers from contributing runtime modules.
+ # Reads source files, strips imports, embeds definitions — so internal
+ # cross-calls always resolve (no NameError from missing __all__ entries).
+ embedded_helpers = get_embedded_helpers()
static_imports = [
"# Imports",
+ "import gc",
+ "import importlib.util",
"import json",
+ "import logging",
"import os",
"import random",
"import sys",
+ "import warnings",
"from typing import Sequence, Mapping, Any, Union",
- ] + func_strings
-
+ "",
+ "log = logging.getLogger(__name__)",
+ embedded_helpers,
+ ]
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}")
+ return static_imports
- workflow_section = [
+ def _build_workflow_section(self, plan: GenerationPlan) -> list[str]:
+ """Build workflow and PNG metadata literals."""
+ workflow_literal = self.format_python_literal(plan.workflow_data)
+ if plan.metadata_workflow_data is None:
+ extra_pnginfo_literal = "None"
+ else:
+ extra_pnginfo_literal = self.format_python_literal(
+ {"workflow": plan.metadata_workflow_data}
+ )
+ return [
"# Workflow data",
"def build_workflow() -> dict[str, Any]:",
f" return {workflow_literal}",
@@ -74,17 +74,22 @@ def render(self, plan: GenerationPlan) -> str:
"extra_pnginfo = build_extra_pnginfo()",
]
+ def _build_execution_section(self, plan: GenerationPlan) -> list[str]:
+ """Build the bootstrap, import, loop, and cleanup body."""
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 plan.custom_nodes:
+ execution_section.append(" import_custom_nodes()")
+
+ imports_code = self._build_imports_code(plan)
if imports_code:
execution_section.extend(["", " # Node imports"])
execution_section.extend(f" {line}" for line in imports_code)
+
execution_section.extend(
[
"",
@@ -111,24 +116,26 @@ def render(self, plan: GenerationPlan) -> str:
" cleanup_comfyui_runtime(unload_models=unload_models)",
]
)
+ return execution_section
+
+ @staticmethod
+ def _build_imports_code(plan: GenerationPlan) -> list[str]:
+ """Build sorted node import statements for the main function."""
+ 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}")
+ return imports_code
- entrypoint_section = [
+ @staticmethod
+ def _build_entrypoint_section() -> list[str]:
+ """Build the standalone script entrypoint."""
+ return [
"# 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)
diff --git a/comfyui_to_python/node_runtime.py b/comfyui_to_python/node_runtime.py
index a74e9b0..187e63e 100644
--- a/comfyui_to_python/node_runtime.py
+++ b/comfyui_to_python/node_runtime.py
@@ -1,74 +1,210 @@
+"""Node runtime: ComfyUI import path resolution, module loading, and bootstrap.
+
+This module is the public facade for the comfyui_to_python.runtime
+subpackage. All imports that previously came from this file continue to work
+unchanged — the internal implementation has been reorganized into focused
+submodules for clarity.
+
+Submodule structure:
+ runtime/path_discovery.py — Locate ComfyUI root and auxiliary files
+ runtime/module_loader.py — Load Python modules via importlib/bootstrap
+ runtime/bootstrap.py — CLI discovery, argv filtering, runtime init
+
+Public API (import from this module):
+ Path resolution: get_comfyui_path(), find_path(), add_comfyui_directory_to_sys_path()
+ Model paths: add_extra_model_paths()
+ Bootstrap: bootstrap_comfyui_runtime()
+ Cleanup: cleanup_comfyui_runtime()
+ Custom nodes: import_custom_nodes()
+ Node mappings: get_node_class_mappings()
+ Helpers: get_value_at_index()
+
+Internal (prefixed with _): Available for embedding in generated scripts.
+"""
+
+from __future__ import annotations
+
+import gc
+import logging
import os
import sys
import warnings
from typing import Any, Mapping, Sequence, Union
-
-def find_path(name: str, path: str = None) -> str:
- """Recursively search parent folders until the named entry is found."""
- if path is None:
- path = os.getcwd()
-
- if name in os.listdir(path):
- path_name = os.path.join(path, name)
- print(f"{name} found: {path_name}")
- return path_name
-
- parent_directory = os.path.dirname(path)
- if parent_directory == path:
- return None
-
- return find_path(name, parent_directory)
-
-
-def get_comfyui_path() -> str:
- """Return the configured ComfyUI path, preferring COMFYUI_PATH when set."""
- comfyui_path = os.environ.get("COMFYUI_PATH")
- if comfyui_path:
- return comfyui_path
- return find_path("ComfyUI")
+# ── Re-exports from runtime/bootstrap.py ────────────────────────────────────
+from .runtime.bootstrap import (
+ _DISCOVERED_OPTIONS,
+ _discover_comfyui_cli_options,
+ _filter_comfyui_args,
+)
+
+# ── Re-exports from runtime/module_loader.py ────────────────────────────────
+from .runtime.module_loader import (
+ _bootstrap_import,
+ _load_module,
+ _load_module_temp,
+)
+
+# ── Re-exports from runtime/path_discovery.py ────────────────────────────────
+from .runtime.path_discovery import (
+ _find_file,
+ _find_from_extension_location,
+ _is_comfyui_directory,
+ find_path,
+ get_comfyui_path,
+)
+
+log = logging.getLogger(__name__)
+
+
+# ── Public API ────────────────────────────────────────────────────────────────
+# Re-exported names for import from this module.
+# External code imports these from comfyui_to_python.node_runtime (not submodules).
+__all__: list[str] = [
+ # Path discovery
+ "_find_file",
+ "_find_from_extension_location",
+ "_is_comfyui_directory",
+ "add_comfyui_directory_to_sys_path",
+ "find_path",
+ "get_comfyui_path",
+ # Module loading
+ "_bootstrap_import",
+ "_load_module",
+ "_load_module_temp",
+ # Bootstrap / CLI
+ "_DISCOVERED_OPTIONS",
+ "_discover_comfyui_cli_options",
+ "_filter_comfyui_args",
+ # Public API
+ "add_extra_model_paths",
+ "bootstrap_comfyui_runtime",
+ "cleanup_comfyui_runtime",
+ "get_node_class_mappings",
+ "get_value_at_index",
+ "import_custom_nodes",
+]
+
+
+# ── Public API: sys.path management ─────────────────────────────────────────
def add_comfyui_directory_to_sys_path() -> None:
- """Add the ComfyUI checkout to sys.path."""
+ """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)
- print(f"'{comfyui_path}' added to sys.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."""
- 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."
+ """Load ComfyUI extra model paths configuration when available.
+
+ Attempts to load load_extra_path_config from ComfyUI's main.py,
+ falling back to utils/extra_config.py if main.py is unavailable.
+ Then locates and loads the extra_model_paths.yaml configuration file.
+ """
+ comfyui_path = get_comfyui_path()
+ if comfyui_path is None:
+ log.debug("Cannot load extra model paths: ComfyUI path not found")
+ return
+
+ 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")
)
- from utils.extra_config import load_extra_path_config
+ 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")
+ extra_model_paths = _find_file("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.")
+ log.debug("Could not find the extra_model_paths config file.")
+
+
+# ── Public API: bootstrap (self-contained for generated script embedding) ────
def bootstrap_comfyui_runtime() -> None:
- """Mirror the allocator-related ComfyUI startup steps before torch import."""
+ """Mirror the allocator-related ComfyUI startup steps before torch import.
+
+ Uses normal imports so that parsed CLI args (e.g. --cpu) persist in
+ sys.modules and are reused when ComfyUI's internal chain later imports
+ comfy.cli_args and comfy.options.
+
+ Sequence:
+ 1. Add ComfyUI to sys.path[0]
+ 2. Filter sys.argv to known ComfyUI options
+ 3. Import and enable CLI arg parsing via _bootstrap_import()
+ 4. Force --cpu mode if CUDA is unavailable
+ 5. Apply device, directory, and allocator settings from parsed args
+ 6. Load cuda_malloc.py for ROCm detection (temp module)
+ """
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
- import comfy.options
+ # Filter sys.argv to keep only ComfyUI-recognized flags. This prevents
+ # argparse crashes when bootstrap runs inside a subprocess (e.g. test
+ # runner) where sys.argv contains non-ComfyUI arguments.
+ original_argv = sys.argv
+ try:
+ sys.argv = _filter_comfyui_args(sys.argv)
+
+ # Load via _bootstrap_import() for namespace-package-safe imports.
+ options_mod = _bootstrap_import("comfy.options")
+ if options_mod is not None:
+ options_mod.enable_args_parsing()
- comfy.options.enable_args_parsing()
+ cli_args_mod = _bootstrap_import("comfy.cli_args")
+ finally:
+ # Restore original argv so downstream and error paths see real inputs.
+ sys.argv = original_argv
- from comfy.cli_args import args
+ args = getattr(cli_args_mod, "args", None) if cli_args_mod else None
if os.name == "nt":
os.environ["MIMALLOC_PURGE_DELAY"] = "0"
+ # Guard all args access — args may be None during export path
+ if args is not None:
+ _apply_device_settings(args)
+ _apply_directory_overrides(args)
+
+ 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 _apply_device_settings(args: Any) -> None:
+ """Apply GPU device settings from parsed CLI arguments.
+
+ Sets environment variables for CUDA, HIP, and oneAPI device selection
+ based on the user's command-line arguments.
+ """
if args.default_device is not None:
default_dev = args.default_device
devices = list(range(32))
@@ -89,15 +225,39 @@ def bootstrap_comfyui_runtime() -> None:
if args.deterministic and "CUBLAS_WORKSPACE_CONFIG" not in os.environ:
os.environ["CUBLAS_WORKSPACE_CONFIG"] = ":4096:8"
- import cuda_malloc
- if "rocm" in cuda_malloc.get_torch_version_noimport():
- os.environ["OCL_SET_SVM_SIZE"] = "262144"
+def _apply_directory_overrides(args: Any) -> None:
+ """Apply output/input/user directory overrides from CLI arguments.
+
+ Redirects ComfyUI's default directories to user-specified paths,
+ enabling operation when default mounts are read-only.
+ """
+ folder_paths_mod = _bootstrap_import("folder_paths")
+ if folder_paths_mod is None:
+ return
+
+ if args.output_directory and hasattr(folder_paths_mod, "set_output_directory"):
+ folder_paths_mod.set_output_directory(os.path.abspath(args.output_directory))
+ if args.input_directory and hasattr(folder_paths_mod, "set_input_directory"):
+ folder_paths_mod.set_input_directory(os.path.abspath(args.input_directory))
+ if args.user_directory and hasattr(folder_paths_mod, "set_user_directory"):
+ folder_paths_mod.set_user_directory(os.path.abspath(args.user_directory))
+
+
+# ── Public API: cleanup ─────────────────────────────────────────────────────
def cleanup_comfyui_runtime(unload_models: bool | None = None) -> None:
- """Best-effort cleanup for embedded or repeated generated-script execution."""
- import gc
+ """Best-effort cleanup for embedded or repeated generated-script execution.
+
+ Runs ComfyUI's model cleanup hooks and garbage collection. Designed for
+ scenarios where the script is executed repeatedly within a single process
+ (e.g., notebook cells, test runners).
+
+ Args:
+ unload_models: Force model unloading. If None, reads from
+ COMFYUI_TOPYTHON_UNLOAD_MODELS environment variable.
+ """
def run_cleanup_hook(name: str, should_run: bool = True) -> None:
if not should_run or not hasattr(model_management, name):
@@ -114,15 +274,12 @@ def run_cleanup_hook(name: str, should_run: bool = True) -> None:
should_unload = unload_models
if should_unload is None:
- should_unload = os.environ.get("COMFYUI_TOPYTHON_UNLOAD_MODELS", "").lower() in {
- "1",
- "true",
- "yes",
- "on",
- }
+ should_unload = os.environ.get(
+ "COMFYUI_TOPYTHON_UNLOAD_MODELS", ""
+ ).lower() in {"1", "true", "yes", "on"}
try:
- import comfy.model_management as model_management
+ import comfy.model_management as model_management # noqa: PLC0414
except ModuleNotFoundError:
gc.collect()
return
@@ -133,39 +290,150 @@ def run_cleanup_hook(name: str, should_run: bool = True) -> None:
gc.collect()
+# ── Public API: custom nodes ────────────────────────────────────────────────
+
+
+def _load_custom_node_modules(
+ comfyui_path: str,
+) -> tuple[Any, Any, Any]:
+ """Load the three core ComfyUI modules for custom node initialization.
+
+ Loads execution.py, nodes.py, and server.py from the ComfyUI checkout.
+ Temporarily filters out comfy/ subdirectory from sys.path to prevent
+ import shadowing when loading server.py.
+
+ Args:
+ comfyui_path: Absolute path to the ComfyUI root directory.
+
+ Returns:
+ Tuple of (execution_mod, nodes_mod, server_mod). Any may be None
+ if the corresponding module could not be loaded.
+ """
+ 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.
+ sys.path[:] = original_sys_path
+
+ return execution_mod, nodes_mod, server_mod
+
+
+def _init_extra_nodes(nodes_mod: Any) -> None:
+ """Call init_extra_nodes on the nodes module if available.
+
+ Args:
+ nodes_mod: The loaded nodes module (may be None).
+ """
+ import asyncio
+
+ if nodes_mod is not None and hasattr(nodes_mod, "init_extra_nodes"):
+ asyncio.run(nodes_mod.init_extra_nodes())
+
+
def import_custom_nodes() -> None:
- """Initialize ComfyUI custom nodes in the exporter runtime."""
- comfyui_path = get_comfyui_path()
- if comfyui_path and comfyui_path not in sys.path:
- sys.path.insert(0, comfyui_path)
+ """Initialize ComfyUI custom nodes in the exporter runtime.
+
+ Loads execution.py, nodes.py, and server.py from the ComfyUI checkout.
+ Sets up PromptServer and PromptQueue for the async event loop.
+ Calls init_extra_nodes() to populate NODE_CLASS_MAPPINGS with extras.
+ Uses _load_module() for all ComfyUI imports — no bare imports,
+ no sys.path remove/re-insert gap.
+ """
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)
+ comfyui_path = get_comfyui_path()
+ if comfyui_path is None:
+ log.debug("import_custom_nodes: ComfyUI path not found")
+ return
- import server
+ # Idempotent insert-once — never removes from sys.path
+ if comfyui_path not in sys.path:
+ sys.path.insert(0, comfyui_path)
+
+ execution_mod, nodes_mod, server_mod = _load_custom_node_modules(comfyui_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."
+ )
+ _init_extra_nodes(nodes_mod)
+ return
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
- server_instance = server.PromptServer(loop)
- execution.PromptQueue(server_instance)
- asyncio.run(init_extra_nodes())
+ server_instance = server_mod.PromptServer(loop)
+ execution_mod.PromptQueue(server_instance)
+ _init_extra_nodes(nodes_mod)
+
+
+# ── Public API: node mappings ───────────────────────────────────────────────
def get_node_class_mappings() -> dict:
- """Load ComfyUI node mappings on demand."""
+ """Load ComfyUI node mappings on demand via _load_module().
+
+ Calls bootstrap_comfyui_runtime() first so that CLI args (e.g. --cpu)
+ are parsed and cached in sys.modules before nodes.py triggers the
+ comfy.cli_args import chain. This prevents CUDA init crashes on
+ systems without a GPU.
+
+ Reuses the cached "nodes" module from sys.modules if already loaded
+ (e.g. by import_custom_nodes) to avoid resetting NODE_CLASS_MAPPINGS.
+
+ Returns:
+ Dictionary mapping node class names to class objects, or empty dict
+ if nodes module could not be loaded.
+ """
add_comfyui_directory_to_sys_path()
- from nodes import NODE_CLASS_MAPPINGS
+ comfyui_path = get_comfyui_path()
+ if comfyui_path is None:
+ log.debug("get_node_class_mappings: ComfyUI path not found")
+ return {}
+
+ # Ensure bootstrap has run so that parsed CLI args are cached in
+ # sys.modules before nodes.py triggers the comfy import chain.
+ bootstrap_comfyui_runtime()
+
+ # 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", {})
- return NODE_CLASS_MAPPINGS
+
+# ── Public API: helpers ─────────────────────────────────────────────────────
def get_value_at_index(obj: Union[Sequence, Mapping], index: int) -> Any:
- """Return a sequence or mapping result item by index."""
+ """Return a sequence or mapping result item by index.
+
+ Used in generated scripts to extract items from ComfyUI node outputs.
+ Supports both list indexing and dict key access with "result" fallback.
+
+ Args:
+ obj: A sequence (list, tuple) or mapping (dict) to index into.
+ index: Integer index for sequences, or dict key / numeric fallback.
+
+ Returns:
+ The item at the given index, or the result[index] value for dicts.
+ """
try:
return obj[index]
except KeyError:
diff --git a/comfyui_to_python/runtime/__init__.py b/comfyui_to_python/runtime/__init__.py
new file mode 100644
index 0000000..4bf272a
--- /dev/null
+++ b/comfyui_to_python/runtime/__init__.py
@@ -0,0 +1,7 @@
+"""Runtime support for ComfyUI import path resolution and bootstrap.
+
+Submodules:
+ - path_discovery: Locate ComfyUI root directory and auxiliary files
+ - module_loader: Load Python modules via importlib or bootstrap import
+ - bootstrap: CLI option discovery, argv filtering, runtime initialization
+"""
diff --git a/comfyui_to_python/runtime/bootstrap.py b/comfyui_to_python/runtime/bootstrap.py
new file mode 100644
index 0000000..429473f
--- /dev/null
+++ b/comfyui_to_python/runtime/bootstrap.py
@@ -0,0 +1,199 @@
+"""CLI option discovery and argv filtering helpers.
+
+This module provides internal utilities for:
+ - Dynamic discovery of valid CLI options from ComfyUI's argparse parser
+ - Filtering sys.argv to keep only ComfyUI-recognized flags
+
+These are imported by node_runtime.py which contains the public
+bootstrap_comfyui_runtime() function. Keeping them separate clarifies
+the distinction between data analysis (this module) and runtime execution
+(node_runtime.py).
+"""
+
+from __future__ import annotations
+
+import logging
+import sys
+
+from .module_loader import _bootstrap_import
+
+log = logging.getLogger(__name__)
+
+
+# Cache for discovered CLI options — populated once, reused thereafter.
+_DISCOVERED_OPTIONS: tuple[frozenset[str], frozenset[str]] | None = None
+
+
+_EMPTY_OPTIONS: tuple[frozenset[str], frozenset[str]] = frozenset(), frozenset()
+
+
+def _parse_parser_actions(parser) -> tuple[set[str], set[str]]:
+ """Parse argparse actions into known options and value-taking subsets.
+
+ Inspects `parser._actions` to extract all recognized option strings and
+ which ones take values. This is the core parsing logic extracted from
+ _discover_comfyui_cli_options() for readability.
+
+ Args:
+ parser: An argparse.ArgumentParser instance with configured actions.
+
+ Returns:
+ Tuple of (known_options, value_taking_options) as sets.
+ """
+ known: set[str] = set()
+ value_taking: set[str] = set()
+ for action in parser._actions:
+ for opt in action.option_strings:
+ if not opt.startswith("--"):
+ continue
+ base = opt.split("[")[0].strip()
+ known.add(base)
+ nargs = getattr(action, "nargs", None)
+ # Skip boolean store_true/store_false actions — they don't take values
+ action_name = type(action).__name__
+ if action_name in ("_StoreTrueAction", "_StoreFalseAction"):
+ continue
+ if nargs is not None and nargs != 0:
+ value_taking.add(base)
+ elif hasattr(action, "const") and action.const is not None:
+ value_taking.add(base)
+ elif getattr(action, "type", None) is not None or nargs is None:
+ if action.dest != "help":
+ value_taking.add(base)
+ return known, value_taking
+
+
+def _discover_comfyui_cli_options() -> tuple[frozenset[str], frozenset[str]]:
+ """Dynamically discover CLI options from ComfyUI's argparse parser.
+
+ Inspects `comfy.cli_args.parser._actions` to extract all recognized
+ option strings and which ones take values. This eliminates the need for
+ a hardcoded list that drifts when ComfyUI adds/removes flags.
+
+ Results are cached after first call. Cache is process-lifetime — it is
+ not automatically invalidated if ComfyUI code changes on disk.
+
+ Returns:
+ Tuple of (known_options, value_taking_options) as frozensets.
+ value_taking_options is a subset of known_options.
+ """
+ global _DISCOVERED_OPTIONS # noqa: PLW0603
+ if _DISCOVERED_OPTIONS is not None:
+ return _DISCOVERED_OPTIONS
+
+ original_argv = sys.argv
+ pre_discovery_modules = set(sys.modules.keys())
+ try:
+ sys.argv = ["_discover"]
+ cli_args_mod = _bootstrap_import("comfy.cli_args")
+ except ModuleNotFoundError:
+ log.debug("comfy.cli_args not available for option discovery")
+ sys.argv = original_argv
+ _DISCOVERED_OPTIONS = _EMPTY_OPTIONS
+ return _DISCOVERED_OPTIONS
+ finally:
+ sys.argv = original_argv
+
+ # Remove ComfyUI modules loaded during discovery so that
+ # bootstrap_comfyui_runtime() can import them fresh with real argv.
+ for mod_name in set(sys.modules.keys()) - pre_discovery_modules:
+ if mod_name.startswith("comfy.") or mod_name in (
+ "cli_args",
+ "folder_paths",
+ "execution",
+ "nodes",
+ "server",
+ "comfy_main",
+ ):
+ sys.modules.pop(mod_name, None)
+
+ if cli_args_mod is None:
+ log.debug("bootstrap returned None for comfy.cli_args")
+ _DISCOVERED_OPTIONS = _EMPTY_OPTIONS
+ return _DISCOVERED_OPTIONS
+
+ parser = getattr(cli_args_mod, "parser", None)
+ if parser is None:
+ log.debug("Could not find parser in comfy.cli_args")
+ _DISCOVERED_OPTIONS = _EMPTY_OPTIONS
+ return _DISCOVERED_OPTIONS
+
+ known, value_taking = _parse_parser_actions(parser)
+ _DISCOVERED_OPTIONS = frozenset(known), frozenset(value_taking)
+ return _DISCOVERED_OPTIONS
+
+
+def _get_base_option(token: str) -> str:
+ """Extract base option name from a CLI token.
+
+ Strips inline values from tokens like '--cuda-device=0' to get '--cuda-device'.
+
+ Args:
+ token: A CLI argument string (e.g., '--cuda-device=0' or '--cpu').
+
+ Returns:
+ Base option name without inline value.
+ """
+ if "=" in token:
+ return token.split("=")[0]
+ return token
+
+
+def _filter_comfyui_args(argv: list[str]) -> list[str]:
+ """Filter sys.argv to keep only ComfyUI-recognized CLI arguments.
+
+ When bootstrap runs inside a subprocess (e.g. test runner), sys.argv may
+ contain flags that aren't valid for ComfyUI's argparse. This filters them
+ out so the import doesn't crash while still preserving --cpu and other
+ ComfyUI flags passed by the user.
+
+ Uses _discover_comfyui_cli_options() to dynamically discover recognized
+ options from ComfyUI's parser rather than maintaining a hardcoded list.
+
+ Handles edge cases:
+ - Tokens like '--cuda-device=0' (inline =value)
+ - Single-char flags are always skipped (-v, -s from test runners)
+ - Unknown --flags and their values are dropped
+ - Known option values that look like flags are preserved (not consumed as flags)
+
+ Args:
+ argv: List of command-line argument strings (typically sys.argv).
+
+ Returns:
+ Filtered list containing only recognized ComfyUI arguments.
+ """
+ known, value_taking = _discover_comfyui_cli_options()
+ result = [argv[0]] if argv else []
+
+ i = 1
+ while i < len(argv):
+ token = argv[i]
+
+ # Skip single-char flags (e.g. -v, -s from test runners)
+ if token.startswith("-") and not token.startswith("--"):
+ i += 1
+ continue
+
+ base = _get_base_option(token)
+
+ if base in known:
+ result.append(token)
+ # If option takes a value and it's not inline (=), consume next arg
+ if base in value_taking and "=" not in token:
+ if i + 1 < len(argv):
+ next_token = argv[i + 1]
+ if (
+ next_token.startswith("--")
+ and _get_base_option(next_token) in known
+ ):
+ pass # Next token is itself a flag — don't consume it
+ elif not next_token.startswith("--"):
+ result.append(next_token)
+ i += 1
+ elif token.startswith("--"):
+ if i + 1 < len(argv) and not argv[i + 1].startswith("--"):
+ i += 1
+ else:
+ result.append(token)
+ i += 1
+ return result
diff --git a/comfyui_to_python/runtime/module_loader.py b/comfyui_to_python/runtime/module_loader.py
new file mode 100644
index 0000000..e0a47db
--- /dev/null
+++ b/comfyui_to_python/runtime/module_loader.py
@@ -0,0 +1,118 @@
+"""Module loading via importlib and controlled bootstrap imports.
+
+This module provides two loading strategies:
+ - _load_module(): Load from explicit file path using importlib.util
+ (bypasses sys.path resolution, caches in sys.modules)
+ - _bootstrap_import(): Load via standard __import__() for namespace packages
+ (comfy.* resolves correctly through Python's package machinery)
+
+All modules loaded here are considered trusted — the caller must ensure
+the file paths and module names come from verified sources.
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import logging
+import os
+import sys
+from typing import Any
+
+log = logging.getLogger(__name__)
+
+
+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.
+
+ Args:
+ module_name: Name to register in sys.modules (e.g., "nodes").
+ filepath: Absolute path to the .py file to load.
+
+ Returns:
+ The loaded module object, or None if loading failed.
+ """
+ # 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
+ spec.loader.exec_module(mod)
+ return mod
+ except BaseException as exc:
+ log.debug("Failed to load %s from %s: %s", module_name, filepath, exc)
+ sys.modules.pop(module_name, None)
+ 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.
+
+ Args:
+ module_name: Temporary name for the module (removed after load).
+ filepath: Absolute path to the .py file to load.
+
+ Returns:
+ The loaded module object, or None if loading failed.
+ """
+ mod = _load_module(module_name, filepath)
+ sys.modules.pop(module_name, None)
+ return mod
+
+
+def _bootstrap_import(module_name: str) -> Any:
+ """Import a ComfyUI module using normal import machinery.
+
+ Uses __import__() so namespace packages (e.g. comfy/) resolve correctly.
+ The module remains cached in sys.modules so later re-imports by ComfyUI's
+ internal chain reuse the same instance (including parsed CLI args).
+
+ An allowlist of permitted module name prefixes prevents arbitrary module
+ loading. Only modules whose name starts with one of the allowed prefixes
+ can be imported through this function.
+
+ Args:
+ module_name: Dotted Python module name (e.g., "comfy.cli_args").
+
+ Returns:
+ The loaded module object.
+
+ Raises:
+ ValueError: If the module name is not in the allowed prefix list.
+ ModuleNotFoundError: If the module cannot be imported.
+ """
+ # Validate against allowlist — defense-in-depth against arbitrary imports
+ _ALLOWED = ("comfy", "folder_paths", "execution", "nodes", "server")
+ top_level = module_name.split(".")[0]
+ if not any(
+ top_level == prefix or module_name.startswith(prefix + ".")
+ for prefix in _ALLOWED
+ ):
+ raise ValueError(
+ f"Bootstrap import blocked: '{module_name}' is not in allowed prefixes "
+ f"{list(_ALLOWED)}"
+ )
+
+ # Ensure parent namespace exists for dotted names (namespace package support)
+ parts = module_name.split(".")
+ for i in range(1, len(parts)):
+ parent = ".".join(parts[:i])
+ if parent not in sys.modules:
+ __import__(parent)
+ return __import__(module_name, fromlist=[""])
diff --git a/comfyui_to_python/runtime/path_discovery.py b/comfyui_to_python/runtime/path_discovery.py
new file mode 100644
index 0000000..4f64a1a
--- /dev/null
+++ b/comfyui_to_python/runtime/path_discovery.py
@@ -0,0 +1,143 @@
+"""Path discovery for ComfyUI root and auxiliary files.
+
+This module handles locating the ComfyUI checkout directory and related
+files on disk. It provides multiple resolution strategies with structural
+verification to ensure the target is a genuine ComfyUI installation.
+
+Trust model: The caller must trust that the COMFYUI_PATH environment
+variable or filesystem contents come from a verified source. This module
+verifies structural markers (nodes.py, main.py, comfy/) but does NOT
+verify content integrity of those files.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+
+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.
+
+ Args:
+ path: Directory path to verify.
+
+ Returns:
+ True if all three structural markers exist, False otherwise.
+ """
+ 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 _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. Uses
+ realpath resolution to handle symlinks in custom node directories.
+
+ Returns:
+ Full path to ComfyUI root, or None if not found within 10 levels.
+ """
+ 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. Depth-limited to prevent slow
+ startup on deep trees.
+
+ Args:
+ name: Basename of the file to find (e.g., "extra_model_paths.yaml").
+ max_depth: Maximum directory levels to walk upward (default 20).
+
+ Returns:
+ Full path to the file, or None if not found.
+ """
+ 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:
+ """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 should be verified by the caller
+ (e.g., with _is_comfyui_directory()).
+
+ Args:
+ name: Basename of the directory to find (e.g., "ComfyUI").
+ max_depth: Maximum directory levels to walk upward (default 20).
+
+ Returns:
+ Full path to the matching directory, or None if not found.
+ """
+ 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)
+
+ Returns:
+ Full path to ComfyUI root directory, or None if not found.
+
+ Note:
+ The COMFYUI_PATH environment variable must be set by a trusted source.
+ This module verifies structural markers but does not validate file
+ contents. An attacker who controls the target directory could provide
+ malicious nodes.py/main.py that pass structural checks.
+ """
+ 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
diff --git a/install.py b/install.py
index 8d6ba79..f5a180d 100644
--- a/install.py
+++ b/install.py
@@ -3,18 +3,26 @@
from subprocess import Popen, check_output, PIPE
-requirements = open(os.path.join(os.path.dirname(__file__), "requirements.txt")).read().split("\n")
+requirements = (
+ open(os.path.join(os.path.dirname(__file__), "requirements.txt")).read().split("\n")
+)
installed_packages = check_output(
- [sys.executable, "-m", "pip", "list"],
- universal_newlines=True
+ [sys.executable, "-m", "pip", "list"], universal_newlines=True
).split("\n")
-installed_packages = set([package.split(" ")[0].lower() for package in installed_packages if package.strip()])
+installed_packages = set(
+ [package.split(" ")[0].lower() for package in installed_packages if package.strip()]
+)
for requirement in requirements:
if requirement.lower() not in installed_packages:
print(f"Installing requirements...")
- Popen([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"], stdout=PIPE, stderr=PIPE, cwd=os.path.dirname(__file__)).communicate()
+ Popen(
+ [sys.executable, "-m", "pip", "install", "-r", "requirements.txt"],
+ stdout=PIPE,
+ stderr=PIPE,
+ cwd=os.path.dirname(__file__),
+ ).communicate()
print(f"Installed.")
break
diff --git a/pyproject.toml b/pyproject.toml
index a9e6650..c09df5f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -2,10 +2,16 @@
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 = "2.1.0"
-license = { text = "MIT License" }
+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
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/runtime/__init__.py b/tests/runtime/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/runtime/generated/text-to-image.py b/tests/runtime/generated/text-to-image.py
index 99c66dc..6bed3cd 100644
--- a/tests/runtime/generated/text-to-image.py
+++ b/tests/runtime/generated/text-to-image.py
@@ -1,84 +1,639 @@
# Imports
+import gc
+import importlib.util
import json
+import logging
import os
import random
import sys
+import warnings
from typing import Sequence, Mapping, Any, Union
+log = logging.getLogger(__name__)
+# --- Embedded from runtime/module_loader.py ---
-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]
+"""Module loading via importlib and controlled bootstrap imports.
+This module provides two loading strategies:
+ - _load_module(): Load from explicit file path using importlib.util
+ (bypasses sys.path resolution, caches in sys.modules)
+ - _bootstrap_import(): Load via standard __import__() for namespace packages
+ (comfy.* resolves correctly through Python's package machinery)
-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")
+All modules loaded here are considered trusted — the caller must ensure
+the file paths and module names come from verified sources.
+"""
-def find_path(name: str, path: str = None) -> str:
- """Recursively search parent folders until the named entry is found."""
- if path is None:
- path = os.getcwd()
+def _load_module(module_name: str, filepath: str) -> Any:
+ """Load a Python module from an explicit file path, bypassing sys.path.
- if name in os.listdir(path):
- path_name = os.path.join(path, name)
- print(f"{name} found: {path_name}")
- return path_name
+ 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.
- parent_directory = os.path.dirname(path)
- if parent_directory == path:
+ Args:
+ module_name: Name to register in sys.modules (e.g., "nodes").
+ filepath: Absolute path to the .py file to load.
+
+ Returns:
+ The loaded module object, or None if loading failed.
+ """
+ # 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
+ spec.loader.exec_module(mod)
+ return mod
+ except BaseException as exc:
+ log.debug("Failed to load %s from %s: %s", module_name, filepath, exc)
+ sys.modules.pop(module_name, None)
return None
- return find_path(name, parent_directory)
+
+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.
+
+ Args:
+ module_name: Temporary name for the module (removed after load).
+ filepath: Absolute path to the .py file to load.
+
+ Returns:
+ The loaded module object, or None if loading failed.
+ """
+ mod = _load_module(module_name, filepath)
+ sys.modules.pop(module_name, None)
+ return mod
+
+
+def _bootstrap_import(module_name: str) -> Any:
+ """Import a ComfyUI module using normal import machinery.
+
+ Uses __import__() so namespace packages (e.g. comfy/) resolve correctly.
+ The module remains cached in sys.modules so later re-imports by ComfyUI's
+ internal chain reuse the same instance (including parsed CLI args).
+
+ An allowlist of permitted module name prefixes prevents arbitrary module
+ loading. Only modules whose name starts with one of the allowed prefixes
+ can be imported through this function.
+
+ Args:
+ module_name: Dotted Python module name (e.g., "comfy.cli_args").
+
+ Returns:
+ The loaded module object.
+
+ Raises:
+ ValueError: If the module name is not in the allowed prefix list.
+ ModuleNotFoundError: If the module cannot be imported.
+ """
+ # Validate against allowlist — defense-in-depth against arbitrary imports
+ _ALLOWED = ("comfy", "folder_paths", "execution", "nodes", "server")
+ top_level = module_name.split(".")[0]
+ if not any(
+ top_level == prefix or module_name.startswith(prefix + ".")
+ for prefix in _ALLOWED
+ ):
+ raise ValueError(
+ f"Bootstrap import blocked: '{module_name}' is not in allowed prefixes "
+ f"{list(_ALLOWED)}"
+ )
+
+ # Ensure parent namespace exists for dotted names (namespace package support)
+ parts = module_name.split(".")
+ for i in range(1, len(parts)):
+ parent = ".".join(parts[:i])
+ if parent not in sys.modules:
+ __import__(parent)
+ return __import__(module_name, fromlist=[""])
+
+
+# --- Embedded from runtime/path_discovery.py ---
+
+"""Path discovery for ComfyUI root and auxiliary files.
+
+This module handles locating the ComfyUI checkout directory and related
+files on disk. It provides multiple resolution strategies with structural
+verification to ensure the target is a genuine ComfyUI installation.
+
+Trust model: The caller must trust that the COMFYUI_PATH environment
+variable or filesystem contents come from a verified source. This module
+verifies structural markers (nodes.py, main.py, comfy/) but does NOT
+verify content integrity of those files.
+"""
+
+
+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.
+
+ Args:
+ path: Directory path to verify.
+
+ Returns:
+ True if all three structural markers exist, False otherwise.
+ """
+ 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 _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. Uses
+ realpath resolution to handle symlinks in custom node directories.
+
+ Returns:
+ Full path to ComfyUI root, or None if not found within 10 levels.
+ """
+ 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. Depth-limited to prevent slow
+ startup on deep trees.
+
+ Args:
+ name: Basename of the file to find (e.g., "extra_model_paths.yaml").
+ max_depth: Maximum directory levels to walk upward (default 20).
+
+ Returns:
+ Full path to the file, or None if not found.
+ """
+ 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:
+ """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 should be verified by the caller
+ (e.g., with _is_comfyui_directory()).
+
+ Args:
+ name: Basename of the directory to find (e.g., "ComfyUI").
+ max_depth: Maximum directory levels to walk upward (default 20).
+
+ Returns:
+ Full path to the matching directory, or None if not found.
+ """
+ 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)
+
+ Returns:
+ Full path to ComfyUI root directory, or None if not found.
+
+ Note:
+ The COMFYUI_PATH environment variable must be set by a trusted source.
+ This module verifies structural markers but does not validate file
+ contents. An attacker who controls the target directory could provide
+ malicious nodes.py/main.py that pass structural checks.
+ """
+ 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
+
+
+# --- Embedded from runtime/bootstrap.py ---
+
+"""CLI option discovery and argv filtering helpers.
+
+This module provides internal utilities for:
+ - Dynamic discovery of valid CLI options from ComfyUI's argparse parser
+ - Filtering sys.argv to keep only ComfyUI-recognized flags
+
+These are imported by node_runtime.py which contains the public
+bootstrap_comfyui_runtime() function. Keeping them separate clarifies
+the distinction between data analysis (this module) and runtime execution
+(node_runtime.py).
+"""
+
+
+# Cache for discovered CLI options — populated once, reused thereafter.
+_DISCOVERED_OPTIONS: tuple[frozenset[str], frozenset[str]] | None = None
+
+
+_EMPTY_OPTIONS: tuple[frozenset[str], frozenset[str]] = frozenset(), frozenset()
+
+
+def _parse_parser_actions(parser) -> tuple[set[str], set[str]]:
+ """Parse argparse actions into known options and value-taking subsets.
+
+ Inspects `parser._actions` to extract all recognized option strings and
+ which ones take values. This is the core parsing logic extracted from
+ _discover_comfyui_cli_options() for readability.
+
+ Args:
+ parser: An argparse.ArgumentParser instance with configured actions.
+
+ Returns:
+ Tuple of (known_options, value_taking_options) as sets.
+ """
+ known: set[str] = set()
+ value_taking: set[str] = set()
+ for action in parser._actions:
+ for opt in action.option_strings:
+ if not opt.startswith("--"):
+ continue
+ base = opt.split("[")[0].strip()
+ known.add(base)
+ nargs = getattr(action, "nargs", None)
+ # Skip boolean store_true/store_false actions — they don't take values
+ action_name = type(action).__name__
+ if action_name in ("_StoreTrueAction", "_StoreFalseAction"):
+ continue
+ if nargs is not None and nargs != 0:
+ value_taking.add(base)
+ elif hasattr(action, "const") and action.const is not None:
+ value_taking.add(base)
+ elif getattr(action, "type", None) is not None or nargs is None:
+ if action.dest != "help":
+ value_taking.add(base)
+ return known, value_taking
+
+
+def _discover_comfyui_cli_options() -> tuple[frozenset[str], frozenset[str]]:
+ """Dynamically discover CLI options from ComfyUI's argparse parser.
+
+ Inspects `comfy.cli_args.parser._actions` to extract all recognized
+ option strings and which ones take values. This eliminates the need for
+ a hardcoded list that drifts when ComfyUI adds/removes flags.
+
+ Results are cached after first call. Cache is process-lifetime — it is
+ not automatically invalidated if ComfyUI code changes on disk.
+
+ Returns:
+ Tuple of (known_options, value_taking_options) as frozensets.
+ value_taking_options is a subset of known_options.
+ """
+ global _DISCOVERED_OPTIONS # noqa: PLW0603
+ if _DISCOVERED_OPTIONS is not None:
+ return _DISCOVERED_OPTIONS
+
+ original_argv = sys.argv
+ pre_discovery_modules = set(sys.modules.keys())
+ try:
+ sys.argv = ["_discover"]
+ cli_args_mod = _bootstrap_import("comfy.cli_args")
+ except ModuleNotFoundError:
+ log.debug("comfy.cli_args not available for option discovery")
+ sys.argv = original_argv
+ _DISCOVERED_OPTIONS = _EMPTY_OPTIONS
+ return _DISCOVERED_OPTIONS
+ finally:
+ sys.argv = original_argv
+
+ # Remove ComfyUI modules loaded during discovery so that
+ # bootstrap_comfyui_runtime() can import them fresh with real argv.
+ for mod_name in set(sys.modules.keys()) - pre_discovery_modules:
+ if mod_name.startswith("comfy.") or mod_name in (
+ "cli_args",
+ "folder_paths",
+ "execution",
+ "nodes",
+ "server",
+ "comfy_main",
+ ):
+ sys.modules.pop(mod_name, None)
+
+ if cli_args_mod is None:
+ log.debug("bootstrap returned None for comfy.cli_args")
+ _DISCOVERED_OPTIONS = _EMPTY_OPTIONS
+ return _DISCOVERED_OPTIONS
+
+ parser = getattr(cli_args_mod, "parser", None)
+ if parser is None:
+ log.debug("Could not find parser in comfy.cli_args")
+ _DISCOVERED_OPTIONS = _EMPTY_OPTIONS
+ return _DISCOVERED_OPTIONS
+
+ known, value_taking = _parse_parser_actions(parser)
+ _DISCOVERED_OPTIONS = frozenset(known), frozenset(value_taking)
+ return _DISCOVERED_OPTIONS
+
+
+def _get_base_option(token: str) -> str:
+ """Extract base option name from a CLI token.
+
+ Strips inline values from tokens like '--cuda-device=0' to get '--cuda-device'.
+
+ Args:
+ token: A CLI argument string (e.g., '--cuda-device=0' or '--cpu').
+
+ Returns:
+ Base option name without inline value.
+ """
+ if "=" in token:
+ return token.split("=")[0]
+ return token
+
+
+def _filter_comfyui_args(argv: list[str]) -> list[str]:
+ """Filter sys.argv to keep only ComfyUI-recognized CLI arguments.
+
+ When bootstrap runs inside a subprocess (e.g. test runner), sys.argv may
+ contain flags that aren't valid for ComfyUI's argparse. This filters them
+ out so the import doesn't crash while still preserving --cpu and other
+ ComfyUI flags passed by the user.
+
+ Uses _discover_comfyui_cli_options() to dynamically discover recognized
+ options from ComfyUI's parser rather than maintaining a hardcoded list.
+
+ Handles edge cases:
+ - Tokens like '--cuda-device=0' (inline =value)
+ - Single-char flags are always skipped (-v, -s from test runners)
+ - Unknown --flags and their values are dropped
+ - Known option values that look like flags are preserved (not consumed as flags)
+
+ Args:
+ argv: List of command-line argument strings (typically sys.argv).
+
+ Returns:
+ Filtered list containing only recognized ComfyUI arguments.
+ """
+ known, value_taking = _discover_comfyui_cli_options()
+ result = [argv[0]] if argv else []
+
+ i = 1
+ while i < len(argv):
+ token = argv[i]
+
+ # Skip single-char flags (e.g. -v, -s from test runners)
+ if token.startswith("-") and not token.startswith("--"):
+ i += 1
+ continue
+
+ base = _get_base_option(token)
+
+ if base in known:
+ result.append(token)
+ # If option takes a value and it's not inline (=), consume next arg
+ if base in value_taking and "=" not in token:
+ if i + 1 < len(argv):
+ next_token = argv[i + 1]
+ if (
+ next_token.startswith("--")
+ and _get_base_option(next_token) in known
+ ):
+ pass # Next token is itself a flag — don't consume it
+ elif not next_token.startswith("--"):
+ result.append(next_token)
+ i += 1
+ elif token.startswith("--"):
+ if i + 1 < len(argv) and not argv[i + 1].startswith("--"):
+ i += 1
+ else:
+ result.append(token)
+ i += 1
+ return result
+
+
+# --- Embedded from node_runtime.py ---
+
+"""Node runtime: ComfyUI import path resolution, module loading, and bootstrap.
+
+This module is the public facade for the comfyui_to_python.runtime
+subpackage. All imports that previously came from this file continue to work
+unchanged — the internal implementation has been reorganized into focused
+submodules for clarity.
+
+Submodule structure:
+ runtime/path_discovery.py — Locate ComfyUI root and auxiliary files
+ runtime/module_loader.py — Load Python modules via importlib/bootstrap
+ runtime/bootstrap.py — CLI discovery, argv filtering, runtime init
+
+Public API (import from this module):
+ Path resolution: get_comfyui_path(), find_path(), add_comfyui_directory_to_sys_path()
+ Model paths: add_extra_model_paths()
+ Bootstrap: bootstrap_comfyui_runtime()
+ Cleanup: cleanup_comfyui_runtime()
+ Custom nodes: import_custom_nodes()
+ Node mappings: get_node_class_mappings()
+ Helpers: get_value_at_index()
+
+Internal (prefixed with _): Available for embedding in generated scripts.
+"""
+
+# ── Re-exports from runtime/bootstrap.py ────────────────────────────────────
+# ── Re-exports from runtime/module_loader.py ────────────────────────────────
+# ── Re-exports from runtime/path_discovery.py ────────────────────────────────
+
+# ── Public API ────────────────────────────────────────────────────────────────
+# Re-exported names for import from this module.
+# External code imports these from comfyui_to_python.node_runtime (not submodules).
+__all__: list[str] = [
+ # Path discovery
+ "_find_file",
+ "_find_from_extension_location",
+ "_is_comfyui_directory",
+ "add_comfyui_directory_to_sys_path",
+ "find_path",
+ "get_comfyui_path",
+ # Module loading
+ "_bootstrap_import",
+ "_load_module",
+ "_load_module_temp",
+ # Bootstrap / CLI
+ "_DISCOVERED_OPTIONS",
+ "_discover_comfyui_cli_options",
+ "_filter_comfyui_args",
+ # Public API
+ "add_extra_model_paths",
+ "bootstrap_comfyui_runtime",
+ "cleanup_comfyui_runtime",
+ "get_node_class_mappings",
+ "get_value_at_index",
+ "import_custom_nodes",
+]
+
+
+# ── Public API: sys.path management ─────────────────────────────────────────
def add_comfyui_directory_to_sys_path() -> None:
- """Add the ComfyUI checkout to sys.path."""
+ """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)
- print(f"'{comfyui_path}' added to sys.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."""
- 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."
+ """Load ComfyUI extra model paths configuration when available.
+
+ Attempts to load load_extra_path_config from ComfyUI's main.py,
+ falling back to utils/extra_config.py if main.py is unavailable.
+ Then locates and loads the extra_model_paths.yaml configuration file.
+ """
+ comfyui_path = get_comfyui_path()
+ if comfyui_path is None:
+ log.debug("Cannot load extra model paths: ComfyUI path not found")
+ return
+
+ 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")
)
- from utils.extra_config import load_extra_path_config
+ 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")
+ extra_model_paths = _find_file("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.")
+ log.debug("Could not find the extra_model_paths config file.")
+
+
+# ── Public API: bootstrap (self-contained for generated script embedding) ────
def bootstrap_comfyui_runtime() -> None:
- """Mirror the allocator-related ComfyUI startup steps before torch import."""
+ """Mirror the allocator-related ComfyUI startup steps before torch import.
+
+ Uses normal imports so that parsed CLI args (e.g. --cpu) persist in
+ sys.modules and are reused when ComfyUI's internal chain later imports
+ comfy.cli_args and comfy.options.
+
+ Sequence:
+ 1. Add ComfyUI to sys.path[0]
+ 2. Filter sys.argv to known ComfyUI options
+ 3. Import and enable CLI arg parsing via _bootstrap_import()
+ 4. Force --cpu mode if CUDA is unavailable
+ 5. Apply device, directory, and allocator settings from parsed args
+ 6. Load cuda_malloc.py for ROCm detection (temp module)
+ """
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
- import comfy.options
+ # Filter sys.argv to keep only ComfyUI-recognized flags. This prevents
+ # argparse crashes when bootstrap runs inside a subprocess (e.g. test
+ # runner) where sys.argv contains non-ComfyUI arguments.
+ original_argv = sys.argv
+ try:
+ sys.argv = _filter_comfyui_args(sys.argv)
+
+ # Load via _bootstrap_import() for namespace-package-safe imports.
+ options_mod = _bootstrap_import("comfy.options")
+ if options_mod is not None:
+ options_mod.enable_args_parsing()
- comfy.options.enable_args_parsing()
+ cli_args_mod = _bootstrap_import("comfy.cli_args")
+ finally:
+ # Restore original argv so downstream and error paths see real inputs.
+ sys.argv = original_argv
- from comfy.cli_args import args
+ args = getattr(cli_args_mod, "args", None) if cli_args_mod else None
if os.name == "nt":
os.environ["MIMALLOC_PURGE_DELAY"] = "0"
+ # Guard all args access — args may be None during export path
+ if args is not None:
+ _apply_device_settings(args)
+ _apply_directory_overrides(args)
+
+ 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 _apply_device_settings(args: Any) -> None:
+ """Apply GPU device settings from parsed CLI arguments.
+
+ Sets environment variables for CUDA, HIP, and oneAPI device selection
+ based on the user's command-line arguments.
+ """
if args.default_device is not None:
default_dev = args.default_device
devices = list(range(32))
@@ -99,15 +654,39 @@ def bootstrap_comfyui_runtime() -> None:
if args.deterministic and "CUBLAS_WORKSPACE_CONFIG" not in os.environ:
os.environ["CUBLAS_WORKSPACE_CONFIG"] = ":4096:8"
- import cuda_malloc
- if "rocm" in cuda_malloc.get_torch_version_noimport():
- os.environ["OCL_SET_SVM_SIZE"] = "262144"
+def _apply_directory_overrides(args: Any) -> None:
+ """Apply output/input/user directory overrides from CLI arguments.
+
+ Redirects ComfyUI's default directories to user-specified paths,
+ enabling operation when default mounts are read-only.
+ """
+ folder_paths_mod = _bootstrap_import("folder_paths")
+ if folder_paths_mod is None:
+ return
+
+ if args.output_directory and hasattr(folder_paths_mod, "set_output_directory"):
+ folder_paths_mod.set_output_directory(os.path.abspath(args.output_directory))
+ if args.input_directory and hasattr(folder_paths_mod, "set_input_directory"):
+ folder_paths_mod.set_input_directory(os.path.abspath(args.input_directory))
+ if args.user_directory and hasattr(folder_paths_mod, "set_user_directory"):
+ folder_paths_mod.set_user_directory(os.path.abspath(args.user_directory))
+
+
+# ── Public API: cleanup ─────────────────────────────────────────────────────
def cleanup_comfyui_runtime(unload_models: bool | None = None) -> None:
- """Best-effort cleanup for embedded or repeated generated-script execution."""
- import gc
+ """Best-effort cleanup for embedded or repeated generated-script execution.
+
+ Runs ComfyUI's model cleanup hooks and garbage collection. Designed for
+ scenarios where the script is executed repeatedly within a single process
+ (e.g., notebook cells, test runners).
+
+ Args:
+ unload_models: Force model unloading. If None, reads from
+ COMFYUI_TOPYTHON_UNLOAD_MODELS environment variable.
+ """
def run_cleanup_hook(name: str, should_run: bool = True) -> None:
if not should_run or not hasattr(model_management, name):
@@ -126,15 +705,10 @@ def run_cleanup_hook(name: str, should_run: bool = True) -> None:
if should_unload is None:
should_unload = os.environ.get(
"COMFYUI_TOPYTHON_UNLOAD_MODELS", ""
- ).lower() in {
- "1",
- "true",
- "yes",
- "on",
- }
+ ).lower() in {"1", "true", "yes", "on"}
try:
- import comfy.model_management as model_management
+ import comfy.model_management as model_management # noqa: PLC0414
except ModuleNotFoundError:
gc.collect()
return
@@ -145,6 +719,156 @@ def run_cleanup_hook(name: str, should_run: bool = True) -> None:
gc.collect()
+# ── Public API: custom nodes ────────────────────────────────────────────────
+
+
+def _load_custom_node_modules(
+ comfyui_path: str,
+) -> tuple[Any, Any, Any]:
+ """Load the three core ComfyUI modules for custom node initialization.
+
+ Loads execution.py, nodes.py, and server.py from the ComfyUI checkout.
+ Temporarily filters out comfy/ subdirectory from sys.path to prevent
+ import shadowing when loading server.py.
+
+ Args:
+ comfyui_path: Absolute path to the ComfyUI root directory.
+
+ Returns:
+ Tuple of (execution_mod, nodes_mod, server_mod). Any may be None
+ if the corresponding module could not be loaded.
+ """
+ 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.
+ sys.path[:] = original_sys_path
+
+ return execution_mod, nodes_mod, server_mod
+
+
+def _init_extra_nodes(nodes_mod: Any) -> None:
+ """Call init_extra_nodes on the nodes module if available.
+
+ Args:
+ nodes_mod: The loaded nodes module (may be None).
+ """
+ import asyncio
+
+ if nodes_mod is not None and hasattr(nodes_mod, "init_extra_nodes"):
+ asyncio.run(nodes_mod.init_extra_nodes())
+
+
+def import_custom_nodes() -> None:
+ """Initialize ComfyUI custom nodes in the exporter runtime.
+
+ Loads execution.py, nodes.py, and server.py from the ComfyUI checkout.
+ Sets up PromptServer and PromptQueue for the async event loop.
+ Calls init_extra_nodes() to populate NODE_CLASS_MAPPINGS with extras.
+
+ Uses _load_module() for all ComfyUI imports — no bare imports,
+ no sys.path remove/re-insert gap.
+ """
+ import asyncio
+
+ 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)
+
+ execution_mod, nodes_mod, server_mod = _load_custom_node_modules(comfyui_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."
+ )
+ _init_extra_nodes(nodes_mod)
+ return
+
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ server_instance = server_mod.PromptServer(loop)
+ execution_mod.PromptQueue(server_instance)
+ _init_extra_nodes(nodes_mod)
+
+
+# ── Public API: node mappings ───────────────────────────────────────────────
+
+
+def get_node_class_mappings() -> dict:
+ """Load ComfyUI node mappings on demand via _load_module().
+
+ Calls bootstrap_comfyui_runtime() first so that CLI args (e.g. --cpu)
+ are parsed and cached in sys.modules before nodes.py triggers the
+ comfy.cli_args import chain. This prevents CUDA init crashes on
+ systems without a GPU.
+
+ Reuses the cached "nodes" module from sys.modules if already loaded
+ (e.g. by import_custom_nodes) to avoid resetting NODE_CLASS_MAPPINGS.
+
+ Returns:
+ Dictionary mapping node class names to class objects, or empty dict
+ if nodes module could not be loaded.
+ """
+ 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 {}
+
+ # Ensure bootstrap has run so that parsed CLI args are cached in
+ # sys.modules before nodes.py triggers the comfy import chain.
+ bootstrap_comfyui_runtime()
+
+ # 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", {})
+
+
+# ── Public API: helpers ─────────────────────────────────────────────────────
+
+
+def get_value_at_index(obj: Union[Sequence, Mapping], index: int) -> Any:
+ """Return a sequence or mapping result item by index.
+
+ Used in generated scripts to extract items from ComfyUI node outputs.
+ Supports both list indexing and dict key access with "result" fallback.
+
+ Args:
+ obj: A sequence (list, tuple) or mapping (dict) to index into.
+ index: Integer index for sequences, or dict key / numeric fallback.
+
+ Returns:
+ The item at the given index, or the result[index] value for dicts.
+ """
+ try:
+ return obj[index]
+ except KeyError:
+ return obj["result"][index]
+
+
# Workflow data
def build_workflow() -> dict[str, Any]:
return {
diff --git a/tests/runtime/generated/upscale-model-loader.py b/tests/runtime/generated/upscale-model-loader.py
index 98ed0ae..b90365f 100644
--- a/tests/runtime/generated/upscale-model-loader.py
+++ b/tests/runtime/generated/upscale-model-loader.py
@@ -1,84 +1,639 @@
# Imports
+import gc
+import importlib.util
import json
+import logging
import os
import random
import sys
+import warnings
from typing import Sequence, Mapping, Any, Union
+log = logging.getLogger(__name__)
+# --- Embedded from runtime/module_loader.py ---
-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]
+"""Module loading via importlib and controlled bootstrap imports.
+This module provides two loading strategies:
+ - _load_module(): Load from explicit file path using importlib.util
+ (bypasses sys.path resolution, caches in sys.modules)
+ - _bootstrap_import(): Load via standard __import__() for namespace packages
+ (comfy.* resolves correctly through Python's package machinery)
-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")
+All modules loaded here are considered trusted — the caller must ensure
+the file paths and module names come from verified sources.
+"""
-def find_path(name: str, path: str = None) -> str:
- """Recursively search parent folders until the named entry is found."""
- if path is None:
- path = os.getcwd()
+def _load_module(module_name: str, filepath: str) -> Any:
+ """Load a Python module from an explicit file path, bypassing sys.path.
- if name in os.listdir(path):
- path_name = os.path.join(path, name)
- print(f"{name} found: {path_name}")
- return path_name
+ 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.
- parent_directory = os.path.dirname(path)
- if parent_directory == path:
+ Args:
+ module_name: Name to register in sys.modules (e.g., "nodes").
+ filepath: Absolute path to the .py file to load.
+
+ Returns:
+ The loaded module object, or None if loading failed.
+ """
+ # 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
+ spec.loader.exec_module(mod)
+ return mod
+ except BaseException as exc:
+ log.debug("Failed to load %s from %s: %s", module_name, filepath, exc)
+ sys.modules.pop(module_name, None)
return None
- return find_path(name, parent_directory)
+
+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.
+
+ Args:
+ module_name: Temporary name for the module (removed after load).
+ filepath: Absolute path to the .py file to load.
+
+ Returns:
+ The loaded module object, or None if loading failed.
+ """
+ mod = _load_module(module_name, filepath)
+ sys.modules.pop(module_name, None)
+ return mod
+
+
+def _bootstrap_import(module_name: str) -> Any:
+ """Import a ComfyUI module using normal import machinery.
+
+ Uses __import__() so namespace packages (e.g. comfy/) resolve correctly.
+ The module remains cached in sys.modules so later re-imports by ComfyUI's
+ internal chain reuse the same instance (including parsed CLI args).
+
+ An allowlist of permitted module name prefixes prevents arbitrary module
+ loading. Only modules whose name starts with one of the allowed prefixes
+ can be imported through this function.
+
+ Args:
+ module_name: Dotted Python module name (e.g., "comfy.cli_args").
+
+ Returns:
+ The loaded module object.
+
+ Raises:
+ ValueError: If the module name is not in the allowed prefix list.
+ ModuleNotFoundError: If the module cannot be imported.
+ """
+ # Validate against allowlist — defense-in-depth against arbitrary imports
+ _ALLOWED = ("comfy", "folder_paths", "execution", "nodes", "server")
+ top_level = module_name.split(".")[0]
+ if not any(
+ top_level == prefix or module_name.startswith(prefix + ".")
+ for prefix in _ALLOWED
+ ):
+ raise ValueError(
+ f"Bootstrap import blocked: '{module_name}' is not in allowed prefixes "
+ f"{list(_ALLOWED)}"
+ )
+
+ # Ensure parent namespace exists for dotted names (namespace package support)
+ parts = module_name.split(".")
+ for i in range(1, len(parts)):
+ parent = ".".join(parts[:i])
+ if parent not in sys.modules:
+ __import__(parent)
+ return __import__(module_name, fromlist=[""])
+
+
+# --- Embedded from runtime/path_discovery.py ---
+
+"""Path discovery for ComfyUI root and auxiliary files.
+
+This module handles locating the ComfyUI checkout directory and related
+files on disk. It provides multiple resolution strategies with structural
+verification to ensure the target is a genuine ComfyUI installation.
+
+Trust model: The caller must trust that the COMFYUI_PATH environment
+variable or filesystem contents come from a verified source. This module
+verifies structural markers (nodes.py, main.py, comfy/) but does NOT
+verify content integrity of those files.
+"""
+
+
+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.
+
+ Args:
+ path: Directory path to verify.
+
+ Returns:
+ True if all three structural markers exist, False otherwise.
+ """
+ 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 _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. Uses
+ realpath resolution to handle symlinks in custom node directories.
+
+ Returns:
+ Full path to ComfyUI root, or None if not found within 10 levels.
+ """
+ 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. Depth-limited to prevent slow
+ startup on deep trees.
+
+ Args:
+ name: Basename of the file to find (e.g., "extra_model_paths.yaml").
+ max_depth: Maximum directory levels to walk upward (default 20).
+
+ Returns:
+ Full path to the file, or None if not found.
+ """
+ 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:
+ """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 should be verified by the caller
+ (e.g., with _is_comfyui_directory()).
+
+ Args:
+ name: Basename of the directory to find (e.g., "ComfyUI").
+ max_depth: Maximum directory levels to walk upward (default 20).
+
+ Returns:
+ Full path to the matching directory, or None if not found.
+ """
+ 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)
+
+ Returns:
+ Full path to ComfyUI root directory, or None if not found.
+
+ Note:
+ The COMFYUI_PATH environment variable must be set by a trusted source.
+ This module verifies structural markers but does not validate file
+ contents. An attacker who controls the target directory could provide
+ malicious nodes.py/main.py that pass structural checks.
+ """
+ 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
+
+
+# --- Embedded from runtime/bootstrap.py ---
+
+"""CLI option discovery and argv filtering helpers.
+
+This module provides internal utilities for:
+ - Dynamic discovery of valid CLI options from ComfyUI's argparse parser
+ - Filtering sys.argv to keep only ComfyUI-recognized flags
+
+These are imported by node_runtime.py which contains the public
+bootstrap_comfyui_runtime() function. Keeping them separate clarifies
+the distinction between data analysis (this module) and runtime execution
+(node_runtime.py).
+"""
+
+
+# Cache for discovered CLI options — populated once, reused thereafter.
+_DISCOVERED_OPTIONS: tuple[frozenset[str], frozenset[str]] | None = None
+
+
+_EMPTY_OPTIONS: tuple[frozenset[str], frozenset[str]] = frozenset(), frozenset()
+
+
+def _parse_parser_actions(parser) -> tuple[set[str], set[str]]:
+ """Parse argparse actions into known options and value-taking subsets.
+
+ Inspects `parser._actions` to extract all recognized option strings and
+ which ones take values. This is the core parsing logic extracted from
+ _discover_comfyui_cli_options() for readability.
+
+ Args:
+ parser: An argparse.ArgumentParser instance with configured actions.
+
+ Returns:
+ Tuple of (known_options, value_taking_options) as sets.
+ """
+ known: set[str] = set()
+ value_taking: set[str] = set()
+ for action in parser._actions:
+ for opt in action.option_strings:
+ if not opt.startswith("--"):
+ continue
+ base = opt.split("[")[0].strip()
+ known.add(base)
+ nargs = getattr(action, "nargs", None)
+ # Skip boolean store_true/store_false actions — they don't take values
+ action_name = type(action).__name__
+ if action_name in ("_StoreTrueAction", "_StoreFalseAction"):
+ continue
+ if nargs is not None and nargs != 0:
+ value_taking.add(base)
+ elif hasattr(action, "const") and action.const is not None:
+ value_taking.add(base)
+ elif getattr(action, "type", None) is not None or nargs is None:
+ if action.dest != "help":
+ value_taking.add(base)
+ return known, value_taking
+
+
+def _discover_comfyui_cli_options() -> tuple[frozenset[str], frozenset[str]]:
+ """Dynamically discover CLI options from ComfyUI's argparse parser.
+
+ Inspects `comfy.cli_args.parser._actions` to extract all recognized
+ option strings and which ones take values. This eliminates the need for
+ a hardcoded list that drifts when ComfyUI adds/removes flags.
+
+ Results are cached after first call. Cache is process-lifetime — it is
+ not automatically invalidated if ComfyUI code changes on disk.
+
+ Returns:
+ Tuple of (known_options, value_taking_options) as frozensets.
+ value_taking_options is a subset of known_options.
+ """
+ global _DISCOVERED_OPTIONS # noqa: PLW0603
+ if _DISCOVERED_OPTIONS is not None:
+ return _DISCOVERED_OPTIONS
+
+ original_argv = sys.argv
+ pre_discovery_modules = set(sys.modules.keys())
+ try:
+ sys.argv = ["_discover"]
+ cli_args_mod = _bootstrap_import("comfy.cli_args")
+ except ModuleNotFoundError:
+ log.debug("comfy.cli_args not available for option discovery")
+ sys.argv = original_argv
+ _DISCOVERED_OPTIONS = _EMPTY_OPTIONS
+ return _DISCOVERED_OPTIONS
+ finally:
+ sys.argv = original_argv
+
+ # Remove ComfyUI modules loaded during discovery so that
+ # bootstrap_comfyui_runtime() can import them fresh with real argv.
+ for mod_name in set(sys.modules.keys()) - pre_discovery_modules:
+ if mod_name.startswith("comfy.") or mod_name in (
+ "cli_args",
+ "folder_paths",
+ "execution",
+ "nodes",
+ "server",
+ "comfy_main",
+ ):
+ sys.modules.pop(mod_name, None)
+
+ if cli_args_mod is None:
+ log.debug("bootstrap returned None for comfy.cli_args")
+ _DISCOVERED_OPTIONS = _EMPTY_OPTIONS
+ return _DISCOVERED_OPTIONS
+
+ parser = getattr(cli_args_mod, "parser", None)
+ if parser is None:
+ log.debug("Could not find parser in comfy.cli_args")
+ _DISCOVERED_OPTIONS = _EMPTY_OPTIONS
+ return _DISCOVERED_OPTIONS
+
+ known, value_taking = _parse_parser_actions(parser)
+ _DISCOVERED_OPTIONS = frozenset(known), frozenset(value_taking)
+ return _DISCOVERED_OPTIONS
+
+
+def _get_base_option(token: str) -> str:
+ """Extract base option name from a CLI token.
+
+ Strips inline values from tokens like '--cuda-device=0' to get '--cuda-device'.
+
+ Args:
+ token: A CLI argument string (e.g., '--cuda-device=0' or '--cpu').
+
+ Returns:
+ Base option name without inline value.
+ """
+ if "=" in token:
+ return token.split("=")[0]
+ return token
+
+
+def _filter_comfyui_args(argv: list[str]) -> list[str]:
+ """Filter sys.argv to keep only ComfyUI-recognized CLI arguments.
+
+ When bootstrap runs inside a subprocess (e.g. test runner), sys.argv may
+ contain flags that aren't valid for ComfyUI's argparse. This filters them
+ out so the import doesn't crash while still preserving --cpu and other
+ ComfyUI flags passed by the user.
+
+ Uses _discover_comfyui_cli_options() to dynamically discover recognized
+ options from ComfyUI's parser rather than maintaining a hardcoded list.
+
+ Handles edge cases:
+ - Tokens like '--cuda-device=0' (inline =value)
+ - Single-char flags are always skipped (-v, -s from test runners)
+ - Unknown --flags and their values are dropped
+ - Known option values that look like flags are preserved (not consumed as flags)
+
+ Args:
+ argv: List of command-line argument strings (typically sys.argv).
+
+ Returns:
+ Filtered list containing only recognized ComfyUI arguments.
+ """
+ known, value_taking = _discover_comfyui_cli_options()
+ result = [argv[0]] if argv else []
+
+ i = 1
+ while i < len(argv):
+ token = argv[i]
+
+ # Skip single-char flags (e.g. -v, -s from test runners)
+ if token.startswith("-") and not token.startswith("--"):
+ i += 1
+ continue
+
+ base = _get_base_option(token)
+
+ if base in known:
+ result.append(token)
+ # If option takes a value and it's not inline (=), consume next arg
+ if base in value_taking and "=" not in token:
+ if i + 1 < len(argv):
+ next_token = argv[i + 1]
+ if (
+ next_token.startswith("--")
+ and _get_base_option(next_token) in known
+ ):
+ pass # Next token is itself a flag — don't consume it
+ elif not next_token.startswith("--"):
+ result.append(next_token)
+ i += 1
+ elif token.startswith("--"):
+ if i + 1 < len(argv) and not argv[i + 1].startswith("--"):
+ i += 1
+ else:
+ result.append(token)
+ i += 1
+ return result
+
+
+# --- Embedded from node_runtime.py ---
+
+"""Node runtime: ComfyUI import path resolution, module loading, and bootstrap.
+
+This module is the public facade for the comfyui_to_python.runtime
+subpackage. All imports that previously came from this file continue to work
+unchanged — the internal implementation has been reorganized into focused
+submodules for clarity.
+
+Submodule structure:
+ runtime/path_discovery.py — Locate ComfyUI root and auxiliary files
+ runtime/module_loader.py — Load Python modules via importlib/bootstrap
+ runtime/bootstrap.py — CLI discovery, argv filtering, runtime init
+
+Public API (import from this module):
+ Path resolution: get_comfyui_path(), find_path(), add_comfyui_directory_to_sys_path()
+ Model paths: add_extra_model_paths()
+ Bootstrap: bootstrap_comfyui_runtime()
+ Cleanup: cleanup_comfyui_runtime()
+ Custom nodes: import_custom_nodes()
+ Node mappings: get_node_class_mappings()
+ Helpers: get_value_at_index()
+
+Internal (prefixed with _): Available for embedding in generated scripts.
+"""
+
+# ── Re-exports from runtime/bootstrap.py ────────────────────────────────────
+# ── Re-exports from runtime/module_loader.py ────────────────────────────────
+# ── Re-exports from runtime/path_discovery.py ────────────────────────────────
+
+# ── Public API ────────────────────────────────────────────────────────────────
+# Re-exported names for import from this module.
+# External code imports these from comfyui_to_python.node_runtime (not submodules).
+__all__: list[str] = [
+ # Path discovery
+ "_find_file",
+ "_find_from_extension_location",
+ "_is_comfyui_directory",
+ "add_comfyui_directory_to_sys_path",
+ "find_path",
+ "get_comfyui_path",
+ # Module loading
+ "_bootstrap_import",
+ "_load_module",
+ "_load_module_temp",
+ # Bootstrap / CLI
+ "_DISCOVERED_OPTIONS",
+ "_discover_comfyui_cli_options",
+ "_filter_comfyui_args",
+ # Public API
+ "add_extra_model_paths",
+ "bootstrap_comfyui_runtime",
+ "cleanup_comfyui_runtime",
+ "get_node_class_mappings",
+ "get_value_at_index",
+ "import_custom_nodes",
+]
+
+
+# ── Public API: sys.path management ─────────────────────────────────────────
def add_comfyui_directory_to_sys_path() -> None:
- """Add the ComfyUI checkout to sys.path."""
+ """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)
- print(f"'{comfyui_path}' added to sys.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."""
- 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
+ """Load ComfyUI extra model paths configuration when available.
- extra_model_paths = find_path("extra_model_paths.yaml")
+ Attempts to load load_extra_path_config from ComfyUI's main.py,
+ falling back to utils/extra_config.py if main.py is unavailable.
+ Then locates and loads the extra_model_paths.yaml configuration file.
+ """
+ comfyui_path = get_comfyui_path()
+ if comfyui_path is None:
+ log.debug("Cannot load extra model paths: ComfyUI path not found")
+ return
+
+ 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:
- print("Could not find the extra_model_paths config file.")
+ log.debug("Could not find the extra_model_paths config file.")
+
+
+# ── Public API: bootstrap (self-contained for generated script embedding) ────
def bootstrap_comfyui_runtime() -> None:
- """Mirror the allocator-related ComfyUI startup steps before torch import."""
+ """Mirror the allocator-related ComfyUI startup steps before torch import.
+
+ Uses normal imports so that parsed CLI args (e.g. --cpu) persist in
+ sys.modules and are reused when ComfyUI's internal chain later imports
+ comfy.cli_args and comfy.options.
+
+ Sequence:
+ 1. Add ComfyUI to sys.path[0]
+ 2. Filter sys.argv to known ComfyUI options
+ 3. Import and enable CLI arg parsing via _bootstrap_import()
+ 4. Force --cpu mode if CUDA is unavailable
+ 5. Apply device, directory, and allocator settings from parsed args
+ 6. Load cuda_malloc.py for ROCm detection (temp module)
+ """
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
- import comfy.options
+ # Filter sys.argv to keep only ComfyUI-recognized flags. This prevents
+ # argparse crashes when bootstrap runs inside a subprocess (e.g. test
+ # runner) where sys.argv contains non-ComfyUI arguments.
+ original_argv = sys.argv
+ try:
+ sys.argv = _filter_comfyui_args(sys.argv)
- comfy.options.enable_args_parsing()
+ # Load via _bootstrap_import() for namespace-package-safe imports.
+ options_mod = _bootstrap_import("comfy.options")
+ if options_mod is not None:
+ options_mod.enable_args_parsing()
- from comfy.cli_args import args
+ cli_args_mod = _bootstrap_import("comfy.cli_args")
+ finally:
+ # Restore original argv so downstream and error paths see real inputs.
+ sys.argv = original_argv
+
+ args = getattr(cli_args_mod, "args", None) if cli_args_mod else None
if os.name == "nt":
os.environ["MIMALLOC_PURGE_DELAY"] = "0"
+ # Guard all args access — args may be None during export path
+ if args is not None:
+ _apply_device_settings(args)
+ _apply_directory_overrides(args)
+
+ 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 _apply_device_settings(args: Any) -> None:
+ """Apply GPU device settings from parsed CLI arguments.
+
+ Sets environment variables for CUDA, HIP, and oneAPI device selection
+ based on the user's command-line arguments.
+ """
if args.default_device is not None:
default_dev = args.default_device
devices = list(range(32))
@@ -99,63 +654,257 @@ def bootstrap_comfyui_runtime() -> None:
if args.deterministic and "CUBLAS_WORKSPACE_CONFIG" not in os.environ:
os.environ["CUBLAS_WORKSPACE_CONFIG"] = ":4096:8"
- import cuda_malloc
- if "rocm" in cuda_malloc.get_torch_version_noimport():
- os.environ["OCL_SET_SVM_SIZE"] = "262144"
+def _apply_directory_overrides(args: Any) -> None:
+ """Apply output/input/user directory overrides from CLI arguments.
+
+ Redirects ComfyUI's default directories to user-specified paths,
+ enabling operation when default mounts are read-only.
+ """
+ folder_paths_mod = _bootstrap_import("folder_paths")
+ if folder_paths_mod is None:
+ return
+
+ if args.output_directory and hasattr(folder_paths_mod, "set_output_directory"):
+ folder_paths_mod.set_output_directory(os.path.abspath(args.output_directory))
+ if args.input_directory and hasattr(folder_paths_mod, "set_input_directory"):
+ folder_paths_mod.set_input_directory(os.path.abspath(args.input_directory))
+ if args.user_directory and hasattr(folder_paths_mod, "set_user_directory"):
+ folder_paths_mod.set_user_directory(os.path.abspath(args.user_directory))
+
+
+# ── Public API: cleanup ─────────────────────────────────────────────────────
def cleanup_comfyui_runtime(unload_models: bool | None = None) -> None:
- """Best-effort cleanup for embedded or repeated generated-script execution."""
- import gc
+ """Best-effort cleanup for embedded or repeated generated-script execution.
+
+ Runs ComfyUI's model cleanup hooks and garbage collection. Designed for
+ scenarios where the script is executed repeatedly within a single process
+ (e.g., notebook cells, test runners).
+
+ Args:
+ unload_models: Force model unloading. If None, reads from
+ COMFYUI_TOPYTHON_UNLOAD_MODELS environment variable.
+ """
+
+ 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",
- }
+ ).lower() in {"1", "true", "yes", "on"}
try:
- import comfy.model_management as model_management
+ import comfy.model_management as model_management # noqa: PLC0414
except ModuleNotFoundError:
gc.collect()
return
- if hasattr(model_management, "cleanup_models_gc"):
- model_management.cleanup_models_gc()
- if should_unload and hasattr(model_management, "unload_all_models"):
- model_management.unload_all_models()
- if hasattr(model_management, "soft_empty_cache"):
- model_management.soft_empty_cache()
+ run_cleanup_hook("cleanup_models_gc")
+ run_cleanup_hook("unload_all_models", should_run=should_unload)
+ run_cleanup_hook("soft_empty_cache")
gc.collect()
+# ── Public API: custom nodes ────────────────────────────────────────────────
+
+
+def _load_custom_node_modules(
+ comfyui_path: str,
+) -> tuple[Any, Any, Any]:
+ """Load the three core ComfyUI modules for custom node initialization.
+
+ Loads execution.py, nodes.py, and server.py from the ComfyUI checkout.
+ Temporarily filters out comfy/ subdirectory from sys.path to prevent
+ import shadowing when loading server.py.
+
+ Args:
+ comfyui_path: Absolute path to the ComfyUI root directory.
+
+ Returns:
+ Tuple of (execution_mod, nodes_mod, server_mod). Any may be None
+ if the corresponding module could not be loaded.
+ """
+ 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.
+ sys.path[:] = original_sys_path
+
+ return execution_mod, nodes_mod, server_mod
+
+
+def _init_extra_nodes(nodes_mod: Any) -> None:
+ """Call init_extra_nodes on the nodes module if available.
+
+ Args:
+ nodes_mod: The loaded nodes module (may be None).
+ """
+ import asyncio
+
+ if nodes_mod is not None and hasattr(nodes_mod, "init_extra_nodes"):
+ asyncio.run(nodes_mod.init_extra_nodes())
+
+
def import_custom_nodes() -> None:
- """Initialize ComfyUI custom nodes in the exporter runtime."""
+ """Initialize ComfyUI custom nodes in the exporter runtime.
+
+ Loads execution.py, nodes.py, and server.py from the ComfyUI checkout.
+ Sets up PromptServer and PromptQueue for the async event loop.
+ Calls init_extra_nodes() to populate NODE_CLASS_MAPPINGS with extras.
+
+ Uses _load_module() for all ComfyUI imports — no bare imports,
+ no sys.path remove/re-insert gap.
+ """
+ import asyncio
+
comfyui_path = get_comfyui_path()
- if comfyui_path and comfyui_path not in sys.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)
+ execution_mod, nodes_mod, server_mod = _load_custom_node_modules(comfyui_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."
+ )
+ _init_extra_nodes(nodes_mod)
+ return
+
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ server_instance = server_mod.PromptServer(loop)
+ execution_mod.PromptQueue(server_instance)
+ _init_extra_nodes(nodes_mod)
+
+
+# ── Public API: node mappings ───────────────────────────────────────────────
+
+
+def get_node_class_mappings() -> dict:
+ """Load ComfyUI node mappings on demand via _load_module().
+
+ Calls bootstrap_comfyui_runtime() first so that CLI args (e.g. --cpu)
+ are parsed and cached in sys.modules before nodes.py triggers the
+ comfy.cli_args import chain. This prevents CUDA init crashes on
+ systems without a GPU.
+
+ Reuses the cached "nodes" module from sys.modules if already loaded
+ (e.g. by import_custom_nodes) to avoid resetting NODE_CLASS_MAPPINGS.
+
+ Returns:
+ Dictionary mapping node class names to class objects, or empty dict
+ if nodes module could not be loaded.
+ """
+ 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 {}
+
+ # Ensure bootstrap has run so that parsed CLI args are cached in
+ # sys.modules before nodes.py triggers the comfy import chain.
+ bootstrap_comfyui_runtime()
+
+ # 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", {})
+
+
+# ── Public API: helpers ─────────────────────────────────────────────────────
+
+
+def get_value_at_index(obj: Union[Sequence, Mapping], index: int) -> Any:
+ """Return a sequence or mapping result item by index.
+
+ Used in generated scripts to extract items from ComfyUI node outputs.
+ Supports both list indexing and dict key access with "result" fallback.
+
+ Args:
+ obj: A sequence (list, tuple) or mapping (dict) to index into.
+ index: Integer index for sequences, or dict key / numeric fallback.
+
+ Returns:
+ The item at the given index, or the result[index] value for dicts.
+ """
+ try:
+ return obj[index]
+ except KeyError:
+ return obj["result"][index]
+
+
+def import_custom_nodes() -> None:
+ """Initialize ComfyUI custom nodes in the exporter runtime.
+
+ Loads execution.py, nodes.py, and server.py from the ComfyUI checkout.
+ Sets up PromptServer and PromptQueue for the async event loop.
+ Calls init_extra_nodes() to populate NODE_CLASS_MAPPINGS with extras.
+
+ Uses _load_module() for all ComfyUI imports — no bare imports,
+ no sys.path remove/re-insert gap.
+ """
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)
+ comfyui_path = get_comfyui_path()
+ if comfyui_path is None:
+ log.debug("import_custom_nodes: ComfyUI path not found")
+ return
- import server
+ # Idempotent insert-once — never removes from sys.path
+ if comfyui_path not in sys.path:
+ sys.path.insert(0, comfyui_path)
+
+ execution_mod, nodes_mod, server_mod = _load_custom_node_modules(comfyui_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."
+ )
+ _init_extra_nodes(nodes_mod)
+ return
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
- server_instance = server.PromptServer(loop)
- execution.PromptQueue(server_instance)
- asyncio.run(init_extra_nodes())
+ server_instance = server_mod.PromptServer(loop)
+ execution_mod.PromptQueue(server_instance)
+ _init_extra_nodes(nodes_mod)
# Workflow data
diff --git a/tests/runtime/run_runtime_validation.py b/tests/runtime/run_runtime_validation.py
index 235f99a..14694be 100644
--- a/tests/runtime/run_runtime_validation.py
+++ b/tests/runtime/run_runtime_validation.py
@@ -3,6 +3,7 @@
import json
import os
import shutil
+import signal
import struct
import subprocess
import sys
@@ -18,6 +19,7 @@
GENERATED_DIR = ROOT / "tests" / "runtime" / "generated"
COMFYUI_OUTPUT_DIRNAME = "output"
COMFYUI_INPUT_DIRNAME = "input"
+PINNED_RUNTIME_ROOT = Path("/opt/ComfyUI")
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
@@ -320,9 +322,16 @@ def parse_args() -> argparse.Namespace:
"--generated-path",
help=argparse.SUPPRESS,
)
+ parser.add_argument(
+ "--check-stale",
+ action="store_true",
+ help="Check if committed generated scripts match current generator output.",
+ )
args = parser.parse_args()
- if not args.internal_export and not args.tier:
- parser.error("--tier is required unless --internal-export is used.")
+ if not args.internal_export and not args.tier and not args.check_stale:
+ parser.error(
+ "--tier or --check-stale is required unless --internal-export is used."
+ )
return args
@@ -348,7 +357,19 @@ def ensure_runtime_path(tier: str) -> str:
"parent directory contains ComfyUI.",
)
- return str(runtime_path)
+ return _ensure_pinned_runtime_path(runtime_path)
+
+
+def _ensure_pinned_runtime_path(runtime_path: str) -> str:
+ resolved_runtime = Path(runtime_path).resolve()
+ expected_runtime = PINNED_RUNTIME_ROOT.resolve()
+ if resolved_runtime != expected_runtime:
+ raise ValidationFailure(
+ "environment/setup failure",
+ "Runtime validation must use canonical /opt/ComfyUI. "
+ f"Resolved runtime path was {resolved_runtime}.",
+ )
+ return str(resolved_runtime)
def get_runtime_python(runtime_path: str) -> str:
@@ -581,7 +602,9 @@ def execute_generated_python(
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir) / f"{fixture.name}.py"
tmp_path.write_text(generated_code, encoding="utf-8")
- output_dir = Path(runtime_path) / COMFYUI_OUTPUT_DIRNAME
+ output_dir = Path(tmpdir) / "output" # Writable temp dir for outputs
+ output_dir.mkdir(parents=True, exist_ok=True)
+
# Compare against the pre-run snapshot so validation can prove this
# execution created a fresh artifact instead of reusing an old output.
existing_outputs = set(output_dir.glob("*.png"))
@@ -592,8 +615,16 @@ def execute_generated_python(
).rstrip(os.pathsep)
runtime_python = get_runtime_python(runtime_path)
+ # Always pass --cpu so that model_management doesn't try CUDA init.
+ # Redirect output to a writable temp directory (avoids read-only FS errors).
result = subprocess.run(
- [runtime_python, str(tmp_path)],
+ [
+ runtime_python,
+ str(tmp_path),
+ "--cpu",
+ "--output-directory",
+ str(output_dir),
+ ],
cwd=ROOT,
env=env,
capture_output=True,
@@ -621,6 +652,22 @@ def execute_generated_python(
output = stderr or stdout or "generated script exited with a non-zero status"
lower_output = output.lower()
+ # Detect signal-based crashes (returncode > 128 means killed by signal)
+ if result.returncode > 128:
+ sig_num = result.returncode - 128
+ sig_name = (
+ signal.Signals(sig_num).name
+ if sig_num in signal.Signals
+ else f"SIG{sig_num}"
+ )
+ classification = "repo regression"
+ raise ValidationFailure(
+ classification,
+ f"Generated script for {fixture.name} was killed by signal {sig_name} ({result.returncode}). "
+ f"This usually indicates a segfault in CUDA/torch during bootstrap or import.\n"
+ f"stderr: {stderr}\nstdout: {stdout}",
+ )
+
if "no module named 'torch'" in lower_output:
classification = "environment/setup failure"
elif "no such file or directory" in lower_output or "not found" in lower_output:
@@ -634,10 +681,133 @@ def execute_generated_python(
)
-def run_fixture(fixture: FixtureConfig, tier: str, execute: bool, runtime_path: str) -> str:
+# --- Bootstrap-only smoke test script (injected into subprocess) ---
+# Injects --cpu so that the generated script's bootstrap phase never touches CUDA.
+# This isolates the test to import-order and module-lifecycle correctness.
+_BOOTSTRAP_SMOKETEST_SCRIPT = """
+import sys
+sys.path.insert(0, {script_dir!r})
+
+# Force --cpu before importing so CLI arg parsing picks it up
+sys.argv = ["bootstrap_smoke_test", "--cpu"]
+
+# Import the generated script module so its module-level code runs
+import importlib.util
+spec = importlib.util.spec_from_file_location("test_generated", {script_path!r})
+mod = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(mod)
+
+# Run only the bootstrap phase — no nodes, no models
+mod.bootstrap_comfyui_runtime()
+mod.add_extra_model_paths()
+
+print("BOOTSTRAP_OK")
+"""
+
+
+def validate_bootstrap(
+ generated_code: str, fixture: FixtureConfig, runtime_path: str
+) -> None:
+ """Validate that the generated script's bootstrap phase doesn't crash.
+
+ This catches import-order bugs (e.g., premature CUDA initialization,
+ sys.modules corruption from load-and-discard cycles) WITHOUT needing
+ models, GPU, or a full workflow execution.
+
+ Runs in a subprocess so segfaults are caught as process exit codes
+ rather than killing the test runner.
+ """
+ if not fixture.runtime_capable:
+ return # Only for runtime-capable fixtures
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ script_path = Path(tmpdir) / f"{fixture.name}.py"
+ script_path.write_text(generated_code, encoding="utf-8")
+
+ env = os.environ.copy()
+ env["COMFYUI_PATH"] = runtime_path
+ env["PYTHONPATH"] = os.pathsep.join(
+ [str(ROOT), env.get("PYTHONPATH", "")]
+ ).rstrip(os.pathsep)
+
+ runtime_python = get_runtime_python(runtime_path)
+
+ smoke_code = _BOOTSTRAP_SMOKETEST_SCRIPT.format(
+ script_dir=str(script_path.parent),
+ script_path=str(script_path),
+ )
+
+ result = subprocess.run(
+ [runtime_python, "-c", smoke_code],
+ cwd=tmpdir,
+ env=env,
+ capture_output=True,
+ text=True,
+ timeout=60,
+ )
+
+ # Check for signal-based crashes (segfault, etc.)
+ if result.returncode > 128:
+ sig_num = result.returncode - 128
+ sig_name = (
+ signal.Signals(sig_num).name
+ if sig_num in signal.Signals
+ else f"SIG{sig_num}"
+ )
+ raise ValidationFailure(
+ "repo regression",
+ f"Bootstrap smoke test for {fixture.name} was killed by {sig_name} ({result.returncode}). "
+ f"The generated script's import/bootstrap sequence corrupts runtime state.\n"
+ f"stderr: {result.stderr[:2000]}\nstdout: {result.stdout[:2000]}",
+ )
+
+ if result.returncode != 0:
+ stderr = (result.stderr or "").strip()
+ stdout = (result.stdout or "").strip()
+ output = (
+ stderr or stdout or "bootstrap subprocess exited with non-zero status"
+ )
+
+ # Classify common import failures
+ lower_output = output.lower()
+ if "no module named" in lower_output:
+ classification = "repo regression"
+ elif "segmentation fault" in lower_output:
+ classification = "repo regression"
+ else:
+ classification = "repo regression"
+
+ raise ValidationFailure(
+ classification,
+ f"Bootstrap smoke test for {fixture.name} failed (exit {result.returncode}).\n"
+ f"stderr: {stderr[:2000]}\nstdout: {stdout[:2000]}",
+ )
+
+ if "BOOTSTRAP_OK" not in result.stdout:
+ raise ValidationFailure(
+ "repo regression",
+ f"Bootstrap smoke test for {fixture.name} completed but did not emit BOOTSTRAP_OK marker.\n"
+ f"stdout: {result.stdout[:2000]}",
+ )
+
+
+def run_fixture(
+ fixture: FixtureConfig, tier: str, execute: bool, runtime_path: str
+) -> str:
if tier == "fast":
_, generated_code = export_workflow(fixture, tier, runtime_path)
else:
+ generated_code = export_workflow_in_runtime_env(fixture, runtime_path)
+
+ # Always validate syntax regardless of tier
+ validate_generated_python(generated_code, fixture.name)
+
+ if tier == "runtime":
+ # Bootstrap smoke test: validates import/bootstrap sequence doesn't crash
+ # (segfaults, module corruption, premature CUDA init) — runs WITHOUT models/GPU.
+ validate_bootstrap(generated_code, fixture, runtime_path)
+
+ # Full execution requires models + writable filesystem.
missing_models = check_models(fixture, runtime_path)
if missing_models:
raise ValidationFailure(
@@ -648,33 +818,112 @@ def run_fixture(fixture: FixtureConfig, tier: str, execute: bool, runtime_path:
f"{item.relative_dir}/{item.filename}" for item in missing_models
),
)
- stage_inputs(fixture, runtime_path)
- generated_code = export_workflow_in_runtime_env(fixture, runtime_path)
- validate_generated_python(generated_code, fixture.name)
+ try:
+ stage_inputs(fixture, runtime_path)
+ except OSError as exc:
+ raise ValidationFailure(
+ "environment/setup failure",
+ f"Cannot stage inputs for {fixture.name}: {exc}",
+ ) from exc
- # Fast keeps execution opt-in because its stub node mappings are intended for
- # export coverage only. Runtime always executes the generated script.
- should_execute = execute or tier == "runtime"
- if should_execute and tier == "runtime":
+ # Runtime always executes the generated script end-to-end.
execute_generated_python(generated_code, fixture, runtime_path)
return "pass"
+def check_stale(runtime_path: str) -> int:
+ """Check if committed generated scripts match current generator output.
+
+ Regenerates each runtime-capable fixture using the ComfyUI runtime Python
+ (same as export_workflow_in_runtime_env) and diffs against the committed
+ file in tests/runtime/generated/. Returns 1 if any are stale, 0 if all match.
+ """
+ fixture_names = [name for name, cfg in FIXTURES.items() if cfg.runtime_capable]
+ stale_count = 0
+
+ for fixture_name in fixture_names:
+ fixture = FIXTURES[fixture_name]
+ committed_path = GENERATED_DIR / f"{fixture.name}.py"
+
+ if not committed_path.is_file():
+ print(f"{fixture.name}: missing (no committed file at {committed_path})")
+ stale_count += 1
+ continue
+
+ try:
+ regenerated_code = export_workflow_in_runtime_env(fixture, runtime_path)
+ except ValidationFailure as exc:
+ print(f"{fixture.name}: export error ({exc.message})")
+ stale_count += 1
+ continue
+ except Exception as exc:
+ print(f"{fixture.name}: export error ({exc})")
+ stale_count += 1
+ continue
+
+ current = committed_path.read_text(encoding="utf-8")
+
+ if current != regenerated_code:
+ # Show first differing region
+ current_lines = current.splitlines()
+ new_lines = regenerated_code.splitlines()
+ first_diff = ""
+ for i, (a, b) in enumerate(zip(current_lines, new_lines), 1):
+ if a != b:
+ first_diff = f"\n First diff at line {i}:\n was: {a[:80]}\n now: {b[:80]}"
+ break
+ if not first_diff and len(current_lines) != len(new_lines):
+ first_diff = f"\n Line count differs: {len(current_lines)} committed vs {len(new_lines)} regenerated"
+
+ print(f"{fixture.name}: stale (generator output changed){first_diff}")
+ stale_count += 1
+ else:
+ print(f"{fixture.name}: in-sync")
+
+ if stale_count:
+ print(
+ f"\n{stale_count} fixture(s) are stale. "
+ f"Regenerate: cd COMFYUI_ROOT && PYTHONPATH=EXT_PATH COMFYUI_PATH=. uv run python -c '...see AGENTS.md...'",
+ file=sys.stderr,
+ )
+ else:
+ print("All committed generated scripts match current generator output.")
+
+ return 1 if stale_count else 0
+
+
def main() -> int:
args = parse_args()
if args.internal_export:
fixture = get_fixture(args.internal_export)
output_path = Path(args.generated_path)
- _, generated_code = export_workflow(
- fixture=fixture,
- tier="runtime",
- runtime_path=os.environ.get("COMFYUI_PATH", ""),
- )
+ # Set sys.argv with --cpu so that bootstrap_comfyui_runtime() parses
+ # CLI args correctly (avoids CUDA init during node discovery).
+ # Save original argv for potential debugging.
+ _orig_argv = list(sys.argv)
+ try:
+ sys.argv = ["internal-export", "--cpu"]
+ _, generated_code = export_workflow(
+ fixture=fixture,
+ tier="runtime",
+ runtime_path=os.environ.get("COMFYUI_PATH", ""),
+ )
+ finally:
+ sys.argv = _orig_argv
output_path.write_text(generated_code, encoding="utf-8")
return 0
+ if args.check_stale:
+ runtime_path = ensure_runtime_path("runtime")
+ try:
+ return check_stale(runtime_path)
+ except ValidationFailure as exc:
+ print(f"classification: {exc.classification}", file=sys.stderr)
+ print(exc.message, file=sys.stderr)
+ return 1
+
try:
runtime_path = ensure_runtime_path(args.tier)
fixture_names = load_fixture_names(args.fixture)
diff --git a/tests/test_app_base_mappings.py b/tests/test_app_base_mappings.py
new file mode 100644
index 0000000..56a028a
--- /dev/null
+++ b/tests/test_app_base_mappings.py
@@ -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()
diff --git a/tests/test_bootstrap_import_allowlist.py b/tests/test_bootstrap_import_allowlist.py
new file mode 100644
index 0000000..1695ae2
--- /dev/null
+++ b/tests/test_bootstrap_import_allowlist.py
@@ -0,0 +1,69 @@
+"""Tests for _bootstrap_import allowlist enforcement.
+
+Ensures the allowlist guard in _bootstrap_import cannot be bypassed,
+protecting against arbitrary module loading attacks via COMFYUI_PATH.
+Must survive all refactoring of module_loader.py.
+"""
+
+import unittest
+
+from comfyui_to_python.runtime.module_loader import _bootstrap_import
+
+
+class TestBootstrapImportAllowlist(unittest.TestCase):
+ """_bootstrap_import() must block modules outside allowed prefixes."""
+
+ def test_blocks_unallowed_module_simple(self):
+ """Simple unallowed module name is blocked with ValueError."""
+ with self.assertRaises(ValueError) as ctx:
+ _bootstrap_import("os")
+ self.assertIn("'os'", str(ctx.exception))
+
+ def test_blocks_unallowed_module_dotted(self):
+ """Dotted unallowed module name is blocked with ValueError."""
+ with self.assertRaises(ValueError) as ctx:
+ _bootstrap_import("subprocess.popen")
+ self.assertIn("'subprocess.popen'", str(ctx.exception))
+
+ def test_blocks_unallowed_module_nested(self):
+ """Deeply nested unallowed module name is blocked with ValueError."""
+ with self.assertRaises(ValueError) as ctx:
+ _bootstrap_import("ctypes.windll.kernel32")
+ self.assertIn("'ctypes.windll.kernel32'", str(ctx.exception))
+
+ def test_allows_comfy_prefix(self):
+ """Module starting with allowed 'comfy' prefix is not blocked by allowlist."""
+ # Will fail with ModuleNotFoundError if comfy isn't available, but should
+ # NOT fail with ValueError (which means allowlist blocked it)
+ try:
+ _bootstrap_import("comfy.nonexistent_module_xyz")
+ except ValueError as exc:
+ self.fail(f"Allowlist incorrectly blocked 'comfy' prefix: {exc}")
+ except ModuleNotFoundError:
+ pass # Expected — module doesn't exist, but allowlist passed
+
+ def test_allows_folder_paths_prefix(self):
+ """Module starting with allowed 'folder_paths' prefix is not blocked."""
+ try:
+ _bootstrap_import("folder_paths.nonexistent_xyz")
+ except ValueError as exc:
+ self.fail(f"Allowlist incorrectly blocked 'folder_paths' prefix: {exc}")
+ except ModuleNotFoundError:
+ pass # Expected — module doesn't exist, but allowlist passed
+
+ def test_allows_execution_prefix(self):
+ """Module starting with allowed 'execution' prefix is not blocked."""
+ try:
+ _bootstrap_import("execution.nonexistent_xyz")
+ except ValueError as exc:
+ self.fail(f"Allowlist incorrectly blocked 'execution' prefix: {exc}")
+ except ModuleNotFoundError:
+ pass # Expected — module doesn't exist, but allowlist passed
+
+ def test_allowlist_list_in_exception(self):
+ """Error message includes the allowed prefixes for debugging."""
+ with self.assertRaises(ValueError) as ctx:
+ _bootstrap_import("os")
+ error_msg = str(ctx.exception)
+ self.assertIn("comfy", error_msg)
+ self.assertIn("folder_paths", error_msg)
diff --git a/tests/test_cli_args_propagation.py b/tests/test_cli_args_propagation.py
new file mode 100644
index 0000000..7b36299
--- /dev/null
+++ b/tests/test_cli_args_propagation.py
@@ -0,0 +1,442 @@
+"""Tests for CLI args propagation through ComfyUI's import chain.
+
+Ensures that parsed CLI arguments (e.g., --cpu) persist in sys.modules so that
+when ComfyUI's internal chain re-imports comfy.cli_args, it gets the cached
+instance with already-parsed args instead of a fresh copy with empty argv.
+"""
+
+import os
+import sys
+import tempfile
+import unittest
+from unittest.mock import patch
+
+
+class TestLoadModuleCachesInSysModules(unittest.TestCase):
+ """Tests for _load_module - verifies modules are cached in sys.modules."""
+
+ def tearDown(self):
+ # Clean up test modules from sys.modules
+ for name in list(sys.modules):
+ if name.startswith("_test_cli_"):
+ del sys.modules[name]
+
+ def test_load_module_caches_under_canonical_name(self):
+ """Given _load_module with canonical name, 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, "_test_cli_opts.py")
+ with open(mod_file, "w") as f:
+ f.write("VALUE = 42\n")
+
+ mod = _load_module("comfy.options", mod_file)
+ self.assertIsNotNone(mod)
+ self.assertEqual(mod.VALUE, 42)
+ # Module must remain cached under canonical name
+ self.assertIn("comfy.options", sys.modules)
+ self.assertIs(sys.modules["comfy.options"], mod)
+
+ def test_reimport_via_load_module_returns_cached_instance(self):
+ """Second _load_module() for same name returns cached module with mutations."""
+ from comfyui_to_python.node_runtime import _load_module
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ mod_file = os.path.join(tmpdir, "_test_cli_args.py")
+ with open(mod_file, "w") as f:
+ f.write("parsed_flags = []\n")
+
+ first = _load_module("comfy.cli_args", mod_file)
+ first.parsed_flags.append("--cpu") # Simulate args parsing
+
+ # Re-import (ComfyUI's internal chain) - must return same instance
+ second = _load_module("comfy.cli_args", mod_file)
+
+ self.assertIs(first, second)
+ self.assertIn("--cpu", second.parsed_flags)
+
+ def test_reimport_returns_same_object_identity(self):
+ """Cached module identity is preserved (is, not ==)."""
+ from comfyui_to_python.node_runtime import _load_module
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ mod_file = os.path.join(tmpdir, "_test_cli_identity.py")
+ with open(mod_file, "w") as f:
+ f.write("DATA = 'original'\n")
+
+ first = _load_module("_test_cli_mod", mod_file)
+ first.DATA = "mutated"
+
+ second = _load_module("_test_cli_mod", mod_file)
+ self.assertIs(first, second)
+ self.assertEqual(second.DATA, "mutated")
+
+
+class TestLoadModuleTempRemovesFromSysModules(unittest.TestCase):
+ """Tests for _load_module_temp - removes modules from sys.modules.
+
+ Documents why _bootstrap_import is preferred for options/cli_args:
+ _load_module_temp removes the module so ComfyUI's chain re-imports fresh.
+ """
+
+ def tearDown(self):
+ for name in list(sys.modules):
+ if name.startswith("_test_temp_"):
+ del sys.modules[name]
+
+ def test_load_module_temp_removes_from_sys_modules(self):
+ """After _load_module_temp(), the module is removed from sys.modules."""
+ from comfyui_to_python.node_runtime import _load_module_temp
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ mod_file = os.path.join(tmpdir, "_test_temp_mod.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)
+ # This is the problematic behavior that caused CLI args to be lost
+ self.assertNotIn(
+ "_test_temp_mod",
+ sys.modules,
+ "_load_module_temp intentionally removes from sys.modules",
+ )
+
+ def test_load_module_temp_causes_stale_reimport(self):
+ """When cli_args is loaded via _load_module_temp, re-import gets fresh copy.
+
+ Demonstrates the bug: _load_module_temp removes module from sys.modules,
+ so the next import creates a new instance without parsed args.
+ """
+ from comfyui_to_python.node_runtime import _load_module, _load_module_temp
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ mod_file = os.path.join(tmpdir, "_test_temp_stale.py")
+ with open(mod_file, "w") as f:
+ f.write("parsed_args = []\n")
+
+ # First import via _load_module_temp (old bootstrap behavior)
+ mod1 = _load_module_temp("_test_temp_stale", mod_file)
+ mod1.parsed_args.append("--cpu")
+
+ # Re-import (ComfyUI's internal chain) - gets FRESH instance, no args!
+ mod2 = _load_module("_test_temp_stale", mod_file)
+
+ self.assertIsNot(mod1, mod2) # Different instances
+ self.assertNotIn("--cpu", mod2.parsed_args) # Args lost!
+
+
+class TestDiscoverComfyuiCliOptions(unittest.TestCase):
+ """Tests for _discover_comfyui_cli_options - dynamic parser inspection."""
+
+ def tearDown(self):
+ # Reset global cache so tests are isolated.
+ # After refactor, the actual cache lives in runtime/bootstrap.py;
+ # reset both to ensure full isolation.
+ import comfyui_to_python.node_runtime as rt
+ from comfyui_to_python.runtime import bootstrap as bs
+
+ rt._DISCOVERED_OPTIONS = None
+ bs._DISCOVERED_OPTIONS = None
+
+ def test_returns_known_boolean_flags(self):
+ """Discovered options include known boolean flags like --cpu."""
+ from comfyui_to_python.node_runtime import _discover_comfyui_cli_options
+
+ # Force discovery to work even without ComfyUI by mocking parser
+ import argparse
+
+ mock_parser = argparse.ArgumentParser()
+ mock_parser.add_argument("--cpu", action="store_true")
+ mock_parser.add_argument("--lowvram", action="store_true")
+
+ with patch(
+ "comfyui_to_python.runtime.bootstrap._bootstrap_import",
+ return_value=type("FakeMod", (), {"parser": mock_parser})(),
+ ):
+ known, _ = _discover_comfyui_cli_options()
+ self.assertIn("--cpu", known)
+ self.assertIn("--lowvram", known)
+
+ def test_returns_value_taking_flags(self):
+ """Discovered value_taking set includes flags that take arguments."""
+ from comfyui_to_python.node_runtime import _discover_comfyui_cli_options
+
+ import argparse
+
+ mock_parser = argparse.ArgumentParser()
+ mock_parser.add_argument("--cpu", action="store_true")
+ mock_parser.add_argument("--reserve-vram", type=float)
+ mock_parser.add_argument("--cuda-device", type=int)
+
+ with patch(
+ "comfyui_to_python.runtime.bootstrap._bootstrap_import",
+ return_value=type("FakeMod", (), {"parser": mock_parser})(),
+ ):
+ known, value_taking = _discover_comfyui_cli_options()
+ self.assertIn("--reserve-vram", value_taking)
+ self.assertIn("--cuda-device", value_taking)
+ # --cpu is boolean (store_true), not in value_taking
+ self.assertNotIn("--cpu", value_taking)
+
+ def test_caches_result(self):
+ """Second call returns cached result without re-importing."""
+ from comfyui_to_python.node_runtime import _discover_comfyui_cli_options
+
+ import argparse
+
+ mock_parser = argparse.ArgumentParser()
+ mock_parser.add_argument("--cpu", action="store_true")
+
+ call_count = [0]
+
+ def counting_import(name):
+ call_count[0] += 1
+ return type("FakeMod", (), {"parser": mock_parser})()
+
+ with patch(
+ "comfyui_to_python.runtime.bootstrap._bootstrap_import",
+ side_effect=counting_import,
+ ):
+ _discover_comfyui_cli_options()
+ _discover_comfyui_cli_options()
+
+ self.assertEqual(call_count[0], 1, "Should only import once (cached)")
+
+ def test_returns_empty_when_no_parser(self):
+ """Returns empty sets when comfy.cli_args has no parser attribute."""
+ from comfyui_to_python.node_runtime import _discover_comfyui_cli_options
+
+ with patch(
+ "comfyui_to_python.runtime.bootstrap._bootstrap_import",
+ return_value=type("FakeMod", (), {})(),
+ ):
+ known, value_taking = _discover_comfyui_cli_options()
+ self.assertEqual(known, frozenset())
+ self.assertEqual(value_taking, frozenset())
+
+ def test_strips_bracket_defaults_from_option_names(self):
+ """Option names with inline defaults (e.g. '--listen [IP]') are stripped."""
+ from comfyui_to_python.node_runtime import _discover_comfyui_cli_options
+
+ import argparse
+
+ mock_parser = argparse.ArgumentParser()
+ mock_parser.add_argument("--listen", type=str, default="127.0.0.1")
+
+ # Manually add a bracket-style option string (as argparse shows in help)
+ for action in mock_parser._actions:
+ if "--listen" in action.option_strings:
+ action.option_strings = ["--listen"]
+ break
+
+ with patch(
+ "comfyui_to_python.runtime.bootstrap._bootstrap_import",
+ return_value=type("FakeMod", (), {"parser": mock_parser})(),
+ ):
+ known, _ = _discover_comfyui_cli_options()
+ self.assertIn("--listen", known)
+
+
+class TestFilterComfyuiArgs(unittest.TestCase):
+ """Tests for _filter_comfyui_args - strips non-ComfyUI flags from argv."""
+
+ def setUp(self):
+ import comfyui_to_python.node_runtime as rt
+ from comfyui_to_python.runtime import bootstrap as bs
+
+ self._original_cache = rt._DISCOVERED_OPTIONS
+ # Pre-populate cache so filter doesn't try to import ComfyUI.
+ # Set on BOTH node_runtime (re-export) and bootstrap (actual storage).
+ rt._DISCOVERED_OPTIONS = (
+ frozenset(
+ [
+ "--cpu",
+ "--lowvram",
+ "--reserve-vram",
+ "--cuda-device",
+ "--verbose",
+ "--front-end-version",
+ ]
+ ),
+ frozenset(
+ [
+ "--reserve-vram",
+ "--cuda-device",
+ "--verbose",
+ "--front-end-version",
+ ]
+ ),
+ )
+ bs._DISCOVERED_OPTIONS = rt._DISCOVERED_OPTIONS
+
+ def tearDown(self):
+ import comfyui_to_python.node_runtime as rt
+ from comfyui_to_python.runtime import bootstrap as bs
+
+ rt._DISCOVERED_OPTIONS = self._original_cache
+ bs._DISCOVERED_OPTIONS = self._original_cache
+
+ def test_preserves_known_flags(self):
+ """Known ComfyUI flags like --cpu are preserved."""
+ from comfyui_to_python.generator.generated_helpers import _filter_comfyui_args
+
+ result = _filter_comfyui_args(["script.py", "--cpu"])
+ self.assertEqual(result, ["script.py", "--cpu"])
+
+ def test_strips_unknown_flags(self):
+ """Unknown flags are removed."""
+ from comfyui_to_python.generator.generated_helpers import _filter_comfyui_args
+
+ result = _filter_comfyui_args(
+ ["script.py", "--internal-export", "--cpu", "--unknown-flag"]
+ )
+ self.assertEqual(result, ["script.py", "--cpu"])
+
+ def test_strips_unknown_flags_with_values(self):
+ """Unknown flags and their values are both removed."""
+ from comfyui_to_python.generator.generated_helpers import _filter_comfyui_args
+
+ result = _filter_comfyui_args(["script.py", "--some-value", "foo", "--cpu"])
+ self.assertEqual(result, ["script.py", "--cpu"])
+
+ def test_preserves_flag_values_for_known_flags(self):
+ """Known flags that expect values keep their values."""
+ from comfyui_to_python.generator.generated_helpers import _filter_comfyui_args
+
+ result = _filter_comfyui_args(["script.py", "--reserve-vram", "4096"])
+ self.assertEqual(result, ["script.py", "--reserve-vram", "4096"])
+
+ def test_preserves_inline_flag_values(self):
+ """Flags with inline values (--flag=value) are preserved as-is."""
+ from comfyui_to_python.generator.generated_helpers import _filter_comfyui_args
+
+ result = _filter_comfyui_args(["script.py", "--cuda-device=0"])
+ self.assertEqual(result, ["script.py", "--cuda-device=0"])
+
+ def test_handles_empty_argv(self):
+ """Empty argv returns empty result."""
+ from comfyui_to_python.generator.generated_helpers import _filter_comfyui_args
+
+ result = _filter_comfyui_args([])
+ self.assertEqual(result, [])
+
+ def test_preserves_positional_args(self):
+ """Positional (non-flag) args are preserved."""
+ from comfyui_to_python.generator.generated_helpers import _filter_comfyui_args
+
+ result = _filter_comfyui_args(["script.py", "output.png"])
+ self.assertEqual(result, ["script.py", "output.png"])
+
+ def test_boolean_flags_dont_consume_next_arg(self):
+ """Boolean flags like --cpu don't consume the next positional arg."""
+ from comfyui_to_python.generator.generated_helpers import _filter_comfyui_args
+
+ result = _filter_comfyui_args(["script.py", "--cpu", "output.png"])
+ self.assertEqual(result, ["script.py", "--cpu", "output.png"])
+
+ def test_strips_single_char_flags(self):
+ """Single-char flags (e.g. -v from pytest) are stripped."""
+ from comfyui_to_python.generator.generated_helpers import _filter_comfyui_args
+
+ result = _filter_comfyui_args(["script.py", "-v", "--cpu", "-x", "--lowvram"])
+ self.assertEqual(result, ["script.py", "--cpu", "--lowvram"])
+
+ def test_mixed_known_and_unknown(self):
+ """Mix of known, unknown, and single-char flags is correctly filtered."""
+ from comfyui_to_python.generator.generated_helpers import _filter_comfyui_args
+
+ result = _filter_comfyui_args(
+ [
+ "script.py",
+ "--cpu",
+ "-s",
+ "--unknown-thing",
+ "foo",
+ "--reserve-vram",
+ "8",
+ ]
+ )
+ self.assertEqual(result, ["script.py", "--cpu", "--reserve-vram", "8"])
+
+
+class TestBootstrapUsesBootstrapImport(unittest.TestCase):
+ """Verify bootstrap_comfyui_runtime uses _bootstrap_import (not _load_module_temp).
+
+ Regression test: if someone accidentally reverts to _load_module_temp for
+ options/cli_args, the CLI args will be lost on re-import.
+ """
+
+ def test_bootstrap_uses_bootstrap_import_for_options(self):
+ """bootstrap_comfyui_runtime must use _bootstrap_import for options."""
+ import inspect
+ from comfyui_to_python.node_runtime import bootstrap_comfyui_runtime
+
+ source = inspect.getsource(bootstrap_comfyui_runtime)
+ self.assertIn('_bootstrap_import("comfy.options")', source)
+ self.assertIn('_bootstrap_import("comfy.cli_args")', source)
+
+ def test_bootstrap_uses_bootstrap_import_for_cli_args(self):
+ """bootstrap_comfyui_runtime must use _bootstrap_import for cli_args."""
+ import inspect
+ from comfyui_to_python.node_runtime import bootstrap_comfyui_runtime
+
+ source = inspect.getsource(bootstrap_comfyui_runtime)
+ # Must NOT use _load_module with file paths for options/cli_args
+ # (namespace packages don't work with file-path loading)
+ self.assertNotIn('"comfy.options", os.path.join', source)
+
+
+class TestGeneratedScriptEmbedsBootstrap(unittest.TestCase):
+ """Verify generated scripts include bootstrap functions with correct behavior."""
+
+ def test_generated_script_has_filter_comfyui_args(self):
+ """Generated scripts must embed _filter_comfyui_args."""
+ 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("_filter_comfyui_args", generated)
+ self.assertIn("_discover_comfyui_cli_options", generated)
+ self.assertIn("sys.argv = _filter_comfyui_args(sys.argv)", generated)
+
+ def test_generated_script_filters_argv_before_bootstrap(self):
+ """Generated script filters argv before calling options enable_args_parsing."""
+ 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)
+
+ # _filter_comfyui_args must appear before enable_args_parsing
+ filter_pos = generated.find("_filter_comfyui_args")
+ parsing_pos = generated.find("enable_args_parsing()")
+ self.assertLess(
+ filter_pos,
+ parsing_pos,
+ "argv filtering must occur before options enable_args_parsing",
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_embedded_modules.py b/tests/test_embedded_modules.py
new file mode 100644
index 0000000..5c0099a
--- /dev/null
+++ b/tests/test_embedded_modules.py
@@ -0,0 +1,205 @@
+"""Tests for embedded_modules — auto-discovery of runtime helpers.
+
+Verifies that the module-level source embedding approach correctly:
+ - Strips all import statements from contributing modules
+ - Preserves function definitions, constants, and docstrings
+ - Produces valid Python when concatenated
+ - Includes all expected helper functions
+"""
+
+import ast
+import unittest
+
+
+class TestStripImports(unittest.TestCase):
+ """Tests for _strip_imports — removes import lines, keeps everything else."""
+
+ def setUp(self):
+ from comfyui_to_python.generator.embedded_modules import _strip_imports
+
+ self._strip_imports = _strip_imports
+
+ def test_removes_simple_imports(self):
+ source = "import os\nimport sys\n\ndef foo(): pass"
+ result = self._strip_imports(source)
+ self.assertNotIn("import os", result)
+ self.assertNotIn("import sys", result)
+ self.assertIn("def foo(): pass", result)
+
+ def test_removes_from_imports(self):
+ source = "from typing import Any\nfrom .runtime import x\n\ndef bar(): pass"
+ result = self._strip_imports(source)
+ self.assertNotIn("from typing", result)
+ self.assertNotIn("from .runtime", result)
+ self.assertIn("def bar(): pass", result)
+
+ def test_removes_future_imports(self):
+ source = "from __future__ import annotations\n\ndef baz(): pass"
+ result = self._strip_imports(source)
+ self.assertNotIn("__future__", result)
+ self.assertIn("def baz(): pass", result)
+
+ def test_preserves_docstrings(self):
+ source = (
+ '"""Module docstring."""\n'
+ "import os\n\n"
+ "def foo():\n"
+ ' """Function docstring."""\n'
+ " pass"
+ )
+ result = self._strip_imports(source)
+ self.assertIn("Module docstring", result)
+ self.assertIn("Function docstring", result)
+
+ def test_preserves_constants(self):
+ source = "import os\n\nCONST = 42\n"
+ result = self._strip_imports(source)
+ self.assertIn("CONST = 42", result)
+ self.assertNotIn("import os", result)
+
+ def test_removes_multiline_imports(self):
+ source = (
+ "from typing import (\n Any,\n List,\n Dict,\n)\ndef foo(): pass\n"
+ )
+ result = self._strip_imports(source)
+ self.assertNotIn("from typing", result)
+ self.assertNotIn("Any", result) or "Any" not in result.split("def")[0]
+ self.assertIn("def foo(): pass", result)
+
+
+class TestGetEmbeddedHelpers(unittest.TestCase):
+ """Tests for get_embedded_helpers — produces valid, complete embedded code."""
+
+ def setUp(self):
+ from comfyui_to_python.generator.embedded_modules import (
+ get_embedded_helpers,
+ list_embedded_names,
+ )
+
+ self.get_embedded_helpers = get_embedded_helpers
+ self.list_embedded_names = list_embedded_names
+
+ def test_returns_string(self):
+ result = self.get_embedded_helpers()
+ self.assertIsInstance(result, str)
+ self.assertTrue(len(result) > 1000)
+
+ def test_is_valid_python(self):
+ result = self.get_embedded_helpers()
+ # Should parse without syntax errors (may have NameErrors at runtime
+ # for non-builtin references, but should be syntactically valid)
+ ast.parse(result)
+
+ def test_no_import_statements(self):
+ result = self.get_embedded_helpers()
+ tree = ast.parse(result)
+ top_level_imports = [
+ node
+ for node in ast.iter_child_nodes(tree)
+ if isinstance(node, (ast.Import, ast.ImportFrom))
+ ]
+ self.assertEqual(
+ top_level_imports,
+ [],
+ f"Found {len(top_level_imports)} top-level import(s) in embedded block",
+ )
+
+ def test_contains_key_functions(self):
+ result = self.get_embedded_helpers()
+ required = [
+ "_apply_device_settings",
+ "_apply_directory_overrides",
+ "_bootstrap_import",
+ "_discover_comfyui_cli_options",
+ "_filter_comfyui_args",
+ "_load_module",
+ "_load_module_temp",
+ "add_extra_model_paths",
+ "bootstrap_comfyui_runtime",
+ "cleanup_comfyui_runtime",
+ "import_custom_nodes",
+ ]
+ for name in required:
+ self.assertIn(
+ f"def {name}(",
+ result,
+ f"Missing function: {name}",
+ )
+
+ def test_no_relative_imports(self):
+ import re
+
+ result = self.get_embedded_helpers()
+ matches = re.findall(r"^from\s+\.\s*", result, re.MULTILINE)
+ self.assertEqual(
+ matches,
+ [],
+ f"Found {len(matches)} relative imports in embedded block",
+ )
+
+ def test_names_match_definitions(self):
+ names = self.list_embedded_names()
+ result = self.get_embedded_helpers()
+ for name in names:
+ self.assertIn(
+ f"def {name}(",
+ result,
+ f"list_embedded_names includes '{name}' but definition not found",
+ )
+
+
+class TestListEmbeddedNames(unittest.TestCase):
+ """Tests for list_embedded_names — discovers all top-level definitions."""
+
+ def test_includes_private_functions(self):
+ from comfyui_to_python.generator.embedded_modules import list_embedded_names
+
+ names = list_embedded_names()
+ private_funcs = {"_bootstrap_import", "_load_module", "_filter_comfyui_args"}
+ for name in private_funcs:
+ self.assertIn(name, names)
+
+ def test_includes_public_functions(self):
+ from comfyui_to_python.generator.embedded_modules import list_embedded_names
+
+ names = list_embedded_names()
+ public_funcs = {
+ "bootstrap_comfyui_runtime",
+ "cleanup_comfyui_runtime",
+ "import_custom_nodes",
+ "add_extra_model_paths",
+ }
+ for name in public_funcs:
+ self.assertIn(name, names)
+
+ def test_excludes_log(self):
+ from comfyui_to_python.generator.embedded_modules import list_embedded_names
+
+ names = list_embedded_names()
+ # 'log' is the logger instance — not useful to embed
+ self.assertNotIn("log", names)
+
+ def test_surface_matches_approved_manifest(self):
+ from comfyui_to_python.generator.embedded_modules import (
+ verify_embedded_surface_matches_manifest,
+ )
+
+ differences = verify_embedded_surface_matches_manifest()
+
+ self.assertEqual(differences, [])
+
+
+class TestVerifyNoMissingCrossCalls(unittest.TestCase):
+ """Tests for verify_no_missing_cross_calls — catches missing dependencies."""
+
+ def test_returns_empty_list(self):
+ from comfyui_to_python.generator.embedded_modules import (
+ verify_no_missing_cross_calls,
+ )
+
+ unresolved = verify_no_missing_cross_calls()
+ self.assertEqual(unresolved, [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_generated_script_standalone.py b/tests/test_generated_script_standalone.py
new file mode 100644
index 0000000..b726abd
--- /dev/null
+++ b/tests/test_generated_script_standalone.py
@@ -0,0 +1,190 @@
+"""Regression tests for generated script standalone execution.
+
+Ensures that freshly-generated scripts can run as __main__ without
+failing on relative imports or NameError from missing cross-references.
+"""
+
+import re
+import subprocess
+import sys
+import tempfile
+import unittest
+
+
+class TestGeneratedScriptNoRelativeImports(unittest.TestCase):
+ """Verify generated scripts contain no relative imports with dots."""
+
+ def test_no_relative_imports_in_embedded_block(self):
+ """All embedded helpers must be free of 'from .' imports.
+
+ Generated scripts embed module bodies (imports stripped) rather than
+ individual functions. Any remaining relative import would fail with
+ ImportError when the script runs as __main__ with no package context.
+ """
+ from comfyui_to_python.generator.embedded_modules import get_embedded_helpers
+
+ embedded = get_embedded_helpers()
+ relative_import_pattern = re.compile(r"^\s*from\s+\.\s*")
+ matches = relative_import_pattern.findall(embedded)
+
+ self.assertEqual(
+ matches,
+ [],
+ f"Found {len(matches)} relative imports in embedded helpers",
+ )
+
+ def test_all_contributing_modules_embedded(self):
+ """Every contributing module is present in the embedded block.
+
+ If a new helper function is added to a runtime module but that module
+ isn't listed in _SOURCE_FILES, it won't be embedded and will cause
+ NameError at runtime. This test verifies all expected modules are included.
+ """
+ from comfyui_to_python.generator.embedded_modules import (
+ get_embedded_helpers,
+ list_embedded_names,
+ )
+
+ names = list_embedded_names()
+ embedded = get_embedded_helpers()
+
+ # All named functions must appear in the embedded block
+ for name in names:
+ self.assertIn(
+ f"def {name}(",
+ embedded,
+ f"Function '{name}' is listed but not found in embedded source",
+ )
+
+ def test_no_unresolved_cross_calls(self):
+ """All function calls within embedded code must resolve.
+
+ If a new internal helper is added to a runtime module and called by
+ an existing embedded function, it must also be embedded. This catches
+ the class of bug where _apply_device_settings was added but forgotten
+ from the embed list.
+ """
+ from comfyui_to_python.generator.embedded_modules import (
+ verify_no_missing_cross_calls,
+ )
+
+ unresolved = verify_no_missing_cross_calls()
+ self.assertEqual(
+ unresolved,
+ [],
+ f"Found unresolved cross-calls:\n"
+ + "\n".join(f" - {u}" for u in unresolved),
+ )
+
+ def test_no_relative_imports_in_full_rendered_script(self):
+ """A full rendered script must contain no 'from .' patterns.
+
+ Renders a complete GenerationPlan and checks the final output
+ for any relative import statements that would break standalone execution.
+ """
+ from comfyui_to_python.generator.render import WorkflowRenderer
+ from comfyui_to_python.generator.model import GenerationPlan
+
+ plan = GenerationPlan(
+ workflow_data={
+ "1": {"class_type": "CheckpointLoaderSimple", "inputs": {}},
+ "2": {"class_type": "CLIPTextEncode", "inputs": {}},
+ },
+ 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)
+
+ # Check for 'from .' patterns (relative imports)
+ relative_imports = re.findall(r"^\s*from\s+\.", generated, re.MULTILINE)
+ self.assertEqual(
+ relative_imports,
+ [],
+ f"Generated script contains relative imports: {relative_imports}",
+ )
+
+ def test_rendered_script_defines_logger_once(self):
+ """A rendered script must define the module logger exactly once."""
+ from comfyui_to_python.generator.render import WorkflowRenderer
+ from comfyui_to_python.generator.model import GenerationPlan
+
+ plan = GenerationPlan(
+ workflow_data={
+ "1": {"class_type": "CheckpointLoaderSimple", "inputs": {}},
+ },
+ metadata_workflow_data=None,
+ custom_nodes=False,
+ import_statements={},
+ special_functions_code=[],
+ loop_code=["result = some_node()"],
+ queue_size=1,
+ )
+
+ generated = WorkflowRenderer().render(plan)
+ logger_assignments = re.findall(
+ r"^log = logging\.getLogger\(__name__\)$", generated, re.MULTILINE
+ )
+
+ self.assertEqual(logger_assignments, ["log = logging.getLogger(__name__)"])
+
+ def test_rendered_script_compiles_as_standalone(self):
+ """A rendered script must compile without ImportError as standalone code.
+
+ Writes generated code to a temp file and verifies Python can compile
+ it. This catches syntax errors and top-level import failures (like
+ relative imports in __main__ context).
+ """
+ from comfyui_to_python.generator.render import WorkflowRenderer
+ from comfyui_to_python.generator.model import GenerationPlan
+
+ plan = GenerationPlan(
+ workflow_data={
+ "1": {"class_type": "CheckpointLoaderSimple", "inputs": {}},
+ },
+ 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)
+
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
+ f.write(generated)
+ tmp_path = f.name
+
+ try:
+ # Compile the script — catches relative import errors at parse time
+ result = subprocess.run(
+ [
+ sys.executable,
+ "-c",
+ f"compile(open('{tmp_path}').read(), '{tmp_path}', 'exec')",
+ ],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+
+ self.assertEqual(
+ result.returncode,
+ 0,
+ f"Generated script failed to compile:\n{result.stderr}",
+ )
+ finally:
+ import os
+
+ os.unlink(tmp_path)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_generator_codegen_issue_regressions.py b/tests/test_generator_codegen_issue_regressions.py
index 169a0f0..f671a00 100644
--- a/tests/test_generator_codegen_issue_regressions.py
+++ b/tests/test_generator_codegen_issue_regressions.py
@@ -176,7 +176,9 @@ def export_workflow(workflow: dict, node_class_mappings: dict) -> str:
class GeneratorCodegenIssueRegressionTest(unittest.TestCase):
- def test_export_uses_dictionary_expansion_for_rgthree_symbol_heavy_input_names(self):
+ def test_export_uses_dictionary_expansion_for_rgthree_symbol_heavy_input_names(
+ self,
+ ):
generated = export_workflow(
load_fixture("unsafe-rgthree-kwargs.json"),
{
@@ -187,7 +189,7 @@ def test_export_uses_dictionary_expansion_for_rgthree_symbol_heavy_input_names(s
)
self.assertIn(
- 'powerloraloaderrgthree_631 = powerloraloaderrgthree.load_loras(',
+ "powerloraloaderrgthree_631 = powerloraloaderrgthree.load_loras(",
generated,
)
self.assertIn(
@@ -207,7 +209,9 @@ def test_export_sanitizes_subgraph_identifiers_for_upscaler_workflows(self):
},
)
- self.assertIn("upscalemodelloader_42_0 = upscalemodelloader.load_model(", generated)
+ self.assertIn(
+ "upscalemodelloader_42_0 = upscalemodelloader.load_model(", generated
+ )
self.assertIn(
"imageupscalewithmodel_42_1 = imageupscalewithmodel.upscale(",
generated,
@@ -268,13 +272,13 @@ def test_export_randomizes_string_seed_inputs_as_strings(self):
def test_issue_cluster_regressions_render_parseable_python(self):
workflows = [
(
- load_fixture("unsafe-rgthree-kwargs.json"),
- {
- "AnySwitchRgthree": AnySwitchRgthree,
- "DualClipLoader": DualClipLoader,
- "PowerLoraLoaderRgthree": PowerLoraLoaderRgthree,
- },
- ),
+ load_fixture("unsafe-rgthree-kwargs.json"),
+ {
+ "AnySwitchRgthree": AnySwitchRgthree,
+ "DualClipLoader": DualClipLoader,
+ "PowerLoraLoaderRgthree": PowerLoraLoaderRgthree,
+ },
+ ),
(
load_fixture("subgraph-upscaler-identifiers.json"),
{
diff --git a/tests/test_import_path_resolution.py b/tests/test_import_path_resolution.py
new file mode 100644
index 0000000..f17be7c
--- /dev/null
+++ b/tests/test_import_path_resolution.py
@@ -0,0 +1,735 @@
+"""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_includes_warnings_import(self):
+ """Generated scripts must include 'import warnings' for cleanup_comfyui_runtime."""
+ 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(
+ "import warnings",
+ generated,
+ "Generated script must import 'warnings' (used by cleanup_comfyui_runtime)",
+ )
+
+ 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()
diff --git a/tests/test_node_runtime.py b/tests/test_node_runtime.py
new file mode 100644
index 0000000..fad1301
--- /dev/null
+++ b/tests/test_node_runtime.py
@@ -0,0 +1,45 @@
+"""Tests for node_runtime bootstrap state invariants."""
+
+import sys
+import tempfile
+import unittest
+from unittest.mock import patch
+
+from comfyui_to_python.node_runtime import bootstrap_comfyui_runtime
+
+
+class BootstrapStateInvariantTest(unittest.TestCase):
+ def test_bootstrap_restores_argv_when_bootstrap_import_fails(self):
+ original_argv = ["generated.py", "--internal-export", "--cpu"]
+ filtered_argv = ["generated.py", "--cpu"]
+ previous_argv = sys.argv
+ sys.argv = list(original_argv)
+ try:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with (
+ patch(
+ "comfyui_to_python.node_runtime.add_comfyui_directory_to_sys_path"
+ ),
+ patch(
+ "comfyui_to_python.node_runtime.get_comfyui_path",
+ return_value=tmpdir,
+ ),
+ patch(
+ "comfyui_to_python.node_runtime._filter_comfyui_args",
+ return_value=filtered_argv,
+ ),
+ patch(
+ "comfyui_to_python.node_runtime._bootstrap_import",
+ side_effect=RuntimeError("boom"),
+ ),
+ ):
+ with self.assertRaises(RuntimeError):
+ bootstrap_comfyui_runtime()
+
+ self.assertEqual(sys.argv, original_argv)
+ finally:
+ sys.argv = previous_argv
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_node_runtime_cleanup.py b/tests/test_node_runtime_cleanup.py
index 3379ef5..2055ac9 100644
--- a/tests/test_node_runtime_cleanup.py
+++ b/tests/test_node_runtime_cleanup.py
@@ -17,13 +17,16 @@ def test_cleanup_releases_caches_without_forcing_model_unload_by_default(self):
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):
+ 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()
@@ -39,16 +42,19 @@ def test_cleanup_can_force_model_unload_from_environment(self):
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,
+ 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()
@@ -59,17 +65,24 @@ def test_cleanup_suppresses_hook_failures_and_warns(self):
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"))
+ 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:
+ 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)
@@ -90,17 +103,22 @@ 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.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):
+ 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:
diff --git a/tests/test_planner.py b/tests/test_planner.py
new file mode 100644
index 0000000..65c1e35
--- /dev/null
+++ b/tests/test_planner.py
@@ -0,0 +1,79 @@
+"""Tests for planner readability helpers."""
+
+import time
+import unittest
+
+from comfyui_to_python.generator.planner import WorkflowPlanner
+
+
+class ScaleNode:
+ FUNCTION = "run"
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {"required": {"value": ("INT",)}}
+
+ def run(self, value):
+ return (value,)
+
+
+class WorkflowPlannerReadabilityTest(unittest.TestCase):
+ def test_required_input_helper_detects_missing_values(self):
+ input_types = {"required": {"prompt": ("STRING",), "seed": ("INT",)}}
+
+ self.assertTrue(
+ WorkflowPlanner._node_has_missing_required_inputs(
+ input_types, {"prompt": "hello"}
+ )
+ )
+ self.assertFalse(
+ WorkflowPlanner._node_has_missing_required_inputs(
+ input_types, {"prompt": "hello", "seed": 1}
+ )
+ )
+
+ def test_hidden_input_helper_adds_allowed_runtime_values(self):
+ inputs = {}
+ input_types = {
+ "hidden": {
+ "unique_id": "UNIQUE_ID",
+ "prompt": "PROMPT",
+ "extra_pnginfo": "EXTRA_PNGINFO",
+ }
+ }
+
+ WorkflowPlanner._apply_hidden_inputs(
+ inputs,
+ input_types,
+ ["unique_id", "prompt", "extra_pnginfo"],
+ )
+
+ self.assertIsInstance(inputs["unique_id"], int)
+ self.assertEqual(inputs["prompt"], {"variable_name": "prompt"})
+ self.assertEqual(inputs["extra_pnginfo"], {"variable_name": "extra_pnginfo"})
+
+ def test_large_workflow_build_plan_stays_linear_enough(self):
+ planner = WorkflowPlanner(
+ node_class_mappings={"ScaleNode": ScaleNode},
+ base_node_class_mappings={"ScaleNode": ScaleNode},
+ )
+ load_order = [
+ (
+ str(index),
+ {"class_type": "ScaleNode", "inputs": {"value": index}},
+ False,
+ )
+ for index in range(300)
+ ]
+
+ started = time.perf_counter()
+ plan = planner.build_plan(load_order, workflow_data={})
+ elapsed = time.perf_counter() - started
+
+ self.assertLess(elapsed, 1.0)
+ self.assertEqual(len(plan.loop_code), 300)
+ self.assertEqual(len(plan.special_functions_code), 1)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_project_contracts.py b/tests/test_project_contracts.py
index 5632583..1617944 100644
--- a/tests/test_project_contracts.py
+++ b/tests/test_project_contracts.py
@@ -8,7 +8,9 @@
class ProjectContractsTest(unittest.TestCase):
def test_project_declares_supported_python_floor(self):
- pyproject = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
+ pyproject = tomllib.loads(
+ (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")
+ )
self.assertEqual(pyproject["project"]["requires-python"], ">=3.12")
@@ -26,8 +28,11 @@ def test_extension_import_path_requires_uv_sync_instead_of_running_install_py(se
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")
+ 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.assertIn(
+ 'const DEFAULT_SCRIPT_FILENAME = "workflow_api.py";', save_as_script
+ )
self.assertNotIn("prompt(", save_as_script)
-
diff --git a/tests/test_render.py b/tests/test_render.py
new file mode 100644
index 0000000..2acd8c8
--- /dev/null
+++ b/tests/test_render.py
@@ -0,0 +1,34 @@
+"""Tests for renderer readability helpers."""
+
+import unittest
+
+from comfyui_to_python.generator.model import GenerationPlan
+from comfyui_to_python.generator.render import WorkflowRenderer
+
+
+class WorkflowRendererReadabilityTest(unittest.TestCase):
+ def test_renderer_exposes_named_section_builders(self):
+ renderer = WorkflowRenderer()
+ plan = GenerationPlan(
+ workflow_data={"1": {"class_type": "Example", "inputs": {}}},
+ metadata_workflow_data=None,
+ custom_nodes=False,
+ import_statements={},
+ special_functions_code=[],
+ loop_code=[],
+ queue_size=1,
+ )
+
+ static_imports = renderer._build_static_imports(plan)
+ workflow_section = renderer._build_workflow_section(plan)
+ execution_section = renderer._build_execution_section(plan)
+ entrypoint_section = renderer._build_entrypoint_section()
+
+ self.assertIn("# Imports", static_imports)
+ self.assertIn("def build_workflow()", "\n".join(workflow_section))
+ self.assertIn("def main", "\n".join(execution_section))
+ self.assertEqual(entrypoint_section[-1], " main()")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_runtime_validation_harness.py b/tests/test_runtime_validation_harness.py
index 0e30253..fed1a97 100644
--- a/tests/test_runtime_validation_harness.py
+++ b/tests/test_runtime_validation_harness.py
@@ -25,12 +25,7 @@ def make_png_bytes(
) -> 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)
- )
+ 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 []
@@ -76,7 +71,21 @@ def test_ensure_runtime_path_runtime_tier_requires_valid_checkout(self, _mock_pa
ensure_runtime_path("runtime")
self.assertEqual(context.exception.classification, "environment/setup failure")
- self.assertIn("Could not find a valid ComfyUI checkout", context.exception.message)
+ self.assertIn(
+ "Could not find a valid ComfyUI checkout", context.exception.message
+ )
+
+ def test_ensure_runtime_path_runtime_tier_requires_pinned_opt_comfyui(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with patch(
+ "tests.runtime.run_runtime_validation.get_comfyui_path",
+ return_value=tmpdir,
+ ):
+ with self.assertRaises(ValidationFailure) as context:
+ ensure_runtime_path("runtime")
+
+ self.assertEqual(context.exception.classification, "environment/setup failure")
+ self.assertIn("/opt/ComfyUI", context.exception.message)
def test_check_models_returns_only_missing_requirements(self):
with tempfile.TemporaryDirectory() as tmpdir:
@@ -165,7 +174,10 @@ def test_parse_png_info_requires_dimensions(self):
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.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,
@@ -188,7 +200,10 @@ def test_execute_generated_python_classifies_missing_torch_as_environment_failur
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.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,
@@ -211,7 +226,10 @@ def test_execute_generated_python_classifies_missing_files_as_environment_failur
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.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,
@@ -234,7 +252,10 @@ def test_execute_generated_python_classifies_other_failures_as_repo_regression(
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.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,
@@ -259,18 +280,24 @@ def test_execute_generated_python_requires_fresh_matching_artifact(
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.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 = ""
+ """Test that execute_generated_python finds the newest matching output file.
+
+ The side_effect extracts the --output-directory path from subprocess args
+ and writes artifacts there, so the test works regardless of internal
+ temp dir handling.
+ """
+ import subprocess as _subprocess
+
fixture = FixtureConfig(
name="runtime-fixture",
path=Path("unused.json"),
@@ -278,28 +305,33 @@ def test_execute_generated_python_validates_newest_matching_artifact(
filename_prefix="expected_prefix",
)
+ result_mock = unittest.mock.MagicMock()
+ result_mock.returncode = 0
+ result_mock.stderr = ""
+ result_mock.stdout = ""
+
+ def _write_runtime_artifact(cmd, **_kwargs):
+ # Extract --output-directory from the command args
+ output_dir = None
+ for i, arg in enumerate(cmd):
+ if arg == "--output-directory" and i + 1 < len(cmd):
+ output_dir = Path(cmd[i + 1])
+ break
+ if output_dir is not None:
+ (output_dir / "expected_prefix_00002_.png").write_bytes(
+ make_png_bytes(2, 2)
+ )
+ return result_mock
+
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),
+ with unittest.mock.patch.object(
+ _subprocess, "run", side_effect=_write_runtime_artifact
):
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()
diff --git a/tests/test_upscale_model_loader_export.py b/tests/test_upscale_model_loader_export.py
index 9c1ff38..1c9180e 100644
--- a/tests/test_upscale_model_loader_export.py
+++ b/tests/test_upscale_model_loader_export.py
@@ -146,9 +146,12 @@ def test_export_defers_comfyui_bootstrap_until_main(self):
self.assertIn("def bootstrap_comfyui_runtime()", generated)
self.assertIn("def cleanup_comfyui_runtime(", generated)
- self.assertIn("import comfy.options", generated)
- self.assertIn("comfy.options.enable_args_parsing()", generated)
- self.assertIn("import cuda_malloc", generated)
+ # Bootstrap uses _load_module() from verified file paths, keeping
+ # parsed CLI args cached in sys.modules for the full runtime lifecycle.
+ self.assertIn("_load_module", generated)
+ self.assertIn("comfy.options", 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"
@@ -177,7 +180,9 @@ def test_export_defers_comfyui_bootstrap_until_main(self):
main_section.index("add_extra_model_paths()"),
main_section.index("import torch"),
)
- self.assertLess(generated.index("import cuda_malloc"), generated.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,
@@ -383,10 +388,13 @@ def test_run_cli_export_leaves_png_workflow_metadata_absent(self):
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),
diff --git a/uv.lock b/uv.lock
index 7fe2fb1..a9c9abf 100644
--- a/uv.lock
+++ b/uv.lock
@@ -63,8 +63,17 @@ 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" },
+]