2081 lines
71 KiB
Python
2081 lines
71 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_is_consolidated(tmp_path, monkeypatch):
|
|
initial = {
|
|
"libraries": {
|
|
"default": {
|
|
"folder_paths": {"loras": ["/loras-a"]},
|
|
"extra_folder_paths": {"loras": ["/extra-a"]},
|
|
"default_lora_root": "/loras-a",
|
|
"workflow_path": "/workflows-a",
|
|
"recipes_path": "/recipes-a",
|
|
},
|
|
"archive": {
|
|
"folder_paths": {
|
|
"loras": ["/loras-b", "/loras-a"],
|
|
"checkpoints": ["/checkpoints-b"],
|
|
},
|
|
"extra_folder_paths": {"loras": ["/extra-b"]},
|
|
"default_lora_root": "/loras-b",
|
|
"workflow_path": "/workflows-b",
|
|
"recipes_path": "/recipes-b",
|
|
},
|
|
},
|
|
"active_library": "default",
|
|
}
|
|
|
|
manager = _create_manager_with_settings(tmp_path, monkeypatch, initial)
|
|
|
|
assert manager.get_active_library_name() == "default"
|
|
assert list(manager.get_libraries()) == ["default"]
|
|
assert manager.get("folder_paths") == {
|
|
"loras": ["/loras-a", "/loras-b"],
|
|
"checkpoints": ["/checkpoints-b"],
|
|
}
|
|
assert manager.get("extra_folder_paths") == {
|
|
"loras": ["/extra-a", "/extra-b"]
|
|
}
|
|
assert manager.get("default_lora_root") == "/loras-a"
|
|
assert manager.get("workflow_path") == _normalized_workflow_path("/workflows-a")
|
|
assert manager.get("recipes_path") == "/recipes-a"
|
|
migration = [
|
|
message
|
|
for message in manager.get_startup_messages()
|
|
if message["code"] == "multi-library-consolidated"
|
|
]
|
|
assert len(migration) == 1
|
|
assert migration[0]["severity"] == "info"
|
|
assert "archive" in migration[0]["message"]
|
|
|
|
|
|
def test_single_library_configuration_has_no_consolidation_message(
|
|
tmp_path, monkeypatch
|
|
):
|
|
initial = {
|
|
"libraries": {
|
|
"default": {"folder_paths": {"loras": ["/loras-a"]}},
|
|
},
|
|
"active_library": "default",
|
|
}
|
|
|
|
manager = _create_manager_with_settings(tmp_path, monkeypatch, initial)
|
|
|
|
assert not any(
|
|
message["code"] == "multi-library-consolidated"
|
|
for message in manager.get_startup_messages()
|
|
)
|
|
|
|
|
|
def test_multi_library_consolidation_is_persisted(tmp_path, monkeypatch):
|
|
settings_path = tmp_path / "settings.json"
|
|
settings_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"libraries": {
|
|
"default": {"folder_paths": {"loras": ["/loras-a"]}},
|
|
"archive": {"folder_paths": {"loras": ["/loras-b"]}},
|
|
},
|
|
"active_library": "default",
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(
|
|
"py.services.settings_manager.ensure_settings_file",
|
|
lambda logger=None: str(settings_path),
|
|
)
|
|
|
|
first = SettingsManager()
|
|
stored = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
assert "libraries" not in stored
|
|
assert "active_library" not in stored
|
|
assert stored["folder_paths"]["loras"] == ["/loras-a", "/loras-b"]
|
|
|
|
second = SettingsManager()
|
|
assert second.get("folder_paths")["loras"] == ["/loras-a", "/loras-b"]
|
|
assert not any(
|
|
message["code"] == "multi-library-consolidated"
|
|
for message in second.get_startup_messages()
|
|
)
|
|
assert first.get_active_library_name() == "default"
|
|
|
|
|
|
def test_single_library_registry_is_removed_and_caches_migrated(
|
|
tmp_path, monkeypatch
|
|
):
|
|
settings_path = tmp_path / "settings.json"
|
|
extra_folder_paths = {
|
|
"loras": ["/extra-loras"],
|
|
"checkpoints": ["/extra-checkpoints"],
|
|
}
|
|
settings_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"libraries": {
|
|
"comfyui": {
|
|
"folder_paths": {"loras": ["/loras"]},
|
|
"extra_folder_paths": extra_folder_paths,
|
|
"default_lora_root": "/loras",
|
|
}
|
|
},
|
|
"active_library": "comfyui",
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(
|
|
"py.services.settings_manager.ensure_settings_file",
|
|
lambda logger=None: str(settings_path),
|
|
)
|
|
monkeypatch.setattr(
|
|
"py.utils.cache_paths.get_settings_dir",
|
|
lambda create=True: str(tmp_path),
|
|
)
|
|
for subdir in ("model", "model_update", "recipe"):
|
|
cache = tmp_path / "cache" / subdir / "comfyui.sqlite"
|
|
cache.parent.mkdir(parents=True, exist_ok=True)
|
|
cache.write_text(subdir, encoding="utf-8")
|
|
|
|
manager = SettingsManager()
|
|
|
|
stored = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
assert "libraries" not in stored
|
|
assert "active_library" not in stored
|
|
assert stored["folder_paths"] == {"loras": ["/loras"]}
|
|
assert stored["extra_folder_paths"] == extra_folder_paths
|
|
assert stored["default_lora_root"] == "/loras"
|
|
for subdir in ("model", "model_update", "recipe"):
|
|
assert (tmp_path / "cache" / subdir / "default.sqlite").read_text(
|
|
encoding="utf-8"
|
|
) == subdir
|
|
assert not (tmp_path / "cache" / subdir / "comfyui.sqlite").exists()
|
|
assert manager.get("folder_paths") == {"loras": ["/loras"]}
|
|
assert manager.get("extra_folder_paths") == extra_folder_paths
|
|
|
|
restarted = SettingsManager()
|
|
|
|
stored = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
assert restarted.get("extra_folder_paths") == extra_folder_paths
|
|
assert stored["extra_folder_paths"] == extra_folder_paths
|
|
|
|
|
|
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_defaults_to_base_model_for_workflow(manager):
|
|
"""Workflow downloads and Auto-Organize share this template. Local
|
|
workflows rarely carry CivitAI tags, so the default stops at the base
|
|
model instead of adding a "no tags" folder."""
|
|
manager.settings["download_path_templates"] = {}
|
|
|
|
assert manager.get_download_path_template("workflow") == "{base_model}"
|
|
|
|
# An explicit user configuration still wins.
|
|
manager.settings["download_path_templates"] = {"workflow": "{first_tag}"}
|
|
assert manager.get_download_path_template("workflow") == "{first_tag}"
|
|
|
|
|
|
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", "/loras"]
|
|
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"
|
|
|
|
|
|
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_effective_recipes_dir_falls_back_to_config_lora_roots(
|
|
manager, tmp_path, monkeypatch
|
|
):
|
|
from py.config import config
|
|
|
|
lora_root = tmp_path / "config_loras"
|
|
monkeypatch.setattr(config, "loras_roots", [str(lora_root)])
|
|
manager.settings["folder_paths"] = {}
|
|
|
|
assert manager._get_effective_recipes_dir("") == os.path.abspath(
|
|
os.path.join(str(lora_root), "recipes")
|
|
)
|
|
|
|
|
|
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_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.upsert_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.upsert_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.upsert_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.upsert_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.upsert_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_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",
|
|
}
|
|
]
|
|
|
|
|
|
def _normalized_workflow_path(path: str) -> str:
|
|
"""Apply the production normalization so POSIX-style inputs also work on Windows."""
|
|
|
|
return os.path.abspath(os.path.normpath(os.path.expanduser(path)))
|
|
|
|
|
|
def test_get_workflow_path_defaults_to_empty(manager):
|
|
assert manager.get_workflow_path() == ""
|
|
|
|
|
|
def test_set_workflow_path_stores_single_path_and_syncs_library(manager):
|
|
_configure_lora_roots(manager, "/loras-main")
|
|
|
|
manager.set("workflow_path", " /workflows ")
|
|
|
|
assert manager.get_workflow_path() == _normalized_workflow_path("/workflows")
|
|
# Core folder_paths stay untouched: workflow_path is its own setting.
|
|
assert manager.settings["folder_paths"] == {"loras": ["/loras-main"]}
|
|
active = manager.get_active_library()
|
|
assert active.get("workflow_path") == _normalized_workflow_path("/workflows")
|
|
assert "workflows" not in active.get("folder_paths", {})
|
|
|
|
|
|
def test_set_workflow_path_empty_clears_setting(manager):
|
|
manager.set("workflow_path", "/workflows")
|
|
|
|
manager.set("workflow_path", "")
|
|
|
|
assert manager.get_workflow_path() == ""
|
|
assert manager.get_active_library().get("workflow_path") == ""
|
|
|
|
|
|
def test_workflow_path_syncs_on_library_switch(manager):
|
|
manager.set("workflow_path", "/workflows")
|
|
manager.settings["libraries"]["other"] = {
|
|
**manager.get_active_library(),
|
|
"workflow_path": "/other-workflows",
|
|
}
|
|
manager.settings["active_library"] = "other"
|
|
|
|
manager._sync_active_library_to_root(save=False)
|
|
|
|
assert manager.get_workflow_path() == _normalized_workflow_path("/other-workflows")
|
|
|
|
|
|
def test_legacy_workflow_paths_migrate_to_scalar_setting(manager, tmp_path):
|
|
existing = tmp_path / "workflows"
|
|
existing.mkdir()
|
|
missing = tmp_path / "missing-workflows"
|
|
|
|
active_name = manager.get_active_library_name()
|
|
library = manager.settings["libraries"][active_name]
|
|
library["folder_paths"] = {
|
|
"loras": ["/loras-main"],
|
|
"workflows": [str(missing), str(existing)],
|
|
}
|
|
manager.settings["folder_paths"] = dict(library["folder_paths"])
|
|
manager.settings["workflow_path"] = ""
|
|
|
|
manager._migrate_legacy_workflow_paths()
|
|
|
|
# The first existing folder wins; the legacy key is cleaned up.
|
|
assert manager.get_workflow_path() == str(existing)
|
|
assert manager.settings["workflow_path"] == str(existing)
|
|
assert "workflows" not in manager.settings["folder_paths"]
|
|
assert "workflows" not in manager.get_active_library()["folder_paths"]
|
|
assert "loras" in manager.settings["folder_paths"]
|
|
|
|
|
|
def test_legacy_extra_workflow_paths_migrate_to_scalar_setting(manager):
|
|
active_name = manager.get_active_library_name()
|
|
library = manager.settings["libraries"][active_name]
|
|
library["extra_folder_paths"] = {"workflows": ["/extra-workflows"]}
|
|
library["folder_paths"] = {"loras": ["/loras-main"]}
|
|
manager.settings["workflow_path"] = ""
|
|
|
|
manager._migrate_legacy_workflow_paths()
|
|
|
|
assert manager.get_workflow_path() == _normalized_workflow_path("/extra-workflows")
|
|
assert "workflows" not in manager.get_active_library()["extra_folder_paths"]
|
|
|
|
|
|
def test_existing_workflow_path_wins_over_legacy_lists(manager):
|
|
active_name = manager.get_active_library_name()
|
|
library = manager.settings["libraries"][active_name]
|
|
library["workflow_path"] = "/new-workflows"
|
|
library["folder_paths"] = {"workflows": ["/old-workflows"]}
|
|
manager.settings["workflow_path"] = "/new-workflows"
|
|
|
|
manager._migrate_legacy_workflow_paths()
|
|
|
|
assert manager.get_workflow_path() == _normalized_workflow_path("/new-workflows")
|
|
assert "workflows" not in manager.get_active_library()["folder_paths"]
|
|
|
|
|
|
def test_top_level_legacy_workflow_path_migrates_into_active_library(manager):
|
|
active_name = manager.get_active_library_name()
|
|
manager.settings["libraries"][active_name]["folder_paths"] = {
|
|
"loras": ["/loras-main"]
|
|
}
|
|
manager.settings["libraries"][active_name]["workflow_path"] = ""
|
|
manager.settings["folder_paths"] = {
|
|
"loras": ["/loras-main"],
|
|
"workflows": ["/top-level-workflows"],
|
|
}
|
|
manager.settings["workflow_path"] = ""
|
|
|
|
manager._migrate_legacy_workflow_paths()
|
|
|
|
expected = _normalized_workflow_path("/top-level-workflows")
|
|
assert manager.get_workflow_path() == expected
|
|
# Mirrored into the library so the next startup's sync keeps it.
|
|
assert manager.get_active_library()["workflow_path"] == expected
|
|
|
|
|
|
def test_settings_load_migrates_legacy_workflow_folder(tmp_path, monkeypatch):
|
|
settings_path = tmp_path / "settings.json"
|
|
settings_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"enable_workflows": True,
|
|
"folder_paths": {
|
|
"loras": ["/loras-main"],
|
|
"workflows": ["/legacy-workflows"],
|
|
},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(
|
|
"py.services.settings_manager.ensure_settings_file",
|
|
lambda logger=None: str(settings_path),
|
|
)
|
|
monkeypatch.setattr(SettingsManager, "_save_settings", lambda self: None)
|
|
|
|
manager = SettingsManager()
|
|
|
|
assert manager.get_workflow_path() == _normalized_workflow_path("/legacy-workflows")
|
|
assert "workflows" not in manager.settings["folder_paths"]
|