ddeutil-workflow 0.0.5__py3-none-any.whl → 0.0.7__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 +31 -0
- ddeutil/workflow/__types.py +53 -1
- ddeutil/workflow/api.py +120 -0
- ddeutil/workflow/app.py +41 -0
- ddeutil/workflow/exceptions.py +16 -1
- ddeutil/workflow/loader.py +13 -115
- ddeutil/workflow/log.py +30 -0
- ddeutil/workflow/on.py +78 -26
- ddeutil/workflow/pipeline.py +599 -414
- ddeutil/workflow/repeat.py +134 -0
- ddeutil/workflow/route.py +78 -0
- ddeutil/workflow/{__scheduler.py → scheduler.py} +73 -45
- ddeutil/workflow/stage.py +431 -0
- ddeutil/workflow/utils.py +442 -48
- {ddeutil_workflow-0.0.5.dist-info → ddeutil_workflow-0.0.7.dist-info}/METADATA +144 -68
- ddeutil_workflow-0.0.7.dist-info/RECORD +20 -0
- {ddeutil_workflow-0.0.5.dist-info → ddeutil_workflow-0.0.7.dist-info}/WHEEL +1 -1
- ddeutil/workflow/__regex.py +0 -44
- ddeutil/workflow/tasks/__init__.py +0 -6
- ddeutil/workflow/tasks/dummy.py +0 -52
- ddeutil_workflow-0.0.5.dist-info/RECORD +0 -17
- {ddeutil_workflow-0.0.5.dist-info → ddeutil_workflow-0.0.7.dist-info}/LICENSE +0 -0
- {ddeutil_workflow-0.0.5.dist-info → ddeutil_workflow-0.0.7.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,134 @@
|
|
1
|
+
# ------------------------------------------------------------------------------
|
2
|
+
# Copyright (c) 2023 Priyanshu Panwar. All rights reserved.
|
3
|
+
# Licensed under the MIT License.
|
4
|
+
# This code refs from: https://github.com/priyanshu-panwar/fastapi-utilities
|
5
|
+
# ------------------------------------------------------------------------------
|
6
|
+
import asyncio
|
7
|
+
import logging
|
8
|
+
from asyncio import ensure_future
|
9
|
+
from datetime import datetime
|
10
|
+
from functools import wraps
|
11
|
+
|
12
|
+
from croniter import croniter
|
13
|
+
from starlette.concurrency import run_in_threadpool
|
14
|
+
|
15
|
+
|
16
|
+
def get_delta(cron: str):
|
17
|
+
"""This function returns the time delta between now and the next cron
|
18
|
+
execution time.
|
19
|
+
"""
|
20
|
+
now: datetime = datetime.now()
|
21
|
+
cron = croniter(cron, now)
|
22
|
+
return (cron.get_next(datetime) - now).total_seconds()
|
23
|
+
|
24
|
+
|
25
|
+
def repeat_at(
|
26
|
+
*,
|
27
|
+
cron: str,
|
28
|
+
logger: logging.Logger = None,
|
29
|
+
raise_exceptions: bool = False,
|
30
|
+
max_repetitions: int = None,
|
31
|
+
):
|
32
|
+
"""This function returns a decorator that makes a function execute
|
33
|
+
periodically as per the cron expression provided.
|
34
|
+
|
35
|
+
:param cron: str
|
36
|
+
Cron-style string for periodic execution, eg. '0 0 * * *' every midnight
|
37
|
+
:param logger: logging.Logger (default None)
|
38
|
+
Logger object to log exceptions
|
39
|
+
:param raise_exceptions: bool (default False)
|
40
|
+
Whether to raise exceptions or log them
|
41
|
+
:param max_repetitions: int (default None)
|
42
|
+
Maximum number of times to repeat the function. If None, repeat
|
43
|
+
indefinitely.
|
44
|
+
|
45
|
+
"""
|
46
|
+
|
47
|
+
def decorator(func):
|
48
|
+
is_coroutine = asyncio.iscoroutinefunction(func)
|
49
|
+
|
50
|
+
@wraps(func)
|
51
|
+
def wrapper(*_args, **_kwargs):
|
52
|
+
repititions = 0
|
53
|
+
if not croniter.is_valid(cron):
|
54
|
+
raise ValueError("Invalid cron expression")
|
55
|
+
|
56
|
+
async def loop(*args, **kwargs):
|
57
|
+
nonlocal repititions
|
58
|
+
while max_repetitions is None or repititions < max_repetitions:
|
59
|
+
try:
|
60
|
+
sleepTime = get_delta(cron)
|
61
|
+
await asyncio.sleep(sleepTime)
|
62
|
+
if is_coroutine:
|
63
|
+
await func(*args, **kwargs)
|
64
|
+
else:
|
65
|
+
await run_in_threadpool(func, *args, **kwargs)
|
66
|
+
except Exception as e:
|
67
|
+
if logger is not None:
|
68
|
+
logger.exception(e)
|
69
|
+
if raise_exceptions:
|
70
|
+
raise e
|
71
|
+
repititions += 1
|
72
|
+
|
73
|
+
ensure_future(loop(*_args, **_kwargs))
|
74
|
+
|
75
|
+
return wrapper
|
76
|
+
|
77
|
+
return decorator
|
78
|
+
|
79
|
+
|
80
|
+
def repeat_every(
|
81
|
+
*,
|
82
|
+
seconds: float,
|
83
|
+
wait_first: bool = False,
|
84
|
+
logger: logging.Logger = None,
|
85
|
+
raise_exceptions: bool = False,
|
86
|
+
max_repetitions: int = None,
|
87
|
+
):
|
88
|
+
"""This function returns a decorator that schedules a function to execute
|
89
|
+
periodically after every `seconds` seconds.
|
90
|
+
|
91
|
+
:param seconds: float
|
92
|
+
The number of seconds to wait before executing the function again.
|
93
|
+
:param wait_first: bool (default False)
|
94
|
+
Whether to wait `seconds` seconds before executing the function for the
|
95
|
+
first time.
|
96
|
+
:param logger: logging.Logger (default None)
|
97
|
+
The logger to use for logging exceptions.
|
98
|
+
:param raise_exceptions: bool (default False)
|
99
|
+
Whether to raise exceptions instead of logging them.
|
100
|
+
:param max_repetitions: int (default None)
|
101
|
+
The maximum number of times to repeat the function. If None, the
|
102
|
+
function will repeat indefinitely.
|
103
|
+
"""
|
104
|
+
|
105
|
+
def decorator(func):
|
106
|
+
is_coroutine = asyncio.iscoroutinefunction(func)
|
107
|
+
|
108
|
+
@wraps(func)
|
109
|
+
async def wrapper(*_args, **_kwargs):
|
110
|
+
repetitions = 0
|
111
|
+
|
112
|
+
async def loop(*args, **kwargs):
|
113
|
+
nonlocal repetitions
|
114
|
+
if wait_first:
|
115
|
+
await asyncio.sleep(seconds)
|
116
|
+
while max_repetitions is None or repetitions < max_repetitions:
|
117
|
+
try:
|
118
|
+
if is_coroutine:
|
119
|
+
await func(*args, **kwargs)
|
120
|
+
else:
|
121
|
+
await run_in_threadpool(func, *args, **kwargs)
|
122
|
+
except Exception as e:
|
123
|
+
if logger is not None:
|
124
|
+
logger.exception(e)
|
125
|
+
if raise_exceptions:
|
126
|
+
raise e
|
127
|
+
repetitions += 1
|
128
|
+
await asyncio.sleep(seconds)
|
129
|
+
|
130
|
+
ensure_future(loop(*_args, **_kwargs))
|
131
|
+
|
132
|
+
return wrapper
|
133
|
+
|
134
|
+
return decorator
|
@@ -0,0 +1,78 @@
|
|
1
|
+
from enum import Enum
|
2
|
+
|
3
|
+
from fastapi import APIRouter, Request, status
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field
|
5
|
+
|
6
|
+
from .log import get_logger
|
7
|
+
|
8
|
+
logger = get_logger(__name__)
|
9
|
+
workflow_route = APIRouter(prefix="/workflow")
|
10
|
+
|
11
|
+
|
12
|
+
@workflow_route.get("/{name}")
|
13
|
+
async def get_pipeline(name: str):
|
14
|
+
return {"message": f"getting pipeline {name}"}
|
15
|
+
|
16
|
+
|
17
|
+
@workflow_route.get("/{name}/logs")
|
18
|
+
async def get_pipeline_log(name: str):
|
19
|
+
return {"message": f"getting pipeline {name} logs"}
|
20
|
+
|
21
|
+
|
22
|
+
class JobNotFoundError(Exception):
|
23
|
+
pass
|
24
|
+
|
25
|
+
|
26
|
+
schedule_route = APIRouter(prefix="/schedule", tags=["schedule"])
|
27
|
+
|
28
|
+
|
29
|
+
class TriggerEnum(str, Enum):
|
30
|
+
interval = "interval"
|
31
|
+
cron = "cron"
|
32
|
+
|
33
|
+
|
34
|
+
class Job(BaseModel):
|
35
|
+
model_config = ConfigDict(
|
36
|
+
json_schema_extra={
|
37
|
+
"example": {
|
38
|
+
"func": "example.main:pytest_job",
|
39
|
+
"trigger": "interval",
|
40
|
+
"seconds": 3,
|
41
|
+
"id": "pytest_job",
|
42
|
+
},
|
43
|
+
},
|
44
|
+
)
|
45
|
+
func: str = Field()
|
46
|
+
trigger: TriggerEnum = Field(title="Trigger type")
|
47
|
+
seconds: int = Field(title="Interval in seconds")
|
48
|
+
id: str = Field(title="Job ID")
|
49
|
+
|
50
|
+
|
51
|
+
@schedule_route.post(
|
52
|
+
"/", name="scheduler:add_job", status_code=status.HTTP_201_CREATED
|
53
|
+
)
|
54
|
+
async def add_job(request: Request, job: Job):
|
55
|
+
job = request.app.scheduler.add_job(**job.dict())
|
56
|
+
return {"job": f"{job.id}"}
|
57
|
+
|
58
|
+
|
59
|
+
@schedule_route.get("/", name="scheduler:get_jobs", response_model=list)
|
60
|
+
async def get_jobs(request: Request):
|
61
|
+
jobs = request.app.scheduler.get_jobs()
|
62
|
+
jobs = [
|
63
|
+
{k: v for k, v in job.__getstate__().items() if k != "trigger"}
|
64
|
+
for job in jobs
|
65
|
+
]
|
66
|
+
return jobs
|
67
|
+
|
68
|
+
|
69
|
+
@schedule_route.delete("/{job_id}", name="scheduler:remove_job")
|
70
|
+
async def remove_job(request: Request, job_id: str):
|
71
|
+
try:
|
72
|
+
deleted = request.app.scheduler.remove_job(job_id=job_id)
|
73
|
+
logger.debug(f"Job {job_id} deleted: {deleted}")
|
74
|
+
return {"job": f"{job_id}"}
|
75
|
+
except AttributeError as err:
|
76
|
+
raise JobNotFoundError(
|
77
|
+
f"No job by the id of {job_id} was found"
|
78
|
+
) from err
|
@@ -8,13 +8,9 @@ from __future__ import annotations
|
|
8
8
|
import copy
|
9
9
|
from collections.abc import Iterator
|
10
10
|
from dataclasses import dataclass, field
|
11
|
-
from datetime import datetime, timedelta
|
11
|
+
from datetime import datetime, timedelta
|
12
12
|
from functools import partial, total_ordering
|
13
|
-
from typing import
|
14
|
-
Callable,
|
15
|
-
Optional,
|
16
|
-
Union,
|
17
|
-
)
|
13
|
+
from typing import Callable, Optional, Union
|
18
14
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
19
15
|
|
20
16
|
from ddeutil.core import (
|
@@ -117,7 +113,7 @@ CRON_UNITS: tuple[Unit, ...] = (
|
|
117
113
|
),
|
118
114
|
)
|
119
115
|
|
120
|
-
|
116
|
+
CRON_UNITS_YEAR: tuple[Unit, ...] = CRON_UNITS + (
|
121
117
|
Unit(
|
122
118
|
name="year",
|
123
119
|
range=partial(range, 1990, 2101),
|
@@ -152,6 +148,7 @@ class CronPart:
|
|
152
148
|
values: list[int] = self.replace_weekday(values)
|
153
149
|
else:
|
154
150
|
raise TypeError(f"Invalid type of value in cron part: {values}.")
|
151
|
+
|
155
152
|
self.values: list[int] = self.out_of_range(
|
156
153
|
sorted(dict.fromkeys(values))
|
157
154
|
)
|
@@ -190,10 +187,16 @@ class CronPart:
|
|
190
187
|
)
|
191
188
|
|
192
189
|
def __lt__(self, other) -> bool:
|
193
|
-
|
190
|
+
if isinstance(other, CronPart):
|
191
|
+
return self.values < other.values
|
192
|
+
elif isinstance(other, list):
|
193
|
+
return self.values < other
|
194
194
|
|
195
195
|
def __eq__(self, other) -> bool:
|
196
|
-
|
196
|
+
if isinstance(other, CronPart):
|
197
|
+
return self.values == other.values
|
198
|
+
elif isinstance(other, list):
|
199
|
+
return self.values == other
|
197
200
|
|
198
201
|
@property
|
199
202
|
def min(self) -> int:
|
@@ -225,11 +228,11 @@ class CronPart:
|
|
225
228
|
"""Parses a string as a range of positive integers. The string should
|
226
229
|
include only `-` and `,` special strings.
|
227
230
|
|
228
|
-
:param value: A string value
|
231
|
+
:param value: A string value that want to parse
|
229
232
|
:type value: str
|
230
233
|
|
231
234
|
TODO: support for `L`, `W`, and `#`
|
232
|
-
TODO:
|
235
|
+
TODO: if you didn't care what day of the week the 7th was, you
|
233
236
|
could enter ? in the Day-of-week field.
|
234
237
|
TODO: L : the Day-of-month or Day-of-week fields specifies the last day
|
235
238
|
of the month or week.
|
@@ -239,7 +242,7 @@ class CronPart:
|
|
239
242
|
TODO: # : 3#2 would be the second Tuesday of the month,
|
240
243
|
the 3 refers to Tuesday because it is the third day of each week.
|
241
244
|
|
242
|
-
|
245
|
+
Noted:
|
243
246
|
- 0 10 * * ? *
|
244
247
|
Run at 10:00 am (UTC) every day
|
245
248
|
|
@@ -302,9 +305,14 @@ class CronPart:
|
|
302
305
|
return value
|
303
306
|
|
304
307
|
def replace_weekday(self, values: list[int] | Iterator[int]) -> list[int]:
|
305
|
-
"""Replaces all 7 with 0 as Sunday can be represented by both.
|
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
|
+
"""
|
306
313
|
if self.unit.name == "weekday":
|
307
|
-
|
314
|
+
# NOTE: change weekday value in range 0-6 (div-mod by 7).
|
315
|
+
return [value % 7 for value in values]
|
308
316
|
return list(values)
|
309
317
|
|
310
318
|
def out_of_range(self, values: list[int]) -> list[int]:
|
@@ -531,27 +539,27 @@ class CronJob:
|
|
531
539
|
return reversed(self.parts[:3] + [self.parts[4], self.parts[3]])
|
532
540
|
|
533
541
|
@property
|
534
|
-
def minute(self):
|
542
|
+
def minute(self) -> CronPart:
|
535
543
|
"""Return part of minute."""
|
536
544
|
return self.parts[0]
|
537
545
|
|
538
546
|
@property
|
539
|
-
def hour(self):
|
547
|
+
def hour(self) -> CronPart:
|
540
548
|
"""Return part of hour."""
|
541
549
|
return self.parts[1]
|
542
550
|
|
543
551
|
@property
|
544
|
-
def day(self):
|
552
|
+
def day(self) -> CronPart:
|
545
553
|
"""Return part of day."""
|
546
554
|
return self.parts[2]
|
547
555
|
|
548
556
|
@property
|
549
|
-
def month(self):
|
557
|
+
def month(self) -> CronPart:
|
550
558
|
"""Return part of month."""
|
551
559
|
return self.parts[3]
|
552
560
|
|
553
561
|
@property
|
554
|
-
def dow(self):
|
562
|
+
def dow(self) -> CronPart:
|
555
563
|
"""Return part of day of month."""
|
556
564
|
return self.parts[4]
|
557
565
|
|
@@ -560,15 +568,29 @@ class CronJob:
|
|
560
568
|
return [part.values for part in self.parts]
|
561
569
|
|
562
570
|
def schedule(
|
563
|
-
self,
|
571
|
+
self,
|
572
|
+
date: datetime | None = None,
|
573
|
+
*,
|
574
|
+
tz: str | None = None,
|
564
575
|
) -> CronRunner:
|
565
|
-
"""Returns the
|
566
|
-
|
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)
|
567
584
|
|
568
585
|
|
569
|
-
class
|
586
|
+
class CronJobYear(CronJob):
|
570
587
|
cron_length = 6
|
571
|
-
cron_units =
|
588
|
+
cron_units = CRON_UNITS_YEAR
|
589
|
+
|
590
|
+
@property
|
591
|
+
def year(self) -> CronPart:
|
592
|
+
"""Return part of year."""
|
593
|
+
return self.parts[5]
|
572
594
|
|
573
595
|
|
574
596
|
class CronRunner:
|
@@ -586,33 +608,37 @@ class CronRunner:
|
|
586
608
|
|
587
609
|
def __init__(
|
588
610
|
self,
|
589
|
-
cron: CronJob,
|
590
|
-
date:
|
611
|
+
cron: CronJob | CronJobYear,
|
612
|
+
date: datetime | None = None,
|
591
613
|
*,
|
592
|
-
|
614
|
+
tz: str | None = None,
|
593
615
|
) -> None:
|
594
|
-
# NOTE: Prepare
|
595
|
-
self.tz =
|
596
|
-
if
|
616
|
+
# NOTE: Prepare timezone if this value does not set, it will use UTC.
|
617
|
+
self.tz: ZoneInfo = ZoneInfo("UTC")
|
618
|
+
if tz:
|
597
619
|
try:
|
598
|
-
self.tz = ZoneInfo(
|
620
|
+
self.tz = ZoneInfo(tz)
|
599
621
|
except ZoneInfoNotFoundError as err:
|
600
|
-
raise ValueError(f"Invalid timezone: {
|
622
|
+
raise ValueError(f"Invalid timezone: {tz}") from err
|
623
|
+
|
624
|
+
# NOTE: Prepare date
|
601
625
|
if date:
|
602
626
|
if not isinstance(date, datetime):
|
603
627
|
raise ValueError(
|
604
628
|
"Input schedule start time is not a valid datetime object."
|
605
629
|
)
|
606
|
-
|
607
|
-
|
630
|
+
if tz is None:
|
631
|
+
self.tz = date.tzinfo
|
632
|
+
self.date: datetime = date.astimezone(self.tz)
|
608
633
|
else:
|
609
634
|
self.date: datetime = datetime.now(tz=self.tz)
|
610
635
|
|
636
|
+
# NOTE: Add one minute if the second value more than 0.
|
611
637
|
if self.date.second > 0:
|
612
|
-
self.date: datetime = self.date + timedelta(minutes
|
638
|
+
self.date: datetime = self.date + timedelta(minutes=1)
|
613
639
|
|
614
640
|
self.__start_date: datetime = self.date
|
615
|
-
self.cron: CronJob = cron
|
641
|
+
self.cron: CronJob | CronJobYear = cron
|
616
642
|
self.reset_flag: bool = True
|
617
643
|
|
618
644
|
def reset(self) -> None:
|
@@ -637,7 +663,11 @@ class CronRunner:
|
|
637
663
|
return self.find_date(reverse=True)
|
638
664
|
|
639
665
|
def find_date(self, reverse: bool = False) -> datetime:
|
640
|
-
"""Returns the time the schedule would run by `next` or `prev`.
|
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.
|
641
671
|
self.reset_flag: bool = False
|
642
672
|
for _ in range(25):
|
643
673
|
if all(
|
@@ -656,7 +686,7 @@ class CronRunner:
|
|
656
686
|
"minute": "hour",
|
657
687
|
}
|
658
688
|
current_value: int = getattr(self.date, switch[mode])
|
659
|
-
|
689
|
+
_addition_condition: Callable[[], bool] = (
|
660
690
|
(
|
661
691
|
lambda: WEEKDAYS.get(self.date.strftime("%a"))
|
662
692
|
not in self.cron.dow.values
|
@@ -664,15 +694,12 @@ class CronRunner:
|
|
664
694
|
if mode == "day"
|
665
695
|
else lambda: False
|
666
696
|
)
|
697
|
+
# NOTE: Start while-loop for checking this date include in this cronjob.
|
667
698
|
while (
|
668
699
|
getattr(self.date, mode) not in getattr(self.cron, mode).values
|
669
|
-
) or
|
670
|
-
self.date: datetime = next_date(
|
671
|
-
|
672
|
-
)
|
673
|
-
self.date: datetime = replace_date(
|
674
|
-
self.date, mode=mode, reverse=reverse
|
675
|
-
)
|
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)
|
676
703
|
if current_value != getattr(self.date, switch[mode]):
|
677
704
|
return mode != "month"
|
678
705
|
return False
|
@@ -680,6 +707,7 @@ class CronRunner:
|
|
680
707
|
|
681
708
|
__all__ = (
|
682
709
|
"CronJob",
|
710
|
+
"CronJobYear",
|
683
711
|
"CronRunner",
|
684
712
|
"WEEKDAYS",
|
685
713
|
)
|