file_value.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722
  1. # -*- coding: utf-8 -*-
  2. """
  3. File Value Conditions - Prüft Werte in JSON/INI/YAML-Dateien.
  4. """
  5. import json
  6. import os
  7. from configparser import ConfigParser
  8. from enum import Enum
  9. from typing import Any
  10. from trixy_core.scheduler.condition.base import Condition
  11. from trixy_core.scheduler.condition.sensor import ComparisonOperator
  12. from trixy_core.scheduler.form_fields import FieldType, FormField, FormValidationError
  13. class FileValueCondition(Condition):
  14. """
  15. Basis-Klasse für Datei-Wert-Conditions.
  16. Liest einen Wert aus einer Konfigurationsdatei und prüft ihn.
  17. """
  18. def __init__(
  19. self,
  20. file_path: str,
  21. key_path: str,
  22. operator: ComparisonOperator = ComparisonOperator.EQUAL,
  23. expected: Any = None,
  24. default: Any = None,
  25. name: str | None = None,
  26. invert: bool = False,
  27. ):
  28. """
  29. Initialisiert die Condition.
  30. Args:
  31. file_path: Pfad zur Datei
  32. key_path: Pfad zum Wert (z.B. "section.key" oder "nested.deep.value")
  33. operator: Vergleichsoperator
  34. expected: Erwarteter Wert
  35. default: Default-Wert wenn Key nicht existiert
  36. name: Condition-Name
  37. invert: Ergebnis invertieren
  38. """
  39. super().__init__(name, invert)
  40. self._file_path = file_path
  41. self._key_path = key_path
  42. self._operator = operator
  43. self._expected = expected
  44. self._default = default
  45. @property
  46. def file_path(self) -> str:
  47. """Dateipfad."""
  48. return self._file_path
  49. @property
  50. def key_path(self) -> str:
  51. """Schlüsselpfad."""
  52. return self._key_path
  53. @property
  54. def operator(self) -> ComparisonOperator:
  55. """Vergleichsoperator."""
  56. return self._operator
  57. @property
  58. def expected(self) -> Any:
  59. """Erwarteter Wert."""
  60. return self._expected
  61. def _read_file(self) -> dict:
  62. """Liest Datei und gibt Dictionary zurück. Muss überschrieben werden."""
  63. raise NotImplementedError
  64. def _get_value(self, data: dict, key_path: str) -> Any:
  65. """
  66. Extrahiert Wert aus verschachteltem Dictionary.
  67. Args:
  68. data: Dictionary mit Daten
  69. key_path: Pfad wie "section.key.subkey"
  70. Returns:
  71. Gefundener Wert oder self._default
  72. """
  73. keys = key_path.split(".")
  74. current = data
  75. for key in keys:
  76. if isinstance(current, dict):
  77. if key in current:
  78. current = current[key]
  79. else:
  80. return self._default
  81. elif isinstance(current, list):
  82. try:
  83. index = int(key)
  84. current = current[index]
  85. except (ValueError, IndexError):
  86. return self._default
  87. else:
  88. return self._default
  89. return current
  90. def _compare(self, value: Any) -> bool:
  91. """Vergleicht Wert mit expected."""
  92. try:
  93. if self._operator == ComparisonOperator.EQUAL:
  94. return value == self._expected
  95. elif self._operator == ComparisonOperator.NOT_EQUAL:
  96. return value != self._expected
  97. elif self._operator == ComparisonOperator.GREATER:
  98. return value > self._expected
  99. elif self._operator == ComparisonOperator.GREATER_EQUAL:
  100. return value >= self._expected
  101. elif self._operator == ComparisonOperator.LESS:
  102. return value < self._expected
  103. elif self._operator == ComparisonOperator.LESS_EQUAL:
  104. return value <= self._expected
  105. elif self._operator == ComparisonOperator.BETWEEN:
  106. min_val, max_val = self._expected
  107. return min_val <= value <= max_val
  108. elif self._operator == ComparisonOperator.IN:
  109. return value in self._expected
  110. elif self._operator == ComparisonOperator.NOT_IN:
  111. return value not in self._expected
  112. except (TypeError, ValueError):
  113. return False
  114. return False
  115. def _evaluate(self, context: dict) -> bool:
  116. """Prüft Datei-Wert."""
  117. try:
  118. # Datei lesen
  119. data = self._read_file()
  120. # Wert extrahieren
  121. value = self._get_value(data, self._key_path)
  122. # Vergleichen
  123. return self._compare(value)
  124. except FileNotFoundError:
  125. from trixy_core.utils.debug import pwarn
  126. pwarn(f"Datei nicht gefunden: {self._file_path}")
  127. return False
  128. except Exception as e:
  129. from trixy_core.utils.debug import perror
  130. perror(f"Fehler beim Lesen von {self._file_path}: {e}")
  131. return False
  132. def _base_file_dict(self) -> dict:
  133. """Basis-Dictionary für File-Conditions."""
  134. return {
  135. **self._base_dict(),
  136. "file_path": self._file_path,
  137. "key_path": self._key_path,
  138. "operator": self._operator.value,
  139. "expected": self._expected,
  140. "default": self._default,
  141. }
  142. @classmethod
  143. def _operator_choices(cls) -> list[tuple[str, str]]:
  144. """Operator-Auswahl fuer Formular-Felder."""
  145. return [
  146. ("eq", "Gleich (==)"),
  147. ("ne", "Ungleich (!=)"),
  148. ("gt", "Groesser (>)"),
  149. ("gte", "Groesser/Gleich (>=)"),
  150. ("lt", "Kleiner (<)"),
  151. ("lte", "Kleiner/Gleich (<=)"),
  152. ]
  153. @classmethod
  154. def _base_form_fields(cls) -> list[FormField]:
  155. """Basis-Formular-Felder fuer alle Datei-Wert-Conditions."""
  156. return [
  157. FormField(
  158. name="file_path", label="Dateipfad", required=True,
  159. placeholder="/path/to/config.json",
  160. help_text="Pfad zur Konfigurationsdatei",
  161. ),
  162. FormField(
  163. name="key_path", label="Schluessel-Pfad", required=True,
  164. placeholder="section.key",
  165. help_text="Pfad zum Wert (z.B. 'settings.enabled')",
  166. ),
  167. FormField(
  168. name="expected_value", label="Erwarteter Wert",
  169. placeholder="true",
  170. ),
  171. FormField(
  172. name="operator", label="Operator",
  173. field_type=FieldType.SELECT, default="eq",
  174. choices=cls._operator_choices(),
  175. ),
  176. FormField(
  177. name="invert", label="Invertieren",
  178. field_type=FieldType.CHECKBOX, default=False,
  179. ),
  180. ]
  181. @classmethod
  182. def _base_validate_form(cls, data: dict) -> list[FormValidationError]:
  183. """Basis-Validierung fuer alle Datei-Wert-Conditions."""
  184. errors: list[FormValidationError] = []
  185. if not data.get("file_path", "").strip():
  186. errors.append(FormValidationError("file_path", "Dateipfad ist erforderlich"))
  187. if not data.get("key_path", "").strip():
  188. errors.append(FormValidationError("key_path", "Schluessel-Pfad ist erforderlich"))
  189. return errors
  190. class JsonValueCondition(FileValueCondition):
  191. """
  192. Prüft einen Wert in einer JSON-Datei.
  193. Beispiel:
  194. ```json
  195. // config.json
  196. {
  197. "settings": {
  198. "enabled": true,
  199. "threshold": 25
  200. }
  201. }
  202. ```
  203. JsonValueCondition("config.json", "settings.enabled", expected=True)
  204. JsonValueCondition("config.json", "settings.threshold",
  205. ComparisonOperator.GREATER, 20)
  206. """
  207. def _read_file(self) -> dict:
  208. """Liest JSON-Datei."""
  209. with open(self._file_path, "r", encoding="utf-8") as f:
  210. return json.load(f)
  211. def to_dict(self) -> dict:
  212. """Konvertiert zu Dictionary."""
  213. return self._base_file_dict()
  214. @classmethod
  215. def from_dict(cls, data: dict) -> "JsonValueCondition":
  216. """Erstellt Condition aus Dictionary."""
  217. return cls(
  218. file_path=data["file_path"],
  219. key_path=data["key_path"],
  220. operator=ComparisonOperator(data.get("operator", "eq")),
  221. expected=data.get("expected"),
  222. default=data.get("default"),
  223. name=data.get("name"),
  224. invert=data.get("invert", False),
  225. )
  226. @classmethod
  227. def display_name(cls) -> str:
  228. """Anzeigename fuer die TUI."""
  229. return "JSON-Wert"
  230. @classmethod
  231. def form_fields(cls) -> list[FormField]:
  232. """Gibt die Formular-Felder fuer die TUI zurueck."""
  233. fields = cls._base_form_fields()
  234. fields[0] = FormField(
  235. name="file_path", label="JSON-Dateipfad", required=True,
  236. placeholder="/path/to/config.json",
  237. help_text="Pfad zur JSON-Datei",
  238. )
  239. return fields
  240. @classmethod
  241. def validate_form(cls, data: dict) -> list[FormValidationError]:
  242. """Validiert Formular-Daten. Gibt Liste von Fehlern zurueck."""
  243. return cls._base_validate_form(data)
  244. class IniValueCondition(FileValueCondition):
  245. """
  246. Prüft einen Wert in einer INI-Datei.
  247. Beispiel:
  248. ```ini
  249. ; config.ini
  250. [settings]
  251. enabled = true
  252. threshold = 25
  253. ```
  254. IniValueCondition("config.ini", "settings.enabled", expected="true")
  255. IniValueCondition("config.ini", "settings.threshold",
  256. ComparisonOperator.GREATER, 20, value_type=int)
  257. """
  258. def __init__(
  259. self,
  260. file_path: str,
  261. key_path: str,
  262. operator: ComparisonOperator = ComparisonOperator.EQUAL,
  263. expected: Any = None,
  264. default: Any = None,
  265. value_type: type | None = None,
  266. name: str | None = None,
  267. invert: bool = False,
  268. ):
  269. """
  270. Initialisiert die Condition.
  271. Args:
  272. file_path: Pfad zur INI-Datei
  273. key_path: Pfad als "section.key"
  274. operator: Vergleichsoperator
  275. expected: Erwarteter Wert
  276. default: Default-Wert
  277. value_type: Typ für Konvertierung (int, float, bool)
  278. name: Condition-Name
  279. invert: Ergebnis invertieren
  280. """
  281. super().__init__(
  282. file_path, key_path, operator, expected, default, name, invert
  283. )
  284. self._value_type = value_type
  285. def _read_file(self) -> dict:
  286. """Liest INI-Datei als Dictionary."""
  287. config = ConfigParser()
  288. config.read(self._file_path, encoding="utf-8")
  289. # Konvertiere zu Dictionary
  290. result: dict[str, Any] = {}
  291. for section in config.sections():
  292. result[section] = dict(config[section])
  293. # DEFAULT-Sektion hinzufügen
  294. if config.defaults():
  295. result["DEFAULT"] = dict(config.defaults())
  296. return result
  297. def _get_value(self, data: dict, key_path: str) -> Any:
  298. """Extrahiert Wert mit optionaler Typkonvertierung."""
  299. value = super()._get_value(data, key_path)
  300. if value is None or value == self._default:
  301. return value
  302. # Typkonvertierung
  303. if self._value_type:
  304. try:
  305. if self._value_type == bool:
  306. # Spezielle Boolean-Behandlung
  307. if isinstance(value, str):
  308. return value.lower() in ("true", "1", "yes", "on")
  309. return bool(value)
  310. return self._value_type(value)
  311. except (TypeError, ValueError):
  312. return value
  313. return value
  314. def to_dict(self) -> dict:
  315. """Konvertiert zu Dictionary."""
  316. data = self._base_file_dict()
  317. if self._value_type:
  318. data["value_type"] = self._value_type.__name__
  319. return data
  320. @classmethod
  321. def from_dict(cls, data: dict) -> "IniValueCondition":
  322. """Erstellt Condition aus Dictionary."""
  323. value_type = None
  324. if data.get("value_type"):
  325. type_map = {"int": int, "float": float, "bool": bool, "str": str}
  326. value_type = type_map.get(data["value_type"])
  327. return cls(
  328. file_path=data["file_path"],
  329. key_path=data["key_path"],
  330. operator=ComparisonOperator(data.get("operator", "eq")),
  331. expected=data.get("expected"),
  332. default=data.get("default"),
  333. value_type=value_type,
  334. name=data.get("name"),
  335. invert=data.get("invert", False),
  336. )
  337. @classmethod
  338. def display_name(cls) -> str:
  339. """Anzeigename fuer die TUI."""
  340. return "INI-Wert"
  341. @classmethod
  342. def form_fields(cls) -> list[FormField]:
  343. """Gibt die Formular-Felder fuer die TUI zurueck."""
  344. fields = cls._base_form_fields()
  345. fields[0] = FormField(
  346. name="file_path", label="INI-Dateipfad", required=True,
  347. placeholder="/path/to/config.ini",
  348. help_text="Pfad zur INI-Datei",
  349. )
  350. fields[1] = FormField(
  351. name="key_path", label="Schluessel-Pfad", required=True,
  352. placeholder="section.key",
  353. help_text="Format: Sektion.Schluessel",
  354. )
  355. return fields
  356. @classmethod
  357. def validate_form(cls, data: dict) -> list[FormValidationError]:
  358. """Validiert Formular-Daten. Gibt Liste von Fehlern zurueck."""
  359. return cls._base_validate_form(data)
  360. class YamlValueCondition(FileValueCondition):
  361. """
  362. Prüft einen Wert in einer YAML-Datei.
  363. Beispiel:
  364. ```yaml
  365. # config.yaml
  366. settings:
  367. enabled: true
  368. threshold: 25
  369. tags:
  370. - important
  371. - production
  372. ```
  373. YamlValueCondition("config.yaml", "settings.enabled", expected=True)
  374. YamlValueCondition("config.yaml", "settings.tags.0", expected="important")
  375. """
  376. def _read_file(self) -> dict:
  377. """Liest YAML-Datei."""
  378. try:
  379. import yaml
  380. except ImportError:
  381. raise ImportError(
  382. "PyYAML ist nicht installiert. "
  383. "Installieren mit: pip install pyyaml"
  384. )
  385. with open(self._file_path, "r", encoding="utf-8") as f:
  386. return yaml.safe_load(f) or {}
  387. def to_dict(self) -> dict:
  388. """Konvertiert zu Dictionary."""
  389. return self._base_file_dict()
  390. @classmethod
  391. def from_dict(cls, data: dict) -> "YamlValueCondition":
  392. """Erstellt Condition aus Dictionary."""
  393. return cls(
  394. file_path=data["file_path"],
  395. key_path=data["key_path"],
  396. operator=ComparisonOperator(data.get("operator", "eq")),
  397. expected=data.get("expected"),
  398. default=data.get("default"),
  399. name=data.get("name"),
  400. invert=data.get("invert", False),
  401. )
  402. @classmethod
  403. def display_name(cls) -> str:
  404. """Anzeigename fuer die TUI."""
  405. return "YAML-Wert"
  406. @classmethod
  407. def form_fields(cls) -> list[FormField]:
  408. """Gibt die Formular-Felder fuer die TUI zurueck."""
  409. fields = cls._base_form_fields()
  410. fields[0] = FormField(
  411. name="file_path", label="YAML-Dateipfad", required=True,
  412. placeholder="/path/to/config.yaml",
  413. help_text="Pfad zur YAML-Datei",
  414. )
  415. return fields
  416. @classmethod
  417. def validate_form(cls, data: dict) -> list[FormValidationError]:
  418. """Validiert Formular-Daten. Gibt Liste von Fehlern zurueck."""
  419. return cls._base_validate_form(data)
  420. class TomlValueCondition(FileValueCondition):
  421. """
  422. Prüft einen Wert in einer TOML-Datei.
  423. Beispiel:
  424. ```toml
  425. # config.toml
  426. [settings]
  427. enabled = true
  428. threshold = 25
  429. ```
  430. TomlValueCondition("config.toml", "settings.enabled", expected=True)
  431. """
  432. def _read_file(self) -> dict:
  433. """Liest TOML-Datei."""
  434. try:
  435. import tomllib
  436. except ImportError:
  437. try:
  438. import tomli as tomllib # type: ignore
  439. except ImportError:
  440. raise ImportError(
  441. "tomli ist nicht installiert (Python < 3.11). "
  442. "Installieren mit: pip install tomli"
  443. )
  444. with open(self._file_path, "rb") as f:
  445. return tomllib.load(f)
  446. def to_dict(self) -> dict:
  447. """Konvertiert zu Dictionary."""
  448. return self._base_file_dict()
  449. @classmethod
  450. def from_dict(cls, data: dict) -> "TomlValueCondition":
  451. """Erstellt Condition aus Dictionary."""
  452. return cls(
  453. file_path=data["file_path"],
  454. key_path=data["key_path"],
  455. operator=ComparisonOperator(data.get("operator", "eq")),
  456. expected=data.get("expected"),
  457. default=data.get("default"),
  458. name=data.get("name"),
  459. invert=data.get("invert", False),
  460. )
  461. @classmethod
  462. def display_name(cls) -> str:
  463. """Anzeigename fuer die TUI."""
  464. return "TOML-Wert"
  465. @classmethod
  466. def form_fields(cls) -> list[FormField]:
  467. """Gibt die Formular-Felder fuer die TUI zurueck."""
  468. fields = cls._base_form_fields()
  469. fields[0] = FormField(
  470. name="file_path", label="TOML-Dateipfad", required=True,
  471. placeholder="/path/to/config.toml",
  472. help_text="Pfad zur TOML-Datei",
  473. )
  474. return fields
  475. @classmethod
  476. def validate_form(cls, data: dict) -> list[FormValidationError]:
  477. """Validiert Formular-Daten. Gibt Liste von Fehlern zurueck."""
  478. return cls._base_validate_form(data)
  479. class EnvFileValueCondition(FileValueCondition):
  480. """
  481. Prüft einen Wert in einer .env-Datei.
  482. Beispiel:
  483. ```env
  484. # .env
  485. DEBUG=true
  486. API_KEY=secret123
  487. MAX_CONNECTIONS=10
  488. ```
  489. EnvFileValueCondition(".env", "DEBUG", expected="true")
  490. EnvFileValueCondition(".env", "MAX_CONNECTIONS",
  491. ComparisonOperator.GREATER, 5, value_type=int)
  492. """
  493. def __init__(
  494. self,
  495. file_path: str,
  496. key: str,
  497. operator: ComparisonOperator = ComparisonOperator.EQUAL,
  498. expected: Any = None,
  499. default: Any = None,
  500. value_type: type | None = None,
  501. name: str | None = None,
  502. invert: bool = False,
  503. ):
  504. """
  505. Initialisiert die Condition.
  506. Args:
  507. file_path: Pfad zur .env-Datei
  508. key: Variablenname
  509. operator: Vergleichsoperator
  510. expected: Erwarteter Wert
  511. default: Default-Wert
  512. value_type: Typ für Konvertierung
  513. name: Condition-Name
  514. invert: Ergebnis invertieren
  515. """
  516. super().__init__(
  517. file_path, key, operator, expected, default, name, invert
  518. )
  519. self._value_type = value_type
  520. def _read_file(self) -> dict:
  521. """Liest .env-Datei."""
  522. result: dict[str, str] = {}
  523. with open(self._file_path, "r", encoding="utf-8") as f:
  524. for line in f:
  525. line = line.strip()
  526. # Kommentare und leere Zeilen überspringen
  527. if not line or line.startswith("#"):
  528. continue
  529. # KEY=VALUE parsen
  530. if "=" in line:
  531. key, _, value = line.partition("=")
  532. key = key.strip()
  533. value = value.strip()
  534. # Quotes entfernen
  535. if (value.startswith('"') and value.endswith('"')) or \
  536. (value.startswith("'") and value.endswith("'")):
  537. value = value[1:-1]
  538. result[key] = value
  539. return result
  540. def _get_value(self, data: dict, key_path: str) -> Any:
  541. """Extrahiert Wert mit optionaler Typkonvertierung."""
  542. # Bei .env ist key_path einfach der Key (kein Nesting)
  543. value = data.get(key_path, self._default)
  544. if value is None or value == self._default:
  545. return value
  546. # Typkonvertierung
  547. if self._value_type:
  548. try:
  549. if self._value_type == bool:
  550. if isinstance(value, str):
  551. return value.lower() in ("true", "1", "yes", "on")
  552. return bool(value)
  553. return self._value_type(value)
  554. except (TypeError, ValueError):
  555. return value
  556. return value
  557. def to_dict(self) -> dict:
  558. """Konvertiert zu Dictionary."""
  559. data = self._base_file_dict()
  560. if self._value_type:
  561. data["value_type"] = self._value_type.__name__
  562. return data
  563. @classmethod
  564. def from_dict(cls, data: dict) -> "EnvFileValueCondition":
  565. """Erstellt Condition aus Dictionary."""
  566. value_type = None
  567. if data.get("value_type"):
  568. type_map = {"int": int, "float": float, "bool": bool, "str": str}
  569. value_type = type_map.get(data["value_type"])
  570. return cls(
  571. file_path=data["file_path"],
  572. key=data["key_path"],
  573. operator=ComparisonOperator(data.get("operator", "eq")),
  574. expected=data.get("expected"),
  575. default=data.get("default"),
  576. value_type=value_type,
  577. name=data.get("name"),
  578. invert=data.get("invert", False),
  579. )
  580. @classmethod
  581. def display_name(cls) -> str:
  582. """Anzeigename fuer die TUI."""
  583. return "Env-Datei-Wert"
  584. @classmethod
  585. def form_fields(cls) -> list[FormField]:
  586. """Gibt die Formular-Felder fuer die TUI zurueck."""
  587. return [
  588. FormField(
  589. name="file_path", label="Env-Dateipfad", required=True,
  590. placeholder="/path/to/.env",
  591. help_text="Pfad zur .env-Datei",
  592. ),
  593. FormField(
  594. name="key_path", label="Variablenname", required=True,
  595. placeholder="DEBUG",
  596. help_text="Name der Umgebungsvariable",
  597. ),
  598. FormField(
  599. name="expected_value", label="Erwarteter Wert",
  600. placeholder="true",
  601. ),
  602. FormField(
  603. name="operator", label="Operator",
  604. field_type=FieldType.SELECT, default="eq",
  605. choices=cls._operator_choices(),
  606. ),
  607. FormField(
  608. name="invert", label="Invertieren",
  609. field_type=FieldType.CHECKBOX, default=False,
  610. ),
  611. ]
  612. @classmethod
  613. def validate_form(cls, data: dict) -> list[FormValidationError]:
  614. """Validiert Formular-Daten. Gibt Liste von Fehlern zurueck."""
  615. errors: list[FormValidationError] = []
  616. if not data.get("file_path", "").strip():
  617. errors.append(FormValidationError("file_path", "Dateipfad ist erforderlich"))
  618. if not data.get("key_path", "").strip():
  619. errors.append(FormValidationError("key_path", "Variablenname ist erforderlich"))
  620. return errors