mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-23 03:54:09 -03:00
Follow-up to the keyword-dump guard. The reported model's tag list is ["lora, character, ... face", "base model"], so skipping the dump left the "base model" label to be picked as the folder name. That label describes Civitai's listing rather than the model's content, which makes it as meaningless as a folder as the blob was. Add CIVITAI_META_TAGS and is_civitai_meta_tag(), and skip those labels in the automatic fallback. An explicit priority entry still matches them, so a user who does want a "base model" folder can configure one. The reported model now resolves to "Krea 2/no tags" instead of "Krea 2/base model". Also correct a comment that listed ".civitai.info" among the files sitting next to a model. LoRA Manager only reads that sidecar -- other tools write it -- and writes ".metadata.json" itself.
1501 lines
52 KiB
Python
1501 lines
52 KiB
Python
import asyncio
|
|
import copy
|
|
import json
|
|
import os
|
|
from concurrent.futures import Future
|
|
|
|
import pytest
|
|
|
|
from py.services import service_registry
|
|
from py.services import settings_manager as settings_manager_module
|
|
from py.services.settings_manager import SettingsManager
|
|
from py.utils import settings_paths
|
|
|
|
|
|
@pytest.mark.no_settings_dir_isolation
|
|
def test_portable_settings_use_project_root(tmp_path, monkeypatch):
|
|
from importlib import reload
|
|
|
|
settings_paths_module = reload(settings_paths)
|
|
monkeypatch.setattr(
|
|
settings_paths_module, "get_project_root", lambda: str(tmp_path)
|
|
)
|
|
monkeypatch.setattr(
|
|
settings_paths_module,
|
|
"user_config_dir",
|
|
lambda *_args, **_kwargs: str(tmp_path / "user_config"),
|
|
)
|
|
|
|
portable_settings = {"use_portable_settings": True}
|
|
(tmp_path / "settings.json").write_text(
|
|
json.dumps(portable_settings), encoding="utf-8"
|
|
)
|
|
|
|
config_dir = settings_paths_module.get_settings_dir(create=True)
|
|
assert config_dir == str(tmp_path)
|
|
|
|
from py.services import persistent_model_cache as persistent_model_cache_module
|
|
|
|
cache_module = reload(persistent_model_cache_module)
|
|
monkeypatch.setattr(cache_module.PersistentModelCache, "_instances", {})
|
|
monkeypatch.delenv("LORA_MANAGER_CACHE_DB", raising=False)
|
|
|
|
cache = cache_module.PersistentModelCache(library_name="portable_lib")
|
|
expected_cache_path = tmp_path / "cache" / "model" / "portable_lib.sqlite"
|
|
|
|
assert cache.get_database_path() == str(expected_cache_path)
|
|
assert expected_cache_path.parent.is_dir()
|
|
|
|
|
|
@pytest.fixture
|
|
def manager(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(SettingsManager, "_save_settings", lambda self: None)
|
|
fake_settings_path = tmp_path / "settings.json"
|
|
monkeypatch.setattr(
|
|
"py.services.settings_manager.ensure_settings_file",
|
|
lambda logger=None: str(fake_settings_path),
|
|
)
|
|
mgr = SettingsManager()
|
|
mgr.settings_file = str(fake_settings_path)
|
|
return mgr
|
|
|
|
|
|
def test_initial_save_persists_minimal_template(tmp_path, monkeypatch):
|
|
settings_path = tmp_path / "settings.json"
|
|
|
|
monkeypatch.setattr(
|
|
"py.services.settings_manager.ensure_settings_file",
|
|
lambda logger=None: str(settings_path),
|
|
)
|
|
|
|
template = {
|
|
"_note": "template note",
|
|
"language": "fr",
|
|
"folder_paths": {"loras": ["/loras"]},
|
|
}
|
|
|
|
def fake_template_loader(self):
|
|
self._seed_template = copy.deepcopy(template)
|
|
return copy.deepcopy(template)
|
|
|
|
monkeypatch.setattr(
|
|
SettingsManager, "_load_settings_template", fake_template_loader
|
|
)
|
|
|
|
manager = SettingsManager()
|
|
|
|
persisted = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
assert persisted["_note"] == "template note"
|
|
assert "libraries" not in persisted
|
|
assert persisted["folder_paths"]["loras"] == ["/loras"]
|
|
assert manager.get_libraries()["default"]["folder_paths"]["loras"] == ["/loras"]
|
|
|
|
|
|
def test_existing_folder_paths_seed_default_library(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("LORA_MANAGER_STANDALONE", "1")
|
|
|
|
lora_dir = tmp_path / "loras"
|
|
checkpoint_dir = tmp_path / "checkpoints"
|
|
unet_dir = tmp_path / "unet"
|
|
diffusion_dir = tmp_path / "diffusion_models"
|
|
embedding_dir = tmp_path / "embeddings"
|
|
|
|
for directory in (lora_dir, checkpoint_dir, unet_dir, diffusion_dir, embedding_dir):
|
|
directory.mkdir()
|
|
|
|
initial = {
|
|
"folder_paths": {
|
|
"loras": [str(lora_dir)],
|
|
"checkpoints": [str(checkpoint_dir)],
|
|
"unet": [str(diffusion_dir), str(unet_dir)],
|
|
"embeddings": [str(embedding_dir)],
|
|
}
|
|
}
|
|
|
|
manager = _create_manager_with_settings(tmp_path, monkeypatch, initial)
|
|
|
|
stored_paths = manager.get("folder_paths")
|
|
assert stored_paths["loras"] == [str(lora_dir)]
|
|
assert stored_paths["checkpoints"] == [str(checkpoint_dir)]
|
|
assert stored_paths["unet"] == [str(diffusion_dir), str(unet_dir)]
|
|
assert stored_paths["embeddings"] == [str(embedding_dir)]
|
|
|
|
libraries = manager.get_libraries()
|
|
assert "default" in libraries
|
|
assert libraries["default"]["folder_paths"]["loras"] == [str(lora_dir)]
|
|
assert libraries["default"]["folder_paths"]["checkpoints"] == [str(checkpoint_dir)]
|
|
assert libraries["default"]["folder_paths"]["unet"] == [
|
|
str(diffusion_dir),
|
|
str(unet_dir),
|
|
]
|
|
assert libraries["default"]["folder_paths"]["embeddings"] == [str(embedding_dir)]
|
|
|
|
assert manager.get_startup_messages() == []
|
|
|
|
|
|
def test_environment_variable_overrides_settings(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(SettingsManager, "_save_settings", lambda self: None)
|
|
monkeypatch.setenv("CIVITAI_API_KEY", "secret")
|
|
fake_settings_path = tmp_path / "settings.json"
|
|
monkeypatch.setattr(
|
|
"py.services.settings_manager.ensure_settings_file",
|
|
lambda logger=None: str(fake_settings_path),
|
|
)
|
|
mgr = SettingsManager()
|
|
mgr.settings_file = str(fake_settings_path)
|
|
|
|
assert mgr.get("civitai_api_key") == "secret"
|
|
|
|
|
|
def test_default_download_backend_is_python(manager):
|
|
assert manager.get("download_backend") == "python"
|
|
assert manager.get("aria2c_path") == ""
|
|
|
|
|
|
def _create_manager_with_settings(
|
|
tmp_path, monkeypatch, initial_settings, *, save_spy=None
|
|
):
|
|
"""Helper to instantiate SettingsManager with predefined settings."""
|
|
|
|
fake_settings_path = tmp_path / "settings.json"
|
|
|
|
monkeypatch.setattr(
|
|
"py.services.settings_manager.ensure_settings_file",
|
|
lambda logger=None: str(fake_settings_path),
|
|
)
|
|
|
|
if save_spy is None:
|
|
monkeypatch.setattr(SettingsManager, "_save_settings", lambda self: None)
|
|
else:
|
|
monkeypatch.setattr(SettingsManager, "_save_settings", save_spy)
|
|
|
|
monkeypatch.setattr(
|
|
SettingsManager,
|
|
"_load_settings",
|
|
lambda self: copy.deepcopy(initial_settings),
|
|
)
|
|
|
|
mgr = SettingsManager()
|
|
mgr.settings_file = str(fake_settings_path)
|
|
return mgr
|
|
|
|
|
|
def _setup_storage_paths(tmp_path, monkeypatch):
|
|
project_root = tmp_path / "repo"
|
|
project_root.mkdir(parents=True, exist_ok=True)
|
|
user_dir = tmp_path / "user_config"
|
|
user_dir.mkdir(parents=True, exist_ok=True)
|
|
user_settings_path = user_dir / "settings.json"
|
|
|
|
monkeypatch.setattr(
|
|
"py.services.settings_manager.ensure_settings_file",
|
|
lambda logger=None: str(user_settings_path),
|
|
)
|
|
monkeypatch.setattr(
|
|
settings_manager_module,
|
|
"user_config_dir",
|
|
lambda *args, **kwargs: str(user_dir),
|
|
)
|
|
monkeypatch.setattr(settings_paths, "get_project_root", lambda: str(project_root))
|
|
return project_root, user_dir, user_settings_path
|
|
|
|
|
|
def _populate_settings_dir(root_dir):
|
|
"""Create test data for all managed subdirectories under a settings directory."""
|
|
(root_dir / "cache" / "symlink").mkdir(parents=True, exist_ok=True)
|
|
(root_dir / "cache" / "symlink" / "symlink_map.json").write_text(
|
|
'{"migrated": true}', encoding="utf-8"
|
|
)
|
|
(root_dir / "backups").mkdir(parents=True, exist_ok=True)
|
|
(root_dir / "backups" / "backup_test.zip").write_text(
|
|
"backup", encoding="utf-8"
|
|
)
|
|
(root_dir / "logs").mkdir(parents=True, exist_ok=True)
|
|
(root_dir / "logs" / "session.log").write_text("log", encoding="utf-8")
|
|
(root_dir / "stats").mkdir(parents=True, exist_ok=True)
|
|
(root_dir / "stats" / "stats.json").write_text(
|
|
'{"stats": true}', encoding="utf-8"
|
|
)
|
|
(root_dir / "wildcards").mkdir(parents=True, exist_ok=True)
|
|
(root_dir / "wildcards" / "test.txt").write_text("wildcard", encoding="utf-8")
|
|
|
|
|
|
def test_switch_to_portable_mode_copies_subdirectories(tmp_path, monkeypatch):
|
|
project_root, user_dir, user_settings = _setup_storage_paths(tmp_path, monkeypatch)
|
|
_populate_settings_dir(user_dir)
|
|
|
|
manager = SettingsManager()
|
|
|
|
manager.set("use_portable_settings", True)
|
|
|
|
assert manager.settings_file == str(project_root / "settings.json")
|
|
# Managed subdirectories should all be migrated
|
|
assert (
|
|
project_root / "cache" / "symlink" / "symlink_map.json"
|
|
).read_text(encoding="utf-8") == '{"migrated": true}'
|
|
assert (
|
|
project_root / "backups" / "backup_test.zip"
|
|
).read_text(encoding="utf-8") == "backup"
|
|
assert (project_root / "logs" / "session.log").read_text(
|
|
encoding="utf-8"
|
|
) == "log"
|
|
assert (project_root / "stats" / "stats.json").read_text(
|
|
encoding="utf-8"
|
|
) == '{"stats": true}'
|
|
assert (project_root / "wildcards" / "test.txt").read_text(
|
|
encoding="utf-8"
|
|
) == "wildcard"
|
|
assert user_settings.exists()
|
|
|
|
|
|
def test_switching_back_to_user_config_moves_subdirectories(tmp_path, monkeypatch):
|
|
project_root, user_dir, user_settings = _setup_storage_paths(tmp_path, monkeypatch)
|
|
_populate_settings_dir(user_dir)
|
|
|
|
manager = SettingsManager()
|
|
manager.set("use_portable_settings", True)
|
|
|
|
# Populate project-root managed subdirectories
|
|
(project_root / "cache" / "model").mkdir(parents=True, exist_ok=True)
|
|
(project_root / "cache" / "model" / "default.sqlite").write_text(
|
|
"project_db", encoding="utf-8"
|
|
)
|
|
(project_root / "backups" / "project_backup.zip").write_text(
|
|
"project_backup", encoding="utf-8"
|
|
)
|
|
(project_root / "logs" / "project.log").write_text(
|
|
"project_log", encoding="utf-8"
|
|
)
|
|
(project_root / "stats" / "project_stats.json").write_text(
|
|
'{"project": true}', encoding="utf-8"
|
|
)
|
|
(project_root / "wildcards" / "project.txt").write_text(
|
|
"project_wildcard", encoding="utf-8"
|
|
)
|
|
|
|
manager.set("use_portable_settings", False)
|
|
|
|
assert manager.settings_file == str(user_settings)
|
|
assert (user_dir / "cache" / "model" / "default.sqlite").read_text(
|
|
encoding="utf-8"
|
|
) == "project_db"
|
|
assert (user_dir / "backups" / "project_backup.zip").read_text(
|
|
encoding="utf-8"
|
|
) == "project_backup"
|
|
assert (user_dir / "logs" / "project.log").read_text(
|
|
encoding="utf-8"
|
|
) == "project_log"
|
|
assert (user_dir / "stats" / "project_stats.json").read_text(
|
|
encoding="utf-8"
|
|
) == '{"project": true}'
|
|
assert (user_dir / "wildcards" / "project.txt").read_text(
|
|
encoding="utf-8"
|
|
) == "project_wildcard"
|
|
|
|
|
|
def test_portable_switch_ignored_when_settings_dir_pinned(tmp_path, monkeypatch):
|
|
"""An explicit settings dir (--settings-path) must never trigger the
|
|
portable-mode directory migration between project root and user config."""
|
|
project_root, user_dir, user_settings = _setup_storage_paths(tmp_path, monkeypatch)
|
|
_populate_settings_dir(user_dir)
|
|
|
|
custom_dir = tmp_path / "custom_settings"
|
|
custom_dir.mkdir()
|
|
monkeypatch.setattr(
|
|
"py.services.settings_manager.ensure_settings_file",
|
|
lambda logger=None: str(custom_dir / "settings.json"),
|
|
)
|
|
settings_paths.set_settings_dir_override(str(custom_dir))
|
|
try:
|
|
manager = SettingsManager()
|
|
manager.settings_file = str(custom_dir / "settings.json")
|
|
|
|
manager.set("use_portable_settings", True)
|
|
|
|
# Settings file stays pinned; no directories are migrated anywhere and
|
|
# the settings file is not mirrored to the user config dir.
|
|
assert manager.settings_file == str(custom_dir / "settings.json")
|
|
assert not (project_root / "cache").exists()
|
|
assert not (project_root / "backups").exists()
|
|
assert not (project_root / "settings.json").exists()
|
|
assert not user_settings.exists()
|
|
finally:
|
|
settings_paths.set_settings_dir_override(None)
|
|
|
|
|
|
def test_download_path_template_parses_json_string(manager):
|
|
templates = {"lora": "{author}", "checkpoint": "{author}", "embedding": "{author}"}
|
|
manager.settings["download_path_templates"] = json.dumps(templates)
|
|
|
|
template = manager.get_download_path_template("lora")
|
|
|
|
assert template == "{author}"
|
|
assert isinstance(manager.settings["download_path_templates"], dict)
|
|
|
|
|
|
def test_download_path_template_invalid_json(manager):
|
|
manager.settings["download_path_templates"] = "not json"
|
|
|
|
template = manager.get_download_path_template("checkpoint")
|
|
|
|
assert template == "{base_model}/{first_tag}"
|
|
assert (
|
|
manager.settings["download_path_templates"]["lora"]
|
|
== "{base_model}/{first_tag}"
|
|
)
|
|
|
|
|
|
def test_download_path_template_defaults_flat_for_other(manager):
|
|
"""'other' has no settings-UI row and no priority_tags entry, so an
|
|
unconfigured template must resolve to a flat layout rather than the
|
|
{base_model}/{first_tag} fallback (which scatters files into arbitrary
|
|
CivitAI-tag folders)."""
|
|
manager.settings["download_path_templates"] = {}
|
|
|
|
assert manager.get_download_path_template("other") == ""
|
|
|
|
# An explicit user configuration still wins.
|
|
manager.settings["download_path_templates"] = {"other": "{base_model}"}
|
|
assert manager.get_download_path_template("other") == "{base_model}"
|
|
|
|
|
|
def test_download_path_template_unknown_type_is_flat(manager):
|
|
manager.settings["download_path_templates"] = {}
|
|
|
|
assert manager.get_download_path_template("not-a-model-type") == ""
|
|
|
|
|
|
# Real CivitAI data for the model reported in issue #1119: the uploader dumped
|
|
# a whole keyword list into a single tag.
|
|
KEYWORD_DUMP_TAG = (
|
|
"lora, character, rosie, irish, redhead, auburn, freckles, green eyes, "
|
|
"curly hair, woman, female, photorealistic, realistic, krea2, dark beast, "
|
|
"kreativity, nsfw, nude, portrait, face"
|
|
)
|
|
|
|
|
|
def test_resolve_priority_tag_prefers_configured_priority(manager):
|
|
# Priority order from CIVITAI_MODEL_TAGS: "character" precedes "anime".
|
|
assert manager.resolve_priority_tag_for_model(["anime", "character"], "lora") == (
|
|
"character"
|
|
)
|
|
|
|
|
|
def test_resolve_priority_tag_falls_back_to_first_usable_tag(manager):
|
|
assert (
|
|
manager.resolve_priority_tag_for_model(["portrait", "anime-ish"], "lora")
|
|
== "portrait"
|
|
)
|
|
|
|
|
|
def test_resolve_priority_tag_skips_keyword_dump_tag(manager):
|
|
"""A keyword-dump tag must not be used as a folder name (#1119)."""
|
|
assert manager.resolve_priority_tag_for_model([KEYWORD_DUMP_TAG], "lora") == ""
|
|
|
|
|
|
def test_resolve_priority_tag_skips_keyword_dump_and_uses_next_tag(manager):
|
|
assert (
|
|
manager.resolve_priority_tag_for_model([KEYWORD_DUMP_TAG, "portrait"], "lora")
|
|
== "portrait"
|
|
)
|
|
|
|
|
|
def test_resolve_priority_tag_skips_unusable_tags(manager):
|
|
overlong_tag = "x" * 51
|
|
|
|
assert manager.resolve_priority_tag_for_model([overlong_tag], "lora") == ""
|
|
assert manager.resolve_priority_tag_for_model([overlong_tag, " "], "lora") == ""
|
|
# Non-string entries never win the fallback.
|
|
assert manager.resolve_priority_tag_for_model([None, 42], "lora") == ""
|
|
# A tag at the length budget is still accepted and stripped.
|
|
assert manager.resolve_priority_tag_for_model(["x" * 50], "lora") == "x" * 50
|
|
assert manager.resolve_priority_tag_for_model([" portrait "], "lora") == "portrait"
|
|
|
|
|
|
def test_resolve_priority_tag_skips_civitai_meta_tags(manager):
|
|
"""Civitai's structural labels are not content, so they cannot be folders."""
|
|
assert manager.resolve_priority_tag_for_model(["base model"], "lora") == ""
|
|
assert (
|
|
manager.resolve_priority_tag_for_model(["Base Model"], "lora") == ""
|
|
), "the meta tag check must be case-insensitive"
|
|
assert manager.resolve_priority_tag_for_model(["base model", " "], "lora") == ""
|
|
# A real tag after the label is still used.
|
|
assert (
|
|
manager.resolve_priority_tag_for_model(["base model", "portrait"], "lora")
|
|
== "portrait"
|
|
)
|
|
|
|
|
|
def test_resolve_priority_tag_meta_tag_can_be_opted_into(manager):
|
|
"""An explicit priority entry still wins over the meta tag exclusion."""
|
|
manager.settings["priority_tags"] = {"lora": "base model"}
|
|
|
|
assert (
|
|
manager.resolve_priority_tag_for_model(["base model", "portrait"], "lora")
|
|
== "base model"
|
|
)
|
|
|
|
|
|
def test_resolve_priority_tag_real_1119_tag_list(manager):
|
|
"""End to end for the reported model: both of its tags are unusable."""
|
|
assert (
|
|
manager.resolve_priority_tag_for_model(
|
|
[KEYWORD_DUMP_TAG, "base model"], "lora"
|
|
)
|
|
== ""
|
|
)
|
|
|
|
|
|
def test_auto_set_default_roots(manager):
|
|
# Clear any previously auto-set values to test fresh behavior
|
|
manager.settings["default_lora_root"] = ""
|
|
manager.settings["default_checkpoint_root"] = ""
|
|
manager.settings["default_embedding_root"] = ""
|
|
manager.settings["default_unet_root"] = ""
|
|
|
|
manager.settings["folder_paths"] = {
|
|
"loras": ["/loras"],
|
|
"checkpoints": ["/checkpoints"],
|
|
"embeddings": ["/embeddings"],
|
|
}
|
|
|
|
manager._auto_set_default_roots()
|
|
|
|
assert manager.get("default_lora_root") == "/loras"
|
|
assert manager.get("default_checkpoint_root") == "/checkpoints"
|
|
assert manager.get("default_embedding_root") == "/embeddings"
|
|
|
|
|
|
def test_auto_set_default_roots_repairs_stale_values(manager):
|
|
manager.settings["default_lora_root"] = "/stale-lora"
|
|
manager.settings["default_checkpoint_root"] = "/stale-checkpoint"
|
|
manager.settings["default_embedding_root"] = "/stale-embedding"
|
|
manager.settings["default_unet_root"] = "/stale-unet"
|
|
|
|
manager.settings["folder_paths"] = {
|
|
"loras": ["/loras"],
|
|
"checkpoints": ["/checkpoints"],
|
|
"unet": ["/unet"],
|
|
"embeddings": ["/embeddings"],
|
|
}
|
|
|
|
manager._auto_set_default_roots()
|
|
|
|
assert manager.get("default_lora_root") == "/loras"
|
|
assert manager.get("default_checkpoint_root") == "/checkpoints"
|
|
assert manager.get("default_unet_root") == "/unet"
|
|
assert manager.get("default_embedding_root") == "/embeddings"
|
|
|
|
|
|
def test_auto_set_default_roots_keeps_valid_values(manager):
|
|
manager.settings["default_lora_root"] = "/loras"
|
|
manager.settings["default_checkpoint_root"] = "/checkpoints"
|
|
manager.settings["default_embedding_root"] = "/embeddings"
|
|
manager.settings["default_unet_root"] = "/unet"
|
|
|
|
manager.settings["folder_paths"] = {
|
|
"loras": ["/loras", "/other-loras"],
|
|
"checkpoints": ["/checkpoints"],
|
|
"unet": ["/unet", "/other-unet"],
|
|
"embeddings": ["/embeddings"],
|
|
}
|
|
|
|
manager._auto_set_default_roots()
|
|
|
|
assert manager.get("default_lora_root") == "/loras"
|
|
assert manager.get("default_checkpoint_root") == "/checkpoints"
|
|
assert manager.get("default_unet_root") == "/unet"
|
|
assert manager.get("default_embedding_root") == "/embeddings"
|
|
|
|
|
|
def test_auto_set_default_roots_keeps_valid_extra_values(manager):
|
|
manager.settings["default_lora_root"] = "/extra-loras"
|
|
manager.settings["default_checkpoint_root"] = "/extra-checkpoints"
|
|
manager.settings["default_embedding_root"] = "/extra-embeddings"
|
|
manager.settings["default_unet_root"] = "/extra-unet"
|
|
|
|
manager.settings["folder_paths"] = {
|
|
"loras": ["/loras"],
|
|
"checkpoints": ["/checkpoints"],
|
|
"unet": ["/unet"],
|
|
"embeddings": ["/embeddings"],
|
|
}
|
|
manager.settings["extra_folder_paths"] = {
|
|
"loras": ["/extra-loras"],
|
|
"checkpoints": ["/extra-checkpoints"],
|
|
"unet": ["/extra-unet"],
|
|
"embeddings": ["/extra-embeddings"],
|
|
}
|
|
|
|
manager._auto_set_default_roots()
|
|
|
|
assert manager.get("default_lora_root") == "/extra-loras"
|
|
assert manager.get("default_checkpoint_root") == "/extra-checkpoints"
|
|
assert manager.get("default_unet_root") == "/extra-unet"
|
|
assert manager.get("default_embedding_root") == "/extra-embeddings"
|
|
|
|
|
|
def test_auto_set_default_roots_keeps_valid_extra_values_with_windows_slash_mismatch(manager):
|
|
manager.settings["default_lora_root"] = "U:/Lora7/Loras"
|
|
manager.settings["default_checkpoint_root"] = "U:/Lora7/Models"
|
|
|
|
manager.settings["folder_paths"] = {
|
|
"loras": ["R:/ComfyUI/models/loras"],
|
|
"checkpoints": ["R:/ComfyUI/models/checkpoints"],
|
|
}
|
|
manager.settings["extra_folder_paths"] = {
|
|
"loras": ["U:\\Lora7\\Loras"],
|
|
"checkpoints": ["U:\\Lora7\\Models"],
|
|
}
|
|
|
|
manager._auto_set_default_roots()
|
|
|
|
assert manager.get("default_lora_root") == "U:/Lora7/Loras"
|
|
assert manager.get("default_checkpoint_root") == "U:/Lora7/Models"
|
|
|
|
|
|
def test_auto_set_default_roots_falls_back_to_extra_when_primary_missing(manager):
|
|
manager.settings["default_lora_root"] = ""
|
|
manager.settings["folder_paths"] = {"loras": []}
|
|
manager.settings["extra_folder_paths"] = {"loras": ["/extra-loras"]}
|
|
|
|
manager._auto_set_default_roots()
|
|
|
|
assert manager.get("default_lora_root") == "/extra-loras"
|
|
|
|
|
|
def test_delete_setting(manager):
|
|
manager.set("example", 1)
|
|
manager.delete("example")
|
|
assert manager.get("example") is None
|
|
|
|
|
|
def test_missing_mature_blur_level_defaults_to_r(tmp_path, monkeypatch):
|
|
manager = _create_manager_with_settings(
|
|
tmp_path,
|
|
monkeypatch,
|
|
{
|
|
"blur_mature_content": True,
|
|
"folder_paths": {},
|
|
},
|
|
)
|
|
|
|
assert manager.get("mature_blur_level") == "R"
|
|
|
|
|
|
def test_invalid_mature_blur_level_is_normalized_to_r(tmp_path, monkeypatch):
|
|
manager = _create_manager_with_settings(
|
|
tmp_path,
|
|
monkeypatch,
|
|
{
|
|
"mature_blur_level": "unsafe",
|
|
"folder_paths": {},
|
|
},
|
|
)
|
|
|
|
assert manager.get("mature_blur_level") == "R"
|
|
|
|
|
|
def test_model_name_display_setting_notifies_scanners(tmp_path, monkeypatch):
|
|
initial = {
|
|
"libraries": {
|
|
"default": {
|
|
"folder_paths": {},
|
|
"default_lora_root": "",
|
|
"default_checkpoint_root": "",
|
|
"default_embedding_root": "",
|
|
}
|
|
},
|
|
"active_library": "default",
|
|
"model_name_display": "model_name",
|
|
}
|
|
|
|
manager = _create_manager_with_settings(tmp_path, monkeypatch, initial)
|
|
|
|
loop = asyncio.new_event_loop()
|
|
setattr(loop, "_thread_id", 1)
|
|
|
|
class DummyScanner:
|
|
def __init__(self):
|
|
self.calls = []
|
|
self.loop = loop
|
|
|
|
async def on_model_name_display_changed(self, mode: str) -> None:
|
|
self.calls.append(mode)
|
|
|
|
dummy_scanner = DummyScanner()
|
|
|
|
dispatched_loops = []
|
|
futures = []
|
|
|
|
def tracking_run_coroutine_threadsafe(coro, target_loop):
|
|
dispatched_loops.append(target_loop)
|
|
future = Future()
|
|
futures.append(future)
|
|
try:
|
|
result = asyncio.run(coro)
|
|
except Exception as exc:
|
|
future.set_exception(exc)
|
|
else:
|
|
future.set_result(result)
|
|
return future
|
|
|
|
def fake_get_service_sync(cls, name):
|
|
return dummy_scanner if name == "lora_scanner" else None
|
|
|
|
monkeypatch.setattr(
|
|
service_registry.ServiceRegistry,
|
|
"get_service_sync",
|
|
classmethod(fake_get_service_sync),
|
|
)
|
|
monkeypatch.setattr(
|
|
asyncio, "run_coroutine_threadsafe", tracking_run_coroutine_threadsafe
|
|
)
|
|
|
|
try:
|
|
manager.set("model_name_display", "file_name")
|
|
|
|
for future in futures:
|
|
future.result(timeout=1)
|
|
|
|
assert dummy_scanner.calls == ["file_name"]
|
|
assert dispatched_loops == [dummy_scanner.loop]
|
|
finally:
|
|
setattr(loop, "_thread_id", None)
|
|
loop.close()
|
|
|
|
|
|
def test_migrates_legacy_settings_file(tmp_path, monkeypatch):
|
|
legacy_root = tmp_path / "legacy"
|
|
legacy_root.mkdir()
|
|
legacy_file = legacy_root / "settings.json"
|
|
legacy_file.write_text('{"value": 1}', encoding="utf-8")
|
|
|
|
target_dir = tmp_path / "config"
|
|
|
|
monkeypatch.setattr(settings_paths, "get_project_root", lambda: str(legacy_root))
|
|
monkeypatch.setattr(
|
|
settings_paths, "user_config_dir", lambda *_, **__: str(target_dir)
|
|
)
|
|
|
|
migrated_path = settings_paths.ensure_settings_file()
|
|
|
|
assert migrated_path == str(target_dir / "settings.json")
|
|
assert (target_dir / "settings.json").exists()
|
|
assert not legacy_file.exists()
|
|
|
|
|
|
def test_uses_portable_settings_file_when_enabled(tmp_path, monkeypatch):
|
|
repo_root = tmp_path / "repo"
|
|
repo_root.mkdir()
|
|
repo_settings = repo_root / "settings.json"
|
|
repo_settings.write_text(
|
|
json.dumps({"use_portable_settings": True, "value": 1}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
user_dir = tmp_path / "user"
|
|
|
|
monkeypatch.setattr(settings_paths, "get_project_root", lambda: str(repo_root))
|
|
monkeypatch.setattr(
|
|
settings_paths, "user_config_dir", lambda *_, **__: str(user_dir)
|
|
)
|
|
|
|
resolved = settings_paths.ensure_settings_file()
|
|
|
|
assert resolved == str(repo_settings)
|
|
assert repo_settings.exists()
|
|
assert not user_dir.exists()
|
|
|
|
|
|
def test_migrate_creates_default_library(manager):
|
|
libraries = manager.get_libraries()
|
|
assert "default" in libraries
|
|
assert manager.get_active_library_name() == "default"
|
|
assert libraries["default"].get("folder_paths", {}) == manager.settings.get(
|
|
"folder_paths", {}
|
|
)
|
|
|
|
|
|
def test_migrate_sanitizes_legacy_libraries(tmp_path, monkeypatch):
|
|
initial = {
|
|
"libraries": {"legacy": "not-a-dict"},
|
|
"active_library": "legacy",
|
|
"folder_paths": {"loras": ["/old"]},
|
|
}
|
|
|
|
manager = _create_manager_with_settings(tmp_path, monkeypatch, initial)
|
|
|
|
libraries = manager.get_libraries()
|
|
assert set(libraries.keys()) == {"legacy"}
|
|
payload = libraries["legacy"]
|
|
assert payload["folder_paths"] == {}
|
|
assert payload["default_lora_root"] == ""
|
|
assert payload["default_checkpoint_root"] == ""
|
|
assert payload["default_embedding_root"] == ""
|
|
assert payload["recipes_path"] == ""
|
|
assert manager.get_active_library_name() == "legacy"
|
|
|
|
|
|
def test_active_library_syncs_top_level_settings(tmp_path, monkeypatch):
|
|
initial = {
|
|
"libraries": {
|
|
"default": {
|
|
"folder_paths": {"loras": ["/loras"]},
|
|
"default_lora_root": "/loras",
|
|
"default_checkpoint_root": "/ckpt",
|
|
"default_embedding_root": "/embed",
|
|
"recipes_path": "/loras/recipes",
|
|
},
|
|
"studio": {
|
|
"folder_paths": {"loras": ["/studio"]},
|
|
"default_lora_root": "/studio",
|
|
"default_checkpoint_root": "/studio_ckpt",
|
|
"default_embedding_root": "/studio_embed",
|
|
"recipes_path": "/studio/custom-recipes",
|
|
},
|
|
},
|
|
"active_library": "studio",
|
|
# Drifted top-level values that should be corrected during init
|
|
"folder_paths": {"loras": ["/loras"]},
|
|
"default_lora_root": "/loras",
|
|
"default_checkpoint_root": "/ckpt",
|
|
"default_embedding_root": "/embed",
|
|
"recipes_path": "/loras/recipes",
|
|
}
|
|
|
|
manager = _create_manager_with_settings(tmp_path, monkeypatch, initial)
|
|
|
|
assert manager.get_active_library_name() == "studio"
|
|
assert manager.get("folder_paths")["loras"] == ["/studio"]
|
|
assert manager.get("default_lora_root") == "/studio"
|
|
assert manager.get("default_checkpoint_root") == "/studio_ckpt"
|
|
assert manager.get("default_embedding_root") == "/studio_embed"
|
|
assert manager.get("recipes_path") == "/studio/custom-recipes"
|
|
|
|
# Drift the top-level values again and ensure activate_library repairs them
|
|
manager.settings["folder_paths"] = {"loras": ["/loras"]}
|
|
manager.settings["default_lora_root"] = "/loras"
|
|
manager.settings["recipes_path"] = "/loras/recipes"
|
|
manager.activate_library("studio")
|
|
|
|
assert manager.get("folder_paths")["loras"] == ["/studio"]
|
|
assert manager.get("default_lora_root") == "/studio"
|
|
assert manager.get("recipes_path") == "/studio/custom-recipes"
|
|
|
|
|
|
def test_refresh_environment_variables_updates_stored_value(tmp_path, monkeypatch):
|
|
calls = []
|
|
|
|
def save_spy(self):
|
|
calls.append(self.settings.get("civitai_api_key"))
|
|
|
|
initial = {
|
|
"civitai_api_key": "stale",
|
|
"libraries": {
|
|
"default": {
|
|
"folder_paths": {},
|
|
"default_lora_root": "",
|
|
"default_checkpoint_root": "",
|
|
"default_embedding_root": "",
|
|
"recipes_path": "",
|
|
}
|
|
},
|
|
"active_library": "default",
|
|
}
|
|
|
|
monkeypatch.setenv("CIVITAI_API_KEY", "from-init")
|
|
manager = _create_manager_with_settings(
|
|
tmp_path, monkeypatch, initial, save_spy=save_spy
|
|
)
|
|
|
|
assert calls[-1] == "from-init"
|
|
|
|
monkeypatch.setenv("CIVITAI_API_KEY", "refreshed")
|
|
manager.refresh_environment_variables()
|
|
|
|
assert calls[-1] == "refreshed"
|
|
|
|
|
|
def test_upsert_library_creates_entry_and_activates(manager, tmp_path):
|
|
lora_dir = tmp_path / "loras"
|
|
lora_dir.mkdir()
|
|
|
|
manager.upsert_library(
|
|
"studio",
|
|
folder_paths={"loras": [str(lora_dir)]},
|
|
activate=True,
|
|
)
|
|
|
|
assert manager.get_active_library_name() == "studio"
|
|
libraries = manager.get_libraries()
|
|
stored_paths = libraries["studio"]["folder_paths"]["loras"]
|
|
normalized_stored_paths = [p.replace(os.sep, "/") for p in stored_paths]
|
|
assert str(lora_dir).replace(os.sep, "/") in normalized_stored_paths
|
|
|
|
|
|
def test_set_recipes_path_updates_active_library_entry(manager, tmp_path):
|
|
recipes_dir = tmp_path / "custom" / "recipes"
|
|
|
|
manager.set("recipes_path", str(recipes_dir))
|
|
|
|
assert manager.get("recipes_path") == str(recipes_dir.resolve())
|
|
assert (
|
|
manager.get_libraries()["default"]["recipes_path"]
|
|
== str(recipes_dir.resolve())
|
|
)
|
|
|
|
|
|
def test_set_recipes_path_migrates_existing_recipe_files(manager, tmp_path):
|
|
lora_root = tmp_path / "loras"
|
|
old_recipes_dir = lora_root / "recipes" / "nested"
|
|
old_recipes_dir.mkdir(parents=True)
|
|
manager.set("folder_paths", {"loras": [str(lora_root)]})
|
|
|
|
recipe_id = "recipe-1"
|
|
old_image_path = old_recipes_dir / f"{recipe_id}.webp"
|
|
old_json_path = old_recipes_dir / f"{recipe_id}.recipe.json"
|
|
old_image_path.write_bytes(b"image-bytes")
|
|
old_json_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"id": recipe_id,
|
|
"file_path": str(old_image_path),
|
|
"title": "Recipe 1",
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
new_recipes_dir = tmp_path / "custom_recipes"
|
|
manager.set("recipes_path", str(new_recipes_dir))
|
|
|
|
migrated_image_path = new_recipes_dir / "nested" / f"{recipe_id}.webp"
|
|
migrated_json_path = new_recipes_dir / "nested" / f"{recipe_id}.recipe.json"
|
|
|
|
assert manager.get("recipes_path") == str(new_recipes_dir.resolve())
|
|
assert migrated_image_path.read_bytes() == b"image-bytes"
|
|
migrated_payload = json.loads(migrated_json_path.read_text(encoding="utf-8"))
|
|
assert migrated_payload["file_path"] == str(migrated_image_path)
|
|
assert not old_image_path.exists()
|
|
assert not old_json_path.exists()
|
|
|
|
|
|
def test_clearing_recipes_path_migrates_files_to_default_location(manager, tmp_path):
|
|
lora_root = tmp_path / "loras"
|
|
custom_recipes_dir = tmp_path / "custom_recipes"
|
|
old_recipes_dir = custom_recipes_dir / "nested"
|
|
old_recipes_dir.mkdir(parents=True)
|
|
manager.set("folder_paths", {"loras": [str(lora_root)]})
|
|
manager.settings["recipes_path"] = str(custom_recipes_dir)
|
|
|
|
recipe_id = "recipe-2"
|
|
old_image_path = old_recipes_dir / f"{recipe_id}.webp"
|
|
old_json_path = old_recipes_dir / f"{recipe_id}.recipe.json"
|
|
old_image_path.write_bytes(b"image-bytes")
|
|
old_json_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"id": recipe_id,
|
|
"file_path": str(old_image_path),
|
|
"title": "Recipe 2",
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
manager.set("recipes_path", "")
|
|
|
|
fallback_recipes_dir = lora_root / "recipes"
|
|
migrated_image_path = fallback_recipes_dir / "nested" / f"{recipe_id}.webp"
|
|
migrated_json_path = fallback_recipes_dir / "nested" / f"{recipe_id}.recipe.json"
|
|
|
|
assert manager.get("recipes_path") == ""
|
|
assert migrated_image_path.read_bytes() == b"image-bytes"
|
|
migrated_payload = json.loads(migrated_json_path.read_text(encoding="utf-8"))
|
|
assert migrated_payload["file_path"] == str(migrated_image_path)
|
|
assert not old_image_path.exists()
|
|
assert not old_json_path.exists()
|
|
|
|
|
|
def test_moving_recipes_path_back_to_parent_directory_is_allowed(manager, tmp_path):
|
|
lora_root = tmp_path / "loras"
|
|
manager.set("folder_paths", {"loras": [str(lora_root)]})
|
|
|
|
source_recipes_dir = lora_root / "recipes" / "custom"
|
|
source_recipes_dir.mkdir(parents=True)
|
|
|
|
recipe_id = "recipe-parent"
|
|
old_image_path = source_recipes_dir / f"{recipe_id}.webp"
|
|
old_json_path = source_recipes_dir / f"{recipe_id}.recipe.json"
|
|
old_image_path.write_bytes(b"parent-bytes")
|
|
old_json_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"id": recipe_id,
|
|
"file_path": str(old_image_path),
|
|
"title": "Recipe Parent",
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
manager.settings["recipes_path"] = str(source_recipes_dir)
|
|
manager.set("recipes_path", str(lora_root / "recipes"))
|
|
|
|
migrated_image_path = lora_root / "recipes" / f"{recipe_id}.webp"
|
|
migrated_json_path = lora_root / "recipes" / f"{recipe_id}.recipe.json"
|
|
|
|
assert manager.get("recipes_path") == str((lora_root / "recipes").resolve())
|
|
assert migrated_image_path.read_bytes() == b"parent-bytes"
|
|
migrated_payload = json.loads(migrated_json_path.read_text(encoding="utf-8"))
|
|
assert migrated_payload["file_path"] == str(migrated_image_path)
|
|
assert not old_image_path.exists()
|
|
assert not old_json_path.exists()
|
|
|
|
|
|
def test_set_recipes_path_rewrites_symlinked_recipe_metadata(manager, tmp_path):
|
|
real_recipes_dir = tmp_path / "real_recipes"
|
|
real_recipes_dir.mkdir()
|
|
symlink_recipes_dir = tmp_path / "linked_recipes"
|
|
symlink_recipes_dir.symlink_to(real_recipes_dir, target_is_directory=True)
|
|
|
|
manager.settings["recipes_path"] = str(symlink_recipes_dir)
|
|
manager.set("folder_paths", {"loras": [str(tmp_path / "loras")]})
|
|
|
|
recipe_id = "recipe-symlink"
|
|
old_image_path = real_recipes_dir / f"{recipe_id}.webp"
|
|
old_json_path = real_recipes_dir / f"{recipe_id}.recipe.json"
|
|
old_image_path.write_bytes(b"symlink-bytes")
|
|
old_json_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"id": recipe_id,
|
|
"file_path": str(old_image_path),
|
|
"title": "Recipe Symlink",
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
new_recipes_dir = tmp_path / "migrated_recipes"
|
|
manager.set("recipes_path", str(new_recipes_dir))
|
|
|
|
migrated_image_path = new_recipes_dir / f"{recipe_id}.webp"
|
|
migrated_json_path = new_recipes_dir / f"{recipe_id}.recipe.json"
|
|
|
|
assert migrated_image_path.read_bytes() == b"symlink-bytes"
|
|
migrated_payload = json.loads(migrated_json_path.read_text(encoding="utf-8"))
|
|
assert migrated_payload["file_path"] == str(migrated_image_path)
|
|
assert not old_image_path.exists()
|
|
assert not old_json_path.exists()
|
|
|
|
|
|
def test_set_recipes_path_allows_cross_drive_migration(manager, tmp_path, monkeypatch):
|
|
# Windows regression: os.path.commonpath raises ValueError for paths on
|
|
# different drives (ntpath semantics). Cross-drive moves must succeed.
|
|
lora_root = tmp_path / "loras"
|
|
old_recipes_dir = lora_root / "recipes" / "nested"
|
|
old_recipes_dir.mkdir(parents=True)
|
|
manager.set("folder_paths", {"loras": [str(lora_root)]})
|
|
|
|
recipe_id = "recipe-cross-drive"
|
|
old_image_path = old_recipes_dir / f"{recipe_id}.webp"
|
|
old_json_path = old_recipes_dir / f"{recipe_id}.recipe.json"
|
|
old_image_path.write_bytes(b"image-bytes")
|
|
old_json_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"id": recipe_id,
|
|
"file_path": str(old_image_path),
|
|
"title": "Recipe Cross Drive",
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
new_recipes_dir = tmp_path / "N_drive" / "AI" / "Library" / "Recipes"
|
|
|
|
# The effective current recipes dir (source of the migration) is
|
|
# lora_root/recipes — the nested subdirectory holds the recipe files.
|
|
source = str(lora_root / "recipes")
|
|
target = str(new_recipes_dir)
|
|
real_commonpath = os.path.commonpath
|
|
|
|
def fake_commonpath(paths):
|
|
# Simulate ntpath on Windows: a source/target pair on different
|
|
# drives shares no common root and raises ValueError.
|
|
if {source, target} <= set(paths):
|
|
raise ValueError("Paths don't have the same drive")
|
|
return real_commonpath(paths)
|
|
|
|
monkeypatch.setattr(os.path, "commonpath", fake_commonpath)
|
|
|
|
manager.set("recipes_path", str(new_recipes_dir))
|
|
|
|
migrated_image_path = new_recipes_dir / "nested" / f"{recipe_id}.webp"
|
|
migrated_json_path = new_recipes_dir / "nested" / f"{recipe_id}.recipe.json"
|
|
|
|
assert manager.get("recipes_path") == str(new_recipes_dir.resolve())
|
|
assert migrated_image_path.read_bytes() == b"image-bytes"
|
|
migrated_payload = json.loads(migrated_json_path.read_text(encoding="utf-8"))
|
|
assert migrated_payload["file_path"] == str(migrated_image_path)
|
|
assert not old_image_path.exists()
|
|
assert not old_json_path.exists()
|
|
|
|
|
|
def test_set_recipes_path_rejects_file_target(manager, tmp_path):
|
|
lora_root = tmp_path / "loras"
|
|
lora_root.mkdir()
|
|
manager.set("folder_paths", {"loras": [str(lora_root)]})
|
|
|
|
target_file = tmp_path / "not_a_directory"
|
|
target_file.write_text("blocked", encoding="utf-8")
|
|
|
|
with pytest.raises(ValueError, match="directory"):
|
|
manager.set("recipes_path", str(target_file))
|
|
|
|
assert manager.get("recipes_path") == ""
|
|
|
|
|
|
def test_extra_folder_paths_stored_separately(manager, tmp_path):
|
|
lora_dir = tmp_path / "loras"
|
|
extra_dir = tmp_path / "extra_loras"
|
|
lora_dir.mkdir()
|
|
extra_dir.mkdir()
|
|
|
|
manager.upsert_library(
|
|
"test_library",
|
|
folder_paths={"loras": [str(lora_dir)]},
|
|
extra_folder_paths={"loras": [str(extra_dir)]},
|
|
activate=True,
|
|
)
|
|
|
|
libraries = manager.get_libraries()
|
|
lib = libraries["test_library"]
|
|
|
|
# Verify folder_paths contains main path
|
|
assert str(lora_dir) in lib["folder_paths"]["loras"]
|
|
# Verify extra_folder_paths contains extra path
|
|
assert str(extra_dir) in lib["extra_folder_paths"]["loras"]
|
|
# Verify they are separate
|
|
assert str(extra_dir) not in lib["folder_paths"]["loras"]
|
|
|
|
|
|
def test_get_extra_folder_paths(manager, tmp_path):
|
|
extra_dir = tmp_path / "extra_loras"
|
|
extra_dir.mkdir()
|
|
|
|
manager.update_extra_folder_paths({"loras": [str(extra_dir)]})
|
|
|
|
extra_paths = manager.get_extra_folder_paths()
|
|
assert str(extra_dir) in extra_paths.get("loras", [])
|
|
|
|
|
|
def test_library_switch_preserves_extra_paths(manager, tmp_path):
|
|
"""Test that switching libraries preserves each library's extra paths."""
|
|
lora_dir1 = tmp_path / "lib1_loras"
|
|
extra_dir1 = tmp_path / "lib1_extra"
|
|
lora_dir2 = tmp_path / "lib2_loras"
|
|
extra_dir2 = tmp_path / "lib2_extra"
|
|
|
|
for directory in (lora_dir1, extra_dir1, lora_dir2, extra_dir2):
|
|
directory.mkdir()
|
|
|
|
manager.create_library(
|
|
"library1",
|
|
folder_paths={"loras": [str(lora_dir1)]},
|
|
extra_folder_paths={"loras": [str(extra_dir1)]},
|
|
activate=True,
|
|
)
|
|
|
|
manager.create_library(
|
|
"library2",
|
|
folder_paths={"loras": [str(lora_dir2)]},
|
|
extra_folder_paths={"loras": [str(extra_dir2)]},
|
|
)
|
|
|
|
assert manager.get_active_library_name() == "library1"
|
|
lib1 = manager.get_active_library()
|
|
assert str(lora_dir1) in lib1["folder_paths"]["loras"]
|
|
assert str(extra_dir1) in lib1["extra_folder_paths"]["loras"]
|
|
|
|
manager.activate_library("library2")
|
|
|
|
assert manager.get_active_library_name() == "library2"
|
|
lib2 = manager.get_active_library()
|
|
assert str(lora_dir2) in lib2["folder_paths"]["loras"]
|
|
assert str(extra_dir2) in lib2["extra_folder_paths"]["loras"]
|
|
|
|
|
|
def test_extra_paths_validation_no_overlap_with_other_libraries(manager, tmp_path):
|
|
"""Test that extra paths cannot overlap with other libraries' paths."""
|
|
lora_dir1 = tmp_path / "lib1_loras"
|
|
lora_dir1.mkdir()
|
|
|
|
manager.create_library(
|
|
"library1",
|
|
folder_paths={"loras": [str(lora_dir1)]},
|
|
activate=True,
|
|
)
|
|
|
|
extra_dir = tmp_path / "extra_loras"
|
|
extra_dir.mkdir()
|
|
|
|
manager.create_library(
|
|
"library2",
|
|
folder_paths={"loras": [str(extra_dir)]},
|
|
activate=True,
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="already assigned to library"):
|
|
manager.update_extra_folder_paths({"loras": [str(lora_dir1)]})
|
|
|
|
|
|
def test_extra_paths_validation_no_overlap_with_active_primary_lora_root(
|
|
manager, tmp_path
|
|
):
|
|
"""Test that extra LoRA paths cannot overlap the active library primary LoRA roots."""
|
|
real_lora_dir = tmp_path / "loras_real"
|
|
real_lora_dir.mkdir()
|
|
lora_link = tmp_path / "loras_link"
|
|
lora_link.symlink_to(real_lora_dir, target_is_directory=True)
|
|
|
|
manager.create_library(
|
|
"library1",
|
|
folder_paths={"loras": [str(lora_link)]},
|
|
activate=True,
|
|
)
|
|
|
|
with pytest.raises(
|
|
ValueError, match="overlap with the active library's primary LoRA roots"
|
|
):
|
|
manager.update_extra_folder_paths({"loras": [str(real_lora_dir)]})
|
|
|
|
|
|
def test_extra_paths_validation_no_overlap_with_active_primary_lora_root_case_insensitive(
|
|
manager, monkeypatch, tmp_path
|
|
):
|
|
"""Overlap validation should treat differently-cased Windows-like paths as the same path."""
|
|
real_lora_dir = tmp_path / "loras_real"
|
|
real_lora_dir.mkdir()
|
|
lora_link = tmp_path / "loras_link"
|
|
lora_link.symlink_to(real_lora_dir, target_is_directory=True)
|
|
|
|
manager.create_library(
|
|
"library1",
|
|
folder_paths={"loras": [str(lora_link)]},
|
|
activate=True,
|
|
)
|
|
|
|
original_exists = settings_manager_module.os.path.exists
|
|
original_realpath = settings_manager_module.os.path.realpath
|
|
original_normcase = settings_manager_module.os.path.normcase
|
|
|
|
def fake_exists(path):
|
|
if isinstance(path, str) and path.lower() in {
|
|
str(lora_link).lower(),
|
|
str(real_lora_dir).lower(),
|
|
}:
|
|
return True
|
|
return original_exists(path)
|
|
|
|
def fake_realpath(path):
|
|
if isinstance(path, str) and path.lower() == str(lora_link).lower():
|
|
return str(real_lora_dir)
|
|
return original_realpath(path)
|
|
|
|
monkeypatch.setattr(settings_manager_module.os.path, "exists", fake_exists)
|
|
monkeypatch.setattr(settings_manager_module.os.path, "realpath", fake_realpath)
|
|
monkeypatch.setattr(
|
|
settings_manager_module.os.path,
|
|
"normcase",
|
|
lambda value: original_normcase(value).lower(),
|
|
)
|
|
|
|
with pytest.raises(
|
|
ValueError, match="overlap with the active library's primary LoRA roots"
|
|
):
|
|
manager.update_extra_folder_paths({"loras": [str(real_lora_dir).upper()]})
|
|
|
|
|
|
def test_extra_paths_validation_allows_missing_non_overlapping_lora_root(
|
|
manager, tmp_path
|
|
):
|
|
"""Missing non-overlapping extra LoRA paths should not be rejected."""
|
|
lora_dir = tmp_path / "loras"
|
|
lora_dir.mkdir()
|
|
missing_extra = tmp_path / "missing_loras"
|
|
|
|
manager.create_library(
|
|
"library1",
|
|
folder_paths={"loras": [str(lora_dir)]},
|
|
activate=True,
|
|
)
|
|
|
|
manager.update_extra_folder_paths({"loras": [str(missing_extra)]})
|
|
|
|
extra_paths = manager.get_extra_folder_paths()
|
|
assert extra_paths["loras"] == [str(missing_extra)]
|
|
|
|
|
|
def test_extra_paths_validation_rejects_primary_root_first_level_symlink_target(
|
|
manager, tmp_path
|
|
):
|
|
"""Extra LoRA paths should be rejected when already reachable via a first-level symlink under the primary root."""
|
|
lora_dir = tmp_path / "loras"
|
|
lora_dir.mkdir()
|
|
external_dir = tmp_path / "external_loras"
|
|
external_dir.mkdir()
|
|
link_dir = lora_dir / "link"
|
|
link_dir.symlink_to(external_dir, target_is_directory=True)
|
|
|
|
manager.create_library(
|
|
"library1",
|
|
folder_paths={"loras": [str(lora_dir)]},
|
|
activate=True,
|
|
)
|
|
|
|
with pytest.raises(
|
|
ValueError, match="overlap with the active library's primary LoRA roots"
|
|
):
|
|
manager.update_extra_folder_paths({"loras": [str(external_dir)]})
|
|
|
|
|
|
def test_delete_library_switches_active(manager, tmp_path):
|
|
other_dir = tmp_path / "other"
|
|
other_dir.mkdir()
|
|
|
|
manager.create_library(
|
|
"other",
|
|
folder_paths={"loras": [str(other_dir)]},
|
|
activate=True,
|
|
)
|
|
|
|
assert manager.get_active_library_name() == "other"
|
|
|
|
manager.delete_library("other")
|
|
|
|
assert manager.get_active_library_name() == "default"
|
|
|
|
|
|
def test_download_skip_base_models_are_normalized(manager):
|
|
manager.settings["download_skip_base_models"] = [
|
|
"SDXL 1.0",
|
|
"Invalid",
|
|
"SDXL 1.0",
|
|
"Pony",
|
|
"Other",
|
|
]
|
|
|
|
result = manager.get_download_skip_base_models()
|
|
|
|
assert result == ["SDXL 1.0", "Pony"]
|
|
assert manager.settings["download_skip_base_models"] == ["SDXL 1.0", "Pony"]
|
|
|
|
|
|
def test_setting_download_skip_base_models_normalizes_string_input(manager):
|
|
manager.set("download_skip_base_models", "SDXL 1.0, Pony; Invalid\nSDXL 1.0")
|
|
|
|
assert manager.get("download_skip_base_models") == ["SDXL 1.0", "Pony"]
|
|
|
|
|
|
def test_skip_previously_downloaded_model_versions_defaults_false(manager):
|
|
assert manager.get_skip_previously_downloaded_model_versions() is False
|
|
|
|
|
|
def test_skip_previously_downloaded_model_versions_coerces_string_input(manager):
|
|
manager.settings["skip_previously_downloaded_model_versions"] = "true"
|
|
|
|
assert manager.get_skip_previously_downloaded_model_versions() is True
|
|
assert manager.settings["skip_previously_downloaded_model_versions"] is True
|
|
|
|
|
|
def test_default_other_roots_stay_empty_without_other_folders(manager):
|
|
assert manager._get_default_settings()["default_other_roots"] == {}
|
|
|
|
manager.settings["default_other_roots"] = {}
|
|
manager.settings["folder_paths"] = {}
|
|
manager.settings["extra_folder_paths"] = {}
|
|
|
|
manager._auto_set_default_roots()
|
|
|
|
assert manager.get("default_other_roots") == {}
|
|
|
|
|
|
def test_other_models_disabled_by_default(manager):
|
|
assert manager.is_other_models_enabled() is False
|
|
assert manager.get_enabled_other_sub_types() == []
|
|
assert manager.is_other_sub_type_enabled("vae") is False
|
|
|
|
|
|
def test_default_enabled_other_sub_types_are_the_dependency_trio(manager):
|
|
"""clip_vision and controlnet are workflow-driven, so both stay opt-in."""
|
|
manager.settings["enable_other_models"] = True
|
|
|
|
assert manager.get_enabled_other_sub_types() == [
|
|
"vae",
|
|
"upscaler",
|
|
"text_encoder",
|
|
]
|
|
assert manager.is_other_sub_type_enabled("clip_vision") is False
|
|
assert manager.is_other_sub_type_enabled("controlnet") is False
|
|
|
|
|
|
def test_enabled_other_sub_types_falls_back_to_defaults(manager):
|
|
manager.settings["enable_other_models"] = True
|
|
manager.settings["enabled_other_sub_types"] = None
|
|
|
|
assert manager.get_enabled_other_sub_types() == [
|
|
"vae",
|
|
"upscaler",
|
|
"text_encoder",
|
|
]
|
|
|
|
|
|
def test_auto_set_default_other_roots_skipped_when_feature_off(manager):
|
|
manager.settings["enable_other_models"] = False
|
|
manager.settings["default_other_roots"] = {}
|
|
manager.settings["folder_paths"] = {"vae": ["/vae"]}
|
|
|
|
manager._auto_set_default_roots()
|
|
|
|
assert manager.get("default_other_roots") == {}
|
|
|
|
|
|
def test_set_enabled_other_sub_types_normalizes(manager):
|
|
manager.settings["enable_other_models"] = True
|
|
manager.set("enabled_other_sub_types", ["controlnet", "vae", "nope", "vae", 42])
|
|
|
|
assert manager.get("enabled_other_sub_types") == ["vae", "controlnet"]
|
|
assert manager.is_other_sub_type_enabled("vae") is True
|
|
assert manager.is_other_sub_type_enabled("upscaler") is False
|
|
|
|
|
|
def test_auto_set_default_other_roots(manager):
|
|
manager.settings["enable_other_models"] = True
|
|
manager.settings["default_other_roots"] = {}
|
|
# clip_vision is opt-in, so enable it explicitly for this iteration test.
|
|
manager.settings["enabled_other_sub_types"] = ["vae", "upscaler", "clip_vision"]
|
|
manager.settings["folder_paths"] = {
|
|
"vae": ["/vae"],
|
|
"upscale_models": ["/upscalers"],
|
|
"clip_vision": ["/clip_vision"],
|
|
}
|
|
|
|
manager._auto_set_default_roots()
|
|
|
|
roots = manager.get("default_other_roots")
|
|
assert roots["vae"] == "/vae"
|
|
assert roots["upscaler"] == "/upscalers"
|
|
assert roots["clip_vision"] == "/clip_vision"
|
|
# text_encoder has no configured folders -> no entry
|
|
assert "text_encoder" not in roots
|
|
assert "controlnet" not in roots
|
|
|
|
|
|
def test_auto_set_default_other_roots_text_encoder_dual_key_union(manager):
|
|
"""text_encoder candidates merge text_encoders and the legacy clip key."""
|
|
manager.settings["enable_other_models"] = True
|
|
manager.settings["default_other_roots"] = {}
|
|
manager.settings["folder_paths"] = {
|
|
"clip": ["/legacy-clip"],
|
|
"text_encoders": ["/text-encoders"],
|
|
}
|
|
|
|
manager._auto_set_default_roots()
|
|
|
|
roots = manager.get("default_other_roots")
|
|
assert roots["text_encoder"] in {"/legacy-clip", "/text-encoders"}
|
|
|
|
# A value pointing at either key's root is considered valid
|
|
manager.settings["default_other_roots"] = {"text_encoder": "/legacy-clip"}
|
|
manager._auto_set_default_roots()
|
|
assert manager.get("default_other_roots")["text_encoder"] == "/legacy-clip"
|
|
|
|
|
|
def test_auto_set_default_other_roots_repairs_stale(manager):
|
|
manager.settings["enable_other_models"] = True
|
|
manager.settings["default_other_roots"] = {"vae": "/stale-vae"}
|
|
manager.settings["folder_paths"] = {"vae": ["/vae"]}
|
|
|
|
manager._auto_set_default_roots()
|
|
|
|
assert manager.get("default_other_roots")["vae"] == "/vae"
|
|
|
|
|
|
def test_auto_set_default_other_roots_uses_extra_folder_paths(manager):
|
|
manager.settings["enable_other_models"] = True
|
|
manager.settings["default_other_roots"] = {}
|
|
manager.settings["folder_paths"] = {"vae": []}
|
|
manager.settings["extra_folder_paths"] = {"vae": ["/extra-vae"]}
|
|
|
|
manager._auto_set_default_roots()
|
|
|
|
assert manager.get("default_other_roots")["vae"] == "/extra-vae"
|
|
|
|
|
|
def test_set_default_other_roots_syncs_active_library(manager):
|
|
manager.set("default_other_roots", {"vae": "/vae"})
|
|
|
|
libraries = manager.get_libraries()
|
|
active = manager.get_active_library_name()
|
|
assert libraries[active]["default_other_roots"] == {"vae": "/vae"}
|
|
assert manager.get("default_other_roots") == {"vae": "/vae"}
|
|
|
|
|
|
def test_set_default_other_roots_rejects_illegal_sub_type(manager):
|
|
with pytest.raises(ValueError, match="Unknown other-model sub-type"):
|
|
manager.set("default_other_roots", {"vae": "/vae", "lora": "/loras"})
|
|
|
|
|
|
def test_set_default_other_roots_normalizes_values(manager):
|
|
manager.set("default_other_roots", {"vae": " /vae ", "upscaler": ""})
|
|
assert manager.get("default_other_roots") == {"vae": "/vae"}
|
|
|
|
|
|
def test_upsert_library_passthrough_default_other_roots(manager, tmp_path):
|
|
manager.upsert_library(
|
|
"studio",
|
|
folder_paths={"loras": ["/studio/loras"], "vae": ["/studio/vae"]},
|
|
default_other_roots={"vae": "/studio/vae"},
|
|
activate=True,
|
|
)
|
|
|
|
libraries = manager.get_libraries()
|
|
assert libraries["studio"]["default_other_roots"] == {"vae": "/studio/vae"}
|
|
assert manager.get("default_other_roots") == {"vae": "/studio/vae"}
|
|
|
|
# Omitting the argument preserves the stored value
|
|
manager.upsert_library("studio", folder_paths={"loras": ["/studio/loras"]})
|
|
libraries = manager.get_libraries()
|
|
assert libraries["studio"]["default_other_roots"] == {"vae": "/studio/vae"}
|
|
|
|
|
|
def test_library_switch_restores_default_other_roots(manager):
|
|
manager.set("default_other_roots", {"vae": "/default-vae"})
|
|
manager.create_library(
|
|
"studio",
|
|
folder_paths={"loras": ["/studio/loras"]},
|
|
default_other_roots={"vae": "/studio-vae"},
|
|
)
|
|
|
|
manager.activate_library("studio")
|
|
assert manager.get("default_other_roots") == {"vae": "/studio-vae"}
|
|
|
|
manager.activate_library("default")
|
|
assert manager.get("default_other_roots") == {"vae": "/default-vae"}
|
|
|
|
|
|
def test_migrate_sanitizes_legacy_libraries_includes_other_roots(tmp_path, monkeypatch):
|
|
initial = {
|
|
"libraries": {"legacy": "not-a-dict"},
|
|
"active_library": "legacy",
|
|
"folder_paths": {"loras": ["/old"]},
|
|
}
|
|
|
|
manager = _create_manager_with_settings(tmp_path, monkeypatch, initial)
|
|
|
|
payload = manager.get_libraries()["legacy"]
|
|
assert payload["default_other_roots"] == {}
|