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
23import re
24import datetime
25import calendar
26
27from .GetText import GetText
28from .CasingTypeEnum import CasingTypeEnum
29from .DescriptionTypeEnum import DescriptionTypeEnum
30from .ExpressionParser import ExpressionParser
31from .Options import Options
32from .StringBuilder import StringBuilder
33from .Exception import FormatException, WrongArgumentException
34
35
36class ExpressionDescriptor:
37
38 """
39 Converts a Cron Expression into a human readable string
40 """
41
42 _special_characters = ['/', '-', ',', '*']
43
44 _expression = ''
45 _options = None
46 _expression_parts = []
47
48 def __init__(self, expression, options=None, **kwargs):
49 """Initializes a new instance of the ExpressionDescriptor
50
51 Args:
52 expression: The cron expression string
53 options: Options to control the output description
54 Raises:
55 WrongArgumentException: if kwarg is unknown
56
57 """
58 if options is None:
59 options = Options()
60 self._expression = expression
61 self._options = options
62 self._expression_parts = []
63
64 # if kwargs in _options, overwrite it, if not raise exception
65 for kwarg in kwargs:
66 if hasattr(self._options, kwarg):
67 setattr(self._options, kwarg, kwargs[kwarg])
68 else:
69 raise WrongArgumentException("Unknown {} configuration argument".format(kwarg))
70
71 # Initializes localization
72 self.get_text = GetText(options.locale_code, options.locale_location)
73
74 # Parse expression
75 parser = ExpressionParser(self._expression, self._options)
76 self._expression_parts = parser.parse()
77
78 def _(self, message):
79 return self.get_text.trans.gettext(message)
80
81 def get_description(self, description_type=DescriptionTypeEnum.FULL):
82 """Generates a humanreadable string for the Cron Expression
83
84 Args:
85 description_type: Which part(s) of the expression to describe
86 Returns:
87 The cron expression description
88 Raises:
89 Exception:
90
91 """
92 choices = {
93 DescriptionTypeEnum.FULL: self.get_full_description,
94 DescriptionTypeEnum.TIMEOFDAY: self.get_time_of_day_description,
95 DescriptionTypeEnum.HOURS: self.get_hours_description,
96 DescriptionTypeEnum.MINUTES: self.get_minutes_description,
97 DescriptionTypeEnum.SECONDS: self.get_seconds_description,
98 DescriptionTypeEnum.DAYOFMONTH: self.get_day_of_month_description,
99 DescriptionTypeEnum.MONTH: self.get_month_description,
100 DescriptionTypeEnum.DAYOFWEEK: self.get_day_of_week_description,
101 DescriptionTypeEnum.YEAR: self.get_year_description,
102 }
103
104 return choices.get(description_type, self.get_seconds_description)()
105
106 def get_full_description(self):
107 """Generates the FULL description
108
109 Returns:
110 The FULL description
111 Raises:
112 FormatException: if formatting fails
113
114 """
115
116 try:
117 time_segment = self.get_time_of_day_description()
118 day_of_month_desc = self.get_day_of_month_description()
119 month_desc = self.get_month_description()
120 day_of_week_desc = self.get_day_of_week_description()
121 year_desc = self.get_year_description()
122
123 description = "{0}{1}{2}{3}{4}".format(
124 time_segment,
125 day_of_month_desc,
126 day_of_week_desc,
127 month_desc,
128 year_desc)
129
130 description = self.transform_verbosity(description, self._options.verbose)
131 description = ExpressionDescriptor.transform_case(description, self._options.casing_type)
132 except Exception:
133 description = self._(
134 "An error occurred when generating the expression description. Check the cron expression syntax."
135 )
136 raise FormatException(description)
137
138 return description
139
140 def get_time_of_day_description(self):
141 """Generates a description for only the TIMEOFDAY portion of the expression
142
143 Returns:
144 The TIMEOFDAY description
145
146 """
147 seconds_expression = self._expression_parts[0]
148 minute_expression = self._expression_parts[1]
149 hour_expression = self._expression_parts[2]
150
151 description = StringBuilder()
152
153 # handle special cases first
154 if any(exp in minute_expression for exp in self._special_characters) is False and \
155 any(exp in hour_expression for exp in self._special_characters) is False and \
156 any(exp in seconds_expression for exp in self._special_characters) is False:
157 # specific time of day (i.e. 10 14)
158 description.append(self._("At "))
159 description.append(
160 self.format_time(
161 hour_expression,
162 minute_expression,
163 seconds_expression))
164 elif seconds_expression == "" and "-" in minute_expression and \
165 "," not in minute_expression and \
166 any(exp in hour_expression for exp in self._special_characters) is False:
167 # minute range in single hour (i.e. 0-10 11)
168 minute_parts = minute_expression.split('-')
169 description.append(self._("Every minute between {0} and {1}").format(
170 self.format_time(hour_expression, minute_parts[0]), self.format_time(hour_expression, minute_parts[1])))
171 elif seconds_expression == "" and "," in hour_expression and "-" not in hour_expression and \
172 any(exp in minute_expression for exp in self._special_characters) is False:
173 # hours list with single minute (o.e. 30 6,14,16)
174 hour_parts = hour_expression.split(',')
175 description.append(self._("At"))
176 for i, hour_part in enumerate(hour_parts):
177 description.append(" ")
178 description.append(self.format_time(hour_part, minute_expression))
179
180 if i < (len(hour_parts) - 2):
181 description.append(",")
182
183 if i == len(hour_parts) - 2:
184 description.append(self._(" and"))
185 else:
186 # default time description
187 seconds_description = self.get_seconds_description()
188 minutes_description = self.get_minutes_description()
189 hours_description = self.get_hours_description()
190
191 description.append(seconds_description)
192
193 if description and minutes_description:
194 description.append(", ")
195
196 description.append(minutes_description)
197
198 if description and hours_description:
199 description.append(", ")
200
201 description.append(hours_description)
202 return str(description)
203
204 def get_seconds_description(self):
205 """Generates a description for only the SECONDS portion of the expression
206
207 Returns:
208 The SECONDS description
209
210 """
211
212 def get_description_format(s):
213 if s == "0":
214 return ""
215
216 try:
217 if int(s) < 20:
218 return self._("at {0} seconds past the minute")
219 else:
220 return self._("at {0} seconds past the minute [grThen20]") or self._("at {0} seconds past the minute")
221 except ValueError:
222 return self._("at {0} seconds past the minute")
223
224 return self.get_segment_description(
225 self._expression_parts[0],
226 self._("every second"),
227 lambda s: s,
228 lambda s: self._("every {0} seconds").format(s),
229 lambda s: self._("seconds {0} through {1} past the minute"),
230 get_description_format,
231 lambda s: self._(", second {0} through second {1}") or self._(", {0} through {1}")
232 )
233
234 def get_minutes_description(self):
235 """Generates a description for only the MINUTE portion of the expression
236
237 Returns:
238 The MINUTE description
239
240 """
241 seconds_expression = self._expression_parts[0]
242
243 def get_description_format(s):
244 if s == "0" and seconds_expression == "":
245 return ""
246
247 try:
248 if int(s) < 20:
249 return self._("at {0} minutes past the hour")
250 else:
251 return self._("at {0} minutes past the hour [grThen20]") or self._("at {0} minutes past the hour")
252 except ValueError:
253 return self._("at {0} minutes past the hour")
254
255 return self.get_segment_description(
256 self._expression_parts[1],
257 self._("every minute"),
258 lambda s: s,
259 lambda s: self._("every {0} minutes").format(s),
260 lambda s: self._("minutes {0} through {1} past the hour"),
261 get_description_format,
262 lambda s: self._(", minute {0} through minute {1}") or self._(", {0} through {1}")
263 )
264
265 def get_hours_description(self):
266 """Generates a description for only the HOUR portion of the expression
267
268 Returns:
269 The HOUR description
270
271 """
272 expression = self._expression_parts[2]
273 return self.get_segment_description(
274 expression,
275 self._("every hour"),
276 lambda s: self.format_time(s, "0"),
277 lambda s: self._("every {0} hours").format(s),
278 lambda s: self._("between {0} and {1}"),
279 lambda s: self._("at {0}"),
280 lambda s: self._(", hour {0} through hour {1}") or self._(", {0} through {1}")
281 )
282
283 def get_day_of_week_description(self):
284 """Generates a description for only the DAYOFWEEK portion of the expression
285
286 Returns:
287 The DAYOFWEEK description
288
289 """
290
291 if self._expression_parts[5] == "*":
292 # DOW is specified as * so we will not generate a description and defer to DOM part.
293 # Otherwise, we could get a contradiction like "on day 1 of the month, every day"
294 # or a dupe description like "every day, every day".
295 return ""
296
297 def get_day_name(s):
298 exp = s
299 if "#" in s:
300 exp, _ = s.split("#", 2)
301 elif "L" in s:
302 exp = exp.replace("L", '')
303 return ExpressionDescriptor.number_to_day(int(exp))
304
305 def get_format(s):
306 if "#" in s:
307 day_of_week_of_month = s[s.find("#") + 1:]
308
309 try:
310 day_of_week_of_month_number = int(day_of_week_of_month)
311 choices = {
312 1: self._("first"),
313 2: self._("second"),
314 3: self._("third"),
315 4: self._("forth"),
316 5: self._("fifth"),
317 }
318 day_of_week_of_month_description = choices.get(day_of_week_of_month_number, '')
319 except ValueError:
320 day_of_week_of_month_description = ''
321
322 formatted = "{}{}{}".format(self._(", on the "), day_of_week_of_month_description, self._(" {0} of the month"))
323 elif "L" in s:
324 formatted = self._(", on the last {0} of the month")
325 else:
326 formatted = self._(", only on {0}")
327
328 return formatted
329
330 return self.get_segment_description(
331 self._expression_parts[5],
332 self._(", every day"),
333 lambda s: get_day_name(s),
334 lambda s: self._(", every {0} days of the week").format(s),
335 lambda s: self._(", {0} through {1}"),
336 lambda s: get_format(s),
337 lambda s: self._(", {0} through {1}")
338 )
339
340 def get_month_description(self):
341 """Generates a description for only the MONTH portion of the expression
342
343 Returns:
344 The MONTH description
345
346 """
347 return self.get_segment_description(
348 self._expression_parts[4],
349 '',
350 lambda s: datetime.date(datetime.date.today().year, int(s), 1).strftime("%B"),
351 lambda s: self._(", every {0} months").format(s),
352 lambda s: self._(", month {0} through month {1}") or self._(", {0} through {1}"),
353 lambda s: self._(", only in {0}"),
354 lambda s: self._(", month {0} through month {1}") or self._(", {0} through {1}")
355 )
356
357 def get_day_of_month_description(self):
358 """Generates a description for only the DAYOFMONTH portion of the expression
359
360 Returns:
361 The DAYOFMONTH description
362
363 """
364 expression = self._expression_parts[3]
365
366 if expression == "L":
367 description = self._(", on the last day of the month")
368 elif expression == "LW" or expression == "WL":
369 description = self._(", on the last weekday of the month")
370 else:
371 regex = re.compile(r"(\d{1,2}W)|(W\d{1,2})")
372 m = regex.match(expression)
373 if m: # if matches
374 day_number = int(m.group().replace("W", ""))
375
376 day_string = self._("first weekday") if day_number == 1 else self._("weekday nearest day {0}").format(day_number)
377 description = self._(", on the {0} of the month").format(day_string)
378 else:
379 # Handle "last day offset"(i.e.L - 5: "5 days before the last day of the month")
380 regex = re.compile(r"L-(\d{1,2})")
381 m = regex.match(expression)
382 if m: # if matches
383 off_set_days = m.group(1)
384 description = self._(", {0} days before the last day of the month").format(off_set_days)
385 else:
386 description = self.get_segment_description(
387 expression,
388 self._(", every day"),
389 lambda s: s,
390 lambda s: self._(", every day") if s == "1" else self._(", every {0} days"),
391 lambda s: self._(", between day {0} and {1} of the month"),
392 lambda s: self._(", on day {0} of the month"),
393 lambda s: self._(", {0} through {1}")
394 )
395
396 return description
397
398 def get_year_description(self):
399 """Generates a description for only the YEAR portion of the expression
400
401 Returns:
402 The YEAR description
403
404 """
405
406 def format_year(s):
407 regex = re.compile(r"^\d+$")
408 if regex.match(s):
409 year_int = int(s)
410 if year_int < 1900:
411 return year_int
412 return datetime.date(year_int, 1, 1).strftime("%Y")
413 else:
414 return s
415
416 return self.get_segment_description(
417 self._expression_parts[6],
418 '',
419 lambda s: format_year(s),
420 lambda s: self._(", every {0} years").format(s),
421 lambda s: self._(", year {0} through year {1}") or self._(", {0} through {1}"),
422 lambda s: self._(", only in {0}"),
423 lambda s: self._(", year {0} through year {1}") or self._(", {0} through {1}")
424 )
425
426 def get_segment_description(
427 self,
428 expression,
429 all_description,
430 get_single_item_description,
431 get_interval_description_format,
432 get_between_description_format,
433 get_description_format,
434 get_range_format
435 ):
436 """Returns segment description
437 Args:
438 expression: Segment to descript
439 all_description: *
440 get_single_item_description: 1
441 get_interval_description_format: 1/2
442 get_between_description_format: 1-2
443 get_description_format: format get_single_item_description
444 get_range_format: function that formats range expressions depending on cron parts
445 Returns:
446 segment description
447
448 """
449 description = None
450 if expression is None or expression == '':
451 description = ''
452 elif expression == "*":
453 description = all_description
454 elif any(ext in expression for ext in ['/', '-', ',']) is False:
455 description = get_description_format(expression).format(get_single_item_description(expression))
456 elif "/" in expression:
457 segments = expression.split('/')
458 description = get_interval_description_format(segments[1]).format(get_single_item_description(segments[1]))
459
460 # interval contains 'between' piece (i.e. 2-59/3 )
461 if "-" in segments[0]:
462 between_segment_description = self.generate_between_segment_description(
463 segments[0],
464 get_between_description_format,
465 get_single_item_description
466 )
467 if not between_segment_description.startswith(", "):
468 description += ", "
469
470 description += between_segment_description
471 elif any(ext in segments[0] for ext in ['*', ',']) is False:
472 range_item_description = get_description_format(segments[0]).format(
473 get_single_item_description(segments[0])
474 )
475 range_item_description = range_item_description.replace(", ", "")
476
477 description += self._(", starting {0}").format(range_item_description)
478 elif "," in expression:
479 segments = expression.split(',')
480
481 description_content = ''
482 for i, segment in enumerate(segments):
483 if i > 0 and len(segments) > 2:
484 description_content += ","
485
486 if i < len(segments) - 1:
487 description_content += " "
488
489 if i > 0 and len(segments) > 1 and (i == len(segments) - 1 or len(segments) == 2):
490 description_content += self._(" and ")
491
492 if "-" in segment:
493 between_segment_description = self.generate_between_segment_description(
494 segment,
495 get_range_format,
496 get_single_item_description
497 )
498
499 between_segment_description = between_segment_description.replace(", ", "")
500
501 description_content += between_segment_description
502 else:
503 description_content += get_single_item_description(segment)
504
505 description = get_description_format(expression).format(description_content)
506 elif "-" in expression:
507 description = self.generate_between_segment_description(
508 expression,
509 get_between_description_format,
510 get_single_item_description
511 )
512
513 return description
514
515 def generate_between_segment_description(
516 self,
517 between_expression,
518 get_between_description_format,
519 get_single_item_description
520 ):
521 """
522 Generates the between segment description
523 :param between_expression:
524 :param get_between_description_format:
525 :param get_single_item_description:
526 :return: The between segment description
527 """
528 description = ""
529 between_segments = between_expression.split('-')
530 between_segment_1_description = get_single_item_description(between_segments[0])
531 between_segment_2_description = get_single_item_description(between_segments[1])
532 between_segment_2_description = between_segment_2_description.replace(":00", ":59")
533
534 between_description_format = get_between_description_format(between_expression)
535 description += between_description_format.format(between_segment_1_description, between_segment_2_description)
536
537 return description
538
539 def format_time(
540 self,
541 hour_expression,
542 minute_expression,
543 second_expression=''
544 ):
545 """Given time parts, will construct a formatted time description
546 Args:
547 hour_expression: Hours part
548 minute_expression: Minutes part
549 second_expression: Seconds part
550 Returns:
551 Formatted time description
552
553 """
554 hour = int(hour_expression)
555
556 period = ''
557 if self._options.use_24hour_time_format is False:
558 period = self._("PM") if (hour >= 12) else self._("AM")
559 if period:
560 # add preceding space
561 period = " " + period
562
563 if hour > 12:
564 hour -= 12
565
566 if hour == 0:
567 hour = 12
568
569 minute = str(int(minute_expression)) # Removes leading zero if any
570 second = ''
571 if second_expression is not None and second_expression:
572 second = "{}{}".format(":", str(int(second_expression)).zfill(2))
573
574 return "{0}:{1}{2}{3}".format(str(hour).zfill(2), minute.zfill(2), second, period)
575
576 def transform_verbosity(self, description, use_verbose_format):
577 """Transforms the verbosity of the expression description by stripping verbosity from original description
578 Args:
579 description: The description to transform
580 use_verbose_format: If True, will leave description as it, if False, will strip verbose parts
581 Returns:
582 The transformed description with proper verbosity
583
584 """
585 if use_verbose_format is False:
586 description = description.replace(self._(", every minute"), '')
587 description = description.replace(self._(", every hour"), '')
588 description = description.replace(self._(", every day"), '')
589 description = re.sub(r', ?$', '', description)
590 return description
591
592 @staticmethod
593 def transform_case(description, case_type):
594 """Transforms the case of the expression description, based on options
595 Args:
596 description: The description to transform
597 case_type: The casing type that controls the output casing
598 Returns:
599 The transformed description with proper casing
600 """
601 if case_type == CasingTypeEnum.Sentence:
602 description = "{}{}".format(
603 description[0].upper(),
604 description[1:])
605 elif case_type == CasingTypeEnum.Title:
606 description = description.title()
607 else:
608 description = description.lower()
609 return description
610
611 @staticmethod
612 def number_to_day(day_number):
613 """Returns localized day name by its CRON number
614
615 Args:
616 day_number: Number of a day
617 Returns:
618 Day corresponding to day_number
619 Raises:
620 IndexError: When day_number is not found
621 """
622 try:
623 return [
624 calendar.day_name[6],
625 calendar.day_name[0],
626 calendar.day_name[1],
627 calendar.day_name[2],
628 calendar.day_name[3],
629 calendar.day_name[4],
630 calendar.day_name[5]
631 ][day_number]
632 except IndexError:
633 raise IndexError("Day {} is out of range!".format(day_number))
634
635 def __str__(self):
636 return self.get_description()
637
638 def __repr__(self):
639 return self.get_description()
640
641
642def get_description(expression, options=None):
643 """Generates a human readable string for the Cron Expression
644 Args:
645 expression: The cron expression string
646 options: Options to control the output description
647 Returns:
648 The cron expression description
649
650 """
651 descriptor = ExpressionDescriptor(expression, options)
652 return descriptor.get_description(DescriptionTypeEnum.FULL)