From 51d131bbd5efd33b00e63d936087591d309a4812 Mon Sep 17 00:00:00 2001 From: kishore Date: Wed, 24 Jun 2026 22:10:24 -0700 Subject: [PATCH 1/3] feat: live-refresh public data sources; make gallery a CQL engine Replace hand-maintained / stale bundled data with live-refresh from the public Comfy repos, and fix the architecture around CQL + templates. Data freshness (live fetch + TTL cache + offline fallback): - Node annotations (supported_nodes.yaml, cloud_disable_config.yaml) now resolve via new cql/annotations_source.py: 7-day TTL cache -> live fetch from Comfy-Org/comfy-complete -> bundled snapshot fallback. Repurpose the no-op `comfy nodes refresh` to force a re-fetch. COMFY_CLI_NO_REMOTE_REFRESH disables network for airgapped/CI. - Gallery (templates/index.json) brought to parity: 7-day TTL auto-refresh and graceful fall back to stale cache when offline (previously cached forever and errored offline). Remove dead data / code: - Delete no_gpu_nodes.json (upstream source gone, file always empty) and the needs_gpu field / parse_no_gpu_nodes / annotate+load params it fed. - Remove the templates `--query` CQL stub (the grammar was never ported; it only ever returned an error) plus the now-orphaned cql_query_invalid error code and its stale skill-doc section. - Remove fictional `comfy query` references (help_json examples + run_cli demo step that invoked a non-existent command); the run_cli step now demos the real flag-based `comfy nodes` commands. - Tighten cql.data package-data glob to *.yaml. Architecture: - Extract the gallery-search engine (port of gallery_search.go predicates) out of command/templates.py into cql/gallery.py, so it sits beside the node-graph engine (cql/engine.py) and command/templates.py is a thin shell -- mirroring how command/nodes.py wraps cql.engine.Graph. generate refresh: - Fail gracefully on the now-404 api.comfy.org/openapi.yml: explain the partner catalog ships bundled and updates via `pip install -U` instead of dumping a raw HTTP error. Tests: add cql/test_annotations_source.py and cql/test_gallery.py; cover nodes refresh and templates --query removal. All suites pass, ruff clean. --- comfy_cli/command/generate/app.py | 15 ++ comfy_cli/command/nodes.py | 34 ++- comfy_cli/command/run_cli.py | 21 +- comfy_cli/command/templates.py | 242 +++--------------- comfy_cli/cql/annotations_source.py | 147 +++++++++++ comfy_cli/cql/data/no_gpu_nodes.json | 1 - comfy_cli/cql/engine.py | 45 +--- comfy_cli/cql/gallery.py | 226 ++++++++++++++++ comfy_cli/error_codes.py | 5 - comfy_cli/help_json.py | 4 - comfy_cli/skills/comfy-debug/SKILL.md | 3 - pyproject.toml | 2 +- tests/comfy_cli/command/test_nodes_cli.py | 30 +++ tests/comfy_cli/command/test_templates.py | 19 +- .../comfy_cli/cql/test_annotations_source.py | 93 +++++++ tests/comfy_cli/cql/test_gallery.py | 152 +++++++++++ 16 files changed, 753 insertions(+), 286 deletions(-) create mode 100644 comfy_cli/cql/annotations_source.py delete mode 100644 comfy_cli/cql/data/no_gpu_nodes.json create mode 100644 comfy_cli/cql/gallery.py create mode 100644 tests/comfy_cli/cql/test_annotations_source.py create mode 100644 tests/comfy_cli/cql/test_gallery.py diff --git a/comfy_cli/command/generate/app.py b/comfy_cli/command/generate/app.py index ab6ab7fb..85c41c97 100644 --- a/comfy_cli/command/generate/app.py +++ b/comfy_cli/command/generate/app.py @@ -507,6 +507,21 @@ def _refresh() -> None: with httpx.Client(timeout=30.0, follow_redirects=True) as cli: r = cli.get(url, headers={"Comfy-Env": "comfy-cli", "User-Agent": "comfy-cli/api"}) r.raise_for_status() + except httpx.HTTPStatusError as e: + # The partner-proxy OpenAPI spec is not published at a public URL, so a + # 404 here is expected, not a transient failure. Explain that the model + # catalog ships bundled with the CLI and updates via `pip install -U`, + # rather than dumping a raw HTTP error. + if e.response.status_code == 404: + rprint( + "[bold yellow]The partner model catalog is not available for live refresh.[/bold yellow]\n" + f"[dim]No public catalog is published at {url}.[/dim]\n" + f"The CLI uses its bundled catalog at [dim]{spec.active_spec_path()}[/dim].\n" + "To get newer models, update the CLI: [bold]pip install -U comfy-cli[/bold]." + ) + raise typer.Exit(code=0) + rprint(f"[bold red]Failed to fetch {url}: {e}[/bold red]") + raise typer.Exit(code=1) except httpx.HTTPError as e: rprint(f"[bold red]Failed to fetch {url}: {e}[/bold red]") raise typer.Exit(code=1) diff --git a/comfy_cli/command/nodes.py b/comfy_cli/command/nodes.py index d1565cea..bb7f8498 100644 --- a/comfy_cli/command/nodes.py +++ b/comfy_cli/command/nodes.py @@ -864,16 +864,30 @@ def categories_cmd( @app.command( "refresh", - help="object_info is fetched live from the server on each command — nothing to refresh.", + help="Re-fetch node annotation data (pack/labels/cloud_disabled) from Comfy-Org/comfy-complete.", ) @tracking.track_command("nodes") -def refresh_cmd( - where: Annotated[ - str | None, - typer.Option("--where", show_default=False, help="Override the resolved routing mode."), - ] = None, -): - """Explain that object_info is fetched live and exit.""" +def refresh_cmd(): + """Force-refresh the node annotation cache from the public comfy-complete repo. + + ``object_info`` itself is fetched live from the server on every command, so + there is nothing to refresh there. The *annotations* (which custom-node pack + a node belongs to, its behavioral labels, and whether it's disabled on + cloud) come from Comfy-Org/comfy-complete and are cached locally with a TTL; + this command pulls the latest copy immediately. + """ renderer = get_renderer() - rprint("[dim]object_info is fetched live from the server on each command — nothing to refresh.[/dim]") - renderer.emit({"refreshed": False, "reason": "live_fetch"}, command="nodes refresh") + from comfy_cli.cql import annotations_source + + results = annotations_source.refresh_annotations() + ok = all(r["source"] == "remote" for r in results) + if renderer.is_pretty(): + for r in results: + if r["source"] == "remote": + rprint(f"[green]✓[/green] {r['name']} ({r['bytes']:,} bytes) → {r['path']}") + elif r["source"] == "bundled": + rprint(f"[yellow]![/yellow] {r['name']}: remote fetch failed, using bundled snapshot " + f"([dim]{r.get('error', '')}[/dim])") + else: + rprint(f"[red]✗[/red] {r['name']}: unavailable ([dim]{r.get('error', '')}[/dim])") + renderer.emit({"refreshed": ok, "files": results}, command="nodes refresh") diff --git a/comfy_cli/command/run_cli.py b/comfy_cli/command/run_cli.py index f05210ef..0d1f57d0 100644 --- a/comfy_cli/command/run_cli.py +++ b/comfy_cli/command/run_cli.py @@ -429,26 +429,15 @@ def _build_steps(state: _DemoState) -> list[Step]: ], ), Step( - title="query — CQL against the live node graph", + title="nodes — introspect the live node graph (CQL engine, flag-based)", invocations=[ Invocation( - argv=[ - *comfy, - "query", - "-q", - "from nodes where name in ('EmptyImage','ImageInvert','SaveImage') select name, category", - ], - label="comfy query", + argv=[*comfy, "nodes", "search", "SaveImage"], + label="comfy nodes search SaveImage", ), Invocation( - argv=[ - *comfy, - "--json", - "query", - "-q", - "from nodes where name in ('EmptyImage','ImageInvert','SaveImage') select name, category", - ], - label="comfy --json query", + argv=[*comfy, "--json", "nodes", "ls", "--produces", "IMAGE", "--limit", "5"], + label="comfy --json nodes ls --produces IMAGE --limit 5", ), ], ), diff --git a/comfy_cli/command/templates.py b/comfy_cli/command/templates.py index 92b9a264..402db42f 100644 --- a/comfy_cli/command/templates.py +++ b/comfy_cli/command/templates.py @@ -10,186 +10,35 @@ comfy templates refresh # re-fetch index.json The gallery file ``templates/index.json`` is cached under -``~/.cache/comfy-cli/gallery/index.json``. The CLI side here parses the -index in Python (no WASM needed); for the full CQL grammar over templates -use the flag-based filters for browsing. +``~/.cache/comfy-cli/gallery/index.json`` and parsed in Python. Browsing is +flag-based (``--type``/``--category``/``--tag``/``--model``/``--provider``/ +``--name``); there is no separate query grammar. """ from __future__ import annotations import json -import os import urllib.error -import urllib.parse -import urllib.request from pathlib import Path -from typing import Annotated, Any +from typing import Annotated import typer from comfy_cli import tracking +from comfy_cli.cql import gallery from comfy_cli.output import get_renderer, rprint app = typer.Typer(no_args_is_help=True, help="Browse the Comfy workflow-template gallery.") -GALLERY_URL = "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/index.json" - - -# --------------------------------------------------------------------------- -# Gallery loading + caching -# --------------------------------------------------------------------------- - - -def _cache_path() -> Path: - """Where the gallery index lives on disk. XDG-respecting.""" - base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache") - return Path(base) / "comfy-cli" / "gallery" / "index.json" - - -def _fetch_gallery(url: str = GALLERY_URL, timeout: float = 15.0) -> bytes: - req = urllib.request.Request(url, headers={"User-Agent": "comfy-cli"}) - with urllib.request.urlopen(req, timeout=timeout) as resp: - if resp.status != 200: - raise RuntimeError(f"gallery fetch failed: HTTP {resp.status}") - return resp.read() - - -def _load_gallery( - explicit_path: str | None, - *, - refresh: bool = False, -) -> list[dict[str, Any]]: - """Resolve the gallery index. Precedence: explicit --gallery > cache > fetch. - - Returns the raw decoded JSON (a list of category dicts). The CLI does - its own filtering on top. - """ - if explicit_path: - return json.loads(Path(explicit_path).read_bytes()) - - cache = _cache_path() - if refresh or not cache.exists(): - data = _fetch_gallery() - cache.parent.mkdir(parents=True, exist_ok=True) - cache.write_bytes(data) - return json.loads(data) - return json.loads(cache.read_bytes()) - - -def _flatten_templates(categories: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Walk the nested (category → templates) shape and flatten to a list. - - Each row gets a few extras: ``category_title``, ``group_category``, and - ``output_type`` (from the parent category's ``type`` — the per-template - ``mediaType`` is actually the thumbnail format and is misleading). - Providers from ``logos[].provider`` are flattened to a flat string list - that tolerates the scalar-or-array variance in real data. - """ - rows: list[dict[str, Any]] = [] - for cat in categories: - if not isinstance(cat, dict): - continue - output_type = cat.get("type") or "" - for t in cat.get("templates", []) or []: - if not isinstance(t, dict): - continue - rows.append( - { - "name": t.get("name") or "", - "title": (t.get("title") or "").strip(), - "description": t.get("description") or "", - "output_type": output_type, - "category_title": cat.get("title") or "", - "group_category": cat.get("category") or "", - "tags": list(t.get("tags") or []), - "models": list(t.get("models") or []), - "providers": _flatten_providers(t.get("logos") or []), - "date": t.get("date") or "", - "open_source": bool(t.get("openSource", False)), - "usage": int(t.get("usage") or 0), - "media_subtype": t.get("mediaSubtype") or "", - "io": t.get("io") or {}, - } - ) - return rows - - -def _flatten_providers(logos: list[Any]) -> list[str]: - """``logos[].provider`` may be a string or a list-of-strings. Coalesce.""" - out: list[str] = [] - seen: set[str] = set() - for logo in logos: - if not isinstance(logo, dict): - continue - prov = logo.get("provider") - if isinstance(prov, str): - if prov and prov not in seen: - seen.add(prov) - out.append(prov) - elif isinstance(prov, list): - for p in prov: - if isinstance(p, str) and p and p not in seen: - seen.add(p) - out.append(p) - return out - - -# --------------------------------------------------------------------------- -# Filters — Python equivalents of nodegraph/gallery_search.go predicates -# --------------------------------------------------------------------------- - - -def _matches( - row: dict[str, Any], - *, - type_: str | None, - category: str | None, - tag: str | None, - model: str | None, - provider: str | None, - name_sub: str | None, -) -> bool: - if type_ and (row.get("output_type") or "").lower() != type_.lower(): - return False - if category and (row.get("category_title") or "").lower() != category.lower(): - return False - if tag and not any((t or "").lower() == tag.lower() for t in row.get("tags") or []): - return False - if model and not any(model.lower() in (m or "").lower() for m in row.get("models") or []): - return False - if provider and not any(provider.lower() in (p or "").lower() for p in row.get("providers") or []): - return False - if name_sub and name_sub.lower() not in (row.get("name") or "").lower(): - return False - return True - # --------------------------------------------------------------------------- # Commands # --------------------------------------------------------------------------- -def _ls_via_query( - renderer, - query: str, - gallery_path: str | None, - refresh: bool, - limit: int | None, -) -> None: - """CQL grammar queries over the template gallery are not available. - Emit an actionable error pointing the user at the flag-based filters instead. - """ - renderer.error( - code="cql_query_invalid", - message="CQL grammar queries are not available. Use flag-based filtering instead.", - hint="comfy templates ls --type image --tag API --model Flux", - ) - raise typer.Exit(code=1) - - @app.command( "ls", - help="List gallery templates. Filter by type/category/tag/model/provider/name, or pass --query for the full CQL grammar.", + help="List gallery templates. Filter by type/category/tag/model/provider/name.", ) @tracking.track_command("templates") def ls_cmd( @@ -217,15 +66,6 @@ def ls_cmd( str | None, typer.Option("--name", help="Substring match on template name."), ] = None, - query: Annotated[ - str | None, - typer.Option( - "--query", - "-q", - show_default=False, - help="A CQL grammar query (e.g. 'templates type video | sort name | limit 5'). Bypasses the flag filters.", - ), - ] = None, limit: Annotated[ int | None, typer.Option(show_default=False, help="Cap output to N rows."), @@ -245,13 +85,9 @@ def ls_cmd( ): renderer = get_renderer() - # CQL grammar path — routes through WASM with the gallery loaded. - if query is not None: - return _ls_via_query(renderer, query, gallery_path, refresh, limit) - try: - cats = _load_gallery(gallery_path, refresh=refresh) - except (urllib.error.URLError, OSError, json.JSONDecodeError) as e: + cats = gallery.load_gallery(gallery_path, refresh=refresh) + except (gallery.GalleryError, urllib.error.URLError, OSError, json.JSONDecodeError) as e: renderer.error( code="gallery_load_failed", message=str(e), @@ -259,21 +95,17 @@ def ls_cmd( ) raise typer.Exit(code=1) from e - rows = _flatten_templates(cats) + rows = gallery.flatten_templates(cats) total = len(rows) - rows = [ - r - for r in rows - if _matches( - r, - type_=type_, - category=category, - tag=tag, - model=model, - provider=provider, - name_sub=name_sub, - ) - ] + rows = gallery.filter_rows( + rows, + type_=type_, + category=category, + tag=tag, + model=model, + provider=provider, + name_sub=name_sub, + ) matched = len(rows) if limit is not None: rows = rows[: max(0, limit)] @@ -347,12 +179,12 @@ def show_cmd( ): renderer = get_renderer() try: - cats = _load_gallery(gallery_path, refresh=refresh) - except (urllib.error.URLError, OSError, json.JSONDecodeError) as e: + cats = gallery.load_gallery(gallery_path, refresh=refresh) + except (gallery.GalleryError, urllib.error.URLError, OSError, json.JSONDecodeError) as e: renderer.error(code="gallery_load_failed", message=str(e)) raise typer.Exit(code=1) from e - rows = _flatten_templates(cats) + rows = gallery.flatten_templates(cats) match = next((r for r in rows if r["name"] == name), None) if match is None: renderer.error( @@ -387,11 +219,11 @@ def show_cmd( def refresh_cmd(): renderer = get_renderer() try: - data = _fetch_gallery() - except (urllib.error.URLError, OSError) as e: + data = gallery.fetch_gallery() + except (gallery.GalleryError, urllib.error.URLError, OSError) as e: renderer.error(code="gallery_fetch_failed", message=str(e)) raise typer.Exit(code=1) from e - cache = _cache_path() + cache = gallery.cache_path() cache.parent.mkdir(parents=True, exist_ok=True) cache.write_bytes(data) payload = {"path": str(cache), "bytes": len(data)} @@ -400,22 +232,6 @@ def refresh_cmd(): renderer.emit(payload, command="templates refresh") -# Where the per-template workflow JSONs live on GitHub. The gallery index lists -# each template by ``name``; the corresponding workflow is at -# ``Comfy-Org/workflow_templates/templates/.json``. -_TEMPLATE_WORKFLOW_URL = "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/{name}.json" - - -def _fetch_template_workflow(name: str, *, timeout: float = 15.0) -> bytes: - """Pull a single template's workflow JSON from the canonical GitHub raw URL.""" - url = _TEMPLATE_WORKFLOW_URL.format(name=urllib.parse.quote(name, safe="")) - req = urllib.request.Request(url, headers={"User-Agent": "comfy-cli"}) - with urllib.request.urlopen(req, timeout=timeout) as resp: - if resp.status != 200: - raise RuntimeError(f"template workflow fetch failed: HTTP {resp.status}") - return resp.read() - - @app.command( "fetch", help=( @@ -446,12 +262,12 @@ def fetch_cmd( # with the same close_matches affordance the rest of the CLI uses, instead # of letting the user hit a raw GitHub 404. try: - cats = _load_gallery(gallery_path, refresh=refresh) - except (urllib.error.URLError, OSError, json.JSONDecodeError) as e: + cats = gallery.load_gallery(gallery_path, refresh=refresh) + except (gallery.GalleryError, urllib.error.URLError, OSError, json.JSONDecodeError) as e: renderer.error(code="gallery_load_failed", message=str(e)) raise typer.Exit(code=1) from e - rows = _flatten_templates(cats) + rows = gallery.flatten_templates(cats) match = next((r for r in rows if r["name"] == name), None) if match is None: # Build a small list of close matches so the agent can self-correct. @@ -466,8 +282,8 @@ def fetch_cmd( raise typer.Exit(code=1) try: - body = _fetch_template_workflow(name) - except (urllib.error.HTTPError, urllib.error.URLError, OSError) as e: + body = gallery.fetch_template_workflow(name) + except (gallery.GalleryError, urllib.error.HTTPError, urllib.error.URLError, OSError) as e: status = getattr(e, "code", None) renderer.error( code="template_fetch_failed", diff --git a/comfy_cli/cql/annotations_source.py b/comfy_cli/cql/annotations_source.py new file mode 100644 index 00000000..6d91cb1e --- /dev/null +++ b/comfy_cli/cql/annotations_source.py @@ -0,0 +1,147 @@ +"""Resolve CQL node-annotation data from Comfy-Org/comfy-complete. + +The annotation files (``supported_nodes.yaml`` — pack membership + behavioral +labels; ``cloud_disable_config.yaml`` — which labels disable a node on cloud) +drive ``comfy nodes`` annotations (``pack``, ``labels``, ``cloud_disabled``). + +They live in the public repo `Comfy-Org/comfy-complete` and change far more +often than comfy-cli ships releases, so we resolve them with a **live-refresh + +fallback** strategy instead of pinning to the bundled snapshot: + + 1. a TTL-fresh local cache (``~/.cache/comfy-cli/comfy-complete/``) + 2. a live fetch from the public repo (short timeout, cached on success) + 3. the package-bundled snapshot under ``comfy_cli/cql/data/`` (offline-safe) + +This keeps the data current without a ``pip install -U`` while never breaking +offline / airgapped use. Set ``COMFY_CLI_NO_REMOTE_REFRESH=1`` to skip the +network entirely (cache → bundled only). +""" + +from __future__ import annotations + +import os +import time +import urllib.request +from pathlib import Path + +# Public source of truth. Both files are at the repo root on ``main``. +_BASE_URL = "https://raw.githubusercontent.com/Comfy-Org/comfy-complete/main" +_FILES = ("supported_nodes.yaml", "cloud_disable_config.yaml") + +# Auto-refresh cadence for the implicit (hot-path) load. A manual +# ``comfy nodes refresh`` always forces a fetch regardless of this. +_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60 +_FETCH_TIMEOUT = 5.0 + + +def _network_disabled() -> bool: + return os.environ.get("COMFY_CLI_NO_REMOTE_REFRESH", "").strip() not in ("", "0", "false", "False") + + +def _cache_dir() -> Path: + base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache") + return Path(base) / "comfy-cli" / "comfy-complete" + + +def _bundled_bytes(filename: str) -> bytes | None: + try: + from importlib import resources + + return (resources.files("comfy_cli.cql.data") / filename).read_bytes() + except Exception: + return None + + +def _fetch(filename: str) -> bytes: + url = f"{_BASE_URL}/{filename}" + req = urllib.request.Request(url, headers={"User-Agent": "comfy-cli"}) + with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) as resp: # noqa: S310 — fixed https host + if resp.status != 200: + raise RuntimeError(f"annotation fetch failed: HTTP {resp.status}") + return resp.read() + + +def _is_fresh(path: Path) -> bool: + try: + return (time.time() - path.stat().st_mtime) < _CACHE_TTL_SECONDS + except OSError: + return False + + +def _resolve_one(filename: str, *, refresh: bool) -> bytes | None: + """Resolve one annotation file: cache(TTL) → fetch → stale cache → bundled.""" + cache = _cache_dir() / filename + + # Fresh cache wins outright (unless an explicit refresh was requested). + if not refresh and cache.is_file() and _is_fresh(cache): + try: + return cache.read_bytes() + except OSError: + pass + + # Try the network when allowed and either forced or the cache is stale/missing. + if not _network_disabled(): + try: + data = _fetch(filename) + try: + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_bytes(data) + except OSError: + pass # cache write is best-effort + return data + except Exception: + pass # fall through to stale cache / bundled + + # Network unavailable or disabled — use whatever stale cache we have. + if cache.is_file(): + try: + return cache.read_bytes() + except OSError: + pass + + # Last resort: the snapshot shipped with the package. + return _bundled_bytes(filename) + + +def load_annotation_bytes(*, refresh: bool = False) -> tuple[bytes | None, bytes | None]: + """Return ``(supported_nodes_yaml, cloud_disable_config_yaml)`` bytes. + + Either element may be ``None`` if it cannot be resolved from any source. + """ + sup = _resolve_one("supported_nodes.yaml", refresh=refresh) + dis = _resolve_one("cloud_disable_config.yaml", refresh=refresh) + return sup, dis + + +def refresh_annotations() -> list[dict]: + """Force a re-fetch of every annotation file into the cache. + + Returns one status dict per file: ``{name, source, bytes, path}`` where + ``source`` is ``"remote"`` (freshly fetched), ``"bundled"`` (network failed, + used the package snapshot), or ``"unavailable"``. + """ + cache_dir = _cache_dir() + results: list[dict] = [] + for filename in _FILES: + entry: dict = {"name": filename} + if not _network_disabled(): + try: + data = _fetch(filename) + cache = cache_dir / filename + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_bytes(data) + entry.update(source="remote", bytes=len(data), path=str(cache)) + results.append(entry) + continue + except Exception as e: # noqa: BLE001 — degrade to bundled, report why + entry["error"] = str(e) + else: + entry["error"] = "remote refresh disabled (COMFY_CLI_NO_REMOTE_REFRESH)" + + bundled = _bundled_bytes(filename) + if bundled is not None: + entry.update(source="bundled", bytes=len(bundled), path=None) + else: + entry.update(source="unavailable", bytes=0, path=None) + results.append(entry) + return results diff --git a/comfy_cli/cql/data/no_gpu_nodes.json b/comfy_cli/cql/data/no_gpu_nodes.json deleted file mode 100644 index e9b7865a..00000000 --- a/comfy_cli/cql/data/no_gpu_nodes.json +++ /dev/null @@ -1 +0,0 @@ -{"schema_version": 1, "no_gpu_nodes": []} diff --git a/comfy_cli/cql/engine.py b/comfy_cli/cql/engine.py index 52c3dd39..53e94cee 100644 --- a/comfy_cli/cql/engine.py +++ b/comfy_cli/cql/engine.py @@ -149,7 +149,6 @@ class Morphism: pack: str = "" labels: list[str] = field(default_factory=list) cloud_disabled: bool = False - needs_gpu: bool = True # default True per Go def output_types(self) -> list[str]: seen: set[str] = set() @@ -403,17 +402,6 @@ def parse_disable_config(data: bytes) -> set[str]: return labels -def parse_no_gpu_nodes(data: bytes) -> set[str]: - """Parse no_gpu_nodes.json → set of CPU-only node IDs.""" - try: - cfg = json.loads(data) - except Exception: - return set() - if not isinstance(cfg, dict) or cfg.get("schema_version") != 1: - return set() - return set(cfg.get("no_gpu_nodes") or []) - - # --------------------------------------------------------------------------- # Graph — mirrors nodegraph/graph.go # --------------------------------------------------------------------------- @@ -461,19 +449,15 @@ def annotate( self, supported_nodes_yaml: bytes | None = None, cloud_disable_yaml: bytes | None = None, - no_gpu_json: bytes | None = None, ) -> None: node_pack: dict[str, str] = {} node_labels: dict[str, list[str]] = {} disable_labels: set[str] = set() - no_gpu: set[str] = set() if supported_nodes_yaml: node_pack, node_labels = parse_supported_nodes(supported_nodes_yaml) if cloud_disable_yaml: disable_labels = parse_disable_config(cloud_disable_yaml) - if no_gpu_json: - no_gpu = parse_no_gpu_nodes(no_gpu_json) for nid, m in self._nodes.items(): if nid in node_pack: @@ -481,7 +465,6 @@ def annotate( if nid in node_labels: m.labels = node_labels[nid] m.cloud_disabled = any(label in disable_labels for label in m.labels) - m.needs_gpu = nid not in no_gpu self._annotated = True # -- Lookup -- @@ -944,7 +927,6 @@ def load( port: int = 8188, supported_nodes_yaml: bytes | None = None, cloud_disable_yaml: bytes | None = None, - no_gpu_json: bytes | None = None, ) -> Graph: """Unified entry point: resolve object_info, build graph, annotate. @@ -966,17 +948,21 @@ def load( raw = _load_from_target(mode=mode, host=host, port=port) g = cls.from_object_info(raw) - if supported_nodes_yaml or cloud_disable_yaml or no_gpu_json: - g.annotate(supported_nodes_yaml, cloud_disable_yaml, no_gpu_json) + if supported_nodes_yaml or cloud_disable_yaml: + g.annotate(supported_nodes_yaml, cloud_disable_yaml) else: g._try_default_annotations() return g def _try_default_annotations(self) -> None: - """Load bundled annotation files from ``comfy_cli.cql.data``. + """Load node annotation data from Comfy-Org/comfy-complete. + + Resolves via :mod:`comfy_cli.cql.annotations_source`, which prefers a + TTL-fresh local cache, falls back to a live fetch from the public repo, + and finally to the package-bundled snapshot — so the data stays fresh + without a ``pip install -U`` while remaining fully offline-safe. - These ship as package data (40 KB total) from Comfy-Org/comfy-complete. - They enrich every node with: + The annotations enrich every node with: - pack membership (which custom-node pack it belongs to) - behavioral labels (ReadsArbitraryFile, NetworkAccess, etc.) - cloud_disabled (whether this node is disabled on cloud) @@ -987,15 +973,13 @@ def _try_default_annotations(self) -> None: and cloud_disabled=False (safe default). """ try: - from importlib import resources + from comfy_cli.cql import annotations_source - data_pkg = resources.files("comfy_cli.cql.data") - sup = (data_pkg / "supported_nodes.yaml").read_bytes() - dis = (data_pkg / "cloud_disable_config.yaml").read_bytes() - nogpu = (data_pkg / "no_gpu_nodes.json").read_bytes() - self.annotate(sup, dis, nogpu) + sup, dis = annotations_source.load_annotation_bytes() + if sup or dis: + self.annotate(sup, dis) except Exception: - pass # missing package data is non-fatal + pass # missing data / network is non-fatal # -- Serialization helpers for CLI compat -- @@ -1013,7 +997,6 @@ def morphism_to_dict(self, m: Morphism) -> dict[str, Any]: "pack": m.pack, "labels": m.labels, "cloud_disabled": m.cloud_disabled, - "needs_gpu": m.needs_gpu, "inputs": [ { "name": p.name, diff --git a/comfy_cli/cql/gallery.py b/comfy_cli/cql/gallery.py new file mode 100644 index 00000000..c26a4b69 --- /dev/null +++ b/comfy_cli/cql/gallery.py @@ -0,0 +1,226 @@ +"""Pure-Python CQL gallery-search engine. + +The companion to :mod:`comfy_cli.cql.engine` (the node-graph engine over +``object_info``). Where that engine answers *node* questions, this one answers +*workflow-template gallery* questions: it loads the curated gallery index from +``Comfy-Org/workflow_templates``, flattens the nested (category → templates) +shape into queryable rows, and evaluates the gallery-search predicates. + +Port of ``github.com/Comfy-Org/cql/nodegraph`` gallery_search predicates. + +This module is pure value-in, value-out: it loads/flattens/filters and returns +plain dicts. It does no rendering and knows nothing about Typer or error codes — +the CLI shell in ``command/templates.py`` wraps it (exactly as ``command/nodes`` +wraps the node engine). +""" + +from __future__ import annotations + +import json +import os +import time +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + +GALLERY_URL = "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/index.json" + +# Auto-refresh cadence for the implicit load. `templates refresh` / `--refresh` +# always force a fetch regardless. Mirrors comfy_cli.cql.annotations_source so +# both public-repo data sources stay fresh without a `pip install -U`. +_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60 + +# Where each template's workflow JSON lives. The gallery index lists each +# template by ``name``; the workflow is at ``templates/.json``. +TEMPLATE_WORKFLOW_URL = "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/{name}.json" + + +class GalleryError(RuntimeError): + """A gallery fetch/parse failed (HTTP error, bad JSON, etc.).""" + + +# --------------------------------------------------------------------------- +# Loading + caching +# --------------------------------------------------------------------------- + + +def cache_path() -> Path: + """Where the gallery index lives on disk. XDG-respecting.""" + base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache") + return Path(base) / "comfy-cli" / "gallery" / "index.json" + + +def fetch_gallery(url: str = GALLERY_URL, timeout: float = 15.0) -> bytes: + req = urllib.request.Request(url, headers={"User-Agent": "comfy-cli"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 — fixed https host + if resp.status != 200: + raise GalleryError(f"gallery fetch failed: HTTP {resp.status}") + return resp.read() + + +def _is_fresh(path: Path) -> bool: + try: + return (time.time() - path.stat().st_mtime) < _CACHE_TTL_SECONDS + except OSError: + return False + + +def load_gallery(explicit_path: str | None, *, refresh: bool = False) -> list[dict[str, Any]]: + """Resolve the gallery index. Precedence: explicit path > fresh cache > fetch. + + Resolution (when no explicit path): a TTL-fresh cache wins outright; an + expired/missing cache triggers a fetch (cached on success); and if that + fetch fails the stale cache is reused so the command degrades gracefully + offline instead of erroring. ``refresh=True`` always forces a fetch. + + Returns the raw decoded JSON (a list of category dicts). Filtering is a + separate step (:func:`flatten_templates` + :func:`matches`). + """ + if explicit_path: + return json.loads(Path(explicit_path).read_bytes()) + + cache = cache_path() + if not refresh and cache.is_file() and _is_fresh(cache): + return json.loads(cache.read_bytes()) + + try: + data = fetch_gallery() + except (GalleryError, OSError) as e: + # Fetch failed — fall back to whatever cache we have (even if stale). + if cache.is_file(): + return json.loads(cache.read_bytes()) + raise GalleryError(f"gallery unavailable and no cache present: {e}") from e + + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_bytes(data) + return json.loads(data) + + +def fetch_template_workflow(name: str, *, timeout: float = 15.0) -> bytes: + """Pull a single template's workflow JSON from the canonical GitHub raw URL.""" + url = TEMPLATE_WORKFLOW_URL.format(name=urllib.parse.quote(name, safe="")) + req = urllib.request.Request(url, headers={"User-Agent": "comfy-cli"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 — fixed https host + if resp.status != 200: + raise GalleryError(f"template workflow fetch failed: HTTP {resp.status}") + return resp.read() + + +# --------------------------------------------------------------------------- +# Flattening +# --------------------------------------------------------------------------- + + +def flatten_templates(categories: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Walk the nested (category → templates) shape and flatten to a list. + + Each row gets a few extras: ``category_title``, ``group_category``, and + ``output_type`` (from the parent category's ``type`` — the per-template + ``mediaType`` is actually the thumbnail format and is misleading). + Providers from ``logos[].provider`` are flattened to a flat string list + that tolerates the scalar-or-array variance in real data. + """ + rows: list[dict[str, Any]] = [] + for cat in categories: + if not isinstance(cat, dict): + continue + output_type = cat.get("type") or "" + for t in cat.get("templates", []) or []: + if not isinstance(t, dict): + continue + rows.append( + { + "name": t.get("name") or "", + "title": (t.get("title") or "").strip(), + "description": t.get("description") or "", + "output_type": output_type, + "category_title": cat.get("title") or "", + "group_category": cat.get("category") or "", + "tags": list(t.get("tags") or []), + "models": list(t.get("models") or []), + "providers": flatten_providers(t.get("logos") or []), + "date": t.get("date") or "", + "open_source": bool(t.get("openSource", False)), + "usage": int(t.get("usage") or 0), + "media_subtype": t.get("mediaSubtype") or "", + "io": t.get("io") or {}, + } + ) + return rows + + +def flatten_providers(logos: list[Any]) -> list[str]: + """``logos[].provider`` may be a string or a list-of-strings. Coalesce.""" + out: list[str] = [] + seen: set[str] = set() + for logo in logos: + if not isinstance(logo, dict): + continue + prov = logo.get("provider") + if isinstance(prov, str): + if prov and prov not in seen: + seen.add(prov) + out.append(prov) + elif isinstance(prov, list): + for p in prov: + if isinstance(p, str) and p and p not in seen: + seen.add(p) + out.append(p) + return out + + +# --------------------------------------------------------------------------- +# Predicates — port of gallery_search.go +# --------------------------------------------------------------------------- + + +def matches( + row: dict[str, Any], + *, + type_: str | None = None, + category: str | None = None, + tag: str | None = None, + model: str | None = None, + provider: str | None = None, + name_sub: str | None = None, +) -> bool: + if type_ and (row.get("output_type") or "").lower() != type_.lower(): + return False + if category and (row.get("category_title") or "").lower() != category.lower(): + return False + if tag and not any((t or "").lower() == tag.lower() for t in row.get("tags") or []): + return False + if model and not any(model.lower() in (m or "").lower() for m in row.get("models") or []): + return False + if provider and not any(provider.lower() in (p or "").lower() for p in row.get("providers") or []): + return False + if name_sub and name_sub.lower() not in (row.get("name") or "").lower(): + return False + return True + + +def filter_rows( + rows: list[dict[str, Any]], + *, + type_: str | None = None, + category: str | None = None, + tag: str | None = None, + model: str | None = None, + provider: str | None = None, + name_sub: str | None = None, +) -> list[dict[str, Any]]: + """Apply the gallery-search predicates to a flattened row list.""" + return [ + r + for r in rows + if matches( + r, + type_=type_, + category=category, + tag=tag, + model=model, + provider=provider, + name_sub=name_sub, + ) + ] diff --git a/comfy_cli/error_codes.py b/comfy_cli/error_codes.py index b04f319b..3a0fc406 100644 --- a/comfy_cli/error_codes.py +++ b/comfy_cli/error_codes.py @@ -458,11 +458,6 @@ class ErrorCode: "Surfaced in `data.warnings[]` (not as an error envelope) so the command still succeeds.", "re-run once the server/session is reachable to get a fresh schema", ), - ErrorCode( - "cql_query_invalid", - "Grammar query failed to parse or evaluate.", - "check the grammar; `comfy nodes ls --help` has examples", - ), ErrorCode( "node_not_found", "Requested node class isn't in the loaded environment.", diff --git a/comfy_cli/help_json.py b/comfy_cli/help_json.py index f8034c13..3e6d9b73 100644 --- a/comfy_cli/help_json.py +++ b/comfy_cli/help_json.py @@ -26,10 +26,6 @@ "comfy --json discover", "comfy --json discover --schemas-only", ], - "comfy query": [ - "comfy --json query --query 'from nodes select name'", - "comfy query --input object_info.json --query 'from nodes where category=\"loaders\"'", - ], "comfy auth": ["comfy auth list", "comfy auth set civitai --key ..."], "comfy auth set": ["comfy auth set comfy-cloud --key sk-…"], "comfy auth list": ["comfy auth list", "comfy --json auth list"], diff --git a/comfy_cli/skills/comfy-debug/SKILL.md b/comfy_cli/skills/comfy-debug/SKILL.md index 056a737a..f32cdb07 100644 --- a/comfy_cli/skills/comfy-debug/SKILL.md +++ b/comfy_cli/skills/comfy-debug/SKILL.md @@ -90,9 +90,6 @@ comfy --json-stream jobs watch # re-attach ``` The local server keeps the job; the CLI just lost its tail. -### `cql_query_invalid` -`cql_query_invalid` — raised when a legacy `--query` string is passed to `comfy templates ls`. There is no query grammar; use flag-based filtering instead: `--type image|video|audio`, `--tag `, `--model ` (templates) and `--produces/--accepts/--category/--pack` (`comfy nodes ls`). - ### `cql_no_graph` The CLI needs an `object_info.json` to query against. Two options: ``` diff --git a/pyproject.toml b/pyproject.toml index 6cecbd4f..4171c18a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,7 +72,7 @@ include = [ "comfy_cli*" ] "comfy_cli.skills" = [ "*/SKILL.md" ] "comfy_cli.schemas" = [ "*.json", "*.md" ] -"comfy_cli.cql.data" = [ "*.yaml", "*.json" ] +"comfy_cli.cql.data" = [ "*.yaml" ] "comfy_cli.command.generate" = [ "spec/openapi.yml" ] [tool.ruff] diff --git a/tests/comfy_cli/command/test_nodes_cli.py b/tests/comfy_cli/command/test_nodes_cli.py index 7ad8c43a..fab89bea 100644 --- a/tests/comfy_cli/command/test_nodes_cli.py +++ b/tests/comfy_cli/command/test_nodes_cli.py @@ -204,3 +204,33 @@ def test_query_flag_rejected(self): runner = CliRunner() result = runner.invoke(nodes_cmd.app, ["ls", "--query", "produces IMAGE"]) assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# nodes refresh — re-fetch annotation data from comfy-complete +# --------------------------------------------------------------------------- + + +class TestNodesRefresh: + def test_refresh_reports_remote_success(self, monkeypatch, capsys): + from comfy_cli.cql import annotations_source + + fake = [ + {"name": "supported_nodes.yaml", "source": "remote", "bytes": 100, "path": "/c/supported_nodes.yaml"}, + {"name": "cloud_disable_config.yaml", "source": "remote", "bytes": 50, "path": "/c/cloud_disable_config.yaml"}, + ] + monkeypatch.setattr(annotations_source, "refresh_annotations", lambda: fake) + env = _run(["refresh"], capsys) + assert env["data"]["refreshed"] is True + assert env["data"]["files"] == fake + + def test_refresh_reports_bundled_fallback(self, monkeypatch, capsys): + from comfy_cli.cql import annotations_source + + fake = [ + {"name": "supported_nodes.yaml", "source": "bundled", "bytes": 100, "path": None, "error": "offline"}, + {"name": "cloud_disable_config.yaml", "source": "bundled", "bytes": 50, "path": None, "error": "offline"}, + ] + monkeypatch.setattr(annotations_source, "refresh_annotations", lambda: fake) + env = _run(["refresh"], capsys) + assert env["data"]["refreshed"] is False diff --git a/tests/comfy_cli/command/test_templates.py b/tests/comfy_cli/command/test_templates.py index faa5fe8d..e86e8194 100644 --- a/tests/comfy_cli/command/test_templates.py +++ b/tests/comfy_cli/command/test_templates.py @@ -15,6 +15,7 @@ from comfy_cli.caller import Caller from comfy_cli.command import templates as templates_cmd +from comfy_cli.cql import gallery as gallery_engine from comfy_cli.output.renderer import ( OutputMode, Renderer, @@ -126,6 +127,20 @@ def test_ls_default_returns_all_three(gallery_file): assert env["data"]["matched"] == 3 +def test_ls_query_option_removed(gallery_file): + """The dead `--query` CQL stub was removed; templates browse via flags only. + + Regression guard: there is no CQL query-language in the CLI, so `--query` + must not be a recognized option. + """ + _force_json_renderer() + runner = CliRunner() + result = runner.invoke(templates_cmd.app, ["ls", "--gallery", gallery_file, "--query", "type video"]) + assert result.exit_code == 2 # Click: "No such option" + assert "No such option" in result.output + assert "--query" in result.output + + def test_ls_type_filter(gallery_file): _force_json_renderer() runner = CliRunner() @@ -196,7 +211,7 @@ def _impl(name, timeout=15.0): raise body_or_exc return body_or_exc - monkeypatch.setattr(templates_cmd, "_fetch_template_workflow", _impl) + monkeypatch.setattr(gallery_engine, "fetch_template_workflow", _impl) def test_fetch_writes_to_stdout_in_pretty_mode(gallery_file, monkeypatch, capsys): @@ -240,7 +255,7 @@ def _should_not_fire(name, timeout=15.0): sentinel_called["fired"] = True raise AssertionError("fetch was called for an unknown template") - monkeypatch.setattr(templates_cmd, "_fetch_template_workflow", _should_not_fire) + monkeypatch.setattr(gallery_engine, "fetch_template_workflow", _should_not_fire) runner = CliRunner() result = runner.invoke(templates_cmd.app, ["fetch", "--gallery", gallery_file, "no_such_template"]) diff --git a/tests/comfy_cli/cql/test_annotations_source.py b/tests/comfy_cli/cql/test_annotations_source.py new file mode 100644 index 00000000..91894708 --- /dev/null +++ b/tests/comfy_cli/cql/test_annotations_source.py @@ -0,0 +1,93 @@ +"""Tests for node-annotation resolution (cache → fetch → bundled fallback).""" + +from __future__ import annotations + +import time + +import pytest + +from comfy_cli.cql import annotations_source as src + + +@pytest.fixture(autouse=True) +def _isolate_cache(tmp_path, monkeypatch): + """Point the cache at a temp dir and default to network-off for safety.""" + monkeypatch.setattr(src, "_cache_dir", lambda: tmp_path / "comfy-complete") + monkeypatch.setenv("COMFY_CLI_NO_REMOTE_REFRESH", "1") + yield + + +def test_network_disabled_env_parsing(monkeypatch): + monkeypatch.setenv("COMFY_CLI_NO_REMOTE_REFRESH", "1") + assert src._network_disabled() is True + monkeypatch.setenv("COMFY_CLI_NO_REMOTE_REFRESH", "0") + assert src._network_disabled() is False + monkeypatch.delenv("COMFY_CLI_NO_REMOTE_REFRESH", raising=False) + assert src._network_disabled() is False + + +def test_falls_back_to_bundled_when_offline(): + """Network disabled + empty cache → the package-bundled snapshot is used.""" + sup, dis = src.load_annotation_bytes() + assert sup is not None and b"node_packs" in sup + assert dis is not None and b"disable_nodes" in dis + + +def test_fresh_cache_wins_without_network(tmp_path, monkeypatch): + cache_dir = tmp_path / "comfy-complete" + cache_dir.mkdir(parents=True) + (cache_dir / "supported_nodes.yaml").write_bytes(b"cached-sup") + (cache_dir / "cloud_disable_config.yaml").write_bytes(b"cached-dis") + + # Network must never be called; assert that by making _fetch explode. + monkeypatch.setattr(src, "_fetch", lambda name: pytest.fail("network used")) + sup, dis = src.load_annotation_bytes() + assert sup == b"cached-sup" + assert dis == b"cached-dis" + + +def test_stale_cache_used_when_fetch_fails(tmp_path, monkeypatch): + cache_dir = tmp_path / "comfy-complete" + cache_dir.mkdir(parents=True) + f = cache_dir / "supported_nodes.yaml" + f.write_bytes(b"stale-sup") + # Make it stale (older than the TTL). + old = time.time() - src._CACHE_TTL_SECONDS - 100 + import os + + os.utime(f, (old, old)) + + monkeypatch.setenv("COMFY_CLI_NO_REMOTE_REFRESH", "0") # allow network path + + def boom(name): + raise RuntimeError("network down") + + monkeypatch.setattr(src, "_fetch", boom) + sup = src._resolve_one("supported_nodes.yaml", refresh=False) + assert sup == b"stale-sup" # stale cache beats nothing + + +def test_fetch_success_writes_cache(tmp_path, monkeypatch): + monkeypatch.setenv("COMFY_CLI_NO_REMOTE_REFRESH", "0") + monkeypatch.setattr(src, "_fetch", lambda name: b"fresh-" + name.encode()) + + sup, dis = src.load_annotation_bytes(refresh=True) + assert sup == b"fresh-supported_nodes.yaml" + assert dis == b"fresh-cloud_disable_config.yaml" + # Written through to the cache. + cache_dir = tmp_path / "comfy-complete" + assert (cache_dir / "supported_nodes.yaml").read_bytes() == sup + + +def test_refresh_annotations_reports_bundled_when_disabled(): + results = src.refresh_annotations() + assert {r["name"] for r in results} == set(src._FILES) + assert all(r["source"] == "bundled" for r in results) + assert all("disabled" in r["error"] for r in results) + + +def test_refresh_annotations_reports_remote_on_success(monkeypatch): + monkeypatch.setenv("COMFY_CLI_NO_REMOTE_REFRESH", "0") + monkeypatch.setattr(src, "_fetch", lambda name: b"x" * 10) + results = src.refresh_annotations() + assert all(r["source"] == "remote" and r["bytes"] == 10 for r in results) diff --git a/tests/comfy_cli/cql/test_gallery.py b/tests/comfy_cli/cql/test_gallery.py new file mode 100644 index 00000000..3bec51b0 --- /dev/null +++ b/tests/comfy_cli/cql/test_gallery.py @@ -0,0 +1,152 @@ +"""Tests for the CQL gallery-search engine (cql/gallery.py). + +These exercise the engine directly (load → flatten → predicates), independent +of the Typer command shell in ``command/templates.py``. +""" + +from __future__ import annotations + +import json +import time + +import pytest + +from comfy_cli.cql import gallery + +CATEGORIES = [ + { + "title": "Image", + "type": "image", + "category": "GENERATION TYPE", + "templates": [ + { + "name": "image_flux2", + "title": " Flux 2 ", + "description": "Text-to-image via BFL.", + "tags": ["API", "Text to Image"], + "models": ["Flux 2"], + "logos": [{"provider": ["Black Forest Labs"]}], + "openSource": False, + "usage": 42, + "mediaSubtype": "webp", + }, + { + "name": "image_sd15", + "title": "SD 1.5", + "tags": ["Text to Image"], + "models": ["SD1.5"], + "logos": [{"provider": "Stability"}], + }, + ], + }, + { + "title": "Video", + "type": "video", + "templates": [ + {"name": "video_kling", "title": "Kling", "tags": ["API"], "logos": [{"provider": "Kling"}]}, + ], + }, + "not-a-dict", # tolerated/skipped +] + + +def test_flatten_walks_categories_and_adds_extras(): + rows = gallery.flatten_templates(CATEGORIES) + assert [r["name"] for r in rows] == ["image_flux2", "image_sd15", "video_kling"] + flux = rows[0] + assert flux["output_type"] == "image" # from parent category type, not mediaSubtype + assert flux["category_title"] == "Image" + assert flux["title"] == "Flux 2" # stripped + assert flux["providers"] == ["Black Forest Labs"] + assert flux["open_source"] is False + assert flux["usage"] == 42 + + +def test_flatten_providers_handles_scalar_and_array_and_dedupes(): + assert gallery.flatten_providers([{"provider": "Kling"}]) == ["Kling"] + assert gallery.flatten_providers([{"provider": ["A", "B", "A"]}]) == ["A", "B"] + assert gallery.flatten_providers(["junk", {"no": "provider"}]) == [] + + +def test_matches_predicates(): + rows = gallery.flatten_templates(CATEGORIES) + flux = rows[0] + assert gallery.matches(flux, type_="image") is True + assert gallery.matches(flux, type_="video") is False + assert gallery.matches(flux, tag="api") is True # case-insensitive exact + assert gallery.matches(flux, tag="vid") is False # not a substring match + assert gallery.matches(flux, model="flux") is True # substring + assert gallery.matches(flux, provider="forest") is True # substring + assert gallery.matches(flux, name_sub="flux2") is True + + +def test_filter_rows_applies_all_predicates(): + rows = gallery.flatten_templates(CATEGORIES) + out = gallery.filter_rows(rows, type_="image", tag="API") + assert [r["name"] for r in out] == ["image_flux2"] + assert gallery.filter_rows(rows, type_="video") == [r for r in rows if r["name"] == "video_kling"] + assert gallery.filter_rows(rows) == rows # no predicates → identity + + +def test_load_gallery_from_explicit_path(tmp_path): + p = tmp_path / "index.json" + p.write_text(json.dumps(CATEGORIES[:1])) + cats = gallery.load_gallery(str(p)) + assert cats[0]["title"] == "Image" + + +def test_load_gallery_fetches_and_caches(tmp_path, monkeypatch): + monkeypatch.setattr(gallery, "cache_path", lambda: tmp_path / "g" / "index.json") + payload = json.dumps(CATEGORIES[:1]).encode() + monkeypatch.setattr(gallery, "fetch_gallery", lambda *a, **k: payload) + cats = gallery.load_gallery(None, refresh=True) + assert cats[0]["title"] == "Image" + # Cached to disk; a second non-refresh load reads cache without fetching. + monkeypatch.setattr(gallery, "fetch_gallery", lambda *a, **k: pytest.fail("should use cache")) + again = gallery.load_gallery(None) + assert again[0]["title"] == "Image" + + +def test_load_gallery_refetches_when_cache_stale(tmp_path, monkeypatch): + cache = tmp_path / "g" / "index.json" + cache.parent.mkdir(parents=True) + cache.write_bytes(json.dumps([{"title": "OldImage", "type": "image", "templates": []}]).encode()) + # Age the cache past the TTL. + import os + + old = time.time() - gallery._CACHE_TTL_SECONDS - 100 + os.utime(cache, (old, old)) + monkeypatch.setattr(gallery, "cache_path", lambda: cache) + monkeypatch.setattr(gallery, "fetch_gallery", lambda *a, **k: json.dumps(CATEGORIES[:1]).encode()) + + cats = gallery.load_gallery(None) + assert cats[0]["title"] == "Image" # fetched fresh, not the stale "OldImage" + + +def test_load_gallery_falls_back_to_stale_cache_when_offline(tmp_path, monkeypatch): + cache = tmp_path / "g" / "index.json" + cache.parent.mkdir(parents=True) + cache.write_bytes(json.dumps([{"title": "StaleImage", "type": "image", "templates": []}]).encode()) + old = time.time() - gallery._CACHE_TTL_SECONDS - 100 + import os + + os.utime(cache, (old, old)) + monkeypatch.setattr(gallery, "cache_path", lambda: cache) + + def offline(*a, **k): + raise gallery.GalleryError("network down") + + monkeypatch.setattr(gallery, "fetch_gallery", offline) + cats = gallery.load_gallery(None) + assert cats[0]["title"] == "StaleImage" # degraded gracefully to stale cache + + +def test_load_gallery_errors_when_offline_and_no_cache(tmp_path, monkeypatch): + monkeypatch.setattr(gallery, "cache_path", lambda: tmp_path / "g" / "index.json") + + def offline(*a, **k): + raise gallery.GalleryError("network down") + + monkeypatch.setattr(gallery, "fetch_gallery", offline) + with pytest.raises(gallery.GalleryError, match="no cache present"): + gallery.load_gallery(None) From 4720d3f1ae515492dbe03698a1f618225e821295 Mon Sep 17 00:00:00 2001 From: kishore Date: Wed, 24 Jun 2026 23:12:15 -0700 Subject: [PATCH 2/3] fix(templates): ride full workflow in `fetch` JSON envelope when no --out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `comfy --json templates fetch ` (no --out) previously returned only metadata — the documented `data.workflow` field was never populated, so a JSON consumer had no way to retrieve the fetched workflow. Include the parsed workflow under `data.workflow` when there's no file destination (pretty mode already streams it to stdout); omit it with --out since it's on disk. Document the field in schemas/templates.json and add tests for both paths. Found via subagent CLI testing of the CQL command surface. --- comfy_cli/command/templates.py | 8 ++++++-- comfy_cli/schemas/templates.json | 4 ++++ tests/comfy_cli/command/test_templates.py | 17 +++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/comfy_cli/command/templates.py b/comfy_cli/command/templates.py index 402db42f..96d55480 100644 --- a/comfy_cli/command/templates.py +++ b/comfy_cli/command/templates.py @@ -333,10 +333,14 @@ def fetch_cmd( "output_type": match["output_type"], "out": target_repr, "bytes": len(body), - # `nodes` count is the only field the agent needs to confirm the - # workflow loaded; the full JSON ride-along bloats every envelope. "node_count": len(wf) if isinstance(wf, dict) else None, } + # When there's no file destination, ride the full workflow along in the + # envelope so a JSON consumer can actually retrieve it (pretty mode already + # wrote it to stdout above). With --out the workflow is on disk, so we keep + # the envelope lean and omit it. + if out is None: + payload["workflow"] = wf if renderer.is_pretty() and out: rprint(f"[green]✓[/green] wrote {len(body):,} bytes ({payload['node_count']} nodes) to {target_repr}") renderer.emit(payload, command="templates fetch") diff --git a/comfy_cli/schemas/templates.json b/comfy_cli/schemas/templates.json index 62a6ee8e..358a6385 100644 --- a/comfy_cli/schemas/templates.json +++ b/comfy_cli/schemas/templates.json @@ -67,6 +67,10 @@ { "type": "null" } ], "description": "Number of nodes in the fetched workflow (fetch)." + }, + "workflow": { + "type": "object", + "description": "The full fetched workflow JSON, included only when no --out file was given (fetch)." } } } diff --git a/tests/comfy_cli/command/test_templates.py b/tests/comfy_cli/command/test_templates.py index e86e8194..ee95cc87 100644 --- a/tests/comfy_cli/command/test_templates.py +++ b/tests/comfy_cli/command/test_templates.py @@ -244,6 +244,23 @@ def test_fetch_with_out_writes_to_file(gallery_file, tmp_path: Path, monkeypatch assert env["data"]["node_count"] == 1 assert out_path.exists() assert out_path.read_bytes() == workflow_body + # With --out the workflow is on disk; keep the envelope lean (no ride-along). + assert "workflow" not in env["data"] + + +def test_fetch_json_mode_no_out_rides_workflow_in_envelope(gallery_file, monkeypatch): + """In JSON mode without --out, the full workflow must be retrievable from + the envelope under data.workflow (otherwise there's no way to get it).""" + _force_json_renderer() + wf = {"1": {"class_type": "KSampler", "inputs": {}}} + _stub_template_workflow_fetch(monkeypatch, json.dumps(wf).encode()) + + runner = CliRunner() + result = runner.invoke(templates_cmd.app, ["fetch", "--gallery", gallery_file, "image_flux2"]) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + assert env["data"]["out"] == "stdout" + assert env["data"]["workflow"] == wf def test_fetch_unknown_template_surfaces_template_not_found(gallery_file, monkeypatch, capsys): From b43914ced2d788e8d37dd097e1426c9853bec75f Mon Sep 17 00:00:00 2001 From: kishore Date: Wed, 24 Jun 2026 23:52:14 -0700 Subject: [PATCH 3/3] fix: ruff format + make templates --query-removed test terminal-width-robust - Apply `ruff format` to nodes.py + test_nodes_cli.py (CI runs `ruff format --diff`, which I'd missed locally). - test_ls_query_option_removed asserted on Rich-rendered error text that wraps by terminal width; assert only on Click's exit code 2 (usage error), the stable cross-environment signal. --- comfy_cli/command/nodes.py | 6 ++++-- tests/comfy_cli/command/test_nodes_cli.py | 7 ++++++- tests/comfy_cli/command/test_templates.py | 7 ++++--- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/comfy_cli/command/nodes.py b/comfy_cli/command/nodes.py index bb7f8498..772c8c60 100644 --- a/comfy_cli/command/nodes.py +++ b/comfy_cli/command/nodes.py @@ -886,8 +886,10 @@ def refresh_cmd(): if r["source"] == "remote": rprint(f"[green]✓[/green] {r['name']} ({r['bytes']:,} bytes) → {r['path']}") elif r["source"] == "bundled": - rprint(f"[yellow]![/yellow] {r['name']}: remote fetch failed, using bundled snapshot " - f"([dim]{r.get('error', '')}[/dim])") + rprint( + f"[yellow]![/yellow] {r['name']}: remote fetch failed, using bundled snapshot " + f"([dim]{r.get('error', '')}[/dim])" + ) else: rprint(f"[red]✗[/red] {r['name']}: unavailable ([dim]{r.get('error', '')}[/dim])") renderer.emit({"refreshed": ok, "files": results}, command="nodes refresh") diff --git a/tests/comfy_cli/command/test_nodes_cli.py b/tests/comfy_cli/command/test_nodes_cli.py index fab89bea..ef235b99 100644 --- a/tests/comfy_cli/command/test_nodes_cli.py +++ b/tests/comfy_cli/command/test_nodes_cli.py @@ -217,7 +217,12 @@ def test_refresh_reports_remote_success(self, monkeypatch, capsys): fake = [ {"name": "supported_nodes.yaml", "source": "remote", "bytes": 100, "path": "/c/supported_nodes.yaml"}, - {"name": "cloud_disable_config.yaml", "source": "remote", "bytes": 50, "path": "/c/cloud_disable_config.yaml"}, + { + "name": "cloud_disable_config.yaml", + "source": "remote", + "bytes": 50, + "path": "/c/cloud_disable_config.yaml", + }, ] monkeypatch.setattr(annotations_source, "refresh_annotations", lambda: fake) env = _run(["refresh"], capsys) diff --git a/tests/comfy_cli/command/test_templates.py b/tests/comfy_cli/command/test_templates.py index ee95cc87..f1162c71 100644 --- a/tests/comfy_cli/command/test_templates.py +++ b/tests/comfy_cli/command/test_templates.py @@ -136,9 +136,10 @@ def test_ls_query_option_removed(gallery_file): _force_json_renderer() runner = CliRunner() result = runner.invoke(templates_cmd.app, ["ls", "--gallery", gallery_file, "--query", "type video"]) - assert result.exit_code == 2 # Click: "No such option" - assert "No such option" in result.output - assert "--query" in result.output + # Exit code 2 is Click's "usage error" (unrecognized option). This is the + # only cross-environment-stable signal — the rendered error text wraps + # differently depending on terminal width, so don't assert on its content. + assert result.exit_code == 2 def test_ls_type_filter(gallery_file):