| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393 |
- """
- Trigger System for Trixy Scheduler
- This module provides various trigger types that determine when schedule entries
- should execute. All triggers implement a common interface and support multiple
- triggering mechanisms including date/time based triggers, event-based triggers,
- and advanced cron expressions.
- Trigger Types:
- - DateTrigger: Specific dates and times
- - TimeTrigger: Daily, hourly, and other time-based schedules
- - EventTrigger: Triggered by events from the event system
- - WeekdayTrigger: Specific days of the week
- - CronTrigger: Full cron expression support
- - IntervalTrigger: Fixed intervals (every N seconds/minutes/hours)
- - ManualTrigger: Manual execution only
- Key Features:
- - Thread-safe implementations
- - Comprehensive validation
- - Next execution time calculation
- - Integration with Trixy event system
- - Serialization support for persistence
- - Factory pattern for dynamic creation
- Usage:
- from trixy_core.scheduler.triggers import DateTrigger, CronTrigger, EventTrigger
-
- # Date-based trigger (daily at 9 AM)
- date_trigger = DateTrigger(hour=9, minute=0)
-
- # Cron-based trigger (weekdays at 9 AM)
- cron_trigger = CronTrigger("0 9 * * 1-5")
-
- # Event-based trigger
- event_trigger = EventTrigger("wakeword_received", speaker_name="john")
- """
- import time
- import threading
- from abc import ABC, abstractmethod
- from datetime import datetime, timedelta, timezone
- from typing import Dict, Any, Optional, List, Set, Union, Callable
- from dataclasses import dataclass, field
- from enum import Enum
- import calendar
- import re
- from .cron_parser import CronExpression, parse_cron_expression, CronParseError, CronValidationError
- def pprint(message: str) -> None:
- """
- Trigger logging function that adapts based on mode.
- Uses the same pattern as specified in CLAUDE.md.
- """
- print(f"[SCHEDULER.TRIGGERS] {message}")
- class TriggerError(Exception):
- """Base exception for trigger-related errors."""
- pass
- class TriggerValidationError(TriggerError):
- """Raised when trigger validation fails."""
- pass
- class TriggerExecutionError(TriggerError):
- """Raised when trigger execution fails."""
- pass
- class TriggerType(Enum):
- """Types of available triggers."""
- DATE = "date"
- TIME = "time"
- EVENT = "event"
- WEEKDAY = "weekday"
- CRON = "cron"
- INTERVAL = "interval"
- MANUAL = "manual"
- class BaseTrigger(ABC):
- """
- Abstract base class for all triggers.
-
- All trigger implementations must inherit from this class and implement
- the required methods for determining when to fire and calculating
- next execution times.
- """
-
- def __init__(self, name: Optional[str] = None, enabled: bool = True, **kwargs):
- """
- Initialize base trigger.
-
- Args:
- name: Optional name for the trigger
- enabled: Whether the trigger is enabled
- **kwargs: Additional trigger-specific parameters
- """
- self.name = name or f"{self.__class__.__name__}_{id(self)}"
- self.enabled = enabled
- self.created_at = time.time()
- self.last_fired = None
- self.fire_count = 0
- self._lock = threading.RLock()
-
- # Store additional parameters
- self.parameters = kwargs
-
- pprint(f"Created trigger: {self.name} ({self.__class__.__name__})")
-
- @abstractmethod
- def should_fire(self, current_time: float) -> bool:
- """
- Determine if the trigger should fire at the given time.
-
- Args:
- current_time: Unix timestamp to check
-
- Returns:
- bool: True if trigger should fire
- """
- pass
-
- @abstractmethod
- def get_next_fire_time(self, after_time: float) -> Optional[float]:
- """
- Get the next time this trigger will fire.
-
- Args:
- after_time: Find next fire time after this timestamp
-
- Returns:
- Optional[float]: Next fire timestamp or None if no future fires
- """
- pass
-
- def fire(self, execution_context: Optional[Dict[str, Any]] = None) -> None:
- """
- Mark the trigger as fired and update statistics.
-
- Args:
- execution_context: Optional context for the fire event
- """
- with self._lock:
- self.last_fired = time.time()
- self.fire_count += 1
-
- pprint(f"Trigger '{self.name}' fired (count: {self.fire_count})")
-
- def enable(self) -> None:
- """Enable the trigger."""
- with self._lock:
- self.enabled = True
- pprint(f"Trigger '{self.name}' enabled")
-
- def disable(self) -> None:
- """Disable the trigger."""
- with self._lock:
- self.enabled = False
- pprint(f"Trigger '{self.name}' disabled")
-
- def reset(self) -> None:
- """Reset trigger statistics."""
- with self._lock:
- self.last_fired = None
- self.fire_count = 0
- pprint(f"Trigger '{self.name}' reset")
-
- def validate(self) -> List[str]:
- """
- Validate the trigger configuration.
-
- Returns:
- List[str]: List of validation errors (empty if valid)
- """
- errors = []
-
- if not self.name:
- errors.append("Trigger name is required")
-
- return errors
-
- def get_info(self) -> Dict[str, Any]:
- """
- Get information about the trigger.
-
- Returns:
- Dict[str, Any]: Trigger information
- """
- with self._lock:
- next_fire = None
- try:
- next_fire = self.get_next_fire_time(time.time())
- except Exception:
- pass
-
- return {
- 'name': self.name,
- 'type': self.__class__.__name__,
- 'enabled': self.enabled,
- 'created_at': self.created_at,
- 'last_fired': self.last_fired,
- 'fire_count': self.fire_count,
- 'next_fire_time': next_fire,
- 'parameters': self.parameters.copy()
- }
-
- def to_dict(self) -> Dict[str, Any]:
- """
- Convert trigger to dictionary for serialization.
-
- Returns:
- Dict[str, Any]: Dictionary representation
- """
- base_dict = {
- 'type': self.__class__.__name__,
- 'name': self.name,
- 'enabled': self.enabled,
- 'created_at': self.created_at,
- 'last_fired': self.last_fired,
- 'fire_count': self.fire_count
- }
-
- # Add trigger-specific parameters
- base_dict.update(self._get_specific_dict())
-
- return base_dict
-
- @abstractmethod
- def _get_specific_dict(self) -> Dict[str, Any]:
- """
- Get trigger-specific dictionary data.
-
- Returns:
- Dict[str, Any]: Trigger-specific data
- """
- pass
-
- @classmethod
- @abstractmethod
- def from_dict(cls, data: Dict[str, Any]) -> 'BaseTrigger':
- """
- Create trigger from dictionary data.
-
- Args:
- data: Dictionary data
-
- Returns:
- BaseTrigger: Created trigger instance
- """
- pass
-
- def __str__(self) -> str:
- """String representation of the trigger."""
- return f"{self.__class__.__name__}(name='{self.name}', enabled={self.enabled})"
-
- def __repr__(self) -> str:
- """Detailed representation of the trigger."""
- return f"{self.__class__.__name__}(name='{self.name}', enabled={self.enabled}, fires={self.fire_count})"
- class DateTrigger(BaseTrigger):
- """
- Trigger based on specific date and time values.
-
- Supports various date/time specifications:
- - Specific date and time
- - Daily at specific time
- - Monthly on specific day
- - Yearly on specific date
- - Flexible combinations
- """
-
- def __init__(
- self,
- year: Optional[int] = None,
- month: Optional[int] = None,
- day: Optional[int] = None,
- hour: Optional[int] = None,
- minute: Optional[int] = None,
- second: int = 0,
- timezone_offset: Optional[int] = None,
- **kwargs
- ):
- """
- Initialize date trigger.
-
- Args:
- year: Specific year (None for any year)
- month: Specific month 1-12 (None for any month)
- day: Specific day of month 1-31 (None for any day)
- hour: Specific hour 0-23 (None for any hour)
- minute: Specific minute 0-59 (None for any minute)
- second: Specific second 0-59 (default: 0)
- timezone_offset: Timezone offset in hours from UTC (None for local)
- **kwargs: Additional parameters
- """
- super().__init__(**kwargs)
-
- self.year = year
- self.month = month
- self.day = day
- self.hour = hour
- self.minute = minute
- self.second = second
- self.timezone_offset = timezone_offset
-
- # Validate parameters
- self._validate_parameters()
-
- def _validate_parameters(self) -> None:
- """Validate date/time parameters."""
- if self.month is not None and not (1 <= self.month <= 12):
- raise TriggerValidationError(f"Month must be 1-12, got {self.month}")
-
- if self.day is not None and not (1 <= self.day <= 31):
- raise TriggerValidationError(f"Day must be 1-31, got {self.day}")
-
- if self.hour is not None and not (0 <= self.hour <= 23):
- raise TriggerValidationError(f"Hour must be 0-23, got {self.hour}")
-
- if self.minute is not None and not (0 <= self.minute <= 59):
- raise TriggerValidationError(f"Minute must be 0-59, got {self.minute}")
-
- if not (0 <= self.second <= 59):
- raise TriggerValidationError(f"Second must be 0-59, got {self.second}")
-
- if self.timezone_offset is not None and not (-12 <= self.timezone_offset <= 14):
- raise TriggerValidationError(f"Timezone offset must be -12 to +14, got {self.timezone_offset}")
-
- def should_fire(self, current_time: float) -> bool:
- """Check if trigger should fire at current time."""
- if not self.enabled:
- return False
-
- # Convert to datetime
- if self.timezone_offset is not None:
- # Use specific timezone
- tz = timezone(timedelta(hours=self.timezone_offset))
- dt = datetime.fromtimestamp(current_time, tz)
- else:
- # Use local timezone
- dt = datetime.fromtimestamp(current_time)
-
- # Check each specified component
- if self.year is not None and dt.year != self.year:
- return False
-
- if self.month is not None and dt.month != self.month:
- return False
-
- if self.day is not None and dt.day != self.day:
- return False
-
- if self.hour is not None and dt.hour != self.hour:
- return False
-
- if self.minute is not None and dt.minute != self.minute:
- return False
-
- if dt.second != self.second:
- return False
-
- return True
-
- def get_next_fire_time(self, after_time: float) -> Optional[float]:
- """Get next fire time after given time."""
- if not self.enabled:
- return None
-
- # Convert to datetime
- if self.timezone_offset is not None:
- tz = timezone(timedelta(hours=self.timezone_offset))
- dt = datetime.fromtimestamp(after_time, tz)
- else:
- dt = datetime.fromtimestamp(after_time)
-
- # Start from next second
- dt = dt.replace(microsecond=0) + timedelta(seconds=1)
-
- # If specific year/month/day is set and we've passed it, no future fires
- if (self.year is not None and dt.year > self.year):
- return None
-
- if (self.year is not None and dt.year == self.year and
- self.month is not None and dt.month > self.month):
- return None
-
- if (self.year is not None and dt.year == self.year and
- self.month is not None and dt.month == self.month and
- self.day is not None and dt.day > self.day):
- return None
-
- # Find next matching time
- max_iterations = 366 * 24 * 60 # Maximum one year of minutes
- iterations = 0
-
- while iterations < max_iterations:
- # Check if current datetime matches our criteria
- matches = True
-
- if self.year is not None and dt.year != self.year:
- matches = False
- if self.month is not None and dt.month != self.month:
- matches = False
- if self.day is not None and dt.day != self.day:
- matches = False
- if self.hour is not None and dt.hour != self.hour:
- matches = False
- if self.minute is not None and dt.minute != self.minute:
- matches = False
- if dt.second != self.second:
- matches = False
-
- if matches:
- return dt.timestamp()
-
- # Advance time intelligently
- if self.minute is not None and dt.second != self.second:
- # Advance to correct second
- dt = dt.replace(second=self.second)
- if dt.second < self.second:
- dt += timedelta(seconds=self.second - dt.second)
- elif self.hour is not None and dt.minute != self.minute:
- # Advance to correct minute
- target_minute = self.minute if self.minute is not None else 0
- if dt.minute < target_minute:
- dt = dt.replace(minute=target_minute, second=self.second)
- else:
- dt = dt.replace(minute=target_minute, second=self.second) + timedelta(hours=1)
- else:
- # Advance by minimum increment based on what's specified
- if self.hour is None and self.minute is None:
- dt += timedelta(seconds=1) # Every second
- elif self.hour is None:
- dt += timedelta(minutes=1) # Every minute
- else:
- dt += timedelta(hours=1) # Every hour
-
- iterations += 1
-
- return None
-
- def _get_specific_dict(self) -> Dict[str, Any]:
- """Get date trigger specific data."""
- return {
- 'year': self.year,
- 'month': self.month,
- 'day': self.day,
- 'hour': self.hour,
- 'minute': self.minute,
- 'second': self.second,
- 'timezone_offset': self.timezone_offset
- }
-
- @classmethod
- def from_dict(cls, data: Dict[str, Any]) -> 'DateTrigger':
- """Create DateTrigger from dictionary."""
- trigger = cls(
- year=data.get('year'),
- month=data.get('month'),
- day=data.get('day'),
- hour=data.get('hour'),
- minute=data.get('minute'),
- second=data.get('second', 0),
- timezone_offset=data.get('timezone_offset'),
- name=data.get('name'),
- enabled=data.get('enabled', True)
- )
-
- # Restore state
- trigger.created_at = data.get('created_at', trigger.created_at)
- trigger.last_fired = data.get('last_fired')
- trigger.fire_count = data.get('fire_count', 0)
-
- return trigger
- class TimeTrigger(BaseTrigger):
- """
- Trigger based on time intervals and patterns.
-
- Supports:
- - Every N seconds/minutes/hours/days
- - Daily at specific time
- - Hourly at specific minute
- - Custom time patterns
- """
-
- def __init__(
- self,
- interval_seconds: Optional[int] = None,
- daily_at_hour: Optional[int] = None,
- daily_at_minute: Optional[int] = None,
- hourly_at_minute: Optional[int] = None,
- **kwargs
- ):
- """
- Initialize time trigger.
-
- Args:
- interval_seconds: Fire every N seconds
- daily_at_hour: Fire daily at this hour (0-23)
- daily_at_minute: Fire daily at this minute (0-59) - requires daily_at_hour
- hourly_at_minute: Fire hourly at this minute (0-59)
- **kwargs: Additional parameters
- """
- super().__init__(**kwargs)
-
- self.interval_seconds = interval_seconds
- self.daily_at_hour = daily_at_hour
- self.daily_at_minute = daily_at_minute
- self.hourly_at_minute = hourly_at_minute
-
- self._validate_parameters()
-
- def _validate_parameters(self) -> None:
- """Validate time trigger parameters."""
- param_count = sum(1 for p in [self.interval_seconds, self.daily_at_hour, self.hourly_at_minute] if p is not None)
-
- if param_count != 1 and not (self.daily_at_hour is not None and self.daily_at_minute is not None):
- raise TriggerValidationError("Exactly one time pattern must be specified")
-
- if self.interval_seconds is not None and self.interval_seconds <= 0:
- raise TriggerValidationError("Interval seconds must be positive")
-
- if self.daily_at_hour is not None and not (0 <= self.daily_at_hour <= 23):
- raise TriggerValidationError("Daily hour must be 0-23")
-
- if self.daily_at_minute is not None:
- if not (0 <= self.daily_at_minute <= 59):
- raise TriggerValidationError("Daily minute must be 0-59")
- if self.daily_at_hour is None:
- raise TriggerValidationError("Daily minute requires daily hour to be specified")
-
- if self.hourly_at_minute is not None and not (0 <= self.hourly_at_minute <= 59):
- raise TriggerValidationError("Hourly minute must be 0-59")
-
- def should_fire(self, current_time: float) -> bool:
- """Check if trigger should fire at current time."""
- if not self.enabled:
- return False
-
- dt = datetime.fromtimestamp(current_time)
-
- # Interval-based firing
- if self.interval_seconds is not None:
- if self.last_fired is None:
- return True
- return (current_time - self.last_fired) >= self.interval_seconds
-
- # Daily firing
- elif self.daily_at_hour is not None:
- if dt.hour != self.daily_at_hour:
- return False
- if self.daily_at_minute is not None and dt.minute != self.daily_at_minute:
- return False
- return dt.second == 0 # Fire at the start of the minute
-
- # Hourly firing
- elif self.hourly_at_minute is not None:
- return dt.minute == self.hourly_at_minute and dt.second == 0
-
- return False
-
- def get_next_fire_time(self, after_time: float) -> Optional[float]:
- """Get next fire time after given time."""
- if not self.enabled:
- return None
-
- dt = datetime.fromtimestamp(after_time)
-
- # Interval-based
- if self.interval_seconds is not None:
- if self.last_fired is None:
- return after_time
- return self.last_fired + self.interval_seconds
-
- # Daily firing
- elif self.daily_at_hour is not None:
- target_minute = self.daily_at_minute if self.daily_at_minute is not None else 0
-
- # Try today first
- target_dt = dt.replace(hour=self.daily_at_hour, minute=target_minute, second=0, microsecond=0)
- if target_dt > dt:
- return target_dt.timestamp()
-
- # Try tomorrow
- target_dt += timedelta(days=1)
- return target_dt.timestamp()
-
- # Hourly firing
- elif self.hourly_at_minute is not None:
- # Try this hour first
- target_dt = dt.replace(minute=self.hourly_at_minute, second=0, microsecond=0)
- if target_dt > dt:
- return target_dt.timestamp()
-
- # Try next hour
- target_dt += timedelta(hours=1)
- return target_dt.timestamp()
-
- return None
-
- def _get_specific_dict(self) -> Dict[str, Any]:
- """Get time trigger specific data."""
- return {
- 'interval_seconds': self.interval_seconds,
- 'daily_at_hour': self.daily_at_hour,
- 'daily_at_minute': self.daily_at_minute,
- 'hourly_at_minute': self.hourly_at_minute
- }
-
- @classmethod
- def from_dict(cls, data: Dict[str, Any]) -> 'TimeTrigger':
- """Create TimeTrigger from dictionary."""
- trigger = cls(
- interval_seconds=data.get('interval_seconds'),
- daily_at_hour=data.get('daily_at_hour'),
- daily_at_minute=data.get('daily_at_minute'),
- hourly_at_minute=data.get('hourly_at_minute'),
- name=data.get('name'),
- enabled=data.get('enabled', True)
- )
-
- # Restore state
- trigger.created_at = data.get('created_at', trigger.created_at)
- trigger.last_fired = data.get('last_fired')
- trigger.fire_count = data.get('fire_count', 0)
-
- return trigger
- class EventTrigger(BaseTrigger):
- """
- Trigger based on events from the Trixy event system.
-
- Fires when specific events occur, with optional filtering
- based on event data properties.
- """
-
- def __init__(
- self,
- event_names: Union[str, List[str]],
- event_filters: Optional[Dict[str, Any]] = None,
- max_fires_per_minute: Optional[int] = None,
- **kwargs
- ):
- """
- Initialize event trigger.
-
- Args:
- event_names: Event name(s) to listen for
- event_filters: Optional filters for event data (key-value pairs)
- max_fires_per_minute: Maximum number of fires per minute (rate limiting)
- **kwargs: Additional parameters
- """
- super().__init__(**kwargs)
-
- # Normalize event names to list
- if isinstance(event_names, str):
- self.event_names = [event_names]
- else:
- self.event_names = list(event_names)
-
- self.event_filters = event_filters or {}
- self.max_fires_per_minute = max_fires_per_minute
-
- # Rate limiting tracking
- self._fire_times: List[float] = []
-
- # Event system integration
- self._event_handler = None
- self._registered = False
-
- self._validate_parameters()
-
- def _validate_parameters(self) -> None:
- """Validate event trigger parameters."""
- if not self.event_names:
- raise TriggerValidationError("At least one event name must be specified")
-
- for event_name in self.event_names:
- if not isinstance(event_name, str) or not event_name.strip():
- raise TriggerValidationError(f"Invalid event name: {event_name}")
-
- if self.max_fires_per_minute is not None and self.max_fires_per_minute <= 0:
- raise TriggerValidationError("Max fires per minute must be positive")
-
- def set_event_handler(self, event_handler) -> None:
- """Set the event handler for integration."""
- self._event_handler = event_handler
- if event_handler and not self._registered:
- self._register_with_event_system()
-
- def _register_with_event_system(self) -> None:
- """Register with the event system to listen for events."""
- if self._event_handler and not self._registered:
- # Note: In a real implementation, this would use the @TrixyEvent decorator
- # or register a callback with the event handler
- self._registered = True
- pprint(f"EventTrigger '{self.name}' registered for events: {self.event_names}")
-
- def _unregister_with_event_system(self) -> None:
- """Unregister from the event system."""
- if self._event_handler and self._registered:
- # Note: In a real implementation, this would unregister the callback
- self._registered = False
- pprint(f"EventTrigger '{self.name}' unregistered from events")
-
- def handle_event(self, event_name: str, event_data) -> None:
- """
- Handle an event from the event system.
-
- Args:
- event_name: Name of the triggered event
- event_data: Event data object
- """
- if not self.enabled:
- return
-
- if event_name not in self.event_names:
- return
-
- # Check rate limiting
- if self.max_fires_per_minute is not None:
- current_time = time.time()
- # Remove fires older than 1 minute
- cutoff_time = current_time - 60
- self._fire_times = [t for t in self._fire_times if t > cutoff_time]
-
- if len(self._fire_times) >= self.max_fires_per_minute:
- pprint(f"EventTrigger '{self.name}' rate limited")
- return
-
- # Check event filters
- if self.event_filters and event_data:
- for filter_key, filter_value in self.event_filters.items():
- if not hasattr(event_data, filter_key):
- return
-
- actual_value = getattr(event_data, filter_key)
- if actual_value != filter_value:
- return
-
- # Event matches - mark as should fire
- if self.max_fires_per_minute is not None:
- self._fire_times.append(time.time())
-
- pprint(f"EventTrigger '{self.name}' triggered by event '{event_name}'")
- self.fire()
-
- def should_fire(self, current_time: float) -> bool:
- """
- Check if trigger should fire.
-
- Note: Event triggers fire immediately when events occur,
- not based on time checks.
- """
- return False # Event triggers don't fire based on time
-
- def get_next_fire_time(self, after_time: float) -> Optional[float]:
- """
- Get next fire time.
-
- Note: Event triggers can't predict when events will occur.
- """
- return None # Event triggers can't predict next fire time
-
- def _get_specific_dict(self) -> Dict[str, Any]:
- """Get event trigger specific data."""
- return {
- 'event_names': self.event_names,
- 'event_filters': self.event_filters,
- 'max_fires_per_minute': self.max_fires_per_minute
- }
-
- @classmethod
- def from_dict(cls, data: Dict[str, Any]) -> 'EventTrigger':
- """Create EventTrigger from dictionary."""
- trigger = cls(
- event_names=data['event_names'],
- event_filters=data.get('event_filters'),
- max_fires_per_minute=data.get('max_fires_per_minute'),
- name=data.get('name'),
- enabled=data.get('enabled', True)
- )
-
- # Restore state
- trigger.created_at = data.get('created_at', trigger.created_at)
- trigger.last_fired = data.get('last_fired')
- trigger.fire_count = data.get('fire_count', 0)
-
- return trigger
-
- def __del__(self):
- """Cleanup when trigger is destroyed."""
- self._unregister_with_event_system()
- class WeekdayTrigger(BaseTrigger):
- """
- Trigger based on specific days of the week.
-
- Supports triggering on specific weekdays at specific times,
- with flexible day selection and time specification.
- """
-
- def __init__(
- self,
- weekdays: Union[int, List[int], str, List[str]],
- hour: Optional[int] = None,
- minute: Optional[int] = None,
- second: int = 0,
- **kwargs
- ):
- """
- Initialize weekday trigger.
-
- Args:
- weekdays: Day(s) of week (0=Monday, 6=Sunday or names like 'monday')
- hour: Specific hour 0-23 (None for any hour)
- minute: Specific minute 0-59 (None for any minute)
- second: Specific second 0-59 (default: 0)
- **kwargs: Additional parameters
- """
- super().__init__(**kwargs)
-
- # Convert weekdays to standardized format
- self.weekdays = self._normalize_weekdays(weekdays)
- self.hour = hour
- self.minute = minute
- self.second = second
-
- self._validate_parameters()
-
- def _normalize_weekdays(self, weekdays: Union[int, List[int], str, List[str]]) -> Set[int]:
- """
- Normalize weekdays to a set of integers (0=Monday, 6=Sunday).
-
- Args:
- weekdays: Various formats of weekday specification
-
- Returns:
- Set[int]: Normalized weekday numbers
- """
- if isinstance(weekdays, int):
- weekdays = [weekdays]
- elif isinstance(weekdays, str):
- weekdays = [weekdays]
-
- day_names = {
- 'monday': 0, 'tuesday': 1, 'wednesday': 2, 'thursday': 3,
- 'friday': 4, 'saturday': 5, 'sunday': 6,
- 'mon': 0, 'tue': 1, 'wed': 2, 'thu': 3, 'fri': 4, 'sat': 5, 'sun': 6
- }
-
- result = set()
- for day in weekdays:
- if isinstance(day, int):
- if 0 <= day <= 6:
- result.add(day)
- else:
- raise TriggerValidationError(f"Weekday number must be 0-6, got {day}")
- elif isinstance(day, str):
- day_lower = day.lower().strip()
- if day_lower in day_names:
- result.add(day_names[day_lower])
- else:
- raise TriggerValidationError(f"Invalid weekday name: {day}")
- else:
- raise TriggerValidationError(f"Invalid weekday type: {type(day)}")
-
- return result
-
- def _validate_parameters(self) -> None:
- """Validate weekday trigger parameters."""
- if not self.weekdays:
- raise TriggerValidationError("At least one weekday must be specified")
-
- if self.hour is not None and not (0 <= self.hour <= 23):
- raise TriggerValidationError(f"Hour must be 0-23, got {self.hour}")
-
- if self.minute is not None and not (0 <= self.minute <= 59):
- raise TriggerValidationError(f"Minute must be 0-59, got {self.minute}")
-
- if not (0 <= self.second <= 59):
- raise TriggerValidationError(f"Second must be 0-59, got {self.second}")
-
- def should_fire(self, current_time: float) -> bool:
- """Check if trigger should fire at current time."""
- if not self.enabled:
- return False
-
- dt = datetime.fromtimestamp(current_time)
-
- # Check weekday (convert from Monday=0 to Monday=0)
- current_weekday = dt.weekday()
- if current_weekday not in self.weekdays:
- return False
-
- # Check time components
- if self.hour is not None and dt.hour != self.hour:
- return False
-
- if self.minute is not None and dt.minute != self.minute:
- return False
-
- if dt.second != self.second:
- return False
-
- return True
-
- def get_next_fire_time(self, after_time: float) -> Optional[float]:
- """Get next fire time after given time."""
- if not self.enabled:
- return None
-
- dt = datetime.fromtimestamp(after_time)
- dt = dt.replace(microsecond=0) + timedelta(seconds=1)
-
- # Find next matching time
- max_days = 7 # Maximum one week to find next occurrence
-
- for _ in range(max_days * 24 * 60): # Check every minute for a week
- current_weekday = dt.weekday()
-
- # Check if this day/time matches
- if (current_weekday in self.weekdays and
- (self.hour is None or dt.hour == self.hour) and
- (self.minute is None or dt.minute == self.minute) and
- dt.second == self.second):
- return dt.timestamp()
-
- # Advance time intelligently
- if self.minute is not None and dt.second != self.second:
- dt = dt.replace(second=self.second)
- elif self.hour is not None and dt.minute != self.minute:
- target_minute = self.minute if self.minute is not None else 0
- if dt.minute < target_minute:
- dt = dt.replace(minute=target_minute, second=self.second)
- else:
- dt = dt.replace(minute=target_minute, second=self.second) + timedelta(hours=1)
- else:
- # Advance by minimum increment
- if self.hour is None and self.minute is None:
- dt += timedelta(seconds=1)
- elif self.hour is None:
- dt += timedelta(minutes=1)
- else:
- dt += timedelta(hours=1)
-
- return None
-
- def _get_specific_dict(self) -> Dict[str, Any]:
- """Get weekday trigger specific data."""
- return {
- 'weekdays': list(self.weekdays),
- 'hour': self.hour,
- 'minute': self.minute,
- 'second': self.second
- }
-
- @classmethod
- def from_dict(cls, data: Dict[str, Any]) -> 'WeekdayTrigger':
- """Create WeekdayTrigger from dictionary."""
- trigger = cls(
- weekdays=data['weekdays'],
- hour=data.get('hour'),
- minute=data.get('minute'),
- second=data.get('second', 0),
- name=data.get('name'),
- enabled=data.get('enabled', True)
- )
-
- # Restore state
- trigger.created_at = data.get('created_at', trigger.created_at)
- trigger.last_fired = data.get('last_fired')
- trigger.fire_count = data.get('fire_count', 0)
-
- return trigger
- class CronTrigger(BaseTrigger):
- """
- Trigger based on cron expressions.
-
- Supports full standard cron syntax with 5 fields:
- minute hour day_of_month month day_of_week
- """
-
- def __init__(self, cron_expression: str, timezone_offset: Optional[int] = None, **kwargs):
- """
- Initialize cron trigger.
-
- Args:
- cron_expression: Standard 5-field cron expression
- timezone_offset: Timezone offset in hours from UTC (None for local)
- **kwargs: Additional parameters
- """
- super().__init__(**kwargs)
-
- self.cron_expression_str = cron_expression.strip()
- self.timezone_offset = timezone_offset
-
- # Parse the cron expression
- try:
- self.cron_expression = parse_cron_expression(self.cron_expression_str)
- except (CronParseError, CronValidationError) as e:
- raise TriggerValidationError(f"Invalid cron expression '{cron_expression}': {e}")
-
- self._validate_parameters()
-
- def _validate_parameters(self) -> None:
- """Validate cron trigger parameters."""
- if self.timezone_offset is not None and not (-12 <= self.timezone_offset <= 14):
- raise TriggerValidationError(f"Timezone offset must be -12 to +14, got {self.timezone_offset}")
-
- def should_fire(self, current_time: float) -> bool:
- """Check if trigger should fire at current time."""
- if not self.enabled:
- return False
-
- # Adjust time for timezone if specified
- if self.timezone_offset is not None:
- # Convert to specified timezone
- tz = timezone(timedelta(hours=self.timezone_offset))
- adjusted_time = datetime.fromtimestamp(current_time, tz).timestamp()
- else:
- adjusted_time = current_time
-
- return self.cron_expression.matches(adjusted_time)
-
- def get_next_fire_time(self, after_time: float) -> Optional[float]:
- """Get next fire time after given time."""
- if not self.enabled:
- return None
-
- try:
- # Adjust time for timezone if specified
- if self.timezone_offset is not None:
- tz = timezone(timedelta(hours=self.timezone_offset))
- adjusted_time = datetime.fromtimestamp(after_time, tz).timestamp()
- else:
- adjusted_time = after_time
-
- next_time = self.cron_expression.get_next_time(adjusted_time)
-
- # Convert back to UTC if timezone was specified
- if self.timezone_offset is not None:
- # The cron expression already handles timezone, so next_time is in the target timezone
- # Convert back to UTC timestamp
- next_dt = datetime.fromtimestamp(next_time, tz)
- return next_dt.astimezone(timezone.utc).timestamp()
-
- return next_time
-
- except CronValidationError:
- return None
-
- def get_description(self) -> str:
- """Get human-readable description of the cron expression."""
- return self.cron_expression.get_description()
-
- def _get_specific_dict(self) -> Dict[str, Any]:
- """Get cron trigger specific data."""
- return {
- 'cron_expression': self.cron_expression_str,
- 'timezone_offset': self.timezone_offset,
- 'description': self.get_description()
- }
-
- @classmethod
- def from_dict(cls, data: Dict[str, Any]) -> 'CronTrigger':
- """Create CronTrigger from dictionary."""
- trigger = cls(
- cron_expression=data['cron_expression'],
- timezone_offset=data.get('timezone_offset'),
- name=data.get('name'),
- enabled=data.get('enabled', True)
- )
-
- # Restore state
- trigger.created_at = data.get('created_at', trigger.created_at)
- trigger.last_fired = data.get('last_fired')
- trigger.fire_count = data.get('fire_count', 0)
-
- return trigger
- class IntervalTrigger(BaseTrigger):
- """
- Trigger based on fixed intervals.
-
- Fires repeatedly at fixed intervals, with optional start delay
- and maximum fire count.
- """
-
- def __init__(
- self,
- interval_seconds: int,
- start_delay_seconds: int = 0,
- max_fires: Optional[int] = None,
- **kwargs
- ):
- """
- Initialize interval trigger.
-
- Args:
- interval_seconds: Interval between fires in seconds
- start_delay_seconds: Initial delay before first fire
- max_fires: Maximum number of fires (None for unlimited)
- **kwargs: Additional parameters
- """
- super().__init__(**kwargs)
-
- self.interval_seconds = interval_seconds
- self.start_delay_seconds = start_delay_seconds
- self.max_fires = max_fires
-
- # Calculate first fire time
- self.first_fire_time = self.created_at + start_delay_seconds
-
- self._validate_parameters()
-
- def _validate_parameters(self) -> None:
- """Validate interval trigger parameters."""
- if self.interval_seconds <= 0:
- raise TriggerValidationError("Interval seconds must be positive")
-
- if self.start_delay_seconds < 0:
- raise TriggerValidationError("Start delay seconds cannot be negative")
-
- if self.max_fires is not None and self.max_fires <= 0:
- raise TriggerValidationError("Max fires must be positive")
-
- def should_fire(self, current_time: float) -> bool:
- """Check if trigger should fire at current time."""
- if not self.enabled:
- return False
-
- # Check if we've exceeded max fires
- if self.max_fires is not None and self.fire_count >= self.max_fires:
- return False
-
- # Check if we've reached the first fire time
- if current_time < self.first_fire_time:
- return False
-
- # Check if enough time has passed since last fire
- if self.last_fired is None:
- return current_time >= self.first_fire_time
-
- return (current_time - self.last_fired) >= self.interval_seconds
-
- def get_next_fire_time(self, after_time: float) -> Optional[float]:
- """Get next fire time after given time."""
- if not self.enabled:
- return None
-
- # Check if we've exceeded max fires
- if self.max_fires is not None and self.fire_count >= self.max_fires:
- return None
-
- # If we haven't fired yet and first fire time is in the future
- if self.last_fired is None:
- if self.first_fire_time > after_time:
- return self.first_fire_time
- else:
- return after_time + 1 # Fire soon if we missed the first fire time
-
- # Calculate next fire based on last fire
- next_fire = self.last_fired + self.interval_seconds
- if next_fire <= after_time:
- # We're overdue, fire soon
- return after_time + 1
-
- return next_fire
-
- def _get_specific_dict(self) -> Dict[str, Any]:
- """Get interval trigger specific data."""
- return {
- 'interval_seconds': self.interval_seconds,
- 'start_delay_seconds': self.start_delay_seconds,
- 'max_fires': self.max_fires,
- 'first_fire_time': self.first_fire_time
- }
-
- @classmethod
- def from_dict(cls, data: Dict[str, Any]) -> 'IntervalTrigger':
- """Create IntervalTrigger from dictionary."""
- trigger = cls(
- interval_seconds=data['interval_seconds'],
- start_delay_seconds=data.get('start_delay_seconds', 0),
- max_fires=data.get('max_fires'),
- name=data.get('name'),
- enabled=data.get('enabled', True)
- )
-
- # Restore state
- trigger.created_at = data.get('created_at', trigger.created_at)
- trigger.last_fired = data.get('last_fired')
- trigger.fire_count = data.get('fire_count', 0)
- trigger.first_fire_time = data.get('first_fire_time', trigger.first_fire_time)
-
- return trigger
- class ManualTrigger(BaseTrigger):
- """
- Trigger that only fires when manually activated.
-
- Useful for schedules that should only run on-demand.
- """
-
- def __init__(self, **kwargs):
- """
- Initialize manual trigger.
-
- Args:
- **kwargs: Additional parameters
- """
- super().__init__(**kwargs)
- self._manual_fire_requested = False
- self._manual_fire_lock = threading.Lock()
-
- def should_fire(self, current_time: float) -> bool:
- """Check if trigger should fire (only when manually triggered)."""
- if not self.enabled:
- return False
-
- with self._manual_fire_lock:
- if self._manual_fire_requested:
- self._manual_fire_requested = False
- return True
-
- return False
-
- def get_next_fire_time(self, after_time: float) -> Optional[float]:
- """Manual triggers cannot predict next fire time."""
- return None
-
- def trigger_manually(self) -> None:
- """Manually trigger the schedule."""
- with self._manual_fire_lock:
- self._manual_fire_requested = True
- pprint(f"ManualTrigger '{self.name}' manually triggered")
-
- def _get_specific_dict(self) -> Dict[str, Any]:
- """Get manual trigger specific data."""
- return {}
-
- @classmethod
- def from_dict(cls, data: Dict[str, Any]) -> 'ManualTrigger':
- """Create ManualTrigger from dictionary."""
- trigger = cls(
- name=data.get('name'),
- enabled=data.get('enabled', True)
- )
-
- # Restore state
- trigger.created_at = data.get('created_at', trigger.created_at)
- trigger.last_fired = data.get('last_fired')
- trigger.fire_count = data.get('fire_count', 0)
-
- return trigger
- # Trigger factory for dynamic creation
- class TriggerFactory:
- """Factory class for creating triggers from configuration."""
-
- _trigger_classes = {
- 'DateTrigger': DateTrigger,
- 'TimeTrigger': TimeTrigger,
- 'EventTrigger': EventTrigger,
- 'WeekdayTrigger': WeekdayTrigger,
- 'CronTrigger': CronTrigger,
- 'IntervalTrigger': IntervalTrigger,
- 'ManualTrigger': ManualTrigger
- }
-
- @classmethod
- def create_trigger(cls, trigger_type: str, **kwargs) -> BaseTrigger:
- """
- Create a trigger instance.
-
- Args:
- trigger_type: Type of trigger to create
- **kwargs: Trigger-specific parameters
-
- Returns:
- BaseTrigger: Created trigger instance
-
- Raises:
- TriggerValidationError: If trigger type is unknown or parameters are invalid
- """
- if trigger_type not in cls._trigger_classes:
- raise TriggerValidationError(f"Unknown trigger type: {trigger_type}")
-
- trigger_class = cls._trigger_classes[trigger_type]
- return trigger_class(**kwargs)
-
- @classmethod
- def from_dict(cls, data: Dict[str, Any]) -> BaseTrigger:
- """
- Create trigger from dictionary data.
-
- Args:
- data: Dictionary containing trigger configuration
-
- Returns:
- BaseTrigger: Created trigger instance
- """
- trigger_type = data.get('type')
- if not trigger_type:
- raise TriggerValidationError("Trigger type not specified in data")
-
- if trigger_type not in cls._trigger_classes:
- raise TriggerValidationError(f"Unknown trigger type: {trigger_type}")
-
- trigger_class = cls._trigger_classes[trigger_type]
- return trigger_class.from_dict(data)
-
- @classmethod
- def get_supported_types(cls) -> List[str]:
- """Get list of supported trigger types."""
- return list(cls._trigger_classes.keys())
- # Convenience functions
- def create_trigger_from_dict(data: Dict[str, Any]) -> BaseTrigger:
- """
- Create trigger from dictionary data.
-
- Args:
- data: Dictionary containing trigger configuration
-
- Returns:
- BaseTrigger: Created trigger instance
- """
- return TriggerFactory.from_dict(data)
- def get_supported_trigger_types() -> List[str]:
- """Get list of supported trigger types."""
- return TriggerFactory.get_supported_types()
- def validate_trigger_config(config: Dict[str, Any]) -> List[str]:
- """
- Validate trigger configuration.
-
- Args:
- config: Trigger configuration dictionary
-
- Returns:
- List[str]: List of validation errors (empty if valid)
- """
- try:
- trigger = create_trigger_from_dict(config)
- return trigger.validate()
- except Exception as e:
- return [str(e)]
- # Module exports
- __all__ = [
- 'BaseTrigger',
- 'TriggerError',
- 'TriggerValidationError',
- 'TriggerExecutionError',
- 'TriggerType',
- 'DateTrigger',
- 'TimeTrigger',
- 'EventTrigger',
- 'WeekdayTrigger',
- 'CronTrigger',
- 'IntervalTrigger',
- 'ManualTrigger',
- 'TriggerFactory',
- 'create_trigger_from_dict',
- 'get_supported_trigger_types',
- 'validate_trigger_config',
- 'pprint'
- ]
|