The zero-byte digest identifies no file, but CivArchive's by-hash index lists 211 unrelated foreign files for it (198 on huggingface.co, plus a civision pair pointing at ppxjmqq/pam-krea2). Resolving it linked seven unrelated LoRAs to that ModelScope repository (issue #1162); the archive DB could do the same. - CivArchive: get_model_by_hash returns not-found without a request, the file-entry fallback returns nothing, and no foreign-source ref is recorded - SQLite archive provider: guarded before a connection is opened - MetadataSyncService._try_link_foreign_source: refuses the placeholder - ModelScope: a published 0-byte upload is dropped from the digest map and the strict hash gate rejects the placeholder itself
518 lines
18 KiB
Python
518 lines
18 KiB
Python
"""Tests for download-time metadata hydration.
|
|
|
|
`py/services/model_sources/hydration.py` is the deterministic counterpart of
|
|
the `enrich_hf_metadata` skill: it turns a freshly downloaded ModelScope /
|
|
Hugging Face file into the populated model card a CivitAI download produces,
|
|
without an LLM and without the user running anything.
|
|
|
|
These tests cover the orchestration — which source data is fetched, what is
|
|
handed to the post-processor, and that nothing here can fail a download. The
|
|
field-by-field mapping lives in `tests/services/test_post_processor.py`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from py.services.model_sources import ModelCardContext, ModelSourceCache, SourceRef
|
|
from py.services.model_sources import hydration
|
|
from py.services.model_sources.base import ModelSource
|
|
from py.services.model_sources.modelscope import ModelScopeSource
|
|
from py.services.model_sources.hydration import (
|
|
SHARED_CACHE_MAX_ENTRIES,
|
|
hydrate_from_source,
|
|
load_model_card,
|
|
reset_shared_caches,
|
|
resolve_site_base_model,
|
|
shared_source_cache,
|
|
)
|
|
|
|
REF = SourceRef(
|
|
platform="modelscope",
|
|
source_id="user/repo",
|
|
url="https://modelscope.cn/models/user/repo",
|
|
)
|
|
|
|
SIDECAR = {
|
|
"sha256": "a" * 64,
|
|
"base_model": "Unknown",
|
|
# Written by the download handler just before hydration runs.
|
|
"source_platform": "modelscope",
|
|
"source_url": "https://modelscope.cn/models/user/repo",
|
|
}
|
|
|
|
|
|
class _FakeSource(ModelSource):
|
|
"""Minimal provider that records what hydration asked of it."""
|
|
|
|
platform = "modelscope"
|
|
label = "ModelScope"
|
|
supports_enrichment = True
|
|
|
|
def __init__(self, *, context=None, readme="", fail=False):
|
|
self.context = context if context is not None else ModelCardContext()
|
|
self.readme = readme
|
|
self.fail = fail
|
|
self.readme_calls = 0
|
|
self.context_calls = 0
|
|
self.context_kwargs: dict = {}
|
|
|
|
async def fetch_model_card(self, source_id):
|
|
self.readme_calls += 1
|
|
if self.fail:
|
|
raise RuntimeError("network down")
|
|
return self.readme
|
|
|
|
async def fetch_model_card_context(
|
|
self, source_id, filename="", *, sha256="", cache=None
|
|
):
|
|
self.context_calls += 1
|
|
self.context_kwargs = {"filename": filename, "sha256": sha256}
|
|
if self.fail:
|
|
raise RuntimeError("network down")
|
|
return self.context
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolated_shared_caches():
|
|
reset_shared_caches()
|
|
yield
|
|
reset_shared_caches()
|
|
|
|
|
|
def _async(value):
|
|
async def _call(*_args, **_kwargs):
|
|
return value
|
|
|
|
return _call
|
|
|
|
|
|
def _wire(monkeypatch, source, *, metadata=SIDECAR, result=None):
|
|
"""Patch hydration's collaborators; return the recorded process() calls."""
|
|
|
|
monkeypatch.setattr(hydration, "get_source", lambda _platform: source)
|
|
monkeypatch.setattr("py.metadata_ops.read_metadata", _async(metadata))
|
|
|
|
calls: list = []
|
|
|
|
class _Processor:
|
|
async def process(self, **kwargs):
|
|
calls.append(kwargs)
|
|
if result is not None:
|
|
return result
|
|
return {"success": True, "updated_fields": ["model_name"]}
|
|
|
|
monkeypatch.setattr("py.services.agent.post_processor.PostProcessor", _Processor)
|
|
return calls
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Orchestration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestHydrateFromSource:
|
|
@pytest.mark.asyncio
|
|
async def test_applies_the_site_card_without_an_llm(self, monkeypatch):
|
|
source = _FakeSource(
|
|
context=ModelCardContext(
|
|
model_name="Krea-2-LORA",
|
|
version_name="c1-st1000",
|
|
description="权重0.5-1.2。",
|
|
official_tags=["photography"],
|
|
),
|
|
readme="# Krea-2-LORA",
|
|
)
|
|
calls = _wire(monkeypatch, source)
|
|
|
|
updated = await hydrate_from_source("/models/lora.safetensors", ref=REF)
|
|
|
|
assert updated == ["model_name"]
|
|
assert len(calls) == 1
|
|
call = calls[0]
|
|
# No provider is consulted: everything applied is what the site published.
|
|
assert call["llm_output"] == {}
|
|
assert call["skill_name"] == "enrich_hf_metadata"
|
|
assert call["readme_content"] == "# Krea-2-LORA"
|
|
assert call["source_context"].model_name == "Krea-2-LORA"
|
|
assert call["metadata_source"] == "source:modelscope"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_matches_the_file_by_hash_and_basename(self, monkeypatch):
|
|
source = _FakeSource(context=ModelCardContext(model_name="X"))
|
|
_wire(monkeypatch, source)
|
|
|
|
await hydrate_from_source("/models/sub/Krea-2-LORA_c1-st1000.safetensors", ref=REF)
|
|
|
|
assert source.context_kwargs == {
|
|
"filename": "Krea-2-LORA_c1-st1000.safetensors",
|
|
"sha256": "a" * 64,
|
|
}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_early_for_an_unknown_platform(self, monkeypatch):
|
|
source = _FakeSource(context=ModelCardContext(model_name="X"))
|
|
calls = _wire(monkeypatch, source)
|
|
monkeypatch.setattr(hydration, "get_source", lambda _platform: None)
|
|
|
|
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
|
|
assert calls == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_early_for_a_link_only_source(self, monkeypatch):
|
|
source = _FakeSource(context=ModelCardContext(model_name="X"))
|
|
source.supports_enrichment = False
|
|
calls = _wire(monkeypatch, source)
|
|
|
|
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
|
|
assert calls == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_early_without_a_sidecar(self, monkeypatch):
|
|
source = _FakeSource(context=ModelCardContext(model_name="X"))
|
|
calls = _wire(monkeypatch, source, metadata={})
|
|
|
|
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
|
|
assert calls == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_early_when_the_site_published_nothing(self, monkeypatch):
|
|
source = _FakeSource(context=ModelCardContext(), readme="")
|
|
calls = _wire(monkeypatch, source)
|
|
|
|
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
|
|
assert calls == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_deferred_hash_still_matches_by_filename(self, monkeypatch):
|
|
"""Checkpoints and other large files are stored with
|
|
``hash_status="pending"`` and an empty ``sha256`` (see
|
|
``CheckpointScanner._create_default_metadata``), so hydration has to
|
|
work from the filename alone."""
|
|
source = _FakeSource(context=ModelCardContext(model_name="X"))
|
|
calls = _wire(
|
|
monkeypatch,
|
|
source,
|
|
metadata={**SIDECAR, "sha256": "", "hash_status": "pending"},
|
|
)
|
|
|
|
await hydrate_from_source("/models/big_checkpoint.safetensors", ref=REF)
|
|
|
|
assert calls[0]["source_context"].model_name == "X"
|
|
assert source.context_kwargs == {
|
|
"filename": "big_checkpoint.safetensors",
|
|
"sha256": "",
|
|
}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_early_when_the_model_is_not_linked(self, monkeypatch):
|
|
"""A file that merely shares a name must not get another model's card."""
|
|
source = _FakeSource(context=ModelCardContext(model_name="X"))
|
|
calls = _wire(
|
|
monkeypatch, source, metadata={"sha256": "a" * 64, "base_model": "Unknown"}
|
|
)
|
|
|
|
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
|
|
assert calls == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_early_when_linked_to_another_repository(self, monkeypatch):
|
|
source = _FakeSource(context=ModelCardContext(model_name="X"))
|
|
calls = _wire(
|
|
monkeypatch,
|
|
source,
|
|
metadata={
|
|
**SIDECAR,
|
|
"source_url": "https://modelscope.cn/models/user/other",
|
|
},
|
|
)
|
|
|
|
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
|
|
assert calls == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_early_when_linked_to_another_platform(self, monkeypatch):
|
|
source = _FakeSource(context=ModelCardContext(model_name="X"))
|
|
calls = _wire(
|
|
monkeypatch,
|
|
source,
|
|
metadata={
|
|
"sha256": "a" * 64,
|
|
"source_platform": "huggingface",
|
|
"source_url": "https://huggingface.co/user/repo",
|
|
},
|
|
)
|
|
|
|
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
|
|
assert calls == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_readme_alone_is_enough_to_run(self, monkeypatch):
|
|
source = _FakeSource(context=ModelCardContext(), readme="# hi")
|
|
calls = _wire(monkeypatch, source)
|
|
|
|
await hydrate_from_source("/models/lora.safetensors", ref=REF)
|
|
|
|
assert len(calls) == 1
|
|
assert calls[0]["readme_content"] == "# hi"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_failing_site_never_breaks_the_download(self, monkeypatch):
|
|
source = _FakeSource(fail=True)
|
|
calls = _wire(monkeypatch, source)
|
|
|
|
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
|
|
assert calls == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_failing_post_processor_never_breaks_the_download(
|
|
self, monkeypatch
|
|
):
|
|
source = _FakeSource(context=ModelCardContext(model_name="X"))
|
|
_wire(monkeypatch, source, result={"success": False, "errors": ["boom"]})
|
|
|
|
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_updates_are_reported_for_logging(self, monkeypatch):
|
|
source = _FakeSource(context=ModelCardContext(model_name="X"))
|
|
_wire(
|
|
monkeypatch,
|
|
source,
|
|
result={"success": True, "updated_fields": ["tags", "civitai"]},
|
|
)
|
|
|
|
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == [
|
|
"tags",
|
|
"civitai",
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Per-repository memo
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFetchCardContextByHash:
|
|
"""The strict hash gate used when linking a local file to a repository."""
|
|
|
|
DETAIL = {
|
|
"Name": "Kiara-Krea",
|
|
"Description": "Kiara LoRA",
|
|
"AigcType": "LoRA",
|
|
"BaseModel": ["krea-community/Krea-2-Turbo"],
|
|
"ModelInfos": {
|
|
"safetensor": {
|
|
"files": [
|
|
{"name": "Kiara-Krea_c1-st1000.safetensors", "sha256": "69a8c59a"},
|
|
{"name": "Kiara-Krea_c1-st4000.safetensors", "sha256": "f3a5370b"},
|
|
]
|
|
}
|
|
},
|
|
"MuseInfo": {
|
|
"versions": [
|
|
{
|
|
"modelVersion": {
|
|
"modelId": 24152,
|
|
"id": 130022,
|
|
"showName": "c1-st4000",
|
|
"triggerWords": '["Kiara"]',
|
|
},
|
|
"stats": {"fileList": ["Kiara-Krea_c1-st4000.safetensors"]},
|
|
"coverImages": [{"url": "https://example.com/cover.png"}],
|
|
}
|
|
]
|
|
},
|
|
}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_context_for_a_published_hash(self, monkeypatch):
|
|
source = ModelScopeSource()
|
|
monkeypatch.setattr(source, "_fetch_detail", _async(self.DETAIL))
|
|
|
|
context = await source.fetch_card_context_by_hash("owner/repo", "F3A5370B")
|
|
|
|
assert context is not None
|
|
assert context.model_name == "Kiara-Krea"
|
|
assert context.trigger_words == ["Kiara"]
|
|
assert context.example_images == ["https://example.com/cover.png"]
|
|
assert context.source_model_id == "24152"
|
|
assert context.source_version_id == "130022"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_none_for_a_hash_the_repo_does_not_publish(self, monkeypatch):
|
|
source = ModelScopeSource()
|
|
monkeypatch.setattr(source, "_fetch_detail", _async(self.DETAIL))
|
|
|
|
assert await source.fetch_card_context_by_hash("owner/repo", "deadbeef") is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_none_for_a_blank_hash_without_fetching(self, monkeypatch):
|
|
source = ModelScopeSource()
|
|
|
|
def _fail(*_args, **_kwargs): # pragma: no cover - must not run
|
|
raise AssertionError("no API call expected for a blank hash")
|
|
|
|
monkeypatch.setattr(source, "_fetch_detail", _fail)
|
|
|
|
assert await source.fetch_card_context_by_hash("owner/repo", "") is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_none_when_the_detail_api_fails(self, monkeypatch):
|
|
source = ModelScopeSource()
|
|
monkeypatch.setattr(source, "_fetch_detail", _async(None))
|
|
|
|
assert await source.fetch_card_context_by_hash("owner/repo", "f3a5370b") is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_none_for_the_empty_hash_placeholder(self, monkeypatch):
|
|
"""A repository's 0-byte upload must not capture unrelated models (#1162)."""
|
|
from py.utils.constants import EMPTY_HASH_SHA256
|
|
|
|
detail = {
|
|
"ModelInfos": {
|
|
"safetensor": {
|
|
"files": [
|
|
{
|
|
"name": "pam-krea2_c1-st4000.safetensors",
|
|
"sha256": EMPTY_HASH_SHA256,
|
|
"size": 0,
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
source = ModelScopeSource()
|
|
monkeypatch.setattr(source, "_fetch_detail", _async(detail))
|
|
|
|
assert (
|
|
await source.fetch_card_context_by_hash("owner/repo", EMPTY_HASH_SHA256)
|
|
is None
|
|
)
|
|
|
|
def test_file_digests_skip_zero_byte_uploads(self):
|
|
from py.services.model_sources.modelscope import _file_digests
|
|
from py.utils.constants import EMPTY_HASH_SHA256
|
|
|
|
detail = {
|
|
"ModelInfos": {
|
|
"safetensor": {
|
|
"files": [
|
|
{"name": "real.safetensors", "sha256": "a" * 64, "size": 1024},
|
|
{
|
|
"name": "empty.safetensors",
|
|
"sha256": EMPTY_HASH_SHA256,
|
|
"size": 0,
|
|
},
|
|
]
|
|
}
|
|
}
|
|
}
|
|
|
|
assert _file_digests(detail) == {"real.safetensors": "a" * 64}
|
|
|
|
|
|
class TestSharedSourceCache:
|
|
def test_same_repository_reuses_one_memo(self):
|
|
assert shared_source_cache("modelscope", "u/r") is shared_source_cache(
|
|
"modelscope", "u/r"
|
|
)
|
|
|
|
def test_different_repositories_get_different_memos(self):
|
|
assert shared_source_cache("modelscope", "u/r") is not shared_source_cache(
|
|
"modelscope", "u/other"
|
|
)
|
|
|
|
def test_entry_expires(self, monkeypatch):
|
|
clock = {"now": 1000.0}
|
|
monkeypatch.setattr(hydration.time, "monotonic", lambda: clock["now"])
|
|
|
|
first = shared_source_cache("modelscope", "u/r")
|
|
clock["now"] += hydration.SHARED_CACHE_TTL + 1
|
|
|
|
assert shared_source_cache("modelscope", "u/r") is not first
|
|
|
|
def test_cache_is_bounded(self):
|
|
for index in range(SHARED_CACHE_MAX_ENTRIES + 5):
|
|
shared_source_cache("modelscope", f"u/r{index}")
|
|
|
|
assert len(hydration._shared_caches) == SHARED_CACHE_MAX_ENTRIES
|
|
|
|
|
|
class TestLoadModelCard:
|
|
@pytest.mark.asyncio
|
|
async def test_successful_read_is_memoised(self):
|
|
source = _FakeSource(readme="# hi")
|
|
cache = ModelSourceCache()
|
|
|
|
assert await load_model_card(source, "u/r", cache) == "# hi"
|
|
assert await load_model_card(source, "u/r", cache) == "# hi"
|
|
assert source.readme_calls == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_read_is_retried(self):
|
|
"""A transient failure must not be cached as "this repo has no card"."""
|
|
source = _FakeSource(readme="")
|
|
cache = ModelSourceCache()
|
|
|
|
await load_model_card(source, "u/r", cache)
|
|
await load_model_card(source, "u/r", cache)
|
|
|
|
assert source.readme_calls == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_works_without_a_cache(self):
|
|
source = _FakeSource(readme="# hi")
|
|
|
|
assert await load_model_card(source, "u/r") == "# hi"
|
|
assert source.readme_calls == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Base-model resolution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResolveSiteBaseModel:
|
|
@pytest.mark.asyncio
|
|
async def test_maps_the_sites_own_vocabulary(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"py.metadata_ops.list_base_models",
|
|
_async(["Krea 2", "Flux.1 D"]),
|
|
)
|
|
|
|
context = ModelCardContext(
|
|
base_model="krea/Krea-2-Turbo",
|
|
base_model_aliases=["KREA_2_TURBO", "krea/Krea-2-Turbo"],
|
|
)
|
|
|
|
assert await resolve_site_base_model(context) == "Krea 2"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_hint_defers_instead_of_guessing(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"py.metadata_ops.list_base_models", _async(["Flux.1 D"])
|
|
)
|
|
|
|
context = ModelCardContext(base_model="something/else")
|
|
|
|
assert await resolve_site_base_model(context) == ""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_hints_needs_no_vocabulary_lookup(self, monkeypatch):
|
|
async def _boom(*_args, **_kwargs): # pragma: no cover - must not run
|
|
raise AssertionError("list_base_models should not be called")
|
|
|
|
monkeypatch.setattr("py.metadata_ops.list_base_models", _boom)
|
|
|
|
assert await resolve_site_base_model(ModelCardContext()) == ""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_vocabulary_failure_is_not_fatal(self, monkeypatch):
|
|
async def _boom(*_args, **_kwargs):
|
|
raise RuntimeError("civitai down")
|
|
|
|
monkeypatch.setattr("py.metadata_ops.list_base_models", _boom)
|
|
|
|
assert await resolve_site_base_model(ModelCardContext(base_model="x")) == ""
|