import json import time import sqlite3 import threading import functools from pathlib import Path from typing import Any, Optional # ---------- SINGLETON DECORATOR ---------- T = Any def singleton_loader(func): """Ensure only one cache instance exists.""" cache: dict[str, T] = {} lock = threading.Lock() @functools.wraps(func) def wrapper(*args, **kwargs) -> T: with lock: if func.__name__ not in cache: cache[func.__name__] = func(*args, **kwargs) return cache[func.__name__] return wrapper # ---------- CACHE CLASS ---------- class CacheDB: """SQLite-backed cache with expiration in minutes, CRUD, auto-cleanup, singleton support.""" TABLE_NAME = "cache" def __init__(self, db_path: str | Path = "cache.db", default_expiration_minutes: int = 1440): """ :param default_expiration_minutes: default expiration in minutes (default 24 hours) """ self.db_path = Path(db_path) self.default_expiration = default_expiration_minutes * 60 # convert minutes -> seconds self.conn = sqlite3.connect(self.db_path, check_same_thread=False) self.conn.row_factory = sqlite3.Row self._lock = threading.Lock() self._create_table() def _create_table(self): """Create the cache table if it doesn't exist.""" with self._lock: self.conn.execute(f""" CREATE TABLE IF NOT EXISTS {self.TABLE_NAME} ( key TEXT PRIMARY KEY, value TEXT, expires_at INTEGER ) """) self.conn.commit() def _cleanup_expired(self): """Delete expired rows.""" now = int(time.time()) with self._lock: self.conn.execute( f"DELETE FROM {self.TABLE_NAME} WHERE expires_at IS NOT NULL AND expires_at < ?", (now,) ) self.conn.commit() # ---------- CRUD ---------- def create(self, key: str, value: Any, expires_in_minutes: Optional[int] = None): """Insert or update a cache entry. expires_in_minutes overrides default expiration.""" self._cleanup_expired() if expires_in_minutes is None: expires_in_seconds = self.default_expiration else: expires_in_seconds = expires_in_minutes * 60 expires_at = int(time.time()) + expires_in_seconds value_json = json.dumps(value) with self._lock: self.conn.execute( f"INSERT OR REPLACE INTO {self.TABLE_NAME} (key, value, expires_at) VALUES (?, ?, ?)", (key, value_json, expires_at) ) self.conn.commit() def read(self, key: str) -> Optional[Any]: """Read a cache entry. Auto-cleans expired items.""" self._cleanup_expired() with self._lock: row = self.conn.execute( f"SELECT * FROM {self.TABLE_NAME} WHERE key = ?", (key,) ).fetchone() if not row: return None return json.loads(row["value"]) def update(self, key: str, value: Any, expires_in_minutes: Optional[int] = None): """Update a cache entry. Optional expiration in minutes.""" if expires_in_minutes is None: expires_in_seconds = self.default_expiration else: expires_in_seconds = expires_in_minutes * 60 expires_at = int(time.time()) + expires_in_seconds value_json = json.dumps(value) with self._lock: self.conn.execute( f"UPDATE {self.TABLE_NAME} SET value = ?, expires_at = ? WHERE key = ?", (value_json, expires_at, key) ) self.conn.commit() def delete(self, key: str): with self._lock: self.conn.execute(f"DELETE FROM {self.TABLE_NAME} WHERE key = ?", (key,)) self.conn.commit() def clear(self): """Delete all rows from the cache table.""" with self._lock: self.conn.execute(f"DELETE FROM {self.TABLE_NAME}") self.conn.commit() def close(self): self.conn.close() # ---------- SINGLETON INSTANCE ---------- @singleton_loader def get_cache(db_path: str = "cache.db", default_expiration_minutes: int = 1440) -> CacheDB: return CacheDB(db_path=db_path, default_expiration_minutes=default_expiration_minutes)