GGUF: port v4's gguf_support.py (backend selection, content-based .gguf detection, dequantize fallback) and wire it into both the diffusion-model and CLIP loaders. Pipeline editor: reopening the Models-and-pipeline settings window was silently discarding the whole edited pipeline (a loaded preset, every loader file, every sampler setting) because GET /api/pipeline is deliberately stateless server-side and boot.js never fed its own already-saved slots back into a freshly remounted editor -- found live while walking through the MiniMax-H3 setup flow. Fixed by re-validating the saved slots through check() on reopen instead of always replaying the raw stateless default. Also fixes, found via the extensive_testing adversarial loop over both of the above: a stale file-listing cache in the pipeline window (no way to see a newly-added model file without a full page reload), and a chain of races/leaks in its own refresh/commit error-banner handling that surfaced across five review rounds -- a rejection from a superseded request overwriting a fresher success, a failure banner never clearing on success, banners bleeding across groups on navigation, and a commit()'s refusal landing on the wrong view after the user backs out before it answers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
249 lines
11 KiB
Python
249 lines
11 KiB
Python
"""GGUF backend selection and file discovery.
|
|
|
|
Nothing here needs a .gguf file or the gguf package: what is testable is the
|
|
routing -- which backend gets picked, what happens when none is available, and
|
|
whether .gguf files are found at all (core's extension set excludes them,
|
|
which is the whole reason this module exists).
|
|
"""
|
|
import sys
|
|
import types
|
|
|
|
import pytest
|
|
|
|
from modules.loaders import gguf_support
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_folders(tmp_path, monkeypatch):
|
|
"""A stand-in folder_paths with two model roots on disk."""
|
|
diff = tmp_path / "diffusion_models"
|
|
enc = tmp_path / "text_encoders"
|
|
sub = diff / "nested"
|
|
for d in (diff, enc, sub):
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
(diff / "ltx.safetensors").write_bytes(b"x")
|
|
(diff / "ltx-Q4_K_M.gguf").write_bytes(b"x")
|
|
(sub / "h3-Q8_0.gguf").write_bytes(b"x")
|
|
(enc / "gemma.safetensors").write_bytes(b"x")
|
|
(enc / "gemma-Q5_K.gguf").write_bytes(b"x")
|
|
|
|
mod = types.ModuleType("folder_paths")
|
|
mod.base_path = str(tmp_path)
|
|
roots = {"diffusion_models": [str(diff)], "text_encoders": [str(enc)]}
|
|
mod.get_folder_paths = lambda name: roots.get(name, [])
|
|
monkeypatch.setitem(sys.modules, "folder_paths", mod)
|
|
return tmp_path
|
|
|
|
|
|
def test_is_gguf_is_case_insensitive():
|
|
assert gguf_support.is_gguf("model.gguf")
|
|
assert gguf_support.is_gguf("MODEL.GGUF")
|
|
assert not gguf_support.is_gguf("model.safetensors")
|
|
assert not gguf_support.is_gguf("")
|
|
assert not gguf_support.is_gguf(None)
|
|
|
|
|
|
def test_finds_gguf_files_core_cannot_list(fake_folders):
|
|
names = gguf_support.gguf_names("diffusion_models")
|
|
assert "ltx-Q4_K_M.gguf" in names
|
|
# Nested, because model folders are routinely organised into subdirectories.
|
|
assert any(n.endswith("h3-Q8_0.gguf") for n in names)
|
|
# Only .gguf -- the ordinary files still come from core's own listing.
|
|
assert not any(n.endswith(".safetensors") for n in names)
|
|
|
|
|
|
def test_encoders_are_listed_separately(fake_folders):
|
|
assert gguf_support.gguf_names("text_encoders") == ["gemma-Q5_K.gguf"]
|
|
|
|
|
|
def test_missing_folder_is_empty_not_an_error(fake_folders):
|
|
assert gguf_support.gguf_names("vae") == []
|
|
|
|
|
|
def test_path_resolves_a_listed_name(fake_folders):
|
|
p = gguf_support.gguf_path("diffusion_models", "ltx-Q4_K_M.gguf")
|
|
assert p and p.endswith("ltx-Q4_K_M.gguf")
|
|
assert gguf_support.gguf_path("diffusion_models", "not-there.gguf") is None
|
|
|
|
|
|
def test_no_backend_refuses_with_both_remedies(monkeypatch):
|
|
monkeypatch.setattr(gguf_support, "_pack_dir", lambda: None)
|
|
monkeypatch.setattr(gguf_support.importlib.util, "find_spec", lambda name: None)
|
|
assert gguf_support.backend() is None
|
|
with pytest.raises(RuntimeError) as e:
|
|
gguf_support.load_state_dict("/nope.gguf")
|
|
msg = str(e.value)
|
|
# Both ways out must be named: they have different consequences for VRAM.
|
|
# `gguf` is a FunPack requirement now, so the first is a re-run of them
|
|
# rather than a lone package.
|
|
assert "ComfyUI-GGUF" in msg
|
|
assert "requirements.txt" in msg
|
|
assert "quantized in VRAM" in msg
|
|
|
|
|
|
def test_the_pack_wins_when_both_are_available(monkeypatch):
|
|
monkeypatch.setattr(gguf_support, "_pack_dir", lambda: "/fake/ComfyUI-GGUF")
|
|
monkeypatch.setattr(gguf_support.importlib.util, "find_spec", lambda name: object())
|
|
# Quantized-in-VRAM is the point of GGUF; the dequantizing fallback is second best.
|
|
assert gguf_support.backend() == "pack"
|
|
|
|
|
|
def test_native_is_used_when_only_the_library_is_present(monkeypatch):
|
|
monkeypatch.setattr(gguf_support, "_pack_dir", lambda: None)
|
|
monkeypatch.setattr(gguf_support.importlib.util, "find_spec",
|
|
lambda name: object() if name == "gguf" else None)
|
|
assert gguf_support.backend() == "native"
|
|
|
|
|
|
def test_pack_is_found_by_content_not_by_folder_name(tmp_path, monkeypatch):
|
|
"""Manager, forks and hand-installs all rename the folder; the marker is the code."""
|
|
cn = tmp_path / "custom_nodes"
|
|
good = cn / "some-renamed-fork"
|
|
decoy = cn / "ComfyUI-GGUF-Themed-Nodes" # named right, contains nothing
|
|
for d in (good, decoy):
|
|
d.mkdir(parents=True)
|
|
(good / "loader.py").write_text("def gguf_sd_loader(path):\n return {}\n")
|
|
(good / "ops.py").write_text("class GGMLOps:\n pass\n")
|
|
(decoy / "loader.py").write_text("# nothing useful\n")
|
|
|
|
mod = types.ModuleType("folder_paths")
|
|
mod.base_path = str(tmp_path)
|
|
mod.get_folder_paths = lambda name: []
|
|
monkeypatch.setitem(sys.modules, "folder_paths", mod)
|
|
|
|
assert gguf_support._pack_dir() == str(good)
|
|
|
|
|
|
def test_pack_load_passes_custom_operations_through(tmp_path, monkeypatch):
|
|
"""The quantized weights are unusable without their ops -- losing them
|
|
would produce a matmul against block-quantized storage rather than a clear
|
|
failure."""
|
|
d = tmp_path / "pack"
|
|
d.mkdir()
|
|
(d / "loader.py").write_text(
|
|
"def gguf_sd_loader(path):\n return {'w': 'quantized'}\n")
|
|
(d / "ops.py").write_text("class GGMLOps:\n pass\n")
|
|
monkeypatch.setattr(gguf_support, "_pack_dir", lambda: str(d))
|
|
|
|
sd, options, note = gguf_support.load_state_dict("/whatever.gguf")
|
|
assert sd == {"w": "quantized"}
|
|
assert "custom_operations" in options
|
|
assert type(options["custom_operations"]).__name__ == "GGMLOps"
|
|
assert "quantized" in note
|
|
|
|
|
|
def test_container_magic_beats_the_extension(tmp_path):
|
|
"""A .gguf renamed to .safetensors reached the safetensors parser, which
|
|
read its binary header as UTF-8 JSON and failed with a decode error --
|
|
true, and no help at all."""
|
|
renamed = tmp_path / "ltx-Q4_K_M.safetensors"
|
|
renamed.write_bytes(b"GGUF\x03\x00\x00\x00" + b"\x80" * 200)
|
|
assert gguf_support.has_gguf_magic(str(renamed))
|
|
# ...and the name alone still says nothing.
|
|
assert not gguf_support.is_gguf(renamed.name)
|
|
|
|
|
|
def test_a_real_safetensors_is_not_mistaken_for_gguf(tmp_path):
|
|
st = tmp_path / "real.safetensors"
|
|
st.write_bytes((8).to_bytes(8, "little") + b'{"a":{}}')
|
|
assert not gguf_support.has_gguf_magic(str(st))
|
|
|
|
|
|
def test_magic_check_survives_a_missing_file():
|
|
assert gguf_support.has_gguf_magic("/definitely/not/here.safetensors") is False
|
|
|
|
|
|
def test_pack_modules_can_use_relative_imports(tmp_path, monkeypatch):
|
|
"""ComfyUI-GGUF's loader.py does `from .ops import ...`. Loading it as a
|
|
standalone module by file path raised "attempted relative import with no
|
|
known parent package"; mounting the directory as a package is what makes
|
|
it resolve."""
|
|
d = tmp_path / "ComfyUI-GGUF"
|
|
d.mkdir()
|
|
(d / "__init__.py").write_text("raise AssertionError('the pack __init__ must not run')")
|
|
(d / "ops.py").write_text("class GGMLOps:\n pass\n")
|
|
(d / "dequant.py").write_text("MARKER = 'dequant'\n")
|
|
(d / "loader.py").write_text(
|
|
"from .ops import GGMLOps\n"
|
|
"from .dequant import MARKER\n"
|
|
"def gguf_sd_loader(path):\n"
|
|
" return {'w': MARKER}\n"
|
|
)
|
|
monkeypatch.setattr(gguf_support, "_pack_dir", lambda: str(d))
|
|
monkeypatch.delitem(sys.modules, gguf_support._PKG_NAME, raising=False)
|
|
|
|
sd, options, note = gguf_support.load_state_dict("/whatever.gguf")
|
|
assert sd == {"w": "dequant"}
|
|
assert type(options["custom_operations"]).__name__ == "GGMLOps"
|
|
|
|
|
|
def test_switching_pack_directories_remounts(tmp_path, monkeypatch):
|
|
"""The synthesized package is cached in sys.modules; a different directory
|
|
must not keep resolving to the first one."""
|
|
for name, marker in (("a", "first"), ("b", "second")):
|
|
d = tmp_path / name
|
|
d.mkdir()
|
|
(d / "ops.py").write_text("class GGMLOps:\n pass\n")
|
|
(d / "loader.py").write_text(
|
|
f"def gguf_sd_loader(path):\n return {{'w': '{marker}'}}\n")
|
|
monkeypatch.delitem(sys.modules, gguf_support._PKG_NAME, raising=False)
|
|
|
|
monkeypatch.setattr(gguf_support, "_pack_dir", lambda: str(tmp_path / "a"))
|
|
assert gguf_support.load_state_dict("/x.gguf")[0] == {"w": "first"}
|
|
|
|
# No manual cache clearing: remounting a different directory must purge it
|
|
# by itself.
|
|
monkeypatch.setattr(gguf_support, "_pack_dir", lambda: str(tmp_path / "b"))
|
|
assert gguf_support.load_state_dict("/x.gguf")[0] == {"w": "second"}
|
|
|
|
|
|
def test_pack_refusing_an_architecture_falls_back(tmp_path, monkeypatch):
|
|
"""ComfyUI-GGUF raises "Unexpected architecture type in GGUF file:
|
|
'minimax_h3'" for architectures it has no handling for. That is a fair
|
|
refusal on its part and a dead end for us -- but the pack DEPENDS on the
|
|
gguf package, so the fallback is always there."""
|
|
d = tmp_path / "pack"
|
|
d.mkdir()
|
|
(d / "ops.py").write_text("class GGMLOps:\n pass\n")
|
|
(d / "loader.py").write_text(
|
|
"def gguf_sd_loader(path):\n"
|
|
" raise ValueError(\"Unexpected architecture type in GGUF file: 'minimax_h3'\")\n")
|
|
monkeypatch.setattr(gguf_support, "_pack_dir", lambda: str(d))
|
|
monkeypatch.delitem(sys.modules, gguf_support._PKG_NAME, raising=False)
|
|
monkeypatch.setattr(gguf_support.importlib.util, "find_spec",
|
|
lambda name: object() if name == "gguf" else None)
|
|
monkeypatch.setattr(gguf_support, "_load_native",
|
|
lambda p: ({"w": 1}, {}, "gguf: dequantized at load"))
|
|
|
|
sd, options, note = gguf_support.load_state_dict("/h3.gguf")
|
|
assert sd == {"w": 1}
|
|
# Both halves must be in the note: what refused, and what happened instead.
|
|
assert "minimax_h3" in note
|
|
assert "dequantized at load" in note
|
|
# The quantized ops must NOT be carried over -- these tensors are plain.
|
|
assert "custom_operations" not in options
|
|
|
|
|
|
def test_a_pack_refusal_without_the_library_still_raises(tmp_path, monkeypatch):
|
|
"""No silent success: with nothing to fall back to, the original refusal stands."""
|
|
d = tmp_path / "pack"
|
|
d.mkdir()
|
|
(d / "ops.py").write_text("class GGMLOps:\n pass\n")
|
|
(d / "loader.py").write_text(
|
|
"def gguf_sd_loader(path):\n raise ValueError('nope')\n")
|
|
monkeypatch.setattr(gguf_support, "_pack_dir", lambda: str(d))
|
|
monkeypatch.delitem(sys.modules, gguf_support._PKG_NAME, raising=False)
|
|
monkeypatch.setattr(gguf_support.importlib.util, "find_spec", lambda name: None)
|
|
with pytest.raises(ValueError, match="nope"):
|
|
gguf_support.load_state_dict("/h3.gguf")
|
|
|
|
|
|
def test_gguf_is_a_declared_requirement():
|
|
"""The advice in UNAVAILABLE is "re-run FunPack's requirements", which is
|
|
only true while gguf is actually in them."""
|
|
import pathlib
|
|
reqs = pathlib.Path(__file__).resolve().parents[3] / "requirements.txt"
|
|
lines = [ln.strip() for ln in reqs.read_text(encoding="utf-8").splitlines()
|
|
if ln.strip() and not ln.startswith("#")]
|
|
assert any(ln == "gguf" or ln.startswith("gguf") for ln in lines)
|