* feat(lora): cache LoraManager's layout behind file signatures (#362, phase 1) A new py/lora_layout_cache.py keeps one (install, layout) entry and reuses it while the configured path, the plugin settings file's and the settings file's signatures (st_mtime_ns, st_size), the install and every media root's real path are unchanged and the entry is younger than 30 s. A load whose watched file changed within 3 s (git's racy rule), could not be checked, or whose settings were unreadable serves its request only. clear() bumps a generation so a load that started earlier stores nothing. Hits take no lock. Not wired yet; phase 2 puts it behind the image hot paths. * fix(lora): treat an offline Windows share as unvalidated and make cache tests portable (#362) Review findings on phase 1: Windows raises FileNotFoundError with winerror 53/67 for an offline share, which is a temporary failure, not a missing file. Test times are whole milliseconds (NTFS keeps 100 ns steps) and a file system that rounds them skips; cleanup no longer ignores errors; tests added for ValueError, NotADirectoryError, a failed reload, resolve failures and the watched-file set. * perf(lora): serve the image hot paths from the layout cache (#362, phase 2) _lora_image_dirs takes (install, layout) from lora_utils.cached_lora_manager_layout instead of resolving the install and reading LoraManager's settings files on every Library, inspect, reveal and serve request. serve_image builds the allow-list in the executor instead of on the event loop. A finished import (System job or legacy scan, both through replace_lora_imports) clears the cache, so files linked under a folder LoraManager just created are served at once. The integration-off check and the configured-path rule still run before the cache. * fix(lora): fingerprint every scanned sidecar, kept or skipped (#362, phase 3) Discovery keeps every metadata file it saw (scanned_files, resolved and sorted; scanned is now its length). The fingerprint hashes each one's path, size, modification time and whether it was kept, so a skipped sidecar added, removed or edited between the review and Start refuses Start with plan-changed, as the review's counts promised. * fix(lora): warn about refused default LoRA folders and log what failed (#362, phase 4) ComfyUI's default LoRA folders already pass the folder guard; a refused one that exists is now listed in the layout's refused_defaults, and the layout cache warns once per newly refused folder. The cache writes one INFO line when a stored layout differs from the last one logged. BAD_SIDECAR_ROOT and the module docstring name a folder that contains the home folder. A relative preview_url writes a DEBUG line before the suffix search; a non-OSError failure while loading trigger words logs its traceback. Review follow-ups harden two serve tests. * fix(lora): skip a sidecar that is not UTF-8 instead of failing the load (#362) Review follow-up on phase 4: read_lora_metadata caught JSONDecodeError but not UnicodeDecodeError, so one non-UTF-8 sidecar failed the whole trigger-word load (now logged with a traceback every retry) and the import review. It is skipped like any unreadable sidecar. The relative preview_url DEBUG line uses lazy formatting. * docs(testing): the LoRA linked-only gate, layout cache and fingerprint scope (#362, phase 5) TESTING.md's LoRA reference import contract now states what #358 and #362 shipped: the linked-only gate and _media_roots, linked_only media, the cover link order, the guard on ComfyUI's default LoRA folders and refused_defaults, the folder-unreadable code, the trigger cache's catch and 60 s retry, the fingerprint over every scanned sidecar, and the layout cache's key, signatures, racy window, maximum age, root re-check and clears. The targeted command includes tests.test_lora_layout_cache and tests.test_api_images. * fix(lora): a cached layout needs the same home folder; give the rebuild poll its time (#362) Final review follow-ups: the folder guard compares against the home folder, so a cache hit now also needs Path.home() unchanged. TESTING.md says the configured-path rule runs on the cache's result and that a non-UTF-8 sidecar is skipped. CI: 'Rebuild all is explicit...' (webkit) polled the job for the default 5 s while it regenerated 24 previews; the failure screenshot shows 12 of 24 done at the deadline on the busy runner (0.17 s locally). The poll now allows 30 s for that real work.
227 lines
9.0 KiB
Python
227 lines
9.0 KiB
Python
"""The staged LoRA reference import (#221, #249 System S10, #355).
|
|
|
|
The import reads LoraManager's ``*.metadata.json`` sidecars and turns each
|
|
LoRA into one prompt of the ``lora-manager`` category, tagged with its name and
|
|
trigger words and linked to its images. It runs in three stages, and only the
|
|
last writes to the database:
|
|
|
|
1. **discover**: 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 every scanned sidecar,
|
|
kept or skipped (path, size, modification time, kept or not, #362) and the
|
|
layout; ``preview`` adds the counts a review shows. Nothing is written
|
|
and nothing is requested.
|
|
2. **stage**: read each sidecar and build its record. Its LoraManager preview
|
|
and example images are linked where they are (``local_media``); only a
|
|
LoRA without any local image downloads its CivitAI example images into the
|
|
image cache first (best effort, counted). A cancelled or failed stage
|
|
leaves the database untouched; downloaded images stay in the cache.
|
|
3. **replace**: the sidecars are found and fingerprinted once more (a file
|
|
changed, removed or added while staging aborts the import), then
|
|
``PromptDatabase.replace_category_prompts`` swaps the old collection for
|
|
the staged records in one transaction (or changes nothing).
|
|
|
|
Both the System job (``POST /lora/import/apply``) and the legacy ``POST
|
|
/lora/scan`` run these stages (``py/api/lora_integration.py``).
|
|
"""
|
|
|
|
import hashlib
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import AbstractSet, Callable, Dict, List, Optional
|
|
|
|
from . import lora_utils
|
|
from .lora_manager_layout import (
|
|
Discovery,
|
|
discover_sidecars,
|
|
layout_summary,
|
|
local_media,
|
|
)
|
|
|
|
try:
|
|
from ..utils.hashing import generate_prompt_hash
|
|
except ImportError: # pragma: no cover - flat test imports
|
|
from utils.hashing import generate_prompt_hash
|
|
|
|
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")
|
|
|
|
|
|
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.
|
|
|
|
``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.
|
|
"""
|
|
return discover_sidecars(lora_utils.lora_manager_layout(lora_manager_path))
|
|
|
|
|
|
def fingerprint(discovery: Discovery) -> str:
|
|
"""What a review saw: the layout, then every scanned sidecar, kept or
|
|
skipped, with its path, size, modification time and whether it was kept
|
|
(#362). A LoraManager save that changes unrelated settings leaves it as
|
|
it was."""
|
|
layout = discovery.layout
|
|
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"))
|
|
kept = {str(sidecar.metadata_path) for sidecar in discovery.sidecars}
|
|
for path in discovery.scanned_files:
|
|
flag = 1 if str(path) in kept else 0
|
|
try:
|
|
stat = path.stat()
|
|
entry = f"{path}\0{stat.st_size}\0{stat.st_mtime_ns}\0{flag}\n"
|
|
except OSError:
|
|
entry = f"{path}\0missing\0{flag}\n"
|
|
digest.update(entry.encode("utf-8", "surrogatepass"))
|
|
return "sha256:" + digest.hexdigest()
|
|
|
|
|
|
def preview(
|
|
lora_manager_path: Optional[str],
|
|
cache_dir: Path,
|
|
media_extensions: AbstractSet[str],
|
|
) -> Dict:
|
|
"""The review's counts (blocking; reads only, requests nothing).
|
|
|
|
``{lora_files, with_metadata, 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``).
|
|
"""
|
|
discovery = discover(lora_manager_path)
|
|
layout = discovery.layout
|
|
with_metadata = 0
|
|
with_local_media = 0
|
|
planned = 0
|
|
names = []
|
|
for sidecar in discovery.sidecars:
|
|
metadata = lora_utils.read_lora_metadata(sidecar.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
|
|
)
|
|
if len(names) < 50:
|
|
names.append(lora_utils.get_model_name_from_metadata(metadata))
|
|
return {
|
|
"lora_files": discovery.scanned,
|
|
"with_metadata": with_metadata,
|
|
"skipped": discovery.scanned - with_metadata,
|
|
"with_local_media": with_local_media,
|
|
"planned_downloads": planned,
|
|
"fingerprint": fingerprint(discovery),
|
|
"names": names,
|
|
"layout": layout_summary(layout, Path.home()),
|
|
}
|
|
|
|
|
|
def build_record(metadata: Dict, path: Path, images: List[str]) -> Dict:
|
|
"""The prompt a LoRA becomes: its example prompt (else its name), tags, images."""
|
|
model_name = lora_utils.get_model_name_from_metadata(metadata)
|
|
text = lora_utils.get_example_prompt_from_metadata(metadata) or model_name
|
|
tags = ["lora-manager", f"lora:{model_name}"]
|
|
tags.extend(lora_utils.get_trigger_words_from_metadata(metadata))
|
|
return {
|
|
"name": model_name,
|
|
"text": text,
|
|
"hash": generate_prompt_hash(text),
|
|
"tags": list(dict.fromkeys(tags)),
|
|
"images": images,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class StagedImport:
|
|
"""The records an import will write, and what staging counted."""
|
|
|
|
total: int
|
|
records: List[Dict] = field(default_factory=list)
|
|
skipped: List[str] = field(default_factory=list)
|
|
stats: Dict[str, int] = field(
|
|
default_factory=lambda: {"downloaded": 0, "download_errors": 0}
|
|
)
|
|
|
|
|
|
def stage(
|
|
discovery: Discovery,
|
|
cache_dir: Path,
|
|
api_key: str = "",
|
|
*,
|
|
media_extensions: AbstractSet[str],
|
|
on_record: Optional[Callable[[int, Dict, "StagedImport"], None]] = None,
|
|
cancelled: Callable[[], bool] = lambda: False,
|
|
logger=None,
|
|
) -> StagedImport:
|
|
"""Read each sidecar and build its record with its 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.
|
|
"""
|
|
sidecars = discovery.sidecars
|
|
staged = StagedImport(total=len(sidecars))
|
|
staged.skipped.extend(discovery.unreadable)
|
|
for index, sidecar in enumerate(sidecars):
|
|
if cancelled():
|
|
raise ImportCancelled()
|
|
record = None
|
|
path = sidecar.metadata_path
|
|
metadata = lora_utils.read_lora_metadata(path)
|
|
if not metadata:
|
|
staged.skipped.append(path.name)
|
|
else:
|
|
try:
|
|
local = local_media(
|
|
sidecar, metadata, discovery.layout, media_extensions
|
|
)
|
|
remote = []
|
|
if not local:
|
|
remote = lora_utils.download_civitai_images(
|
|
metadata, path, cache_dir, api_key, stats=staged.stats
|
|
)
|
|
images = list(dict.fromkeys([*local, *remote]))
|
|
record = build_record(metadata, path, images)
|
|
staged.records.append(record)
|
|
except Exception as error: # one LoRA never stops the import
|
|
name = lora_utils.get_model_name_from_metadata(metadata)
|
|
if logger is not None:
|
|
logger.warning(f"Failed to prepare LoRA {name}: {error}")
|
|
staged.skipped.append(name)
|
|
if on_record is not None:
|
|
on_record(index, record, staged)
|
|
return staged
|