Files
willmiao-ComfyUI-Lora-Manager/tests/services/test_settings_manager.py
T
Will Miao 3f93683231 fix(settings): validate download base-model settings against merged vocabulary
The download_skip_base_models and download_root_rules normalizers validated
against the hardcoded SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS frozenset only, so
base models known solely via the remote CivitAI snapshot (e.g. Qwen 3,
Flux.3) were silently dropped on save, on startup, and again at download
time.

CivitaiBaseModelService now persists the fetched snapshot to
{settings_dir}/cache/civitai_base_models.json and exposes a synchronous
get_known_base_models() (hardcoded + latest remote snapshot, stale snapshots
accepted on purpose) for validation contexts. The settings normalizers use
it with a hardcoded fallback, and DownloadManager drops its redundant
hardcoded membership check since the exclusion list is already validated.
2026-10-19 09:21:42 +08:00

1891 lines
64 KiB
Python

import asyncio
import copy
import json
import logging
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_multi_library_configuration_triggers_deprecation_warning(
tmp_path, monkeypatch, caplog
):
initial = {
"libraries": {
"default": {"folder_paths": {"loras": ["/loras-a"]}},
"archive": {"folder_paths": {"loras": ["/loras-b"]}},
},
"active_library": "default",
}
with caplog.at_level(logging.WARNING, logger="py.services.settings_manager"):
manager = _create_manager_with_settings(tmp_path, monkeypatch, initial)
assert any(
"Multi-library support is deprecated" in record.getMessage()
for record in caplog.records
)
deprecation = [
message
for message in manager.get_startup_messages()
if message["code"] == "multi-library-deprecated"
]
assert len(deprecation) == 1
assert deprecation[0]["severity"] == "warning"
assert deprecation[0]["dismissible"] is True
assert "archive" in deprecation[0]["message"]
def test_single_library_configuration_has_no_deprecation_warning(
tmp_path, monkeypatch, caplog
):
initial = {
"libraries": {
"default": {"folder_paths": {"loras": ["/loras-a"]}},
},
"active_library": "default",
}
with caplog.at_level(logging.WARNING, logger="py.services.settings_manager"):
manager = _create_manager_with_settings(tmp_path, monkeypatch, initial)
assert not any(
"Multi-library support is deprecated" in record.getMessage()
for record in caplog.records
)
assert not any(
message["code"] == "multi-library-deprecated"
for message in 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_unknown_base_model_routing_defaults_to_diffusion_model(manager):
assert manager.get("unknown_base_model_routing") == "diffusion_model"
def test_unknown_base_model_routing_is_normalized(manager):
manager.set("unknown_base_model_routing", "CHECKPOINT")
assert manager.get("unknown_base_model_routing") == "checkpoint"
manager.set("unknown_base_model_routing", "bogus")
assert manager.get("unknown_base_model_routing") == "diffusion_model"
manager.set("unknown_base_model_routing", None)
assert manager.get("unknown_base_model_routing") == "diffusion_model"
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"] == {}
def _configure_lora_roots(manager, *roots):
manager.settings["folder_paths"] = {"loras": [str(root) for root in roots]}
def test_download_root_rules_defaults_to_empty_per_type(manager):
assert manager.get_download_root_rules() == {
"loras": [],
"checkpoints": [],
"unet": [],
"embeddings": [],
}
def test_download_root_rules_normalize_keeps_valid_rules_in_order(manager):
_configure_lora_roots(manager, "/loras-main", "/loras-archive")
manager.settings["download_root_rules"] = {
"loras": [
{
"match_type": "base_model",
"match": ["Flux.1 D", "Wan Video"],
"root": "/loras-archive",
},
{"match_type": "base_model", "match": ["Pony"], "root": "/loras-main"},
]
}
result = manager.get_download_root_rules()
assert result["loras"] == [
{
"match_type": "base_model",
"match": ["Flux.1 D", "Wan Video"],
"root": "/loras-archive",
},
{"match_type": "base_model", "match": ["Pony"], "root": "/loras-main"},
]
assert manager.settings["download_root_rules"]["loras"] == result["loras"]
def test_download_root_rules_normalize_drops_invalid_match_values(manager):
_configure_lora_roots(manager, "/loras-archive")
manager.settings["download_root_rules"] = {
"loras": [
{
"match_type": "base_model",
"match": ["Flux.1 D", "Not A Model", "Flux.1 D", 42, ""],
"root": "/loras-archive",
}
]
}
result = manager.get_download_root_rules()
assert result["loras"] == [
{"match_type": "base_model", "match": ["Flux.1 D"], "root": "/loras-archive"}
]
def test_download_root_rules_normalize_drops_rule_with_unknown_root(
manager, caplog
):
_configure_lora_roots(manager, "/loras-main")
manager.settings["download_root_rules"] = {
"loras": [
{
"match_type": "base_model",
"match": ["Flux.1 D"],
"root": "/not-configured",
},
{"match_type": "base_model", "match": ["Pony"], "root": "/loras-main"},
]
}
with caplog.at_level(logging.WARNING):
result = manager.get_download_root_rules()
assert result["loras"] == [
{"match_type": "base_model", "match": ["Pony"], "root": "/loras-main"}
]
assert "/not-configured" in caplog.text
def test_download_root_rules_root_matches_with_slash_normalization(manager):
_configure_lora_roots(manager, "/loras-archive/")
manager.settings["download_root_rules"] = {
"loras": [
{"match_type": "base_model", "match": ["Flux.1 D"], "root": "/loras-archive"}
]
}
result = manager.get_download_root_rules()
assert result["loras"][0]["root"] == "/loras-archive"
def test_download_root_rules_accepts_extra_folder_paths_roots(manager):
_configure_lora_roots(manager, "/loras-main")
manager.settings["extra_folder_paths"] = {"loras": ["/loras-extra"]}
manager.settings["download_root_rules"] = {
"loras": [
{"match_type": "base_model", "match": ["Flux.1 D"], "root": "/loras-extra"}
]
}
result = manager.get_download_root_rules()
assert result["loras"][0]["root"] == "/loras-extra"
def test_download_root_rules_normalize_drops_unknown_match_type(manager, caplog):
_configure_lora_roots(manager, "/loras-archive")
manager.settings["download_root_rules"] = {
"loras": [
{"match_type": "tags", "match": ["Flux.1 D"], "root": "/loras-archive"}
]
}
with caplog.at_level(logging.WARNING):
result = manager.get_download_root_rules()
assert result["loras"] == []
assert "tags" in caplog.text
def test_download_root_rules_normalize_drops_malformed_input(manager):
_configure_lora_roots(manager, "/loras-archive")
assert manager.normalize_download_root_rules(None) == {
"loras": [],
"checkpoints": [],
"unet": [],
"embeddings": [],
}
assert manager.normalize_download_root_rules("not-a-dict")["loras"] == []
assert manager.normalize_download_root_rules({"loras": "not-a-list"})[
"loras"
] == []
assert manager.normalize_download_root_rules({"loras": ["not-a-dict", 42]})[
"loras"
] == []
# Unknown type keys are dropped; known keys survive.
result = manager.normalize_download_root_rules(
{
"videos": [
{"match_type": "base_model", "match": ["Pony"], "root": "/x"}
],
"loras": [
{
"match_type": "base_model",
"match": ["Flux.1 D"],
"root": "/loras-archive",
}
],
}
)
assert "videos" not in result
assert len(result["loras"]) == 1
def test_download_root_rules_normalize_drops_rule_with_empty_match(manager):
_configure_lora_roots(manager, "/loras-archive")
manager.settings["download_root_rules"] = {
"loras": [
{"match_type": "base_model", "match": ["Not A Model"], "root": "/loras-archive"}
]
}
result = manager.get_download_root_rules()
assert result["loras"] == []
def test_set_download_root_rules_normalizes(manager):
_configure_lora_roots(manager, "/loras-main", "/loras-archive")
manager.set(
"download_root_rules",
{
"loras": [
{
"match_type": "base_model",
"match": ["Flux.1 D", "Invalid"],
"root": "/loras-archive",
}
]
},
)
assert manager.get("download_root_rules")["loras"] == [
{"match_type": "base_model", "match": ["Flux.1 D"], "root": "/loras-archive"}
]
def test_download_root_rules_normalize_keeps_tag_rules_with_free_text(manager):
"""Tag rules accept arbitrary free-text tags (no base model whitelist),
trimmed and deduplicated case-insensitively."""
_configure_lora_roots(manager, "/loras-archive")
manager.settings["download_root_rules"] = {
"loras": [
{
"match_type": "tag",
"match": [" character ", "Style", "STYLE", 42, ""],
"root": "/loras-archive",
}
]
}
result = manager.get_download_root_rules()
assert result["loras"] == [
{
"match_type": "tag",
"match": ["character", "Style"],
"root": "/loras-archive",
}
]
def test_download_root_rules_normalize_drops_tag_rule_with_empty_match(manager, caplog):
_configure_lora_roots(manager, "/loras-archive")
manager.settings["download_root_rules"] = {
"loras": [
{"match_type": "tag", "match": ["", " "], "root": "/loras-archive"}
]
}
with caplog.at_level(logging.WARNING):
result = manager.get_download_root_rules()
assert result["loras"] == []
assert "tag" in caplog.text
def test_download_root_rules_normalize_preserves_mixed_rule_order(manager):
"""base_model and tag rules can be interleaved; order is significant."""
_configure_lora_roots(manager, "/nas", "/main")
manager.settings["download_root_rules"] = {
"loras": [
{"match_type": "tag", "match": ["character"], "root": "/nas"},
{"match_type": "base_model", "match": ["Pony"], "root": "/main"},
]
}
result = manager.get_download_root_rules()
assert [rule["match_type"] for rule in result["loras"]] == [
"tag",
"base_model",
]
assert [rule["root"] for rule in result["loras"]] == ["/nas", "/main"]
def test_download_root_rules_tag_rules_still_validate_root(manager, caplog):
_configure_lora_roots(manager, "/loras-main")
manager.settings["download_root_rules"] = {
"loras": [
{"match_type": "tag", "match": ["character"], "root": "/not-configured"}
]
}
with caplog.at_level(logging.WARNING):
result = manager.get_download_root_rules()
assert result["loras"] == []
assert "/not-configured" in caplog.text
def test_download_skip_base_models_accept_remote_only_models(manager, monkeypatch):
"""Base models missing from the hardcoded list (new CivitAI families) must
survive validation once the merged vocabulary knows them."""
monkeypatch.setattr(
settings_manager_module,
"_get_supported_base_models",
lambda: {"SDXL 1.0", "Qwen 3"},
)
manager.set("download_skip_base_models", ["Qwen 3", "SDXL 1.0", "Not A Model"])
assert manager.get("download_skip_base_models") == ["Qwen 3", "SDXL 1.0"]
def test_download_skip_base_models_fall_back_to_hardcoded_list(manager, monkeypatch):
"""When the merged vocabulary is unavailable, the hardcoded list decides."""
monkeypatch.setattr(
settings_manager_module,
"_get_supported_base_models",
lambda: {"SDXL 1.0"},
)
manager.set("download_skip_base_models", ["Qwen 3", "SDXL 1.0"])
assert manager.get("download_skip_base_models") == ["SDXL 1.0"]
def test_download_root_rules_accept_remote_only_base_models(manager, monkeypatch):
monkeypatch.setattr(
settings_manager_module,
"_get_supported_base_models",
lambda: {"Flux.1 D", "Flux.3"},
)
_configure_lora_roots(manager, "/loras-archive")
manager.settings["download_root_rules"] = {
"loras": [
{
"match_type": "base_model",
"match": ["Flux.3", "Flux.1 D"],
"root": "/loras-archive",
}
]
}
result = manager.get_download_root_rules()
assert result["loras"] == [
{
"match_type": "base_model",
"match": ["Flux.3", "Flux.1 D"],
"root": "/loras-archive",
}
]