Files
ComfyAssets-ComfyUI_PromptM…/tests/test_lora_manager_layout.py
T
Vito d868745194 fix(lora): cache LoraManager's layout and close the #355 follow-ups (#362) (#364)
* feat(lora): cache LoraManager's layout behind file signatures (#362, phase 1)

A new py/lora_layout_cache.py keeps one (install, layout) entry and reuses it
while the configured path, the plugin settings file's and the settings file's
signatures (st_mtime_ns, st_size), the install and every media root's real
path are unchanged and the entry is younger than 30 s. A load whose watched
file changed within 3 s (git's racy rule), could not be checked, or whose
settings were unreadable serves its request only. clear() bumps a generation
so a load that started earlier stores nothing. Hits take no lock.

Not wired yet; phase 2 puts it behind the image hot paths.

* fix(lora): treat an offline Windows share as unvalidated and make cache tests portable (#362)

Review findings on phase 1: Windows raises FileNotFoundError with winerror
53/67 for an offline share, which is a temporary failure, not a missing file.
Test times are whole milliseconds (NTFS keeps 100 ns steps) and a file system
that rounds them skips; cleanup no longer ignores errors; tests added for
ValueError, NotADirectoryError, a failed reload, resolve failures and the
watched-file set.

* perf(lora): serve the image hot paths from the layout cache (#362, phase 2)

_lora_image_dirs takes (install, layout) from lora_utils.cached_lora_manager_layout
instead of resolving the install and reading LoraManager's settings files on
every Library, inspect, reveal and serve request. serve_image builds the
allow-list in the executor instead of on the event loop. A finished import
(System job or legacy scan, both through replace_lora_imports) clears the
cache, so files linked under a folder LoraManager just created are served at
once. The integration-off check and the configured-path rule still run before
the cache.

* fix(lora): fingerprint every scanned sidecar, kept or skipped (#362, phase 3)

Discovery keeps every metadata file it saw (scanned_files, resolved and
sorted; scanned is now its length). The fingerprint hashes each one's path,
size, modification time and whether it was kept, so a skipped sidecar added,
removed or edited between the review and Start refuses Start with
plan-changed, as the review's counts promised.

* fix(lora): warn about refused default LoRA folders and log what failed (#362, phase 4)

ComfyUI's default LoRA folders already pass the folder guard; a refused one
that exists is now listed in the layout's refused_defaults, and the layout
cache warns once per newly refused folder. The cache writes one INFO line
when a stored layout differs from the last one logged. BAD_SIDECAR_ROOT and
the module docstring name a folder that contains the home folder. A relative
preview_url writes a DEBUG line before the suffix search; a non-OSError
failure while loading trigger words logs its traceback. Review follow-ups
harden two serve tests.

* fix(lora): skip a sidecar that is not UTF-8 instead of failing the load (#362)

Review follow-up on phase 4: read_lora_metadata caught JSONDecodeError but not
UnicodeDecodeError, so one non-UTF-8 sidecar failed the whole trigger-word
load (now logged with a traceback every retry) and the import review. It is
skipped like any unreadable sidecar. The relative preview_url DEBUG line uses
lazy formatting.

* docs(testing): the LoRA linked-only gate, layout cache and fingerprint scope (#362, phase 5)

TESTING.md's LoRA reference import contract now states what #358 and #362
shipped: the linked-only gate and _media_roots, linked_only media, the cover
link order, the guard on ComfyUI's default LoRA folders and refused_defaults,
the folder-unreadable code, the trigger cache's catch and 60 s retry, the
fingerprint over every scanned sidecar, and the layout cache's key,
signatures, racy window, maximum age, root re-check and clears. The targeted
command includes tests.test_lora_layout_cache and tests.test_api_images.

* fix(lora): a cached layout needs the same home folder; give the rebuild poll its time (#362)

Final review follow-ups: the folder guard compares against the home folder,
so a cache hit now also needs Path.home() unchanged. TESTING.md says the
configured-path rule runs on the cache's result and that a non-UTF-8
sidecar is skipped.

CI: 'Rebuild all is explicit...' (webkit) polled the job for the default
5 s while it regenerated 24 previews; the failure screenshot shows 12 of 24
done at the deadline on the busy runner (0.17 s locally). The poll now
allows 30 s for that real work.
2026-10-27 05:00:55 -07:00

970 lines
38 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"
BAD_SIDECAR_REASON = (
"the sidecar folder is a filesystem root, the home folder or a folder "
"that contains it"
)
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, BAD_SIDECAR_REASON)
self.assertNotIn(os.path.normcase(os.path.realpath("/")), layout.media_roots)
def test_a_centralized_sidecar_root_that_contains_home(self):
home = self.tmp / "outer" / "me"
home.mkdir(parents=True)
self.write_settings(
self.user_config,
{
"sidecar_storage_mode": "centralized",
"sidecar_storage_path": str(self.tmp / "outer"),
},
)
with patch.object(layout_module.Path, "home", return_value=home):
layout = self.load()
self.assertEqual(layout.error, BAD_SIDECAR_REASON)
self.assertEqual(
layout.media_roots, (os.path.normcase(str(self.default_loras)),)
)
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_every_scanned_file_is_listed_resolved_and_sorted(self):
# #362: the fingerprint covers every scanned sidecar, kept or skipped.
ckpt = self.model(self.checkpoints, "base")
alpha = self.model(self.loras, "alpha")
self.sidecar(self.sidecars / "z", "base", {"file_path": str(ckpt)})
self.sidecar(self.sidecars / "a", "alpha", {"file_path": str(alpha)})
self.sidecar(self.sidecars / "m", "nopath", {"model_name": "x"})
discovery = discover_sidecars(self.load())
files = discovery.scanned_files
self.assertIsInstance(files, tuple)
self.assertEqual(len(files), 3)
self.assertEqual(discovery.scanned, len(files))
self.assertEqual([str(f) for f in files], sorted(str(f) for f in files))
self.assertEqual(
[f.name for f in files],
[
f"alpha{METADATA_SUFFIX}",
f"nopath{METADATA_SUFFIX}",
f"base{METADATA_SUFFIX}",
],
)
for path in files:
self.assertEqual(str(path), os.path.realpath(path))
self.assertEqual(len(discovery.sidecars), 1)
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_a_relative_preview_url_is_logged_and_skipped(self):
fallback = self.touch(self.default_loras / "alpha.webp")
self.touch(self.default_loras / "relative.png")
with self.assertLogs("prompt_manager.lora_manager_layout", "DEBUG") as logs:
media = self.media({"preview_url": "relative.png"})
self.assertEqual(media, [fallback])
self.assertEqual(len(logs.records), 1)
self.assertEqual(logs.records[0].levelname, "DEBUG")
self.assertIn(self.meta.name, logs.output[0])
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 RefusedDefaultsTests(LayoutTestCase):
"""#362: ComfyUI's default LoRA folders pass the same guard; the refused
ones that exist are listed so the layout cache can warn about them."""
def setUp(self):
super().setUp()
self.outer = self.tmp / "outer"
self.home = self.outer / "me"
self.home.mkdir(parents=True)
self.fs_root = os.path.abspath(os.sep)
self.defaults = [
str(self.home),
str(self.outer),
self.fs_root,
str(self.tmp / "missing"),
str(self.default_loras),
]
self.expected = (
layout_module._canonical(str(self.home)),
layout_module._canonical(str(self.outer)),
layout_module._canonical(self.fs_root),
)
home_patch = patch.object(layout_module.Path, "home", return_value=self.home)
home_patch.start()
self.addCleanup(home_patch.stop)
def load_defaults(self, path="__plugin__"):
return self.load(path, default_roots=lambda: list(self.defaults))
def test_refused_existing_defaults_are_listed_on_a_usable_layout(self):
self.write_settings(self.user_config, {})
layout = self.load_defaults()
self.assertIsNone(layout.error)
self.assertEqual(layout.refused_defaults, self.expected)
self.assertEqual(
layout.lora_roots, (os.path.normcase(str(self.default_loras)),)
)
def test_they_are_listed_on_an_unusable_centralized_layout(self):
self.write_settings(
self.user_config,
{"sidecar_storage_mode": "centralized", "sidecar_storage_path": "/"},
)
layout = self.load_defaults()
self.assertEqual(layout.error, BAD_SIDECAR_REASON)
self.assertEqual(layout.refused_defaults, self.expected)
def test_they_are_listed_without_an_install(self):
layout = self.load_defaults(None)
self.assertEqual(layout.error, "no LoraManager install found")
self.assertEqual(layout.refused_defaults, self.expected)
def test_nothing_is_listed_when_every_default_is_accepted(self):
self.write_settings(self.user_config, {})
self.assertEqual(self.load().refused_defaults, ())
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()