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.
@@ -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
+ )