feat(ml): comfyui_run_foreign_workflow_oneshot + helper fetch_output_video

Pipeline one-shot para ejecutar workflows ComfyUI ajenos end-to-end
(import desde cualquier fuente -> resolve deps -> validate -> submit ->
wait -> fetch del output imagen/video/malla) componiendo 9 funciones
existentes del grupo comfyui. Gate de seguridad: si faltan nodos/modelos
NO encola y los reporta en `missing`; nunca descarga modelos a ciegas y
solo instala nodos custom confiables opt-in (install_nodes + node_repos).

Helper comfyui_fetch_output_video: hermana de fetch_output_image y
fetch_output_mesh para los nodos de video/animacion (SaveAnimatedWEBP,
SaveVideo nativo, VHS_VideoCombine). Localiza el output bajo images/gifs/
videos en /history y lo baja via /view a disco; acepta outputs= de
wait_result para evitar re-consultar /history.

Cierra la pieza marcada por el completeness critic (report 0107) del
roadmap 0064/0087. 13 tests unitarios de las partes puras en verde;
validacion de integracion contra server vivo sin generacion pesada
(report 0110).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 12:53:40 +02:00
parent 898502a321
commit e8a66f0dad
7 changed files with 794 additions and 0 deletions
@@ -0,0 +1,124 @@
---
name: comfyui_run_foreign_workflow_oneshot
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def comfyui_run_foreign_workflow_oneshot(source, *, server: str = \"127.0.0.1:8188\", dest: str | None = None, output_kind: str = \"auto\", install_nodes: bool = False, node_repos: dict | None = None, wait_timeout: float = 600.0, civitai_token: str | None = None, hf_token: str | None = None) -> dict"
description: "Ejecuta un workflow ComfyUI AJENO end-to-end en una sola llamada: importa desde cualquier fuente (URL Drive/GitHub/Civitai/HuggingFace, PNG/JSON local, o dict en API format) -> resuelve dependencias (nodos/modelos faltantes) -> valida -> encola -> espera -> descarga los outputs (imagen/video/malla). SEGURIDAD: nunca instala modelos a ciegas (los reporta en 'missing') y solo instala nodos custom si install_nodes=True y el caller pasa su URL de repo confiable en node_repos. Si faltan deps, NO encola. Compone comfyui_download_workflow + resolve_workflow_deps + install_custom_node + validate_workflow + submit_workflow + wait_result + fetch_output_image/video/mesh. Pipeline impuro: HTTP + disco."
tags: [comfyui, pipeline, workflow, foreign, oneshot, ml, video, image, mesh]
uses_functions:
- comfyui_download_workflow_py_ml
- comfyui_resolve_workflow_deps_py_ml
- comfyui_install_custom_node_py_ml
- comfyui_validate_workflow_py_ml
- comfyui_submit_workflow_py_ml
- comfyui_wait_result_py_ml
- comfyui_fetch_output_image_py_ml
- comfyui_fetch_output_video_py_ml
- comfyui_fetch_output_mesh_py_ml
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
params:
- name: source
desc: "Workflow de entrada: URL (Google Drive, GitHub, Civitai, HuggingFace o directa a .json/.png/.webp), ruta local (.json/.png), o dict ya en API format ({node_id: {class_type, inputs}})."
- name: server
desc: "host:port del servidor ComfyUI sin esquema. keyword-only."
- name: dest
desc: "Directorio destino de los outputs descargados. None = cwd. Se crea si no existe. keyword-only."
- name: output_kind
desc: "Que outputs descargar: 'auto' (todos: imagenes + primer video + primera malla), 'image', 'video' o 'mesh'. keyword-only."
- name: install_nodes
desc: "Si True, instala los nodos custom faltantes cuyo class_type este mapeado a una URL de repo confiable en node_repos. Tras instalar hay que reiniciar ComfyUI para cargarlos, asi que el pipeline NO completa el submit en esa llamada. Por defecto False. keyword-only."
- name: node_repos
desc: "Mapa {class_type: repo_url} con las URLs de repo confiables de los nodos custom que se permite instalar. Solo se usa si install_nodes=True. Los modelos faltantes NUNCA se instalan. keyword-only."
- name: wait_timeout
desc: "Segundos maximos esperando a que el server termine. keyword-only."
- name: civitai_token
desc: "Token de Civitai (Bearer) para fuentes gated. keyword-only."
- name: hf_token
desc: "Token de HuggingFace (Bearer) para datasets privados. keyword-only."
output: "dict {ok, prompt_id, outputs, missing, source_type, error}. outputs = lista de rutas locales descargadas. missing = lista de dependencias faltantes (cada una {kind: 'node'|'model', name, action, hint, ...}); no vacia cuando el workflow pide algo que no tenemos, y entonces ok=False y NO se encola. source_type = de donde vino ('github', 'drive', 'local', 'dict', ...). Si falla, ok=False y error explica."
tested: true
tests:
- "test_classify_outputs_reparte_por_extension"
- "test_resolve_workflow_dict_api_format"
- "test_resolve_workflow_dict_mal_formado"
- "test_resolve_workflow_tipo_invalido"
- "test_pipeline_output_kind_invalido_no_toca_server"
- "test_pipeline_dict_mal_formado_no_toca_server"
test_file_path: "python/functions/pipelines/comfyui_run_foreign_workflow_oneshot_test.py"
file_path: "python/functions/pipelines/comfyui_run_foreign_workflow_oneshot.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from pipelines.comfyui_run_foreign_workflow_oneshot import comfyui_run_foreign_workflow_oneshot
# Workflow ajeno desde GitHub raw -> se importa, valida y ejecuta en nuestro server.
res = comfyui_run_foreign_workflow_oneshot(
"https://raw.githubusercontent.com/cubiq/ComfyUI_Workflows/main/ComfyUI_Simple/SDXL_simple.json",
dest="/tmp/comfy_foreign",
)
# Si faltan deps NO encola:
# res == {"ok": False, "prompt_id": "", "outputs": [], "source_type": "github",
# "missing": [{"kind": "model", "name": "sd_xl_base_1.0.safetensors", ...}],
# "error": "dependencias faltantes; no se encola"}
# Con todo presente:
# res == {"ok": True, "prompt_id": "...", "outputs": ["/tmp/comfy_foreign/out_00001_.png"],
# "missing": [], "source_type": "github", "error": ""}
# Tambien acepta un workflow ya validado de la libreria local o un dict en API format.
res2 = comfyui_run_foreign_workflow_oneshot(
os.path.expanduser("~/ComfyUI/workflows_library/txt2img_flux_schnell.api.json"),
dest="/tmp/comfy_foreign", output_kind="image",
)
```
Lánzalo con el python del venv (import de arriba o heredoc). `./fn run` directo no aplica porque la firma usa `*` (keyword-only).
## Cuando usarla
Cuando te pasan un workflow de ComfyUI de internet (un `.json`/`.png` de Drive,
GitHub, Civitai o HuggingFace, o un dict en API format) y quieres ejecutarlo en
nuestro servidor sin montar el flujo a mano. Hace en una llamada lo que antes eran
6-9 pasos: importar a API format, detectar qué nodos/modelos faltan, (opcionalmente)
instalar nodos custom confiables, validar, encolar, esperar y bajar los outputs.
Úsalo como puerta de entrada segura para workflows de terceros: te dice exactamente
qué falta antes de tocar la GPU. Para builders propios del registry (txt2img, img2vid,
img-to-3d) usa sus pipelines dedicados; este es para workflows que NO construimos nosotros.
## Gotchas
- Pipeline impuro: HTTP (import/validate/submit/poll/fetch) + escritura en disco
(+ subprocess git/pip si instala nodos). Requiere el server ComfyUI vivo.
- **Seguridad — código de terceros**: un workflow ajeno se ejecuta como grafo en
NUESTRO servidor. Por eso el pipeline SIEMPRE resuelve dependencias antes del
submit y NUNCA instala nada a ciegas. Los **modelos** faltantes se REPORTAN en
`missing`, jamás se descargan automáticamente. Los **nodos custom** solo se instalan
si `install_nodes=True` y el caller pasa la URL del repo en `node_repos` (fuente
confiable explícita).
- Tras instalar un nodo custom, ComfyUI debe reiniciarse para cargarlo (no en
caliente: cortaría generaciones en curso). Por eso, si instala nodos, el pipeline
devuelve `ok=False` con la instrucción de reiniciar y reintentar — no completa el
submit en esa misma llamada.
- Si tras resolver quedan dependencias faltantes (modelos, o nodos sin cargar), NO
encola: `ok=False` con `missing` poblado. Revisa `missing` para saber qué bajar/instalar.
- `output_kind="auto"` baja todas las imágenes + el primer vídeo + la primera malla.
Un `.webp` se clasifica como vídeo/animación (no imagen estática).
- `wait_timeout` por defecto 600s cubre vídeo/3D; súbelo para workflows muy largos.
- `dest` se trata siempre como directorio y se crea si no existe.
## Capability growth log
- v1.0.0 (2026-06-24) — creación. Promueve la secuencia del roadmap 0064/0087
(import → resolve deps → validate → submit → wait → fetch) a un pipeline one-shot
para workflows ComfyUI ajenos, con gate de dependencias y sin instalación a ciegas.
@@ -0,0 +1,275 @@
"""comfyui_run_foreign_workflow_oneshot — ejecuta un workflow ComfyUI ajeno end-to-end.
Promocion de la secuencia del roadmap (issue 0064/0087): traer un workflow
foraneo (URL de Drive/GitHub/Civitai/HuggingFace, PNG/JSON local, o dict en API
format) y ejecutarlo en NUESTRO servidor ComfyUI en una sola llamada, resolviendo
antes sus dependencias para no fallar a ciegas. Compone funciones del registry del
grupo `comfyui`:
comfyui_download_workflow_py_ml (cualquier fuente -> API format)
comfyui_resolve_workflow_deps_py_ml (detecta nodos/modelos faltantes)
comfyui_install_custom_node_py_ml (instala nodos custom, opt-in + confiable)
comfyui_validate_workflow_py_ml (revalida tras instalar)
comfyui_submit_workflow_py_ml (POST /prompt)
comfyui_wait_result_py_ml (poll /history)
comfyui_fetch_output_image_py_ml (GET /view -> disco, imagenes)
comfyui_fetch_output_video_py_ml (GET /view -> disco, video/animacion)
comfyui_fetch_output_mesh_py_ml (GET /view -> disco, mallas 3D)
SEGURIDAD: un workflow foraneo es codigo de terceros que se ejecuta como grafo en
nuestro servidor. Por eso el pipeline SIEMPRE resuelve dependencias antes del
submit y NUNCA instala nada a ciegas:
- Modelos faltantes -> se REPORTAN en `missing`, jamas se descargan
automaticamente (no hay forma de saber si la fuente es confiable).
- Nodos custom faltantes -> solo se instalan si install_nodes=True Y el caller
pasa su URL de repo en `node_repos` (fuente confiable explicita). Tras
instalar, ComfyUI debe reiniciarse para cargarlos, asi que el pipeline NO
completa el submit en esa misma llamada: devuelve ok=False con la instruccion
de reiniciar y reintentar.
Si tras resolver quedan dependencias faltantes, el pipeline NO encola: devuelve
ok=False con `missing` poblado.
Pipeline impuro: red (HTTP) + escritura en disco (+ subprocess git/pip si se
instalan nodos custom).
"""
from __future__ import annotations
import json
import os
import sys
# Importa las funciones del registry (mismo arbol python/functions).
_FUNCTIONS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _FUNCTIONS_ROOT not in sys.path:
sys.path.insert(0, _FUNCTIONS_ROOT)
from ml.comfyui_download_workflow import comfyui_download_workflow
from ml.comfyui_fetch_output_image import comfyui_fetch_output_image
from ml.comfyui_fetch_output_mesh import comfyui_fetch_output_mesh
from ml.comfyui_fetch_output_video import comfyui_fetch_output_video
from ml.comfyui_install_custom_node import comfyui_install_custom_node
from ml.comfyui_resolve_workflow_deps import comfyui_resolve_workflow_deps
from ml.comfyui_submit_workflow import comfyui_submit_workflow
from ml.comfyui_validate_workflow import comfyui_validate_workflow
from ml.comfyui_wait_result import comfyui_wait_result
# Clasificacion de los outputs por extension del filename.
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".tiff")
_VIDEO_EXTS = (".mp4", ".webm", ".webp", ".gif", ".mkv", ".mov", ".avif")
_MESH_EXTS = (".glb", ".gltf", ".obj", ".ply", ".fbx", ".stl", ".usdz", ".splat")
def _classify_outputs(outputs: dict) -> dict:
"""Reparte los items de los outputs de /history en image/video/mesh por extension.
Devuelve {"image": [items], "video": [items], "mesh": [items]} donde cada
item es {filename, subfolder, type}. Un .webp se considera video/animacion
(los nodos de animacion de ComfyUI lo usan); las imagenes estaticas usan png/jpg.
"""
buckets: dict[str, list] = {"image": [], "video": [], "mesh": []}
for node_out in outputs.values():
if not isinstance(node_out, dict):
continue
for items in node_out.values():
if not isinstance(items, list):
continue
for item in items:
if not isinstance(item, dict):
continue
fn = (item.get("filename") or "").lower()
rec = {
"filename": item.get("filename", ""),
"subfolder": item.get("subfolder", ""),
"type": item.get("type", "output"),
}
if fn.endswith(_MESH_EXTS):
buckets["mesh"].append(rec)
elif fn.endswith(_VIDEO_EXTS):
buckets["video"].append(rec)
elif fn.endswith(_IMAGE_EXTS):
buckets["image"].append(rec)
return buckets
def _resolve_workflow(source, server, civitai_token, hf_token):
"""Resuelve el `source` (dict | str) a un workflow en API format.
Devuelve (workflow, source_type, error). Si source ya es un dict en API
format se usa tal cual; si es str se delega a comfyui_download_workflow.
"""
if isinstance(source, dict):
if source and all(
isinstance(v, dict) and "class_type" in v for v in source.values()
):
return source, "dict", ""
return {}, "dict", "el dict pasado no esta en API format ({node_id: {class_type, inputs}})"
if not isinstance(source, str):
return {}, "", f"source debe ser str (URL/path) o dict (API format), no {type(source).__name__}"
dl = comfyui_download_workflow(
source, server=server, civitai_token=civitai_token, hf_token=hf_token
)
if not dl.get("ok"):
return {}, dl.get("source_type", ""), dl.get("error", "no se pudo importar el workflow")
return dl.get("workflow", {}), dl.get("source_type", ""), ""
def comfyui_run_foreign_workflow_oneshot(
source,
*,
server: str = "127.0.0.1:8188",
dest: str | None = None,
output_kind: str = "auto",
install_nodes: bool = False,
node_repos: dict | None = None,
wait_timeout: float = 600.0,
civitai_token: str | None = None,
hf_token: str | None = None,
) -> dict:
"""Importa, valida, ejecuta y descarga los outputs de un workflow ComfyUI ajeno.
Args:
source: workflow de entrada. Puede ser una URL (Google Drive, GitHub,
Civitai, HuggingFace o directa a .json/.png/.webp), una ruta local
(.json/.png), o un dict ya en API format ({node_id: {class_type,
inputs}}).
server: host:port del servidor ComfyUI (sin esquema). keyword-only.
dest: directorio destino de los outputs descargados. None = cwd.
keyword-only.
output_kind: que outputs descargar: "auto" (todos: imagenes + el primer
video + la primera malla), "image", "video" o "mesh". keyword-only.
install_nodes: si True, instala los nodos custom faltantes cuyo class_type
este mapeado a una URL de repo confiable en `node_repos`. Tras instalar
hay que reiniciar ComfyUI para cargarlos, asi que el pipeline NO
completa el submit en esa llamada. Por defecto False. keyword-only.
node_repos: mapa {class_type: repo_url} con las URLs de repo confiables de
los nodos custom que se permite instalar. Solo se usa si
install_nodes=True. Los modelos faltantes NUNCA se instalan. keyword-only.
wait_timeout: segundos maximos esperando a que el server termine.
keyword-only.
civitai_token / hf_token: tokens opcionales para fuentes gated. keyword-only.
Returns:
dict {ok, prompt_id, outputs, missing, source_type, error}:
- outputs: lista de rutas locales de los archivos descargados.
- missing: lista de dependencias faltantes (suggestions de
comfyui_resolve_workflow_deps): cada una {kind: "node"|"model", name,
action, hint, ...}. No vacia cuando el workflow pide algo que no
tenemos; en ese caso ok=False y NO se encola.
- source_type: de donde vino el workflow ("github", "drive", "local",
"dict", ...).
Si falla en cualquier paso, ok=False y error explica el motivo. Nunca
instala modelos ni nodos de fuentes no confiables a ciegas.
"""
if output_kind not in ("auto", "image", "video", "mesh"):
return {"ok": False, "prompt_id": "", "outputs": [], "missing": [],
"source_type": "", "error": f"output_kind {output_kind!r} no valido"}
# 1. Importar el workflow a API format desde cualquier fuente.
workflow, source_type, err = _resolve_workflow(source, server, civitai_token, hf_token)
if err:
return {"ok": False, "prompt_id": "", "outputs": [], "missing": [],
"source_type": source_type, "error": f"import fallo: {err}"}
if not workflow:
return {"ok": False, "prompt_id": "", "outputs": [], "missing": [],
"source_type": source_type, "error": "el workflow importado esta vacio"}
# 2. Resolver dependencias (nodos/modelos faltantes) ANTES de encolar.
deps = comfyui_resolve_workflow_deps(workflow, server=server)
if not deps.get("ok"):
return {"ok": False, "prompt_id": "", "outputs": [], "missing": [],
"source_type": source_type,
"error": f"no se pudieron resolver dependencias (¿server vivo?): {deps.get('error')}"}
missing_nodes = list(deps.get("missing_nodes", []))
missing_models = list(deps.get("missing_models", []))
suggestions = list(deps.get("suggestions", []))
# 3. Instalar SOLO nodos custom confiables (opt-in + URL provista por el caller).
install_notes = []
repos = node_repos or {}
if install_nodes and missing_nodes and repos:
for ctype in list(missing_nodes):
repo = repos.get(ctype)
if not repo:
continue # sin URL confiable -> no se instala, queda en missing.
res = comfyui_install_custom_node(repo, restart=False)
if res.get("ok"):
install_notes.append(f"nodo '{ctype}' instalado desde {repo}")
else:
install_notes.append(f"fallo instalando '{ctype}': {res.get('error')}")
# 4. Si quedan dependencias faltantes (modelos, o nodos sin cargar) -> NO encolar.
# Los nodos recien instalados requieren reiniciar ComfyUI; no se cargan ahora.
if missing_nodes or missing_models:
note = "dependencias faltantes; no se encola"
if install_notes:
note += ". " + "; ".join(install_notes)
note += ". Reinicia ComfyUI (cuando no haya generaciones en curso) y reintenta"
return {"ok": False, "prompt_id": "", "outputs": [], "missing": suggestions,
"source_type": source_type, "error": note}
# 5. Encolar el workflow.
try:
sub = comfyui_submit_workflow(workflow, server=server)
prompt_id = sub["prompt_id"]
except (RuntimeError, KeyError) as exc:
return {"ok": False, "prompt_id": "", "outputs": [], "missing": [],
"source_type": source_type, "error": f"submit fallo: {exc}"}
# 6. Esperar a que termine.
try:
outputs = comfyui_wait_result(prompt_id, server=server, timeout=wait_timeout)
except (TimeoutError, RuntimeError) as exc:
return {"ok": False, "prompt_id": prompt_id, "outputs": [], "missing": [],
"source_type": source_type, "error": f"wait fallo: {exc}"}
# 7. Descargar los outputs segun output_kind. `dest` se trata SIEMPRE como
# directorio (el workflow puede producir varios archivos): se crea de
# antemano para que fetch_video/fetch_mesh escriban el basename dentro
# (si el dir no existe, esos helpers interpretarian la ruta como archivo).
out_dir = dest or "."
try:
os.makedirs(out_dir, exist_ok=True)
except OSError as exc:
return {"ok": False, "prompt_id": prompt_id, "outputs": [], "missing": [],
"source_type": source_type, "error": f"no se pudo crear dest {out_dir!r}: {exc}"}
buckets = _classify_outputs(outputs)
paths: list[str] = []
want_image = output_kind in ("auto", "image")
want_video = output_kind in ("auto", "video")
want_mesh = output_kind in ("auto", "mesh")
if want_image:
for item in buckets["image"]:
r = comfyui_fetch_output_image(
item["filename"], subfolder=item["subfolder"],
type_=item["type"], server=server, dest_dir=out_dir,
)
if r.get("ok"):
paths.append(r["path"])
if want_video and buckets["video"]:
r = comfyui_fetch_output_video(prompt_id, server=server, dest=out_dir, outputs=outputs)
if r.get("ok"):
paths.append(r["path"])
if want_mesh and buckets["mesh"]:
r = comfyui_fetch_output_mesh(prompt_id, server=server, dest=out_dir)
if r.get("ok"):
paths.append(r["path"])
if not paths:
return {"ok": False, "prompt_id": prompt_id, "outputs": [], "missing": [],
"source_type": source_type,
"error": f"el workflow termino pero no se descargo ningun output de tipo {output_kind!r}"}
return {"ok": True, "prompt_id": prompt_id, "outputs": paths, "missing": [],
"source_type": source_type, "error": ""}
if __name__ == "__main__":
# Smoke: ejecuta un workflow ya validado de la libreria local (deps presentes).
src = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser(
"~/ComfyUI/workflows_library/txt2img_flux_schnell.api.json")
res = comfyui_run_foreign_workflow_oneshot(src, dest="/tmp/comfy_foreign")
print(json.dumps(res, indent=2))
@@ -0,0 +1,63 @@
"""Tests de las partes puras de comfyui_run_foreign_workflow_oneshot.
Cubren sin red ni GPU: la clasificacion de outputs por tipo, la resolucion de un
`source` que ya es dict en API format, y las ramas de error tempranas que retornan
antes de tocar el servidor (output_kind invalido, source de tipo invalido, dict mal
formado). El flujo completo import->resolve->submit->wait->fetch se valida por
integracion contra el server (ver report 0110).
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from pipelines.comfyui_run_foreign_workflow_oneshot import (
_classify_outputs,
_resolve_workflow,
comfyui_run_foreign_workflow_oneshot,
)
def test_classify_outputs_reparte_por_extension():
outs = {
"1": {"images": [{"filename": "a.png", "subfolder": "", "type": "output"}]},
"2": {"gifs": [{"filename": "b.webm", "subfolder": "", "type": "output"}]},
"3": {"images": [{"filename": "c.webp", "subfolder": "", "type": "output"}]},
"4": {"3d": [{"filename": "d.glb", "subfolder": "", "type": "output"}]},
}
b = _classify_outputs(outs)
assert [i["filename"] for i in b["image"]] == ["a.png"]
assert sorted(i["filename"] for i in b["video"]) == ["b.webm", "c.webp"]
assert [i["filename"] for i in b["mesh"]] == ["d.glb"]
def test_resolve_workflow_dict_api_format():
wf = {"1": {"class_type": "CheckpointLoaderSimple", "inputs": {}}}
got, st, err = _resolve_workflow(wf, "127.0.0.1:8188", None, None)
assert err == "" and st == "dict" and got is wf
def test_resolve_workflow_dict_mal_formado():
got, st, err = _resolve_workflow({"1": {"no_class": 1}}, "127.0.0.1:8188", None, None)
assert got == {} and "API format" in err
def test_resolve_workflow_tipo_invalido():
got, st, err = _resolve_workflow(123, "127.0.0.1:8188", None, None)
assert got == {} and "source debe ser" in err
def test_pipeline_output_kind_invalido_no_toca_server():
r = comfyui_run_foreign_workflow_oneshot("cualquier", output_kind="bogus")
assert not r["ok"] and "output_kind" in r["error"] and r["prompt_id"] == ""
def test_pipeline_dict_mal_formado_no_toca_server():
r = comfyui_run_foreign_workflow_oneshot({"1": {"no_class": 1}}, server="127.0.0.1:8188")
assert not r["ok"] and "import fallo" in r["error"] and r["prompt_id"] == ""
if __name__ == "__main__":
import pytest
raise SystemExit(pytest.main([__file__, "-v"]))