"""Base class for JSON entity stores — eliminates boilerplate across 12+ stores.""" import json from pathlib import Path from typing import Callable, Dict, Generic, List, TypeVar from wled_controller.utils import atomic_write_json, get_logger T = TypeVar("T") logger = get_logger(__name__) class BaseJsonStore(Generic[T]): """JSON-file-backed entity store with common CRUD helpers. Provides: - ``_load()`` / ``_save()``: atomic JSON file I/O - ``get_all()`` / ``get(id)`` / ``delete(id)`` / ``count()``: read/delete - ``_check_name_unique(name, exclude_id)``: duplicate-name guard Subclasses must set class attributes: - ``_json_key``: root key in JSON file (e.g. ``"sync_clocks"``) - ``_entity_name``: human label for errors (e.g. ``"Sync clock"``) - ``_version``: schema version string (default ``"1.0.0"``) """ _json_key: str _entity_name: str _version: str = "1.0.0" def __init__(self, file_path: str, deserializer: Callable[[dict], T]): self.file_path = Path(file_path) self._items: Dict[str, T] = {} self._deserializer = deserializer self._load() # ── I/O ──────────────────────────────────────────────────────── def _load(self) -> None: if not self.file_path.exists(): logger.info(f"{self._entity_name} store file not found — starting empty") return try: with open(self.file_path, "r", encoding="utf-8") as f: data = json.load(f) items_data = data.get(self._json_key, {}) loaded = 0 for item_id, item_dict in items_data.items(): try: self._items[item_id] = self._deserializer(item_dict) loaded += 1 except Exception as e: logger.error( f"Failed to load {self._entity_name} {item_id}: {e}", exc_info=True, ) if loaded > 0: logger.info(f"Loaded {loaded} {self._json_key} from storage") except Exception as e: logger.error(f"Failed to load {self._json_key} from {self.file_path}: {e}") raise logger.info( f"{self._entity_name} store initialized with {len(self._items)} items" ) def _save(self) -> None: try: data = { "version": self._version, self._json_key: { item_id: item.to_dict() for item_id, item in self._items.items() }, } atomic_write_json(self.file_path, data) except Exception as e: logger.error(f"Failed to save {self._json_key} to {self.file_path}: {e}") raise # ── Common CRUD ──────────────────────────────────────────────── def get_all(self) -> List[T]: return list(self._items.values()) def get(self, item_id: str) -> T: if item_id not in self._items: raise ValueError(f"{self._entity_name} not found: {item_id}") return self._items[item_id] def delete(self, item_id: str) -> None: if item_id not in self._items: raise ValueError(f"{self._entity_name} not found: {item_id}") del self._items[item_id] self._save() logger.info(f"Deleted {self._entity_name}: {item_id}") def count(self) -> int: return len(self._items) # ── Helpers ──────────────────────────────────────────────────── def _check_name_unique(self, name: str, exclude_id: str = None) -> None: """Raise ValueError if *name* is empty or already taken.""" if not name or not name.strip(): raise ValueError("Name is required") for item_id, item in self._items.items(): if item_id != exclude_id and getattr(item, "name", None) == name: raise ValueError( f"{self._entity_name} with name '{name}' already exists" )