scripts/check_deps.py
scripts/check_deps.pyBrowse 33 files
4,263 tokens
17,184 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32"""3check_deps.py — Verify a ComfyUI workflow's dependencies (custom nodes, models,4embeddings) against a running server.5 6Improvements over v1:7 - Cloud-aware endpoint mapping (handles `/api/experiment/models/{folder}` and8 `/api/object_info` variants verified against live cloud API)9 - Distinguishes 200-empty (genuinely no models in folder) vs 40410 (folder doesn't exist) vs 403 (auth/tier issue) — no silent passes11 - Outputs concrete remediation commands (e.g. `comfy node install <name>`)12 when nodes are missing13 - Detects embedding references inside prompt strings as model deps14 - Skips check on cloud free tier `/api/object_info` (403) without false alarm15 - Accepts API key from CLI flag OR $COMFY_CLOUD_API_KEY env var16 17Usage:18 python3 check_deps.py workflow_api.json19 python3 check_deps.py workflow_api.json --host 127.0.0.1 --port 818820 python3 check_deps.py workflow_api.json --host https://cloud.comfy.org21 22Stdlib-only. Python 3.10+.23"""24 25from __future__ import annotations26 27import argparse28import json29import sys30from pathlib import Path31 32sys.path.insert(0, str(Path(__file__).resolve().parent))33from _common import ( # noqa: E40234 DEFAULT_LOCAL_HOST, ENV_API_KEY,35 emit_json, folder_aliases_for, http_get, is_cloud_host,36 iter_embedding_refs, iter_model_deps, iter_nodes, parse_model_list,37 resolve_api_key, resolve_url, unwrap_workflow,38)39 40 41# Known node → custom-node-package map. When a workflow needs a node we don't42# recognize, suggesting the right `comfy node install ...` makes the difference43# between a working agent and a stuck one.44NODE_TO_PACKAGE: dict[str, str] = {45 # rgthree (Reroute is JS-only and doesn't appear in /object_info)46 "Power Lora Loader (rgthree)": "rgthree-comfy",47 "Image Comparer (rgthree)": "rgthree-comfy",48 "Seed (rgthree)": "rgthree-comfy",49 "Display Any (rgthree)": "rgthree-comfy",50 "Display Int (rgthree)": "rgthree-comfy",51 # Impact pack52 "FaceDetailer": "comfyui-impact-pack",53 "DetailerForEach": "comfyui-impact-pack",54 "BboxDetectorSEGS": "comfyui-impact-pack",55 "SAMLoader": "comfyui-impact-pack",56 "ImpactWildcardProcessor": "comfyui-impact-pack",57 # Impact subpack (separate package)58 "UltralyticsDetectorProvider": "comfyui-impact-subpack",59 # Was Node Suite60 "Image Save": "was-node-suite-comfyui",61 "Number Counter": "was-node-suite-comfyui",62 "Text String": "was-node-suite-comfyui",63 # easy-use64 "easy fullLoader": "comfyui-easy-use",65 "easy positive": "comfyui-easy-use",66 "easy negative": "comfyui-easy-use",67 "easy seed": "comfyui-easy-use",68 "easy imageSave": "comfyui-easy-use",69 # Video Helper Suite70 "VHS_VideoCombine": "comfyui-videohelpersuite",71 "VHS_LoadVideo": "comfyui-videohelpersuite",72 "VHS_LoadAudio": "comfyui-videohelpersuite",73 # AnimateDiff74 "ADE_AnimateDiffLoaderWithContext": "comfyui-animatediff-evolved",75 "ADE_AnimateDiffLoaderGen1": "comfyui-animatediff-evolved",76 "ADE_LoadAnimateDiffModel": "comfyui-animatediff-evolved",77 # ControlNet aux preprocessors (full class names)78 "CannyEdgePreprocessor": "comfyui_controlnet_aux",79 "DWPreprocessor": "comfyui_controlnet_aux",80 "OpenposePreprocessor": "comfyui_controlnet_aux",81 "DepthAnythingPreprocessor": "comfyui_controlnet_aux",82 "Zoe_DepthAnythingPreprocessor": "comfyui_controlnet_aux",83 "AnimalPosePreprocessor": "comfyui_controlnet_aux",84 # IPAdapter Plus85 "IPAdapterAdvanced": "comfyui_ipadapter_plus",86 "IPAdapterUnifiedLoader": "comfyui_ipadapter_plus",87 "IPAdapterModelLoader": "comfyui_ipadapter_plus",88 "IPAdapterInsightFaceLoader": "comfyui_ipadapter_plus",89 # InstantID90 "InstantIDModelLoader": "comfyui_instantid",91 "ApplyInstantID": "comfyui_instantid",92 # Comfy essentials (note: registry slug uses underscore, not hyphen)93 "GetImageSize+": "comfyui_essentials",94 "ImageBatchMultiple+": "comfyui_essentials",95 # pysssss96 "ShowText|pysssss": "comfyui-custom-scripts",97 "PreviewImage|pysssss": "comfyui-custom-scripts",98 # SUPIR99 "SUPIR_Upscale": "comfyui-supir",100 "SUPIR_first_stage": "comfyui-supir",101 # GGUF (case-sensitive registry slug)102 "UNETLoaderGGUF": "ComfyUI-GGUF",103 "DualCLIPLoaderGGUF": "ComfyUI-GGUF",104 # Florence2105 "Florence2Run": "comfyui-florence2",106 # WAS107 "Image Filter Adjustments": "was-node-suite-comfyui",108 # Photomaker (case-sensitive)109 "PhotoMakerLoader": "ComfyUI-PhotoMaker-Plus",110 # Wan video (case-sensitive)111 "WanVideoSampler": "ComfyUI-WanVideoWrapper",112 "WanVideoModelLoader": "ComfyUI-WanVideoWrapper",113}114 115# Nodes whose package isn't on the comfy registry — need git-URL install via116# ComfyUI-Manager. We surface a helpful hint instead of an unrunnable command.117NODE_TO_GIT_URL: dict[str, str] = {118 "HunyuanVideoSampler": "https://github.com/kijai/ComfyUI-HunyuanVideoWrapper",119 "HunyuanVideoModelLoader": "https://github.com/kijai/ComfyUI-HunyuanVideoWrapper",120}121 122 123def fetch_object_info(url: str, headers: dict) -> tuple[set[str] | None, dict | None]:124 """Returns (installed_node_set, error_info). Error info is a dict if we125 couldn't query (e.g. cloud free tier), else None.126 """127 r = http_get(url, headers=headers, retries=2, timeout=30)128 if r.status == 200:129 try:130 data = r.json()131 if isinstance(data, dict):132 return set(data.keys()), None133 except Exception:134 pass135 return None, {"http_status": 200, "reason": "non-dict response"}136 if r.status == 403:137 try:138 body = r.json()139 except Exception:140 body = {"raw": r.text()[:200]}141 return None, {"http_status": 403, "reason": "forbidden", "body": body}142 if r.status == 404:143 return None, {"http_status": 404, "reason": "endpoint not found"}144 return None, {"http_status": r.status, "reason": "unexpected", "body": r.text()[:200]}145 146 147def _fetch_one_folder(148 base: str, folder: str, headers: dict, *, is_cloud: bool,149) -> tuple[set[str] | None, dict | None]:150 """Single-folder fetch, no aliasing. Returns (installed_set, error_info)."""151 url = resolve_url(base, f"/models/{folder}", is_cloud=is_cloud)152 r = http_get(url, headers=headers, retries=2, timeout=30)153 if r.status == 200:154 try:155 return parse_model_list(r.json()), None156 except Exception:157 return set(), {"http_status": 200, "reason": "non-list response"}158 if r.status == 404:159 body_text = r.text()160 try:161 body = r.json()162 except Exception:163 body = {"raw": body_text[:200]}164 code = body.get("code") if isinstance(body, dict) else None165 if code == "folder_not_found":166 # Folder is genuinely empty/missing on server — not the same as167 # "endpoint missing". Return empty set with informational error.168 return set(), {"http_status": 404, "reason": "folder_empty_or_unknown", "body": body}169 return None, {"http_status": 404, "reason": "endpoint not found", "body": body}170 if r.status == 403:171 try:172 body = r.json()173 except Exception:174 body = {}175 return None, {"http_status": 403, "reason": "forbidden", "body": body}176 return None, {"http_status": r.status, "reason": "unexpected"}177 178 179def fetch_models_for_folder(180 base: str, folder: str, headers: dict, *, is_cloud: bool,181) -> tuple[set[str] | None, dict | None]:182 """Fetch installed models for a folder, trying aliases.183 184 Folder renames over time (e.g. unet → diffusion_models, clip → text_encoders)185 mean a workflow asking for a model in `unet` may need to look in186 `diffusion_models`. We union models from every reachable alias.187 188 Returns (combined_set | None, last_error | None).189 """190 aliases = folder_aliases_for(folder)191 combined: set[str] = set()192 any_success = False193 last_err: dict | None = None194 for alias in aliases:195 models, err = _fetch_one_folder(base, alias, headers, is_cloud=is_cloud)196 if models is not None:197 combined.update(models)198 any_success = True199 last_err = None200 else:201 last_err = err202 if not any_success:203 return None, last_err204 return combined, None205 206 207def fetch_embeddings(base: str, headers: dict, *, is_cloud: bool) -> tuple[set[str] | None, dict | None]:208 """Local ComfyUI exposes /embeddings; cloud uses /experiment/models/embeddings."""209 if is_cloud:210 return fetch_models_for_folder(base, "embeddings", headers, is_cloud=True)211 # Local: dedicated /embeddings returns a flat list of names212 r = http_get(resolve_url(base, "/embeddings", is_cloud=False), headers=headers, retries=2)213 if r.status == 200:214 try:215 data = r.json()216 if isinstance(data, list):217 # Strip extensions from the registered names since prompt syntax218 # usually omits them ("embedding:goodvibes" vs "goodvibes.pt")219 names = set()220 for n in data:221 if isinstance(n, str):222 names.add(n)223 # Also store stem for fuzzy matching224 names.add(Path(n).stem)225 return names, None226 except Exception:227 pass228 return None, {"http_status": r.status, "reason": "unexpected"}229 230 231def normalize_for_match(name: str) -> set[str]:232 """Generate matching variants of a model name (with/without extension, slashes, etc.)"""233 s = {name}234 s.add(Path(name).stem)235 s.add(Path(name).name)236 # ComfyUI sometimes strips/keeps the leading folder237 if "/" in name or "\\" in name:238 flat = name.replace("\\", "/").split("/")[-1]239 s.add(flat)240 s.add(Path(flat).stem)241 return {x for x in s if x}242 243 244def model_present(needed: str, installed: set[str]) -> bool:245 if not installed:246 return False247 needed_variants = normalize_for_match(needed)248 installed_norm: set[str] = set()249 for inst in installed:250 installed_norm.update(normalize_for_match(inst))251 return bool(needed_variants & installed_norm)252 253 254def suggest_install_command(node_class: str) -> str | None:255 pkg = NODE_TO_PACKAGE.get(node_class)256 if pkg:257 return f"comfy node install {pkg}"258 return None259 260 261def suggest_git_url(node_class: str) -> str | None:262 """For nodes not on the registry, return a git URL the user can hand to263 ComfyUI-Manager's `/manager/queue/install` endpoint."""264 return NODE_TO_GIT_URL.get(node_class)265 266 267def check_deps(268 workflow: dict, host: str, *, api_key: str | None = None,269) -> dict:270 headers: dict[str, str] = {}271 if api_key:272 headers["X-API-Key"] = api_key273 274 is_cloud = is_cloud_host(host)275 base = host.rstrip("/")276 277 # ---- 1. Required nodes ----278 required_nodes: set[str] = set()279 for _, node in iter_nodes(workflow):280 required_nodes.add(node["class_type"])281 282 object_info_url = resolve_url(base, "/object_info", is_cloud=is_cloud)283 installed_nodes, obj_err = fetch_object_info(object_info_url, headers)284 285 missing_nodes: list[dict] = []286 node_check_skipped = False287 if installed_nodes is None:288 # Couldn't query (e.g. cloud free tier). Don't false-alarm; mark skipped.289 node_check_skipped = True290 else:291 for cls in sorted(required_nodes):292 if cls not in installed_nodes:293 entry = {"class_type": cls}294 cmd = suggest_install_command(cls)295 git_url = suggest_git_url(cls)296 if cmd:297 entry["fix_command"] = cmd298 elif git_url:299 entry["fix_git_url"] = git_url300 entry["fix_hint"] = (301 f"Not on registry. Install via Manager with this git URL: {git_url}"302 )303 else:304 entry["fix_hint"] = (305 "Search https://registry.comfy.org or "306 "use ComfyUI-Manager UI to find the package providing this node."307 )308 missing_nodes.append(entry)309 310 # ---- 2. Required models ----311 model_cache: dict[str, tuple[set[str] | None, dict | None]] = {}312 missing_models: list[dict] = []313 folder_errors: dict[str, dict] = {}314 315 for dep in iter_model_deps(workflow):316 folder = dep["folder"]317 if folder not in model_cache:318 model_cache[folder] = fetch_models_for_folder(319 base, folder, headers, is_cloud=is_cloud,320 )321 installed, err = model_cache[folder]322 if installed is None:323 # Couldn't enumerate this folder — record once324 folder_errors.setdefault(folder, err or {})325 # Don't flag as missing (we don't know); the folder_errors block surfaces this326 continue327 if not model_present(dep["value"], installed):328 entry = dict(dep)329 entry["fix_hint"] = (330 f"comfy model download --url <URL> --relative-path models/{folder} "331 f"--filename {dep['value']!r}"332 )333 missing_models.append(entry)334 335 # ---- 3. Embedding refs in prompts ----336 emb_installed, emb_err = fetch_embeddings(base, headers, is_cloud=is_cloud)337 missing_embeddings: list[dict] = []338 seen_emb: set[tuple[str, str]] = set()339 for nid, emb_name in iter_embedding_refs(workflow):340 if (nid, emb_name) in seen_emb:341 continue342 seen_emb.add((nid, emb_name))343 if emb_installed is None:344 # Couldn't enumerate — skip silently here, surface the error in the345 # folder_errors block346 continue347 if not model_present(emb_name, emb_installed):348 missing_embeddings.append({349 "node_id": nid,350 "embedding_name": emb_name,351 "folder": "embeddings",352 "fix_hint": (353 f"Download {emb_name}.pt or .safetensors and place in "354 f"models/embeddings/, or `comfy model download --url <URL> "355 f"--relative-path models/embeddings`"356 ),357 })358 359 if emb_err and emb_installed is None:360 folder_errors.setdefault("embeddings", emb_err)361 362 is_ready = (363 not node_check_skipped364 and not missing_nodes365 and not missing_models366 and not missing_embeddings367 )368 369 return {370 "is_ready": is_ready,371 "node_check_skipped": node_check_skipped,372 "node_check_skip_reason": obj_err if node_check_skipped else None,373 "missing_nodes": missing_nodes,374 "missing_models": missing_models,375 "missing_embeddings": missing_embeddings,376 "folder_errors": folder_errors,377 # 0 is a legitimate count (e.g. empty server). Use None only when not queried.378 "installed_node_count": len(installed_nodes) if installed_nodes is not None else None,379 "required_node_count": len(required_nodes),380 "required_nodes": sorted(required_nodes),381 "host": base,382 "is_cloud": is_cloud,383 }384 385 386def main(argv: list[str] | None = None) -> int:387 p = argparse.ArgumentParser(description="Check ComfyUI workflow dependencies against a running server")388 p.add_argument("workflow", help="Path to workflow API JSON file")389 p.add_argument("--host", default=DEFAULT_LOCAL_HOST, help="ComfyUI server URL")390 p.add_argument("--port", type=int, help="Server port (overrides --host port)")391 p.add_argument("--api-key", help=f"API key for cloud (or set ${ENV_API_KEY} env var)")392 p.add_argument("--strict", action="store_true",393 help="Exit non-zero if node check is skipped (e.g. on cloud free tier)")394 args = p.parse_args(argv)395 396 host = args.host397 if args.port is not None:398 # Strip any port from host and append --port399 from urllib.parse import urlparse, urlunparse400 parsed = urlparse(host if "://" in host else f"http://{host}")401 new_netloc = f"{parsed.hostname}:{args.port}"402 host = urlunparse(parsed._replace(netloc=new_netloc))403 404 api_key = resolve_api_key(args.api_key)405 406 wf_path = Path(args.workflow).expanduser()407 if not wf_path.exists():408 emit_json({"error": f"Workflow file not found: {args.workflow}"})409 return 1410 try:411 with wf_path.open(encoding="utf-8-sig") as f:412 payload = json.load(f)413 workflow = unwrap_workflow(payload)414 except ValueError as e:415 emit_json({"error": str(e)})416 return 1417 except json.JSONDecodeError as e:418 emit_json({"error": f"Invalid JSON: {e}"})419 return 1420 421 try:422 result = check_deps(workflow, host=host, api_key=api_key)423 except Exception as e:424 emit_json({"error": f"Dep check failed: {e}", "host": host})425 return 1426 427 emit_json(result)428 429 if not result["is_ready"]:430 return 1431 if args.strict and result["node_check_skipped"]:432 return 1433 return 0434 435 436if __name__ == "__main__":437 sys.exit(main())438