diff options
| -rw-r--r-- | .gitignore | 7 | ||||
| -rw-r--r-- | README.md | 17 | ||||
| -rw-r--r-- | comfier.py | 20 | ||||
| -rw-r--r-- | devkit/__init__.py | 5 | ||||
| -rw-r--r-- | devkit/fake_open_webui.py | 96 | ||||
| -rw-r--r-- | fixtures/nodes.json | 7 | ||||
| -rw-r--r-- | fixtures/workflow.json (renamed from workflow.json) | 0 | ||||
| -rw-r--r-- | harness.py | 166 | ||||
| -rw-r--r-- | pyproject.toml | 31 | ||||
| -rw-r--r-- | requirements.txt | 2 | ||||
| -rw-r--r-- | scripts/check_stubs.py | 172 | ||||
| -rw-r--r-- | stubs/fastapi/__init__.pyi | 1 | ||||
| -rw-r--r-- | stubs/open_webui/__init__.pyi | 0 | ||||
| -rw-r--r-- | stubs/open_webui/models/__init__.pyi | 0 | ||||
| -rw-r--r-- | stubs/open_webui/models/config.pyi | 5 | ||||
| -rw-r--r-- | stubs/open_webui/models/users.pyi | 11 | ||||
| -rw-r--r-- | stubs/open_webui/routers/__init__.pyi | 0 | ||||
| -rw-r--r-- | stubs/open_webui/routers/images.pyi | 14 | ||||
| -rw-r--r-- | tests/__init__.py | 0 | ||||
| -rw-r--r-- | tests/conftest.py | 144 | ||||
| -rw-r--r-- | tests/test_generate.py | 93 | ||||
| -rw-r--r-- | tests/test_helpers.py | 59 |
22 files changed, 719 insertions, 131 deletions
@@ -1 +1,6 @@ -__pycache__/
\ No newline at end of file +__pycache__/ +test_out/ +.pytest_cache/ +.ruff_cache/ +.venv/ +*.egg-info/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed51e3f --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# Comfier + +Development repo for the Comfier Open WebUI tool (`comfier.py`). To deploy, paste `comfier.py` into Open WebUI. + +## Setup + source .venv/bin/activate + pip install -e . --group dev + +## Workflow +- Run against a real ComfyUI: `python harness.py --url http://host:8188` (see `--help`; `--cancel-after 2` simulates Stop). + Node mappings come from `fixtures/nodes.json` (mirrors `COMFYUI_WORKFLOW_NODES`), workflow from `fixtures/workflow.json`. +- Offline tests (in-process fake ComfyUI): `pytest` +- Type check: `pyright` (Open WebUI/FastAPI types come from `stubs/`) +- Verify stubs against upstream: `python scripts/check_stubs.py` (needs network; checks against the tag in `required_open_webui_version`, or pass `--ref main`). Run it before bumping that version. +- Lint/format: `ruff check . && ruff format .` + +`devkit/` provides the fake `open_webui.*` / `fastapi` modules used by the harness and tests. @@ -8,7 +8,6 @@ version: 0.1.0 license: 3-clause-BSD """ -import aiohttp import asyncio import copy import inspect @@ -17,14 +16,14 @@ import mimetypes import random import time import uuid - -from fastapi import Request -from pydantic import BaseModel, Field from typing import Annotated, Any, Awaitable, Callable, Optional +import aiohttp +from fastapi import Request from open_webui.models.config import Config from open_webui.models.users import Users from open_webui.routers.images import get_image_config, upload_image +from pydantic import BaseModel, Field EventEmitter = Callable[[dict[str, Any]], Awaitable[None]] EventCall = Callable[[dict[str, Any]], Awaitable[Any]] @@ -286,7 +285,7 @@ class Tools: status_info = (h_data[prompt_id] or {}).get("status") or {} if status_info.get("status_str") == "error": raise RuntimeError( - "The job was terminated win an error or interruption on the backend." + "The job was terminated with an error or interruption on the backend." ) return True @@ -378,7 +377,7 @@ class Tools: if not is_alive: raise ConnectionError( f"ComfyUI backend lost prompt {prompt_id} (backend may have restarted or crashed)." - ) + ) from None continue if msg.type != aiohttp.WSMsgType.TEXT: @@ -457,7 +456,7 @@ class Tools: if not output_images: raise RuntimeError( - "Generation finished without producing any output. The Job may have beend cancelled or removed from the queue." + "Generation finished without producing any output. The Job may have been cancelled or removed from the queue." ) await self._emit_status( @@ -486,7 +485,7 @@ class Tools: or "image/png" ) - uploaded = upload_image( + uploaded: Any = upload_image( request=request, image_data=image_bytes, content_type=content_type, @@ -502,10 +501,10 @@ class Tools: if isinstance(uploaded, str): url = uploaded - elif hasattr(uploaded, "id"): - url = f"/api/v1/files/{uploaded.id}/content" elif isinstance(uploaded, dict) and "id" in uploaded: url = f"/api/v1/files/{uploaded['id']}/content" + elif hasattr(uploaded, "id"): + url = f"/api/v1/files/{uploaded.id}/content" # pyright: ignore[reportAttributeAccessIssue] else: raise RuntimeError( f"Unexpected return type from upload_image: {type(uploaded)!r}" @@ -699,4 +698,3 @@ class Tools: raw_steps = await Config.get("image_generation.steps", 25) return int(raw_steps) - diff --git a/devkit/__init__.py b/devkit/__init__.py new file mode 100644 index 0000000..ad4cfbc --- /dev/null +++ b/devkit/__init__.py @@ -0,0 +1,5 @@ +"""Development kit: fake Open WebUI environment for running Comfier outside Open WebUI.""" + +from devkit.fake_open_webui import FakeEnv, install + +__all__ = ["FakeEnv", "install"] diff --git a/devkit/fake_open_webui.py b/devkit/fake_open_webui.py new file mode 100644 index 0000000..eb0a1b0 --- /dev/null +++ b/devkit/fake_open_webui.py @@ -0,0 +1,96 @@ +"""Fake `fastapi` / `open_webui.*` modules, installed into `sys.modules`.""" + +import pathlib +import sys +import time +import types +from dataclasses import dataclass, field +from types import SimpleNamespace +from typing import Any + + +@dataclass +class FakeEnv: + """Stand-in for the Open WebUI configuration the tool reads.""" + + comfyui_url: str + workflow: str + api_key: str = "" + nodes: list[dict[str, Any]] = field(default_factory=list) + image_size: str = "1024x1024" + image_steps: int = 25 + output_dir: pathlib.Path = pathlib.Path("test_out") + stored: list[pathlib.Path] = field(default_factory=list) + + +def _module(name: str, **attrs: Any) -> types.ModuleType: + mod = types.ModuleType(name) + for key, value in attrs.items(): + setattr(mod, key, value) + return mod + + +def install(env: FakeEnv) -> None: + """Register fake modules. Call before importing `comfier`.""" + + class Request: ... + + class Config: + @staticmethod + async def get(key: str, default: Any = None) -> Any: + return default + + class UsersTable: + async def get_user_by_id(self, id: str, db: Any = None) -> Any: + return SimpleNamespace(id=id, name="dev") + + Users = UsersTable() + + async def get_image_config() -> Any: + return SimpleNamespace( + COMFYUI_BASE_URL=env.comfyui_url, + COMFYUI_API_KEY=env.api_key, + COMFYUI_WORKFLOW=env.workflow, + COMFYUI_WORKFLOW_NODES=env.nodes, + IMAGE_SIZE=env.image_size, + IMAGE_STEPS=env.image_steps, + ) + + async def upload_image( + request: Any, + image_data: bytes, + content_type: str, + metadata: dict[str, Any], + user: Any, + ) -> Any: + env.output_dir.mkdir(parents=True, exist_ok=True) + ext = ".png" if "png" in content_type else ".jpg" + file_id = f"seed_{metadata.get('seed', 'unknown')}_{time.monotonic_ns()}" + out_file = env.output_dir / f"{file_id}{ext}" + out_file.write_bytes(image_data) + env.stored.append(out_file) + print(f" [Storage] Saved {len(image_data)} bytes -> {out_file}") + # Open WebUI 0.12 returns (file_item, {id, url, name, content_type}) + return SimpleNamespace(id=file_id), { + "id": file_id, + "url": f"/api/v1/files/{file_id}/content", + } + + modules = { + "fastapi": _module("fastapi", Request=Request), + "open_webui": _module("open_webui"), + "open_webui.models": _module("open_webui.models"), + "open_webui.routers": _module("open_webui.routers"), + "open_webui.models.config": _module("open_webui.models.config", Config=Config), + "open_webui.models.users": _module("open_webui.models.users", Users=Users), + "open_webui.routers.images": _module( + "open_webui.routers.images", + get_image_config=get_image_config, + upload_image=upload_image, + ), + } + sys.modules.pop("comfier", None) + sys.modules.update(modules) + + +__all__ = ["FakeEnv", "install"] diff --git a/fixtures/nodes.json b/fixtures/nodes.json new file mode 100644 index 0000000..0e03ae6 --- /dev/null +++ b/fixtures/nodes.json @@ -0,0 +1,7 @@ +[ + {"type": "prompt", "key": "value", "node_ids": ["520:506"]}, + {"type": "seed", "key": "seed", "node_ids": ["520:517"]}, + {"type": "width", "key": "value", "node_ids": ["520:518"]}, + {"type": "height", "key": "value", "node_ids": ["520:519"]}, + {"type": "steps", "key": "steps", "node_ids": ["459:458"]} +] diff --git a/workflow.json b/fixtures/workflow.json index 40d24a8..40d24a8 100644 --- a/workflow.json +++ b/fixtures/workflow.json @@ -1,97 +1,19 @@ #!/usr/bin/env python3 +"""Standalone CLI harness: run the Comfier tool against a real ComfyUI.""" + import argparse import asyncio import json +import os import pathlib -import sys -import types from types import SimpleNamespace -from typing import Any - -# --------------------------------------------------------------------------- -# 1. Stub out `fastapi` and `open_webui.*` in sys.modules BEFORE importing tool -# --------------------------------------------------------------------------- -def install_open_webui_stubs( - comfyui_url: str, - api_key: str, - workflow_path: pathlib.Path, - nodes_config: list[dict[str, Any]], - image_size: str, - image_steps: int, - output_dir: pathlib.Path, -) -> None: - # Ensure fastapi.Request exists even in a minimal venv - if "fastapi" not in sys.modules: - fastapi_mod = types.ModuleType("fastapi") - fastapi_mod.Request = object - sys.modules["fastapi"] = fastapi_mod - - workflow_raw = workflow_path.read_text(encoding="utf-8") - - # Mock `open_webui.models.config.Config` - config_mod = types.ModuleType("open_webui.models.config") - - class MockConfig: - @staticmethod - async def get(key: str, default: Any = None) -> Any: - return default - - config_mod.Config = MockConfig - - # Mock `open_webui.models.users.Users` - users_mod = types.ModuleType("open_webui.models.users") - - class MockUsers: - @staticmethod - def get_user_by_id(user_id: str) -> Any: - return SimpleNamespace(id=user_id, name="Felix") - - users_mod.Users = MockUsers - - # Mock `open_webui.routers.images` - images_mod = types.ModuleType("open_webui.routers.images") - - async def mock_get_image_config() -> Any: - return SimpleNamespace( - COMFYUI_BASE_URL=comfyui_url, - COMFYUI_API_KEY=api_key, - COMFYUI_WORKFLOW=workflow_raw, - COMFYUI_WORKFLOW_NODES=nodes_config, - IMAGE_SIZE=image_size, - IMAGE_STEPS=image_steps, - ) +from typing import Any, cast + +from devkit import FakeEnv, install + +FIXTURES = pathlib.Path(__file__).parent / "fixtures" + - def mock_upload_image( - request: Any, - image_data: bytes, - content_type: str, - metadata: dict[str, Any], - user: Any, - ) -> Any: - output_dir.mkdir(parents=True, exist_ok=True) - ext = ".png" if "png" in content_type else ".jpg" - file_id = f"seed_{metadata.get('seed', 'unknown')}_{int(asyncio.get_event_loop().time() * 1000)}" - out_file = output_dir / f"{file_id}{ext}" - out_file.write_bytes(image_data) - print(f" [Storage] Saved {len(image_data)} bytes -> {out_file}") - # Return a mock FileModel with an `.id` attribute just like Open WebUI - return SimpleNamespace(id=file_id) - - images_mod.get_image_config = mock_get_image_config - images_mod.upload_image = mock_upload_image - - # Register package hierarchy in sys.modules - sys.modules["open_webui"] = types.ModuleType("open_webui") - sys.modules["open_webui.models"] = types.ModuleType("open_webui.models") - sys.modules["open_webui.routers"] = types.ModuleType("open_webui.routers") - sys.modules["open_webui.models.config"] = config_mod - sys.modules["open_webui.models.users"] = users_mod - sys.modules["open_webui.routers.images"] = images_mod - - -# --------------------------------------------------------------------------- -# 2. Terminal Event Emitter & Interactive Event Call -# --------------------------------------------------------------------------- async def console_event_emitter(event: dict[str, Any]) -> None: ev_type = event.get("type") data = event.get("data", {}) @@ -105,9 +27,8 @@ async def console_event_emitter(event: dict[str, Any]) -> None: async def console_event_call(event: dict[str, Any]) -> bool: - ev_type = event.get("type") data = event.get("data", {}) - print(f"\n--- MODAL ({ev_type}): {data.get('title')} ---") + print(f"\n--- MODAL ({event.get('type')}): {data.get('title')} ---") print(data.get("message", "")) loop = asyncio.get_running_loop() answer = await loop.run_in_executor( @@ -116,42 +37,53 @@ async def console_event_call(event: dict[str, Any]) -> bool: return answer in ("y", "yes") -# --------------------------------------------------------------------------- -# 3. CLI Runner -# --------------------------------------------------------------------------- async def main() -> None: parser = argparse.ArgumentParser(description="Standalone test harness for Comfier") - parser.add_argument("--url", default="http://10.42.0.146:8188", help="ComfyUI base URL") - parser.add_argument("--workflow", type=pathlib.Path, required=True, help="Path to ComfyUI API workflow JSON") - parser.add_argument("--prompt", default="a tiny blue marble on a white surface", help="Positive prompt") + parser.add_argument( + "--url", + default=os.environ.get("COMFYUI_URL", "http://127.0.0.1:8188"), + help="ComfyUI base URL (default: $COMFYUI_URL or http://127.0.0.1:8188)", + ) + parser.add_argument("--api-key", default=os.environ.get("COMFYUI_API_KEY", "")) + parser.add_argument( + "--workflow", type=pathlib.Path, default=FIXTURES / "workflow.json" + ) + parser.add_argument( + "--nodes", + type=pathlib.Path, + default=FIXTURES / "nodes.json", + help="JSON file mirroring Open WebUI's COMFYUI_WORKFLOW_NODES", + ) + parser.add_argument("--prompt", default="a tiny blue marble on a white surface") parser.add_argument("--neg", default=None, help="Negative prompt") parser.add_argument("--size", default="256x256", help="Image size (WIDTHxHEIGHT)") - parser.add_argument("--seed", default="123", help="Seed") + parser.add_argument("--seed", default="123") parser.add_argument("--steps", type=int, default=30, help="Global IMAGE_STEPS") - parser.add_argument("--neg-nodes", default="459:452", help="Valve: negative_prompt_node_ids") - parser.add_argument("--cancel-after", type=float, default=None, help="Simulate user clicking Stop after N seconds") + parser.add_argument( + "--neg-nodes", default="459:452", help="Valve: negative_prompt_node_ids" + ) + parser.add_argument("--out", type=pathlib.Path, default=pathlib.Path("test_out")) + parser.add_argument( + "--cancel-after", + type=float, + default=None, + help="Simulate user clicking Stop after N seconds", + ) args = parser.parse_args() - # Match your Open WebUI global COMFYUI_WORKFLOW_NODES setup - global_nodes = [ - {"type": "prompt", "key": "value", "node_ids": ["520:506"]}, - {"type": "seed", "key": "seed", "node_ids": ["520:517"]}, - {"type": "width", "key": "value", "node_ids": ["520:518"]}, - {"type": "height", "key": "value", "node_ids": ["520:519"]}, - {"type": "steps", "key": "steps", "node_ids": ["459:458"]}, - ] - - install_open_webui_stubs( - comfyui_url=args.url, - api_key="", - workflow_path=args.workflow, - nodes_config=global_nodes, - image_size="1024x1024", - image_steps=args.steps, - output_dir=pathlib.Path("./test_out"), + install( + FakeEnv( + comfyui_url=args.url, + api_key=args.api_key, + workflow=args.workflow.read_text(encoding="utf-8"), + nodes=json.loads(args.nodes.read_text(encoding="utf-8")), + image_size="1024x1024", + image_steps=args.steps, + output_dir=args.out, + ) ) - # Import your tool AFTER stubs are installed + # Import the tool AFTER the fake Open WebUI modules are installed. from comfier import Tools tools = Tools() @@ -163,7 +95,7 @@ async def main() -> None: negative_prompt=args.neg, size=args.size, seed=args.seed, - __request__=SimpleNamespace(), + __request__=cast(Any, SimpleNamespace()), __user__={"id": "local-dev"}, __event_emitter__=console_event_emitter, __event_call__=console_event_call, diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..295ae49 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +[project] +name = "comfier" +version = "0.1.0" +description = "Development repository for the Comfier Open WebUI tool" +requires-python = ">=3.12" +dependencies = ["aiohttp", "pydantic"] + +[dependency-groups] +dev = ["pyright", "ruff", "pytest", "pytest-asyncio"] + +[tool.setuptools] +py-modules = ["comfier", "harness"] +packages = ["devkit"] + +[tool.pyright] +include = ["comfier.py", "harness.py", "devkit", "tests", "scripts"] +stubPath = "stubs" +typeCheckingMode = "standard" +reportMissingModuleSource = false +pythonVersion = "3.14" + +[tool.ruff] +extend-exclude = ["stubs"] + +[tool.ruff.lint] +select = ["E", "F", "I", "B"] +ignore = ["E501"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 7925b0b..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -aiohttp -pydantic
\ No newline at end of file diff --git a/scripts/check_stubs.py b/scripts/check_stubs.py new file mode 100644 index 0000000..5d5d7e4 --- /dev/null +++ b/scripts/check_stubs.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Check that stubs/ match the real Open WebUI signatures. + +Fetches the relevant Open WebUI source files from GitHub at a given ref +(default: the tag from `required_open_webui_version` in comfier.py), parses +them with `ast` (nothing is imported or installed) and compares function +signatures with the .pyi stubs: async-ness, parameter names/kinds and whether +each parameter has a default. Annotations are not compared. +""" + +import argparse +import ast +import pathlib +import re +import sys +import urllib.request + +ROOT = pathlib.Path(__file__).parent.parent +RAW = "https://raw.githubusercontent.com/open-webui/open-webui/{ref}/backend/open_webui/{path}.py" + +# (stub file, upstream module path, owner class or None, function names) +CHECKS: list[tuple[str, str, str | None, list[str]]] = [ + ("models/config", "models/config", "Config", ["get"]), + ("models/users", "models/users", "UsersTable", ["get_user_by_id"]), + ("routers/images", "routers/images", None, ["get_image_config", "upload_image"]), +] + +Fn = ast.FunctionDef | ast.AsyncFunctionDef + + +def default_ref() -> str: + header = (ROOT / "comfier.py").read_text(encoding="utf-8") + match = re.search(r"^required_open_webui_version:\s*(\S+)", header, re.MULTILINE) + if not match: + sys.exit("Could not find required_open_webui_version in comfier.py") + return "v" + match.group(1).lstrip("v") + + +def fetch(ref: str, path: str) -> str: + url = RAW.format(ref=ref, path=path) + with urllib.request.urlopen(url) as resp: + return resp.read().decode("utf-8") + + +def find_fn(tree: ast.Module, owner: str | None, name: str) -> Fn | None: + body: list[ast.stmt] = tree.body + if owner is not None: + cls = next( + (n for n in body if isinstance(n, ast.ClassDef) and n.name == owner), None + ) + if cls is None: + return None + body = cls.body + return next( + ( + n + for n in body + if isinstance(n, ast.FunctionDef | ast.AsyncFunctionDef) and n.name == name + ), + None, + ) + + +def signature(fn: Fn, drop_self: bool) -> tuple[bool, list[tuple[str, str, bool]]]: + """(is_async, [(name, kind, has_default)])""" + a = fn.args + params: list[tuple[str, str, bool]] = [] + positional = [*a.posonlyargs, *a.args] + n_defaults = len(a.defaults) + for i, arg in enumerate(positional): + has_default = i >= len(positional) - n_defaults + kind = "posonly" if arg in a.posonlyargs else "pos" + params.append((arg.arg, kind, has_default)) + if a.vararg: + params.append((a.vararg.arg, "vararg", False)) + for arg, default in zip(a.kwonlyargs, a.kw_defaults, strict=True): + params.append((arg.arg, "kwonly", default is not None)) + if a.kwarg: + params.append((a.kwarg.arg, "kwarg", False)) + if drop_self and params and params[0][0] in ("self", "cls"): + params = params[1:] + return isinstance(fn, ast.AsyncFunctionDef), params + + +def check_users_singleton(upstream: ast.Module, stub: ast.Module) -> list[str]: + """`Users` must be an instance of UsersTable in both.""" + problems: list[str] = [] + real = any( + isinstance(n, ast.Assign) + and any(isinstance(t, ast.Name) and t.id == "Users" for t in n.targets) + and isinstance(n.value, ast.Call) + and isinstance(n.value.func, ast.Name) + and n.value.func.id == "UsersTable" + for n in upstream.body + ) + stubbed = any( + isinstance(n, ast.AnnAssign) + and isinstance(n.target, ast.Name) + and n.target.id == "Users" + and isinstance(n.annotation, ast.Name) + and n.annotation.id == "UsersTable" + for n in stub.body + ) + if not real: + problems.append("models/users: upstream no longer has `Users = UsersTable()`") + if not stubbed: + problems.append("models/users: stub lacks `Users: UsersTable`") + return problems + + +def is_static(fn: Fn) -> bool: + return any( + isinstance(d, ast.Name) and d.id == "staticmethod" for d in fn.decorator_list + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0]) + parser.add_argument( + "--ref", default=None, help="git ref/tag (default: from comfier.py)" + ) + args = parser.parse_args() + ref = args.ref or default_ref() + print(f"Checking stubs against open-webui @ {ref}") + + problems: list[str] = [] + for stub_path, upstream_path, owner, names in CHECKS: + stub_file = ROOT / "stubs" / "open_webui" / f"{stub_path}.pyi" + stub_tree = ast.parse(stub_file.read_text(encoding="utf-8")) + try: + upstream_tree = ast.parse(fetch(ref, upstream_path)) + except Exception as err: + problems.append(f"{upstream_path}: could not fetch/parse upstream ({err})") + continue + + for name in names: + label = f"{upstream_path}:{owner + '.' if owner else ''}{name}" + real_fn = find_fn(upstream_tree, owner, name) + stub_fn = find_fn(stub_tree, owner, name) + if real_fn is None: + problems.append(f"{label}: missing upstream") + continue + if stub_fn is None: + problems.append(f"{label}: missing in stub") + continue + real_sig = signature( + real_fn, drop_self=owner is not None and not is_static(real_fn) + ) + stub_sig = signature( + stub_fn, drop_self=owner is not None and not is_static(stub_fn) + ) + if real_sig != stub_sig: + problems.append( + f"{label}:\n upstream: {real_sig}\n stub: {stub_sig}" + ) + else: + print(f" ok {label}") + + if owner == "UsersTable": + problems += check_users_singleton(upstream_tree, stub_tree) + + if problems: + print("\nMismatches:") + for p in problems: + print(f" - {p}") + return 1 + print("All stubs match.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stubs/fastapi/__init__.pyi b/stubs/fastapi/__init__.pyi new file mode 100644 index 0000000..2eda7fc --- /dev/null +++ b/stubs/fastapi/__init__.pyi @@ -0,0 +1 @@ +class Request: ... diff --git a/stubs/open_webui/__init__.pyi b/stubs/open_webui/__init__.pyi new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/stubs/open_webui/__init__.pyi diff --git a/stubs/open_webui/models/__init__.pyi b/stubs/open_webui/models/__init__.pyi new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/stubs/open_webui/models/__init__.pyi diff --git a/stubs/open_webui/models/config.pyi b/stubs/open_webui/models/config.pyi new file mode 100644 index 0000000..ae951b2 --- /dev/null +++ b/stubs/open_webui/models/config.pyi @@ -0,0 +1,5 @@ +from typing import Any + +class Config: + @staticmethod + async def get(key: str, default: Any = None) -> Any: ... diff --git a/stubs/open_webui/models/users.pyi b/stubs/open_webui/models/users.pyi new file mode 100644 index 0000000..79d409e --- /dev/null +++ b/stubs/open_webui/models/users.pyi @@ -0,0 +1,11 @@ +from typing import Any + +class UserModel: + id: str + name: str + def __getattr__(self, name: str) -> Any: ... + +class UsersTable: + async def get_user_by_id(self, id: str, db: Any = None) -> UserModel | None: ... + +Users: UsersTable diff --git a/stubs/open_webui/routers/__init__.pyi b/stubs/open_webui/routers/__init__.pyi new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/stubs/open_webui/routers/__init__.pyi diff --git a/stubs/open_webui/routers/images.pyi b/stubs/open_webui/routers/images.pyi new file mode 100644 index 0000000..04c759f --- /dev/null +++ b/stubs/open_webui/routers/images.pyi @@ -0,0 +1,14 @@ +from types import SimpleNamespace +from typing import Any + +from fastapi import Request + +async def get_image_config() -> SimpleNamespace: ... +async def upload_image( + request: Request, + image_data: bytes, + content_type: str, + metadata: dict[str, Any], + user: Any, + db: Any = None, +) -> tuple[Any, dict[str, Any]]: ... diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/__init__.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7370405 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,144 @@ +import asyncio +import json +import pathlib +from collections.abc import AsyncIterator, Callable +from typing import Any + +import pytest +import pytest_asyncio +from aiohttp import WSMsgType, web + +from devkit import FakeEnv, install + +ROOT = pathlib.Path(__file__).parent.parent +PNG = bytes.fromhex( + "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489" + "0000000d49444154789c6360000002000001e221bc330000000049454e44ae426082" +) + + +# Make `import comfier` work at collection time; fixtures re-install per test. +install(FakeEnv(comfyui_url="", workflow="{}")) + + +class FakeComfy: + """Minimal in-process ComfyUI. `script` lists WS messages sent after /prompt.""" + + def __init__(self) -> None: + self.script: list[dict[str, Any]] = [] + self.submitted: list[dict[str, Any]] = [] + self.interrupts: list[Any] = [] + self.drop_ws = False + self.hang = False + self.url = "" + self._ws: web.WebSocketResponse | None = None + self._ws_ready = asyncio.Event() + + def success_script(self, prompt_id: str) -> list[dict[str, Any]]: + return [ + {"type": "executing", "data": {"node": "1", "prompt_id": prompt_id}}, + { + "type": "progress", + "data": {"value": 1, "max": 2, "prompt_id": prompt_id}, + }, + { + "type": "executed", + "data": { + "prompt_id": prompt_id, + "output": {"images": [{"filename": "a.png", "type": "output"}]}, + }, + }, + {"type": "executing", "data": {"node": None, "prompt_id": prompt_id}}, + ] + + async def ws(self, request: web.Request) -> web.WebSocketResponse: + ws = web.WebSocketResponse() + await ws.prepare(request) + self._ws = ws + self._ws_ready.set() + async for msg in ws: + if msg.type == WSMsgType.ERROR: + break + return ws + + async def prompt(self, request: web.Request) -> web.Response: + body = await request.json() + self.submitted.append(body["prompt"]) + pid = "pid-1" + await self._ws_ready.wait() + assert self._ws is not None + ws = self._ws + asyncio.get_running_loop().call_soon( + lambda: asyncio.ensure_future(self._play(ws, pid)) + ) + return web.json_response({"prompt_id": pid}) + + async def _play(self, ws: web.WebSocketResponse, pid: str) -> None: + if self.hang: + return + if self.drop_ws: + await ws.close() + return + for msg in self.script or self.success_script(pid): + await ws.send_str(json.dumps(msg)) + + async def queue(self, request: web.Request) -> web.Response: + if request.method == "POST": + return web.json_response({}) + return web.json_response({"queue_running": [[0, "pid-1"]], "queue_pending": []}) + + async def interrupt(self, request: web.Request) -> web.Response: + self.interrupts.append(await request.json()) + return web.json_response({}) + + async def history(self, request: web.Request) -> web.Response: + return web.json_response({}) + + async def view(self, request: web.Request) -> web.Response: + return web.Response(body=PNG, content_type="image/png") + + +@pytest_asyncio.fixture +async def comfy() -> AsyncIterator[FakeComfy]: + fake = FakeComfy() + app = web.Application() + app.add_routes( + [ + web.get("/ws", fake.ws), + web.post("/prompt", fake.prompt), + web.get("/queue", fake.queue), + web.post("/queue", fake.queue), + web.post("/interrupt", fake.interrupt), + web.get("/history/{id}", fake.history), + web.get("/view", fake.view), + ] + ) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + port = site._server.sockets[0].getsockname()[1] # type: ignore[union-attr] + fake.url = f"http://127.0.0.1:{port}" + yield fake + await runner.cleanup() + + +@pytest.fixture +def make_tools( + tmp_path: pathlib.Path, comfy: FakeComfy +) -> Callable[[], tuple[Any, FakeEnv]]: + def factory() -> tuple[Any, FakeEnv]: + env = FakeEnv( + comfyui_url=comfy.url, + workflow=(ROOT / "fixtures" / "workflow.json").read_text(encoding="utf-8"), + nodes=json.loads( + (ROOT / "fixtures" / "nodes.json").read_text(encoding="utf-8") + ), + output_dir=tmp_path, + ) + install(env) + from comfier import Tools + + return Tools(), env + + return factory diff --git a/tests/test_generate.py b/tests/test_generate.py new file mode 100644 index 0000000..37dda32 --- /dev/null +++ b/tests/test_generate.py @@ -0,0 +1,93 @@ +import asyncio +from collections.abc import Callable +from types import SimpleNamespace +from typing import Any + +import pytest + +from tests.conftest import FakeComfy + + +async def _run(tools: Any, **kw: Any) -> tuple[dict[str, Any], list[dict[str, Any]]]: + events: list[dict[str, Any]] = [] + + async def emit(e: dict[str, Any]) -> None: + events.append(e) + + result = await tools.comfier_generate_image( + positive_prompt="a marble", + seed="5", + size="64x64", + __request__=SimpleNamespace(), + __user__={"id": "u"}, + __event_emitter__=emit, + **kw, + ) + return result, events + + +async def test_success(make_tools: Callable[[], Any], comfy: FakeComfy) -> None: + tools, env = make_tools() + result, events = await _run(tools) + assert result["status"] == "success" + assert len(result["images"]) == 1 and len(env.stored) == 1 + assert comfy.submitted[0]["520:517"]["inputs"]["seed"] == 5 + assert comfy.submitted[0]["520:518"]["inputs"]["value"] == 64 + assert events[-1]["type"] == "files" + + +async def test_execution_error(make_tools: Callable[[], Any], comfy: FakeComfy) -> None: + comfy.script = [ + { + "type": "execution_error", + "data": {"prompt_id": "pid-1", "exception_message": "boom"}, + } + ] + tools, _ = make_tools() + with pytest.raises(RuntimeError, match="boom"): + await _run(tools) + + +async def test_ws_drop_retry_declined( + make_tools: Callable[[], Any], comfy: FakeComfy +) -> None: + comfy.drop_ws = True + tools, _ = make_tools() + asked: list[dict[str, Any]] = [] + + async def call(e: dict[str, Any]) -> bool: + asked.append(e) + return False + + with pytest.raises(ConnectionError): + await _run(tools, __event_call__=call) + assert len(asked) == 1 + + +async def test_ws_drop_retry_accepted( + make_tools: Callable[[], Any], comfy: FakeComfy +) -> None: + comfy.drop_ws = True + tools, _ = make_tools() + + async def call(e: dict[str, Any]) -> bool: + comfy.drop_ws = False + return True + + result, _ = await _run(tools, __event_call__=call) + assert result["status"] == "success" + + +async def test_cancel_interrupts_backend( + make_tools: Callable[[], Any], comfy: FakeComfy +) -> None: + comfy.hang = True + tools, _ = make_tools() + task = asyncio.create_task(_run(tools)) + while not comfy.submitted: + await asyncio.sleep(0.05) + await asyncio.sleep(0.1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert comfy.interrupts == [{"prompt_id": "pid-1"}] diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..0c0f875 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,59 @@ +import json +import pathlib + +import pytest + +from comfier import Tools + +ROOT = pathlib.Path(__file__).parent.parent + + +@pytest.fixture +def tools(make_tools): # type: ignore[no-untyped-def] + return make_tools()[0] + + +def test_normalize_seed(tools: Tools) -> None: + assert tools._normalize_seed("42") == 42 + assert tools._normalize_seed("0x10") == 16 + assert tools._normalize_seed(7) == 7 + assert 0 <= tools._normalize_seed(None) < 1 << 63 + assert 0 <= tools._normalize_seed(-5) < 1 << 63 + assert tools._normalize_seed((1 << 63) + 3) == 3 + with pytest.raises(ValueError): + tools._normalize_seed("abc") + + +async def test_resolve_dimensions(tools: Tools) -> None: + assert await tools._resolve_dimensions("512X768") == (512, 768) + assert await tools._resolve_dimensions(None) == (1024, 1024) + with pytest.raises(ValueError): + await tools._resolve_dimensions("0x10") + with pytest.raises(ValueError): + await tools._resolve_dimensions("bogus") + + +def test_resolve_node_mappings(tools: Tools) -> None: + tools.valves.negative_prompt_node_ids = "1, 2,," + nodes = json.loads((ROOT / "fixtures" / "nodes.json").read_text()) + m = tools._resolve_node_mappings(nodes) + assert m["negative_prompt"]["node_ids"] == ["1", "2"] + assert m["positive_prompt"] == {"key": "value", "node_ids": ["520:506"]} + assert m["steps"]["node_ids"] == ["459:458"] + + +def test_apply_workflow_parameters(tools: Tools) -> None: + wf = json.loads((ROOT / "fixtures" / "workflow.json").read_text()) + m = tools._resolve_node_mappings( + json.loads((ROOT / "fixtures" / "nodes.json").read_text()) + ) + applied = tools._apply_workflow_parameters( + wf, m, {"seed": 9, "width": 64, "steps": None} + ) + assert wf["520:517"]["inputs"]["seed"] == 9 + assert wf["520:518"]["inputs"]["value"] == 64 + assert "steps" not in applied + with pytest.raises(KeyError): + tools._apply_workflow_parameters( + wf, {"seed": {"key": "seed", "node_ids": ["nope"]}}, {"seed": 1} + ) |
