cron.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. # -*- coding: utf-8 -*-
  2. """
  3. Cron Trigger - Zeitbasierter Trigger mit Cron-Ausdrücken.
  4. """
  5. from datetime import datetime
  6. from typing import Any
  7. from trixy_core.scheduler.form_fields import FieldType, FormField, FormValidationError
  8. from trixy_core.scheduler.trigger.base import Trigger, TriggerContext, TriggerState
  9. class CronField:
  10. """Parser für ein Cron-Feld."""
  11. def __init__(
  12. self,
  13. expression: str,
  14. min_val: int,
  15. max_val: int,
  16. aliases: dict[str, int] | None = None,
  17. ):
  18. """
  19. Initialisiert das Cron-Feld.
  20. Args:
  21. expression: Cron-Ausdruck (z.B. "*/5", "1-10", "1,5,10")
  22. min_val: Minimaler Wert
  23. max_val: Maximaler Wert
  24. aliases: Aliase (z.B. {"MON": 1, "TUE": 2})
  25. """
  26. self._expression = expression
  27. self._min_val = min_val
  28. self._max_val = max_val
  29. self._aliases = aliases or {}
  30. self._values: set[int] = set()
  31. self._parse()
  32. def _parse(self) -> None:
  33. """Parsed den Cron-Ausdruck."""
  34. expr = self._expression.upper()
  35. # Aliase ersetzen
  36. for alias, val in self._aliases.items():
  37. expr = expr.replace(alias, str(val))
  38. # Wildcard
  39. if expr == "*":
  40. self._values = set(range(self._min_val, self._max_val + 1))
  41. return
  42. # Mehrere Werte
  43. for part in expr.split(","):
  44. part = part.strip()
  45. # Range mit Step: */5, 1-10/2
  46. if "/" in part:
  47. range_part, step_part = part.split("/", 1)
  48. step = int(step_part)
  49. if range_part == "*":
  50. start, end = self._min_val, self._max_val
  51. elif "-" in range_part:
  52. start, end = map(int, range_part.split("-", 1))
  53. else:
  54. start = int(range_part)
  55. end = self._max_val
  56. self._values.update(range(start, end + 1, step))
  57. # Range: 1-10
  58. elif "-" in part:
  59. start, end = map(int, part.split("-", 1))
  60. self._values.update(range(start, end + 1))
  61. # Einzelwert
  62. else:
  63. self._values.add(int(part))
  64. # Werte validieren
  65. self._values = {
  66. v for v in self._values
  67. if self._min_val <= v <= self._max_val
  68. }
  69. def matches(self, value: int) -> bool:
  70. """Prüft ob Wert matched."""
  71. return value in self._values
  72. @property
  73. def values(self) -> set[int]:
  74. """Erlaubte Werte."""
  75. return self._values.copy()
  76. class CronExpression:
  77. """Parser für komplette Cron-Ausdrücke."""
  78. # Aliase für Wochentage (0=Sonntag in cron)
  79. DAY_ALIASES = {
  80. "SUN": 0, "MON": 1, "TUE": 2, "WED": 3,
  81. "THU": 4, "FRI": 5, "SAT": 6,
  82. }
  83. # Aliase für Monate
  84. MONTH_ALIASES = {
  85. "JAN": 1, "FEB": 2, "MAR": 3, "APR": 4,
  86. "MAY": 5, "JUN": 6, "JUL": 7, "AUG": 8,
  87. "SEP": 9, "OCT": 10, "NOV": 11, "DEC": 12,
  88. }
  89. def __init__(self, expression: str):
  90. """
  91. Initialisiert den Cron-Ausdruck.
  92. Format: minute hour day_of_month month day_of_week
  93. Erweitert: second minute hour day_of_month month day_of_week
  94. Args:
  95. expression: Cron-Ausdruck
  96. """
  97. self._expression = expression
  98. self._parse()
  99. def _parse(self) -> None:
  100. """Parsed den Cron-Ausdruck."""
  101. parts = self._expression.split()
  102. # 5 Felder: Standard Cron (ohne Sekunden)
  103. if len(parts) == 5:
  104. self._second = CronField("0", 0, 59)
  105. self._minute = CronField(parts[0], 0, 59)
  106. self._hour = CronField(parts[1], 0, 23)
  107. self._day = CronField(parts[2], 1, 31)
  108. self._month = CronField(parts[3], 1, 12, self.MONTH_ALIASES)
  109. self._weekday = CronField(parts[4], 0, 6, self.DAY_ALIASES)
  110. # 6 Felder: Mit Sekunden
  111. elif len(parts) == 6:
  112. self._second = CronField(parts[0], 0, 59)
  113. self._minute = CronField(parts[1], 0, 59)
  114. self._hour = CronField(parts[2], 0, 23)
  115. self._day = CronField(parts[3], 1, 31)
  116. self._month = CronField(parts[4], 1, 12, self.MONTH_ALIASES)
  117. self._weekday = CronField(parts[5], 0, 6, self.DAY_ALIASES)
  118. else:
  119. raise ValueError(
  120. f"Ungültiger Cron-Ausdruck: {self._expression}. "
  121. "Erwartet 5 oder 6 Felder."
  122. )
  123. def matches(self, dt: datetime) -> bool:
  124. """Prüft ob Zeitpunkt matched."""
  125. # Wochentag: Python=0-6 (Mo-So), Cron=0-6 (So-Sa)
  126. cron_weekday = (dt.weekday() + 1) % 7
  127. return (
  128. self._second.matches(dt.second) and
  129. self._minute.matches(dt.minute) and
  130. self._hour.matches(dt.hour) and
  131. self._day.matches(dt.day) and
  132. self._month.matches(dt.month) and
  133. self._weekday.matches(cron_weekday)
  134. )
  135. def get_next(self, after: datetime) -> datetime:
  136. """
  137. Berechnet nächsten Auslösezeitpunkt.
  138. Args:
  139. after: Zeitpunkt ab dem gesucht wird
  140. Returns:
  141. Nächster passender Zeitpunkt
  142. """
  143. from datetime import timedelta
  144. # Zur nächsten Sekunde
  145. current = after.replace(microsecond=0) + timedelta(seconds=1)
  146. # Maximale Suche: 4 Jahre (für Schaltjahre)
  147. max_iterations = 366 * 24 * 60 * 60 * 4
  148. for _ in range(max_iterations):
  149. if self.matches(current):
  150. return current
  151. # Inkrementiere - optimiert für häufige Fälle
  152. if not self._second.matches(current.second):
  153. # Nächste Sekunde
  154. current = current + timedelta(seconds=1)
  155. elif not self._minute.matches(current.minute):
  156. # Nächste Minute
  157. current = current.replace(second=0) + timedelta(minutes=1)
  158. elif not self._hour.matches(current.hour):
  159. # Nächste Stunde
  160. current = current.replace(second=0, minute=0) + timedelta(hours=1)
  161. else:
  162. # Nächster Tag
  163. current = (current + timedelta(days=1)).replace(
  164. hour=0, minute=0, second=0
  165. )
  166. raise ValueError(
  167. f"Kein nächster Zeitpunkt gefunden für: {self._expression}"
  168. )
  169. @property
  170. def expression(self) -> str:
  171. """Original-Ausdruck."""
  172. return self._expression
  173. class CronTrigger(Trigger):
  174. """
  175. Trigger basierend auf Cron-Ausdrücken.
  176. Beispiele:
  177. - "* * * * *" - Jede Minute
  178. - "0 * * * *" - Jede Stunde
  179. - "0 0 * * *" - Jeden Tag um Mitternacht
  180. - "0 0 * * 1" - Jeden Montag um Mitternacht
  181. - "*/5 * * * *" - Alle 5 Minuten
  182. - "0 9-17 * * 1-5" - Stündlich 9-17 Uhr, Mo-Fr
  183. """
  184. def __init__(
  185. self,
  186. expression: str,
  187. name: str | None = None,
  188. timezone: str | None = None,
  189. ):
  190. """
  191. Initialisiert den Cron-Trigger.
  192. Args:
  193. expression: Cron-Ausdruck
  194. name: Trigger-Name
  195. timezone: Zeitzone (optional, default: lokal)
  196. """
  197. super().__init__(name)
  198. self._expression = expression
  199. self._timezone = timezone
  200. self._cron = CronExpression(expression)
  201. @property
  202. def expression(self) -> str:
  203. """Cron-Ausdruck."""
  204. return self._expression
  205. @property
  206. def timezone(self) -> str | None:
  207. """Zeitzone."""
  208. return self._timezone
  209. def should_fire(self, context: TriggerContext) -> bool:
  210. """Prüft ob Trigger auslösen soll."""
  211. if not self.is_active:
  212. return False
  213. if not self._cron.matches(context.current_time):
  214. return False
  215. # Verhindern, dass der Trigger mehrfach in derselben Sekunde feuert
  216. if context.last_fire_time:
  217. last = context.last_fire_time.replace(microsecond=0)
  218. now = context.current_time.replace(microsecond=0)
  219. if now <= last:
  220. return False
  221. return True
  222. def get_next_fire_time(
  223. self,
  224. last_fire_time: datetime | None = None,
  225. ) -> datetime | None:
  226. """Berechnet nächsten Auslösezeitpunkt."""
  227. if not self.is_active:
  228. return None
  229. reference = last_fire_time or datetime.now()
  230. return self._cron.get_next(reference)
  231. def to_dict(self) -> dict:
  232. """Konvertiert zu Dictionary."""
  233. return {
  234. **self._base_dict(),
  235. "expression": self._expression,
  236. "timezone": self._timezone,
  237. }
  238. @classmethod
  239. def from_dict(cls, data: dict) -> "CronTrigger":
  240. """Erstellt Trigger aus Dictionary."""
  241. return cls(
  242. expression=data["expression"],
  243. name=data.get("name"),
  244. timezone=data.get("timezone"),
  245. )
  246. @classmethod
  247. def display_name(cls) -> str:
  248. """Anzeigename fuer die TUI."""
  249. return "Cron"
  250. @classmethod
  251. def form_fields(cls) -> list[FormField]:
  252. """Gibt die Formular-Felder fuer die TUI zurueck."""
  253. return [
  254. FormField(
  255. name="expression", label="Cron-Ausdruck", required=True,
  256. placeholder="*/5 * * * *",
  257. help_text="Format: Minute Stunde Tag Monat Wochentag",
  258. ),
  259. FormField(
  260. name="timezone", label="Zeitzone",
  261. placeholder="Europe/Berlin",
  262. ),
  263. ]
  264. @classmethod
  265. def validate_form(cls, data: dict) -> list[FormValidationError]:
  266. """Validiert Formular-Daten. Gibt Liste von Fehlern zurueck."""
  267. errors: list[FormValidationError] = []
  268. expr = data.get("expression", "").strip()
  269. if not expr:
  270. errors.append(FormValidationError("expression", "Cron-Ausdruck ist erforderlich"))
  271. else:
  272. parts = expr.split()
  273. if len(parts) not in (5, 6):
  274. errors.append(FormValidationError("expression", "Erwartet 5 oder 6 Felder"))
  275. return errors
  276. def __repr__(self) -> str:
  277. return f"CronTrigger(expression={self._expression})"
  278. # Vordefinierte Cron-Ausdrücke
  279. class CronPresets:
  280. """Häufig verwendete Cron-Ausdrücke."""
  281. EVERY_MINUTE = "* * * * *"
  282. EVERY_5_MINUTES = "*/5 * * * *"
  283. EVERY_10_MINUTES = "*/10 * * * *"
  284. EVERY_15_MINUTES = "*/15 * * * *"
  285. EVERY_30_MINUTES = "*/30 * * * *"
  286. EVERY_HOUR = "0 * * * *"
  287. EVERY_2_HOURS = "0 */2 * * *"
  288. EVERY_6_HOURS = "0 */6 * * *"
  289. EVERY_12_HOURS = "0 */12 * * *"
  290. DAILY = "0 0 * * *"
  291. DAILY_NOON = "0 12 * * *"
  292. WEEKLY = "0 0 * * 0"
  293. MONTHLY = "0 0 1 * *"
  294. YEARLY = "0 0 1 1 *"
  295. WEEKDAYS = "0 0 * * 1-5"
  296. WEEKENDS = "0 0 * * 0,6"