Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 113 additions & 7 deletions comfy_cli/command/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

import json
import os
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
Expand All @@ -34,6 +36,21 @@

GALLERY_URL = "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/index.json"

# How long a cached gallery index stays fresh before ``_load_gallery`` transparently
# re-fetches it. 24h (not the spec's 7 days): the gallery updates weekly-ish and the
# fetch is one small JSON file, so a tighter TTL keeps agents off a frozen catalog
# cheaply. A network-down machine still lists from the stale cache (fetch failure
# falls back), and ``comfy templates refresh`` remains the manual force-refresh.
GALLERY_TTL_SECONDS = 24 * 60 * 60

# Everything a gallery load can throw. ``_fetch_gallery`` raises ``RuntimeError``
# on a non-200 status (which ``urlopen`` doesn't already turn into an
# ``HTTPError``), the fetch itself raises ``URLError``/``OSError``, and decoding a
# 200-with-garbage body raises ``JSONDecodeError`` — all of which must route
# through the same stale-cache fallback / command-level error, never an uncaught
# traceback.
_GALLERY_LOAD_ERRORS = (urllib.error.URLError, OSError, RuntimeError, json.JSONDecodeError)


# ---------------------------------------------------------------------------
# Gallery loading + caching
Expand Down Expand Up @@ -63,17 +80,106 @@ def _load_gallery(

Returns the raw decoded JSON (a list of category dicts). The CLI does
its own filtering on top.

A cache older than ``GALLERY_TTL_SECONDS`` is transparently re-fetched so
``templates ls/show`` never serves a frozen catalog forever. If that refresh
fails (offline / GitHub down), we fall back to the stale cache with a
non-fatal renderer warning rather than erroring out.
"""
if explicit_path:
return json.loads(Path(explicit_path).read_bytes())

cache = _cache_path()
if refresh or not cache.exists():
have_cache = cache.exists()

if not refresh and have_cache and not _cache_is_stale(cache):
return json.loads(cache.read_bytes())

# A TTL-expired cache is refreshed transparently, so a fetch failure here
# must NOT break `templates ls` — fall back to the stale cache with a
# non-fatal warning. An explicit `--refresh` (or a genuinely absent cache),
# by contrast, surfaces the fetch error so the user learns it failed.
ttl_auto_refresh = have_cache and not refresh
try:
data = _fetch_gallery()
Comment thread
mattmillerai marked this conversation as resolved.
# Validate BEFORE we touch the cache: a 200 with a non-JSON body
# (rate-limit HTML, captive portal, truncated response) must never
# clobber the last-known-good cache with garbage.
parsed = json.loads(data)
except _GALLERY_LOAD_ERRORS as e:
if ttl_auto_refresh:
# The stale cache is our fallback — but a concurrent `refresh` may
# have removed it or left it corrupt mid-write. If reading it back
# also fails, surface the original fetch error, not the read error.
try:
stale = json.loads(cache.read_bytes())
except (OSError, json.JSONDecodeError):
raise e
get_renderer().warn(
f"gallery refresh failed ({e}); using cached index (last updated {_cache_age_str(cache)} ago)",
hint="run `comfy templates refresh` once back online to update it",
)
return stale
raise
_persist_cache(cache, data)
return parsed


def _persist_cache(cache: Path, data: bytes) -> None:
"""Persist a freshly fetched index to the cache, atomically and best-effort.

* Atomic — write to a temp file in the same directory then ``os.replace``
it into place, so a concurrent ``templates`` reader never observes a
half-written index (which would parse-fail as ``gallery_load_failed``).
* Best-effort — a read-only cache dir (e.g. a gallery baked into a
container image) or a full disk must not break the command once we
already hold valid data, so a write failure is swallowed rather than
propagated.
"""
try:
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_bytes(data)
return json.loads(data)
return json.loads(cache.read_bytes())
fd, tmp = tempfile.mkstemp(dir=str(cache.parent), prefix=".index-", suffix=".tmp")
try:
with os.fdopen(fd, "wb") as f:
f.write(data)
os.replace(tmp, cache)
except OSError:
try:
os.unlink(tmp)
except OSError:
pass
raise
except OSError:
# Couldn't persist (read-only dir, disk full, …). We still have valid
# data in hand, so proceed without caching rather than failing the run.
pass


def _cache_is_stale(cache: Path) -> bool:
"""True when the cache file is older than ``GALLERY_TTL_SECONDS``."""
try:
age = time.time() - cache.stat().st_mtime
Comment thread
mattmillerai marked this conversation as resolved.
except OSError:
# Can't stat it → treat as stale so we attempt a refresh.
return True
# A future mtime (clock skew, or a restored/tampered file) yields a
# negative age; treat it as stale so the cache can't be pinned "fresh"
# indefinitely until wall-clock time catches up.
return age < 0 or age > GALLERY_TTL_SECONDS


def _cache_age_str(cache: Path) -> str:
"""Human-friendly age of the cache file for the stale-fallback warning."""
try:
age = max(0.0, time.time() - cache.stat().st_mtime)
except OSError:
return "unknown time"
hours = age / 3600.0
if hours >= 24:
return f"{hours / 24:.1f}d"
if hours >= 1:
return f"{hours:.1f}h"
return f"{age / 60:.0f}m"


def _flatten_templates(categories: list[dict[str, Any]]) -> list[dict[str, Any]]:
Expand Down Expand Up @@ -251,7 +357,7 @@ def ls_cmd(

try:
cats = _load_gallery(gallery_path, refresh=refresh)
except (urllib.error.URLError, OSError, json.JSONDecodeError) as e:
except _GALLERY_LOAD_ERRORS as e:
renderer.error(
code="gallery_load_failed",
message=str(e),
Expand Down Expand Up @@ -348,7 +454,7 @@ def show_cmd(
renderer = get_renderer()
try:
cats = _load_gallery(gallery_path, refresh=refresh)
except (urllib.error.URLError, OSError, json.JSONDecodeError) as e:
except _GALLERY_LOAD_ERRORS as e:
renderer.error(code="gallery_load_failed", message=str(e))
raise typer.Exit(code=1) from e

Expand Down Expand Up @@ -447,7 +553,7 @@ def fetch_cmd(
# 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:
except _GALLERY_LOAD_ERRORS as e:
renderer.error(code="gallery_load_failed", message=str(e))
raise typer.Exit(code=1) from e

Expand Down
Loading
Loading