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.
- ddeutil/workflow/__about__.py +1 -1
- ddeutil/workflow/__init__.py +3 -14
- ddeutil/workflow/api.py +44 -75
- ddeutil/workflow/cli.py +51 -0
- ddeutil/workflow/cron.py +713 -0
- ddeutil/workflow/exceptions.py +1 -4
- ddeutil/workflow/loader.py +65 -13
- ddeutil/workflow/log.py +164 -17
- ddeutil/workflow/on.py +18 -15
- ddeutil/workflow/pipeline.py +644 -235
- ddeutil/workflow/repeat.py +9 -5
- ddeutil/workflow/route.py +30 -37
- ddeutil/workflow/scheduler.py +398 -659
- ddeutil/workflow/stage.py +269 -103
- ddeutil/workflow/utils.py +198 -29
- ddeutil_workflow-0.0.9.dist-info/METADATA +273 -0
- ddeutil_workflow-0.0.9.dist-info/RECORD +22 -0
- {ddeutil_workflow-0.0.7.dist-info → ddeutil_workflow-0.0.9.dist-info}/WHEEL +1 -1
- ddeutil_workflow-0.0.9.dist-info/entry_points.txt +2 -0
- ddeutil/workflow/app.py +0 -41
- ddeutil_workflow-0.0.7.dist-info/METADATA +0 -341
- ddeutil_workflow-0.0.7.dist-info/RECORD +0 -20
- {ddeutil_workflow-0.0.7.dist-info → ddeutil_workflow-0.0.9.dist-info}/LICENSE +0 -0
- {ddeutil_workflow-0.0.7.dist-info → ddeutil_workflow-0.0.9.dist-info}/top_level.txt +0 -0
ddeutil/workflow/cron.py
ADDED
@@ -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
|
+
)
|