feat: Add extra folder paths support for LoRA Manager

Introduce extra_folder_paths feature to allow users to add additional
model roots that are managed by LoRA Manager but not shared with ComfyUI.

Changes:
- Add extra_folder_paths support in SettingsManager (stored per library)
- Add extra path attributes in Config class (extra_loras_roots, etc.)
- Merge folder_paths with extra_folder_paths when applying library settings
- Update LoraScanner, CheckpointScanner, EmbeddingScanner to include
  extra paths in their model roots
- Add comprehensive tests for the new functionality

This enables users to manage models from additional directories without
modifying ComfyUI's model folder configuration.
This commit is contained in:
Will Miao
2026-02-25 18:16:17 +08:00
parent 8ecdd016e6
commit 87b462192b
8 changed files with 401 additions and 17 deletions

View File

@@ -215,3 +215,110 @@ def test_save_paths_removes_template_default_library(monkeypatch, tmp_path):
)
assert payload["metadata"] == {"display_name": "ComfyUI", "source": "comfyui"}
assert payload["activate"] is True
def test_apply_library_settings_merges_extra_paths(monkeypatch, tmp_path):
"""Test that apply_library_settings correctly merges folder_paths with extra_folder_paths."""
loras_dir = tmp_path / "loras"
extra_loras_dir = tmp_path / "extra_loras"
checkpoints_dir = tmp_path / "checkpoints"
extra_checkpoints_dir = tmp_path / "extra_checkpoints"
embeddings_dir = tmp_path / "embeddings"
extra_embeddings_dir = tmp_path / "extra_embeddings"
for directory in (loras_dir, extra_loras_dir, checkpoints_dir, extra_checkpoints_dir, embeddings_dir, extra_embeddings_dir):
directory.mkdir()
config_instance = config_module.Config()
folder_paths = {
"loras": [str(loras_dir)],
"checkpoints": [str(checkpoints_dir)],
"unet": [],
"embeddings": [str(embeddings_dir)],
}
extra_folder_paths = {
"loras": [str(extra_loras_dir)],
"checkpoints": [str(extra_checkpoints_dir)],
"unet": [],
"embeddings": [str(extra_embeddings_dir)],
}
library_config = {
"folder_paths": folder_paths,
"extra_folder_paths": extra_folder_paths,
}
config_instance.apply_library_settings(library_config)
assert str(loras_dir) in config_instance.loras_roots
assert str(extra_loras_dir) in config_instance.extra_loras_roots
assert str(checkpoints_dir) in config_instance.base_models_roots
assert str(extra_checkpoints_dir) in config_instance.extra_checkpoints_roots
assert str(embeddings_dir) in config_instance.embeddings_roots
assert str(extra_embeddings_dir) in config_instance.extra_embeddings_roots
def test_apply_library_settings_without_extra_paths(monkeypatch, tmp_path):
"""Test that apply_library_settings works when extra_folder_paths is not provided."""
loras_dir = tmp_path / "loras"
checkpoints_dir = tmp_path / "checkpoints"
embeddings_dir = tmp_path / "embeddings"
for directory in (loras_dir, checkpoints_dir, embeddings_dir):
directory.mkdir()
config_instance = config_module.Config()
folder_paths = {
"loras": [str(loras_dir)],
"checkpoints": [str(checkpoints_dir)],
"unet": [],
"embeddings": [str(embeddings_dir)],
}
library_config = {
"folder_paths": folder_paths,
}
config_instance.apply_library_settings(library_config)
assert str(loras_dir) in config_instance.loras_roots
assert config_instance.extra_loras_roots == []
assert str(checkpoints_dir) in config_instance.base_models_roots
assert config_instance.extra_checkpoints_roots == []
assert str(embeddings_dir) in config_instance.embeddings_roots
assert config_instance.extra_embeddings_roots == []
def test_extra_paths_deduplication(monkeypatch, tmp_path):
"""Test that extra paths are stored separately from main paths in Config."""
loras_dir = tmp_path / "loras"
extra_loras_dir = tmp_path / "extra_loras"
loras_dir.mkdir()
extra_loras_dir.mkdir()
config_instance = config_module.Config()
folder_paths = {
"loras": [str(loras_dir)],
"checkpoints": [],
"unet": [],
"embeddings": [],
}
extra_folder_paths = {
"loras": [str(extra_loras_dir)],
"checkpoints": [],
"unet": [],
"embeddings": [],
}
library_config = {
"folder_paths": folder_paths,
"extra_folder_paths": extra_folder_paths,
}
config_instance.apply_library_settings(library_config)
assert config_instance.loras_roots == [str(loras_dir)]
assert config_instance.extra_loras_roots == [str(extra_loras_dir)]

View File

@@ -132,4 +132,59 @@ async def test_persisted_cache_restores_model_type(tmp_path: Path, monkeypatch):
assert types_by_path[normalized_unet_file] == "diffusion_model"
assert ws_stub.payloads
assert ws_stub.payloads[-1]["stage"] == "loading_cache"
@pytest.mark.asyncio
async def test_checkpoint_scanner_get_model_roots_includes_extra_paths(monkeypatch, tmp_path):
"""Test that get_model_roots includes both main and extra paths."""
checkpoints_root = tmp_path / "checkpoints"
extra_checkpoints_root = tmp_path / "extra_checkpoints"
unet_root = tmp_path / "unet"
extra_unet_root = tmp_path / "extra_unet"
for directory in (checkpoints_root, extra_checkpoints_root, unet_root, extra_unet_root):
directory.mkdir()
normalized_checkpoints = _normalize(checkpoints_root)
normalized_extra_checkpoints = _normalize(extra_checkpoints_root)
normalized_unet = _normalize(unet_root)
normalized_extra_unet = _normalize(extra_unet_root)
monkeypatch.setattr(
model_scanner.config,
"base_models_roots",
[normalized_checkpoints, normalized_unet],
raising=False,
)
monkeypatch.setattr(
model_scanner.config,
"checkpoints_roots",
[normalized_checkpoints],
raising=False,
)
monkeypatch.setattr(
model_scanner.config,
"unet_roots",
[normalized_unet],
raising=False,
)
monkeypatch.setattr(
model_scanner.config,
"extra_checkpoints_roots",
[normalized_extra_checkpoints],
raising=False,
)
monkeypatch.setattr(
model_scanner.config,
"extra_unet_roots",
[normalized_extra_unet],
raising=False,
)
scanner = CheckpointScanner()
roots = scanner.get_model_roots()
assert normalized_checkpoints in roots
assert normalized_unet in roots
assert normalized_extra_checkpoints in roots
assert normalized_extra_unet in roots

View File

@@ -470,6 +470,100 @@ def test_upsert_library_creates_entry_and_activates(manager, tmp_path):
assert str(lora_dir).replace(os.sep, "/") in normalized_stored_paths
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_delete_library_switches_active(manager, tmp_path):
other_dir = tmp_path / "other"
other_dir.mkdir()