validation.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
  1. # -*- coding: utf-8 -*-
  2. """
  3. Schema-basierte Validierung für Konfigurationsdateien.
  4. Bietet Validierung von Konfigurationen gegen definierte Schemas
  5. mit detaillierten Fehlermeldungen.
  6. """
  7. from __future__ import annotations
  8. import dataclasses
  9. from dataclasses import dataclass, field
  10. from enum import Enum
  11. from typing import Any, Callable, Sequence, Type, get_type_hints
  12. class ConfigValueType(Enum):
  13. """
  14. Unterstützte Konfigurationswert-Typen.
  15. """
  16. STRING = "string"
  17. INTEGER = "integer"
  18. FLOAT = "float"
  19. BOOLEAN = "boolean"
  20. LIST = "list"
  21. DICT = "dict"
  22. ANY = "any"
  23. @dataclass
  24. class ConfigFieldError:
  25. """
  26. Fehler bei der Konfigurationsvalidierung.
  27. """
  28. path: str
  29. """Pfad zum fehlerhaften Feld (z.B. 'network.host')."""
  30. message: str
  31. """Fehlermeldung."""
  32. value: Any = None
  33. """Der fehlerhafte Wert."""
  34. expected: str = ""
  35. """Beschreibung des erwarteten Wertes."""
  36. def __str__(self) -> str:
  37. """String-Repräsentation."""
  38. msg = f"{self.path}: {self.message}"
  39. if self.expected:
  40. msg += f" (erwartet: {self.expected})"
  41. return msg
  42. class ConfigValidationError(Exception):
  43. """
  44. Exception für Konfigurationsvalidierungsfehler.
  45. """
  46. def __init__(
  47. self,
  48. message: str,
  49. errors: list[ConfigFieldError] | None = None,
  50. ) -> None:
  51. """
  52. Initialisiert den Fehler.
  53. Args:
  54. message: Übergreifende Fehlermeldung.
  55. errors: Liste von Feldfehlern.
  56. """
  57. super().__init__(message)
  58. self.errors = errors or []
  59. def __str__(self) -> str:
  60. """String-Repräsentation."""
  61. if not self.errors:
  62. return str(self.args[0])
  63. lines = [str(self.args[0])]
  64. for error in self.errors:
  65. lines.append(f" - {error}")
  66. return "\n".join(lines)
  67. @dataclass
  68. class ConfigFieldSpec:
  69. """
  70. Spezifikation eines Konfigurationsfeldes.
  71. """
  72. name: str
  73. """Feldname."""
  74. value_type: ConfigValueType = ConfigValueType.ANY
  75. """Erwarteter Werttyp."""
  76. required: bool = True
  77. """Ob das Feld erforderlich ist."""
  78. default: Any = None
  79. """Standardwert."""
  80. min_value: float | None = None
  81. """Minimaler Wert (für Zahlen)."""
  82. max_value: float | None = None
  83. """Maximaler Wert (für Zahlen)."""
  84. min_length: int | None = None
  85. """Minimale Länge (für Strings/Listen)."""
  86. max_length: int | None = None
  87. """Maximale Länge (für Strings/Listen)."""
  88. pattern: str | None = None
  89. """Regex-Pattern (für Strings)."""
  90. choices: Sequence[Any] | None = None
  91. """Erlaubte Werte."""
  92. validator: Callable[[Any], bool] | None = None
  93. """Benutzerdefinierte Validierungsfunktion."""
  94. description: str = ""
  95. """Beschreibung für Dokumentation."""
  96. nested_schema: "ConfigSchema | None" = None
  97. """Schema für verschachtelte Dicts."""
  98. item_spec: "ConfigFieldSpec | None" = None
  99. """Spezifikation für Listen-Elemente."""
  100. class ConfigSchema:
  101. """
  102. Schema für Konfigurationsvalidierung.
  103. Definiert die erwartete Struktur und Regeln für eine
  104. Konfiguration.
  105. Example:
  106. schema = ConfigSchema("ServerConfig")
  107. schema.field("host", ConfigValueType.STRING, required=True)
  108. schema.field("port", ConfigValueType.INTEGER, min_value=1, max_value=65535)
  109. schema.field("debug", ConfigValueType.BOOLEAN, default=False)
  110. # Validieren
  111. schema.validate(config_data)
  112. """
  113. def __init__(
  114. self,
  115. name: str = "",
  116. strict: bool = False,
  117. ) -> None:
  118. """
  119. Initialisiert das Schema.
  120. Args:
  121. name: Name des Schemas.
  122. strict: Ob unbekannte Felder einen Fehler verursachen.
  123. """
  124. self.name = name
  125. self.strict = strict
  126. self._fields: dict[str, ConfigFieldSpec] = {}
  127. def field(
  128. self,
  129. name: str,
  130. value_type: ConfigValueType = ConfigValueType.ANY,
  131. required: bool = True,
  132. default: Any = None,
  133. min_value: float | None = None,
  134. max_value: float | None = None,
  135. min_length: int | None = None,
  136. max_length: int | None = None,
  137. pattern: str | None = None,
  138. choices: Sequence[Any] | None = None,
  139. validator: Callable[[Any], bool] | None = None,
  140. description: str = "",
  141. nested_schema: "ConfigSchema | None" = None,
  142. item_spec: ConfigFieldSpec | None = None,
  143. ) -> "ConfigSchema":
  144. """
  145. Fügt eine Feldspezifikation hinzu.
  146. Args:
  147. name: Feldname.
  148. value_type: Erwarteter Typ.
  149. required: Ob erforderlich.
  150. default: Standardwert.
  151. min_value: Minimaler Wert.
  152. max_value: Maximaler Wert.
  153. min_length: Minimale Länge.
  154. max_length: Maximale Länge.
  155. pattern: Regex-Pattern.
  156. choices: Erlaubte Werte.
  157. validator: Benutzerdefinierte Prüffunktion.
  158. description: Beschreibung.
  159. nested_schema: Schema für verschachtelte Dicts.
  160. item_spec: Spezifikation für Listen-Elemente.
  161. Returns:
  162. Self für Method-Chaining.
  163. """
  164. self._fields[name] = ConfigFieldSpec(
  165. name=name,
  166. value_type=value_type,
  167. required=required,
  168. default=default,
  169. min_value=min_value,
  170. max_value=max_value,
  171. min_length=min_length,
  172. max_length=max_length,
  173. pattern=pattern,
  174. choices=choices,
  175. validator=validator,
  176. description=description,
  177. nested_schema=nested_schema,
  178. item_spec=item_spec,
  179. )
  180. return self
  181. def nested(
  182. self,
  183. name: str,
  184. schema: "ConfigSchema",
  185. required: bool = True,
  186. description: str = "",
  187. ) -> "ConfigSchema":
  188. """
  189. Fügt ein verschachteltes Schema hinzu.
  190. Args:
  191. name: Feldname.
  192. schema: Das verschachtelte Schema.
  193. required: Ob erforderlich.
  194. description: Beschreibung.
  195. Returns:
  196. Self für Method-Chaining.
  197. """
  198. self._fields[name] = ConfigFieldSpec(
  199. name=name,
  200. value_type=ConfigValueType.DICT,
  201. required=required,
  202. description=description,
  203. nested_schema=schema,
  204. )
  205. return self
  206. def _validate_type(
  207. self,
  208. value: Any,
  209. spec: ConfigFieldSpec,
  210. path: str,
  211. ) -> list[ConfigFieldError]:
  212. """Validiert den Typ eines Wertes."""
  213. errors: list[ConfigFieldError] = []
  214. if spec.value_type == ConfigValueType.ANY:
  215. return errors
  216. type_map = {
  217. ConfigValueType.STRING: str,
  218. ConfigValueType.INTEGER: int,
  219. ConfigValueType.FLOAT: (int, float),
  220. ConfigValueType.BOOLEAN: bool,
  221. ConfigValueType.LIST: (list, tuple),
  222. ConfigValueType.DICT: dict,
  223. }
  224. expected_type = type_map.get(spec.value_type)
  225. if expected_type and not isinstance(value, expected_type):
  226. errors.append(ConfigFieldError(
  227. path=path,
  228. message=f"Ungültiger Typ: {type(value).__name__}",
  229. value=value,
  230. expected=spec.value_type.value,
  231. ))
  232. return errors
  233. def _validate_constraints(
  234. self,
  235. value: Any,
  236. spec: ConfigFieldSpec,
  237. path: str,
  238. ) -> list[ConfigFieldError]:
  239. """Validiert Constraints für einen Wert."""
  240. errors: list[ConfigFieldError] = []
  241. # Bereichsprüfung
  242. if spec.min_value is not None and isinstance(value, (int, float)):
  243. if value < spec.min_value:
  244. errors.append(ConfigFieldError(
  245. path=path,
  246. message=f"Wert {value} ist kleiner als Minimum",
  247. value=value,
  248. expected=f">= {spec.min_value}",
  249. ))
  250. if spec.max_value is not None and isinstance(value, (int, float)):
  251. if value > spec.max_value:
  252. errors.append(ConfigFieldError(
  253. path=path,
  254. message=f"Wert {value} ist größer als Maximum",
  255. value=value,
  256. expected=f"<= {spec.max_value}",
  257. ))
  258. # Längenprüfung
  259. if hasattr(value, "__len__"):
  260. if spec.min_length is not None and len(value) < spec.min_length:
  261. errors.append(ConfigFieldError(
  262. path=path,
  263. message=f"Länge {len(value)} ist zu kurz",
  264. value=value,
  265. expected=f">= {spec.min_length}",
  266. ))
  267. if spec.max_length is not None and len(value) > spec.max_length:
  268. errors.append(ConfigFieldError(
  269. path=path,
  270. message=f"Länge {len(value)} ist zu lang",
  271. value=value,
  272. expected=f"<= {spec.max_length}",
  273. ))
  274. # Pattern-Prüfung
  275. if spec.pattern and isinstance(value, str):
  276. import re
  277. if not re.match(spec.pattern, value):
  278. errors.append(ConfigFieldError(
  279. path=path,
  280. message="Wert entspricht nicht dem erwarteten Muster",
  281. value=value,
  282. expected=f"Pattern: {spec.pattern}",
  283. ))
  284. # Choices-Prüfung
  285. if spec.choices is not None:
  286. if value not in spec.choices:
  287. errors.append(ConfigFieldError(
  288. path=path,
  289. message=f"Ungültiger Wert",
  290. value=value,
  291. expected=f"einer von {list(spec.choices)}",
  292. ))
  293. # Custom Validator
  294. if spec.validator:
  295. try:
  296. if not spec.validator(value):
  297. errors.append(ConfigFieldError(
  298. path=path,
  299. message="Benutzerdefinierte Validierung fehlgeschlagen",
  300. value=value,
  301. ))
  302. except Exception as e:
  303. errors.append(ConfigFieldError(
  304. path=path,
  305. message=f"Validierungsfehler: {e}",
  306. value=value,
  307. ))
  308. return errors
  309. def _validate_field(
  310. self,
  311. data: dict,
  312. spec: ConfigFieldSpec,
  313. path_prefix: str,
  314. ) -> list[ConfigFieldError]:
  315. """Validiert ein einzelnes Feld."""
  316. errors: list[ConfigFieldError] = []
  317. path = f"{path_prefix}.{spec.name}" if path_prefix else spec.name
  318. # Existenz prüfen
  319. if spec.name not in data:
  320. if spec.required:
  321. errors.append(ConfigFieldError(
  322. path=path,
  323. message="Erforderliches Feld fehlt",
  324. ))
  325. return errors
  326. value = data[spec.name]
  327. # None-Wert
  328. if value is None:
  329. if spec.required:
  330. errors.append(ConfigFieldError(
  331. path=path,
  332. message="Erforderliches Feld darf nicht null sein",
  333. value=value,
  334. ))
  335. return errors
  336. # Typ validieren
  337. errors.extend(self._validate_type(value, spec, path))
  338. # Constraints validieren
  339. errors.extend(self._validate_constraints(value, spec, path))
  340. # Verschachteltes Schema
  341. if spec.nested_schema and isinstance(value, dict):
  342. errors.extend(spec.nested_schema.validate_and_collect(value, path))
  343. # Listen-Elemente
  344. if spec.item_spec and isinstance(value, (list, tuple)):
  345. for i, item in enumerate(value):
  346. item_path = f"{path}[{i}]"
  347. errors.extend(self._validate_type(item, spec.item_spec, item_path))
  348. errors.extend(self._validate_constraints(item, spec.item_spec, item_path))
  349. if spec.item_spec.nested_schema and isinstance(item, dict):
  350. errors.extend(
  351. spec.item_spec.nested_schema.validate_and_collect(item, item_path)
  352. )
  353. return errors
  354. def validate_and_collect(
  355. self,
  356. data: dict,
  357. path_prefix: str = "",
  358. ) -> list[ConfigFieldError]:
  359. """
  360. Validiert Daten und sammelt alle Fehler.
  361. Args:
  362. data: Zu validierende Daten.
  363. path_prefix: Pfad-Präfix für Fehler.
  364. Returns:
  365. Liste von Fehlern.
  366. """
  367. errors: list[ConfigFieldError] = []
  368. if not isinstance(data, dict):
  369. errors.append(ConfigFieldError(
  370. path=path_prefix or "(root)",
  371. message="Konfiguration muss ein Dictionary sein",
  372. value=data,
  373. ))
  374. return errors
  375. # Bekannte Felder validieren
  376. for spec in self._fields.values():
  377. errors.extend(self._validate_field(data, spec, path_prefix))
  378. # Unbekannte Felder prüfen
  379. if self.strict:
  380. known_fields = set(self._fields.keys())
  381. for key in data:
  382. if key not in known_fields:
  383. path = f"{path_prefix}.{key}" if path_prefix else key
  384. errors.append(ConfigFieldError(
  385. path=path,
  386. message="Unbekanntes Feld",
  387. value=data[key],
  388. ))
  389. return errors
  390. def validate(self, data: dict) -> dict:
  391. """
  392. Validiert Daten und gibt sie mit Defaults zurück.
  393. Args:
  394. data: Zu validierende Daten.
  395. Returns:
  396. Validierte Daten mit Defaults.
  397. Raises:
  398. ConfigValidationError: Wenn Validierung fehlschlägt.
  399. """
  400. errors = self.validate_and_collect(data)
  401. if errors:
  402. raise ConfigValidationError(
  403. f"Konfigurationsvalidierung für '{self.name}' fehlgeschlagen",
  404. errors=errors,
  405. )
  406. # Defaults anwenden
  407. result = dict(data)
  408. for name, spec in self._fields.items():
  409. if name not in result and spec.default is not None:
  410. result[name] = spec.default
  411. return result
  412. def is_valid(self, data: dict) -> bool:
  413. """
  414. Prüft, ob Daten valide sind.
  415. Args:
  416. data: Zu prüfende Daten.
  417. Returns:
  418. True wenn valide.
  419. """
  420. errors = self.validate_and_collect(data)
  421. return len(errors) == 0
  422. def validate_config(
  423. data: dict,
  424. schema: ConfigSchema,
  425. ) -> dict:
  426. """
  427. Validiert eine Konfiguration gegen ein Schema.
  428. Args:
  429. data: Konfigurationsdaten.
  430. schema: Validierungsschema.
  431. Returns:
  432. Validierte Daten mit Defaults.
  433. Raises:
  434. ConfigValidationError: Wenn Validierung fehlschlägt.
  435. """
  436. return schema.validate(data)
  437. def schema_from_dataclass(
  438. dataclass_type: Type,
  439. strict: bool = False,
  440. ) -> ConfigSchema:
  441. """
  442. Erstellt ein Schema aus einer Dataclass.
  443. Args:
  444. dataclass_type: Die Dataclass.
  445. strict: Ob unbekannte Felder einen Fehler verursachen.
  446. Returns:
  447. ConfigSchema basierend auf der Dataclass.
  448. """
  449. if not dataclasses.is_dataclass(dataclass_type):
  450. raise ValueError("Typ muss eine Dataclass sein")
  451. schema = ConfigSchema(name=dataclass_type.__name__, strict=strict)
  452. type_hints = get_type_hints(dataclass_type)
  453. type_map = {
  454. str: ConfigValueType.STRING,
  455. int: ConfigValueType.INTEGER,
  456. float: ConfigValueType.FLOAT,
  457. bool: ConfigValueType.BOOLEAN,
  458. list: ConfigValueType.LIST,
  459. dict: ConfigValueType.DICT,
  460. }
  461. for dc_field in dataclasses.fields(dataclass_type):
  462. field_type = type_hints.get(dc_field.name, Any)
  463. has_default = (
  464. dc_field.default is not dataclasses.MISSING
  465. or dc_field.default_factory is not dataclasses.MISSING
  466. )
  467. value_type = type_map.get(field_type, ConfigValueType.ANY)
  468. default = None
  469. if dc_field.default is not dataclasses.MISSING:
  470. default = dc_field.default
  471. elif dc_field.default_factory is not dataclasses.MISSING:
  472. default = dc_field.default_factory()
  473. schema.field(
  474. name=dc_field.name,
  475. value_type=value_type,
  476. required=not has_default,
  477. default=default,
  478. )
  479. return schema