Compare commits

..
Author SHA1 Message Date
Vito Sansevero 5a9971784c fix(images): keep the 3.10-3.13 suffix rule in the scandir walk and harden the warm-up (#348)
Review of the walk speedup:
- os.path.splitext drops names like ..png that Path.suffix listed on Python
  3.10-3.13 (3.14 changed Path.suffix, which hid it from the parity test on
  a 3.14 venv). _name_suffix spells out the 3.10-3.13 rule; the test now
  asserts ..png and ...webp are listed and g. is not.
- _warm_workspace reads the roots inside its try, so a failing
  _get_all_output_dirs warns instead of leaving an unretrieved task error.
- The folder_sets fallback test now takes the fallback (a Windows case
  mismatch); the coalescing test pins the walk count.
2026-10-26 11:23:41 -07:00
Vito Sansevero 3dd45c0efd test(browser): accept KB or MB for the picked safety backup's size
The restore preview showed '2.5 MB' for the test server's database copy in a
CI coverage shard: its size depends on what earlier tests on that server
wrote, and the formatter switches to MB at 1 MiB.
2026-10-26 10:44:02 -07:00
Vito Sansevero a320acc41b perf(images): start a stale listing's refresh walk a second after the read (#348)
After the faster walk, a read that found the listing stale still took
0.5-1.0 s against 0.07 s: it was served from the old listing but ran beside
the refresh walk it had just started, sharing the GIL. The refresh now starts
REFRESH_DELAY (1 s) after that read through a per-root rewalk_handle, so the
page's own requests finish first. One pending refresh per root and none while
a walk runs; any walk start (a cold read, Rescan, the warm-up) cancels a
pending one; on_cleanup cancels them before its first await.
2026-10-26 10:34:05 -07:00
Vito Sansevero b7b99004c7 perf(images): build the workspace caches behind the warm-up and changed walks (#348)
The live test after the faster walk still took 2.9-5.2 s for the first
Images open after a restart. py-spy showed the walk was now small; the time
went to the workspace's own caches, cold after a restart and built by three
parallel first requests at once: the favorites listing (Path.relative_to per
file), the per-file values and newest order, and the prompt-link read.

- Once the host's startup warm-up has run, a stored walk that changed a
  listing schedules one executor job that builds what a first page reads,
  through the request path itself (_select_workspace_page with the default
  filters, then _workspace_favorite_paths). A walk stored during a build
  asks for one more build; an unchanged walk (same list kept) builds
  nothing; a failed build only warns; on_cleanup stops it. Servers that
  never ran the warm-up (the test servers) start no build.
- _favorite_listing slices the path strings as _listed does, falling back
  to relative_to for a path outside the root string.
2026-10-26 10:14:31 -07:00
Vito Sansevero 168af0d868 perf(images): walk the output with scandir entries and build folders from strings (#348)
The live test showed a read during a background refresh taking 2.2 s instead
of 0.07 s: the walk held the GIL for about 2 s in the server. py-spy split
that walk into two halves: Path.relative_to per file in folder_sets, and
Path.stat + Path.is_symlink + Path joins per file in the scan.

- _walk_media visits folders in os.walk's order with os.scandir entries:
  the file type comes from the listing (no lstat per file) and the mtime
  from entry.stat (one call, none on Windows). _iter_media_files is now a
  thin wrapper, so its other caller is unchanged.
- folder_sets slices the path strings (in the path flavour's separator)
  instead of calling relative_to, falling back to it for a path that does
  not start with the root string.

On the maintainer's 19,935-file output folder: scan 0.386 s -> 0.112 s,
folder sets 0.452 s -> 0.007 s, both results identical to the previous code.
A reference copy of the os.walk version checks order, filters, the depth
cap, symlinks and the file cap.
2026-10-26 09:57:33 -07:00
Vito Sansevero 19097ed6e1 test(browser): poll the import review's rows instead of reading them once
gridOf reads the dialog's rows once; right after the click the review may
not have rendered them yet (firefox CI: received undefined).
2026-10-26 08:27:30 -07:00
Vito Sansevero 476b1f73dc test(images): let the settle loops yield so a finished walk leaves the set (#348)
On CI (ubuntu and macOS py3.12) the runner's watchdog dumped
test_a_root_that_recovers_warns_again_on_its_next_failure spinning in
_settle: asyncio.gather over tasks that are already done returns without
yielding, so the set's discard callback, queued when the walk finished,
never ran and the loop never ended (wait_for could not fire either).
Delaying discard by one loop turn reproduces it locally; both settle
loops now yield one turn after each gather.
2026-10-26 08:27:28 -07:00
Vito Sansevero 7dc8d99363 Merge remote-tracking branch 'origin/main' into perf/348-images-listing 2026-10-26 07:35:31 -07:00
Vito Sansevero a478ae0a1d test(images): bound the shared-listing test waits (#348)
A macOS py3.12 run stopped inside the shared-listing tests and sat until
the 6 h job limit with no traceback. It does not reproduce locally (about
300 runs on 3.12 and 3.14, pinned to one core). Requests and background
settles in TestSharedListing now give up after 30 s, so a recurrence
fails with the stuck await instead of hanging the run.
2026-10-26 03:52:18 -07:00
Vito Sansevero 02be260a14 fix(images): drop listings on the loop when invalidated from a worker thread (#348)
A settings save (gallery roots change) and the browser-test seeds call
invalidate_gallery_cache() from the executor. Listing records are only
touched on the event loop, so an off-loop call now hands the drop to the
loop and waits for it, keeping the call synchronous for its callers.

The shared-Rescan test waits for the later Rescans to take their walk
number instead of sleeping.
2026-10-25 21:11:31 -07:00
Vito Sansevero 2dbf79c9ae perf(images): share and warm one listing per output root (#348)
The Images page, its sidebar counts, the subfolder list and the legacy
gallery each rebuilt the output listing when the 30 s cache expired, all
at once, and the subfolder list computed relative paths on the event
loop. Thumbnail writes also threw the listing away.

Keep one RootListing per output root (py/api/gallery_listing.py):
- one walk per root at a time; concurrent readers wait for it (shielded)
- a listing older than 30 s is served at once and refreshed in the
  background
- the walk, the comparison with the previous listing and both folder
  sets run in the executor; an unchanged walk keeps the same list object
- os.scandir probe: a missing root fails the walk and keeps the previous
  listing (one warning per failure streak)
- Rescan waits for a walk started after the click and is a 500 when it
  fails, even with a previous listing
- deletes keep the blocking invalidation; a generation counter that
  survives drops discards walks that raced it
- thumbnail jobs, conversion and the legacy generate/clear routes no
  longer drop the listing
- warm-up on the host's on_startup, cancelled on on_cleanup

Adds tools/vendor/perf/images_load_probe.mjs (maintainer tool, not CI)
and the TESTING.md section "Images listing cache (#348)".
2026-10-25 20:59:14 -07:00
44 changed files with 460 additions and 5472 deletions
+6 -3
View File
@@ -2,15 +2,18 @@
"required_status_checks": {
"strict": true,
"contexts": [
"py3.10 on ubuntu-latest",
"py3.11 on ubuntu-latest",
"py3.12 on ubuntu-latest",
"py3.13 on ubuntu-latest",
"py3.14 on ubuntu-latest",
"py3.10 on windows-latest",
"py3.11 on windows-latest",
"py3.12 on windows-latest",
"py3.13 on windows-latest",
"py3.14 on windows-latest",
"py3.10 on macos-latest",
"py3.11 on macos-latest",
"py3.12 on macos-latest",
"py3.13 on macos-latest",
"py3.14 on macos-latest",
"coverage gate (85%)",
"black and flake8",
"frontend modules",
+2 -2
View File
@@ -26,10 +26,10 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: Set up Python 3.12
- name: Set up Python 3.10
uses: actions/setup-python@v7
with:
python-version: "3.12"
python-version: "3.10"
- name: Install bandit
run: pip install bandit[toml]
+1 -2
View File
@@ -26,7 +26,6 @@ env:
# Measured 2026-10-26 on runs 40257217681 (3be0ada), 40240706568 (fb9134f), 40228167328 (f9835aa):
# py matrix, slowest py3.10 on windows-latest: 12m43s / 15m54s / 14m53s -> 27 min
# (ubuntu 5m27s-6m27s, macos 6m14s-8m30s, windows 11m42s-15m54s)
# 3.10 and 3.11 left the matrix and 3.14 joined it in #356; re-measure after three green main runs.
# real ComfyUI compatibility: 13m32s / 8m55s / 11m46s -> 24 min
# coverage gate (85%): 7m26s / 7m22s / 7m54s -> 15 min
# perf budget 30s / 29s / 27s, black and flake8 39s / 37s / 39s,
@@ -40,7 +39,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ["3.12", "3.13", "3.14"]
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v7
+4 -8
View File
@@ -1,7 +1,7 @@
# ComfyUI Prompt Manager
![License](https://img.shields.io/badge/license-MIT-blue.svg)
![Python](https://img.shields.io/badge/python-3.12%2B-blue.svg)
![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)
![ComfyUI](https://img.shields.io/badge/ComfyUI-compatible-green.svg)
![Tests](https://github.com/ComfyAssets/ComfyUI_PromptManager/workflows/Tests/badge.svg)
![Code Quality](https://github.com/ComfyAssets/ComfyUI_PromptManager/workflows/Code%20Quality/badge.svg)
@@ -483,15 +483,11 @@ 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:
- 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
- Scans all LoRA directories (including `extra_model_paths.yaml` paths) for metadata
- Downloads example images from CivitAI as 512px thumbnails
- 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.
@@ -933,7 +929,7 @@ node --test tests/js/*.test.js # frontend pure modules
python -m coverage run -m tests.run_suite && python -m coverage combine && python -m coverage report
```
CI runs the suite on Linux, Windows and macOS for Python 3.12 to 3.14 with an 85% coverage gate. `python -m unittest discover -s tests` still works; see `TESTING.md` ("Time limits and hangs") for the watchdog. Tests that delete a database file first check that nothing in the process still holds it open (`tests/open_handles.py`), so a connection leak fails on Linux the way it would on Windows.
CI runs the suite on Linux, Windows and macOS for Python 3.10 to 3.13 with an 85% coverage gate. `python -m unittest discover -s tests` still works; see `TESTING.md` ("Time limits and hangs") for the watchdog. Tests that delete a database file first check that nothing in the process still holds it open (`tests/open_handles.py`), so a connection leak fails on Linux the way it would on Windows.
### Code Style
+2 -109
View File
@@ -86,18 +86,6 @@ timer that "usually" covers a frame is a race on a loaded runner. Library
virtualization specs settle with `libraryObservation.settled()` (two consecutive
frames with no Library frame queued). To look for such races, repeat a spec under
load (`--workers 8 --repeat-each 10`, and in the image with `--cpus=1` or `2`).
CPU limits do not reproduce disk-bound timeouts (backup, restore). For those,
throttle writes in the image: with rootful Docker on a host with cgroup v2, add
`--device-write-iops /dev/<disk>:20` to the `docker run`, where `<disk>` is the
whole disk (not a partition) holding Docker's data root, because the test server
keeps its database in the container's `/tmp`. On a plain partition,
`lsblk -no pkname "$(df --output=source "$(docker info -f '{{.DockerRootDir}}')" | tail -1)"`
prints it; on LVM or LUKS, `lsblk -s` walks down to the disk. A spec that waits
on a timer then fails at its 5 s expect. A spec that waits on the response can
still fail at the 30 s test timeout when several workers share the throttled
disk; that means the disk is too slow for the test budget, not that the spec is
wrong.
On unsupported Linux
distributions, browser system dependencies may need manual installation. For a
quick browser run, use `test:workflows -- --project chromium`.
@@ -345,75 +333,6 @@ hold a walk open with a gated scan instead of sleeping.
listing gets a 500. Rescan waits for a walk that started after the click
(by walk number, not by clock), Rescans clicked during one walk share the
next, and a failed walk is a 500 even when a previous listing exists.
- **New outputs (added without a walk, PR 2).** The image monitor reports
every settled output it accepts (by default `.png .jpg .jpeg .webp .gif`;
videos only when their extension is in `monitoring.extensions`), linked or
not, with its `realpath`, through `set_output_listener`. On its worker
thread the API's `notify_output_settled` only does
`loop.call_soon_threadsafe(output_settled, path)`, and does nothing before
the loop is known or after `cleanup_gallery_listings`. `output_settled`
matches the path against each walk's `resolved_root` (`normcase(realpath)`,
computed in the executor; `root_contains` ends the match at a separator
and ignores a Windows `\\?\` or `\\?\UNC\` prefix on either side).
- **The add.** A root with a stored listing and no walk running gets the
file added without a walk. `relative_part` slices the path below the
root in its own case (Windows compares folded, slices the original);
`_run_adds` places the pending files of one root in one executor batch
(`_patch_listing`): `_settled_entry` applies the walk's filters
(not a symlink or folder by `lstat`, `_name_suffix` in `MEDIA_EXTENSIONS`,
no `thumbnails` folder, at most `MAX_SCAN_DEPTH` folders, every folder on
the way listable by `os.access`, which on Windows ignores ACLs: a folder
whose ACL denies listing is added there and corrected by the next walk). Where names fold case the stored
spelling must match first, as the filters compare it: Windows through
`nt._getfinalpathname` (its `realpath` keeps the given case when it cannot
open the file), macOS through `F_GETPATH` on a non-blocking open (a FIFO
never holds the executor); a case-insensitive mount on Linux is not
checked and is corrected by the next walk. `insert_entry` builds a new list with
the `(Path, mtime)` entry where the walk's newest-first sort puts it,
replacing the same path's entry. `add_folder` copies both folder sets. The
batch is stored only when no walk started and no drop happened while it
ran (a walk started after the report lists the file itself); `walked_at`
is untouched, so the 30 s refresh still walks and corrects any drift, and
`walk_root` keeps the patched list object when that walk finds the same.
Tests compare every added listing with a real walk (`_walked`).
- **When it walks instead.** The root is marked dirty (`mark_root_dirty`,
`_mark_dirty`) when the add cannot be exact: a walk is running (it may have
passed the folder: the trailing walk), the path cannot be sliced, the
`lstat` or the spelling read fails, the stored spelling differs, another
file has the same mtime (a walk orders those by its
visit order), the listing is at `MAX_GALLERY_FILES`, or the batch raised.
A file the walk's filters exclude is neither added nor walked. A root
without a listing, or a dropped one, is walked by its next read. A dirty
root gets one background walk, no sooner than `rewalk_interval` =
`min(15, max(5, 5 × last walk's duration))` seconds after the previous walk
started. `_schedule_walk` never arms a timer while a walk runs (the walk's
end re-checks `dirty`: the trailing walk), arms at most one per root and
never sooner than 0.5 s, and keeps the earlier deadline when the stale
refresh and a dirty root both want one; every walk start (read, Rescan,
warm-up or timer) cancels it and forgets the pending adds. A failed walk
clears `dirty`; a cancelled walk starts no trailing walk. When the output
roots change (`reset_gallery_roots_cache`) every record is `retired`: its
timer is cancelled and no add, mark, timer or trailing walk touches it
until a reader or the warm-up asks for that root again (the monitor keeps
watching a removed root until restart). Tests patch
`REWALK_FLOOR`/`REWALK_CAP` and run the pending timer's callback by hand
(`_fire`) instead of waiting for it, and hold an add batch in the executor
(`_hold_adds`) to race it with a walk, a drop or the cleanup.
- **Workspace caches after an add.** A patched listing shares every
unchanged entry object with the one before it, so `listing_delta` finds
the change by identity (a walk builds new tuples and is rebuilt in full,
as is any change of more than `MAX_DELTA` entries). The per-file values
build only the added files (`_workspace_listed_step`), the newest order
places them by bisection (`_newest_step`, only when the per-file cache
still holds exactly the values this call built: a page read and the warm
build run in executor threads at once, and an order labelled with older
values than it holds would add a file twice), the favorites lookup is copied
and extended (`_favorite_listing_step`, rebuilt when a key collides), and
the other orders, wanted paths and locators rebuild lazily. On a synthetic
20,000-file listing a one-file add costs about 3 ms in the executor and
15 ms for the warm build, against about 155 ms + 80 ms for a walk and a
full build (`test_a_warm_build_after_an_add_builds_only_the_added_values`
counts the values built and the sort keys read).
- **Measuring it.** `tools/vendor/perf/images_load_probe.mjs` is a maintainer
tool, never run in CI. After `npm ci --prefix tools/vendor` and
`npx --prefix tools/vendor playwright install chromium`, run
@@ -3422,40 +3341,14 @@ 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 `<LORA_MANAGER_SETTINGS_DIR>/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 `<base>.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
`<example-images root>[/<library>]/<sha256>/` 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: <reason>. 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_manager_layout tests.test_lora_import tests.test_api_lora \
tests.test_lora_database tests.test_lora_utils -v
python3 -m unittest 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 \
-4
View File
@@ -149,9 +149,6 @@ try:
# Every settled output's preview is queued for the API's background
# worker (#249): the workspace never asks to generate thumbnails.
_global_image_monitor.set_thumbnail_queue(_review_api.queue_capture_thumbnail)
# Every settled output walks its Images listing again (#348): the
# API hops from the monitor's thread onto the server's loop.
_global_image_monitor.set_output_listener(_review_api.notify_output_settled)
_review_host = getattr(config.server_instance, "app", None)
if _review_host is not None and hasattr(_review_host, "on_cleanup"):
@@ -160,7 +157,6 @@ try:
):
monitor.set_review_recorder(None)
monitor.set_thumbnail_queue(None)
monitor.set_output_listener(None)
api.close_auto_thumbnails(final=True)
_review_host.on_cleanup.insert(0, _stop_review_recording)
+1 -4
View File
@@ -192,10 +192,7 @@ class CategoryImportOperations:
outcome = "imported"
created[prompt_hash] = prompt_id
result[_COUNTERS[outcome]] += 1
# 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 []):
for image in record.get("images") or []:
if self._insert_image_link(conn, prompt_id, image):
result["images"] += 1
result["rows"].append({"name": name, "outcome": outcome})
+2 -4
View File
@@ -268,10 +268,8 @@ class PromptManagerBase:
cache = get_trigger_cache()
# 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():
# Lazy-load cache on first use
if not cache.is_loaded and IntegrationConfig.LORA_MANAGER_PATH:
cache.load(IntegrationConfig.LORA_MANAGER_PATH)
modified, injected = inject_trigger_words(text, cache)
+7 -52
View File
@@ -362,11 +362,8 @@ class PromptManagerAPI(
# background walks refreshing them (py/api/gallery_listing.py, #348).
self._root_listings: dict[str, RootListing] = {}
self._listing_tasks: set = set()
# The loop the walks run on, set by the startup warm-up or the first
# walk; the monitor's thread hops onto it (notify_output_settled).
# The loop the walks run on, set by the first walk.
self._listing_loop = None
# Set by cleanup_gallery_listings: no background walk starts after it.
self._listings_closed = False
# The workspace caches rebuilt behind a stored walk that changed the
# listing, once the host's startup warm-up has run (#348).
self._workspace_warm_enabled = False
@@ -495,7 +492,7 @@ class PromptManagerAPI(
"""The shared listing record of one output root (created empty)."""
return self._root_listings.setdefault(str(root), RootListing())
def invalidate_gallery_cache(self, retire=False):
def invalidate_gallery_cache(self):
"""Drop every root's listing, so the next read waits for a new walk.
Called after files are deleted through the app (duplicate cleanup and
@@ -506,9 +503,6 @@ class PromptManagerAPI(
_workspace_listed and _card_facts); its sort orders are dropped, as
the walk replaces them anyway, and so is the favorites lookup, which
holds the old list.
``retire`` (the roots changed): no background walk of a root starts
again until a reader or the warm-up asks for it.
"""
loop = self._listing_loop
if loop is not None and loop.is_running() and _running_loop() is not loop:
@@ -519,7 +513,7 @@ class PromptManagerAPI(
def drop():
try:
self._drop_listings(retire)
self._drop_listings()
finally:
dropped.set()
@@ -527,19 +521,11 @@ class PromptManagerAPI(
if not dropped.wait(_OFF_LOOP_DROP_WAIT):
self.logger.warning("The Images listing drop is still queued")
return
self._drop_listings(retire)
self._drop_listings()
def _drop_listings(self, retire=False):
def _drop_listings(self):
for record in list(self._root_listings.values()):
record.drop()
if retire:
# The roots changed: a removed root must not come back through
# a pending timer, a trailing walk or a monitor mark.
record.retired = True
record.dirty = False
handle, record.rewalk_handle = record.rewalk_handle, None
if handle is not None:
handle.cancel()
self._workspace_order_cache = {}
self._favorite_listing_cache = {}
@@ -553,36 +539,15 @@ class PromptManagerAPI(
# Each stored walk that changes a listing then rebuilds the workspace's
# per-file values, orders and favorites behind it (_warm_workspace).
self._workspace_warm_enabled = True
self._listing_loop = asyncio.get_running_loop()
self._listings_closed = False
try:
for root in self._get_all_output_dirs():
self._root_listing(root).retired = False
self._schedule_walk(root)
except Exception as e:
self.logger.warning(f"Could not warm the Images listing: {e}")
def notify_output_settled(self, path):
"""The image monitor settled an output at *path* (any thread).
Called on the monitor's worker thread with the resolved path. It only
hands the path to the event loop (``output_settled``) and never
touches a listing from this thread; before the loop is known, or once
the listings are cleaned up, it does nothing.
"""
loop = self._listing_loop
if loop is None or self._listings_closed or not loop.is_running():
return
try:
loop.call_soon_threadsafe(self.output_settled, path)
except RuntimeError:
pass # the loop closed meanwhile (shutdown)
async def cleanup_gallery_listings(self, app=None):
"""Stop the background listing walks (aiohttp on_cleanup)."""
self._workspace_warm_enabled = False
# No mark, deferred walk or trailing walk starts a walk from now on.
self._listings_closed = True
# Before any await, so no delayed refresh can start a walk meanwhile.
for record in self._root_listings.values():
handle, record.rewalk_handle = record.rewalk_handle, None
@@ -606,7 +571,7 @@ class PromptManagerAPI(
def reset_gallery_roots_cache(self):
"""Drop every cached listing and derived workspace value (roots changed)."""
self.invalidate_gallery_cache(retire=True)
self.invalidate_gallery_cache()
self._workspace_listed_cache = {}
self._workspace_card_cache = {}
@@ -750,12 +715,7 @@ 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.
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).
"""
"""Add url and thumbnail_url to a flat list of image dicts."""
from urllib.parse import quote as url_quote
output_dirs = self._get_all_output_dirs()
@@ -765,11 +725,6 @@ 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
+20 -211
View File
@@ -6,20 +6,13 @@ loop; the walk itself, the comparison with the previous listing and both
folder sets run in the executor through ``walk_root``. Routes live in
``py/api/images.py`` (``_listing``); this module has no aiohttp import so
tests can exercise it without a server.
A settled output the image monitor reports is added to its root's stored
listing without a walk (``output_settled``, ``insert_entry``), in the place
a walk would list it. When that cannot be exact, the root is marked dirty
(``mark_root_dirty``) instead; a dirty root gets one background walk, no
sooner than ``rewalk_interval`` after its previous walk started (PR 2).
"""
from __future__ import annotations
import asyncio
import os
from bisect import bisect_left
from dataclasses import dataclass, field
from dataclasses import dataclass
from pathlib import PurePath
from typing import Callable, NamedTuple
@@ -33,19 +26,6 @@ REFRESH_AFTER = 30
# (#348 live test: reads during the walk took 2.2 s instead of 0.07 s).
REFRESH_DELAY = 1.0
# No background walk of a root starts sooner than rewalk_interval(last walk's
# duration) after its previous walk started: FACTOR times that duration, at
# least FLOOR and at most CAP seconds. Walks up to CAP / FACTOR (3 s) then
# hold the GIL at most ~20% of the time; the cap keeps a new output listed
# within ~20 s, at the cost of a higher share (back to back from 15 s) on
# slower walks. Read through this module so tests can patch them.
REWALK_FLOOR = 5
REWALK_FACTOR = 5
REWALK_CAP = 15
# A deferred walk is never armed sooner than this: no zero-delay loop.
MIN_REWALK_DELAY = 0.5
# A reader with no listing gives up (500) after this many walks in a row were
# discarded by invalidations made while they ran.
MAX_DISCARDED_WALKS = 3
@@ -100,35 +80,14 @@ class RootListing:
flight: asyncio.Future | None = None
# True while walks keep failing: one warning per streak.
failing: bool = False
# The one deferred background walk not started yet (loop.call_later), or
# None: a stale refresh or a dirty root's rewalk, whichever is due first.
# The delayed refresh not started yet (loop.call_later), or None.
rewalk_handle: asyncio.TimerHandle | None = None
# normcase(realpath(root)), computed by the walk in the executor: monitor
# paths are matched against it without file I/O on the loop.
resolved_root: str | None = None
# Monotonic start and duration of the last walk (rewalk_interval).
last_started_at: float | None = None
last_duration: float | None = None
# A settled output was reported under this root; cleared when a walk starts.
dirty: bool = False
# Dropped because the output roots changed: no background walk until a
# reader (or the warm-up) asks for this root again. The monitor keeps
# watching a removed root until ComfyUI restarts.
retired: bool = False
# Relative paths of settled outputs waiting to be added to ``files``
# without a walk, and the one task adding them (``output_settled``).
pending_adds: list = field(default_factory=list)
add_task: asyncio.Task | None = None
# Settled outputs added to the stored listing since the last walk stored.
patched: int = 0
def drop(self) -> None:
self.files = None
self.folders = None
self.folders_with_ancestors = None
self.walked_at = None
self.pending_adds = []
self.patched = 0
self.generation += 1
@@ -140,174 +99,26 @@ def folder_sets(root, files) -> tuple[frozenset, frozenset]:
files, plus every ancestor joined by ``/``.
"""
# From the path strings: relative_to per file was half of a large walk.
sep, prefix = _root_prefix(root)
folders = set()
for path, _mtime in files:
rel_dir = _folder_of(root, path, sep, prefix)
if rel_dir:
folders.add(rel_dir)
with_ancestors = set(folders)
for folder in folders:
with_ancestors.update(_ancestors(folder))
return frozenset(folders), frozenset(with_ancestors)
def add_folder(root, path, folders, with_ancestors) -> tuple[frozenset, frozenset]:
"""Both folder sets once *path* is listed too (the same sets when it adds none)."""
sep, prefix = _root_prefix(root)
rel_dir = _folder_of(root, path, sep, prefix)
if not rel_dir or rel_dir in folders:
return folders, with_ancestors
return folders | {rel_dir}, with_ancestors | {rel_dir, *_ancestors(rel_dir)}
def _root_prefix(root) -> tuple[str, str]:
# The separator is the path flavour's own (tests use PureWindowsPath).
sep = str(type(root)("a", "b"))[1]
prefix = str(root)
if not prefix.endswith(sep):
prefix += sep
return sep, prefix
def _folder_of(root, path, sep, prefix) -> str:
"""The folder ``/gallery/subfolders`` names for *path*; "" for the root."""
text = str(path)
if text.startswith(prefix):
rel_dir = text[len(prefix) :].rpartition(sep)[0]
else:
rel_dir = str(path.relative_to(root).parent)
return "" if rel_dir == "." else rel_dir
def _ancestors(folder: str) -> list[str]:
parts = folder.replace("\\", "/").split("/")
return ["/".join(parts[:i]) for i in range(1, len(parts))]
def relative_part(resolved_root: str, path, pathmod=os.path) -> str | None:
"""The part of the resolved *path* below *resolved_root*, as a walk spells it.
Kept in *path*'s own case (``realpath`` gives a file's on-disk case on
Windows) with the native separator. None when *path* is not below the
root, or when the case folding changed the length so the slice would
not be exact.
"""
sep = pathmod.sep
text = str(path)
if sep == "\\":
text = _plain(text.replace("/", sep), pathmod)
folded = pathmod.normcase(text)
prefix = _plain(resolved_root, pathmod)
if not prefix.endswith(sep):
prefix += sep
if len(folded) != len(text) or not folded.startswith(prefix):
return None
relative = text[len(prefix) :]
if any(part in ("", ".", "..") for part in relative.split(sep)):
return None
return relative
def insert_entry(files: list, entry: tuple, max_files: int) -> list | None:
"""A new listing with *entry* ``(path, mtime)`` where a walk would list it.
*files* is newest first; an entry for the same path is replaced. Returns
*files* itself when it already holds *entry*, and None when the place is
not exact: another file has the same mtime (a walk orders those by its
own visit order), or the listing is at the walk's *max_files* cap.
Blocking (one pass over *files*): run it in the executor.
"""
path, mtime = entry
text = str(path)
kept = files
for index, (listed, listed_mtime) in enumerate(files):
if str(listed) == text:
if listed_mtime == mtime:
return files
kept = files[:index] + files[index + 1 :]
break
else:
if len(files) >= max_files:
return None
index = bisect_left(kept, -mtime, key=lambda item: -item[1])
if index < len(kept) and kept[index][1] == mtime:
return None
return [*kept[:index], entry, *kept[index:]]
# A listing that differs from the cached one by more entries than this is
# rebuilt in full by the workspace caches (listing_delta).
MAX_DELTA = 256
def listing_delta(old: list, new: list, limit: int = MAX_DELTA):
"""``(added, removed)`` entries from *old* to *new*, or None past *limit*.
A listing patched by ``insert_entry`` shares every unchanged entry object
with the one it came from, so entries are told apart by identity: a walk
builds new tuples, shares none and is rebuilt in full. *old* must still
be alive (the
caller's cache holds it), so no identity was reused.
"""
if abs(len(new) - len(old)) > limit:
return None
old_ids = set(map(id, old))
added = []
for entry in new:
if id(entry) not in old_ids:
added.append(entry)
if len(added) > limit:
return None
if new and len(added) == len(new):
return None # nothing shared: a walk
if len(old) + len(added) == len(new):
return added, []
new_ids = set(map(id, new))
return added, [entry for entry in old if id(entry) not in new_ids]
def rewalk_interval(last_duration) -> float:
"""Seconds from a walk's start to the next background walk of its root."""
scaled = REWALK_FACTOR * (last_duration or 0.0)
return min(REWALK_CAP, max(REWALK_FLOOR, scaled))
def root_contains(resolved_root: str, path, pathmod=os.path) -> bool:
"""True when the resolved *path* lies under *resolved_root* (no file I/O).
*resolved_root* is already ``normcase``d; *path* is folded the same way,
so Windows matches case-insensitively and with either separator. The
match ends at a separator: ``/out2/x.png`` is not under ``/out``.
"""
target = _plain(pathmod.normcase(str(path)), pathmod)
prefix = _plain(resolved_root, pathmod)
if not prefix.endswith(pathmod.sep):
prefix += pathmod.sep
return target.startswith(prefix)
def _plain(path: str, pathmod) -> str:
"""*path* (normcased or not) without a Windows extended-length prefix.
``realpath`` may keep ``\\\\?\\`` on a file past MAX_PATH while its shorter
root resolves without it.
"""
if pathmod.sep != "\\":
return path
if path[:8].lower() == "\\\\?\\unc\\":
return "\\\\" + path[8:]
if path.startswith("\\\\?\\"):
return path[4:]
return path
def resolve_root(root) -> str:
"""``normcase(realpath(root))``, the spelling monitor paths are matched in.
File I/O (realpath follows links): call it in the executor.
"""
return os.path.normcase(os.path.realpath(str(root)))
folders = set()
for path, _mtime in files:
text = str(path)
if text.startswith(prefix):
rel_dir = text[len(prefix) :].rpartition(sep)[0]
else:
rel_dir = str(path.relative_to(root).parent)
if rel_dir and rel_dir != ".":
folders.add(rel_dir)
with_ancestors = set(folders)
for folder in folders:
parts = folder.replace("\\", "/").split("/")
for i in range(1, len(parts)):
with_ancestors.add("/".join(parts[:i]))
return frozenset(folders), frozenset(with_ancestors)
def same_listing(old, new) -> bool:
@@ -319,10 +130,8 @@ def same_listing(old, new) -> bool:
def walk_root(
scan_fn: Callable[[PurePath], list], root, previous_files
) -> tuple[list, frozenset, frozenset, str]:
"""The single executor job of a walk.
Returns ``(files, folders, with_ancestors, resolved_root)``.
) -> tuple[list, frozenset, frozenset]:
"""The single executor job of a walk: ``(files, folders, with_ancestors)``.
Opens the root first, so a missing or unreadable root raises ``OSError``
instead of listing nothing. Returns *previous_files* itself when nothing
@@ -334,4 +143,4 @@ def walk_root(
if same_listing(previous_files, files):
files = previous_files
folders, with_ancestors = folder_sets(root, files)
return files, folders, with_ancestors, resolve_root(root)
return files, folders, with_ancestors
+46 -514
View File
@@ -10,12 +10,10 @@ import io
import json
import os
import re
import stat
import subprocess # nosec B404 - ffmpeg frames through run_encoder, fixed argv
import sys
import time as _time
import urllib.parse
from bisect import bisect_left, bisect_right, insort
from bisect import bisect_left, bisect_right
from collections.abc import Mapping
from pathlib import Path
from typing import Callable, NamedTuple
@@ -591,10 +589,6 @@ def _workspace_key(entry: tuple, order: str) -> tuple:
return (mtime, entry.name, entry.tie, entry.index)
def _newest_key(entry: _Listed) -> tuple:
return _workspace_key(entry, "newest")
def _rated_key(entry: _Listed, rank: int) -> tuple:
"""Highest rated: rating 5..1, linked unrated (0), unlinked (-1); then newest."""
return (-rank, *_workspace_key(entry, "rating"))
@@ -770,34 +764,6 @@ def _favorite_relative_key(relative: str) -> str:
return _path_flavor.normcase(relative).replace(_path_flavor.sep, "/")
def _listing_posix(root, prefix, path) -> str:
"""*path*'s POSIX spelling below *root* (*prefix*: ``join(root, "")``)."""
text = str(path)
if text.startswith(prefix):
relative = text[len(prefix) :]
return relative if os.sep == "/" else relative.replace(os.sep, "/")
return path.relative_to(root).as_posix()
def _favorite_listing_step(root, listing, added, removed) -> dict | None:
"""*listing* after a patched listing's change, or None to rebuild it.
Only when every removed file is added again under the same spelling (a
rewritten output) and no added file's key is already taken by another
spelling: the full build keeps the first spelling in listing order.
"""
prefix = os.path.join(str(root), "")
spellings = {_listing_posix(root, prefix, path) for path, _mtime in added}
if any(_listing_posix(root, prefix, path) not in spellings for path, _ in removed):
return None
listing = dict(listing)
for posix in spellings:
known = listing.setdefault(_favorite_relative_key(posix), posix)
if known != posix:
return None
return listing
def _favorite_root_key(root) -> str:
"""Server-side identity of a gallery root: its real, case-normalized path.
@@ -861,91 +827,6 @@ def _walk_media(output_path, max_files):
stack.append((entry.path, rel_parts + (entry.name,)))
def _settled_entry(root, relative):
"""``(Path, mtime)`` of a settled output as a walk would list it.
*relative* is its part below *root* (``gallery_listing.relative_part``).
None when a walk would not list it (``_walk_media``'s filters, and every
folder on the way must be listable); raises ``OSError`` when that cannot
be told (the file is gone or unreadable, or its on-disk spelling differs
or cannot be read). Blocking: run it in the executor.
"""
path = Path(os.path.join(os.fspath(root), relative))
info = os.lstat(path)
if stat.S_ISLNK(info.st_mode) or stat.S_ISDIR(info.st_mode):
return None
# Before the name filters: they compare the walk's (stored) spelling.
on_disk = _on_disk_path(path)
if on_disk is not None and not on_disk.endswith(os.sep + relative):
raise OSError(f"{relative} is spelled differently on disk")
*folders, name = relative.split(os.sep)
if _name_suffix(name).lower() not in MEDIA_EXTENSIONS:
return None
if "thumbnails" in folders or len(folders) > MAX_SCAN_DEPTH:
return None
folder = os.fspath(root)
for part in ("", *folders):
folder = os.path.join(folder, part) if part else folder
if not os.access(folder, os.R_OK | os.X_OK):
return None # the walk cannot list this folder
return path, info.st_mtime
def _on_disk_path(path, platform=None):
"""The file's path as its folders store the names, where that can differ.
Windows and macOS file systems ignore case. Windows' ``realpath`` gives
the stored case, but keeps the given one when it cannot open the file;
macOS' ``realpath`` always keeps the given one. A walk lists the names as
stored, so an add checks them: Windows through ``GetFinalPathNameByHandle``
(``nt._getfinalpathname``), macOS through ``F_GETPATH``. Raises OSError
when it cannot be read; None elsewhere (case-sensitive file systems).
"""
platform = platform or sys.platform
if platform == "win32":
import nt
return nt._getfinalpathname(os.fspath(path))
if platform != "darwin":
return None
import fcntl
# Non-blocking: a FIFO named like an image must not hold the executor.
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0))
try:
raw = fcntl.fcntl(descriptor, fcntl.F_GETPATH, bytes(1024))
finally:
os.close(descriptor)
return os.fsdecode(raw.split(b"\0", 1)[0])
def _patch_listing(root, files, folders, with_ancestors, relatives):
"""The listing with each settled output in *relatives* added (blocking).
Returns ``(files, folders, with_ancestors, unresolved)``; *files* is the
same list when nothing changed. ``unresolved`` is True when an output
could not be placed exactly, so the root needs a walk.
"""
unresolved = False
for relative in relatives:
try:
entry = _settled_entry(root, relative)
except OSError:
unresolved = True
continue
if entry is None:
continue # a walk would not list it either
patched = gallery_listing.insert_entry(files, entry, MAX_GALLERY_FILES)
if patched is None:
unresolved = True
elif patched is not files:
files = patched
folders, with_ancestors = gallery_listing.add_folder(
root, entry[0], folders, with_ancestors
)
return files, folders, with_ancestors, unresolved
def _iter_media_files(output_path, max_files):
"""Yield the ``Path`` of each media file under *output_path* (see _walk_media)."""
for entry in _walk_media(output_path, max_files):
@@ -1527,7 +1408,6 @@ class ImageRoutesMixin:
even if a previous listing exists.
"""
record = self._root_listing(output_path)
record.retired = False # a configured root: read, so listened to
needed = record.walk_seq + 1 if fresh else 0
discarded = 0
while True:
@@ -1553,172 +1433,28 @@ class ImageRoutesMixin:
)
def _refresh_if_stale(self, output_path, record):
"""Ask for a background walk when the listing is stale or dirty.
"""Walk a stale listing again, REFRESH_DELAY after this read (on the loop).
A stale listing (older than REFRESH_AFTER) is walked REFRESH_DELAY
after this read, so the walk does not share the GIL with the page's
own requests; a dirty one as soon as its interval allows.
At most one pending refresh per root, none while a walk runs.
"""
age = _time.monotonic() - (record.walked_at or 0.0)
if age >= gallery_listing.REFRESH_AFTER:
self._schedule_walk(output_path, gallery_listing.REFRESH_DELAY)
elif record.dirty:
self._schedule_walk(output_path)
def output_settled(self, path):
"""The monitor settled an output at *path*: list it (on the loop).
*path* is resolved by the monitor's thread (``notify_output_settled``).
A root with a stored listing and no walk running gets the file added
in the executor (``_run_adds``), without a walk. Every other root
holding it is marked dirty (``mark_root_dirty``): one with a walk
running gets the trailing walk, as that walk may have passed the
file's folder already.
"""
for key, record in self._settled_records(path):
relative = None
if (
record.files is not None
and record.flight is None
and record.resolved_root is not None
):
relative = gallery_listing.relative_part(record.resolved_root, path)
if relative is None:
self._mark_dirty(key, record)
continue
record.pending_adds.append(relative)
if record.add_task is None:
task = asyncio.get_running_loop().create_task(
self._run_adds(Path(key), record)
)
record.add_task = task
self._listing_tasks.add(task)
task.add_done_callback(self._listing_tasks.discard)
def mark_root_dirty(self, path):
"""A settled output at *path*: walk every root holding it again (on the loop).
*path* is resolved by the monitor's thread (``notify_output_settled``),
so this does no file I/O: it compares ``normcase`` strings with each
walk's ``resolved_root``. Only roots with a listing, or a walk running,
are marked; a root without either is walked by its next read anyway.
"""
for key, record in self._settled_records(path):
self._mark_dirty(key, record)
def _settled_records(self, path):
"""``(key, record)`` of every listed or walking root holding *path*."""
if self._listings_closed:
return []
found = []
for key, record in list(self._root_listings.items()):
if record.retired or (record.files is None and record.flight is None):
continue
resolved = record.resolved_root or os.path.normcase(key)
if gallery_listing.root_contains(resolved, path):
found.append((key, record))
return found
def _mark_dirty(self, key, record):
if self._listings_closed or record.retired:
if age < gallery_listing.REFRESH_AFTER:
return
if record.files is None and record.flight is None:
return # walked by its next read anyway
record.dirty = True
self._schedule_walk(Path(key))
async def _run_adds(self, root, record):
"""Add the root's pending settled outputs to its listing (on the loop).
Each batch is placed in the executor against the stored listing, and
stored only when no walk started and no drop happened meanwhile: a
walk started after the outputs settled lists them itself, and a
dropped listing is walked by its next read. An output that cannot be
placed exactly marks the root dirty instead.
"""
key = str(root)
try:
while record.pending_adds:
relatives, record.pending_adds = record.pending_adds, []
if record.files is None or record.flight is not None:
continue # dropped, or a walk started after they settled
base, generation, seq = record.files, record.generation, record.walk_seq
files, folders, with_ancestors, unresolved = (
await self._run_in_executor(
_patch_listing,
root,
base,
record.folders,
record.folders_with_ancestors,
relatives,
)
)
if self._listings_closed or record.retired:
return
if record.generation != generation or record.walk_seq != seq:
continue
if files is not base:
record.files = files
record.folders = folders
record.folders_with_ancestors = with_ancestors
record.patched += 1
self._schedule_workspace_warm()
if unresolved:
self._mark_dirty(key, record)
except asyncio.CancelledError:
raise
except Exception as exc:
self.logger.warning(f"Could not add a new output to the listing: {exc}")
record.pending_adds = []
self._mark_dirty(key, record)
finally:
record.add_task = None
def _schedule_walk(self, output_path, delay=0.0):
"""Start or defer a background walk of *output_path* (on the loop).
- A walk is running: nothing; its end re-checks ``dirty``.
- Otherwise the walk is due *delay* from now, and never sooner than
``rewalk_interval`` after the previous walk started. Due now: start
it. Later: one deferred walk per root, at least MIN_REWALK_DELAY
away; when one is already pending, the earlier deadline wins.
Every walk start cancels the pending one (``_start_walk``).
"""
if self._listings_closed:
if record.flight is not None or record.rewalk_handle is not None:
return
record = self._root_listing(output_path)
if record.flight is not None or record.retired:
return
now = _time.monotonic()
due = now + delay
if record.last_started_at is not None:
interval = gallery_listing.rewalk_interval(record.last_duration)
due = max(due, record.last_started_at + interval)
if due <= now:
self._start_walk(output_path, record)
return
wait = max(gallery_listing.MIN_REWALK_DELAY, due - now)
loop = asyncio.get_running_loop()
handle = record.rewalk_handle
if handle is not None:
if handle.when() - loop.time() <= wait:
return # the pending walk comes first
handle.cancel()
record.rewalk_handle = loop.call_later(
wait, self._delayed_walk, output_path, record
record.rewalk_handle = asyncio.get_running_loop().call_later(
gallery_listing.REFRESH_DELAY, self._delayed_walk, output_path, record
)
def _delayed_walk(self, output_path, record):
"""The deferred walk's timer: clear the handle first, then walk.
It was armed for a stale or dirty listing at the earliest moment the
interval allowed. Only a walk start clears either reason, and every
start cancels this timer, so when it fires the walk is still due.
"""
record.rewalk_handle = None
if self._listings_closed or record.flight is not None or record.retired:
return
self._start_walk(output_path, record)
self._schedule_walk(output_path)
def _schedule_walk(self, output_path):
"""Start a background walk of *output_path* unless one is running."""
record = self._root_listing(output_path)
if record.flight is None:
self._start_walk(output_path, record)
def _start_walk(self, output_path, record):
"""Start the root's one walk; returns its flight future (on the loop)."""
@@ -1726,11 +1462,7 @@ class ImageRoutesMixin:
self._listing_loop = loop
handle, record.rewalk_handle = record.rewalk_handle, None
if handle is not None:
handle.cancel() # this walk is the one it was waiting to start
# This walk lists every output reported so far.
record.dirty = False
record.pending_adds = []
record.last_started_at = _time.monotonic()
handle.cancel() # this walk is the refresh it was waiting to start
record.walk_seq += 1
flight = loop.create_future()
record.flight = flight
@@ -1750,14 +1482,11 @@ class ImageRoutesMixin:
error never reaches a future nobody awaits.
"""
outcome = None
cancelled = False
started = record.last_started_at
try:
files, folders, with_ancestors, resolved = await self._run_in_executor(
files, folders, with_ancestors = await self._run_in_executor(
walk_root, self._scan_gallery_files_sync, output_path, record.files
)
except asyncio.CancelledError:
cancelled = True
outcome = WalkOutcome.failed(RuntimeError(WALK_STOPPED))
raise
except Exception as exc:
@@ -1767,15 +1496,11 @@ class ImageRoutesMixin:
f"Could not list output folder {output_path}: {exc}"
)
record.failing = True
# Retried by the 30 s rule, not by a trailing walk.
record.dirty = False
outcome = WalkOutcome.failed(exc)
else:
record.failing = False
record.resolved_root = resolved
if record.generation == generation:
changed = files is not record.files
record.patched = 0
record.files = files
record.folders = folders
record.folders_with_ancestors = with_ancestors
@@ -1787,14 +1512,10 @@ class ImageRoutesMixin:
else:
outcome = WalkOutcome.discarded()
finally:
record.last_duration = _time.monotonic() - started
if record.flight is flight:
record.flight = None
if not flight.done():
flight.set_result(outcome)
if record.dirty and not cancelled:
# An output settled during this walk: the trailing walk.
self._schedule_walk(output_path)
async def get_output_images(self, request):
"""Get all images from ComfyUI output folder(s)."""
@@ -1923,10 +1644,6 @@ class ImageRoutesMixin:
cached = self._workspace_listed_cache.get(key)
if cached is not None and cached[0] is files:
return cached[1]
if cached is not None:
delta = gallery_listing.listing_delta(cached[0], files)
if delta is not None:
return self._workspace_listed_step(key, cached, files, delta)
previous = cached[2] if cached is not None else {}
listed = []
for path, mtime in files:
@@ -1946,42 +1663,7 @@ class ImageRoutesMixin:
self._workspace_card_cache[key] = {
file: facts for file, facts in cards.items() if file in lookup
}
self._workspace_listed_cache[key] = (files, listed, lookup, None)
return listed
def _workspace_listed_step(self, key, cached, files, delta) -> list:
"""The _Listed values after a few files were added or replaced.
A listing patched by a settled output (``output_settled``) shares
every other entry with the cached one, so only the added files are
built; the order of the values does not matter (every sort is a total
order). The step ``(previous values, added, removed)`` lets the sort
orders follow it the same way (``_workspace_orders``).
"""
index, root = key[0], Path(key[1])
_files, old_listed, old_lookup, _step = cached
added, removed = delta
if not added and not removed:
self._workspace_listed_cache[key] = (files, old_listed, old_lookup, None)
return old_listed
lookup = dict(old_lookup)
gone = [lookup.pop(item) for item in removed if item in lookup]
fresh = [_listed(path, mtime, root, index) for path, mtime in added]
for entry in fresh:
lookup[(entry.path, entry.mtime)] = entry
if gone:
gone_ids = {id(entry) for entry in gone}
listed = [entry for entry in old_listed if id(entry) not in gone_ids]
cards = self._workspace_card_cache.get(key)
if cards:
self._workspace_card_cache[key] = {
file: facts for file, facts in cards.items() if file in lookup
}
else:
listed = list(old_listed)
listed.extend(fresh)
step = (old_listed, fresh, gone)
self._workspace_listed_cache[key] = (files, listed, lookup, step)
self._workspace_listed_cache[key] = (files, listed, lookup)
return listed
def _workspace_orders(self, listings: list[tuple]) -> dict:
@@ -1996,54 +1678,14 @@ class ImageRoutesMixin:
old is new for old, new in zip(cached[0], parts, strict=True)
):
return cached[1]
newest = None
if cached is not None:
newest = self._newest_step(listings, parts, cached)
if newest is None:
newest = sorted(
(entry for part in parts for entry in part),
key=_newest_key,
)
# The other orders, the wanted paths and the locators follow lazily.
newest = sorted(
(entry for part in parts for entry in part),
key=lambda entry: _workspace_key(entry, "newest"),
)
orders = {"newest": newest}
self._workspace_order_cache[key] = (parts, orders)
return orders
def _newest_step(self, listings, parts, cached) -> list | None:
"""The cached newest order with each part's last step applied, or None.
Only when every changed part is exactly the value list this call
built (*parts*) and one step (``_workspace_listed_step``) past the
part the cached order was built from: the added values are placed by
bisection instead of sorting the whole listing again. Page reads and
the warm build run in executor threads at once, so the per-file cache
may already hold a newer step than *parts*; then this sorts in full.
"""
old_parts, orders = cached
added, gone = [], []
for (index, root, _files), part, old in zip(
listings, parts, old_parts, strict=True
):
if part is old:
continue
listed = self._workspace_listed_cache.get((index, str(root)))
if listed is None or listed[1] is not part:
return None
step = listed[3]
if step is None or step[0] is not old:
return None
added.extend(step[1])
gone.extend(step[2])
newest = orders["newest"]
if gone:
gone_ids = {id(entry) for entry in gone}
newest = [entry for entry in newest if id(entry) not in gone_ids]
else:
newest = list(newest)
for entry in added:
insort(newest, entry, key=_newest_key)
return newest
def _workspace_wanted(self, listings: list[tuple]) -> frozenset:
"""The selected listing's normalized paths (cached with its orders)."""
orders = self._workspace_orders(listings)
@@ -2295,43 +1937,14 @@ 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.
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.
"""
"""Extra directories served when the LoRA integration is enabled."""
try:
from ..config import IntegrationConfig
if not IntegrationConfig.LORA_MANAGER_ENABLED:
return []
from ..lora_utils import (
get_lora_image_cache_dir,
layout_for_install,
lora_manager_layout,
)
from ..lora_utils import find_lora_directories, get_lora_image_cache_dir
lora_root = IntegrationConfig.LORA_MANAGER_PATH or ""
if lora_root.strip():
@@ -2344,13 +1957,9 @@ 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 layout.media_roots]
# Named, not made: listing and serving never create the cache.
dirs.append(Path(get_lora_image_cache_dir(create=False)))
dirs = [Path(d) for d in find_lora_directories(lora_root)]
dirs.append(Path(get_lora_image_cache_dir()))
return dirs
except Exception:
# LoRA integration is optional; never widen access on failure.
@@ -2381,21 +1990,9 @@ class ImageRoutesMixin:
return []
async def serve_output_image(self, request):
"""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.
"""
"""Serve a media file from the ComfyUI output folder(s)."""
try:
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)
roots = list(self._get_all_output_dirs())
scope_error = self._gallery_scope_error(request.query, roots)
if scope_error is not None:
return scope_error
@@ -2410,13 +2007,9 @@ class ImageRoutesMixin:
):
return _forbidden("Only media files can be served")
output_dirs = self._select_root(roots, root_param)
output_dirs = self._select_root(roots, request.query.get("root"))
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
@@ -2448,46 +2041,6 @@ 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.
@@ -3342,9 +2895,7 @@ class ImageRoutesMixin:
if error is not None:
return error
index, scope, relative = target
# 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)
roots = list(self._get_all_output_dirs())
error = self._gallery_scope_error(
{"root_scope": scope, "root": str(index)}, roots
)
@@ -3353,9 +2904,6 @@ 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(
@@ -3367,16 +2915,12 @@ class ImageRoutesMixin:
status=429,
)
try:
return await self._reveal(relative, root, linked_only=index >= len(outputs))
return await self._reveal(relative, root)
finally:
gate.leave()
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.
"""
async def _reveal(self, relative: str, root: Path):
"""Resolve and open, inside the reveal gate."""
try:
path = await self._run_in_executor(self._reveal_path, relative, root)
except (OSError, ValueError) as exc:
@@ -3387,10 +2931,6 @@ 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
@@ -3427,19 +2967,18 @@ class ImageRoutesMixin:
cached = self._favorite_listing_cache.get(cache_key)
if cached is not None and cached[0] is files:
return cached[1]
listing = None
if cached is not None:
delta = gallery_listing.listing_delta(cached[0], files)
if delta is not None:
listing = _favorite_listing_step(root, cached[1], *delta)
if listing is None:
# From the path strings, as _listed does: relative_to per file was
# the largest cost of a cold first open (#348).
prefix = os.path.join(str(root), "")
listing = {}
for path, _mtime in files:
posix = _listing_posix(root, prefix, path)
listing.setdefault(_favorite_relative_key(posix), posix)
# From the path strings, as _listed does: relative_to per file was the
# largest cost of a cold first open (#348).
prefix = os.path.join(str(root), "")
listing = {}
for path, _mtime in files:
text = str(path)
if text.startswith(prefix):
relative = text[len(prefix) :]
posix = relative if os.sep == "/" else relative.replace(os.sep, "/")
else:
posix = path.relative_to(root).as_posix()
listing.setdefault(_favorite_relative_key(posix), posix)
self._favorite_listing_cache[cache_key] = (files, listing)
return listing
@@ -3482,13 +3021,8 @@ class ImageRoutesMixin:
return set()
async def inspect_image(self, request):
"""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)
"""Read prompt evidence for one explicitly rooted physical media file."""
roots = list(self._get_all_output_dirs())
scope_error = self._gallery_scope_error(request.query, roots)
if scope_error is not None:
return scope_error
@@ -3513,8 +3047,6 @@ 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():
+8 -36
View File
@@ -234,22 +234,9 @@ class LibraryRoutesMixin:
)
def _library_media(
self,
records: list[dict],
roots: list[Path],
*,
linked: bool = True,
outputs: int | None = None,
self, records: list[dict], roots: list[Path], *, linked: bool = True
) -> 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"])
@@ -263,25 +250,10 @@ 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
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)
return self._format_workspace_page(page, linked=linked)
@pair_response
async def get_library_options(self, request: web.Request) -> web.Response:
@@ -322,13 +294,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, outputs=outputs
records[parent["id"]], roots, linked=False
)
await self._run_in_executor(previews)
@@ -370,9 +342,9 @@ class LibraryRoutesMixin:
result, records = await self._run_in_executor(read)
if result is None:
return _missing()
roots, outputs = await self._run_in_executor(self._library_roots)
roots = list(self._get_all_output_dirs())
result["positive"]["previews"] = await self._run_in_executor(
lambda: self._library_media(records, roots, outputs=outputs)
self._library_media, records, roots
)
return web.json_response({"success": True, **result})
@@ -386,7 +358,7 @@ class LibraryRoutesMixin:
archive = _archive_scope(request)
limit = _page_limit(request)
cursor = request.query.get("cursor")
roots, outputs = await self._run_in_executor(self._library_roots)
roots = list(self._get_all_output_dirs())
canonical_roots = [str(_canonical(root)) for root in roots]
def read() -> dict | None:
@@ -419,7 +391,7 @@ class LibraryRoutesMixin:
if result is None:
return _missing()
result["images"] = await self._run_in_executor(
lambda: self._library_media(result["images"], roots, outputs=outputs)
self._library_media, result["images"], roots
)
return web.json_response({"success": True, **result})
+17 -47
View File
@@ -5,12 +5,9 @@ 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, 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).
``{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).
``POST /prompt_manager/lora/import/apply {token}`` starts the job (202
``{job}``): kind ``lora_import``, titled "LoRA reference import", admission
@@ -34,7 +31,6 @@ 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 (
@@ -156,9 +152,8 @@ class LoraImportRoutesMixin:
return None, ("lora-path", _LORA_PATH_ERROR)
return allowed, None
async def stage_lora_import(self, discovery, *, on_record=None, cancelled=None):
"""Stage ``discovery``'s sidecars off the loop: records, skips and
download counts."""
async def stage_lora_import(self, files, *, on_record=None, cancelled=None):
"""Stage ``files`` 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)
@@ -171,10 +166,9 @@ class LoraImportRoutesMixin:
return await self._run_in_executor(
lambda: lora_import.stage(
discovery,
files,
cache_dir,
IntegrationConfig.CIVITAI_API_KEY,
media_extensions=SERVABLE_EXTENSIONS,
on_record=report,
cancelled=cancelled or (lambda: False),
logger=self.logger,
@@ -182,8 +176,8 @@ class LoraImportRoutesMixin:
)
async def lora_files_unchanged(self, lm_path, expected):
"""Whether LoraManager's sidecars for ``lm_path`` still fingerprint as
``expected`` (found again, so an added file counts too)."""
"""Whether the metadata files under ``lm_path`` still fingerprint as
``expected`` (listed again, so an added file counts too)."""
def current():
return lora_import.fingerprint(lora_import.discover(lm_path))
@@ -226,22 +220,8 @@ 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, SERVABLE_EXTENSIONS
)
found = await self._run_in_executor(lora_import.preview, lm_path, cache_dir)
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)
@@ -268,9 +248,7 @@ class LoraImportRoutesMixin:
return _failure("The LoRA import could not be reviewed.", 500)
token = None
expires_at = None
layout = found["layout"]
# An unusable LoraManager layout found nothing it may replace with.
if not blocked and layout["error"] is None:
if not blocked:
payload = {
"path": lm_path,
"fingerprint": found["fingerprint"],
@@ -302,9 +280,6 @@ 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,
@@ -367,8 +342,8 @@ class LoraImportRoutesMixin:
store.discard(token)
return _failure(INTEGRATION_CHANGED, 409, "plan-changed")
try:
discovery = await self._run_in_executor(lora_import.discover, lm_path)
now = await self._run_in_executor(lora_import.fingerprint, discovery)
files = await self._run_in_executor(lora_import.discover, lm_path)
now = await self._run_in_executor(lora_import.fingerprint, files)
backup = self._backup_promised(plan)
_disk, short, _size = await self._run_in_executor(
disk_plan, db.model.db_path, backup=backup, vacuum=False
@@ -392,14 +367,9 @@ class LoraImportRoutesMixin:
lora_import.JOB_TITLE,
admission="records",
cancellable=True,
scope={
"operation": LORA_OPERATION,
"lora_files": len(discovery.sidecars),
},
scope={"operation": LORA_OPERATION, "lora_files": len(files)},
groups=lora_import.GROUPS,
runner=lambda job: self._run_lora_import(
job, db, plan, discovery, backup
),
runner=lambda job: self._run_lora_import(job, db, plan, files, backup),
)
except JobBusy as busy:
return busy_response(busy)
@@ -408,8 +378,8 @@ class LoraImportRoutesMixin:
# ── the job ───────────────────────────────────────────────────────
async def _run_lora_import(self, job, db, plan, discovery, backup_on):
total = len(discovery.sidecars)
async def _run_lora_import(self, job, db, plan, files, backup_on):
total = len(files)
job.update(total=total, status_line="Preparing LoRA records")
job.log_line(f"Found {_count(total, 'LoRA metadata file')}")
@@ -423,7 +393,7 @@ class LoraImportRoutesMixin:
try:
staged = await self.stage_lora_import(
discovery, on_record=on_record, cancelled=lambda: job.cancelled
files, on_record=on_record, cancelled=lambda: job.cancelled
)
except lora_import.ImportCancelled:
job.log_line(f"{CANCELLED} {CACHE_NOTE}")
+7 -25
View File
@@ -50,15 +50,6 @@ _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."""
@@ -407,19 +398,13 @@ class LoraIntegrationMixin:
return
if isinstance(error, PlanChanged):
message = _IMPORTS_CHANGED
elif isinstance(error, (LoraFilesChanged, LoraSettingsUnusable)):
elif isinstance(error, LoraFilesChanged):
message = str(error)
else:
message = self._public_error(error)
if isinstance(
error,
(
PlanChanged,
LoraFilesChanged,
LoraSettingsUnusable,
EmptyReplacement,
PairDependencyError,
),
(PlanChanged, LoraFilesChanged, EmptyReplacement, PairDependencyError),
):
self.logger.warning(f"LoRA scan refused: {message}")
else:
@@ -447,11 +432,8 @@ class LoraIntegrationMixin:
"progress": 2,
}
)
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)
files = await self._run_in_executor(lora_import.discover, lm_path)
total = len(files)
await send_progress(
{
"type": "progress",
@@ -461,7 +443,7 @@ class LoraIntegrationMixin:
}
)
reviewed = await self._run_in_executor(lora_import.fingerprint, discovery)
reviewed = await self._run_in_executor(lora_import.fingerprint, files)
events = asyncio.Queue()
state = await self._run_in_executor(
self.db.category_import_state, lora_import.LORA_CATEGORY
@@ -485,7 +467,7 @@ class LoraIntegrationMixin:
)
if not existing:
counts["imported"] += 1
status = record["name"] if record else sidecars[index].metadata_path.name
status = record["name"] if record else files[index].name
if record and record["images"]:
status += f" ({len(record['images'])} images)"
await send_progress(
@@ -508,7 +490,7 @@ class LoraIntegrationMixin:
stop = threading.Event()
staging = asyncio.ensure_future(
self.stage_lora_import(
discovery,
files,
on_record=on_record,
cancelled=lambda: stop.is_set() or disconnected(),
)
+53 -103
View File
@@ -1,26 +1,18 @@
"""The staged LoRA reference import (#221, #249 System S10, #355).
"""The staged LoRA reference import (#221, #249 System S10).
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:
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:
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 ``<base>.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
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
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).
@@ -32,15 +24,9 @@ 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 AbstractSet, Callable, Dict, List, Optional
from typing import 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
@@ -51,44 +37,35 @@ 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: Optional[str]) -> Discovery:
"""The sidecars of every LoRA whose model exists inside a LoRA root.
def discover(lora_manager_path: str) -> List[Path]:
"""Every LoRA metadata file, sorted and without repeats.
``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.
Raises whatever listing a folder raised: a discovery that cannot see
every folder must not replace the collection with part of it.
"""
return discover_sidecars(lora_utils.lora_manager_layout(lora_manager_path))
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
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
def fingerprint(files: List[Path]) -> str:
"""What a review saw of ``files``: each path, size and modification time."""
digest = hashlib.sha256()
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
for path in files:
try:
stat = path.stat()
entry = f"{path}\0{stat.st_size}\0{stat.st_mtime_ns}\n"
@@ -98,48 +75,31 @@ def fingerprint(discovery: Discovery) -> str:
return "sha256:" + digest.hexdigest()
def preview(
lora_manager_path: Optional[str],
cache_dir: Path,
media_extensions: AbstractSet[str],
) -> Dict:
def preview(lora_manager_path: str, cache_dir: Path) -> Dict:
"""The review's counts (blocking; reads only, requests nothing).
``{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``).
``{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.
"""
discovery = discover(lora_manager_path)
layout = discovery.layout
files = discover(lora_manager_path)
with_metadata = 0
with_local_media = 0
planned = 0
names = []
for sidecar in discovery.sidecars:
metadata = lora_utils.read_lora_metadata(sidecar.metadata_path)
for path in files:
metadata = lora_utils.read_lora_metadata(path)
if not metadata:
continue
with_metadata += 1
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
)
planned += lora_utils.planned_civitai_downloads(metadata, path, cache_dir)
if len(names) < 50:
names.append(lora_utils.get_model_name_from_metadata(metadata))
return {
"lora_files": discovery.scanned,
"lora_files": len(files),
"with_metadata": with_metadata,
"skipped": discovery.scanned - with_metadata,
"with_local_media": with_local_media,
"planned_downloads": planned,
"fingerprint": fingerprint(discovery),
"fingerprint": fingerprint(files),
"names": names,
"layout": layout_summary(layout, Path.home()),
}
@@ -171,45 +131,35 @@ class StagedImport:
def stage(
discovery: Discovery,
files: List[Path],
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 sidecar and build its record with its images.
"""Read each file and build its record, downloading its example images.
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.
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.
"""
sidecars = discovery.sidecars
staged = StagedImport(total=len(sidecars))
staged.skipped.extend(discovery.unreadable)
for index, sidecar in enumerate(sidecars):
staged = StagedImport(total=len(files))
for index, path in enumerate(files):
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 = local_media(
sidecar, metadata, discovery.layout, media_extensions
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
)
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)
-602
View File
@@ -1,602 +0,0 @@
"""Where LoraManager keeps its files (#355).
LoraManager (``custom_nodes/comfyui-lora-manager``) stores one
``<name>.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,
}
+96 -97
View File
@@ -13,7 +13,6 @@ import json
import os
import re
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
@@ -28,16 +27,6 @@ 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 ────────────────────────────────────────────
@@ -245,22 +234,23 @@ def resolve_lora_manager_path(path) -> Optional[str]:
# ── Metadata reading ─────────────────────────────────────────────────
def default_lora_roots() -> List[str]:
"""The LoRA folders ComfyUI knows, without LoraManager's settings.
def find_lora_directories(lora_manager_path: str) -> List[str]:
"""Find directories that contain LoRA models (with .metadata.json files).
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.
Searches: ComfyUI models/loras, extra_model_paths.yaml lora dirs,
and the LoraManager extension dir itself.
"""
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()))
@@ -277,28 +267,13 @@ def default_lora_roots() -> 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 = []
@@ -404,6 +379,46 @@ 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
@@ -822,16 +837,30 @@ 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"<lora:([^:>]+):[^>]+>", 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.
@@ -842,81 +871,52 @@ 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:
"""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.
"""Scan LoRA metadata files and build the trigger word mapping.
Returns:
Number of LoRAs with trigger words in the map.
Number of LoRAs with trigger words found.
"""
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)
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 <lora:NAME:weight>)
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
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 <lora:NAME:weight>)
# 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,7 +926,6 @@ class TriggerWordCache:
with self._lock:
self._cache.clear()
self._loaded = False
self._failed_at = None
# Module-level singleton
+1 -1
View File
@@ -3,7 +3,7 @@ name = "promptmanager"
description = "A powerful ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, and an automatic image gallery system using SQLite."
version = "3.3.1"
license = {file = "LICENSE"}
requires-python = ">=3.12"
requires-python = ">=3.10"
dependencies = ["watchdog>=2.1.0", "Pillow>=8.0.0"]
[build-system]
-13
View File
@@ -166,20 +166,7 @@ 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()
@@ -270,8 +270,7 @@ 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: [['LoraManager', 'environment settings · alongside storage · library default'],
['Scope', plan.scope.label], ['Found', '2 metadata files · 2 LoRAs · 0 with local images'],
grid: { ...reference.grid, rows: [['Scope', plan.scope.label], ['Found', '2 metadata files · 2 readable'],
['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']] },
@@ -313,7 +312,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, preview: false });
await fakeLoraManager(request, { loras: 2, civitai: true });
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`) });
@@ -325,15 +324,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, preview: false });
await fakeLoraManager(request, { loras: 2, civitai: true });
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')[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'; });
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'; });
const undoGrid = await mutate(page, { css: 'dialog#modal dl.meta-grid { column-gap: 3px; }' });
expect(await paths()).toEqual(expect.arrayContaining(['grid.columnGap']));
await undoGrid();
+10 -15
View File
@@ -257,18 +257,6 @@ async function chooseRestore(page, file) {
await page.locator('#restoreFileInput').setInputFiles(file);
}
// Confirms the chosen restore and waits for the server's answer, not for a timer: a restore is disk-bound and can take
// longer than an expect timeout on a slow runner (#359). The modal closes only after that answer.
async function confirmRestoreAndWait(page) {
const restored = page.waitForResponse(response => response.url().endsWith('/prompt_manager/restore'));
await page.locator('#confirmRestore').click();
const response = await restored;
const body = await response.text();
expect(response.status(), body).toBe(200);
expect(JSON.parse(body)).toMatchObject({ success: true, prompt_count: 2, backup_created: expect.any(String) });
await expect(page.locator('#restoreModal')).toBeHidden();
}
function confirmNextDialog(page, accept, message) {
page.once('dialog', async dialog => {
expect(dialog.type()).toBe('confirm');
@@ -299,7 +287,12 @@ test('downloaded backup restores deleted prompts, tags, and image links through
expect(await promptSnapshot(request)).not.toEqual(before);
await chooseRestore(page, backup);
confirmNextDialog(page, true, backup.name);
await confirmRestoreAndWait(page);
const restored = page.waitForResponse(response => response.url().endsWith('/prompt_manager/restore'));
await page.locator('#confirmRestore').click();
const response = await restored;
expect(response.ok()).toBe(true);
expect(await response.json()).toMatchObject({ success: true, prompt_count: 2, backup_created: expect.any(String) });
await expect(page.locator('#restoreModal')).toBeHidden();
await expect(page.locator('.prompt-text')).toHaveCount(2);
await page.reload();
await expect(page.locator('.prompt-text').filter({ hasText: 'Cat portrait' })).toBeVisible();
@@ -342,7 +335,8 @@ test('restore validation and cancellation preserve live data and allow a valid r
expect(await promptSnapshot(request)).toEqual(before);
await page.locator('#restoreFileInput').setInputFiles(backup);
confirmNextDialog(page, true, backup.name);
await confirmRestoreAndWait(page);
await page.locator('#confirmRestore').click();
await expect(page.locator('#restoreModal')).toBeHidden();
expect(await promptSnapshot(request)).toEqual(before);
});
@@ -362,7 +356,8 @@ test('backup and restore recover from interrupted network requests without losin
await expect(page.locator('#restoreModal')).toBeVisible();
expect(await promptSnapshot(request)).toEqual(before);
confirmNextDialog(page, true, backup.name);
await confirmRestoreAndWait(page);
await page.locator('#confirmRestore').click();
await expect(page.locator('#restoreModal')).toBeHidden();
expect(await promptSnapshot(request)).toEqual(before);
});
@@ -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, preview: false, previous: 2 });
await lora(request, { loras: 3, civitai: true, previous: 2 });
const previews = [];
const applies = [];
page.on('request', item => {
@@ -339,17 +339,13 @@ 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 LoRAs · 0 with local images'],
['Found', '3 metadata files · 3 readable'],
['Protected', 'Nothing is protected'],
['Network', 'Downloads up to 3 example images from CivitAI'],
['Backup', 'Create and verify before changes (browser.db.backup_<date and time>)'],
['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');
@@ -386,7 +382,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))[3]).toEqual(['Protected', '1 imported prompt used by saved pairs']);
await expect.poll(async () => (await gridOf(page))[2]).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']);
@@ -394,51 +390,6 @@ 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);
@@ -457,7 +408,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, preview: false, previous: 1 });
await lora(request, { loras: 2, civitai: true, previous: 1 });
await openIntegrations(page);
await enable(page);
const before = await serverJson(request, '/__test__/integrations/state');
@@ -7,18 +7,13 @@ 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, 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 ``<stem>.safetensors`` model beside its
``<stem>.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/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/gate`` ``{hold}`` holds every CivitAI
download until it is released (``{"hold": false}``), so a test sees the job
while it downloads;
@@ -35,7 +30,6 @@ test ever opens a network connection.
import io
import json
import os
import shutil
import threading
from pathlib import Path
@@ -97,11 +91,7 @@ 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
@@ -128,17 +118,11 @@ 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)
@@ -157,10 +141,8 @@ 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))
if preview:
(loras / f"{stem}.png").write_bytes(_png())
(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(
+4 -31
View File
@@ -108,17 +108,13 @@ 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: 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 },
const plan = { lora_files: 3, with_metadata: 2, current_imports: 5, blocked_by_pairs: 0, planned_downloads: 4,
scope: { count: 2, label: 'Replace 5 imported prompts with 2 LoRAs' }, protected: { count: 0, reasons: [] },
backup: { policy: 'on', destination_label: 'prompts.db.backup_<date and time>' }, 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', '2,221 metadata files · 615 LoRAs · 476 with local images'],
['Found', '3 metadata files · 2 readable'],
['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_<date and time>)'],
@@ -126,34 +122,11 @@ 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[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(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(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 () => {
-5
View File
@@ -841,11 +841,6 @@ 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']]);
+10 -29
View File
@@ -33,9 +33,7 @@ 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
@@ -43,9 +41,6 @@ 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):
@@ -120,16 +115,6 @@ 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
@@ -143,20 +128,16 @@ def main(argv=None):
return USAGE_EXIT
faulthandler.dump_traceback_later(per_test_limit, exit=True, file=stream)
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)
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)
if result.testsRun == 0 and not result.skipped:
return NO_TESTS_EXIT
return 0 if result.wasSuccessful() else 1
+4 -1263
View File
File diff suppressed because it is too large Load Diff
-183
View File
@@ -1,12 +1,9 @@
"""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:
@@ -981,186 +978,6 @@ 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
+18 -71
View File
@@ -70,16 +70,8 @@ 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),
"LORA_MANAGER_SETTINGS_DIR": str(self.lm_settings),
},
os.environ, {"PROMPT_MANAGER_CONFIG_PATH": str(self.config_file)}
)
self._env_patch.start()
@@ -202,8 +194,6 @@ 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:
``<stem>.metadata.json`` next to ``<stem>.safetensors`` (#355)."""
meta = {"file_name": f"{stem}.safetensors", "model_name": stem, "civitai": {}}
if words is not None:
meta["civitai"]["trainedWords"] = words
@@ -211,8 +201,7 @@ 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
]
(directory / f"{stem}.safetensors").write_bytes(b"model")
path = directory / f"{stem}.metadata.json"
path = directory / f"{stem}.safetensors.metadata.json"
path.write_text(json.dumps(meta))
return path
@@ -311,7 +300,9 @@ class TestLoraEnableRoute(LoraAPITestCase):
_write_metadata(loras, "neon", words=["glow"])
self.detect_mock.return_value = str(lm)
with patch.object(lora_utils, "default_lora_roots", return_value=[str(loras)]):
with patch.object(
lora_utils, "find_lora_directories", return_value=[str(loras)]
):
resp = await self._post_json(
"/prompt_manager/lora/enable",
{
@@ -857,7 +848,9 @@ class TestLoraScanRoute(LoraAPITestCase):
cache_dir = self.tmp_root / "img_cache"
with (
patch.object(lora_utils, "default_lora_roots", return_value=[str(loras)]),
patch.object(
lora_utils, "find_lora_directories", return_value=[str(loras)]
),
patch.object(
lora_utils, "get_lora_image_cache_dir", return_value=cache_dir
),
@@ -887,10 +880,9 @@ class TestLoraScanRoute(LoraAPITestCase):
images=["https://civitai.com/a.png", "https://evil.example/b.png"],
prompt="neon street",
)
# No local image: its CivitAI images are downloaded (#355).
(loras / "neon.png").write_bytes(_png_bytes())
_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,
@@ -918,56 +910,8 @@ 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"])), 1)
self.assertEqual(len(self.api.db.get_prompt_images(imported[0]["id"])), 2)
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()
@@ -1005,7 +949,9 @@ class TestLoraTriggerWordRoutes(LoraAPITestCase):
loras.mkdir()
_write_metadata(loras, "neon", words=["glow", "bright"])
with patch.object(lora_utils, "default_lora_roots", return_value=[str(loras)]):
with patch.object(
lora_utils, "find_lora_directories", return_value=[str(loras)]
):
resp = await self.client.request(
"GET", "/prompt_manager/lora/trigger-words?name=NEON"
)
@@ -1039,16 +985,17 @@ class TestLoraTriggerWordRoutes(LoraAPITestCase):
loras.mkdir()
_write_metadata(loras, "neon", words=["glow"])
with patch.object(lora_utils, "default_lora_roots", return_value=[str(loras)]):
with patch.object(
lora_utils, "find_lora_directories", 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 the model name and the sidecar name, which are the same
# for LoraManager's alongside layout.
self.assertEqual(data["loras_with_trigger_words"], 1)
# keyed by both the file_name stem and the metadata-file stem
self.assertEqual(data["loras_with_trigger_words"], 2)
async def test_refresh_error_is_500(self):
IntegrationConfig.LORA_MANAGER_ENABLED = True
-1
View File
@@ -188,7 +188,6 @@ class TestWorkspaceImages(ImageAPITestCase):
newest = self.api._workspace_order_cache[key][1]["newest"]
favorites = self.api._favorite_listing_cache[str(self.output_dir)]
record.walked_at -= gallery_listing.REFRESH_AFTER + 1
record.last_started_at -= gallery_listing.REFRESH_AFTER + 1
with patch.object(
self.api,
"_scan_gallery_files_sync",
-16
View File
@@ -79,21 +79,5 @@ 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()
+4 -297
View File
@@ -1,13 +1,10 @@
"""Pure helpers of the shared Images listing (py/api/gallery_listing.py, #348)."""
import ntpath
import os
import posixpath
import sys
import tempfile
import unittest
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
from types import SimpleNamespace
from pathlib import Path, PurePath, PureWindowsPath
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -19,13 +16,7 @@ from py.api.gallery_listing import ( # noqa: E402
STORED,
RootListing,
WalkOutcome,
add_folder,
folder_sets,
insert_entry,
listing_delta,
relative_part,
rewalk_interval,
root_contains,
same_listing,
walk_root,
)
@@ -132,36 +123,24 @@ class WalkRootTests(unittest.TestCase):
def test_returns_the_listing_and_both_folder_sets(self):
files = [(self.root / "sub" / "a.png", 2.0), (self.root / "b.png", 1.0)]
scan, calls = self._scan(files)
listed, folders, with_ancestors, resolved = walk_root(scan, self.root, None)
listed, folders, with_ancestors = walk_root(scan, self.root, None)
self.assertEqual(calls, [self.root])
self.assertEqual(listed, files)
self.assertEqual(folders, frozenset({"sub"}))
self.assertEqual(with_ancestors, frozenset({"sub"}))
self.assertEqual(resolved, os.path.normcase(os.path.realpath(self.root)))
def test_the_resolved_root_follows_a_symlinked_root(self):
link = Path(self._tmp.name) / "linked"
try:
os.symlink(self.root, link, target_is_directory=True)
except (OSError, NotImplementedError) as error:
self.skipTest(f"symlinks unavailable: {error}")
scan, _calls = self._scan([])
resolved = walk_root(scan, link, None)[3]
self.assertEqual(resolved, os.path.normcase(os.path.realpath(self.root)))
self.assertNotEqual(resolved, os.path.normcase(str(link)))
def test_an_unchanged_walk_returns_the_previous_list_object(self):
files = [(self.root / "sub" / "a.png", 2.0)]
previous = list(files)
scan, _calls = self._scan(files)
listed, _folders, _ancestors, _resolved = walk_root(scan, self.root, previous)
listed, _folders, _ancestors = walk_root(scan, self.root, previous)
self.assertIs(listed, previous)
def test_a_changed_walk_returns_the_new_list(self):
previous = [(self.root / "sub" / "a.png", 2.0)]
files = [(self.root / "sub" / "a.png", 3.0)]
scan, _calls = self._scan(files)
listed, _folders, _ancestors, _resolved = walk_root(scan, self.root, previous)
listed, _folders, _ancestors = walk_root(scan, self.root, previous)
self.assertIsNot(listed, previous)
self.assertEqual(listed, files)
@@ -201,278 +180,6 @@ class RecordTests(unittest.TestCase):
self.assertEqual(gallery_listing.REFRESH_DELAY, 1.0)
self.assertIsNone(RootListing().rewalk_handle)
def test_a_new_record_is_clean_and_never_walked(self):
record = RootListing()
self.assertFalse(record.dirty)
self.assertIsNone(record.resolved_root)
self.assertIsNone(record.last_started_at)
self.assertIsNone(record.last_duration)
def test_drop_forgets_pending_adds(self):
record = RootListing(files=[], pending_adds=["a.png"], patched=2)
record.drop()
self.assertEqual((record.pending_adds, record.patched), ([], 0))
def test_drop_keeps_the_resolved_root_and_the_walk_timing(self):
# A drop clears the listing, not what is known about the root itself.
record = RootListing(resolved_root="/o", last_started_at=1.0)
record.last_duration = 0.5
record.dirty = True
record.drop()
self.assertEqual(record.resolved_root, "/o")
self.assertEqual((record.last_started_at, record.last_duration), (1.0, 0.5))
self.assertTrue(record.dirty)
class RewalkIntervalTests(unittest.TestCase):
def test_constants(self):
self.assertEqual(
(
gallery_listing.REWALK_FLOOR,
gallery_listing.REWALK_FACTOR,
gallery_listing.REWALK_CAP,
gallery_listing.MIN_REWALK_DELAY,
),
(5, 5, 15, 0.5),
)
def test_short_walks_wait_the_floor(self):
self.assertEqual(rewalk_interval(0.2), 5)
self.assertEqual(rewalk_interval(0), 5)
self.assertEqual(rewalk_interval(None), 5) # never walked
def test_the_interval_scales_with_the_last_walk(self):
self.assertEqual(rewalk_interval(1.0), 5)
self.assertEqual(rewalk_interval(2.0), 10)
self.assertAlmostEqual(rewalk_interval(2.5), 12.5)
def test_slow_walks_are_capped(self):
self.assertEqual(rewalk_interval(3.0), 15)
self.assertEqual(rewalk_interval(60.0), 15)
def test_the_constants_are_read_when_called(self):
with (
patch.object(gallery_listing, "REWALK_FLOOR", 0),
patch.object(gallery_listing, "REWALK_CAP", 0),
):
self.assertEqual(rewalk_interval(2.0), 0)
class RootContainsTests(unittest.TestCase):
def test_posix_paths_under_the_root_match(self):
root = posixpath.normcase("/srv/out")
self.assertTrue(root_contains(root, "/srv/out/a.png", posixpath))
self.assertTrue(root_contains(root, "/srv/out/sub/deep/a.png", posixpath))
def test_a_sibling_sharing_the_prefix_does_not_match(self):
self.assertFalse(root_contains("/srv/out", "/srv/out2/a.png", posixpath))
self.assertFalse(root_contains("/srv/out", "/srv/outside.png", posixpath))
self.assertFalse(root_contains("/srv/out", "/srv/other/a.png", posixpath))
def test_posix_keeps_case(self):
self.assertFalse(root_contains("/srv/out", "/srv/OUT/a.png", posixpath))
def test_the_root_itself_is_not_an_output(self):
self.assertFalse(root_contains("/srv/out", "/srv/out", posixpath))
def test_windows_folds_case_and_separators(self):
root = ntpath.normcase("C:\\ComfyUI\\Output")
self.assertTrue(root_contains(root, "c:\\comfyui\\output\\a.png", ntpath))
self.assertTrue(root_contains(root, "C:/ComfyUI/OUTPUT/sub/a.PNG", ntpath))
self.assertFalse(root_contains(root, "C:\\ComfyUI\\Output2\\a.png", ntpath))
self.assertFalse(root_contains(root, "D:\\ComfyUI\\Output\\a.png", ntpath))
def test_a_windows_drive_root_and_a_unc_share(self):
drive = ntpath.normcase("E:\\")
self.assertTrue(root_contains(drive, "e:\\x.png", ntpath))
self.assertFalse(root_contains(drive, "F:\\x.png", ntpath))
share = ntpath.normcase("\\\\Server\\Share\\Out")
self.assertTrue(root_contains(share, "\\\\server\\share\\out\\a.png", ntpath))
self.assertFalse(root_contains(share, "\\\\server\\share\\a.png", ntpath))
def test_a_windows_extended_length_prefix_is_ignored(self):
root = ntpath.normcase("C:\\ComfyUI\\Output")
long_file = "\\\\?\\C:\\ComfyUI\\Output\\deep\\a.png"
self.assertTrue(root_contains(root, long_file, ntpath))
prefixed_root = ntpath.normcase("\\\\?\\C:\\ComfyUI\\Output")
self.assertTrue(
root_contains(prefixed_root, "C:\\ComfyUI\\Output\\a.png", ntpath)
)
share = ntpath.normcase("\\\\Server\\Share\\Out")
unc_file = "\\\\?\\UNC\\server\\share\\out\\a.png"
self.assertTrue(root_contains(share, unc_file, ntpath))
self.assertFalse(
root_contains(root, "\\\\?\\C:\\ComfyUI\\Output2\\a.png", ntpath)
)
def test_posix_keeps_a_literal_question_mark_folder(self):
self.assertFalse(root_contains("/srv/out", "//?/srv/out/a.png", posixpath))
def test_the_default_is_this_platforms_rule(self):
root = os.path.normcase(os.path.realpath(tempfile.gettempdir()))
inside = os.path.join(root, "x.png")
self.assertTrue(root_contains(root, inside))
self.assertTrue(root_contains(root, inside.upper()) == (os.name == "nt"))
if __name__ == "__main__":
unittest.main()
class RelativePartTests(unittest.TestCase):
def test_posix_keeps_the_case_and_the_nested_folders(self):
self.assertEqual(
relative_part("/srv/out", "/srv/out/Day 1/A.png", posixpath),
"Day 1/A.png",
)
def test_posix_outside_the_root_a_sibling_or_the_root_itself_is_none(self):
for path in ("/srv/other/a.png", "/srv/out2/a.png", "/srv/out", "/srv/out/"):
self.assertIsNone(relative_part("/srv/out", path, posixpath), path)
def test_a_root_spelled_with_a_trailing_separator(self):
self.assertEqual(relative_part("/", "/a.png", posixpath), "a.png")
def test_windows_matches_folded_and_slices_the_original_case(self):
root = ntpath.normcase("C:\\ComfyUI\\Output")
self.assertEqual(
relative_part(root, "c:/COMFYUI/output/Sub/ComfyUI_001.PNG", ntpath),
"Sub\\ComfyUI_001.PNG",
)
def test_windows_extended_length_and_unc_forms(self):
root = ntpath.normcase("C:\\Out")
self.assertEqual(
relative_part(root, "\\\\?\\C:\\Out\\Deep\\a.png", ntpath),
"Deep\\a.png",
)
share = ntpath.normcase("\\\\Server\\Share\\Out")
self.assertEqual(
relative_part(share, "\\\\?\\UNC\\Server\\Share\\Out\\B.png", ntpath),
"B.png",
)
drive = ntpath.normcase("D:\\")
self.assertEqual(relative_part(drive, "D:\\X\\a.png", ntpath), "X\\a.png")
def test_a_case_fold_that_changes_the_length_is_not_sliced(self):
# str.lower folds the Turkish dotted I to two characters (Windows'
# own normcase keeps the length; the guard covers any fold that not).
folding = SimpleNamespace(
sep="\\", normcase=lambda text: text.replace("/", "\\").lower()
)
path = "C:\\Out\\\u0130mage.png"
self.assertNotEqual(len(folding.normcase(path)), len(path))
self.assertIsNone(relative_part("c:\\out", path, folding))
self.assertEqual(relative_part("c:\\out", "C:\\Out\\Ab.png", folding), "Ab.png")
def test_dot_parts_are_never_sliced(self):
for path in ("/srv/out/../x.png", "/srv/out/./x.png", "/srv/out//x.png"):
self.assertIsNone(relative_part("/srv/out", path, posixpath), path)
class InsertEntryTests(unittest.TestCase):
def setUp(self):
self.root = PurePosixPath("/out")
self.files = [
(self.root / "c.png", 30.0),
(self.root / "b.png", 20.0),
(self.root / "a.png", 10.0),
]
def test_newest_first_position_at_the_front_middle_and_end(self):
for mtime, index in ((40.0, 0), (25.0, 1), (15.0, 2), (5.0, 3)):
entry = (self.root / "n.png", mtime)
patched = insert_entry(self.files, entry, 100)
self.assertIs(patched[index], entry)
self.assertEqual(len(patched), 4)
self.assertEqual(len(self.files), 3) # never changed in place
def test_unchanged_entries_are_the_same_objects(self):
patched = insert_entry(self.files, (self.root / "n.png", 40.0), 100)
self.assertTrue(all(new is old for new, old in zip(patched[1:], self.files)))
def test_the_same_path_with_a_new_mtime_moves(self):
patched = insert_entry(self.files, (PurePosixPath("/out/a.png"), 35.0), 100)
self.assertEqual(
[(str(path), mtime) for path, mtime in patched],
[("/out/a.png", 35.0), ("/out/c.png", 30.0), ("/out/b.png", 20.0)],
)
def test_an_entry_already_listed_returns_the_listing_itself(self):
self.assertIs(
insert_entry(self.files, (PurePosixPath("/out/b.png"), 20.0), 3), self.files
)
def test_a_tie_in_mtime_is_not_placed(self):
self.assertIsNone(insert_entry(self.files, (self.root / "n.png", 20.0), 100))
def test_a_tie_with_the_replaced_entry_itself_is_placed(self):
files = [(self.root / "b.png", 20.0), (self.root / "a.png", 10.0)]
patched = insert_entry(files, (self.root / "a.png", 20.5), 100)
self.assertEqual([mtime for _path, mtime in patched], [20.5, 20.0])
def test_the_cap_refuses_a_new_path_but_replaces_a_listed_one(self):
self.assertIsNone(insert_entry(self.files, (self.root / "n.png", 40.0), 3))
patched = insert_entry(self.files, (self.root / "a.png", 40.0), 3)
self.assertEqual(str(patched[0][0]), "/out/a.png")
def test_an_empty_listing(self):
entry = (self.root / "n.png", 1.0)
self.assertEqual(insert_entry([], entry, 100), [entry])
class AddFolderTests(unittest.TestCase):
def test_a_new_nested_folder_adds_it_and_its_ancestors(self):
root = PurePath("/out")
folders, with_ancestors = folder_sets(root, [(root / "x" / "a.png", 1.0)])
new = add_folder(root, root / "y" / "z" / "b.png", folders, with_ancestors)
self.assertEqual(
new,
folder_sets(
root, [(root / "x" / "a.png", 1.0), (root / "y" / "z" / "b.png", 2.0)]
),
)
def test_a_known_folder_or_the_root_keeps_the_same_sets(self):
root = PurePath("/out")
sets = folder_sets(root, [(root / "x" / "a.png", 1.0)])
for path in (root / "x" / "b.png", root / "c.png"):
new = add_folder(root, path, *sets)
self.assertIs(new[0], sets[0])
self.assertIs(new[1], sets[1])
def test_windows_spelling_matches_folder_sets(self):
root = PureWindowsPath("C:/Out")
path = root / "A" / "B" / "c.png"
self.assertEqual(
add_folder(root, path, frozenset(), frozenset()),
folder_sets(root, [(path, 1.0)]),
)
class ListingDeltaTests(unittest.TestCase):
def setUp(self):
self.old = [(PurePath(f"/out/{i}.png"), float(i)) for i in range(10)]
def test_an_added_entry(self):
entry = (PurePath("/out/n.png"), 99.0)
self.assertEqual(listing_delta(self.old, [entry, *self.old]), ([entry], []))
def test_a_replaced_entry(self):
entry = (PurePath("/out/3.png"), 99.0)
new = insert_entry(self.old, entry, 100)
self.assertEqual(listing_delta(self.old, new), ([entry], [self.old[3]]))
def test_the_same_listing_has_no_change(self):
self.assertEqual(listing_delta(self.old, list(self.old)), ([], []))
def test_a_walk_builds_new_entries_and_is_past_the_limit(self):
walked = [(PurePath(str(path)), mtime) for path, mtime in self.old]
self.assertEqual(walked, self.old)
self.assertIsNone(listing_delta(self.old, walked, limit=5))
self.assertIsNone(listing_delta(self.old, walked)) # shares nothing
self.assertIsNone(listing_delta(self.old, self.old[:2], limit=5))
def test_an_emptied_listing_and_a_first_file(self):
self.assertEqual(listing_delta(self.old[:1], []), ([], self.old[:1]))
self.assertIsNone(listing_delta([], self.old[:1]))
-103
View File
@@ -449,109 +449,6 @@ class TestHandlerScheduling(unittest.TestCase):
self.assertEqual(self.processed, [first, second])
class TestOutputListener(unittest.TestCase):
"""Every settled output is reported to the Images listing (#348, PR 2)."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.handler = im_mod.ImageGenerationHandler(MagicMock(), MagicMock())
self.addCleanup(self.handler.shutdown)
self.handler.processing_delay = 0
self.handler.settle_interval = 0.01
self.handler.process_new_image = lambda path, prompt_snapshot=None: None
self.reported = []
self.handler.output_listener = self.reported.append
def _image(self, name, content=b"x" * 16):
path = os.path.join(self.tmp.name, name)
with open(path, "wb") as f:
f.write(content)
return path
def test_a_settled_output_is_reported_once_with_its_resolved_path(self):
path = self._image("a.png")
self.handler._process_pending(path)
self.assertEqual(self.reported, [os.path.realpath(path)])
def test_a_symlinked_spelling_is_reported_resolved(self):
real_dir = os.path.join(self.tmp.name, "real")
os.makedirs(real_dir)
link_dir = os.path.join(self.tmp.name, "link")
try:
os.symlink(real_dir, link_dir, target_is_directory=True)
except (OSError, NotImplementedError) as error:
self.skipTest(f"symlinks unavailable: {error}")
with open(os.path.join(real_dir, "b.png"), "wb") as f:
f.write(b"x" * 16)
self.handler._process_pending(os.path.join(link_dir, "b.png"))
self.assertEqual(
self.reported, [os.path.realpath(os.path.join(real_dir, "b.png"))]
)
def test_it_is_reported_even_when_linking_raises(self):
def explode(path, prompt_snapshot=None):
raise RuntimeError("boom")
self.handler.process_new_image = explode
path = self._image("c.png")
with self.assertRaises(RuntimeError):
self.handler._process_pending(path)
self.assertEqual(self.reported, [os.path.realpath(path)])
self.assertEqual(self.handler.pending_count(), 0)
def test_an_output_that_never_settles_is_not_reported(self):
self.handler.settle_attempts = 3
self.handler._process_pending(os.path.join(self.tmp.name, "gone.png"))
empty = self._image("empty.png", content=b"")
self.handler._process_pending(empty)
self.assertEqual(self.reported, [])
def test_a_video_is_reported_when_its_extension_is_monitored(self):
self.handler.supported_extensions = (".png", ".mp4")
path = self._image("clip.mp4")
self.handler.on_created(FakeEvent(path))
self.assertTrue(wait_for(lambda: self.reported != []))
self.assertEqual(self.reported, [os.path.realpath(path)])
def test_a_failing_listener_is_logged_and_the_worker_carries_on(self):
def broken(_path):
raise RuntimeError("listing gone")
self.handler.output_listener = broken
first = self._image("d.png")
with self.assertLogs("prompt_manager.image_monitor", "WARNING") as logs:
self.handler._process_pending(first) # no exception
self.assertIn("Images listing", "\n".join(logs.output))
self.handler.output_listener = self.reported.append
second = self._image("e.png")
self.handler.schedule(second)
self.assertTrue(wait_for(lambda: self.reported != []))
self.assertEqual(self.reported, [os.path.realpath(second)])
def test_no_listener_reports_nothing(self):
self.handler.output_listener = None
self.handler._process_pending(self._image("f.png")) # no exception
self.assertEqual(self.reported, [])
def test_the_monitor_wires_running_and_later_handlers(self):
monitor = im_mod.ImageMonitor(MagicMock(), MagicMock())
listener = self.reported.append
monitor.set_output_listener(listener)
with (
patch.object(im_mod, "Observer", FakeObserver),
patch.object(im_mod.ImageMonitor, "_gallery_config", return_value=None),
):
monitor.start_monitoring([self.tmp.name])
try:
self.assertIs(monitor.handler.output_listener, listener)
monitor.set_output_listener(None)
self.assertIsNone(monitor.output_listener)
self.assertIsNone(monitor.handler.output_listener)
finally:
monitor.stop_monitoring()
class TestModuleIsolation(unittest.TestCase):
"""This module must not leave mocks behind for later test modules."""
-35
View File
@@ -8,7 +8,6 @@ import ctypes
import json
import logging
import os
import shutil
import subprocess
import sys
import tempfile
@@ -695,40 +694,6 @@ 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()
+28 -312
View File
@@ -252,7 +252,7 @@ class LoraImportAPITestCase(LoraAPITestCase):
self._patches = [
patch.object(
lora_utils, "default_lora_roots", return_value=[str(self.loras)]
lora_utils, "find_lora_directories", return_value=[str(self.loras)]
),
patch.object(
lora_utils, "get_lora_image_cache_dir", return_value=self.cache_dir
@@ -276,8 +276,6 @@ 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",
@@ -285,8 +283,8 @@ class LoraImportAPITestCase(LoraAPITestCase):
images=["https://civitai.com/a.png"],
prompt="neon street",
)
_write_metadata(self.loras, "plain", images=["https://civitai.com/p.png"])
(self.loras / "plain.png").write_bytes(_png_bytes())
(self.loras / "neon.png").write_bytes(_png_bytes())
_write_metadata(self.loras, "plain")
async def _post(self, url, body=None):
resp = await self._post_json(url, {} if body is None else body)
@@ -323,34 +321,8 @@ class LoraImportRouteTests(LoraImportAPITestCase):
self.assertEqual(resp.status, 200, plan)
self.assertEqual(
{
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,
},
{k: plan[k] for k in ("lora_files", "with_metadata", "current_imports")},
{"lora_files": 3, "with_metadata": 2, "current_imports": 2},
)
self.assertEqual(plan["blocked_by_pairs"], 0)
self.assertEqual(plan["planned_downloads"], 1)
@@ -364,95 +336,7 @@ class LoraImportRouteTests(LoraImportAPITestCase):
self.assertEqual(_size(db_path + "-shm"), shm)
self.assertEqual(_snapshot(self.api.db), rows)
self.assertNotIn(SECRET_KEY, 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))
self.assertNotIn(str(self.tmp_root), json.dumps(plan))
async def test_preview_requires_the_integration(self):
IntegrationConfig.LORA_MANAGER_ENABLED = False
@@ -474,7 +358,7 @@ class LoraImportRouteTests(LoraImportAPITestCase):
self._two_loras()
resp, plan = await self._post(PREVIEW)
rows = _snapshot(self.api.db)
meta = self.loras / "plain.metadata.json"
meta = self.loras / "plain.safetensors.metadata.json"
meta.write_text(meta.read_text() + " ")
resp, body = await self._post(APPLY, {"token": plan["token"]})
@@ -513,17 +397,7 @@ 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"])), 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.get_prompt_images(neon["id"])), 2)
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"]})
@@ -535,7 +409,7 @@ class LoraImportRouteTests(LoraImportAPITestCase):
# Legacy scan: discovery raises once the stream is open.
with patch.object(
lora_import, "discover_sidecars", side_effect=OSError("share gone")
lora_utils, "find_lora_directories", side_effect=OSError("share gone")
):
resp = await self.client.request("POST", SCAN)
events = self._sse(await resp.text())
@@ -691,7 +565,6 @@ 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())
@@ -980,13 +853,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.metadata.json"
meta = self.loras / "plain.safetensors.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.metadata.json"
meta = self.loras / "plain.safetensors.metadata.json"
await self._change_during_download(meta.unlink)
async def test_a_file_added_while_staging_fails_the_job(self):
@@ -996,7 +869,7 @@ class LoraImportRefusalTests(LoraImportAPITestCase):
self._two_loras()
before = _snapshot(self.api.db)
self.gate = threading.Event()
meta = self.loras / "plain.metadata.json"
meta = self.loras / "plain.safetensors.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))
@@ -1113,194 +986,37 @@ class LoraImportRestoreTests(LoraImportAPITestCase):
class LoraImportStageTests(unittest.TestCase):
"""The stages over a disposable LoraManager install (#355)."""
def setUp(self):
self.tmp = Path(tempfile.mkdtemp()).resolve()
self.tmp = Path(tempfile.mkdtemp())
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.loras / "a"
b = self.loras / "a" / "b"
a = self.tmp / "a"
b = self.tmp / "a" / "b"
b.mkdir(parents=True)
_write_metadata(b, "zeta")
_write_metadata(a, "alpha")
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, [])
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)
def test_fingerprint_follows_size_and_mtime(self):
meta = _write_metadata(self.loras, "one")
first = lora_import.fingerprint(self.discover())
self.assertEqual(lora_import.fingerprint(self.discover()), first)
meta = _write_metadata(self.tmp, "one")
first = lora_import.fingerprint([meta])
self.assertEqual(lora_import.fingerprint([meta]), first)
meta.write_text(meta.read_text() + " ")
self.assertNotEqual(lora_import.fingerprint(self.discover()), first)
changed = lora_import.fingerprint(self.discover())
self.assertNotEqual(lora_import.fingerprint([meta]), first)
meta.unlink()
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)
self.assertNotEqual(lora_import.fingerprint([meta]), first)
def test_stage_cancel_before_a_file_raises(self):
_write_metadata(self.loras, "one")
meta = _write_metadata(self.tmp, "one")
with self.assertRaises(lora_import.ImportCancelled):
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"})
lora_import.stage([meta], self.tmp, cancelled=lambda: True)
if __name__ == "__main__":
-858
View File
@@ -1,858 +0,0 @@
"""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()
+52 -173
View File
@@ -1263,12 +1263,11 @@ class TestTriggerWordCache(unittest.TestCase):
"file_name": "my_lora.safetensors",
"civitai": {"trainedWords": ["trigger1", "trigger2"]},
}
meta_path = Path(tmpdir) / "my_lora.metadata.json"
meta_path = Path(tmpdir) / "my_lora.safetensors.metadata.json"
meta_path.write_text(json.dumps(meta))
(Path(tmpdir) / "my_lora.safetensors").write_bytes(b"m")
# No LoraManager install: the default LoRA folders, alongside.
with patch("py.lora_utils.default_lora_roots", return_value=[tmpdir]):
# Patch find_lora_directories to return our temp dir
with patch("py.lora_utils.find_lora_directories", return_value=[tmpdir]):
count = self.cache.load(tmpdir)
self.assertTrue(self.cache.is_loaded)
@@ -1284,10 +1283,11 @@ class TestTriggerWordCache(unittest.TestCase):
"file_name": "MyLoRA.safetensors",
"civitai": {"trainedWords": ["word1"]},
}
(Path(tmpdir) / "MyLoRA.metadata.json").write_text(json.dumps(meta))
(Path(tmpdir) / "MyLoRA.safetensors").write_bytes(b"m")
(Path(tmpdir) / "MyLoRA.safetensors.metadata.json").write_text(
json.dumps(meta)
)
with patch("py.lora_utils.default_lora_roots", return_value=[tmpdir]):
with patch("py.lora_utils.find_lora_directories", 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 TestDefaultLoraRoots(unittest.TestCase):
class TestFindLoraDirectories(unittest.TestCase):
def setUp(self):
self.tmp = Path(tempfile.mkdtemp())
@@ -1520,7 +1520,7 @@ class TestDefaultLoraRoots(unittest.TestCase):
self.root = _make_comfy_root(self.tmp)
self.lm = _make_lora_manager(self.root / "custom_nodes")
def test_collects_default_extra_and_runtime_dirs_only(self):
def test_collects_default_extra_runtime_and_metadata_dirs(self):
extra_abs = self.tmp / "extra_abs"
extra_abs.mkdir()
(self.root / "rel_loras").mkdir()
@@ -1542,14 +1542,14 @@ class TestDefaultLoraRoots(unittest.TestCase):
{"folder_paths": _FakeFolderPaths(str(self.root), [str(runtime)])},
),
):
dirs = lora_utils.default_lora_roots()
dirs = lora_utils.find_lora_directories(str(self.lm))
# #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,21 +1560,12 @@ class TestDefaultLoraRoots(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_nothing_is_found(self):
def test_without_root_only_metadata_dirs_are_found(self):
with (
patch.object(lora_utils, "find_comfyui_root", return_value=None),
patch.dict(sys.modules, {"folder_paths": None}),
):
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)
self.assertEqual(lora_utils.find_lora_directories(str(self.lm)), [])
class TestModelNameAndPreviews(unittest.TestCase):
@@ -1590,6 +1581,44 @@ 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):
@@ -1674,161 +1703,11 @@ 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, "default_lora_roots", return_value=[str(tmp)]):
with patch.object(lora_utils, "find_lora_directories", 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()
-37
View File
@@ -315,43 +315,6 @@ 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 <lora:neon:1>")
self.assertEqual(text, "a cat <lora:neon:1>")
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 <lora:neon:1>")
self.assertEqual(discover.call_count, 1)
class TestPromptManagerEncoding(NodeTestCase):
node_cls = PromptManager
+23 -5
View File
@@ -26,9 +26,27 @@ def _read(name):
def _pyproject():
import tomllib
return tomllib.loads(_read("pyproject.toml"))
try:
import tomllib # Python 3.11+
except ImportError: # pragma: no cover - exercised on 3.10 only
tomllib = None
text = _read("pyproject.toml")
if tomllib is not None:
return tomllib.loads(text)
# Minimal fallback for 3.10: pull the dependency list out with a regex.
match = re.search(r"dependencies\s*=\s*\[(.*?)\]", text, re.S)
deps = re.findall(r'"([^"]*)"', match.group(1)) if match else []
return {
"project": {
"dependencies": deps,
"requires-python": (
re.search(r'requires-python\s*=\s*"([^"]+)"', text).group(1)
if "requires-python" in text
else None
),
},
"build-system": {} if "[build-system]" in text else None,
}
class PyprojectContract(unittest.TestCase):
@@ -47,8 +65,8 @@ class PyprojectContract(unittest.TestCase):
}
self.assertEqual(deps, wanted)
def test_requires_python_floor_is_3_12(self):
self.assertEqual(_pyproject()["project"]["requires-python"], ">=3.12")
def test_requires_python_floor_is_3_10(self):
self.assertEqual(_pyproject()["project"]["requires-python"], ">=3.10")
def test_build_system_is_declared(self):
self.assertIsNotNone(_pyproject().get("build-system"))
-24
View File
@@ -152,30 +152,6 @@ 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)
+2 -38
View File
@@ -106,10 +106,6 @@ class ImageGenerationHandler(FileSystemEventHandler):
# ``queue_capture_thumbnail``, wired at start-up; #249). It only
# queues: the preview is written on its own worker. None: no queue.
self.thumbnail_queue: Optional[Callable[[str], str]] = None
# Told about every settled output, with its resolved path (the API's
# ``notify_output_settled``, wired at start-up; #348): the Images
# listing walks that root again. None: nobody is told.
self.output_listener: Optional[Callable[[str], None]] = None
# Read from GalleryConfig if available, otherwise use defaults
try:
@@ -263,12 +259,8 @@ class ImageGenerationHandler(FileSystemEventHandler):
f"Image never settled or vanished, skipping: {image_path}"
)
return
try:
self.process_new_image(image_path, prompt_snapshot=prompt_snapshot)
self._queue_thumbnail(image_path)
finally:
# Linked or not, even if linking raised: the file exists.
self._report_output(image_path)
self.process_new_image(image_path, prompt_snapshot=prompt_snapshot)
self._queue_thumbnail(image_path)
finally:
with self._pending_lock:
self._pending.pop(image_path, None)
@@ -293,25 +285,6 @@ class ImageGenerationHandler(FileSystemEventHandler):
return
self.logger.debug(f"Preview {outcome}: {os.path.basename(image_path)}")
def _report_output(self, image_path: str) -> None:
"""Tell the output listener a settled output exists, best effort.
The path is resolved here, on the worker thread, so the listener's
event loop does no file I/O. A failure is logged, never raised: the
output is then listed by the listing's 30 s refresh.
"""
listener = getattr(self, "output_listener", None)
if listener is None:
return
try:
listener(os.path.realpath(image_path))
except Exception:
self.logger.warning(
f"{os.path.basename(image_path)} could not be reported to the "
"Images listing",
exc_info=True,
)
def wait_until_settled(self, image_path: str, stop=None) -> bool:
"""True once the file's size is non-zero and unchanged across two reads.
@@ -875,14 +848,6 @@ class ImageMonitor:
self.logger = get_logger("prompt_manager.image_monitor")
self.review_recorder: Optional[Callable[[str], str]] = None
self.thumbnail_queue: Optional[Callable[[str], str]] = None
self.output_listener: Optional[Callable[[str], None]] = None
def set_output_listener(self, listener: Optional[Callable[[str], None]]) -> None:
"""Report every settled output's resolved path to ``listener`` (None
stops it), on the running handler and every later one (#348)."""
self.output_listener = listener
if self.handler is not None:
self.handler.output_listener = listener
def set_thumbnail_queue(
self, queue_preview: Optional[Callable[[str], str]]
@@ -960,7 +925,6 @@ class ImageMonitor:
handler = ImageGenerationHandler(self.db_manager, self.prompt_tracker)
handler.review_recorder = self.review_recorder
handler.thumbnail_queue = self.thumbnail_queue
handler.output_listener = self.output_listener
observer = Observer()
scheduled = []
for output_dir in valid_directories:
+2 -5
View File
@@ -50,8 +50,7 @@ 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). A `linked_only` image (LoRA media in
* LoraManager's folders, #355) has no Tag action.
* cannot be served, and `showInfo: false` hides the title and file line (D97).
*/
export function createImageCard(image, { checked = null, favoriteBusy = false, media = true, showInfo = true } = {}) {
const card = el('article', undefined, 'media-card');
@@ -88,9 +87,7 @@ 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);
// #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));
actions.append(inspectButton, createTagButton(image));
info.append(text, actions);
card.append(art, info);
return card;
+11 -43
View File
@@ -16,9 +16,6 @@
* - "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';
@@ -58,9 +55,6 @@ 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');
@@ -181,38 +175,17 @@ 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 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);
const found = Number.isSafeInteger(plan.lora_files) ? plan.lora_files : 0;
const readable = Number.isSafeInteger(plan.with_metadata) ? plan.with_metadata : 0;
return [
...(layout ? [[LAYOUT_TERM, layout]] : []),
['Scope', plan.scope?.label || `${number(readable)} ${noun(readable, 'LoRA')}`],
['Found', `${number(found)} metadata ${noun(found, 'file')} · ${number(readable)} ${noun(readable, 'LoRA')} · `
+ `${number(local)} with local images`],
['Found', `${number(found)} metadata ${noun(found, 'file')} · ${number(readable)} readable`],
['Protected', protectedText(plan.protected)],
['Network', network],
['Backup', backupText(plan.backup)],
@@ -232,18 +205,11 @@ 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');
const dd = el('dd', value);
if (term === LAYOUT_TERM) settingsTitle(dd, plan);
entry.append(el('dt', term), dd);
entry.append(el('dt', term), el('dd', value));
grid.append(entry);
}
const close = createButton('Close');
@@ -257,19 +223,21 @@ 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() }) {
const note = closeNote(plan);
if (note) {
closeOnly({ scope, trigger, plan, note });
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 });
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(' ');
const review = openReviewDialog({ scope, trigger, title: IMPORT_TITLE, heading: 'LoRA reference data', grid: importGrid(plan),
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. */
+1 -5
View File
@@ -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, and `{setTitle(term, text)}` for a value's hover title.
* an option.
*/
export function openReviewDialog({ scope, title, heading = null, grid, options = [], sentence = null, backup = null,
cancelLabel, primary, onStart, trigger = null, onDismiss = null }) {
@@ -475,10 +475,6 @@ 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;
},
};
}