ddeutil-workflow 0.0.9__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/api.py +16 -16
- ddeutil/workflow/cli.py +105 -22
- ddeutil/workflow/cron.py +116 -26
- ddeutil/workflow/exceptions.py +3 -0
- ddeutil/workflow/log.py +66 -59
- ddeutil/workflow/on.py +10 -4
- ddeutil/workflow/pipeline.py +267 -223
- ddeutil/workflow/repeat.py +66 -39
- ddeutil/workflow/route.py +59 -38
- ddeutil/workflow/scheduler.py +355 -187
- ddeutil/workflow/stage.py +15 -11
- ddeutil/workflow/utils.py +142 -6
- {ddeutil_workflow-0.0.9.dist-info → ddeutil_workflow-0.0.10.dist-info}/METADATA +17 -108
- ddeutil_workflow-0.0.10.dist-info/RECORD +21 -0
- ddeutil_workflow-0.0.10.dist-info/entry_points.txt +2 -0
- ddeutil/workflow/loader.py +0 -132
- ddeutil_workflow-0.0.9.dist-info/RECORD +0 -22
- ddeutil_workflow-0.0.9.dist-info/entry_points.txt +0 -2
- {ddeutil_workflow-0.0.9.dist-info → ddeutil_workflow-0.0.10.dist-info}/LICENSE +0 -0
- {ddeutil_workflow-0.0.9.dist-info → ddeutil_workflow-0.0.10.dist-info}/WHEEL +0 -0
- {ddeutil_workflow-0.0.9.dist-info → ddeutil_workflow-0.0.10.dist-info}/top_level.txt +0 -0
ddeutil/workflow/__about__.py
CHANGED
@@ -1 +1 @@
|
|
1
|
-
__version__: str = "0.0.
|
1
|
+
__version__: str = "0.0.10"
|
ddeutil/workflow/api.py
CHANGED
@@ -6,7 +6,6 @@
|
|
6
6
|
from __future__ import annotations
|
7
7
|
|
8
8
|
import asyncio
|
9
|
-
import logging
|
10
9
|
import os
|
11
10
|
import uuid
|
12
11
|
from queue import Empty, Queue
|
@@ -18,33 +17,33 @@ from fastapi.middleware.gzip import GZipMiddleware
|
|
18
17
|
from fastapi.responses import UJSONResponse
|
19
18
|
from pydantic import BaseModel
|
20
19
|
|
20
|
+
from .__about__ import __version__
|
21
|
+
from .log import get_logger
|
21
22
|
from .repeat import repeat_every
|
22
23
|
|
23
24
|
load_dotenv()
|
24
|
-
logger =
|
25
|
-
logging.basicConfig(
|
26
|
-
level=logging.DEBUG,
|
27
|
-
format=(
|
28
|
-
"%(asctime)s.%(msecs)03d (%(name)-10s, %(process)-5d, %(thread)-5d) "
|
29
|
-
"[%(levelname)-7s] %(message)-120s (%(filename)s:%(lineno)s)"
|
30
|
-
),
|
31
|
-
handlers=[logging.StreamHandler()],
|
32
|
-
datefmt="%Y-%m-%d %H:%M:%S",
|
33
|
-
)
|
25
|
+
logger = get_logger("ddeutil.workflow")
|
34
26
|
|
35
27
|
|
36
|
-
app = FastAPI(
|
28
|
+
app = FastAPI(
|
29
|
+
titile="Workflow API",
|
30
|
+
description=(
|
31
|
+
"This is workflow FastAPI web application that use to manage manual "
|
32
|
+
"execute or schedule workflow via RestAPI."
|
33
|
+
),
|
34
|
+
version=__version__,
|
35
|
+
)
|
37
36
|
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
38
37
|
app.queue = Queue()
|
39
38
|
app.output_dict = {}
|
40
|
-
app.queue_limit =
|
39
|
+
app.queue_limit = 5
|
41
40
|
|
42
41
|
|
43
42
|
@app.on_event("startup")
|
44
|
-
@repeat_every(seconds=10
|
43
|
+
@repeat_every(seconds=10)
|
45
44
|
def broker_upper_messages():
|
46
45
|
"""Broker for receive message from the `/upper` path and change it to upper
|
47
|
-
case.
|
46
|
+
case. This broker use interval running in background every 10 seconds.
|
48
47
|
"""
|
49
48
|
for _ in range(app.queue_limit):
|
50
49
|
try:
|
@@ -66,11 +65,12 @@ async def get_result(request_id):
|
|
66
65
|
result = app.output_dict[request_id]
|
67
66
|
del app.output_dict[request_id]
|
68
67
|
return {"message": result}
|
69
|
-
await asyncio.sleep(0.
|
68
|
+
await asyncio.sleep(0.0025)
|
70
69
|
|
71
70
|
|
72
71
|
@app.post("/upper", response_class=UJSONResponse)
|
73
72
|
async def message_upper(payload: Payload):
|
73
|
+
"""Convert message from any case to the upper case."""
|
74
74
|
request_id: str = str(uuid.uuid4())
|
75
75
|
app.queue.put(
|
76
76
|
{"text": payload.text, "request_id": request_id},
|
ddeutil/workflow/cli.py
CHANGED
@@ -5,46 +5,129 @@
|
|
5
5
|
# ------------------------------------------------------------------------------
|
6
6
|
from __future__ import annotations
|
7
7
|
|
8
|
-
|
8
|
+
import json
|
9
|
+
import os
|
10
|
+
from datetime import datetime
|
11
|
+
from enum import Enum
|
12
|
+
from typing import Annotated, Optional
|
13
|
+
from zoneinfo import ZoneInfo
|
9
14
|
|
10
|
-
from
|
15
|
+
from ddeutil.core import str2list
|
16
|
+
from typer import Argument, Option, Typer
|
11
17
|
|
18
|
+
from .log import get_logger
|
19
|
+
|
20
|
+
logger = get_logger("ddeutil.workflow")
|
12
21
|
cli: Typer = Typer()
|
13
|
-
|
22
|
+
cli_log: Typer = Typer()
|
23
|
+
cli.add_typer(
|
24
|
+
cli_log,
|
25
|
+
name="log",
|
26
|
+
help="Logging of workflow CLI",
|
27
|
+
)
|
14
28
|
|
15
29
|
|
16
30
|
@cli.command()
|
17
|
-
def run(
|
18
|
-
|
19
|
-
|
20
|
-
|
31
|
+
def run(
|
32
|
+
pipeline: Annotated[
|
33
|
+
str,
|
34
|
+
Argument(help="A pipeline name that want to run manually"),
|
35
|
+
],
|
36
|
+
params: Annotated[
|
37
|
+
str,
|
38
|
+
Argument(
|
39
|
+
help="A json string for parameters of this pipeline execution."
|
40
|
+
),
|
41
|
+
],
|
42
|
+
):
|
43
|
+
"""Run pipeline workflow manually with an input custom parameters that able
|
44
|
+
to receive with pipeline params config.
|
45
|
+
"""
|
46
|
+
logger.info(f"Running pipeline name: {pipeline}")
|
47
|
+
logger.info(f"... with Parameters: {json.dumps(json.loads(params))}")
|
21
48
|
|
22
|
-
print(f"Creating user: {pipeline}")
|
23
49
|
|
24
|
-
|
25
|
-
|
50
|
+
@cli.command()
|
51
|
+
def schedule(
|
52
|
+
stop: Annotated[
|
53
|
+
Optional[datetime],
|
54
|
+
Argument(
|
55
|
+
formats=["%Y-%m-%d", "%Y-%m-%d %H:%M:%S"],
|
56
|
+
help="A stopping datetime that want to stop on schedule app.",
|
57
|
+
),
|
58
|
+
] = None,
|
59
|
+
excluded: Annotated[
|
60
|
+
Optional[str],
|
61
|
+
Argument(help="A list of exclude workflow name in str."),
|
62
|
+
] = None,
|
63
|
+
externals: Annotated[
|
64
|
+
Optional[str],
|
65
|
+
Argument(
|
66
|
+
help="A json string for parameters of this pipeline execution."
|
67
|
+
),
|
68
|
+
] = None,
|
69
|
+
):
|
70
|
+
"""Start workflow scheduler that will call workflow function from scheduler
|
71
|
+
module.
|
72
|
+
"""
|
73
|
+
excluded: list[str] = str2list(excluded) if excluded else []
|
74
|
+
externals: str = externals or "{}"
|
75
|
+
if stop:
|
76
|
+
stop: datetime = stop.astimezone(
|
77
|
+
tz=ZoneInfo(os.getenv("WORKFLOW_CORE_TIMEZONE", "UTC"))
|
78
|
+
)
|
26
79
|
|
80
|
+
from .scheduler import workflow
|
27
81
|
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
82
|
+
# NOTE: Start running workflow scheduler application.
|
83
|
+
workflow_rs: list[str] = workflow(
|
84
|
+
stop=stop, excluded=excluded, externals=json.loads(externals)
|
85
|
+
)
|
86
|
+
logger.info(f"Application run success: {workflow_rs}")
|
87
|
+
|
88
|
+
|
89
|
+
@cli_log.command("pipeline-get")
|
90
|
+
def pipeline_log_get(
|
91
|
+
name: Annotated[
|
92
|
+
str,
|
93
|
+
Argument(help="A pipeline name that want to getting log"),
|
94
|
+
],
|
95
|
+
limit: Annotated[
|
96
|
+
int,
|
97
|
+
Argument(help="A number of the limitation of logging"),
|
98
|
+
] = 100,
|
99
|
+
desc: Annotated[
|
100
|
+
bool,
|
101
|
+
Option(
|
102
|
+
"--desc",
|
103
|
+
help="A descending flag that order by logging release datetime.",
|
104
|
+
),
|
105
|
+
] = True,
|
106
|
+
):
|
107
|
+
logger.info(f"{name} : limit {limit} : desc: {desc}")
|
108
|
+
return [""]
|
109
|
+
|
110
|
+
|
111
|
+
class LogMode(str, Enum):
|
112
|
+
get = "get"
|
113
|
+
delete = "delete"
|
33
114
|
|
34
|
-
print(f"Deleting user: {exclude}")
|
35
115
|
|
36
|
-
|
37
|
-
|
116
|
+
@cli_log.command("pipeline-delete")
|
117
|
+
def pipeline_log_delete(
|
118
|
+
mode: Annotated[
|
119
|
+
LogMode,
|
120
|
+
Argument(case_sensitive=True),
|
121
|
+
]
|
122
|
+
):
|
123
|
+
logger.info(mode)
|
38
124
|
|
39
125
|
|
40
126
|
@cli.callback()
|
41
|
-
def main(
|
127
|
+
def main():
|
42
128
|
"""
|
43
129
|
Manage workflow with CLI.
|
44
130
|
"""
|
45
|
-
if verbose:
|
46
|
-
print("Will write verbose output")
|
47
|
-
state["verbose"] = True
|
48
131
|
|
49
132
|
|
50
133
|
if __name__ == "__main__":
|
ddeutil/workflow/cron.py
CHANGED
@@ -10,7 +10,7 @@ from collections.abc import Iterator
|
|
10
10
|
from dataclasses import dataclass, field
|
11
11
|
from datetime import datetime, timedelta
|
12
12
|
from functools import partial, total_ordering
|
13
|
-
from typing import
|
13
|
+
from typing import ClassVar, Optional, Union
|
14
14
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
15
15
|
|
16
16
|
from ddeutil.core import (
|
@@ -34,6 +34,34 @@ WEEKDAYS: dict[str, int] = {
|
|
34
34
|
}
|
35
35
|
|
36
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
|
+
|
37
65
|
@dataclass(frozen=True)
|
38
66
|
class Unit:
|
39
67
|
name: str
|
@@ -232,17 +260,21 @@ class CronPart:
|
|
232
260
|
:type value: str
|
233
261
|
|
234
262
|
TODO: support for `L`, `W`, and `#`
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
of the
|
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.
|
239
270
|
DEV: use -1 for represent with L
|
240
|
-
TODO: W :
|
241
|
-
|
242
|
-
|
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,
|
243
275
|
the 3 refers to Tuesday because it is the third day of each week.
|
244
276
|
|
245
|
-
|
277
|
+
Examples:
|
246
278
|
- 0 10 * * ? *
|
247
279
|
Run at 10:00 am (UTC) every day
|
248
280
|
|
@@ -275,6 +307,7 @@ class CronPart:
|
|
275
307
|
:rtype: tuple[int, ...]
|
276
308
|
"""
|
277
309
|
interval_list: list[list[int]] = []
|
310
|
+
# NOTE: Start replace alternative like JAN to FEB or MON to SUN.
|
278
311
|
for _value in self.replace_alternative(value.upper()).split(","):
|
279
312
|
if _value == "?":
|
280
313
|
continue
|
@@ -294,11 +327,18 @@ class CronPart:
|
|
294
327
|
f"{self.unit.name!r}"
|
295
328
|
)
|
296
329
|
|
330
|
+
# NOTE: Generate interval that has step
|
297
331
|
interval_list.append(self._interval(value_range_list, value_step))
|
332
|
+
|
298
333
|
return tuple(item for sublist in interval_list for item in sublist)
|
299
334
|
|
300
335
|
def replace_alternative(self, value: str) -> str:
|
301
|
-
"""Replaces the alternative representations of numbers in a string.
|
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
|
+
"""
|
302
342
|
for i, alt in enumerate(self.unit.alt):
|
303
343
|
if alt in value:
|
304
344
|
value: str = value.replace(alt, str(self.unit.min + i))
|
@@ -455,7 +495,7 @@ class CronJob:
|
|
455
495
|
"""The Cron Job Converter object that generate datetime dimension of cron
|
456
496
|
job schedule format,
|
457
497
|
|
458
|
-
|
498
|
+
* * * * * <command to execute>
|
459
499
|
|
460
500
|
(i) minute (0 - 59)
|
461
501
|
(ii) hour (0 - 23)
|
@@ -469,9 +509,20 @@ class CronJob:
|
|
469
509
|
Support special value with `/`, `*`, `-`, `,`, and `?` (in day of month
|
470
510
|
and day of week value).
|
471
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
|
+
|
472
521
|
References:
|
473
522
|
- https://github.com/Sonic0/cron-converter
|
474
523
|
- https://pypi.org/project/python-crontab/
|
524
|
+
- https://docs.aws.amazon.com/glue/latest/dg/ -
|
525
|
+
monitor-data-warehouse-schedule.html
|
475
526
|
"""
|
476
527
|
|
477
528
|
cron_length: int = 5
|
@@ -567,6 +618,10 @@ class CronJob:
|
|
567
618
|
"""Returns the cron schedule as a 2-dimensional list of integers."""
|
568
619
|
return [part.values for part in self.parts]
|
569
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
|
+
|
570
625
|
def schedule(
|
571
626
|
self,
|
572
627
|
date: datetime | None = None,
|
@@ -598,9 +653,12 @@ class CronRunner:
|
|
598
653
|
cron schedule object value.
|
599
654
|
"""
|
600
655
|
|
656
|
+
shift_limit: ClassVar[int] = 25
|
657
|
+
|
601
658
|
__slots__: tuple[str, ...] = (
|
602
659
|
"__start_date",
|
603
660
|
"cron",
|
661
|
+
"is_year",
|
604
662
|
"date",
|
605
663
|
"reset_flag",
|
606
664
|
"tz",
|
@@ -639,6 +697,7 @@ class CronRunner:
|
|
639
697
|
|
640
698
|
self.__start_date: datetime = self.date
|
641
699
|
self.cron: CronJob | CronJobYear = cron
|
700
|
+
self.is_year: bool = isinstance(cron, CronJobYear)
|
642
701
|
self.reset_flag: bool = True
|
643
702
|
|
644
703
|
def reset(self) -> None:
|
@@ -663,45 +722,76 @@ class CronRunner:
|
|
663
722
|
return self.find_date(reverse=True)
|
664
723
|
|
665
724
|
def find_date(self, reverse: bool = False) -> datetime:
|
666
|
-
"""Returns the time the schedule would run by `next` or `prev
|
725
|
+
"""Returns the time the schedule would run by `next` or `prev` methods.
|
667
726
|
|
668
727
|
:param reverse: A reverse flag.
|
669
728
|
"""
|
670
729
|
# NOTE: Set reset flag to false if start any action.
|
671
730
|
self.reset_flag: bool = False
|
672
|
-
|
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
|
673
738
|
if all(
|
674
739
|
not self.__shift_date(mode, reverse)
|
675
|
-
for mode in ("month", "day", "hour", "minute")
|
740
|
+
for mode in ("year", "month", "day", "hour", "minute")
|
676
741
|
):
|
677
742
|
return copy.deepcopy(self.date.replace(second=0, microsecond=0))
|
743
|
+
|
678
744
|
raise RecursionError("Unable to find execution time for schedule")
|
679
745
|
|
680
746
|
def __shift_date(self, mode: str, reverse: bool = False) -> bool:
|
681
|
-
"""Increments the mode
|
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
|
+
"""
|
682
753
|
switch: dict[str, str] = {
|
754
|
+
"year": "year",
|
683
755
|
"month": "year",
|
684
756
|
"day": "month",
|
685
757
|
"hour": "day",
|
686
758
|
"minute": "hour",
|
687
759
|
}
|
688
760
|
current_value: int = getattr(self.date, switch[mode])
|
689
|
-
|
690
|
-
|
691
|
-
|
692
|
-
|
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
|
693
771
|
)
|
694
|
-
|
695
|
-
|
696
|
-
|
697
|
-
|
698
|
-
|
699
|
-
|
700
|
-
|
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.
|
701
786
|
self.date: datetime = next_date(self.date, mode, reverse=reverse)
|
787
|
+
|
788
|
+
# NOTE: Replace date that less than it mode to zero.
|
702
789
|
self.date: datetime = replace_date(self.date, mode, reverse=reverse)
|
790
|
+
|
703
791
|
if current_value != getattr(self.date, switch[mode]):
|
704
792
|
return mode != "month"
|
793
|
+
|
794
|
+
# NOTE: Return False if the date that match with condition.
|
705
795
|
return False
|
706
796
|
|
707
797
|
|