ddeutil-workflow 0.0.26.post1__py3-none-any.whl → 0.0.28__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 +19 -14
- ddeutil/workflow/api/api.py +1 -53
- ddeutil/workflow/conf.py +44 -23
- ddeutil/workflow/cron.py +7 -7
- ddeutil/workflow/exceptions.py +1 -4
- ddeutil/workflow/hook.py +168 -0
- ddeutil/workflow/job.py +18 -17
- ddeutil/workflow/params.py +3 -3
- ddeutil/workflow/result.py +3 -3
- ddeutil/workflow/scheduler.py +9 -9
- ddeutil/workflow/stage.py +87 -170
- ddeutil/workflow/templates.py +336 -0
- ddeutil/workflow/utils.py +23 -404
- ddeutil/workflow/workflow.py +22 -23
- ddeutil_workflow-0.0.28.dist-info/METADATA +284 -0
- ddeutil_workflow-0.0.28.dist-info/RECORD +25 -0
- {ddeutil_workflow-0.0.26.post1.dist-info → ddeutil_workflow-0.0.28.dist-info}/WHEEL +1 -1
- ddeutil_workflow-0.0.26.post1.dist-info/METADATA +0 -230
- ddeutil_workflow-0.0.26.post1.dist-info/RECORD +0 -23
- {ddeutil_workflow-0.0.26.post1.dist-info → ddeutil_workflow-0.0.28.dist-info}/LICENSE +0 -0
- {ddeutil_workflow-0.0.26.post1.dist-info → ddeutil_workflow-0.0.28.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,336 @@
|
|
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 inspect
|
9
|
+
import logging
|
10
|
+
from ast import Call, Constant, Expr, Module, Name, parse
|
11
|
+
from datetime import datetime
|
12
|
+
from functools import wraps
|
13
|
+
from importlib import import_module
|
14
|
+
from typing import Any, Callable, Protocol, TypeVar, Union
|
15
|
+
|
16
|
+
try:
|
17
|
+
from typing import ParamSpec
|
18
|
+
except ImportError:
|
19
|
+
from typing_extensions import ParamSpec
|
20
|
+
|
21
|
+
from ddeutil.core import getdot, hasdot, import_string
|
22
|
+
from ddeutil.io import search_env_replace
|
23
|
+
|
24
|
+
from .__types import DictData, Re
|
25
|
+
from .conf import config
|
26
|
+
from .exceptions import UtilException
|
27
|
+
|
28
|
+
T = TypeVar("T")
|
29
|
+
P = ParamSpec("P")
|
30
|
+
|
31
|
+
logger = logging.getLogger("ddeutil.workflow")
|
32
|
+
|
33
|
+
|
34
|
+
FILTERS: dict[str, callable] = { # pragma: no cov
|
35
|
+
"abs": abs,
|
36
|
+
"str": str,
|
37
|
+
"int": int,
|
38
|
+
"title": lambda x: x.title(),
|
39
|
+
"upper": lambda x: x.upper(),
|
40
|
+
"lower": lambda x: x.lower(),
|
41
|
+
"rstr": [str, repr],
|
42
|
+
}
|
43
|
+
|
44
|
+
|
45
|
+
class FilterFunc(Protocol):
|
46
|
+
"""Tag Function Protocol. This protocol that use to represent any callable
|
47
|
+
object that able to access the filter attribute.
|
48
|
+
"""
|
49
|
+
|
50
|
+
filter: str
|
51
|
+
|
52
|
+
def __call__(self, *args, **kwargs): ... # pragma: no cov
|
53
|
+
|
54
|
+
|
55
|
+
FilterRegistry = Union[FilterFunc, Callable[[...], Any]]
|
56
|
+
|
57
|
+
|
58
|
+
def custom_filter(name: str) -> Callable[P, FilterFunc]:
|
59
|
+
"""Custom filter decorator function that set function attributes, ``filter``
|
60
|
+
for making filter registries variable.
|
61
|
+
|
62
|
+
:param: name: A filter name for make different use-case of a function.
|
63
|
+
:rtype: Callable[P, FilterFunc]
|
64
|
+
"""
|
65
|
+
|
66
|
+
def func_internal(func: Callable[[...], Any]) -> FilterFunc:
|
67
|
+
func.filter = name
|
68
|
+
|
69
|
+
@wraps(func)
|
70
|
+
def wrapped(*args, **kwargs):
|
71
|
+
# NOTE: Able to do anything before calling custom filter function.
|
72
|
+
return func(*args, **kwargs)
|
73
|
+
|
74
|
+
return wrapped
|
75
|
+
|
76
|
+
return func_internal
|
77
|
+
|
78
|
+
|
79
|
+
def make_filter_registry() -> dict[str, FilterRegistry]:
|
80
|
+
"""Return registries of all functions that able to called with task.
|
81
|
+
|
82
|
+
:rtype: dict[str, Registry]
|
83
|
+
"""
|
84
|
+
rs: dict[str, FilterRegistry] = {}
|
85
|
+
for module in config.regis_filter:
|
86
|
+
# NOTE: try to sequential import task functions
|
87
|
+
try:
|
88
|
+
importer = import_module(module)
|
89
|
+
except ModuleNotFoundError:
|
90
|
+
continue
|
91
|
+
|
92
|
+
for fstr, func in inspect.getmembers(importer, inspect.isfunction):
|
93
|
+
# NOTE: check function attribute that already set tag by
|
94
|
+
# ``utils.tag`` decorator.
|
95
|
+
if not hasattr(func, "filter"):
|
96
|
+
continue
|
97
|
+
|
98
|
+
func: FilterFunc
|
99
|
+
|
100
|
+
rs[func.filter] = import_string(f"{module}.{fstr}")
|
101
|
+
|
102
|
+
rs.update(FILTERS)
|
103
|
+
return rs
|
104
|
+
|
105
|
+
|
106
|
+
def get_args_const(
|
107
|
+
expr: str,
|
108
|
+
) -> tuple[str, list[Constant], dict[str, Constant]]:
|
109
|
+
"""Get arguments and keyword-arguments from function calling string.
|
110
|
+
|
111
|
+
:rtype: tuple[str, list[Constant], dict[str, Constant]]
|
112
|
+
"""
|
113
|
+
try:
|
114
|
+
mod: Module = parse(expr)
|
115
|
+
except SyntaxError:
|
116
|
+
raise UtilException(
|
117
|
+
f"Post-filter: {expr} does not valid because it raise syntax error."
|
118
|
+
) from None
|
119
|
+
|
120
|
+
body: list[Expr] = mod.body
|
121
|
+
if len(body) > 1:
|
122
|
+
raise UtilException(
|
123
|
+
"Post-filter function should be only one calling per workflow."
|
124
|
+
)
|
125
|
+
|
126
|
+
caller: Union[Name, Call]
|
127
|
+
if isinstance((caller := body[0].value), Name):
|
128
|
+
return caller.id, [], {}
|
129
|
+
elif not isinstance(caller, Call):
|
130
|
+
raise UtilException(
|
131
|
+
f"Get arguments does not support for caller type: {type(caller)}"
|
132
|
+
)
|
133
|
+
|
134
|
+
name: Name = caller.func
|
135
|
+
args: list[Constant] = caller.args
|
136
|
+
keywords: dict[str, Constant] = {k.arg: k.value for k in caller.keywords}
|
137
|
+
|
138
|
+
if any(not isinstance(i, Constant) for i in args):
|
139
|
+
raise UtilException(f"Argument of {expr} should be constant.")
|
140
|
+
|
141
|
+
if any(not isinstance(i, Constant) for i in keywords.values()):
|
142
|
+
raise UtilException(f"Keyword argument of {expr} should be constant.")
|
143
|
+
|
144
|
+
return name.id, args, keywords
|
145
|
+
|
146
|
+
|
147
|
+
def get_args_from_filter(
|
148
|
+
ft: str,
|
149
|
+
filters: dict[str, FilterRegistry],
|
150
|
+
) -> tuple[str, FilterRegistry, list[Any], dict[Any, Any]]: # pragma: no cov
|
151
|
+
"""Get arguments and keyword-arguments from filter function calling string.
|
152
|
+
and validate it with the filter functions mapping dict.
|
153
|
+
"""
|
154
|
+
func_name, _args, _kwargs = get_args_const(ft)
|
155
|
+
args: list[Any] = [arg.value for arg in _args]
|
156
|
+
kwargs: dict[Any, Any] = {k: v.value for k, v in _kwargs.items()}
|
157
|
+
|
158
|
+
if func_name not in filters:
|
159
|
+
raise UtilException(
|
160
|
+
f"The post-filter: {func_name!r} does not support yet."
|
161
|
+
)
|
162
|
+
|
163
|
+
if isinstance((f_func := filters[func_name]), list) and (args or kwargs):
|
164
|
+
raise UtilException(
|
165
|
+
"Chain filter function does not support for passing arguments."
|
166
|
+
)
|
167
|
+
|
168
|
+
return func_name, f_func, args, kwargs
|
169
|
+
|
170
|
+
|
171
|
+
def map_post_filter(
|
172
|
+
value: T,
|
173
|
+
post_filter: list[str],
|
174
|
+
filters: dict[str, FilterRegistry],
|
175
|
+
) -> T:
|
176
|
+
"""Mapping post-filter to value with sequence list of filter function name
|
177
|
+
that will get from the filter registry.
|
178
|
+
|
179
|
+
:param value: A string value that want to map with filter function.
|
180
|
+
:param post_filter: A list of post-filter function name.
|
181
|
+
:param filters: A filter registry.
|
182
|
+
|
183
|
+
:rtype: T
|
184
|
+
"""
|
185
|
+
for ft in post_filter:
|
186
|
+
func_name, f_func, args, kwargs = get_args_from_filter(ft, filters)
|
187
|
+
try:
|
188
|
+
if isinstance(f_func, list):
|
189
|
+
for func in f_func:
|
190
|
+
value: T = func(value)
|
191
|
+
else:
|
192
|
+
value: T = f_func(value, *args, **kwargs)
|
193
|
+
except UtilException as err:
|
194
|
+
logger.warning(str(err))
|
195
|
+
raise
|
196
|
+
except Exception as err:
|
197
|
+
logger.warning(str(err))
|
198
|
+
raise UtilException(
|
199
|
+
f"The post-filter function: {func_name} does not fit with "
|
200
|
+
f"{value} (type: {type(value).__name__})."
|
201
|
+
) from None
|
202
|
+
return value
|
203
|
+
|
204
|
+
|
205
|
+
def not_in_template(value: Any, *, not_in: str = "matrix.") -> bool:
|
206
|
+
"""Check value should not pass template with not_in value prefix.
|
207
|
+
|
208
|
+
:param value: A value that want to find parameter template prefix.
|
209
|
+
:param not_in: The not-in string that use in the `.startswith` function.
|
210
|
+
|
211
|
+
:rtype: bool
|
212
|
+
"""
|
213
|
+
if isinstance(value, dict):
|
214
|
+
return any(not_in_template(value[k], not_in=not_in) for k in value)
|
215
|
+
elif isinstance(value, (list, tuple, set)):
|
216
|
+
return any(not_in_template(i, not_in=not_in) for i in value)
|
217
|
+
elif not isinstance(value, str):
|
218
|
+
return False
|
219
|
+
return any(
|
220
|
+
(not found.caller.strip().startswith(not_in))
|
221
|
+
for found in Re.finditer_caller(value.strip())
|
222
|
+
)
|
223
|
+
|
224
|
+
|
225
|
+
def has_template(value: Any) -> bool:
|
226
|
+
"""Check value include templating string.
|
227
|
+
|
228
|
+
:param value: A value that want to find parameter template.
|
229
|
+
|
230
|
+
:rtype: bool
|
231
|
+
"""
|
232
|
+
if isinstance(value, dict):
|
233
|
+
return any(has_template(value[k]) for k in value)
|
234
|
+
elif isinstance(value, (list, tuple, set)):
|
235
|
+
return any(has_template(i) for i in value)
|
236
|
+
elif not isinstance(value, str):
|
237
|
+
return False
|
238
|
+
return bool(Re.RE_CALLER.findall(value.strip()))
|
239
|
+
|
240
|
+
|
241
|
+
def str2template(
|
242
|
+
value: str,
|
243
|
+
params: DictData,
|
244
|
+
*,
|
245
|
+
filters: dict[str, FilterRegistry] | None = None,
|
246
|
+
) -> Any:
|
247
|
+
"""(Sub-function) Pass param to template string that can search by
|
248
|
+
``RE_CALLER`` regular expression.
|
249
|
+
|
250
|
+
The getter value that map a template should have typing support align
|
251
|
+
with the workflow parameter types that is `str`, `int`, `datetime`, and
|
252
|
+
`list`.
|
253
|
+
|
254
|
+
:param value: A string value that want to map with params
|
255
|
+
:param params: A parameter value that getting with matched regular
|
256
|
+
expression.
|
257
|
+
:param filters:
|
258
|
+
"""
|
259
|
+
filters: dict[str, FilterRegistry] = filters or make_filter_registry()
|
260
|
+
|
261
|
+
# NOTE: remove space before and after this string value.
|
262
|
+
value: str = value.strip()
|
263
|
+
for found in Re.finditer_caller(value):
|
264
|
+
# NOTE:
|
265
|
+
# Get caller and filter values that setting inside;
|
266
|
+
#
|
267
|
+
# ... ``${{ <caller-value> [ | <filter-value>] ... }}``
|
268
|
+
#
|
269
|
+
caller: str = found.caller
|
270
|
+
pfilter: list[str] = [
|
271
|
+
i.strip()
|
272
|
+
for i in (found.post_filters.strip().removeprefix("|").split("|"))
|
273
|
+
if i != ""
|
274
|
+
]
|
275
|
+
if not hasdot(caller, params):
|
276
|
+
raise UtilException(f"The params does not set caller: {caller!r}.")
|
277
|
+
|
278
|
+
# NOTE: from validate step, it guarantees that caller exists in params.
|
279
|
+
getter: Any = getdot(caller, params)
|
280
|
+
|
281
|
+
# NOTE:
|
282
|
+
# If type of getter caller is not string type, and it does not use to
|
283
|
+
# concat other string value, it will return origin value from the
|
284
|
+
# ``getdot`` function.
|
285
|
+
if value.replace(found.full, "", 1) == "":
|
286
|
+
return map_post_filter(getter, pfilter, filters=filters)
|
287
|
+
|
288
|
+
# NOTE: map post-filter function.
|
289
|
+
getter: Any = map_post_filter(getter, pfilter, filters=filters)
|
290
|
+
if not isinstance(getter, str):
|
291
|
+
getter: str = str(getter)
|
292
|
+
|
293
|
+
value: str = value.replace(found.full, getter, 1)
|
294
|
+
|
295
|
+
return search_env_replace(value)
|
296
|
+
|
297
|
+
|
298
|
+
def param2template(
|
299
|
+
value: Any,
|
300
|
+
params: DictData,
|
301
|
+
) -> Any:
|
302
|
+
"""Pass param to template string that can search by ``RE_CALLER`` regular
|
303
|
+
expression.
|
304
|
+
|
305
|
+
:param value: A value that want to map with params
|
306
|
+
:param params: A parameter value that getting with matched regular
|
307
|
+
expression.
|
308
|
+
|
309
|
+
:rtype: Any
|
310
|
+
:returns: An any getter value from the params input.
|
311
|
+
"""
|
312
|
+
filters: dict[str, FilterRegistry] = make_filter_registry()
|
313
|
+
if isinstance(value, dict):
|
314
|
+
return {k: param2template(value[k], params) for k in value}
|
315
|
+
elif isinstance(value, (list, tuple, set)):
|
316
|
+
return type(value)([param2template(i, params) for i in value])
|
317
|
+
elif not isinstance(value, str):
|
318
|
+
return value
|
319
|
+
return str2template(value, params, filters=filters)
|
320
|
+
|
321
|
+
|
322
|
+
@custom_filter("fmt") # pragma: no cov
|
323
|
+
def datetime_format(value: datetime, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
|
324
|
+
"""Format datetime object to string with the format.
|
325
|
+
|
326
|
+
:param value: A datetime value that want to format to string value.
|
327
|
+
:param fmt: A format string pattern that passing to the `dt.strftime`
|
328
|
+
method.
|
329
|
+
|
330
|
+
:rtype: str
|
331
|
+
"""
|
332
|
+
if isinstance(value, datetime):
|
333
|
+
return value.strftime(fmt)
|
334
|
+
raise UtilException(
|
335
|
+
"This custom function should pass input value with datetime type."
|
336
|
+
)
|