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
ddeutil/workflow/__about__.py
CHANGED
@@ -1 +1 @@
|
|
1
|
-
__version__: str = "0.0.
|
1
|
+
__version__: str = "0.0.7"
|
ddeutil/workflow/__init__.py
CHANGED
@@ -0,0 +1,31 @@
|
|
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 .exceptions import (
|
7
|
+
JobException,
|
8
|
+
ParamValueException,
|
9
|
+
PipelineException,
|
10
|
+
StageException,
|
11
|
+
UtilException,
|
12
|
+
)
|
13
|
+
from .on import AwsOn, On
|
14
|
+
from .pipeline import Job, Pipeline
|
15
|
+
from .stage import (
|
16
|
+
BashStage,
|
17
|
+
EmptyStage,
|
18
|
+
HookStage,
|
19
|
+
PyStage,
|
20
|
+
Stage,
|
21
|
+
TriggerStage,
|
22
|
+
)
|
23
|
+
from .utils import (
|
24
|
+
ChoiceParam,
|
25
|
+
DatetimeParam,
|
26
|
+
IntParam,
|
27
|
+
Param,
|
28
|
+
StrParam,
|
29
|
+
dash2underscore,
|
30
|
+
param2template,
|
31
|
+
)
|
ddeutil/workflow/__types.py
CHANGED
@@ -5,8 +5,60 @@
|
|
5
5
|
# ------------------------------------------------------------------------------
|
6
6
|
from __future__ import annotations
|
7
7
|
|
8
|
-
|
8
|
+
import re
|
9
|
+
from re import (
|
10
|
+
IGNORECASE,
|
11
|
+
MULTILINE,
|
12
|
+
UNICODE,
|
13
|
+
VERBOSE,
|
14
|
+
Pattern,
|
15
|
+
)
|
16
|
+
from typing import Any, Union
|
9
17
|
|
10
18
|
TupleStr = tuple[str, ...]
|
11
19
|
DictData = dict[str, Any]
|
12
20
|
DictStr = dict[str, str]
|
21
|
+
Matrix = dict[str, Union[list[str], list[int]]]
|
22
|
+
MatrixInclude = list[dict[str, Union[str, int]]]
|
23
|
+
MatrixExclude = list[dict[str, Union[str, int]]]
|
24
|
+
|
25
|
+
|
26
|
+
class Re:
|
27
|
+
"""Regular expression config."""
|
28
|
+
|
29
|
+
# NOTE: Search caller
|
30
|
+
# \${{\s*(?P<caller>[a-zA-Z0-9_.\s'\"\[\]\(\)\-\{}]+?)\s*(?P<post_filters>(?:\|\s*(?:[a-zA-Z0-9_]{3,}[a-zA-Z0-9_.,-\\%\s'\"[\]()\{}]+)\s*)*)}}
|
31
|
+
__re_caller: str = r"""
|
32
|
+
\$
|
33
|
+
{{
|
34
|
+
\s*
|
35
|
+
(?P<caller>
|
36
|
+
[a-zA-Z0-9_.\s'\"\[\]\(\)\-\{}]+?
|
37
|
+
)\s*
|
38
|
+
(?P<post_filters>
|
39
|
+
(?:
|
40
|
+
\|\s*
|
41
|
+
(?:[a-zA-Z0-9_]{3,}[a-zA-Z0-9_.,-\\%\s'\"[\]()\{}]*)
|
42
|
+
\s*
|
43
|
+
)*
|
44
|
+
)
|
45
|
+
}}
|
46
|
+
"""
|
47
|
+
RE_CALLER: Pattern = re.compile(
|
48
|
+
__re_caller, MULTILINE | IGNORECASE | UNICODE | VERBOSE
|
49
|
+
)
|
50
|
+
|
51
|
+
# NOTE: Search task
|
52
|
+
# ^(?P<path>[^/@]+)/(?P<func>[^@]+)@(?P<tag>.+)$
|
53
|
+
__re_task_fmt: str = r"""
|
54
|
+
^
|
55
|
+
(?P<path>[^/@]+)
|
56
|
+
/
|
57
|
+
(?P<func>[^@]+)
|
58
|
+
@
|
59
|
+
(?P<tag>.+)
|
60
|
+
$
|
61
|
+
"""
|
62
|
+
RE_TASK_FMT: Pattern = re.compile(
|
63
|
+
__re_task_fmt, MULTILINE | IGNORECASE | UNICODE | VERBOSE
|
64
|
+
)
|
ddeutil/workflow/api.py
ADDED
@@ -0,0 +1,120 @@
|
|
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 asyncio
|
9
|
+
import queue
|
10
|
+
import time
|
11
|
+
import uuid
|
12
|
+
from contextlib import asynccontextmanager
|
13
|
+
from datetime import datetime
|
14
|
+
|
15
|
+
from apscheduler.executors.pool import ProcessPoolExecutor
|
16
|
+
from apscheduler.jobstores.memory import MemoryJobStore
|
17
|
+
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
|
18
|
+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
19
|
+
from fastapi import BackgroundTasks, FastAPI
|
20
|
+
from fastapi.middleware.gzip import GZipMiddleware
|
21
|
+
from fastapi.responses import UJSONResponse
|
22
|
+
from pydantic import BaseModel
|
23
|
+
|
24
|
+
from .log import get_logger
|
25
|
+
from .repeat import repeat_every
|
26
|
+
from .route import schedule_route, workflow_route
|
27
|
+
|
28
|
+
logger = get_logger(__name__)
|
29
|
+
|
30
|
+
|
31
|
+
def broker_upper_messages():
|
32
|
+
for _ in range(app.queue_limit):
|
33
|
+
try:
|
34
|
+
obj = app.queue.get_nowait()
|
35
|
+
app.output_dict[obj["request_id"]] = obj["text"].upper()
|
36
|
+
logger.info(f"Upper message: {app.output_dict}")
|
37
|
+
except queue.Empty:
|
38
|
+
pass
|
39
|
+
|
40
|
+
|
41
|
+
jobstores = {
|
42
|
+
"default": MemoryJobStore(),
|
43
|
+
"sqlite": SQLAlchemyJobStore(url="sqlite:///jobs-store.sqlite"),
|
44
|
+
}
|
45
|
+
executors = {
|
46
|
+
"default": {"type": "threadpool", "max_workers": 5},
|
47
|
+
"processpool": ProcessPoolExecutor(max_workers=5),
|
48
|
+
}
|
49
|
+
scheduler = AsyncIOScheduler(
|
50
|
+
jobstores=jobstores,
|
51
|
+
executors=executors,
|
52
|
+
timezone="Asia/Bangkok",
|
53
|
+
)
|
54
|
+
|
55
|
+
|
56
|
+
@asynccontextmanager
|
57
|
+
async def lifespan(_: FastAPI):
|
58
|
+
scheduler.start()
|
59
|
+
yield
|
60
|
+
scheduler.shutdown(wait=False)
|
61
|
+
|
62
|
+
|
63
|
+
app = FastAPI(lifespan=lifespan)
|
64
|
+
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
65
|
+
app.include_router(schedule_route)
|
66
|
+
app.include_router(workflow_route)
|
67
|
+
|
68
|
+
app.scheduler = scheduler
|
69
|
+
app.scheduler.add_job(
|
70
|
+
broker_upper_messages,
|
71
|
+
"interval",
|
72
|
+
seconds=10,
|
73
|
+
)
|
74
|
+
app.queue = queue.Queue()
|
75
|
+
app.output_dict = {}
|
76
|
+
app.queue_limit = 2
|
77
|
+
|
78
|
+
|
79
|
+
def write_pipeline(task_id: str, message=""):
|
80
|
+
logger.info(f"{task_id} : {message}")
|
81
|
+
time.sleep(5)
|
82
|
+
logger.info(f"{task_id} : run task successfully!!!")
|
83
|
+
|
84
|
+
|
85
|
+
@app.post("/schedule/{name}", response_class=UJSONResponse)
|
86
|
+
async def send_schedule(name: str, background_tasks: BackgroundTasks):
|
87
|
+
background_tasks.add_task(
|
88
|
+
write_pipeline,
|
89
|
+
name,
|
90
|
+
message=f"some message for {name}",
|
91
|
+
)
|
92
|
+
await fetch_current_time()
|
93
|
+
return {"message": f"Schedule sent {name!r} in the background"}
|
94
|
+
|
95
|
+
|
96
|
+
@repeat_every(seconds=2, max_repetitions=3)
|
97
|
+
async def fetch_current_time():
|
98
|
+
logger.info(f"Fetch: {datetime.now()}")
|
99
|
+
|
100
|
+
|
101
|
+
class Payload(BaseModel):
|
102
|
+
text: str
|
103
|
+
|
104
|
+
|
105
|
+
async def get_result(request_id):
|
106
|
+
while 1:
|
107
|
+
if request_id in app.output_dict:
|
108
|
+
result = app.output_dict[request_id]
|
109
|
+
del app.output_dict[request_id]
|
110
|
+
return {"message": result}
|
111
|
+
await asyncio.sleep(0.001)
|
112
|
+
|
113
|
+
|
114
|
+
@app.post("/upper", response_class=UJSONResponse)
|
115
|
+
async def message_upper(payload: Payload):
|
116
|
+
request_id: str = str(uuid.uuid4())
|
117
|
+
app.queue.put(
|
118
|
+
{"text": payload.text, "request_id": request_id},
|
119
|
+
)
|
120
|
+
return await get_result(request_id)
|
ddeutil/workflow/app.py
ADDED
@@ -0,0 +1,41 @@
|
|
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
|
+
import functools
|
7
|
+
import time
|
8
|
+
|
9
|
+
import schedule
|
10
|
+
|
11
|
+
|
12
|
+
def catch_exceptions(cancel_on_failure=False):
|
13
|
+
def catch_exceptions_decorator(job_func):
|
14
|
+
@functools.wraps(job_func)
|
15
|
+
def wrapper(*args, **kwargs):
|
16
|
+
try:
|
17
|
+
return job_func(*args, **kwargs)
|
18
|
+
except Exception as err:
|
19
|
+
print(err)
|
20
|
+
|
21
|
+
if cancel_on_failure:
|
22
|
+
return schedule.CancelJob
|
23
|
+
|
24
|
+
return wrapper
|
25
|
+
|
26
|
+
return catch_exceptions_decorator
|
27
|
+
|
28
|
+
|
29
|
+
@catch_exceptions(cancel_on_failure=True)
|
30
|
+
def bad_task():
|
31
|
+
return 1 / 0
|
32
|
+
|
33
|
+
|
34
|
+
schedule.every(5).seconds.do(bad_task)
|
35
|
+
|
36
|
+
if __name__ == "__main__":
|
37
|
+
while True:
|
38
|
+
schedule.run_pending()
|
39
|
+
time.sleep(1)
|
40
|
+
if not schedule.get_jobs():
|
41
|
+
break
|
ddeutil/workflow/exceptions.py
CHANGED
@@ -9,4 +9,19 @@ Define Errors Object for Node package
|
|
9
9
|
from __future__ import annotations
|
10
10
|
|
11
11
|
|
12
|
-
class
|
12
|
+
class WorkflowException(Exception): ...
|
13
|
+
|
14
|
+
|
15
|
+
class UtilException(WorkflowException): ...
|
16
|
+
|
17
|
+
|
18
|
+
class StageException(WorkflowException): ...
|
19
|
+
|
20
|
+
|
21
|
+
class JobException(WorkflowException): ...
|
22
|
+
|
23
|
+
|
24
|
+
class PipelineException(WorkflowException): ...
|
25
|
+
|
26
|
+
|
27
|
+
class ParamValueException(ValueError): ...
|
ddeutil/workflow/loader.py
CHANGED
@@ -6,49 +6,17 @@
|
|
6
6
|
from __future__ import annotations
|
7
7
|
|
8
8
|
from functools import cached_property
|
9
|
-
from typing import
|
9
|
+
from typing import TypeVar
|
10
10
|
|
11
|
-
from ddeutil.core import
|
12
|
-
|
13
|
-
|
14
|
-
import_string,
|
15
|
-
)
|
16
|
-
from ddeutil.io import (
|
17
|
-
PathData,
|
18
|
-
PathSearch,
|
19
|
-
YamlEnvFl,
|
20
|
-
)
|
21
|
-
from pydantic import BaseModel, Field
|
22
|
-
from pydantic.functional_validators import model_validator
|
11
|
+
from ddeutil.core import import_string
|
12
|
+
from ddeutil.io import PathSearch, YamlFlResolve
|
13
|
+
from pydantic import BaseModel
|
23
14
|
|
24
|
-
from .__regex import RegexConf
|
25
15
|
from .__types import DictData
|
16
|
+
from .utils import ConfParams, config
|
26
17
|
|
27
|
-
T = TypeVar("T")
|
28
|
-
BaseModelType = type[BaseModel]
|
29
18
|
AnyModel = TypeVar("AnyModel", bound=BaseModel)
|
30
|
-
|
31
|
-
|
32
|
-
class Engine(BaseModel):
|
33
|
-
"""Engine Model"""
|
34
|
-
|
35
|
-
paths: PathData = Field(default_factory=PathData)
|
36
|
-
registry: list[str] = Field(default_factory=lambda: ["ddeutil.workflow"])
|
37
|
-
|
38
|
-
@model_validator(mode="before")
|
39
|
-
def __prepare_registry(cls, values: DictData) -> DictData:
|
40
|
-
"""Prepare registry value that passing with string type. It convert the
|
41
|
-
string type to list of string.
|
42
|
-
"""
|
43
|
-
if (_regis := values.get("registry")) and isinstance(_regis, str):
|
44
|
-
values["registry"] = [_regis]
|
45
|
-
return values
|
46
|
-
|
47
|
-
|
48
|
-
class Params(BaseModel):
|
49
|
-
"""Params Model"""
|
50
|
-
|
51
|
-
engine: Engine = Field(default_factory=Engine)
|
19
|
+
AnyModelType = type[AnyModel]
|
52
20
|
|
53
21
|
|
54
22
|
class SimLoad:
|
@@ -66,26 +34,22 @@ class SimLoad:
|
|
66
34
|
def __init__(
|
67
35
|
self,
|
68
36
|
name: str,
|
69
|
-
params:
|
37
|
+
params: ConfParams,
|
70
38
|
externals: DictData,
|
71
39
|
) -> None:
|
72
40
|
self.data: DictData = {}
|
73
41
|
for file in PathSearch(params.engine.paths.conf).files:
|
74
42
|
if any(file.suffix.endswith(s) for s in ("yml", "yaml")) and (
|
75
|
-
data :=
|
43
|
+
data := YamlFlResolve(file).read().get(name, {})
|
76
44
|
):
|
77
45
|
self.data = data
|
78
46
|
if not self.data:
|
79
47
|
raise ValueError(f"Config {name!r} does not found on conf path")
|
80
|
-
self.
|
48
|
+
self.conf_params: ConfParams = params
|
81
49
|
self.externals: DictData = externals
|
82
50
|
|
83
|
-
@property
|
84
|
-
def conf_params(self) -> Params:
|
85
|
-
return self.__conf_params
|
86
|
-
|
87
51
|
@cached_property
|
88
|
-
def type(self) ->
|
52
|
+
def type(self) -> AnyModelType:
|
89
53
|
"""Return object of string type which implement on any registry. The
|
90
54
|
object type
|
91
55
|
"""
|
@@ -104,79 +68,13 @@ class SimLoad:
|
|
104
68
|
continue
|
105
69
|
return import_string(f"{_typ}")
|
106
70
|
|
107
|
-
def load(self) -> AnyModel:
|
108
|
-
"""Parsing config data to the object type for initialize with model
|
109
|
-
validate method.
|
110
|
-
"""
|
111
|
-
return self.type.model_validate(self.data)
|
112
|
-
|
113
71
|
|
114
72
|
class Loader(SimLoad):
|
115
|
-
"""
|
73
|
+
"""Loader Object that get the config `yaml` file from current path.
|
116
74
|
|
117
75
|
:param name: A name of config data that will read by Yaml Loader object.
|
118
76
|
:param externals: An external parameters
|
119
77
|
"""
|
120
78
|
|
121
|
-
|
122
|
-
|
123
|
-
def __init__(
|
124
|
-
self,
|
125
|
-
name: str,
|
126
|
-
externals: DictData,
|
127
|
-
*,
|
128
|
-
path: str | None = None,
|
129
|
-
) -> None:
|
130
|
-
self.data: DictData = {}
|
131
|
-
|
132
|
-
# NOTE: import params object from specific config file
|
133
|
-
params: Params = self.config(path)
|
134
|
-
|
135
|
-
super().__init__(name, params, externals)
|
136
|
-
|
137
|
-
@classmethod
|
138
|
-
def config(cls, path: str | None = None) -> Params:
|
139
|
-
"""Load Config data from ``workflows-conf.yaml`` file."""
|
140
|
-
return Params.model_validate(
|
141
|
-
YamlEnvFl(path or f"./{cls.conf_name}.yaml").read()
|
142
|
-
)
|
143
|
-
|
144
|
-
|
145
|
-
def map_params(value: Any, params: dict[str, Any]) -> Any:
|
146
|
-
"""Map caller value that found from ``RE_CALLER`` regular expression.
|
147
|
-
|
148
|
-
:param value: A value that want to mapped with an params
|
149
|
-
:param params: A parameter value that getting with matched regular
|
150
|
-
expression.
|
151
|
-
|
152
|
-
:rtype: Any
|
153
|
-
:returns: An any getter value from the params input.
|
154
|
-
"""
|
155
|
-
if isinstance(value, dict):
|
156
|
-
return {k: map_params(value[k], params) for k in value}
|
157
|
-
elif isinstance(value, (list, tuple, set)):
|
158
|
-
return type(value)([map_params(i, params) for i in value])
|
159
|
-
elif not isinstance(value, str):
|
160
|
-
return value
|
161
|
-
|
162
|
-
if not (found := RegexConf.RE_CALLER.search(value)):
|
163
|
-
return value
|
164
|
-
|
165
|
-
# NOTE: get caller value that setting inside; ``${{ <caller-value> }}``
|
166
|
-
caller: str = found.group("caller")
|
167
|
-
if not hasdot(caller, params):
|
168
|
-
raise ValueError(f"params does not set caller: {caller!r}")
|
169
|
-
getter: Any = getdot(caller, params)
|
170
|
-
|
171
|
-
# NOTE: check type of vars
|
172
|
-
if isinstance(getter, (str, int)):
|
173
|
-
return value.replace(found.group(0), str(getter))
|
174
|
-
|
175
|
-
# NOTE:
|
176
|
-
# If type of getter caller does not formatting, it will return origin
|
177
|
-
# value.
|
178
|
-
if value.replace(found.group(0), "") != "":
|
179
|
-
raise ValueError(
|
180
|
-
"Callable variable should not pass other outside ${{ ... }}"
|
181
|
-
)
|
182
|
-
return getter
|
79
|
+
def __init__(self, name: str, externals: DictData) -> None:
|
80
|
+
super().__init__(name, config(), externals)
|
ddeutil/workflow/log.py
ADDED
@@ -0,0 +1,30 @@
|
|
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 logging
|
9
|
+
from functools import lru_cache
|
10
|
+
|
11
|
+
from rich.console import Console
|
12
|
+
from rich.logging import RichHandler
|
13
|
+
|
14
|
+
console = Console(color_system="256", width=200, style="blue")
|
15
|
+
|
16
|
+
|
17
|
+
@lru_cache
|
18
|
+
def get_logger(module_name):
|
19
|
+
logger = logging.getLogger(module_name)
|
20
|
+
handler = RichHandler(
|
21
|
+
rich_tracebacks=True, console=console, tracebacks_show_locals=True
|
22
|
+
)
|
23
|
+
handler.setFormatter(
|
24
|
+
logging.Formatter(
|
25
|
+
"[ %(threadName)s:%(funcName)s:%(process)d ] - %(message)s"
|
26
|
+
)
|
27
|
+
)
|
28
|
+
logger.addHandler(handler)
|
29
|
+
logger.setLevel(logging.DEBUG)
|
30
|
+
return logger
|
ddeutil/workflow/on.py
CHANGED
@@ -10,29 +10,35 @@ from typing import Annotated, Literal
|
|
10
10
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
11
11
|
|
12
12
|
from pydantic import BaseModel, ConfigDict, Field
|
13
|
-
from pydantic.functional_validators import field_validator
|
13
|
+
from pydantic.functional_validators import field_validator, model_validator
|
14
14
|
from typing_extensions import Self
|
15
15
|
|
16
16
|
try:
|
17
|
-
from .__schedule import WEEKDAYS
|
18
17
|
from .__types import DictData, DictStr
|
19
|
-
from .loader import
|
18
|
+
from .loader import Loader
|
19
|
+
from .scheduler import WEEKDAYS, CronJob, CronJobYear, CronRunner
|
20
20
|
except ImportError:
|
21
|
-
from ddeutil.workflow.__scheduler import WEEKDAYS, CronJob, CronRunner
|
22
21
|
from ddeutil.workflow.__types import DictData, DictStr
|
23
22
|
from ddeutil.workflow.loader import Loader
|
23
|
+
from ddeutil.workflow.scheduler import (
|
24
|
+
WEEKDAYS,
|
25
|
+
CronJob,
|
26
|
+
CronJobYear,
|
27
|
+
CronRunner,
|
28
|
+
)
|
24
29
|
|
25
30
|
|
26
31
|
def interval2crontab(
|
27
32
|
interval: Literal["daily", "weekly", "monthly"],
|
28
|
-
day: str =
|
33
|
+
day: str | None = None,
|
29
34
|
time: str = "00:00",
|
30
35
|
) -> str:
|
31
36
|
"""Return the crontab string that was generated from specific values.
|
32
37
|
|
33
38
|
:param interval: A interval value that is one of 'daily', 'weekly', or
|
34
39
|
'monthly'.
|
35
|
-
:param day: A day value that will be day of week.
|
40
|
+
:param day: A day value that will be day of week. The default value is
|
41
|
+
monday if it be weekly interval.
|
36
42
|
:param time: A time value that passing with format '%H:%M'.
|
37
43
|
|
38
44
|
Examples:
|
@@ -42,18 +48,23 @@ def interval2crontab(
|
|
42
48
|
'18 30 * * 5'
|
43
49
|
>>> interval2crontab(interval='monthly', time='00:00')
|
44
50
|
'0 0 1 * *'
|
51
|
+
>>> interval2crontab(interval='monthly', day='tuesday', time='12:00')
|
52
|
+
'12 0 1 * 2'
|
45
53
|
"""
|
54
|
+
d: str = "*"
|
55
|
+
if interval == "weekly":
|
56
|
+
d = WEEKDAYS[(day or "monday")[:3].title()]
|
57
|
+
elif interval == "monthly" and day:
|
58
|
+
d = WEEKDAYS[day[:3].title()]
|
59
|
+
|
46
60
|
h, m = tuple(
|
47
61
|
i.lstrip("0") if i != "00" else "0" for i in time.split(":", maxsplit=1)
|
48
62
|
)
|
49
|
-
return
|
50
|
-
f"{h} {m} {'1' if interval == 'monthly' else '*'} * "
|
51
|
-
f"{WEEKDAYS[day[:3].title()] if interval == 'weekly' else '*'}"
|
52
|
-
)
|
63
|
+
return f"{h} {m} {'1' if interval == 'monthly' else '*'} * {d}"
|
53
64
|
|
54
65
|
|
55
|
-
class
|
56
|
-
"""
|
66
|
+
class On(BaseModel):
|
67
|
+
"""On Model (Schedule)
|
57
68
|
|
58
69
|
See Also:
|
59
70
|
* ``generate()`` is the main usecase of this schedule object.
|
@@ -62,8 +73,17 @@ class Schedule(BaseModel):
|
|
62
73
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
63
74
|
|
64
75
|
# NOTE: This is fields of the base schedule.
|
65
|
-
cronjob: Annotated[
|
66
|
-
|
76
|
+
cronjob: Annotated[
|
77
|
+
CronJob,
|
78
|
+
Field(description="Cron job of this schedule"),
|
79
|
+
]
|
80
|
+
tz: Annotated[
|
81
|
+
str,
|
82
|
+
Field(
|
83
|
+
description="A timezone string value",
|
84
|
+
alias="timezone",
|
85
|
+
),
|
86
|
+
] = "Etc/UTC"
|
67
87
|
extras: Annotated[
|
68
88
|
DictData,
|
69
89
|
Field(
|
@@ -105,17 +125,36 @@ class Schedule(BaseModel):
|
|
105
125
|
if loader.type != cls:
|
106
126
|
raise ValueError(f"Type {loader.type} does not match with {cls}")
|
107
127
|
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
128
|
+
loader_data: DictData = loader.data
|
129
|
+
if "interval" in loader_data:
|
130
|
+
return cls.model_validate(
|
131
|
+
obj=dict(
|
132
|
+
cronjob=interval2crontab(
|
133
|
+
**{
|
134
|
+
v: loader_data[v]
|
135
|
+
for v in loader_data
|
136
|
+
if v in ("interval", "day", "time")
|
137
|
+
}
|
138
|
+
),
|
139
|
+
extras=externals,
|
140
|
+
**loader_data,
|
141
|
+
)
|
142
|
+
)
|
143
|
+
if "cronjob" not in loader_data:
|
144
|
+
raise ValueError("Config does not set ``cronjob`` key")
|
145
|
+
return cls.model_validate(
|
146
|
+
obj=dict(
|
147
|
+
cronjob=loader_data.pop("cronjob"),
|
116
148
|
extras=externals,
|
149
|
+
**loader_data,
|
117
150
|
)
|
118
|
-
|
151
|
+
)
|
152
|
+
|
153
|
+
@model_validator(mode="before")
|
154
|
+
def __prepare_values(cls, values):
|
155
|
+
if tz := values.pop("tz", None):
|
156
|
+
values["timezone"] = tz
|
157
|
+
return values
|
119
158
|
|
120
159
|
@field_validator("tz")
|
121
160
|
def __validate_tz(cls, value: str):
|
@@ -136,8 +175,21 @@ class Schedule(BaseModel):
|
|
136
175
|
"""Return Cron runner object."""
|
137
176
|
if not isinstance(start, datetime):
|
138
177
|
start: datetime = datetime.fromisoformat(start)
|
139
|
-
return self.cronjob.schedule(date=
|
178
|
+
return self.cronjob.schedule(date=start, tz=self.tz)
|
140
179
|
|
141
180
|
|
142
|
-
class
|
143
|
-
"""Implement Schedule for AWS Service."""
|
181
|
+
class AwsOn(On):
|
182
|
+
"""Implement On AWS Schedule for AWS Service like AWS Glue."""
|
183
|
+
|
184
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
185
|
+
|
186
|
+
# NOTE: This is fields of the base schedule.
|
187
|
+
cronjob: Annotated[
|
188
|
+
CronJobYear,
|
189
|
+
Field(description="Cron job of this schedule"),
|
190
|
+
]
|
191
|
+
|
192
|
+
@field_validator("cronjob", mode="before")
|
193
|
+
def __prepare_cronjob(cls, value: str | CronJobYear) -> CronJobYear:
|
194
|
+
"""Prepare crontab value that able to receive with string type."""
|
195
|
+
return CronJobYear(value) if isinstance(value, str) else value
|