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.
- ddeutil/workflow/__about__.py +1 -1
- ddeutil/workflow/__init__.py +3 -14
- ddeutil/workflow/api.py +44 -75
- ddeutil/workflow/cli.py +134 -0
- ddeutil/workflow/cron.py +803 -0
- ddeutil/workflow/exceptions.py +3 -0
- ddeutil/workflow/log.py +152 -47
- ddeutil/workflow/on.py +27 -18
- ddeutil/workflow/pipeline.py +527 -234
- ddeutil/workflow/repeat.py +71 -40
- ddeutil/workflow/route.py +77 -63
- ddeutil/workflow/scheduler.py +523 -616
- ddeutil/workflow/stage.py +158 -82
- ddeutil/workflow/utils.py +273 -46
- ddeutil_workflow-0.0.10.dist-info/METADATA +182 -0
- ddeutil_workflow-0.0.10.dist-info/RECORD +21 -0
- {ddeutil_workflow-0.0.8.dist-info → ddeutil_workflow-0.0.10.dist-info}/WHEEL +1 -1
- ddeutil_workflow-0.0.10.dist-info/entry_points.txt +2 -0
- ddeutil/workflow/app.py +0 -45
- ddeutil/workflow/loader.py +0 -80
- ddeutil_workflow-0.0.8.dist-info/METADATA +0 -266
- ddeutil_workflow-0.0.8.dist-info/RECORD +0 -20
- {ddeutil_workflow-0.0.8.dist-info → ddeutil_workflow-0.0.10.dist-info}/LICENSE +0 -0
- {ddeutil_workflow-0.0.8.dist-info → ddeutil_workflow-0.0.10.dist-info}/top_level.txt +0 -0
ddeutil/workflow/cron.py
ADDED
@@ -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
|
+
)
|