diff --git a/py/autotag.py b/py/autotag.py index ab426af8..ae908bba 100644 --- a/py/autotag.py +++ b/py/autotag.py @@ -8,6 +8,7 @@ Refactored from standalone_tagger.py for integration with PromptManager API. import errno import gc import os +import sys from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple from PIL import Image @@ -705,14 +706,11 @@ class AutoTagService: self._current_model_type = None gc.collect() - # Try to clear CUDA cache if available - try: - import torch - - if torch.cuda.is_available(): - torch.cuda.empty_cache() - except ImportError: - pass + # Clear the CUDA cache only if torch is already loaded; importing + # it here would pull in a heavy dependency the backend never used. + torch = sys.modules.get("torch") + if torch is not None and torch.cuda.is_available(): + torch.cuda.empty_cache() self.logger.info("Model unloaded, memory freed") diff --git a/tests/test_api_autotag.py b/tests/test_api_autotag.py index fb05f546..31ef94e6 100644 --- a/tests/test_api_autotag.py +++ b/tests/test_api_autotag.py @@ -532,5 +532,298 @@ class TestAutotagBatch(AutotagAPITestCase): self.assertEqual(self.service.unload_calls, 1) +class TestAutotagModelsAndDownloadErrors(AutotagAPITestCase): + + async def test_models_error_is_500(self): + def boom(): + raise RuntimeError("/srv/models unreadable") + + self.service.get_models_status = boom + resp = await self.client.request("GET", "/prompt_manager/autotag/models") + self.assertEqual(resp.status, 500) + self.assertFalse((await resp.json())["success"]) + + async def test_download_invalid_model_type_is_error_event(self): + resp = await self.client.request( + "POST", "/prompt_manager/autotag/download/nope" + ) + events = self._sse_events(await resp.text()) + self.assertEqual(events[-1]["type"], "error") + self.assertEqual(self.service.download_calls, []) + + async def test_download_failure_is_error_event(self): + self.service.download_result = False + resp = await self.client.request( + "POST", "/prompt_manager/autotag/download/gguf" + ) + events = self._sse_events(await resp.text()) + self.assertEqual(events[-1], {"type": "error", "message": "Download failed"}) + + async def test_download_exception_is_generic_error_event(self): + def boom(model_type, progress_callback=None): + raise OSError("/srv/models is full") + + self.service.download_model = boom + resp = await self.client.request( + "POST", "/prompt_manager/autotag/download/gguf" + ) + events = self._sse_events(await resp.text()) + self.assertEqual(events[-1]["type"], "error") + self.assertNotIn("/srv/models", events[-1]["message"]) + + +class TestAutotagSingleEdgeCases(AutotagAPITestCase): + + async def test_missing_file_error_hides_the_server_path(self): + import errno + + image = self._make_image(self.output_dir) + self.service.generate_error = FileNotFoundError( + errno.ENOENT, "Image not found", str(image) + ) + + resp = await self._post_json( + "/prompt_manager/autotag/single", {"path": str(image)} + ) + + self.assertEqual(resp.status, 500) + data = await resp.json() + self.assertFalse(data["success"]) + self.assertNotIn(str(self.output_dir), data["error"]) + self.assertIn("img.png", data["error"]) + + async def test_generic_error_is_500(self): + image = self._make_image(self.output_dir) + self.service.generate_error = RuntimeError("model crashed") + resp = await self._post_json( + "/prompt_manager/autotag/single", {"path": str(image)} + ) + self.assertEqual(resp.status, 500) + self.assertEqual((await resp.json())["error"], "model crashed") + + async def test_oserror_without_filename_uses_strerror(self): + from py.api.autotag_routes import _public_error + + self.assertEqual(_public_error(OSError(5, "I/O error")), "I/O error") + self.assertEqual(_public_error(ValueError("plain")), "plain") + + async def test_path_with_nul_byte_is_forbidden(self): + from py.api.autotag_routes import path_is_within + + self.assertFalse(path_is_within("bad\x00name.png", self.output_dir)) + resp = await self._post_json( + "/prompt_manager/autotag/single", {"path": str(self.output_dir / "a\x00b")} + ) + self.assertEqual(resp.status, 403) + + async def test_boolean_image_id_is_400(self): + resp = await self._post_json( + "/prompt_manager/autotag/single", {"image_id": True} + ) + self.assertEqual(resp.status, 400) + + async def test_numeric_string_image_id_is_accepted(self): + prompt_id = self._save_prompt() + image_id = self._link_image(prompt_id, self._make_image(self.output_dir)) + resp = await self._post_json( + "/prompt_manager/autotag/single", {"image_id": str(image_id)} + ) + self.assertEqual(resp.status, 200) + + async def test_non_object_body_is_400(self): + resp = await self._post_json("/prompt_manager/autotag/single", [1, 2]) + self.assertEqual(resp.status, 400) + + async def test_invalid_json_is_400(self): + resp = await self.client.request( + "POST", + "/prompt_manager/autotag/single", + data="{nope", + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(resp.status, 400) + + async def test_non_numeric_threshold_is_400(self): + image = self._make_image(self.output_dir) + resp = await self._post_json( + "/prompt_manager/autotag/single", + {"path": str(image), "general_threshold": "high"}, + ) + self.assertEqual(resp.status, 400) + + async def test_prompt_lookup_failure_still_returns_tags(self): + image = self._make_image(self.output_dir) + with patch.object( + self.api.db, "get_prompt_id_for_image", side_effect=RuntimeError("db") + ): + resp = await self._post_json( + "/prompt_manager/autotag/single", {"path": str(image)} + ) + data = await resp.json() + self.assertEqual(resp.status, 200) + self.assertIsNone(data["prompt_id"]) + + async def test_prompt_is_found_via_the_real_path(self): + prompt_id = self._save_prompt() + image = self._make_image(self.output_dir) + self._link_image(prompt_id, image) + dotted = os.path.join(str(self.output_dir), ".", "img.png") + + resp = await self._post_json("/prompt_manager/autotag/single", {"path": dotted}) + + self.assertEqual((await resp.json())["prompt_id"], prompt_id) + + async def test_custom_prompt_and_model_switch(self): + image = self._make_image(self.output_dir) + self.service._loaded_type = "gguf" + + resp = await self._post_json( + "/prompt_manager/autotag/single", + {"path": str(image), "model_type": "wd14-vit", "prompt": "terse"}, + ) + + self.assertEqual(resp.status, 200) + self.assertEqual(self.service.load_calls, [("wd14-vit", True)]) + self.assertEqual(self.service.custom_prompt, "terse") + + async def test_image_id_without_stored_path_is_404(self): + prompt_id = self._save_prompt() + image_id = self._link_image(prompt_id, self._make_image(self.output_dir)) + with patch.object( + self.api.db, + "get_image_by_id", + return_value={"id": image_id, "image_path": ""}, + ): + resp = await self._post_json( + "/prompt_manager/autotag/single", {"image_id": image_id} + ) + self.assertEqual(resp.status, 404) + + +class TestApplyAutotag(AutotagAPITestCase): + + async def test_missing_prompt_id_is_400(self): + resp = await self._post_json("/prompt_manager/autotag/apply", {"tags": ["a"]}) + self.assertEqual(resp.status, 400) + + async def test_no_tags_is_a_noop(self): + resp = await self._post_json("/prompt_manager/autotag/apply", {"prompt_id": 1}) + data = await resp.json() + self.assertTrue(data["success"]) + self.assertEqual(data["message"], "No tags to apply") + + async def test_unknown_prompt_is_404(self): + resp = await self._post_json( + "/prompt_manager/autotag/apply", {"prompt_id": 999, "tags": ["a"]} + ) + self.assertEqual(resp.status, 404) + + async def test_tags_are_merged_without_duplicates(self): + prompt_id = self._save_prompt(tags=["cat"]) + + resp = await self._post_json( + "/prompt_manager/autotag/apply", + {"prompt_id": prompt_id, "tags": ["cat", "dog"]}, + ) + + data = await resp.json() + self.assertEqual(data["added_tags"], ["dog"]) + self.assertEqual(data["total_tags"], 2) + self.assertEqual( + self.api.db.get_prompt_by_id(prompt_id)["tags"], ["cat", "dog"] + ) + + async def test_string_tags_from_db_are_split(self): + prompt_id = self._save_prompt() + with patch.object( + self.api.db, + "get_prompt_by_id", + return_value={"id": prompt_id, "tags": "cat, dog"}, + ): + resp = await self._post_json( + "/prompt_manager/autotag/apply", + {"prompt_id": prompt_id, "tags": ["dog", "owl"]}, + ) + data = await resp.json() + self.assertEqual(data["added_tags"], ["owl"]) + self.assertEqual(data["total_tags"], 3) + + async def test_db_error_is_500(self): + with patch.object( + self.api.db, "get_prompt_by_id", side_effect=RuntimeError("db") + ): + resp = await self._post_json( + "/prompt_manager/autotag/apply", {"prompt_id": 1, "tags": ["a"]} + ) + self.assertEqual(resp.status, 500) + + +class TestUnloadAutotag(AutotagAPITestCase): + + async def test_nothing_loaded(self): + resp = await self.client.request("POST", "/prompt_manager/autotag/unload") + data = await resp.json() + self.assertTrue(data["success"]) + self.assertEqual(data["message"], "No model was loaded") + + async def test_unloads_loaded_model(self): + self.service._loaded_type = "gguf" + resp = await self.client.request("POST", "/prompt_manager/autotag/unload") + data = await resp.json() + self.assertEqual(data["message"], "GGUF model unloaded successfully") + self.assertFalse(data["model_loaded"]) + self.assertEqual(self.service.unload_calls, 1) + + async def test_error_is_500(self): + def boom(): + raise RuntimeError("x") + + self.service.is_model_loaded = boom + resp = await self.client.request("POST", "/prompt_manager/autotag/unload") + self.assertEqual(resp.status, 500) + + +class TestScanOutputDir(AutotagAPITestCase): + + async def test_lists_images_with_thumbnails(self): + sub = self.output_dir / "sub" + sub.mkdir() + self._make_image(self.output_dir, "a.png") + self._make_image(sub, "b.JPG") + thumbs = self.output_dir / "thumbnails" / "sub" + thumbs.mkdir(parents=True) + (thumbs / "b_thumb.JPG").write_bytes(b"t") + self._make_image(self.output_dir / "thumbnails", "ignored.png") + self.api._find_comfyui_output_dir = lambda: str(self.output_dir) + + resp = await self.client.request("GET", "/prompt_manager/scan_output_dir") + + data = await resp.json() + self.assertTrue(data["success"]) + self.assertEqual([i["filename"] for i in data["images"]], ["a.png", "b.JPG"]) + self.assertEqual(data["count"], 2) + self.assertIsNone(data["images"][0]["thumbnail_url"]) + self.assertEqual( + data["images"][1]["thumbnail_url"], + "/prompt_manager/images/serve/thumbnails/sub/b_thumb.JPG", + ) + self.assertEqual( + data["images"][1]["url"], "/prompt_manager/images/serve/sub/b.JPG" + ) + + async def test_missing_output_dir_is_404(self): + self.api._find_comfyui_output_dir = lambda: None + resp = await self.client.request("GET", "/prompt_manager/scan_output_dir") + self.assertEqual(resp.status, 404) + + async def test_error_is_500(self): + def boom(): + raise RuntimeError("x") + + self.api._find_comfyui_output_dir = boom + resp = await self.client.request("GET", "/prompt_manager/scan_output_dir") + self.assertEqual(resp.status, 500) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_api_lora.py b/tests/test_api_lora.py index 90a74b23..2c170e88 100644 --- a/tests/test_api_lora.py +++ b/tests/test_api_lora.py @@ -34,8 +34,10 @@ from aiohttp.test_utils import AioHTTPTestCase from database.operations import PromptDatabase from py.api import PromptManagerAPI -from py.config import IntegrationConfig +from py import lora_utils +from py.config import IntegrationConfig, PromptManagerConfig from py.lora_utils import get_trigger_cache +from utils.hashing import generate_prompt_hash SECRET_KEY = "sk-civitai-super-secret-0123456789" @@ -60,6 +62,8 @@ class LoraAPITestCase(AioHTTPTestCase): "py.lora_utils.detect_lora_manager", return_value=None ) self.detect_mock = self._detect_patch.start() + self._save_patch = patch.object(PromptManagerConfig, "save_to_file") + self.save_mock = self._save_patch.start() get_trigger_cache().clear() app = web.Application() @@ -71,6 +75,7 @@ class LoraAPITestCase(AioHTTPTestCase): return app async def tearDownAsync(self): + self._save_patch.stop() self._detect_patch.stop() self._config_patch.stop() get_trigger_cache().clear() @@ -115,5 +120,338 @@ class TestLoraStatusNeverReturnsKey(LoraAPITestCase): self.assertNotIn("civitai_api_key", data) +def _png_bytes(): + from io import BytesIO + + from PIL import Image + + buf = BytesIO() + Image.new("RGB", (2, 2), (1, 2, 3)).save(buf, "PNG") + return buf.getvalue() + + +class _FakeResponse: + def __init__(self, body): + self._body = body + self._pos = 0 + self.headers = {} + + def read(self, amt=-1): + if amt is None or amt < 0: + amt = len(self._body) + chunk = self._body[self._pos : self._pos + amt] + self._pos += len(chunk) + return chunk + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +def _make_lora_manager(where): + lm = where / "ComfyUI-Lora-Manager" + (lm / "py").mkdir(parents=True) + (lm / "__init__.py").write_text("") + return lm + + +def _write_metadata(directory, stem, words=None, images=None, prompt=None): + meta = {"file_name": f"{stem}.safetensors", "model_name": stem, "civitai": {}} + if words is not None: + meta["civitai"]["trainedWords"] = words + if images is not None: + meta["civitai"]["images"] = [ + {"url": url, "meta": {"prompt": prompt} if prompt else {}} for url in images + ] + path = directory / f"{stem}.safetensors.metadata.json" + path.write_text(json.dumps(meta)) + return path + + +class TestLoraDetectRoute(LoraAPITestCase): + + async def test_detected(self): + self.detect_mock.return_value = str(self.tmp_root) + resp = await self.client.request("GET", "/prompt_manager/lora/detect") + data = await resp.json() + self.assertTrue(data["success"]) + self.assertTrue(data["detected"]) + self.assertEqual(data["path"], str(self.tmp_root)) + + async def test_not_detected(self): + resp = await self.client.request("GET", "/prompt_manager/lora/detect") + data = await resp.json() + self.assertFalse(data["detected"]) + self.assertEqual(data["path"], "") + + async def test_detection_error_is_500(self): + self.detect_mock.side_effect = RuntimeError("disk gone") + resp = await self.client.request("GET", "/prompt_manager/lora/detect") + self.assertEqual(resp.status, 500) + self.assertFalse((await resp.json())["success"]) + + +class TestLoraStatusRoute(LoraAPITestCase): + + async def test_status_reports_detection_and_cache(self): + self.detect_mock.return_value = str(self.tmp_root) + IntegrationConfig.LORA_MANAGER_ENABLED = True + IntegrationConfig.LORA_MANAGER_PATH = str(self.tmp_root) + + data = await ( + await self.client.request("GET", "/prompt_manager/lora/status") + ).json() + + self.assertTrue(data["enabled"]) + self.assertTrue(data["detected"]) + self.assertEqual(data["detected_path"], str(self.tmp_root)) + self.assertFalse(data["trigger_cache_loaded"]) + + async def test_status_error_is_500(self): + with patch("py.lora_utils.get_trigger_cache", side_effect=RuntimeError("x")): + resp = await self.client.request("GET", "/prompt_manager/lora/status") + self.assertEqual(resp.status, 500) + + +class TestLoraEnableRoute(LoraAPITestCase): + + async def test_enable_with_unknown_path_is_400(self): + resp = await self._post_json( + "/prompt_manager/lora/enable", {"enabled": True, "path": "/nowhere"} + ) + self.assertEqual(resp.status, 400) + self.assertFalse(IntegrationConfig.LORA_MANAGER_ENABLED) + self.save_mock.assert_not_called() + + async def test_enable_persists_and_loads_trigger_cache(self): + lm = _make_lora_manager(self.tmp_root) + loras = self.tmp_root / "loras" + loras.mkdir() + _write_metadata(loras, "neon", words=["glow"]) + self.detect_mock.return_value = str(lm) + + with patch.object( + lora_utils, "find_lora_directories", return_value=[str(loras)] + ): + resp = await self._post_json( + "/prompt_manager/lora/enable", + { + "enabled": True, + "path": str(lm), + "trigger_words_enabled": True, + "civitai_api_key": SECRET_KEY, + }, + ) + + self.assertEqual(resp.status, 200) + body = await resp.text() + data = json.loads(body) + self.assertTrue(data["success"]) + self.assertEqual(data["path"], str(lm)) + self.assertNotIn(SECRET_KEY, body) + self.assertTrue(IntegrationConfig.LORA_MANAGER_ENABLED) + self.assertEqual(IntegrationConfig.CIVITAI_API_KEY, SECRET_KEY) + self.save_mock.assert_called_once() + self.assertTrue(get_trigger_cache().is_loaded) + self.assertEqual(get_trigger_cache().get_trigger_words("neon"), ["glow"]) + + async def test_disable_clears_cache(self): + get_trigger_cache()._cache = {"x": ["y"]} + get_trigger_cache()._loaded = True + + resp = await self._post_json("/prompt_manager/lora/enable", {"enabled": False}) + + self.assertEqual(resp.status, 200) + self.assertFalse(get_trigger_cache().is_loaded) + self.assertFalse(IntegrationConfig.LORA_MANAGER_ENABLED) + + async def test_persist_failure_is_500(self): + self.save_mock.side_effect = OSError("read-only") + resp = await self._post_json("/prompt_manager/lora/enable", {"enabled": False}) + self.assertEqual(resp.status, 500) + + +class TestLoraScanRoute(LoraAPITestCase): + + def _enable(self): + IntegrationConfig.LORA_MANAGER_ENABLED = True + IntegrationConfig.LORA_MANAGER_PATH = str(self.tmp_root) + IntegrationConfig.CIVITAI_API_KEY = SECRET_KEY + + @staticmethod + def _sse_events(text): + return [ + json.loads(line[len("data: ") :]) + for line in text.splitlines() + if line.startswith("data: ") + ] + + async def _scan(self, loras): + opener_calls = [] + + def fake_open(req, timeout=None): + opener_calls.append(req) + return _FakeResponse(_png_bytes()) + + cache_dir = self.tmp_root / "img_cache" + with ( + patch.object( + lora_utils, "find_lora_directories", return_value=[str(loras)] + ), + patch.object( + lora_utils, "get_lora_image_cache_dir", return_value=cache_dir + ), + patch.object(lora_utils, "_open_url", fake_open), + ): + resp = await self.client.request("POST", "/prompt_manager/lora/scan") + text = await resp.text() + return resp, self._sse_events(text), opener_calls + + async def test_not_enabled_is_400(self): + resp = await self.client.request("POST", "/prompt_manager/lora/scan") + self.assertEqual(resp.status, 400) + + async def test_no_path_is_400(self): + IntegrationConfig.LORA_MANAGER_ENABLED = True + resp = await self.client.request("POST", "/prompt_manager/lora/scan") + self.assertEqual(resp.status, 400) + + async def test_scan_imports_loras_with_images_and_links_existing(self): + self._enable() + loras = self.tmp_root / "loras" + loras.mkdir() + _write_metadata( + loras, + "neon", + words=["glow"], + images=["https://civitai.com/a.png", "https://evil.example/b.png"], + prompt="neon street", + ) + (loras / "neon.png").write_bytes(_png_bytes()) + _write_metadata(loras, "plain") + (loras / "broken.metadata.json").write_text("{{{") + existing_id = self.api.db.save_prompt( + text="plain", + category=None, + tags=[], + rating=None, + prompt_hash=generate_prompt_hash("plain"), + ) + + resp, events, opener_calls = await self._scan(loras) + + self.assertEqual(resp.status, 200) + done = events[-1] + self.assertEqual(done["type"], "complete") + self.assertEqual(done["total"], 3) + self.assertEqual(done["imported"], 1) + self.assertEqual(done["skipped"], 2) + self.assertEqual( + [r.full_url for r in opener_calls], ["https://civitai.com/a.png"] + ) + self.assertEqual( + opener_calls[0].get_header("Authorization"), f"Bearer {SECRET_KEY}" + ) + imported = self.api.db.search_prompts(category="lora-manager") + self.assertEqual(len(imported), 1) + self.assertEqual(imported[0]["text"], "neon street") + self.assertIn("lora:neon", imported[0]["tags"]) + self.assertIn("glow", imported[0]["tags"]) + self.assertEqual(len(self.api.db.get_prompt_images(imported[0]["id"])), 2) + self.assertEqual(len(self.api.db.get_prompt_images(existing_id)), 0) + + async def test_save_failure_is_counted_as_skipped(self): + self._enable() + loras = self.tmp_root / "loras" + loras.mkdir() + _write_metadata(loras, "neon", words=["glow"]) + + with patch.object(self.api.db, "save_prompt", side_effect=RuntimeError("db")): + resp, events, _ = await self._scan(loras) + + self.assertEqual(events[-1]["imported"], 0) + self.assertEqual(events[-1]["skipped"], 1) + + +class TestLoraTriggerWordRoutes(LoraAPITestCase): + + async def test_trigger_words_not_enabled_is_400(self): + resp = await self.client.request( + "GET", "/prompt_manager/lora/trigger-words?name=x" + ) + self.assertEqual(resp.status, 400) + + async def test_trigger_words_missing_name_is_400(self): + IntegrationConfig.LORA_MANAGER_ENABLED = True + resp = await self.client.request("GET", "/prompt_manager/lora/trigger-words") + self.assertEqual(resp.status, 400) + + async def test_trigger_words_loads_cache_on_demand(self): + IntegrationConfig.LORA_MANAGER_ENABLED = True + IntegrationConfig.LORA_MANAGER_PATH = str(self.tmp_root) + loras = self.tmp_root / "loras" + loras.mkdir() + _write_metadata(loras, "neon", words=["glow", "bright"]) + + with patch.object( + lora_utils, "find_lora_directories", return_value=[str(loras)] + ): + resp = await self.client.request( + "GET", "/prompt_manager/lora/trigger-words?name=NEON" + ) + + data = await resp.json() + self.assertTrue(data["success"]) + self.assertEqual(data["trigger_words"], ["glow", "bright"]) + self.assertTrue(get_trigger_cache().is_loaded) + + async def test_trigger_words_error_is_500(self): + IntegrationConfig.LORA_MANAGER_ENABLED = True + with patch("py.lora_utils.get_trigger_cache", side_effect=RuntimeError("x")): + resp = await self.client.request( + "GET", "/prompt_manager/lora/trigger-words?name=x" + ) + self.assertEqual(resp.status, 500) + + async def test_refresh_not_enabled_is_400(self): + resp = await self.client.request("POST", "/prompt_manager/lora/refresh-cache") + self.assertEqual(resp.status, 400) + + async def test_refresh_without_path_is_400(self): + IntegrationConfig.LORA_MANAGER_ENABLED = True + resp = await self.client.request("POST", "/prompt_manager/lora/refresh-cache") + self.assertEqual(resp.status, 400) + + async def test_refresh_reloads_cache(self): + IntegrationConfig.LORA_MANAGER_ENABLED = True + IntegrationConfig.LORA_MANAGER_PATH = str(self.tmp_root) + loras = self.tmp_root / "loras" + loras.mkdir() + _write_metadata(loras, "neon", words=["glow"]) + + with patch.object( + lora_utils, "find_lora_directories", return_value=[str(loras)] + ): + resp = await self.client.request( + "POST", "/prompt_manager/lora/refresh-cache" + ) + + data = await resp.json() + self.assertTrue(data["success"]) + # keyed by both the file_name stem and the metadata-file stem + self.assertEqual(data["loras_with_trigger_words"], 2) + + async def test_refresh_error_is_500(self): + IntegrationConfig.LORA_MANAGER_ENABLED = True + IntegrationConfig.LORA_MANAGER_PATH = str(self.tmp_root) + with patch("py.lora_utils.get_trigger_cache", side_effect=RuntimeError("x")): + resp = await self.client.request( + "POST", "/prompt_manager/lora/refresh-cache" + ) + self.assertEqual(resp.status, 500) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_autotag_engine.py b/tests/test_autotag_engine.py index 38904581..ddc2c957 100644 --- a/tests/test_autotag_engine.py +++ b/tests/test_autotag_engine.py @@ -4,20 +4,47 @@ No real ML model is loaded; ``model_loader`` returns small fakes that mimic the onnxruntime session (WD14) and the llama.cpp chat model (GGUF). """ +import contextlib +import csv import os import shutil import sys import tempfile +import types import unittest from pathlib import Path +from unittest.mock import MagicMock, patch sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from PIL import Image +from py import autotag from py.autotag import MODELS, AutoTagService +@contextlib.contextmanager +def _patch_modules(mapping): + """Temporarily set entries of sys.modules, restoring only those keys. + + unittest.mock.patch.dict restores by clearing the whole dict, which evicts + any heavy module first imported inside the window (torch cannot be + re-imported in-process), so this patcher touches only the named keys. + """ + missing = object() + saved = {name: sys.modules.get(name, missing) for name in mapping} + try: + for name, module in mapping.items(): + sys.modules[name] = module + yield + finally: + for name, previous in saved.items(): + if previous is missing: + sys.modules.pop(name, None) + else: + sys.modules[name] = previous + + class FakeInput: def __init__(self, name="input", shape=(1, 8, 8, 3)): self.name = name @@ -327,5 +354,363 @@ class TestModelsStatus(EngineTestCase): ) +# ── Model directory resolution and singleton ────────────────────────── + + +class TestModelsDirResolution(unittest.TestCase): + + def test_uses_folder_paths_models_dir(self): + fake = types.ModuleType("folder_paths") + fake.models_dir = os.path.join(tempfile.gettempdir(), "fp_models") + with _patch_modules({"folder_paths": fake}): + service = AutoTagService() + self.assertEqual(service.models_dir, Path(fake.models_dir) / "LLM") + + def test_falls_back_without_folder_paths(self): + with _patch_modules({"folder_paths": None}): + service = AutoTagService() + self.assertEqual(service.models_dir.name, "LLM") + + def test_singleton_is_created_once(self): + with patch.object(autotag, "_service_instance", None): + first = autotag.get_autotag_service() + second = autotag.get_autotag_service() + self.assertIs(first, second) + self.assertIsInstance(first, AutoTagService) + + +# ── HuggingFace cache lookup ────────────────────────────────────────── + + +def _fake_hub(snapshot_dir=None, repo_id=None, raise_scan=False): + hub = types.ModuleType("huggingface_hub") + hub.HFCacheInfo = object + + def scan_cache_dir(): + if raise_scan: + raise OSError("no cache") + revision = types.SimpleNamespace(snapshot_path=str(snapshot_dir)) + repo = types.SimpleNamespace( + repo_id=repo_id, repo_type="model", revisions=[revision] + ) + other = types.SimpleNamespace( + repo_id="someone/else", repo_type="dataset", revisions=[] + ) + return types.SimpleNamespace(repos=[other, repo]) + + hub.scan_cache_dir = scan_cache_dir + return hub + + +class TestHfCacheLookup(EngineTestCase): + + def test_cache_snapshot_with_config_is_found(self): + snapshot = self.tmp / "snap" + snapshot.mkdir() + (snapshot / "config.json").write_text("{}") + hub = _fake_hub(snapshot, MODELS["hf"]["repo"]) + service = AutoTagService(models_dir=self.models_dir) + + with _patch_modules({"huggingface_hub": hub}): + self.assertTrue(service._check_hf_model()) + status = service.get_models_status() + + self.assertTrue(status["hf"]["downloaded"]) + self.assertEqual(Path(status["hf"]["model_path"]), snapshot) + + def test_snapshot_without_config_is_ignored(self): + snapshot = self.tmp / "snap" + snapshot.mkdir() + hub = _fake_hub(snapshot, MODELS["hf"]["repo"]) + service = AutoTagService(models_dir=self.models_dir) + with _patch_modules({"huggingface_hub": hub}): + self.assertIsNone(service._get_hf_model_path()) + + def test_scan_errors_and_missing_hub_give_none(self): + service = AutoTagService(models_dir=self.models_dir) + with _patch_modules({"huggingface_hub": _fake_hub(raise_scan=True)}): + self.assertIsNone(service._get_hf_cache_path("x/y")) + with _patch_modules({"huggingface_hub": None}): + self.assertIsNone(service._get_hf_cache_path("x/y")) + + +# ── Downloads (hub patched, no network) ─────────────────────────────── + + +class _FakeDownloadHub: + """huggingface_hub stand-in that materialises files instead of fetching.""" + + def __init__(self): + self.module = types.ModuleType("huggingface_hub") + self.module.hf_hub_download = self.hf_hub_download + self.module.snapshot_download = self.snapshot_download + self.calls = [] + + def hf_hub_download(self, repo_id, filename, local_dir, **kwargs): + self.calls.append(("file", repo_id, filename)) + Path(local_dir).mkdir(parents=True, exist_ok=True) + (Path(local_dir) / filename).write_bytes(b"model") + return str(Path(local_dir) / filename) + + def snapshot_download(self, repo_id, local_dir, **kwargs): + self.calls.append(("snapshot", repo_id)) + Path(local_dir).mkdir(parents=True, exist_ok=True) + (Path(local_dir) / "config.json").write_text("{}") + return local_dir + + +class TestDownloadModel(EngineTestCase): + + def setUp(self): + super().setUp() + self.hub = _FakeDownloadHub() + self.service = AutoTagService(models_dir=self.models_dir) + self.progress = [] + + def _cb(self, status, progress): + self.progress.append((status, progress)) + + def test_invalid_type_raises(self): + with self.assertRaises(ValueError): + self.service.download_model("nope") + + def test_missing_hub_reports_failure(self): + with _patch_modules({"huggingface_hub": None}): + self.assertFalse(self.service.download_model("gguf", self._cb)) + self.assertEqual(self.progress[-1][1], 0) + self.assertIn("huggingface_hub", self.progress[-1][0]) + + def test_wd14_downloads_each_missing_file(self): + with _patch_modules({"huggingface_hub": self.hub.module}): + self.assertTrue(self.service.download_model("wd14-vit", self._cb)) + self.assertTrue(self.service.download_model("wd14-vit", self._cb)) + + self.assertEqual( + [c[2] for c in self.hub.calls], ["model.onnx", "selected_tags.csv"] + ) + self.assertTrue(self.service.get_models_status()["wd14-vit"]["downloaded"]) + self.assertEqual(self.progress[-1], ("Download complete", 100)) + + def test_gguf_downloads_model_then_mmproj(self): + with _patch_modules({"huggingface_hub": self.hub.module}): + self.assertTrue(self.service.download_model("gguf", self._cb)) + + self.assertEqual( + [c[1] for c in self.hub.calls], + [MODELS["gguf"]["repo"], MODELS["gguf"]["mmproj_repo"]], + ) + self.assertTrue(self.service.get_models_status()["gguf"]["downloaded"]) + self.assertIn(("Main model ready", 50), self.progress) + + def test_gguf_skips_files_already_present(self): + _touch_model_files(self.models_dir, "gguf") + with _patch_modules({"huggingface_hub": self.hub.module}): + self.assertTrue(self.service.download_model("gguf")) + self.assertEqual(self.hub.calls, []) + + def test_hf_snapshot_download(self): + with _patch_modules({"huggingface_hub": self.hub.module}): + self.assertTrue(self.service.download_model("hf", self._cb)) + self.assertTrue(self.service.download_model("hf")) + + self.assertEqual(self.hub.calls, [("snapshot", MODELS["hf"]["repo"])]) + self.assertTrue(self.service.get_models_status()["hf"]["downloaded"]) + + def test_download_errors_are_reported_not_raised(self): + def boom(**kwargs): + raise OSError("disk full") + + self.hub.module.hf_hub_download = boom + with _patch_modules({"huggingface_hub": self.hub.module}): + self.assertFalse(self.service.download_model("wd14-vit", self._cb)) + self.assertTrue(self.progress[-1][0].startswith("Error:")) + + +# ── Default backend loaders with the ML libraries faked ─────────────── + + +class _FakeOrt: + def __init__(self): + self.module = types.ModuleType("onnxruntime") + self.module.InferenceSession = self.InferenceSession + self.sessions = [] + + def InferenceSession(self, path, providers): + session = FakeOnnxSession([0.9, 0.1]) + session.path = path + session.providers = providers + self.sessions.append(session) + return session + + +class TestDefaultLoaders(EngineTestCase): + + def test_wd14_loader_builds_session_and_reads_tags(self): + _touch_model_files(self.models_dir, "wd14-vit") + csv_path = self.models_dir / MODELS["wd14-vit"]["subdir"] / "selected_tags.csv" + with open(csv_path, "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=["tag_id", "name", "category"]) + writer.writeheader() + writer.writerow({"tag_id": 1, "name": "1girl", "category": "0"}) + writer.writerow({"tag_id": 2, "name": "smile", "category": "0"}) + ort = _FakeOrt() + service = AutoTagService(models_dir=self.models_dir) + + with _patch_modules({"onnxruntime": ort.module}): + service.load_model("wd14-vit", use_gpu=False) + cpu_providers = ort.sessions[-1].providers + service.load_model("wd14-vit", use_gpu=True) + gpu_providers = ort.sessions[-1].providers + + self.assertEqual(cpu_providers, ["CPUExecutionProvider"]) + self.assertEqual(gpu_providers[0], "CUDAExecutionProvider") + self.assertEqual(service.generate_tags(str(self.image_path)), ["1girl"]) + + def test_gguf_loader_wires_llama_with_mmproj(self): + _touch_model_files(self.models_dir, "gguf") + llama_cpp = types.ModuleType("llama_cpp") + chat_format = types.ModuleType("llama_cpp.llama_chat_format") + created = {} + + class Llama: + def __init__(self, **kwargs): + created.update(kwargs) + + def create_chat_completion(self, **kwargs): + return {"choices": [{"message": {"content": "Aa, Bb"}}]} + + class Llava15ChatHandler: + def __init__(self, clip_model_path): + created["clip"] = clip_model_path + + llama_cpp.Llama = Llama + chat_format.Llava15ChatHandler = Llava15ChatHandler + service = AutoTagService(models_dir=self.models_dir) + + with _patch_modules( + {"llama_cpp": llama_cpp, "llama_cpp.llama_chat_format": chat_format}, + ): + service.load_model("gguf", use_gpu=False) + + self.assertEqual(created["n_gpu_layers"], 0) + self.assertTrue(created["clip"].endswith(MODELS["gguf"]["mmproj_filename"])) + self.assertEqual(service.generate_tags(str(self.image_path)), ["aa", "bb"]) + + def _fake_torch(self, cuda=False): + torch = MagicMock(name="torch") + torch.cuda.is_available.return_value = cuda + torch.float16 = "f16" + torch.bfloat16 = "bf16" + torch.inference_mode.return_value.__enter__ = lambda s: None + torch.inference_mode.return_value.__exit__ = lambda s, *a: False + torch.cuda.amp.autocast.return_value.__enter__ = lambda s: None + torch.cuda.amp.autocast.return_value.__exit__ = lambda s, *a: False + return torch + + def _fake_transformers(self): + transformers = MagicMock(name="transformers") + processor = MagicMock(name="processor") + processor.apply_chat_template.return_value = "" + inputs = {"input_ids": MagicMock(shape=(1, 3)), "pixel_values": MagicMock()} + processed = MagicMock(name="inputs") + processed.to.return_value = inputs + processor.return_value = processed + processor.tokenizer.decode.return_value = "Red Hat, blue sky" + transformers.AutoProcessor.from_pretrained.return_value = processor + model = MagicMock(name="model") + model.generate.return_value = [list(range(10))] + transformers.LlavaForConditionalGeneration.from_pretrained.return_value = model + return transformers, model, processor, inputs + + def test_hf_loader_8bit_on_cpu_and_generation(self): + hf_dir = self.models_dir / MODELS["hf"]["subdir"] + hf_dir.mkdir(parents=True) + (hf_dir / "config.json").write_text("{}") + torch = self._fake_torch(cuda=False) + transformers, model, processor, inputs = self._fake_transformers() + pixel = inputs["pixel_values"] + service = AutoTagService(models_dir=self.models_dir) + + with _patch_modules({"torch": torch, "transformers": transformers}): + service.load_model("hf") + tags = service.generate_tags(str(self.image_path)) + + self.assertEqual(tags, ["red_hat", "blue_sky"]) + kwargs = ( + transformers.LlavaForConditionalGeneration.from_pretrained.call_args.kwargs + ) + self.assertEqual(kwargs["device_map"], "cpu") + self.assertIn("quantization_config", kwargs) + model.eval.assert_called_once() + pixel.to.assert_called_with("f16") + processor.tokenizer.decode.assert_called_once() + decoded = processor.tokenizer.decode.call_args.args[0] + self.assertEqual(decoded, list(range(3, 10))) + + def test_hf_loader_without_quantization_uses_bf16_and_cuda(self): + hf_dir = self.models_dir / MODELS["hf"]["subdir"] + hf_dir.mkdir(parents=True) + (hf_dir / "config.json").write_text("{}") + torch = self._fake_torch(cuda=True) + transformers, model, processor, inputs = self._fake_transformers() + pixel = inputs["pixel_values"] + service = AutoTagService(models_dir=self.models_dir) + + with _patch_modules({"torch": torch, "transformers": transformers}): + kind, (loaded, _, device, dtype) = service._load_hf_tagger( + quantization="none" + ) + service._tagger = (kind, (loaded, processor, device, dtype)) + service._current_model_type = "hf" + service.generate_tags(str(self.image_path)) + service.unload_model() + + self.assertEqual((kind, device, dtype), ("hf", "cuda", "bf16")) + kwargs = ( + transformers.LlavaForConditionalGeneration.from_pretrained.call_args.kwargs + ) + self.assertNotIn("quantization_config", kwargs) + pixel.to.assert_called_with("bf16") + torch.cuda.empty_cache.assert_called_once() + + def test_hf_loader_without_model_raises(self): + service = AutoTagService(models_dir=self.models_dir) + with _patch_modules( + {"torch": self._fake_torch(), "transformers": self._fake_transformers()[0]}, + ): + with self.assertRaises(RuntimeError): + service._load_hf_tagger() + + +class TestUnloadDoesNotImportTorch(EngineTestCase): + + def test_unload_skips_torch_when_not_imported(self): + _touch_model_files(self.models_dir, "gguf") + service = self._service(("gguf", FakeGgufModel("x"))) + service.load_model("gguf") + + with _patch_modules({"torch": None}): + with patch("builtins.__import__", side_effect=ImportError("no torch")): + service.unload_model() + + self.assertFalse(service.is_model_loaded()) + + +class TestImageConversion(EngineTestCase): + + def test_grayscale_images_are_converted_to_rgb(self): + _touch_model_files(self.models_dir, "wd14-vit") + gray = self.tmp / "gray.png" + Image.new("L", (3, 3), 128).save(gray, "PNG") + session = FakeOnnxSession([0.9] * 6) + service = self._service(("wd14", (session, WD14_TAGS))) + service.load_model("wd14-vit") + + tags = service.generate_tags(str(gray)) + + self.assertIn("1girl", tags) + self.assertEqual(tuple(session.run_calls[0]["input"].shape), (1, 8, 8, 3)) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_lora_utils.py b/tests/test_lora_utils.py index c713a04f..6e6f4f60 100644 --- a/tests/test_lora_utils.py +++ b/tests/test_lora_utils.py @@ -406,11 +406,15 @@ class _FakeHTTPSHandler(urllib.request.HTTPSHandler): host = (urllib.parse.urlsplit(req.full_url).hostname or "").lower() if host in CIVITAI_HOSTS: headers = email.message_from_string(f"Location: {self.redirect_to}\n") - return urllib.response.addinfourl(BytesIO(b""), headers, req.full_url, 302) + resp = urllib.response.addinfourl(BytesIO(b""), headers, req.full_url, 302) + resp.msg = "Found" # urllib's error processor reads response.msg + return resp headers = email.message_from_string("Content-Type: image/png\n") - return urllib.response.addinfourl( + resp = urllib.response.addinfourl( BytesIO(_png_bytes()), headers, req.full_url, 200 ) + resp.msg = "OK" + return resp class TestRedirectsAreRefused(unittest.TestCase): @@ -586,5 +590,298 @@ class TestTriggerWordCache(unittest.TestCase): self.assertEqual(errors, []) +# ── Detection, directories and metadata helpers ─────────────────────── + + +class _FakeFolderPaths: + def __init__(self, base_path, lora_dirs=None): + self.base_path = base_path + self._lora_dirs = lora_dirs or [] + + def get_folder_paths(self, name): + return list(self._lora_dirs) + + +def _make_comfy_root(tmp): + """Create a fake ComfyUI tree: main.py + custom_nodes/ + models/loras.""" + root = Path(tmp) / "ComfyUI" + (root / "custom_nodes").mkdir(parents=True) + (root / "models" / "loras").mkdir(parents=True) + (root / "main.py").write_text("") + return root + + +def _make_lora_manager(custom_nodes, name="ComfyUI-Lora-Manager"): + lm = custom_nodes / name + (lm / "py").mkdir(parents=True) + (lm / "__init__.py").write_text("") + return lm + + +class TestFindComfyuiRoot(unittest.TestCase): + + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.tmp, True) + + def test_folder_paths_base_path_wins(self): + root = _make_comfy_root(self.tmp) + with patch.dict(sys.modules, {"folder_paths": _FakeFolderPaths(str(root))}): + self.assertEqual(lora_utils.find_comfyui_root(), root) + + def test_walks_up_from_module_file_without_folder_paths(self): + root = _make_comfy_root(self.tmp) + fake_file = root / "custom_nodes" / "ext" / "py" / "lora_utils.py" + fake_file.parent.mkdir(parents=True) + fake_file.write_text("") + with ( + patch.dict(sys.modules, {"folder_paths": None}), + patch.object(lora_utils, "__file__", str(fake_file)), + ): + self.assertEqual(lora_utils.find_comfyui_root(), root.resolve()) + + def test_returns_none_when_no_root_found(self): + fake_file = self.tmp / "lonely" / "lora_utils.py" + fake_file.parent.mkdir() + fake_file.write_text("") + with ( + patch.dict(sys.modules, {"folder_paths": None}), + patch.object(lora_utils, "__file__", str(fake_file)), + ): + self.assertIsNone(lora_utils.find_comfyui_root()) + + +class TestDetectLoraManager(unittest.TestCase): + + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.tmp, True) + + def test_custom_path_is_used_when_it_looks_right(self): + lm = _make_lora_manager(self.tmp) + with patch.object(lora_utils, "find_comfyui_root", return_value=None): + self.assertEqual(lora_utils.detect_lora_manager(str(lm)), str(lm.resolve())) + + def test_custom_path_without_init_is_ignored(self): + bogus = self.tmp / "nope" + bogus.mkdir() + with patch.object(lora_utils, "find_comfyui_root", return_value=None): + self.assertIsNone(lora_utils.detect_lora_manager(str(bogus))) + + def test_auto_detects_under_custom_nodes_case_insensitively(self): + root = _make_comfy_root(self.tmp) + (root / "custom_nodes" / "other").mkdir() + lm = _make_lora_manager(root / "custom_nodes", "comfyui-LORA-manager") + with patch.object(lora_utils, "find_comfyui_root", return_value=root): + self.assertEqual(lora_utils.detect_lora_manager(), str(lm.resolve())) + + def test_lora_manager_dir_variant_is_accepted(self): + lm = self.tmp / "LoraManager" + (lm / "lora_manager").mkdir(parents=True) + (lm / "__init__.py").write_text("") + self.assertTrue(lora_utils._looks_like_lora_manager(lm)) + + def test_nothing_found(self): + with patch.object(lora_utils, "find_comfyui_root", return_value=None): + self.assertIsNone(lora_utils.detect_lora_manager()) + + +class TestFindLoraDirectories(unittest.TestCase): + + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.tmp, True) + self.root = _make_comfy_root(self.tmp) + self.lm = _make_lora_manager(self.root / "custom_nodes") + + def test_collects_default_extra_runtime_and_metadata_dirs(self): + extra_abs = self.tmp / "extra_abs" + extra_abs.mkdir() + (self.root / "rel_loras").mkdir() + (self.root / "extra_model_paths.yaml").write_text( + "a:\n base_path: %s\n loras: |\n rel_loras\n %s\n\n" + "b: notadict\nc:\n base_path: x\n" + % (self.root.as_posix(), extra_abs.as_posix()) + ) + runtime = self.tmp / "runtime" + runtime.mkdir() + nested = self.lm / "user" / "loras" + nested.mkdir(parents=True) + (nested / "x.metadata.json").write_text("{}") + + with ( + patch.object(lora_utils, "find_comfyui_root", return_value=self.root), + patch.dict( + sys.modules, + {"folder_paths": _FakeFolderPaths(str(self.root), [str(runtime)])}, + ), + ): + dirs = lora_utils.find_lora_directories(str(self.lm)) + + expected = { + str((self.root / "models" / "loras").resolve()), + str((self.root / "rel_loras").resolve()), + str(extra_abs.resolve()), + str(runtime.resolve()), + str(nested.resolve()), + } + self.assertEqual(set(dirs), expected) + self.assertEqual(dirs, sorted(dirs)) + + def test_non_dict_yaml_and_parse_errors_are_ignored(self): + (self.root / "extra_model_paths.yml").write_text("- just\n- a list\n") + self.assertEqual(lora_utils._get_extra_lora_paths(self.root), []) + (self.root / "extra_model_paths.yml").write_text("a: [unclosed\n") + self.assertEqual(lora_utils._get_extra_lora_paths(self.root), []) + + def test_without_root_only_metadata_dirs_are_found(self): + with ( + patch.object(lora_utils, "find_comfyui_root", return_value=None), + patch.dict(sys.modules, {"folder_paths": None}), + ): + self.assertEqual(lora_utils.find_lora_directories(str(self.lm)), []) + + +class TestModelNameAndPreviews(unittest.TestCase): + + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.tmp, True) + + def test_model_name_precedence(self): + get_name = lora_utils.get_model_name_from_metadata + self.assertEqual(get_name({"model_name": "top"}), "top") + self.assertEqual(get_name({"civitai": {"model": {"name": "civ"}}}), "civ") + self.assertEqual(get_name({"file_name": "f.safetensors"}), "f.safetensors") + self.assertEqual(get_name({}), "unknown") + + def test_preview_images_found_by_stem(self): + meta_path = self.tmp / "mylora.safetensors.metadata.json" + meta_path.write_text("{}") + (self.tmp / "mylora.png").write_bytes(b"x") + (self.tmp / "mylora.preview.jpg").write_bytes(b"x") + + found = lora_utils.get_preview_images_from_metadata({}, meta_path) + + self.assertEqual( + sorted(os.path.basename(f) for f in found), + ["mylora.png", "mylora.preview.jpg"], + ) + self.assertEqual( + lora_utils.get_preview_image_from_metadata({}, meta_path), found[0] + ) + + def test_preview_uses_file_name_from_metadata(self): + meta_path = self.tmp / "other.metadata.json" + (self.tmp / "named.webp").write_bytes(b"x") + found = lora_utils.get_preview_images_from_metadata( + {"file_name": "named.safetensors"}, meta_path + ) + self.assertEqual([os.path.basename(f) for f in found], ["named.webp"]) + self.assertIsNone(lora_utils.get_preview_image_from_metadata({}, meta_path)) + + def test_example_images_dir(self): + self.assertIsNone(lora_utils.get_example_images_dir(str(self.tmp))) + nested = self.tmp / "user" / "example_images" + nested.mkdir(parents=True) + self.assertEqual( + lora_utils.get_example_images_dir(str(self.tmp)), str(nested.resolve()) + ) + direct = self.tmp / "example_images" + direct.mkdir() + self.assertEqual( + lora_utils.get_example_images_dir(str(self.tmp)), str(direct.resolve()) + ) + + +class TestDownloadEdgeCases(unittest.TestCase): + + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.tmp, True) + + def test_malformed_url_is_refused(self): + self.assertFalse(is_civitai_url("https://[invalid")) + + def test_non_numeric_content_length_is_ignored(self): + resp = _FakeResponse(b"abc") + resp.headers["Content-Length"] = "lots" + self.assertEqual(lora_utils._read_capped(resp, 10), b"abc") + + def test_download_uses_metadata_filename_when_file_name_missing(self): + opener = _FakeOpener(_FakeResponse(_png_bytes())) + meta_path = self.tmp / "stemmed.safetensors.metadata.json" + metadata = {"civitai": {"images": [{"url": "https://civitai.com/a.png"}]}} + + with patch.object(lora_utils, "_open_url", opener): + paths = download_civitai_images(metadata, meta_path, self.tmp / "cache") + + self.assertEqual(len(paths), 1) + self.assertIn("stemmed", paths[0]) + + def test_failed_downloads_are_dropped(self): + opener = _FakeOpener(_FakeResponse(b"not an image")) + metadata = {"civitai": {"images": [{"url": "https://civitai.com/a.png"}]}} + + with patch.object(lora_utils, "_open_url", opener): + paths = download_civitai_images( + metadata, self.tmp / "x.metadata.json", self.tmp / "cache" + ) + + self.assertEqual(paths, []) + + +class TestInjectTriggerWords(unittest.TestCase): + + def setUp(self): + self.cache = TriggerWordCache() + self.cache._cache = {"style": ["neon", "glow"], "empty": []} + self.cache._loaded = True + + def test_not_loaded_returns_unchanged(self): + cache = TriggerWordCache() + self.assertEqual( + lora_utils.inject_trigger_words("", cache), + ("", []), + ) + + def test_no_lora_tags_returns_unchanged(self): + self.assertEqual( + lora_utils.inject_trigger_words("plain prompt", self.cache), + ("plain prompt", []), + ) + + def test_words_are_appended_once(self): + text = "a b " + self.assertEqual( + lora_utils.inject_trigger_words(text, self.cache), + (f"{text}, neon, glow", ["neon", "glow"]), + ) + + def test_words_already_present_are_not_repeated(self): + text = "NEON city " + self.assertEqual( + lora_utils.inject_trigger_words(text, self.cache), + (f"{text}, glow", ["glow"]), + ) + + def test_lora_without_words_leaves_text_alone(self): + text = "x " + self.assertEqual(lora_utils.inject_trigger_words(text, self.cache), (text, [])) + + def test_cache_load_skips_bad_and_wordless_metadata(self): + tmp = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, tmp, True) + (tmp / "bad.metadata.json").write_text("{{{") + (tmp / "nowords.metadata.json").write_text("{}") + (tmp / "named.metadata.json").write_text( + json.dumps({"civitai": {"trainedWords": ["w"]}}) + ) + cache = TriggerWordCache() + with patch.object(lora_utils, "find_lora_directories", return_value=[str(tmp)]): + self.assertEqual(cache.load(str(tmp)), 1) + self.assertEqual(cache.get_trigger_words("named"), ["w"]) + + if __name__ == "__main__": unittest.main()