script.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. # -*- coding: utf-8 -*-
  2. """
  3. Script Condition - Führt Python-Script aus und prüft Rückgabewert.
  4. """
  5. import asyncio
  6. import os
  7. import sys
  8. from typing import Any
  9. from trixy_core.scheduler.condition.base import Condition
  10. from trixy_core.scheduler.form_fields import FieldType, FormField, FormValidationError
  11. class ScriptCondition(Condition):
  12. """
  13. Führt ein Python-Script aus und prüft den Rückgabewert.
  14. Das Script kann:
  15. - Einen Boolean zurückgeben (True/False)
  16. - Einen Wert zurückgeben der mit expected verglichen wird
  17. - exit(0) für True, exit(1) für False
  18. Beispiel:
  19. ```python
  20. # check_temperature.py
  21. import requests
  22. temp = requests.get("http://sensor/temp").json()["value"]
  23. return temp > 20 # oder: exit(0 if temp > 20 else 1)
  24. ```
  25. ScriptCondition("scripts/check_temperature.py")
  26. ScriptCondition("scripts/get_value.py", expected=42)
  27. """
  28. def __init__(
  29. self,
  30. script_path: str | None = None,
  31. script_code: str | None = None,
  32. expected: Any = True,
  33. timeout_seconds: float = 30.0,
  34. pass_context: bool = False,
  35. name: str | None = None,
  36. invert: bool = False,
  37. ):
  38. """
  39. Initialisiert die Condition.
  40. Args:
  41. script_path: Pfad zum Python-Script
  42. script_code: Inline Python-Code (alternativ zu script_path)
  43. expected: Erwarteter Rückgabewert (default: True)
  44. timeout_seconds: Timeout für Script-Ausführung
  45. pass_context: Kontext als Variable 'context' verfügbar machen
  46. name: Condition-Name
  47. invert: Ergebnis invertieren
  48. """
  49. super().__init__(name, invert)
  50. if not script_path and not script_code:
  51. raise ValueError("script_path oder script_code muss angegeben werden")
  52. self._script_path = script_path
  53. self._script_code = script_code
  54. self._expected = expected
  55. self._timeout_seconds = timeout_seconds
  56. self._pass_context = pass_context
  57. @property
  58. def script_path(self) -> str | None:
  59. """Script-Pfad."""
  60. return self._script_path
  61. @property
  62. def expected(self) -> Any:
  63. """Erwarteter Wert."""
  64. return self._expected
  65. def _get_script_code(self) -> str:
  66. """Lädt Script-Code."""
  67. if self._script_code:
  68. return self._script_code
  69. if self._script_path and os.path.isfile(self._script_path):
  70. with open(self._script_path, "r", encoding="utf-8") as f:
  71. return f.read()
  72. raise FileNotFoundError(f"Script nicht gefunden: {self._script_path}")
  73. def _evaluate(self, context: dict) -> bool:
  74. """Führt Script aus und prüft Ergebnis."""
  75. try:
  76. code = self._get_script_code()
  77. # Globale Variablen für Script
  78. script_globals = {
  79. "__builtins__": __builtins__,
  80. "__name__": "__script__",
  81. "__file__": self._script_path or "<inline>",
  82. }
  83. # Kontext verfügbar machen
  84. if self._pass_context:
  85. script_globals["context"] = context
  86. # Lokale Variablen
  87. script_locals: dict[str, Any] = {}
  88. # Script ausführen
  89. exec(code, script_globals, script_locals)
  90. # Rückgabewert suchen
  91. # 1. Explizite 'result' Variable
  92. if "result" in script_locals:
  93. result = script_locals["result"]
  94. # 2. Funktion 'check()' oder 'evaluate()'
  95. elif "check" in script_locals and callable(script_locals["check"]):
  96. result = script_locals["check"]()
  97. elif "evaluate" in script_locals and callable(script_locals["evaluate"]):
  98. result = script_locals["evaluate"]()
  99. # 3. Funktion 'main()'
  100. elif "main" in script_locals and callable(script_locals["main"]):
  101. result = script_locals["main"]()
  102. else:
  103. # Kein expliziter Rückgabewert = True (Script lief durch)
  104. result = True
  105. # Vergleich
  106. if self._expected is True:
  107. return bool(result)
  108. elif self._expected is False:
  109. return not bool(result)
  110. else:
  111. return result == self._expected
  112. except SystemExit as e:
  113. # exit(0) = True, exit(1) = False
  114. exit_code = e.code if e.code is not None else 0
  115. return exit_code == 0
  116. except Exception as e:
  117. from trixy_core.utils.debug import perror
  118. perror(f"Script-Fehler: {e}")
  119. return False
  120. def to_dict(self) -> dict:
  121. """Konvertiert zu Dictionary."""
  122. return {
  123. **self._base_dict(),
  124. "script_path": self._script_path,
  125. "script_code": self._script_code,
  126. "expected": self._expected,
  127. "timeout_seconds": self._timeout_seconds,
  128. "pass_context": self._pass_context,
  129. }
  130. @classmethod
  131. def from_dict(cls, data: dict) -> "ScriptCondition":
  132. """Erstellt Condition aus Dictionary."""
  133. return cls(
  134. script_path=data.get("script_path"),
  135. script_code=data.get("script_code"),
  136. expected=data.get("expected", True),
  137. timeout_seconds=data.get("timeout_seconds", 30.0),
  138. pass_context=data.get("pass_context", False),
  139. name=data.get("name"),
  140. invert=data.get("invert", False),
  141. )
  142. @classmethod
  143. def display_name(cls) -> str:
  144. """Anzeigename fuer die TUI."""
  145. return "Script-Bedingung"
  146. @classmethod
  147. def form_fields(cls) -> list[FormField]:
  148. """Gibt die Formular-Felder fuer die TUI zurueck."""
  149. return [
  150. FormField(
  151. name="script_path", label="Script-Pfad", required=True,
  152. placeholder="/path/to/check_script.py",
  153. help_text="Pfad zum Python-Script",
  154. ),
  155. FormField(
  156. name="args", label="Argumente",
  157. field_type=FieldType.TAGS,
  158. placeholder="--verbose, --threshold=25",
  159. help_text="Komma-separierte Argumente",
  160. ),
  161. FormField(
  162. name="invert", label="Invertieren",
  163. field_type=FieldType.CHECKBOX, default=False,
  164. ),
  165. ]
  166. @classmethod
  167. def validate_form(cls, data: dict) -> list[FormValidationError]:
  168. """Validiert Formular-Daten. Gibt Liste von Fehlern zurueck."""
  169. errors: list[FormValidationError] = []
  170. if not data.get("script_path", "").strip():
  171. errors.append(FormValidationError("script_path", "Script-Pfad ist erforderlich"))
  172. return errors
  173. class AsyncScriptCondition(Condition):
  174. """
  175. Führt ein async Python-Script aus.
  176. Beispiel:
  177. ```python
  178. # async_check.py
  179. import aiohttp
  180. async def check():
  181. async with aiohttp.ClientSession() as session:
  182. async with session.get("http://api/status") as resp:
  183. data = await resp.json()
  184. return data["status"] == "ok"
  185. ```
  186. """
  187. def __init__(
  188. self,
  189. script_path: str | None = None,
  190. script_code: str | None = None,
  191. expected: Any = True,
  192. timeout_seconds: float = 30.0,
  193. name: str | None = None,
  194. invert: bool = False,
  195. ):
  196. """
  197. Initialisiert die Condition.
  198. Args:
  199. script_path: Pfad zum Python-Script
  200. script_code: Inline Python-Code
  201. expected: Erwarteter Rückgabewert
  202. timeout_seconds: Timeout
  203. name: Condition-Name
  204. invert: Ergebnis invertieren
  205. """
  206. super().__init__(name, invert)
  207. if not script_path and not script_code:
  208. raise ValueError("script_path oder script_code muss angegeben werden")
  209. self._script_path = script_path
  210. self._script_code = script_code
  211. self._expected = expected
  212. self._timeout_seconds = timeout_seconds
  213. def _evaluate(self, context: dict) -> bool:
  214. """Führt async Script aus."""
  215. try:
  216. # Event-Loop holen oder erstellen
  217. try:
  218. loop = asyncio.get_running_loop()
  219. # Wir sind bereits in einem Loop - synchron ausführen
  220. return self._evaluate_sync(context)
  221. except RuntimeError:
  222. # Kein laufender Loop - neuen erstellen
  223. return asyncio.run(self._evaluate_async(context))
  224. except Exception as e:
  225. from trixy_core.utils.debug import perror
  226. perror(f"Async Script-Fehler: {e}")
  227. return False
  228. def _evaluate_sync(self, context: dict) -> bool:
  229. """Synchrone Evaluation (Fallback)."""
  230. # Nutze reguläre ScriptCondition
  231. sync_cond = ScriptCondition(
  232. script_path=self._script_path,
  233. script_code=self._script_code,
  234. expected=self._expected,
  235. )
  236. return sync_cond._evaluate(context)
  237. async def _evaluate_async(self, context: dict) -> bool:
  238. """Asynchrone Evaluation."""
  239. code = self._get_script_code()
  240. script_globals = {
  241. "__builtins__": __builtins__,
  242. "asyncio": asyncio,
  243. "context": context,
  244. }
  245. script_locals: dict[str, Any] = {}
  246. exec(code, script_globals, script_locals)
  247. # Async Funktion suchen
  248. check_fn = None
  249. for name in ["check", "evaluate", "main"]:
  250. if name in script_locals and asyncio.iscoroutinefunction(script_locals[name]):
  251. check_fn = script_locals[name]
  252. break
  253. if check_fn:
  254. result = await asyncio.wait_for(
  255. check_fn(),
  256. timeout=self._timeout_seconds,
  257. )
  258. else:
  259. result = script_locals.get("result", True)
  260. if self._expected is True:
  261. return bool(result)
  262. elif self._expected is False:
  263. return not bool(result)
  264. else:
  265. return result == self._expected
  266. def _get_script_code(self) -> str:
  267. """Lädt Script-Code."""
  268. if self._script_code:
  269. return self._script_code
  270. if self._script_path and os.path.isfile(self._script_path):
  271. with open(self._script_path, "r", encoding="utf-8") as f:
  272. return f.read()
  273. raise FileNotFoundError(f"Script nicht gefunden: {self._script_path}")
  274. def to_dict(self) -> dict:
  275. """Konvertiert zu Dictionary."""
  276. return {
  277. **self._base_dict(),
  278. "script_path": self._script_path,
  279. "script_code": self._script_code,
  280. "expected": self._expected,
  281. "timeout_seconds": self._timeout_seconds,
  282. }
  283. @classmethod
  284. def from_dict(cls, data: dict) -> "AsyncScriptCondition":
  285. """Erstellt Condition aus Dictionary."""
  286. return cls(
  287. script_path=data.get("script_path"),
  288. script_code=data.get("script_code"),
  289. expected=data.get("expected", True),
  290. timeout_seconds=data.get("timeout_seconds", 30.0),
  291. name=data.get("name"),
  292. invert=data.get("invert", False),
  293. )
  294. @classmethod
  295. def display_name(cls) -> str:
  296. """Anzeigename fuer die TUI."""
  297. return "Async Script-Bedingung"
  298. @classmethod
  299. def form_fields(cls) -> list[FormField]:
  300. """Gibt die Formular-Felder fuer die TUI zurueck."""
  301. return [
  302. FormField(
  303. name="script_path", label="Script-Pfad", required=True,
  304. placeholder="/path/to/async_check.py",
  305. help_text="Pfad zum async Python-Script",
  306. ),
  307. FormField(
  308. name="args", label="Argumente",
  309. field_type=FieldType.TAGS,
  310. placeholder="--verbose, --threshold=25",
  311. help_text="Komma-separierte Argumente",
  312. ),
  313. FormField(
  314. name="invert", label="Invertieren",
  315. field_type=FieldType.CHECKBOX, default=False,
  316. ),
  317. ]
  318. @classmethod
  319. def validate_form(cls, data: dict) -> list[FormValidationError]:
  320. """Validiert Formular-Daten. Gibt Liste von Fehlern zurueck."""
  321. errors: list[FormValidationError] = []
  322. if not data.get("script_path", "").strip():
  323. errors.append(FormValidationError("script_path", "Script-Pfad ist erforderlich"))
  324. return errors