* fix(lora): read LoraManager's settings and storage layout the way it does (#355) Add py/lora_manager_layout.py: the settings file in LoraManager's lookup order (environment folder, portable plugin file, user config folder), only the seven keys the import needs, the LoRA roots, the centralized sidecar root, the example-images root and the active library, each folder through one guard. Sidecar discovery for both storage modes and the local preview and example-image lookup live there too. The test runner and the browser test server point LoraManager's settings lookup at a disposable folder, so no test reads the real settings. * fix(lora): import the sidecars and local images LoraManager really uses (#355) The import, the legacy scan, the trigger-word cache and the image server now share LoraManager's layout: sidecars beside the models (alongside) or under the sidecar root (centralized), kept only when their model exists inside a LoRA root. Each LoRA links its LoraManager preview and example images where they are; CivitAI downloads run only for a LoRA without any local image. The review adds skipped, with_local_media and the layout, and offers no token when LoraManager's settings cannot be used; the legacy scan reports the same reason without replacing anything. find_lora_directories becomes default_lora_roots (no scan of the LoraManager install); the preview and example-folder helpers it replaced are removed. The trigger cache never raises into a running node. * fix(lora): never serve a LoraManager folder that holds the home folder (#355) The folder guard also rejects ancestors of the home folder (/home, C:\Users), compared in the realpath + normcase form, so a LoraManager setting cannot widen the image server's allow-list to every user's files. * fix(lora): show linked LoraManager videos as videos (#355) _enrich_images marks every image without is_video by its extension, so an .mp4 preview outside the output folders shows the Video preview label and plays in the inspector. Tests cover serving the sidecar and example-image folders only while the integration is on, and a filesystem root as the sidecar folder adding nothing. * feat(lora): the import review names the LoraManager settings it read (#355) The review gains a first row, LoraManager ("<source> settings · <mode> storage · library <name>", the settings file as its hover title), and its Found row counts metadata files, LoRAs and LoRAs with local images. A plan whose LoraManager settings cannot be used takes the close-only path: "LoraManager settings: not usable: <reason>. Nothing was changed." The browser fixture writes each LoRA's model file and can leave out the local preview, so the download, gate and cancel specs still exercise CivitAI. * docs(lora): describe how the import finds LoraManager's files (#355) * test(lora): set the settings folder relative to the process folder (#355) os.path.relpath fails across Windows drives (the temp folder on C:, the checkout on D:); the test now changes into its temp folder instead. * fix(lora): review findings for the LoraManager layout (#355) - The example-images library subfolder goes through the folder guard like its root, so its canonical (normcased) form matches the media check on Windows for a library name with capitals. - A failed trigger-word load is retried by a running node only after TRIGGER_RETRY_SECONDS, not on every prompt run. - A review whose discovery cannot list a folder says so (folder-unreadable, the folder's name only) instead of a generic error. * test(images): hold the listing clock in the deferred-walk test test_a_deferred_walk_waits_at_least_half_a_second set the previous walk 29.99 s back against a 30 s interval and then read the real clock again: a runner slower than 10 ms between the two lines passed the interval and the walk started at once (failed on macOS 3.13 and Windows 3.11). The module's clock is now held for the mark, so the deferred walk is always the one measured. * test(lora): write the unkept-settings fixture as raw text CodeQL read the dict with a credential-named key flowing into write_text as clear-text storage of a secret. The value is a marker, so the fixture now writes the JSON text directly and names the value MARKER. * fix(lora): link each LoRA's cover as its newest image (#355) Workspace lists show a prompt's media newest link first, so the import links a record's images last to first: the card shows the cover (a still in front of a leading video) the import put first. * fix(lora): show LoRA-linked media on the Library page and in the inspector (#355) Library previews, the positive detail and the per-prompt images page list a prompt's linked media under the output folders plus, while the LoRA integration is on, LoraManager's guarded folders and the download cache (_media_roots; output root indices keep their meaning). That media keeps its rooted identity, so Info and the media viewer open it; the path-based serve route answers for those roots only with a file the database records (either path spelling), never serves or makes a preview there, and output requests never read LoraManager's layout. Such media is marked linked_only: no preview file, no favorite star, no Tag action. Show in folder accepts those roots; the download cache is named, not created. * fix(lora): expose only linked files under LoraManager's folders (#355) Serve, inspect and Show in folder share one gate for a LoRA-linked root (_media_roots past the output folders): a file the database does not record as a linked image is refused with 403, checked by its given and its real path. Before, inspect and reveal read any file under those folders by path. * fix(lora): check the file Show in folder would open under a LoRA root (#355) reveal_image resolved the requested path through the client-path resolver, which can fall back to a path anchor; under a LoRA-linked root the file that would open now passes the same recorded-media gate, so only a linked file is ever revealed there.
859 lines
34 KiB
Python
859 lines
34 KiB
Python
"""Where LoraManager keeps its files (#355): ``py/lora_manager_layout.py``.
|
|
|
|
Every test builds its own LoraManager install, settings file, LoRA roots,
|
|
sidecar root and example-images folder in a temporary folder, and injects the
|
|
environment and the user config folder: no test reads the developer's real
|
|
LoraManager settings or opens the network.
|
|
"""
|
|
|
|
import json
|
|
import ntpath
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from py import lora_manager_layout as layout_module
|
|
from py.lora_manager_layout import (
|
|
METADATA_SUFFIX,
|
|
PREVIEW_EXTENSIONS,
|
|
Sidecar,
|
|
discover_sidecars,
|
|
layout_summary,
|
|
load_layout,
|
|
local_media,
|
|
)
|
|
|
|
MEDIA = frozenset(
|
|
{".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".webm", ".mov", ".bmp"}
|
|
)
|
|
MARKER = "marker-from-unkept-settings-0123"
|
|
|
|
|
|
class LayoutTestCase(unittest.TestCase):
|
|
"""A disposable LoraManager install and the folders its settings name."""
|
|
|
|
def setUp(self):
|
|
self.tmp = Path(tempfile.mkdtemp()).resolve()
|
|
self.addCleanup(shutil.rmtree, self.tmp, True)
|
|
self.plugin = self.tmp / "custom_nodes" / "ComfyUI-Lora-Manager"
|
|
(self.plugin / "py").mkdir(parents=True)
|
|
(self.plugin / "__init__.py").write_text("")
|
|
self.user_config = self.tmp / "user-config" / "ComfyUI-LoRA-Manager"
|
|
self.env_dir = self.tmp / "env-settings"
|
|
self.default_loras = self.tmp / "models" / "loras"
|
|
self.default_loras.mkdir(parents=True)
|
|
self.environ = {}
|
|
self.config_calls = []
|
|
|
|
def user_config_dir(self):
|
|
self.config_calls.append(True)
|
|
return str(self.user_config)
|
|
|
|
def write_settings(self, folder, data):
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
path = folder / "settings.json"
|
|
path.write_text(json.dumps(data), encoding="utf-8")
|
|
return path
|
|
|
|
def load(self, path="__plugin__", **kwargs):
|
|
lora_manager = str(self.plugin) if path == "__plugin__" else path
|
|
kwargs.setdefault("default_roots", lambda: [str(self.default_loras)])
|
|
kwargs.setdefault("environ", self.environ)
|
|
kwargs.setdefault("user_config_dir", self.user_config_dir)
|
|
return load_layout(lora_manager, **kwargs)
|
|
|
|
def model(self, folder, stem):
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
path = folder / f"{stem}.safetensors"
|
|
path.write_bytes(b"model")
|
|
return path
|
|
|
|
def sidecar(self, folder, stem, data=None):
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
path = folder / f"{stem}{METADATA_SUFFIX}"
|
|
path.write_text(json.dumps(data if data is not None else {}), "utf-8")
|
|
return path
|
|
|
|
|
|
class SettingsLookupTests(LayoutTestCase):
|
|
|
|
def test_environment_folder_wins_over_portable_and_user_config(self):
|
|
env_file = self.write_settings(self.env_dir, {"sidecar_storage_mode": "x"})
|
|
self.write_settings(self.plugin, {"use_portable_settings": True})
|
|
self.write_settings(self.user_config, {})
|
|
self.environ["LORA_MANAGER_SETTINGS_DIR"] = str(self.env_dir)
|
|
|
|
layout = self.load()
|
|
|
|
self.assertEqual(layout.source, "environment")
|
|
self.assertEqual(layout.settings_path, env_file)
|
|
self.assertIsNone(layout.error)
|
|
self.assertEqual(self.config_calls, [])
|
|
|
|
def test_an_empty_environment_folder_is_ignored(self):
|
|
self.environ["LORA_MANAGER_SETTINGS_DIR"] = ""
|
|
self.assertEqual(self.load().source, "user config")
|
|
|
|
def test_portable_flag_in_the_plugin_settings(self):
|
|
plugin_file = self.write_settings(self.plugin, {"use_portable_settings": True})
|
|
layout = self.load()
|
|
self.assertEqual(layout.source, "portable")
|
|
self.assertEqual(layout.settings_path, plugin_file)
|
|
self.assertEqual(self.config_calls, [])
|
|
|
|
def test_portable_environment_one_turns_it_on(self):
|
|
self.environ["LORA_MANAGER_PORTABLE"] = "1"
|
|
layout = self.load()
|
|
self.assertEqual(layout.source, "portable")
|
|
self.assertEqual(layout.settings_path, self.plugin / "settings.json")
|
|
|
|
def test_portable_environment_zero_turns_it_off_despite_the_file(self):
|
|
self.write_settings(self.plugin, {"use_portable_settings": True})
|
|
self.environ["LORA_MANAGER_PORTABLE"] = "0"
|
|
self.assertEqual(self.load().source, "user config")
|
|
|
|
def test_other_portable_environment_values_are_ignored(self):
|
|
for value in ("true", "yes"):
|
|
with self.subTest(value=value):
|
|
self.environ["LORA_MANAGER_PORTABLE"] = value
|
|
self.assertEqual(self.load().source, "user config")
|
|
self.write_settings(self.plugin, {"use_portable_settings": True})
|
|
self.assertEqual(self.load().source, "portable")
|
|
(self.plugin / "settings.json").unlink()
|
|
|
|
def test_a_string_portable_flag_is_ignored(self):
|
|
self.write_settings(self.plugin, {"use_portable_settings": "true"})
|
|
self.assertEqual(self.load().source, "user config")
|
|
|
|
def test_an_unreadable_plugin_file_means_not_portable(self):
|
|
(self.plugin / "settings.json").write_text("{not json")
|
|
self.assertEqual(self.load().source, "user config")
|
|
|
|
def test_user_config_folder_is_the_injected_one(self):
|
|
user_file = self.write_settings(self.user_config, {})
|
|
layout = self.load()
|
|
self.assertEqual(layout.source, "user config")
|
|
self.assertEqual(layout.settings_path, user_file)
|
|
self.assertEqual(self.config_calls, [True])
|
|
|
|
def test_the_real_user_config_folder_is_never_opened(self):
|
|
opened = []
|
|
real_open = open
|
|
|
|
def spy(path, *args, **kwargs):
|
|
opened.append(os.path.realpath(str(path)))
|
|
return real_open(path, *args, **kwargs)
|
|
|
|
with patch("builtins.open", spy):
|
|
layout = self.load()
|
|
self.assertEqual(layout.settings_path, self.user_config / "settings.json")
|
|
for path in opened:
|
|
self.assertTrue(path.startswith(str(self.tmp)), path)
|
|
|
|
def test_a_missing_settings_file_means_defaults(self):
|
|
layout = self.load()
|
|
self.assertIsNone(layout.error)
|
|
self.assertEqual(layout.mode, "alongside")
|
|
self.assertIsNone(layout.example_images_root)
|
|
self.assertIsNone(layout.sidecar_root)
|
|
self.assertEqual(layout.library, "default")
|
|
self.assertEqual(
|
|
layout.lora_roots, (os.path.normcase(str(self.default_loras)),)
|
|
)
|
|
|
|
def test_settings_dir_is_expanded_and_made_absolute(self):
|
|
self.write_settings(self.env_dir, {})
|
|
# Relative to the process folder (relpath fails across Windows drives).
|
|
# The cleanup returns before the temp folder is removed (LIFO).
|
|
self.addCleanup(os.chdir, os.getcwd())
|
|
os.chdir(self.tmp)
|
|
self.environ["LORA_MANAGER_SETTINGS_DIR"] = self.env_dir.name
|
|
self.assertEqual(self.load().settings_path, self.env_dir / "settings.json")
|
|
|
|
|
|
class UnusableLayoutTests(LayoutTestCase):
|
|
|
|
def test_no_install(self):
|
|
layout = self.load(path=None)
|
|
self.assertEqual(layout.error, "no LoraManager install found")
|
|
self.assertIsNone(layout.settings_path)
|
|
self.assertIsNone(layout.source)
|
|
# The default LoRA roots stay known for serving and trigger words.
|
|
self.assertEqual(
|
|
layout.lora_roots, (os.path.normcase(str(self.default_loras)),)
|
|
)
|
|
self.assertEqual(layout.media_roots, layout.lora_roots)
|
|
self.assertEqual(discover_sidecars(layout).sidecars, [])
|
|
|
|
def test_unreadable_settings(self):
|
|
self.write_settings(self.user_config, {})
|
|
with patch.object(layout_module, "_read_bytes", side_effect=PermissionError):
|
|
layout = self.load()
|
|
self.assertEqual(layout.error, "the settings file could not be read")
|
|
|
|
def test_settings_that_are_not_json(self):
|
|
self.user_config.mkdir(parents=True)
|
|
(self.user_config / "settings.json").write_text("{nope")
|
|
self.assertEqual(self.load().error, "the settings file is not valid JSON")
|
|
|
|
def test_settings_that_are_not_utf8(self):
|
|
self.user_config.mkdir(parents=True)
|
|
(self.user_config / "settings.json").write_bytes(b'{"a": "\xff"}')
|
|
self.assertEqual(self.load().error, "the settings file is not valid JSON")
|
|
|
|
def test_settings_that_are_not_an_object(self):
|
|
self.write_settings(self.user_config, ["a"])
|
|
self.assertEqual(self.load().error, "the settings file is not a JSON object")
|
|
|
|
def test_oversized_settings(self):
|
|
self.user_config.mkdir(parents=True)
|
|
with open(self.user_config / "settings.json", "wb") as handle:
|
|
handle.write(b" " * (layout_module.MAX_SETTINGS_BYTES + 1))
|
|
self.assertEqual(self.load().error, "the settings file is larger than 5 MB")
|
|
|
|
def test_platformdirs_import_failure(self):
|
|
def missing():
|
|
raise ImportError("no platformdirs")
|
|
|
|
layout = self.load(user_config_dir=missing)
|
|
self.assertEqual(layout.error, "platformdirs is not available")
|
|
self.assertEqual(layout.mode, "alongside")
|
|
|
|
def test_default_user_config_dir_needs_platformdirs(self):
|
|
with patch.dict(sys.modules, {"platformdirs": None}):
|
|
with self.assertRaises(ImportError):
|
|
layout_module._platform_user_config_dir()
|
|
|
|
def test_default_user_config_dir_uses_the_app_name(self):
|
|
fake = type(sys)("platformdirs")
|
|
calls = []
|
|
fake.user_config_dir = lambda *a, **k: calls.append((a, k)) or "/x"
|
|
with patch.dict(sys.modules, {"platformdirs": fake}):
|
|
self.assertEqual(layout_module._platform_user_config_dir(), "/x")
|
|
self.assertEqual(calls, [(("ComfyUI-LoRA-Manager",), {"appauthor": False})])
|
|
|
|
def test_missing_centralized_sidecar_root(self):
|
|
self.write_settings(
|
|
self.user_config,
|
|
{
|
|
"sidecar_storage_mode": "centralized",
|
|
"sidecar_storage_path": str(self.tmp / "gone"),
|
|
},
|
|
)
|
|
layout = self.load()
|
|
self.assertEqual(layout.error, "the sidecar folder does not exist")
|
|
self.assertEqual(layout.mode, "centralized")
|
|
self.assertEqual(
|
|
layout.lora_roots, (os.path.normcase(str(self.default_loras)),)
|
|
)
|
|
self.assertEqual(layout.media_roots, layout.lora_roots)
|
|
self.assertEqual(discover_sidecars(layout).scanned, 0)
|
|
|
|
def test_a_centralized_sidecar_root_that_is_a_filesystem_root(self):
|
|
self.write_settings(
|
|
self.user_config,
|
|
{"sidecar_storage_mode": "centralized", "sidecar_storage_path": "/"},
|
|
)
|
|
layout = self.load()
|
|
self.assertEqual(
|
|
layout.error, "the sidecar folder is a filesystem root or the home folder"
|
|
)
|
|
self.assertNotIn(os.path.normcase(os.path.realpath("/")), layout.media_roots)
|
|
|
|
|
|
class SecretTests(LayoutTestCase):
|
|
|
|
def test_other_settings_keys_never_leave_the_module(self):
|
|
# Raw text keeps credential-named keys out of test variables.
|
|
self.user_config.mkdir(parents=True)
|
|
(self.user_config / "settings.json").write_text(
|
|
'{"civitai_api_key": "%s", "nested": {"token": "%s"}}' % (MARKER, MARKER),
|
|
encoding="utf-8",
|
|
)
|
|
layout = self.load()
|
|
self.assertNotIn(MARKER, repr(layout))
|
|
self.assertNotIn(MARKER, json.dumps(layout_summary(layout, Path.home())))
|
|
|
|
def test_only_the_seven_keys_are_kept(self):
|
|
self.assertEqual(
|
|
layout_module.SETTINGS_KEYS,
|
|
frozenset(
|
|
{
|
|
"sidecar_storage_mode",
|
|
"sidecar_storage_path",
|
|
"example_images_path",
|
|
"folder_paths",
|
|
"extra_folder_paths",
|
|
"libraries",
|
|
"active_library",
|
|
}
|
|
),
|
|
)
|
|
kept = layout_module._settings_subset(
|
|
json.loads('{"civitai_api_key": "%s", "libraries": {}}' % MARKER)
|
|
)
|
|
self.assertEqual(kept, {"libraries": {}})
|
|
|
|
|
|
class ActiveLibraryTests(LayoutTestCase):
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.top = self.tmp / "top-loras"
|
|
self.lib_a = self.tmp / "a-loras"
|
|
self.lib_b = self.tmp / "b-loras"
|
|
self.extra = self.tmp / "b-extra"
|
|
for folder in (self.top, self.lib_a, self.lib_b, self.extra):
|
|
folder.mkdir()
|
|
self.examples = self.tmp / "examples"
|
|
self.examples.mkdir()
|
|
|
|
def settings(self, **extra):
|
|
data = {
|
|
"folder_paths": {"loras": [str(self.top)]},
|
|
"libraries": {
|
|
"first lib": {"folder_paths": {"loras": [str(self.lib_a)]}},
|
|
"second": {
|
|
"folder_paths": {"loras": [str(self.lib_b)]},
|
|
"extra_folder_paths": {"loras": [str(self.extra)]},
|
|
},
|
|
},
|
|
"example_images_path": str(self.examples),
|
|
}
|
|
data.update(extra)
|
|
self.write_settings(self.user_config, data)
|
|
|
|
def test_the_active_library_lists_are_used(self):
|
|
self.settings(active_library="second")
|
|
(self.examples / "second").mkdir()
|
|
layout = self.load()
|
|
self.assertEqual(layout.library, "second")
|
|
self.assertIn(os.path.normcase(str(self.lib_b)), layout.lora_roots)
|
|
self.assertIn(os.path.normcase(str(self.extra)), layout.lora_roots)
|
|
self.assertNotIn(os.path.normcase(str(self.top)), layout.lora_roots)
|
|
self.assertNotIn(os.path.normcase(str(self.lib_a)), layout.lora_roots)
|
|
self.assertIn(os.path.normcase(str(self.default_loras)), layout.lora_roots)
|
|
self.assertEqual(
|
|
layout.example_images_root, os.path.normcase(str(self.examples / "second"))
|
|
)
|
|
|
|
def test_an_unknown_active_library_falls_back_to_the_first(self):
|
|
self.settings(active_library="missing")
|
|
(self.examples / "first_lib").mkdir()
|
|
layout = self.load()
|
|
self.assertEqual(layout.library, "first lib")
|
|
self.assertIn(os.path.normcase(str(self.lib_a)), layout.lora_roots)
|
|
# The sanitized library name is the example-images subfolder.
|
|
self.assertEqual(
|
|
layout.example_images_root,
|
|
os.path.normcase(str(self.examples / "first_lib")),
|
|
)
|
|
|
|
def test_no_libraries_means_default_and_the_top_level_lists(self):
|
|
self.write_settings(
|
|
self.user_config,
|
|
{
|
|
"folder_paths": {"loras": str(self.top)},
|
|
"extra_folder_paths": {"loras": [str(self.extra), 7]},
|
|
"example_images_path": str(self.examples),
|
|
},
|
|
)
|
|
layout = self.load()
|
|
self.assertEqual(layout.library, "default")
|
|
self.assertIn(os.path.normcase(str(self.top)), layout.lora_roots)
|
|
self.assertIn(os.path.normcase(str(self.extra)), layout.lora_roots)
|
|
self.assertEqual(
|
|
layout.example_images_root, os.path.normcase(str(self.examples))
|
|
)
|
|
|
|
def test_one_library_has_no_library_subfolder(self):
|
|
self.write_settings(
|
|
self.user_config,
|
|
{
|
|
"libraries": {
|
|
"comfyui": {"folder_paths": {"loras": [str(self.lib_a)]}}
|
|
},
|
|
"active_library": "comfyui",
|
|
"example_images_path": str(self.examples),
|
|
},
|
|
)
|
|
layout = self.load()
|
|
self.assertEqual(layout.library, "comfyui")
|
|
self.assertEqual(
|
|
layout.example_images_root, os.path.normcase(str(self.examples))
|
|
)
|
|
|
|
def test_a_mixed_case_library_folder_keeps_its_example_images(self):
|
|
# Review finding: the library subfolder must be canonical like its
|
|
# root, or every example image fails the media check on Windows
|
|
# (normcase lowercases there; native proof is the Windows CI job).
|
|
self.write_settings(
|
|
self.user_config,
|
|
{
|
|
"libraries": {"Studio A": {}, "other": {}},
|
|
"active_library": "Studio A",
|
|
"example_images_path": str(self.examples),
|
|
},
|
|
)
|
|
image = self.examples / "Studio_A" / "abc" / "1.png"
|
|
image.parent.mkdir(parents=True)
|
|
image.write_bytes(b"x")
|
|
model = self.default_loras / "m.safetensors"
|
|
model.write_bytes(b"m")
|
|
(self.default_loras / "m.metadata.json").write_text("{}")
|
|
layout = self.load()
|
|
self.assertEqual(
|
|
layout.example_images_root,
|
|
os.path.normcase(os.path.realpath(self.examples / "Studio_A")),
|
|
)
|
|
entry = discover_sidecars(layout).sidecars[0]
|
|
self.assertEqual(
|
|
local_media(entry, {"sha256": "ABC"}, layout, MEDIA),
|
|
[os.path.realpath(image)],
|
|
)
|
|
|
|
def test_a_missing_library_subfolder_means_no_example_images(self):
|
|
self.settings(active_library="second")
|
|
self.assertIsNone(self.load().example_images_root)
|
|
|
|
def test_an_unset_or_missing_example_folder_means_none(self):
|
|
self.write_settings(self.user_config, {"example_images_path": " "})
|
|
self.assertIsNone(self.load().example_images_root)
|
|
self.write_settings(
|
|
self.user_config, {"example_images_path": str(self.tmp / "gone")}
|
|
)
|
|
self.assertIsNone(self.load().example_images_root)
|
|
|
|
def test_sanitize_library_name(self):
|
|
sanitize = layout_module._sanitize_library_name
|
|
self.assertEqual(sanitize("My Lib/2"), "My_Lib_2")
|
|
self.assertEqual(sanitize("ok-name_1.0"), "ok-name_1.0")
|
|
self.assertEqual(sanitize(""), "default")
|
|
|
|
def test_odd_library_shapes_are_ignored(self):
|
|
self.write_settings(
|
|
self.user_config,
|
|
{"libraries": {"x": "not a dict"}, "folder_paths": ["not", "a dict"]},
|
|
)
|
|
layout = self.load()
|
|
self.assertEqual(layout.library, "x")
|
|
self.assertEqual(
|
|
layout.lora_roots, (os.path.normcase(str(self.default_loras)),)
|
|
)
|
|
|
|
|
|
class CentralizedDiscoveryTests(LayoutTestCase):
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.loras = self.tmp / "loras"
|
|
self.checkpoints = self.tmp / "checkpoints"
|
|
self.checkpoints.mkdir()
|
|
self.sidecars = self.tmp / "sidecars"
|
|
self.sidecars.mkdir()
|
|
self.write_settings(
|
|
self.user_config,
|
|
{
|
|
"sidecar_storage_mode": "centralized",
|
|
"sidecar_storage_path": str(self.sidecars),
|
|
"folder_paths": {"loras": [str(self.loras)]},
|
|
},
|
|
)
|
|
self.loras.mkdir()
|
|
|
|
def test_component_folders_and_the_legacy_nested_form(self):
|
|
a = self.model(self.loras, "alpha")
|
|
b = self.model(self.loras / "sub", "beta")
|
|
self.sidecar(self.sidecars / "loras-1a2b3c4d", "alpha", {"file_path": str(a)})
|
|
self.sidecar(
|
|
self.sidecars / "comfyui" / "loras-1a2b3c4d" / "sub",
|
|
"beta",
|
|
{"file_path": b.as_posix()},
|
|
)
|
|
discovery = discover_sidecars(self.load())
|
|
self.assertEqual(discovery.scanned, 2)
|
|
self.assertEqual(
|
|
sorted(s.model_path.name for s in discovery.sidecars),
|
|
["alpha.safetensors", "beta.safetensors"],
|
|
)
|
|
self.assertEqual(discovery.unreadable, [])
|
|
self.assertEqual(
|
|
[str(s.metadata_path) for s in discovery.sidecars],
|
|
sorted(str(s.metadata_path) for s in discovery.sidecars),
|
|
)
|
|
|
|
def test_missing_models_and_checkpoints_are_skipped(self):
|
|
ckpt = self.model(self.checkpoints, "base")
|
|
self.sidecar(self.sidecars / "c", "base", {"file_path": str(ckpt)})
|
|
self.sidecar(
|
|
self.sidecars / "l",
|
|
"gone",
|
|
{"file_path": str(self.loras / "gone.safetensors")},
|
|
)
|
|
self.sidecar(self.sidecars / "l", "nopath", {"model_name": "x"})
|
|
discovery = discover_sidecars(self.load())
|
|
self.assertEqual(discovery.scanned, 3)
|
|
self.assertEqual(discovery.sidecars, [])
|
|
|
|
def test_leftover_sidecars_beside_models_are_not_scanned(self):
|
|
model = self.model(self.loras, "alpha")
|
|
self.sidecar(self.loras, "alpha", {"file_path": str(model)})
|
|
discovery = discover_sidecars(self.load())
|
|
self.assertEqual((discovery.scanned, discovery.sidecars), (0, []))
|
|
|
|
def test_unparseable_sidecars_are_recorded(self):
|
|
(self.sidecars / f"broken{METADATA_SUFFIX}").write_text("{{{")
|
|
(self.sidecars / f"list{METADATA_SUFFIX}").write_text("[1]")
|
|
discovery = discover_sidecars(self.load())
|
|
self.assertEqual(discovery.scanned, 2)
|
|
self.assertEqual(
|
|
sorted(discovery.unreadable),
|
|
[f"broken{METADATA_SUFFIX}", f"list{METADATA_SUFFIX}"],
|
|
)
|
|
|
|
def test_default_sidecar_root_is_beside_the_settings(self):
|
|
self.write_settings(
|
|
self.user_config,
|
|
{
|
|
"sidecar_storage_mode": "centralized",
|
|
"folder_paths": {"loras": [str(self.loras)]},
|
|
},
|
|
)
|
|
(self.user_config / "sidecars").mkdir()
|
|
layout = self.load()
|
|
self.assertEqual(
|
|
layout.sidecar_root, os.path.normcase(str(self.user_config / "sidecars"))
|
|
)
|
|
self.assertIn(layout.sidecar_root, layout.media_roots)
|
|
|
|
def test_an_unknown_mode_is_alongside(self):
|
|
self.write_settings(self.user_config, {"sidecar_storage_mode": "elsewhere"})
|
|
self.assertEqual(self.load().mode, "alongside")
|
|
|
|
def test_an_unlistable_folder_raises(self):
|
|
layout = self.load()
|
|
|
|
def refuse(error):
|
|
raise error
|
|
|
|
def failing_walk(top, onerror=None, followlinks=False):
|
|
onerror(PermissionError(13, "denied", top))
|
|
return iter(())
|
|
|
|
with patch.object(layout_module.os, "walk", failing_walk):
|
|
with self.assertRaises(OSError):
|
|
discover_sidecars(layout)
|
|
|
|
|
|
class AlongsideDiscoveryTests(LayoutTestCase):
|
|
|
|
def test_a_sidecar_with_its_model_is_kept(self):
|
|
model = self.model(self.default_loras, "alpha")
|
|
meta = self.sidecar(self.default_loras, "alpha")
|
|
self.sidecar(self.default_loras, "orphan")
|
|
discovery = discover_sidecars(self.load())
|
|
self.assertEqual(discovery.scanned, 2)
|
|
self.assertEqual(
|
|
discovery.sidecars,
|
|
[Sidecar(Path(os.path.realpath(meta)), Path(os.path.realpath(model)))],
|
|
)
|
|
|
|
def test_a_folder_reached_through_two_nested_roots_is_listed_once(self):
|
|
nested = self.default_loras / "nested"
|
|
self.model(nested, "alpha")
|
|
self.sidecar(nested, "alpha")
|
|
layout = self.load(default_roots=lambda: [str(self.default_loras), str(nested)])
|
|
discovery = discover_sidecars(layout)
|
|
self.assertEqual((discovery.scanned, len(discovery.sidecars)), (1, 1))
|
|
|
|
@unittest.skipUnless(hasattr(os, "symlink"), "symlinks")
|
|
def test_a_symlink_loop_is_walked_once(self):
|
|
self.model(self.default_loras, "alpha")
|
|
self.sidecar(self.default_loras, "alpha")
|
|
try:
|
|
os.symlink(self.default_loras, self.default_loras / "loop")
|
|
except (OSError, NotImplementedError):
|
|
self.skipTest("symlinks need privileges here")
|
|
discovery = discover_sidecars(self.load())
|
|
self.assertEqual((discovery.scanned, len(discovery.sidecars)), (1, 1))
|
|
|
|
|
|
class LocalMediaTests(LayoutTestCase):
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.examples = self.tmp / "examples"
|
|
self.examples.mkdir()
|
|
self.write_settings(
|
|
self.user_config, {"example_images_path": str(self.examples)}
|
|
)
|
|
self.model(self.default_loras, "alpha")
|
|
self.meta = self.sidecar(self.default_loras, "alpha")
|
|
self.layout = self.load()
|
|
self.entry = discover_sidecars(self.layout).sidecars[0]
|
|
|
|
def media(self, metadata=None):
|
|
return local_media(self.entry, metadata or {}, self.layout, MEDIA)
|
|
|
|
def touch(self, path):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(b"x")
|
|
return os.path.realpath(path)
|
|
|
|
def test_preview_url_comes_first(self):
|
|
preview = self.touch(self.default_loras / "custom.jpeg")
|
|
self.touch(self.default_loras / "alpha.webp")
|
|
self.assertEqual(self.media({"preview_url": preview}), [preview])
|
|
|
|
def test_preview_url_outside_media_roots_or_not_servable_is_ignored(self):
|
|
outside = self.touch(self.tmp / "elsewhere" / "p.png")
|
|
avif = self.touch(self.default_loras / "p.avif")
|
|
fallback = self.touch(self.default_loras / "alpha.png")
|
|
for url in (
|
|
outside,
|
|
avif,
|
|
"relative.png",
|
|
7,
|
|
str(self.default_loras / "x.png"),
|
|
):
|
|
with self.subTest(url=url):
|
|
self.assertEqual(self.media({"preview_url": url}), [fallback])
|
|
|
|
def test_suffix_order_follows_lora_manager(self):
|
|
self.assertEqual(
|
|
PREVIEW_EXTENSIONS,
|
|
(
|
|
".webp",
|
|
".preview.webp",
|
|
".preview.png",
|
|
".preview.jpeg",
|
|
".preview.jpg",
|
|
".preview.mp4",
|
|
".png",
|
|
".jpeg",
|
|
".jpg",
|
|
".mp4",
|
|
".gif",
|
|
".webm",
|
|
".avif",
|
|
".jxl",
|
|
),
|
|
)
|
|
mp4 = self.touch(self.default_loras / "alpha.mp4")
|
|
self.assertEqual(self.media(), [mp4])
|
|
webp = self.touch(self.default_loras / "alpha.preview.webp")
|
|
self.assertEqual(self.media(), [webp])
|
|
|
|
def test_an_avif_only_preview_gives_nothing(self):
|
|
self.touch(self.default_loras / "alpha.avif")
|
|
self.assertEqual(self.media(), [])
|
|
|
|
def test_example_images_in_numeric_order(self):
|
|
folder = self.examples / "abcdef"
|
|
names = ["image_10.png", "image_2.png", "image_1.jpg", "notes.txt"]
|
|
for name in names:
|
|
self.touch(folder / name)
|
|
(folder / "sub").mkdir()
|
|
preview = self.touch(self.default_loras / "alpha.png")
|
|
found = self.media({"sha256": "ABCDEF"})
|
|
self.assertEqual(
|
|
[os.path.basename(p) for p in found],
|
|
["alpha.png", "image_1.jpg", "image_2.png", "image_10.png"],
|
|
)
|
|
self.assertEqual(found[0], preview)
|
|
|
|
def test_missing_sha256_or_folder_gives_no_example_images(self):
|
|
self.touch(self.examples / "abc" / "1.png")
|
|
self.assertEqual(self.media(), [])
|
|
self.assertEqual(self.media({"sha256": "zzz"}), [])
|
|
self.assertEqual(self.media({"sha256": 5}), [])
|
|
|
|
def test_an_unlistable_example_folder_gives_what_was_found(self):
|
|
self.touch(self.examples / "abc" / "1.png")
|
|
with patch.object(layout_module.os, "scandir", side_effect=PermissionError):
|
|
self.assertEqual(self.media({"sha256": "abc"}), [])
|
|
|
|
def test_cover_moves_the_first_still_in_front_of_a_video(self):
|
|
video = self.touch(self.default_loras / "alpha.mp4")
|
|
still = self.touch(self.examples / "abc" / "2.png")
|
|
other = self.touch(self.examples / "abc" / "1.webm")
|
|
self.assertEqual(self.media({"sha256": "abc"}), [still, video, other])
|
|
|
|
def test_videos_only_keep_their_order(self):
|
|
video = self.touch(self.default_loras / "alpha.mp4")
|
|
self.assertEqual(self.media(), [video])
|
|
|
|
def test_repeats_are_dropped(self):
|
|
preview = self.touch(self.default_loras / "alpha.png")
|
|
self.assertEqual(self.media({"preview_url": preview}), [preview])
|
|
|
|
def test_video_suffixes_match_the_video_module(self):
|
|
from utils.video_metadata import VIDEO_EXTENSIONS
|
|
|
|
self.assertEqual(layout_module.VIDEO_SUFFIXES, frozenset(VIDEO_EXTENSIONS))
|
|
|
|
|
|
class FolderGuardTests(unittest.TestCase):
|
|
"""Portable path-flavor controls (TESTING.md Windows portability preflight)."""
|
|
|
|
def setUp(self):
|
|
self.tmp = Path(tempfile.mkdtemp()).resolve()
|
|
self.addCleanup(shutil.rmtree, self.tmp, True)
|
|
|
|
def guard_nt(self, path, home="C:\\Users\\Vito"):
|
|
return layout_module._guard_folder(
|
|
path, home, pathmod=ntpath, isdir=lambda _p: True
|
|
)
|
|
|
|
def test_filesystem_roots_and_home_are_rejected(self):
|
|
self.assertIsNone(layout_module._guard_folder("/", str(self.tmp)))
|
|
self.assertIsNone(self.guard_nt("C:\\"))
|
|
self.assertIsNone(self.guard_nt("c:/"))
|
|
self.assertIsNone(self.guard_nt("\\\\server\\share\\"))
|
|
self.assertIsNone(self.guard_nt("c:\\USERS\\vito"))
|
|
self.assertIsNone(self.guard_nt("C:\\Users\\Vito\\"))
|
|
self.assertEqual(
|
|
self.guard_nt("C:\\Users\\Vito\\Loras"), "c:\\users\\vito\\loras"
|
|
)
|
|
self.assertIsNone(layout_module._guard_folder(str(self.tmp), str(self.tmp)))
|
|
|
|
def test_ancestors_of_home_are_rejected(self):
|
|
# Security review (#355): a settings folder that contains the home
|
|
# folder would serve every user's files.
|
|
home = self.tmp / "home" / "me"
|
|
inside = home / "loras"
|
|
inside.mkdir(parents=True)
|
|
guard = layout_module._guard_folder
|
|
self.assertIsNone(guard(str(self.tmp / "home"), str(home)))
|
|
self.assertIsNone(guard(str(self.tmp), str(home)))
|
|
self.assertIsNone(guard(str(home), str(home)))
|
|
self.assertEqual(guard(str(inside), str(home)), os.path.normcase(str(inside)))
|
|
# A sibling sharing the name prefix is not an ancestor.
|
|
sibling = self.tmp / "home" / "me2"
|
|
sibling.mkdir()
|
|
self.assertEqual(guard(str(sibling), str(home)), os.path.normcase(str(sibling)))
|
|
|
|
def test_windows_ancestors_of_home_are_rejected(self):
|
|
self.assertIsNone(self.guard_nt("C:\\Users", home="C:\\Users\\me"))
|
|
self.assertIsNone(self.guard_nt("c:/users/", home="C:\\Users\\me"))
|
|
self.assertEqual(
|
|
self.guard_nt("C:\\Users\\me\\Loras", home="C:\\Users\\me"),
|
|
"c:\\users\\me\\loras",
|
|
)
|
|
self.assertEqual(
|
|
self.guard_nt("C:\\Users\\meta", home="C:\\Users\\me"),
|
|
"c:\\users\\meta",
|
|
)
|
|
|
|
def test_settings_folders_that_contain_home_are_not_served(self):
|
|
home = self.tmp / "home" / "me"
|
|
home.mkdir(parents=True)
|
|
settings = self.tmp / "settings"
|
|
settings.mkdir()
|
|
(settings / "settings.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"folder_paths": {"loras": [str(self.tmp / "home")]},
|
|
"example_images_path": str(self.tmp),
|
|
}
|
|
)
|
|
)
|
|
with patch.object(layout_module.Path, "home", return_value=home):
|
|
layout = load_layout(
|
|
str(self.tmp),
|
|
default_roots=lambda: [],
|
|
environ={"LORA_MANAGER_SETTINGS_DIR": str(settings)},
|
|
user_config_dir=lambda: str(self.tmp / "unused"),
|
|
)
|
|
self.assertIsNone(layout.error)
|
|
self.assertEqual(layout.lora_roots, ())
|
|
self.assertIsNone(layout.example_images_root)
|
|
self.assertEqual(layout.media_roots, ())
|
|
|
|
def test_missing_folders_add_nothing(self):
|
|
self.assertIsNone(layout_module._guard_folder(str(self.tmp / "x"), "/home"))
|
|
self.assertIsNone(
|
|
layout_module._guard_folder("\\\\server\\share\\loras", str(self.tmp))
|
|
)
|
|
self.assertEqual(
|
|
layout_module._guard_folder(str(self.tmp), "/nonexistent-home"),
|
|
os.path.normcase(str(self.tmp)),
|
|
)
|
|
|
|
def test_windows_model_path_matches_a_lower_case_root(self):
|
|
self.assertTrue(
|
|
layout_module._inside(
|
|
"C:/Models/Loras/A.safetensors", ("c:\\models\\loras",), ntpath
|
|
)
|
|
)
|
|
self.assertFalse(
|
|
layout_module._inside(
|
|
"C:/Models/LorasX/A.safetensors", ("c:\\models\\loras",), ntpath
|
|
)
|
|
)
|
|
self.assertFalse(
|
|
layout_module._inside(
|
|
"\\\\other\\share\\A.safetensors", ("c:\\models\\loras",), ntpath
|
|
)
|
|
)
|
|
|
|
def test_mixed_case_native_containment(self):
|
|
inside = str(self.tmp / "Loras" / "a.png")
|
|
root = os.path.normcase(str(self.tmp / "Loras"))
|
|
self.assertTrue(layout_module._inside(inside, (root,)))
|
|
self.assertFalse(layout_module._inside(str(self.tmp / "other.png"), (root,)))
|
|
|
|
|
|
class SummaryTests(LayoutTestCase):
|
|
|
|
def test_summary_names_what_the_import_read(self):
|
|
examples = self.tmp / "ex"
|
|
examples.mkdir()
|
|
sidecars = self.plugin / "sidecars"
|
|
sidecars.mkdir()
|
|
self.write_settings(
|
|
self.plugin,
|
|
{
|
|
"use_portable_settings": True,
|
|
"sidecar_storage_mode": "centralized",
|
|
"example_images_path": str(examples),
|
|
"libraries": {"comfyui": {}},
|
|
},
|
|
)
|
|
layout = self.load()
|
|
summary = layout_summary(layout, self.tmp)
|
|
self.assertEqual(
|
|
summary,
|
|
{
|
|
"source": "portable",
|
|
"settings_file": os.path.join(
|
|
"~", "custom_nodes", "ComfyUI-Lora-Manager", "settings.json"
|
|
),
|
|
"error": None,
|
|
"mode": "centralized",
|
|
"library": "comfyui",
|
|
"example_images": True,
|
|
},
|
|
)
|
|
|
|
def test_summary_outside_home_and_without_settings(self):
|
|
summary = layout_summary(self.load(path=None), self.tmp / "home")
|
|
self.assertEqual(summary["settings_file"], None)
|
|
self.assertEqual(summary["error"], "no LoraManager install found")
|
|
self.assertFalse(summary["example_images"])
|
|
layout = self.load()
|
|
self.assertEqual(
|
|
layout_summary(layout, self.tmp / "home")["settings_file"],
|
|
str(self.user_config / "settings.json"),
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|