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/repeat.py
CHANGED
@@ -3,33 +3,62 @@
|
|
3
3
|
# Licensed under the MIT License.
|
4
4
|
# This code refs from: https://github.com/priyanshu-panwar/fastapi-utilities
|
5
5
|
# ------------------------------------------------------------------------------
|
6
|
+
from __future__ import annotations
|
7
|
+
|
6
8
|
import asyncio
|
7
|
-
import logging
|
8
9
|
import os
|
9
10
|
from asyncio import ensure_future
|
10
11
|
from datetime import datetime
|
11
12
|
from functools import wraps
|
12
13
|
from zoneinfo import ZoneInfo
|
13
14
|
|
14
|
-
from croniter import croniter
|
15
15
|
from starlette.concurrency import run_in_threadpool
|
16
16
|
|
17
|
+
from .cron import CronJob
|
18
|
+
from .log import get_logger
|
19
|
+
|
20
|
+
logger = get_logger("ddeutil.workflow")
|
17
21
|
|
18
|
-
|
22
|
+
|
23
|
+
def get_cronjob_delta(cron: str):
|
19
24
|
"""This function returns the time delta between now and the next cron
|
20
25
|
execution time.
|
21
26
|
"""
|
22
27
|
now: datetime = datetime.now(
|
23
28
|
tz=ZoneInfo(os.getenv("WORKFLOW_CORE_TIMEZONE", "UTC"))
|
24
29
|
)
|
25
|
-
cron =
|
26
|
-
return (cron.
|
30
|
+
cron = CronJob(cron)
|
31
|
+
return (cron.schedule(now).next - now).total_seconds()
|
32
|
+
|
33
|
+
|
34
|
+
def cron_valid(cron: str):
|
35
|
+
try:
|
36
|
+
CronJob(cron)
|
37
|
+
except Exception as err:
|
38
|
+
raise ValueError(f"Crontab value does not valid, {cron}") from err
|
39
|
+
|
40
|
+
|
41
|
+
async def run_func(
|
42
|
+
is_coroutine,
|
43
|
+
func,
|
44
|
+
*args,
|
45
|
+
raise_exceptions: bool = False,
|
46
|
+
**kwargs,
|
47
|
+
):
|
48
|
+
try:
|
49
|
+
if is_coroutine:
|
50
|
+
await func(*args, **kwargs)
|
51
|
+
else:
|
52
|
+
await run_in_threadpool(func, *args, **kwargs)
|
53
|
+
except Exception as e:
|
54
|
+
logger.exception(e)
|
55
|
+
if raise_exceptions:
|
56
|
+
raise e
|
27
57
|
|
28
58
|
|
29
59
|
def repeat_at(
|
30
60
|
*,
|
31
61
|
cron: str,
|
32
|
-
logger: logging.Logger = None,
|
33
62
|
raise_exceptions: bool = False,
|
34
63
|
max_repetitions: int = None,
|
35
64
|
):
|
@@ -38,40 +67,37 @@ def repeat_at(
|
|
38
67
|
|
39
68
|
:param cron: str
|
40
69
|
Cron-style string for periodic execution, eg. '0 0 * * *' every midnight
|
41
|
-
:param logger: logging.Logger (default None)
|
42
|
-
Logger object to log exceptions
|
43
70
|
:param raise_exceptions: bool (default False)
|
44
71
|
Whether to raise exceptions or log them
|
45
72
|
:param max_repetitions: int (default None)
|
46
73
|
Maximum number of times to repeat the function. If None, repeat
|
47
74
|
indefinitely.
|
48
|
-
|
49
75
|
"""
|
76
|
+
if max_repetitions and max_repetitions <= 0:
|
77
|
+
raise ValueError(
|
78
|
+
"max_repetitions should more than zero if it want to set"
|
79
|
+
)
|
50
80
|
|
51
81
|
def decorator(func):
|
52
|
-
is_coroutine = asyncio.iscoroutinefunction(func)
|
82
|
+
is_coroutine: bool = asyncio.iscoroutinefunction(func)
|
53
83
|
|
54
84
|
@wraps(func)
|
55
85
|
def wrapper(*_args, **_kwargs):
|
56
|
-
repititions = 0
|
57
|
-
|
58
|
-
raise ValueError("Invalid cron expression")
|
86
|
+
repititions: int = 0
|
87
|
+
cron_valid(cron)
|
59
88
|
|
60
89
|
async def loop(*args, **kwargs):
|
61
90
|
nonlocal repititions
|
62
91
|
while max_repetitions is None or repititions < max_repetitions:
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
logger.exception(e)
|
73
|
-
if raise_exceptions:
|
74
|
-
raise e
|
92
|
+
sleep_time = get_cronjob_delta(cron)
|
93
|
+
await asyncio.sleep(sleep_time)
|
94
|
+
await run_func(
|
95
|
+
is_coroutine,
|
96
|
+
func,
|
97
|
+
*args,
|
98
|
+
raise_exceptions=raise_exceptions,
|
99
|
+
**kwargs,
|
100
|
+
)
|
75
101
|
repititions += 1
|
76
102
|
|
77
103
|
ensure_future(loop(*_args, **_kwargs))
|
@@ -85,7 +111,6 @@ def repeat_every(
|
|
85
111
|
*,
|
86
112
|
seconds: float,
|
87
113
|
wait_first: bool = False,
|
88
|
-
logger: logging.Logger = None,
|
89
114
|
raise_exceptions: bool = False,
|
90
115
|
max_repetitions: int = None,
|
91
116
|
):
|
@@ -97,17 +122,19 @@ def repeat_every(
|
|
97
122
|
:param wait_first: bool (default False)
|
98
123
|
Whether to wait `seconds` seconds before executing the function for the
|
99
124
|
first time.
|
100
|
-
:param logger: logging.Logger (default None)
|
101
|
-
The logger to use for logging exceptions.
|
102
125
|
:param raise_exceptions: bool (default False)
|
103
126
|
Whether to raise exceptions instead of logging them.
|
104
127
|
:param max_repetitions: int (default None)
|
105
128
|
The maximum number of times to repeat the function. If None, the
|
106
129
|
function will repeat indefinitely.
|
107
130
|
"""
|
131
|
+
if max_repetitions and max_repetitions <= 0:
|
132
|
+
raise ValueError(
|
133
|
+
"max_repetitions should more than zero if it want to set"
|
134
|
+
)
|
108
135
|
|
109
136
|
def decorator(func):
|
110
|
-
is_coroutine = asyncio.iscoroutinefunction(func)
|
137
|
+
is_coroutine: bool = asyncio.iscoroutinefunction(func)
|
111
138
|
|
112
139
|
@wraps(func)
|
113
140
|
async def wrapper(*_args, **_kwargs):
|
@@ -115,19 +142,19 @@ def repeat_every(
|
|
115
142
|
|
116
143
|
async def loop(*args, **kwargs):
|
117
144
|
nonlocal repetitions
|
145
|
+
|
118
146
|
if wait_first:
|
119
147
|
await asyncio.sleep(seconds)
|
148
|
+
|
120
149
|
while max_repetitions is None or repetitions < max_repetitions:
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
if raise_exceptions:
|
130
|
-
raise e
|
150
|
+
await run_func(
|
151
|
+
is_coroutine,
|
152
|
+
func,
|
153
|
+
*args,
|
154
|
+
raise_exceptions=raise_exceptions,
|
155
|
+
**kwargs,
|
156
|
+
)
|
157
|
+
|
131
158
|
repetitions += 1
|
132
159
|
await asyncio.sleep(seconds)
|
133
160
|
|
ddeutil/workflow/route.py
CHANGED
@@ -1,22 +1,66 @@
|
|
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
|
+
# ------------------------------------------------------------------------------
|
1
6
|
from __future__ import annotations
|
2
7
|
|
3
|
-
from fastapi import APIRouter, Request
|
8
|
+
from fastapi import APIRouter, HTTPException, Request
|
4
9
|
from fastapi import status as st
|
10
|
+
from fastapi.responses import UJSONResponse
|
5
11
|
|
12
|
+
from .__types import DictData
|
6
13
|
from .log import get_logger
|
7
|
-
|
8
|
-
|
9
|
-
|
14
|
+
from .pipeline import Pipeline
|
15
|
+
from .repeat import repeat_every
|
16
|
+
from .utils import Loader
|
17
|
+
|
18
|
+
logger = get_logger("ddeutil.workflow")
|
19
|
+
workflow = APIRouter(
|
20
|
+
prefix="/workflow",
|
21
|
+
tags=["workflow"],
|
22
|
+
)
|
23
|
+
schedule = APIRouter(
|
24
|
+
prefix="/schedule",
|
25
|
+
tags=["schedule"],
|
26
|
+
)
|
10
27
|
|
11
28
|
|
12
|
-
@workflow.get(
|
29
|
+
@workflow.get(
|
30
|
+
"/",
|
31
|
+
response_class=UJSONResponse,
|
32
|
+
status_code=st.HTTP_200_OK,
|
33
|
+
)
|
13
34
|
async def get_workflows():
|
14
|
-
|
35
|
+
"""Return all pipeline workflows that exists in config path."""
|
36
|
+
pipelines: DictData = Loader.finds(Pipeline)
|
37
|
+
return {
|
38
|
+
"message": f"getting all pipelines: {pipelines}",
|
39
|
+
}
|
15
40
|
|
16
41
|
|
17
|
-
@workflow.get(
|
18
|
-
|
19
|
-
|
42
|
+
@workflow.get(
|
43
|
+
"/{name}",
|
44
|
+
response_class=UJSONResponse,
|
45
|
+
status_code=st.HTTP_200_OK,
|
46
|
+
)
|
47
|
+
async def get_workflow(name: str) -> DictData:
|
48
|
+
"""Return model of pipeline that passing an input pipeline name."""
|
49
|
+
try:
|
50
|
+
pipeline: Pipeline = Pipeline.from_loader(name=name, externals={})
|
51
|
+
except ValueError:
|
52
|
+
raise HTTPException(
|
53
|
+
status_code=st.HTTP_404_NOT_FOUND,
|
54
|
+
detail=(
|
55
|
+
f"Workflow pipeline name: {name!r} does not found in /conf path"
|
56
|
+
),
|
57
|
+
) from None
|
58
|
+
return pipeline.model_dump(
|
59
|
+
by_alias=True,
|
60
|
+
exclude_none=True,
|
61
|
+
exclude_unset=True,
|
62
|
+
exclude_defaults=True,
|
63
|
+
)
|
20
64
|
|
21
65
|
|
22
66
|
@workflow.get("/{name}/logs")
|
@@ -37,35 +81,12 @@ async def del_workflow_release_log(name: str, release: str):
|
|
37
81
|
return {"message": f"getting pipeline {name} log in release {release}"}
|
38
82
|
|
39
83
|
|
40
|
-
|
41
|
-
|
42
|
-
|
84
|
+
@schedule.on_event("startup")
|
85
|
+
@repeat_every(seconds=60)
|
86
|
+
def schedule_broker_up():
|
87
|
+
logger.info("Start listening schedule from queue ...")
|
43
88
|
|
44
|
-
schedule = APIRouter(prefix="/schedule", tags=["schedule"])
|
45
89
|
|
46
|
-
|
47
|
-
@schedule.post("/", name="scheduler:add_job", status_code=st.HTTP_201_CREATED)
|
48
|
-
async def add_job(request: Request):
|
49
|
-
return {"job": f"{request}"}
|
50
|
-
|
51
|
-
|
52
|
-
@schedule.get("/", name="scheduler:get_jobs", response_model=list)
|
90
|
+
@schedule.get("/", response_class=UJSONResponse)
|
53
91
|
async def get_jobs(request: Request):
|
54
|
-
|
55
|
-
jobs = [
|
56
|
-
{k: v for k, v in job.__getstate__().items() if k != "trigger"}
|
57
|
-
for job in jobs
|
58
|
-
]
|
59
|
-
return jobs
|
60
|
-
|
61
|
-
|
62
|
-
@schedule.delete("/{job_id}", name="scheduler:remove_job")
|
63
|
-
async def remove_job(request: Request, job_id: str):
|
64
|
-
try:
|
65
|
-
deleted = request.app.scheduler.remove_job(job_id=job_id)
|
66
|
-
logger.debug(f"Job {job_id} deleted: {deleted}")
|
67
|
-
return {"job": f"{job_id}"}
|
68
|
-
except AttributeError as err:
|
69
|
-
raise JobNotFoundError(
|
70
|
-
f"No job by the id of {job_id} was found"
|
71
|
-
) from err
|
92
|
+
return {}
|