Fix recipe parsing for metadata-free local LoRAs (#1065)

* fix(recipes): resolve metadata-free local LoRAs

* fix(recipes): prioritize LoRA hashes over names
This commit is contained in:
Aaalice
2026-08-19 19:07:17 +08:00
committed by GitHub
parent 6411d83d46
commit b0c7a1baae
7 changed files with 831 additions and 132 deletions
@@ -3,6 +3,40 @@ import pytest
from py.recipes.parsers.automatic import AutomaticMetadataParser
class LocalRecipeScanner:
class LoraScanner:
@staticmethod
def has_hash(model_hash):
return False
def __init__(self, models):
self.models = models
self.queries = []
self.hash_queries = []
self._lora_scanner = self.LoraScanner()
async def get_local_lora(self, name, base_model=None):
self.queries.append(name)
return self.models.get(name)
async def get_local_lora_by_hash(self, hash_value):
self.hash_queries.append(hash_value)
return next((model for model in self.models.values() if model.get("sha256") == hash_value), None)
def local_lora(file_name="local_only"):
return {
"file_path": f"/models/loras/styles/{file_name}.safetensors",
"file_name": file_name,
"model_name": "Local Only",
"sha256": "a" * 64,
"size": 123456,
"base_model": "Flux.1 D",
"preview_url": f"/models/loras/styles/{file_name}.preview.png",
"civitai": None,
}
@pytest.mark.asyncio
async def test_parse_metadata_extracts_checkpoint_from_civitai_resources(monkeypatch):
checkpoint_info = {
@@ -132,6 +166,218 @@ async def test_parse_metadata_merges_lora_hashes_over_empty_hashes_json(monkeypa
assert "UnusedLora" not in lora_names, "UnusedLora should have been skipped"
@pytest.mark.asyncio
async def test_parse_metadata_resolves_local_lora_with_empty_hash(monkeypatch):
async def fake_metadata_provider():
class Provider:
async def get_model_by_hash(self, model_hash):
raise AssertionError("Local and empty-hash LoRAs must not query Civitai")
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
scanner = LocalRecipeScanner({"local_only": local_lora()})
metadata_text = (
"portrait <lora:local_only:0.65> <lora:missing:0.4>\n"
"Steps: 20, Sampler: Euler, CFG scale: 7, Seed: 1, "
'Hashes: {"lora:local_only": "", "lora:missing": ""}'
)
result = await AutomaticMetadataParser().parse_metadata(metadata_text, scanner)
assert len(result["loras"]) == 1
entry = result["loras"][0]
assert entry["name"] == "Local Only"
assert entry["file_name"] == "local_only"
assert entry["weight"] == 0.65
assert entry["hash"] == "a" * 64
assert entry["localPath"].endswith("local_only.safetensors")
assert entry["size"] == 123456
assert entry["baseModel"] == "Flux.1 D"
assert entry["existsLocally"] is True
assert entry["isDeleted"] is False
assert scanner.queries == ["local_only", "missing"]
@pytest.mark.asyncio
async def test_parse_metadata_resolves_prompt_lora_without_hashes(monkeypatch):
async def fake_metadata_provider():
return None
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
model = local_lora()
scanner = LocalRecipeScanner({"styles/local_only": model})
metadata_text = "portrait <lora:styles/local_only:0.7>\nSteps: 20, Seed: 1"
result = await AutomaticMetadataParser().parse_metadata(metadata_text, scanner)
assert len(result["loras"]) == 1
assert result["loras"][0]["weight"] == 0.7
assert result["loras"][0]["hash"] == "a" * 64
assert scanner.queries == ["styles/local_only"]
@pytest.mark.asyncio
async def test_parse_metadata_prefers_hash_over_colliding_local_name(monkeypatch):
remote_info = {
"id": 100,
"modelId": 200,
"model": {"name": "Hash Match", "type": "LORA"},
"name": "v1",
"files": [{"type": "Model", "primary": True, "name": "hash_match.safetensors", "hashes": {"SHA256": "b" * 64}}],
}
async def fake_metadata_provider():
class Provider:
async def get_model_by_hash(self, model_hash):
assert model_hash == "deadbeef00"
return remote_info, None
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
scanner = LocalRecipeScanner({"local_only": local_lora()})
metadata_text = (
"portrait <lora:local_only:0.8>\n"
"Steps: 20, Seed: 1, "
'Hashes: {"lora:local_only": "deadbeef00"}'
)
result = await AutomaticMetadataParser().parse_metadata(metadata_text, scanner)
assert len(result["loras"]) == 1
assert result["loras"][0]["id"] == 100
assert result["loras"][0]["weight"] == 0.8
assert scanner.queries == []
assert scanner.hash_queries == ["deadbeef00"]
@pytest.mark.asyncio
async def test_parse_metadata_falls_back_to_name_when_hash_is_unresolved(monkeypatch):
async def fake_metadata_provider():
class Provider:
async def get_model_by_hash(self, model_hash):
return None, "Model not found"
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
scanner = LocalRecipeScanner({"local_only": local_lora()})
metadata_text = (
"portrait <lora:local_only:0.8>\nSteps: 20, Seed: 1, "
'Hashes: {"lora:local_only": "deadbeef00"}'
)
result = await AutomaticMetadataParser().parse_metadata(metadata_text, scanner)
assert result["loras"][0]["hash"] == "a" * 64
assert scanner.queries == ["local_only"]
@pytest.mark.asyncio
async def test_parse_metadata_uses_prompt_weight_for_civitai_resource(monkeypatch):
remote_info = {
"id": 100,
"modelId": 200,
"model": {"name": "local_only", "type": "LORA"},
"name": "v1",
"files": [
{
"type": "Model",
"primary": True,
"name": "remote_file.safetensors",
"hashes": {"SHA256": "b" * 64},
}
],
}
async def fake_metadata_provider():
class Provider:
async def get_model_version_info(self, version_id):
assert version_id == 100
return remote_info, None
async def get_model_by_hash(self, model_hash):
raise AssertionError("The Civitai resource should not be fetched again by hash")
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
scanner = LocalRecipeScanner({"local_only": local_lora()})
metadata_text = (
"portrait <lora:remote_file:0.35> <lora:local_only:0.6>\n"
"Steps: 20, Seed: 1, "
'Civitai resources: [{"type":"lora","modelVersionId":100,"modelName":"local_only"}]'
)
result = await AutomaticMetadataParser().parse_metadata(metadata_text, scanner)
assert len(result["loras"]) == 2
assert [entry["file_name"] for entry in result["loras"]] == ["remote_file", "local_only"]
assert [entry["weight"] for entry in result["loras"]] == [0.35, 0.6]
assert result["loras"][1]["existsLocally"] is True
assert scanner.queries == ["local_only"]
@pytest.mark.asyncio
async def test_parse_metadata_keeps_mixed_local_and_civitai_loras(monkeypatch):
remote_info = {
"id": 100,
"modelId": 200,
"model": {"name": "Remote LoRA", "type": "LORA"},
"name": "v1",
"files": [
{
"type": "Model",
"primary": True,
"name": "remote.safetensors",
"hashes": {"SHA256": "b" * 64},
}
],
}
async def fake_metadata_provider():
class Provider:
async def get_model_by_hash(self, model_hash):
assert model_hash == "bbbbbbbbbb"
return remote_info, None
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
scanner = LocalRecipeScanner({"local_only": local_lora()})
metadata_text = (
"portrait <lora:local_only:0.6> <lora:remote:0.9>\n"
"Steps: 20, Seed: 1, "
'Hashes: {"lora:local_only": "", "lora:remote": "bbbbbbbbbb"}'
)
result = await AutomaticMetadataParser().parse_metadata(metadata_text, scanner)
assert [entry["name"] for entry in result["loras"]] == ["Local Only", "Remote LoRA"]
assert [entry["weight"] for entry in result["loras"]] == [0.6, 0.9]
assert result["loras"][0]["existsLocally"] is True
assert result["loras"][1]["existsLocally"] is False
@pytest.mark.asyncio
async def test_parse_metadata_extracts_checkpoint_from_model_hash(monkeypatch):
checkpoint_info = {
@@ -2,6 +2,38 @@ import pytest
import json
from py.recipes.parsers.comfy import ComfyMetadataParser
class LocalRecipeScanner:
class LoraScanner:
@staticmethod
def has_hash(model_hash):
return False
def __init__(self, models):
self.models = models
self.queries = []
self.base_models = []
self._lora_scanner = self.LoraScanner()
async def get_local_lora(self, name, base_model=None):
self.queries.append(name)
self.base_models.append(base_model)
return self.models.get(name)
def local_lora(file_name):
return {
"file_path": f"/models/loras/{file_name}.safetensors",
"file_name": file_name.rsplit("/", 1)[-1],
"model_name": file_name.rsplit("/", 1)[-1],
"sha256": file_name[0] * 64,
"size": 4096,
"base_model": "SDXL 1.0",
"preview_url": "",
"civitai": None,
}
@pytest.mark.asyncio
async def test_parse_metadata_without_loras(monkeypatch):
checkpoint_info = {
@@ -84,6 +116,140 @@ async def test_parse_metadata_without_loras(monkeypatch):
assert result["gen_params"]["size"] == "1024x1024"
assert result["from_comfy_metadata"] is True
@pytest.mark.asyncio
async def test_parse_metadata_resolves_standard_and_manager_local_loras(monkeypatch):
async def fake_metadata_provider():
class Provider:
async def get_model_version_info(self, version_id):
raise AssertionError("Local LoRAs must not query Civitai")
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.comfy.get_default_metadata_provider",
fake_metadata_provider,
)
scanner = LocalRecipeScanner({
"styles/standard.safetensors": local_lora("standard"),
"manager": local_lora("manager"),
})
metadata_json = {
"1": {
"class_type": "LoraLoader",
"inputs": {
"lora_name": "styles/standard.safetensors",
"strength_model": 0.55,
},
},
"2": {
"class_type": "LoraLoaderLM",
"inputs": {
"loras": {
"__value__": [
{"name": "manager", "strength": "0.80", "active": True},
{"name": "disabled", "strength": 1.0, "active": False},
{"name": "dummy", "strength": 1.0, "active": True, "_isDummy": True},
]
}
},
},
}
result = await ComfyMetadataParser().parse_metadata(json.dumps(metadata_json), scanner)
assert [entry["file_name"] for entry in result["loras"]] == ["standard", "manager"]
assert [entry["weight"] for entry in result["loras"]] == [0.55, 0.8]
assert all(isinstance(entry["weight"], float) for entry in result["loras"])
assert all(entry["existsLocally"] is True for entry in result["loras"])
assert all(entry["isDeleted"] is False for entry in result["loras"])
assert scanner.queries == ["styles/standard.safetensors", "manager"]
@pytest.mark.asyncio
async def test_parse_metadata_defaults_malformed_weight_and_passes_checkpoint_base_model(monkeypatch):
checkpoint_info = {
"id": 456,
"modelId": 123,
"model": {"name": "Checkpoint", "type": "checkpoint"},
"name": "v1",
"baseModel": "SDXL 1.0",
}
async def fake_metadata_provider():
class Provider:
async def get_model_version_info(self, version_id):
return checkpoint_info, None
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.comfy.get_default_metadata_provider",
fake_metadata_provider,
)
scanner = LocalRecipeScanner({"style": local_lora("style")})
metadata_json = {
"1": {"class_type": "LoraLoader", "inputs": {"lora_name": "style", "strength_model": "invalid"}},
"2": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "civitai:123@456"}},
}
result = await ComfyMetadataParser().parse_metadata(json.dumps(metadata_json), scanner)
assert result["loras"][0]["weight"] == 1.0
assert scanner.base_models == ["SDXL 1.0"]
@pytest.mark.asyncio
async def test_parse_metadata_keeps_civitai_urn_with_local_lora(monkeypatch):
remote_info = {
"id": 456,
"modelId": 123,
"model": {"name": "Remote LoRA", "type": "LORA"},
"name": "v1",
"files": [
{
"type": "Model",
"primary": True,
"name": "remote.safetensors",
"hashes": {"SHA256": "c" * 64},
}
],
}
async def fake_metadata_provider():
class Provider:
async def get_model_version_info(self, version_id):
assert version_id == "456"
return remote_info, None
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.comfy.get_default_metadata_provider",
fake_metadata_provider,
)
scanner = LocalRecipeScanner({"local": local_lora("local")})
metadata_json = {
"1": {
"class_type": "LoraLoader",
"inputs": {"lora_name": "local", "strength_model": 0.4},
},
"2": {
"class_type": "LoraLoader",
"inputs": {
"lora_name": "urn:air:sdxl:lora:civitai:123@456",
"strength_model": 0.9,
},
},
}
result = await ComfyMetadataParser().parse_metadata(json.dumps(metadata_json), scanner)
assert [entry["name"] for entry in result["loras"]] == ["local", "Remote LoRA"]
assert [entry["weight"] for entry in result["loras"]] == [0.4, 0.9]
assert result["loras"][0]["existsLocally"] is True
assert result["loras"][1]["id"] == 456
@pytest.mark.asyncio
async def test_parse_metadata_without_extra_metadata(monkeypatch):
async def fake_metadata_provider():
+29
View File
@@ -107,6 +107,35 @@ def recipe_scanner(tmp_path: Path, monkeypatch):
settings_manager_module.reset_settings_manager()
@pytest.mark.asyncio
async def test_local_lora_lookup_requires_unambiguous_name_and_matching_base_model(recipe_scanner):
scanner, stub = recipe_scanner
models = [
{
"file_name": "style.safetensors",
"folder": "sd15",
"file_path": "/models/loras/sd15/style.safetensors",
"sha256": "a" * 64,
"base_model": "SD 1.5",
},
{
"file_name": "style.safetensors",
"folder": "sdxl",
"file_path": "/models/loras/sdxl/style.safetensors",
"sha256": "b" * 64,
"base_model": "SDXL 1.0",
},
]
stub._cache.raw_data = models
stub._hash_meta["b" * 64] = {"path": models[1]["file_path"]}
assert await scanner.get_local_lora("style") is None
assert await scanner.get_local_lora("sdxl/style.safetensors", "SDXL 1.0") is models[1]
assert await scanner.get_local_lora("sdxl/style.safetensors", "SD 1.5") is None
assert await scanner.get_local_lora("other/style.safetensors") is None
assert await scanner.get_local_lora_by_hash("b" * 64) is models[1]
def test_recipes_dir_uses_custom_settings_path(tmp_path: Path, monkeypatch):
RecipeScanner._instance = None
settings_manager_module.reset_settings_manager()