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