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" }, +]