meerschaum.utils.schedule
Schedule processes and threads.
1#! /usr/bin/env python3 2# -*- coding: utf-8 -*- 3# vim:fenc=utf-8 4 5""" 6Schedule processes and threads. 7""" 8 9from __future__ import annotations 10import signal 11import traceback 12from datetime import datetime, timezone, timedelta 13import meerschaum as mrsm 14from meerschaum.utils.typing import Callable, Any, Optional, List, Dict 15from meerschaum.utils.warnings import warn, error 16 17STARTING_KEYWORD: str = 'starting' 18INTERVAL_UNITS: List[str] = ['months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'years'] 19FREQUENCY_ALIASES: Dict[str, str] = { 20 'daily': 'every 1 day', 21 'hourly': 'every 1 hour', 22 'minutely': 'every 1 minute', 23 'weekly': 'every 1 week', 24 'monthly': 'every 1 month', 25 'secondly': 'every 1 second', 26 'yearly': 'every 1 year', 27} 28LOGIC_ALIASES: Dict[str, str] = { 29 'and': '&', 30 'or': '|', 31 ' through ': '-', 32 ' thru ': '-', 33 ' - ': '-', 34 'beginning': STARTING_KEYWORD, 35} 36CRON_DAYS_OF_WEEK: List[str] = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] 37CRON_DAYS_OF_WEEK_ALIASES: Dict[str, str] = { 38 'monday': 'mon', 39 'tuesday': 'tue', 40 'tues': 'tue', 41 'wednesday': 'wed', 42 'thursday': 'thu', 43 'thurs': 'thu', 44 'friday': 'fri', 45 'saturday': 'sat', 46 'sunday': 'sun', 47} 48CRON_MONTHS: List[str] = [ 49 'jan', 'feb', 'mar', 'apr', 'may', 'jun', 50 'jul', 'aug', 'sep', 'oct', 'nov', 'dec', 51] 52CRON_MONTHS_ALIASES: Dict[str, str] = { 53 'january': 'jan', 54 'february': 'feb', 55 'march': 'mar', 56 'april': 'apr', 57 'may': 'may', 58 'june': 'jun', 59 'july': 'jul', 60 'august': 'aug', 61 'september': 'sep', 62 'october': 'oct', 63 'november': 'nov', 64 'december': 'dec', 65} 66SCHEDULE_ALIASES: Dict[str, str] = { 67 **FREQUENCY_ALIASES, 68 **LOGIC_ALIASES, 69 **CRON_DAYS_OF_WEEK_ALIASES, 70 **CRON_MONTHS_ALIASES, 71} 72 73_scheduler = None 74def schedule_function( 75 function: Callable[[Any], Any], 76 schedule: str, 77 *args, 78 debug: bool = False, 79 **kw 80) -> mrsm.SuccessTuple: 81 """ 82 Block the process and execute the function intermittently according to the frequency. 83 https://meerschaum.io/reference/background-jobs/#-schedules 84 85 Parameters 86 ---------- 87 function: Callable[[Any], Any] 88 The function to execute. 89 90 schedule: str 91 The frequency schedule at which `function` should be executed (e.g. `'daily'`). 92 93 Returns 94 ------- 95 A `SuccessTuple` upon exit. 96 """ 97 import asyncio 98 from meerschaum.utils.misc import filter_keywords, round_time 99 100 global _scheduler 101 kw['debug'] = debug 102 kw = filter_keywords(function, **kw) 103 104 _ = mrsm.attempt_import('attrs', lazy=False) 105 apscheduler = mrsm.attempt_import('apscheduler', lazy=False) 106 now = round_time(datetime.now(timezone.utc), timedelta(minutes=1)) 107 trigger = parse_schedule(schedule, now=now) 108 _scheduler = apscheduler.AsyncScheduler(identity='mrsm-scheduler') 109 try: 110 loop = asyncio.get_running_loop() 111 except RuntimeError: 112 loop = asyncio.new_event_loop() 113 114 async def run_scheduler(): 115 async with _scheduler: 116 job = await _scheduler.add_schedule( 117 function, 118 trigger, 119 **filter_keywords( 120 _scheduler.add_schedule, 121 args=args, 122 kwargs=kw, 123 max_running_jobs=1, 124 conflict_policy=apscheduler.ConflictPolicy.replace, 125 ) 126 ) 127 try: 128 await _scheduler.run_until_stopped() 129 except (KeyboardInterrupt, SystemExit) as e: 130 await _stop_scheduler() 131 raise e 132 133 try: 134 loop.run_until_complete(run_scheduler()) 135 except (KeyboardInterrupt, SystemExit) as e: 136 loop.run_until_complete(_stop_scheduler()) 137 138 return True, "Success" 139 140 141def parse_schedule(schedule: str, now: Optional[datetime] = None): 142 """ 143 Parse a schedule string (e.g. 'daily') into a Trigger object. 144 """ 145 from meerschaum.utils.misc import items_str, is_int, filter_keywords 146 ( 147 apscheduler_triggers_cron, 148 apscheduler_triggers_interval, 149 apscheduler_triggers_calendarinterval, 150 apscheduler_triggers_combining, 151 ) = ( 152 mrsm.attempt_import( 153 'apscheduler.triggers.cron', 154 'apscheduler.triggers.interval', 155 'apscheduler.triggers.calendarinterval', 156 'apscheduler.triggers.combining', 157 lazy = False, 158 ) 159 ) 160 161 starting_ts = parse_start_time(schedule, now=now) 162 schedule = schedule.split(STARTING_KEYWORD)[0].strip() 163 for alias_keyword, true_keyword in SCHEDULE_ALIASES.items(): 164 schedule = schedule.replace(alias_keyword, true_keyword) 165 166 ### TODO Allow for combining `and` + `or` logic. 167 if '&' in schedule and '|' in schedule: 168 raise ValueError(f"Cannot accept both 'and' + 'or' logic in the schedule frequency.") 169 170 join_str = '|' if '|' in schedule else '&' 171 join_trigger = ( 172 apscheduler_triggers_combining.OrTrigger 173 if join_str == '|' 174 else apscheduler_triggers_combining.AndTrigger 175 ) 176 join_kwargs = { 177 'max_iterations': 1_000_000, 178 'threshold': 0, 179 } if join_str == '&' else {} 180 181 schedule_parts = [part.strip() for part in schedule.split(join_str)] 182 triggers = [] 183 184 has_seconds = 'second' in schedule 185 has_minutes = 'minute' in schedule 186 187 for schedule_part in schedule_parts: 188 189 ### Intervals must begin with 'every' (after alias substitution). 190 if schedule_part.lower().startswith('every '): 191 schedule_num_str, schedule_unit = ( 192 schedule_part[len('every '):].split(' ', maxsplit=1) 193 ) 194 schedule_unit = schedule_unit.rstrip('s') + 's' 195 if schedule_unit not in INTERVAL_UNITS: 196 raise ValueError( 197 f"Invalid interval '{schedule_unit}'.\n" 198 + f" Accepted values are {items_str(INTERVAL_UNITS)}." 199 ) 200 201 schedule_num = ( 202 int(schedule_num_str) 203 if is_int(schedule_num_str) 204 else float(schedule_num_str) 205 ) 206 207 trigger = ( 208 apscheduler_triggers_interval.IntervalTrigger( 209 **filter_keywords( 210 apscheduler_triggers_interval.IntervalTrigger.__init__, 211 **{ 212 schedule_unit: schedule_num, 213 'start_time': starting_ts, 214 'start_date': starting_ts, 215 } 216 ) 217 ) 218 if schedule_unit not in ('months', 'years') else ( 219 apscheduler_triggers_calendarinterval.CalendarIntervalTrigger( 220 **{ 221 schedule_unit: schedule_num, 222 'start_date': starting_ts, 223 'timezone': starting_ts.tzinfo, 224 } 225 ) 226 ) 227 ) 228 229 ### Determine whether this is a pure cron string or a cron subset (e.g. 'may-aug')_. 230 else: 231 first_three_prefix = schedule_part[:3].lower() 232 first_four_prefix = schedule_part[:4].lower() 233 cron_kw = {} 234 if first_three_prefix in CRON_DAYS_OF_WEEK: 235 cron_kw['day_of_week'] = schedule_part 236 elif first_three_prefix in CRON_MONTHS: 237 cron_kw['month'] = schedule_part 238 elif is_int(first_four_prefix) and len(first_four_prefix) == 4: 239 cron_kw['year'] = int(first_four_prefix) 240 trigger = ( 241 apscheduler_triggers_cron.CronTrigger( 242 **{ 243 **cron_kw, 244 'hour': '*', 245 'minute': '*' if has_minutes else starting_ts.minute, 246 'second': '*' if has_seconds else starting_ts.second, 247 'start_time': starting_ts, 248 'timezone': starting_ts.tzinfo, 249 } 250 ) 251 if cron_kw 252 else apscheduler_triggers_cron.CronTrigger.from_crontab( 253 schedule_part, 254 timezone = starting_ts.tzinfo, 255 ) 256 ) 257 ### Explicitly set the `start_time` after building with `from_crontab`. 258 if trigger.start_time != starting_ts: 259 trigger.start_time = starting_ts 260 261 triggers.append(trigger) 262 263 return ( 264 join_trigger(triggers, **join_kwargs) 265 if len(triggers) != 1 266 else triggers[0] 267 ) 268 269 270def parse_start_time(schedule: str, now: Optional[datetime] = None) -> datetime: 271 """ 272 Return the datetime to use for the given schedule string. 273 274 Parameters 275 ---------- 276 schedule: str 277 The schedule frequency to be parsed into a starting datetime. 278 279 now: Optional[datetime], default None 280 If provided, use this value as a default if no start time is explicitly stated. 281 282 Returns 283 ------- 284 A `datetime` object, either `now` or the datetime embedded in the schedule string. 285 286 Examples 287 -------- 288 >>> parse_start_time('daily starting 2024-01-01') 289 datetime.datetime(2024, 1, 1, 0, 0, tzinfo=datetime.timezone.utc) 290 >>> parse_start_time('monthly starting 1st') 291 datetime.datetime(2024, 5, 1, 0, 0, tzinfo=datetime.timezone.utc) 292 >>> parse_start_time('hourly starting 00:30') 293 datetime.datetime(2024, 5, 13, 0, 30, tzinfo=datetime.timezone.utc) 294 """ 295 from meerschaum.utils.misc import round_time 296 dateutil_parser = mrsm.attempt_import('dateutil.parser') 297 starting_parts = schedule.split(STARTING_KEYWORD) 298 starting_str = ('now' if len(starting_parts) == 1 else starting_parts[-1]).strip() 299 now = now or round_time(datetime.now(timezone.utc), timedelta(minutes=1)) 300 try: 301 if starting_str == 'now': 302 starting_ts = now 303 elif 'tomorrow' in starting_str or 'today' in starting_str: 304 today = round_time(now, timedelta(days=1)) 305 tomorrow = today + timedelta(days=1) 306 is_tomorrow = 'tomorrow' in starting_str 307 time_str = starting_str.replace('tomorrow', '').replace('today', '').strip() 308 time_ts = dateutil_parser.parse(time_str) if time_str else today 309 starting_ts = ( 310 (tomorrow if is_tomorrow else today) 311 + timedelta(hours=time_ts.hour) 312 + timedelta(minutes=time_ts.minute) 313 ) 314 else: 315 starting_ts = dateutil_parser.parse(starting_str) 316 schedule_parse_error = None 317 except Exception as e: 318 warn(f"Unable to parse starting time from '{starting_str}'.", stack=False) 319 schedule_parse_error = str(e) 320 if schedule_parse_error: 321 error(schedule_parse_error, ValueError, stack=False) 322 if not starting_ts.tzinfo: 323 starting_ts = starting_ts.replace(tzinfo=timezone.utc) 324 return starting_ts 325 326 327async def _stop_scheduler(): 328 if _scheduler is None: 329 return 330 await _scheduler.stop() 331 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 ': '-', ' - ': '-', 'beginning': 'starting'}
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 ': '-', ' - ': '-', 'beginning': 'starting', '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]:
75def schedule_function( 76 function: Callable[[Any], Any], 77 schedule: str, 78 *args, 79 debug: bool = False, 80 **kw 81) -> mrsm.SuccessTuple: 82 """ 83 Block the process and execute the function intermittently according to the frequency. 84 https://meerschaum.io/reference/background-jobs/#-schedules 85 86 Parameters 87 ---------- 88 function: Callable[[Any], Any] 89 The function to execute. 90 91 schedule: str 92 The frequency schedule at which `function` should be executed (e.g. `'daily'`). 93 94 Returns 95 ------- 96 A `SuccessTuple` upon exit. 97 """ 98 import asyncio 99 from meerschaum.utils.misc import filter_keywords, round_time 100 101 global _scheduler 102 kw['debug'] = debug 103 kw = filter_keywords(function, **kw) 104 105 _ = mrsm.attempt_import('attrs', lazy=False) 106 apscheduler = mrsm.attempt_import('apscheduler', lazy=False) 107 now = round_time(datetime.now(timezone.utc), timedelta(minutes=1)) 108 trigger = parse_schedule(schedule, now=now) 109 _scheduler = apscheduler.AsyncScheduler(identity='mrsm-scheduler') 110 try: 111 loop = asyncio.get_running_loop() 112 except RuntimeError: 113 loop = asyncio.new_event_loop() 114 115 async def run_scheduler(): 116 async with _scheduler: 117 job = await _scheduler.add_schedule( 118 function, 119 trigger, 120 **filter_keywords( 121 _scheduler.add_schedule, 122 args=args, 123 kwargs=kw, 124 max_running_jobs=1, 125 conflict_policy=apscheduler.ConflictPolicy.replace, 126 ) 127 ) 128 try: 129 await _scheduler.run_until_stopped() 130 except (KeyboardInterrupt, SystemExit) as e: 131 await _stop_scheduler() 132 raise e 133 134 try: 135 loop.run_until_complete(run_scheduler()) 136 except (KeyboardInterrupt, SystemExit) as e: 137 loop.run_until_complete(_stop_scheduler()) 138 139 return True, "Success"
Block the process and execute the function intermittently according to the frequency. https://meerschaum.io/reference/background-jobs/#-schedules
Parameters
- function (Callable[[Any], Any]): The function to execute.
- schedule (str):
The frequency schedule at which
function
should be executed (e.g.'daily'
).
Returns
- A
SuccessTuple
upon exit.
def
parse_schedule(schedule: str, now: Optional[datetime.datetime] = None):
142def parse_schedule(schedule: str, now: Optional[datetime] = None): 143 """ 144 Parse a schedule string (e.g. 'daily') into a Trigger object. 145 """ 146 from meerschaum.utils.misc import items_str, is_int, filter_keywords 147 ( 148 apscheduler_triggers_cron, 149 apscheduler_triggers_interval, 150 apscheduler_triggers_calendarinterval, 151 apscheduler_triggers_combining, 152 ) = ( 153 mrsm.attempt_import( 154 'apscheduler.triggers.cron', 155 'apscheduler.triggers.interval', 156 'apscheduler.triggers.calendarinterval', 157 'apscheduler.triggers.combining', 158 lazy = False, 159 ) 160 ) 161 162 starting_ts = parse_start_time(schedule, now=now) 163 schedule = schedule.split(STARTING_KEYWORD)[0].strip() 164 for alias_keyword, true_keyword in SCHEDULE_ALIASES.items(): 165 schedule = schedule.replace(alias_keyword, true_keyword) 166 167 ### TODO Allow for combining `and` + `or` logic. 168 if '&' in schedule and '|' in schedule: 169 raise ValueError(f"Cannot accept both 'and' + 'or' logic in the schedule frequency.") 170 171 join_str = '|' if '|' in schedule else '&' 172 join_trigger = ( 173 apscheduler_triggers_combining.OrTrigger 174 if join_str == '|' 175 else apscheduler_triggers_combining.AndTrigger 176 ) 177 join_kwargs = { 178 'max_iterations': 1_000_000, 179 'threshold': 0, 180 } if join_str == '&' else {} 181 182 schedule_parts = [part.strip() for part in schedule.split(join_str)] 183 triggers = [] 184 185 has_seconds = 'second' in schedule 186 has_minutes = 'minute' in schedule 187 188 for schedule_part in schedule_parts: 189 190 ### Intervals must begin with 'every' (after alias substitution). 191 if schedule_part.lower().startswith('every '): 192 schedule_num_str, schedule_unit = ( 193 schedule_part[len('every '):].split(' ', maxsplit=1) 194 ) 195 schedule_unit = schedule_unit.rstrip('s') + 's' 196 if schedule_unit not in INTERVAL_UNITS: 197 raise ValueError( 198 f"Invalid interval '{schedule_unit}'.\n" 199 + f" Accepted values are {items_str(INTERVAL_UNITS)}." 200 ) 201 202 schedule_num = ( 203 int(schedule_num_str) 204 if is_int(schedule_num_str) 205 else float(schedule_num_str) 206 ) 207 208 trigger = ( 209 apscheduler_triggers_interval.IntervalTrigger( 210 **filter_keywords( 211 apscheduler_triggers_interval.IntervalTrigger.__init__, 212 **{ 213 schedule_unit: schedule_num, 214 'start_time': starting_ts, 215 'start_date': starting_ts, 216 } 217 ) 218 ) 219 if schedule_unit not in ('months', 'years') else ( 220 apscheduler_triggers_calendarinterval.CalendarIntervalTrigger( 221 **{ 222 schedule_unit: schedule_num, 223 'start_date': starting_ts, 224 'timezone': starting_ts.tzinfo, 225 } 226 ) 227 ) 228 ) 229 230 ### Determine whether this is a pure cron string or a cron subset (e.g. 'may-aug')_. 231 else: 232 first_three_prefix = schedule_part[:3].lower() 233 first_four_prefix = schedule_part[:4].lower() 234 cron_kw = {} 235 if first_three_prefix in CRON_DAYS_OF_WEEK: 236 cron_kw['day_of_week'] = schedule_part 237 elif first_three_prefix in CRON_MONTHS: 238 cron_kw['month'] = schedule_part 239 elif is_int(first_four_prefix) and len(first_four_prefix) == 4: 240 cron_kw['year'] = int(first_four_prefix) 241 trigger = ( 242 apscheduler_triggers_cron.CronTrigger( 243 **{ 244 **cron_kw, 245 'hour': '*', 246 'minute': '*' if has_minutes else starting_ts.minute, 247 'second': '*' if has_seconds else starting_ts.second, 248 'start_time': starting_ts, 249 'timezone': starting_ts.tzinfo, 250 } 251 ) 252 if cron_kw 253 else apscheduler_triggers_cron.CronTrigger.from_crontab( 254 schedule_part, 255 timezone = starting_ts.tzinfo, 256 ) 257 ) 258 ### Explicitly set the `start_time` after building with `from_crontab`. 259 if trigger.start_time != starting_ts: 260 trigger.start_time = starting_ts 261 262 triggers.append(trigger) 263 264 return ( 265 join_trigger(triggers, **join_kwargs) 266 if len(triggers) != 1 267 else triggers[0] 268 )
Parse a schedule string (e.g. 'daily') into a Trigger object.
def
parse_start_time( schedule: str, now: Optional[datetime.datetime] = None) -> datetime.datetime:
271def parse_start_time(schedule: str, now: Optional[datetime] = None) -> datetime: 272 """ 273 Return the datetime to use for the given schedule string. 274 275 Parameters 276 ---------- 277 schedule: str 278 The schedule frequency to be parsed into a starting datetime. 279 280 now: Optional[datetime], default None 281 If provided, use this value as a default if no start time is explicitly stated. 282 283 Returns 284 ------- 285 A `datetime` object, either `now` or the datetime embedded in the schedule string. 286 287 Examples 288 -------- 289 >>> parse_start_time('daily starting 2024-01-01') 290 datetime.datetime(2024, 1, 1, 0, 0, tzinfo=datetime.timezone.utc) 291 >>> parse_start_time('monthly starting 1st') 292 datetime.datetime(2024, 5, 1, 0, 0, tzinfo=datetime.timezone.utc) 293 >>> parse_start_time('hourly starting 00:30') 294 datetime.datetime(2024, 5, 13, 0, 30, tzinfo=datetime.timezone.utc) 295 """ 296 from meerschaum.utils.misc import round_time 297 dateutil_parser = mrsm.attempt_import('dateutil.parser') 298 starting_parts = schedule.split(STARTING_KEYWORD) 299 starting_str = ('now' if len(starting_parts) == 1 else starting_parts[-1]).strip() 300 now = now or round_time(datetime.now(timezone.utc), timedelta(minutes=1)) 301 try: 302 if starting_str == 'now': 303 starting_ts = now 304 elif 'tomorrow' in starting_str or 'today' in starting_str: 305 today = round_time(now, timedelta(days=1)) 306 tomorrow = today + timedelta(days=1) 307 is_tomorrow = 'tomorrow' in starting_str 308 time_str = starting_str.replace('tomorrow', '').replace('today', '').strip() 309 time_ts = dateutil_parser.parse(time_str) if time_str else today 310 starting_ts = ( 311 (tomorrow if is_tomorrow else today) 312 + timedelta(hours=time_ts.hour) 313 + timedelta(minutes=time_ts.minute) 314 ) 315 else: 316 starting_ts = dateutil_parser.parse(starting_str) 317 schedule_parse_error = None 318 except Exception as e: 319 warn(f"Unable to parse starting time from '{starting_str}'.", stack=False) 320 schedule_parse_error = str(e) 321 if schedule_parse_error: 322 error(schedule_parse_error, ValueError, stack=False) 323 if not starting_ts.tzinfo: 324 starting_ts = starting_ts.replace(tzinfo=timezone.utc) 325 return starting_ts
Return the datetime to use for the given schedule string.
Parameters
- schedule (str): The schedule frequency to be parsed into a starting datetime.
- now (Optional[datetime], default None): If provided, use this value as a default if no start time is explicitly stated.
Returns
- A
datetime
object, eithernow
or the datetime embedded in the schedule string.
Examples
>>> parse_start_time('daily starting 2024-01-01')
datetime.datetime(2024, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
>>> parse_start_time('monthly starting 1st')
datetime.datetime(2024, 5, 1, 0, 0, tzinfo=datetime.timezone.utc)
>>> parse_start_time('hourly starting 00:30')
datetime.datetime(2024, 5, 13, 0, 30, tzinfo=datetime.timezone.utc)