diff.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. # -*- coding: utf-8 -*-
  2. """
  3. Konfigurationsdifferenzen für Vergleich und Analyse.
  4. Bietet Funktionen zum Vergleichen von Konfigurationsversionen
  5. und Generieren von Änderungsberichten.
  6. """
  7. from __future__ import annotations
  8. from dataclasses import dataclass, field
  9. from enum import Enum
  10. from typing import Any, Sequence
  11. class DiffType(Enum):
  12. """
  13. Typ einer Konfigurationsänderung.
  14. """
  15. ADDED = "added"
  16. """Neuer Schlüssel hinzugefügt."""
  17. REMOVED = "removed"
  18. """Schlüssel entfernt."""
  19. MODIFIED = "modified"
  20. """Wert geändert."""
  21. TYPE_CHANGED = "type_changed"
  22. """Typ des Wertes geändert."""
  23. @dataclass
  24. class DiffEntry:
  25. """
  26. Ein einzelner Unterschied zwischen zwei Konfigurationen.
  27. """
  28. path: str
  29. """Pfad zum geänderten Wert (z.B. 'network.host')."""
  30. diff_type: DiffType
  31. """Art der Änderung."""
  32. old_value: Any = None
  33. """Alter Wert (bei REMOVED, MODIFIED, TYPE_CHANGED)."""
  34. new_value: Any = None
  35. """Neuer Wert (bei ADDED, MODIFIED, TYPE_CHANGED)."""
  36. old_type: str = ""
  37. """Alter Typ (bei TYPE_CHANGED)."""
  38. new_type: str = ""
  39. """Neuer Typ (bei TYPE_CHANGED)."""
  40. def __str__(self) -> str:
  41. """String-Repräsentation."""
  42. if self.diff_type == DiffType.ADDED:
  43. return f"+ {self.path}: {self.new_value!r}"
  44. elif self.diff_type == DiffType.REMOVED:
  45. return f"- {self.path}: {self.old_value!r}"
  46. elif self.diff_type == DiffType.MODIFIED:
  47. return f"~ {self.path}: {self.old_value!r} -> {self.new_value!r}"
  48. elif self.diff_type == DiffType.TYPE_CHANGED:
  49. return (
  50. f"! {self.path}: {self.old_type}({self.old_value!r}) -> "
  51. f"{self.new_type}({self.new_value!r})"
  52. )
  53. return f"? {self.path}"
  54. @dataclass
  55. class ConfigDiff:
  56. """
  57. Differenz zwischen zwei Konfigurationen.
  58. Enthält alle Unterschiede und bietet Analyse-Methoden.
  59. """
  60. entries: list[DiffEntry] = field(default_factory=list)
  61. """Liste aller Unterschiede."""
  62. old_config_name: str = "old"
  63. """Name der alten Konfiguration."""
  64. new_config_name: str = "new"
  65. """Name der neuen Konfiguration."""
  66. @property
  67. def has_changes(self) -> bool:
  68. """Prüft, ob es Änderungen gibt."""
  69. return len(self.entries) > 0
  70. @property
  71. def additions(self) -> list[DiffEntry]:
  72. """Gibt alle Hinzufügungen zurück."""
  73. return [e for e in self.entries if e.diff_type == DiffType.ADDED]
  74. @property
  75. def removals(self) -> list[DiffEntry]:
  76. """Gibt alle Entfernungen zurück."""
  77. return [e for e in self.entries if e.diff_type == DiffType.REMOVED]
  78. @property
  79. def modifications(self) -> list[DiffEntry]:
  80. """Gibt alle Modifikationen zurück."""
  81. return [e for e in self.entries if e.diff_type == DiffType.MODIFIED]
  82. @property
  83. def type_changes(self) -> list[DiffEntry]:
  84. """Gibt alle Typänderungen zurück."""
  85. return [e for e in self.entries if e.diff_type == DiffType.TYPE_CHANGED]
  86. def filter_by_path(self, prefix: str) -> list[DiffEntry]:
  87. """
  88. Filtert Einträge nach Pfadpräfix.
  89. Args:
  90. prefix: Präfix zum Filtern (z.B. 'network.').
  91. Returns:
  92. Liste der passenden Einträge.
  93. """
  94. return [e for e in self.entries if e.path.startswith(prefix)]
  95. def summary(self) -> dict[str, int]:
  96. """
  97. Gibt eine Zusammenfassung der Änderungen zurück.
  98. Returns:
  99. Dictionary mit Anzahlen pro Typ.
  100. """
  101. return {
  102. "total": len(self.entries),
  103. "added": len(self.additions),
  104. "removed": len(self.removals),
  105. "modified": len(self.modifications),
  106. "type_changed": len(self.type_changes),
  107. }
  108. def to_dict(self) -> dict[str, Any]:
  109. """
  110. Konvertiert das Diff in ein Dictionary.
  111. Returns:
  112. Dictionary-Repräsentation.
  113. """
  114. return {
  115. "old": self.old_config_name,
  116. "new": self.new_config_name,
  117. "summary": self.summary(),
  118. "entries": [
  119. {
  120. "path": e.path,
  121. "type": e.diff_type.value,
  122. "old_value": e.old_value,
  123. "new_value": e.new_value,
  124. }
  125. for e in self.entries
  126. ],
  127. }
  128. def format_report(
  129. self,
  130. show_values: bool = True,
  131. colorize: bool = False,
  132. ) -> str:
  133. """
  134. Formatiert einen lesbaren Bericht.
  135. Args:
  136. show_values: Ob Werte angezeigt werden.
  137. colorize: Ob ANSI-Farben verwendet werden.
  138. Returns:
  139. Formatierter Bericht.
  140. """
  141. lines = [
  142. f"Konfigurationsdifferenz: {self.old_config_name} -> {self.new_config_name}",
  143. "=" * 60,
  144. ]
  145. summary = self.summary()
  146. lines.append(
  147. f"Änderungen: {summary['total']} "
  148. f"(+{summary['added']} -{summary['removed']} "
  149. f"~{summary['modified']} !{summary['type_changed']})"
  150. )
  151. lines.append("")
  152. # ANSI-Farben
  153. if colorize:
  154. GREEN = "\033[32m"
  155. RED = "\033[31m"
  156. YELLOW = "\033[33m"
  157. MAGENTA = "\033[35m"
  158. RESET = "\033[0m"
  159. else:
  160. GREEN = RED = YELLOW = MAGENTA = RESET = ""
  161. for entry in self.entries:
  162. if entry.diff_type == DiffType.ADDED:
  163. prefix = f"{GREEN}+{RESET}"
  164. if show_values:
  165. lines.append(f"{prefix} {entry.path}: {entry.new_value!r}")
  166. else:
  167. lines.append(f"{prefix} {entry.path}")
  168. elif entry.diff_type == DiffType.REMOVED:
  169. prefix = f"{RED}-{RESET}"
  170. if show_values:
  171. lines.append(f"{prefix} {entry.path}: {entry.old_value!r}")
  172. else:
  173. lines.append(f"{prefix} {entry.path}")
  174. elif entry.diff_type == DiffType.MODIFIED:
  175. prefix = f"{YELLOW}~{RESET}"
  176. if show_values:
  177. lines.append(
  178. f"{prefix} {entry.path}: "
  179. f"{entry.old_value!r} -> {entry.new_value!r}"
  180. )
  181. else:
  182. lines.append(f"{prefix} {entry.path}")
  183. elif entry.diff_type == DiffType.TYPE_CHANGED:
  184. prefix = f"{MAGENTA}!{RESET}"
  185. if show_values:
  186. lines.append(
  187. f"{prefix} {entry.path}: "
  188. f"[{entry.old_type}] -> [{entry.new_type}]"
  189. )
  190. else:
  191. lines.append(f"{prefix} {entry.path}")
  192. return "\n".join(lines)
  193. def __str__(self) -> str:
  194. """String-Repräsentation."""
  195. return self.format_report(show_values=False)
  196. def __len__(self) -> int:
  197. """Anzahl der Änderungen."""
  198. return len(self.entries)
  199. def __bool__(self) -> bool:
  200. """True wenn Änderungen vorhanden."""
  201. return self.has_changes
  202. def _flatten_dict(
  203. data: dict[str, Any],
  204. prefix: str = "",
  205. separator: str = ".",
  206. ) -> dict[str, Any]:
  207. """Flacht ein verschachteltes Dictionary ab."""
  208. result: dict[str, Any] = {}
  209. for key, value in data.items():
  210. full_key = f"{prefix}{separator}{key}" if prefix else key
  211. if isinstance(value, dict):
  212. result.update(_flatten_dict(value, full_key, separator))
  213. else:
  214. result[full_key] = value
  215. return result
  216. def diff_configs(
  217. old_config: dict[str, Any],
  218. new_config: dict[str, Any],
  219. old_name: str = "old",
  220. new_name: str = "new",
  221. ignore_paths: Sequence[str] | None = None,
  222. separator: str = ".",
  223. ) -> ConfigDiff:
  224. """
  225. Vergleicht zwei Konfigurationen und gibt die Unterschiede zurück.
  226. Args:
  227. old_config: Alte Konfiguration.
  228. new_config: Neue Konfiguration.
  229. old_name: Name der alten Konfiguration.
  230. new_name: Name der neuen Konfiguration.
  231. ignore_paths: Pfade, die ignoriert werden sollen.
  232. separator: Pfad-Trennzeichen.
  233. Returns:
  234. ConfigDiff mit allen Unterschieden.
  235. """
  236. ignore_set = set(ignore_paths or [])
  237. entries: list[DiffEntry] = []
  238. # Dicts abflachen
  239. old_flat = _flatten_dict(old_config, separator=separator)
  240. new_flat = _flatten_dict(new_config, separator=separator)
  241. all_keys = set(old_flat.keys()) | set(new_flat.keys())
  242. for key in sorted(all_keys):
  243. # Ignorierte Pfade überspringen
  244. if any(key.startswith(p) for p in ignore_set):
  245. continue
  246. old_value = old_flat.get(key)
  247. new_value = new_flat.get(key)
  248. if key not in old_flat:
  249. # Hinzugefügt
  250. entries.append(DiffEntry(
  251. path=key,
  252. diff_type=DiffType.ADDED,
  253. new_value=new_value,
  254. ))
  255. elif key not in new_flat:
  256. # Entfernt
  257. entries.append(DiffEntry(
  258. path=key,
  259. diff_type=DiffType.REMOVED,
  260. old_value=old_value,
  261. ))
  262. elif type(old_value) != type(new_value):
  263. # Typ geändert
  264. entries.append(DiffEntry(
  265. path=key,
  266. diff_type=DiffType.TYPE_CHANGED,
  267. old_value=old_value,
  268. new_value=new_value,
  269. old_type=type(old_value).__name__,
  270. new_type=type(new_value).__name__,
  271. ))
  272. elif old_value != new_value:
  273. # Wert geändert
  274. entries.append(DiffEntry(
  275. path=key,
  276. diff_type=DiffType.MODIFIED,
  277. old_value=old_value,
  278. new_value=new_value,
  279. ))
  280. return ConfigDiff(
  281. entries=entries,
  282. old_config_name=old_name,
  283. new_config_name=new_name,
  284. )
  285. def apply_diff(
  286. config: dict[str, Any],
  287. diff: ConfigDiff,
  288. reverse: bool = False,
  289. ) -> dict[str, Any]:
  290. """
  291. Wendet ein Diff auf eine Konfiguration an.
  292. Args:
  293. config: Basis-Konfiguration.
  294. diff: Das anzuwendende Diff.
  295. reverse: Ob das Diff rückwärts angewendet werden soll.
  296. Returns:
  297. Aktualisierte Konfiguration.
  298. """
  299. import copy
  300. result = copy.deepcopy(config)
  301. def set_nested(data: dict, path: str, value: Any) -> None:
  302. keys = path.split(".")
  303. current = data
  304. for key in keys[:-1]:
  305. if key not in current:
  306. current[key] = {}
  307. current = current[key]
  308. current[keys[-1]] = value
  309. def delete_nested(data: dict, path: str) -> None:
  310. keys = path.split(".")
  311. current = data
  312. for key in keys[:-1]:
  313. if key not in current:
  314. return
  315. current = current[key]
  316. if keys[-1] in current:
  317. del current[keys[-1]]
  318. for entry in diff.entries:
  319. if reverse:
  320. # Rückwärts anwenden
  321. if entry.diff_type == DiffType.ADDED:
  322. delete_nested(result, entry.path)
  323. elif entry.diff_type == DiffType.REMOVED:
  324. set_nested(result, entry.path, entry.old_value)
  325. elif entry.diff_type in (DiffType.MODIFIED, DiffType.TYPE_CHANGED):
  326. set_nested(result, entry.path, entry.old_value)
  327. else:
  328. # Vorwärts anwenden
  329. if entry.diff_type == DiffType.ADDED:
  330. set_nested(result, entry.path, entry.new_value)
  331. elif entry.diff_type == DiffType.REMOVED:
  332. delete_nested(result, entry.path)
  333. elif entry.diff_type in (DiffType.MODIFIED, DiffType.TYPE_CHANGED):
  334. set_nested(result, entry.path, entry.new_value)
  335. return result