ddeutil-workflow 0.0.7__py3-none-any.whl → 0.0.8__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
@@ -3,6 +3,18 @@
3
3
  # Licensed under the MIT License. See LICENSE in the project root for
4
4
  # license information.
5
5
  # ------------------------------------------------------------------------------
6
+ """Stage Model that use for getting stage data template from Job Model.
7
+ The stage that handle the minimize task that run in some thread (same thread at
8
+ its job owner) that mean it is the lowest executor of a pipeline workflow that
9
+ can tracking logs.
10
+
11
+ The output of stage execution only return 0 status because I do not want to
12
+ handle stage error on this stage model. I think stage model should have a lot of
13
+ usecase and it does not worry when I want to create a new one.
14
+
15
+ Execution --> Ok --> Result with 0
16
+ --> Error --> Raise StageException
17
+ """
6
18
  from __future__ import annotations
7
19
 
8
20
  import contextlib
@@ -15,6 +27,7 @@ import uuid
15
27
  from abc import ABC, abstractmethod
16
28
  from collections.abc import Iterator
17
29
  from dataclasses import dataclass
30
+ from functools import wraps
18
31
  from inspect import Parameter
19
32
  from pathlib import Path
20
33
  from subprocess import CompletedProcess
@@ -22,6 +35,7 @@ from typing import Callable, Optional, Union
22
35
 
23
36
  from ddeutil.core import str2bool
24
37
  from pydantic import BaseModel, Field
38
+ from pydantic.functional_validators import model_validator
25
39
 
26
40
  from .__types import DictData, DictStr, Re, TupleStr
27
41
  from .exceptions import StageException
@@ -36,6 +50,35 @@ from .utils import (
36
50
  )
37
51
 
38
52
 
53
+ def handler_result(message: str | None = None):
54
+ """Decorator function for handler result from the stage execution."""
55
+ message: str = message or ""
56
+
57
+ def decorator(func):
58
+
59
+ @wraps(func)
60
+ def wrapped(self: BaseStage, *args, **kwargs):
61
+ try:
62
+ rs: DictData = func(self, *args, **kwargs)
63
+ return Result(status=0, context=rs)
64
+ except Exception as err:
65
+ logging.error(
66
+ f"({self.run_id}) [STAGE]: {err.__class__.__name__}: {err}"
67
+ )
68
+ if isinstance(err, StageException):
69
+ raise StageException(
70
+ f"{self.__class__.__name__}: {message}\n---\n\t{err}"
71
+ ) from err
72
+ raise StageException(
73
+ f"{self.__class__.__name__}: {message}\n---\n\t"
74
+ f"{err.__class__.__name__}: {err}"
75
+ ) from None
76
+
77
+ return wrapped
78
+
79
+ return decorator
80
+
81
+
39
82
  class BaseStage(BaseModel, ABC):
40
83
  """Base Stage Model that keep only id and name fields for the stage
41
84
  metadata. If you want to implement any custom stage, you can use this class
@@ -56,6 +99,17 @@ class BaseStage(BaseModel, ABC):
56
99
  default=None,
57
100
  alias="if",
58
101
  )
102
+ run_id: Optional[str] = Field(
103
+ default=None,
104
+ description="A running stage ID.",
105
+ repr=False,
106
+ )
107
+
108
+ @model_validator(mode="after")
109
+ def __prepare_running_id(self):
110
+ if self.run_id is None:
111
+ self.run_id = gen_id(self.name + (self.id or ""), unique=True)
112
+ return self
59
113
 
60
114
  @abstractmethod
61
115
  def execute(self, params: DictData) -> Result:
@@ -74,24 +128,40 @@ class BaseStage(BaseModel, ABC):
74
128
  :param params: A context data that want to add output result.
75
129
  :rtype: DictData
76
130
  """
77
- if self.id:
78
- _id: str = param2template(self.id, params)
79
- elif str2bool(os.getenv("WORKFLOW_CORE_DEFAULT_STAGE_ID", "false")):
80
- _id: str = gen_id(param2template(self.name, params))
81
- else:
131
+ if not (
132
+ self.id
133
+ or str2bool(os.getenv("WORKFLOW_CORE_DEFAULT_STAGE_ID", "false"))
134
+ ):
135
+ logging.debug(
136
+ f"({self.run_id}) [STAGE]: Output does not set because this "
137
+ f"stage does not set ID or default stage ID config flag not be "
138
+ f"True."
139
+ )
82
140
  return params
83
141
 
84
142
  # NOTE: Create stages key to receive an output from the stage execution.
85
143
  if "stages" not in params:
86
144
  params["stages"] = {}
87
145
 
146
+ # TODO: Validate stage id and name should not dynamic with params
147
+ # template. (allow only matrix)
148
+ if self.id:
149
+ _id: str = param2template(self.id, params=params)
150
+ else:
151
+ _id: str = gen_id(param2template(self.name, params=params))
152
+
153
+ # NOTE: Set the output to that stage generated ID.
88
154
  params["stages"][_id] = {"outputs": output}
155
+ logging.debug(
156
+ f"({self.run_id}) [STAGE]: Set output complete with stage ID: {_id}"
157
+ )
89
158
  return params
90
159
 
91
- def is_skip(self, params: DictData | None = None) -> bool:
160
+ def is_skipped(self, params: DictData | None = None) -> bool:
92
161
  """Return true if condition of this stage do not correct.
93
162
 
94
163
  :param params: A parameters that want to pass to condition template.
164
+ :rtype: bool
95
165
  """
96
166
  params: DictData = params or {}
97
167
  if self.condition is None:
@@ -104,7 +174,7 @@ class BaseStage(BaseModel, ABC):
104
174
  raise TypeError("Return type of condition does not be boolean")
105
175
  return not rs
106
176
  except Exception as err:
107
- logging.error(str(err))
177
+ logging.error(f"({self.run_id}) [STAGE]: {err}")
108
178
  raise StageException(str(err)) from err
109
179
 
110
180
 
@@ -131,8 +201,10 @@ class EmptyStage(BaseStage):
131
201
  :param params: A context data that want to add output result. But this
132
202
  stage does not pass any output.
133
203
  """
134
- stm: str = param2template(self.echo, params=params) or "..."
135
- logging.info(f"[STAGE]: Empty-Execute: {self.name!r}: " f"( {stm} )")
204
+ logging.info(
205
+ f"({self.run_id}) [STAGE]: Empty-Execute: {self.name!r}: "
206
+ f"( {param2template(self.echo, params=params) or '...'} )"
207
+ )
136
208
  return Result(status=0, context={})
137
209
 
138
210
 
@@ -183,12 +255,17 @@ class BashStage(BaseStage):
183
255
  f.write(bash.replace("\r\n", "\n"))
184
256
 
185
257
  make_exec(f"./{f_name}")
258
+ logging.debug(
259
+ f"({self.run_id}) [STAGE]: Start create `.sh` file and running a "
260
+ f"bash statement."
261
+ )
186
262
 
187
263
  yield [f_shebang, f_name]
188
264
 
189
265
  Path(f"./{f_name}").unlink()
190
266
 
191
- def execute(self, params: DictData) -> Result:
267
+ @handler_result()
268
+ def execute(self, params: DictData) -> DictData:
192
269
  """Execute the Bash statement with the Python build-in ``subprocess``
193
270
  package.
194
271
 
@@ -199,7 +276,7 @@ class BashStage(BaseStage):
199
276
  with self.__prepare_bash(
200
277
  bash=bash, env=param2template(self.env, params)
201
278
  ) as sh:
202
- logging.info(f"[STAGE]: Shell-Execute: {sh}")
279
+ logging.info(f"({self.run_id}) [STAGE]: Shell-Execute: {sh}")
203
280
  rs: CompletedProcess = subprocess.run(
204
281
  sh,
205
282
  shell=False,
@@ -212,21 +289,32 @@ class BashStage(BaseStage):
212
289
  if "\\x00" in rs.stderr
213
290
  else rs.stderr
214
291
  )
215
- logging.error(f"{err}\n\n```bash\n{bash}```")
216
- raise StageException(f"{err}\n\n```bash\n{bash}```")
217
- return Result(
218
- status=0,
219
- context={
220
- "return_code": rs.returncode,
221
- "stdout": rs.stdout.rstrip("\n"),
222
- "stderr": rs.stderr.rstrip("\n"),
223
- },
224
- )
292
+ logging.error(
293
+ f"({self.run_id}) [STAGE]: {err}\n\n```bash\n{bash}```"
294
+ )
295
+ raise StageException(
296
+ f"{err.__class__.__name__}: {err}\nRunning Statement:"
297
+ f"\n---\n```bash\n{bash}\n```"
298
+ )
299
+ return {
300
+ "return_code": rs.returncode,
301
+ "stdout": rs.stdout.rstrip("\n"),
302
+ "stderr": rs.stderr.rstrip("\n"),
303
+ }
225
304
 
226
305
 
227
306
  class PyStage(BaseStage):
228
307
  """Python executor stage that running the Python statement that receive
229
308
  globals nad additional variables.
309
+
310
+ Data Validate:
311
+ >>> stage = {
312
+ ... "name": "Python stage execution",
313
+ ... "run": 'print("Hello {x}")',
314
+ ... "vars": {
315
+ ... "x": "BAR",
316
+ ... },
317
+ ... }
230
318
  """
231
319
 
232
320
  run: str = Field(
@@ -259,7 +347,8 @@ class PyStage(BaseStage):
259
347
  params.update({k: _globals[k] for k in params if k in _globals})
260
348
  return params
261
349
 
262
- def execute(self, params: DictData) -> Result:
350
+ @handler_result()
351
+ def execute(self, params: DictData) -> DictData:
263
352
  """Execute the Python statement that pass all globals and input params
264
353
  to globals argument on ``exec`` build-in function.
265
354
 
@@ -271,18 +360,10 @@ class PyStage(BaseStage):
271
360
  globals() | params | param2template(self.vars, params)
272
361
  )
273
362
  _locals: DictData = {}
274
- try:
275
- logging.info(f"[STAGE]: Py-Execute: {uuid.uuid4()}")
276
- exec(param2template(self.run, params), _globals, _locals)
277
- except Exception as err:
278
- raise StageException(
279
- f"{err.__class__.__name__}: {err}\nRunning Statement:\n---\n"
280
- f"{self.run}"
281
- ) from None
282
- return Result(
283
- status=0,
284
- context={"locals": _locals, "globals": _globals},
285
- )
363
+ run: str = param2template(self.run, params)
364
+ logging.info(f"({self.run_id}) [STAGE]: Py-Execute: {uuid.uuid4()}")
365
+ exec(run, _globals, _locals)
366
+ return {"locals": _locals, "globals": _globals}
286
367
 
287
368
 
288
369
  @dataclass
@@ -294,6 +375,34 @@ class HookSearch:
294
375
  tag: str
295
376
 
296
377
 
378
+ def extract_hook(hook: str) -> Callable[[], TagFunc]:
379
+ """Extract Hook string value to hook function.
380
+
381
+ :param hook: A hook value that able to match with Task regex.
382
+ :rtype: Callable[[], TagFunc]
383
+ """
384
+ if not (found := Re.RE_TASK_FMT.search(hook)):
385
+ raise ValueError("Task does not match with task format regex.")
386
+
387
+ # NOTE: Pass the searching hook string to `path`, `func`, and `tag`.
388
+ hook: HookSearch = HookSearch(**found.groupdict())
389
+
390
+ # NOTE: Registry object should implement on this package only.
391
+ rgt: dict[str, Registry] = make_registry(f"{hook.path}")
392
+ if hook.func not in rgt:
393
+ raise NotImplementedError(
394
+ f"``REGISTER-MODULES.{hook.path}.registries`` does not "
395
+ f"implement registry: {hook.func!r}."
396
+ )
397
+
398
+ if hook.tag not in rgt[hook.func]:
399
+ raise NotImplementedError(
400
+ f"tag: {hook.tag!r} does not found on registry func: "
401
+ f"``REGISTER-MODULES.{hook.path}.registries.{hook.func}``"
402
+ )
403
+ return rgt[hook.func][hook.tag]
404
+
405
+
297
406
  class HookStage(BaseStage):
298
407
  """Hook executor that hook the Python function from registry with tag
299
408
  decorator function in ``utils`` module and run it with input arguments.
@@ -314,48 +423,27 @@ class HookStage(BaseStage):
314
423
  """
315
424
 
316
425
  uses: str = Field(
317
- description="A pointer that want to load function from registry",
426
+ description="A pointer that want to load function from registry.",
427
+ )
428
+ args: DictData = Field(
429
+ description="An arguments that want to pass to the hook function.",
430
+ alias="with",
318
431
  )
319
- args: DictData = Field(alias="with")
320
-
321
- @staticmethod
322
- def extract_hook(hook: str) -> Callable[[], TagFunc]:
323
- """Extract Hook string value to hook function.
324
-
325
- :param hook: A hook value that able to match with Task regex.
326
- """
327
- if not (found := Re.RE_TASK_FMT.search(hook)):
328
- raise ValueError("Task does not match with task format regex.")
329
-
330
- # NOTE: Pass the searching hook string to `path`, `func`, and `tag`.
331
- hook: HookSearch = HookSearch(**found.groupdict())
332
-
333
- # NOTE: Registry object should implement on this package only.
334
- rgt: dict[str, Registry] = make_registry(f"{hook.path}")
335
- if hook.func not in rgt:
336
- raise NotImplementedError(
337
- f"``REGISTER-MODULES.{hook.path}.registries`` does not "
338
- f"implement registry: {hook.func!r}."
339
- )
340
-
341
- if hook.tag not in rgt[hook.func]:
342
- raise NotImplementedError(
343
- f"tag: {hook.tag!r} does not found on registry func: "
344
- f"``REGISTER-MODULES.{hook.path}.registries.{hook.func}``"
345
- )
346
- return rgt[hook.func][hook.tag]
347
432
 
348
- def execute(self, params: DictData) -> Result:
433
+ @handler_result()
434
+ def execute(self, params: DictData) -> DictData:
349
435
  """Execute the Hook function that already in the hook registry.
350
436
 
351
437
  :param params: A parameter that want to pass before run any statement.
352
438
  :type params: DictData
353
439
  :rtype: Result
354
440
  """
355
- t_func: TagFunc = self.extract_hook(param2template(self.uses, params))()
441
+ t_func_hook: str = param2template(self.uses, params)
442
+ t_func: TagFunc = extract_hook(t_func_hook)()
356
443
  if not callable(t_func):
357
- raise ImportError("Hook caller function does not callable.")
358
-
444
+ raise ImportError(
445
+ f"Hook caller {t_func_hook!r} function does not callable."
446
+ )
359
447
  # VALIDATE: check input task caller parameters that exists before
360
448
  # calling.
361
449
  args: DictData = param2template(self.args, params)
@@ -369,56 +457,62 @@ class HookStage(BaseStage):
369
457
  f"Necessary params, ({', '.join(ips.parameters.keys())}), "
370
458
  f"does not set to args"
371
459
  )
372
-
373
460
  # NOTE: add '_' prefix if it want to use.
374
461
  for k in ips.parameters:
375
462
  if k.removeprefix("_") in args:
376
463
  args[k] = args.pop(k.removeprefix("_"))
377
464
 
378
- try:
379
- logging.info(f"[STAGE]: Hook-Execute: {t_func.name}@{t_func.tag}")
380
- rs: DictData = t_func(**param2template(args, params))
381
- except Exception as err:
382
- raise StageException(f"{err.__class__.__name__}: {err}") from err
465
+ logging.info(
466
+ f"({self.run_id}) [STAGE]: Hook-Execute: "
467
+ f"{t_func.name}@{t_func.tag}"
468
+ )
469
+ rs: DictData = t_func(**param2template(args, params))
383
470
 
384
- # VALIDATE: Check the result type from hook function, it should be dict.
471
+ # VALIDATE:
472
+ # Check the result type from hook function, it should be dict.
385
473
  if not isinstance(rs, dict):
386
- raise StageException(
387
- f"Return of hook function: {t_func.name}@{t_func.tag} does not "
388
- f"serialize to result model, you should fix it to `dict` type."
474
+ raise TypeError(
475
+ f"Return of hook function: {t_func.name}@{t_func.tag} does "
476
+ f"not serialize to result model, you should fix it to "
477
+ f"`dict` type."
389
478
  )
390
- return Result(status=0, context=rs)
479
+ return rs
391
480
 
392
481
 
393
482
  class TriggerStage(BaseStage):
394
- """Trigger Pipeline execution stage that execute another pipeline object."""
483
+ """Trigger Pipeline execution stage that execute another pipeline object.
484
+
485
+ Data Validate:
486
+ >>> stage = {
487
+ ... "name": "Trigger pipeline stage execution",
488
+ ... "trigger": 'pipeline-name-for-loader',
489
+ ... "params": {
490
+ ... "run-date": "2024-08-01",
491
+ ... "source": "src",
492
+ ... },
493
+ ... }
494
+ """
395
495
 
396
496
  trigger: str = Field(description="A trigger pipeline name.")
397
- params: DictData = Field(default_factory=dict)
497
+ params: DictData = Field(
498
+ default_factory=dict,
499
+ description="A parameter that want to pass to pipeline execution.",
500
+ )
398
501
 
399
- def execute(self, params: DictData) -> Result:
400
- """Trigger execution.
502
+ @handler_result("Raise from trigger pipeline")
503
+ def execute(self, params: DictData) -> DictData:
504
+ """Trigger pipeline execution.
401
505
 
402
506
  :param params: A parameter data that want to use in this execution.
403
507
  :rtype: Result
404
508
  """
405
- from .exceptions import PipelineException
406
509
  from .pipeline import Pipeline
407
510
 
408
- try:
409
- # NOTE: Loading pipeline object from trigger name.
410
- pipe: Pipeline = Pipeline.from_loader(
411
- name=self.trigger, externals={}
412
- )
413
- rs: Result = pipe.execute(
414
- params=param2template(self.params, params)
415
- )
416
- except PipelineException as err:
417
- _alias_stage: str = self.id or self.name
418
- raise StageException(
419
- f"Trigger Stage: {_alias_stage} get trigger pipeline exception."
420
- ) from err
421
- return rs
511
+ # NOTE: Loading pipeline object from trigger name.
512
+ _trigger: str = param2template(self.trigger, params=params)
513
+ pipe: Pipeline = Pipeline.from_loader(name=_trigger, externals={})
514
+ rs: Result = pipe.execute(params=param2template(self.params, params))
515
+ return rs.context
422
516
 
423
517
 
424
518
  # NOTE: Order of parsing stage data
ddeutil/workflow/utils.py CHANGED
@@ -12,17 +12,17 @@ import stat
12
12
  from abc import ABC, abstractmethod
13
13
  from ast import Call, Constant, Expr, Module, Name, parse
14
14
  from collections.abc import Iterator
15
- from dataclasses import dataclass, field
16
15
  from datetime import date, datetime
17
16
  from functools import wraps
18
17
  from hashlib import md5
19
18
  from importlib import import_module
19
+ from inspect import isfunction
20
20
  from itertools import product
21
21
  from pathlib import Path
22
22
  from typing import Any, Callable, Literal, Optional, Protocol, Union
23
23
  from zoneinfo import ZoneInfo
24
24
 
25
- from ddeutil.core import getdot, hasdot, import_string, lazy
25
+ from ddeutil.core import getdot, hasdot, hash_str, import_string, lazy, str2bool
26
26
  from ddeutil.io import PathData, search_env_replace
27
27
  from ddeutil.io.models.lineage import dt_now
28
28
  from pydantic import BaseModel, ConfigDict, Field
@@ -47,10 +47,10 @@ class Engine(BaseModel):
47
47
 
48
48
  paths: PathData = Field(default_factory=PathData)
49
49
  registry: list[str] = Field(
50
- default_factory=lambda: ["ddeutil.workflow"],
50
+ default_factory=lambda: ["ddeutil.workflow"], # pragma: no cover
51
51
  )
52
52
  registry_filter: list[str] = Field(
53
- default=lambda: ["ddeutil.workflow.utils"]
53
+ default_factory=lambda: ["ddeutil.workflow.utils"], # pragma: no cover
54
54
  )
55
55
 
56
56
  @model_validator(mode="before")
@@ -89,7 +89,15 @@ class ConfParams(BaseModel):
89
89
 
90
90
 
91
91
  def config() -> ConfParams:
92
- """Load Config data from ``workflows-conf.yaml`` file."""
92
+ """Load Config data from ``workflows-conf.yaml`` file.
93
+
94
+ Configuration Docs:
95
+ ---
96
+ :var engine.registry:
97
+ :var engine.registry_filter:
98
+ :var paths.root:
99
+ :var paths.conf:
100
+ """
93
101
  root_path: str = os.getenv("WORKFLOW_ROOT_PATH", ".")
94
102
 
95
103
  regis: list[str] = ["ddeutil.workflow"]
@@ -119,19 +127,31 @@ def config() -> ConfParams:
119
127
  )
120
128
 
121
129
 
122
- def gen_id(value: Any, *, sensitive: bool = True, unique: bool = False) -> str:
130
+ def gen_id(
131
+ value: Any,
132
+ *,
133
+ sensitive: bool = True,
134
+ unique: bool = False,
135
+ ) -> str:
123
136
  """Generate running ID for able to tracking. This generate process use `md5`
124
- function.
125
-
126
- :param value:
127
- :param sensitive:
128
- :param unique:
137
+ algorithm function if ``WORKFLOW_CORE_PIPELINE_ID_SIMPLE`` set to false.
138
+ But it will cut this hashing value length to 10 it the setting value set to
139
+ true.
140
+
141
+ :param value: A value that want to add to prefix before hashing with md5.
142
+ :param sensitive: A flag that convert the value to lower case before hashing
143
+ :param unique: A flag that add timestamp at microsecond level to value
144
+ before hashing.
129
145
  :rtype: str
130
146
  """
131
147
  if not isinstance(value, str):
132
148
  value: str = str(value)
133
149
 
134
150
  tz: ZoneInfo = ZoneInfo(os.getenv("WORKFLOW_CORE_TIMEZONE", "UTC"))
151
+ if str2bool(os.getenv("WORKFLOW_CORE_PIPELINE_ID_SIMPLE", "true")):
152
+ return hash_str(f"{(value if sensitive else value.lower())}", n=10) + (
153
+ f"{datetime.now(tz=tz):%Y%m%d%H%M%S%f}" if unique else ""
154
+ )
135
155
  return md5(
136
156
  (
137
157
  f"{(value if sensitive else value.lower())}"
@@ -328,9 +348,42 @@ Param = Union[
328
348
  ]
329
349
 
330
350
 
331
- @dataclass
332
- class Result:
333
- """Result Dataclass object for passing parameter and receiving output from
351
+ class Context(BaseModel):
352
+ """Context Pydantic Model"""
353
+
354
+ params: dict = Field(default_factory=dict)
355
+ jobs: dict = Field(default_factory=dict)
356
+ error: dict = Field(default_factory=dict)
357
+
358
+
359
+ class Result(BaseModel):
360
+ """Result Pydantic Model for passing parameter and receiving output from
361
+ the pipeline execution.
362
+ """
363
+
364
+ # TODO: Add running ID to this result dataclass.
365
+ # ---
366
+ # parent_run_id: str
367
+ # run_id: str
368
+ #
369
+ status: int = Field(default=2)
370
+ context: DictData = Field(default_factory=dict)
371
+
372
+ def receive(self, result: Result) -> Result:
373
+ self.__dict__["status"] = result.status
374
+ self.__dict__["context"].update(result.context)
375
+ return self
376
+
377
+ def receive_jobs(self, result: Result) -> Result:
378
+ self.__dict__["status"] = result.status
379
+ if "jobs" not in self.__dict__["context"]:
380
+ self.__dict__["context"]["jobs"] = {}
381
+ self.__dict__["context"]["jobs"].update(result.context)
382
+ return self
383
+
384
+
385
+ class ReResult(BaseModel):
386
+ """Result Pydantic Model for passing parameter and receiving output from
334
387
  the pipeline execution.
335
388
  """
336
389
 
@@ -339,8 +392,14 @@ class Result:
339
392
  # parent_run_id: str
340
393
  # run_id: str
341
394
  #
342
- status: int = field(default=2)
343
- context: DictData = field(default_factory=dict)
395
+ status: int = Field(default=2)
396
+ context: Context = Field(default_factory=Context)
397
+
398
+ def receive(self, result: ReResult) -> ReResult:
399
+ self.__dict__["status"] = result.status
400
+ self.__dict__["context"].__dict__["jobs"].update(result.context.jobs)
401
+ self.__dict__["context"].__dict__["error"].update(result.context.error)
402
+ return self
344
403
 
345
404
 
346
405
  def make_exec(path: str | Path):
@@ -580,6 +639,25 @@ def param2template(
580
639
  return str2template(value, params, filters=filters)
581
640
 
582
641
 
642
+ def filter_func(value: Any):
643
+ """Filter own created function out of any value with replace it to its
644
+ function name. If it is built-in function, it does not have any changing.
645
+ """
646
+ if isinstance(value, dict):
647
+ return {k: filter_func(value[k]) for k in value}
648
+ elif isinstance(value, (list, tuple, set)):
649
+ return type(value)([filter_func(i) for i in value])
650
+
651
+ if isfunction(value):
652
+ # NOTE: If it want to improve to get this function, it able to save to
653
+ # some global memory storage.
654
+ # ---
655
+ # >>> GLOBAL_DICT[value.__name__] = value
656
+ #
657
+ return value.__name__
658
+ return value
659
+
660
+
583
661
  def dash2underscore(
584
662
  key: str,
585
663
  values: DictData,