mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-08 10:32:13 -03:00
fix(sidebar): keep the recipes storage dir out of folder management
The default recipes dir (<first lora root>/recipes) is auto-created by the recipe scanner and recorded by the model-root walks as an empty folder, so it showed up in the folder sidebar — and since recipe files are not model weight files, the folder delete endpoint's model_count check let a right-click delete wipe the whole recipe library via shutil.rmtree. - scan walks (full scan, reconcile, all-folders backfill) no longer descend into the effective recipes dir, and get_all_folders() filters it out of snapshots persisted before the exclusion existed - delete_folder/rename_folder refuse the recipes dir and any ancestor of it with code "protected" - the delete modal renders a protected state (confirm hidden, per-row checkbox disabled) instead of a deletable 'no models' prediction - RecipeScanner.recipes_dir now shares the side-effect-free resolver in py/utils/recipes_paths.py
This commit is contained in:
@@ -628,3 +628,86 @@ async def test_rename_folder_requires_path(tmp_path: Path):
|
||||
result = await service.rename_folder("", "renamed")
|
||||
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_refuses_the_recipes_dir(tmp_path: Path, monkeypatch):
|
||||
"""The default recipes dir lives in the first lora root and holds no model
|
||||
weight files, so the model_count check alone would let it be wiped."""
|
||||
from py.config import config
|
||||
|
||||
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
target = tmp_path / "recipes"
|
||||
target.mkdir()
|
||||
recipe_file = target / "abc123.recipe.json"
|
||||
recipe_file.write_text("{}", encoding="utf-8")
|
||||
|
||||
result = await service.delete_folder(str(target))
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["code"] == "protected"
|
||||
assert recipe_file.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_refuses_an_ancestor_of_the_recipes_dir(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
"""Deleting a parent of the recipes dir wipes the library just the same."""
|
||||
from py.services.settings_manager import get_settings_manager
|
||||
|
||||
recipes_dir = tmp_path / "data" / "recipes"
|
||||
recipes_dir.mkdir(parents=True)
|
||||
manager = get_settings_manager()
|
||||
monkeypatch.setitem(manager.settings, "recipes_path", str(recipes_dir))
|
||||
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.delete_folder(str(tmp_path / "data"))
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["code"] == "protected"
|
||||
assert recipes_dir.is_dir()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_allows_normal_folders_next_to_the_recipes_dir(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
from py.config import config
|
||||
|
||||
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
|
||||
(tmp_path / "recipes").mkdir()
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
target = tmp_path / "recipes_backup"
|
||||
target.mkdir()
|
||||
|
||||
result = await service.delete_folder(str(target))
|
||||
|
||||
assert result["success"] is True
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_refuses_the_recipes_dir(tmp_path: Path, monkeypatch):
|
||||
from py.config import config
|
||||
|
||||
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
target = tmp_path / "recipes"
|
||||
target.mkdir()
|
||||
|
||||
result = await service.rename_folder(str(target), "recipes2")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["code"] == "protected"
|
||||
assert target.is_dir()
|
||||
assert scanner.renamed_folders == []
|
||||
|
||||
@@ -2859,3 +2859,93 @@ def test_root_display_labels_dedupe_by_parent_segments(monkeypatch):
|
||||
label = _root_display_labels([long_root])[long_root]
|
||||
assert len(label) <= 40
|
||||
assert label.endswith("loras")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_excludes_recipes_dir_from_all_folders(tmp_path: Path, monkeypatch):
|
||||
"""The default recipes dir lives under the first lora root; it is a
|
||||
reserved directory and must never appear in the folder tree."""
|
||||
_create_files(tmp_path)
|
||||
recipes = tmp_path / "recipes"
|
||||
(recipes / "sub").mkdir(parents=True)
|
||||
# A stray weight file inside the recipes dir is not indexed either.
|
||||
(recipes / "stray.txt").write_text("stray", encoding="utf-8")
|
||||
monkeypatch.setattr(model_scanner.config, "loras_roots", [str(tmp_path)])
|
||||
|
||||
scanner = DummyScanner(tmp_path)
|
||||
await scanner._initialize_cache()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
assert not any("/recipes/" in item["file_path"] for item in cache.raw_data)
|
||||
all_folders = await scanner.get_all_folders()
|
||||
assert "recipes" not in all_folders
|
||||
assert "recipes/sub" not in all_folders
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_folders_backfill_excludes_recipes_dir(tmp_path: Path, monkeypatch):
|
||||
_create_files(tmp_path)
|
||||
(tmp_path / "recipes").mkdir()
|
||||
monkeypatch.setattr(model_scanner.config, "loras_roots", [str(tmp_path)])
|
||||
|
||||
scanner = DummyScanner(tmp_path)
|
||||
await scanner._initialize_cache()
|
||||
cache = await scanner.get_cached_data()
|
||||
cache.all_folders = None
|
||||
|
||||
await scanner.get_all_folders()
|
||||
for _ in range(200):
|
||||
if not scanner._all_folders_backfill_running:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
assert scanner._all_folders_backfill_running is False
|
||||
assert cache.all_folders is not None
|
||||
assert "recipes" not in cache.all_folders
|
||||
assert "nested" in cache.all_folders
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_folders_filters_recipes_dir_from_legacy_snapshots(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
"""Snapshots persisted before the walk exclusion still carry the recipes
|
||||
dir; readers filter it out until the next scan rewrites the list."""
|
||||
_create_files(tmp_path)
|
||||
monkeypatch.setattr(model_scanner.config, "loras_roots", [str(tmp_path)])
|
||||
|
||||
scanner = DummyScanner(tmp_path)
|
||||
await scanner._initialize_cache()
|
||||
cache = await scanner.get_cached_data()
|
||||
cache.all_folders = sorted(
|
||||
set(cache.all_folders or []) | {"recipes", "recipes/sub"},
|
||||
key=lambda value: value.lower(),
|
||||
)
|
||||
|
||||
all_folders = await scanner.get_all_folders()
|
||||
|
||||
assert "recipes" not in all_folders
|
||||
assert "recipes/sub" not in all_folders
|
||||
assert "nested" in all_folders
|
||||
|
||||
|
||||
def test_reconcile_walk_skips_the_recipes_dir(tmp_path: Path, monkeypatch):
|
||||
(tmp_path / "recipes" / "sub").mkdir(parents=True)
|
||||
(tmp_path / "keep").mkdir()
|
||||
monkeypatch.setattr(model_scanner.config, "loras_roots", [str(tmp_path)])
|
||||
|
||||
result = model_scanner._walk_root_for_reconcile(
|
||||
root_path=str(tmp_path),
|
||||
file_extensions={".txt"},
|
||||
cached_paths=set(),
|
||||
path_to_item={},
|
||||
lookups=model_scanner._CachedPathLookups(set()),
|
||||
dir_claims=model_scanner._RealDirClaims(),
|
||||
excluded_models=set(),
|
||||
is_cancelled=lambda: False,
|
||||
report_progress=lambda _files_seen: None,
|
||||
)
|
||||
|
||||
assert "keep" in result.discovered_folders
|
||||
assert "recipes" not in result.discovered_folders
|
||||
assert "recipes/sub" not in result.discovered_folders
|
||||
|
||||
Reference in New Issue
Block a user