Skip to content

Instantly share code, notes, and snippets.

@kkew3
Created March 11, 2023 08:40
Show Gist options
  • Save kkew3/4d8736293912884d9e542449b02e2ce7 to your computer and use it in GitHub Desktop.
Save kkew3/4d8736293912884d9e542449b02e2ce7 to your computer and use it in GitHub Desktop.
Convert Apple cron-style time notation to an array of StartCalendarInterval (launchd plist key) dicts.
def parse_crontab_field(acc_values: range, acc_names: dict, string: str):
"""
Parse a field of crontab from ``string``.
>>> values = range(1, 8)
>>> names = dict(zip(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'],
... values))
>>> parse_crontab_field(values, names, '*')
[None]
>>> parse_crontab_field(values, names, '*/2')
[1, 3, 5, 7]
>>> parse_crontab_field(values, names, '2,5-7')
[2, 5, 6, 7]
>>> parse_crontab_field(values, names, 'tue,5-sun')
[2, 5, 6, 7]
>>> parse_crontab_field(values, names, '*,5')
[None]
>>> parse_crontab_field(values, names, '2-7/2')
[2, 4, 6]
>>> parse_crontab_field(values, names, '1-7/2,2-7/2')
[None]
:param acc_values: accepted values of integers
:param acc_names: name => acc_value
:param string: string to parse
:rtype: List[int]
"""
string = string.lower()
values = []
for token in string.split(','):
matched = re.fullmatch(r'\*', token)
if matched:
values.append(None)
continue
matched = re.fullmatch(r'\*/(\d+)', token)
if matched:
values.extend(
range(acc_values.start, acc_values.stop,
int(matched.group(1))))
continue
matched = re.fullmatch(r'(\d+|[a-z]{3})', token)
if matched:
value = int(acc_names.get(matched.group(1), matched.group(1)))
values.append(value)
continue
matched = re.fullmatch(r'(\d+|[a-z]{3})-(\d+|[a-z]{3})', token)
if matched:
start = int(acc_names.get(matched.group(1), matched.group(1)))
stop = int(acc_names.get(matched.group(2), matched.group(2))) + 1
values.extend(range(start, stop))
continue
matched = re.fullmatch(r'(\d+|[a-z]{3})-(\d+|[a-z]{3})/(\d+)', token)
if matched:
start = int(acc_names.get(matched.group(1), matched.group(1)))
stop = int(acc_names.get(matched.group(2), matched.group(2))) + 1
step = int(matched.group(3))
values.extend(range(start, stop, step))
continue
raise ValueError(token)
if None in values:
values = [None]
else:
values = set(values)
if values == set(acc_values):
values = [None]
else:
values = sorted(values)
return values
def tuple_to_StartCalendarInterval_dict(tup: tuple):
"""
>>> assert(tuple_to_StartCalendarInterval_dict((0, 1, 2, 3, 4))
... == {'Minute': 0, 'Hour': 1, 'Weekday': 2, 'Day': 3, 'Month': 4})
>>> assert(tuple_to_StartCalendarInterval_dict((None, None, 2, 3, None))
... == {'Weekday': 2, 'Day': 3})
>>> assert(tuple_to_StartCalendarInterval_dict(
... (None, None, None, None, None)) == {})
"""
keys = ['Minute', 'Hour', 'Weekday', 'Day', 'Month']
dct = {}
for k, v in zip(keys, tup):
if v is not None:
dct[k] = v
return dct
def cron_calendar(string: str):
"""
Parse crontab time with minute, hour, day of month, month, day of year
fields and convert to a list of plist StartCalendarInterval dicts.
'@reboot' is not supported, as in launchd, this is implemented by
'RunAtLoad' key.
:rtype: List[Dict[str, int]]
"""
minute_values = range(60)
hour_values = range(24)
weekday_values = range(7)
weekday_names = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
weekday_names = dict(zip(weekday_names, weekday_values))
day_values = range(1, 32)
month_values = range(1, 13)
month_names = [
'jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct',
'nov', 'dec'
]
month_names = dict(zip(month_names, month_values))
# preprocess special strings starting with '@'
string = {
'@yearly': '0 0 1 1 *',
'@annually': '0 0 1 1 *',
'@monthly': '0 0 1 * *',
'@weekly': '0 0 * * 0',
'@daily': '0 0 * * *',
'@midnight': '0 0 * * *',
'@hourly': '0 * * * *',
}.get(string, string)
fminute, fhour, fdow, fday, fmonth = string.split()
tuples = itertools.product(
parse_crontab_field(minute_values, {}, fminute),
parse_crontab_field(hour_values, {}, fhour),
parse_crontab_field(weekday_values, weekday_names, fdow),
parse_crontab_field(day_values, {}, fday),
parse_crontab_field(month_values, month_names, fmonth),
)
dicts = list(map(tuple_to_StartCalendarInterval_dict, tuples))
if len(dicts) == 1:
return dicts[0]
return dicts
import plistlib
definition = {
'Label': 'com.example',
'Program': '/path/to/script.sh',
'StartCalendarInterval': cron_calendar('5 0 * 8 *'),
}
with open('com.example.plist', 'wb') as outfile:
plistlib.dump(definitions, outfile)
# $ cat com.example.plist
# <?xml version="1.0" encoding="UTF-8"?>
# <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
# <plist version="1.0">
# <dict>
# <key>Label</key>
# <string>com.example</string>
# <key>Program</key>
# <string>/path/to/script.sh</string>
# <key>StartCalendarInterval</key>
# <dict>
# <key>Day</key>
# <integer>8</integer>
# <key>Hour</key>
# <integer>0</integer>
# <key>Minute</key>
# <integer>5</integer>
# </dict>
# </dict>
# </plist>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment