From 80c6ec534077336b8df1362ce43dce5692dad894 Mon Sep 17 00:00:00 2001 From: Vito Date: Mon, 26 Oct 2026 21:21:33 -0700 Subject: [PATCH] fix(lora): find LoraManager's sidecars, previews and example images in every storage configuration (#355) (#358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 (" settings · storage · library ", 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: . 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. --- README.md | 8 +- TESTING.md | 30 +- database/category_import.py | 5 +- prompt_manager_base.py | 6 +- py/api/__init__.py | 12 +- py/api/images.py | 135 ++- py/api/library.py | 44 +- py/api/lora_import_routes.py | 64 +- py/api/lora_integration.py | 32 +- py/lora_import.py | 156 ++-- py/lora_manager_layout.py | 602 ++++++++++++ py/lora_utils.py | 193 ++-- tests/browser/server.py | 13 + .../system-integrations-concept.spec.mjs | 13 +- .../workspace-system-integrations.spec.mjs | 57 +- .../browser/workspace_integrations_fixture.py | 34 +- .../js/workspace-system-integrations.test.js | 35 +- tests/js/workspace-toolbar.test.js | 5 + tests/run_suite.py | 39 +- tests/test_api_images.py | 132 ++- tests/test_api_library.py | 183 ++++ tests/test_api_lora.py | 89 +- tests/test_browser_server_lifetime.py | 16 + tests/test_image_reveal.py | 35 + tests/test_lora_import.py | 340 ++++++- tests/test_lora_manager_layout.py | 858 ++++++++++++++++++ tests/test_lora_utils.py | 225 +++-- tests/test_node_execution_contract.py | 37 + tests/test_run_suite.py | 24 + web/js/workspace/image-card.mjs | 7 +- web/js/workspace/system-integrations.mjs | 54 +- web/js/workspace/workspace-dialogs.mjs | 6 +- 32 files changed, 3138 insertions(+), 351 deletions(-) create mode 100644 py/lora_manager_layout.py create mode 100644 tests/test_lora_manager_layout.py diff --git a/README.md b/README.md index e59a5e8..67a2698 100644 --- a/README.md +++ b/README.md @@ -483,11 +483,15 @@ If you use [ComfyUI-Lora-Manager](https://github.com/willmiao/ComfyUI-Lora-Manag Click **Import LoRA Data** in the settings panel to start the import. A progress popup shows real-time status as each LoRA is processed: -- Scans all LoRA directories (including `extra_model_paths.yaml` paths) for metadata -- Downloads example images from CivitAI as 512px thumbnails +- Reads LoRA Manager's own settings the way LoRA Manager does: from the folder in `LORA_MANAGER_SETTINGS_DIR`, from the plugin's `settings.json` in portable mode, or from your user config folder, and only the keys that say where its files are +- Finds each LoRA's metadata in both storage modes: beside the model (the default) or in the central sidecar folder, and imports only LoRAs whose model file still exists in a LoRA folder (the review says how many metadata files it skipped) +- Links LoRA Manager's preview (including `.preview.webp` and video previews) and every downloaded example image and video where they are, without copying them +- Downloads example images from CivitAI as 512px thumbnails only for LoRAs that have no local image - Creates a prompt entry for each LoRA with trigger words, example prompts, and preview images - All imported LoRAs are tagged with `lora-manager` category for easy filtering +The System › Integrations review names the settings it read (source, storage mode and LoRA Manager library) and counts metadata files, LoRAs and LoRAs with local images. If LoRA Manager's settings cannot be used, it says why and nothing is changed. + ![LoRA Results](images/pm-lora-filtered.png) After import, filter by the `lora-manager` category to browse your LoRA collection. diff --git a/TESTING.md b/TESTING.md index 3d26c39..f43da6d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -3410,14 +3410,40 @@ refused while a restore runs; the review is a request lease ("LoRA import review accepts the optional `expected_revision`. LoraManager detection also searches every `custom_nodes` folder ComfyUI registers through `folder_paths` (#144), main folder first. +**Where LoraManager keeps its files (#355).** `py/lora_manager_layout.py` repeats +LoraManager's rules without importing it (`tests/test_lora_manager_layout.py`). The +settings file is `/settings.json`, else the plugin's own +`settings.json` when portable mode is on (`LORA_MANAGER_PORTABLE` `1`/`0` decides, other +values are ignored; otherwise the JSON value `use_portable_settings: true`), else +`platformdirs.user_config_dir("ComfyUI-LoRA-Manager")`. It is read as UTF-8, at most 5 MB, +and only seven keys are kept (`SETTINGS_KEYS`); a missing file means LoraManager's +defaults. Discovery: in `alongside` mode every LoRA root is searched and a sidecar is kept +when `.safetensors` exists beside it; in `centralized` mode only the sidecar root is +searched and a sidecar is kept when its `file_path` exists inside a LoRA root (unparseable +ones are skipped by name). `lora_files` counts the metadata files seen, `with_metadata` +the kept, readable LoRAs and `skipped` the rest. Media per LoRA, without repeats: the +preview (`preview_url`, else LoraManager's `PREVIEW_EXTENSIONS` order), then every file in +`[/]//` in natural order; a leading video yields to the +first still. A file counts only with a servable extension and inside the guarded media +roots. CivitAI downloads run only for a LoRA without local media. The folder guard keeps a +settings folder only when it exists and is neither a filesystem root (`/`, `C:\`, a UNC +share root) nor the home folder or one of its ancestors (realpath + `normcase`). An +unusable layout (no install, unreadable / non-JSON / non-object / oversized settings, no +`platformdirs`, a missing or refused centralized sidecar root) gives a review without a +token ("LoraManager settings: not usable: . Nothing was changed."), a legacy scan +error with that reason, the default LoRA roots for serving, and trigger words from the +default roots in alongside mode. `tests/run_suite.py` and `tests/browser/server.py` point +`LORA_MANAGER_SETTINGS_DIR` at a disposable folder, so no test reads the developer's real +LoraManager settings; tests that call `load_layout` inject their own folder too. + `tests/browser/workspace_integrations_fixture.py` (`/__test__/integrations/*`) makes a fake LoraManager install and LoRA folder under the server's temp root, holds or fails the import, saves a blocking pair and answers CivitAI downloads with a stub opener, so no test reaches the network. ```sh -python3 -m unittest tests.test_lora_import tests.test_api_lora tests.test_lora_database \ - tests.test_lora_utils -v +python3 -m unittest tests.test_lora_manager_layout tests.test_lora_import tests.test_api_lora \ + tests.test_lora_database tests.test_lora_utils -v node --test tests/js/workspace-system-integrations.test.js tests/js/prompt-bridge-adapter.test.js npm run --prefix tools/vendor test:workflows -- workspace-system-integrations.spec.mjs \ system-integrations-concept.spec.mjs \ diff --git a/database/category_import.py b/database/category_import.py index 89e876e..ce0b2d5 100644 --- a/database/category_import.py +++ b/database/category_import.py @@ -192,7 +192,10 @@ class CategoryImportOperations: outcome = "imported" created[prompt_hash] = prompt_id result[_COUNTERS[outcome]] += 1 - for image in record.get("images") or []: + # Linked last to first: every workspace list shows a prompt's media + # newest link first, so the record's first image (the LoRA cover, + # #355) is the one a Library card shows. + for image in reversed(record.get("images") or []): if self._insert_image_link(conn, prompt_id, image): result["images"] += 1 result["rows"].append({"name": name, "outcome": outcome}) diff --git a/prompt_manager_base.py b/prompt_manager_base.py index 5d34acc..3a65531 100644 --- a/prompt_manager_base.py +++ b/prompt_manager_base.py @@ -268,8 +268,10 @@ class PromptManagerBase: cache = get_trigger_cache() - # Lazy-load cache on first use - if not cache.is_loaded and IntegrationConfig.LORA_MANAGER_PATH: + # Lazy-load cache on first use; a failed load waits before the next try + # (#355: never a rescan of LoraManager's folders on every run). + retry_due = getattr(cache, "retry_due", lambda: True) + if not cache.is_loaded and IntegrationConfig.LORA_MANAGER_PATH and retry_due(): cache.load(IntegrationConfig.LORA_MANAGER_PATH) modified, injected = inject_trigger_words(text, cache) diff --git a/py/api/__init__.py b/py/api/__init__.py index 12619d5..ba47bbb 100644 --- a/py/api/__init__.py +++ b/py/api/__init__.py @@ -750,7 +750,12 @@ class PromptManagerAPI( # ── Shared utilities used by multiple mixins ────────────────────── def _enrich_images(self, images): - """Add url and thumbnail_url to a flat list of image dicts.""" + """Add url and thumbnail_url to a flat list of image dicts. + + Each dict without ``is_video`` also gets ``is_video`` and + ``media_type`` from its extension (#355: a linked LoraManager video + outside the output folders is shown as a video, not a picture). + """ from urllib.parse import quote as url_quote output_dirs = self._get_all_output_dirs() @@ -760,6 +765,11 @@ class PromptManagerAPI( if not image_path_str: continue + if "is_video" not in image: + video = is_video_path(image_path_str) + image["is_video"] = video + image["media_type"] = "video" if video else "image" + img_path = Path(image_path_str) # Set fallback url via image ID diff --git a/py/api/images.py b/py/api/images.py index d7e5094..45196f0 100644 --- a/py/api/images.py +++ b/py/api/images.py @@ -2295,14 +2295,43 @@ class ImageRoutesMixin: {"success": False, "error": _safe_error(e)}, status=500 ) + def _media_roots(self, outputs=None): + """The roots a prompt's linked media is listed and inspected under. + + The output folders first (``outputs``, else ``_get_all_output_dirs``), + so every output ``root_index`` keeps its meaning, then, while the + LoRA integration is on, the guarded LoraManager folders and the + CivitAI download cache (``_lora_image_dirs``, #355). Media under those + is linked-only: ``serve_output_image`` serves a file under them only + when the database records it, and no preview file is made there. + """ + roots = list(self._get_all_output_dirs() if outputs is None else outputs) + seen = {_canonical(root) for root in roots} + for extra in self._lora_image_dirs(): + key = _canonical(extra) + if key not in seen: + seen.add(key) + roots.append(Path(extra)) + return roots + def _lora_image_dirs(self): - """Extra directories served when the LoRA integration is enabled.""" + """Extra directories served when the LoRA integration is enabled. + + LoraManager's guarded LoRA roots, sidecar root and example-images + root (``lora_manager_layout.media_roots``, #355) and PromptManager's + CivitAI download cache. An unusable LoraManager layout gives the + default LoRA roots and the cache. + """ try: from ..config import IntegrationConfig if not IntegrationConfig.LORA_MANAGER_ENABLED: return [] - from ..lora_utils import find_lora_directories, get_lora_image_cache_dir + from ..lora_utils import ( + get_lora_image_cache_dir, + layout_for_install, + lora_manager_layout, + ) lora_root = IntegrationConfig.LORA_MANAGER_PATH or "" if lora_root.strip(): @@ -2315,9 +2344,13 @@ class ImageRoutesMixin: lora_root = resolve_lora_manager_path(lora_root) if not lora_root: return [] + layout = layout_for_install(lora_root) + else: + layout = lora_manager_layout("") - dirs = [Path(d) for d in find_lora_directories(lora_root)] - dirs.append(Path(get_lora_image_cache_dir())) + dirs = [Path(d) for d in layout.media_roots] + # Named, not made: listing and serving never create the cache. + dirs.append(Path(get_lora_image_cache_dir(create=False))) return dirs except Exception: # LoRA integration is optional; never widen access on failure. @@ -2348,9 +2381,21 @@ class ImageRoutesMixin: return [] async def serve_output_image(self, request): - """Serve a media file from the ComfyUI output folder(s).""" + """Serve a media file from the ComfyUI output folder(s). + + With ``?root=N`` past the output folders (a LoRA-linked root, + ``_media_roots``, #355) only a file the database records is served, + and no preview is served or made there. + """ try: - roots = list(self._get_all_output_dirs()) + outputs = list(self._get_all_output_dirs()) + root_param = request.query.get("root") + roots = outputs + if root_param is not None and re.fullmatch(r"[0-9]+", root_param): + if int(root_param) >= len(outputs): + # Only a LoRA-linked request reads LoraManager's layout; + # output media (and its previews) never waits on it. + roots = await self._run_in_executor(self._media_roots, outputs) scope_error = self._gallery_scope_error(request.query, roots) if scope_error is not None: return scope_error @@ -2365,9 +2410,13 @@ class ImageRoutesMixin: ): return _forbidden("Only media files can be served") - output_dirs = self._select_root(roots, request.query.get("root")) + output_dirs = self._select_root(roots, root_param) if not output_dirs: return _forbidden("No allowed image directories configured") + if len(outputs) <= int(root_param or 0): + return await self._serve_linked_only( + output_dirs[0], filepath, int(root_param), len(outputs) + ) for output_path in output_dirs: image_path = output_path / filepath @@ -2399,6 +2448,46 @@ class ImageRoutesMixin: {"success": False, "error": _safe_error(e)}, status=500 ) + def _is_recorded_media(self, path) -> bool: + """Whether the database records ``path`` as a linked image. + + Imported local media is stored resolved; downloaded CivitAI images + keep the cache folder's own spelling (a symlinked or junctioned data + folder), so either spelling names the record. + """ + spellings = dict.fromkeys([str(path), os.path.realpath(path)]) + return any( + self.db.get_prompt_id_for_image(spelling) is not None + for spelling in spellings + ) + + async def _linked_only_error(self, index: int, outputs: int, path): + """The one gate for a LoRA-linked root (``_media_roots`` past the + ``outputs`` output folders, #355): only a file the database records + is served, inspected or revealed there; anything else is 403. + Output roots pass (None).""" + if index < outputs: + return None + if not await self._run_in_executor(self._is_recorded_media, path): + return _forbidden() + return None + + async def _serve_linked_only(self, root, filepath, index, outputs): + """A file under a LoRA-linked root, served only when it is recorded.""" + if _is_thumbnail_cache_path(filepath): + return _forbidden() + image_path = root / filepath + if not _is_within(image_path, root): + return _forbidden() + if not image_path.is_file(): + return web.json_response( + {"success": False, "error": "Image file not found"}, status=404 + ) + denied = await self._linked_only_error(index, outputs, image_path) + if denied is not None: + return denied + return self._file_response(image_path) + async def rescan_gallery(self, request): """Rewalk every output root now and answer with the new listing. @@ -3253,7 +3342,9 @@ class ImageRoutesMixin: if error is not None: return error index, scope, relative = target - roots = list(self._get_all_output_dirs()) + # A LoRA-linked root too (#355), for the files the import linked only. + outputs = list(self._get_all_output_dirs()) + roots = await self._run_in_executor(self._media_roots, outputs) error = self._gallery_scope_error( {"root_scope": scope, "root": str(index)}, roots ) @@ -3262,6 +3353,9 @@ class ImageRoutesMixin: root = roots[index] if Path(relative).suffix.lower() not in SERVABLE_EXTENSIONS: return _forbidden("Only media files can be shown") + denied = await self._linked_only_error(index, len(outputs), root / relative) + if denied is not None: + return denied gate = self._reveal_gate if not gate.try_enter(): return web.json_response( @@ -3273,12 +3367,16 @@ class ImageRoutesMixin: status=429, ) try: - return await self._reveal(relative, root) + return await self._reveal(relative, root, linked_only=index >= len(outputs)) finally: gate.leave() - async def _reveal(self, relative: str, root: Path): - """Resolve and open, inside the reveal gate.""" + async def _reveal(self, relative: str, root: Path, *, linked_only=False): + """Resolve and open, inside the reveal gate. + + Under a LoRA-linked root the file that would open is checked again + (it may resolve through a path anchor): only a recorded one opens. + """ try: path = await self._run_in_executor(self._reveal_path, relative, root) except (OSError, ValueError) as exc: @@ -3289,6 +3387,10 @@ class ImageRoutesMixin: return _reveal_not_found() if path is None: return _reveal_not_found() + if linked_only and not await self._run_in_executor( + self._is_recorded_media, path + ): + return _forbidden() try: await asyncio.get_running_loop().run_in_executor( reveal_executor(), self._reveal_opener, path, root @@ -3380,8 +3482,13 @@ class ImageRoutesMixin: return set() async def inspect_image(self, request): - """Read prompt evidence for one explicitly rooted physical media file.""" - roots = list(self._get_all_output_dirs()) + """Read prompt evidence for one explicitly rooted physical media file. + + The roots include LoRA-linked folders (``_media_roots``, #355), so the + inspector opens a LoRA import's media (recorded files only); it only + reads them.""" + outputs = list(self._get_all_output_dirs()) + roots = await self._run_in_executor(self._media_roots, outputs) scope_error = self._gallery_scope_error(request.query, roots) if scope_error is not None: return scope_error @@ -3406,6 +3513,8 @@ class ImageRoutesMixin: return _forbidden("Previews are not inspected; inspect the original") path = Path(roots[index]) / relative denied = _media_access_error(path, [roots[index]]) + if denied is None: + denied = await self._linked_only_error(index, len(outputs), path) if denied is not None: return denied if not path.is_file(): diff --git a/py/api/library.py b/py/api/library.py index ba9e458..2e3dc4b 100644 --- a/py/api/library.py +++ b/py/api/library.py @@ -234,9 +234,22 @@ class LibraryRoutesMixin: ) def _library_media( - self, records: list[dict], roots: list[Path], *, linked: bool = True + self, + records: list[dict], + roots: list[Path], + *, + linked: bool = True, + outputs: int | None = None, ) -> list[dict]: + """Workspace descriptors for a prompt's linked media under ``roots``. + + Roots from index ``outputs`` on are linked-only (LoRA media, #355): + their media keeps its rooted identity, but gets no preview file (the + card loads the original; a video shows its label), no favorite star + and is marked ``linked_only`` (no Tag action). + """ page = [] + linked_only = set() for record in records: try: raw_path = Path(record["image_path"]) @@ -250,10 +263,25 @@ class LibraryRoutesMixin: canonical_root = _canonical(root) if path.is_relative_to(canonical_root): page.append((path, 0, canonical_root, index)) + if outputs is not None and index >= outputs: + relative = path.relative_to(canonical_root).as_posix() + linked_only.add((index, relative)) break except (OSError, ValueError, RuntimeError): continue - return self._format_workspace_page(page, linked=linked) + images = self._format_workspace_page(page, linked=linked) + for image in images: + if (image["root_index"], image["path"]) in linked_only: + image["thumbnail_url"] = None + image["thumbnail_ready"] = False + image["linked_only"] = True + image.pop("favorite", None) + return images + + def _library_roots(self) -> tuple[list[Path], int]: + """``(roots, number of output roots)`` for a prompt's linked media.""" + outputs = list(self._get_all_output_dirs()) + return self._media_roots(outputs), len(outputs) @pair_response async def get_library_options(self, request: web.Request) -> web.Response: @@ -294,13 +322,13 @@ class LibraryRoutesMixin: return result, records result, records = await self._run_in_executor(read) - roots = list(self._get_all_output_dirs()) def previews() -> None: + roots, outputs = self._library_roots() for parent in result["items"]: # Previews belong to their parent; a per-parent link read is N+1. parent["previews"] = self._library_media( - records[parent["id"]], roots, linked=False + records[parent["id"]], roots, linked=False, outputs=outputs ) await self._run_in_executor(previews) @@ -342,9 +370,9 @@ class LibraryRoutesMixin: result, records = await self._run_in_executor(read) if result is None: return _missing() - roots = list(self._get_all_output_dirs()) + roots, outputs = await self._run_in_executor(self._library_roots) result["positive"]["previews"] = await self._run_in_executor( - self._library_media, records, roots + lambda: self._library_media(records, roots, outputs=outputs) ) return web.json_response({"success": True, **result}) @@ -358,7 +386,7 @@ class LibraryRoutesMixin: archive = _archive_scope(request) limit = _page_limit(request) cursor = request.query.get("cursor") - roots = list(self._get_all_output_dirs()) + roots, outputs = await self._run_in_executor(self._library_roots) canonical_roots = [str(_canonical(root)) for root in roots] def read() -> dict | None: @@ -391,7 +419,7 @@ class LibraryRoutesMixin: if result is None: return _missing() result["images"] = await self._run_in_executor( - self._library_media, result["images"], roots + lambda: self._library_media(result["images"], roots, outputs=outputs) ) return web.json_response({"success": True, **result}) diff --git a/py/api/lora_import_routes.py b/py/api/lora_import_routes.py index 2d45de3..cb31341 100644 --- a/py/api/lora_import_routes.py +++ b/py/api/lora_import_routes.py @@ -5,9 +5,12 @@ it lists the LoraManager metadata files and parses them, and counts the current imports, those a saved pair uses and the example images an import would download. It writes nothing and requests nothing. It answers a ``Plan`` (the maintenance plan's shape, ``py/api/maintenance.py``) plus -``{lora_files, with_metadata, current_imports, blocked_by_pairs, -planned_downloads, network:{planned_downloads, api_key}}``. A plan that saved -pairs block carries no token (nothing can be applied). +``{lora_files, with_metadata, skipped, with_local_media, current_imports, +blocked_by_pairs, planned_downloads, network:{planned_downloads, api_key}, +layout}`` (#355: ``layout`` says which LoraManager settings file, storage mode +and library the import read, and ``error`` when they cannot be used). A plan +that saved pairs block, or whose layout is unusable, carries no token +(nothing can be applied). ``POST /prompt_manager/lora/import/apply {token}`` starts the job (202 ``{job}``): kind ``lora_import``, titled "LoRA reference import", admission @@ -31,6 +34,7 @@ import sqlite3 from aiohttp import web from .. import lora_import, lora_utils +from .inspection_service import SERVABLE_EXTENSIONS from .job_routes import busy_response from .jobs import LORA_IMPORT_JOB_KIND, JobBusy from .maintenance import ( @@ -152,8 +156,9 @@ class LoraImportRoutesMixin: return None, ("lora-path", _LORA_PATH_ERROR) return allowed, None - async def stage_lora_import(self, files, *, on_record=None, cancelled=None): - """Stage ``files`` off the loop: records, skips and download counts.""" + async def stage_lora_import(self, discovery, *, on_record=None, cancelled=None): + """Stage ``discovery``'s sidecars off the loop: records, skips and + download counts.""" from ..config import IntegrationConfig cache_dir = await self._run_in_executor(lora_utils.get_lora_image_cache_dir) @@ -166,9 +171,10 @@ class LoraImportRoutesMixin: return await self._run_in_executor( lambda: lora_import.stage( - files, + discovery, cache_dir, IntegrationConfig.CIVITAI_API_KEY, + media_extensions=SERVABLE_EXTENSIONS, on_record=report, cancelled=cancelled or (lambda: False), logger=self.logger, @@ -176,8 +182,8 @@ class LoraImportRoutesMixin: ) async def lora_files_unchanged(self, lm_path, expected): - """Whether the metadata files under ``lm_path`` still fingerprint as - ``expected`` (listed again, so an added file counts too).""" + """Whether LoraManager's sidecars for ``lm_path`` still fingerprint as + ``expected`` (found again, so an added file counts too).""" def current(): return lora_import.fingerprint(lora_import.discover(lm_path)) @@ -220,8 +226,22 @@ class LoraImportRoutesMixin: cache_dir = await self._run_in_executor( lambda: lora_utils.get_lora_image_cache_dir(create=False) ) - found = await self._run_in_executor(lora_import.preview, lm_path, cache_dir) + found = await self._run_in_executor( + lora_import.preview, lm_path, cache_dir, SERVABLE_EXTENSIONS + ) state = await self._run_in_executor(db.category_import_state, CATEGORY) + except OSError as error: + # A folder discovery cannot list refuses the import (it must not + # replace the collection with part of it): say which one. + self.logger.warning( + f"LoRA import review: a folder could not be listed: {error}" + ) + return _failure( + f"A LoRA folder could not be listed ({self._public_error(error)}). " + "Nothing was changed.", + 500, + "folder-unreadable", + ) except Exception: self.logger.exception("LoRA import preview failed") return _failure("The LoRA import could not be reviewed.", 500) @@ -248,7 +268,9 @@ class LoraImportRoutesMixin: return _failure("The LoRA import could not be reviewed.", 500) token = None expires_at = None - if not blocked: + layout = found["layout"] + # An unusable LoraManager layout found nothing it may replace with. + if not blocked and layout["error"] is None: payload = { "path": lm_path, "fingerprint": found["fingerprint"], @@ -280,6 +302,9 @@ class LoraImportRoutesMixin: "expires_at": expires_at, "lora_files": found["lora_files"], "with_metadata": found["with_metadata"], + "skipped": found["skipped"], + "with_local_media": found["with_local_media"], + "layout": layout, "current_imports": current, "blocked_by_pairs": blocked, "planned_downloads": downloads, @@ -342,8 +367,8 @@ class LoraImportRoutesMixin: store.discard(token) return _failure(INTEGRATION_CHANGED, 409, "plan-changed") try: - files = await self._run_in_executor(lora_import.discover, lm_path) - now = await self._run_in_executor(lora_import.fingerprint, files) + discovery = await self._run_in_executor(lora_import.discover, lm_path) + now = await self._run_in_executor(lora_import.fingerprint, discovery) backup = self._backup_promised(plan) _disk, short, _size = await self._run_in_executor( disk_plan, db.model.db_path, backup=backup, vacuum=False @@ -367,9 +392,14 @@ class LoraImportRoutesMixin: lora_import.JOB_TITLE, admission="records", cancellable=True, - scope={"operation": LORA_OPERATION, "lora_files": len(files)}, + scope={ + "operation": LORA_OPERATION, + "lora_files": len(discovery.sidecars), + }, groups=lora_import.GROUPS, - runner=lambda job: self._run_lora_import(job, db, plan, files, backup), + runner=lambda job: self._run_lora_import( + job, db, plan, discovery, backup + ), ) except JobBusy as busy: return busy_response(busy) @@ -378,8 +408,8 @@ class LoraImportRoutesMixin: # ── the job ─────────────────────────────────────────────────────── - async def _run_lora_import(self, job, db, plan, files, backup_on): - total = len(files) + async def _run_lora_import(self, job, db, plan, discovery, backup_on): + total = len(discovery.sidecars) job.update(total=total, status_line="Preparing LoRA records") job.log_line(f"Found {_count(total, 'LoRA metadata file')}") @@ -393,7 +423,7 @@ class LoraImportRoutesMixin: try: staged = await self.stage_lora_import( - files, on_record=on_record, cancelled=lambda: job.cancelled + discovery, on_record=on_record, cancelled=lambda: job.cancelled ) except lora_import.ImportCancelled: job.log_line(f"{CANCELLED} {CACHE_NOTE}") diff --git a/py/api/lora_integration.py b/py/api/lora_integration.py index f038a22..3531d4a 100644 --- a/py/api/lora_integration.py +++ b/py/api/lora_integration.py @@ -50,6 +50,15 @@ _IMPORTS_CHANGED = ( ) +class LoraSettingsUnusable(Exception): + """LoraManager's settings cannot be used; nothing was imported (#355).""" + + def __init__(self, reason): + super().__init__( + f"LoraManager settings: not usable: {reason}. Nothing was changed." + ) + + class LoraFilesChanged(Exception): """A metadata file changed, appeared or went while the legacy scan staged.""" @@ -398,13 +407,19 @@ class LoraIntegrationMixin: return if isinstance(error, PlanChanged): message = _IMPORTS_CHANGED - elif isinstance(error, LoraFilesChanged): + elif isinstance(error, (LoraFilesChanged, LoraSettingsUnusable)): message = str(error) else: message = self._public_error(error) if isinstance( error, - (PlanChanged, LoraFilesChanged, EmptyReplacement, PairDependencyError), + ( + PlanChanged, + LoraFilesChanged, + LoraSettingsUnusable, + EmptyReplacement, + PairDependencyError, + ), ): self.logger.warning(f"LoRA scan refused: {message}") else: @@ -432,8 +447,11 @@ class LoraIntegrationMixin: "progress": 2, } ) - files = await self._run_in_executor(lora_import.discover, lm_path) - total = len(files) + discovery = await self._run_in_executor(lora_import.discover, lm_path) + if discovery.layout.error is not None: + raise LoraSettingsUnusable(discovery.layout.error) + sidecars = discovery.sidecars + total = len(sidecars) await send_progress( { "type": "progress", @@ -443,7 +461,7 @@ class LoraIntegrationMixin: } ) - reviewed = await self._run_in_executor(lora_import.fingerprint, files) + reviewed = await self._run_in_executor(lora_import.fingerprint, discovery) events = asyncio.Queue() state = await self._run_in_executor( self.db.category_import_state, lora_import.LORA_CATEGORY @@ -467,7 +485,7 @@ class LoraIntegrationMixin: ) if not existing: counts["imported"] += 1 - status = record["name"] if record else files[index].name + status = record["name"] if record else sidecars[index].metadata_path.name if record and record["images"]: status += f" ({len(record['images'])} images)" await send_progress( @@ -490,7 +508,7 @@ class LoraIntegrationMixin: stop = threading.Event() staging = asyncio.ensure_future( self.stage_lora_import( - files, + discovery, on_record=on_record, cancelled=lambda: stop.is_set() or disconnected(), ) diff --git a/py/lora_import.py b/py/lora_import.py index c17f631..e405d9d 100644 --- a/py/lora_import.py +++ b/py/lora_import.py @@ -1,18 +1,26 @@ -"""The staged LoRA reference import (#221, #249 System S10). +"""The staged LoRA reference import (#221, #249 System S10, #355). -The import reads LoraManager's ``*.metadata.json`` files and turns each LoRA -into one prompt of the ``lora-manager`` category, tagged with its name and -trigger words and linked to its preview images. It runs in three stages, and -only the last writes to the database: +The import reads LoraManager's ``*.metadata.json`` sidecars and turns each +LoRA into one prompt of the ``lora-manager`` category, tagged with its name and +trigger words and linked to its images. It runs in three stages, and only the +last writes to the database: -1. **discover**: list the metadata files (``discover``) and fingerprint them - (path, size and modification time; ``fingerprint``). ``preview`` adds the - counts a review shows. Nothing is written and nothing is requested. -2. **stage**: read each file and build its record; CivitAI example images are - downloaded into the image cache first (best effort, counted). A cancelled - or failed stage leaves the database untouched; downloaded images stay in - the cache. -3. **replace**: the files are listed and fingerprinted once more (a file +1. **discover**: read LoraManager's settings the way LoraManager does + (``py/lora_manager_layout.py``: the settings file from the environment + folder, the portable plugin file or the user config folder; the LoRA + roots, the storage mode, the sidecar and example-images roots) and find + the sidecars (``discover``). In ``alongside`` mode a sidecar beside its + model is kept when ``.safetensors`` exists; in ``centralized`` mode + every sidecar under the sidecar root is kept when its ``file_path`` names + a model inside a LoRA root. ``fingerprint`` covers the kept sidecars (path, + size and modification time) and the layout; ``preview`` adds the counts a + review shows. Nothing is written and nothing is requested. +2. **stage**: read each sidecar and build its record. Its LoraManager preview + and example images are linked where they are (``local_media``); only a + LoRA without any local image downloads its CivitAI example images into the + image cache first (best effort, counted). A cancelled or failed stage + leaves the database untouched; downloaded images stay in the cache. +3. **replace**: the sidecars are found and fingerprinted once more (a file changed, removed or added while staging aborts the import), then ``PromptDatabase.replace_category_prompts`` swaps the old collection for the staged records in one transaction (or changes nothing). @@ -24,9 +32,15 @@ Both the System job (``POST /lora/import/apply``) and the legacy ``POST import hashlib from dataclasses import dataclass, field from pathlib import Path -from typing import Callable, Dict, List, Optional +from typing import AbstractSet, Callable, Dict, List, Optional from . import lora_utils +from .lora_manager_layout import ( + Discovery, + discover_sidecars, + layout_summary, + local_media, +) try: from ..utils.hashing import generate_prompt_hash @@ -37,35 +51,44 @@ LORA_CATEGORY = "lora-manager" JOB_TITLE = "LoRA reference import" # The job's outcome groups, in its counter-tile order. GROUPS = ("imported", "linked", "skipped", "downloaded", "download_errors", "errors") -METADATA_SUFFIX = "*.metadata.json" class ImportCancelled(Exception): """The import was cancelled while staging; nothing was written.""" -def discover(lora_manager_path: str) -> List[Path]: - """Every LoRA metadata file, sorted and without repeats. +def discover(lora_manager_path: Optional[str]) -> Discovery: + """The sidecars of every LoRA whose model exists inside a LoRA root. - Raises whatever listing a folder raised: a discovery that cannot see - every folder must not replace the collection with part of it. + ``lora_manager_path`` is resolved as the integration resolves it (an + install under ``custom_nodes``; empty means auto-detection). Raises + whatever listing a folder raised: a discovery that cannot see every + folder must not replace the collection with part of it. """ - seen = set() - files = [] - for folder in lora_utils.find_lora_directories(lora_manager_path): - for path in Path(folder).rglob(METADATA_SUFFIX): - key = str(path) - if key not in seen: - seen.add(key) - files.append(path) - files.sort(key=str) - return files + return discover_sidecars(lora_utils.lora_manager_layout(lora_manager_path)) -def fingerprint(files: List[Path]) -> str: - """What a review saw of ``files``: each path, size and modification time.""" +def fingerprint(discovery: Discovery) -> str: + """What a review saw: the layout, then each kept sidecar's path, size and + modification time (a LoraManager save that changes unrelated settings + leaves it as it was).""" + layout = discovery.layout digest = hashlib.sha256() - for path in files: + for value in ( + layout.settings_path, + layout.source, + layout.error, + layout.mode, + "\0".join(layout.lora_roots), + layout.sidecar_root, + layout.example_images_root, + layout.library, + ): + digest.update(f"{value}\n".encode("utf-8", "surrogatepass")) + for name in discovery.unreadable: + digest.update(f"unreadable\0{name}\n".encode("utf-8", "surrogatepass")) + for sidecar in discovery.sidecars: + path = sidecar.metadata_path try: stat = path.stat() entry = f"{path}\0{stat.st_size}\0{stat.st_mtime_ns}\n" @@ -75,31 +98,48 @@ def fingerprint(files: List[Path]) -> str: return "sha256:" + digest.hexdigest() -def preview(lora_manager_path: str, cache_dir: Path) -> Dict: +def preview( + lora_manager_path: Optional[str], + cache_dir: Path, + media_extensions: AbstractSet[str], +) -> Dict: """The review's counts (blocking; reads only, requests nothing). - ``{lora_files, with_metadata, planned_downloads, fingerprint, names}``: - the metadata files found, those that parse, the example images an import - would download (not yet in the cache) and the first LoRA names. + ``{lora_files, with_metadata, skipped, with_local_media, + planned_downloads, fingerprint, names, layout}``: the metadata files + seen, the kept LoRAs whose sidecar parses, the rest, the LoRAs with a + local image, the CivitAI images an import would download for the LoRAs + without one (not yet in the cache), the first LoRA names and what the + layout says (``layout_summary``). """ - files = discover(lora_manager_path) + discovery = discover(lora_manager_path) + layout = discovery.layout with_metadata = 0 + with_local_media = 0 planned = 0 names = [] - for path in files: - metadata = lora_utils.read_lora_metadata(path) + for sidecar in discovery.sidecars: + metadata = lora_utils.read_lora_metadata(sidecar.metadata_path) if not metadata: continue with_metadata += 1 - planned += lora_utils.planned_civitai_downloads(metadata, path, cache_dir) + if local_media(sidecar, metadata, layout, media_extensions): + with_local_media += 1 + else: + planned += lora_utils.planned_civitai_downloads( + metadata, sidecar.metadata_path, cache_dir + ) if len(names) < 50: names.append(lora_utils.get_model_name_from_metadata(metadata)) return { - "lora_files": len(files), + "lora_files": discovery.scanned, "with_metadata": with_metadata, + "skipped": discovery.scanned - with_metadata, + "with_local_media": with_local_media, "planned_downloads": planned, - "fingerprint": fingerprint(files), + "fingerprint": fingerprint(discovery), "names": names, + "layout": layout_summary(layout, Path.home()), } @@ -131,35 +171,45 @@ class StagedImport: def stage( - files: List[Path], + discovery: Discovery, cache_dir: Path, api_key: str = "", *, + media_extensions: AbstractSet[str], on_record: Optional[Callable[[int, Dict, "StagedImport"], None]] = None, cancelled: Callable[[], bool] = lambda: False, logger=None, ) -> StagedImport: - """Read each file and build its record, downloading its example images. + """Read each sidecar and build its record with its images. - Blocking. A file that cannot be read, or whose record fails to build, is - skipped by its name. ``cancelled()`` is asked before each file (raises - ImportCancelled); ``on_record(index, record_or_None, staged)`` follows - each one. + Blocking. Local images (``local_media``) come first; a LoRA without any + downloads its CivitAI example images. A sidecar that cannot be read, or + whose record fails to build, is skipped by its name, as are the + centralized sidecars discovery could not parse. ``cancelled()`` is asked + before each sidecar (raises ImportCancelled); ``on_record(index, + record_or_None, staged)`` follows each one. """ - staged = StagedImport(total=len(files)) - for index, path in enumerate(files): + sidecars = discovery.sidecars + staged = StagedImport(total=len(sidecars)) + staged.skipped.extend(discovery.unreadable) + for index, sidecar in enumerate(sidecars): if cancelled(): raise ImportCancelled() record = None + path = sidecar.metadata_path metadata = lora_utils.read_lora_metadata(path) if not metadata: staged.skipped.append(path.name) else: try: - local = lora_utils.get_preview_images_from_metadata(metadata, path) - remote = lora_utils.download_civitai_images( - metadata, path, cache_dir, api_key, stats=staged.stats + local = local_media( + sidecar, metadata, discovery.layout, media_extensions ) + remote = [] + if not local: + remote = lora_utils.download_civitai_images( + metadata, path, cache_dir, api_key, stats=staged.stats + ) images = list(dict.fromkeys([*local, *remote])) record = build_record(metadata, path, images) staged.records.append(record) diff --git a/py/lora_manager_layout.py b/py/lora_manager_layout.py new file mode 100644 index 0000000..dc3716e --- /dev/null +++ b/py/lora_manager_layout.py @@ -0,0 +1,602 @@ +"""Where LoraManager keeps its files (#355). + +LoraManager (``custom_nodes/comfyui-lora-manager``) stores one +``.metadata.json`` sidecar per model, a preview image or video beside +it, and each model's downloaded CivitAI example images. Where it stores them +is a user setting, and its settings file itself can live in three places. +This module repeats LoraManager's own rules, reading its files only (never +importing it), so the LoRA import, the trigger-word cache and the image +server share one definition: + +- ``load_layout`` finds the settings file (environment folder, portable + plugin file, or the user config folder, in LoraManager's order), keeps only + the seven keys the import needs, and resolves the LoRA roots, the sidecar + root (centralized storage), the example-images root and the active + LoraManager library. Every folder from the settings passes the folder + guard (exists, not a filesystem root, not the home folder itself). +- ``discover_sidecars`` finds every sidecar whose model file exists inside a + LoRA root: beside the models in ``alongside`` mode, under the sidecar root + (matched to its model by ``file_path``) in ``centralized`` mode. +- ``local_media`` lists a LoRA's preview and example images that the image + server may serve. + +Standard library plus ``platformdirs`` only (LoraManager depends on it), so +it is testable outside ComfyUI. +""" + +import json +import os +import re +from dataclasses import dataclass, replace +from pathlib import Path +from typing import ( + AbstractSet, + Callable, + Dict, + Iterator, + List, + Mapping, + Optional, + Sequence, + Tuple, +) + +# py/utils/settings_paths.py: SETTINGS_DIR_ENV, _LM_PORTABLE_ENV, APP_NAME. +SETTINGS_DIR_ENV = "LORA_MANAGER_SETTINGS_DIR" +PORTABLE_ENV = "LORA_MANAGER_PORTABLE" +APP_NAME = "ComfyUI-LoRA-Manager" +# py/utils/constants.py PREVIEW_EXTENSIONS, in LoraManager's order. +PREVIEW_EXTENSIONS = ( + ".webp", + ".preview.webp", + ".preview.png", + ".preview.jpeg", + ".preview.jpg", + ".preview.mp4", + ".png", + ".jpeg", + ".jpg", + ".mp4", + ".gif", + ".webm", + ".avif", + ".jxl", +) +# LoraManager's LoRA scanner accepts .safetensors only (py/services/lora_scanner.py). +MODEL_SUFFIX = ".safetensors" +# py/utils/sidecar_paths.py METADATA_SUFFIX. +METADATA_SUFFIX = ".metadata.json" +MAX_SETTINGS_BYTES = 5 * 1024 * 1024 +# The only settings the import reads; every other key (the file can hold API +# keys) is dropped right after parsing. +SETTINGS_KEYS = frozenset( + { + "sidecar_storage_mode", + "sidecar_storage_path", + "example_images_path", + "folder_paths", + "extra_folder_paths", + "libraries", + "active_library", + } +) +# py/utils/sidecar_paths.py STORAGE_MODE_ALONGSIDE / STORAGE_MODE_CENTRALIZED. +MODE_ALONGSIDE = "alongside" +MODE_CENTRALIZED = "centralized" +# utils/video_metadata.py VIDEO_EXTENSIONS (not imported: that package needs +# the node's relative imports). +VIDEO_SUFFIXES = frozenset({".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi", ".wmv"}) + +SOURCE_ENVIRONMENT = "environment" +SOURCE_PORTABLE = "portable" +SOURCE_USER_CONFIG = "user config" + +NO_INSTALL = "no LoraManager install found" +UNREADABLE = "the settings file could not be read" +NOT_JSON = "the settings file is not valid JSON" +NOT_OBJECT = "the settings file is not a JSON object" +TOO_LARGE = "the settings file is larger than 5 MB" +NO_PLATFORMDIRS = "platformdirs is not available" +NO_SIDECAR_ROOT = "the sidecar folder does not exist" +BAD_SIDECAR_ROOT = "the sidecar folder is a filesystem root or the home folder" + + +@dataclass(frozen=True) +class LoraManagerLayout: + """What LoraManager's settings say about where its files are.""" + + settings_path: Optional[Path] + source: Optional[str] + error: Optional[str] + mode: str + lora_roots: Tuple[str, ...] + sidecar_root: Optional[str] + example_images_root: Optional[str] + library: str + media_roots: Tuple[str, ...] + + +@dataclass(frozen=True) +class Sidecar: + """One LoRA's metadata file and the model file it describes.""" + + metadata_path: Path + model_path: Path + + +@dataclass(frozen=True) +class Discovery: + """The sidecars kept, how many metadata files were seen, and those unread.""" + + layout: LoraManagerLayout + sidecars: List[Sidecar] + scanned: int + unreadable: List[str] + + +class _SettingsError(Exception): + """The settings file cannot be used, for the reason it carries.""" + + +# ── paths ──────────────────────────────────────────────────────────── + + +def _canonical(path: str, pathmod=os.path) -> str: + # py/config.py _canonical_path (not imported: py.config imports ComfyUI's + # ``server`` at import time). + return pathmod.normcase(pathmod.realpath(path)) + + +def _is_filesystem_root(path: str, pathmod=os.path) -> bool: + # py/config.py _is_filesystem_root: '/', 'C:\\' and UNC share roots. + return pathmod.dirname(path) == path + + +def _guard_folder( + path: str, + home: str, + *, + pathmod=os.path, + isdir: Optional[Callable[[str], bool]] = None, +) -> Optional[str]: + """The canonical form of ``path`` when it may be read and served, else None. + + Kept only when it is a folder, not a filesystem root, and neither the + home folder nor one of its ancestors (``/home``, ``C:\\Users``: a + security review ruled that a folder holding the home folder must never + be read or served). + """ + isdir = isdir or os.path.isdir + try: + canonical = _canonical(path, pathmod) + if not isdir(canonical) or _is_filesystem_root(canonical, pathmod): + return None + if _inside(home, (canonical,), pathmod): + return None + except (OSError, ValueError): + return None + return canonical + + +def _inside(path, roots: Sequence[str], pathmod=os.path) -> bool: + """True when ``path`` (canonical) is one of ``roots`` or below one.""" + try: + candidate = _canonical(str(path), pathmod) + except (OSError, ValueError): + return False + for root in roots: + prefix = root if root.endswith(pathmod.sep) else root + pathmod.sep + if candidate == root or candidate.startswith(prefix): + return True + return False + + +def _absolute(value) -> Optional[str]: + """A settings path, stripped, ``~``-expanded and absolute; None when unset.""" + if not isinstance(value, str) or not value.strip(): + return None + return os.path.abspath(os.path.expanduser(value.strip())) + + +# ── the settings file ─────────────────────────────────────────────── + + +def _platform_user_config_dir() -> str: + from platformdirs import user_config_dir + + return user_config_dir(APP_NAME, appauthor=False) + + +def _read_bytes(path: Path) -> bytes: + with open(path, "rb") as handle: + return handle.read(MAX_SETTINGS_BYTES + 1) + + +def _read_settings(path: Path) -> Optional[Dict]: + """The parsed object in ``path``; None when it does not exist.""" + try: + raw = _read_bytes(path) + except FileNotFoundError: + return None + except OSError: + raise _SettingsError(UNREADABLE) from None + if len(raw) > MAX_SETTINGS_BYTES: + raise _SettingsError(TOO_LARGE) + try: + data = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, ValueError): + raise _SettingsError(NOT_JSON) from None + if not isinstance(data, dict): + raise _SettingsError(NOT_OBJECT) + return data + + +def _settings_subset(data: Mapping) -> Dict: + """Only the keys the import reads.""" + return {key: data[key] for key in SETTINGS_KEYS if key in data} + + +def _portable_flag(environ: Mapping[str, str], plugin_settings: Path) -> bool: + """LoraManager's portable mode (settings_paths._should_use_portable_settings).""" + forced = environ.get(PORTABLE_ENV) + if forced == "1": + return True + if forced == "0": + return False + try: + data = _read_settings(plugin_settings) + except _SettingsError: + return False + return bool(data) and data.get("use_portable_settings") is True + + +def _settings_location( + lora_manager_path: str, + environ: Mapping[str, str], + user_config_dir: Callable[[], str], +) -> Tuple[Path, str]: + """``(settings file, source)`` in LoraManager's lookup order.""" + override = environ.get(SETTINGS_DIR_ENV) + if override: + folder = os.path.abspath(os.path.expanduser(override)) + return Path(folder) / "settings.json", SOURCE_ENVIRONMENT + plugin_settings = Path(lora_manager_path) / "settings.json" + if _portable_flag(environ, plugin_settings): + return plugin_settings, SOURCE_PORTABLE + try: + folder = user_config_dir() + except ImportError: + raise _SettingsError(NO_PLATFORMDIRS) from None + return Path(folder) / "settings.json", SOURCE_USER_CONFIG + + +# ── libraries and folders ─────────────────────────────────────────── + + +def _sanitize_library_name(name: str) -> str: + # py/utils/example_images_paths.py sanitize_library_name. + safe = re.sub(r"[^A-Za-z0-9_.-]", "_", name or "") + return safe or "default" + + +def _active_library(settings: Mapping) -> Tuple[str, Optional[Dict], int]: + """``(name, library dict or None, number of libraries)``.""" + libraries = settings.get("libraries") + if not isinstance(libraries, dict) or not libraries: + return "default", None, 0 + active = settings.get("active_library") + if not isinstance(active, str) or active not in libraries: + active = next(iter(libraries)) + library = libraries[active] + return str(active), library if isinstance(library, dict) else None, len(libraries) + + +def _lora_list(settings: Mapping, library: Optional[Dict], key: str) -> List[str]: + """The ``loras`` folders of ``key`` (the library's, else the top level).""" + source = library if library is not None and key in library else settings + section = source.get(key) + if not isinstance(section, dict): + return [] + value = section.get("loras") + if isinstance(value, str): + value = [value] + if not isinstance(value, list): + return [] + return [entry for entry in value if isinstance(entry, str) and entry.strip()] + + +def _guarded(paths, home: str) -> Tuple[str, ...]: + kept = [] + for path in paths: + absolute = _absolute(path) + guarded = _guard_folder(absolute, home) if absolute else None + if guarded and guarded not in kept: + kept.append(guarded) + return tuple(kept) + + +def _example_images_root( + settings: Mapping, library: str, libraries: int, home: str +) -> Optional[str]: + """py/utils/example_images_paths.py get_library_root, when the folder exists.""" + configured = _absolute(settings.get("example_images_path")) + root = _guard_folder(configured, home) if configured else None + if root is None or libraries <= 1: + return root + # Guarded (canonical) like its root: a library name keeps its case, and + # the media check compares normcased paths. + return _guard_folder(os.path.join(root, _sanitize_library_name(library)), home) + + +def _media_roots(*groups) -> Tuple[str, ...]: + roots = [] + for group in groups: + for root in group: + if root and root not in roots: + roots.append(root) + return tuple(roots) + + +def load_layout( + lora_manager_path: Optional[str], + *, + default_roots: Callable[[], List[str]], + environ: Mapping[str, str] = os.environ, + user_config_dir: Callable[[], str] = _platform_user_config_dir, +) -> LoraManagerLayout: + """Read LoraManager's settings the way LoraManager does (read only). + + ``lora_manager_path`` is the resolved install (``None`` when none was + found). An unusable layout carries ``error``, alongside mode and the + default LoRA roots, so serving and trigger words keep working. + """ + home = str(Path.home()) + defaults = _guarded(default_roots(), home) + fallback = LoraManagerLayout( + settings_path=None, + source=None, + error=NO_INSTALL, + mode=MODE_ALONGSIDE, + lora_roots=defaults, + sidecar_root=None, + example_images_root=None, + library="default", + media_roots=defaults, + ) + if not lora_manager_path: + return fallback + try: + settings_path, source = _settings_location( + lora_manager_path, environ, user_config_dir + ) + except _SettingsError as error: + return replace(fallback, error=str(error)) + fallback = replace(fallback, settings_path=settings_path, source=source) + try: + raw = _read_settings(settings_path) + except _SettingsError as error: + return replace(fallback, error=str(error)) + settings = _settings_subset(raw or {}) + return _layout_from_settings(settings, settings_path, source, defaults, home) + + +def _layout_from_settings(settings, settings_path, source, defaults, home): + library, entry, libraries = _active_library(settings) + lora_roots = _media_roots( + defaults, + _guarded(_lora_list(settings, entry, "folder_paths"), home), + _guarded(_lora_list(settings, entry, "extra_folder_paths"), home), + ) + mode = settings.get("sidecar_storage_mode") + mode = MODE_CENTRALIZED if mode == MODE_CENTRALIZED else MODE_ALONGSIDE + example_root = _example_images_root(settings, library, libraries, home) + layout = LoraManagerLayout( + settings_path=settings_path, + source=source, + error=None, + mode=mode, + lora_roots=lora_roots, + sidecar_root=None, + example_images_root=example_root, + library=library, + media_roots=_media_roots(lora_roots, [example_root]), + ) + if mode != MODE_CENTRALIZED: + return layout + configured = _absolute(settings.get("sidecar_storage_path")) + configured = configured or os.path.join(str(settings_path.parent), "sidecars") + sidecar_root = _guard_folder(configured, home) + if sidecar_root is None: + reason = NO_SIDECAR_ROOT if not os.path.isdir(configured) else BAD_SIDECAR_ROOT + return replace( + layout, + error=reason, + lora_roots=defaults, + example_images_root=None, + media_roots=defaults, + ) + return replace( + layout, + sidecar_root=sidecar_root, + media_roots=_media_roots(lora_roots, [sidecar_root], [example_root]), + ) + + +# ── sidecars ──────────────────────────────────────────────────────── + + +def _metadata_files(folder: str) -> Iterator[Path]: + """Every ``*.metadata.json`` under ``folder``; an unlistable folder raises. + + Symlinked folders are followed, each real folder once (no loops). + """ + + def fail(error): + raise error + + visited = set() + for current, dirs, files in os.walk(folder, onerror=fail, followlinks=True): + real = _canonical(current) + if real in visited: + dirs[:] = [] + continue + visited.add(real) + dirs.sort() + for name in sorted(files): + if name.endswith(METADATA_SUFFIX): + yield Path(current) / name + + +def _read_sidecar(path: Path) -> Optional[Dict]: + try: + with open(path, "r", encoding="utf-8") as handle: + data = json.load(handle) + except (OSError, ValueError): + return None + return data if isinstance(data, dict) else None + + +def _model_for(path: Path, layout: LoraManagerLayout, unreadable: List[str]): + """The model file a sidecar describes, when it exists inside a LoRA root.""" + if layout.mode == MODE_ALONGSIDE: + base = path.name[: -len(METADATA_SUFFIX)] + model = path.parent / f"{base}{MODEL_SUFFIX}" + return model if model.is_file() else None + metadata = _read_sidecar(path) + if metadata is None: + unreadable.append(path.name) + return None + file_path = metadata.get("file_path") + if not isinstance(file_path, str) or not file_path.strip(): + return None + model = Path(file_path) + if not model.is_file() or not _inside(model, layout.lora_roots): + return None + return model + + +def discover_sidecars(layout: LoraManagerLayout) -> Discovery: + """Every sidecar whose model exists inside a LoRA root, sorted, once each. + + Raises ``OSError`` when a folder cannot be listed: a discovery that + cannot see every folder must not replace the collection with part of it. + """ + if layout.error is not None: + return Discovery(layout, [], 0, []) + if layout.mode == MODE_CENTRALIZED: + folders = [layout.sidecar_root] + else: + folders = list(layout.lora_roots) + seen = set() + scanned = 0 + unreadable: List[str] = [] + sidecars = [] + for folder in folders: + for path in _metadata_files(folder): + resolved = Path(os.path.realpath(path)) + key = os.path.normcase(str(resolved)) + if key in seen: + continue + seen.add(key) + scanned += 1 + model = _model_for(resolved, layout, unreadable) + if model is not None: + sidecars.append(Sidecar(resolved, Path(os.path.realpath(model)))) + sidecars.sort(key=lambda sidecar: str(sidecar.metadata_path)) + return Discovery(layout, sidecars, scanned, unreadable) + + +# ── images ────────────────────────────────────────────────────────── + + +def _natural_key(name: str): + parts = re.split(r"(\d+)", name) + return [int(part) if part.isdigit() else part.lower() for part in parts], name + + +def _example_files(folder: str) -> List[str]: + try: + with os.scandir(folder) as entries: + names = [entry.name for entry in entries if entry.is_file()] + except OSError: + return [] + return [os.path.join(folder, name) for name in sorted(names, key=_natural_key)] + + +def _preview_candidates(sidecar: Sidecar, metadata: Dict) -> Iterator[str]: + """The paths a preview is looked for at, in LoraManager's order.""" + url = metadata.get("preview_url") + if isinstance(url, str) and url.strip() and os.path.isabs(url.strip()): + yield url.strip() + base = sidecar.metadata_path.name[: -len(METADATA_SUFFIX)] + for suffix in PREVIEW_EXTENSIONS: + yield str(sidecar.metadata_path.parent / f"{base}{suffix}") + + +def local_media( + sidecar: Sidecar, + metadata: Dict, + layout: LoraManagerLayout, + media_extensions: AbstractSet[str], +) -> List[str]: + """The LoRA's preview, then its example images: absolute, resolved paths. + + A file counts only when its extension is in ``media_extensions`` (what the + image server serves) and it lies inside ``layout.media_roots``. + """ + + def usable(path: str) -> bool: + return ( + os.path.splitext(path)[1].lower() in media_extensions + and os.path.isfile(path) + and _inside(path, layout.media_roots) + ) + + found: List[str] = [] + for path in _preview_candidates(sidecar, metadata): + if usable(path): + found.append(path) + break + sha256 = metadata.get("sha256") + if layout.example_images_root and isinstance(sha256, str) and sha256.strip(): + folder = os.path.join(layout.example_images_root, sha256.strip().lower()) + found.extend(path for path in _example_files(folder) if usable(path)) + media: List[str] = [] + for path in found: + resolved = os.path.realpath(path) + if resolved not in media: + media.append(resolved) + return _cover_first(media) + + +def _cover_first(media: List[str]) -> List[str]: + """A still image in front of a leading video (no thumbnails for these yet).""" + if not media or os.path.splitext(media[0])[1].lower() not in VIDEO_SUFFIXES: + return media + for index, path in enumerate(media): + if os.path.splitext(path)[1].lower() not in VIDEO_SUFFIXES: + return [path] + media[:index] + media[index + 1 :] + return media + + +# ── the review ────────────────────────────────────────────────────── + + +def layout_summary(layout: LoraManagerLayout, home: Path) -> Dict: + """What the review shows about the layout (no settings values).""" + settings_file = None + if layout.settings_path is not None: + settings_file = str(layout.settings_path) + try: + relative = Path(settings_file).relative_to(home) + settings_file = os.path.join("~", str(relative)) + except ValueError: + pass + return { + "source": layout.source, + "settings_file": settings_file, + "error": layout.error, + "mode": layout.mode, + "library": layout.library, + "example_images": layout.example_images_root is not None, + } diff --git a/py/lora_utils.py b/py/lora_utils.py index 5965af4..5d33489 100644 --- a/py/lora_utils.py +++ b/py/lora_utils.py @@ -13,6 +13,7 @@ import json import os import re import threading +import time import urllib.error import urllib.request from pathlib import Path @@ -27,6 +28,16 @@ except ImportError: sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from utils.logging_config import get_logger +from dataclasses import replace + +from .lora_manager_layout import ( + MODE_ALONGSIDE, + METADATA_SUFFIX, + LoraManagerLayout, + discover_sidecars, + load_layout, +) + logger = get_logger("prompt_manager.lora_utils") # ── LoraManager detection ──────────────────────────────────────────── @@ -234,23 +245,22 @@ def resolve_lora_manager_path(path) -> Optional[str]: # ── Metadata reading ───────────────────────────────────────────────── -def find_lora_directories(lora_manager_path: str) -> List[str]: - """Find directories that contain LoRA models (with .metadata.json files). +def default_lora_roots() -> List[str]: + """The LoRA folders ComfyUI knows, without LoraManager's settings. - Searches: ComfyUI models/loras, extra_model_paths.yaml lora dirs, - and the LoraManager extension dir itself. + ComfyUI's ``models/loras``, the ``loras`` entries of + ``extra_model_paths.yaml`` and, inside ComfyUI, every folder + ``folder_paths`` registers for ``loras``. ``lora_manager_layout`` adds + the folders LoraManager's own settings name. """ dirs = set() - lm_path = Path(lora_manager_path) root = find_comfyui_root() if root: - # Default models/loras models_loras = root / "models" / "loras" if models_loras.is_dir(): dirs.add(str(models_loras.resolve())) - # Extra model paths from ComfyUI config for extra_dir in _get_extra_lora_paths(root): if extra_dir.is_dir(): dirs.add(str(extra_dir.resolve())) @@ -267,13 +277,28 @@ def find_lora_directories(lora_manager_path: str) -> List[str]: # folder_paths unavailable — not running inside ComfyUI runtime pass - # Check for any .metadata.json in the LoraManager dir tree - for meta in lm_path.rglob("*.metadata.json"): - dirs.add(str(meta.parent.resolve())) - return sorted(dirs) +def lora_manager_install(lora_manager_path: Optional[str]) -> Optional[str]: + """The LoraManager install to read: the configured path when it is an + allowed install under ``custom_nodes``, auto-detection when it is empty.""" + if isinstance(lora_manager_path, str) and lora_manager_path.strip(): + return resolve_lora_manager_path(lora_manager_path) + detected = detect_lora_manager() + return resolve_lora_manager_path(detected) if detected else None + + +def layout_for_install(install: Optional[str]) -> LoraManagerLayout: + """The layout of an already resolved install (None: none was found).""" + return load_layout(install, default_roots=default_lora_roots) + + +def lora_manager_layout(lora_manager_path: Optional[str]) -> LoraManagerLayout: + """Where LoraManager keeps its files (``py/lora_manager_layout.py``).""" + return layout_for_install(lora_manager_install(lora_manager_path)) + + def _get_extra_lora_paths(comfyui_root: Path) -> List[Path]: """Parse extra_model_paths.yaml for additional LoRA directories.""" results = [] @@ -379,46 +404,6 @@ def get_model_name_from_metadata(metadata: Dict) -> str: return name -def get_preview_images_from_metadata(metadata: Dict, metadata_path: Path) -> List[str]: - """Find all local preview/example image paths for a LoRA. - - Returns: - List of absolute path strings to image files. - """ - results = [] - lora_dir = metadata_path.parent - file_name = metadata.get("file_name", "") - if not file_name: - stem = metadata_path.name.replace(".metadata.json", "") - file_name = stem - - base_name = Path(file_name).stem - - # Check standard preview naming conventions - for ext in ( - ".png", - ".jpg", - ".jpeg", - ".webp", - ".preview.png", - ".preview.jpg", - ".preview.jpeg", - ): - candidate = lora_dir / f"{base_name}{ext}" - if candidate.exists(): - results.append(str(candidate.resolve())) - - return results - - -def get_preview_image_from_metadata( - metadata: Dict, metadata_path: Path -) -> Optional[str]: - """Find the first preview image path for a LoRA (backward compat).""" - images = get_preview_images_from_metadata(metadata, metadata_path) - return images[0] if images else None - - _THUMB_MAX_SIZE = 512 # CivitAI's own domains (#340: civitai.red and civitai.green serve the same @@ -837,30 +822,16 @@ def get_lora_image_cache_dir(create: bool = True) -> Path: return cache -def get_example_images_dir(lora_manager_path: str) -> Optional[str]: - """Find the LoraManager example_images directory.""" - lm_path = Path(lora_manager_path) - - # Direct subdirectory - candidate = lm_path / "example_images" - if candidate.is_dir(): - return str(candidate.resolve()) - - # Search one level in user data dirs - for child in lm_path.iterdir(): - if child.is_dir(): - sub = child / "example_images" - if sub.is_dir(): - return str(sub.resolve()) - - return None - - # ── Trigger word cache & injection ─────────────────────────────────── _LORA_PATTERN = re.compile(r"]+):[^>]+>", re.IGNORECASE) +# A failed load is retried by a running node only after this long (an explicit +# reload, from the settings routes, always runs). +TRIGGER_RETRY_SECONDS = 60 + + class TriggerWordCache: """Thread-safe cache mapping LoRA names to their trigger words. @@ -871,52 +842,81 @@ class TriggerWordCache: self._cache: Dict[str, List[str]] = {} self._lock = threading.Lock() self._loaded = False + self._failed_at: Optional[float] = None def load(self, lora_manager_path: str) -> int: - """Scan LoRA metadata files and build the trigger word mapping. + """Build the trigger word map from the sidecars LoraManager uses. + + Never raises (a node calls this while it runs): a folder that cannot + be listed, or any other failure, is logged and the previous map is + kept. When LoraManager's settings cannot be used, the reason is + logged and the default LoRA folders are read in alongside mode. Returns: - Number of LoRAs with trigger words found. + Number of LoRAs with trigger words in the map. """ - new_cache: Dict[str, List[str]] = {} - - lora_dirs = find_lora_directories(lora_manager_path) - for lora_dir in lora_dirs: - dir_path = Path(lora_dir) - for meta_file in dir_path.rglob("*.metadata.json"): - metadata = read_lora_metadata(meta_file) - if not metadata: - continue - - words = get_trigger_words_from_metadata(metadata) - if not words: - continue - - # Key by filename stem (what appears in ) - file_name = metadata.get("file_name", "") - if file_name: - stem = Path(file_name).stem - new_cache[stem.lower()] = words - - # Also key by the metadata file stem - meta_stem = meta_file.name.replace(".metadata.json", "") - if meta_stem.lower() not in new_cache: - new_cache[meta_stem.lower()] = words + try: + new_cache = self._build(lora_manager_path) + except Exception as error: + logger.warning(f"Trigger word cache not refreshed: {error}") + with self._lock: + self._failed_at = time.monotonic() + return len(self._cache) with self._lock: self._cache = new_cache self._loaded = True + self._failed_at = None logger.info( f"Trigger word cache loaded: {len(new_cache)} LoRAs with trigger words" ) return len(new_cache) + @staticmethod + def _build(lora_manager_path: str) -> Dict[str, List[str]]: + layout = lora_manager_layout(lora_manager_path) + if layout.error is not None: + logger.warning( + f"LoraManager settings not usable ({layout.error}); trigger words " + "come from the default LoRA folders" + ) + layout = replace( + layout, + error=None, + mode=MODE_ALONGSIDE, + sidecar_root=None, + example_images_root=None, + ) + new_cache: Dict[str, List[str]] = {} + for sidecar in discover_sidecars(layout).sidecars: + metadata = read_lora_metadata(sidecar.metadata_path) + if not metadata: + continue + words = get_trigger_words_from_metadata(metadata) + if not words: + continue + # Key by the model file name (what appears in ) + # and by the sidecar's name. + new_cache[sidecar.model_path.stem.lower()] = words + sidecar_stem = sidecar.metadata_path.name[: -len(METADATA_SUFFIX)] + new_cache.setdefault(sidecar_stem.lower(), words) + return new_cache + def get_trigger_words(self, lora_name: str) -> List[str]: """Look up trigger words for a LoRA by name (case-insensitive).""" with self._lock: return self._cache.get(lora_name.lower(), []) + def retry_due(self) -> bool: + """Whether a node may try loading again: never failed, or the last + failure is TRIGGER_RETRY_SECONDS old (no rescan on every prompt run).""" + with self._lock: + failed_at = self._failed_at + return failed_at is None or time.monotonic() - failed_at >= ( + TRIGGER_RETRY_SECONDS + ) + @property def is_loaded(self) -> bool: with self._lock: @@ -926,6 +926,7 @@ class TriggerWordCache: with self._lock: self._cache.clear() self._loaded = False + self._failed_at = None # Module-level singleton diff --git a/tests/browser/server.py b/tests/browser/server.py index 38766aa..abccbfd 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -166,7 +166,20 @@ def register_reveal_stub(routes, api, root: Path): return types.SimpleNamespace(reset=reset) +def isolate_lora_manager_settings(root: Path) -> Path: + """LoraManager's settings lookup reads an empty folder under ``root`` (#355). + + The server's temporary root is removed when it exits, and with it this + folder: no page test reads the developer's real LoraManager settings. + """ + folder = root / "lora-manager-settings" + folder.mkdir(exist_ok=True) + os.environ["LORA_MANAGER_SETTINGS_DIR"] = str(folder) + return folder + + def create_app(root: Path) -> web.Application: + isolate_lora_manager_settings(root) os.environ["PROMPT_MANAGER_CONFIG_PATH"] = str(root / "config.json") os.chdir(root) app = web.Application() diff --git a/tests/browser/system-integrations-concept.spec.mjs b/tests/browser/system-integrations-concept.spec.mjs index b9c41cd..30be732 100644 --- a/tests/browser/system-integrations-concept.spec.mjs +++ b/tests/browser/system-integrations-concept.spec.mjs @@ -270,7 +270,8 @@ function mapImportConcept(reference, plan) { title: 'Review LoRA import', tree: ['h3', 'dl.meta-grid', 'details', 'p.small', 'div.notice', 'div.dialog-actions'], heading: { ...reference.heading, text: 'LoRA reference data' }, - grid: { ...reference.grid, rows: [['Scope', plan.scope.label], ['Found', '2 metadata files · 2 readable'], + grid: { ...reference.grid, rows: [['LoraManager', 'environment settings · alongside storage · library default'], + ['Scope', plan.scope.label], ['Found', '2 metadata files · 2 LoRAs · 0 with local images'], ['Protected', 'Nothing is protected'], ['Network', 'Downloads up to 2 example images from CivitAI'], ['Backup', `Create and verify before changes (${plan.backup.destination_label})`], ['Media on disk', 'No files are removed; image links are replaced']] }, @@ -312,7 +313,7 @@ async function productionReview(page, request, theme) { for (const theme of THEMES) for (const [width, height] of SIZES) { test(`LoRA import review matches the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => { await page.setViewportSize({ width, height }); - await fakeLoraManager(request, { loras: 2, civitai: true }); + await fakeLoraManager(request, { loras: 2, civitai: true, preview: false }); const concept = await conceptReview(page, theme); expect(concept.tree).toEqual(['p.demo-note', 'h3', 'dl.meta-grid', 'p.small', 'div.dialog-actions']); await page.screenshot({ path: testInfo.outputPath(`concept-plan-review-${theme}-${width}.png`) }); @@ -324,15 +325,15 @@ for (const theme of THEMES) for (const [width, height] of SIZES) { test('LoRA import review comparison detects mutations', async ({ page, request }) => { await page.setViewportSize({ width: 1600, height: 1100 }); - await fakeLoraManager(request, { loras: 2, civitai: true }); + await fakeLoraManager(request, { loras: 2, civitai: true, preview: false }); const concept = await conceptReview(page, 'dark'); const { plan } = await productionReview(page, request, 'dark'); const expected = mapImportConcept(concept, plan); const paths = async () => diffInventory(expected, await page.evaluate(measureReview)).map(item => item.path); expect(await paths()).toEqual([]); - await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[3].textContent = 'Downloads'; }); - expect(await paths()).toEqual(['grid.rows.3.0']); - await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[3].textContent = 'Network'; }); + await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[4].textContent = 'Downloads'; }); + expect(await paths()).toEqual(['grid.rows.4.0']); + await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[4].textContent = 'Network'; }); const undoGrid = await mutate(page, { css: 'dialog#modal dl.meta-grid { column-gap: 3px; }' }); expect(await paths()).toEqual(expect.arrayContaining(['grid.columnGap'])); await undoGrid(); diff --git a/tests/browser/workspace-system-integrations.spec.mjs b/tests/browser/workspace-system-integrations.spec.mjs index f8ee02c..1d5ad38 100644 --- a/tests/browser/workspace-system-integrations.spec.mjs +++ b/tests/browser/workspace-system-integrations.spec.mjs @@ -322,7 +322,7 @@ test('a partial save made from Integrations keeps its outcome when Sources opens test('Review import shows replacement, blocked items and network effects with the backup box; Start opens the job (D107)', async ({ page, request }) => { - await lora(request, { loras: 3, civitai: true, previous: 2 }); + await lora(request, { loras: 3, civitai: true, preview: false, previous: 2 }); const previews = []; const applies = []; page.on('request', item => { @@ -339,13 +339,17 @@ test('Review import shows replacement, blocked items and network effects with th await expect(dialog(page).locator('#modal-body > h3')).toHaveText('LoRA reference data'); expect(previews).toEqual([{}]); expect(await gridOf(page)).toEqual([ + ['LoraManager', 'environment settings · alongside storage · library default'], ['Scope', 'Replace 2 imported prompts with 3 LoRAs'], - ['Found', '3 metadata files · 3 readable'], + ['Found', '3 metadata files · 3 LoRAs · 0 with local images'], ['Protected', 'Nothing is protected'], ['Network', 'Downloads up to 3 example images from CivitAI'], ['Backup', 'Create and verify before changes (browser.db.backup_)'], ['Media on disk', 'No files are removed; image links are replaced'], ]); + // #355: the settings file the server read is the LoraManager row's hover title. + await expect(dialog(page).locator('dl.meta-grid > div').first().locator('dd')) + .toHaveAttribute('title', /lora-manager-settings[\/\\]settings\.json$/); await expect(dialog(page).locator('details > summary')).toHaveText('LoRAs found (3)'); await expect(dialog(page).locator('.notice h3')).toHaveText('Back up the database first (recommended)'); await expect(dialog(page).locator('p.small').last()).toContainText('stay in the image cache'); @@ -382,7 +386,7 @@ test('saved pairs block the import: the review explains it and offers only Close await enable(page); await layoutOf(page).getByRole('button', { name: 'Review import', exact: true }).click(); // gridOf reads once; poll until the review has rendered its rows. - await expect.poll(async () => (await gridOf(page))[2]).toEqual(['Protected', '1 imported prompt used by saved pairs']); + await expect.poll(async () => (await gridOf(page))[3]).toEqual(['Protected', '1 imported prompt used by saved pairs']); await expect(dialog(page).locator('p.small')).toHaveText( 'Delete or change the saved pairs that use these prompts, then review the import again. Nothing was changed.'); expect(await dialog(page).locator('.dialog-actions button').allTextContents()).toEqual(['Close']); @@ -390,6 +394,51 @@ test('saved pairs block the import: the review explains it and offers only Close await expect(dialog(page)).toHaveCount(0); }); +test('unusable LoraManager settings: the review says why and offers only Close (#355)', async ({ page, request }) => { + await lora(request, { previous: 1, settings: 'invalid' }); + await openIntegrations(page); + await enable(page); + const before = await serverJson(request, '/__test__/integrations/state'); + await layoutOf(page).getByRole('button', { name: 'Review import', exact: true }).click(); + await expect.poll(async () => (await gridOf(page))[0]) + .toEqual(['LoraManager', 'Not usable: the settings file is not valid JSON']); + await expect(dialog(page).locator('p.small')).toHaveText( + 'LoraManager settings: not usable: the settings file is not valid JSON. Nothing was changed.'); + expect(await dialog(page).locator('.dialog-actions button').allTextContents()).toEqual(['Close']); + await dialog(page).getByRole('button', { name: 'Close', exact: true }).click(); + await expect(dialog(page)).toHaveCount(0); + expect(await serverJson(request, '/__test__/integrations/state')).toEqual(before); +}); + +test('imported LoRA previews show on the Library card and open in the inspector and viewer (#355 G6)', async ({ page, request }) => { + await lora(request, { loras: 1 }); + await openIntegrations(page); + await enable(page); + await layoutOf(page).getByRole('button', { name: 'Review import', exact: true }).click(); + await dialog(page).getByRole('button', { name: 'Start import', exact: true }).click(); + await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/); + expect((await ended(request, page.url().split('/').pop())).state).toBe('finished'); + await page.goto(`${workspace}#/library`); + const card = page.locator('#workspace-main article.prompt-card').filter({ hasText: 'Style 01' }); + const cover = card.locator('.card-art img'); + await expect(cover).toHaveAttribute('src', /\/prompt_manager\/images\/serve\/style_01\.png\?root=\d+&root_scope=[0-9a-f]{64}$/); + await expect.poll(() => cover.evaluate(node => node.complete && node.naturalWidth)).toBeGreaterThan(0); + await expect(card.locator('.card-art')).not.toContainText('No available image previews'); + // The prompt's gallery: Info inspects the file and the viewer opens it; no Tag action or star for LoRA media. + await card.getByRole('button', { name: 'Explore history', exact: true }).click(); + const item = page.getByRole('region', { name: 'Shared gallery', exact: true }).locator('article[data-image-key]').first(); + await expect(item.getByRole('button', { name: 'Tag or auto-tag style_01.png', exact: true })).toHaveCount(0); + await expect(item.locator('.star-button')).toHaveCount(0); + await item.getByRole('button', { name: 'Inspect style_01.png', exact: true }).click(); + const inspector = page.getByRole('complementary', { name: 'Selected image details', exact: true }); + await expect(inspector).toContainText('style_01.png'); + await item.getByRole('button', { name: 'Open style_01.png', exact: true }).click(); + await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible(); + await expect.poll(() => page.locator('dialog[open] .viewer-canvas img').evaluate(node => node.complete && node.naturalWidth)) + .toBeGreaterThan(0); + await page.keyboard.press('Escape'); +}); + test('a failed import keeps old imports and its job says so', async ({ page, request }) => { await lora(request, { loras: 2, previous: 2 }); await openIntegrations(page); @@ -408,7 +457,7 @@ test('a failed import keeps old imports and its job says so', async ({ page, req }); test('the import can be cancelled while it downloads; then nothing is written', async ({ page, request }) => { - await lora(request, { loras: 2, civitai: true, previous: 1 }); + await lora(request, { loras: 2, civitai: true, preview: false, previous: 1 }); await openIntegrations(page); await enable(page); const before = await serverJson(request, '/__test__/integrations/state'); diff --git a/tests/browser/workspace_integrations_fixture.py b/tests/browser/workspace_integrations_fixture.py index af7c328..b56f799 100644 --- a/tests/browser/workspace_integrations_fixture.py +++ b/tests/browser/workspace_integrations_fixture.py @@ -7,13 +7,18 @@ reaches a real LoraManager, ComfyUI install or the network: no key, no trigger words), removes the fake install, its LoRA folder and the image cache, forgets every LoRA import and injected failure, and opens the download gate; -- ``POST /__test__/integrations/lora-manager`` ``{loras, civitai, previous}`` - writes ``custom_nodes/ComfyUI-Lora-Manager`` (detected through the - ``folder_paths`` stub's base path) and ``loras`` metadata files under - ``models/loras``, each with a local preview PNG; ``civitai`` adds one - CivitAI example image per LoRA (served by the stub opener below); - ``previous`` saves that many earlier ``lora-manager`` imports with tags and - image links; +- ``POST /__test__/integrations/lora-manager`` + ``{loras, civitai, preview, previous, settings}`` writes + ``custom_nodes/ComfyUI-Lora-Manager`` (detected through the + ``folder_paths`` stub's base path) and ``loras`` LoRAs under + ``models/loras``: each a ``.safetensors`` model beside its + ``.metadata.json`` sidecar (LoraManager's alongside layout, #355) + and, unless ``preview`` is false, a local preview PNG; ``civitai`` adds one + CivitAI example image per LoRA (served by the stub opener below; an import + downloads it only for a LoRA without a local preview); ``previous`` saves + that many earlier ``lora-manager`` imports with tags and image links; + ``settings: "invalid"`` makes LoraManager's settings file unreadable JSON + (the server's own isolated settings folder, ``LORA_MANAGER_SETTINGS_DIR``); - ``POST /__test__/integrations/gate`` ``{hold}`` holds every CivitAI download until it is released (``{"hold": false}``), so a test sees the job while it downloads; @@ -30,6 +35,7 @@ test ever opens a network connection. import io import json +import os import shutil import threading from pathlib import Path @@ -91,7 +97,11 @@ def register_integrations_commands(app, routes, api, root: Path): type(api.db).replace_category_prompts = replace + def settings_file(): + return Path(os.environ["LORA_MANAGER_SETTINGS_DIR"]) / "settings.json" + def reset(): + settings_file().unlink(missing_ok=True) IntegrationConfig.LORA_MANAGER_ENABLED = False IntegrationConfig.LORA_MANAGER_PATH = "" IntegrationConfig.LORA_TRIGGER_WORDS_ENABLED = False @@ -118,11 +128,17 @@ def register_integrations_commands(app, routes, api, root: Path): data = await request.json() if request.can_read_body else {} count = data.get("loras", 2) previous = data.get("previous", 0) + preview = data.get("preview", True) + settings = data.get("settings") for value in (count, previous): if isinstance(value, bool) or not isinstance(value, int): raise web.HTTPBadRequest() if not 0 <= count <= MAX_LORAS or not 0 <= previous <= MAX_LORAS: raise web.HTTPBadRequest() + if not isinstance(preview, bool) or settings not in (None, "invalid"): + raise web.HTTPBadRequest() + if settings == "invalid": + settings_file().write_text("{not json", encoding="utf-8") (lora_manager / "py").mkdir(parents=True, exist_ok=True) (lora_manager / "__init__.py").write_text("") loras.mkdir(parents=True, exist_ok=True) @@ -141,8 +157,10 @@ def register_integrations_commands(app, routes, api, root: Path): "model_name": f"Style {number + 1:02}", "civitai": civitai, } + (loras / f"{stem}.safetensors").write_bytes(b"model") (loras / f"{stem}.metadata.json").write_text(json.dumps(meta)) - (loras / f"{stem}.png").write_bytes(_png()) + if preview: + (loras / f"{stem}.png").write_bytes(_png()) for number in range(previous): text = f"previous lora import {number + 1}" prompt_id = api.db.save_prompt( diff --git a/tests/js/workspace-system-integrations.test.js b/tests/js/workspace-system-integrations.test.js index d8eac71..2cba260 100644 --- a/tests/js/workspace-system-integrations.test.js +++ b/tests/js/workspace-system-integrations.test.js @@ -108,13 +108,17 @@ test('host compatibility shows the detected renderer as status (D106)', async () test('the import review shows replacement, blocked items and network effects (D107)', async () => { const { importGrid } = await integrations; - const plan = { lora_files: 3, with_metadata: 2, current_imports: 5, blocked_by_pairs: 0, planned_downloads: 4, + const plan = { lora_files: 2221, with_metadata: 615, with_local_media: 476, skipped: 1606, current_imports: 5, + blocked_by_pairs: 0, planned_downloads: 4, + layout: { source: 'portable', settings_file: '~/ComfyUI/custom_nodes/LM/settings.json', error: null, mode: 'centralized', + library: 'comfyui', example_images: true }, scope: { count: 2, label: 'Replace 5 imported prompts with 2 LoRAs' }, protected: { count: 0, reasons: [] }, backup: { policy: 'on', destination_label: 'prompts.db.backup_' }, network: { planned_downloads: 4, api_key: true }, media: { deletes_files: false, label: 'No files are removed; image links are replaced' } }; assert.deepEqual(importGrid(plan), [ + ['LoraManager', 'portable settings · centralized storage · library comfyui'], ['Scope', 'Replace 5 imported prompts with 2 LoRAs'], - ['Found', '3 metadata files · 2 readable'], + ['Found', '2,221 metadata files · 615 LoRAs · 476 with local images'], ['Protected', 'Nothing is protected'], ['Network', 'Downloads up to 4 example images from CivitAI with your saved key'], ['Backup', 'Create and verify before changes (prompts.db.backup_)'], @@ -122,11 +126,34 @@ test('the import review shows replacement, blocked items and network effects (D1 ]); const blocked = importGrid({ ...plan, planned_downloads: 1, network: { api_key: false }, protected: { count: 2, reasons: ['2 imported prompts used by saved pairs'] } }); - assert.deepEqual(blocked[2], ['Protected', '2 imported prompts used by saved pairs']); - assert.deepEqual(blocked[3], ['Network', 'Downloads up to 1 example image from CivitAI']); + assert.deepEqual(blocked[3], ['Protected', '2 imported prompts used by saved pairs']); + assert.deepEqual(blocked[4], ['Network', 'Downloads up to 1 example image from CivitAI']); + // A plan without a layout (no LoraManager row) and with no counts. assert.deepEqual(importGrid({})[0], ['Scope', '0 LoRAs']); + assert.deepEqual(importGrid({})[1], ['Found', '0 metadata files · 0 LoRAs · 0 with local images']); assert.deepEqual(importGrid({})[3], ['Network', 'No downloads']); assert.deepEqual(importGrid({})[5], ['Media on disk', 'No files are removed']); + assert.deepEqual(importGrid({ ...plan, lora_files: 1, with_metadata: 1, with_local_media: 0 })[2], + ['Found', '1 metadata file · 1 LoRA · 0 with local images']); +}); + +test('the LoraManager row names what the import read, and an unusable layout closes the review (#355)', async () => { + const { layoutText, closeNote, LAYOUT_UNUSABLE, BLOCKED_NOTE, NOTHING_TO_IMPORT } = await integrations; + assert.equal(layoutText({ source: 'environment', mode: 'alongside', library: 'default', error: null }), + 'environment settings · alongside storage · library default'); + assert.equal(layoutText({ source: 'user config', mode: 'alongside', library: 'x', error: 'the settings file is not valid JSON' }), + 'Not usable: the settings file is not valid JSON'); + assert.equal(layoutText(null), null); + assert.equal(layoutText('nope'), null); + const error = 'the sidecar folder does not exist'; + assert.equal(closeNote({ token: null, layout: { error } }), + 'LoraManager settings: not usable: the sidecar folder does not exist. Nothing was changed.'); + assert.equal(LAYOUT_UNUSABLE(error), closeNote({ token: null, layout: { error }, blocked_by_pairs: 2 })); + assert.equal(closeNote({ token: null, blocked_by_pairs: 1, layout: { error: null } }), BLOCKED_NOTE); + assert.equal(closeNote({ token: null }), NOTHING_TO_IMPORT); + assert.equal(closeNote({ token: 't', with_metadata: 0, current_imports: 0 }), NOTHING_TO_IMPORT); + assert.equal(closeNote({ token: 't', with_metadata: 1, current_imports: 0 }), null); + assert.equal(closeNote({ token: 't', with_metadata: 0, current_imports: 3 }), null); }); test('a LoRA import job reads in Activity: tiles, outcomes and the kept collection on failure', async () => { diff --git a/tests/js/workspace-toolbar.test.js b/tests/js/workspace-toolbar.test.js index 74ff422..761c2c6 100644 --- a/tests/js/workspace-toolbar.test.js +++ b/tests/js/workspace-toolbar.test.js @@ -841,6 +841,11 @@ test('openReviewDialog lays out heading, grid, options, sentence, backup box and [['dt', 'dd'], 'Scope', '2 stale image links'], [['dt', 'dd'], 'Media on disk', 'No source files removed']]); review.setValue('Scope', '3 stale image links'); assert.equal(grid.children[0].children[1].textContent, '3 stale image links'); + // #355: a row's hover title (the LoRA review's settings file); an unknown term changes nothing. + review.setTitle('Media on disk', '~/settings.json'); + review.setTitle('Absent', 'x'); + review.setValue('Absent', 'x'); + assert.equal(grid.children[1].children[1].title, '~/settings.json'); const actions = byId(dom, 'modal-body').children.at(-1); assert.deepEqual(actions.children.map(node => [node.textContent, node.className]), [['Cancel', ''], ['Apply reviewed operation', 'ca-primary']]); diff --git a/tests/run_suite.py b/tests/run_suite.py index 14a83f8..9775d27 100644 --- a/tests/run_suite.py +++ b/tests/run_suite.py @@ -33,7 +33,9 @@ written next to it (TESTING.md "Time limits and hangs"). import argparse import faulthandler import os +import shutil import sys +import tempfile import unittest from pathlib import Path @@ -41,6 +43,9 @@ DEFAULT_PER_TEST_LIMIT = 120 DEFAULT_SHUTDOWN_LIMIT = 120 NO_TESTS_EXIT = 5 USAGE_EXIT = 2 +# LoraManager's settings-folder override (#355): every test reads an empty, +# disposable LoraManager settings folder, never the developer's real one. +LORA_MANAGER_SETTINGS_ENV = "LORA_MANAGER_SETTINGS_DIR" class LimitError(ValueError): @@ -115,6 +120,16 @@ def parse_args(argv): return parser.parse_args(argv) +def isolate_lora_manager_settings(): + """Point LoraManager's settings lookup at a fresh temporary folder. + + Returns the folder; the caller removes it when the run ends. + """ + folder = tempfile.mkdtemp(prefix="pm-lora-settings-") + os.environ[LORA_MANAGER_SETTINGS_ENV] = folder + return folder + + def main(argv=None): args = parse_args(sys.argv[1:] if argv is None else argv) # Keep the real stderr: a test may replace sys.stderr, and the watchdog @@ -128,16 +143,20 @@ def main(argv=None): return USAGE_EXIT faulthandler.dump_traceback_later(per_test_limit, exit=True, file=stream) - start = os.path.abspath(args.start_directory) - suite = unittest.TestLoader().discover(start, args.pattern, top_level_dir=start) - runner = unittest.TextTestRunner( - stream=stream, - verbosity=args.verbosity, - # As unittest.main does: show warnings unless -W/PYTHONWARNINGS set them. - warnings=None if sys.warnoptions else "default", - resultclass=make_result_class(stream, per_test_limit, shutdown_limit), - ) - result = runner.run(suite) + settings_folder = isolate_lora_manager_settings() + try: + start = os.path.abspath(args.start_directory) + suite = unittest.TestLoader().discover(start, args.pattern, top_level_dir=start) + runner = unittest.TextTestRunner( + stream=stream, + verbosity=args.verbosity, + # As unittest.main does: show warnings unless -W/PYTHONWARNINGS set them. + warnings=None if sys.warnoptions else "default", + resultclass=make_result_class(stream, per_test_limit, shutdown_limit), + ) + result = runner.run(suite) + finally: + shutil.rmtree(settings_folder, ignore_errors=True) if result.testsRun == 0 and not result.skipped: return NO_TESTS_EXIT return 0 if result.wasSuccessful() else 1 diff --git a/tests/test_api_images.py b/tests/test_api_images.py index b93ad42..5b0fa3e 100644 --- a/tests/test_api_images.py +++ b/tests/test_api_images.py @@ -12,6 +12,7 @@ import pathlib import sys import tempfile import threading +import types import unittest from contextlib import ExitStack from pathlib import Path, PurePath @@ -1317,7 +1318,8 @@ class TestServeErrorBranches(ImageRouteCoverageCase): with ( patch.object(IntegrationConfig, "LORA_MANAGER_ENABLED", True), patch.object(IntegrationConfig, "LORA_MANAGER_PATH", ""), - patch("py.lora_utils.find_lora_directories", return_value=[lora]), + patch("py.lora_utils.detect_lora_manager", return_value=None), + patch("py.lora_utils.default_lora_roots", return_value=[lora]), patch("py.lora_utils.get_lora_image_cache_dir", return_value=cache), ): resp = await self.client.request( @@ -1328,7 +1330,8 @@ class TestServeErrorBranches(ImageRouteCoverageCase): with ( patch.object(IntegrationConfig, "LORA_MANAGER_ENABLED", True), patch.object(IntegrationConfig, "LORA_MANAGER_PATH", ""), - patch("py.lora_utils.find_lora_directories", side_effect=RuntimeError), + patch("py.lora_utils.detect_lora_manager", return_value=None), + patch("py.lora_utils.default_lora_roots", side_effect=RuntimeError), ): resp = await self.client.request( "GET", f"/prompt_manager/images/{image_id}/file" @@ -1347,7 +1350,12 @@ class TestLoraManagerPathAllowList(ImageRouteCoverageCase): ) stack.enter_context(patch.object(IntegrationConfig, "LORA_MANAGER_PATH", path)) stack.enter_context( - patch("py.lora_utils.find_lora_directories", return_value=[lora]) + patch("py.lora_utils.default_lora_roots", return_value=[lora]) + ) + # Never the developer's LoraManager settings (#355). + settings = stack.enter_context(tempfile.TemporaryDirectory()) + stack.enter_context( + patch.dict(os.environ, {"LORA_MANAGER_SETTINGS_DIR": settings}) ) stack.enter_context( patch("py.lora_utils.get_lora_image_cache_dir", return_value=cache) @@ -1389,6 +1397,115 @@ class TestLoraManagerPathAllowList(ImageRouteCoverageCase): resolve.assert_called_once_with(configured) +class TestLoraManagerMediaServing(ImageRouteCoverageCase): + """#355: LoraManager's sidecar and example-image folders are served while + the integration is on, only through the folder guard.""" + + async def setUpAsync(self): + await super().setUpAsync() + from py.config import IntegrationConfig + + self._lm_tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) + self.addCleanup(self._lm_tmp.cleanup) + root = Path(os.path.realpath(self._lm_tmp.name)) + custom_nodes = root / "custom_nodes" + self.lm = custom_nodes / "ComfyUI-Lora-Manager" + (self.lm / "py").mkdir(parents=True) + (self.lm / "__init__.py").write_text("") + self.settings = root / "settings" + self.settings.mkdir() + self.sidecars = root / "sidecars" + self.examples = root / "examples" + self.cache = root / "cache" + for folder in (self.sidecars, self.examples, self.cache): + folder.mkdir() + self.write_settings(str(self.sidecars)) + self.enabled = patch.object(IntegrationConfig, "LORA_MANAGER_ENABLED", True) + for item in ( + self.enabled, + patch.object(IntegrationConfig, "LORA_MANAGER_PATH", str(self.lm)), + patch( + "py.lora_utils.custom_nodes_directories", return_value=[custom_nodes] + ), + patch("py.lora_utils.default_lora_roots", return_value=[]), + patch("py.lora_utils.get_lora_image_cache_dir", return_value=self.cache), + patch.dict(os.environ, {"LORA_MANAGER_SETTINGS_DIR": str(self.settings)}), + ): + item.start() + self.addCleanup(item.stop) + + def write_settings(self, sidecar_root): + (self.settings / "settings.json").write_text( + json.dumps( + { + "sidecar_storage_mode": "centralized", + "sidecar_storage_path": sidecar_root, + "example_images_path": str(self.examples), + "civitai_api_key": "never-served", + } + ) + ) + + async def _status(self, image_id): + resp = await self.client.request( + "GET", f"/prompt_manager/images/{image_id}/file" + ) + await resp.read() # release the served file before the folder goes + return resp.status + + async def test_sidecar_and_example_media_are_served_while_enabled(self): + preview = self.sidecars / "loras-0a1b2c3d" / "neon.webp" + preview.parent.mkdir() + preview.write_bytes(b"RIFF") + example = self.examples / "ab12" / "1.mp4" + example.parent.mkdir() + example.write_bytes(b"video") + ids = [self._link_image(preview, "a"), self._link_image(example, "b")] + + for image_id in ids: + self.assertEqual(await self._status(image_id), 200) + self.enabled.stop() + for image_id in ids: + self.assertEqual(await self._status(image_id), 403) + self.enabled.start() + + async def test_a_filesystem_root_as_sidecar_folder_adds_nothing(self): + self.write_settings(os.path.abspath(os.sep)) + dirs = self.api._lora_image_dirs() + # An unusable layout: the default LoRA roots (none here) and the cache. + self.assertEqual(dirs, [self.cache]) + + async def test_the_layout_folders_are_the_served_ones(self): + dirs = self.api._lora_image_dirs() + self.assertEqual( + dirs, + [ + Path(os.path.normcase(str(self.sidecars))), + Path(os.path.normcase(str(self.examples))), + self.cache, + ], + ) + + +class TestEnrichMarksVideos(ImageAPITestCase): + """#355: a linked video outside the output folders is marked as video.""" + + async def test_enrich_sets_is_video_and_media_type(self): + elsewhere = Path(tempfile.gettempdir()) / "lora-media" + images = [ + {"id": 1, "image_path": str(elsewhere / "a.MP4")}, + {"id": 2, "image_path": str(elsewhere / "b.png")}, + {"id": 3, "image_path": str(elsewhere / "c.mp4"), "is_video": False}, + {"id": 4, "image_path": ""}, + ] + self.api._enrich_images(images) + self.assertEqual( + [(i.get("is_video"), i.get("media_type")) for i in images], + [(True, "video"), (False, "image"), (False, None), (None, None)], + ) + self.assertEqual(images[0]["url"], "/prompt_manager/images/1/file") + + class TestLinkImageRelativePaths(ImageAPITestCase): """POST /images/link accepts the path forms the API hands out.""" @@ -3343,8 +3460,13 @@ class TestSharedListing(ImageAPITestCase): record = await self._listed() self._interval(30) # The interval ends in a few milliseconds: still never a zero delay. - record.last_started_at = images_module._time.monotonic() - 29.99 - self.api.mark_root_dirty(self._output("b.png")) + # The module's clock is held, so a slow runner cannot pass the end + # of the interval between these lines (the walk would start at once). + now = images_module._time.monotonic() + record.last_started_at = now - 29.99 + held = types.SimpleNamespace(monotonic=lambda: now) + with patch.object(images_module, "_time", held): + self.api.mark_root_dirty(self._output("b.png")) handle = record.rewalk_handle self.assertIsNotNone(handle) self.assertGreaterEqual(self._remaining(handle), 0.45) diff --git a/tests/test_api_library.py b/tests/test_api_library.py index 80597a5..0e65097 100644 --- a/tests/test_api_library.py +++ b/tests/test_api_library.py @@ -1,9 +1,12 @@ """Native library endpoints use bounded cursors and rooted media identities.""" import json +import os import unittest from unittest.mock import patch +from utils.hashing import generate_prompt_hash + try: from test_api_images import ImageAPITestCase, make_png except ImportError: @@ -978,6 +981,186 @@ class TestLibraryAPI(ImageAPITestCase): self.assertNotIn("literal", json.dumps(result)) +class TestLibraryLoraMedia(ImageAPITestCase): + """#355 (G6): media a LoRA import linked outside the output folders shows + on the Library page and opens in the inspector while the integration is on.""" + + async def setUpAsync(self): + await super().setUpAsync() + import tempfile + from pathlib import Path + + self._lora_tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) + self.addCleanup(self._lora_tmp.cleanup) + self.lora_dir = Path(self._lora_tmp.name).resolve() / "sidecars" + self.lora_dir.mkdir() + self.lora_on = True + self.api._lora_image_dirs = lambda: [self.lora_dir] if self.lora_on else [] + + async def get(self, path="", **params): + response = await self.client.get( + "/prompt_manager/library" + path, params=params + ) + return response, await response.json() + + def _imported(self): + """A LoRA prompt linked like the import links it: the cover first.""" + still = make_png(self.lora_dir / "abc" / "cover.png") + video = self.lora_dir / "abc" / "clip.mp4" + video.write_bytes(b"video") + self.api.db.replace_category_prompts( + "lora-manager", + [ + { + "name": "neon", + "text": "neon street", + "hash": generate_prompt_hash("neon street"), + "tags": ["lora-manager"], + "images": [str(still), str(video)], + } + ], + ) + positive = self.api.db.search_prompts(category="lora-manager")[0]["id"] + ids = { + os.path.basename(row["image_path"]): row["id"] + for row in self.api.db.get_prompt_images(positive) + } + return positive, ids + + async def test_list_previews_show_linked_lora_media_cover_first(self): + positive, _ids = self._imported() + response, result = await self.get() + self.assertEqual(response.status, 200, result) + item = next(i for i in result["items"] if i["id"] == positive) + previews = item["previews"] + self.assertEqual([p["filename"] for p in previews], ["cover.png", "clip.mp4"]) + # The media keeps the rooted identity every workspace view checks; no + # preview is made inside LoraManager's folders, so the card loads the + # original and a video shows its label. + self.assertEqual({p["root_index"] for p in previews}, {1}) + self.assertTrue( + all( + p["url"].startswith("/prompt_manager/images/serve/abc/") + for p in previews + ) + ) + self.assertEqual([p["thumbnail_url"] for p in previews], [None, None]) + self.assertEqual([p["is_video"] for p in previews], [False, True]) + self.assertEqual([p["linked_only"] for p in previews], [True, True]) + self.assertNotIn(str(self.lora_dir), json.dumps(result)) + file = await self.client.get(previews[0]["url"]) + self.assertEqual(file.status, 200) + await file.read() + self.assertFalse((self.lora_dir / "thumbnails").exists()) + + async def test_detail_and_images_page_list_lora_media(self): + positive, _ids = self._imported() + make_png(self.output_dir / "out.png") + _, detail = await self.get(f"/{positive}") + self.assertEqual( + [p["filename"] for p in detail["positive"]["previews"]], + ["cover.png", "clip.mp4"], + ) + _, page = await self.get(f"/{positive}/images") + self.assertEqual( + [i["filename"] for i in page["images"]], ["cover.png", "clip.mp4"] + ) + # No favorite star: favorites belong to output listings. + self.assertNotIn("favorite", page["images"][0]) + self.assertTrue(page["images"][0]["linked_only"]) + + async def test_inspector_reads_lora_media_by_its_root(self): + positive, _ids = self._imported() + _, page = await self.get(f"/{positive}/images") + image = page["images"][0] + response = await self.client.get( + "/prompt_manager/images/inspect/" + image["path"], + params={ + "root": str(image["root_index"]), + "root_scope": image["root_scope"], + }, + ) + self.assertEqual(response.status, 200, await response.text()) + + async def test_the_inspector_reads_only_linked_files_under_a_lora_root(self): + # Security review: a LoRA root exposes only the files the import linked. + self._imported() + make_png(self.lora_dir / "abc" / "unlinked.png") + scope = self.api._gallery_root_scope(self.lora_dir, 1) + for name, status in (("unlinked.png", 403), ("cover.png", 200)): + with self.subTest(name=name): + response = await self.client.get( + "/prompt_manager/images/inspect/abc/" + name, + params={"root": "1", "root_scope": scope}, + ) + self.assertEqual(response.status, status, await response.text()) + + async def test_only_recorded_lora_media_is_served_by_path(self): + self._imported() + make_png(self.lora_dir / "abc" / "unlinked.png") + served = await self.client.get( + "/prompt_manager/images/serve/abc/cover.png", params={"root": "1"} + ) + self.assertEqual(served.status, 200) + await served.read() + unlinked = await self.client.get( + "/prompt_manager/images/serve/abc/unlinked.png", params={"root": "1"} + ) + self.assertEqual(unlinked.status, 403) + preview = await self.client.get( + "/prompt_manager/images/serve/thumbnails/abc/cover_thumb.webp", + params={"root": "1"}, + ) + self.assertEqual(preview.status, 403) + self.assertFalse((self.lora_dir / "thumbnails").exists()) + # Without a root, only the output folders are searched. + unrooted = await self.client.get("/prompt_manager/images/serve/abc/cover.png") + self.assertEqual(unrooted.status, 404) + + async def test_a_download_recorded_through_a_symlinked_cache_is_served(self): + from pathlib import Path + + real = self.lora_dir.parent / "real-cache" + real.mkdir() + link = self.lora_dir.parent / "cache-link" + try: + os.symlink(real, link, target_is_directory=True) + except (OSError, NotImplementedError): + self.skipTest("symlinks are not available here") + self.api._lora_image_dirs = lambda: [link] + image = make_png(real / "neon" / "a1b2.jpg") + stored = str(link / "neon" / "a1b2.jpg") + positive = self._save_prompt("downloaded") + self.api.db.link_image_to_prompt(positive, stored) + _, page = await self.get(f"/{positive}/images") + self.assertEqual([i["filename"] for i in page["images"]], [image.name]) + served = await self.client.get(page["images"][0]["url"]) + self.assertEqual(served.status, 200) + await served.read() + self.assertTrue(Path(stored).is_file()) + + async def test_a_malformed_root_is_refused_not_an_error(self): + response = await self.client.get( + "/prompt_manager/images/serve/abc/cover.png", params={"root": "\u00b2"} + ) + self.assertEqual(response.status, 403) + + async def test_lora_media_is_hidden_while_the_integration_is_off(self): + positive, _ids = self._imported() + self.lora_on = False + _, result = await self.get() + item = next(i for i in result["items"] if i["id"] == positive) + self.assertEqual(item["previews"], []) + response = await self.client.get( + "/prompt_manager/images/inspect/abc/cover.png", params={"root": "1"} + ) + self.assertEqual(response.status, 400) + served = await self.client.get( + "/prompt_manager/images/serve/abc/cover.png", params={"root": "1"} + ) + self.assertEqual(served.status, 403) + + class TestNameCursorBudget(unittest.TestCase): def test_lru_byte_budget_scope_and_oversize_keys_are_bounded(self): from py.api.library import _NameCursorStore diff --git a/tests/test_api_lora.py b/tests/test_api_lora.py index 43ebc14..6b7ec49 100644 --- a/tests/test_api_lora.py +++ b/tests/test_api_lora.py @@ -70,8 +70,16 @@ class LoraAPITestCase(AioHTTPTestCase): # Never the repository's config.json (live user data): every save in # these tests goes to a file in this test's temp directory. self.config_file = self.tmp_root / "config.json" + # Never the developer's LoraManager settings either (#355): an empty + # settings folder of this test's own. + self.lm_settings = self.tmp_root / "lora-manager-settings" + self.lm_settings.mkdir() self._env_patch = patch.dict( - os.environ, {"PROMPT_MANAGER_CONFIG_PATH": str(self.config_file)} + os.environ, + { + "PROMPT_MANAGER_CONFIG_PATH": str(self.config_file), + "LORA_MANAGER_SETTINGS_DIR": str(self.lm_settings), + }, ) self._env_patch.start() @@ -194,6 +202,8 @@ def _make_lora_manager(where): def _write_metadata(directory, stem, words=None, images=None, prompt=None): + """A LoraManager sidecar beside its model, as LoraManager names them: + ``.metadata.json`` next to ``.safetensors`` (#355).""" meta = {"file_name": f"{stem}.safetensors", "model_name": stem, "civitai": {}} if words is not None: meta["civitai"]["trainedWords"] = words @@ -201,7 +211,8 @@ def _write_metadata(directory, stem, words=None, images=None, prompt=None): meta["civitai"]["images"] = [ {"url": url, "meta": {"prompt": prompt} if prompt else {}} for url in images ] - path = directory / f"{stem}.safetensors.metadata.json" + (directory / f"{stem}.safetensors").write_bytes(b"model") + path = directory / f"{stem}.metadata.json" path.write_text(json.dumps(meta)) return path @@ -300,9 +311,7 @@ class TestLoraEnableRoute(LoraAPITestCase): _write_metadata(loras, "neon", words=["glow"]) self.detect_mock.return_value = str(lm) - with patch.object( - lora_utils, "find_lora_directories", return_value=[str(loras)] - ): + with patch.object(lora_utils, "default_lora_roots", return_value=[str(loras)]): resp = await self._post_json( "/prompt_manager/lora/enable", { @@ -848,9 +857,7 @@ class TestLoraScanRoute(LoraAPITestCase): cache_dir = self.tmp_root / "img_cache" with ( - patch.object( - lora_utils, "find_lora_directories", return_value=[str(loras)] - ), + patch.object(lora_utils, "default_lora_roots", return_value=[str(loras)]), patch.object( lora_utils, "get_lora_image_cache_dir", return_value=cache_dir ), @@ -880,9 +887,10 @@ class TestLoraScanRoute(LoraAPITestCase): images=["https://civitai.com/a.png", "https://evil.example/b.png"], prompt="neon street", ) - (loras / "neon.png").write_bytes(_png_bytes()) + # No local image: its CivitAI images are downloaded (#355). _write_metadata(loras, "plain") (loras / "broken.metadata.json").write_text("{{{") + (loras / "broken.safetensors").write_bytes(b"model") existing_id = self.api.db.save_prompt( text="plain", category=None, @@ -910,8 +918,56 @@ class TestLoraScanRoute(LoraAPITestCase): self.assertEqual(imported[0]["text"], "neon street") self.assertIn("lora:neon", imported[0]["tags"]) self.assertIn("glow", imported[0]["tags"]) - self.assertEqual(len(self.api.db.get_prompt_images(imported[0]["id"])), 2) + self.assertEqual(len(self.api.db.get_prompt_images(imported[0]["id"])), 1) self.assertEqual(len(self.api.db.get_prompt_images(existing_id)), 0) + # The unreadable sidecar is reported by its file name. + statuses = [e.get("status") for e in events if e.get("processed")] + self.assertIn("broken.metadata.json", statuses) + + async def test_scan_links_local_media_without_downloading(self): + self._enable() + loras = self.tmp_root / "loras" + loras.mkdir() + _write_metadata( + loras, "neon", images=["https://civitai.com/a.png"], prompt="neon street" + ) + (loras / "neon.preview.webp").write_bytes(b"x") + + resp, events, opener_calls = await self._scan(loras) + + self.assertEqual(events[-1]["imported"], 1) + self.assertEqual(opener_calls, []) + imported = self.api.db.search_prompts(category="lora-manager") + images = self.api.db.get_prompt_images(imported[0]["id"]) + self.assertEqual( + [os.path.basename(i["image_path"]) for i in images], ["neon.preview.webp"] + ) + + async def test_scan_reports_unusable_settings_without_replacing(self): + self._enable() + loras = self.tmp_root / "loras" + loras.mkdir() + _write_metadata(loras, "neon") + (self.lm_settings / "settings.json").write_text("{not json") + old_id = self.api.db.save_prompt( + text="kept", + category="lora-manager", + tags=[], + rating=None, + prompt_hash=generate_prompt_hash("kept"), + ) + + resp, events, _ = await self._scan(loras) + + self.assertEqual(resp.status, 200) + self.assertEqual(events[-1]["type"], "error") + self.assertEqual( + events[-1]["message"], + "LoraManager settings: not usable: the settings file is not valid " + "JSON. Nothing was changed.", + ) + kept = self.api.db.search_prompts(category="lora-manager") + self.assertEqual([p["id"] for p in kept], [old_id]) async def test_save_failure_is_counted_as_skipped(self): self._enable() @@ -949,9 +1005,7 @@ class TestLoraTriggerWordRoutes(LoraAPITestCase): loras.mkdir() _write_metadata(loras, "neon", words=["glow", "bright"]) - with patch.object( - lora_utils, "find_lora_directories", return_value=[str(loras)] - ): + with patch.object(lora_utils, "default_lora_roots", return_value=[str(loras)]): resp = await self.client.request( "GET", "/prompt_manager/lora/trigger-words?name=NEON" ) @@ -985,17 +1039,16 @@ class TestLoraTriggerWordRoutes(LoraAPITestCase): loras.mkdir() _write_metadata(loras, "neon", words=["glow"]) - with patch.object( - lora_utils, "find_lora_directories", return_value=[str(loras)] - ): + with patch.object(lora_utils, "default_lora_roots", return_value=[str(loras)]): resp = await self.client.request( "POST", "/prompt_manager/lora/refresh-cache" ) data = await resp.json() self.assertTrue(data["success"]) - # keyed by both the file_name stem and the metadata-file stem - self.assertEqual(data["loras_with_trigger_words"], 2) + # Keyed by the model name and the sidecar name, which are the same + # for LoraManager's alongside layout. + self.assertEqual(data["loras_with_trigger_words"], 1) async def test_refresh_error_is_500(self): IntegrationConfig.LORA_MANAGER_ENABLED = True diff --git a/tests/test_browser_server_lifetime.py b/tests/test_browser_server_lifetime.py index eb68bbb..8a7821b 100644 --- a/tests/test_browser_server_lifetime.py +++ b/tests/test_browser_server_lifetime.py @@ -79,5 +79,21 @@ class TestBrowserServerLifetime(unittest.TestCase): process.wait(timeout=1) +class TestBrowserServerLoraIsolation(unittest.TestCase): + """#355: page tests never read the developer's LoraManager settings.""" + + def test_settings_folder_is_inside_the_server_root(self): + from unittest.mock import patch + + from tests.browser.server import isolate_lora_manager_settings + + with tempfile.TemporaryDirectory() as root: + with patch.dict(os.environ, {"LORA_MANAGER_SETTINGS_DIR": "/real/one"}): + folder = isolate_lora_manager_settings(Path(root)) + self.assertEqual(os.environ["LORA_MANAGER_SETTINGS_DIR"], str(folder)) + self.assertEqual(folder.parent, Path(root)) + self.assertTrue(folder.is_dir()) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_image_reveal.py b/tests/test_image_reveal.py index 4b4b050..5041586 100644 --- a/tests/test_image_reveal.py +++ b/tests/test_image_reveal.py @@ -8,6 +8,7 @@ import ctypes import json import logging import os +import shutil import subprocess import sys import tempfile @@ -694,6 +695,40 @@ class TestRevealRoute(ImageAPITestCase): # Its own worker, never ComfyUI's shared default executor (L1). self.assertTrue(self.threads[0].startswith("pm-reveal"), self.threads) + async def test_a_lora_root_reveals_only_linked_files(self): + # #355 security review: a LoRA root exposes only the files the import + # linked; any other file under it is refused before the opener runs. + lora = Path(tempfile.mkdtemp()).resolve() + self.addCleanup(shutil.rmtree, lora, True) + self.api._lora_image_dirs = lambda: [lora] + linked = make_png(lora / "abc" / "cover.png") + make_png(lora / "abc" / "unlinked.png") + self.api.db.link_image_to_prompt(self._save_prompt("lora"), str(linked)) + scope = self.api._gallery_root_scope(lora, 1) + status, _data = await self.post( + {"root_index": 1, "root_scope": scope, "path": "abc/unlinked.png"} + ) + self.assertEqual(status, 403) + self.assertEqual(self.opened, []) + status, data = await self.post( + {"root_index": 1, "root_scope": scope, "path": "abc/cover.png"} + ) + self.assertEqual((status, data), (200, {"success": True})) + self.assertEqual(self.opened, [Path(os.path.realpath(linked))]) + # The file that would open is checked too, whatever the request named + # (a path anchor could resolve it elsewhere in the root). + other = make_png(lora / "abc" / "other.png") + from py.api.images import ImageRoutesMixin + + with patch.object( + ImageRoutesMixin, "_resolve_client_media_path", return_value=other + ): + status, _data = await self.post( + {"root_index": 1, "root_scope": scope, "path": "abc/cover.png"} + ) + self.assertEqual(status, 403) + self.assertEqual(len(self.opened), 1) + async def test_target_changed_before_launch_is_404(self): make_png(self.output_dir / "sub" / "a.png") self.outcome = file_reveal.RevealTargetChanged() diff --git a/tests/test_lora_import.py b/tests/test_lora_import.py index b50b671..f5ff24d 100644 --- a/tests/test_lora_import.py +++ b/tests/test_lora_import.py @@ -252,7 +252,7 @@ class LoraImportAPITestCase(LoraAPITestCase): self._patches = [ patch.object( - lora_utils, "find_lora_directories", return_value=[str(self.loras)] + lora_utils, "default_lora_roots", return_value=[str(self.loras)] ), patch.object( lora_utils, "get_lora_image_cache_dir", return_value=self.cache_dir @@ -276,6 +276,8 @@ class LoraImportAPITestCase(LoraAPITestCase): await super().tearDownAsync() def _two_loras(self): + """``neon`` has only a CivitAI image (downloaded); ``plain`` has a + local preview (linked where it is, nothing downloaded).""" _write_metadata( self.loras, "neon", @@ -283,8 +285,8 @@ class LoraImportAPITestCase(LoraAPITestCase): images=["https://civitai.com/a.png"], prompt="neon street", ) - (self.loras / "neon.png").write_bytes(_png_bytes()) - _write_metadata(self.loras, "plain") + _write_metadata(self.loras, "plain", images=["https://civitai.com/p.png"]) + (self.loras / "plain.png").write_bytes(_png_bytes()) async def _post(self, url, body=None): resp = await self._post_json(url, {} if body is None else body) @@ -321,8 +323,34 @@ class LoraImportRouteTests(LoraImportAPITestCase): self.assertEqual(resp.status, 200, plan) self.assertEqual( - {k: plan[k] for k in ("lora_files", "with_metadata", "current_imports")}, - {"lora_files": 3, "with_metadata": 2, "current_imports": 2}, + { + k: plan[k] + for k in ( + "lora_files", + "with_metadata", + "skipped", + "with_local_media", + "current_imports", + ) + }, + { + "lora_files": 3, + "with_metadata": 2, + "skipped": 1, + "with_local_media": 1, + "current_imports": 2, + }, + ) + self.assertEqual( + plan["layout"], + { + "source": "environment", + "settings_file": str(self.lm_settings / "settings.json"), + "error": None, + "mode": "alongside", + "library": "default", + "example_images": False, + }, ) self.assertEqual(plan["blocked_by_pairs"], 0) self.assertEqual(plan["planned_downloads"], 1) @@ -336,7 +364,95 @@ class LoraImportRouteTests(LoraImportAPITestCase): self.assertEqual(_size(db_path + "-shm"), shm) self.assertEqual(_snapshot(self.api.db), rows) self.assertNotIn(SECRET_KEY, json.dumps(plan)) - self.assertNotIn(str(self.tmp_root), json.dumps(plan)) + # The settings file is the only absolute path the review shows. + shown = dict(plan, layout=dict(plan["layout"], settings_file=None)) + self.assertNotIn(str(self.tmp_root), json.dumps(shown)) + + async def test_unusable_settings_review_offers_no_token(self): + self._two_loras() + (self.lm_settings / "settings.json").write_text("[1, 2]") + + resp, plan = await self._post(PREVIEW) + + self.assertEqual(resp.status, 200, plan) + self.assertIsNone(plan["token"]) + self.assertEqual( + plan["layout"]["error"], "the settings file is not a JSON object" + ) + self.assertEqual((plan["lora_files"], plan["with_metadata"]), (0, 0)) + self.assertEqual(self.requests, []) + + async def test_centralized_review_counts_what_it_skips(self): + sidecars = self.tmp_root / "sidecars" + examples = self.tmp_root / "examples" + (self.lm_settings / "settings.json").write_text( + json.dumps( + { + "sidecar_storage_mode": "centralized", + "sidecar_storage_path": str(sidecars), + "example_images_path": str(examples), + "libraries": {"comfyui": {}}, + "active_library": "comfyui", + } + ) + ) + component = sidecars / "loras-0a1b2c3d" + component.mkdir(parents=True) + model = self.loras / "neon.safetensors" + model.write_bytes(b"m") + meta = {"model_name": "neon", "file_path": str(model), "sha256": "AB12"} + (component / "neon.metadata.json").write_text(json.dumps(meta)) + (examples / "ab12").mkdir(parents=True) + (examples / "ab12" / "image_1.png").write_bytes(_png_bytes()) + gone = dict(meta, file_path=str(self.loras / "gone.safetensors")) + (component / "gone.metadata.json").write_text(json.dumps(gone)) + (component / "broken.metadata.json").write_text("{{{") + + resp, plan = await self._post(PREVIEW) + + self.assertEqual(resp.status, 200, plan) + self.assertEqual( + ( + plan["lora_files"], + plan["with_metadata"], + plan["skipped"], + plan["with_local_media"], + plan["planned_downloads"], + ), + (3, 1, 2, 1, 0), + ) + self.assertEqual( + (plan["layout"]["mode"], plan["layout"]["library"]), + ("centralized", "comfyui"), + ) + self.assertTrue(plan["layout"]["example_images"]) + resp, body = await self._post(APPLY, {"token": plan["token"]}) + job = self.api._jobs.get(body["job"]["id"]) + await job.task + self.assertEqual(job.state, "finished", job.log) + self.assertEqual(self.requests, []) + self.assertEqual( + [row["detail"] for row in job.outcomes["skipped"]], + ["broken.metadata.json"], + ) + imported = self.api.db.search_prompts(category=CATEGORY) + images = self.api.db.get_prompt_images(imported[0]["id"]) + self.assertEqual( + [os.path.basename(i["image_path"]) for i in images], ["image_1.png"] + ) + + async def test_an_unlistable_folder_says_so_in_the_review(self): + self._two_loras() + error = PermissionError(13, "Permission denied", str(self.loras / "lost+found")) + with patch.object(lora_import, "discover_sidecars", side_effect=error): + resp, body = await self._post(PREVIEW) + self.assertEqual((resp.status, body["code"]), (500, "folder-unreadable")) + self.assertEqual( + body["error"], + "A LoRA folder could not be listed (Permission denied: lost+found). " + "Nothing was changed.", + ) + self.assertNotIn(str(self.tmp_root), json.dumps(body)) async def test_preview_requires_the_integration(self): IntegrationConfig.LORA_MANAGER_ENABLED = False @@ -358,7 +474,7 @@ class LoraImportRouteTests(LoraImportAPITestCase): self._two_loras() resp, plan = await self._post(PREVIEW) rows = _snapshot(self.api.db) - meta = self.loras / "plain.safetensors.metadata.json" + meta = self.loras / "plain.metadata.json" meta.write_text(meta.read_text() + " ") resp, body = await self._post(APPLY, {"token": plan["token"]}) @@ -397,7 +513,17 @@ class LoraImportRouteTests(LoraImportAPITestCase): imported = self.api.db.search_prompts(category=CATEGORY) self.assertEqual(sorted(p["text"] for p in imported), ["neon street", "plain"]) neon = next(p for p in imported if p["text"] == "neon street") - self.assertEqual(len(self.api.db.get_prompt_images(neon["id"])), 2) + self.assertEqual(len(self.api.db.get_prompt_images(neon["id"])), 1) + # plain's local preview is linked where it is; its CivitAI image is not + # downloaded (#355). + plain = next(p for p in imported if p["text"] == "plain") + self.assertEqual( + [i["image_path"] for i in self.api.db.get_prompt_images(plain["id"])], + [os.path.realpath(self.loras / "plain.png")], + ) + self.assertEqual( + [r.full_url for r in self.requests], ["https://civitai.com/a.png"] + ) self.assertEqual(len(self.api.db.search_prompts(category="portraits")), 1) # The token is single-use. resp, body = await self._post(APPLY, {"token": plan["token"]}) @@ -409,7 +535,7 @@ class LoraImportRouteTests(LoraImportAPITestCase): # Legacy scan: discovery raises once the stream is open. with patch.object( - lora_utils, "find_lora_directories", side_effect=OSError("share gone") + lora_import, "discover_sidecars", side_effect=OSError("share gone") ): resp = await self.client.request("POST", SCAN) events = self._sse(await resp.text()) @@ -565,6 +691,7 @@ class LoraImportRouteTests(LoraImportAPITestCase): async def test_legacy_scan_events_unchanged_on_success(self): self._two_loras() (self.loras / "broken.metadata.json").write_text("{{{") + (self.loras / "broken.safetensors").write_bytes(b"model") resp = await self.client.request("POST", SCAN) events = self._sse(await resp.text()) @@ -853,13 +980,13 @@ class LoraImportRefusalTests(LoraImportAPITestCase): async def test_files_changed_while_staging_fail_the_job(self): # I1: LoraManager rewrites a metadata file while the downloads run. - meta = self.loras / "plain.safetensors.metadata.json" + meta = self.loras / "plain.metadata.json" await self._change_during_download( lambda: meta.write_text(meta.read_text() + " ") ) async def test_a_file_removed_while_staging_fails_the_job(self): - meta = self.loras / "plain.safetensors.metadata.json" + meta = self.loras / "plain.metadata.json" await self._change_during_download(meta.unlink) async def test_a_file_added_while_staging_fails_the_job(self): @@ -869,7 +996,7 @@ class LoraImportRefusalTests(LoraImportAPITestCase): self._two_loras() before = _snapshot(self.api.db) self.gate = threading.Event() - meta = self.loras / "plain.safetensors.metadata.json" + meta = self.loras / "plain.metadata.json" resp = await self.client.request("POST", SCAN) loop = asyncio.get_running_loop() self.assertTrue(await loop.run_in_executor(None, self.downloading.wait, 10)) @@ -986,37 +1113,194 @@ class LoraImportRestoreTests(LoraImportAPITestCase): class LoraImportStageTests(unittest.TestCase): + """The stages over a disposable LoraManager install (#355).""" def setUp(self): - self.tmp = Path(tempfile.mkdtemp()) + self.tmp = Path(tempfile.mkdtemp()).resolve() self.addCleanup(lambda: __import__("shutil").rmtree(self.tmp, True)) + self.lm = _make_lora_manager(self.tmp / "custom_nodes") + self.loras = self.tmp / "loras" + self.loras.mkdir() + self.settings = self.tmp / "settings" + self.settings.mkdir() + self.roots = [str(self.loras)] + patchers = [ + patch.object( + lora_utils, + "custom_nodes_directories", + return_value=[self.tmp / "custom_nodes"], + ), + patch.object(lora_utils, "default_lora_roots", lambda: list(self.roots)), + patch.dict(os.environ, {"LORA_MANAGER_SETTINGS_DIR": str(self.settings)}), + ] + for patcher in patchers: + patcher.start() + self.addCleanup(patcher.stop) + + def settings_file(self, data): + (self.settings / "settings.json").write_text(json.dumps(data)) + + def discover(self): + return lora_import.discover(str(self.lm)) def test_discover_sorts_and_drops_repeats(self): - a = self.tmp / "a" - b = self.tmp / "a" / "b" + a = self.loras / "a" + b = self.loras / "a" / "b" b.mkdir(parents=True) _write_metadata(b, "zeta") _write_metadata(a, "alpha") - with patch.object( - lora_utils, "find_lora_directories", return_value=[str(a), str(b)] - ): - files = lora_import.discover(str(self.tmp)) - self.assertEqual([f.name for f in files][0], "alpha.safetensors.metadata.json") - self.assertEqual(len(files), 2) + self.roots = [str(a), str(b)] + discovery = self.discover() + names = [s.metadata_path.name for s in discovery.sidecars] + self.assertEqual(names, ["alpha.metadata.json", "zeta.metadata.json"]) + self.assertEqual(discovery.scanned, 2) + + def test_discover_without_an_install_finds_nothing(self): + _write_metadata(self.loras, "alpha") + discovery = lora_import.discover(str(self.tmp)) + self.assertEqual(discovery.layout.error, "no LoraManager install found") + self.assertEqual(discovery.sidecars, []) def test_fingerprint_follows_size_and_mtime(self): - meta = _write_metadata(self.tmp, "one") - first = lora_import.fingerprint([meta]) - self.assertEqual(lora_import.fingerprint([meta]), first) + meta = _write_metadata(self.loras, "one") + first = lora_import.fingerprint(self.discover()) + self.assertEqual(lora_import.fingerprint(self.discover()), first) meta.write_text(meta.read_text() + " ") - self.assertNotEqual(lora_import.fingerprint([meta]), first) + self.assertNotEqual(lora_import.fingerprint(self.discover()), first) + changed = lora_import.fingerprint(self.discover()) meta.unlink() - self.assertNotEqual(lora_import.fingerprint([meta]), first) + self.assertNotEqual(lora_import.fingerprint(self.discover()), changed) + + def test_fingerprint_ignores_unrelated_settings(self): + _write_metadata(self.loras, "one") + self.settings_file({"civitai_api_key": "a"}) + first = lora_import.fingerprint(self.discover()) + self.settings_file({"civitai_api_key": "b", "theme": "dark"}) + self.assertEqual(lora_import.fingerprint(self.discover()), first) + sidecars = self.tmp / "sidecars" + sidecars.mkdir() + self.settings_file( + { + "sidecar_storage_mode": "centralized", + "sidecar_storage_path": str(sidecars), + } + ) + self.assertNotEqual(lora_import.fingerprint(self.discover()), first) + + def test_fingerprint_follows_a_missing_file_between_stat_calls(self): + meta = _write_metadata(self.loras, "one") + discovery = self.discover() + first = lora_import.fingerprint(discovery) + meta.unlink() + self.assertNotEqual(lora_import.fingerprint(discovery), first) def test_stage_cancel_before_a_file_raises(self): - meta = _write_metadata(self.tmp, "one") + _write_metadata(self.loras, "one") with self.assertRaises(lora_import.ImportCancelled): - lora_import.stage([meta], self.tmp, cancelled=lambda: True) + lora_import.stage( + self.discover(), + self.tmp, + media_extensions=MEDIA, + cancelled=lambda: True, + ) + + def test_local_media_means_no_download(self): + _write_metadata(self.loras, "neon", images=["https://civitai.com/a.png"]) + (self.loras / "neon.mp4").write_bytes(b"video") + + def refuse(*args, **kwargs): + raise AssertionError("a LoRA with local media downloads nothing") + + with patch.object(lora_utils, "download_civitai_images", refuse): + staged = lora_import.stage( + self.discover(), self.tmp, media_extensions=MEDIA + ) + self.assertEqual(staged.skipped, []) + self.assertEqual( + staged.records[0]["images"], [os.path.realpath(self.loras / "neon.mp4")] + ) + + def test_a_lora_without_local_media_downloads(self): + _write_metadata(self.loras, "neon", images=["https://civitai.com/a.png"]) + calls = [] + + def download(metadata, path, cache_dir, api_key, stats=None): + calls.append(path.name) + return ["/cache/a.jpg"] + + with patch.object(lora_utils, "download_civitai_images", download): + staged = lora_import.stage( + self.discover(), self.tmp, media_extensions=MEDIA + ) + self.assertEqual(calls, ["neon.metadata.json"]) + self.assertEqual(staged.records[0]["images"], ["/cache/a.jpg"]) + + def test_alongside_records_match_the_previous_import(self): + _write_metadata( + self.loras, + "neon", + words=["glow", "glow"], + images=["https://civitai.com/a.png"], + prompt="neon street", + ) + (self.loras / "neon.png").write_bytes(b"x") + staged = lora_import.stage(self.discover(), self.tmp, media_extensions=MEDIA) + self.assertEqual( + staged.records, + [ + { + "name": "neon", + "text": "neon street", + "hash": generate_prompt_hash("neon street"), + "tags": ["lora-manager", "lora:neon", "glow"], + "images": [os.path.realpath(self.loras / "neon.png")], + } + ], + ) + + def test_preview_counts_local_media_and_downloads(self): + _write_metadata(self.loras, "local", images=["https://civitai.com/l.png"]) + (self.loras / "local.webp").write_bytes(b"x") + _write_metadata( + self.loras, + "remote", + images=["https://civitai.com/r1.png", "https://civitai.com/r2.png"], + ) + (self.loras / "orphan.metadata.json").write_text("{}") + found = lora_import.preview(str(self.lm), self.tmp / "cache", MEDIA) + self.assertEqual( + { + k: found[k] + for k in ( + "lora_files", + "with_metadata", + "skipped", + "with_local_media", + "planned_downloads", + ) + }, + { + "lora_files": 3, + "with_metadata": 2, + "skipped": 1, + "with_local_media": 1, + "planned_downloads": 2, + }, + ) + self.assertEqual(found["names"], ["local", "remote"]) + self.assertEqual(found["layout"]["mode"], "alongside") + + def test_preview_of_an_unusable_layout(self): + (self.settings / "settings.json").write_text("nope") + _write_metadata(self.loras, "one") + found = lora_import.preview(str(self.lm), self.tmp / "cache", MEDIA) + self.assertEqual( + found["layout"]["error"], "the settings file is not valid JSON" + ) + self.assertEqual((found["lora_files"], found["with_metadata"]), (0, 0)) + + +MEDIA = frozenset({".png", ".jpg", ".jpeg", ".webp", ".mp4", ".webm"}) if __name__ == "__main__": diff --git a/tests/test_lora_manager_layout.py b/tests/test_lora_manager_layout.py new file mode 100644 index 0000000..52003fa --- /dev/null +++ b/tests/test_lora_manager_layout.py @@ -0,0 +1,858 @@ +"""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() diff --git a/tests/test_lora_utils.py b/tests/test_lora_utils.py index 7348c83..33f3668 100644 --- a/tests/test_lora_utils.py +++ b/tests/test_lora_utils.py @@ -1263,11 +1263,12 @@ class TestTriggerWordCache(unittest.TestCase): "file_name": "my_lora.safetensors", "civitai": {"trainedWords": ["trigger1", "trigger2"]}, } - meta_path = Path(tmpdir) / "my_lora.safetensors.metadata.json" + meta_path = Path(tmpdir) / "my_lora.metadata.json" meta_path.write_text(json.dumps(meta)) + (Path(tmpdir) / "my_lora.safetensors").write_bytes(b"m") - # Patch find_lora_directories to return our temp dir - with patch("py.lora_utils.find_lora_directories", return_value=[tmpdir]): + # No LoraManager install: the default LoRA folders, alongside. + with patch("py.lora_utils.default_lora_roots", return_value=[tmpdir]): count = self.cache.load(tmpdir) self.assertTrue(self.cache.is_loaded) @@ -1283,11 +1284,10 @@ class TestTriggerWordCache(unittest.TestCase): "file_name": "MyLoRA.safetensors", "civitai": {"trainedWords": ["word1"]}, } - (Path(tmpdir) / "MyLoRA.safetensors.metadata.json").write_text( - json.dumps(meta) - ) + (Path(tmpdir) / "MyLoRA.metadata.json").write_text(json.dumps(meta)) + (Path(tmpdir) / "MyLoRA.safetensors").write_bytes(b"m") - with patch("py.lora_utils.find_lora_directories", return_value=[tmpdir]): + with patch("py.lora_utils.default_lora_roots", return_value=[tmpdir]): self.cache.load(tmpdir) self.assertEqual(self.cache.get_trigger_words("mylora"), ["word1"]) @@ -1512,7 +1512,7 @@ class TestDetectLoraManager(unittest.TestCase): self.assertEqual(lora_utils.detect_lora_manager(), str(lm.resolve())) -class TestFindLoraDirectories(unittest.TestCase): +class TestDefaultLoraRoots(unittest.TestCase): def setUp(self): self.tmp = Path(tempfile.mkdtemp()) @@ -1520,7 +1520,7 @@ class TestFindLoraDirectories(unittest.TestCase): self.root = _make_comfy_root(self.tmp) self.lm = _make_lora_manager(self.root / "custom_nodes") - def test_collects_default_extra_runtime_and_metadata_dirs(self): + def test_collects_default_extra_and_runtime_dirs_only(self): extra_abs = self.tmp / "extra_abs" extra_abs.mkdir() (self.root / "rel_loras").mkdir() @@ -1542,14 +1542,14 @@ class TestFindLoraDirectories(unittest.TestCase): {"folder_paths": _FakeFolderPaths(str(self.root), [str(runtime)])}, ), ): - dirs = lora_utils.find_lora_directories(str(self.lm)) + dirs = lora_utils.default_lora_roots() + # #355: folders inside the LoraManager install are no longer roots. expected = { str((self.root / "models" / "loras").resolve()), str((self.root / "rel_loras").resolve()), str(extra_abs.resolve()), str(runtime.resolve()), - str(nested.resolve()), } self.assertEqual(set(dirs), expected) self.assertEqual(dirs, sorted(dirs)) @@ -1560,12 +1560,21 @@ class TestFindLoraDirectories(unittest.TestCase): (self.root / "extra_model_paths.yml").write_text("a: [unclosed\n") self.assertEqual(lora_utils._get_extra_lora_paths(self.root), []) - def test_without_root_only_metadata_dirs_are_found(self): + def test_without_root_nothing_is_found(self): with ( patch.object(lora_utils, "find_comfyui_root", return_value=None), patch.dict(sys.modules, {"folder_paths": None}), ): - self.assertEqual(lora_utils.find_lora_directories(str(self.lm)), []) + self.assertEqual(lora_utils.default_lora_roots(), []) + + def test_the_removed_helpers_are_gone(self): + for name in ( + "find_lora_directories", + "get_preview_images_from_metadata", + "get_preview_image_from_metadata", + "get_example_images_dir", + ): + self.assertFalse(hasattr(lora_utils, name), name) class TestModelNameAndPreviews(unittest.TestCase): @@ -1581,44 +1590,6 @@ class TestModelNameAndPreviews(unittest.TestCase): self.assertEqual(get_name({"file_name": "f.safetensors"}), "f.safetensors") self.assertEqual(get_name({}), "unknown") - def test_preview_images_found_by_stem(self): - meta_path = self.tmp / "mylora.safetensors.metadata.json" - meta_path.write_text("{}") - (self.tmp / "mylora.png").write_bytes(b"x") - (self.tmp / "mylora.preview.jpg").write_bytes(b"x") - - found = lora_utils.get_preview_images_from_metadata({}, meta_path) - - self.assertEqual( - sorted(os.path.basename(f) for f in found), - ["mylora.png", "mylora.preview.jpg"], - ) - self.assertEqual( - lora_utils.get_preview_image_from_metadata({}, meta_path), found[0] - ) - - def test_preview_uses_file_name_from_metadata(self): - meta_path = self.tmp / "other.metadata.json" - (self.tmp / "named.webp").write_bytes(b"x") - found = lora_utils.get_preview_images_from_metadata( - {"file_name": "named.safetensors"}, meta_path - ) - self.assertEqual([os.path.basename(f) for f in found], ["named.webp"]) - self.assertIsNone(lora_utils.get_preview_image_from_metadata({}, meta_path)) - - def test_example_images_dir(self): - self.assertIsNone(lora_utils.get_example_images_dir(str(self.tmp))) - nested = self.tmp / "user" / "example_images" - nested.mkdir(parents=True) - self.assertEqual( - lora_utils.get_example_images_dir(str(self.tmp)), str(nested.resolve()) - ) - direct = self.tmp / "example_images" - direct.mkdir() - self.assertEqual( - lora_utils.get_example_images_dir(str(self.tmp)), str(direct.resolve()) - ) - class TestDownloadEdgeCases(unittest.TestCase): @@ -1703,11 +1674,161 @@ class TestInjectTriggerWords(unittest.TestCase): (tmp / "named.metadata.json").write_text( json.dumps({"civitai": {"trainedWords": ["w"]}}) ) + for stem in ("bad", "nowords", "named"): + (tmp / f"{stem}.safetensors").write_bytes(b"m") cache = TriggerWordCache() - with patch.object(lora_utils, "find_lora_directories", return_value=[str(tmp)]): + with patch.object(lora_utils, "default_lora_roots", return_value=[str(tmp)]): self.assertEqual(cache.load(str(tmp)), 1) self.assertEqual(cache.get_trigger_words("named"), ["w"]) +class TestTriggerWordCacheLayout(unittest.TestCase): + """#355: trigger words come from the sidecars LoraManager really uses.""" + + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()).resolve() + self.addCleanup(shutil.rmtree, self.tmp, True) + self.custom_nodes = self.tmp / "custom_nodes" + self.lm = _make_lora_manager(self.custom_nodes) + self.loras = self.tmp / "loras" + self.loras.mkdir() + self.sidecars = self.tmp / "sidecars" + self.settings = self.tmp / "settings" + self.settings.mkdir() + patchers = [ + patch.object( + lora_utils, "custom_nodes_directories", return_value=[self.custom_nodes] + ), + patch.object(lora_utils, "default_lora_roots", return_value=[]), + patch.dict(os.environ, {"LORA_MANAGER_SETTINGS_DIR": str(self.settings)}), + ] + for patcher in patchers: + patcher.start() + self.addCleanup(patcher.stop) + + def centralized(self): + self.sidecars.mkdir(exist_ok=True) + (self.settings / "settings.json").write_text( + json.dumps( + { + "sidecar_storage_mode": "centralized", + "sidecar_storage_path": str(self.sidecars), + "folder_paths": {"loras": [str(self.loras)]}, + } + ) + ) + + def sidecar(self, folder, stem, model, words): + folder.mkdir(parents=True, exist_ok=True) + meta = { + "file_name": stem, + "file_path": str(model), + "civitai": {"trainedWords": words}, + } + (folder / f"{stem}.metadata.json").write_text(json.dumps(meta)) + + def test_centralized_sidecars_are_read(self): + self.centralized() + model = self.loras / "neon.safetensors" + model.write_bytes(b"m") + self.sidecar(self.sidecars / "loras-0a1b2c3d", "neon", model, ["glow"]) + # A leftover sidecar beside the model is not what LoraManager reads. + self.sidecar(self.loras, "neon", model, ["stale"]) + cache = TriggerWordCache() + self.assertEqual(cache.load(str(self.lm)), 1) + self.assertEqual(cache.get_trigger_words("neon"), ["glow"]) + + def test_both_the_model_and_the_sidecar_name_are_keys(self): + self.centralized() + model = self.loras / "model_name.safetensors" + model.write_bytes(b"m") + self.sidecar(self.sidecars / "c", "sidecar_name", model, ["w"]) + cache = TriggerWordCache() + self.assertEqual(cache.load(str(self.lm)), 2) + self.assertEqual(cache.get_trigger_words("model_name"), ["w"]) + self.assertEqual(cache.get_trigger_words("sidecar_name"), ["w"]) + + def test_an_empty_path_means_auto_detection(self): + self.centralized() + model = self.loras / "neon.safetensors" + model.write_bytes(b"m") + self.sidecar(self.sidecars / "c", "neon", model, ["glow"]) + cache = TriggerWordCache() + with patch.object(lora_utils, "detect_lora_manager", return_value=str(self.lm)): + self.assertEqual(cache.load(""), 1) + + def test_an_unlistable_folder_keeps_the_previous_map(self): + cache = TriggerWordCache() + cache._cache = {"old": ["word"]} + cache._loaded = True + with ( + patch.object( + lora_utils, "discover_sidecars", side_effect=OSError("share gone") + ), + self.assertLogs("prompt_manager.lora_utils", level="WARNING") as logs, + ): + self.assertEqual(cache.load(str(self.lm)), 1) + self.assertEqual(cache.get_trigger_words("old"), ["word"]) + self.assertIn("share gone", "\n".join(logs.output)) + + def test_a_failed_load_waits_before_a_node_retries(self): + # Review finding: a node calls load while the cache is not loaded; a + # failing discovery must not rescan on every prompt run. + cache = TriggerWordCache() + self.assertTrue(cache.retry_due()) + clock = [100.0] + with ( + patch.object(lora_utils.time, "monotonic", lambda: clock[0]), + patch.object(lora_utils, "discover_sidecars", side_effect=OSError("x")), + self.assertLogs("prompt_manager.lora_utils", level="WARNING"), + ): + cache.load(str(self.lm)) + self.assertFalse(cache.is_loaded) + self.assertFalse(cache.retry_due()) + clock[0] += lora_utils.TRIGGER_RETRY_SECONDS + self.assertTrue(cache.retry_due()) + cache.load(str(self.lm)) + self.assertTrue(cache.is_loaded) + self.assertTrue(cache.retry_due()) + + def test_any_other_failure_never_raises(self): + cache = TriggerWordCache() + with ( + patch.object(lora_utils, "load_layout", side_effect=RuntimeError("bad")), + self.assertLogs("prompt_manager.lora_utils", level="WARNING"), + ): + self.assertEqual(cache.load(str(self.lm)), 0) + self.assertFalse(cache.is_loaded) + + def test_an_unusable_layout_falls_back_to_the_default_roots(self): + (self.settings / "settings.json").write_text("{not json") + model = self.loras / "neon.safetensors" + model.write_bytes(b"m") + self.sidecar(self.loras, "neon", model, ["glow"]) + cache = TriggerWordCache() + with ( + patch.object( + lora_utils, "default_lora_roots", return_value=[str(self.loras)] + ), + self.assertLogs("prompt_manager.lora_utils", level="WARNING") as logs, + ): + self.assertEqual(cache.load(str(self.lm)), 1) + self.assertEqual(cache.get_trigger_words("neon"), ["glow"]) + self.assertIn("not valid JSON", "\n".join(logs.output)) + + def test_layout_wraps_the_install_and_the_default_roots(self): + with patch.object( + lora_utils, "default_lora_roots", return_value=[str(self.loras)] + ): + layout = lora_utils.lora_manager_layout(str(self.lm)) + self.assertIsNone(layout.error) + self.assertEqual(layout.source, "environment") + self.assertIn(os.path.normcase(str(self.loras)), layout.lora_roots) + self.assertEqual( + lora_utils.lora_manager_layout(str(self.tmp)).error, + "no LoraManager install found", + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_node_execution_contract.py b/tests/test_node_execution_contract.py index 75de204..a8af10a 100644 --- a/tests/test_node_execution_contract.py +++ b/tests/test_node_execution_contract.py @@ -315,6 +315,43 @@ class TestLoraTriggerWords(NodeTestCase): ) cache.load.assert_called_once_with("/loras") + def test_a_failing_discovery_logs_and_the_node_still_runs(self): + # #355: the trigger cache reads LoraManager's folders while a node + # runs; a folder that cannot be listed must never stop the node. + from py import lora_utils + + cache = lora_utils.get_trigger_cache() + cache.clear() + self.addCleanup(cache.clear) + with ( + mock.patch.dict(sys.modules, {"py.config": fake_integration_config(True)}), + mock.patch.object( + lora_utils, "discover_sidecars", side_effect=OSError("share gone") + ), + self.assertLogs("prompt_manager.lora_utils", level="WARNING") as logs, + ): + text = self.node._inject_lora_trigger_words("a cat ") + self.assertEqual(text, "a cat ") + self.assertIn("share gone", "\n".join(logs.output)) + + def test_a_failed_load_is_not_retried_on_every_run(self): + from py import lora_utils + + cache = lora_utils.get_trigger_cache() + cache.clear() + self.addCleanup(cache.clear) + with ( + mock.patch.dict(sys.modules, {"py.config": fake_integration_config(True)}), + mock.patch.object( + lora_utils, "discover_sidecars", side_effect=OSError("share gone") + ) as discover, + mock.patch.object(lora_utils, "default_lora_roots", return_value=[]), + self.assertLogs("prompt_manager.lora_utils", level="WARNING"), + ): + for _ in range(3): + self.node._inject_lora_trigger_words("a cat ") + self.assertEqual(discover.call_count, 1) + class TestPromptManagerEncoding(NodeTestCase): node_cls = PromptManager diff --git a/tests/test_run_suite.py b/tests/test_run_suite.py index 7ce5077..b35fe56 100644 --- a/tests/test_run_suite.py +++ b/tests/test_run_suite.py @@ -152,6 +152,30 @@ class RunSuiteTests(unittest.TestCase): result = run_suite(self.folder, None, "-p", "check_*.py") self.assertEqual(result.returncode, 1, result.stderr) + def test_lora_manager_settings_read_a_disposable_folder(self): + # #355: no test reads the developer's real LoraManager settings. + record = self.folder / "seen.txt" + self.write( + "test_probe_lora.py", + f""" + import os + import unittest + + class Probe(unittest.TestCase): + def test_isolated(self): + folder = os.environ["LORA_MANAGER_SETTINGS_DIR"] + with open({str(record)!r}, "w", encoding="utf-8") as handle: + handle.write(folder) + self.assertTrue(os.path.isdir(folder)) + self.assertEqual(os.listdir(folder), []) + """, + ) + result = run_suite(self.folder, {"LORA_MANAGER_SETTINGS_DIR": "/real/one"}) + self.assertEqual(result.returncode, 0, result.stderr) + folder = record.read_text(encoding="utf-8") + self.assertNotEqual(folder, "/real/one") + self.assertFalse(os.path.exists(folder), "removed when the run ends") + def test_empty_folder_exits_five(self): result = run_suite(self.folder) self.assertEqual(result.returncode, 5, result.stderr) diff --git a/web/js/workspace/image-card.mjs b/web/js/workspace/image-card.mjs index 4058fe7..53a7663 100644 --- a/web/js/workspace/image-card.mjs +++ b/web/js/workspace/image-card.mjs @@ -50,7 +50,8 @@ export function cardMedia(image, lazy = false) { /** * One card for `image`. `checked` (a boolean) adds the D8 selection checkbox, `favoriteBusy` paints a pending star * (D7, shown wherever the image reports `favorite`), `media: false` leaves the open button empty for a file that - * cannot be served, and `showInfo: false` hides the title and file line (D97). + * cannot be served, and `showInfo: false` hides the title and file line (D97). A `linked_only` image (LoRA media in + * LoraManager's folders, #355) has no Tag action. */ export function createImageCard(image, { checked = null, favoriteBusy = false, media = true, showInfo = true } = {}) { const card = el('article', undefined, 'media-card'); @@ -87,7 +88,9 @@ export function createImageCard(image, { checked = null, favoriteBusy = false, m const inspectButton = createButton('', 'icon-btn'); inspectButton.append(createWorkspaceIcon('info')); inspectButton.setAttribute('aria-label', `Inspect ${image.filename}`); - actions.append(inspectButton, createTagButton(image)); + actions.append(inspectButton); + // #355: LoRA-linked media lives in LoraManager's folders; tags are kept for output files only. + if (image.linked_only !== true) actions.append(createTagButton(image)); info.append(text, actions); card.append(art, info); return card; diff --git a/web/js/workspace/system-integrations.mjs b/web/js/workspace/system-integrations.mjs index 456bbbb..4b24a1b 100644 --- a/web/js/workspace/system-integrations.mjs +++ b/web/js/workspace/system-integrations.mjs @@ -16,6 +16,9 @@ * - "LoRA reference data" → "Review import" (D107; the concept's button only simulated a job): the server * reviews the import (`POST /lora/import/preview`, read-only) and the shared review dialog shows what it replaces, * what saved pairs block, what it downloads and the backup box; Start runs the "LoRA reference import" Activity job. + * #355: its first row, "LoraManager", names the settings source, storage mode and library the server read (the + * settings file is the row's hover title); "Found" counts metadata files, LoRAs and LoRAs with local images. When + * LoraManager's settings cannot be used, the plan has no token and the review says why and offers only Close. */ import { createButton } from '../shared/toolkit/core.js'; import { createBridgeClient } from '../shared/toolkit/prompt-bridge.js'; @@ -55,6 +58,9 @@ export const KEY_COPY = Object.freeze({ saved: 'A key is saved.', none: 'No key export const NOTHING_TO_IMPORT = 'There is nothing to import; nothing was changed.'; export const BLOCKED_NOTE = 'Delete or change the saved pairs that use these prompts, then review the import again. Nothing was changed.'; export const CACHE_NOTE = 'Example images downloaded before a failure stay in the image cache.'; +export const LAYOUT_TERM = 'LoraManager'; +/** #355: the close-only note for LoraManager settings the server cannot use. */ +export const LAYOUT_UNUSABLE = reason => `LoraManager settings: not usable: ${reason}. Nothing was changed.`; const number = value => value.toLocaleString('en-US'); @@ -175,17 +181,38 @@ export function keyText(values, changes) { return values.lora_has_key ? KEY_COPY.saved : KEY_COPY.none; } +/** #355: the "LoraManager" row: which settings, storage mode and library the server read; null without a layout. */ +export function layoutText(layout) { + if (!layout || typeof layout !== 'object') return null; + if (layout.error) return `Not usable: ${layout.error}`; + return `${layout.source} settings · ${layout.mode} storage · library ${layout.library}`; +} + +/** The note of a review that offers only Close, or null when the plan can start. */ +export function closeNote(plan) { + if (!plan.token) { + if (plan.layout?.error) return LAYOUT_UNUSABLE(plan.layout.error); + return plan.blocked_by_pairs ? BLOCKED_NOTE : NOTHING_TO_IMPORT; + } + return !plan.with_metadata && !plan.current_imports ? NOTHING_TO_IMPORT : null; +} + /** D107: the import review's rows from the server's plan. */ export function importGrid(plan) { const downloads = Number.isSafeInteger(plan.planned_downloads) ? plan.planned_downloads : 0; const network = downloads ? `Downloads up to ${number(downloads)} example ${noun(downloads, 'image')} from CivitAI${plan.network?.api_key ? ' with your saved key' : ''}` : 'No downloads'; - const found = Number.isSafeInteger(plan.lora_files) ? plan.lora_files : 0; - const readable = Number.isSafeInteger(plan.with_metadata) ? plan.with_metadata : 0; + const count = value => (Number.isSafeInteger(value) ? value : 0); + const found = count(plan.lora_files); + const readable = count(plan.with_metadata); + const local = count(plan.with_local_media); + const layout = layoutText(plan.layout); return [ + ...(layout ? [[LAYOUT_TERM, layout]] : []), ['Scope', plan.scope?.label || `${number(readable)} ${noun(readable, 'LoRA')}`], - ['Found', `${number(found)} metadata ${noun(found, 'file')} · ${number(readable)} readable`], + ['Found', `${number(found)} metadata ${noun(found, 'file')} · ${number(readable)} ${noun(readable, 'LoRA')} · ` + + `${number(local)} with local images`], ['Protected', protectedText(plan.protected)], ['Network', network], ['Backup', backupText(plan.backup)], @@ -205,11 +232,18 @@ function namesList(plan) { return [list]; } +/** The settings file (home shown as `~`) as the LoraManager row's hover title. */ +function settingsTitle(dd, plan) { + if (typeof plan.layout?.settings_file === 'string') dd.title = plan.layout.settings_file; +} + function closeOnly({ scope, trigger, plan, note }) { const grid = el('dl', undefined, 'meta-grid'); for (const [term, value] of importGrid(plan)) { const entry = el('div'); - entry.append(el('dt', term), el('dd', value)); + const dd = el('dd', value); + if (term === LAYOUT_TERM) settingsTitle(dd, plan); + entry.append(el('dt', term), dd); grid.append(entry); } const close = createButton('Close'); @@ -223,21 +257,19 @@ function closeOnly({ scope, trigger, plan, note }) { /** Show the reviewed import; Start applies exactly this token (D107). */ export function showImportPlan(plan, { api, store, scope, trigger = null, reviewAgain, notify = showWorkspaceToast, navigate = navigateTo, backup = () => downloadDatabaseBackup() }) { - if (!plan.token) { - closeOnly({ scope, trigger, plan, note: plan.blocked_by_pairs ? BLOCKED_NOTE : NOTHING_TO_IMPORT }); - return; - } - if (!plan.with_metadata && !plan.current_imports) { - closeOnly({ scope, trigger, plan, note: NOTHING_TO_IMPORT }); + const note = closeNote(plan); + if (note) { + closeOnly({ scope, trigger, plan, note }); return; } const removesAll = !plan.with_metadata; const sentence = [planNote(plan), removesAll ? 'No LoRA metadata was found: this removes every imported LoRA prompt.' : '', CACHE_NOTE] .filter(Boolean).join(' '); - openReviewDialog({ scope, trigger, title: IMPORT_TITLE, heading: 'LoRA reference data', grid: importGrid(plan), + const review = openReviewDialog({ scope, trigger, title: IMPORT_TITLE, heading: 'LoRA reference data', grid: importGrid(plan), options: namesList(plan), sentence, backup: { copy: BACKUP_COPY, onBackup: backup }, cancelLabel: 'Cancel', primary: { label: IMPORT_START, danger: removesAll }, onStart: () => applyPlan({ api, store, scope, token: plan.token, reviewAgain, notify, navigate, url: IMPORT_APPLY_URL }) }); + if (typeof plan.layout?.settings_file === 'string') review.setTitle(LAYOUT_TERM, plan.layout.settings_file); } /** Review the import (read-only) and show its plan; the pressed button reads "Reviewing…" until it answered. */ diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs index 610fda4..f6a4af0 100644 --- a/web/js/workspace/workspace-dialogs.mjs +++ b/web/js/workspace/workspace-dialogs.mjs @@ -429,7 +429,7 @@ function requireGrid(grid) { * primary (`ca-primary`, or the concept's `danger` when `primary.danger`). The primary closes the dialog, then calls * `onStart()`; `onDismiss()` runs once when the dialog closes any other way (Cancel, ×, Escape, a replacing dialog, the * page leaving), so a staged review is discarded (S9). Returns `{setValue(term, text)}` for a grid value that follows - * an option. + * an option, and `{setTitle(term, text)}` for a value's hover title. */ export function openReviewDialog({ scope, title, heading = null, grid, options = [], sentence = null, backup = null, cancelLabel, primary, onStart, trigger = null, onDismiss = null }) { @@ -475,6 +475,10 @@ export function openReviewDialog({ scope, title, heading = null, grid, options = const dd = values.get(term); if (dd) dd.textContent = text; }, + setTitle(term, text) { + const dd = values.get(term); + if (dd) dd.title = text; + }, }; }