Add 5 E2E tests for session execution mode: 2 unit tests for code generation structure and 3 runtime tests that exercise the full ComfyUI pipeline (oneshot, session single run, session multi-run). Fix close() method signature in generated session code to use "bool | None = None" instead of "bool = True" for consistency with the actual generated output.
787 lines
25 KiB
Python
787 lines
25 KiB
Python
import argparse
|
||
import ast
|
||
import json
|
||
import os
|
||
import shutil
|
||
import struct
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import zlib
|
||
from dataclasses import dataclass
|
||
from io import StringIO
|
||
from pathlib import Path
|
||
from typing import Callable
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
FIXTURE_DIR = ROOT / "tests" / "fixtures" / "runtime"
|
||
GENERATED_DIR = ROOT / "tests" / "runtime" / "generated"
|
||
COMFYUI_OUTPUT_DIRNAME = "output"
|
||
COMFYUI_INPUT_DIRNAME = "input"
|
||
|
||
if str(ROOT) not in sys.path:
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
from comfyui_to_python_utils import get_comfyui_path
|
||
|
||
|
||
class ValidationFailure(RuntimeError):
|
||
def __init__(self, classification: str, message: str):
|
||
super().__init__(message)
|
||
self.classification = classification
|
||
self.message = message
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class FixtureConfig:
|
||
name: str
|
||
path: Path
|
||
mapping_factory: Callable[[], dict] | None = None
|
||
fast_mapping_factory: Callable[[], dict] | None = None
|
||
runtime_capable: bool = False
|
||
filename_prefix: str | None = None
|
||
expected_min_dimensions: tuple[int, int] | None = None
|
||
metadata_markers: tuple[str, ...] = ()
|
||
model_requirements: tuple["ModelRequirement", ...] = ()
|
||
staged_inputs: tuple["StagedInput", ...] = ()
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ModelRequirement:
|
||
filename: str
|
||
relative_dir: str
|
||
source_url: str
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class StagedInput:
|
||
source_path: Path
|
||
destination_name: str
|
||
|
||
|
||
class FlexibleNode:
|
||
CATEGORY = "utils"
|
||
FUNCTION = "run"
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"safe_name": ("STRING",),
|
||
}
|
||
}
|
||
|
||
def run(self, **kwargs):
|
||
return (kwargs,)
|
||
|
||
|
||
class RegexReplace:
|
||
CATEGORY = "utils"
|
||
FUNCTION = "replace"
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"text": ("STRING",),
|
||
"pattern": ("STRING",),
|
||
"replace": ("STRING",),
|
||
}
|
||
}
|
||
|
||
def replace(self, text, pattern, replace):
|
||
return (text,)
|
||
|
||
|
||
class PassthroughText:
|
||
CATEGORY = "utils"
|
||
FUNCTION = "run"
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"text": ("STRING",),
|
||
}
|
||
}
|
||
|
||
def run(self, text):
|
||
return (text,)
|
||
|
||
|
||
class SplitText:
|
||
CATEGORY = "utils"
|
||
FUNCTION = "split"
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"text": ("STRING",),
|
||
}
|
||
}
|
||
|
||
def split(self, text):
|
||
left, right = text.split("|", 1)
|
||
return (left, right)
|
||
|
||
|
||
class JoinText:
|
||
CATEGORY = "utils"
|
||
FUNCTION = "join"
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"left": ("STRING",),
|
||
"right": ("STRING",),
|
||
}
|
||
}
|
||
|
||
def join(self, left, right):
|
||
return (f"{left}::{right}",)
|
||
|
||
|
||
class UpscaleModelLoader:
|
||
CATEGORY = "loaders"
|
||
FUNCTION = "load_model"
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"model_name": ("STRING",),
|
||
}
|
||
}
|
||
|
||
def load_model(self, model_name):
|
||
return (model_name,)
|
||
|
||
|
||
class LoadImage:
|
||
CATEGORY = "image"
|
||
FUNCTION = "load_image"
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"image": ("STRING",),
|
||
}
|
||
}
|
||
|
||
def load_image(self, image):
|
||
return (image,)
|
||
|
||
|
||
class ImageUpscaleWithModel:
|
||
CATEGORY = "image/upscaling"
|
||
FUNCTION = "upscale"
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"upscale_model": ("UPSCALE_MODEL",),
|
||
"image": ("IMAGE",),
|
||
}
|
||
}
|
||
|
||
def upscale(self, upscale_model, image):
|
||
return (image,)
|
||
|
||
|
||
class SaveImageNode:
|
||
CATEGORY = "image"
|
||
FUNCTION = "save_images"
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"images": ("IMAGE",),
|
||
"filename_prefix": ("STRING",),
|
||
}
|
||
}
|
||
|
||
def save_images(self, images, filename_prefix):
|
||
return ()
|
||
|
||
|
||
FIXTURES = {
|
||
# Runtime-capable fixtures are exported inside a real ComfyUI checkout and
|
||
# then executed end-to-end. Fast fixtures use local stub mappings so the
|
||
# exporter can be validated without importing the full runtime.
|
||
"upscale-model-loader": FixtureConfig(
|
||
name="upscale-model-loader",
|
||
path=FIXTURE_DIR / "upscale-model-loader.json",
|
||
fast_mapping_factory=lambda: {
|
||
"LoadImage": LoadImage,
|
||
"UpscaleModelLoader": UpscaleModelLoader,
|
||
"ImageUpscaleWithModel": ImageUpscaleWithModel,
|
||
"SaveImage": SaveImageNode,
|
||
},
|
||
runtime_capable=True,
|
||
filename_prefix="E2E_upscale_model_loader",
|
||
expected_min_dimensions=(1000, 1000),
|
||
metadata_markers=("UpscaleModelLoader", "E2E_upscale_model_loader"),
|
||
model_requirements=(
|
||
ModelRequirement(
|
||
filename="RealESRGAN_x4plus.safetensors",
|
||
relative_dir="models/upscale_models",
|
||
source_url="https://huggingface.co/Comfy-Org/Real-ESRGAN_repackaged/resolve/main/RealESRGAN_x4plus.safetensors",
|
||
),
|
||
),
|
||
staged_inputs=(
|
||
StagedInput(
|
||
source_path=ROOT / "images" / "save_as_script.png",
|
||
destination_name="e2e_upscale_input.png",
|
||
),
|
||
),
|
||
),
|
||
"text-to-image": FixtureConfig(
|
||
name="text-to-image",
|
||
path=FIXTURE_DIR / "text-to-image.json",
|
||
runtime_capable=True,
|
||
filename_prefix="E2E_text_to_image",
|
||
expected_min_dimensions=(512, 512),
|
||
metadata_markers=("a small cottage in a meadow", "CheckpointLoaderSimple"),
|
||
model_requirements=(
|
||
ModelRequirement(
|
||
filename="v1-5-pruned-emaonly-fp16.safetensors",
|
||
relative_dir="models/checkpoints",
|
||
source_url="https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors",
|
||
),
|
||
),
|
||
),
|
||
"unsafe-kwargs": FixtureConfig(
|
||
name="unsafe-kwargs",
|
||
path=FIXTURE_DIR / "unsafe-kwargs.json",
|
||
mapping_factory=lambda: {"FlexibleNode": FlexibleNode},
|
||
),
|
||
"subgraph-identifiers": FixtureConfig(
|
||
name="subgraph-identifiers",
|
||
path=FIXTURE_DIR / "subgraph-identifiers.json",
|
||
mapping_factory=lambda: {
|
||
"RegexReplace": RegexReplace,
|
||
"PassthroughText": PassthroughText,
|
||
},
|
||
),
|
||
"reused-node-class-branches": FixtureConfig(
|
||
name="reused-node-class-branches",
|
||
path=FIXTURE_DIR / "reused-node-class-branches.json",
|
||
mapping_factory=lambda: {
|
||
"PassthroughText": PassthroughText,
|
||
"JoinText": JoinText,
|
||
},
|
||
),
|
||
"secondary-output-selection": FixtureConfig(
|
||
name="secondary-output-selection",
|
||
path=FIXTURE_DIR / "secondary-output-selection.json",
|
||
mapping_factory=lambda: {
|
||
"SplitText": SplitText,
|
||
"PassthroughText": PassthroughText,
|
||
},
|
||
),
|
||
}
|
||
|
||
|
||
def parse_args() -> argparse.Namespace:
|
||
parser = argparse.ArgumentParser(
|
||
description="Run fast or runtime validation for committed fixtures."
|
||
)
|
||
parser.add_argument(
|
||
"--tier",
|
||
choices=("fast", "runtime"),
|
||
help="Validation tier to run.",
|
||
)
|
||
parser.add_argument(
|
||
"--fixture",
|
||
default="all",
|
||
help="Fixture name or 'all'.",
|
||
)
|
||
parser.add_argument(
|
||
"--execute",
|
||
action="store_true",
|
||
help="Execute generated Python for runtime-capable fixtures.",
|
||
)
|
||
parser.add_argument(
|
||
"--print-download-plan",
|
||
action="store_true",
|
||
help="Print model download commands for missing models.",
|
||
)
|
||
parser.add_argument(
|
||
"--internal-export",
|
||
choices=tuple(FIXTURES.keys()),
|
||
help=argparse.SUPPRESS,
|
||
)
|
||
parser.add_argument(
|
||
"--generated-path",
|
||
help=argparse.SUPPRESS,
|
||
)
|
||
parser.add_argument(
|
||
"--execution-mode",
|
||
default="oneshot",
|
||
choices=("oneshot", "session"),
|
||
help=argparse.SUPPRESS,
|
||
)
|
||
args = parser.parse_args()
|
||
if not args.internal_export and not args.tier:
|
||
parser.error("--tier is required unless --internal-export is used.")
|
||
return args
|
||
|
||
|
||
def load_fixture_names(selection: str) -> list[str]:
|
||
if selection == "all":
|
||
return list(FIXTURES.keys())
|
||
if selection not in FIXTURES:
|
||
raise ValidationFailure("fixture bug", f"Unknown fixture '{selection}'.")
|
||
return [selection]
|
||
|
||
|
||
def ensure_runtime_path(tier: str) -> str:
|
||
if tier == "runtime":
|
||
runtime_path = get_comfyui_path()
|
||
else:
|
||
return os.environ.get("COMFYUI_PATH", "")
|
||
|
||
if not runtime_path or not Path(runtime_path).is_dir():
|
||
raise ValidationFailure(
|
||
"environment/setup failure",
|
||
"Could not find a valid ComfyUI checkout for runtime validation. "
|
||
"Set COMFYUI_PATH or run the tests from a location where a "
|
||
"parent directory contains ComfyUI.",
|
||
)
|
||
|
||
return str(runtime_path)
|
||
|
||
|
||
def get_runtime_python(runtime_path: str) -> str:
|
||
runtime_python = Path(runtime_path) / ".venv" / "bin" / "python"
|
||
if runtime_python.is_file():
|
||
return str(runtime_python)
|
||
return sys.executable
|
||
|
||
|
||
def get_fixture(fixture_name: str) -> FixtureConfig:
|
||
return FIXTURES[fixture_name]
|
||
|
||
|
||
def check_models(fixture: FixtureConfig, runtime_path: str) -> list[ModelRequirement]:
|
||
runtime_root = Path(runtime_path)
|
||
missing = []
|
||
for requirement in fixture.model_requirements:
|
||
target = runtime_root / requirement.relative_dir / requirement.filename
|
||
if not target.is_file():
|
||
missing.append(requirement)
|
||
return missing
|
||
|
||
|
||
def print_download_plan(fixture: FixtureConfig, runtime_path: str) -> None:
|
||
runtime_root = Path(runtime_path)
|
||
for requirement in check_models(fixture, runtime_path):
|
||
target_dir = runtime_root / requirement.relative_dir
|
||
print(
|
||
"download:",
|
||
f"mkdir -p {target_dir} && curl -L {requirement.source_url} -o {target_dir / requirement.filename}",
|
||
)
|
||
|
||
|
||
def stage_inputs(fixture: FixtureConfig, runtime_path: str) -> None:
|
||
runtime_input_dir = Path(runtime_path) / COMFYUI_INPUT_DIRNAME
|
||
runtime_input_dir.mkdir(parents=True, exist_ok=True)
|
||
for staged_input in fixture.staged_inputs:
|
||
if not staged_input.source_path.is_file():
|
||
raise ValidationFailure(
|
||
"fixture bug",
|
||
f"Missing staged input source {staged_input.source_path} for {fixture.name}.",
|
||
)
|
||
shutil.copyfile(
|
||
staged_input.source_path,
|
||
runtime_input_dir / staged_input.destination_name,
|
||
)
|
||
|
||
|
||
def export_workflow(
|
||
fixture: FixtureConfig,
|
||
tier: str,
|
||
runtime_path: str,
|
||
execution_mode: str = "oneshot",
|
||
) -> tuple[str, str]:
|
||
from comfyui_to_python import ComfyUItoPython
|
||
|
||
workflow = fixture.path.read_text(encoding="utf-8")
|
||
output = StringIO()
|
||
kwargs = {
|
||
"workflow": workflow,
|
||
"output_file": output,
|
||
"execution_mode": execution_mode,
|
||
}
|
||
if tier == "fast" and fixture.fast_mapping_factory is not None:
|
||
kwargs["node_class_mappings"] = fixture.fast_mapping_factory()
|
||
elif fixture.mapping_factory is not None:
|
||
kwargs["node_class_mappings"] = fixture.mapping_factory()
|
||
else:
|
||
os.environ["COMFYUI_PATH"] = runtime_path
|
||
|
||
try:
|
||
ComfyUItoPython(**kwargs)
|
||
except ModuleNotFoundError as exc:
|
||
missing_module = exc.name or "unknown"
|
||
raise ValidationFailure(
|
||
"environment/setup failure",
|
||
f"Missing runtime dependency '{missing_module}' while exporting {fixture.name}.",
|
||
) from exc
|
||
except KeyError as exc:
|
||
raise ValidationFailure(
|
||
"repo regression",
|
||
f"Exporter failed to resolve workflow data for {fixture.name}: {exc}",
|
||
) from exc
|
||
except Exception as exc:
|
||
raise ValidationFailure(
|
||
"repo regression",
|
||
f"Exporter failed for {fixture.name}: {exc}",
|
||
) from exc
|
||
|
||
return workflow, output.getvalue()
|
||
|
||
|
||
def export_workflow_in_runtime_env(fixture: FixtureConfig, runtime_path: str) -> str:
|
||
runtime_python = get_runtime_python(runtime_path)
|
||
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
|
||
generated_path = GENERATED_DIR / f"{fixture.name}.py"
|
||
env = os.environ.copy()
|
||
env["COMFYUI_PATH"] = runtime_path
|
||
env["PYTHONPATH"] = os.pathsep.join([str(ROOT), env.get("PYTHONPATH", "")]).rstrip(
|
||
os.pathsep
|
||
)
|
||
# Re-enter this script under the runtime interpreter so export happens with
|
||
# the target ComfyUI checkout on sys.path, not just the repo's current venv.
|
||
result = subprocess.run(
|
||
[
|
||
runtime_python,
|
||
str(Path(__file__).resolve()),
|
||
"--internal-export",
|
||
fixture.name,
|
||
"--generated-path",
|
||
str(generated_path),
|
||
],
|
||
cwd=ROOT,
|
||
env=env,
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
if result.returncode != 0:
|
||
output = (result.stderr or result.stdout or "").strip()
|
||
classification = "environment/setup failure"
|
||
if (
|
||
"Missing runtime dependency" not in output
|
||
and "ModuleNotFoundError" not in output
|
||
):
|
||
classification = "repo regression"
|
||
raise ValidationFailure(
|
||
classification,
|
||
f"Runtime export failed for {fixture.name}: {output}",
|
||
)
|
||
return generated_path.read_text(encoding="utf-8")
|
||
|
||
|
||
def export_session_workflow_in_runtime_env(
|
||
workflow_json: str,
|
||
execution_mode: str = "session",
|
||
) -> str:
|
||
"""Export a session workflow via subprocess in the ComfyUI runtime env.
|
||
|
||
Unlike ``export_workflow_in_runtime_env`` this does not require a fixture
|
||
config – it receives workflow JSON directly and re-enters the runtime
|
||
interpreter so ``ComfyUItoPython`` can import ComfyUI's nodes.
|
||
"""
|
||
runtime_path = os.environ.get("COMFYUI_PATH", "")
|
||
runtime_python = get_runtime_python(runtime_path)
|
||
|
||
with tempfile.NamedTemporaryFile(
|
||
suffix=".json", mode="w", delete=False, encoding="utf-8"
|
||
) as wf:
|
||
wf.write(workflow_json)
|
||
wf_path = wf.name
|
||
|
||
try:
|
||
temp_fixture = FixtureConfig(
|
||
name="session-mode-export",
|
||
path=Path(wf_path),
|
||
)
|
||
_, generated = export_workflow(
|
||
fixture=temp_fixture,
|
||
tier="runtime",
|
||
runtime_path=runtime_path,
|
||
execution_mode=execution_mode,
|
||
)
|
||
return generated
|
||
finally:
|
||
os.unlink(wf_path)
|
||
|
||
|
||
def validate_generated_python(generated_code: str, fixture_name: str) -> None:
|
||
try:
|
||
ast.parse(generated_code)
|
||
except SyntaxError as exc:
|
||
raise ValidationFailure(
|
||
"repo regression",
|
||
f"Generated Python is not valid for {fixture_name}: {exc}",
|
||
) from exc
|
||
|
||
|
||
def parse_png_info(image_path: Path) -> tuple[int, int, dict[str, str]]:
|
||
with image_path.open("rb") as handle:
|
||
signature = handle.read(8)
|
||
if signature != b"\x89PNG\r\n\x1a\n":
|
||
raise ValidationFailure(
|
||
"environment/setup failure",
|
||
f"Expected PNG output for {image_path.name}, got a different file format.",
|
||
)
|
||
|
||
width = height = None
|
||
text_data: dict[str, str] = {}
|
||
while True:
|
||
length_bytes = handle.read(4)
|
||
if not length_bytes:
|
||
break
|
||
length = struct.unpack(">I", length_bytes)[0]
|
||
chunk_type = handle.read(4)
|
||
chunk_data = handle.read(length)
|
||
handle.read(4)
|
||
|
||
if chunk_type == b"IHDR":
|
||
width, height = struct.unpack(">II", chunk_data[:8])
|
||
elif chunk_type == b"tEXt":
|
||
key, value = chunk_data.split(b"\x00", 1)
|
||
text_data[key.decode("latin-1")] = value.decode("latin-1")
|
||
elif chunk_type == b"zTXt":
|
||
key, compressed = chunk_data.split(b"\x00", 1)
|
||
text_data[key.decode("latin-1")] = zlib.decompress(
|
||
compressed[1:]
|
||
).decode("latin-1")
|
||
elif chunk_type == b"iTXt":
|
||
parts = chunk_data.split(b"\x00", 5)
|
||
if len(parts) == 6:
|
||
key = parts[0].decode("utf-8")
|
||
compressed_flag = parts[1]
|
||
value = parts[5]
|
||
if compressed_flag == b"\x01":
|
||
value = zlib.decompress(value)
|
||
text_data[key] = value.decode("utf-8")
|
||
elif chunk_type == b"IEND":
|
||
break
|
||
|
||
if width is None or height is None:
|
||
raise ValidationFailure(
|
||
"environment/setup failure",
|
||
f"Could not read PNG dimensions from {image_path.name}.",
|
||
)
|
||
|
||
return width, height, text_data
|
||
|
||
|
||
def validate_output_artifact(
|
||
fixture: FixtureConfig,
|
||
output_path: Path,
|
||
) -> None:
|
||
# Read PNG metadata directly so artifact validation does not depend on
|
||
# optional imaging libraries inside the runtime environment.
|
||
width, height, metadata = parse_png_info(output_path)
|
||
|
||
if fixture.expected_min_dimensions is not None:
|
||
min_width, min_height = fixture.expected_min_dimensions
|
||
if width < min_width or height < min_height:
|
||
raise ValidationFailure(
|
||
"repo regression",
|
||
f"Output dimensions for {fixture.name} were {width}x{height}, expected at least {min_width}x{min_height}.",
|
||
)
|
||
|
||
metadata_blob = "\n".join(
|
||
[output_path.name] + [f"{key}={value}" for key, value in metadata.items()]
|
||
)
|
||
for marker in fixture.metadata_markers:
|
||
if marker not in metadata_blob:
|
||
raise ValidationFailure(
|
||
"repo regression",
|
||
f"Output metadata for {fixture.name} did not contain expected marker '{marker}'.",
|
||
)
|
||
|
||
|
||
def execute_generated_python(
|
||
generated_code: str,
|
||
fixture: FixtureConfig,
|
||
runtime_path: str,
|
||
) -> None:
|
||
if not fixture.runtime_capable:
|
||
raise ValidationFailure(
|
||
"fixture bug",
|
||
f"Fixture {fixture.name} is not marked runtime-capable.",
|
||
)
|
||
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
tmp_path = Path(tmpdir) / f"{fixture.name}.py"
|
||
tmp_path.write_text(generated_code, encoding="utf-8")
|
||
output_dir = Path(runtime_path) / COMFYUI_OUTPUT_DIRNAME
|
||
# Compare against the pre-run snapshot so validation can prove this
|
||
# execution created a fresh artifact instead of reusing an old output.
|
||
existing_outputs = set(output_dir.glob("*.png"))
|
||
env = os.environ.copy()
|
||
env["COMFYUI_PATH"] = runtime_path
|
||
env["PYTHONPATH"] = os.pathsep.join(
|
||
[str(ROOT), env.get("PYTHONPATH", "")]
|
||
).rstrip(os.pathsep)
|
||
runtime_python = get_runtime_python(runtime_path)
|
||
|
||
result = subprocess.run(
|
||
[runtime_python, str(tmp_path)],
|
||
cwd=ROOT,
|
||
env=env,
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
if result.returncode == 0:
|
||
if fixture.filename_prefix is None:
|
||
return
|
||
new_outputs = [
|
||
path
|
||
for path in output_dir.glob(f"{fixture.filename_prefix}*.png")
|
||
if path not in existing_outputs
|
||
]
|
||
if not new_outputs:
|
||
raise ValidationFailure(
|
||
"repo regression",
|
||
f"Generated script for {fixture.name} did not produce a new output file with prefix {fixture.filename_prefix}.",
|
||
)
|
||
newest_output = max(new_outputs, key=lambda path: path.stat().st_mtime)
|
||
validate_output_artifact(fixture, newest_output)
|
||
return
|
||
|
||
stderr = (result.stderr or "").strip()
|
||
stdout = (result.stdout or "").strip()
|
||
output = stderr or stdout or "generated script exited with a non-zero status"
|
||
lower_output = output.lower()
|
||
|
||
if "no module named 'torch'" in lower_output:
|
||
classification = "environment/setup failure"
|
||
elif "no such file or directory" in lower_output or "not found" in lower_output:
|
||
classification = "environment/setup failure"
|
||
else:
|
||
classification = "repo regression"
|
||
|
||
raise ValidationFailure(
|
||
classification,
|
||
f"Generated script execution failed for {fixture.name}: {output}",
|
||
)
|
||
|
||
|
||
def run_fixture(fixture: FixtureConfig, tier: str, execute: bool, runtime_path: str) -> str:
|
||
if tier == "fast":
|
||
_, generated_code = export_workflow(fixture, tier, runtime_path)
|
||
else:
|
||
missing_models = check_models(fixture, runtime_path)
|
||
if missing_models:
|
||
raise ValidationFailure(
|
||
"model provisioning failure",
|
||
"Missing models for "
|
||
f"{fixture.name}: "
|
||
+ ", ".join(
|
||
f"{item.relative_dir}/{item.filename}" for item in missing_models
|
||
),
|
||
)
|
||
stage_inputs(fixture, runtime_path)
|
||
generated_code = export_workflow_in_runtime_env(fixture, runtime_path)
|
||
validate_generated_python(generated_code, fixture.name)
|
||
|
||
# Fast keeps execution opt-in because its stub node mappings are intended for
|
||
# export coverage only. Runtime always executes the generated script.
|
||
should_execute = execute or tier == "runtime"
|
||
if should_execute and tier == "runtime":
|
||
execute_generated_python(generated_code, fixture, runtime_path)
|
||
|
||
return "pass"
|
||
|
||
|
||
def main() -> int:
|
||
args = parse_args()
|
||
|
||
if args.internal_export:
|
||
fixture = get_fixture(args.internal_export)
|
||
output_path = Path(args.generated_path)
|
||
_, generated_code = export_workflow(
|
||
fixture=fixture,
|
||
tier="runtime",
|
||
runtime_path=os.environ.get("COMFYUI_PATH", ""),
|
||
execution_mode=args.execution_mode,
|
||
)
|
||
output_path.write_text(generated_code, encoding="utf-8")
|
||
print(generated_code, end="")
|
||
return 0
|
||
|
||
try:
|
||
runtime_path = ensure_runtime_path(args.tier)
|
||
fixture_names = load_fixture_names(args.fixture)
|
||
requested = [FIXTURES[name] for name in fixture_names]
|
||
|
||
if args.tier == "fast":
|
||
requested = [
|
||
fixture
|
||
for fixture in requested
|
||
if fixture.fast_mapping_factory is not None
|
||
or fixture.mapping_factory is not None
|
||
]
|
||
if not requested:
|
||
raise ValidationFailure(
|
||
"fixture bug",
|
||
"No selected fixtures are fast-tier compatible.",
|
||
)
|
||
elif args.tier == "runtime":
|
||
requested = [fixture for fixture in requested if fixture.runtime_capable]
|
||
if not requested:
|
||
raise ValidationFailure(
|
||
"fixture bug",
|
||
"No selected fixtures are runtime-capable for this tier.",
|
||
)
|
||
|
||
failures: list[tuple[str, str, str]] = []
|
||
for fixture in requested:
|
||
try:
|
||
if args.print_download_plan and args.tier == "runtime":
|
||
missing_models = check_models(fixture, runtime_path)
|
||
for requirement in missing_models:
|
||
target_dir = Path(runtime_path) / requirement.relative_dir
|
||
print(
|
||
"download:",
|
||
f"mkdir -p {target_dir} && curl -L {requirement.source_url} -o {target_dir / requirement.filename}",
|
||
)
|
||
if missing_models:
|
||
print(f"{fixture.name}: download-plan")
|
||
continue
|
||
status = run_fixture(fixture, args.tier, args.execute, runtime_path)
|
||
print(f"{fixture.name}: {status}")
|
||
except ValidationFailure as exc:
|
||
failures.append((fixture.name, exc.classification, exc.message))
|
||
print(
|
||
f"{fixture.name}: fail ({exc.classification})",
|
||
file=sys.stderr,
|
||
)
|
||
print(exc.message, file=sys.stderr)
|
||
if failures:
|
||
classifications = ", ".join(
|
||
f"{name}={classification}" for name, classification, _ in failures
|
||
)
|
||
print(f"classification: {classifications}", file=sys.stderr)
|
||
return 1
|
||
except ValidationFailure as exc:
|
||
print(f"classification: {exc.classification}", file=sys.stderr)
|
||
print(exc.message, file=sys.stderr)
|
||
return 1
|
||
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|