triggers.py 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393
  1. """
  2. Trigger System for Trixy Scheduler
  3. This module provides various trigger types that determine when schedule entries
  4. should execute. All triggers implement a common interface and support multiple
  5. triggering mechanisms including date/time based triggers, event-based triggers,
  6. and advanced cron expressions.
  7. Trigger Types:
  8. - DateTrigger: Specific dates and times
  9. - TimeTrigger: Daily, hourly, and other time-based schedules
  10. - EventTrigger: Triggered by events from the event system
  11. - WeekdayTrigger: Specific days of the week
  12. - CronTrigger: Full cron expression support
  13. - IntervalTrigger: Fixed intervals (every N seconds/minutes/hours)
  14. - ManualTrigger: Manual execution only
  15. Key Features:
  16. - Thread-safe implementations
  17. - Comprehensive validation
  18. - Next execution time calculation
  19. - Integration with Trixy event system
  20. - Serialization support for persistence
  21. - Factory pattern for dynamic creation
  22. Usage:
  23. from trixy_core.scheduler.triggers import DateTrigger, CronTrigger, EventTrigger
  24. # Date-based trigger (daily at 9 AM)
  25. date_trigger = DateTrigger(hour=9, minute=0)
  26. # Cron-based trigger (weekdays at 9 AM)
  27. cron_trigger = CronTrigger("0 9 * * 1-5")
  28. # Event-based trigger
  29. event_trigger = EventTrigger("wakeword_received", speaker_name="john")
  30. """
  31. import time
  32. import threading
  33. from abc import ABC, abstractmethod
  34. from datetime import datetime, timedelta, timezone
  35. from typing import Dict, Any, Optional, List, Set, Union, Callable
  36. from dataclasses import dataclass, field
  37. from enum import Enum
  38. import calendar
  39. import re
  40. from .cron_parser import CronExpression, parse_cron_expression, CronParseError, CronValidationError
  41. def pprint(message: str) -> None:
  42. """
  43. Trigger logging function that adapts based on mode.
  44. Uses the same pattern as specified in CLAUDE.md.
  45. """
  46. print(f"[SCHEDULER.TRIGGERS] {message}")
  47. class TriggerError(Exception):
  48. """Base exception for trigger-related errors."""
  49. pass
  50. class TriggerValidationError(TriggerError):
  51. """Raised when trigger validation fails."""
  52. pass
  53. class TriggerExecutionError(TriggerError):
  54. """Raised when trigger execution fails."""
  55. pass
  56. class TriggerType(Enum):
  57. """Types of available triggers."""
  58. DATE = "date"
  59. TIME = "time"
  60. EVENT = "event"
  61. WEEKDAY = "weekday"
  62. CRON = "cron"
  63. INTERVAL = "interval"
  64. MANUAL = "manual"
  65. class BaseTrigger(ABC):
  66. """
  67. Abstract base class for all triggers.
  68. All trigger implementations must inherit from this class and implement
  69. the required methods for determining when to fire and calculating
  70. next execution times.
  71. """
  72. def __init__(self, name: Optional[str] = None, enabled: bool = True, **kwargs):
  73. """
  74. Initialize base trigger.
  75. Args:
  76. name: Optional name for the trigger
  77. enabled: Whether the trigger is enabled
  78. **kwargs: Additional trigger-specific parameters
  79. """
  80. self.name = name or f"{self.__class__.__name__}_{id(self)}"
  81. self.enabled = enabled
  82. self.created_at = time.time()
  83. self.last_fired = None
  84. self.fire_count = 0
  85. self._lock = threading.RLock()
  86. # Store additional parameters
  87. self.parameters = kwargs
  88. pprint(f"Created trigger: {self.name} ({self.__class__.__name__})")
  89. @abstractmethod
  90. def should_fire(self, current_time: float) -> bool:
  91. """
  92. Determine if the trigger should fire at the given time.
  93. Args:
  94. current_time: Unix timestamp to check
  95. Returns:
  96. bool: True if trigger should fire
  97. """
  98. pass
  99. @abstractmethod
  100. def get_next_fire_time(self, after_time: float) -> Optional[float]:
  101. """
  102. Get the next time this trigger will fire.
  103. Args:
  104. after_time: Find next fire time after this timestamp
  105. Returns:
  106. Optional[float]: Next fire timestamp or None if no future fires
  107. """
  108. pass
  109. def fire(self, execution_context: Optional[Dict[str, Any]] = None) -> None:
  110. """
  111. Mark the trigger as fired and update statistics.
  112. Args:
  113. execution_context: Optional context for the fire event
  114. """
  115. with self._lock:
  116. self.last_fired = time.time()
  117. self.fire_count += 1
  118. pprint(f"Trigger '{self.name}' fired (count: {self.fire_count})")
  119. def enable(self) -> None:
  120. """Enable the trigger."""
  121. with self._lock:
  122. self.enabled = True
  123. pprint(f"Trigger '{self.name}' enabled")
  124. def disable(self) -> None:
  125. """Disable the trigger."""
  126. with self._lock:
  127. self.enabled = False
  128. pprint(f"Trigger '{self.name}' disabled")
  129. def reset(self) -> None:
  130. """Reset trigger statistics."""
  131. with self._lock:
  132. self.last_fired = None
  133. self.fire_count = 0
  134. pprint(f"Trigger '{self.name}' reset")
  135. def validate(self) -> List[str]:
  136. """
  137. Validate the trigger configuration.
  138. Returns:
  139. List[str]: List of validation errors (empty if valid)
  140. """
  141. errors = []
  142. if not self.name:
  143. errors.append("Trigger name is required")
  144. return errors
  145. def get_info(self) -> Dict[str, Any]:
  146. """
  147. Get information about the trigger.
  148. Returns:
  149. Dict[str, Any]: Trigger information
  150. """
  151. with self._lock:
  152. next_fire = None
  153. try:
  154. next_fire = self.get_next_fire_time(time.time())
  155. except Exception:
  156. pass
  157. return {
  158. 'name': self.name,
  159. 'type': self.__class__.__name__,
  160. 'enabled': self.enabled,
  161. 'created_at': self.created_at,
  162. 'last_fired': self.last_fired,
  163. 'fire_count': self.fire_count,
  164. 'next_fire_time': next_fire,
  165. 'parameters': self.parameters.copy()
  166. }
  167. def to_dict(self) -> Dict[str, Any]:
  168. """
  169. Convert trigger to dictionary for serialization.
  170. Returns:
  171. Dict[str, Any]: Dictionary representation
  172. """
  173. base_dict = {
  174. 'type': self.__class__.__name__,
  175. 'name': self.name,
  176. 'enabled': self.enabled,
  177. 'created_at': self.created_at,
  178. 'last_fired': self.last_fired,
  179. 'fire_count': self.fire_count
  180. }
  181. # Add trigger-specific parameters
  182. base_dict.update(self._get_specific_dict())
  183. return base_dict
  184. @abstractmethod
  185. def _get_specific_dict(self) -> Dict[str, Any]:
  186. """
  187. Get trigger-specific dictionary data.
  188. Returns:
  189. Dict[str, Any]: Trigger-specific data
  190. """
  191. pass
  192. @classmethod
  193. @abstractmethod
  194. def from_dict(cls, data: Dict[str, Any]) -> 'BaseTrigger':
  195. """
  196. Create trigger from dictionary data.
  197. Args:
  198. data: Dictionary data
  199. Returns:
  200. BaseTrigger: Created trigger instance
  201. """
  202. pass
  203. def __str__(self) -> str:
  204. """String representation of the trigger."""
  205. return f"{self.__class__.__name__}(name='{self.name}', enabled={self.enabled})"
  206. def __repr__(self) -> str:
  207. """Detailed representation of the trigger."""
  208. return f"{self.__class__.__name__}(name='{self.name}', enabled={self.enabled}, fires={self.fire_count})"
  209. class DateTrigger(BaseTrigger):
  210. """
  211. Trigger based on specific date and time values.
  212. Supports various date/time specifications:
  213. - Specific date and time
  214. - Daily at specific time
  215. - Monthly on specific day
  216. - Yearly on specific date
  217. - Flexible combinations
  218. """
  219. def __init__(
  220. self,
  221. year: Optional[int] = None,
  222. month: Optional[int] = None,
  223. day: Optional[int] = None,
  224. hour: Optional[int] = None,
  225. minute: Optional[int] = None,
  226. second: int = 0,
  227. timezone_offset: Optional[int] = None,
  228. **kwargs
  229. ):
  230. """
  231. Initialize date trigger.
  232. Args:
  233. year: Specific year (None for any year)
  234. month: Specific month 1-12 (None for any month)
  235. day: Specific day of month 1-31 (None for any day)
  236. hour: Specific hour 0-23 (None for any hour)
  237. minute: Specific minute 0-59 (None for any minute)
  238. second: Specific second 0-59 (default: 0)
  239. timezone_offset: Timezone offset in hours from UTC (None for local)
  240. **kwargs: Additional parameters
  241. """
  242. super().__init__(**kwargs)
  243. self.year = year
  244. self.month = month
  245. self.day = day
  246. self.hour = hour
  247. self.minute = minute
  248. self.second = second
  249. self.timezone_offset = timezone_offset
  250. # Validate parameters
  251. self._validate_parameters()
  252. def _validate_parameters(self) -> None:
  253. """Validate date/time parameters."""
  254. if self.month is not None and not (1 <= self.month <= 12):
  255. raise TriggerValidationError(f"Month must be 1-12, got {self.month}")
  256. if self.day is not None and not (1 <= self.day <= 31):
  257. raise TriggerValidationError(f"Day must be 1-31, got {self.day}")
  258. if self.hour is not None and not (0 <= self.hour <= 23):
  259. raise TriggerValidationError(f"Hour must be 0-23, got {self.hour}")
  260. if self.minute is not None and not (0 <= self.minute <= 59):
  261. raise TriggerValidationError(f"Minute must be 0-59, got {self.minute}")
  262. if not (0 <= self.second <= 59):
  263. raise TriggerValidationError(f"Second must be 0-59, got {self.second}")
  264. if self.timezone_offset is not None and not (-12 <= self.timezone_offset <= 14):
  265. raise TriggerValidationError(f"Timezone offset must be -12 to +14, got {self.timezone_offset}")
  266. def should_fire(self, current_time: float) -> bool:
  267. """Check if trigger should fire at current time."""
  268. if not self.enabled:
  269. return False
  270. # Convert to datetime
  271. if self.timezone_offset is not None:
  272. # Use specific timezone
  273. tz = timezone(timedelta(hours=self.timezone_offset))
  274. dt = datetime.fromtimestamp(current_time, tz)
  275. else:
  276. # Use local timezone
  277. dt = datetime.fromtimestamp(current_time)
  278. # Check each specified component
  279. if self.year is not None and dt.year != self.year:
  280. return False
  281. if self.month is not None and dt.month != self.month:
  282. return False
  283. if self.day is not None and dt.day != self.day:
  284. return False
  285. if self.hour is not None and dt.hour != self.hour:
  286. return False
  287. if self.minute is not None and dt.minute != self.minute:
  288. return False
  289. if dt.second != self.second:
  290. return False
  291. return True
  292. def get_next_fire_time(self, after_time: float) -> Optional[float]:
  293. """Get next fire time after given time."""
  294. if not self.enabled:
  295. return None
  296. # Convert to datetime
  297. if self.timezone_offset is not None:
  298. tz = timezone(timedelta(hours=self.timezone_offset))
  299. dt = datetime.fromtimestamp(after_time, tz)
  300. else:
  301. dt = datetime.fromtimestamp(after_time)
  302. # Start from next second
  303. dt = dt.replace(microsecond=0) + timedelta(seconds=1)
  304. # If specific year/month/day is set and we've passed it, no future fires
  305. if (self.year is not None and dt.year > self.year):
  306. return None
  307. if (self.year is not None and dt.year == self.year and
  308. self.month is not None and dt.month > self.month):
  309. return None
  310. if (self.year is not None and dt.year == self.year and
  311. self.month is not None and dt.month == self.month and
  312. self.day is not None and dt.day > self.day):
  313. return None
  314. # Find next matching time
  315. max_iterations = 366 * 24 * 60 # Maximum one year of minutes
  316. iterations = 0
  317. while iterations < max_iterations:
  318. # Check if current datetime matches our criteria
  319. matches = True
  320. if self.year is not None and dt.year != self.year:
  321. matches = False
  322. if self.month is not None and dt.month != self.month:
  323. matches = False
  324. if self.day is not None and dt.day != self.day:
  325. matches = False
  326. if self.hour is not None and dt.hour != self.hour:
  327. matches = False
  328. if self.minute is not None and dt.minute != self.minute:
  329. matches = False
  330. if dt.second != self.second:
  331. matches = False
  332. if matches:
  333. return dt.timestamp()
  334. # Advance time intelligently
  335. if self.minute is not None and dt.second != self.second:
  336. # Advance to correct second
  337. dt = dt.replace(second=self.second)
  338. if dt.second < self.second:
  339. dt += timedelta(seconds=self.second - dt.second)
  340. elif self.hour is not None and dt.minute != self.minute:
  341. # Advance to correct minute
  342. target_minute = self.minute if self.minute is not None else 0
  343. if dt.minute < target_minute:
  344. dt = dt.replace(minute=target_minute, second=self.second)
  345. else:
  346. dt = dt.replace(minute=target_minute, second=self.second) + timedelta(hours=1)
  347. else:
  348. # Advance by minimum increment based on what's specified
  349. if self.hour is None and self.minute is None:
  350. dt += timedelta(seconds=1) # Every second
  351. elif self.hour is None:
  352. dt += timedelta(minutes=1) # Every minute
  353. else:
  354. dt += timedelta(hours=1) # Every hour
  355. iterations += 1
  356. return None
  357. def _get_specific_dict(self) -> Dict[str, Any]:
  358. """Get date trigger specific data."""
  359. return {
  360. 'year': self.year,
  361. 'month': self.month,
  362. 'day': self.day,
  363. 'hour': self.hour,
  364. 'minute': self.minute,
  365. 'second': self.second,
  366. 'timezone_offset': self.timezone_offset
  367. }
  368. @classmethod
  369. def from_dict(cls, data: Dict[str, Any]) -> 'DateTrigger':
  370. """Create DateTrigger from dictionary."""
  371. trigger = cls(
  372. year=data.get('year'),
  373. month=data.get('month'),
  374. day=data.get('day'),
  375. hour=data.get('hour'),
  376. minute=data.get('minute'),
  377. second=data.get('second', 0),
  378. timezone_offset=data.get('timezone_offset'),
  379. name=data.get('name'),
  380. enabled=data.get('enabled', True)
  381. )
  382. # Restore state
  383. trigger.created_at = data.get('created_at', trigger.created_at)
  384. trigger.last_fired = data.get('last_fired')
  385. trigger.fire_count = data.get('fire_count', 0)
  386. return trigger
  387. class TimeTrigger(BaseTrigger):
  388. """
  389. Trigger based on time intervals and patterns.
  390. Supports:
  391. - Every N seconds/minutes/hours/days
  392. - Daily at specific time
  393. - Hourly at specific minute
  394. - Custom time patterns
  395. """
  396. def __init__(
  397. self,
  398. interval_seconds: Optional[int] = None,
  399. daily_at_hour: Optional[int] = None,
  400. daily_at_minute: Optional[int] = None,
  401. hourly_at_minute: Optional[int] = None,
  402. **kwargs
  403. ):
  404. """
  405. Initialize time trigger.
  406. Args:
  407. interval_seconds: Fire every N seconds
  408. daily_at_hour: Fire daily at this hour (0-23)
  409. daily_at_minute: Fire daily at this minute (0-59) - requires daily_at_hour
  410. hourly_at_minute: Fire hourly at this minute (0-59)
  411. **kwargs: Additional parameters
  412. """
  413. super().__init__(**kwargs)
  414. self.interval_seconds = interval_seconds
  415. self.daily_at_hour = daily_at_hour
  416. self.daily_at_minute = daily_at_minute
  417. self.hourly_at_minute = hourly_at_minute
  418. self._validate_parameters()
  419. def _validate_parameters(self) -> None:
  420. """Validate time trigger parameters."""
  421. param_count = sum(1 for p in [self.interval_seconds, self.daily_at_hour, self.hourly_at_minute] if p is not None)
  422. if param_count != 1 and not (self.daily_at_hour is not None and self.daily_at_minute is not None):
  423. raise TriggerValidationError("Exactly one time pattern must be specified")
  424. if self.interval_seconds is not None and self.interval_seconds <= 0:
  425. raise TriggerValidationError("Interval seconds must be positive")
  426. if self.daily_at_hour is not None and not (0 <= self.daily_at_hour <= 23):
  427. raise TriggerValidationError("Daily hour must be 0-23")
  428. if self.daily_at_minute is not None:
  429. if not (0 <= self.daily_at_minute <= 59):
  430. raise TriggerValidationError("Daily minute must be 0-59")
  431. if self.daily_at_hour is None:
  432. raise TriggerValidationError("Daily minute requires daily hour to be specified")
  433. if self.hourly_at_minute is not None and not (0 <= self.hourly_at_minute <= 59):
  434. raise TriggerValidationError("Hourly minute must be 0-59")
  435. def should_fire(self, current_time: float) -> bool:
  436. """Check if trigger should fire at current time."""
  437. if not self.enabled:
  438. return False
  439. dt = datetime.fromtimestamp(current_time)
  440. # Interval-based firing
  441. if self.interval_seconds is not None:
  442. if self.last_fired is None:
  443. return True
  444. return (current_time - self.last_fired) >= self.interval_seconds
  445. # Daily firing
  446. elif self.daily_at_hour is not None:
  447. if dt.hour != self.daily_at_hour:
  448. return False
  449. if self.daily_at_minute is not None and dt.minute != self.daily_at_minute:
  450. return False
  451. return dt.second == 0 # Fire at the start of the minute
  452. # Hourly firing
  453. elif self.hourly_at_minute is not None:
  454. return dt.minute == self.hourly_at_minute and dt.second == 0
  455. return False
  456. def get_next_fire_time(self, after_time: float) -> Optional[float]:
  457. """Get next fire time after given time."""
  458. if not self.enabled:
  459. return None
  460. dt = datetime.fromtimestamp(after_time)
  461. # Interval-based
  462. if self.interval_seconds is not None:
  463. if self.last_fired is None:
  464. return after_time
  465. return self.last_fired + self.interval_seconds
  466. # Daily firing
  467. elif self.daily_at_hour is not None:
  468. target_minute = self.daily_at_minute if self.daily_at_minute is not None else 0
  469. # Try today first
  470. target_dt = dt.replace(hour=self.daily_at_hour, minute=target_minute, second=0, microsecond=0)
  471. if target_dt > dt:
  472. return target_dt.timestamp()
  473. # Try tomorrow
  474. target_dt += timedelta(days=1)
  475. return target_dt.timestamp()
  476. # Hourly firing
  477. elif self.hourly_at_minute is not None:
  478. # Try this hour first
  479. target_dt = dt.replace(minute=self.hourly_at_minute, second=0, microsecond=0)
  480. if target_dt > dt:
  481. return target_dt.timestamp()
  482. # Try next hour
  483. target_dt += timedelta(hours=1)
  484. return target_dt.timestamp()
  485. return None
  486. def _get_specific_dict(self) -> Dict[str, Any]:
  487. """Get time trigger specific data."""
  488. return {
  489. 'interval_seconds': self.interval_seconds,
  490. 'daily_at_hour': self.daily_at_hour,
  491. 'daily_at_minute': self.daily_at_minute,
  492. 'hourly_at_minute': self.hourly_at_minute
  493. }
  494. @classmethod
  495. def from_dict(cls, data: Dict[str, Any]) -> 'TimeTrigger':
  496. """Create TimeTrigger from dictionary."""
  497. trigger = cls(
  498. interval_seconds=data.get('interval_seconds'),
  499. daily_at_hour=data.get('daily_at_hour'),
  500. daily_at_minute=data.get('daily_at_minute'),
  501. hourly_at_minute=data.get('hourly_at_minute'),
  502. name=data.get('name'),
  503. enabled=data.get('enabled', True)
  504. )
  505. # Restore state
  506. trigger.created_at = data.get('created_at', trigger.created_at)
  507. trigger.last_fired = data.get('last_fired')
  508. trigger.fire_count = data.get('fire_count', 0)
  509. return trigger
  510. class EventTrigger(BaseTrigger):
  511. """
  512. Trigger based on events from the Trixy event system.
  513. Fires when specific events occur, with optional filtering
  514. based on event data properties.
  515. """
  516. def __init__(
  517. self,
  518. event_names: Union[str, List[str]],
  519. event_filters: Optional[Dict[str, Any]] = None,
  520. max_fires_per_minute: Optional[int] = None,
  521. **kwargs
  522. ):
  523. """
  524. Initialize event trigger.
  525. Args:
  526. event_names: Event name(s) to listen for
  527. event_filters: Optional filters for event data (key-value pairs)
  528. max_fires_per_minute: Maximum number of fires per minute (rate limiting)
  529. **kwargs: Additional parameters
  530. """
  531. super().__init__(**kwargs)
  532. # Normalize event names to list
  533. if isinstance(event_names, str):
  534. self.event_names = [event_names]
  535. else:
  536. self.event_names = list(event_names)
  537. self.event_filters = event_filters or {}
  538. self.max_fires_per_minute = max_fires_per_minute
  539. # Rate limiting tracking
  540. self._fire_times: List[float] = []
  541. # Event system integration
  542. self._event_handler = None
  543. self._registered = False
  544. self._validate_parameters()
  545. def _validate_parameters(self) -> None:
  546. """Validate event trigger parameters."""
  547. if not self.event_names:
  548. raise TriggerValidationError("At least one event name must be specified")
  549. for event_name in self.event_names:
  550. if not isinstance(event_name, str) or not event_name.strip():
  551. raise TriggerValidationError(f"Invalid event name: {event_name}")
  552. if self.max_fires_per_minute is not None and self.max_fires_per_minute <= 0:
  553. raise TriggerValidationError("Max fires per minute must be positive")
  554. def set_event_handler(self, event_handler) -> None:
  555. """Set the event handler for integration."""
  556. self._event_handler = event_handler
  557. if event_handler and not self._registered:
  558. self._register_with_event_system()
  559. def _register_with_event_system(self) -> None:
  560. """Register with the event system to listen for events."""
  561. if self._event_handler and not self._registered:
  562. # Note: In a real implementation, this would use the @TrixyEvent decorator
  563. # or register a callback with the event handler
  564. self._registered = True
  565. pprint(f"EventTrigger '{self.name}' registered for events: {self.event_names}")
  566. def _unregister_with_event_system(self) -> None:
  567. """Unregister from the event system."""
  568. if self._event_handler and self._registered:
  569. # Note: In a real implementation, this would unregister the callback
  570. self._registered = False
  571. pprint(f"EventTrigger '{self.name}' unregistered from events")
  572. def handle_event(self, event_name: str, event_data) -> None:
  573. """
  574. Handle an event from the event system.
  575. Args:
  576. event_name: Name of the triggered event
  577. event_data: Event data object
  578. """
  579. if not self.enabled:
  580. return
  581. if event_name not in self.event_names:
  582. return
  583. # Check rate limiting
  584. if self.max_fires_per_minute is not None:
  585. current_time = time.time()
  586. # Remove fires older than 1 minute
  587. cutoff_time = current_time - 60
  588. self._fire_times = [t for t in self._fire_times if t > cutoff_time]
  589. if len(self._fire_times) >= self.max_fires_per_minute:
  590. pprint(f"EventTrigger '{self.name}' rate limited")
  591. return
  592. # Check event filters
  593. if self.event_filters and event_data:
  594. for filter_key, filter_value in self.event_filters.items():
  595. if not hasattr(event_data, filter_key):
  596. return
  597. actual_value = getattr(event_data, filter_key)
  598. if actual_value != filter_value:
  599. return
  600. # Event matches - mark as should fire
  601. if self.max_fires_per_minute is not None:
  602. self._fire_times.append(time.time())
  603. pprint(f"EventTrigger '{self.name}' triggered by event '{event_name}'")
  604. self.fire()
  605. def should_fire(self, current_time: float) -> bool:
  606. """
  607. Check if trigger should fire.
  608. Note: Event triggers fire immediately when events occur,
  609. not based on time checks.
  610. """
  611. return False # Event triggers don't fire based on time
  612. def get_next_fire_time(self, after_time: float) -> Optional[float]:
  613. """
  614. Get next fire time.
  615. Note: Event triggers can't predict when events will occur.
  616. """
  617. return None # Event triggers can't predict next fire time
  618. def _get_specific_dict(self) -> Dict[str, Any]:
  619. """Get event trigger specific data."""
  620. return {
  621. 'event_names': self.event_names,
  622. 'event_filters': self.event_filters,
  623. 'max_fires_per_minute': self.max_fires_per_minute
  624. }
  625. @classmethod
  626. def from_dict(cls, data: Dict[str, Any]) -> 'EventTrigger':
  627. """Create EventTrigger from dictionary."""
  628. trigger = cls(
  629. event_names=data['event_names'],
  630. event_filters=data.get('event_filters'),
  631. max_fires_per_minute=data.get('max_fires_per_minute'),
  632. name=data.get('name'),
  633. enabled=data.get('enabled', True)
  634. )
  635. # Restore state
  636. trigger.created_at = data.get('created_at', trigger.created_at)
  637. trigger.last_fired = data.get('last_fired')
  638. trigger.fire_count = data.get('fire_count', 0)
  639. return trigger
  640. def __del__(self):
  641. """Cleanup when trigger is destroyed."""
  642. self._unregister_with_event_system()
  643. class WeekdayTrigger(BaseTrigger):
  644. """
  645. Trigger based on specific days of the week.
  646. Supports triggering on specific weekdays at specific times,
  647. with flexible day selection and time specification.
  648. """
  649. def __init__(
  650. self,
  651. weekdays: Union[int, List[int], str, List[str]],
  652. hour: Optional[int] = None,
  653. minute: Optional[int] = None,
  654. second: int = 0,
  655. **kwargs
  656. ):
  657. """
  658. Initialize weekday trigger.
  659. Args:
  660. weekdays: Day(s) of week (0=Monday, 6=Sunday or names like 'monday')
  661. hour: Specific hour 0-23 (None for any hour)
  662. minute: Specific minute 0-59 (None for any minute)
  663. second: Specific second 0-59 (default: 0)
  664. **kwargs: Additional parameters
  665. """
  666. super().__init__(**kwargs)
  667. # Convert weekdays to standardized format
  668. self.weekdays = self._normalize_weekdays(weekdays)
  669. self.hour = hour
  670. self.minute = minute
  671. self.second = second
  672. self._validate_parameters()
  673. def _normalize_weekdays(self, weekdays: Union[int, List[int], str, List[str]]) -> Set[int]:
  674. """
  675. Normalize weekdays to a set of integers (0=Monday, 6=Sunday).
  676. Args:
  677. weekdays: Various formats of weekday specification
  678. Returns:
  679. Set[int]: Normalized weekday numbers
  680. """
  681. if isinstance(weekdays, int):
  682. weekdays = [weekdays]
  683. elif isinstance(weekdays, str):
  684. weekdays = [weekdays]
  685. day_names = {
  686. 'monday': 0, 'tuesday': 1, 'wednesday': 2, 'thursday': 3,
  687. 'friday': 4, 'saturday': 5, 'sunday': 6,
  688. 'mon': 0, 'tue': 1, 'wed': 2, 'thu': 3, 'fri': 4, 'sat': 5, 'sun': 6
  689. }
  690. result = set()
  691. for day in weekdays:
  692. if isinstance(day, int):
  693. if 0 <= day <= 6:
  694. result.add(day)
  695. else:
  696. raise TriggerValidationError(f"Weekday number must be 0-6, got {day}")
  697. elif isinstance(day, str):
  698. day_lower = day.lower().strip()
  699. if day_lower in day_names:
  700. result.add(day_names[day_lower])
  701. else:
  702. raise TriggerValidationError(f"Invalid weekday name: {day}")
  703. else:
  704. raise TriggerValidationError(f"Invalid weekday type: {type(day)}")
  705. return result
  706. def _validate_parameters(self) -> None:
  707. """Validate weekday trigger parameters."""
  708. if not self.weekdays:
  709. raise TriggerValidationError("At least one weekday must be specified")
  710. if self.hour is not None and not (0 <= self.hour <= 23):
  711. raise TriggerValidationError(f"Hour must be 0-23, got {self.hour}")
  712. if self.minute is not None and not (0 <= self.minute <= 59):
  713. raise TriggerValidationError(f"Minute must be 0-59, got {self.minute}")
  714. if not (0 <= self.second <= 59):
  715. raise TriggerValidationError(f"Second must be 0-59, got {self.second}")
  716. def should_fire(self, current_time: float) -> bool:
  717. """Check if trigger should fire at current time."""
  718. if not self.enabled:
  719. return False
  720. dt = datetime.fromtimestamp(current_time)
  721. # Check weekday (convert from Monday=0 to Monday=0)
  722. current_weekday = dt.weekday()
  723. if current_weekday not in self.weekdays:
  724. return False
  725. # Check time components
  726. if self.hour is not None and dt.hour != self.hour:
  727. return False
  728. if self.minute is not None and dt.minute != self.minute:
  729. return False
  730. if dt.second != self.second:
  731. return False
  732. return True
  733. def get_next_fire_time(self, after_time: float) -> Optional[float]:
  734. """Get next fire time after given time."""
  735. if not self.enabled:
  736. return None
  737. dt = datetime.fromtimestamp(after_time)
  738. dt = dt.replace(microsecond=0) + timedelta(seconds=1)
  739. # Find next matching time
  740. max_days = 7 # Maximum one week to find next occurrence
  741. for _ in range(max_days * 24 * 60): # Check every minute for a week
  742. current_weekday = dt.weekday()
  743. # Check if this day/time matches
  744. if (current_weekday in self.weekdays and
  745. (self.hour is None or dt.hour == self.hour) and
  746. (self.minute is None or dt.minute == self.minute) and
  747. dt.second == self.second):
  748. return dt.timestamp()
  749. # Advance time intelligently
  750. if self.minute is not None and dt.second != self.second:
  751. dt = dt.replace(second=self.second)
  752. elif self.hour is not None and dt.minute != self.minute:
  753. target_minute = self.minute if self.minute is not None else 0
  754. if dt.minute < target_minute:
  755. dt = dt.replace(minute=target_minute, second=self.second)
  756. else:
  757. dt = dt.replace(minute=target_minute, second=self.second) + timedelta(hours=1)
  758. else:
  759. # Advance by minimum increment
  760. if self.hour is None and self.minute is None:
  761. dt += timedelta(seconds=1)
  762. elif self.hour is None:
  763. dt += timedelta(minutes=1)
  764. else:
  765. dt += timedelta(hours=1)
  766. return None
  767. def _get_specific_dict(self) -> Dict[str, Any]:
  768. """Get weekday trigger specific data."""
  769. return {
  770. 'weekdays': list(self.weekdays),
  771. 'hour': self.hour,
  772. 'minute': self.minute,
  773. 'second': self.second
  774. }
  775. @classmethod
  776. def from_dict(cls, data: Dict[str, Any]) -> 'WeekdayTrigger':
  777. """Create WeekdayTrigger from dictionary."""
  778. trigger = cls(
  779. weekdays=data['weekdays'],
  780. hour=data.get('hour'),
  781. minute=data.get('minute'),
  782. second=data.get('second', 0),
  783. name=data.get('name'),
  784. enabled=data.get('enabled', True)
  785. )
  786. # Restore state
  787. trigger.created_at = data.get('created_at', trigger.created_at)
  788. trigger.last_fired = data.get('last_fired')
  789. trigger.fire_count = data.get('fire_count', 0)
  790. return trigger
  791. class CronTrigger(BaseTrigger):
  792. """
  793. Trigger based on cron expressions.
  794. Supports full standard cron syntax with 5 fields:
  795. minute hour day_of_month month day_of_week
  796. """
  797. def __init__(self, cron_expression: str, timezone_offset: Optional[int] = None, **kwargs):
  798. """
  799. Initialize cron trigger.
  800. Args:
  801. cron_expression: Standard 5-field cron expression
  802. timezone_offset: Timezone offset in hours from UTC (None for local)
  803. **kwargs: Additional parameters
  804. """
  805. super().__init__(**kwargs)
  806. self.cron_expression_str = cron_expression.strip()
  807. self.timezone_offset = timezone_offset
  808. # Parse the cron expression
  809. try:
  810. self.cron_expression = parse_cron_expression(self.cron_expression_str)
  811. except (CronParseError, CronValidationError) as e:
  812. raise TriggerValidationError(f"Invalid cron expression '{cron_expression}': {e}")
  813. self._validate_parameters()
  814. def _validate_parameters(self) -> None:
  815. """Validate cron trigger parameters."""
  816. if self.timezone_offset is not None and not (-12 <= self.timezone_offset <= 14):
  817. raise TriggerValidationError(f"Timezone offset must be -12 to +14, got {self.timezone_offset}")
  818. def should_fire(self, current_time: float) -> bool:
  819. """Check if trigger should fire at current time."""
  820. if not self.enabled:
  821. return False
  822. # Adjust time for timezone if specified
  823. if self.timezone_offset is not None:
  824. # Convert to specified timezone
  825. tz = timezone(timedelta(hours=self.timezone_offset))
  826. adjusted_time = datetime.fromtimestamp(current_time, tz).timestamp()
  827. else:
  828. adjusted_time = current_time
  829. return self.cron_expression.matches(adjusted_time)
  830. def get_next_fire_time(self, after_time: float) -> Optional[float]:
  831. """Get next fire time after given time."""
  832. if not self.enabled:
  833. return None
  834. try:
  835. # Adjust time for timezone if specified
  836. if self.timezone_offset is not None:
  837. tz = timezone(timedelta(hours=self.timezone_offset))
  838. adjusted_time = datetime.fromtimestamp(after_time, tz).timestamp()
  839. else:
  840. adjusted_time = after_time
  841. next_time = self.cron_expression.get_next_time(adjusted_time)
  842. # Convert back to UTC if timezone was specified
  843. if self.timezone_offset is not None:
  844. # The cron expression already handles timezone, so next_time is in the target timezone
  845. # Convert back to UTC timestamp
  846. next_dt = datetime.fromtimestamp(next_time, tz)
  847. return next_dt.astimezone(timezone.utc).timestamp()
  848. return next_time
  849. except CronValidationError:
  850. return None
  851. def get_description(self) -> str:
  852. """Get human-readable description of the cron expression."""
  853. return self.cron_expression.get_description()
  854. def _get_specific_dict(self) -> Dict[str, Any]:
  855. """Get cron trigger specific data."""
  856. return {
  857. 'cron_expression': self.cron_expression_str,
  858. 'timezone_offset': self.timezone_offset,
  859. 'description': self.get_description()
  860. }
  861. @classmethod
  862. def from_dict(cls, data: Dict[str, Any]) -> 'CronTrigger':
  863. """Create CronTrigger from dictionary."""
  864. trigger = cls(
  865. cron_expression=data['cron_expression'],
  866. timezone_offset=data.get('timezone_offset'),
  867. name=data.get('name'),
  868. enabled=data.get('enabled', True)
  869. )
  870. # Restore state
  871. trigger.created_at = data.get('created_at', trigger.created_at)
  872. trigger.last_fired = data.get('last_fired')
  873. trigger.fire_count = data.get('fire_count', 0)
  874. return trigger
  875. class IntervalTrigger(BaseTrigger):
  876. """
  877. Trigger based on fixed intervals.
  878. Fires repeatedly at fixed intervals, with optional start delay
  879. and maximum fire count.
  880. """
  881. def __init__(
  882. self,
  883. interval_seconds: int,
  884. start_delay_seconds: int = 0,
  885. max_fires: Optional[int] = None,
  886. **kwargs
  887. ):
  888. """
  889. Initialize interval trigger.
  890. Args:
  891. interval_seconds: Interval between fires in seconds
  892. start_delay_seconds: Initial delay before first fire
  893. max_fires: Maximum number of fires (None for unlimited)
  894. **kwargs: Additional parameters
  895. """
  896. super().__init__(**kwargs)
  897. self.interval_seconds = interval_seconds
  898. self.start_delay_seconds = start_delay_seconds
  899. self.max_fires = max_fires
  900. # Calculate first fire time
  901. self.first_fire_time = self.created_at + start_delay_seconds
  902. self._validate_parameters()
  903. def _validate_parameters(self) -> None:
  904. """Validate interval trigger parameters."""
  905. if self.interval_seconds <= 0:
  906. raise TriggerValidationError("Interval seconds must be positive")
  907. if self.start_delay_seconds < 0:
  908. raise TriggerValidationError("Start delay seconds cannot be negative")
  909. if self.max_fires is not None and self.max_fires <= 0:
  910. raise TriggerValidationError("Max fires must be positive")
  911. def should_fire(self, current_time: float) -> bool:
  912. """Check if trigger should fire at current time."""
  913. if not self.enabled:
  914. return False
  915. # Check if we've exceeded max fires
  916. if self.max_fires is not None and self.fire_count >= self.max_fires:
  917. return False
  918. # Check if we've reached the first fire time
  919. if current_time < self.first_fire_time:
  920. return False
  921. # Check if enough time has passed since last fire
  922. if self.last_fired is None:
  923. return current_time >= self.first_fire_time
  924. return (current_time - self.last_fired) >= self.interval_seconds
  925. def get_next_fire_time(self, after_time: float) -> Optional[float]:
  926. """Get next fire time after given time."""
  927. if not self.enabled:
  928. return None
  929. # Check if we've exceeded max fires
  930. if self.max_fires is not None and self.fire_count >= self.max_fires:
  931. return None
  932. # If we haven't fired yet and first fire time is in the future
  933. if self.last_fired is None:
  934. if self.first_fire_time > after_time:
  935. return self.first_fire_time
  936. else:
  937. return after_time + 1 # Fire soon if we missed the first fire time
  938. # Calculate next fire based on last fire
  939. next_fire = self.last_fired + self.interval_seconds
  940. if next_fire <= after_time:
  941. # We're overdue, fire soon
  942. return after_time + 1
  943. return next_fire
  944. def _get_specific_dict(self) -> Dict[str, Any]:
  945. """Get interval trigger specific data."""
  946. return {
  947. 'interval_seconds': self.interval_seconds,
  948. 'start_delay_seconds': self.start_delay_seconds,
  949. 'max_fires': self.max_fires,
  950. 'first_fire_time': self.first_fire_time
  951. }
  952. @classmethod
  953. def from_dict(cls, data: Dict[str, Any]) -> 'IntervalTrigger':
  954. """Create IntervalTrigger from dictionary."""
  955. trigger = cls(
  956. interval_seconds=data['interval_seconds'],
  957. start_delay_seconds=data.get('start_delay_seconds', 0),
  958. max_fires=data.get('max_fires'),
  959. name=data.get('name'),
  960. enabled=data.get('enabled', True)
  961. )
  962. # Restore state
  963. trigger.created_at = data.get('created_at', trigger.created_at)
  964. trigger.last_fired = data.get('last_fired')
  965. trigger.fire_count = data.get('fire_count', 0)
  966. trigger.first_fire_time = data.get('first_fire_time', trigger.first_fire_time)
  967. return trigger
  968. class ManualTrigger(BaseTrigger):
  969. """
  970. Trigger that only fires when manually activated.
  971. Useful for schedules that should only run on-demand.
  972. """
  973. def __init__(self, **kwargs):
  974. """
  975. Initialize manual trigger.
  976. Args:
  977. **kwargs: Additional parameters
  978. """
  979. super().__init__(**kwargs)
  980. self._manual_fire_requested = False
  981. self._manual_fire_lock = threading.Lock()
  982. def should_fire(self, current_time: float) -> bool:
  983. """Check if trigger should fire (only when manually triggered)."""
  984. if not self.enabled:
  985. return False
  986. with self._manual_fire_lock:
  987. if self._manual_fire_requested:
  988. self._manual_fire_requested = False
  989. return True
  990. return False
  991. def get_next_fire_time(self, after_time: float) -> Optional[float]:
  992. """Manual triggers cannot predict next fire time."""
  993. return None
  994. def trigger_manually(self) -> None:
  995. """Manually trigger the schedule."""
  996. with self._manual_fire_lock:
  997. self._manual_fire_requested = True
  998. pprint(f"ManualTrigger '{self.name}' manually triggered")
  999. def _get_specific_dict(self) -> Dict[str, Any]:
  1000. """Get manual trigger specific data."""
  1001. return {}
  1002. @classmethod
  1003. def from_dict(cls, data: Dict[str, Any]) -> 'ManualTrigger':
  1004. """Create ManualTrigger from dictionary."""
  1005. trigger = cls(
  1006. name=data.get('name'),
  1007. enabled=data.get('enabled', True)
  1008. )
  1009. # Restore state
  1010. trigger.created_at = data.get('created_at', trigger.created_at)
  1011. trigger.last_fired = data.get('last_fired')
  1012. trigger.fire_count = data.get('fire_count', 0)
  1013. return trigger
  1014. # Trigger factory for dynamic creation
  1015. class TriggerFactory:
  1016. """Factory class for creating triggers from configuration."""
  1017. _trigger_classes = {
  1018. 'DateTrigger': DateTrigger,
  1019. 'TimeTrigger': TimeTrigger,
  1020. 'EventTrigger': EventTrigger,
  1021. 'WeekdayTrigger': WeekdayTrigger,
  1022. 'CronTrigger': CronTrigger,
  1023. 'IntervalTrigger': IntervalTrigger,
  1024. 'ManualTrigger': ManualTrigger
  1025. }
  1026. @classmethod
  1027. def create_trigger(cls, trigger_type: str, **kwargs) -> BaseTrigger:
  1028. """
  1029. Create a trigger instance.
  1030. Args:
  1031. trigger_type: Type of trigger to create
  1032. **kwargs: Trigger-specific parameters
  1033. Returns:
  1034. BaseTrigger: Created trigger instance
  1035. Raises:
  1036. TriggerValidationError: If trigger type is unknown or parameters are invalid
  1037. """
  1038. if trigger_type not in cls._trigger_classes:
  1039. raise TriggerValidationError(f"Unknown trigger type: {trigger_type}")
  1040. trigger_class = cls._trigger_classes[trigger_type]
  1041. return trigger_class(**kwargs)
  1042. @classmethod
  1043. def from_dict(cls, data: Dict[str, Any]) -> BaseTrigger:
  1044. """
  1045. Create trigger from dictionary data.
  1046. Args:
  1047. data: Dictionary containing trigger configuration
  1048. Returns:
  1049. BaseTrigger: Created trigger instance
  1050. """
  1051. trigger_type = data.get('type')
  1052. if not trigger_type:
  1053. raise TriggerValidationError("Trigger type not specified in data")
  1054. if trigger_type not in cls._trigger_classes:
  1055. raise TriggerValidationError(f"Unknown trigger type: {trigger_type}")
  1056. trigger_class = cls._trigger_classes[trigger_type]
  1057. return trigger_class.from_dict(data)
  1058. @classmethod
  1059. def get_supported_types(cls) -> List[str]:
  1060. """Get list of supported trigger types."""
  1061. return list(cls._trigger_classes.keys())
  1062. # Convenience functions
  1063. def create_trigger_from_dict(data: Dict[str, Any]) -> BaseTrigger:
  1064. """
  1065. Create trigger from dictionary data.
  1066. Args:
  1067. data: Dictionary containing trigger configuration
  1068. Returns:
  1069. BaseTrigger: Created trigger instance
  1070. """
  1071. return TriggerFactory.from_dict(data)
  1072. def get_supported_trigger_types() -> List[str]:
  1073. """Get list of supported trigger types."""
  1074. return TriggerFactory.get_supported_types()
  1075. def validate_trigger_config(config: Dict[str, Any]) -> List[str]:
  1076. """
  1077. Validate trigger configuration.
  1078. Args:
  1079. config: Trigger configuration dictionary
  1080. Returns:
  1081. List[str]: List of validation errors (empty if valid)
  1082. """
  1083. try:
  1084. trigger = create_trigger_from_dict(config)
  1085. return trigger.validate()
  1086. except Exception as e:
  1087. return [str(e)]
  1088. # Module exports
  1089. __all__ = [
  1090. 'BaseTrigger',
  1091. 'TriggerError',
  1092. 'TriggerValidationError',
  1093. 'TriggerExecutionError',
  1094. 'TriggerType',
  1095. 'DateTrigger',
  1096. 'TimeTrigger',
  1097. 'EventTrigger',
  1098. 'WeekdayTrigger',
  1099. 'CronTrigger',
  1100. 'IntervalTrigger',
  1101. 'ManualTrigger',
  1102. 'TriggerFactory',
  1103. 'create_trigger_from_dict',
  1104. 'get_supported_trigger_types',
  1105. 'validate_trigger_config',
  1106. 'pprint'
  1107. ]