meerschaum.utils.schedule

Parse schedules and run functions at their next occurrence.

  1#! /usr/bin/env python3
  2"""Parse schedules and run functions at their next occurrence."""
  3
  4from __future__ import annotations
  5
  6import re
  7import threading
  8import traceback
  9from datetime import date, datetime, timezone, timedelta
 10
 11import meerschaum as mrsm
 12from meerschaum.utils.typing import Callable, Any, Optional, List, Dict
 13from meerschaum.utils.warnings import warn, error
 14
 15
 16STARTING_KEYWORD: str = 'starting'
 17INTERVAL_UNITS: List[str] = ['months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'years']
 18FREQUENCY_ALIASES: Dict[str, str] = {
 19    'daily': 'every 1 day', 'hourly': 'every 1 hour',
 20    'minutely': 'every 1 minute', 'weekly': 'every 1 week',
 21    'monthly': 'every 1 month', 'secondly': 'every 1 second',
 22    'yearly': 'every 1 year',
 23}
 24LOGIC_ALIASES: Dict[str, str] = {
 25    'and': '&', 'or': '|', ' through ': '-', ' thru ': '-', ' - ': '-',
 26}
 27CRON_DAYS_OF_WEEK: List[str] = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
 28CRON_DAYS_OF_WEEK_ALIASES: Dict[str, str] = {
 29    'monday': 'mon', 'tuesday': 'tue', 'tues': 'tue', 'wednesday': 'wed',
 30    'thursday': 'thu', 'thurs': 'thu', 'friday': 'fri', 'saturday': 'sat',
 31    'sunday': 'sun',
 32}
 33CRON_MONTHS: List[str] = [
 34    'jan', 'feb', 'mar', 'apr', 'may', 'jun',
 35    'jul', 'aug', 'sep', 'oct', 'nov', 'dec',
 36]
 37CRON_MONTHS_ALIASES: Dict[str, str] = {
 38    'january': 'jan', 'february': 'feb', 'march': 'mar', 'april': 'apr',
 39    'may': 'may', 'june': 'jun', 'july': 'jul', 'august': 'aug',
 40    'september': 'sep', 'october': 'oct', 'november': 'nov', 'december': 'dec',
 41}
 42SCHEDULE_ALIASES: Dict[str, str] = {
 43    **FREQUENCY_ALIASES, **LOGIC_ALIASES,
 44    **CRON_DAYS_OF_WEEK_ALIASES, **CRON_MONTHS_ALIASES,
 45}
 46
 47
 48class _IntervalTrigger:
 49    def __init__(self, start_time: datetime, **interval: float):
 50        self.start_time = start_time
 51        self._interval = timedelta(**interval)
 52        if self._interval.total_seconds() <= 0:
 53            raise ValueError("The time interval must be positive")
 54        self._last_fire_time = None
 55
 56    def next(self) -> datetime:
 57        self._last_fire_time = (
 58            self.start_time
 59            if self._last_fire_time is None
 60            else self._last_fire_time + self._interval
 61        )
 62        return self._last_fire_time
 63
 64    def next_after(self, after: datetime) -> datetime:
 65        intervals = max(0, ((after - self.start_time) // self._interval) + 1)
 66        self._last_fire_time = self.start_time + (self._interval * intervals)
 67        return self._last_fire_time
 68
 69
 70class _CalendarIntervalTrigger:
 71    """Month/year intervals which retain the original day and skip invalid dates."""
 72
 73    def __init__(self, start_time: datetime, years: int = 0, months: int = 0):
 74        if years < 0 or months < 0 or years == months == 0:
 75            raise ValueError("The calendar interval must be positive")
 76        self.start_time = start_time
 77        self.start_date = start_time.date()
 78        self.timezone = start_time.tzinfo
 79        self.years = years
 80        self.months = months
 81        self._time = start_time.timetz()
 82        self._last_fire_date = None
 83
 84    def next(self) -> datetime:
 85        previous_date = self._last_fire_date
 86        while True:
 87            if previous_date is None:
 88                next_date = self.start_date
 89            else:
 90                year, month = previous_date.year, previous_date.month
 91                while True:
 92                    month += self.months
 93                    year += self.years + ((month - 1) // 12)
 94                    month = ((month - 1) % 12) + 1
 95                    try:
 96                        next_date = date(year, month, previous_date.day)
 97                    except ValueError:
 98                        continue
 99                    break
100            candidate = datetime.fromtimestamp(
101                datetime.combine(next_date, self._time).timestamp(), self.timezone,
102            )
103            if candidate.timetz() != self._time:
104                previous_date = candidate.date()
105                continue
106            self._last_fire_date = next_date
107            return candidate
108
109    def next_after(self, after: datetime) -> datetime:
110        self._last_fire_date = None
111        candidate = self.next()
112        while candidate <= after:
113            candidate = self.next()
114        return candidate
115
116
117def _expand_cron_field(
118    expression: str,
119    minimum: int,
120    maximum: int,
121    names: Optional[List[str]] = None,
122) -> set[int]:
123    values = set()
124    names_map = {name: i + minimum for i, name in enumerate(names or [])}
125
126    def as_int(value: str) -> int:
127        return names_map.get(value.lower(), int(value) if value.lstrip('-').isdigit() else -1)
128
129    for part in expression.lower().split(','):
130        range_part, separator, step_str = part.partition('/')
131        step = int(step_str) if step_str else 1
132        if step <= 0:
133            raise ValueError(f"Invalid cron step '{step}'.")
134        if range_part == '*':
135            start, stop = minimum, maximum
136        elif '-' in range_part:
137            start_str, stop_str = range_part.split('-', 1)
138            start, stop = as_int(start_str), as_int(stop_str)
139        else:
140            start = as_int(range_part)
141            stop = maximum if separator else start
142        if start < minimum or stop > maximum or start > stop:
143            raise ValueError(f"Invalid cron field '{expression}'.")
144        values.update(range(start, stop + 1, step))
145    return values
146
147
148def _expand_cron_weekdays(expression: str) -> set[int]:
149    """Return Python weekdays while retaining cron's 0/7 = Sunday convention."""
150    values = set()
151    names_map = {
152        'sun': 0,
153        **{name: i + 1 for i, name in enumerate(CRON_DAYS_OF_WEEK[:-1])},
154    }
155
156    def as_cron_int(value: str) -> int:
157        return names_map.get(
158            value.lower(),
159            int(value) if value.isdigit() else -1,
160        )
161
162    for part in expression.lower().split(','):
163        range_part, separator, step_str = part.partition('/')
164        step = int(step_str) if separator else 1
165        if range_part == '*':
166            cron_values = range(0, 7, step)
167        else:
168            endpoints = [as_cron_int(value) for value in range_part.split('-', 1)]
169            if (
170                any(value < 0 or value > 7 for value in endpoints)
171                or step <= 0
172                or (len(endpoints) == 2 and endpoints[0] > endpoints[1])
173            ):
174                raise ValueError(f"Invalid cron field '{expression}'.")
175            cron_values = (
176                range(endpoints[0], endpoints[1] + 1, step)
177                if len(endpoints) == 2
178                else (range(endpoints[0], 8, step) if separator else endpoints)
179            )
180        if step <= 0:
181            raise ValueError(f"Invalid cron field '{expression}'.")
182        values.update(6 if value in (0, 7) else value - 1 for value in cron_values)
183    return values
184
185
186class _CronTrigger:
187    """The five-field cron subset accepted by :func:`parse_schedule`."""
188
189    def __init__(
190        self,
191        start_time: datetime,
192        minute: str = '*',
193        hour: str = '*',
194        day: str = '*',
195        month: str = '*',
196        day_of_week: str = '*',
197        year: str = '*',
198        second: str = '0',
199        microsecond: int = 0,
200    ):
201        self.start_time = start_time
202        self.timezone = start_time.tzinfo
203        self._minutes = _expand_cron_field(str(minute), 0, 59)
204        self._hours = _expand_cron_field(str(hour), 0, 23)
205        self._days = _expand_cron_field(str(day), 1, 31)
206        self._days_have_wildcard = '*' in str(day)
207        self._months = _expand_cron_field(str(month), 1, 12, CRON_MONTHS)
208        self._weekdays = _expand_cron_weekdays(str(day_of_week))
209        self._weekdays_have_wildcard = '*' in str(day_of_week)
210        self._years = None if str(year) == '*' else _expand_cron_field(str(year), 1, 9999)
211        self._seconds = _expand_cron_field(str(second), 0, 59)
212        self._microsecond = microsecond
213        self._step_seconds = 1 if len(self._seconds) > 1 else 60
214        self._last_fire_time = None
215
216    def _matches(self, candidate: datetime) -> bool:
217        return (
218            self._date_matches(candidate)
219            and candidate.hour in self._hours
220            and candidate.minute in self._minutes
221            and candidate.second in self._seconds
222        )
223
224    def _date_matches(self, candidate: datetime) -> bool:
225        day_matches = candidate.day in self._days
226        weekday_matches = candidate.weekday() in self._weekdays
227        if self._days_have_wildcard or self._weekdays_have_wildcard:
228            day_matches = day_matches and weekday_matches
229        else:
230            day_matches = day_matches or weekday_matches
231        return (
232            (self._years is None or candidate.year in self._years)
233            and candidate.month in self._months
234            and day_matches
235        )
236
237    def next(self) -> Optional[datetime]:
238        if self._last_fire_time is None:
239            candidate = self.start_time.replace(microsecond=self._microsecond)
240            if self._step_seconds == 60:
241                candidate = candidate.replace(second=min(self._seconds))
242            if candidate < self.start_time:
243                candidate = datetime.fromtimestamp(
244                    candidate.timestamp() + self._step_seconds,
245                    self.timezone,
246                )
247        else:
248            candidate = datetime.fromtimestamp(
249                self._last_fire_time.timestamp() + self._step_seconds, self.timezone,
250            )
251
252        # ponytail: skip rejected dates but keep simple stepping within an eligible day.
253        max_iterations = 11 * 366 * 24 * 60 * (60 if self._step_seconds == 1 else 1)
254        for _ in range(max_iterations):
255            if self._matches(candidate):
256                self._last_fire_time = candidate
257                return candidate
258            if not self._date_matches(candidate):
259                candidate = datetime.fromtimestamp(
260                    datetime.combine(
261                        candidate.date() + timedelta(days=1),
262                        candidate.timetz(),
263                    ).timestamp(),
264                    self.timezone,
265                )
266                continue
267            candidate = datetime.fromtimestamp(
268                candidate.timestamp() + self._step_seconds, self.timezone,
269            )
270        return None
271
272    def next_after(self, after: datetime) -> Optional[datetime]:
273        if after < self.start_time:
274            self._last_fire_time = None
275            return self.next()
276        after = after.astimezone(self.timezone)
277        candidate = after.replace(microsecond=self._microsecond)
278        if self._step_seconds == 60:
279            candidate = candidate.replace(second=min(self._seconds))
280        if candidate <= after:
281            candidate = datetime.fromtimestamp(
282                candidate.timestamp() + self._step_seconds,
283                self.timezone,
284            )
285        self._last_fire_time = datetime.fromtimestamp(
286            candidate.timestamp() - self._step_seconds,
287            self.timezone,
288        )
289        return self.next()
290
291
292class _OrTrigger:
293    def __init__(self, triggers):
294        self.triggers = triggers
295        self._next_fire_times = []
296
297    def next(self) -> Optional[datetime]:
298        if not self._next_fire_times:
299            self._next_fire_times = [trigger.next() for trigger in self.triggers]
300        earliest = min((ts for ts in self._next_fire_times if ts is not None), default=None)
301        if earliest is not None:
302            for i, fire_time in enumerate(self._next_fire_times):
303                if fire_time == earliest:
304                    self._next_fire_times[i] = self.triggers[i].next()
305        return earliest
306
307    def next_after(self, after: datetime) -> Optional[datetime]:
308        self._next_fire_times = [trigger.next_after(after) for trigger in self.triggers]
309        return self.next()
310
311
312class _AndTrigger:
313    def __init__(self, triggers, max_iterations: int = 1_000_000):
314        self.triggers = triggers
315        self.max_iterations = max_iterations
316        self._next_fire_times = []
317
318    def next(self) -> Optional[datetime]:
319        if not self._next_fire_times:
320            self._next_fire_times = [trigger.next() for trigger in self.triggers]
321        for _ in range(self.max_iterations):
322            if any(ts is None for ts in self._next_fire_times):
323                return None
324            earliest, latest = min(self._next_fire_times), max(self._next_fire_times)
325            for i, fire_time in enumerate(self._next_fire_times):
326                if fire_time == earliest:
327                    self._next_fire_times[i] = self.triggers[i].next()
328            if latest == earliest:
329                return earliest
330        raise RuntimeError("Maximum iterations reached while combining schedules.")
331
332    def next_after(self, after: datetime) -> Optional[datetime]:
333        self._next_fire_times = [trigger.next_after(after) for trigger in self.triggers]
334        return self.next()
335
336
337class _Scheduler:
338    def __init__(self):
339        self.stop_event = threading.Event()
340
341    async def stop(self):
342        self.stop_event.set()
343
344    async def wait_until_stopped(self):
345        return None
346
347
348_scheduler = None
349def schedule_function(
350    function: Callable[[Any], Any],
351    schedule: str,
352    *args,
353    debug: bool = False,
354    **kw
355) -> mrsm.SuccessTuple:
356    """Block the process and execute ``function`` according to ``schedule``."""
357    from meerschaum.utils.misc import filter_keywords
358
359    global _scheduler
360    kw['debug'] = debug
361    kw = filter_keywords(function, **kw)
362    trigger = parse_schedule(schedule, now=datetime.now(timezone.utc))
363    scheduler = _scheduler = _Scheduler()
364    pending_next_time = None
365    schedule_finished = False
366    try:
367        while not scheduler.stop_event.is_set():
368            next_time = pending_next_time or trigger.next()
369            pending_next_time = None
370            if next_time is None:
371                break
372            now = datetime.now(next_time.tzinfo or timezone.utc)
373            if next_time <= now:
374                pending_next_time = trigger.next_after(now)
375                schedule_finished = pending_next_time is None
376            if scheduler.stop_event.wait(max(0.0, (next_time - now).total_seconds())):
377                break
378            try:
379                function(*args, **kw)
380            except Exception:
381                warn(f"Scheduled function failed:\n{traceback.format_exc()}", stack=False)
382            if schedule_finished:
383                break
384    except (KeyboardInterrupt, SystemExit):
385        scheduler.stop_event.set()
386    return True, "Success"
387
388
389def parse_schedule(schedule: str, now: Optional[datetime] = None):
390    """Parse a schedule string into a stateful object with a ``next()`` method."""
391    from meerschaum.utils.misc import items_str, is_int
392
393    schedule = _canonicalize_starting_keyword(schedule)
394    starting_ts = parse_start_time(schedule, now=now)
395    schedule = schedule.split(STARTING_KEYWORD, maxsplit=1)[0].strip().lower()
396    for alias_keyword, true_keyword in SCHEDULE_ALIASES.items():
397        schedule = schedule.replace(alias_keyword, true_keyword)
398    if '&' in schedule and '|' in schedule:
399        raise ValueError("Cannot accept both 'and' + 'or' logic in the schedule frequency.")
400
401    join_str = '|' if '|' in schedule else '&'
402    schedule_parts = [part.strip() for part in schedule.split(join_str)]
403    triggers = []
404    has_seconds = 'second' in schedule
405    has_minutes = 'minute' in schedule
406    for schedule_part in schedule_parts:
407        if schedule_part.lower().startswith('every '):
408            schedule_num_str, schedule_unit = schedule_part[len('every '):].split(' ', maxsplit=1)
409            schedule_unit = schedule_unit.rstrip('s') + 's'
410            if schedule_unit not in INTERVAL_UNITS:
411                raise ValueError(
412                    f"Invalid interval '{schedule_unit}'.\n"
413                    + f"    Accepted values are {items_str(INTERVAL_UNITS)}."
414                )
415            schedule_num = int(schedule_num_str) if is_int(schedule_num_str) else float(schedule_num_str)
416            if schedule_unit in ('months', 'years'):
417                if not float(schedule_num).is_integer():
418                    raise ValueError(f"Calendar interval '{schedule_num}' must be an integer.")
419                trigger = _CalendarIntervalTrigger(
420                    starting_ts, **{schedule_unit: int(schedule_num)},
421                )
422            else:
423                trigger = _IntervalTrigger(starting_ts, **{schedule_unit: schedule_num})
424        else:
425            first_three_prefix = schedule_part[:3].lower()
426            first_four_prefix = schedule_part[:4].lower()
427            cron_kw = {}
428            if first_three_prefix in CRON_DAYS_OF_WEEK:
429                cron_kw['day_of_week'] = schedule_part
430            elif first_three_prefix in CRON_MONTHS:
431                cron_kw['month'] = schedule_part
432            elif is_int(first_four_prefix) and len(first_four_prefix) == 4:
433                cron_kw['year'] = schedule_part
434            if cron_kw:
435                trigger = _CronTrigger(
436                    starting_ts,
437                    **cron_kw,
438                    hour='*',
439                    minute='*' if (has_minutes or has_seconds) else str(starting_ts.minute),
440                    second='*' if has_seconds else str(starting_ts.second),
441                    microsecond=starting_ts.microsecond,
442                )
443            else:
444                cron_parts = schedule_part.split()
445                if len(cron_parts) != 5:
446                    raise ValueError(f"Invalid cron schedule '{schedule_part}'.")
447                trigger = _CronTrigger(
448                    starting_ts,
449                    minute=cron_parts[0], hour=cron_parts[1], day=cron_parts[2],
450                    month=cron_parts[3], day_of_week=cron_parts[4],
451                )
452        triggers.append(trigger)
453
454    if len(triggers) == 1:
455        return triggers[0]
456    return _OrTrigger(triggers) if join_str == '|' else _AndTrigger(triggers)
457
458
459def parse_start_time(schedule: str, now: Optional[datetime] = None) -> datetime:
460    """Return the explicit starting datetime in ``schedule``, or ``now``."""
461    from meerschaum.utils.dtypes import round_time
462    dateutil_parser = mrsm.attempt_import('dateutil.parser')
463    schedule = _canonicalize_starting_keyword(schedule)
464    starting_parts = schedule.split(STARTING_KEYWORD)
465    starting_str = ('now' if len(starting_parts) == 1 else starting_parts[-1]).strip()
466    now = now or datetime.now(timezone.utc)
467    try:
468        if starting_str == 'now':
469            starting_ts = now
470        elif starting_str.startswith('in '):
471            delta_vals = starting_str.replace('in ', '').split(' ', maxsplit=1)
472            delta_unit = delta_vals[-1].rstrip('s') + 's'
473            delta_num = float(delta_vals[0])
474            starting_ts = now + timedelta(**{delta_unit: delta_num})
475        elif 'tomorrow' in starting_str or 'today' in starting_str:
476            today = round_time(now, timedelta(days=1))
477            tomorrow = today + timedelta(days=1)
478            is_tomorrow = 'tomorrow' in starting_str
479            time_str = starting_str.replace('tomorrow', '').replace('today', '').strip()
480            time_ts = dateutil_parser.parse(time_str) if time_str else today
481            starting_ts = (
482                (tomorrow if is_tomorrow else today)
483                + timedelta(hours=time_ts.hour)
484                + timedelta(minutes=time_ts.minute)
485            )
486        else:
487            starting_ts = dateutil_parser.parse(starting_str)
488        schedule_parse_error = None
489    except Exception as e:
490        warn(f"Unable to parse starting time from '{starting_str}'.", stack=False)
491        schedule_parse_error = str(e)
492    if schedule_parse_error:
493        error(schedule_parse_error, ValueError, stack=False)
494    if not starting_ts.tzinfo:
495        starting_ts = starting_ts.replace(tzinfo=timezone.utc)
496    return starting_ts
497
498
499def _canonicalize_starting_keyword(schedule: str) -> str:
500    return re.sub(
501        rf'\b(?:{STARTING_KEYWORD}|beginning)\b',
502        STARTING_KEYWORD,
503        schedule,
504        flags=re.IGNORECASE,
505    )
506
507
508async def _stop_scheduler():
509    if _scheduler is None:
510        return
511    await _scheduler.stop()
512    await _scheduler.wait_until_stopped()
STARTING_KEYWORD: str = 'starting'
INTERVAL_UNITS: List[str] = ['months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'years']
FREQUENCY_ALIASES: Dict[str, str] = {'daily': 'every 1 day', 'hourly': 'every 1 hour', 'minutely': 'every 1 minute', 'weekly': 'every 1 week', 'monthly': 'every 1 month', 'secondly': 'every 1 second', 'yearly': 'every 1 year'}
LOGIC_ALIASES: Dict[str, str] = {'and': '&', 'or': '|', ' through ': '-', ' thru ': '-', ' - ': '-'}
CRON_DAYS_OF_WEEK: List[str] = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
CRON_DAYS_OF_WEEK_ALIASES: Dict[str, str] = {'monday': 'mon', 'tuesday': 'tue', 'tues': 'tue', 'wednesday': 'wed', 'thursday': 'thu', 'thurs': 'thu', 'friday': 'fri', 'saturday': 'sat', 'sunday': 'sun'}
CRON_MONTHS: List[str] = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
CRON_MONTHS_ALIASES: Dict[str, str] = {'january': 'jan', 'february': 'feb', 'march': 'mar', 'april': 'apr', 'may': 'may', 'june': 'jun', 'july': 'jul', 'august': 'aug', 'september': 'sep', 'october': 'oct', 'november': 'nov', 'december': 'dec'}
SCHEDULE_ALIASES: Dict[str, str] = {'daily': 'every 1 day', 'hourly': 'every 1 hour', 'minutely': 'every 1 minute', 'weekly': 'every 1 week', 'monthly': 'every 1 month', 'secondly': 'every 1 second', 'yearly': 'every 1 year', 'and': '&', 'or': '|', ' through ': '-', ' thru ': '-', ' - ': '-', 'monday': 'mon', 'tuesday': 'tue', 'tues': 'tue', 'wednesday': 'wed', 'thursday': 'thu', 'thurs': 'thu', 'friday': 'fri', 'saturday': 'sat', 'sunday': 'sun', 'january': 'jan', 'february': 'feb', 'march': 'mar', 'april': 'apr', 'may': 'may', 'june': 'jun', 'july': 'jul', 'august': 'aug', 'september': 'sep', 'october': 'oct', 'november': 'nov', 'december': 'dec'}
def schedule_function( function: Callable[[Any], Any], schedule: str, *args, debug: bool = False, **kw) -> Tuple[bool, str]:
350def schedule_function(
351    function: Callable[[Any], Any],
352    schedule: str,
353    *args,
354    debug: bool = False,
355    **kw
356) -> mrsm.SuccessTuple:
357    """Block the process and execute ``function`` according to ``schedule``."""
358    from meerschaum.utils.misc import filter_keywords
359
360    global _scheduler
361    kw['debug'] = debug
362    kw = filter_keywords(function, **kw)
363    trigger = parse_schedule(schedule, now=datetime.now(timezone.utc))
364    scheduler = _scheduler = _Scheduler()
365    pending_next_time = None
366    schedule_finished = False
367    try:
368        while not scheduler.stop_event.is_set():
369            next_time = pending_next_time or trigger.next()
370            pending_next_time = None
371            if next_time is None:
372                break
373            now = datetime.now(next_time.tzinfo or timezone.utc)
374            if next_time <= now:
375                pending_next_time = trigger.next_after(now)
376                schedule_finished = pending_next_time is None
377            if scheduler.stop_event.wait(max(0.0, (next_time - now).total_seconds())):
378                break
379            try:
380                function(*args, **kw)
381            except Exception:
382                warn(f"Scheduled function failed:\n{traceback.format_exc()}", stack=False)
383            if schedule_finished:
384                break
385    except (KeyboardInterrupt, SystemExit):
386        scheduler.stop_event.set()
387    return True, "Success"

Block the process and execute function according to schedule.

def parse_schedule(schedule: str, now: Optional[datetime.datetime] = None):
390def parse_schedule(schedule: str, now: Optional[datetime] = None):
391    """Parse a schedule string into a stateful object with a ``next()`` method."""
392    from meerschaum.utils.misc import items_str, is_int
393
394    schedule = _canonicalize_starting_keyword(schedule)
395    starting_ts = parse_start_time(schedule, now=now)
396    schedule = schedule.split(STARTING_KEYWORD, maxsplit=1)[0].strip().lower()
397    for alias_keyword, true_keyword in SCHEDULE_ALIASES.items():
398        schedule = schedule.replace(alias_keyword, true_keyword)
399    if '&' in schedule and '|' in schedule:
400        raise ValueError("Cannot accept both 'and' + 'or' logic in the schedule frequency.")
401
402    join_str = '|' if '|' in schedule else '&'
403    schedule_parts = [part.strip() for part in schedule.split(join_str)]
404    triggers = []
405    has_seconds = 'second' in schedule
406    has_minutes = 'minute' in schedule
407    for schedule_part in schedule_parts:
408        if schedule_part.lower().startswith('every '):
409            schedule_num_str, schedule_unit = schedule_part[len('every '):].split(' ', maxsplit=1)
410            schedule_unit = schedule_unit.rstrip('s') + 's'
411            if schedule_unit not in INTERVAL_UNITS:
412                raise ValueError(
413                    f"Invalid interval '{schedule_unit}'.\n"
414                    + f"    Accepted values are {items_str(INTERVAL_UNITS)}."
415                )
416            schedule_num = int(schedule_num_str) if is_int(schedule_num_str) else float(schedule_num_str)
417            if schedule_unit in ('months', 'years'):
418                if not float(schedule_num).is_integer():
419                    raise ValueError(f"Calendar interval '{schedule_num}' must be an integer.")
420                trigger = _CalendarIntervalTrigger(
421                    starting_ts, **{schedule_unit: int(schedule_num)},
422                )
423            else:
424                trigger = _IntervalTrigger(starting_ts, **{schedule_unit: schedule_num})
425        else:
426            first_three_prefix = schedule_part[:3].lower()
427            first_four_prefix = schedule_part[:4].lower()
428            cron_kw = {}
429            if first_three_prefix in CRON_DAYS_OF_WEEK:
430                cron_kw['day_of_week'] = schedule_part
431            elif first_three_prefix in CRON_MONTHS:
432                cron_kw['month'] = schedule_part
433            elif is_int(first_four_prefix) and len(first_four_prefix) == 4:
434                cron_kw['year'] = schedule_part
435            if cron_kw:
436                trigger = _CronTrigger(
437                    starting_ts,
438                    **cron_kw,
439                    hour='*',
440                    minute='*' if (has_minutes or has_seconds) else str(starting_ts.minute),
441                    second='*' if has_seconds else str(starting_ts.second),
442                    microsecond=starting_ts.microsecond,
443                )
444            else:
445                cron_parts = schedule_part.split()
446                if len(cron_parts) != 5:
447                    raise ValueError(f"Invalid cron schedule '{schedule_part}'.")
448                trigger = _CronTrigger(
449                    starting_ts,
450                    minute=cron_parts[0], hour=cron_parts[1], day=cron_parts[2],
451                    month=cron_parts[3], day_of_week=cron_parts[4],
452                )
453        triggers.append(trigger)
454
455    if len(triggers) == 1:
456        return triggers[0]
457    return _OrTrigger(triggers) if join_str == '|' else _AndTrigger(triggers)

Parse a schedule string into a stateful object with a next() method.

def parse_start_time( schedule: str, now: Optional[datetime.datetime] = None) -> datetime.datetime:
460def parse_start_time(schedule: str, now: Optional[datetime] = None) -> datetime:
461    """Return the explicit starting datetime in ``schedule``, or ``now``."""
462    from meerschaum.utils.dtypes import round_time
463    dateutil_parser = mrsm.attempt_import('dateutil.parser')
464    schedule = _canonicalize_starting_keyword(schedule)
465    starting_parts = schedule.split(STARTING_KEYWORD)
466    starting_str = ('now' if len(starting_parts) == 1 else starting_parts[-1]).strip()
467    now = now or datetime.now(timezone.utc)
468    try:
469        if starting_str == 'now':
470            starting_ts = now
471        elif starting_str.startswith('in '):
472            delta_vals = starting_str.replace('in ', '').split(' ', maxsplit=1)
473            delta_unit = delta_vals[-1].rstrip('s') + 's'
474            delta_num = float(delta_vals[0])
475            starting_ts = now + timedelta(**{delta_unit: delta_num})
476        elif 'tomorrow' in starting_str or 'today' in starting_str:
477            today = round_time(now, timedelta(days=1))
478            tomorrow = today + timedelta(days=1)
479            is_tomorrow = 'tomorrow' in starting_str
480            time_str = starting_str.replace('tomorrow', '').replace('today', '').strip()
481            time_ts = dateutil_parser.parse(time_str) if time_str else today
482            starting_ts = (
483                (tomorrow if is_tomorrow else today)
484                + timedelta(hours=time_ts.hour)
485                + timedelta(minutes=time_ts.minute)
486            )
487        else:
488            starting_ts = dateutil_parser.parse(starting_str)
489        schedule_parse_error = None
490    except Exception as e:
491        warn(f"Unable to parse starting time from '{starting_str}'.", stack=False)
492        schedule_parse_error = str(e)
493    if schedule_parse_error:
494        error(schedule_parse_error, ValueError, stack=False)
495    if not starting_ts.tzinfo:
496        starting_ts = starting_ts.replace(tzinfo=timezone.utc)
497    return starting_ts

Return the explicit starting datetime in schedule, or now.