* feat: add LoRA utility module for LoraManager integration (#52) Detection, metadata reading, trigger word cache, and prompt injection for ComfyUI-Lora-Manager. All functions return empty results when LoraManager is not installed. * feat: add IntegrationConfig for opt-in third-party integrations (#52) * feat(api): add LoraManager integration endpoints (#52) Detection, enable/disable, scan/import, trigger word lookup, and cache refresh endpoints under /prompt_manager/lora/*. * feat: inject LoRA trigger words into prompts at encoding time (#52) When enabled, scans for <lora:NAME:WEIGHT> tags in prompt text and appends trigger words from LoraManager metadata. Behind config toggle. * feat(ui): add LoraManager integration settings and import UI (#52) Integrations section in settings with auto-detection badge, enable toggle, trigger word toggle, and Import LoRA Data button. * fix: detect LoraManager with case-insensitive directory scan (#52) * fix: simplify LoraManager heuristic to match real install structure (#52) * fix: find ComfyUI root via folder_paths and handle symlinked installs (#52) * fix(ui): add pointer-events-none to toggle switch overlays (#52) The styled div was intercepting clicks meant for the sr-only checkbox input, preventing toggle switches from being clickable. * fix: use lora-manager category and tag for imported LoRA data (#52) * fix: use rglob to find metadata in lora subdirectories (#52) * feat: discover LoRA dirs from extra_model_paths.yaml and folder_paths (#52) Only models/loras under the ComfyUI root was checked. Now also parses extra_model_paths.yaml and uses folder_paths.get_folder_paths('loras') at runtime to find all configured LoRA directories. * fix: use example prompts instead of trigger words, serve LoRA preview images (#52) - Prompt text now uses civitai example prompts (images[].meta.prompt) when available, falling back to model name instead of trigger words - Image serving now allows paths within LoRA directories when the integration is enabled, fixing 403 errors on preview images * fix: handle null civitai field, link all preview images (#52) - Guard against civitai: null in metadata (was crashing the scan) - Link all local preview images per LoRA, not just the first - Add get_civitai_image_urls() for future remote image support * feat: reimport clears previous lora-manager data first (#52) Clicking Import LoRA Data now deletes all existing lora-manager category prompts before scanning, ensuring a clean reimport. * feat: download civitai example images during LoRA import (#52) Images from civitai.images[] are downloaded to data/lora_images/ cache and linked to prompts alongside local preview files. Cached files are reused on reimport. Image serving allows the cache directory. * feat: add CivitAI API key setting for authenticated image downloads (#52) Most civitai example images (especially NSFW) require authentication. Adds API key field to the Integrations settings panel, passed as Bearer token when downloading example images. * feat(ui): add progress modal for LoRA import (#52) Closes settings modal and shows a dedicated progress modal with progress bar, status text, and processed/imported counts during LoRA import. Auto-closes after completion. * fix: reduce download timeout to 5s, update progress per LoRA (#52) 5K images at 15s timeout was painfully slow. Reduced to 5s fail-fast. Progress now updates for every LoRA with image count, not every 5th. * perf: use 512px thumbnails and parallel downloads for LoRA images (#52) Full-size civitai images averaged 5.6MB each (27GB total for 5K images). Now requests /width=512/ thumbnails (~50-100KB) and downloads 8 in parallel per LoRA. Expected speedup: ~100x smaller + 8x parallel. * fix: resize downloaded images to 512px thumbnails locally (#52) Civitai CDN returns 401 for /width=N/ thumbnail URLs with API key auth. Instead, download the original and resize to 512px via PIL before saving. Reduces disk usage from ~5MB to ~30-50KB per image. * docs: update README for v3.2.1 LoRA Manager integration (#52) - Add LoRA Manager integration section with setup guide and CivitAI key docs - Add folder filter section with rescan note for existing libraries - Add v3.2.1 changelog entry, split from v3.2.0 - Add WIP notice for LoRA Manager feature - Update AutoTag section to include WD14 models - Fix stale references (KikoTextEncode, outdated file structure) - Remove dated v2 development note - Add screenshots for settings, integration, and filtered results - Fix code review items: remove unused constant, add comments to empty excepts - Bump version to 3.2.1 * test: add unit tests for LoRA integration and folder filter (#52) - test_lora_utils.py: 32 tests covering trigger word extraction, example prompts, image URLs, metadata parsing, cache dir, TriggerWordCache - test_lora_database.py: 17 tests covering delete_prompts_by_category, folder filter search, get_prompt_subfolders, LoRA import workflow - test_config.py: 7 new IntegrationConfig tests for structure, enable, partial update, reset, and roundtrip * fix: remove unused MagicMock import in test_lora_utils (#52)
254 lines
9.1 KiB
Python
254 lines
9.1 KiB
Python
"""
|
|
Database tests for LoRA Manager integration and folder filter features.
|
|
|
|
Tests delete_prompts_by_category, search_prompts folder filter,
|
|
get_prompt_subfolders, and LoRA-specific prompt workflows using
|
|
an in-memory SQLite database.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from database.operations import PromptDatabase
|
|
from utils.hashing import generate_prompt_hash
|
|
|
|
|
|
class LoraDBTestCase(unittest.TestCase):
|
|
"""Base class with temp database setup/teardown."""
|
|
|
|
def setUp(self):
|
|
self.temp_db = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
|
|
self.temp_db.close()
|
|
self.db = PromptDatabase(self.temp_db.name)
|
|
|
|
def tearDown(self):
|
|
for suffix in ("", "-wal", "-shm"):
|
|
path = self.temp_db.name + suffix
|
|
if os.path.exists(path):
|
|
os.unlink(path)
|
|
|
|
def _save(self, text, category=None, tags=None):
|
|
"""Save a prompt and return its ID."""
|
|
return self.db.save_prompt(
|
|
text=text,
|
|
category=category,
|
|
tags=tags or [],
|
|
prompt_hash=generate_prompt_hash(text),
|
|
)
|
|
|
|
def _link_image(self, prompt_id, image_path):
|
|
"""Link a fake image to a prompt."""
|
|
return self.db.link_image_to_prompt(
|
|
prompt_id=str(prompt_id), image_path=image_path
|
|
)
|
|
|
|
|
|
# ── delete_prompts_by_category ────────────────────────────────────────
|
|
|
|
|
|
class TestDeleteByCategory(LoraDBTestCase):
|
|
"""Test delete_prompts_by_category for LoRA reimport cleanup."""
|
|
|
|
def test_deletes_matching_category(self):
|
|
self._save("lora prompt 1", category="lora-manager")
|
|
self._save("lora prompt 2", category="lora-manager")
|
|
self._save("keep this", category="general")
|
|
|
|
deleted = self.db.delete_prompts_by_category("lora-manager")
|
|
|
|
self.assertEqual(deleted, 2)
|
|
results = self.db.search_prompts(category="lora-manager")
|
|
self.assertEqual(len(results), 0)
|
|
|
|
def test_preserves_other_categories(self):
|
|
self._save("keep this", category="general")
|
|
self._save("and this", category="portraits")
|
|
self.db.delete_prompts_by_category("lora-manager")
|
|
|
|
results = self.db.search_prompts()
|
|
self.assertEqual(len(results), 2)
|
|
|
|
def test_returns_zero_when_none_match(self):
|
|
self._save("no match", category="general")
|
|
deleted = self.db.delete_prompts_by_category("lora-manager")
|
|
self.assertEqual(deleted, 0)
|
|
|
|
def test_cascades_to_images(self):
|
|
pid = self._save("lora with image", category="lora-manager")
|
|
self._link_image(pid, "/fake/path/image.jpg")
|
|
|
|
# Verify image is linked
|
|
images = self.db.get_prompt_images(pid)
|
|
self.assertEqual(len(images), 1)
|
|
|
|
self.db.delete_prompts_by_category("lora-manager")
|
|
|
|
# Prompt gone
|
|
results = self.db.search_prompts(category="lora-manager")
|
|
self.assertEqual(len(results), 0)
|
|
|
|
def test_empty_category_string(self):
|
|
self._save("test", category="general")
|
|
deleted = self.db.delete_prompts_by_category("")
|
|
self.assertEqual(deleted, 0)
|
|
|
|
|
|
# ── Folder filter (search_prompts with folder param) ──────────────────
|
|
|
|
|
|
class TestFolderFilter(LoraDBTestCase):
|
|
"""Test search_prompts folder parameter for subfolder filtering."""
|
|
|
|
def _setup_prompts_with_images(self):
|
|
"""Create prompts linked to images in different directories."""
|
|
pid1 = self._save("landscape prompt", category="nature")
|
|
self._link_image(pid1, "/output/landscapes/sunset.png")
|
|
|
|
pid2 = self._save("portrait prompt", category="portraits")
|
|
self._link_image(pid2, "/output/portraits/face.png")
|
|
|
|
pid3 = self._save("another landscape", category="nature")
|
|
self._link_image(pid3, "/output/landscapes/mountain.png")
|
|
|
|
return pid1, pid2, pid3
|
|
|
|
def test_filter_by_folder(self):
|
|
self._setup_prompts_with_images()
|
|
results = self.db.search_prompts(folder="landscapes")
|
|
self.assertEqual(len(results), 2)
|
|
texts = {r["text"] for r in results}
|
|
self.assertEqual(texts, {"landscape prompt", "another landscape"})
|
|
|
|
def test_filter_different_folder(self):
|
|
self._setup_prompts_with_images()
|
|
results = self.db.search_prompts(folder="portraits")
|
|
self.assertEqual(len(results), 1)
|
|
self.assertEqual(results[0]["text"], "portrait prompt")
|
|
|
|
def test_no_match_returns_empty(self):
|
|
self._setup_prompts_with_images()
|
|
results = self.db.search_prompts(folder="nonexistent")
|
|
self.assertEqual(len(results), 0)
|
|
|
|
def test_no_folder_returns_all(self):
|
|
self._setup_prompts_with_images()
|
|
results = self.db.search_prompts()
|
|
self.assertGreaterEqual(len(results), 3)
|
|
|
|
def test_folder_with_category_filter(self):
|
|
self._setup_prompts_with_images()
|
|
results = self.db.search_prompts(folder="landscapes", category="nature")
|
|
self.assertEqual(len(results), 2)
|
|
|
|
|
|
# ── get_prompt_subfolders ─────────────────────────────────────────────
|
|
|
|
|
|
class TestGetPromptSubfolders(LoraDBTestCase):
|
|
"""Test get_prompt_subfolders — extracts unique folder names from images."""
|
|
|
|
def test_extracts_subfolders(self):
|
|
pid1 = self._save("prompt 1")
|
|
self._link_image(pid1, "/output/folder_a/img1.png")
|
|
|
|
pid2 = self._save("prompt 2")
|
|
self._link_image(pid2, "/output/folder_b/img2.png")
|
|
|
|
folders = self.db.get_prompt_subfolders()
|
|
self.assertIsInstance(folders, list)
|
|
self.assertGreaterEqual(len(folders), 2)
|
|
|
|
def test_deduplicates(self):
|
|
pid1 = self._save("prompt 1")
|
|
self._link_image(pid1, "/output/same_folder/img1.png")
|
|
|
|
pid2 = self._save("prompt 2")
|
|
self._link_image(pid2, "/output/same_folder/img2.png")
|
|
|
|
folders = self.db.get_prompt_subfolders()
|
|
# Count occurrences of the folder — should appear once
|
|
matches = [f for f in folders if "same_folder" in f]
|
|
self.assertEqual(len(matches), 1)
|
|
|
|
def test_empty_database(self):
|
|
folders = self.db.get_prompt_subfolders()
|
|
self.assertEqual(folders, [])
|
|
|
|
def test_returns_sorted(self):
|
|
for i, name in enumerate(["charlie", "alpha", "bravo"]):
|
|
pid = self._save(f"prompt {i}")
|
|
self._link_image(pid, f"/output/{name}/img.png")
|
|
|
|
folders = self.db.get_prompt_subfolders()
|
|
self.assertEqual(folders, sorted(folders))
|
|
|
|
def test_with_root_dirs(self):
|
|
pid = self._save("prompt")
|
|
self._link_image(pid, "/output/sub/deep/img.png")
|
|
|
|
folders = self.db.get_prompt_subfolders(root_dirs=["/output"])
|
|
self.assertIsInstance(folders, list)
|
|
self.assertGreater(len(folders), 0)
|
|
|
|
|
|
# ── LoRA prompt workflow ──────────────────────────────────────────────
|
|
|
|
|
|
class TestLoraPromptWorkflow(LoraDBTestCase):
|
|
"""Test the full LoRA import workflow at the database layer."""
|
|
|
|
def test_save_lora_prompt_with_tags(self):
|
|
"""Simulate what lora_scan does: save prompt with lora-manager tags."""
|
|
pid = self._save(
|
|
text="1girl, detailed face, anime style",
|
|
category="lora-manager",
|
|
tags=["lora-manager", "lora:my_lora", "trigger1"],
|
|
)
|
|
prompt = self.db.get_prompt_by_id(pid)
|
|
self.assertEqual(prompt["category"], "lora-manager")
|
|
self.assertIn("lora-manager", prompt["tags"])
|
|
|
|
def test_reimport_clears_and_recreates(self):
|
|
"""Simulate reimport: delete old, create new."""
|
|
# First import
|
|
pid1 = self._save("old lora prompt", category="lora-manager")
|
|
self._link_image(pid1, "/cache/old.jpg")
|
|
|
|
# Reimport
|
|
self.db.delete_prompts_by_category("lora-manager")
|
|
|
|
# Second import
|
|
pid2 = self._save("new lora prompt", category="lora-manager")
|
|
self._link_image(pid2, "/cache/new.jpg")
|
|
|
|
results = self.db.search_prompts(category="lora-manager")
|
|
self.assertEqual(len(results), 1)
|
|
self.assertEqual(results[0]["text"], "new lora prompt")
|
|
|
|
def test_hash_dedup_prevents_duplicates(self):
|
|
"""Verify hash-based dedup works for LoRA prompts."""
|
|
text = "duplicate lora prompt"
|
|
h = generate_prompt_hash(text)
|
|
|
|
self._save(text, category="lora-manager")
|
|
existing = self.db.get_prompt_by_hash(h)
|
|
self.assertIsNotNone(existing)
|
|
|
|
def test_link_multiple_images_to_lora_prompt(self):
|
|
"""LoRA prompts can have multiple preview images."""
|
|
pid = self._save("multi-image lora", category="lora-manager")
|
|
self._link_image(pid, "/cache/lora/img1.jpg")
|
|
self._link_image(pid, "/cache/lora/img2.jpg")
|
|
self._link_image(pid, "/cache/lora/img3.jpg")
|
|
|
|
images = self.db.get_prompt_images(pid)
|
|
self.assertEqual(len(images), 3)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|