ExpressionParser.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. # The MIT License (MIT)
  2. #
  3. # Copyright (c) 2016 Adam Schubert
  4. #
  5. # Permission is hereby granted, free of charge, to any person obtaining a copy
  6. # of this software and associated documentation files (the "Software"), to deal
  7. # in the Software without restriction, including without limitation the rights
  8. # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  9. # copies of the Software, and to permit persons to whom the Software is
  10. # furnished to do so, subject to the following conditions:
  11. #
  12. # The above copyright notice and this permission notice shall be included in all
  13. # copies or substantial portions of the Software.
  14. #
  15. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  16. # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  17. # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  18. # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  19. # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  20. # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  21. # SOFTWARE.
  22. import re
  23. from .Exception import MissingFieldException, FormatException
  24. class ExpressionParser(object):
  25. _expression = ''
  26. _options = None
  27. _cron_days = {
  28. 0: 'SUN',
  29. 1: 'MON',
  30. 2: 'TUE',
  31. 3: 'WED',
  32. 4: 'THU',
  33. 5: 'FRI',
  34. 6: 'SAT'
  35. }
  36. _cron_months = {
  37. 1: 'JAN',
  38. 2: 'FEB',
  39. 3: 'MAR',
  40. 4: 'APR',
  41. 5: 'MAY',
  42. 6: 'JUN',
  43. 7: 'JUL',
  44. 8: 'AUG',
  45. 9: 'SEP',
  46. 10: 'OCT',
  47. 11: 'NOV',
  48. 12: 'DEC'
  49. }
  50. def __init__(self, expression, options):
  51. """Initializes a new instance of the ExpressionParser class
  52. Args:
  53. expression: The cron expression string
  54. options: Parsing options
  55. """
  56. self._expression = expression
  57. self._options = options
  58. def parse(self):
  59. """Parses the cron expression string
  60. Returns:
  61. A 7 part string array, one part for each component of the cron expression (seconds, minutes, etc.)
  62. Raises:
  63. MissingFieldException: if _expression is empty or None
  64. FormatException: if _expression has wrong format
  65. """
  66. # Initialize all elements of parsed array to empty strings
  67. parsed = ['', '', '', '', '', '', '']
  68. if self._expression is None or len(self._expression) == 0:
  69. raise MissingFieldException("ExpressionDescriptor.expression")
  70. else:
  71. expression_parts_temp = self._expression.split()
  72. expression_parts_temp_length = len(expression_parts_temp)
  73. if expression_parts_temp_length < 5:
  74. raise FormatException(
  75. "Error: Expression only has {0} parts. At least 5 part are required.".format(
  76. expression_parts_temp_length
  77. )
  78. )
  79. elif expression_parts_temp_length == 5:
  80. # 5 part cron so shift array past seconds element
  81. for i, expression_part_temp in enumerate(expression_parts_temp):
  82. parsed[i + 1] = expression_part_temp
  83. elif expression_parts_temp_length == 6:
  84. # We will detect if this 6 part expression has a year specified and if so we will shift the parts and treat the
  85. # first part as a minute part rather than a second part.
  86. # Ways we detect:
  87. # 1. Last part is a literal year (i.e. 2020)
  88. # 2. 3rd or 5th part is specified as "?" (DOM or DOW)
  89. year_regex = re.compile(r"\d{4}$")
  90. is_year_with_no_seconds_part = bool(year_regex.search(expression_parts_temp[5])) or "?" in [expression_parts_temp[4], expression_parts_temp[2]]
  91. for i, expression_part_temp in enumerate(expression_parts_temp):
  92. if is_year_with_no_seconds_part:
  93. # Shift parts over by one
  94. parsed[i + 1] = expression_part_temp
  95. else:
  96. parsed[i] = expression_part_temp
  97. elif expression_parts_temp_length == 7:
  98. parsed = expression_parts_temp
  99. else:
  100. raise FormatException(
  101. "Error: Expression has too many parts ({0}). Expression must not have more than 7 parts.".format(
  102. expression_parts_temp_length
  103. )
  104. )
  105. self.normalize_expression(parsed)
  106. return parsed
  107. def normalize_expression(self, expression_parts):
  108. """Converts cron expression components into consistent, predictable formats.
  109. Args:
  110. expression_parts: A 7 part string array, one part for each component of the cron expression
  111. Returns:
  112. None
  113. """
  114. # convert ? to * only for DOM and DOW
  115. expression_parts[3] = expression_parts[3].replace("?", "*")
  116. expression_parts[5] = expression_parts[5].replace("?", "*")
  117. # convert 0/, 1/ to */
  118. if expression_parts[0].startswith("0/"):
  119. expression_parts[0] = expression_parts[0].replace("0/", "*/") # seconds
  120. if expression_parts[1].startswith("0/"):
  121. expression_parts[1] = expression_parts[1].replace("0/", "*/") # minutes
  122. if expression_parts[2].startswith("0/"):
  123. expression_parts[2] = expression_parts[2].replace("0/", "*/") # hours
  124. if expression_parts[3].startswith("1/"):
  125. expression_parts[3] = expression_parts[3].replace("1/", "*/") # DOM
  126. if expression_parts[4].startswith("1/"):
  127. expression_parts[4] = expression_parts[4].replace("1/", "*/") # Month
  128. if expression_parts[5].startswith("1/"):
  129. expression_parts[5] = expression_parts[5].replace("1/", "*/") # DOW
  130. if expression_parts[6].startswith("1/"):
  131. expression_parts[6] = expression_parts[6].replace("1/", "*/") # Years
  132. # Adjust DOW based on dayOfWeekStartIndexZero option
  133. def digit_replace(match):
  134. match_value = match.group()
  135. dow_digits = re.sub(r'\D', "", match_value)
  136. dow_digits_adjusted = dow_digits
  137. if self._options.day_of_week_start_index_zero:
  138. if dow_digits == "7":
  139. dow_digits_adjusted = "0"
  140. else:
  141. dow_digits_adjusted = str(int(dow_digits) - 1)
  142. return match_value.replace(dow_digits, dow_digits_adjusted)
  143. expression_parts[5] = re.sub(r'(^\d)|([^#/\s]\d)', digit_replace, expression_parts[5])
  144. # Convert DOM '?' to '*'
  145. if expression_parts[3] == "?":
  146. expression_parts[3] = "*"
  147. # convert SUN-SAT format to 0-6 format
  148. for day_number in self._cron_days:
  149. expression_parts[5] = expression_parts[5].upper().replace(self._cron_days[day_number], str(day_number))
  150. # convert JAN-DEC format to 1-12 format
  151. for month_number in self._cron_months:
  152. expression_parts[4] = expression_parts[4].upper().replace(
  153. self._cron_months[month_number], str(month_number))
  154. # convert 0 second to (empty)
  155. if expression_parts[0] == "0":
  156. expression_parts[0] = ''
  157. # If time interval is specified for seconds or minutes and next time part is single item, make it a "self-range" so
  158. # the expression can be interpreted as an interval 'between' range.
  159. # For example:
  160. # 0-20/3 9 * * * => 0-20/3 9-9 * * * (9 => 9-9)
  161. # */5 3 * * * => */5 3-3 * * * (3 => 3-3)
  162. star_and_slash = ['*', '/']
  163. has_part_zero_star_and_slash = any(ext in expression_parts[0] for ext in star_and_slash)
  164. has_part_one_star_and_slash = any(ext in expression_parts[1] for ext in star_and_slash)
  165. has_part_two_special_chars = any(ext in expression_parts[2] for ext in ['*', '-', ',', '/'])
  166. if not has_part_two_special_chars and (has_part_zero_star_and_slash or has_part_one_star_and_slash):
  167. expression_parts[2] += '-{}'.format(expression_parts[2])
  168. # Loop through all parts and apply global normalization
  169. length = len(expression_parts)
  170. for i in range(length):
  171. # convert all '*/1' to '*'
  172. if expression_parts[i] == "*/1":
  173. expression_parts[i] = "*"
  174. """
  175. Convert Month,DOW,Year step values with a starting value (i.e. not '*') to between expressions.
  176. This allows us to reuse the between expression handling for step values.
  177. For Example:
  178. - month part '3/2' will be converted to '3-12/2' (every 2 months between March and December)
  179. - DOW part '3/2' will be converted to '3-6/2' (every 2 days between Tuesday and Saturday)
  180. """
  181. if "/" in expression_parts[i] and not any(exp in expression_parts[i] for exp in ['*', '-', ',']):
  182. choices = {
  183. 4: "12",
  184. 5: "6",
  185. 6: "9999"
  186. }
  187. step_range_through = choices.get(i)
  188. if step_range_through is not None:
  189. parts = expression_parts[i].split('/')
  190. expression_parts[i] = "{0}-{1}/{2}".format(parts[0], step_range_through, parts[1])