ddeutil-workflow 0.0.8__py3-none-any.whl → 0.0.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,803 @@
1
+ # ------------------------------------------------------------------------------
2
+ # Copyright (c) 2022 Korawich Anuttra. All rights reserved.
3
+ # Licensed under the MIT License. See LICENSE in the project root for
4
+ # license information.
5
+ # ------------------------------------------------------------------------------
6
+ from __future__ import annotations
7
+
8
+ import copy
9
+ from collections.abc import Iterator
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime, timedelta
12
+ from functools import partial, total_ordering
13
+ from typing import ClassVar, Optional, Union
14
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
15
+
16
+ from ddeutil.core import (
17
+ is_int,
18
+ isinstance_check,
19
+ must_split,
20
+ )
21
+ from ddeutil.core.dtutils import (
22
+ next_date,
23
+ replace_date,
24
+ )
25
+
26
+ WEEKDAYS: dict[str, int] = {
27
+ "Sun": 0,
28
+ "Mon": 1,
29
+ "Tue": 2,
30
+ "Wed": 3,
31
+ "Thu": 4,
32
+ "Fri": 5,
33
+ "Sat": 6,
34
+ }
35
+
36
+
37
+ class CronYearLimit(Exception): ...
38
+
39
+
40
+ def str2cron(value: str) -> str:
41
+ """Convert Special String to Crontab.
42
+
43
+ @reboot Run once, at system startup
44
+ @yearly Run once every year, "0 0 1 1 *"
45
+ @annually (same as @yearly)
46
+ @monthly Run once every month, "0 0 1 * *"
47
+ @weekly Run once every week, "0 0 * * 0"
48
+ @daily Run once each day, "0 0 * * *"
49
+ @midnight (same as @daily)
50
+ @hourly Run once an hour, "0 * * * *"
51
+ """
52
+ mapping_spacial_str = {
53
+ "@reboot": "",
54
+ "@yearly": "0 0 1 1 *",
55
+ "@annually": "0 0 1 1 *",
56
+ "@monthly": "0 0 1 * *",
57
+ "@weekly": "0 0 * * 0",
58
+ "@daily": "0 0 * * *",
59
+ "@midnight": "0 0 * * *",
60
+ "@hourly": "0 * * * *",
61
+ }
62
+ return mapping_spacial_str[value]
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class Unit:
67
+ name: str
68
+ range: partial
69
+ min: int
70
+ max: int
71
+ alt: list[str] = field(default_factory=list)
72
+
73
+ def __repr__(self) -> str:
74
+ return (
75
+ f"{self.__class__}(name={self.name!r}, range={self.range},"
76
+ f"min={self.min}, max={self.max}"
77
+ f"{f', alt={self.alt}' if self.alt else ''})"
78
+ )
79
+
80
+
81
+ @dataclass
82
+ class Options:
83
+ output_weekday_names: bool = False
84
+ output_month_names: bool = False
85
+ output_hashes: bool = False
86
+
87
+
88
+ CRON_UNITS: tuple[Unit, ...] = (
89
+ Unit(
90
+ name="minute",
91
+ range=partial(range, 0, 60),
92
+ min=0,
93
+ max=59,
94
+ ),
95
+ Unit(
96
+ name="hour",
97
+ range=partial(range, 0, 24),
98
+ min=0,
99
+ max=23,
100
+ ),
101
+ Unit(
102
+ name="day",
103
+ range=partial(range, 1, 32),
104
+ min=1,
105
+ max=31,
106
+ ),
107
+ Unit(
108
+ name="month",
109
+ range=partial(range, 1, 13),
110
+ min=1,
111
+ max=12,
112
+ alt=[
113
+ "JAN",
114
+ "FEB",
115
+ "MAR",
116
+ "APR",
117
+ "MAY",
118
+ "JUN",
119
+ "JUL",
120
+ "AUG",
121
+ "SEP",
122
+ "OCT",
123
+ "NOV",
124
+ "DEC",
125
+ ],
126
+ ),
127
+ Unit(
128
+ name="weekday",
129
+ range=partial(range, 0, 7),
130
+ min=0,
131
+ max=6,
132
+ alt=[
133
+ "SUN",
134
+ "MON",
135
+ "TUE",
136
+ "WED",
137
+ "THU",
138
+ "FRI",
139
+ "SAT",
140
+ ],
141
+ ),
142
+ )
143
+
144
+ CRON_UNITS_YEAR: tuple[Unit, ...] = CRON_UNITS + (
145
+ Unit(
146
+ name="year",
147
+ range=partial(range, 1990, 2101),
148
+ min=1990,
149
+ max=2100,
150
+ ),
151
+ )
152
+
153
+
154
+ @total_ordering
155
+ class CronPart:
156
+ """Part of Cron object that represent a collection of positive integers."""
157
+
158
+ __slots__: tuple[str, ...] = (
159
+ "unit",
160
+ "options",
161
+ "values",
162
+ )
163
+
164
+ def __init__(
165
+ self,
166
+ unit: Unit,
167
+ values: str | list[int],
168
+ options: Options,
169
+ ) -> None:
170
+ self.unit: Unit = unit
171
+ self.options: Options = options
172
+
173
+ if isinstance(values, str):
174
+ values: list[int] = self.from_str(values) if values != "?" else []
175
+ elif isinstance_check(values, list[int]):
176
+ values: list[int] = self.replace_weekday(values)
177
+ else:
178
+ raise TypeError(f"Invalid type of value in cron part: {values}.")
179
+
180
+ self.values: list[int] = self.out_of_range(
181
+ sorted(dict.fromkeys(values))
182
+ )
183
+
184
+ def __str__(self) -> str:
185
+ """Generate String value from part of cronjob."""
186
+ _hash: str = "H" if self.options.output_hashes else "*"
187
+
188
+ if self.is_full:
189
+ return _hash
190
+
191
+ if self.is_interval:
192
+ if self.is_full_interval:
193
+ return f"{_hash}/{self.step}"
194
+ _hash: str = (
195
+ f"H({self.filler(self.min)}-{self.filler(self.max)})"
196
+ if _hash == "H"
197
+ else f"{self.filler(self.min)}-{self.filler(self.max)}"
198
+ )
199
+ return f"{_hash}/{self.step}"
200
+
201
+ cron_range_strings: list[str] = []
202
+ for cron_range in self.ranges():
203
+ if isinstance(cron_range, list):
204
+ cron_range_strings.append(
205
+ f"{self.filler(cron_range[0])}-{self.filler(cron_range[1])}"
206
+ )
207
+ else:
208
+ cron_range_strings.append(f"{self.filler(cron_range)}")
209
+ return ",".join(cron_range_strings) if cron_range_strings else "?"
210
+
211
+ def __repr__(self) -> str:
212
+ return (
213
+ f"{self.__class__.__name__}"
214
+ f"(unit={self.unit}, values={self.__str__()!r})"
215
+ )
216
+
217
+ def __lt__(self, other) -> bool:
218
+ if isinstance(other, CronPart):
219
+ return self.values < other.values
220
+ elif isinstance(other, list):
221
+ return self.values < other
222
+
223
+ def __eq__(self, other) -> bool:
224
+ if isinstance(other, CronPart):
225
+ return self.values == other.values
226
+ elif isinstance(other, list):
227
+ return self.values == other
228
+
229
+ @property
230
+ def min(self) -> int:
231
+ """Returns the smallest value in the range."""
232
+ return self.values[0]
233
+
234
+ @property
235
+ def max(self) -> int:
236
+ """Returns the largest value in the range."""
237
+ return self.values[-1]
238
+
239
+ @property
240
+ def step(self) -> Optional[int]:
241
+ """Returns the difference between first and second elements in the
242
+ range.
243
+ """
244
+ if (
245
+ len(self.values) > 2
246
+ and (step := self.values[1] - self.values[0]) > 1
247
+ ):
248
+ return step
249
+
250
+ @property
251
+ def is_full(self) -> bool:
252
+ """Returns true if range has all the values of the unit."""
253
+ return len(self.values) == (self.unit.max - self.unit.min + 1)
254
+
255
+ def from_str(self, value: str) -> tuple[int, ...]:
256
+ """Parses a string as a range of positive integers. The string should
257
+ include only `-` and `,` special strings.
258
+
259
+ :param value: A string value that want to parse
260
+ :type value: str
261
+
262
+ TODO: support for `L`, `W`, and `#`
263
+ ---
264
+ TODO: The ? (question mark) wildcard specifies one or another.
265
+ In the Day-of-month field you could enter 7, and if you didn't care
266
+ what day of the week the seventh was, you could enter ? in the
267
+ Day-of-week field.
268
+ TODO: L : The L wildcard in the Day-of-month or Day-of-week fields
269
+ specifies the last day of the month or week.
270
+ DEV: use -1 for represent with L
271
+ TODO: W : The W wildcard in the Day-of-month field specifies a weekday.
272
+ In the Day-of-month field, 3W specifies the weekday closest to the
273
+ third day of the month.
274
+ TODO: # : 3#2 would be the second Tuesday of every month,
275
+ the 3 refers to Tuesday because it is the third day of each week.
276
+
277
+ Examples:
278
+ - 0 10 * * ? *
279
+ Run at 10:00 am (UTC) every day
280
+
281
+ - 15 12 * * ? *
282
+ Run at 12:15 pm (UTC) every day
283
+
284
+ - 0 18 ? * MON-FRI *
285
+ Run at 6:00 pm (UTC) every Monday through Friday
286
+
287
+ - 0 8 1 * ? *
288
+ Run at 8:00 am (UTC) every 1st day of the month
289
+
290
+ - 0/15 * * * ? *
291
+ Run every 15 minutes
292
+
293
+ - 0/10 * ? * MON-FRI *
294
+ Run every 10 minutes Monday through Friday
295
+
296
+ - 0/5 8-17 ? * MON-FRI *
297
+ Run every 5 minutes Monday through Friday between 8:00 am and
298
+ 5:55 pm (UTC)
299
+
300
+ - 5,35 14 * * ? *
301
+ Run every day, at 5 and 35 minutes past 2:00 pm (UTC)
302
+
303
+ - 15 10 ? * 6L 2002-2005
304
+ Run at 10:15am UTC on the last Friday of each month during the
305
+ years 2002 to 2005
306
+
307
+ :rtype: tuple[int, ...]
308
+ """
309
+ interval_list: list[list[int]] = []
310
+ # NOTE: Start replace alternative like JAN to FEB or MON to SUN.
311
+ for _value in self.replace_alternative(value.upper()).split(","):
312
+ if _value == "?":
313
+ continue
314
+ elif _value.count("/") > 1:
315
+ raise ValueError(
316
+ f"Invalid value {_value!r} in cron part {value!r}"
317
+ )
318
+
319
+ value_range, value_step = must_split(_value, "/", maxsplit=1)
320
+ value_range_list: list[int] = self.out_of_range(
321
+ self._parse_range(value_range)
322
+ )
323
+
324
+ if (value_step and not is_int(value_step)) or value_step == "":
325
+ raise ValueError(
326
+ f"Invalid interval step value {value_step!r} for "
327
+ f"{self.unit.name!r}"
328
+ )
329
+
330
+ # NOTE: Generate interval that has step
331
+ interval_list.append(self._interval(value_range_list, value_step))
332
+
333
+ return tuple(item for sublist in interval_list for item in sublist)
334
+
335
+ def replace_alternative(self, value: str) -> str:
336
+ """Replaces the alternative representations of numbers in a string.
337
+
338
+ For example if value == 'JAN,AUG' it will replace to '1,8'.
339
+
340
+ :param value: A string value that want to replace alternative to int.
341
+ """
342
+ for i, alt in enumerate(self.unit.alt):
343
+ if alt in value:
344
+ value: str = value.replace(alt, str(self.unit.min + i))
345
+ return value
346
+
347
+ def replace_weekday(self, values: list[int] | Iterator[int]) -> list[int]:
348
+ """Replaces all 7 with 0 as Sunday can be represented by both.
349
+
350
+ :param values: list or iter of int that want to mode by 7
351
+ :rtype: list[int]
352
+ """
353
+ if self.unit.name == "weekday":
354
+ # NOTE: change weekday value in range 0-6 (div-mod by 7).
355
+ return [value % 7 for value in values]
356
+ return list(values)
357
+
358
+ def out_of_range(self, values: list[int]) -> list[int]:
359
+ """Return an integer is a value out of range was found, otherwise None.
360
+
361
+ :param values: A list of int value
362
+ :type values: list[int]
363
+
364
+ :rtype: list[int]
365
+ """
366
+ if values:
367
+ if (first := values[0]) < self.unit.min:
368
+ raise ValueError(
369
+ f"Value {first!r} out of range for {self.unit.name!r}"
370
+ )
371
+ elif (last := values[-1]) > self.unit.max:
372
+ raise ValueError(
373
+ f"Value {last!r} out of range for {self.unit.name!r}"
374
+ )
375
+ return values
376
+
377
+ def _parse_range(self, value: str) -> list[int]:
378
+ """Parses a range string."""
379
+ if value == "*":
380
+ return list(self.unit.range())
381
+ elif value.count("-") > 1:
382
+ raise ValueError(f"Invalid value {value}")
383
+ try:
384
+ sub_parts: list[int] = list(map(int, value.split("-")))
385
+ except ValueError as exc:
386
+ raise ValueError(f"Invalid value {value!r} --> {exc}") from exc
387
+
388
+ if len(sub_parts) == 2:
389
+ min_value, max_value = sub_parts
390
+ if max_value < min_value:
391
+ raise ValueError(f"Max range is less than min range in {value}")
392
+ sub_parts: list[int] = list(range(min_value, max_value + 1))
393
+ return self.replace_weekday(sub_parts)
394
+
395
+ def _interval(
396
+ self,
397
+ values: list[int],
398
+ step: int | None = None,
399
+ ) -> list[int]:
400
+ """Applies an interval step to a collection of values."""
401
+ if not step:
402
+ return values
403
+ elif (_step := int(step)) < 1:
404
+ raise ValueError(
405
+ f"Invalid interval step value {_step!r} for "
406
+ f"{self.unit.name!r}"
407
+ )
408
+ min_value: int = values[0]
409
+ return [
410
+ value
411
+ for value in values
412
+ if (value % _step == min_value % _step) or (value == min_value)
413
+ ]
414
+
415
+ @property
416
+ def is_interval(self) -> bool:
417
+ """Returns true if the range can be represented as an interval."""
418
+ if not (step := self.step):
419
+ return False
420
+ for idx, value in enumerate(self.values):
421
+ if idx == 0:
422
+ continue
423
+ elif (value - self.values[idx - 1]) != step:
424
+ return False
425
+ return True
426
+
427
+ @property
428
+ def is_full_interval(self) -> bool:
429
+ """Returns true if the range contains all the interval values."""
430
+ if step := self.step:
431
+ return (
432
+ self.min == self.unit.min
433
+ and (self.max + step) > self.unit.max
434
+ and (
435
+ len(self.values)
436
+ == (round((self.max - self.min) / step) + 1)
437
+ )
438
+ )
439
+ return False
440
+
441
+ def ranges(self) -> list[Union[int, list[int]]]:
442
+ """Returns the range as an array of ranges defined as arrays of
443
+ positive integers.
444
+
445
+ :rtype: list[Union[int, list[int]]]
446
+ """
447
+ multi_dim_values: list[Union[int, list[int]]] = []
448
+ start_number: Optional[int] = None
449
+ for idx, value in enumerate(self.values):
450
+ try:
451
+ next_value: int = self.values[idx + 1]
452
+ except IndexError:
453
+ next_value: int = -1
454
+ if value != (next_value - 1):
455
+ # NOTE: ``next_value`` is not the subsequent number
456
+ if start_number is None:
457
+ # NOTE:
458
+ # The last number of the list ``self.values`` is not in a
459
+ # range.
460
+ multi_dim_values.append(value)
461
+ else:
462
+ multi_dim_values.append([start_number, value])
463
+ start_number: Optional[int] = None
464
+ elif start_number is None:
465
+ start_number: Optional[int] = value
466
+ return multi_dim_values
467
+
468
+ def filler(self, value: int) -> int | str:
469
+ """Formats weekday and month names as string when the relevant options
470
+ are set.
471
+
472
+ :param value: a int value
473
+ :type value: int
474
+
475
+ :rtype: int | str
476
+ """
477
+ return (
478
+ self.unit.alt[value - self.unit.min]
479
+ if (
480
+ (
481
+ self.options.output_weekday_names
482
+ and self.unit.name == "weekday"
483
+ )
484
+ or (
485
+ self.options.output_month_names
486
+ and self.unit.name == "month"
487
+ )
488
+ )
489
+ else value
490
+ )
491
+
492
+
493
+ @total_ordering
494
+ class CronJob:
495
+ """The Cron Job Converter object that generate datetime dimension of cron
496
+ job schedule format,
497
+
498
+ * * * * * <command to execute>
499
+
500
+ (i) minute (0 - 59)
501
+ (ii) hour (0 - 23)
502
+ (iii) day of the month (1 - 31)
503
+ (iv) month (1 - 12)
504
+ (v) day of the week (0 - 6) (Sunday to Saturday; 7 is also Sunday
505
+ on some systems)
506
+
507
+ This object implement necessary methods and properties for using cron
508
+ job value with other object like Schedule.
509
+ Support special value with `/`, `*`, `-`, `,`, and `?` (in day of month
510
+ and day of week value).
511
+
512
+ Fields | Values | Wildcards
513
+ --- | --- | ---
514
+ Minutes | 0–59 | , - * /
515
+ Hours | 0–23 | , - * /
516
+ Day-of-month | 1–31 | , - * ? / L W
517
+ Month | 1–12 or JAN-DEC | , - * /
518
+ Day-of-week | 1–7 or SUN-SAT | , - * ? / L
519
+ Year | 1970–2199 | , - * /
520
+
521
+ References:
522
+ - https://github.com/Sonic0/cron-converter
523
+ - https://pypi.org/project/python-crontab/
524
+ - https://docs.aws.amazon.com/glue/latest/dg/ -
525
+ monitor-data-warehouse-schedule.html
526
+ """
527
+
528
+ cron_length: int = 5
529
+ cron_units: tuple[Unit, ...] = CRON_UNITS
530
+
531
+ def __init__(
532
+ self,
533
+ value: Union[list[list[int]], str],
534
+ *,
535
+ option: Optional[dict[str, bool]] = None,
536
+ ) -> None:
537
+ if isinstance(value, str):
538
+ value: list[str] = value.strip().split()
539
+ elif not isinstance_check(value, list[list[int]]):
540
+ raise TypeError(
541
+ f"{self.__class__.__name__} cron value does not support "
542
+ f"type: {type(value)}."
543
+ )
544
+
545
+ # NOTE: Validate length of crontab of this class.
546
+ if len(value) != self.cron_length:
547
+ raise ValueError(
548
+ f"Invalid cron value does not have length equal "
549
+ f"{self.cron_length}: {value}."
550
+ )
551
+ self.options: Options = Options(**(option or {}))
552
+
553
+ # NOTE: Start initial crontab for each part
554
+ self.parts: list[CronPart] = [
555
+ CronPart(unit, values=item, options=self.options)
556
+ for item, unit in zip(value, self.cron_units)
557
+ ]
558
+
559
+ # NOTE: Validate values of `day` and `dow` from parts.
560
+ if self.day == self.dow == []:
561
+ raise ValueError(
562
+ "Invalid cron value when set the `?` on day of month and "
563
+ "day of week together"
564
+ )
565
+
566
+ def __str__(self) -> str:
567
+ """Return joining with space of each value in parts."""
568
+ return " ".join(str(part) for part in self.parts)
569
+
570
+ def __repr__(self) -> str:
571
+ return (
572
+ f"{self.__class__.__name__}(value={self.__str__()!r}, "
573
+ f"option={self.options.__dict__})"
574
+ )
575
+
576
+ def __lt__(self, other) -> bool:
577
+ return any(
578
+ part < other_part
579
+ for part, other_part in zip(self.parts_order, other.parts_order)
580
+ )
581
+
582
+ def __eq__(self, other) -> bool:
583
+ return all(
584
+ part == other_part
585
+ for part, other_part in zip(self.parts, other.parts)
586
+ )
587
+
588
+ @property
589
+ def parts_order(self) -> Iterator[CronPart]:
590
+ return reversed(self.parts[:3] + [self.parts[4], self.parts[3]])
591
+
592
+ @property
593
+ def minute(self) -> CronPart:
594
+ """Return part of minute."""
595
+ return self.parts[0]
596
+
597
+ @property
598
+ def hour(self) -> CronPart:
599
+ """Return part of hour."""
600
+ return self.parts[1]
601
+
602
+ @property
603
+ def day(self) -> CronPart:
604
+ """Return part of day."""
605
+ return self.parts[2]
606
+
607
+ @property
608
+ def month(self) -> CronPart:
609
+ """Return part of month."""
610
+ return self.parts[3]
611
+
612
+ @property
613
+ def dow(self) -> CronPart:
614
+ """Return part of day of month."""
615
+ return self.parts[4]
616
+
617
+ def to_list(self) -> list[list[int]]:
618
+ """Returns the cron schedule as a 2-dimensional list of integers."""
619
+ return [part.values for part in self.parts]
620
+
621
+ def check(self, date: datetime, mode: str) -> bool:
622
+ assert mode in ("year", "month", "day", "hour", "minute")
623
+ return getattr(date, mode) in getattr(self, mode).values
624
+
625
+ def schedule(
626
+ self,
627
+ date: datetime | None = None,
628
+ *,
629
+ tz: str | None = None,
630
+ ) -> CronRunner:
631
+ """Returns the schedule datetime runner with this cronjob. It would run
632
+ ``next``, ``prev``, or ``reset`` to generate running date that you want.
633
+
634
+ :param date: An initial date that want to mark as the start point.
635
+ :param tz: A string timezone that want to change on runner.
636
+ :rtype: CronRunner
637
+ """
638
+ return CronRunner(self, date, tz=tz)
639
+
640
+
641
+ class CronJobYear(CronJob):
642
+ cron_length = 6
643
+ cron_units = CRON_UNITS_YEAR
644
+
645
+ @property
646
+ def year(self) -> CronPart:
647
+ """Return part of year."""
648
+ return self.parts[5]
649
+
650
+
651
+ class CronRunner:
652
+ """Create an instance of Date Runner object for datetime generate with
653
+ cron schedule object value.
654
+ """
655
+
656
+ shift_limit: ClassVar[int] = 25
657
+
658
+ __slots__: tuple[str, ...] = (
659
+ "__start_date",
660
+ "cron",
661
+ "is_year",
662
+ "date",
663
+ "reset_flag",
664
+ "tz",
665
+ )
666
+
667
+ def __init__(
668
+ self,
669
+ cron: CronJob | CronJobYear,
670
+ date: datetime | None = None,
671
+ *,
672
+ tz: str | None = None,
673
+ ) -> None:
674
+ # NOTE: Prepare timezone if this value does not set, it will use UTC.
675
+ self.tz: ZoneInfo = ZoneInfo("UTC")
676
+ if tz:
677
+ try:
678
+ self.tz = ZoneInfo(tz)
679
+ except ZoneInfoNotFoundError as err:
680
+ raise ValueError(f"Invalid timezone: {tz}") from err
681
+
682
+ # NOTE: Prepare date
683
+ if date:
684
+ if not isinstance(date, datetime):
685
+ raise ValueError(
686
+ "Input schedule start time is not a valid datetime object."
687
+ )
688
+ if tz is None:
689
+ self.tz = date.tzinfo
690
+ self.date: datetime = date.astimezone(self.tz)
691
+ else:
692
+ self.date: datetime = datetime.now(tz=self.tz)
693
+
694
+ # NOTE: Add one minute if the second value more than 0.
695
+ if self.date.second > 0:
696
+ self.date: datetime = self.date + timedelta(minutes=1)
697
+
698
+ self.__start_date: datetime = self.date
699
+ self.cron: CronJob | CronJobYear = cron
700
+ self.is_year: bool = isinstance(cron, CronJobYear)
701
+ self.reset_flag: bool = True
702
+
703
+ def reset(self) -> None:
704
+ """Resets the iterator to start time."""
705
+ self.date: datetime = self.__start_date
706
+ self.reset_flag: bool = True
707
+
708
+ @property
709
+ def next(self) -> datetime:
710
+ """Returns the next time of the schedule."""
711
+ self.date = (
712
+ self.date
713
+ if self.reset_flag
714
+ else (self.date + timedelta(minutes=+1))
715
+ )
716
+ return self.find_date(reverse=False)
717
+
718
+ @property
719
+ def prev(self) -> datetime:
720
+ """Returns the previous time of the schedule."""
721
+ self.date: datetime = self.date + timedelta(minutes=-1)
722
+ return self.find_date(reverse=True)
723
+
724
+ def find_date(self, reverse: bool = False) -> datetime:
725
+ """Returns the time the schedule would run by `next` or `prev` methods.
726
+
727
+ :param reverse: A reverse flag.
728
+ """
729
+ # NOTE: Set reset flag to false if start any action.
730
+ self.reset_flag: bool = False
731
+
732
+ # NOTE: For loop with 25 times by default.
733
+ for _ in range(
734
+ max(self.shift_limit, 100) if self.is_year else self.shift_limit
735
+ ):
736
+
737
+ # NOTE: Shift the date
738
+ if all(
739
+ not self.__shift_date(mode, reverse)
740
+ for mode in ("year", "month", "day", "hour", "minute")
741
+ ):
742
+ return copy.deepcopy(self.date.replace(second=0, microsecond=0))
743
+
744
+ raise RecursionError("Unable to find execution time for schedule")
745
+
746
+ def __shift_date(self, mode: str, reverse: bool = False) -> bool:
747
+ """Increments the mode of date value ("month", "day", "hour", "minute")
748
+ until matches with the schedule.
749
+
750
+ :param mode: A mode of date that want to shift.
751
+ :param reverse: A flag that define shifting next or previous
752
+ """
753
+ switch: dict[str, str] = {
754
+ "year": "year",
755
+ "month": "year",
756
+ "day": "month",
757
+ "hour": "day",
758
+ "minute": "hour",
759
+ }
760
+ current_value: int = getattr(self.date, switch[mode])
761
+
762
+ if not self.is_year and mode == "year":
763
+ return False
764
+
765
+ # NOTE: Additional condition for weekdays
766
+ def addition_cond(dt: datetime) -> bool:
767
+ return (
768
+ WEEKDAYS.get(dt.strftime("%a")) not in self.cron.dow.values
769
+ if mode == "day"
770
+ else False
771
+ )
772
+
773
+ # NOTE:
774
+ # Start while-loop for checking this date include in this cronjob.
775
+ while not self.cron.check(self.date, mode) or addition_cond(self.date):
776
+ if mode == "year" and (
777
+ getattr(self.date, mode)
778
+ > (max_year := max(self.cron.year.values))
779
+ ):
780
+ raise CronYearLimit(
781
+ f"The year is out of limit with this crontab value: "
782
+ f"{max_year}."
783
+ )
784
+
785
+ # NOTE: Shift date with it mode matrix unit.
786
+ self.date: datetime = next_date(self.date, mode, reverse=reverse)
787
+
788
+ # NOTE: Replace date that less than it mode to zero.
789
+ self.date: datetime = replace_date(self.date, mode, reverse=reverse)
790
+
791
+ if current_value != getattr(self.date, switch[mode]):
792
+ return mode != "month"
793
+
794
+ # NOTE: Return False if the date that match with condition.
795
+ return False
796
+
797
+
798
+ __all__ = (
799
+ "CronJob",
800
+ "CronJobYear",
801
+ "CronRunner",
802
+ "WEEKDAYS",
803
+ )