Files
fn_registry/python/functions/ml/comfyui_save_skill.py
T
egutierrez f8793f96ac fix(comfyui): firmas sin keyword-only para que fn run las despache
El generador de runner de fn run (cmd/fn/pyrunner.go::generatePyRunner)
parsea la signature de la funcion desde el frontmatter del .md y emite
`<param> = _args[i]` por cada parametro posicional. Cuando la firma es
keyword-only (`def f(*, ...)`), el `*` se trata como un nombre de parametro
y genera la linea invalida `* = _args[0]`, que rompe el runner con
`SyntaxError: invalid syntax` antes de ejecutar la funcion.

Se quita el separador keyword-only (`*,`) de la firma — tanto en la `def`
del .py como en el campo `signature:` del .md (la fuente que lee el
indexer y el runner) — convirtiendo los parametros keyword-only en
parametros normales con su mismo default. No cambia nombres, defaults ni
comportamiento: las llamadas con keyword siguen siendo validas.

Afecta a 5 funciones detectadas en el report 0208 §3.3, todas con
SyntaxError reproducido via `fn run <id>`:
- comfyui_fetch_civitai_image_meta
- comfyui_load_skill
- comfyui_save_skill
- comfyui_import_workflow_png
- comfyui_list_skills

Se completa ademas el fix de comfyui_interrupt_queue: el commit 643ebfb8
quito el `*,` del .py pero dejo el `*,` en el campo `signature:` del .md,
que es justo lo que lee el runner — por eso `fn run comfyui_interrupt_queue`
seguia fallando. Aqui se corrige el .md.

Verificado: tras el cambio las 6 despachan sin SyntaxError (las 4 con
primer arg requerido devuelven el `missing required arg` esperado del
runner; list_skills e interrupt_queue ejecutan `ok:true`). Tests
existentes verdes (comfyui_fetch_civitai_image_meta_test.py +
tests/test_comfyui_interrupt_queue.py: 8 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 07:23:59 +02:00

161 lines
6.5 KiB
Python

"""comfyui_save_skill — persiste una receta de *skill* ComfyUI en la libreria de disco.
Una **skill** es una receta versionada del grupo `comfyui-skill`. Esta funcion valida el schema
minimo de la receta y la escribe en la libreria:
<library_dir>/<slug>/
recipe.json # version actual (la receta tal cual, sin mutar)
versions/vN.json # snapshot inmutable de cada save (N incremental)
exports/ # plantillas de workflow exportadas (vacio al crear)
samples/ # imagenes de muestra (vacio al crear)
growth_log.jsonl # bitacora append-only de cada save
<library_dir>/INDEX.md # indice regenerado con todas las skills
`library_dir` por defecto `~/ComfyUI/skills_library`. La funcion NO muta la receta: lo que se
escribe en `recipe.json` es identico al dict de entrada (garantiza round-trip con
`comfyui_load_skill`).
Impura: escribe archivos en disco.
"""
import json
import os
import time
DEFAULT_LIBRARY = "~/ComfyUI/skills_library"
_REQUIRED = ("slug", "base_workflow", "version")
def _lib_dir(library_dir):
return os.path.expanduser(library_dir or DEFAULT_LIBRARY)
def _validate_recipe(recipe):
"""Devuelve una lista de errores de schema (vacia si la receta es valida)."""
errors = []
if not isinstance(recipe, dict):
return [f"recipe debe ser dict, no {type(recipe).__name__}"]
for key in _REQUIRED:
val = recipe.get(key)
if not isinstance(val, str) or not val:
errors.append(f"campo requerido ausente o vacio: {key!r}")
slug = recipe.get("slug", "")
if isinstance(slug, str) and slug and ("/" in slug or "\\" in slug or slug.startswith(".")):
errors.append(f"slug invalido (no puede contener separadores de ruta ni empezar por '.'): {slug!r}")
if "prompt_scaffold" in recipe and not isinstance(recipe["prompt_scaffold"], dict):
errors.append("prompt_scaffold debe ser dict")
if "params" in recipe and not isinstance(recipe["params"], dict):
errors.append("params debe ser dict")
if "loras" in recipe and not isinstance(recipe["loras"], list):
errors.append("loras debe ser lista")
if "blocks" in recipe and not isinstance(recipe["blocks"], list):
errors.append("blocks debe ser lista")
return errors
def _write_json(path, obj):
with open(path, "w", encoding="utf-8") as fh:
json.dump(obj, fh, indent=2, ensure_ascii=False)
def _rewrite_index(lib):
"""Regenera <library_dir>/INDEX.md listando todas las skills (best-effort)."""
rows = []
for slug in sorted(os.listdir(lib)):
recipe_path = os.path.join(lib, slug, "recipe.json")
if not os.path.isfile(recipe_path):
continue
try:
with open(recipe_path, encoding="utf-8") as fh:
r = json.load(fh)
except (OSError, json.JSONDecodeError):
continue
prov = r.get("provenance") or {}
nsfw = "yes" if prov.get("nsfw") else "no"
rows.append(
f"| {slug} | {r.get('title', '')} | {r.get('base_workflow', '')} | "
f"{r.get('version', '')} | {r.get('score_mean', 0)} | {nsfw} |"
)
lines = [
"# Skills library — ComfyUI",
"",
"Recetas versionadas del grupo `comfyui-skill`. Una fila por skill.",
"",
"| slug | title | base_workflow | version | score_mean | nsfw |",
"|---|---|---|---|---|---|",
*rows,
"",
]
with open(os.path.join(lib, "INDEX.md"), "w", encoding="utf-8") as fh:
fh.write("\n".join(lines))
def comfyui_save_skill(recipe: dict, library_dir: str = None) -> dict:
"""Valida y persiste una receta de skill en la libreria de disco.
Args:
recipe: dict de la receta (schema `comfyui-skill`). Requiere al menos `slug`,
`base_workflow` y `version` (strings no vacios). No se muta.
library_dir: raiz de la libreria. Default `~/ComfyUI/skills_library`.
Returns:
dict ``{ok, slug, path, recipe_path, version_file, n_versions, error}``. En error de
validacion o de escritura, ``ok=False`` y ``error`` describe la causa; nunca lanza.
"""
errors = _validate_recipe(recipe)
if errors:
return {"ok": False, "slug": recipe.get("slug", "") if isinstance(recipe, dict) else "",
"path": "", "recipe_path": "", "version_file": "", "n_versions": 0,
"error": "receta invalida: " + "; ".join(errors)}
slug = recipe["slug"]
lib = _lib_dir(library_dir)
skill_dir = os.path.join(lib, slug)
versions_dir = os.path.join(skill_dir, "versions")
try:
for sub in ("versions", "exports", "samples"):
os.makedirs(os.path.join(skill_dir, sub), exist_ok=True)
existing = [f for f in os.listdir(versions_dir)
if f.startswith("v") and f.endswith(".json")]
n = len(existing) + 1
version_file = os.path.join(versions_dir, f"v{n}.json")
recipe_path = os.path.join(skill_dir, "recipe.json")
_write_json(recipe_path, recipe)
_write_json(version_file, recipe)
# bitacora append-only (best-effort, no rompe el save si falla)
try:
entry = {"ts": int(time.time()), "snapshot": f"v{n}",
"recipe_version": recipe.get("version", ""), "action": "save"}
with open(os.path.join(skill_dir, "growth_log.jsonl"), "a", encoding="utf-8") as fh:
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
except OSError:
pass
try:
_rewrite_index(lib)
except OSError:
pass
return {"ok": True, "slug": slug, "path": skill_dir, "recipe_path": recipe_path,
"version_file": version_file, "n_versions": n, "error": ""}
except OSError as exc:
return {"ok": False, "slug": slug, "path": skill_dir, "recipe_path": "",
"version_file": "", "n_versions": 0, "error": f"fallo de escritura: {exc}"}
if __name__ == "__main__":
demo = {
"schema_version": 1, "slug": "demo_skill", "version": "1.0.0",
"title": "Demo", "base_workflow": "txt2img",
"checkpoint": "IMG_dreamshaper_8.safetensors",
"params": {"steps": 20, "cfg": 7.0},
"prompt_scaffold": {"positive": "{subject}", "negative": "", "trigger_words": []},
"provenance": {"source": "manual", "nsfw": False},
}
res = comfyui_save_skill(demo, library_dir="/tmp/skills_demo")
print(json.dumps(res, indent=2, ensure_ascii=False))