cache.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. # -*- coding: utf-8 -*-
  2. """
  3. Offline-Response-Cache.
  4. Speichert HTTP-Responses auf Festplatte, damit sie bei Offline-Zustand
  5. als Fallback zurueckgegeben werden koennen.
  6. Cache-Schluessel: SHA256(method + url + body + sorted_params)
  7. Speicherort: {cache_dir}/online_cache/{hash}.json
  8. """
  9. from __future__ import annotations
  10. import hashlib
  11. import json
  12. import os
  13. import time
  14. from dataclasses import dataclass, field
  15. from pathlib import Path
  16. from typing import Any
  17. from trixy_core.utils.debug import pdebug
  18. @dataclass
  19. class CacheEntry:
  20. """Ein Cache-Eintrag."""
  21. url: str
  22. method: str
  23. status_code: int
  24. headers: dict[str, str]
  25. body: str | bytes
  26. cached_at: float # time.time()
  27. ttl_seconds: float = 0 # 0 = kein Ablauf
  28. is_binary: bool = False
  29. @property
  30. def age_seconds(self) -> float:
  31. """Alter des Eintrags in Sekunden."""
  32. return time.time() - self.cached_at
  33. @property
  34. def is_expired(self) -> bool:
  35. """Prueft ob der Eintrag abgelaufen ist."""
  36. if self.ttl_seconds <= 0:
  37. return False
  38. return self.age_seconds > self.ttl_seconds
  39. def to_dict(self) -> dict[str, Any]:
  40. """Serialisiert den Eintrag."""
  41. body = self.body
  42. if isinstance(body, bytes):
  43. body = body.hex()
  44. return {
  45. "url": self.url,
  46. "method": self.method,
  47. "status_code": self.status_code,
  48. "headers": self.headers,
  49. "body": body,
  50. "cached_at": self.cached_at,
  51. "ttl_seconds": self.ttl_seconds,
  52. "is_binary": self.is_binary,
  53. }
  54. @classmethod
  55. def from_dict(cls, data: dict[str, Any]) -> CacheEntry:
  56. """Deserialisiert einen Eintrag."""
  57. body = data.get("body", "")
  58. is_binary = data.get("is_binary", False)
  59. if is_binary and isinstance(body, str):
  60. body = bytes.fromhex(body)
  61. return cls(
  62. url=data.get("url", ""),
  63. method=data.get("method", "GET"),
  64. status_code=data.get("status_code", 200),
  65. headers=data.get("headers", {}),
  66. body=body,
  67. cached_at=data.get("cached_at", 0),
  68. ttl_seconds=data.get("ttl_seconds", 0),
  69. is_binary=is_binary,
  70. )
  71. class ResponseCache:
  72. """
  73. Persistenter HTTP-Response-Cache fuer Offline-Betrieb.
  74. Speichert Responses als JSON-Dateien auf der Festplatte.
  75. Bei erneutem Request im Offline-Modus wird der Cache zurueckgegeben.
  76. Beispiel:
  77. ```python
  78. cache = ResponseCache("/home/pi/trixy/source/cache")
  79. # Beim Speichern (nach erfolgreichem Request)
  80. cache.put("https://api.weather.com/...", "GET", response_body, headers)
  81. # Beim Abrufen (wenn offline)
  82. entry = cache.get("https://api.weather.com/...", "GET")
  83. if entry:
  84. return entry.body # Gecachte Wetterdaten
  85. ```
  86. """
  87. def __init__(self, cache_dir: str) -> None:
  88. self._cache_dir = Path(cache_dir) / "online_cache"
  89. self._cache_dir.mkdir(parents=True, exist_ok=True)
  90. @staticmethod
  91. def _make_key(
  92. url: str,
  93. method: str = "GET",
  94. body: str | bytes = "",
  95. params: dict[str, str] | None = None,
  96. ) -> str:
  97. """Erzeugt einen Cache-Schluessel."""
  98. parts = [method.upper(), url]
  99. if body:
  100. parts.append(str(body))
  101. if params:
  102. parts.append(str(sorted(params.items())))
  103. raw = "|".join(parts)
  104. return hashlib.sha256(raw.encode()).hexdigest()
  105. def _entry_path(self, key: str) -> Path:
  106. """Pfad zur Cache-Datei."""
  107. return self._cache_dir / f"{key}.json"
  108. def get(
  109. self,
  110. url: str,
  111. method: str = "GET",
  112. body: str | bytes = "",
  113. params: dict[str, str] | None = None,
  114. ignore_ttl: bool = False,
  115. ) -> CacheEntry | None:
  116. """
  117. Liest einen Cache-Eintrag.
  118. Args:
  119. url: Request-URL
  120. method: HTTP-Methode
  121. body: Request-Body (fuer POST)
  122. params: Query-Parameter
  123. ignore_ttl: TTL ignorieren (z.B. im Offline-Modus)
  124. Returns:
  125. CacheEntry oder None wenn nicht gefunden/abgelaufen
  126. """
  127. key = self._make_key(url, method, body, params)
  128. path = self._entry_path(key)
  129. if not path.is_file():
  130. return None
  131. try:
  132. with open(path) as f:
  133. data = json.load(f)
  134. entry = CacheEntry.from_dict(data)
  135. # TTL pruefen (ausser ignore_ttl ist gesetzt)
  136. if not ignore_ttl and entry.is_expired:
  137. pdebug(f"Cache abgelaufen: {url} (Alter: {entry.age_seconds:.0f}s)")
  138. return None
  139. pdebug(f"Cache-Treffer: {url} (Alter: {entry.age_seconds:.0f}s)")
  140. return entry
  141. except (json.JSONDecodeError, OSError, KeyError):
  142. return None
  143. def put(
  144. self,
  145. url: str,
  146. method: str,
  147. status_code: int,
  148. headers: dict[str, str],
  149. body: str | bytes,
  150. params: dict[str, str] | None = None,
  151. ttl_seconds: float = 0,
  152. ) -> None:
  153. """
  154. Speichert eine Response im Cache.
  155. Args:
  156. url: Request-URL
  157. method: HTTP-Methode
  158. status_code: HTTP Status-Code
  159. headers: Response-Headers
  160. body: Response-Body
  161. params: Query-Parameter (fuer Cache-Key)
  162. ttl_seconds: Ablaufzeit (0 = kein Ablauf)
  163. """
  164. key = self._make_key(url, method, "", params)
  165. entry = CacheEntry(
  166. url=url,
  167. method=method,
  168. status_code=status_code,
  169. headers=headers,
  170. body=body,
  171. cached_at=time.time(),
  172. ttl_seconds=ttl_seconds,
  173. is_binary=isinstance(body, bytes),
  174. )
  175. path = self._entry_path(key)
  176. try:
  177. with open(path, "w") as f:
  178. json.dump(entry.to_dict(), f, ensure_ascii=False)
  179. except OSError as e:
  180. pdebug(f"Cache-Schreibfehler: {e}")
  181. def remove(
  182. self,
  183. url: str,
  184. method: str = "GET",
  185. params: dict[str, str] | None = None,
  186. ) -> bool:
  187. """Entfernt einen Cache-Eintrag."""
  188. key = self._make_key(url, method, "", params)
  189. path = self._entry_path(key)
  190. if path.is_file():
  191. path.unlink()
  192. return True
  193. return False
  194. def clear(self) -> int:
  195. """Leert den gesamten Cache. Gibt Anzahl entfernter Eintraege zurueck."""
  196. count = 0
  197. for f in self._cache_dir.glob("*.json"):
  198. f.unlink()
  199. count += 1
  200. return count
  201. def clear_expired(self) -> int:
  202. """Entfernt abgelaufene Eintraege. Gibt Anzahl entfernter Eintraege zurueck."""
  203. count = 0
  204. for f in self._cache_dir.glob("*.json"):
  205. try:
  206. with open(f) as fh:
  207. data = json.load(fh)
  208. entry = CacheEntry.from_dict(data)
  209. if entry.is_expired:
  210. f.unlink()
  211. count += 1
  212. except (json.JSONDecodeError, OSError):
  213. f.unlink()
  214. count += 1
  215. return count
  216. @property
  217. def size(self) -> int:
  218. """Anzahl der Cache-Eintraege."""
  219. return len(list(self._cache_dir.glob("*.json")))