config_manager.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. # -*- coding: utf-8 -*-
  2. """
  3. ConfigManager für das Laden und Verwalten von Konfigurationen.
  4. Unterstützt:
  5. - JSON-Laden mit Typ-Validierung
  6. - Dateiüberwachung mit Auto-Reload
  7. - Default + Override Merging
  8. - Thread-sichere Zugriffsmodi
  9. """
  10. import json
  11. import threading
  12. import time
  13. from dataclasses import dataclass, fields, is_dataclass, asdict
  14. from pathlib import Path
  15. from typing import TypeVar, Type, Any, Callable
  16. from watchdog.observers import Observer
  17. from watchdog.events import FileSystemEventHandler, FileModifiedEvent
  18. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  19. T = TypeVar("T")
  20. class ConfigFileHandler(FileSystemEventHandler):
  21. """Handler für Dateiänderungen."""
  22. def __init__(self, callback: Callable[[str], None]) -> None:
  23. super().__init__()
  24. self.callback = callback
  25. self._last_modified: dict[str, float] = {}
  26. self._debounce_seconds = 0.5
  27. def on_modified(self, event: FileModifiedEvent) -> None:
  28. if event.is_directory:
  29. return
  30. path = str(event.src_path)
  31. now = time.time()
  32. # Debouncing
  33. if path in self._last_modified:
  34. if now - self._last_modified[path] < self._debounce_seconds:
  35. return
  36. self._last_modified[path] = now
  37. self.callback(path)
  38. class ConfigManager:
  39. """
  40. Verwaltet Konfigurationen für Trixy.
  41. Unterstützt das Laden von JSON-Dateien in typisierte Dataclasses,
  42. Dateiüberwachung mit Auto-Reload und Thread-sicheren Zugriff.
  43. """
  44. def __init__(self) -> None:
  45. """Initialisiert den ConfigManager."""
  46. self._configs: dict[str, Any] = {}
  47. self._config_paths: dict[str, Path] = {}
  48. self._config_types: dict[str, Type] = {}
  49. self._callbacks: dict[str, list[Callable[[Any], None]]] = {}
  50. self._lock = threading.RLock()
  51. self._observer: Observer | None = None
  52. self._watching = False
  53. def load(
  54. self,
  55. config_path: str | Path,
  56. config_type: Type[T],
  57. name: str | None = None,
  58. auto_reload: bool = False
  59. ) -> T:
  60. """
  61. Lädt eine Konfiguration aus einer JSON-Datei.
  62. Args:
  63. config_path: Pfad zur JSON-Datei
  64. config_type: Ziel-Dataclass-Typ
  65. name: Optionaler Name für die Konfiguration
  66. auto_reload: Automatisches Neuladen bei Änderungen
  67. Returns:
  68. Geladene Konfiguration als Dataclass
  69. Raises:
  70. FileNotFoundError: Wenn die Datei nicht existiert
  71. ValueError: Bei ungültigem JSON oder Typ-Fehlern
  72. """
  73. path = Path(config_path)
  74. config_name = name or path.stem
  75. if not path.exists():
  76. pwarn(f"Konfigurationsdatei nicht gefunden: {path}, verwende Standardwerte")
  77. config = config_type()
  78. else:
  79. config = self._load_file(path, config_type)
  80. with self._lock:
  81. self._configs[config_name] = config
  82. self._config_paths[config_name] = path
  83. self._config_types[config_name] = config_type
  84. if auto_reload:
  85. self._start_watching(path, config_name)
  86. pinfo(f"Konfiguration geladen: {config_name}")
  87. return config
  88. def _load_file(self, path: Path, config_type: Type[T]) -> T:
  89. """
  90. Lädt eine JSON-Datei und konvertiert sie in eine Dataclass.
  91. Args:
  92. path: Pfad zur Datei
  93. config_type: Ziel-Typ
  94. Returns:
  95. Konfiguration als Dataclass
  96. """
  97. try:
  98. with open(path, "r", encoding="utf-8") as f:
  99. data = json.load(f)
  100. except json.JSONDecodeError as e:
  101. perror(f"Ungültiges JSON in {path}: {e}")
  102. raise ValueError(f"Ungültiges JSON: {e}")
  103. return self._dict_to_dataclass(data, config_type)
  104. def _dict_to_dataclass(self, data: dict[str, Any], dc_type: Type[T]) -> T:
  105. """
  106. Konvertiert ein Dictionary rekursiv in eine Dataclass.
  107. Args:
  108. data: Eingabe-Dictionary
  109. dc_type: Ziel-Dataclass-Typ
  110. Returns:
  111. Instanz der Dataclass
  112. """
  113. if not is_dataclass(dc_type):
  114. return data
  115. field_values: dict[str, Any] = {}
  116. for field in fields(dc_type):
  117. field_name = field.name
  118. field_type = field.type
  119. if field_name in data:
  120. value = data[field_name]
  121. # Rekursive Verarbeitung für verschachtelte Dataclasses
  122. if is_dataclass(field_type) and isinstance(value, dict):
  123. value = self._dict_to_dataclass(value, field_type)
  124. # Handle list[...] inkl. list[Dataclass]
  125. elif hasattr(field_type, "__origin__"):
  126. origin = getattr(field_type, "__origin__")
  127. if origin is list and isinstance(value, list):
  128. args = getattr(field_type, "__args__", ())
  129. if args and is_dataclass(args[0]):
  130. item_type = args[0]
  131. value = [
  132. self._dict_to_dataclass(item, item_type)
  133. if isinstance(item, dict) else item
  134. for item in value
  135. ]
  136. # else: Listen primitiver Typen direkt übernehmen
  137. field_values[field_name] = value
  138. return dc_type(**field_values)
  139. def get(self, name: str) -> Any:
  140. """
  141. Gibt eine geladene Konfiguration zurück.
  142. Args:
  143. name: Name der Konfiguration
  144. Returns:
  145. Konfiguration oder None
  146. """
  147. with self._lock:
  148. return self._configs.get(name)
  149. def get_typed(self, name: str, config_type: Type[T]) -> T | None:
  150. """
  151. Gibt eine typisierte Konfiguration zurück.
  152. Args:
  153. name: Name der Konfiguration
  154. config_type: Erwarteter Typ
  155. Returns:
  156. Typisierte Konfiguration oder None
  157. """
  158. with self._lock:
  159. config = self._configs.get(name)
  160. if config is not None and isinstance(config, config_type):
  161. return config
  162. return None
  163. def save(self, name: str, config: Any | None = None) -> None:
  164. """
  165. Speichert eine Konfiguration in die Datei.
  166. Args:
  167. name: Name der Konfiguration
  168. config: Optionale neue Konfiguration
  169. """
  170. with self._lock:
  171. if config is not None:
  172. self._configs[name] = config
  173. cfg = self._configs.get(name)
  174. path = self._config_paths.get(name)
  175. if cfg is None or path is None:
  176. perror(f"Konfiguration '{name}' nicht gefunden")
  177. return
  178. try:
  179. data = asdict(cfg) if is_dataclass(cfg) else cfg
  180. path.parent.mkdir(parents=True, exist_ok=True)
  181. with open(path, "w", encoding="utf-8") as f:
  182. json.dump(data, f, indent=2, ensure_ascii=False)
  183. pinfo(f"Konfiguration gespeichert: {name}")
  184. except Exception as e:
  185. perror(f"Fehler beim Speichern von '{name}': {e}")
  186. def reload(self, name: str) -> Any | None:
  187. """
  188. Lädt eine Konfiguration neu.
  189. Args:
  190. name: Name der Konfiguration
  191. Returns:
  192. Neu geladene Konfiguration oder None
  193. """
  194. with self._lock:
  195. path = self._config_paths.get(name)
  196. config_type = self._config_types.get(name)
  197. if path is None or config_type is None:
  198. perror(f"Konfiguration '{name}' nicht registriert")
  199. return None
  200. try:
  201. config = self._load_file(path, config_type)
  202. self._configs[name] = config
  203. # Callbacks ausführen
  204. if name in self._callbacks:
  205. for callback in self._callbacks[name]:
  206. try:
  207. callback(config)
  208. except Exception as e:
  209. perror(f"Callback-Fehler für '{name}': {e}")
  210. pinfo(f"Konfiguration neu geladen: {name}")
  211. return config
  212. except Exception as e:
  213. perror(f"Fehler beim Neuladen von '{name}': {e}")
  214. return None
  215. def on_reload(self, name: str, callback: Callable[[Any], None]) -> None:
  216. """
  217. Registriert einen Callback für Konfigurations-Reloads.
  218. Args:
  219. name: Name der Konfiguration
  220. callback: Callback-Funktion
  221. """
  222. with self._lock:
  223. if name not in self._callbacks:
  224. self._callbacks[name] = []
  225. self._callbacks[name].append(callback)
  226. def _start_watching(self, path: Path, name: str) -> None:
  227. """Startet die Dateiüberwachung."""
  228. if self._observer is None:
  229. self._observer = Observer()
  230. self._watching = True
  231. def on_change(changed_path: str) -> None:
  232. if Path(changed_path).resolve() == path.resolve():
  233. pdebug(f"Konfigurationsänderung erkannt: {name}")
  234. self.reload(name)
  235. handler = ConfigFileHandler(on_change)
  236. # Überwache das Verzeichnis der Datei
  237. self._observer.schedule(handler, str(path.parent), recursive=False)
  238. if not self._observer.is_alive():
  239. self._observer.start()
  240. pdebug(f"Dateiüberwachung gestartet für: {path}")
  241. def stop_watching(self) -> None:
  242. """Stoppt die Dateiüberwachung."""
  243. if self._observer is not None:
  244. self._observer.stop()
  245. self._observer.join()
  246. self._observer = None
  247. self._watching = False
  248. pdebug("Dateiüberwachung gestoppt")
  249. def create_default(self, config_type: Type[T], path: str | Path) -> T:
  250. """
  251. Erstellt eine Standard-Konfigurationsdatei.
  252. Args:
  253. config_type: Konfigurationstyp
  254. path: Zielpfad
  255. Returns:
  256. Standard-Konfiguration
  257. """
  258. config = config_type()
  259. file_path = Path(path)
  260. file_path.parent.mkdir(parents=True, exist_ok=True)
  261. data = asdict(config) if is_dataclass(config) else {}
  262. with open(file_path, "w", encoding="utf-8") as f:
  263. json.dump(data, f, indent=2, ensure_ascii=False)
  264. pinfo(f"Standard-Konfiguration erstellt: {file_path}")
  265. return config