mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-12 12:32:11 -03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b197793b10 | ||
|
|
946809f47c | ||
|
|
d25f170ffe | ||
|
|
c16de68640 | ||
|
|
85f79283b6 | ||
|
|
fd40cd952b | ||
|
|
5fbac84910 | ||
|
|
a750ca88ac | ||
|
|
b68af5400e | ||
|
|
5976224d75 | ||
|
|
3a718ce2c3 | ||
|
|
0508daa0fb | ||
|
|
fadad1b507 | ||
|
|
6d0d7cb15b | ||
|
|
e056142669 |
@@ -91,3 +91,62 @@ jobs:
|
|||||||
name: backend-coverage
|
name: backend-coverage
|
||||||
path: coverage/backend
|
path: coverage/backend
|
||||||
if-no-files-found: warn
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
pytest-windows:
|
||||||
|
# Windows-only regressions cannot be caught by the Linux job: model roots,
|
||||||
|
# sidecars and symlink handling all branch on `os.sep`/`os.name`. Issue #1146
|
||||||
|
# (every configured root counted twice and shadowed as unreachable) shipped
|
||||||
|
# because the suite only ever ran on Ubuntu.
|
||||||
|
#
|
||||||
|
# Informational for now: the flag comes off once the suite is Windows-clean.
|
||||||
|
name: Run pytest on Windows (informational)
|
||||||
|
runs-on: windows-latest
|
||||||
|
continue-on-error: true
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python 3.11
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
cache: 'pip'
|
||||||
|
cache-dependency-path: |
|
||||||
|
requirements.txt
|
||||||
|
requirements-dev.txt
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
|
- name: Report symlink support
|
||||||
|
# Symlinked model roots are part of the suite; os.symlink needs Developer
|
||||||
|
# Mode (or an elevated shell) on Windows. Reported, not enforced, so a
|
||||||
|
# runner without it still shows the real test results.
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
python - <<'PY'
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
root = pathlib.Path(tempfile.mkdtemp(prefix="lm-symlink-check-"))
|
||||||
|
target = root / "target"
|
||||||
|
target.mkdir()
|
||||||
|
link = root / "link"
|
||||||
|
try:
|
||||||
|
link.symlink_to(target, target_is_directory=True)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"symlinks unavailable: {exc}")
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"islink={os.path.islink(link)} isdir={os.path.isdir(link)} "
|
||||||
|
f"realpath={os.path.realpath(link)}"
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Run pytest
|
||||||
|
run: python -m pytest -q
|
||||||
|
|||||||
@@ -72,11 +72,25 @@ python scripts/sync_translation_keys.py
|
|||||||
|
|
||||||
Locale files are in `locales/` (en, zh-CN, zh-TW, ja, ko, fr, de, es, ru, he).
|
Locale files are in `locales/` (en, zh-CN, zh-TW, ja, ko, fr, de, es, ru, he).
|
||||||
|
|
||||||
|
`locales/*.json` keep a hand-maintained layout and **the two groups differ**: `en.json` (the
|
||||||
|
reference) uses **2-space indent**, the nine translated locales use **4-space indent** (what
|
||||||
|
`sync_translation_keys.py` writes). Both keep `ensure_ascii=False` and exactly one trailing newline.
|
||||||
|
Edit values **in place** (line/anchored text edit) and add keys with the sync script. Never
|
||||||
|
`json.load` + `json.dumps` a locale file just to change a string: the serializer rewrites the whole
|
||||||
|
layout, so a two-value edit lands as a several-thousand-line diff that has to be reverted and redone
|
||||||
|
(`tests/i18n/test_i18n.py::test_locale_files_keep_their_maintained_layout` now fails on that).
|
||||||
|
After writing to any existing structured file, check `git diff --stat`: the changed-line count must
|
||||||
|
match the change you meant to make.
|
||||||
|
|
||||||
After adding keys to `en.json` and syncing, **stop**: the `[TODO: Translate]` placeholders in
|
After adding keys to `en.json` and syncing, **stop**: the `[TODO: Translate]` placeholders in
|
||||||
the other locales are the expected end state during feature development. Do NOT translate
|
the other locales are the expected end state during feature development. Do NOT translate
|
||||||
proactively — translate only when the feature owner explicitly asks (see
|
proactively — translate only when the feature owner explicitly asks (see
|
||||||
`docs/i18n-translation-guidelines.md` §7).
|
`docs/i18n-translation-guidelines.md` §7).
|
||||||
|
|
||||||
|
Changing an **existing** English string is a translation event too: the other locales' value for
|
||||||
|
that key is now stale, so put it back in the queue (`[TODO: Translate] <new English>`) instead of
|
||||||
|
leaving a translation that describes the old behaviour.
|
||||||
|
|
||||||
**Before translating anything, read `docs/i18n-translation-guidelines.md`** — it defines the
|
**Before translating anything, read `docs/i18n-translation-guidelines.md`** — it defines the
|
||||||
term conventions (e.g. "Recipe" stays untranslated in French, 配方 in Chinese; model-type and
|
term conventions (e.g. "Recipe" stays untranslated in French, 配方 in Chinese; model-type and
|
||||||
brand names are never translated), per-locale preferred renderings, placeholder rules, and
|
brand names are never translated), per-locale preferred renderings, placeholder rules, and
|
||||||
|
|||||||
@@ -22,9 +22,11 @@ Implementation notes / deviations from the draft below:
|
|||||||
they are outside every configured root prefix. The `root_unreachable` path therefore covers the
|
they are outside every configured root prefix. The `root_unreachable` path therefore covers the
|
||||||
*mid-session* case (drive switched off while LM runs), which is the workflow that motivated the
|
*mid-session* case (drive switched off while LM runs), which is the workflow that motivated the
|
||||||
issue; both paths keep the entries, which is what matters.
|
issue; both paths keep the entries, which is what matters.
|
||||||
* Known limitation to keep in mind: after a scoped scan (or one with unreadable paths) the recorded
|
* Known limitation to keep in mind: after a *scoped* scan the recorded empty-folder list is unioned
|
||||||
empty-folder list is unioned instead of replaced, so an empty folder deleted on disk can linger in
|
instead of replaced, so an empty folder deleted on disk can linger in the sidebar until the next
|
||||||
the sidebar until the next full refresh. Models are unaffected.
|
full refresh. A full refresh keeps only the entries a specific unreadable area (a directory
|
||||||
|
`os.walk` could not enter, an offline symlink) may still hold, so one switched-off drive no longer
|
||||||
|
keeps every other root's ghosts alive (issue #1145).
|
||||||
|
|
||||||
## TL;DR (For humans)
|
## TL;DR (For humans)
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -1418,7 +1418,8 @@
|
|||||||
"notEmptyMessageCount": "Dieser Ordner enthält noch {count} Modelldatei(en). Löschen oder verschieben Sie diese zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.",
|
"notEmptyMessageCount": "Dieser Ordner enthält noch {count} Modelldatei(en). Löschen oder verschieben Sie diese zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.",
|
||||||
"notEmptyMessageExcluded": "Dieser Ordner enthält noch {count} Modelldatei(en), davon {excluded} von der Bibliothek ausgeschlossen. Heben Sie den Ausschluss unter „Ausgeschlossene Modelle verwalten“ auf und löschen Sie sie zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.",
|
"notEmptyMessageExcluded": "Dieser Ordner enthält noch {count} Modelldatei(en), davon {excluded} von der Bibliothek ausgeschlossen. Heben Sie den Ausschluss unter „Ausgeschlossene Modelle verwalten“ auf und löschen Sie sie zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.",
|
||||||
"missingTitle": "Ordner nicht gefunden",
|
"missingTitle": "Ordner nicht gefunden",
|
||||||
"missingMessage": "Dieser Ordner existiert nicht mehr auf dem Datenträger. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.",
|
"missingMessage": "[TODO: Translate] This folder no longer exists on disk. You can remove it from the list.",
|
||||||
|
"removeFromList": "[TODO: Translate] Remove from list",
|
||||||
"symlinkTitle": "Ordner ist ein symbolischer Link",
|
"symlinkTitle": "Ordner ist ein symbolischer Link",
|
||||||
"symlinkMessage": "Alle Kopien dieses Ordners sind symbolische Links; entfernen Sie den Link oder sein Ziel außerhalb von LoRA Manager.",
|
"symlinkMessage": "Alle Kopien dieses Ordners sind symbolische Links; entfernen Sie den Link oder sein Ziel außerhalb von LoRA Manager.",
|
||||||
"protectedTitle": "Ordner ist geschützt",
|
"protectedTitle": "Ordner ist geschützt",
|
||||||
@@ -1440,6 +1441,8 @@
|
|||||||
"busy": "In diesem Ordner steht noch eine Löschung aus. Warten Sie, bis das Zeitfenster für das Rückgängigmachen abgelaufen ist.",
|
"busy": "In diesem Ordner steht noch eine Löschung aus. Warten Sie, bis das Zeitfenster für das Rückgängigmachen abgelaufen ist.",
|
||||||
"unsupported": "Das Löschen von Ordnern wird auf dieser Seite nicht unterstützt",
|
"unsupported": "Das Löschen von Ordnern wird auf dieser Seite nicht unterstützt",
|
||||||
"missing": "Dieser Ordner existiert nicht mehr auf dem Datenträger. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.",
|
"missing": "Dieser Ordner existiert nicht mehr auf dem Datenträger. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.",
|
||||||
|
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the list",
|
||||||
|
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
|
||||||
"symlink": "Dieser Ordner ist ein symbolischer Link; er kann nicht über die Seitenleiste gelöscht werden.",
|
"symlink": "Dieser Ordner ist ein symbolischer Link; er kann nicht über die Seitenleiste gelöscht werden.",
|
||||||
"successMulti": "Ordner \"{name}\" aus {count} Modell-Stammverzeichnissen gelöscht",
|
"successMulti": "Ordner \"{name}\" aus {count} Modell-Stammverzeichnissen gelöscht",
|
||||||
"partial": "{count} von {total} Kopien gelöscht — {failed} konnten nicht gelöscht werden. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut."
|
"partial": "{count} von {total} Kopien gelöscht — {failed} konnten nicht gelöscht werden. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut."
|
||||||
|
|||||||
+4
-1
@@ -1418,7 +1418,8 @@
|
|||||||
"notEmptyMessageCount": "This folder still contains {count} model file(s). Delete or move them first — deleting a folder never cascades over model files.",
|
"notEmptyMessageCount": "This folder still contains {count} model file(s). Delete or move them first — deleting a folder never cascades over model files.",
|
||||||
"notEmptyMessageExcluded": "This folder still contains {count} model file(s), {excluded} of them excluded from the library. Un-exclude them in Manage Excluded Models and delete them first — deleting a folder never cascades over model files.",
|
"notEmptyMessageExcluded": "This folder still contains {count} model file(s), {excluded} of them excluded from the library. Un-exclude them in Manage Excluded Models and delete them first — deleting a folder never cascades over model files.",
|
||||||
"missingTitle": "Folder not found",
|
"missingTitle": "Folder not found",
|
||||||
"missingMessage": "This folder no longer exists on disk. Refresh the sidebar and try again.",
|
"missingMessage": "This folder no longer exists on disk. You can remove it from the list.",
|
||||||
|
"removeFromList": "Remove from list",
|
||||||
"symlinkTitle": "Folder is a symbolic link",
|
"symlinkTitle": "Folder is a symbolic link",
|
||||||
"symlinkMessage": "Every copy of this folder is a symbolic link; remove the link or its target outside LoRA Manager.",
|
"symlinkMessage": "Every copy of this folder is a symbolic link; remove the link or its target outside LoRA Manager.",
|
||||||
"protectedTitle": "Folder is protected",
|
"protectedTitle": "Folder is protected",
|
||||||
@@ -1440,6 +1441,8 @@
|
|||||||
"busy": "A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
"busy": "A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
||||||
"unsupported": "Folder deletion is not supported on this page",
|
"unsupported": "Folder deletion is not supported on this page",
|
||||||
"missing": "This folder no longer exists on disk. Refresh the sidebar and try again.",
|
"missing": "This folder no longer exists on disk. Refresh the sidebar and try again.",
|
||||||
|
"forgotten": "Folder \"{name}\" removed from the list",
|
||||||
|
"exists": "The folder is back on disk. Delete it instead.",
|
||||||
"symlink": "This folder is a symbolic link; it cannot be deleted from the sidebar.",
|
"symlink": "This folder is a symbolic link; it cannot be deleted from the sidebar.",
|
||||||
"successMulti": "Folder \"{name}\" deleted from {count} model roots",
|
"successMulti": "Folder \"{name}\" deleted from {count} model roots",
|
||||||
"partial": "Deleted {count} of {total} copies — {failed} could not be deleted. Refresh the sidebar and retry."
|
"partial": "Deleted {count} of {total} copies — {failed} could not be deleted. Refresh the sidebar and retry."
|
||||||
|
|||||||
+4
-1
@@ -1418,7 +1418,8 @@
|
|||||||
"notEmptyMessageCount": "Esta carpeta aún contiene {count} archivo(s) de modelo. Elimínalos o muévelos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.",
|
"notEmptyMessageCount": "Esta carpeta aún contiene {count} archivo(s) de modelo. Elimínalos o muévelos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.",
|
||||||
"notEmptyMessageExcluded": "Esta carpeta aún contiene {count} archivo(s) de modelo, de los cuales {excluded} están excluidos de la biblioteca. Anula su exclusión en «Gestionar modelos excluidos» y elimínalos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.",
|
"notEmptyMessageExcluded": "Esta carpeta aún contiene {count} archivo(s) de modelo, de los cuales {excluded} están excluidos de la biblioteca. Anula su exclusión en «Gestionar modelos excluidos» y elimínalos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.",
|
||||||
"missingTitle": "Carpeta no encontrada",
|
"missingTitle": "Carpeta no encontrada",
|
||||||
"missingMessage": "Esta carpeta ya no existe en el disco. Actualiza la barra lateral e inténtalo de nuevo.",
|
"missingMessage": "[TODO: Translate] This folder no longer exists on disk. You can remove it from the list.",
|
||||||
|
"removeFromList": "[TODO: Translate] Remove from list",
|
||||||
"symlinkTitle": "La carpeta es un enlace simbólico",
|
"symlinkTitle": "La carpeta es un enlace simbólico",
|
||||||
"symlinkMessage": "Todas las copias de esta carpeta son enlaces simbólicos; elimina el enlace o su destino fuera de LoRA Manager.",
|
"symlinkMessage": "Todas las copias de esta carpeta son enlaces simbólicos; elimina el enlace o su destino fuera de LoRA Manager.",
|
||||||
"protectedTitle": "Carpeta protegida",
|
"protectedTitle": "Carpeta protegida",
|
||||||
@@ -1440,6 +1441,8 @@
|
|||||||
"busy": "Todavía hay una eliminación pendiente dentro de esta carpeta. Espera a que caduque la ventana de deshacer.",
|
"busy": "Todavía hay una eliminación pendiente dentro de esta carpeta. Espera a que caduque la ventana de deshacer.",
|
||||||
"unsupported": "La eliminación de carpetas no es compatible con esta página",
|
"unsupported": "La eliminación de carpetas no es compatible con esta página",
|
||||||
"missing": "Esta carpeta ya no existe en el disco. Actualiza la barra lateral e inténtalo de nuevo.",
|
"missing": "Esta carpeta ya no existe en el disco. Actualiza la barra lateral e inténtalo de nuevo.",
|
||||||
|
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the list",
|
||||||
|
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
|
||||||
"symlink": "Esta carpeta es un enlace simbólico; no se puede eliminar desde la barra lateral.",
|
"symlink": "Esta carpeta es un enlace simbólico; no se puede eliminar desde la barra lateral.",
|
||||||
"successMulti": "Carpeta \"{name}\" eliminada de {count} raíces de modelo",
|
"successMulti": "Carpeta \"{name}\" eliminada de {count} raíces de modelo",
|
||||||
"partial": "Se eliminaron {count} de {total} copias — {failed} no se pudieron eliminar. Actualiza la barra lateral e inténtalo de nuevo."
|
"partial": "Se eliminaron {count} de {total} copias — {failed} no se pudieron eliminar. Actualiza la barra lateral e inténtalo de nuevo."
|
||||||
|
|||||||
+4
-1
@@ -1418,7 +1418,8 @@
|
|||||||
"notEmptyMessageCount": "Ce dossier contient encore {count} fichier(s) de modèle. Supprimez-les ou déplacez-les d’abord — la suppression d’un dossier n’entraîne jamais celle des fichiers de modèles.",
|
"notEmptyMessageCount": "Ce dossier contient encore {count} fichier(s) de modèle. Supprimez-les ou déplacez-les d’abord — la suppression d’un dossier n’entraîne jamais celle des fichiers de modèles.",
|
||||||
"notEmptyMessageExcluded": "Ce dossier contient encore {count} fichier(s) de modèle, dont {excluded} sont exclus de la bibliothèque. Annulez leur exclusion dans « Gérer les modèles exclus » et supprimez-les d’abord — la suppression d’un dossier n’entraîne jamais celle des fichiers de modèles.",
|
"notEmptyMessageExcluded": "Ce dossier contient encore {count} fichier(s) de modèle, dont {excluded} sont exclus de la bibliothèque. Annulez leur exclusion dans « Gérer les modèles exclus » et supprimez-les d’abord — la suppression d’un dossier n’entraîne jamais celle des fichiers de modèles.",
|
||||||
"missingTitle": "Dossier introuvable",
|
"missingTitle": "Dossier introuvable",
|
||||||
"missingMessage": "Ce dossier n’existe plus sur le disque. Actualisez la barre latérale et réessayez.",
|
"missingMessage": "[TODO: Translate] This folder no longer exists on disk. You can remove it from the list.",
|
||||||
|
"removeFromList": "[TODO: Translate] Remove from list",
|
||||||
"symlinkTitle": "Le dossier est un lien symbolique",
|
"symlinkTitle": "Le dossier est un lien symbolique",
|
||||||
"symlinkMessage": "Toutes les copies de ce dossier sont des liens symboliques ; supprimez le lien ou sa cible en dehors de LoRA Manager.",
|
"symlinkMessage": "Toutes les copies de ce dossier sont des liens symboliques ; supprimez le lien ou sa cible en dehors de LoRA Manager.",
|
||||||
"protectedTitle": "Dossier protégé",
|
"protectedTitle": "Dossier protégé",
|
||||||
@@ -1440,6 +1441,8 @@
|
|||||||
"busy": "Une suppression est encore en attente dans ce dossier. Attendez la fin de la fenêtre d’annulation.",
|
"busy": "Une suppression est encore en attente dans ce dossier. Attendez la fin de la fenêtre d’annulation.",
|
||||||
"unsupported": "La suppression de dossiers n’est pas prise en charge sur cette page",
|
"unsupported": "La suppression de dossiers n’est pas prise en charge sur cette page",
|
||||||
"missing": "Ce dossier n’existe plus sur le disque. Actualisez la barre latérale et réessayez.",
|
"missing": "Ce dossier n’existe plus sur le disque. Actualisez la barre latérale et réessayez.",
|
||||||
|
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the list",
|
||||||
|
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
|
||||||
"symlink": "Ce dossier est un lien symbolique ; il ne peut pas être supprimé depuis la barre latérale.",
|
"symlink": "Ce dossier est un lien symbolique ; il ne peut pas être supprimé depuis la barre latérale.",
|
||||||
"successMulti": "Dossier \"{name}\" supprimé de {count} racines de modèle",
|
"successMulti": "Dossier \"{name}\" supprimé de {count} racines de modèle",
|
||||||
"partial": "{count} copie(s) sur {total} supprimée(s) — {failed} en échec. Actualisez la barre latérale et réessayez."
|
"partial": "{count} copie(s) sur {total} supprimée(s) — {failed} en échec. Actualisez la barre latérale et réessayez."
|
||||||
|
|||||||
+4
-1
@@ -1418,7 +1418,8 @@
|
|||||||
"notEmptyMessageCount": "בתיקייה זו עדיין יש {count} קובצי מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
|
"notEmptyMessageCount": "בתיקייה זו עדיין יש {count} קובצי מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
|
||||||
"notEmptyMessageExcluded": "בתיקייה זו עדיין יש {count} קובצי מודלים, שמהם {excluded} מוחרגים מהספרייה. בטל את ההחרגה ב«ניהול מודלים מוחרגים» ומחק אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
|
"notEmptyMessageExcluded": "בתיקייה זו עדיין יש {count} קובצי מודלים, שמהם {excluded} מוחרגים מהספרייה. בטל את ההחרגה ב«ניהול מודלים מוחרגים» ומחק אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
|
||||||
"missingTitle": "התיקייה לא נמצאה",
|
"missingTitle": "התיקייה לא נמצאה",
|
||||||
"missingMessage": "תיקייה זו אינה קיימת עוד בדיסק. רענן את סרגל הצד ונסה שוב.",
|
"missingMessage": "[TODO: Translate] This folder no longer exists on disk. You can remove it from the list.",
|
||||||
|
"removeFromList": "[TODO: Translate] Remove from list",
|
||||||
"symlinkTitle": "התיקייה היא קישור סמלי",
|
"symlinkTitle": "התיקייה היא קישור סמלי",
|
||||||
"symlinkMessage": "כל העותקים של תיקייה זו הם קישורים סמליים; מחק את הקישור או את היעד שלו מחוץ ל-LoRA Manager.",
|
"symlinkMessage": "כל העותקים של תיקייה זו הם קישורים סמליים; מחק את הקישור או את היעד שלו מחוץ ל-LoRA Manager.",
|
||||||
"protectedTitle": "תיקייה מוגנת",
|
"protectedTitle": "תיקייה מוגנת",
|
||||||
@@ -1440,6 +1441,8 @@
|
|||||||
"busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.",
|
"busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.",
|
||||||
"unsupported": "מחיקת תיקיות אינה נתמכת בדף זה",
|
"unsupported": "מחיקת תיקיות אינה נתמכת בדף זה",
|
||||||
"missing": "תיקייה זו אינה קיימת עוד בדיסק. רענן את סרגל הצד ונסה שוב.",
|
"missing": "תיקייה זו אינה קיימת עוד בדיסק. רענן את סרגל הצד ונסה שוב.",
|
||||||
|
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the list",
|
||||||
|
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
|
||||||
"symlink": "תיקייה זו היא קישור סמלי; לא ניתן למחוק אותה מסרגל הצד.",
|
"symlink": "תיקייה זו היא קישור סמלי; לא ניתן למחוק אותה מסרגל הצד.",
|
||||||
"successMulti": "התיקייה \"{name}\" נמחקה מ-{count} שורשי מודלים",
|
"successMulti": "התיקייה \"{name}\" נמחקה מ-{count} שורשי מודלים",
|
||||||
"partial": "נמחקו {count} מתוך {total} עותקים — {failed} לא נמחקו. רענן את סרגל הצד ונסה שוב."
|
"partial": "נמחקו {count} מתוך {total} עותקים — {failed} לא נמחקו. רענן את סרגל הצד ונסה שוב."
|
||||||
|
|||||||
+4
-1
@@ -1418,7 +1418,8 @@
|
|||||||
"notEmptyMessageCount": "このフォルダにはまだ {count} 個のモデルファイルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
|
"notEmptyMessageCount": "このフォルダにはまだ {count} 個のモデルファイルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
|
||||||
"notEmptyMessageExcluded": "このフォルダにはまだ {count} 個のモデルファイルがあり、そのうち {excluded} 個はライブラリから除外されています。「除外モデルを管理」で除外を解除してから削除してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
|
"notEmptyMessageExcluded": "このフォルダにはまだ {count} 個のモデルファイルがあり、そのうち {excluded} 個はライブラリから除外されています。「除外モデルを管理」で除外を解除してから削除してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
|
||||||
"missingTitle": "フォルダが見つかりません",
|
"missingTitle": "フォルダが見つかりません",
|
||||||
"missingMessage": "このフォルダはディスク上に存在しません。サイドバーを再読み込みしてからもう一度お試しください。",
|
"missingMessage": "[TODO: Translate] This folder no longer exists on disk. You can remove it from the list.",
|
||||||
|
"removeFromList": "[TODO: Translate] Remove from list",
|
||||||
"symlinkTitle": "フォルダがシンボリックリンクです",
|
"symlinkTitle": "フォルダがシンボリックリンクです",
|
||||||
"symlinkMessage": "このフォルダのすべてのコピーがシンボリックリンクです。LoRA Manager の外でリンクまたはそのリンク先を削除してください。",
|
"symlinkMessage": "このフォルダのすべてのコピーがシンボリックリンクです。LoRA Manager の外でリンクまたはそのリンク先を削除してください。",
|
||||||
"protectedTitle": "フォルダは保護されています",
|
"protectedTitle": "フォルダは保護されています",
|
||||||
@@ -1440,6 +1441,8 @@
|
|||||||
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
|
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
|
||||||
"unsupported": "このページではフォルダを削除できません",
|
"unsupported": "このページではフォルダを削除できません",
|
||||||
"missing": "このフォルダはディスク上に存在しません。サイドバーを再読み込みしてからもう一度お試しください。",
|
"missing": "このフォルダはディスク上に存在しません。サイドバーを再読み込みしてからもう一度お試しください。",
|
||||||
|
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the list",
|
||||||
|
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
|
||||||
"symlink": "このフォルダはシンボリックリンクです。サイドバーからは削除できません。",
|
"symlink": "このフォルダはシンボリックリンクです。サイドバーからは削除できません。",
|
||||||
"successMulti": "{count} 個のモデルルートからフォルダ \"{name}\" を削除しました",
|
"successMulti": "{count} 個のモデルルートからフォルダ \"{name}\" を削除しました",
|
||||||
"partial": "{total} 個のコピーのうち {count} 個を削除しました —— {failed} 個は削除できませんでした。サイドバーを再読み込みしてからもう一度お試しください。"
|
"partial": "{total} 個のコピーのうち {count} 個を削除しました —— {failed} 個は削除できませんでした。サイドバーを再読み込みしてからもう一度お試しください。"
|
||||||
|
|||||||
+4
-1
@@ -1418,7 +1418,8 @@
|
|||||||
"notEmptyMessageCount": "이 폴더에는 아직 모델 파일이 {count}개 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
|
"notEmptyMessageCount": "이 폴더에는 아직 모델 파일이 {count}개 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
|
||||||
"notEmptyMessageExcluded": "이 폴더에는 아직 모델 파일이 {count}개 있으며, 그중 {excluded}개는 라이브러리에서 제외되어 있습니다. '제외된 모델 관리'에서 제외를 해제한 뒤 먼저 삭제하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
|
"notEmptyMessageExcluded": "이 폴더에는 아직 모델 파일이 {count}개 있으며, 그중 {excluded}개는 라이브러리에서 제외되어 있습니다. '제외된 모델 관리'에서 제외를 해제한 뒤 먼저 삭제하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
|
||||||
"missingTitle": "폴더를 찾을 수 없습니다",
|
"missingTitle": "폴더를 찾을 수 없습니다",
|
||||||
"missingMessage": "이 폴더는 디스크에 더 이상 존재하지 않습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
|
"missingMessage": "[TODO: Translate] This folder no longer exists on disk. You can remove it from the list.",
|
||||||
|
"removeFromList": "[TODO: Translate] Remove from list",
|
||||||
"symlinkTitle": "폴더가 심볼릭 링크입니다",
|
"symlinkTitle": "폴더가 심볼릭 링크입니다",
|
||||||
"symlinkMessage": "이 폴더의 모든 복사본이 심볼릭 링크입니다. LoRA Manager 외부에서 링크 또는 그 대상을 삭제하세요.",
|
"symlinkMessage": "이 폴더의 모든 복사본이 심볼릭 링크입니다. LoRA Manager 외부에서 링크 또는 그 대상을 삭제하세요.",
|
||||||
"protectedTitle": "보호된 폴더",
|
"protectedTitle": "보호된 폴더",
|
||||||
@@ -1440,6 +1441,8 @@
|
|||||||
"busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.",
|
"busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.",
|
||||||
"unsupported": "이 페이지에서는 폴더를 삭제할 수 없습니다",
|
"unsupported": "이 페이지에서는 폴더를 삭제할 수 없습니다",
|
||||||
"missing": "이 폴더는 디스크에 더 이상 존재하지 않습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
|
"missing": "이 폴더는 디스크에 더 이상 존재하지 않습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
|
||||||
|
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the list",
|
||||||
|
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
|
||||||
"symlink": "이 폴더는 심볼릭 링크이므로 사이드바에서 삭제할 수 없습니다.",
|
"symlink": "이 폴더는 심볼릭 링크이므로 사이드바에서 삭제할 수 없습니다.",
|
||||||
"successMulti": "모델 루트 {count}개에서 \"{name}\" 폴더를 삭제했습니다",
|
"successMulti": "모델 루트 {count}개에서 \"{name}\" 폴더를 삭제했습니다",
|
||||||
"partial": "복사본 {total}개 중 {count}개를 삭제했습니다 —— {failed}개는 삭제하지 못했습니다. 사이드바를 새로 고친 후 다시 시도하세요."
|
"partial": "복사본 {total}개 중 {count}개를 삭제했습니다 —— {failed}개는 삭제하지 못했습니다. 사이드바를 새로 고친 후 다시 시도하세요."
|
||||||
|
|||||||
+4
-1
@@ -1418,7 +1418,8 @@
|
|||||||
"notEmptyMessageCount": "В этой папке ещё есть {count} файл(ов) модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.",
|
"notEmptyMessageCount": "В этой папке ещё есть {count} файл(ов) модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.",
|
||||||
"notEmptyMessageExcluded": "В этой папке ещё есть {count} файл(ов) модели, из них {excluded} исключены из библиотеки. Снимите исключение в разделе «Управление исключёнными моделями» и удалите их — удаление папки никогда не затрагивает файлы моделей.",
|
"notEmptyMessageExcluded": "В этой папке ещё есть {count} файл(ов) модели, из них {excluded} исключены из библиотеки. Снимите исключение в разделе «Управление исключёнными моделями» и удалите их — удаление папки никогда не затрагивает файлы моделей.",
|
||||||
"missingTitle": "Папка не найдена",
|
"missingTitle": "Папка не найдена",
|
||||||
"missingMessage": "Эта папка больше не существует на диске. Обновите боковую панель и повторите попытку.",
|
"missingMessage": "[TODO: Translate] This folder no longer exists on disk. You can remove it from the list.",
|
||||||
|
"removeFromList": "[TODO: Translate] Remove from list",
|
||||||
"symlinkTitle": "Папка является символической ссылкой",
|
"symlinkTitle": "Папка является символической ссылкой",
|
||||||
"symlinkMessage": "Все копии этой папки являются символическими ссылками; удалите ссылку или её цель вне LoRA Manager.",
|
"symlinkMessage": "Все копии этой папки являются символическими ссылками; удалите ссылку или её цель вне LoRA Manager.",
|
||||||
"protectedTitle": "Папка защищена",
|
"protectedTitle": "Папка защищена",
|
||||||
@@ -1440,6 +1441,8 @@
|
|||||||
"busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.",
|
"busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.",
|
||||||
"unsupported": "Удаление папок не поддерживается на этой странице",
|
"unsupported": "Удаление папок не поддерживается на этой странице",
|
||||||
"missing": "Эта папка больше не существует на диске. Обновите боковую панель и повторите попытку.",
|
"missing": "Эта папка больше не существует на диске. Обновите боковую панель и повторите попытку.",
|
||||||
|
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the list",
|
||||||
|
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
|
||||||
"symlink": "Эта папка является символической ссылкой; её нельзя удалить из боковой панели.",
|
"symlink": "Эта папка является символической ссылкой; её нельзя удалить из боковой панели.",
|
||||||
"successMulti": "Папка \"{name}\" удалена из {count} корневых папок моделей",
|
"successMulti": "Папка \"{name}\" удалена из {count} корневых папок моделей",
|
||||||
"partial": "Удалено {count} из {total} копий — не удалось удалить {failed}. Обновите боковую панель и повторите попытку."
|
"partial": "Удалено {count} из {total} копий — не удалось удалить {failed}. Обновите боковую панель и повторите попытку."
|
||||||
|
|||||||
+4
-1
@@ -1418,7 +1418,8 @@
|
|||||||
"notEmptyMessageCount": "该文件夹中仍有 {count} 个模型文件,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。",
|
"notEmptyMessageCount": "该文件夹中仍有 {count} 个模型文件,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。",
|
||||||
"notEmptyMessageExcluded": "该文件夹中仍有 {count} 个模型文件,其中 {excluded} 个已从模型库中排除。请先在“管理已排除的模型”中取消排除并删除它们 —— 删除文件夹不会级联删除模型文件。",
|
"notEmptyMessageExcluded": "该文件夹中仍有 {count} 个模型文件,其中 {excluded} 个已从模型库中排除。请先在“管理已排除的模型”中取消排除并删除它们 —— 删除文件夹不会级联删除模型文件。",
|
||||||
"missingTitle": "未找到文件夹",
|
"missingTitle": "未找到文件夹",
|
||||||
"missingMessage": "该文件夹在磁盘上已不存在,请刷新侧边栏后重试。",
|
"missingMessage": "[TODO: Translate] This folder no longer exists on disk. You can remove it from the list.",
|
||||||
|
"removeFromList": "[TODO: Translate] Remove from list",
|
||||||
"symlinkTitle": "文件夹是符号链接",
|
"symlinkTitle": "文件夹是符号链接",
|
||||||
"symlinkMessage": "该文件夹的每一份副本都是符号链接;请在 LoRA Manager 之外删除该链接或其指向的目标。",
|
"symlinkMessage": "该文件夹的每一份副本都是符号链接;请在 LoRA Manager 之外删除该链接或其指向的目标。",
|
||||||
"protectedTitle": "文件夹受保护",
|
"protectedTitle": "文件夹受保护",
|
||||||
@@ -1440,6 +1441,8 @@
|
|||||||
"busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。",
|
"busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。",
|
||||||
"unsupported": "此页面不支持删除文件夹",
|
"unsupported": "此页面不支持删除文件夹",
|
||||||
"missing": "该文件夹在磁盘上已不存在,请刷新侧边栏后重试。",
|
"missing": "该文件夹在磁盘上已不存在,请刷新侧边栏后重试。",
|
||||||
|
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the list",
|
||||||
|
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
|
||||||
"symlink": "该文件夹是符号链接,无法从侧边栏中删除。",
|
"symlink": "该文件夹是符号链接,无法从侧边栏中删除。",
|
||||||
"successMulti": "已从 {count} 个模型根目录中删除文件夹 \"{name}\"",
|
"successMulti": "已从 {count} 个模型根目录中删除文件夹 \"{name}\"",
|
||||||
"partial": "已删除 {total} 份副本中的 {count} 份 —— {failed} 份无法删除。请刷新侧边栏后重试。"
|
"partial": "已删除 {total} 份副本中的 {count} 份 —— {failed} 份无法删除。请刷新侧边栏后重试。"
|
||||||
|
|||||||
+4
-1
@@ -1418,7 +1418,8 @@
|
|||||||
"notEmptyMessageCount": "該資料夾中仍有 {count} 個模型檔案,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。",
|
"notEmptyMessageCount": "該資料夾中仍有 {count} 個模型檔案,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。",
|
||||||
"notEmptyMessageExcluded": "該資料夾中仍有 {count} 個模型檔案,其中 {excluded} 個已從模型庫中排除。請先在「管理已排除的模型」中取消排除並刪除它們 —— 刪除資料夾不會串聯刪除模型檔案。",
|
"notEmptyMessageExcluded": "該資料夾中仍有 {count} 個模型檔案,其中 {excluded} 個已從模型庫中排除。請先在「管理已排除的模型」中取消排除並刪除它們 —— 刪除資料夾不會串聯刪除模型檔案。",
|
||||||
"missingTitle": "未找到資料夾",
|
"missingTitle": "未找到資料夾",
|
||||||
"missingMessage": "該資料夾在磁碟上已不存在,請重新整理側邊欄後再試。",
|
"missingMessage": "[TODO: Translate] This folder no longer exists on disk. You can remove it from the list.",
|
||||||
|
"removeFromList": "[TODO: Translate] Remove from list",
|
||||||
"symlinkTitle": "資料夾是符號連結",
|
"symlinkTitle": "資料夾是符號連結",
|
||||||
"symlinkMessage": "該資料夾的每一份副本都是符號連結;請在 LoRA Manager 之外刪除該連結或其指向的目標。",
|
"symlinkMessage": "該資料夾的每一份副本都是符號連結;請在 LoRA Manager 之外刪除該連結或其指向的目標。",
|
||||||
"protectedTitle": "資料夾受保護",
|
"protectedTitle": "資料夾受保護",
|
||||||
@@ -1440,6 +1441,8 @@
|
|||||||
"busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。",
|
"busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。",
|
||||||
"unsupported": "此頁面不支援刪除資料夾",
|
"unsupported": "此頁面不支援刪除資料夾",
|
||||||
"missing": "該資料夾在磁碟上已不存在,請重新整理側邊欄後再試。",
|
"missing": "該資料夾在磁碟上已不存在,請重新整理側邊欄後再試。",
|
||||||
|
"forgotten": "[TODO: Translate] Folder \"{name}\" removed from the list",
|
||||||
|
"exists": "[TODO: Translate] The folder is back on disk. Delete it instead.",
|
||||||
"symlink": "該資料夾是符號連結,無法從側邊欄中刪除。",
|
"symlink": "該資料夾是符號連結,無法從側邊欄中刪除。",
|
||||||
"successMulti": "已從 {count} 個模型根目錄中刪除資料夾 \"{name}\"",
|
"successMulti": "已從 {count} 個模型根目錄中刪除資料夾 \"{name}\"",
|
||||||
"partial": "已刪除 {total} 份副本中的 {count} 份 —— {failed} 份無法刪除。請重新整理側邊欄後再試。"
|
"partial": "已刪除 {total} 份副本中的 {count} 份 —— {failed} 份無法刪除。請重新整理側邊欄後再試。"
|
||||||
|
|||||||
+104
-15
@@ -4,7 +4,6 @@
|
|||||||
# import cycles. Breaking them would require an architectural refactor.
|
# import cycles. Breaking them would require an architectural refactor.
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import posixpath
|
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import folder_paths # pyright: ignore[reportMissingImports]
|
import folder_paths # pyright: ignore[reportMissingImports]
|
||||||
@@ -20,6 +19,7 @@ from .utils.cache_paths import CacheType, get_cache_file_path, get_legacy_cache_
|
|||||||
from .utils.constants import (
|
from .utils.constants import (
|
||||||
OTHER_MODEL_FOLDER_SUBTYPES,
|
OTHER_MODEL_FOLDER_SUBTYPES,
|
||||||
)
|
)
|
||||||
|
from .utils.root_paths import normalize_root_identity
|
||||||
from .utils.settings_paths import (
|
from .utils.settings_paths import (
|
||||||
ensure_settings_file,
|
ensure_settings_file,
|
||||||
get_settings_dir,
|
get_settings_dir,
|
||||||
@@ -34,14 +34,15 @@ standalone_mode = (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Saved folder-path keys mapped to the model type whose cache can tell whether a
|
||||||
def _normalize_root_identity(path: str) -> str:
|
# root that is missing right now is still worth keeping (issue #1146). The
|
||||||
"""Normalize a root path for comparisons across slash styles."""
|
# checkpoint page holds both checkpoints and unet roots.
|
||||||
|
_CACHE_MODEL_TYPE_BY_FOLDER_KEY = {
|
||||||
normalized = posixpath.normpath(path.strip().replace("\\", "/"))
|
"loras": "lora",
|
||||||
if len(normalized) >= 2 and normalized[1] == ":":
|
"checkpoints": "checkpoint",
|
||||||
return normalized.lower()
|
"unet": "checkpoint",
|
||||||
return normalized
|
"embeddings": "embedding",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _append_new_paths(current: List[str], candidates: Iterable[str]) -> List[str]:
|
def _append_new_paths(current: List[str], candidates: Iterable[str]) -> List[str]:
|
||||||
@@ -52,11 +53,11 @@ def _append_new_paths(current: List[str], candidates: Iterable[str]) -> List[str
|
|||||||
to the root *order* would move user data.
|
to the root *order* would move user data.
|
||||||
"""
|
"""
|
||||||
merged = list(current)
|
merged = list(current)
|
||||||
seen = {_normalize_root_identity(path) for path in merged}
|
seen = {normalize_root_identity(path) for path in merged}
|
||||||
for path in candidates:
|
for path in candidates:
|
||||||
if not isinstance(path, str) or not path.strip():
|
if not isinstance(path, str) or not path.strip():
|
||||||
continue
|
continue
|
||||||
identity = _normalize_root_identity(path)
|
identity = normalize_root_identity(path)
|
||||||
if identity in seen:
|
if identity in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(identity)
|
seen.add(identity)
|
||||||
@@ -78,15 +79,15 @@ def _resolve_valid_default_root(
|
|||||||
stripped = path.strip()
|
stripped = path.strip()
|
||||||
if not stripped:
|
if not stripped:
|
||||||
continue
|
continue
|
||||||
identity = _normalize_root_identity(stripped)
|
identity = normalize_root_identity(stripped)
|
||||||
if identity in seen:
|
if identity in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(identity)
|
seen.add(identity)
|
||||||
fallback_paths.append(stripped)
|
fallback_paths.append(stripped)
|
||||||
|
|
||||||
allowed = {_normalize_root_identity(path) for path in fallback_paths}
|
allowed = {normalize_root_identity(path) for path in fallback_paths}
|
||||||
|
|
||||||
if current and _normalize_root_identity(current) in allowed:
|
if current and normalize_root_identity(current) in allowed:
|
||||||
return current
|
return current
|
||||||
|
|
||||||
if not valid_paths:
|
if not valid_paths:
|
||||||
@@ -320,6 +321,77 @@ class Config:
|
|||||||
"Could not load extra paths from library settings: %s", exc
|
"Could not load extra paths from library settings: %s", exc
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _drop_missing_roots_without_cache(
|
||||||
|
self,
|
||||||
|
folder_paths_map: Dict[str, List[str]],
|
||||||
|
default_roots: Mapping[str, str],
|
||||||
|
) -> Dict[str, List[str]]:
|
||||||
|
"""Drop saved roots that neither exist nor hold cached entries (issue #1146).
|
||||||
|
|
||||||
|
The startup sync persists what is *configured* rather than what is readable,
|
||||||
|
so a switched-off drive keeps its path (see ``save_folder_paths_to_settings``).
|
||||||
|
A directory the host registered without ever creating it -- ComfyUI adds
|
||||||
|
``<output>/loras`` and friends for its save nodes whether or not they exist --
|
||||||
|
came along for the ride and stayed there as a permanently "unavailable" root.
|
||||||
|
|
||||||
|
A root that still holds cached entries is exactly the switched-off drive the
|
||||||
|
persistence exists for, so it is kept. A root named by the library's
|
||||||
|
``default_*_root`` is kept even when it is empty: dropping it would let the
|
||||||
|
next boot repair the default onto another root.
|
||||||
|
|
||||||
|
Best effort: when the cache cannot be consulted, every root is kept.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .services.persistent_model_cache import get_persistent_cache
|
||||||
|
|
||||||
|
cache = get_persistent_cache()
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.debug("Keeping every configured model root: %s", exc)
|
||||||
|
return folder_paths_map
|
||||||
|
|
||||||
|
protected = {
|
||||||
|
identity
|
||||||
|
for identity in (
|
||||||
|
normalize_root_identity(path) for path in default_roots.values()
|
||||||
|
)
|
||||||
|
if identity
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered: Dict[str, List[str]] = {}
|
||||||
|
for key, paths in folder_paths_map.items():
|
||||||
|
missing = [path for path in paths if not os.path.exists(path)]
|
||||||
|
if not missing:
|
||||||
|
filtered[key] = list(paths)
|
||||||
|
continue
|
||||||
|
|
||||||
|
probe = [
|
||||||
|
path
|
||||||
|
for path in missing
|
||||||
|
if normalize_root_identity(path) not in protected
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
cached = cache.roots_with_cached_entries(
|
||||||
|
_CACHE_MODEL_TYPE_BY_FOLDER_KEY.get(key, "other"), probe
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(
|
||||||
|
"Keeping every configured %s root (%s): %s", key, missing, exc
|
||||||
|
)
|
||||||
|
filtered[key] = list(paths)
|
||||||
|
continue
|
||||||
|
|
||||||
|
cached_ids = {normalize_root_identity(path) for path in cached}
|
||||||
|
filtered[key] = [
|
||||||
|
path
|
||||||
|
for path in paths
|
||||||
|
if os.path.exists(path)
|
||||||
|
or normalize_root_identity(path) in protected
|
||||||
|
or normalize_root_identity(path) in cached_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
|
||||||
def save_folder_paths_to_settings(self):
|
def save_folder_paths_to_settings(self):
|
||||||
"""Persist ComfyUI-derived folder paths to the multi-library settings."""
|
"""Persist ComfyUI-derived folder paths to the multi-library settings."""
|
||||||
try:
|
try:
|
||||||
@@ -533,9 +605,26 @@ class Config:
|
|||||||
or self._should_activate_comfy_library(libraries, libraries_changed)
|
or self._should_activate_comfy_library(libraries, libraries_changed)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# A root that the host registered but never created (and that holds no
|
||||||
|
# cached models) is not a library path: persisting it would bring it
|
||||||
|
# back as an unavailable root on every boot, and in standalone mode the
|
||||||
|
# saved paths are the only source of truth (issue #1146).
|
||||||
|
default_roots = {
|
||||||
|
"loras": default_lora_root
|
||||||
|
or comfy_library.get("default_lora_root", ""),
|
||||||
|
"checkpoints": default_checkpoint_root
|
||||||
|
or comfy_library.get("default_checkpoint_root", ""),
|
||||||
|
"unet": comfy_library.get("default_unet_root", ""),
|
||||||
|
"embeddings": default_embedding_root
|
||||||
|
or comfy_library.get("default_embedding_root", ""),
|
||||||
|
}
|
||||||
|
persisted_folder_paths = self._drop_missing_roots_without_cache(
|
||||||
|
target_folder_paths, default_roots
|
||||||
|
)
|
||||||
|
|
||||||
settings_service.upsert_library(
|
settings_service.upsert_library(
|
||||||
"comfyui",
|
"comfyui",
|
||||||
folder_paths=target_folder_paths,
|
folder_paths=persisted_folder_paths,
|
||||||
extra_folder_paths=extra_folder_paths,
|
extra_folder_paths=extra_folder_paths,
|
||||||
default_lora_root=default_lora_root,
|
default_lora_root=default_lora_root,
|
||||||
default_checkpoint_root=default_checkpoint_root,
|
default_checkpoint_root=default_checkpoint_root,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ from ...utils.civitai_utils import resolve_license_payload
|
|||||||
from ...utils.file_utils import calculate_sha256
|
from ...utils.file_utils import calculate_sha256
|
||||||
from ...utils.metadata_manager import MetadataManager
|
from ...utils.metadata_manager import MetadataManager
|
||||||
from ...utils.paid_access import is_early_access_deadline_active
|
from ...utils.paid_access import is_early_access_deadline_active
|
||||||
|
from ...utils.root_paths import normalize_root_identity
|
||||||
from ...utils.sidecar_paths import get_metadata_path
|
from ...utils.sidecar_paths import get_metadata_path
|
||||||
from ...utils.url_utils import relative_root_prefix
|
from ...utils.url_utils import relative_root_prefix
|
||||||
|
|
||||||
@@ -1170,7 +1171,18 @@ class ModelQueryHandler:
|
|||||||
|
|
||||||
if requested_roots:
|
if requested_roots:
|
||||||
configured = self._service.get_model_roots()
|
configured = self._service.get_model_roots()
|
||||||
unknown = [root for root in requested_roots if root not in configured]
|
# Compare by identity, not by string: callers may pass the host's
|
||||||
|
# own spelling of a root (backslashes or a differently cased drive
|
||||||
|
# letter on Windows, as settings.json and ComfyUI show it), which
|
||||||
|
# names the same directory the live list holds forward-slashed.
|
||||||
|
known_roots = {
|
||||||
|
normalize_root_identity(root) for root in configured
|
||||||
|
}
|
||||||
|
unknown = [
|
||||||
|
root
|
||||||
|
for root in requested_roots
|
||||||
|
if normalize_root_identity(root) not in known_roots
|
||||||
|
]
|
||||||
if unknown:
|
if unknown:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"error": "Unknown model root(s)", "roots": unknown}, status=400
|
{"error": "Unknown model root(s)", "roots": unknown}, status=400
|
||||||
@@ -2617,6 +2629,39 @@ class ModelMoveHandler:
|
|||||||
self._logger.error("Error deleting folder: %s", exc, exc_info=True)
|
self._logger.error("Error deleting folder: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def forget_folder(self, request: web.Request) -> web.Response:
|
||||||
|
"""Drop the records of a folder that no longer exists on disk (#1145).
|
||||||
|
|
||||||
|
The sidebar's delete flow reaches this when the node it holds maps to no
|
||||||
|
directory any more: there is nothing to delete, so the only cleanup left
|
||||||
|
is forgetting the folder entry and the cached models under it.
|
||||||
|
"""
|
||||||
|
data: Dict[str, Any] = {}
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except Exception:
|
||||||
|
data = {}
|
||||||
|
folder = str(data.get("folder") or request.query.get("folder") or "").strip()
|
||||||
|
if not folder:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Folder is required"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self._move_service.forget_folder(folder)
|
||||||
|
if result.get("success"):
|
||||||
|
_broadcast_models_changed()
|
||||||
|
return web.json_response(result, status=200)
|
||||||
|
|
||||||
|
# `exists` is a conflict between the tree the client rendered and the
|
||||||
|
# on-disk truth: the folder is still there, so it must be deleted.
|
||||||
|
status = 409 if result.get("code") == "exists" else 400
|
||||||
|
return web.json_response(result, status=status)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error forgetting folder: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def rename_folder(self, request: web.Request) -> web.Response:
|
async def rename_folder(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
@@ -3689,6 +3734,7 @@ class ModelHandlerSet:
|
|||||||
"create_folder": self.move.create_folder,
|
"create_folder": self.move.create_folder,
|
||||||
"resolve_folder": self.move.resolve_folder,
|
"resolve_folder": self.move.resolve_folder,
|
||||||
"delete_folder": self.move.delete_folder,
|
"delete_folder": self.move.delete_folder,
|
||||||
|
"forget_folder": self.move.forget_folder,
|
||||||
"rename_folder": self.move.rename_folder,
|
"rename_folder": self.move.rename_folder,
|
||||||
"auto_organize_models": self.auto_organize.auto_organize_models,
|
"auto_organize_models": self.auto_organize.auto_organize_models,
|
||||||
"get_auto_organize_progress": self.auto_organize.get_auto_organize_progress,
|
"get_auto_organize_progress": self.auto_organize.get_auto_organize_progress,
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"),
|
RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"),
|
||||||
RouteDefinition("POST", "/api/lm/{prefix}/create-folder", "create_folder"),
|
RouteDefinition("POST", "/api/lm/{prefix}/create-folder", "create_folder"),
|
||||||
RouteDefinition("POST", "/api/lm/{prefix}/delete-folder", "delete_folder"),
|
RouteDefinition("POST", "/api/lm/{prefix}/delete-folder", "delete_folder"),
|
||||||
|
# Forgetting a folder that is gone from disk is a state change, so it takes
|
||||||
|
# GET with query params too (the browser extension is GET-only by convention).
|
||||||
|
RouteDefinition("POST", "/api/lm/{prefix}/forget-folder", "forget_folder"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/forget-folder", "forget_folder"),
|
||||||
RouteDefinition("POST", "/api/lm/{prefix}/rename-folder", "rename_folder"),
|
RouteDefinition("POST", "/api/lm/{prefix}/rename-folder", "rename_folder"),
|
||||||
RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
|
RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
|
||||||
RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
|
RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import sqlite3
|
|||||||
import time
|
import time
|
||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
|
from ..utils.cache_db import closing_connection
|
||||||
from ..utils.cache_paths import get_cache_base_dir
|
from ..utils.cache_paths import get_cache_base_dir
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -127,7 +128,7 @@ class DownloadQueueService:
|
|||||||
def _initialize_schema(self) -> None:
|
def _initialize_schema(self) -> None:
|
||||||
if self._schema_initialized:
|
if self._schema_initialized:
|
||||||
return
|
return
|
||||||
with self._connect() as conn:
|
with closing_connection(self._connect()) as conn:
|
||||||
conn.executescript(self._SCHEMA_TABLES)
|
conn.executescript(self._SCHEMA_TABLES)
|
||||||
|
|
||||||
# Databases created by older versions lack
|
# Databases created by older versions lack
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import sqlite3
|
|||||||
import time
|
import time
|
||||||
from typing import Iterable, Mapping, Optional, Sequence
|
from typing import Iterable, Mapping, Optional, Sequence
|
||||||
|
|
||||||
|
from ..utils.cache_db import closing_connection
|
||||||
from ..utils.cache_paths import get_cache_base_dir
|
from ..utils.cache_paths import get_cache_base_dir
|
||||||
from .settings_manager import get_settings_manager
|
from .settings_manager import get_settings_manager
|
||||||
|
|
||||||
@@ -100,7 +101,7 @@ class DownloadedVersionHistoryService:
|
|||||||
def _initialize_schema(self) -> None:
|
def _initialize_schema(self) -> None:
|
||||||
if self._schema_initialized:
|
if self._schema_initialized:
|
||||||
return
|
return
|
||||||
with self._connect() as conn:
|
with closing_connection(self._connect()) as conn:
|
||||||
conn.executescript(self._SCHEMA)
|
conn.executescript(self._SCHEMA)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
self._schema_initialized = True
|
self._schema_initialized = True
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import fnmatch
|
import fnmatch
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import posixpath
|
||||||
import shutil
|
import shutil
|
||||||
from typing import Any, Dict, List, Optional, Sequence, Set
|
from typing import Any, Dict, List, Optional, Sequence, Set
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
@@ -12,7 +13,10 @@ from ..utils.recipes_paths import path_is_or_contains_recipes_dir
|
|||||||
from ..utils.sidecar_paths import is_centralized, resolve_centralized_dir_for_dir
|
from ..utils.sidecar_paths import is_centralized, resolve_centralized_dir_for_dir
|
||||||
from ..services.settings_manager import get_settings_manager
|
from ..services.settings_manager import get_settings_manager
|
||||||
from ..services.model_lifecycle_service import _require_path_in_library_roots
|
from ..services.model_lifecycle_service import _require_path_in_library_roots
|
||||||
from ..services.pending_delete_service import PENDING_DELETE_DIR_NAME
|
from ..services.pending_delete_service import (
|
||||||
|
PENDING_DELETE_DIR_NAME,
|
||||||
|
staging_dir_has_content,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -33,12 +37,11 @@ def normalize_relative_folder(folder: str) -> str:
|
|||||||
if normalized.startswith("/") or (len(normalized) > 1 and normalized[1] == ":"):
|
if normalized.startswith("/") or (len(normalized) > 1 and normalized[1] == ":"):
|
||||||
raise ValueError("Folder path must be relative to a library root")
|
raise ValueError("Folder path must be relative to a library root")
|
||||||
|
|
||||||
normalized = os.path.normpath(normalized)
|
# posixpath, not os.path: ntpath.normpath would fold the business form back
|
||||||
if (
|
# to backslashes, and every stored folder, cache entry and comparison in the
|
||||||
normalized == ".."
|
# app is forward-slashed.
|
||||||
or normalized.startswith("../")
|
normalized = posixpath.normpath(normalized)
|
||||||
or normalized.startswith(".." + os.sep)
|
if normalized == ".." or normalized.startswith("../"):
|
||||||
):
|
|
||||||
raise ValueError("Folder path must stay inside the library root")
|
raise ValueError("Folder path must stay inside the library root")
|
||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
@@ -589,6 +592,33 @@ class ModelMoveService:
|
|||||||
return rel.replace(os.sep, "/")
|
return rel.replace(os.sep, "/")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
async def forget_folder(self, folder: str) -> Dict[str, Any]:
|
||||||
|
"""Drop the records of a folder that is on disk under no root (#1145).
|
||||||
|
|
||||||
|
A directory moved or deleted with a file manager can leave the sidebar
|
||||||
|
holding a node no delete can clear: the folder is gone, so there is
|
||||||
|
nothing to remove and every attempt answered "Folder no longer exists".
|
||||||
|
This drops what remains — the recorded folder entry and the cached model
|
||||||
|
rows under it — and is refused while any root still holds the directory,
|
||||||
|
because then the caller wants a delete, not a cleanup.
|
||||||
|
"""
|
||||||
|
resolution = self.resolve_folder(folder)
|
||||||
|
if not resolution.get("success"):
|
||||||
|
return resolution
|
||||||
|
|
||||||
|
normalized = resolution.get("folder") or ""
|
||||||
|
if not normalized:
|
||||||
|
return {"success": False, "error": "Folder is required"}
|
||||||
|
if resolution.get("candidates"):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"code": "exists",
|
||||||
|
"error": "The folder still exists on disk",
|
||||||
|
}
|
||||||
|
|
||||||
|
await self._forget_folder(normalized)
|
||||||
|
return {"success": True, "folder": normalized}
|
||||||
|
|
||||||
def resolve_folder(self, folder: str) -> Dict[str, Any]:
|
def resolve_folder(self, folder: str) -> Dict[str, Any]:
|
||||||
"""Map a library-relative folder onto the directories it stands for.
|
"""Map a library-relative folder onto the directories it stands for.
|
||||||
|
|
||||||
@@ -685,11 +715,29 @@ class ModelMoveService:
|
|||||||
}
|
}
|
||||||
if not os.path.isdir(absolute_path):
|
if not os.path.isdir(absolute_path):
|
||||||
# `missing` is its own code so the sidebar can tell "this node's
|
# `missing` is its own code so the sidebar can tell "this node's
|
||||||
# directory is gone, refresh" apart from a failed operation.
|
# directory is gone" apart from a failed operation. There is
|
||||||
|
# nothing left to delete, but the sidebar can still be holding the
|
||||||
|
# node: a folder moved or removed with a file manager leaves its
|
||||||
|
# records behind, and "Folder no longer exists" was a dead end --
|
||||||
|
# the ghost could not be cleared from the UI at all (issue #1145).
|
||||||
|
# The real delete therefore forgets those records and reports
|
||||||
|
# success; the dry run stays a pure read and keeps answering
|
||||||
|
# `missing`, which is what makes the dialog offer the removal.
|
||||||
|
if dry_run:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"code": "missing",
|
||||||
|
"error": "Folder no longer exists",
|
||||||
|
}
|
||||||
|
|
||||||
|
relative_folder = self._calculate_relative_folder(absolute_path)
|
||||||
|
await self._forget_folder(relative_folder, absolute_path)
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": True,
|
||||||
"code": "missing",
|
"code": "missing",
|
||||||
"error": "Folder no longer exists",
|
"already_missing": True,
|
||||||
|
"folder": relative_folder,
|
||||||
|
"folder_path": absolute_path.replace(os.sep, "/"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if self._is_model_root(absolute_path):
|
if self._is_model_root(absolute_path):
|
||||||
@@ -836,7 +884,12 @@ class ModelMoveService:
|
|||||||
excluded_paths = self._excluded_model_paths()
|
excluded_paths = self._excluded_model_paths()
|
||||||
|
|
||||||
for dirpath, dirnames, filenames in os.walk(absolute_path):
|
for dirpath, dirnames, filenames in os.walk(absolute_path):
|
||||||
if PENDING_DELETE_DIR_NAME in dirnames:
|
# The container alone means nothing: an empty `.lm-pending-delete`
|
||||||
|
# is a leftover from a purge, an undo or a failed staging, and
|
||||||
|
# treating it as pending made the folder undeletable for good.
|
||||||
|
if PENDING_DELETE_DIR_NAME in dirnames and staging_dir_has_content(
|
||||||
|
os.path.join(dirpath, PENDING_DELETE_DIR_NAME)
|
||||||
|
):
|
||||||
pending_delete_job = True
|
pending_delete_job = True
|
||||||
|
|
||||||
for name in dirnames:
|
for name in dirnames:
|
||||||
@@ -1009,7 +1062,12 @@ class ModelMoveService:
|
|||||||
|
|
||||||
new_relative = self._calculate_relative_folder(target)
|
new_relative = self._calculate_relative_folder(target)
|
||||||
await self._rename_folder_records(
|
await self._rename_folder_records(
|
||||||
previous_relative, new_relative, absolute_path, target
|
previous_relative,
|
||||||
|
new_relative,
|
||||||
|
# Business paths, like every other absolute path this service
|
||||||
|
# hands out: the scanner re-keys cache entries by prefix.
|
||||||
|
absolute_path.replace(os.sep, "/"),
|
||||||
|
target.replace(os.sep, "/"),
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1027,9 +1085,15 @@ class ModelMoveService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _has_pending_delete_job(absolute_path: str) -> bool:
|
def _has_pending_delete_job(absolute_path: str) -> bool:
|
||||||
"""Return True when a staged-delete batch lives inside the subtree."""
|
"""Return True when a staged-delete batch lives inside the subtree.
|
||||||
for _dirpath, dirnames, _filenames in os.walk(absolute_path):
|
|
||||||
if PENDING_DELETE_DIR_NAME in dirnames:
|
An empty ``.lm-pending-delete`` container does not count: it is a
|
||||||
|
leftover, not a batch, and a rename must not be refused because of one.
|
||||||
|
"""
|
||||||
|
for dirpath, dirnames, _filenames in os.walk(absolute_path):
|
||||||
|
if PENDING_DELETE_DIR_NAME in dirnames and staging_dir_has_content(
|
||||||
|
os.path.join(dirpath, PENDING_DELETE_DIR_NAME)
|
||||||
|
):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
+158
-42
@@ -25,6 +25,7 @@ from ..utils.recipes_paths import (
|
|||||||
get_effective_recipes_dir,
|
get_effective_recipes_dir,
|
||||||
normalized_recipes_dir_key,
|
normalized_recipes_dir_key,
|
||||||
)
|
)
|
||||||
|
from ..utils.root_paths import normalize_root_identity
|
||||||
from .model_cache import ModelCache
|
from .model_cache import ModelCache
|
||||||
from .model_hash_index import ModelHashIndex
|
from .model_hash_index import ModelHashIndex
|
||||||
from .model_lifecycle_service import delete_model_artifacts, _require_path_in_library_roots
|
from .model_lifecycle_service import delete_model_artifacts, _require_path_in_library_roots
|
||||||
@@ -203,16 +204,33 @@ def _root_display_labels(roots: Sequence[str]) -> Dict[str, str]:
|
|||||||
return {root: _truncate_label(label) for root, label in labels.items()}
|
return {root: _truncate_label(label) for root, label in labels.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _root_identity(root: str) -> str:
|
||||||
|
"""Comparable identity of a model root, folding the host's spelling (issue #1146).
|
||||||
|
|
||||||
|
The live root list is forward-slashed while ``Config`` keeps the configured
|
||||||
|
paths exactly as the host handed them over -- ComfyUI's ``os.path.normpath()``
|
||||||
|
output is all backslashes on Windows. Comparing the two as raw strings turns
|
||||||
|
one directory into two roots: a duplicated menu row, a bogus "unavailable"
|
||||||
|
report, and -- worse -- an unreachable prefix registered over a root the walk
|
||||||
|
just read, which stops deleted models from ever being pruned.
|
||||||
|
"""
|
||||||
|
return normalize_root_identity(root)
|
||||||
|
|
||||||
|
|
||||||
def _normalized_root_prefix(root: str) -> str:
|
def _normalized_root_prefix(root: str) -> str:
|
||||||
"""Forward-slash root prefix used to attribute cached paths to a root."""
|
"""Forward-slash root prefix used to attribute cached paths to a root."""
|
||||||
prefix = os.path.normpath(root).replace(os.sep, "/")
|
prefix = _root_identity(root)
|
||||||
if not prefix.endswith("/"):
|
if prefix and not prefix.endswith("/"):
|
||||||
prefix += "/"
|
prefix += "/"
|
||||||
return prefix.lower() if _CASE_INSENSITIVE_PATHS else prefix
|
return prefix
|
||||||
|
|
||||||
|
|
||||||
def _path_matches_prefix(path: str, prefix: str) -> bool:
|
def _path_matches_prefix(path: str, prefix: str) -> bool:
|
||||||
"""Match a cached business path against a normalized scope prefix."""
|
"""Match a cached business path against a normalized scope prefix."""
|
||||||
|
if not prefix:
|
||||||
|
# An empty prefix would match every path; callers filter empty roots, so
|
||||||
|
# keep it inert instead of turning it into a catch-all.
|
||||||
|
return False
|
||||||
candidate = path.lower() if _CASE_INSENSITIVE_PATHS else path
|
candidate = path.lower() if _CASE_INSENSITIVE_PATHS else path
|
||||||
return candidate.startswith(prefix)
|
return candidate.startswith(prefix)
|
||||||
|
|
||||||
@@ -291,6 +309,36 @@ class _UnreachablePaths:
|
|||||||
return prefix
|
return prefix
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def covers_folder(self, root: str, folder: str) -> bool:
|
||||||
|
"""Whether *folder* (relative to *root*) sits in an unreadable area.
|
||||||
|
|
||||||
|
Used when refreshing the recorded folder list: an entry the walk did not
|
||||||
|
find is only kept while a *specific* area we could not read may still hold
|
||||||
|
it -- a directory ``os.walk`` failed to enter, or a first-level symlink
|
||||||
|
whose target is gone. Both directions count: the folder may be inside the
|
||||||
|
unreadable directory, or hold it.
|
||||||
|
|
||||||
|
A root that is unreadable *as a whole* (drive switched off) deliberately
|
||||||
|
does not count here. The recorded list holds library-relative names, so a
|
||||||
|
missing root would trivially "cover" every entry and no ghost could ever be
|
||||||
|
cleared (issue #1145). Its folders still show up while they hold cached
|
||||||
|
models -- ``get_all_folders()`` unions the model-derived folders -- and a
|
||||||
|
genuinely empty folder comes back the next time that root is walked.
|
||||||
|
"""
|
||||||
|
relative = (folder or "").replace("\\", "/").strip("/")
|
||||||
|
if not relative:
|
||||||
|
return False
|
||||||
|
|
||||||
|
root_prefix = _normalized_root_prefix(root)
|
||||||
|
candidate = f"{root_prefix}{relative}/"
|
||||||
|
if _CASE_INSENSITIVE_PATHS:
|
||||||
|
candidate = candidate.lower()
|
||||||
|
return any(
|
||||||
|
prefix != root_prefix
|
||||||
|
and (candidate.startswith(prefix) or prefix.startswith(candidate))
|
||||||
|
for prefix in self._prefixes
|
||||||
|
)
|
||||||
|
|
||||||
def record_kept(self, prefix: str, amount: int = 1) -> None:
|
def record_kept(self, prefix: str, amount: int = 1) -> None:
|
||||||
detail = self._details.get(prefix)
|
detail = self._details.get(prefix)
|
||||||
if detail is not None:
|
if detail is not None:
|
||||||
@@ -332,7 +380,8 @@ def _count_cached_entries_for_prefixes(
|
|||||||
for path in cached_paths:
|
for path in cached_paths:
|
||||||
candidate = path.lower() if case_insensitive else path
|
candidate = path.lower() if case_insensitive else path
|
||||||
for key, prefix in ordered:
|
for key, prefix in ordered:
|
||||||
if candidate.startswith(prefix):
|
# An empty prefix would claim every path; see _path_matches_prefix.
|
||||||
|
if prefix and candidate.startswith(prefix):
|
||||||
counts[key] += 1
|
counts[key] += 1
|
||||||
break
|
break
|
||||||
return counts
|
return counts
|
||||||
@@ -1621,8 +1670,9 @@ class ModelScanner:
|
|||||||
seen: Set[str] = set()
|
seen: Set[str] = set()
|
||||||
for key in keys:
|
for key in keys:
|
||||||
for root in config.configured_roots_for(key):
|
for root in config.configured_roots_for(key):
|
||||||
if root and root not in seen:
|
identity = _root_identity(root)
|
||||||
seen.add(root)
|
if identity and identity not in seen:
|
||||||
|
seen.add(identity)
|
||||||
roots.append(root)
|
roots.append(root)
|
||||||
return roots
|
return roots
|
||||||
|
|
||||||
@@ -1651,32 +1701,44 @@ class ModelScanner:
|
|||||||
roots: List[str] = []
|
roots: List[str] = []
|
||||||
seen: Set[str] = set()
|
seen: Set[str] = set()
|
||||||
for root in self.get_model_roots():
|
for root in self.get_model_roots():
|
||||||
if not root or root in seen:
|
identity = _root_identity(root)
|
||||||
|
if not identity or identity in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(root)
|
seen.add(identity)
|
||||||
roots.append(root)
|
roots.append(root)
|
||||||
|
|
||||||
# Configured roots that are missing from the live list: their directory
|
# Configured roots that are missing from the live list: their directory
|
||||||
# did not exist when it was built (a drive switched off at startup).
|
# did not exist when it was built (a drive switched off at startup).
|
||||||
# They are reported so the menu offers a row in both states instead of
|
# Compared by identity, never by string -- the live list is forward-slashed
|
||||||
# hiding the root until the process is restarted.
|
# while Config keeps the host's own spelling, which is all backslashes on
|
||||||
unavailable = [
|
# Windows (issue #1146). Only roots that still hold cached entries are
|
||||||
root for root in self.get_configured_model_roots() if root not in seen
|
# reported: a path that neither exists nor was ever scanned has nothing to
|
||||||
]
|
# keep, and ComfyUI registers `<output>/loras` for its save nodes whether or
|
||||||
|
# not that directory is there.
|
||||||
|
candidates: List[str] = []
|
||||||
|
reported: Set[str] = set()
|
||||||
|
for root in self.get_configured_model_roots():
|
||||||
|
identity = _root_identity(root)
|
||||||
|
if not identity or identity in seen or identity in reported:
|
||||||
|
continue
|
||||||
|
reported.add(identity)
|
||||||
|
candidates.append(root)
|
||||||
|
|
||||||
labels = _root_display_labels(roots + unavailable)
|
|
||||||
cached_paths = (
|
cached_paths = (
|
||||||
{item.get('file_path', '') for item in self._cache.raw_data}
|
{item.get('file_path', '') for item in self._cache.raw_data}
|
||||||
if self._cache is not None
|
if self._cache is not None
|
||||||
else set()
|
else set()
|
||||||
)
|
)
|
||||||
counts = _count_cached_entries_per_root(cached_paths, roots + unavailable)
|
counts = _count_cached_entries_per_root(cached_paths, roots + candidates)
|
||||||
|
unavailable = [root for root in candidates if counts.get(root, 0)]
|
||||||
|
|
||||||
|
labels = _root_display_labels(roots + unavailable)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
'path': root.replace(os.sep, '/'),
|
'path': root.replace(os.sep, '/'),
|
||||||
'label': labels[root],
|
'label': labels[root],
|
||||||
'reachable': os.path.exists(root),
|
'reachable': os.path.exists(root),
|
||||||
'available': root in seen,
|
'available': _root_identity(root) in seen,
|
||||||
'models': counts.get(root, 0),
|
'models': counts.get(root, 0),
|
||||||
}
|
}
|
||||||
for root in roots + unavailable
|
for root in roots + unavailable
|
||||||
@@ -1801,25 +1863,37 @@ class ModelScanner:
|
|||||||
configured_roots: List[str] = []
|
configured_roots: List[str] = []
|
||||||
seen_roots: Set[str] = set()
|
seen_roots: Set[str] = set()
|
||||||
for root_path in self.get_model_roots():
|
for root_path in self.get_model_roots():
|
||||||
if not root_path or root_path in seen_roots:
|
identity = _root_identity(root_path)
|
||||||
|
if not identity or identity in seen_roots:
|
||||||
continue
|
continue
|
||||||
seen_roots.add(root_path)
|
seen_roots.add(identity)
|
||||||
configured_roots.append(root_path)
|
configured_roots.append(root_path)
|
||||||
|
|
||||||
# Configured roots that never made it into the live list (their
|
# Configured roots that never made it into the live list (their
|
||||||
# directory was missing when it was built). They are outside the scan
|
# directory was missing when it was built). They are outside the scan
|
||||||
# scope entirely — which is exactly why their cached entries survive
|
# scope entirely — which is exactly why their cached entries survive
|
||||||
# — so they only need reporting, and their labels must not collide
|
# — so they only need reporting, and their labels must not collide
|
||||||
# with the live ones.
|
# with the live ones. Identity, not string: the live list is
|
||||||
configured_only = [
|
# forward-slashed while Config keeps the host's own spelling -- all
|
||||||
root for root in self.get_configured_model_roots()
|
# backslashes on Windows -- and a raw compare would report every root
|
||||||
if root and root not in seen_roots
|
# twice and shadow it as unreachable (issue #1146).
|
||||||
]
|
configured_only: List[str] = []
|
||||||
|
for root in self.get_configured_model_roots():
|
||||||
|
identity = _root_identity(root)
|
||||||
|
if not identity or identity in seen_roots:
|
||||||
|
continue
|
||||||
|
seen_roots.add(identity)
|
||||||
|
configured_only.append(root)
|
||||||
|
|
||||||
all_labels = _root_display_labels(configured_roots + configured_only)
|
all_labels = _root_display_labels(configured_roots + configured_only)
|
||||||
|
scope_root_ids = (
|
||||||
|
{_root_identity(root) for root in scope.roots}
|
||||||
|
if scope is not None and scope.roots is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
scope_roots = [
|
scope_roots = [
|
||||||
root for root in configured_roots
|
root for root in configured_roots
|
||||||
if scope is None or scope.roots is None or root in scope.roots
|
if scope_root_ids is None or _root_identity(root) in scope_root_ids
|
||||||
]
|
]
|
||||||
scope_folder = scope.folder if scope is not None else None
|
scope_folder = scope.folder if scope is not None else None
|
||||||
|
|
||||||
@@ -1853,7 +1927,12 @@ class ModelScanner:
|
|||||||
)
|
)
|
||||||
for root_path, _prefix in folder_candidates:
|
for root_path, _prefix in folder_candidates:
|
||||||
kept = folder_counts.get(root_path, 0)
|
kept = folder_counts.get(root_path, 0)
|
||||||
if scope_folder is not None and not kept:
|
# Only a root that still holds cached entries is reported --
|
||||||
|
# and protected from pruning. A configured path that neither
|
||||||
|
# exists nor was ever scanned has nothing to keep, and ComfyUI
|
||||||
|
# registers `<output>/loras` for its save nodes even when that
|
||||||
|
# directory is absent (issue #1146).
|
||||||
|
if not kept:
|
||||||
continue
|
continue
|
||||||
unreachable.add(root_path, 'root_unavailable', kept=kept)
|
unreachable.add(root_path, 'root_unavailable', kept=kept)
|
||||||
skipped_roots.append({
|
skipped_roots.append({
|
||||||
@@ -2148,22 +2227,43 @@ class ModelScanner:
|
|||||||
total_removed += dedup_removed
|
total_removed += dedup_removed
|
||||||
|
|
||||||
# The walk above visited every directory *in scope*, so refresh the
|
# The walk above visited every directory *in scope*, so refresh the
|
||||||
# recorded folder list (including empty folders) when nothing was
|
# recorded folder list (including empty folders) when this run could
|
||||||
# left unverified — e.g. an empty folder was created or removed
|
# speak for every root: e.g. an empty folder was created or removed
|
||||||
# externally. A scoped scan, or one with unreadable paths, can only
|
# externally.
|
||||||
# add to the list: dropping folders this walk never looked at would
|
#
|
||||||
# empty the sidebar for every drive that was not scanned.
|
# A folder the walk did not find is dropped unless a root we could not
|
||||||
|
# read still covers it — the drive is off, ``os.walk`` failed to enter
|
||||||
|
# a directory, or a first-level symlink lost its target. Keeping the
|
||||||
|
# *whole* list whenever anything was unreadable is what made a ghost
|
||||||
|
# folder outlive every refresh, a restart and even the folder being
|
||||||
|
# deleted in the UI (issue #1145). A scoped scan cannot speak for the
|
||||||
|
# roots it was not asked about, so it still only adds: dropping
|
||||||
|
# folders it never looked at would empty the sidebar for every drive
|
||||||
|
# that was not scanned.
|
||||||
sorted_discovered = sorted(discovered_folders, key=lambda x: x.lower())
|
sorted_discovered = sorted(discovered_folders, key=lambda x: x.lower())
|
||||||
fully_verified = (
|
discovered_set = set(sorted_discovered)
|
||||||
len(roots) == len(configured_roots)
|
recorded = set(self._cache.all_folders or [])
|
||||||
and scope_folder is None
|
speaks_for_every_root = scope_folder is None and (
|
||||||
and not unreachable.has_entries()
|
scope is None
|
||||||
|
or scope.roots is None
|
||||||
|
or len(scope_roots) == len(configured_roots)
|
||||||
)
|
)
|
||||||
if fully_verified:
|
if speaks_for_every_root:
|
||||||
merged_folders = sorted_discovered
|
merged_folders = sorted(
|
||||||
|
discovered_set
|
||||||
|
| {
|
||||||
|
folder
|
||||||
|
for folder in recorded - discovered_set
|
||||||
|
if any(
|
||||||
|
unreachable.covers_folder(root, folder)
|
||||||
|
for root in configured_roots + configured_only
|
||||||
|
)
|
||||||
|
},
|
||||||
|
key=lambda x: x.lower(),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
merged_folders = sorted(
|
merged_folders = sorted(
|
||||||
set(self._cache.all_folders or []) | set(sorted_discovered),
|
recorded | discovered_set,
|
||||||
key=lambda x: x.lower(),
|
key=lambda x: x.lower(),
|
||||||
)
|
)
|
||||||
folders_changed = self._cache.all_folders != merged_folders
|
folders_changed = self._cache.all_folders != merged_folders
|
||||||
@@ -2994,12 +3094,28 @@ class ModelScanner:
|
|||||||
return await MetadataManager.create_default_metadata(file_path, self.model_class)
|
return await MetadataManager.create_default_metadata(file_path, self.model_class)
|
||||||
|
|
||||||
def _calculate_folder(self, file_path: str) -> str:
|
def _calculate_folder(self, file_path: str) -> str:
|
||||||
"""Calculate the folder path for a model file"""
|
"""Return the library-relative folder holding *file_path*.
|
||||||
|
|
||||||
|
The path arrives in whichever spelling its caller holds: ComfyUI's own
|
||||||
|
(backslashes on Windows) or the forward-slashed business form used for
|
||||||
|
cache entries. A raw ``startswith(root)`` matched neither the mixed nor
|
||||||
|
the opposite spelling, so every forward-slashed path on Windows resolved
|
||||||
|
to the root folder and a moved model was filed under the library root.
|
||||||
|
Compare identities instead, then slice the relative part off.
|
||||||
|
"""
|
||||||
|
candidate = _root_identity(file_path)
|
||||||
|
if not candidate:
|
||||||
|
return ""
|
||||||
for root in self.get_model_roots():
|
for root in self.get_model_roots():
|
||||||
if file_path.startswith(root):
|
identity = _root_identity(root)
|
||||||
rel_path = os.path.relpath(file_path, root)
|
if not identity:
|
||||||
return os.path.dirname(rel_path).replace(os.path.sep, '/')
|
continue
|
||||||
return ''
|
if candidate == identity:
|
||||||
|
return ""
|
||||||
|
if candidate.startswith(f"{identity}/"):
|
||||||
|
relative = candidate[len(identity) + 1:]
|
||||||
|
return relative.rsplit("/", 1)[0] if "/" in relative else ""
|
||||||
|
return ""
|
||||||
|
|
||||||
def adjust_metadata(self, metadata, file_path, root_path):
|
def adjust_metadata(self, metadata, file_path, root_path):
|
||||||
"""Hook for subclasses: adjust metadata during scanning"""
|
"""Hook for subclasses: adjust metadata during scanning"""
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from typing import Any, Dict, Iterable, Iterator, List, Mapping, Optional, Seque
|
|||||||
|
|
||||||
from .errors import RateLimitError, ResourceNotFoundError
|
from .errors import RateLimitError, ResourceNotFoundError
|
||||||
from .settings_manager import get_settings_manager
|
from .settings_manager import get_settings_manager
|
||||||
|
from ..utils.cache_db import closing_connection
|
||||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||||
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES
|
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES
|
||||||
from ..utils.civitai_utils import rewrite_preview_url
|
from ..utils.civitai_utils import rewrite_preview_url
|
||||||
@@ -445,7 +446,7 @@ class ModelUpdateService:
|
|||||||
if self._schema_initialized:
|
if self._schema_initialized:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
with self._connect() as conn:
|
with closing_connection(self._connect()) as conn:
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute("PRAGMA foreign_keys = ON")
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
conn.executescript(self._SCHEMA)
|
conn.executescript(self._SCHEMA)
|
||||||
@@ -484,7 +485,7 @@ class ModelUpdateService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with sqlite3.connect(legacy_path, check_same_thread=False) as legacy_conn:
|
with closing_connection(sqlite3.connect(legacy_path, check_same_thread=False)) as legacy_conn:
|
||||||
legacy_conn.row_factory = sqlite3.Row
|
legacy_conn.row_factory = sqlite3.Row
|
||||||
status_rows = legacy_conn.execute(
|
status_rows = legacy_conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -2315,7 +2316,7 @@ class ModelUpdateService:
|
|||||||
status_rows: list[sqlite3.Row] = []
|
status_rows: list[sqlite3.Row] = []
|
||||||
version_rows: list[sqlite3.Row] = []
|
version_rows: list[sqlite3.Row] = []
|
||||||
|
|
||||||
with self._connect() as conn:
|
with closing_connection(self._connect()) as conn:
|
||||||
for start in range(0, len(ids), self._SQLITE_MAX_VARIABLES):
|
for start in range(0, len(ids), self._SQLITE_MAX_VARIABLES):
|
||||||
chunk = tuple(ids[start : start + self._SQLITE_MAX_VARIABLES])
|
chunk = tuple(ids[start : start + self._SQLITE_MAX_VARIABLES])
|
||||||
placeholders = ",".join("?" for _ in chunk)
|
placeholders = ",".join("?" for _ in chunk)
|
||||||
@@ -2418,7 +2419,7 @@ class ModelUpdateService:
|
|||||||
record.last_checked_at,
|
record.last_checked_at,
|
||||||
1 if record.should_ignore_model else 0,
|
1 if record.should_ignore_model else 0,
|
||||||
)
|
)
|
||||||
with self._connect() as conn:
|
with closing_connection(self._connect()) as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO model_update_status (
|
INSERT INTO model_update_status (
|
||||||
|
|||||||
@@ -55,6 +55,28 @@ MANIFEST_FILE_NAME = "manifest.json"
|
|||||||
# deleted by the sweep.
|
# deleted by the sweep.
|
||||||
ORPHANED_SUFFIX = ".orphaned"
|
ORPHANED_SUFFIX = ".orphaned"
|
||||||
|
|
||||||
|
|
||||||
|
def staging_dir_has_content(directory: str) -> bool:
|
||||||
|
"""Whether a staging directory still holds a batch (or a quarantined one).
|
||||||
|
|
||||||
|
``.lm-pending-delete`` is only a container: the batch dir inside it is what
|
||||||
|
carries the staged files, and the purge, the undo and the staging rollback
|
||||||
|
all remove it. An empty container left behind must not be mistaken for a
|
||||||
|
staged delete -- it used to make every later folder delete answer "A
|
||||||
|
deletion is still pending inside this folder" forever, with no undo window
|
||||||
|
to wait for and nothing in the UI to clear it (issue #1145 follow-up).
|
||||||
|
|
||||||
|
Quarantined batches (``*.orphaned``) are kept on purpose, so anything left
|
||||||
|
inside still counts. An unreadable directory counts as content too: never
|
||||||
|
delete over something we could not list.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
return bool(os.listdir(directory))
|
||||||
|
except OSError:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
# Map scanner.model_type (singular) to the manifest page type values.
|
# Map scanner.model_type (singular) to the manifest page type values.
|
||||||
_MODEL_TYPE_PAGE_MAP = {
|
_MODEL_TYPE_PAGE_MAP = {
|
||||||
"lora": "loras",
|
"lora": "loras",
|
||||||
@@ -201,6 +223,7 @@ class PendingDeleteService:
|
|||||||
if batch_dir:
|
if batch_dir:
|
||||||
self._rollback_model_staging(batch_dir, staged_pairs)
|
self._rollback_model_staging(batch_dir, staged_pairs)
|
||||||
self._remove_empty_dir(batch_dir)
|
self._remove_empty_dir(batch_dir)
|
||||||
|
self._remove_empty_staging_parent(batch_dir)
|
||||||
return None
|
return None
|
||||||
except Exception as exc: # defensive - never block the delete flow
|
except Exception as exc: # defensive - never block the delete flow
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -266,6 +289,7 @@ class PendingDeleteService:
|
|||||||
if batch_dir:
|
if batch_dir:
|
||||||
self._rollback_recipe_staging(batch_dir, staged_pairs)
|
self._rollback_recipe_staging(batch_dir, staged_pairs)
|
||||||
self._remove_empty_dir(batch_dir)
|
self._remove_empty_dir(batch_dir)
|
||||||
|
self._remove_empty_staging_parent(batch_dir)
|
||||||
return None
|
return None
|
||||||
except Exception as exc: # defensive - never block the delete flow
|
except Exception as exc: # defensive - never block the delete flow
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -454,6 +478,7 @@ class PendingDeleteService:
|
|||||||
# Remove the manifest + batch dir only after all entries restored.
|
# Remove the manifest + batch dir only after all entries restored.
|
||||||
self._remove_manifest(batch_dir)
|
self._remove_manifest(batch_dir)
|
||||||
self._remove_empty_dir(batch_dir)
|
self._remove_empty_dir(batch_dir)
|
||||||
|
self._remove_empty_staging_parent(batch_dir)
|
||||||
await self._forget_batch(batch_id)
|
await self._forget_batch(batch_id)
|
||||||
# Clean up merged loser dirs (their staged files were restored
|
# Clean up merged loser dirs (their staged files were restored
|
||||||
# above) and drop them from the registry too.
|
# above) and drop them from the registry too.
|
||||||
@@ -832,6 +857,7 @@ class PendingDeleteService:
|
|||||||
continue
|
continue
|
||||||
self._remove_manifest(src)
|
self._remove_manifest(src)
|
||||||
self._remove_empty_dir(src)
|
self._remove_empty_dir(src)
|
||||||
|
self._remove_empty_staging_parent(src)
|
||||||
removed.append(os.path.basename(src))
|
removed.append(os.path.basename(src))
|
||||||
return removed
|
return removed
|
||||||
|
|
||||||
@@ -842,6 +868,13 @@ class PendingDeleteService:
|
|||||||
|
|
||||||
manifest = self._read_manifest(batch_dir)
|
manifest = self._read_manifest(batch_dir)
|
||||||
if manifest is None:
|
if manifest is None:
|
||||||
|
if not staging_dir_has_content(batch_dir):
|
||||||
|
# Crash leftover: the batch dir was created but nothing was ever
|
||||||
|
# staged into it. Quarantining an empty directory would leave a
|
||||||
|
# container that blocks folder deletion for good.
|
||||||
|
self._remove_empty_dir(batch_dir)
|
||||||
|
self._remove_empty_staging_parent(batch_dir)
|
||||||
|
return True
|
||||||
# Corrupted or manifest-less batch: quarantine, NEVER delete the
|
# Corrupted or manifest-less batch: quarantine, NEVER delete the
|
||||||
# staged files (they may be the only copy of the user's data).
|
# staged files (they may be the only copy of the user's data).
|
||||||
self._quarantine_batch_dir(batch_dir)
|
self._quarantine_batch_dir(batch_dir)
|
||||||
@@ -885,6 +918,7 @@ class PendingDeleteService:
|
|||||||
|
|
||||||
self._remove_manifest(batch_dir)
|
self._remove_manifest(batch_dir)
|
||||||
self._remove_empty_dir(batch_dir)
|
self._remove_empty_dir(batch_dir)
|
||||||
|
self._remove_empty_staging_parent(batch_dir)
|
||||||
self._remove_merged_batch_dirs(manifest)
|
self._remove_merged_batch_dirs(manifest)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -1132,6 +1166,18 @@ class PendingDeleteService:
|
|||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
logger.debug("Directory %s not empty or missing: %s", directory, exc)
|
logger.debug("Directory %s not empty or missing: %s", directory, exc)
|
||||||
|
|
||||||
|
def _remove_empty_staging_parent(self, batch_dir: str) -> None:
|
||||||
|
"""Drop the staging container once its last batch is gone.
|
||||||
|
|
||||||
|
Only an empty ``.lm-pending-delete`` directory is removed (``rmdir``
|
||||||
|
refuses anything else), and only when the path really is one, so a model
|
||||||
|
folder is never at risk.
|
||||||
|
"""
|
||||||
|
parent = os.path.dirname(batch_dir)
|
||||||
|
if os.path.basename(parent) != PENDING_DELETE_DIR_NAME:
|
||||||
|
return
|
||||||
|
self._remove_empty_dir(parent)
|
||||||
|
|
||||||
def _new_batch_id(self) -> str:
|
def _new_batch_id(self) -> str:
|
||||||
return uuid.uuid4().hex
|
return uuid.uuid4().hex
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,33 @@ import os
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
||||||
|
|
||||||
from ..utils.cache_db import connect_cache_db
|
from ..utils.cache_db import closing_connection, connect_cache_db
|
||||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||||
from ..utils.file_lock import exclusive_lock
|
from ..utils.file_lock import exclusive_lock
|
||||||
|
from ..utils.root_paths import normalize_root_identity
|
||||||
from .model_sources import normalize_metadata_source
|
from .model_sources import normalize_metadata_source
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _like_prefix_pattern(identity: str) -> str:
|
||||||
|
"""SQLite LIKE pattern matching every path inside *identity*.
|
||||||
|
|
||||||
|
``%``/``_`` are legal characters in a path and ``\\`` is the escape character,
|
||||||
|
so all three are escaped. The separator is added only when the identity does not
|
||||||
|
already end in one, so a filesystem root (``/``) still matches everything below
|
||||||
|
it instead of only itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
escaped = (
|
||||||
|
identity.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
)
|
||||||
|
prefix = escaped if escaped.endswith("/") else f"{escaped}/"
|
||||||
|
return f"{prefix}%"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PersistedCacheData:
|
class PersistedCacheData:
|
||||||
"""Lightweight structure returned by the persistent cache."""
|
"""Lightweight structure returned by the persistent cache."""
|
||||||
@@ -254,6 +271,61 @@ class PersistentModelCache:
|
|||||||
all_folders=all_folders,
|
all_folders=all_folders,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def roots_with_cached_entries(
|
||||||
|
self, model_type: str, roots: Sequence[str]
|
||||||
|
) -> Set[str]:
|
||||||
|
"""Return the subset of *roots* that hold at least one cached entry.
|
||||||
|
|
||||||
|
Used by the settings sync to tell a root that was registered but never
|
||||||
|
scanned -- ComfyUI registers ``<output>/loras`` for its save nodes whether
|
||||||
|
or not that directory exists -- from a real root whose drive happens to be
|
||||||
|
switched off: only the latter must keep its place in the saved library
|
||||||
|
paths (issue #1146). Paths are matched on their forward-slash identity, so
|
||||||
|
the host's own slash style does not matter.
|
||||||
|
|
||||||
|
Raises when the answer is unknown (a disabled cache, a missing database, an
|
||||||
|
unreadable file): the caller keeps every root in that case.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not self.is_enabled():
|
||||||
|
raise RuntimeError("persistent model cache is disabled")
|
||||||
|
|
||||||
|
candidates: Dict[str, str] = {}
|
||||||
|
for root in roots:
|
||||||
|
identity = normalize_root_identity(root)
|
||||||
|
if identity:
|
||||||
|
candidates.setdefault(identity, root)
|
||||||
|
if not candidates:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
matched: Set[str] = set()
|
||||||
|
# One scan per model type rather than one per root: the probe runs at
|
||||||
|
# startup for every configured root that is missing right now, which on
|
||||||
|
# Windows includes all of ComfyUI's `<output>/<type>` folders.
|
||||||
|
clause = "replace(file_path, '\\', '/') LIKE ? ESCAPE '\\'"
|
||||||
|
sql = (
|
||||||
|
"SELECT DISTINCT replace(file_path, '\\', '/') FROM models "
|
||||||
|
f"WHERE model_type = ? AND ({' OR '.join([clause] * len(candidates))})"
|
||||||
|
)
|
||||||
|
params: List[Any] = [model_type]
|
||||||
|
params.extend(_like_prefix_pattern(identity) for identity in candidates)
|
||||||
|
|
||||||
|
with self._db_lock:
|
||||||
|
conn = self._connect(readonly=True)
|
||||||
|
try:
|
||||||
|
rows = conn.execute(sql, params).fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
for (cached_path,) in rows:
|
||||||
|
folded = normalize_root_identity(cached_path)
|
||||||
|
for identity, root in candidates.items():
|
||||||
|
prefix = identity if identity.endswith("/") else f"{identity}/"
|
||||||
|
if folded.startswith(prefix):
|
||||||
|
matched.add(root)
|
||||||
|
break
|
||||||
|
return matched
|
||||||
|
|
||||||
def save_cache(self, model_type: str, raw_data: Sequence[Dict[str, Any]], hash_index: Dict[str, List[str]], excluded_models: Sequence[str], autov3_hash_index: Optional[Dict[str, List[str]]] = None, all_folders: Optional[Sequence[str]] = None) -> None:
|
def save_cache(self, model_type: str, raw_data: Sequence[Dict[str, Any]], hash_index: Dict[str, List[str]], excluded_models: Sequence[str], autov3_hash_index: Optional[Dict[str, List[str]]] = None, all_folders: Optional[Sequence[str]] = None) -> None:
|
||||||
if not self.is_enabled():
|
if not self.is_enabled():
|
||||||
return
|
return
|
||||||
@@ -546,7 +618,7 @@ class PersistentModelCache:
|
|||||||
if self._schema_initialized:
|
if self._schema_initialized:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
with self._connect() as conn:
|
with closing_connection(self._connect()) as conn:
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute("PRAGMA foreign_keys = ON")
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
conn.executescript(
|
conn.executescript(
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import threading
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
from ..utils.cache_db import connect_cache_db
|
from ..utils.cache_db import closing_connection, connect_cache_db
|
||||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||||
from ..utils.file_lock import exclusive_lock
|
from ..utils.file_lock import exclusive_lock
|
||||||
|
|
||||||
@@ -457,7 +457,7 @@ class PersistentRecipeCache:
|
|||||||
if self._schema_initialized:
|
if self._schema_initialized:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
with self._connect() as conn:
|
with closing_connection(self._connect()) as conn:
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute("PRAGMA foreign_keys = ON")
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
conn.executescript(
|
conn.executescript(
|
||||||
|
|||||||
@@ -160,7 +160,13 @@ class RateLimitCoordinator:
|
|||||||
self._normalize(destination),
|
self._normalize(destination),
|
||||||
delay,
|
delay,
|
||||||
)
|
)
|
||||||
await asyncio.sleep(delay)
|
# Re-check rather than trusting a single sleep: the event-loop
|
||||||
|
# timer can fire early (Windows granularity is ~15ms), and
|
||||||
|
# returning then would send the request inside the cooldown this
|
||||||
|
# gate exists to enforce. ``wake_at`` is fixed, so the loop can
|
||||||
|
# never outlast the ``max_wait_seconds`` check above.
|
||||||
|
while (delay := wake_at - time.monotonic()) > 0:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
state.last_send_at = time.monotonic()
|
state.last_send_at = time.monotonic()
|
||||||
|
|
||||||
def register_rate_limit(
|
def register_rate_limit(
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import asyncio
|
|||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import posixpath
|
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import logging
|
import logging
|
||||||
@@ -36,6 +35,7 @@ from ..utils.constants import (
|
|||||||
normalize_other_sub_types,
|
normalize_other_sub_types,
|
||||||
)
|
)
|
||||||
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
|
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
|
||||||
|
from ..utils.root_paths import normalize_root_identity
|
||||||
from ..utils.settings_paths import (
|
from ..utils.settings_paths import (
|
||||||
APP_NAME,
|
APP_NAME,
|
||||||
_portable_env_override,
|
_portable_env_override,
|
||||||
@@ -156,15 +156,6 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _normalize_root_identity(path: str) -> str:
|
|
||||||
"""Normalize a root path for equality checks across slash styles."""
|
|
||||||
|
|
||||||
normalized = posixpath.normpath(path.strip().replace("\\", "/"))
|
|
||||||
if len(normalized) >= 2 and normalized[1] == ":":
|
|
||||||
return normalized.lower()
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
class SettingsManager:
|
class SettingsManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.settings_file = ensure_settings_file(logger)
|
self.settings_file = ensure_settings_file(logger)
|
||||||
@@ -1058,7 +1049,7 @@ class SettingsManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
allowed_roots = self._get_allowed_roots(key)
|
allowed_roots = self._get_allowed_roots(key)
|
||||||
if current and _normalize_root_identity(current) in allowed_roots:
|
if current and normalize_root_identity(current) in allowed_roots:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self.settings[setting_key] = primary_candidates[0]
|
self.settings[setting_key] = primary_candidates[0]
|
||||||
@@ -1094,7 +1085,7 @@ class SettingsManager:
|
|||||||
candidate_identities: set[str] = set()
|
candidate_identities: set[str] = set()
|
||||||
for folder_key in OTHER_SUB_TYPE_FOLDER_KEYS.get(sub_type, []):
|
for folder_key in OTHER_SUB_TYPE_FOLDER_KEYS.get(sub_type, []):
|
||||||
for candidate in self._get_valid_root_candidates(folder_key):
|
for candidate in self._get_valid_root_candidates(folder_key):
|
||||||
identity = _normalize_root_identity(candidate)
|
identity = normalize_root_identity(candidate)
|
||||||
if identity in candidate_identities:
|
if identity in candidate_identities:
|
||||||
continue
|
continue
|
||||||
candidate_identities.add(identity)
|
candidate_identities.add(identity)
|
||||||
@@ -1102,7 +1093,7 @@ class SettingsManager:
|
|||||||
if not candidates:
|
if not candidates:
|
||||||
continue
|
continue
|
||||||
current = other_roots.get(sub_type, "")
|
current = other_roots.get(sub_type, "")
|
||||||
if current and _normalize_root_identity(current) in candidate_identities:
|
if current and normalize_root_identity(current) in candidate_identities:
|
||||||
continue
|
continue
|
||||||
other_roots[sub_type] = candidates[0]
|
other_roots[sub_type] = candidates[0]
|
||||||
if current:
|
if current:
|
||||||
@@ -1152,7 +1143,7 @@ class SettingsManager:
|
|||||||
normalized = value.strip()
|
normalized = value.strip()
|
||||||
if not normalized:
|
if not normalized:
|
||||||
continue
|
continue
|
||||||
identity = _normalize_root_identity(normalized)
|
identity = normalize_root_identity(normalized)
|
||||||
if identity in seen:
|
if identity in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(identity)
|
seen.add(identity)
|
||||||
@@ -1162,7 +1153,7 @@ class SettingsManager:
|
|||||||
def _get_allowed_roots(self, key: str) -> set[str]:
|
def _get_allowed_roots(self, key: str) -> set[str]:
|
||||||
"""Return all valid roots for a model type, including extra roots."""
|
"""Return all valid roots for a model type, including extra roots."""
|
||||||
|
|
||||||
return {_normalize_root_identity(path) for path in self._get_valid_root_candidates(key)}
|
return {normalize_root_identity(path) for path in self._get_valid_root_candidates(key)}
|
||||||
|
|
||||||
def _check_environment_variables(self) -> None:
|
def _check_environment_variables(self) -> None:
|
||||||
"""Check for environment variables and update settings if needed"""
|
"""Check for environment variables and update settings if needed"""
|
||||||
|
|||||||
+18
-1
@@ -14,7 +14,8 @@ the write path in WAL so readers are never blocked by a writer.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from typing import Any
|
from contextlib import contextmanager
|
||||||
|
from typing import Any, Iterator
|
||||||
|
|
||||||
# How long a connection waits for a competing writer before raising.
|
# How long a connection waits for a competing writer before raising.
|
||||||
CONCURRENT_TIMEOUT_SECONDS = 30.0
|
CONCURRENT_TIMEOUT_SECONDS = 30.0
|
||||||
@@ -79,3 +80,19 @@ def connect_cache_db(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def closing_connection(conn: sqlite3.Connection) -> Iterator[sqlite3.Connection]:
|
||||||
|
"""Yield *conn* and close it on exit.
|
||||||
|
|
||||||
|
``with sqlite3.Connection`` only ends the transaction: it leaves the file
|
||||||
|
handle open, and a live handle keeps the cache database undeletable on
|
||||||
|
Windows (``WinError 32``) until the garbage collector happens to run. Every
|
||||||
|
``with`` over a cache connection goes through here instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Path identity helpers for model roots.
|
||||||
|
|
||||||
|
ComfyUI hands its model folders over in whatever spelling the host uses --
|
||||||
|
``os.path.normpath()`` output, which is all backslashes on Windows -- while LoRA
|
||||||
|
Manager's own root lists and cached model paths are forward-slashed. Comparing
|
||||||
|
the two spellings as raw strings makes one directory look like two roots, so every
|
||||||
|
root identity check goes through :func:`normalize_root_identity` (issue #1146).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import posixpath
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_root_identity(path: str) -> str:
|
||||||
|
"""Return the comparable identity of a model root path.
|
||||||
|
|
||||||
|
Folds the separator style (``\\`` and ``/`` are both accepted as separators,
|
||||||
|
because the host may spell a Windows path either way), redundant and trailing
|
||||||
|
separators, and the case where the filesystem does not distinguish it: Windows
|
||||||
|
compares whole paths case-insensitively, and every platform folds a drive
|
||||||
|
letter (``E:`` == ``e:``). Symlinks are never resolved -- business paths stay
|
||||||
|
business paths, matching the rest of the codebase.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not isinstance(path, str):
|
||||||
|
return ""
|
||||||
|
candidate = path.strip()
|
||||||
|
if not candidate:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
normalized = posixpath.normpath(candidate.replace("\\", "/"))
|
||||||
|
if os.name == "nt" or (len(normalized) >= 2 and normalized[1] == ":"):
|
||||||
|
return normalized.lower()
|
||||||
|
return normalized
|
||||||
@@ -480,6 +480,19 @@ def _legacy_component(root_path: str) -> str:
|
|||||||
return f"{sanitize_path_component(os.path.basename(normalized))}-{digest}"
|
return f"{sanitize_path_component(os.path.basename(normalized))}-{digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def _join_component(root: str, component: str) -> str:
|
||||||
|
"""Join a mirror component name onto *root* as a native path.
|
||||||
|
|
||||||
|
A component adopted from the pre-identity-map layout is reported as
|
||||||
|
``<library>/<component>`` -- one *name* spanning two directories. Handing it
|
||||||
|
whole to ``os.path.join`` left a mixed path on Windows
|
||||||
|
(``...\\comfyui/loras-1a2b3c4d\\sub``), which no natively built path
|
||||||
|
compares equal to, so it has to be split first.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return os.path.join(root, *(part for part in component.split("/") if part))
|
||||||
|
|
||||||
|
|
||||||
def _list_component_dirs(sidecar_root: str) -> Set[str]:
|
def _list_component_dirs(sidecar_root: str) -> Set[str]:
|
||||||
"""Return the mirror component directories present under ``sidecar_root``.
|
"""Return the mirror component directories present under ``sidecar_root``.
|
||||||
|
|
||||||
@@ -556,7 +569,7 @@ def _mirror_dir_overlap(sidecar_root: str, component: str, root: str) -> int:
|
|||||||
|
|
||||||
if not component:
|
if not component:
|
||||||
return 0
|
return 0
|
||||||
mirror_names = _subdir_names(os.path.join(sidecar_root, component))
|
mirror_names = _subdir_names(_join_component(sidecar_root, component))
|
||||||
if not mirror_names:
|
if not mirror_names:
|
||||||
return 0
|
return 0
|
||||||
root_names = _subdir_names(root)
|
root_names = _subdir_names(root)
|
||||||
@@ -958,7 +971,7 @@ def resolve_centralized_dir_for_dir(
|
|||||||
if not component:
|
if not component:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
parts = [root, component]
|
parts = [_join_component(root, component)]
|
||||||
if rel_dir and rel_dir != os.curdir:
|
if rel_dir and rel_dir != os.curdir:
|
||||||
parts.extend(
|
parts.extend(
|
||||||
sanitize_path_component(part)
|
sanitize_path_component(part)
|
||||||
|
|||||||
+4
-2
@@ -303,8 +303,10 @@ def _format_model_name_for_comfyui(file_path: str, model_roots: list[str]) -> st
|
|||||||
norm_root += os.sep
|
norm_root += os.sep
|
||||||
|
|
||||||
if norm_file.startswith(norm_root):
|
if norm_file.startswith(norm_root):
|
||||||
# Use os.path.relpath to get relative path with OS-native separator
|
# ComfyUI names a model with forward slashes on every platform
|
||||||
return os.path.relpath(file_path, root)
|
# ("unet/base.safetensors"); os.path.relpath alone returns the
|
||||||
|
# host's separator, which no ComfyUI widget list matches on Windows.
|
||||||
|
return os.path.relpath(file_path, root).replace(os.sep, "/")
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ export function getApiEndpoints(modelType) {
|
|||||||
moveBulk: `/api/lm/${modelType}/move_models_bulk`,
|
moveBulk: `/api/lm/${modelType}/move_models_bulk`,
|
||||||
createFolder: `/api/lm/${modelType}/create-folder`,
|
createFolder: `/api/lm/${modelType}/create-folder`,
|
||||||
deleteFolder: `/api/lm/${modelType}/delete-folder`,
|
deleteFolder: `/api/lm/${modelType}/delete-folder`,
|
||||||
|
// Drops the sidebar records of a folder that no longer exists on disk:
|
||||||
|
// the only cleanup left for a node whose directory was moved or deleted
|
||||||
|
// outside LoRA Manager.
|
||||||
|
forgetFolder: `/api/lm/${modelType}/forget-folder`,
|
||||||
renameFolder: `/api/lm/${modelType}/rename-folder`,
|
renameFolder: `/api/lm/${modelType}/rename-folder`,
|
||||||
// Resolves a tree-relative folder onto the root(s) that hold it: the
|
// Resolves a tree-relative folder onto the root(s) that hold it: the
|
||||||
// unified folder tree merges every root, so a node does not carry its
|
// unified folder tree merges every root, so a node does not carry its
|
||||||
|
|||||||
@@ -1440,7 +1440,9 @@ export class BaseModelApiClient {
|
|||||||
* Only model-free folders can be removed; the backend answers with a 409
|
* Only model-free folders can be removed; the backend answers with a 409
|
||||||
* `not_empty`/`busy` conflict otherwise. Those codes are attached to the
|
* `not_empty`/`busy` conflict otherwise. Those codes are attached to the
|
||||||
* thrown Error (`code`, `manifest`) so callers can explain the refusal
|
* thrown Error (`code`, `manifest`) so callers can explain the refusal
|
||||||
* instead of showing a bare message.
|
* instead of showing a bare message. A folder that is already gone from disk
|
||||||
|
* is forgotten instead (`code: 'missing'`, success), which is what lets the
|
||||||
|
* sidebar clear a node whose directory was moved outside LoRA Manager.
|
||||||
*
|
*
|
||||||
* @param {string} folderPath Absolute business path of the folder
|
* @param {string} folderPath Absolute business path of the folder
|
||||||
* @param {{dryRun?: boolean}} [options]
|
* @param {{dryRun?: boolean}} [options]
|
||||||
@@ -1468,6 +1470,38 @@ export class BaseModelApiClient {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the records of a folder that no longer exists on disk.
|
||||||
|
*
|
||||||
|
* Deleting such a folder cannot work -- there is nothing left to remove --
|
||||||
|
* so a node whose directory was moved or deleted outside LoRA Manager is
|
||||||
|
* cleared with this instead. The backend refuses while any root still holds
|
||||||
|
* the directory (`code: 'exists'`), because then it is a delete.
|
||||||
|
*
|
||||||
|
* @param {string} folder - Library-relative folder path (e.g. "Krea2/Women")
|
||||||
|
* @returns {Promise<Object>} `{success: true, folder}` on success
|
||||||
|
*/
|
||||||
|
async forgetFolder(folder) {
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.forgetFolder, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ folder })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!response.ok || result.success === false) {
|
||||||
|
const error = new Error(result.error || 'Failed to remove folder from the sidebar');
|
||||||
|
error.code = result.code || null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rename a folder inside the library roots.
|
* Rename a folder inside the library roots.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1734,6 +1734,8 @@ export class SidebarManager {
|
|||||||
showToast('sidebar.deleteFolderResult.busy', {}, 'warning');
|
showToast('sidebar.deleteFolderResult.busy', {}, 'warning');
|
||||||
} else if (error?.code === 'missing') {
|
} else if (error?.code === 'missing') {
|
||||||
showToast('sidebar.deleteFolderResult.missing', {}, 'warning');
|
showToast('sidebar.deleteFolderResult.missing', {}, 'warning');
|
||||||
|
} else if (error?.code === 'exists') {
|
||||||
|
showToast('sidebar.deleteFolderResult.exists', {}, 'warning');
|
||||||
} else if (error?.code === 'unresolved') {
|
} else if (error?.code === 'unresolved') {
|
||||||
showToast('sidebar.folderResult.unresolved', {}, 'error');
|
showToast('sidebar.folderResult.unresolved', {}, 'error');
|
||||||
} else {
|
} else {
|
||||||
@@ -1806,6 +1808,8 @@ export class SidebarManager {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this._pendingDeleteFolderPath = null;
|
this._pendingDeleteFolderPath = null;
|
||||||
|
confirmBtn.style.display = 'none';
|
||||||
|
confirmBtn.disabled = true;
|
||||||
if (state === 'busy') {
|
if (state === 'busy') {
|
||||||
title.textContent = translate(
|
title.textContent = translate(
|
||||||
'sidebar.deleteFolderModal.busyTitle', {}, 'A deletion is still pending'
|
'sidebar.deleteFolderModal.busyTitle', {}, 'A deletion is still pending'
|
||||||
@@ -1820,7 +1824,16 @@ export class SidebarManager {
|
|||||||
);
|
);
|
||||||
message.textContent = translate(
|
message.textContent = translate(
|
||||||
'sidebar.deleteFolderModal.missingMessage', {},
|
'sidebar.deleteFolderModal.missingMessage', {},
|
||||||
'This folder no longer exists on disk. Refresh the sidebar and try again.'
|
'This folder no longer exists on disk. You can remove it from the list.'
|
||||||
|
);
|
||||||
|
// There is nothing left to delete, but the records are what keeps
|
||||||
|
// the ghost node in the tree: offer to drop them (issue #1145).
|
||||||
|
// The confirm re-resolves the node, finds no directory and takes
|
||||||
|
// the forget path.
|
||||||
|
confirmBtn.style.display = '';
|
||||||
|
confirmBtn.disabled = false;
|
||||||
|
confirmBtn.textContent = translate(
|
||||||
|
'sidebar.deleteFolderModal.removeFromList', {}, 'Remove from list'
|
||||||
);
|
);
|
||||||
} else if (state === 'symlink') {
|
} else if (state === 'symlink') {
|
||||||
title.textContent = translate(
|
title.textContent = translate(
|
||||||
@@ -1849,8 +1862,6 @@ export class SidebarManager {
|
|||||||
|| 'This folder still contains models. Delete or move them first.'
|
|| 'This folder still contains models. Delete or move them first.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
confirmBtn.style.display = 'none';
|
|
||||||
confirmBtn.disabled = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checking) {
|
if (checking) {
|
||||||
@@ -1943,10 +1954,14 @@ export class SidebarManager {
|
|||||||
);
|
);
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
const empty = (resolution?.candidates || []).length === 0;
|
const empty = (resolution?.candidates || []).length === 0;
|
||||||
|
if (empty) {
|
||||||
|
// No root holds the directory any more: there is nothing
|
||||||
|
// to delete, so drop the records that keep the node in
|
||||||
|
// the tree (issue #1145).
|
||||||
|
return this._forgetFolder(relativePath);
|
||||||
|
}
|
||||||
showToast(
|
showToast(
|
||||||
empty
|
'sidebar.deleteFolderResult.symlink',
|
||||||
? 'sidebar.deleteFolderResult.missing'
|
|
||||||
: 'sidebar.deleteFolderResult.symlink',
|
|
||||||
{},
|
{},
|
||||||
'warning'
|
'warning'
|
||||||
);
|
);
|
||||||
@@ -1967,7 +1982,15 @@ export class SidebarManager {
|
|||||||
await this.refresh();
|
await this.refresh();
|
||||||
|
|
||||||
const name = result.folder || relativePath;
|
const name = result.folder || relativePath;
|
||||||
if (result.restorable) {
|
if (result.code === 'missing') {
|
||||||
|
// The directory was already gone when the delete ran (a race with
|
||||||
|
// the file manager, or a row in the multi-root list): only its
|
||||||
|
// records were dropped, so nothing was deleted and there is
|
||||||
|
// nothing to undo.
|
||||||
|
showToast(
|
||||||
|
'sidebar.deleteFolderResult.forgotten', { name }, 'success'
|
||||||
|
);
|
||||||
|
} else if (result.restorable) {
|
||||||
// A truly empty folder is reproducible one-for-one, so offer
|
// A truly empty folder is reproducible one-for-one, so offer
|
||||||
// the same 20s undo affordance the model delete flow uses.
|
// the same 20s undo affordance the model delete flow uses.
|
||||||
showActionToast('sidebar.deleteFolderResult.success', { name }, 'success', {
|
showActionToast('sidebar.deleteFolderResult.success', { name }, 'success', {
|
||||||
@@ -1992,6 +2015,41 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear a folder node whose directory is gone from every root (issue #1145).
|
||||||
|
*
|
||||||
|
* A folder moved or deleted with a file manager leaves the sidebar holding a
|
||||||
|
* node that no delete can clear, because there is nothing left to remove. The
|
||||||
|
* delete affordance falls back to this instead of refusing: the backend drops
|
||||||
|
* the folder record and the cached models under it, and answers `exists` if
|
||||||
|
* the directory turned out to be back on disk (then it is a delete again).
|
||||||
|
*/
|
||||||
|
async _forgetFolder(relativePath) {
|
||||||
|
if (
|
||||||
|
!this._supportsFolderManagement()
|
||||||
|
|| typeof this.apiClient.forgetFolder !== 'function'
|
||||||
|
) {
|
||||||
|
showToast('sidebar.deleteFolderResult.unsupported', {}, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.apiClient.forgetFolder(relativePath);
|
||||||
|
this._forgetRemovedFolder(relativePath);
|
||||||
|
await this.refresh();
|
||||||
|
showToast(
|
||||||
|
'sidebar.deleteFolderResult.forgotten',
|
||||||
|
{ name: result.folder || relativePath },
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SidebarManager] Error forgetting folder:', error);
|
||||||
|
this._reportDeleteFolderFailure(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recreate removed directories through the create-folder API.
|
* Recreate removed directories through the create-folder API.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ def _normalize(path) -> str:
|
|||||||
return os.path.normpath(str(path)).replace(os.sep, "/")
|
return os.path.normpath(str(path)).replace(os.sep, "/")
|
||||||
|
|
||||||
|
|
||||||
|
def _fold(paths):
|
||||||
|
"""Compare path lists as identities: the code may hand out either spelling."""
|
||||||
|
|
||||||
|
return [_normalize(path) for path in paths]
|
||||||
|
|
||||||
|
|
||||||
class TestAppendNewPaths:
|
class TestAppendNewPaths:
|
||||||
def test_appends_without_reordering_or_duplicating(self):
|
def test_appends_without_reordering_or_duplicating(self):
|
||||||
merged = config_module._append_new_paths(
|
merged = config_module._append_new_paths(
|
||||||
@@ -60,17 +66,17 @@ class TestAdmitConfiguredRoots:
|
|||||||
loras_roots=[str(primary)],
|
loras_roots=[str(primary)],
|
||||||
_configured_root_paths={"lora": [str(primary), str(drive)]},
|
_configured_root_paths={"lora": [str(primary), str(drive)]},
|
||||||
)
|
)
|
||||||
assert config.configured_roots_for("lora") == [str(primary), str(drive)]
|
assert _fold(config.configured_roots_for("lora")) == _fold([primary, drive])
|
||||||
|
|
||||||
# Still switched off: nothing to admit.
|
# Still switched off: nothing to admit.
|
||||||
assert config.admit_configured_roots() == []
|
assert config.admit_configured_roots() == []
|
||||||
assert config.loras_roots == [str(primary)]
|
assert _fold(config.loras_roots) == _fold([primary])
|
||||||
|
|
||||||
drive.mkdir(parents=True)
|
drive.mkdir(parents=True)
|
||||||
admitted = config.admit_configured_roots()
|
admitted = config.admit_configured_roots()
|
||||||
|
|
||||||
assert admitted == [str(drive)]
|
assert _fold(admitted) == _fold([drive])
|
||||||
assert config.loras_roots == [str(primary), str(drive)]
|
assert _fold(config.loras_roots) == _fold([primary, drive])
|
||||||
# Idempotent: a second call adds nothing.
|
# Idempotent: a second call adds nothing.
|
||||||
assert config.admit_configured_roots() == []
|
assert config.admit_configured_roots() == []
|
||||||
|
|
||||||
@@ -91,7 +97,7 @@ class TestAdmitConfiguredRoots:
|
|||||||
drive.rmdir()
|
drive.rmdir()
|
||||||
config.admit_configured_roots()
|
config.admit_configured_roots()
|
||||||
|
|
||||||
assert config.loras_roots == [str(primary), str(drive)]
|
assert _fold(config.loras_roots) == _fold([primary, drive])
|
||||||
|
|
||||||
def test_admits_every_model_type_and_its_side_maps(self, tmp_path):
|
def test_admits_every_model_type_and_its_side_maps(self, tmp_path):
|
||||||
checkpoint_dir = tmp_path / "checkpoints"
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
|||||||
@@ -1,12 +1,33 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, Iterable, List
|
from typing import Any, Dict, Iterable, List, Mapping
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from py import config as config_module
|
from py import config as config_module
|
||||||
|
from py.utils.root_paths import normalize_root_identity
|
||||||
from py.services import settings_manager as settings_manager_module
|
from py.services import settings_manager as settings_manager_module
|
||||||
|
|
||||||
|
|
||||||
|
def _identity(value):
|
||||||
|
"""Fold path spelling so an assertion is about paths, not separators.
|
||||||
|
|
||||||
|
LM keeps the host's own spelling for the paths it is configured with
|
||||||
|
(backslashes on Windows) while the ones it derives itself are forward-slashed,
|
||||||
|
so two values naming the same directory can differ as strings. Compare
|
||||||
|
identities wherever a test asserts *which* paths survived.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if isinstance(value, str):
|
||||||
|
return normalize_root_identity(value)
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return {key: _identity(item) for key, item in value.items()}
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [_identity(item) for item in value]
|
||||||
|
if isinstance(value, set):
|
||||||
|
return {_identity(item) for item in value}
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _setup_config_environment(monkeypatch: pytest.MonkeyPatch, tmp_path) -> Dict[str, List[str]]:
|
def _setup_config_environment(monkeypatch: pytest.MonkeyPatch, tmp_path) -> Dict[str, List[str]]:
|
||||||
loras_dir = tmp_path / "loras"
|
loras_dir = tmp_path / "loras"
|
||||||
checkpoints_dir = tmp_path / "checkpoints"
|
checkpoints_dir = tmp_path / "checkpoints"
|
||||||
@@ -94,11 +115,11 @@ def test_save_paths_renames_default_library(monkeypatch: pytest.MonkeyPatch, tmp
|
|||||||
key: [path.replace("\\", "/") for path in paths]
|
key: [path.replace("\\", "/") for path in paths]
|
||||||
for key, paths in folder_paths.items()
|
for key, paths in folder_paths.items()
|
||||||
}
|
}
|
||||||
assert payload["folder_paths"] == expected_folder_paths
|
assert _identity(payload["folder_paths"]) == _identity(expected_folder_paths)
|
||||||
assert payload["default_lora_root"] == folder_paths["loras"][0].replace("\\", "/")
|
assert _identity(payload["default_lora_root"]) == _identity(folder_paths["loras"][0].replace("\\", "/"))
|
||||||
assert payload["default_checkpoint_root"] == folder_paths["checkpoints"][0].replace("\\", "/")
|
assert _identity(payload["default_checkpoint_root"]) == _identity(folder_paths["checkpoints"][0].replace("\\", "/"))
|
||||||
assert payload["default_embedding_root"] == folder_paths["embeddings"][0].replace("\\", "/")
|
assert _identity(payload["default_embedding_root"]) == _identity(folder_paths["embeddings"][0].replace("\\", "/"))
|
||||||
assert payload["metadata"] == {"display_name": "ComfyUI", "source": "comfyui"}
|
assert _identity(payload["metadata"]) == _identity({"display_name": "ComfyUI", "source": "comfyui"})
|
||||||
assert payload["activate"] is True
|
assert payload["activate"] is True
|
||||||
|
|
||||||
|
|
||||||
@@ -175,9 +196,9 @@ def test_save_paths_repairs_empty_default_roots(monkeypatch: pytest.MonkeyPatch,
|
|||||||
config_module.Config()
|
config_module.Config()
|
||||||
|
|
||||||
assert fake_settings.name == "comfyui"
|
assert fake_settings.name == "comfyui"
|
||||||
assert fake_settings.payload["default_lora_root"] == folder_paths["loras"][0].replace("\\", "/")
|
assert _identity(fake_settings.payload["default_lora_root"]) == _identity(folder_paths["loras"][0].replace("\\", "/"))
|
||||||
assert fake_settings.payload["default_checkpoint_root"] == folder_paths["checkpoints"][0].replace("\\", "/")
|
assert _identity(fake_settings.payload["default_checkpoint_root"]) == _identity(folder_paths["checkpoints"][0].replace("\\", "/"))
|
||||||
assert fake_settings.payload["default_embedding_root"] == folder_paths["embeddings"][0].replace("\\", "/")
|
assert _identity(fake_settings.payload["default_embedding_root"]) == _identity(folder_paths["embeddings"][0].replace("\\", "/"))
|
||||||
|
|
||||||
|
|
||||||
def test_save_paths_repairs_stale_default_roots(monkeypatch: pytest.MonkeyPatch, tmp_path):
|
def test_save_paths_repairs_stale_default_roots(monkeypatch: pytest.MonkeyPatch, tmp_path):
|
||||||
@@ -214,9 +235,9 @@ def test_save_paths_repairs_stale_default_roots(monkeypatch: pytest.MonkeyPatch,
|
|||||||
config_module.Config()
|
config_module.Config()
|
||||||
|
|
||||||
assert fake_settings.name == "comfyui"
|
assert fake_settings.name == "comfyui"
|
||||||
assert fake_settings.payload["default_lora_root"] == folder_paths["loras"][0].replace("\\", "/")
|
assert _identity(fake_settings.payload["default_lora_root"]) == _identity(folder_paths["loras"][0].replace("\\", "/"))
|
||||||
assert fake_settings.payload["default_checkpoint_root"] == folder_paths["checkpoints"][0].replace("\\", "/")
|
assert _identity(fake_settings.payload["default_checkpoint_root"]) == _identity(folder_paths["checkpoints"][0].replace("\\", "/"))
|
||||||
assert fake_settings.payload["default_embedding_root"] == folder_paths["embeddings"][0].replace("\\", "/")
|
assert _identity(fake_settings.payload["default_embedding_root"]) == _identity(folder_paths["embeddings"][0].replace("\\", "/"))
|
||||||
|
|
||||||
|
|
||||||
def test_save_paths_keeps_valid_default_roots(monkeypatch: pytest.MonkeyPatch, tmp_path):
|
def test_save_paths_keeps_valid_default_roots(monkeypatch: pytest.MonkeyPatch, tmp_path):
|
||||||
@@ -253,9 +274,9 @@ def test_save_paths_keeps_valid_default_roots(monkeypatch: pytest.MonkeyPatch, t
|
|||||||
config_module.Config()
|
config_module.Config()
|
||||||
|
|
||||||
assert fake_settings.name == "comfyui"
|
assert fake_settings.name == "comfyui"
|
||||||
assert fake_settings.payload["default_lora_root"] == folder_paths["loras"][0].replace("\\", "/")
|
assert _identity(fake_settings.payload["default_lora_root"]) == _identity(folder_paths["loras"][0].replace("\\", "/"))
|
||||||
assert fake_settings.payload["default_checkpoint_root"] == folder_paths["checkpoints"][0].replace("\\", "/")
|
assert _identity(fake_settings.payload["default_checkpoint_root"]) == _identity(folder_paths["checkpoints"][0].replace("\\", "/"))
|
||||||
assert fake_settings.payload["default_embedding_root"] == folder_paths["embeddings"][0].replace("\\", "/")
|
assert _identity(fake_settings.payload["default_embedding_root"]) == _identity(folder_paths["embeddings"][0].replace("\\", "/"))
|
||||||
|
|
||||||
|
|
||||||
def test_save_paths_removes_template_default_library(monkeypatch, tmp_path):
|
def test_save_paths_removes_template_default_library(monkeypatch, tmp_path):
|
||||||
@@ -338,17 +359,15 @@ def test_save_paths_removes_template_default_library(monkeypatch, tmp_path):
|
|||||||
key: [path.replace("\\", "/") for path in paths]
|
key: [path.replace("\\", "/") for path in paths]
|
||||||
for key, paths in folder_paths.items()
|
for key, paths in folder_paths.items()
|
||||||
}
|
}
|
||||||
assert payload["folder_paths"] == expected_folder_paths
|
assert _identity(payload["folder_paths"]) == _identity(expected_folder_paths)
|
||||||
assert payload["default_lora_root"] == folder_paths["loras"][0].replace("\\", "/")
|
assert _identity(payload["default_lora_root"]) == _identity(folder_paths["loras"][0].replace("\\", "/"))
|
||||||
assert (
|
assert _identity(payload["default_checkpoint_root"]) == _identity(
|
||||||
payload["default_checkpoint_root"]
|
folder_paths["checkpoints"][0].replace("\\", "/")
|
||||||
== folder_paths["checkpoints"][0].replace("\\", "/")
|
|
||||||
)
|
)
|
||||||
assert (
|
assert _identity(payload["default_embedding_root"]) == _identity(
|
||||||
payload["default_embedding_root"]
|
folder_paths["embeddings"][0].replace("\\", "/")
|
||||||
== folder_paths["embeddings"][0].replace("\\", "/")
|
|
||||||
)
|
)
|
||||||
assert payload["metadata"] == {"display_name": "ComfyUI", "source": "comfyui"}
|
assert _identity(payload["metadata"]) == _identity({"display_name": "ComfyUI", "source": "comfyui"})
|
||||||
assert payload["activate"] is True
|
assert payload["activate"] is True
|
||||||
|
|
||||||
|
|
||||||
@@ -397,16 +416,16 @@ def test_save_paths_keeps_default_roots_in_extra_paths(monkeypatch: pytest.Monke
|
|||||||
config_module.Config()
|
config_module.Config()
|
||||||
|
|
||||||
assert fake_settings.name == "comfyui"
|
assert fake_settings.name == "comfyui"
|
||||||
assert fake_settings.payload["extra_folder_paths"]["loras"] == [str(extra_lora_dir).replace("\\", "/")]
|
assert _identity(fake_settings.payload["extra_folder_paths"]["loras"]) == _identity([str(extra_lora_dir).replace("\\", "/")])
|
||||||
assert fake_settings.payload["extra_folder_paths"]["checkpoints"] == [
|
assert _identity(fake_settings.payload["extra_folder_paths"]["checkpoints"]) == _identity([
|
||||||
str(extra_checkpoint_dir).replace("\\", "/")
|
str(extra_checkpoint_dir).replace("\\", "/")
|
||||||
]
|
])
|
||||||
assert fake_settings.payload["extra_folder_paths"]["embeddings"] == [
|
assert _identity(fake_settings.payload["extra_folder_paths"]["embeddings"]) == _identity([
|
||||||
str(extra_embedding_dir).replace("\\", "/")
|
str(extra_embedding_dir).replace("\\", "/")
|
||||||
]
|
])
|
||||||
assert fake_settings.payload["default_lora_root"] == str(extra_lora_dir).replace("\\", "/")
|
assert _identity(fake_settings.payload["default_lora_root"]) == _identity(str(extra_lora_dir).replace("\\", "/"))
|
||||||
assert fake_settings.payload["default_checkpoint_root"] == str(extra_checkpoint_dir).replace("\\", "/")
|
assert _identity(fake_settings.payload["default_checkpoint_root"]) == _identity(str(extra_checkpoint_dir).replace("\\", "/"))
|
||||||
assert fake_settings.payload["default_embedding_root"] == str(extra_embedding_dir).replace("\\", "/")
|
assert _identity(fake_settings.payload["default_embedding_root"]) == _identity(str(extra_embedding_dir).replace("\\", "/"))
|
||||||
assert fake_settings.payload["activate"] is True
|
assert fake_settings.payload["activate"] is True
|
||||||
|
|
||||||
|
|
||||||
@@ -451,8 +470,8 @@ def test_save_paths_keeps_default_roots_in_extra_paths_with_windows_slash_mismat
|
|||||||
config_module.Config()
|
config_module.Config()
|
||||||
|
|
||||||
assert fake_settings.name == "comfyui"
|
assert fake_settings.name == "comfyui"
|
||||||
assert fake_settings.payload["default_lora_root"] == "U:/Lora7/Loras"
|
assert _identity(fake_settings.payload["default_lora_root"]) == _identity("U:/Lora7/Loras")
|
||||||
assert fake_settings.payload["default_checkpoint_root"] == "U:/Lora7/Models"
|
assert _identity(fake_settings.payload["default_checkpoint_root"]) == _identity("U:/Lora7/Models")
|
||||||
|
|
||||||
|
|
||||||
def test_save_paths_repairs_empty_default_roots_to_extra_paths_when_primary_missing(
|
def test_save_paths_repairs_empty_default_roots_to_extra_paths_when_primary_missing(
|
||||||
@@ -505,7 +524,7 @@ def test_save_paths_repairs_empty_default_roots_to_extra_paths_when_primary_miss
|
|||||||
config_module.Config()
|
config_module.Config()
|
||||||
|
|
||||||
assert fake_settings.name == "comfyui"
|
assert fake_settings.name == "comfyui"
|
||||||
assert fake_settings.payload["default_lora_root"] == str(extra_lora_dir).replace("\\", "/")
|
assert _identity(fake_settings.payload["default_lora_root"]) == _identity(str(extra_lora_dir).replace("\\", "/"))
|
||||||
|
|
||||||
|
|
||||||
def test_save_paths_does_not_activate_comfyui_library_when_another_library_is_active(
|
def test_save_paths_does_not_activate_comfyui_library_when_another_library_is_active(
|
||||||
@@ -585,12 +604,12 @@ def test_apply_library_settings_merges_extra_paths(monkeypatch, tmp_path):
|
|||||||
|
|
||||||
config_instance.apply_library_settings(library_config)
|
config_instance.apply_library_settings(library_config)
|
||||||
|
|
||||||
assert str(loras_dir) in config_instance.loras_roots
|
assert _identity(str(loras_dir)) in _identity(config_instance.loras_roots)
|
||||||
assert str(extra_loras_dir) in config_instance.extra_loras_roots
|
assert _identity(str(extra_loras_dir)) in _identity(config_instance.extra_loras_roots)
|
||||||
assert str(checkpoints_dir) in config_instance.base_models_roots
|
assert _identity(str(checkpoints_dir)) in _identity(config_instance.base_models_roots)
|
||||||
assert str(extra_checkpoints_dir) in config_instance.extra_checkpoints_roots
|
assert _identity(str(extra_checkpoints_dir)) in _identity(config_instance.extra_checkpoints_roots)
|
||||||
assert str(embeddings_dir) in (config_instance.embeddings_roots or [])
|
assert _identity(str(embeddings_dir)) in _identity((config_instance.embeddings_roots or []))
|
||||||
assert str(extra_embeddings_dir) in config_instance.extra_embeddings_roots
|
assert _identity(str(extra_embeddings_dir)) in _identity(config_instance.extra_embeddings_roots)
|
||||||
|
|
||||||
|
|
||||||
def test_apply_library_settings_without_extra_paths(monkeypatch, tmp_path):
|
def test_apply_library_settings_without_extra_paths(monkeypatch, tmp_path):
|
||||||
@@ -617,12 +636,12 @@ def test_apply_library_settings_without_extra_paths(monkeypatch, tmp_path):
|
|||||||
|
|
||||||
config_instance.apply_library_settings(library_config)
|
config_instance.apply_library_settings(library_config)
|
||||||
|
|
||||||
assert str(loras_dir) in config_instance.loras_roots
|
assert _identity(str(loras_dir)) in _identity(config_instance.loras_roots)
|
||||||
assert config_instance.extra_loras_roots == []
|
assert _identity(config_instance.extra_loras_roots) == _identity([])
|
||||||
assert str(checkpoints_dir) in config_instance.base_models_roots
|
assert _identity(str(checkpoints_dir)) in _identity(config_instance.base_models_roots)
|
||||||
assert config_instance.extra_checkpoints_roots == []
|
assert _identity(config_instance.extra_checkpoints_roots) == _identity([])
|
||||||
assert str(embeddings_dir) in (config_instance.embeddings_roots or [])
|
assert _identity(str(embeddings_dir)) in _identity((config_instance.embeddings_roots or []))
|
||||||
assert config_instance.extra_embeddings_roots == []
|
assert _identity(config_instance.extra_embeddings_roots) == _identity([])
|
||||||
|
|
||||||
|
|
||||||
def test_extra_paths_deduplication(monkeypatch, tmp_path):
|
def test_extra_paths_deduplication(monkeypatch, tmp_path):
|
||||||
@@ -654,8 +673,8 @@ def test_extra_paths_deduplication(monkeypatch, tmp_path):
|
|||||||
|
|
||||||
config_instance.apply_library_settings(library_config)
|
config_instance.apply_library_settings(library_config)
|
||||||
|
|
||||||
assert config_instance.loras_roots == [str(loras_dir)]
|
assert _identity(config_instance.loras_roots) == _identity([str(loras_dir)])
|
||||||
assert config_instance.extra_loras_roots == [str(extra_loras_dir)]
|
assert _identity(config_instance.extra_loras_roots) == _identity([str(extra_loras_dir)])
|
||||||
|
|
||||||
|
|
||||||
def test_apply_library_settings_ignores_extra_lora_path_overlapping_primary_symlink(
|
def test_apply_library_settings_ignores_extra_lora_path_overlapping_primary_symlink(
|
||||||
@@ -687,8 +706,8 @@ def test_apply_library_settings_ignores_extra_lora_path_overlapping_primary_syml
|
|||||||
with caplog.at_level("WARNING", logger=config_module.logger.name):
|
with caplog.at_level("WARNING", logger=config_module.logger.name):
|
||||||
config_instance.apply_library_settings(library_config)
|
config_instance.apply_library_settings(library_config)
|
||||||
|
|
||||||
assert config_instance.loras_roots == [str(loras_link)]
|
assert _identity(config_instance.loras_roots) == _identity([str(loras_link)])
|
||||||
assert config_instance.extra_loras_roots == []
|
assert _identity(config_instance.extra_loras_roots) == _identity([])
|
||||||
|
|
||||||
warning_messages = [
|
warning_messages = [
|
||||||
record.message
|
record.message
|
||||||
@@ -764,8 +783,8 @@ def test_apply_library_settings_detects_overlap_case_insensitively(
|
|||||||
with caplog.at_level("WARNING", logger=config_module.logger.name):
|
with caplog.at_level("WARNING", logger=config_module.logger.name):
|
||||||
config_instance.apply_library_settings(library_config)
|
config_instance.apply_library_settings(library_config)
|
||||||
|
|
||||||
assert config_instance.loras_roots == [primary_path]
|
assert _identity(config_instance.loras_roots) == _identity([primary_path])
|
||||||
assert config_instance.extra_loras_roots == []
|
assert _identity(config_instance.extra_loras_roots) == _identity([])
|
||||||
assert any("same lora folder" in record.message.lower() for record in caplog.records)
|
assert any("same lora folder" in record.message.lower() for record in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
@@ -794,8 +813,8 @@ def test_apply_library_settings_ignores_missing_extra_lora_paths(monkeypatch, tm
|
|||||||
with caplog.at_level("WARNING", logger=config_module.logger.name):
|
with caplog.at_level("WARNING", logger=config_module.logger.name):
|
||||||
config_instance.apply_library_settings(library_config)
|
config_instance.apply_library_settings(library_config)
|
||||||
|
|
||||||
assert config_instance.loras_roots == [str(loras_dir)]
|
assert _identity(config_instance.loras_roots) == _identity([str(loras_dir)])
|
||||||
assert config_instance.extra_loras_roots == []
|
assert _identity(config_instance.extra_loras_roots) == _identity([])
|
||||||
assert not any("same lora folder" in record.message.lower() for record in caplog.records)
|
assert not any("same lora folder" in record.message.lower() for record in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
@@ -829,8 +848,8 @@ def test_apply_library_settings_ignores_extra_lora_path_overlapping_primary_root
|
|||||||
with caplog.at_level("WARNING", logger=config_module.logger.name):
|
with caplog.at_level("WARNING", logger=config_module.logger.name):
|
||||||
config_instance.apply_library_settings(library_config)
|
config_instance.apply_library_settings(library_config)
|
||||||
|
|
||||||
assert config_instance.loras_roots == [str(loras_dir)]
|
assert _identity(config_instance.loras_roots) == _identity([str(loras_dir)])
|
||||||
assert config_instance.extra_loras_roots == []
|
assert _identity(config_instance.extra_loras_roots) == _identity([])
|
||||||
assert any(
|
assert any(
|
||||||
"same lora folder" in record.message.lower()
|
"same lora folder" in record.message.lower()
|
||||||
for record in caplog.records
|
for record in caplog.records
|
||||||
@@ -960,11 +979,205 @@ def test_save_paths_keeps_configured_root_of_a_switched_off_drive(
|
|||||||
|
|
||||||
persisted = fake_settings.payload["folder_paths"]["loras"]
|
persisted = fake_settings.payload["folder_paths"]["loras"]
|
||||||
# The switched-off drive keeps its configured path...
|
# The switched-off drive keeps its configured path...
|
||||||
assert switched_off.replace("\\", "/") in persisted
|
assert _identity(switched_off.replace("\\", "/")) in _identity(persisted)
|
||||||
assert readable.replace("\\", "/") in persisted
|
assert _identity(readable.replace("\\", "/")) in _identity(persisted)
|
||||||
# ...the path the host really dropped is gone...
|
# ...the path the host really dropped is gone...
|
||||||
assert removed_by_user.replace("\\", "/") not in persisted
|
assert _identity(removed_by_user.replace("\\", "/")) not in _identity(persisted)
|
||||||
# ...and the live list still only holds what is readable right now.
|
# ...and the live list still only holds what is readable right now.
|
||||||
assert config_instance.loras_roots == [readable.replace("\\", "/")]
|
assert _identity(config_instance.loras_roots) == _identity([readable.replace("\\", "/")])
|
||||||
# The user's default root survives because it is merely unavailable.
|
# The user's default root survives because it is merely unavailable.
|
||||||
assert fake_settings.payload["default_lora_root"] == switched_off.replace("\\", "/")
|
assert _identity(fake_settings.payload["default_lora_root"]) == _identity(switched_off.replace("\\", "/"))
|
||||||
|
|
||||||
|
|
||||||
|
# --- roots the host registered but never created (issue #1146) --------------
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePersistentCache:
|
||||||
|
"""Stand-in for PersistentModelCache in the startup sync."""
|
||||||
|
|
||||||
|
def __init__(self, cached_roots=None, error: Exception | None = None):
|
||||||
|
self._cached_roots = cached_roots or {}
|
||||||
|
self._error = error
|
||||||
|
self.queries: List[tuple] = []
|
||||||
|
|
||||||
|
def roots_with_cached_entries(self, model_type: str, roots):
|
||||||
|
self.queries.append((model_type, list(roots)))
|
||||||
|
if self._error is not None:
|
||||||
|
raise self._error
|
||||||
|
known = {path.replace("\\", "/") for path in self._cached_roots.get(model_type, [])}
|
||||||
|
return {
|
||||||
|
root for root in roots if root.replace("\\", "/") in known
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_persistent_cache(monkeypatch: pytest.MonkeyPatch, cache) -> None:
|
||||||
|
from py.services import persistent_model_cache as persistent_model_cache_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
persistent_model_cache_module, "get_persistent_cache", lambda: cache
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _LibrarySettingsService:
|
||||||
|
"""Minimal settings service recording what the startup sync persists."""
|
||||||
|
|
||||||
|
active_library = "comfyui"
|
||||||
|
|
||||||
|
def __init__(self, library: Dict[str, Any]):
|
||||||
|
self._library = library
|
||||||
|
self.payload: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
def get_libraries(self):
|
||||||
|
return {"comfyui": self._library}
|
||||||
|
|
||||||
|
def rename_library(self, *_): # pragma: no cover - defensive guard
|
||||||
|
raise AssertionError("rename_library should not be invoked")
|
||||||
|
|
||||||
|
def delete_library(self, *_): # pragma: no cover - defensive guard
|
||||||
|
raise AssertionError("delete_library should not be invoked")
|
||||||
|
|
||||||
|
def get_active_library_name(self):
|
||||||
|
return self.active_library
|
||||||
|
|
||||||
|
def upsert_library(self, name: str, **payload):
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_paths_drops_registered_directories_that_never_existed(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||||
|
):
|
||||||
|
"""ComfyUI registers `<output>/<type>` for its save nodes unconditionally.
|
||||||
|
|
||||||
|
Those directories are not library paths when they do not exist and hold no
|
||||||
|
cached models: persisting them brings them back as "unavailable" roots on
|
||||||
|
every boot.
|
||||||
|
"""
|
||||||
|
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
|
||||||
|
unet_dir = tmp_path / "unet"
|
||||||
|
unet_dir.mkdir()
|
||||||
|
folder_paths["unet"] = [str(unet_dir)]
|
||||||
|
output_loras = str(tmp_path / "output" / "loras")
|
||||||
|
output_checkpoints = str(tmp_path / "output" / "checkpoints")
|
||||||
|
output_unet = str(tmp_path / "output" / "diffusion_models")
|
||||||
|
output_embeddings = str(tmp_path / "output" / "embeddings")
|
||||||
|
folder_paths["loras"].append(output_loras)
|
||||||
|
folder_paths["checkpoints"].append(output_checkpoints)
|
||||||
|
folder_paths["unet"].append(output_unet)
|
||||||
|
folder_paths["embeddings"].append(output_embeddings)
|
||||||
|
|
||||||
|
fake_settings = _LibrarySettingsService(
|
||||||
|
{
|
||||||
|
"folder_paths": {key: list(value) for key, value in folder_paths.items()},
|
||||||
|
"default_lora_root": folder_paths["loras"][0],
|
||||||
|
"default_checkpoint_root": folder_paths["checkpoints"][0],
|
||||||
|
"default_embedding_root": folder_paths["embeddings"][0],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
|
||||||
|
cache = _FakePersistentCache()
|
||||||
|
_stub_persistent_cache(monkeypatch, cache)
|
||||||
|
|
||||||
|
config_module.Config()
|
||||||
|
|
||||||
|
persisted = fake_settings.payload["folder_paths"]
|
||||||
|
for key, never_created in (
|
||||||
|
("loras", output_loras),
|
||||||
|
("checkpoints", output_checkpoints),
|
||||||
|
("unet", output_unet),
|
||||||
|
("embeddings", output_embeddings),
|
||||||
|
):
|
||||||
|
assert _identity(never_created.replace("\\", "/")) not in _identity(persisted[key]), key
|
||||||
|
# The real root of that key is untouched.
|
||||||
|
assert _identity(folder_paths[key][0].replace("\\", "/")) in _identity(persisted[key]), key
|
||||||
|
# Lora and checkpoint roots are looked up in their own model type.
|
||||||
|
assert [model_type for model_type, _roots in cache.queries] == [
|
||||||
|
"lora",
|
||||||
|
"checkpoint",
|
||||||
|
"checkpoint",
|
||||||
|
"embedding",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_paths_keeps_an_unavailable_root_that_has_cached_models(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||||
|
):
|
||||||
|
"""A switched-off drive keeps its path: its models are still cached."""
|
||||||
|
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
|
||||||
|
readable = folder_paths["loras"][0]
|
||||||
|
switched_off = str(tmp_path / "drive-Z" / "loras")
|
||||||
|
folder_paths["loras"] = [readable, switched_off]
|
||||||
|
|
||||||
|
fake_settings = _LibrarySettingsService(
|
||||||
|
{
|
||||||
|
"folder_paths": {"loras": [readable]},
|
||||||
|
"default_lora_root": readable,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
|
||||||
|
_stub_persistent_cache(
|
||||||
|
monkeypatch, _FakePersistentCache({"lora": [switched_off]})
|
||||||
|
)
|
||||||
|
|
||||||
|
config_module.Config()
|
||||||
|
|
||||||
|
persisted = fake_settings.payload["folder_paths"]["loras"]
|
||||||
|
assert _identity(switched_off.replace("\\", "/")) in _identity(persisted)
|
||||||
|
assert _identity(readable.replace("\\", "/")) in _identity(persisted)
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_paths_keeps_every_root_when_the_cache_cannot_be_read(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||||
|
):
|
||||||
|
"""No answer from the cache means no root is dropped."""
|
||||||
|
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
|
||||||
|
readable = folder_paths["loras"][0]
|
||||||
|
unknown = str(tmp_path / "drive-Z" / "loras")
|
||||||
|
folder_paths["loras"] = [readable, unknown]
|
||||||
|
|
||||||
|
fake_settings = _LibrarySettingsService(
|
||||||
|
{
|
||||||
|
"folder_paths": {"loras": [readable]},
|
||||||
|
"default_lora_root": readable,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
|
||||||
|
_stub_persistent_cache(
|
||||||
|
monkeypatch, _FakePersistentCache(error=FileNotFoundError("no cache"))
|
||||||
|
)
|
||||||
|
|
||||||
|
config_module.Config()
|
||||||
|
|
||||||
|
persisted = fake_settings.payload["folder_paths"]["loras"]
|
||||||
|
assert _identity(unknown.replace("\\", "/")) in _identity(persisted)
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_paths_keeps_the_root_a_default_setting_points_at(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||||
|
):
|
||||||
|
"""An empty unavailable default root must not be dropped.
|
||||||
|
|
||||||
|
Dropping it would let the next boot "repair" the default onto another root and
|
||||||
|
silently retarget downloads. ``default_unet_root`` is only known from the
|
||||||
|
stored library (the sync resolves the lora/checkpoint/embedding ones itself),
|
||||||
|
so this covers the fallback to the stored value.
|
||||||
|
"""
|
||||||
|
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
|
||||||
|
unet_dir = tmp_path / "unet"
|
||||||
|
unet_dir.mkdir()
|
||||||
|
default_target = str(tmp_path / "drive-Z" / "unet")
|
||||||
|
folder_paths["unet"] = [str(unet_dir), default_target]
|
||||||
|
|
||||||
|
fake_settings = _LibrarySettingsService(
|
||||||
|
{
|
||||||
|
"folder_paths": {"unet": [str(unet_dir)]},
|
||||||
|
"default_unet_root": default_target,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
|
||||||
|
_stub_persistent_cache(monkeypatch, _FakePersistentCache())
|
||||||
|
|
||||||
|
config_module.Config()
|
||||||
|
|
||||||
|
persisted = fake_settings.payload["folder_paths"]["unet"]
|
||||||
|
assert _identity(default_target.replace("\\", "/")) in _identity(persisted)
|
||||||
|
assert _identity(str(unet_dir).replace("\\", "/")) in _identity(persisted)
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ function createApiClient(overrides = {}) {
|
|||||||
dir_count: 0,
|
dir_count: 0,
|
||||||
restorable: true,
|
restorable: true,
|
||||||
}),
|
}),
|
||||||
|
forgetFolder: vi.fn().mockResolvedValue({ success: true, folder: 'empty' }),
|
||||||
renameFolder: vi.fn().mockResolvedValue({
|
renameFolder: vi.fn().mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
renamed: true,
|
renamed: true,
|
||||||
@@ -1294,7 +1295,7 @@ describe('SidebarManager folder deletion', () => {
|
|||||||
expect(manager.refresh).toHaveBeenCalledTimes(1);
|
expect(manager.refresh).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports a stale tree node as missing instead of fabricating a path', async () => {
|
it('offers to clear a stale tree node instead of fabricating a path', async () => {
|
||||||
const apiClient = createApiClient({
|
const apiClient = createApiClient({
|
||||||
resolveFolder: vi.fn().mockResolvedValue({
|
resolveFolder: vi.fn().mockResolvedValue({
|
||||||
success: true, folder: 'removed', candidates: [],
|
success: true, folder: 'removed', candidates: [],
|
||||||
@@ -1307,9 +1308,85 @@ describe('SidebarManager folder deletion', () => {
|
|||||||
|
|
||||||
const modal = document.getElementById('deleteFolderModal');
|
const modal = document.getElementById('deleteFolderModal');
|
||||||
expect(modal.dataset.state).toBe('missing');
|
expect(modal.dataset.state).toBe('missing');
|
||||||
expect(confirmBtn().style.display).toBe('none');
|
// Nothing to delete, so the delete probe never runs and no path is guessed...
|
||||||
expect(manager._pendingDeleteFolderPath).toBeNull();
|
|
||||||
expect(apiClient.deleteFolder).not.toHaveBeenCalled();
|
expect(apiClient.deleteFolder).not.toHaveBeenCalled();
|
||||||
|
expect(manager._pendingDeleteFolderPath).toBeNull();
|
||||||
|
// ...but the records are all that keeps the node alive, so they can be
|
||||||
|
// dropped from here (issue #1145).
|
||||||
|
expect(confirmBtn().style.display).toBe('');
|
||||||
|
expect(confirmBtn().disabled).toBe(false);
|
||||||
|
expect(confirmBtn().textContent).toBe('Remove from list');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the node through the forget endpoint when no root holds it', async () => {
|
||||||
|
const apiClient = createApiClient({
|
||||||
|
resolveFolder: vi.fn().mockResolvedValue({
|
||||||
|
success: true, folder: 'removed', candidates: [],
|
||||||
|
}),
|
||||||
|
forgetFolder: vi.fn().mockResolvedValue({ success: true, folder: 'removed' }),
|
||||||
|
});
|
||||||
|
const manager = createManager(apiClient);
|
||||||
|
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||||
|
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await manager.showDeleteFolderModal('removed');
|
||||||
|
const cleared = await manager.handleDeleteFolderConfirm();
|
||||||
|
|
||||||
|
expect(cleared).toBe(true);
|
||||||
|
expect(apiClient.forgetFolder).toHaveBeenCalledWith('removed');
|
||||||
|
expect(apiClient.deleteFolder).not.toHaveBeenCalled();
|
||||||
|
expect(manager.refresh).toHaveBeenCalledTimes(1);
|
||||||
|
expect(showToast).toHaveBeenCalledWith(
|
||||||
|
'sidebar.deleteFolderResult.forgotten',
|
||||||
|
{ name: 'removed' },
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a delete that found the folder gone as cleared, not deleted', async () => {
|
||||||
|
// The probe can pass and the directory still be gone by the time the real
|
||||||
|
// delete runs (a file manager move in between, or another row of the
|
||||||
|
// multi-root list): the records are dropped and nothing was deleted.
|
||||||
|
const apiClient = createApiClient({
|
||||||
|
deleteFolder: vi.fn().mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
code: 'missing',
|
||||||
|
already_missing: true,
|
||||||
|
folder: 'empty',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const manager = createManager(apiClient);
|
||||||
|
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const deleted = await manager._deleteFolder('empty', '/models/loras/empty');
|
||||||
|
|
||||||
|
expect(deleted).toBe(true);
|
||||||
|
expect(showToast).toHaveBeenCalledWith(
|
||||||
|
'sidebar.deleteFolderResult.forgotten',
|
||||||
|
{ name: 'empty' },
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
expect(showActionToast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns when the folder turned out to be back on disk', async () => {
|
||||||
|
const apiClient = createApiClient({
|
||||||
|
resolveFolder: vi.fn().mockResolvedValue({
|
||||||
|
success: true, folder: 'back', candidates: [],
|
||||||
|
}),
|
||||||
|
forgetFolder: vi.fn().mockRejectedValue(
|
||||||
|
Object.assign(new Error('The folder still exists on disk'), { code: 'exists' })
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const manager = createManager(apiClient);
|
||||||
|
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const cleared = await manager._deleteFolder('back');
|
||||||
|
|
||||||
|
expect(cleared).toBe(false);
|
||||||
|
expect(showToast).toHaveBeenCalledWith(
|
||||||
|
'sidebar.deleteFolderResult.exists', {}, 'warning'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refuses a node whose every copy is a symbolic link', async () => {
|
it('refuses a node whose every copy is a symbolic link', async () => {
|
||||||
|
|||||||
@@ -248,3 +248,43 @@ def test_static_sources_only_use_existing_translations(
|
|||||||
"Static sources reference missing translation keys: "
|
"Static sources reference missing translation keys: "
|
||||||
f"{missing_keys[:20]}" + ("..." if len(missing_keys) > 20 else "")
|
f"{missing_keys[:20]}" + ("..." if len(missing_keys) > 20 else "")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# The reference file is hand-maintained with 2-space indent; every other locale
|
||||||
|
# is written by scripts/sync_translation_keys.py with 4-space indent. Both keep
|
||||||
|
# raw non-ASCII and exactly one trailing newline.
|
||||||
|
REFERENCE_INDENT = 2
|
||||||
|
TRANSLATION_INDENT = 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_locale_files_keep_their_maintained_layout():
|
||||||
|
"""A re-serialising edit must fail here instead of landing as a huge diff.
|
||||||
|
|
||||||
|
Loading a locale with ``json.load`` and dumping it back with a different
|
||||||
|
``indent`` rewrites every line: changing two strings once produced a
|
||||||
|
26k-line diff that had to be reverted and redone in place. Values belong in
|
||||||
|
place (a targeted text edit), keys belong to ``sync_translation_keys.py``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
offenders = []
|
||||||
|
for locale in EXPECTED_LOCALES:
|
||||||
|
path = LOCALES_DIR / f"{locale}.json"
|
||||||
|
raw = path.read_text(encoding="utf-8")
|
||||||
|
indent = REFERENCE_INDENT if locale == "en" else TRANSLATION_INDENT
|
||||||
|
canonical = (
|
||||||
|
json.dumps(
|
||||||
|
json.loads(raw),
|
||||||
|
indent=indent,
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ": "),
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
if raw != canonical:
|
||||||
|
offenders.append(path.name)
|
||||||
|
|
||||||
|
assert not offenders, (
|
||||||
|
"these locale files were reformatted: "
|
||||||
|
f"{offenders}. Keep en.json at indent=2 and the others at indent=4 "
|
||||||
|
"(ensure_ascii=False, one trailing newline) and edit values in place."
|
||||||
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import pytest
|
|||||||
|
|
||||||
from py.nodes.lora_cycler import LoraCyclerLM
|
from py.nodes.lora_cycler import LoraCyclerLM
|
||||||
from py.services import service_registry
|
from py.services import service_registry
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -48,7 +49,7 @@ async def test_cycler_uses_scaled_preset_strength_when_available(
|
|||||||
result = await cycler_node.cycle(cycler_config)
|
result = await cycler_node.cycle(cycler_config)
|
||||||
|
|
||||||
assert result["result"][0] == [
|
assert result["result"][0] == [
|
||||||
("/models/loras/preset_lora.safetensors", 1.05, 0.75)
|
("/models/loras/preset_lora.safetensors".replace("/", os.sep), 1.05, 0.75)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -74,7 +75,7 @@ async def test_cycler_falls_back_to_manual_strength_when_preset_missing(
|
|||||||
result = await cycler_node.cycle(cycler_config)
|
result = await cycler_node.cycle(cycler_config)
|
||||||
|
|
||||||
assert result["result"][0] == [
|
assert result["result"][0] == [
|
||||||
("/models/loras/manual_lora.safetensors", 0.8, 0.6)
|
("/models/loras/manual_lora.safetensors".replace("/", os.sep), 0.8, 0.6)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -105,5 +106,5 @@ async def test_cycler_syncs_clip_to_model_when_same_clip_strength_enabled(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result["result"][0] == [
|
assert result["result"][0] == [
|
||||||
("/models/loras/preset_lora.safetensors", 1.05, 1.05)
|
("/models/loras/preset_lora.safetensors".replace("/", os.sep), 1.05, 1.05)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -932,8 +932,8 @@ def test_resolve_folder_answers_with_the_root_that_holds_the_directory(
|
|||||||
assert payload["folder"] == "recipes"
|
assert payload["folder"] == "recipes"
|
||||||
assert payload["candidates"] == [
|
assert payload["candidates"] == [
|
||||||
{
|
{
|
||||||
"folder_path": str(primary / "recipes"),
|
"folder_path": str(primary / "recipes").replace(os.sep, "/"),
|
||||||
"root": str(primary),
|
"root": str(primary).replace(os.sep, "/"),
|
||||||
"is_symlink": False,
|
"is_symlink": False,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -175,6 +175,10 @@ async def test_validate_path_expands_user(tmp_path, monkeypatch):
|
|||||||
subdir = tmp_path / "subdir"
|
subdir = tmp_path / "subdir"
|
||||||
subdir.mkdir()
|
subdir.mkdir()
|
||||||
monkeypatch.setenv("HOME", str(tmp_path))
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
# ntpath.expanduser reads USERPROFILE (or HOMEDRIVE + HOMEPATH), never HOME.
|
||||||
|
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||||
|
monkeypatch.setenv("HOMEDRIVE", tmp_path.drive)
|
||||||
|
monkeypatch.setenv("HOMEPATH", str(tmp_path)[len(tmp_path.drive):])
|
||||||
|
|
||||||
response, payload = await _validate(_make_handler(), "~/subdir")
|
response, payload = await _validate(_make_handler(), "~/subdir")
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,18 @@ def _json_payload(response) -> dict[str, Any]:
|
|||||||
return json.loads(text)
|
return json.loads(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_opener() -> str:
|
||||||
|
"""The command the handler uses to open a path on this platform."""
|
||||||
|
|
||||||
|
return "explorer" if os.name == "nt" else "xdg-open"
|
||||||
|
|
||||||
|
|
||||||
|
def _posix_only(reason: str):
|
||||||
|
"""Mark a test that exercises the POSIX open strategy (xdg-open/dbus)."""
|
||||||
|
|
||||||
|
return pytest.mark.skipif(os.name == "nt", reason=reason)
|
||||||
|
|
||||||
|
|
||||||
class FakeRequest:
|
class FakeRequest:
|
||||||
def __init__(self, *, json_data=None, query=None, method="POST"):
|
def __init__(self, *, json_data=None, query=None, method="POST"):
|
||||||
self._json_data = json_data or {}
|
self._json_data = json_data or {}
|
||||||
@@ -636,7 +648,7 @@ async def test_open_backup_location_uses_settings_directory(tmp_path, monkeypatc
|
|||||||
assert response.status == 200
|
assert response.status == 200
|
||||||
assert payload["success"] is True
|
assert payload["success"] is True
|
||||||
assert payload["path"] == str(backup_dir)
|
assert payload["path"] == str(backup_dir)
|
||||||
assert calls == [["xdg-open", str(backup_dir)]]
|
assert calls == [[_expected_opener(), str(backup_dir)]]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -667,9 +679,10 @@ async def test_open_sidecar_location_opens_configured_root(tmp_path, monkeypatch
|
|||||||
assert payload["path"] == str(root)
|
assert payload["path"] == str(root)
|
||||||
# Created on demand so the button works before any migration ran.
|
# Created on demand so the button works before any migration ran.
|
||||||
assert root.is_dir()
|
assert root.is_dir()
|
||||||
assert calls == [["xdg-open", str(root)]]
|
assert calls == [[_expected_opener(), str(root)]]
|
||||||
|
|
||||||
|
|
||||||
|
@_posix_only("the headless clipboard fallback is the POSIX strategy; Windows always has a shell")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_open_sidecar_location_headless_returns_clipboard_mode(tmp_path, monkeypatch):
|
async def test_open_sidecar_location_headless_returns_clipboard_mode(tmp_path, monkeypatch):
|
||||||
"""Without a GUI session xdg-open cannot work; the handler must hand the
|
"""Without a GUI session xdg-open cannot work; the handler must hand the
|
||||||
@@ -721,6 +734,7 @@ async def test_get_settings_includes_resolved_sidecar_root(tmp_path):
|
|||||||
assert payload["settings"]["sidecar_storage_root_in_repo"] is False
|
assert payload["settings"]["sidecar_storage_root_in_repo"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@_posix_only("the headless clipboard fallback is the POSIX strategy; Windows always has a shell")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_open_settings_location_headless_returns_clipboard_mode(tmp_path, monkeypatch):
|
async def test_open_settings_location_headless_returns_clipboard_mode(tmp_path, monkeypatch):
|
||||||
"""Without a GUI session xdg-open cannot work; the handler must hand the
|
"""Without a GUI session xdg-open cannot work; the handler must hand the
|
||||||
@@ -774,7 +788,11 @@ async def test_open_settings_location_with_display_opens_folder(tmp_path, monkey
|
|||||||
|
|
||||||
assert response.status == 200
|
assert response.status == 200
|
||||||
assert payload["success"] is True
|
assert payload["success"] is True
|
||||||
assert calls == [["xdg-open", str(settings_file.parent)]]
|
if os.name == "nt":
|
||||||
|
# explorer reveals the file itself, there is no "open the folder" call.
|
||||||
|
assert calls == [["explorer", "/select,", str(settings_file)]]
|
||||||
|
else:
|
||||||
|
assert calls == [[_expected_opener(), str(settings_file.parent)]]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -807,9 +825,10 @@ async def test_open_wildcards_location_creates_and_opens_directory(tmp_path, mon
|
|||||||
assert payload["success"] is True
|
assert payload["success"] is True
|
||||||
assert payload["path"] == str(wildcards_dir)
|
assert payload["path"] == str(wildcards_dir)
|
||||||
assert wildcards_dir.is_dir()
|
assert wildcards_dir.is_dir()
|
||||||
assert calls == [["xdg-open", str(wildcards_dir)]]
|
assert calls == [[_expected_opener(), str(wildcards_dir)]]
|
||||||
|
|
||||||
|
|
||||||
|
@_posix_only("dbus file-manager selection is Linux-only")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_open_file_location_linux_selects_via_dbus(tmp_path, monkeypatch):
|
async def test_open_file_location_linux_selects_via_dbus(tmp_path, monkeypatch):
|
||||||
"""On a Linux desktop the handler should ask the freedesktop FileManager1
|
"""On a Linux desktop the handler should ask the freedesktop FileManager1
|
||||||
@@ -849,6 +868,7 @@ async def test_open_file_location_linux_selects_via_dbus(tmp_path, monkeypatch):
|
|||||||
assert popen_calls == []
|
assert popen_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@_posix_only("xdg-open is the Linux opener")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_open_file_location_linux_falls_back_to_xdg_open(tmp_path, monkeypatch):
|
async def test_open_file_location_linux_falls_back_to_xdg_open(tmp_path, monkeypatch):
|
||||||
"""When the FileManager1 DBus interface is unavailable, fall back to
|
"""When the FileManager1 DBus interface is unavailable, fall back to
|
||||||
@@ -877,9 +897,10 @@ async def test_open_file_location_linux_falls_back_to_xdg_open(tmp_path, monkeyp
|
|||||||
|
|
||||||
assert response.status == 200
|
assert response.status == 200
|
||||||
assert payload["success"] is True
|
assert payload["success"] is True
|
||||||
assert popen_calls == [["xdg-open", str(tmp_path)]]
|
assert popen_calls == [[_expected_opener(), str(tmp_path)]]
|
||||||
|
|
||||||
|
|
||||||
|
@_posix_only("the headless clipboard fallback is the POSIX strategy; Windows always has a shell")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_open_file_location_headless_returns_clipboard_mode(tmp_path, monkeypatch):
|
async def test_open_file_location_headless_returns_clipboard_mode(tmp_path, monkeypatch):
|
||||||
"""Without a GUI session there is no file manager to open; the handler
|
"""Without a GUI session there is no file manager to open; the handler
|
||||||
@@ -917,6 +938,7 @@ def _patch_linux_desktop(monkeypatch):
|
|||||||
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_wsl", lambda: False)
|
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_wsl", lambda: False)
|
||||||
|
|
||||||
|
|
||||||
|
@_posix_only("file-manager reveal via dbus is Linux-only")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_open_model_sidecar_location_reveals_metadata_file(tmp_path, monkeypatch):
|
async def test_open_model_sidecar_location_reveals_metadata_file(tmp_path, monkeypatch):
|
||||||
"""Centralized mode: the resolved .metadata.json is revealed selected,
|
"""Centralized mode: the resolved .metadata.json is revealed selected,
|
||||||
@@ -985,7 +1007,7 @@ async def test_open_model_sidecar_location_falls_back_to_sidecar_dir(tmp_path, m
|
|||||||
assert response.status == 200
|
assert response.status == 200
|
||||||
assert payload["success"] is True
|
assert payload["success"] is True
|
||||||
assert payload["path"] == str(sidecar_dir)
|
assert payload["path"] == str(sidecar_dir)
|
||||||
assert popen_calls == [["xdg-open", str(sidecar_dir)]]
|
assert popen_calls == [[_expected_opener(), str(sidecar_dir)]]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -1012,6 +1034,7 @@ async def test_open_model_sidecar_location_missing_sidecar_returns_404(tmp_path,
|
|||||||
assert payload["success"] is False
|
assert payload["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@_posix_only("the headless clipboard fallback is the POSIX strategy; Windows always has a shell")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_open_model_sidecar_location_headless_returns_clipboard_mode(tmp_path, monkeypatch):
|
async def test_open_model_sidecar_location_headless_returns_clipboard_mode(tmp_path, monkeypatch):
|
||||||
model_file = tmp_path / "model.safetensors"
|
model_file = tmp_path / "model.safetensors"
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ class FakeMoveService:
|
|||||||
self.received_dry_run = dry_run
|
self.received_dry_run = dry_run
|
||||||
return self._result
|
return self._result
|
||||||
|
|
||||||
|
async def forget_folder(self, folder):
|
||||||
|
self.received_folder = folder
|
||||||
|
return self._result
|
||||||
|
|
||||||
async def rename_folder(self, folder_path, new_name):
|
async def rename_folder(self, folder_path, new_name):
|
||||||
self.received_path = folder_path
|
self.received_path = folder_path
|
||||||
self.received_new_name = new_name
|
self.received_new_name = new_name
|
||||||
@@ -34,13 +38,26 @@ class FakeMoveService:
|
|||||||
|
|
||||||
|
|
||||||
class FakeRequest:
|
class FakeRequest:
|
||||||
|
method = "POST"
|
||||||
|
|
||||||
def __init__(self, payload):
|
def __init__(self, payload):
|
||||||
self._payload = payload
|
self._payload = payload
|
||||||
|
# A real aiohttp request always carries a query string too.
|
||||||
|
self.query = {}
|
||||||
|
|
||||||
async def json(self):
|
async def json(self):
|
||||||
return self._payload
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
class FakeGetRequest:
|
||||||
|
"""GET stand-in carrying only a query string, as the extension sends it."""
|
||||||
|
|
||||||
|
method = "GET"
|
||||||
|
|
||||||
|
def __init__(self, query):
|
||||||
|
self.query = query
|
||||||
|
|
||||||
|
|
||||||
class FakeQueryRequest:
|
class FakeQueryRequest:
|
||||||
"""Request stand-in exposing only the query string."""
|
"""Request stand-in exposing only the query string."""
|
||||||
|
|
||||||
@@ -394,3 +411,54 @@ async def test_rename_folder_invalid_json_body():
|
|||||||
|
|
||||||
assert response.status == 400
|
assert response.status == 400
|
||||||
assert json.loads(response.text)["success"] is False
|
assert json.loads(response.text)["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_forget_folder_success():
|
||||||
|
handler, service = _make_handler({"success": True, "folder": "Krea2/Women"})
|
||||||
|
|
||||||
|
response = await handler.forget_folder(FakeRequest({"folder": "Krea2/Women"}))
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert json.loads(response.text)["folder"] == "Krea2/Women"
|
||||||
|
assert service.received_folder == "Krea2/Women"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_forget_folder_accepts_get_with_query_params():
|
||||||
|
"""Forgetting is a state change, but the extension is GET-only by convention."""
|
||||||
|
handler, service = _make_handler({"success": True, "folder": "Krea2/Women"})
|
||||||
|
|
||||||
|
response = await handler.forget_folder(
|
||||||
|
FakeGetRequest({"folder": "Krea2/Women"})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert service.received_folder == "Krea2/Women"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_forget_folder_requires_a_folder():
|
||||||
|
handler, service = _make_handler({"success": True})
|
||||||
|
|
||||||
|
response = await handler.forget_folder(FakeRequest({}))
|
||||||
|
|
||||||
|
assert response.status == 400
|
||||||
|
assert json.loads(response.text)["success"] is False
|
||||||
|
assert service.received_folder is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_forget_folder_existing_directory_maps_to_409():
|
||||||
|
"""The tree the client rendered is stale: the folder must be deleted."""
|
||||||
|
handler, _service = _make_handler(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"code": "exists",
|
||||||
|
"error": "The folder still exists on disk",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.forget_folder(FakeRequest({"folder": "Krea2/Women"}))
|
||||||
|
|
||||||
|
assert response.status == 409
|
||||||
|
|||||||
@@ -316,6 +316,32 @@ async def test_scan_models_rejects_unknown_root():
|
|||||||
assert service.received_scope == "not-called"
|
assert service.received_scope == "not-called"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_models_accepts_a_root_in_the_hosts_own_spelling():
|
||||||
|
"""Windows callers may pass a root the way ComfyUI spells it (issue #1146).
|
||||||
|
|
||||||
|
``settings.json`` and the ComfyUI logs show ``E:\\loras`` while the live list
|
||||||
|
holds ``E:/loras``; both name the same directory, so a scoped scan must select
|
||||||
|
it instead of answering 400 "Unknown model root(s)".
|
||||||
|
"""
|
||||||
|
service = ScopedScanService(
|
||||||
|
roots=["E:/StableDiffusion/cc_models/loras"], summary=SUMMARY
|
||||||
|
)
|
||||||
|
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
|
||||||
|
|
||||||
|
for requested in (
|
||||||
|
"E:\\StableDiffusion\\cc_models\\loras",
|
||||||
|
"e:/StableDiffusion/cc_models/loras",
|
||||||
|
):
|
||||||
|
service.received_scope = "not-called"
|
||||||
|
response = await handler.scan_models(
|
||||||
|
SimpleNamespace(query=QueryParams({"roots": [requested]}))
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 200, requested
|
||||||
|
assert service.received_scope.roots == (requested,)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scan_models_rejects_roots_with_full_rebuild():
|
async def test_scan_models_rejects_roots_with_full_rebuild():
|
||||||
service = ScopedScanService(roots=["/mnt/a"], summary=SUMMARY)
|
service = ScopedScanService(roots=["/mnt/a"], summary=SUMMARY)
|
||||||
|
|||||||
@@ -403,9 +403,7 @@ def test_find_matching_root_includes_other_roots(isolated_config_roots, tmp_path
|
|||||||
vae_root = tmp_path / "vae"
|
vae_root = tmp_path / "vae"
|
||||||
isolated_config_roots.other_roots = [str(vae_root)]
|
isolated_config_roots.other_roots = [str(vae_root)]
|
||||||
|
|
||||||
assert model_source_handlers._find_matching_root(str(vae_root / "sub")) == str(
|
assert model_source_handlers._find_matching_root(str(vae_root / "sub")) == str(vae_root).replace(os.sep, "/").replace(os.sep, "/")
|
||||||
vae_root
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_find_matching_root_respects_path_boundaries(isolated_config_roots, tmp_path):
|
def test_find_matching_root_respects_path_boundaries(isolated_config_roots, tmp_path):
|
||||||
@@ -1239,7 +1237,7 @@ async def test_download_hydrates_the_card_from_the_site(tmp_path, monkeypatch):
|
|||||||
"https://resources.modelscope.cn/cover-images/b.png",
|
"https://resources.modelscope.cn/cover-images/b.png",
|
||||||
"https://resources.modelscope.cn/cover-images/c.png",
|
"https://resources.modelscope.cn/cover-images/c.png",
|
||||||
]
|
]
|
||||||
assert saved["preview_url"] == str(tmp_path / "preview.webp")
|
assert saved["preview_url"] == str(tmp_path / "preview.webp").replace(os.sep, "/")
|
||||||
assert saved["usage_tips"] == (
|
assert saved["usage_tips"] == (
|
||||||
'{"strength_min": 0.5, "strength_max": 1.2, "strength_range": "0.5-1.2"}'
|
'{"strength_min": 0.5, "strength_max": 1.2, "strength_range": "0.5-1.2"}'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -532,7 +532,7 @@ async def test_execute_download_extracts_zip_single_model(monkeypatch, tmp_path)
|
|||||||
extracted = save_dir / "model.safetensors"
|
extracted = save_dir / "model.safetensors"
|
||||||
assert extracted.exists()
|
assert extracted.exists()
|
||||||
saved_call = MetadataManager.save_metadata.await_args
|
saved_call = MetadataManager.save_metadata.await_args
|
||||||
assert saved_call.args[0] == str(extracted)
|
assert saved_call.args[0] == str(extracted).replace(os.sep, "/")
|
||||||
# SHA256 comes from metadata (API value), not recalculated
|
# SHA256 comes from metadata (API value), not recalculated
|
||||||
assert saved_call.args[1].sha256 == "sha256"
|
assert saved_call.args[1].sha256 == "sha256"
|
||||||
# The zip-level AutoV3 was cleared; the extracted file is not safetensors,
|
# The zip-level AutoV3 was cleared; the extracted file is not safetensors,
|
||||||
@@ -617,10 +617,10 @@ async def test_execute_download_extracts_zip_multiple_models(monkeypatch, tmp_pa
|
|||||||
assert dummy_scanner.add_model_to_cache.await_count == 2
|
assert dummy_scanner.add_model_to_cache.await_count == 2
|
||||||
|
|
||||||
metadata_calls = MetadataManager.save_metadata.await_args_list
|
metadata_calls = MetadataManager.save_metadata.await_args_list
|
||||||
assert metadata_calls[0].args[0] == str(extracted_one)
|
assert metadata_calls[0].args[0] == str(extracted_one).replace(os.sep, "/")
|
||||||
# SHA256 comes from metadata (API value), not recalculated
|
# SHA256 comes from metadata (API value), not recalculated
|
||||||
assert metadata_calls[0].args[1].sha256 == "sha256"
|
assert metadata_calls[0].args[1].sha256 == "sha256"
|
||||||
assert metadata_calls[1].args[0] == str(extracted_two)
|
assert metadata_calls[1].args[0] == str(extracted_two).replace(os.sep, "/")
|
||||||
assert metadata_calls[1].args[1].sha256 == "sha256"
|
assert metadata_calls[1].args[1].sha256 == "sha256"
|
||||||
|
|
||||||
|
|
||||||
@@ -693,7 +693,7 @@ async def test_execute_download_extracts_zip_pt_embedding(monkeypatch, tmp_path)
|
|||||||
extracted = save_dir / "embedding.pt"
|
extracted = save_dir / "embedding.pt"
|
||||||
assert extracted.exists()
|
assert extracted.exists()
|
||||||
saved_call = MetadataManager.save_metadata.await_args
|
saved_call = MetadataManager.save_metadata.await_args
|
||||||
assert saved_call.args[0] == str(extracted)
|
assert saved_call.args[0] == str(extracted).replace(os.sep, "/")
|
||||||
# SHA256 comes from metadata (API value), not recalculated
|
# SHA256 comes from metadata (API value), not recalculated
|
||||||
assert saved_call.args[1].sha256 == "sha256"
|
assert saved_call.args[1].sha256 == "sha256"
|
||||||
assert dummy_scanner.add_model_to_cache.await_count == 1
|
assert dummy_scanner.add_model_to_cache.await_count == 1
|
||||||
|
|||||||
@@ -359,7 +359,9 @@ async def test_delete_folder_success_manifest_carries_excluded_model_count(tmp_p
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_delete_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path):
|
async def test_delete_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path):
|
||||||
target = _make_nested(tmp_path)
|
target = _make_nested(tmp_path)
|
||||||
(target / ".lm-pending-delete").mkdir()
|
batch = target / ".lm-pending-delete" / "batch1"
|
||||||
|
batch.mkdir(parents=True)
|
||||||
|
(batch / "manifest.json").write_text("{}", encoding="utf-8")
|
||||||
scanner = FakeScanner([tmp_path])
|
scanner = FakeScanner([tmp_path])
|
||||||
service = ModelMoveService(scanner, "lora")
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
@@ -370,6 +372,43 @@ async def test_delete_folder_refuses_while_a_staged_delete_is_pending(tmp_path:
|
|||||||
assert target.exists()
|
assert target.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_folder_ignores_an_empty_staging_container(tmp_path: Path):
|
||||||
|
"""An empty `.lm-pending-delete` is a leftover, not a pending deletion.
|
||||||
|
|
||||||
|
Every purge, undo and failed staging used to leave the container behind, and
|
||||||
|
the name match alone made the folder undeletable for good: "A deletion is
|
||||||
|
still pending inside this folder. Wait for the undo window to expire." with
|
||||||
|
no window left to wait for (issue #1145 follow-up).
|
||||||
|
"""
|
||||||
|
target = _make_nested(tmp_path)
|
||||||
|
(target / ".lm-pending-delete").mkdir()
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
result = await service.delete_folder(str(target))
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert not target.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_folder_refuses_over_a_quarantined_batch(tmp_path: Path):
|
||||||
|
"""Quarantine preserves the user's staged files, so it still blocks."""
|
||||||
|
target = _make_nested(tmp_path)
|
||||||
|
quarantined = target / ".lm-pending-delete" / "batch1.orphaned"
|
||||||
|
quarantined.mkdir(parents=True)
|
||||||
|
(quarantined / "model.safetensors").write_bytes(b"user-data")
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
result = await service.delete_folder(str(target))
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["code"] == "busy"
|
||||||
|
assert (quarantined / "model.safetensors").read_bytes() == b"user-data"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_delete_folder_refuses_the_library_root_itself(tmp_path: Path):
|
async def test_delete_folder_refuses_the_library_root_itself(tmp_path: Path):
|
||||||
scanner = FakeScanner([tmp_path])
|
scanner = FakeScanner([tmp_path])
|
||||||
@@ -400,14 +439,74 @@ async def test_delete_folder_rejects_paths_outside_roots(tmp_path: Path):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_delete_folder_reports_missing_directory(tmp_path: Path):
|
async def test_delete_folder_forgets_a_directory_that_is_already_gone(tmp_path: Path):
|
||||||
|
"""There is nothing to delete, but the sidebar node can still be cleared.
|
||||||
|
|
||||||
|
A folder moved with a file manager leaves its records behind; answering
|
||||||
|
"Folder no longer exists" left the ghost with no way out of the UI (#1145).
|
||||||
|
"""
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
gone = tmp_path / "gone"
|
||||||
|
|
||||||
|
result = await service.delete_folder(str(gone))
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["code"] == "missing"
|
||||||
|
assert result["already_missing"] is True
|
||||||
|
assert result["folder"] == "gone"
|
||||||
|
assert scanner.removed_folders == ["gone"]
|
||||||
|
assert scanner.removed_folder_paths == [str(gone)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_folder_dry_run_still_reports_a_missing_directory(tmp_path: Path):
|
||||||
|
"""The probe stays a pure read — it is what makes the dialog offer the removal."""
|
||||||
scanner = FakeScanner([tmp_path])
|
scanner = FakeScanner([tmp_path])
|
||||||
service = ModelMoveService(scanner, "lora")
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
result = await service.delete_folder(str(tmp_path / "gone"))
|
result = await service.delete_folder(str(tmp_path / "gone"), dry_run=True)
|
||||||
|
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "no longer exists" in result["error"]
|
assert result["code"] == "missing"
|
||||||
|
assert scanner.removed_folders == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_forget_folder_drops_the_records_of_a_gone_directory(tmp_path: Path):
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
result = await service.forget_folder("Krea2/Women")
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["folder"] == "Krea2/Women"
|
||||||
|
# No absolute path: the same relative folder may exist under several roots,
|
||||||
|
# and the scanner keeps an entry another root still holds.
|
||||||
|
assert scanner.removed_folders == ["Krea2/Women"]
|
||||||
|
assert scanner.removed_folder_paths == [None]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_forget_folder_refuses_while_the_directory_still_exists(tmp_path: Path):
|
||||||
|
"""Then the caller wants a delete, not a cleanup."""
|
||||||
|
(tmp_path / "Krea2" / "Women").mkdir(parents=True)
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
result = await service.forget_folder("Krea2/Women")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["code"] == "exists"
|
||||||
|
assert scanner.removed_folders == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_forget_folder_requires_a_folder(tmp_path: Path):
|
||||||
|
service = ModelMoveService(FakeScanner([tmp_path]), "lora")
|
||||||
|
|
||||||
|
assert (await service.forget_folder(""))["success"] is False
|
||||||
|
assert (await service.forget_folder("../outside"))["success"] is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -609,7 +708,9 @@ async def test_rename_folder_refuses_symlinked_directory(tmp_path: Path):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_rename_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path):
|
async def test_rename_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path):
|
||||||
target = _make_nested(tmp_path)
|
target = _make_nested(tmp_path)
|
||||||
(target / ".lm-pending-delete").mkdir()
|
batch = target / ".lm-pending-delete" / "batch1"
|
||||||
|
batch.mkdir(parents=True)
|
||||||
|
(batch / "manifest.json").write_text("{}", encoding="utf-8")
|
||||||
scanner = FakeScanner([tmp_path])
|
scanner = FakeScanner([tmp_path])
|
||||||
service = ModelMoveService(scanner, "lora")
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
@@ -621,6 +722,20 @@ async def test_rename_folder_refuses_while_a_staged_delete_is_pending(tmp_path:
|
|||||||
assert scanner.renamed_folders == []
|
assert scanner.renamed_folders == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_folder_ignores_an_empty_staging_container(tmp_path: Path):
|
||||||
|
"""The leftover container must not block a rename either."""
|
||||||
|
target = _make_nested(tmp_path)
|
||||||
|
(target / ".lm-pending-delete").mkdir()
|
||||||
|
scanner = FakeScanner([tmp_path])
|
||||||
|
service = ModelMoveService(scanner, "lora")
|
||||||
|
|
||||||
|
result = await service.rename_folder(str(target), "animation")
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert scanner.renamed_folders
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_rename_folder_requires_path(tmp_path: Path):
|
async def test_rename_folder_requires_path(tmp_path: Path):
|
||||||
service = ModelMoveService(FakeScanner([tmp_path]), "lora")
|
service = ModelMoveService(FakeScanner([tmp_path]), "lora")
|
||||||
|
|||||||
@@ -48,6 +48,20 @@ class RecordingWebSocketManager:
|
|||||||
self.broadcasts.append(payload)
|
self.broadcasts.append(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _root_label_names(labels) -> List[str]:
|
||||||
|
"""The root names behind display labels.
|
||||||
|
|
||||||
|
``_root_display_labels`` prefixes the drive on Windows ("C: loras") and may
|
||||||
|
grow a label leftwards to keep it unique ("parent/loras"), so a test that only
|
||||||
|
cares *which* root was walked compares the final path component.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return [
|
||||||
|
label.replace("\\", "/").rstrip("/").rsplit("/", 1)[-1].rsplit(" ", 1)[-1]
|
||||||
|
for label in labels
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _normalize_path(path: Path) -> str:
|
def _normalize_path(path: Path) -> str:
|
||||||
return str(path).replace(os.sep, "/")
|
return str(path).replace(os.sep, "/")
|
||||||
|
|
||||||
@@ -2306,6 +2320,10 @@ def test_count_cached_entries_per_root_uses_longest_prefix():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
os.name == "nt",
|
||||||
|
reason="simulates a case-sensitive filesystem; Windows matches case itself",
|
||||||
|
)
|
||||||
async def test_reconcile_case_fold_fallback_is_indexed_not_linear(
|
async def test_reconcile_case_fold_fallback_is_indexed_not_linear(
|
||||||
tmp_path: Path, monkeypatch
|
tmp_path: Path, monkeypatch
|
||||||
):
|
):
|
||||||
@@ -2465,7 +2483,10 @@ async def test_reconcile_walk_broadcasts_per_root_progress(tmp_path: Path, monke
|
|||||||
assert len(walk_messages) >= 2
|
assert len(walk_messages) >= 2
|
||||||
|
|
||||||
# Intermediate ticks name the root being walked.
|
# Intermediate ticks name the root being walked.
|
||||||
assert any(message["active_roots"] == ["loras"] for message in walk_messages)
|
assert any(
|
||||||
|
_root_label_names(message["active_roots"]) == ["loras"]
|
||||||
|
for message in walk_messages
|
||||||
|
)
|
||||||
|
|
||||||
final = walk_messages[-1]
|
final = walk_messages[-1]
|
||||||
assert final["files_seen"] == 4
|
assert final["files_seen"] == 4
|
||||||
@@ -2474,7 +2495,7 @@ async def test_reconcile_walk_broadcasts_per_root_progress(tmp_path: Path, monke
|
|||||||
assert final["roots_total"] == 1
|
assert final["roots_total"] == 1
|
||||||
assert final["roots_done"] == 1
|
assert final["roots_done"] == 1
|
||||||
assert final["active_roots"] == []
|
assert final["active_roots"] == []
|
||||||
assert final["current_name"] == "loras"
|
assert _root_label_names([final["current_name"]]) == ["loras"]
|
||||||
assert 0 < final["progress"] <= 50
|
assert 0 < final["progress"] <= 50
|
||||||
assert final["full_rebuild"] is False
|
assert final["full_rebuild"] is False
|
||||||
assert walk_messages[0]["progress"] <= final["progress"]
|
assert walk_messages[0]["progress"] <= final["progress"]
|
||||||
@@ -2546,7 +2567,7 @@ async def test_reconcile_scoped_scan_leaves_other_roots_untouched(tmp_path: Path
|
|||||||
assert summary is not None
|
assert summary is not None
|
||||||
assert summary["added"] == 1
|
assert summary["added"] == 1
|
||||||
assert summary["removed"] == 0
|
assert summary["removed"] == 0
|
||||||
assert summary["scanned_roots"] == ["a"]
|
assert _root_label_names(summary["scanned_roots"]) == ["a"]
|
||||||
|
|
||||||
cached_paths = {item["file_path"] for item in scanner._cache.raw_data}
|
cached_paths = {item["file_path"] for item in scanner._cache.raw_data}
|
||||||
assert cached_paths == {
|
assert cached_paths == {
|
||||||
@@ -2717,11 +2738,13 @@ async def test_reconcile_reports_root_filtered_out_at_startup(tmp_path: Path, mo
|
|||||||
assert summary["added"] == 0
|
assert summary["added"] == 0
|
||||||
assert summary["removed"] == 0
|
assert summary["removed"] == 0
|
||||||
# ...but the switched-off root is no longer silent: it is reported with the
|
# ...but the switched-off root is no longer silent: it is reported with the
|
||||||
# entries that were kept for it.
|
# entries that were kept for it. Its label is what the shared label helper
|
||||||
|
# makes of the pair (drive-prefixed on Windows).
|
||||||
|
labels = model_scanner._root_display_labels([str(_primary), str(missing)])
|
||||||
assert summary["skipped_roots"] == [
|
assert summary["skipped_roots"] == [
|
||||||
{
|
{
|
||||||
"path": _normalize_path(missing),
|
"path": _normalize_path(missing),
|
||||||
"label": missing.name,
|
"label": labels[str(missing)],
|
||||||
"reason": "root_unavailable",
|
"reason": "root_unavailable",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -2746,7 +2769,7 @@ async def test_scoped_scan_stays_quiet_about_filtered_out_roots(tmp_path: Path,
|
|||||||
assert summary is not None
|
assert summary is not None
|
||||||
# The caller asked for one root: a root it did not ask about is not its
|
# The caller asked for one root: a root it did not ask about is not its
|
||||||
# business, but its entries are still kept.
|
# business, but its entries are still kept.
|
||||||
assert summary["scanned_roots"] == [primary.name]
|
assert _root_label_names(summary["scanned_roots"]) == [primary.name]
|
||||||
assert "root_unavailable" not in {
|
assert "root_unavailable" not in {
|
||||||
entry["reason"] for entry in summary["skipped_roots"]
|
entry["reason"] for entry in summary["skipped_roots"]
|
||||||
}
|
}
|
||||||
@@ -2949,3 +2972,308 @@ def test_reconcile_walk_skips_the_recipes_dir(tmp_path: Path, monkeypatch):
|
|||||||
assert "keep" in result.discovered_folders
|
assert "keep" in result.discovered_folders
|
||||||
assert "recipes" not in result.discovered_folders
|
assert "recipes" not in result.discovered_folders
|
||||||
assert "recipes/sub" not in result.discovered_folders
|
assert "recipes/sub" not in result.discovered_folders
|
||||||
|
|
||||||
|
|
||||||
|
# --- the host's path spelling (issue #1146) ---------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _windows_spelling(path: Path) -> str:
|
||||||
|
"""Return a path the way ComfyUI hands it over on Windows.
|
||||||
|
|
||||||
|
``utils/extra_config.py`` normalizes every configured model folder with
|
||||||
|
``os.path.normpath``, so on Windows the configured list is all backslashes
|
||||||
|
while the live root list is forward-slashed.
|
||||||
|
"""
|
||||||
|
return str(path).replace("/", "\\")
|
||||||
|
|
||||||
|
|
||||||
|
def _simulate_windows_separators(monkeypatch) -> None:
|
||||||
|
"""Make ``os.sep`` a backslash, as it is on the reporting machine.
|
||||||
|
|
||||||
|
``os.path`` stays ``posixpath`` (its separator is its own constant), so this
|
||||||
|
only changes code that folds separators through ``os.sep`` -- which is exactly
|
||||||
|
what turned the backslash twin into the live root's unreachable prefix on
|
||||||
|
Windows and stopped deleted models from being pruned. Without it that half of
|
||||||
|
#1145/#1146 can only be reproduced on Windows.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(os, "sep", "\\")
|
||||||
|
|
||||||
|
|
||||||
|
async def _windows_spelling_scanner(tmp_path: Path, monkeypatch):
|
||||||
|
"""Scanner configured with backslash roots plus a never-created output dir.
|
||||||
|
|
||||||
|
``<output>/loras`` is what ComfyUI registers for its save nodes whether or not
|
||||||
|
the directory exists (``main.py`` calls ``add_model_folder_path`` with it
|
||||||
|
unconditionally).
|
||||||
|
"""
|
||||||
|
primary = tmp_path / "primary"
|
||||||
|
(primary / "pack").mkdir(parents=True)
|
||||||
|
(primary / "pack" / "a.txt").write_text("a", encoding="utf-8")
|
||||||
|
never_created = tmp_path / "output" / "loras"
|
||||||
|
|
||||||
|
scanner = MultiRootDummyScanner([primary])
|
||||||
|
await scanner._initialize_cache()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
scanner,
|
||||||
|
"get_configured_model_roots",
|
||||||
|
lambda: [_windows_spelling(primary), _windows_spelling(never_created)],
|
||||||
|
)
|
||||||
|
_simulate_windows_separators(monkeypatch)
|
||||||
|
return scanner, primary, never_created
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_describe_roots_folds_the_host_path_spelling(tmp_path: Path, monkeypatch):
|
||||||
|
"""One directory is one root, whatever separator the host used."""
|
||||||
|
scanner, primary, _never_created = await _windows_spelling_scanner(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
)
|
||||||
|
|
||||||
|
described = scanner.describe_model_roots()
|
||||||
|
|
||||||
|
assert [entry["path"] for entry in described] == [_normalize_path(primary)]
|
||||||
|
assert described[0]["available"] is True
|
||||||
|
assert described[0]["reachable"] is True
|
||||||
|
assert described[0]["models"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reconcile_does_not_shadow_a_backslash_configured_root(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
"""A configured twin must not register its live root as unreachable.
|
||||||
|
|
||||||
|
``_UnreachablePaths`` normalizes the prefixes it is given, so the backslash
|
||||||
|
spelling of a root the walk just read normalized to the same prefix: every
|
||||||
|
cached entry under it counted as "kept", which is what made the refresh toast
|
||||||
|
claim thousands of reachable models were inaccessible -- and what stopped
|
||||||
|
deleted models from ever being pruned.
|
||||||
|
"""
|
||||||
|
scanner, primary, _never_created = await _windows_spelling_scanner(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
)
|
||||||
|
(primary / "pack" / "a.txt").unlink()
|
||||||
|
|
||||||
|
summary = await scanner._reconcile_cache()
|
||||||
|
|
||||||
|
assert summary is not None
|
||||||
|
assert summary["skipped_roots"] == []
|
||||||
|
assert summary["unavailable_paths"] == []
|
||||||
|
assert summary["kept_unreachable"] == 0
|
||||||
|
assert summary["removed"] == 1
|
||||||
|
assert scanner._cache.raw_data == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_root_that_never_existed_is_not_reported(tmp_path: Path, monkeypatch):
|
||||||
|
"""A registered directory that neither exists nor holds cache is not a root.
|
||||||
|
|
||||||
|
Only roots the user really configured and whose models are cached are worth an
|
||||||
|
offline row or a "models kept" notice.
|
||||||
|
"""
|
||||||
|
primary = tmp_path / "primary"
|
||||||
|
(primary / "pack").mkdir(parents=True)
|
||||||
|
(primary / "pack" / "a.txt").write_text("a", encoding="utf-8")
|
||||||
|
never_created = tmp_path / "output" / "loras"
|
||||||
|
|
||||||
|
scanner = MultiRootDummyScanner([primary])
|
||||||
|
await scanner._initialize_cache()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
scanner,
|
||||||
|
"get_configured_model_roots",
|
||||||
|
lambda: [str(primary), str(never_created)],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [entry["path"] for entry in scanner.describe_model_roots()] == [
|
||||||
|
_normalize_path(primary)
|
||||||
|
]
|
||||||
|
|
||||||
|
summary = await scanner._reconcile_cache()
|
||||||
|
|
||||||
|
assert summary is not None
|
||||||
|
assert summary["skipped_roots"] == []
|
||||||
|
assert summary["kept_unreachable"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scoped_scan_accepts_the_host_path_spelling(tmp_path: Path, monkeypatch):
|
||||||
|
"""A caller may name a root the way the host spells it (issue #1146).
|
||||||
|
|
||||||
|
The Refresh menu sends back the path `/roots` rendered, but the browser
|
||||||
|
extension and scripts can pass what settings.json holds -- backslashes on
|
||||||
|
Windows -- and that must select the root rather than scan nothing.
|
||||||
|
"""
|
||||||
|
primary = tmp_path / "primary"
|
||||||
|
(primary / "pack").mkdir(parents=True)
|
||||||
|
(primary / "pack" / "a.txt").write_text("a", encoding="utf-8")
|
||||||
|
|
||||||
|
scanner = MultiRootDummyScanner([primary])
|
||||||
|
await scanner._initialize_cache()
|
||||||
|
|
||||||
|
scope = ReconcileScope(roots=(_windows_spelling(primary),))
|
||||||
|
summary = await scanner._reconcile_cache(scope=scope)
|
||||||
|
|
||||||
|
assert summary is not None
|
||||||
|
assert _root_label_names(summary["scanned_roots"]) == [primary.name]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_external_folder_move_leaves_no_ghost(tmp_path: Path, monkeypatch):
|
||||||
|
"""A folder moved with a file manager must leave no ghost (issue #1145).
|
||||||
|
|
||||||
|
Both reported symptoms share the cause fixed for #1146: while the configured
|
||||||
|
twin of a live root was registered as unreachable, the cached rows under the
|
||||||
|
old path were "kept" instead of pruned (the same model was then listed at its
|
||||||
|
old and its new location), and the walk could never be considered fully
|
||||||
|
verified -- so `all_folders` only ever grew and the deleted folder stayed in
|
||||||
|
the sidebar, even though the delete dialog correctly reported it as gone.
|
||||||
|
"""
|
||||||
|
primary = tmp_path / "primary"
|
||||||
|
(primary / "Krea2" / "Women").mkdir(parents=True)
|
||||||
|
original = primary / "Krea2" / "Women" / "a.txt"
|
||||||
|
original.write_text("a", encoding="utf-8")
|
||||||
|
|
||||||
|
scanner = MultiRootDummyScanner([primary])
|
||||||
|
await scanner._initialize_cache()
|
||||||
|
assert "Krea2/Women" in await scanner.get_all_folders()
|
||||||
|
|
||||||
|
# What Dopus did: the folder is gone and its file lives somewhere else.
|
||||||
|
(primary / "Krea2" / "New").mkdir()
|
||||||
|
shutil.move(str(original), str(primary / "Krea2" / "New" / "a.txt"))
|
||||||
|
(primary / "Krea2" / "Women").rmdir()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
scanner, "get_configured_model_roots", lambda: [_windows_spelling(primary)]
|
||||||
|
)
|
||||||
|
_simulate_windows_separators(monkeypatch)
|
||||||
|
|
||||||
|
summary = await scanner._reconcile_cache()
|
||||||
|
|
||||||
|
# One model, listed once: the old path is pruned and the new one added.
|
||||||
|
assert summary is not None
|
||||||
|
assert summary["removed"] == 1
|
||||||
|
assert summary["added"] == 1
|
||||||
|
assert summary["kept_unreachable"] == 0
|
||||||
|
assert [item["file_path"] for item in scanner._cache.raw_data] == [
|
||||||
|
_normalize_path(primary / "Krea2" / "New" / "a.txt")
|
||||||
|
]
|
||||||
|
# ...and the folder record follows the filesystem instead of only growing.
|
||||||
|
all_folders = await scanner.get_all_folders()
|
||||||
|
assert "Krea2/Women" not in all_folders
|
||||||
|
assert "Krea2/New" in all_folders
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreachable_paths_cover_whole_folders():
|
||||||
|
"""The folder refresh asks "could this specific entry have been read?"."""
|
||||||
|
unreachable = model_scanner._UnreachablePaths()
|
||||||
|
unreachable.add("/mnt/drive-z/loras", "root_unreachable", kept=1)
|
||||||
|
unreachable.add("/mnt/a/loras/locked", "unreadable_dir")
|
||||||
|
|
||||||
|
# A directory the walk could not enter covers itself and everything below it,
|
||||||
|
# whichever way the root is spelled.
|
||||||
|
assert unreachable.covers_folder("/mnt/a/loras", "locked") is True
|
||||||
|
assert unreachable.covers_folder("/mnt/a/loras", "locked/deep") is True
|
||||||
|
assert unreachable.covers_folder("\\mnt\\a\\loras", "locked/deep") is True
|
||||||
|
|
||||||
|
# A root that is unreadable as a whole does not: library-relative names carry
|
||||||
|
# no root, so it would cover every entry and keep every ghost alive (#1145).
|
||||||
|
assert unreachable.covers_folder("/mnt/drive-z/loras", "pack") is False
|
||||||
|
|
||||||
|
assert unreachable.covers_folder("/mnt/a/loras", "free") is False
|
||||||
|
assert unreachable.covers_folder("/mnt/a/loras", "locked_other") is False
|
||||||
|
assert unreachable.covers_folder("/mnt/a/loras", "") is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ghost_folder_cleared_while_another_root_is_offline(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
"""A switched-off drive must not keep the other roots' ghosts alive (#1145).
|
||||||
|
|
||||||
|
Keeping the whole folder list whenever anything was unreadable is what made a
|
||||||
|
folder moved in a file manager outlive every refresh. Only the entries the
|
||||||
|
unreadable root can actually account for may be kept.
|
||||||
|
"""
|
||||||
|
online = tmp_path / "online"
|
||||||
|
offline = tmp_path / "offline"
|
||||||
|
(online / "old" / "pack").mkdir(parents=True)
|
||||||
|
(online / "old" / "pack" / "a.txt").write_text("a", encoding="utf-8")
|
||||||
|
(offline / "keepme").mkdir(parents=True)
|
||||||
|
(offline / "keepme" / "b.txt").write_text("b", encoding="utf-8")
|
||||||
|
(offline / "empty_folder").mkdir(parents=True)
|
||||||
|
|
||||||
|
scanner = MultiRootDummyScanner([online, offline])
|
||||||
|
await scanner._initialize_cache()
|
||||||
|
assert "old/pack" in scanner._cache.all_folders
|
||||||
|
assert "keepme" in scanner._cache.all_folders
|
||||||
|
|
||||||
|
# The folder is moved externally...
|
||||||
|
(online / "new" / "pack").mkdir(parents=True)
|
||||||
|
shutil.move(
|
||||||
|
str(online / "old" / "pack" / "a.txt"),
|
||||||
|
str(online / "new" / "pack" / "a.txt"),
|
||||||
|
)
|
||||||
|
shutil.rmtree(online / "old")
|
||||||
|
# ...while the other drive is switched off.
|
||||||
|
shutil.rmtree(offline)
|
||||||
|
|
||||||
|
summary = await scanner._reconcile_cache()
|
||||||
|
|
||||||
|
assert summary is not None
|
||||||
|
assert summary["added"] == 1
|
||||||
|
assert summary["removed"] == 1
|
||||||
|
assert summary["kept_unreachable"] == 1
|
||||||
|
|
||||||
|
folders = await scanner.get_all_folders()
|
||||||
|
assert "old" not in folders
|
||||||
|
assert "old/pack" not in folders
|
||||||
|
assert "new/pack" in folders
|
||||||
|
# The unreadable root keeps its model-bearing folders and its cached models
|
||||||
|
# (model-derived folders are always unioned in)...
|
||||||
|
assert "keepme" in folders
|
||||||
|
assert _normalize_path(offline / "keepme" / "b.txt") in {
|
||||||
|
item["file_path"] for item in scanner._cache.raw_data
|
||||||
|
}
|
||||||
|
# ...while an *empty* folder on it cannot be attributed to any root, so it is
|
||||||
|
# not kept alive. It comes back the next time that root is walked.
|
||||||
|
assert "empty_folder" not in folders
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scoped_scan_never_drops_folders_of_other_roots(tmp_path: Path):
|
||||||
|
"""A scoped scan cannot speak for the roots it was not asked about."""
|
||||||
|
root_a = tmp_path / "a"
|
||||||
|
root_b = tmp_path / "b"
|
||||||
|
(root_a / "a_folder").mkdir(parents=True)
|
||||||
|
(root_b / "b_folder").mkdir(parents=True)
|
||||||
|
|
||||||
|
scanner = MultiRootDummyScanner([root_a, root_b])
|
||||||
|
await scanner._initialize_cache()
|
||||||
|
|
||||||
|
shutil.rmtree(root_a / "a_folder")
|
||||||
|
await scanner._reconcile_cache(scope=ReconcileScope(roots=(str(root_b),)))
|
||||||
|
|
||||||
|
folders = await scanner.get_all_folders()
|
||||||
|
assert "a_folder" in folders
|
||||||
|
assert "b_folder" in folders
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_root_scoped_scan_of_every_root_still_cleans_folders(tmp_path: Path):
|
||||||
|
"""Asking for every root is a full refresh: ghosts are dropped."""
|
||||||
|
root_a = tmp_path / "a"
|
||||||
|
root_b = tmp_path / "b"
|
||||||
|
(root_a / "a_folder").mkdir(parents=True)
|
||||||
|
(root_b / "b_folder").mkdir(parents=True)
|
||||||
|
|
||||||
|
scanner = MultiRootDummyScanner([root_a, root_b])
|
||||||
|
await scanner._initialize_cache()
|
||||||
|
|
||||||
|
shutil.rmtree(root_a / "a_folder")
|
||||||
|
await scanner._reconcile_cache(
|
||||||
|
scope=ReconcileScope(roots=(str(root_a), str(root_b)))
|
||||||
|
)
|
||||||
|
|
||||||
|
folders = await scanner.get_all_folders()
|
||||||
|
assert "a_folder" not in folders
|
||||||
|
assert "b_folder" in folders
|
||||||
|
|
||||||
|
|||||||
@@ -901,7 +901,7 @@ async def test_p_checkpoint_pending_discovery_excludes_staging(tmp_path: Path, m
|
|||||||
|
|
||||||
pending = await scanner._find_pending_models_from_filesystem()
|
pending = await scanner._find_pending_models_from_filesystem()
|
||||||
paths = [entry["file_path"] for entry in pending]
|
paths = [entry["file_path"] for entry in pending]
|
||||||
assert str(root / "real.safetensors") in paths
|
assert str(root / "real.safetensors").replace(os.sep, "/") in paths
|
||||||
assert not any(PENDING_DELETE_DIR_NAME in p for p in paths)
|
assert not any(PENDING_DELETE_DIR_NAME in p for p in paths)
|
||||||
|
|
||||||
|
|
||||||
@@ -921,7 +921,7 @@ async def test_p_usage_stats_lookup_excludes_staging(tmp_path: Path) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Staged file is not matched; only the real one is returned.
|
# Staged file is not matched; only the real one is returned.
|
||||||
assert result == str(root / "mycheckpoint.safetensors")
|
assert result == str(root / "mycheckpoint.safetensors").replace(os.sep, "/")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -2298,3 +2298,89 @@ async def test_symlink5_cross_volume_merge_then_one_undo_restores_everything(
|
|||||||
assert not (root_b / PENDING_DELETE_DIR_NAME / bid_b).exists()
|
assert not (root_b / PENDING_DELETE_DIR_NAME / bid_b).exists()
|
||||||
staging = root_a / PENDING_DELETE_DIR_NAME
|
staging = root_a / PENDING_DELETE_DIR_NAME
|
||||||
assert not staging.exists() or not any(staging.iterdir())
|
assert not staging.exists() or not any(staging.iterdir())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# (n) the staging CONTAINER is dropped with its last batch (issue #1145)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
async def test_n_purge_removes_the_empty_staging_container(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
"""`.lm-pending-delete` is a container, not a record of a pending delete.
|
||||||
|
|
||||||
|
Leaving it behind made every later folder delete (and rename) answer "A
|
||||||
|
deletion is still pending inside this folder" forever, because the folder
|
||||||
|
manifest only matched the directory name.
|
||||||
|
"""
|
||||||
|
root = tmp_path / "loras"
|
||||||
|
root.mkdir()
|
||||||
|
await _register_model_root(monkeypatch, lora_roots=[root])
|
||||||
|
|
||||||
|
service = await PendingDeleteService.get_instance()
|
||||||
|
batch_id = await _stage_simple(service, root, "expired")
|
||||||
|
|
||||||
|
manifest_path = root / PENDING_DELETE_DIR_NAME / batch_id / "manifest.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
manifest["expires_at"] = int(time.time()) - 10
|
||||||
|
manifest_path.write_text(json.dumps(manifest))
|
||||||
|
|
||||||
|
await service.purge_expired()
|
||||||
|
|
||||||
|
assert not (root / PENDING_DELETE_DIR_NAME).exists()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_n_undo_removes_the_empty_staging_container(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
root = tmp_path / "loras"
|
||||||
|
root.mkdir()
|
||||||
|
await _register_model_root(monkeypatch, lora_roots=[root])
|
||||||
|
|
||||||
|
service = await PendingDeleteService.get_instance()
|
||||||
|
batch_id = await _stage_simple(service, root, "restored")
|
||||||
|
|
||||||
|
await service.undo(batch_id)
|
||||||
|
|
||||||
|
assert (root / "restored.safetensors").exists()
|
||||||
|
assert not (root / PENDING_DELETE_DIR_NAME).exists()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_n_container_survives_while_another_batch_is_staged(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
"""Only the last batch out drops the container."""
|
||||||
|
root = tmp_path / "loras"
|
||||||
|
root.mkdir()
|
||||||
|
await _register_model_root(monkeypatch, lora_roots=[root])
|
||||||
|
|
||||||
|
service = await PendingDeleteService.get_instance()
|
||||||
|
expired_id = await _stage_simple(service, root, "expired")
|
||||||
|
fresh_id = await _stage_simple(service, root, "fresh")
|
||||||
|
|
||||||
|
manifest_path = root / PENDING_DELETE_DIR_NAME / expired_id / "manifest.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
manifest["expires_at"] = int(time.time()) - 10
|
||||||
|
manifest_path.write_text(json.dumps(manifest))
|
||||||
|
|
||||||
|
await service.purge_expired()
|
||||||
|
|
||||||
|
staging = root / PENDING_DELETE_DIR_NAME
|
||||||
|
assert staging.is_dir()
|
||||||
|
assert not (staging / expired_id).exists()
|
||||||
|
assert (staging / fresh_id).exists()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_n_empty_manifestless_batch_is_dropped_not_quarantined(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
"""A crash between makedirs and the manifest leaves nothing to preserve."""
|
||||||
|
root = tmp_path / "loras"
|
||||||
|
root.mkdir()
|
||||||
|
staging = root / PENDING_DELETE_DIR_NAME
|
||||||
|
(staging / "batch1").mkdir(parents=True)
|
||||||
|
await _register_model_root(monkeypatch, lora_roots=[root])
|
||||||
|
|
||||||
|
service = await PendingDeleteService.get_instance()
|
||||||
|
await service.purge_expired(scan_roots=True)
|
||||||
|
|
||||||
|
assert not staging.exists()
|
||||||
|
|||||||
@@ -451,3 +451,100 @@ def test_get_models_missing_autov3_self_terminates_after_marking(tmp_path: Path,
|
|||||||
store.update_single_model('dummy', new_item, old_item=old_item)
|
store.update_single_model('dummy', new_item, old_item=old_item)
|
||||||
|
|
||||||
assert store.get_models_missing_autov3('dummy') == []
|
assert store.get_models_missing_autov3('dummy') == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- which roots still hold cached entries (issue #1146) --------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_with_paths(tmp_path: Path, monkeypatch, entries) -> PersistentModelCache:
|
||||||
|
"""Build a cache holding ``entries`` (``(model_type, file_path)`` pairs)."""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
monkeypatch.setenv('LORA_MANAGER_DISABLE_PERSISTENT_CACHE', '0')
|
||||||
|
db_path = tmp_path / 'roots.sqlite'
|
||||||
|
store = PersistentModelCache(db_path=str(db_path))
|
||||||
|
with sqlite3.connect(str(db_path)) as conn:
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO models (model_type, file_path) VALUES (?, ?)", entries
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
def test_roots_with_cached_entries_matches_the_hosts_slash_style(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
"""The configured root may be spelled with backslashes (Windows)."""
|
||||||
|
|
||||||
|
store = _cache_with_paths(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
[("lora", "E:/StableDiffusion/cc_models/loras/pack/a.safetensors")],
|
||||||
|
)
|
||||||
|
|
||||||
|
matched = store.roots_with_cached_entries(
|
||||||
|
"lora", ["E:\\StableDiffusion\\cc_models\\loras", "E:\\empty\\loras"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matched == {"E:\\StableDiffusion\\cc_models\\loras"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_roots_with_cached_entries_compares_whole_path_segments(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
"""``loras-extra`` and ``loras2`` are not inside ``loras``."""
|
||||||
|
|
||||||
|
store = _cache_with_paths(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
[
|
||||||
|
("lora", "E:/loras-extra/a.safetensors"),
|
||||||
|
("lora", "E:/loras2/b.safetensors"),
|
||||||
|
("checkpoint", "E:/loras/c.safetensors"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert store.roots_with_cached_entries("lora", ["E:/loras"]) == set()
|
||||||
|
assert store.roots_with_cached_entries("checkpoint", ["E:/loras"]) == {"E:/loras"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_roots_with_cached_entries_escapes_like_wildcards(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
"""A directory name may contain ``_``/``%`` without becoming a wildcard."""
|
||||||
|
|
||||||
|
store = _cache_with_paths(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
[("lora", "E:/myXloras/a.safetensors"), ("lora", "E:/100%/a.safetensors")],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert store.roots_with_cached_entries("lora", ["E:/my_loras"]) == set()
|
||||||
|
assert store.roots_with_cached_entries("lora", ["E:/100x"]) == set()
|
||||||
|
assert store.roots_with_cached_entries("lora", ["E:/100%"]) == {"E:/100%"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_roots_with_cached_entries_raises_when_unknown(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
"""No cache, no answer: the caller must not drop roots on a guess."""
|
||||||
|
|
||||||
|
monkeypatch.setenv('LORA_MANAGER_DISABLE_PERSISTENT_CACHE', '1')
|
||||||
|
disabled = PersistentModelCache(db_path=str(tmp_path / 'disabled.sqlite'))
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
disabled.roots_with_cached_entries("lora", ["E:/loras"])
|
||||||
|
|
||||||
|
monkeypatch.setenv('LORA_MANAGER_DISABLE_PERSISTENT_CACHE', '0')
|
||||||
|
missing = PersistentModelCache(db_path=str(tmp_path / 'missing.sqlite'))
|
||||||
|
(tmp_path / 'missing.sqlite').unlink()
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
missing.roots_with_cached_entries("lora", ["E:/loras"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_roots_with_cached_entries_without_candidates(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv('LORA_MANAGER_DISABLE_PERSISTENT_CACHE', '0')
|
||||||
|
store = PersistentModelCache(db_path=str(tmp_path / 'empty.sqlite'))
|
||||||
|
|
||||||
|
assert store.roots_with_cached_entries("lora", []) == set()
|
||||||
|
assert store.roots_with_cached_entries("lora", ["", " "]) == set()
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ async def test_pacing_enforces_min_interval(monkeypatch):
|
|||||||
await coordinator.wait_for_slot("example.com")
|
await coordinator.wait_for_slot("example.com")
|
||||||
await coordinator.wait_for_slot("example.com")
|
await coordinator.wait_for_slot("example.com")
|
||||||
elapsed = time.monotonic() - start
|
elapsed = time.monotonic() - start
|
||||||
assert elapsed >= 0.1
|
assert elapsed >= 0.099 # 1ms of clock slack on a 0.1s interval
|
||||||
|
|
||||||
|
|
||||||
async def test_pacing_is_per_destination(monkeypatch):
|
async def test_pacing_is_per_destination(monkeypatch):
|
||||||
@@ -84,12 +84,15 @@ async def test_register_rate_limit_arms_cooldown_and_waits(monkeypatch):
|
|||||||
)
|
)
|
||||||
coordinator.register_rate_limit("example.com", retry_after=0.15)
|
coordinator.register_rate_limit("example.com", retry_after=0.15)
|
||||||
assert coordinator.in_cooldown("example.com")
|
assert coordinator.in_cooldown("example.com")
|
||||||
assert 0.1 < coordinator.remaining_seconds("example.com") <= 0.15
|
remaining = coordinator.remaining_seconds("example.com")
|
||||||
|
# The upper bound only says "barely any time has passed": allow the timer's
|
||||||
|
# float noise (Windows reports 0.15000000000000568 for a 0.15s window).
|
||||||
|
assert 0.1 < remaining <= 0.15 + 1e-6
|
||||||
|
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
await coordinator.wait_for_slot("example.com")
|
await coordinator.wait_for_slot("example.com")
|
||||||
elapsed = time.monotonic() - start
|
elapsed = time.monotonic() - start
|
||||||
assert elapsed >= 0.14
|
assert elapsed >= 0.14 # 0.15s cooldown, 10ms of clock slack
|
||||||
assert not coordinator.in_cooldown("example.com")
|
assert not coordinator.in_cooldown("example.com")
|
||||||
|
|
||||||
|
|
||||||
@@ -255,7 +258,7 @@ async def test_make_request_paces_consecutive_calls(monkeypatch):
|
|||||||
await downloader.make_request("GET", "https://api.example.com/a")
|
await downloader.make_request("GET", "https://api.example.com/a")
|
||||||
await downloader.make_request("GET", "https://api.example.com/b")
|
await downloader.make_request("GET", "https://api.example.com/b")
|
||||||
elapsed = time.monotonic() - start
|
elapsed = time.monotonic() - start
|
||||||
assert elapsed >= 0.14
|
assert elapsed >= 0.14 # 0.15s cooldown, 10ms of clock slack
|
||||||
|
|
||||||
|
|
||||||
async def test_make_request_gate_disabled_returns_429_immediately(monkeypatch):
|
async def test_make_request_gate_disabled_returns_429_immediately(monkeypatch):
|
||||||
@@ -461,3 +464,31 @@ async def test_get_response_headers_429_registers_cooldown(monkeypatch):
|
|||||||
coordinator = await RateLimitCoordinator.get_instance()
|
coordinator = await RateLimitCoordinator.get_instance()
|
||||||
remaining = coordinator.remaining_seconds("api.example.com")
|
remaining = coordinator.remaining_seconds("api.example.com")
|
||||||
assert 50.0 < remaining <= 60.0
|
assert 50.0 < remaining <= 60.0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_wait_for_slot_re_sleeps_when_the_timer_wakes_early(monkeypatch):
|
||||||
|
"""An early wake must not let a request through inside the cooldown.
|
||||||
|
|
||||||
|
The event-loop timer can fire before the requested delay has elapsed
|
||||||
|
(Windows granularity is ~15ms), and a single ``asyncio.sleep`` would then
|
||||||
|
return while the cooldown is still armed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
coordinator = await _make_coordinator(monkeypatch)
|
||||||
|
coordinator.register_rate_limit("example.com", retry_after=0.15)
|
||||||
|
|
||||||
|
delays = []
|
||||||
|
real_sleep = asyncio.sleep
|
||||||
|
|
||||||
|
async def early_wake(delay):
|
||||||
|
delays.append(delay)
|
||||||
|
if len(delays) == 1:
|
||||||
|
return # the timer fired early
|
||||||
|
await real_sleep(delay)
|
||||||
|
|
||||||
|
monkeypatch.setattr(asyncio, "sleep", early_wake)
|
||||||
|
|
||||||
|
await coordinator.wait_for_slot("example.com")
|
||||||
|
|
||||||
|
assert len(delays) >= 2, "the gate returned on an early wake"
|
||||||
|
assert not coordinator.in_cooldown("example.com")
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from py.utils.example_images_processor import (
|
|||||||
)
|
)
|
||||||
from py.utils.metadata_manager import MetadataManager
|
from py.utils.metadata_manager import MetadataManager
|
||||||
from tests.conftest import MockCache, MockModelService, MockScanner
|
from tests.conftest import MockCache, MockModelService, MockScanner
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
class StubLockProvider:
|
class StubLockProvider:
|
||||||
@@ -655,7 +656,8 @@ async def test_filename_template_use_case_reverts_to_recorded_original_when_temp
|
|||||||
lifecycle = StubLifecycleService()
|
lifecycle = StubLifecycleService()
|
||||||
|
|
||||||
async def metadata_loader(metadata_path: str) -> Dict[str, Any]:
|
async def metadata_loader(metadata_path: str) -> Dict[str, Any]:
|
||||||
if metadata_path == "/library/alpha-renamed.metadata.json":
|
# Compare the basename: the sidecar path is platform-spelled.
|
||||||
|
if os.path.basename(metadata_path) == "alpha-renamed.metadata.json":
|
||||||
return {"original_file_name": "alpha-original"}
|
return {"original_file_name": "alpha-original"}
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
@@ -713,8 +715,10 @@ async def test_filename_template_use_case_skips_revert_matching_current_name() -
|
|||||||
async def test_filename_template_use_case_counts_conflicts_as_failures() -> None:
|
async def test_filename_template_use_case_counts_conflicts_as_failures() -> None:
|
||||||
_set_filename_template("{model_name}")
|
_set_filename_template("{model_name}")
|
||||||
scanner = MockScanner(cache=MockCache([
|
scanner = MockScanner(cache=MockCache([
|
||||||
_filename_template_model("/library/alpha.safetensors", "Alpha"),
|
# The names differ beyond case: on Windows a case-only difference counts
|
||||||
_filename_template_model("/library/beta.safetensors", "Beta"),
|
# as "already named that" (os.path.normcase) and would be skipped.
|
||||||
|
_filename_template_model("/library/alpha.safetensors", "Alpha Two"),
|
||||||
|
_filename_template_model("/library/beta.safetensors", "Beta Two"),
|
||||||
]))
|
]))
|
||||||
lifecycle = StubLifecycleService()
|
lifecycle = StubLifecycleService()
|
||||||
lifecycle.error = ValueError("A file with this name already exists")
|
lifecycle.error = ValueError("A file with this name already exists")
|
||||||
|
|||||||
@@ -724,3 +724,33 @@ class TestHasWorkflowColumn:
|
|||||||
assert loaded is not None
|
assert loaded is not None
|
||||||
assert loaded.raw_data[0]["id"] == "legacy-1"
|
assert loaded.raw_data[0]["id"] == "legacy-1"
|
||||||
assert loaded.raw_data[0]["has_workflow"] is False
|
assert loaded.raw_data[0]["has_workflow"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_initialization_closes_its_connection(tmp_path, monkeypatch):
|
||||||
|
"""The schema init must not leak a file handle.
|
||||||
|
|
||||||
|
``with sqlite3.Connection`` only ends the transaction: the handle stays open,
|
||||||
|
and on Windows a live handle keeps the cache database undeletable
|
||||||
|
(``WinError 32``) until the garbage collector happens to run -- which broke
|
||||||
|
every teardown that removes a temporary cache file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from py.services import persistent_recipe_cache as module
|
||||||
|
|
||||||
|
opened = []
|
||||||
|
real_connect = sqlite3.connect
|
||||||
|
|
||||||
|
def tracking_connect(*args, **kwargs):
|
||||||
|
conn = real_connect(*args, **kwargs)
|
||||||
|
opened.append(conn)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
monkeypatch.setattr(sqlite3, "connect", tracking_connect)
|
||||||
|
module.PersistentRecipeCache(db_path=str(tmp_path / "recipe.sqlite"))
|
||||||
|
|
||||||
|
assert opened, "initializing the schema should have opened a connection"
|
||||||
|
for conn in opened:
|
||||||
|
with pytest.raises(sqlite3.ProgrammingError):
|
||||||
|
conn.execute("SELECT 1")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import os
|
||||||
|
|
||||||
from py.utils.metadata_manager import MetadataManager
|
from py.utils.metadata_manager import MetadataManager
|
||||||
from py.utils.models import LoraMetadata
|
from py.utils.models import LoraMetadata
|
||||||
@@ -59,7 +60,7 @@ async def test_load_metadata_payload_restores_file_facts_when_sidecar_missing(
|
|||||||
payload = await MetadataManager.load_metadata_payload(str(model_path))
|
payload = await MetadataManager.load_metadata_payload(str(model_path))
|
||||||
|
|
||||||
stat_result = model_path.stat()
|
stat_result = model_path.stat()
|
||||||
assert payload["file_path"] == str(model_path)
|
assert payload["file_path"] == str(model_path).replace(os.sep, "/")
|
||||||
assert payload["file_name"] == "MyModel"
|
assert payload["file_name"] == "MyModel"
|
||||||
assert payload["size"] == stat_result.st_size
|
assert payload["size"] == stat_result.st_size
|
||||||
assert payload["modified"] == stat_result.st_mtime
|
assert payload["modified"] == stat_result.st_mtime
|
||||||
@@ -174,7 +175,7 @@ async def test_load_metadata_payload_tolerates_missing_model_file(tmp_path) -> N
|
|||||||
"""A nonexistent model file must not crash payload loading."""
|
"""A nonexistent model file must not crash payload loading."""
|
||||||
missing_path = tmp_path / "Ghost.safetensors"
|
missing_path = tmp_path / "Ghost.safetensors"
|
||||||
payload = await MetadataManager.load_metadata_payload(str(missing_path))
|
payload = await MetadataManager.load_metadata_payload(str(missing_path))
|
||||||
assert payload["file_path"] == str(missing_path)
|
assert payload["file_path"] == str(missing_path).replace(os.sep, "/")
|
||||||
assert "file_name" not in payload
|
assert "file_name" not in payload
|
||||||
assert "size" not in payload
|
assert "size" not in payload
|
||||||
assert "modified" not in payload
|
assert "modified" not in payload
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py.utils.root_paths import normalize_root_identity
|
||||||
|
|
||||||
|
|
||||||
|
def test_folds_windows_and_posix_separators():
|
||||||
|
"""ComfyUI's normpath output and LoRA Manager's forward slashes agree."""
|
||||||
|
|
||||||
|
assert normalize_root_identity("E:\\StableDiffusion\\cc_models\\loras") == (
|
||||||
|
normalize_root_identity("E:/StableDiffusion/cc_models/loras")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_folds_drive_letter_case_on_every_platform():
|
||||||
|
"""A drive letter is case-insensitive even where the filesystem is not."""
|
||||||
|
|
||||||
|
assert normalize_root_identity("E:/models/loras") == normalize_root_identity(
|
||||||
|
"e:/models/loras"
|
||||||
|
)
|
||||||
|
assert normalize_root_identity("E:\\models\\loras") == normalize_root_identity(
|
||||||
|
"e:/MODELS/loras"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_folds_trailing_and_redundant_separators():
|
||||||
|
assert normalize_root_identity("E:/models/loras/") == normalize_root_identity(
|
||||||
|
"E:/models/loras"
|
||||||
|
)
|
||||||
|
assert normalize_root_identity("E:/models/other/../loras") == (
|
||||||
|
normalize_root_identity("E:/models/loras")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_keeps_posix_case_significant():
|
||||||
|
"""Only Windows compares whole paths case-insensitively."""
|
||||||
|
|
||||||
|
if os.name == "nt":
|
||||||
|
assert normalize_root_identity("C:/Models/Loras") == (
|
||||||
|
normalize_root_identity("c:/models/loras")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
assert normalize_root_identity("/data/Loras") != normalize_root_identity(
|
||||||
|
"/data/loras"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_folds_whole_path_case_on_windows(monkeypatch):
|
||||||
|
"""The Windows branch is exercised on every platform."""
|
||||||
|
|
||||||
|
monkeypatch.setattr(os, "name", "nt")
|
||||||
|
|
||||||
|
assert normalize_root_identity("C:/Models/Loras") == normalize_root_identity(
|
||||||
|
"c:/models/loras"
|
||||||
|
)
|
||||||
|
assert normalize_root_identity("C:\\Models\\Loras\\") == "c:/models/loras"
|
||||||
|
|
||||||
|
|
||||||
|
def test_never_resolves_symlinks_or_abspath():
|
||||||
|
"""Business paths stay business paths: only the spelling is folded."""
|
||||||
|
|
||||||
|
assert normalize_root_identity("./models/loras") == "models/loras"
|
||||||
|
assert normalize_root_identity("/srv/link/loras") == "/srv/link/loras"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["", " ", None, 42])
|
||||||
|
def test_blank_inputs_have_no_identity(value):
|
||||||
|
assert normalize_root_identity(value) == ""
|
||||||
Reference in New Issue
Block a user