From e4d58d0f60f1aa8a9fab2e1abefe3e7b7b925df2 Mon Sep 17 00:00:00 2001 From: pixelpaws Date: Sat, 18 Oct 2025 21:19:09 +0800 Subject: [PATCH] fix(cache): harden metadata defaults --- py/services/model_cache.py | 46 ++++++++++++++---- py/services/model_query.py | 3 ++ tests/services/test_model_cache_resilience.py | 47 +++++++++++++++++++ 3 files changed, 87 insertions(+), 9 deletions(-) create mode 100644 tests/services/test_model_cache_resilience.py diff --git a/py/services/model_cache.py b/py/services/model_cache.py index b5ecc47f..7aaca643 100644 --- a/py/services/model_cache.py +++ b/py/services/model_cache.py @@ -32,6 +32,7 @@ class ModelCache: # Cache for last sort: (sort_key, order) -> sorted list self._last_sort: Tuple[str, str] = (None, None) self._last_sorted_data: List[Dict] = [] + self._normalize_raw_data() self.name_display_mode = self._normalize_display_mode(self.name_display_mode) # Default sort on init asyncio.create_task(self.resort()) @@ -43,20 +44,43 @@ class ModelCache: return value return "model_name" + @staticmethod + def _ensure_string(value: Any) -> str: + """Return a safe string representation for metadata fields.""" + + if isinstance(value, str): + return value + if value is None: + return "" + return str(value) + + def _normalize_item(self, item: Dict) -> None: + """Ensure core metadata fields are present and string typed.""" + + if not isinstance(item, dict): + return + + for field in ("model_name", "file_name", "folder"): + item[field] = self._ensure_string(item.get(field)) + + def _normalize_raw_data(self) -> None: + """Normalize every cached entry before it is consumed.""" + + for item in self.raw_data: + self._normalize_item(item) + def _get_display_name(self, item: Dict) -> str: """Return the value used for name-based sorting based on display settings.""" if self.name_display_mode == "file_name": - primary = item.get("file_name", "") - fallback = item.get("model_name", "") + primary = self._ensure_string(item.get("file_name")) + fallback = self._ensure_string(item.get("model_name")) else: - primary = item.get("model_name", "") - fallback = item.get("file_name", "") + primary = self._ensure_string(item.get("model_name")) + fallback = self._ensure_string(item.get("file_name")) - candidate = primary or fallback or "" - if isinstance(candidate, str): - return candidate - return str(candidate) + candidate = primary or fallback + return candidate or "" @staticmethod def _normalize_version_id(value: Any) -> Optional[int]: @@ -119,7 +143,11 @@ class ModelCache: # Update folder list # else: do nothing - all_folders = set(l['folder'] for l in self.raw_data) + all_folders = { + self._ensure_string(item.get('folder')) + for item in self.raw_data + if isinstance(item, dict) + } self.folders = sorted(list(all_folders), key=lambda x: x.lower()) self.rebuild_version_index() diff --git a/py/services/model_query.py b/py/services/model_query.py index df7bb67a..100f8f8b 100644 --- a/py/services/model_query.py +++ b/py/services/model_query.py @@ -187,6 +187,9 @@ class SearchStrategy: return results def _matches(self, candidate: str, search_term: str, search_lower: str, fuzzy: bool) -> bool: + if not isinstance(candidate, str): + candidate = "" if candidate is None else str(candidate) + if not candidate: return False diff --git a/tests/services/test_model_cache_resilience.py b/tests/services/test_model_cache_resilience.py new file mode 100644 index 00000000..c4dd5dcd --- /dev/null +++ b/tests/services/test_model_cache_resilience.py @@ -0,0 +1,47 @@ +import asyncio + +import pytest + +from py.services.model_cache import ModelCache +from py.services.model_query import SearchStrategy + + +@pytest.mark.asyncio +async def test_model_cache_handles_missing_string_fields(): + cache = ModelCache( + raw_data=[ + { + "file_path": "/models/example.safetensors", + "file_name": None, + "model_name": None, + "folder": None, + "size": 0, + "modified": 0.0, + } + ], + folders=[], + ) + + await asyncio.sleep(0) # allow background resort task to run + sorted_data = await cache.get_sorted_data("name", "asc") + + assert sorted_data[0]["model_name"] == "" + assert sorted_data[0]["file_name"] == "" + assert cache.folders == [""] + + +def test_search_strategy_handles_non_string_candidates(): + strategy = SearchStrategy() + options = strategy.normalize_options(None) + + data = [ + { + "file_name": "example.safetensors", + "model_name": None, + "tags": ["test"], + } + ] + + results = strategy.apply(data, "example", options) + + assert data[0] in results