ddeutil-workflow 0.0.26.post0__py3-none-any.whl → 0.0.27__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/stage.py CHANGED
@@ -31,7 +31,6 @@ import time
31
31
  import uuid
32
32
  from abc import ABC, abstractmethod
33
33
  from collections.abc import Iterator
34
- from dataclasses import dataclass
35
34
  from functools import wraps
36
35
  from inspect import Parameter
37
36
  from pathlib import Path
@@ -48,19 +47,16 @@ from pydantic import BaseModel, Field
48
47
  from pydantic.functional_validators import model_validator
49
48
  from typing_extensions import Self
50
49
 
51
- from .__types import DictData, DictStr, Re, TupleStr
50
+ from .__types import DictData, DictStr, TupleStr
52
51
  from .conf import config, get_logger
53
52
  from .exceptions import StageException
53
+ from .hook import TagFunc, extract_hook
54
54
  from .result import Result
55
+ from .templates import not_in_template, param2template
55
56
  from .utils import (
56
- Registry,
57
- TagFunc,
58
57
  cut_id,
59
58
  gen_id,
60
59
  make_exec,
61
- make_registry,
62
- not_in_template,
63
- param2template,
64
60
  )
65
61
 
66
62
  P = ParamSpec("P")
@@ -76,7 +72,6 @@ __all__: TupleStr = (
76
72
  "HookStage",
77
73
  "TriggerStage",
78
74
  "Stage",
79
- "extract_hook",
80
75
  )
81
76
 
82
77
 
@@ -558,53 +553,6 @@ class PyStage(BaseStage):
558
553
  )
559
554
 
560
555
 
561
- @dataclass(frozen=True)
562
- class HookSearchData:
563
- """Hook Search dataclass that use for receive regular expression grouping
564
- dict from searching hook string value.
565
- """
566
-
567
- path: str
568
- func: str
569
- tag: str
570
-
571
-
572
- def extract_hook(hook: str) -> Callable[[], TagFunc]:
573
- """Extract Hook function from string value to hook partial function that
574
- does run it at runtime.
575
-
576
- :raise NotImplementedError: When the searching hook's function result does
577
- not exist in the registry.
578
- :raise NotImplementedError: When the searching hook's tag result does not
579
- exists in the registry with its function key.
580
-
581
- :param hook: A hook value that able to match with Task regex.
582
- :rtype: Callable[[], TagFunc]
583
- """
584
- if not (found := Re.RE_TASK_FMT.search(hook)):
585
- raise ValueError(
586
- f"Hook {hook!r} does not match with hook format regex."
587
- )
588
-
589
- # NOTE: Pass the searching hook string to `path`, `func`, and `tag`.
590
- hook: HookSearchData = HookSearchData(**found.groupdict())
591
-
592
- # NOTE: Registry object should implement on this package only.
593
- rgt: dict[str, Registry] = make_registry(f"{hook.path}")
594
- if hook.func not in rgt:
595
- raise NotImplementedError(
596
- f"``REGISTER-MODULES.{hook.path}.registries`` does not "
597
- f"implement registry: {hook.func!r}."
598
- )
599
-
600
- if hook.tag not in rgt[hook.func]:
601
- raise NotImplementedError(
602
- f"tag: {hook.tag!r} does not found on registry func: "
603
- f"``REGISTER-MODULES.{hook.path}.registries.{hook.func}``"
604
- )
605
- return rgt[hook.func][hook.tag]
606
-
607
-
608
556
  class HookStage(BaseStage):
609
557
  """Hook executor that hook the Python function from registry with tag
610
558
  decorator function in ``utils`` module and run it with input arguments.
@@ -0,0 +1,334 @@
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 name attribute.
48
+ """
49
+
50
+ name: 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
+ rs[func.filter] = import_string(f"{module}.{fstr}")
99
+
100
+ rs.update(FILTERS)
101
+ return rs
102
+
103
+
104
+ def get_args_const(
105
+ expr: str,
106
+ ) -> tuple[str, list[Constant], dict[str, Constant]]:
107
+ """Get arguments and keyword-arguments from function calling string.
108
+
109
+ :rtype: tuple[str, list[Constant], dict[str, Constant]]
110
+ """
111
+ try:
112
+ mod: Module = parse(expr)
113
+ except SyntaxError:
114
+ raise UtilException(
115
+ f"Post-filter: {expr} does not valid because it raise syntax error."
116
+ ) from None
117
+
118
+ body: list[Expr] = mod.body
119
+ if len(body) > 1:
120
+ raise UtilException(
121
+ "Post-filter function should be only one calling per workflow."
122
+ )
123
+
124
+ caller: Union[Name, Call]
125
+ if isinstance((caller := body[0].value), Name):
126
+ return caller.id, [], {}
127
+ elif not isinstance(caller, Call):
128
+ raise UtilException(
129
+ f"Get arguments does not support for caller type: {type(caller)}"
130
+ )
131
+
132
+ name: Name = caller.func
133
+ args: list[Constant] = caller.args
134
+ keywords: dict[str, Constant] = {k.arg: k.value for k in caller.keywords}
135
+
136
+ if any(not isinstance(i, Constant) for i in args):
137
+ raise UtilException(f"Argument of {expr} should be constant.")
138
+
139
+ if any(not isinstance(i, Constant) for i in keywords.values()):
140
+ raise UtilException(f"Keyword argument of {expr} should be constant.")
141
+
142
+ return name.id, args, keywords
143
+
144
+
145
+ def get_args_from_filter(
146
+ ft: str,
147
+ filters: dict[str, FilterRegistry],
148
+ ) -> tuple[str, FilterRegistry, list[Any], dict[Any, Any]]: # pragma: no cov
149
+ """Get arguments and keyword-arguments from filter function calling string.
150
+ and validate it with the filter functions mapping dict.
151
+ """
152
+ func_name, _args, _kwargs = get_args_const(ft)
153
+ args: list[Any] = [arg.value for arg in _args]
154
+ kwargs: dict[Any, Any] = {k: v.value for k, v in _kwargs.items()}
155
+
156
+ if func_name not in filters:
157
+ raise UtilException(
158
+ f"The post-filter: {func_name!r} does not support yet."
159
+ )
160
+
161
+ if isinstance((f_func := filters[func_name]), list) and (args or kwargs):
162
+ raise UtilException(
163
+ "Chain filter function does not support for passing arguments."
164
+ )
165
+
166
+ return func_name, f_func, args, kwargs
167
+
168
+
169
+ def map_post_filter(
170
+ value: T,
171
+ post_filter: list[str],
172
+ filters: dict[str, FilterRegistry],
173
+ ) -> T:
174
+ """Mapping post-filter to value with sequence list of filter function name
175
+ that will get from the filter registry.
176
+
177
+ :param value: A string value that want to mapped with filter function.
178
+ :param post_filter: A list of post-filter function name.
179
+ :param filters: A filter registry.
180
+
181
+ :rtype: T
182
+ """
183
+ for ft in post_filter:
184
+ func_name, f_func, args, kwargs = get_args_from_filter(ft, filters)
185
+ try:
186
+ if isinstance(f_func, list):
187
+ for func in f_func:
188
+ value: T = func(value)
189
+ else:
190
+ value: T = f_func(value, *args, **kwargs)
191
+ except UtilException as err:
192
+ logger.warning(str(err))
193
+ raise
194
+ except Exception as err:
195
+ logger.warning(str(err))
196
+ raise UtilException(
197
+ f"The post-filter function: {func_name} does not fit with "
198
+ f"{value} (type: {type(value).__name__})."
199
+ ) from None
200
+ return value
201
+
202
+
203
+ def not_in_template(value: Any, *, not_in: str = "matrix.") -> bool:
204
+ """Check value should not pass template with not_in value prefix.
205
+
206
+ :param value: A value that want to find parameter template prefix.
207
+ :param not_in: The not in string that use in the `.startswith` function.
208
+
209
+ :rtype: bool
210
+ """
211
+ if isinstance(value, dict):
212
+ return any(not_in_template(value[k], not_in=not_in) for k in value)
213
+ elif isinstance(value, (list, tuple, set)):
214
+ return any(not_in_template(i, not_in=not_in) for i in value)
215
+ elif not isinstance(value, str):
216
+ return False
217
+ return any(
218
+ (not found.caller.strip().startswith(not_in))
219
+ for found in Re.finditer_caller(value.strip())
220
+ )
221
+
222
+
223
+ def has_template(value: Any) -> bool:
224
+ """Check value include templating string.
225
+
226
+ :param value: A value that want to find parameter template.
227
+
228
+ :rtype: bool
229
+ """
230
+ if isinstance(value, dict):
231
+ return any(has_template(value[k]) for k in value)
232
+ elif isinstance(value, (list, tuple, set)):
233
+ return any(has_template(i) for i in value)
234
+ elif not isinstance(value, str):
235
+ return False
236
+ return bool(Re.RE_CALLER.findall(value.strip()))
237
+
238
+
239
+ def str2template(
240
+ value: str,
241
+ params: DictData,
242
+ *,
243
+ filters: dict[str, FilterRegistry] | None = None,
244
+ ) -> Any:
245
+ """(Sub-function) Pass param to template string that can search by
246
+ ``RE_CALLER`` regular expression.
247
+
248
+ The getter value that map a template should have typing support align
249
+ with the workflow parameter types that is `str`, `int`, `datetime`, and
250
+ `list`.
251
+
252
+ :param value: A string value that want to mapped with an params
253
+ :param params: A parameter value that getting with matched regular
254
+ expression.
255
+ :param filters:
256
+ """
257
+ filters: dict[str, FilterRegistry] = filters or make_filter_registry()
258
+
259
+ # NOTE: remove space before and after this string value.
260
+ value: str = value.strip()
261
+ for found in Re.finditer_caller(value):
262
+ # NOTE:
263
+ # Get caller and filter values that setting inside;
264
+ #
265
+ # ... ``${{ <caller-value> [ | <filter-value>] ... }}``
266
+ #
267
+ caller: str = found.caller
268
+ pfilter: list[str] = [
269
+ i.strip()
270
+ for i in (found.post_filters.strip().removeprefix("|").split("|"))
271
+ if i != ""
272
+ ]
273
+ if not hasdot(caller, params):
274
+ raise UtilException(f"The params does not set caller: {caller!r}.")
275
+
276
+ # NOTE: from validate step, it guarantee that caller exists in params.
277
+ getter: Any = getdot(caller, params)
278
+
279
+ # NOTE:
280
+ # If type of getter caller is not string type and it does not use to
281
+ # concat other string value, it will return origin value from the
282
+ # ``getdot`` function.
283
+ if value.replace(found.full, "", 1) == "":
284
+ return map_post_filter(getter, pfilter, filters=filters)
285
+
286
+ # NOTE: map post-filter function.
287
+ getter: Any = map_post_filter(getter, pfilter, filters=filters)
288
+ if not isinstance(getter, str):
289
+ getter: str = str(getter)
290
+
291
+ value: str = value.replace(found.full, getter, 1)
292
+
293
+ return search_env_replace(value)
294
+
295
+
296
+ def param2template(
297
+ value: Any,
298
+ params: DictData,
299
+ ) -> Any:
300
+ """Pass param to template string that can search by ``RE_CALLER`` regular
301
+ expression.
302
+
303
+ :param value: A value that want to mapped with an params
304
+ :param params: A parameter value that getting with matched regular
305
+ expression.
306
+
307
+ :rtype: Any
308
+ :returns: An any getter value from the params input.
309
+ """
310
+ filters: dict[str, FilterRegistry] = make_filter_registry()
311
+ if isinstance(value, dict):
312
+ return {k: param2template(value[k], params) for k in value}
313
+ elif isinstance(value, (list, tuple, set)):
314
+ return type(value)([param2template(i, params) for i in value])
315
+ elif not isinstance(value, str):
316
+ return value
317
+ return str2template(value, params, filters=filters)
318
+
319
+
320
+ @custom_filter("fmt") # pragma: no cov
321
+ def datetime_format(value: datetime, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
322
+ """Format datetime object to string with the format.
323
+
324
+ :param value: A datetime value that want to format to string value.
325
+ :param fmt: A format string pattern that passing to the `dt.strftime`
326
+ method.
327
+
328
+ :rtype: str
329
+ """
330
+ if isinstance(value, datetime):
331
+ return value.strftime(fmt)
332
+ raise UtilException(
333
+ "This custom function should pass input value with datetime type."
334
+ )