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