""" redis.py — P46: Upstash Redis REST client centralizzato. Centralizza l'accesso a Upstash Redis per tutti i moduli backend. Ogni comando è una POST a UPSTASH_REDIS_REST_URL via HTTPS — zero SDK, zero dipendenze. Configurazione: UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN (già presenti in env Railway). Esportazioni: redis_cmd(command) — comando raw (lista Redis) redis_set(key, value, ttl) — SET [EX ttl] redis_get(key) — GET redis_del(*keys) — DEL redis_mget(keys) — MGET redis_scan(pattern) — SCAN paginato (P40-H fix: loop cursor != "0") redis_expire(key, ttl) — EXPIRE is_available() — PING health check @module redis """ import os import httpx REDIS_URL = os.getenv("UPSTASH_REDIS_REST_URL", "") REDIS_TOKEN = os.getenv("UPSTASH_REDIS_REST_TOKEN", "") async def redis_cmd(command: list, timeout: float = 2.0) -> dict | None: """Esegue un comando Redis via Upstash REST API. Ritorna None su errore.""" if not REDIS_URL or not REDIS_TOKEN: return None try: async with httpx.AsyncClient(timeout=timeout) as c: r = await c.post( REDIS_URL, json=command, headers={ "Authorization": f"Bearer {REDIS_TOKEN}", "Content-Type": "application/json", }, ) return r.json() if r.is_success else None except Exception: return None async def redis_set(key: str, value: str, ttl: int | None = None) -> bool: """SET key value [EX ttl]. Ritorna True su successo.""" cmd: list = ["SET", key, value] if ttl is not None: cmd += ["EX", ttl] return await redis_cmd(cmd) is not None async def redis_get(key: str) -> str | None: """GET key. Ritorna None se chiave assente o errore.""" result = await redis_cmd(["GET", key]) if result and "result" in result: return result["result"] return None async def redis_del(*keys: str) -> int: """DEL key [key ...]. Ritorna il numero di chiavi rimosse.""" if not keys: return 0 result = await redis_cmd(["DEL"] + list(keys)) return result.get("result", 0) if result else 0 async def redis_mget(keys: list[str]) -> list[str | None]: """MGET key [key ...]. Ritorna lista di valori (None se assenti).""" if not keys: return [] result = await redis_cmd(["MGET"] + keys) if result and "result" in result: return result["result"] return [None] * len(keys) async def redis_scan(pattern: str) -> list[str]: """SCAN paginato. Ritorna tutte le chiavi che matchano il pattern. Loop su cursor != "0" (P40-H fix: versioni precedenti usavano cursor fisso "0"). """ all_keys: list[str] = [] cursor = "0" while True: result = await redis_cmd(["SCAN", cursor, "MATCH", pattern, "COUNT", "100"]) if not result or "result" not in result: break scan_result = result["result"] if not isinstance(scan_result, list) or len(scan_result) < 2: break cursor = str(scan_result[0]) keys = scan_result[1] if isinstance(keys, list): all_keys.extend(keys) if cursor == "0": break return all_keys async def redis_expire(key: str, ttl: int) -> bool: """EXPIRE key ttl. Ritorna True se la chiave esiste.""" result = await redis_cmd(["EXPIRE", key, ttl]) return bool(result and result.get("result")) async def is_available() -> bool: """Verifica se Upstash Redis è configurato e raggiungibile (PING).""" if not REDIS_URL or not REDIS_TOKEN: return False result = await redis_cmd(["PING"]) return bool(result and result.get("result") == "PONG")