ddeutil-workflow 0.0.38__py3-none-any.whl → 0.0.40__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/job.py CHANGED
@@ -40,12 +40,8 @@ from .exceptions import (
40
40
  )
41
41
  from .result import Result, Status
42
42
  from .stages import Stage
43
- from .templates import has_template
44
- from .utils import (
45
- cross_product,
46
- filter_func,
47
- gen_id,
48
- )
43
+ from .templates import has_template, param2template
44
+ from .utils import cross_product, filter_func, gen_id
49
45
 
50
46
  MatrixFilter = list[dict[str, Union[str, int]]]
51
47
 
@@ -54,6 +50,7 @@ __all__: TupleStr = (
54
50
  "Strategy",
55
51
  "Job",
56
52
  "TriggerRules",
53
+ "TriggerState",
57
54
  "RunsOn",
58
55
  "RunsOnLocal",
59
56
  "RunsOnSelfHosted",
@@ -208,6 +205,16 @@ class TriggerRules(str, Enum):
208
205
  none_skipped: str = "none_skipped"
209
206
 
210
207
 
208
+ class TriggerState(str, Enum):
209
+ waiting: str = "waiting"
210
+ passed: str = "passed"
211
+ skipped: str = "skipped"
212
+ failed: str = "failed"
213
+
214
+ def is_waiting(self):
215
+ return self.value == "waiting"
216
+
217
+
211
218
  class RunsOnType(str, Enum):
212
219
  """Runs-On enum object."""
213
220
 
@@ -312,13 +319,21 @@ class Job(BaseModel):
312
319
  description="A target node for this job to use for execution.",
313
320
  alias="runs-on",
314
321
  )
322
+ condition: Optional[str] = Field(
323
+ default=None,
324
+ description="A job condition statement to allow job executable.",
325
+ alias="if",
326
+ )
315
327
  stages: list[Stage] = Field(
316
328
  default_factory=list,
317
329
  description="A list of Stage of this job.",
318
330
  )
319
331
  trigger_rule: TriggerRules = Field(
320
332
  default=TriggerRules.all_success,
321
- description="A trigger rule of tracking needed jobs.",
333
+ description=(
334
+ "A trigger rule of tracking needed jobs if feature will use when "
335
+ "the `raise_error` did not set from job and stage executions."
336
+ ),
322
337
  alias="trigger-rule",
323
338
  )
324
339
  needs: list[str] = Field(
@@ -382,12 +397,87 @@ class Job(BaseModel):
382
397
  return stage
383
398
  raise ValueError(f"Stage ID {stage_id} does not exists")
384
399
 
385
- def check_needs(self, jobs: dict[str, Any]) -> bool:
400
+ def check_needs(
401
+ self, jobs: dict[str, Any]
402
+ ) -> TriggerState: # pragma: no cov
386
403
  """Return True if job's need exists in an input list of job's ID.
387
404
 
405
+ :param jobs: A mapping of job model and its ID.
406
+
407
+ :rtype: TriggerState
408
+ """
409
+ if not self.needs:
410
+ return TriggerState.passed
411
+
412
+ def make_return(result: bool) -> TriggerState:
413
+ return TriggerState.passed if result else TriggerState.failed
414
+
415
+ need_exist: dict[str, Any] = {
416
+ need: jobs[need] for need in self.needs if need in jobs
417
+ }
418
+ if len(need_exist) != len(self.needs):
419
+ return TriggerState.waiting
420
+ elif all("skipped" in need_exist[job] for job in need_exist):
421
+ return TriggerState.skipped
422
+ elif self.trigger_rule == TriggerRules.all_done:
423
+ return TriggerState.passed
424
+ elif self.trigger_rule == TriggerRules.all_success:
425
+ rs = all(
426
+ k not in need_exist[job]
427
+ for k in ("errors", "skipped")
428
+ for job in need_exist
429
+ )
430
+ elif self.trigger_rule == TriggerRules.all_failed:
431
+ rs = all("errors" in need_exist[job] for job in need_exist)
432
+ elif self.trigger_rule == TriggerRules.one_success:
433
+ rs = sum(
434
+ k not in need_exist[job]
435
+ for k in ("errors", "skipped")
436
+ for job in need_exist
437
+ ) + 1 == len(self.needs)
438
+ elif self.trigger_rule == TriggerRules.one_failed:
439
+ rs = sum("errors" in need_exist[job] for job in need_exist) == 1
440
+ elif self.trigger_rule == TriggerRules.none_skipped:
441
+ rs = all("skipped" not in need_exist[job] for job in need_exist)
442
+ elif self.trigger_rule == TriggerRules.none_failed:
443
+ rs = all("errors" not in need_exist[job] for job in need_exist)
444
+ else: # pragma: no cov
445
+ raise NotImplementedError(
446
+ f"Trigger rule: {self.trigger_rule} does not support yet."
447
+ )
448
+ return make_return(rs)
449
+
450
+ def is_skipped(self, params: DictData | None = None) -> bool:
451
+ """Return true if condition of this job do not correct. This process
452
+ use build-in eval function to execute the if-condition.
453
+
454
+ :raise JobException: When it has any error raise from the eval
455
+ condition statement.
456
+ :raise JobException: When return type of the eval condition statement
457
+ does not return with boolean type.
458
+
459
+ :param params: (DictData) A parameters that want to pass to condition
460
+ template.
461
+
388
462
  :rtype: bool
389
463
  """
390
- return all(need in jobs for need in self.needs)
464
+ if self.condition is None:
465
+ return False
466
+
467
+ params: DictData = {} if params is None else params
468
+
469
+ try:
470
+ # WARNING: The eval build-in function is very dangerous. So, it
471
+ # should use the `re` module to validate eval-string before
472
+ # running.
473
+ rs: bool = eval(
474
+ param2template(self.condition, params), globals() | params, {}
475
+ )
476
+ if not isinstance(rs, bool):
477
+ raise TypeError("Return type of condition does not be boolean")
478
+ return not rs
479
+ except Exception as err:
480
+ raise JobException(f"{err.__class__.__name__}: {err}") from err
391
481
 
392
482
  def set_outputs(self, output: DictData, to: DictData) -> DictData:
393
483
  """Set an outputs from execution process to the received context. The
@@ -436,7 +526,9 @@ class Job(BaseModel):
436
526
  {"errors": output.pop("errors", {})} if "errors" in output else {}
437
527
  )
438
528
 
439
- if self.strategy.is_set():
529
+ if "SKIP" in output: # pragma: no cov
530
+ to["jobs"][_id] = output["SKIP"]
531
+ elif self.strategy.is_set():
440
532
  to["jobs"][_id] = {"strategies": output, **errors}
441
533
  else:
442
534
  _output = output.get(next(iter(output), "FIRST"), {})
@@ -458,8 +550,8 @@ class Job(BaseModel):
458
550
  multithread on this metrics to the `stages` field of this job.
459
551
 
460
552
  :param params: An input parameters that use on job execution.
461
- :param run_id: A job running ID for this execution.
462
- :param parent_run_id: A parent workflow running ID for this release.
553
+ :param run_id: (str) A job running ID.
554
+ :param parent_run_id: (str) A parent workflow running ID.
463
555
  :param result: (Result) A result object for keeping context and status
464
556
  data.
465
557
  :param event: (Event) An event manager that pass to the
@@ -559,6 +651,7 @@ def local_execute_strategy(
559
651
 
560
652
  if stage.is_skipped(params=context):
561
653
  result.trace.info(f"[STAGE]: Skip stage: {stage.iden!r}")
654
+ stage.set_outputs(output={"skipped": True}, to=context)
562
655
  continue
563
656
 
564
657
  if event and event.is_set():
@@ -623,9 +716,6 @@ def local_execute_strategy(
623
716
  },
624
717
  )
625
718
 
626
- # NOTE: Remove the current stage object for saving memory.
627
- del stage
628
-
629
719
  return result.catch(
630
720
  status=Status.SUCCESS,
631
721
  context={
@@ -680,7 +770,17 @@ def local_execute(
680
770
 
681
771
  for strategy in job.strategy.make():
682
772
 
683
- # TODO: stop and raise error if the event was set.
773
+ if event and event.is_set(): # pragma: no cov
774
+ return result.catch(
775
+ status=Status.FAILED,
776
+ context={
777
+ "errors": JobException(
778
+ "Job strategy was canceled from event that had set "
779
+ "before strategy execution."
780
+ ).to_dict()
781
+ },
782
+ )
783
+
684
784
  local_execute_strategy(
685
785
  job=job,
686
786
  strategy=strategy,
@@ -694,12 +794,22 @@ def local_execute(
694
794
 
695
795
  fail_fast_flag: bool = job.strategy.fail_fast
696
796
  ls: str = "Fail-Fast" if fail_fast_flag else "All-Completed"
697
-
698
797
  result.trace.info(
699
798
  f"[JOB]: Start multithreading: {job.strategy.max_parallel} threads "
700
799
  f"with {ls} mode."
701
800
  )
702
801
 
802
+ if event and event.is_set(): # pragma: no cov
803
+ return result.catch(
804
+ status=Status.FAILED,
805
+ context={
806
+ "errors": JobException(
807
+ "Job strategy was canceled from event that had set "
808
+ "before strategy execution."
809
+ ).to_dict()
810
+ },
811
+ )
812
+
703
813
  # IMPORTANT: Start running strategy execution by multithreading because
704
814
  # it will run by strategy values without waiting previous execution.
705
815
  with ThreadPoolExecutor(
@@ -31,6 +31,7 @@ from concurrent.futures import (
31
31
  from datetime import datetime, timedelta
32
32
  from functools import wraps
33
33
  from heapq import heappop, heappush
34
+ from pathlib import Path
34
35
  from textwrap import dedent
35
36
  from threading import Thread
36
37
  from typing import Callable, Optional, TypedDict, Union
@@ -52,7 +53,7 @@ except ImportError: # pragma: no cov
52
53
  from .__cron import CronRunner
53
54
  from .__types import DictData, TupleStr
54
55
  from .audit import Audit, get_audit
55
- from .conf import Loader, config, get_logger
56
+ from .conf import Loader, SimLoad, config, get_logger
56
57
  from .cron import On
57
58
  from .exceptions import ScheduleException, WorkflowException
58
59
  from .result import Result, Status
@@ -266,6 +267,8 @@ class Schedule(BaseModel):
266
267
  :param externals: An external parameters that want to pass to Loader
267
268
  object.
268
269
 
270
+ :raise ValueError: If the type does not match with current object.
271
+
269
272
  :rtype: Self
270
273
  """
271
274
  loader: Loader = Loader(name, externals=(externals or {}))
@@ -281,6 +284,42 @@ class Schedule(BaseModel):
281
284
 
282
285
  return cls.model_validate(obj=loader_data)
283
286
 
287
+ @classmethod
288
+ def from_path(
289
+ cls,
290
+ name: str,
291
+ path: Path,
292
+ externals: DictData | None = None,
293
+ ) -> Self:
294
+ """Create Schedule instance from the SimLoad object that receive an
295
+ input schedule name and conf path. The loader object will use this
296
+ schedule name to searching configuration data of this schedule model
297
+ in conf path.
298
+
299
+ :param name: (str) A schedule name that want to pass to Loader object.
300
+ :param path: (Path) A config path that want to search.
301
+ :param externals: An external parameters that want to pass to Loader
302
+ object.
303
+
304
+ :raise ValueError: If the type does not match with current object.
305
+
306
+ :rtype: Self
307
+ """
308
+ loader: SimLoad = SimLoad(
309
+ name, conf_path=path, externals=(externals or {})
310
+ )
311
+
312
+ # NOTE: Validate the config type match with current connection model
313
+ if loader.type != cls.__name__:
314
+ raise ValueError(f"Type {loader.type} does not match with {cls}")
315
+
316
+ loader_data: DictData = copy.deepcopy(loader.data)
317
+
318
+ # NOTE: Add name to loader data
319
+ loader_data["name"] = name.replace(" ", "_")
320
+
321
+ return cls.model_validate(obj=loader_data)
322
+
284
323
  def tasks(
285
324
  self,
286
325
  start_date: datetime,
@@ -41,7 +41,7 @@ from inspect import Parameter
41
41
  from pathlib import Path
42
42
  from subprocess import CompletedProcess
43
43
  from textwrap import dedent
44
- from typing import Optional, Union
44
+ from typing import Annotated, Optional, Union
45
45
 
46
46
  from pydantic import BaseModel, Field
47
47
  from pydantic.functional_validators import model_validator
@@ -230,7 +230,10 @@ class BaseStage(BaseModel, ABC):
230
230
 
231
231
  ... (iii) to: {
232
232
  'stages': {
233
- '<stage-id>': {'outputs': {'foo': 'bar'}}
233
+ '<stage-id>': {
234
+ 'outputs': {'foo': 'bar'},
235
+ 'skipped': False
236
+ }
234
237
  }
235
238
  }
236
239
 
@@ -255,8 +258,12 @@ class BaseStage(BaseModel, ABC):
255
258
  errors: DictData = (
256
259
  {"errors": output.pop("errors", {})} if "errors" in output else {}
257
260
  )
258
-
259
- to["stages"][_id] = {"outputs": output, **errors}
261
+ skipping: dict[str, bool] = (
262
+ {"skipped": output.pop("skipped", False)}
263
+ if "skipped" in output
264
+ else {}
265
+ )
266
+ to["stages"][_id] = {"outputs": output, **skipping, **errors}
260
267
  return to
261
268
 
262
269
  def is_skipped(self, params: DictData | None = None) -> bool:
@@ -539,19 +546,11 @@ class PyStage(BaseStage):
539
546
 
540
547
  :rtype: DictData
541
548
  """
542
- # NOTE: The output will fileter unnecessary keys from locals.
543
- lc: DictData = output.get("locals", {})
549
+ lc: DictData = output.pop("locals", {})
550
+ gb: DictData = output.pop("globals", {})
544
551
  super().set_outputs(
545
- (
546
- {k: lc[k] for k in self.filter_locals(lc)}
547
- | ({"errors": output["errors"]} if "errors" in output else {})
548
- ),
549
- to=to,
552
+ {k: lc[k] for k in self.filter_locals(lc)} | output, to=to
550
553
  )
551
-
552
- # NOTE: Override value that changing from the globals that pass via the
553
- # exec function.
554
- gb: DictData = output.get("globals", {})
555
554
  to.update({k: gb[k] for k in to if k in gb})
556
555
  return to
557
556
 
@@ -572,17 +571,13 @@ class PyStage(BaseStage):
572
571
  run_id=gen_id(self.name + (self.id or ""), unique=True)
573
572
  )
574
573
 
575
- # NOTE: Replace the run statement that has templating value.
576
- run: str = param2template(dedent(self.run), params)
577
-
578
- # NOTE: create custom globals value that will pass to exec function.
579
- _globals: DictData = (
574
+ lc: DictData = {}
575
+ gb: DictData = (
580
576
  globals()
581
577
  | params
582
578
  | param2template(self.vars, params)
583
579
  | {"result": result}
584
580
  )
585
- lc: DictData = {}
586
581
 
587
582
  # NOTE: Start exec the run statement.
588
583
  result.trace.info(f"[STAGE]: Py-Execute: {self.name}")
@@ -591,14 +586,12 @@ class PyStage(BaseStage):
591
586
  "check your statement be safe before execute."
592
587
  )
593
588
 
594
- # TODO: Add Python systax wrapper for checking dangerous code before run
595
- # this statement.
596
589
  # WARNING: The exec build-in function is very dangerous. So, it
597
590
  # should use the re module to validate exec-string before running.
598
- exec(run, _globals, lc)
591
+ exec(param2template(dedent(self.run), params), gb, lc)
599
592
 
600
593
  return result.catch(
601
- status=Status.SUCCESS, context={"locals": lc, "globals": _globals}
594
+ status=Status.SUCCESS, context={"locals": lc, "globals": gb}
602
595
  )
603
596
 
604
597
 
@@ -795,7 +788,9 @@ class ParallelStage(BaseStage): # pragma: no cov
795
788
  ... }
796
789
  """
797
790
 
798
- parallel: dict[str, list[Stage]] = Field()
791
+ parallel: dict[str, list[Stage]] = Field(
792
+ description="A mapping of parallel branch ID.",
793
+ )
799
794
  max_parallel_core: int = Field(default=2)
800
795
 
801
796
  @staticmethod
@@ -807,9 +802,10 @@ class ParallelStage(BaseStage): # pragma: no cov
807
802
  ) -> DictData:
808
803
  """Task execution method for passing a branch to each thread.
809
804
 
810
- :param branch:
811
- :param params:
812
- :param result:
805
+ :param branch: A branch ID.
806
+ :param params: A parameter data that want to use in this execution.
807
+ :param result: (Result) A result object for keeping context and status
808
+ data.
813
809
  :param stages:
814
810
 
815
811
  :rtype: DictData
@@ -1008,7 +1004,7 @@ class IfStage(BaseStage): # pragma: no cov
1008
1004
 
1009
1005
  """
1010
1006
 
1011
- case: str
1007
+ case: str = Field(description="A case condition for routing.")
1012
1008
  match: list[dict[str, Union[str, Stage]]]
1013
1009
 
1014
1010
  def execute(
@@ -1016,6 +1012,18 @@ class IfStage(BaseStage): # pragma: no cov
1016
1012
  ) -> Result: ...
1017
1013
 
1018
1014
 
1015
+ class RaiseStage(BaseStage): # pragma: no cov
1016
+ message: str = Field(
1017
+ description="An error message that want to raise",
1018
+ alias="raise",
1019
+ )
1020
+
1021
+ def execute(
1022
+ self, params: DictData, *, result: Result | None = None
1023
+ ) -> Result:
1024
+ raise StageException(self.message)
1025
+
1026
+
1019
1027
  # TODO: Not implement this stages yet
1020
1028
  class HookStage(BaseStage): # pragma: no cov
1021
1029
  hook: str
@@ -1050,6 +1058,11 @@ class VirtualPyStage(PyStage): # pragma: no cov
1050
1058
 
1051
1059
  def create_py_file(self, py: str, run_id: str | None): ...
1052
1060
 
1061
+ def execute(
1062
+ self, params: DictData, *, result: Result | None = None
1063
+ ) -> Result:
1064
+ return super().execute(params, result=result)
1065
+
1053
1066
 
1054
1067
  # TODO: Not implement this stages yet
1055
1068
  class SensorStage(BaseStage): # pragma: no cov
@@ -1064,12 +1077,16 @@ class SensorStage(BaseStage): # pragma: no cov
1064
1077
  # From the current build-in stages, they do not have stage that have the same
1065
1078
  # fields that because of parsing on the Job's stages key.
1066
1079
  #
1067
- Stage = Union[
1068
- EmptyStage,
1069
- BashStage,
1070
- CallStage,
1071
- TriggerStage,
1072
- ForEachStage,
1073
- ParallelStage,
1074
- PyStage,
1080
+ Stage = Annotated[
1081
+ Union[
1082
+ EmptyStage,
1083
+ BashStage,
1084
+ CallStage,
1085
+ TriggerStage,
1086
+ ForEachStage,
1087
+ ParallelStage,
1088
+ PyStage,
1089
+ RaiseStage,
1090
+ ],
1091
+ Field(union_mode="smart"),
1075
1092
  ]
@@ -18,7 +18,7 @@ try:
18
18
  except ImportError:
19
19
  from typing_extensions import ParamSpec
20
20
 
21
- from ddeutil.core import getdot, hasdot, import_string
21
+ from ddeutil.core import getdot, import_string
22
22
  from ddeutil.io import search_env_replace
23
23
 
24
24
  from .__types import DictData, Re
@@ -59,7 +59,8 @@ def custom_filter(name: str) -> Callable[P, FilterFunc]:
59
59
  """Custom filter decorator function that set function attributes, ``filter``
60
60
  for making filter registries variable.
61
61
 
62
- :param: name: A filter name for make different use-case of a function.
62
+ :param: name: (str) A filter name for make different use-case of a function.
63
+
63
64
  :rtype: Callable[P, FilterFunc]
64
65
  """
65
66
 
@@ -108,7 +109,7 @@ def get_args_const(
108
109
  ) -> tuple[str, list[Constant], dict[str, Constant]]:
109
110
  """Get arguments and keyword-arguments from function calling string.
110
111
 
111
- :param expr: An expr string value.
112
+ :param expr: (str) An expr string value.
112
113
 
113
114
  :rtype: tuple[str, list[Constant], dict[str, Constant]]
114
115
  """
@@ -154,7 +155,7 @@ def get_args_from_filter(
154
155
  and validate it with the filter functions mapping dict.
155
156
 
156
157
  :param ft:
157
- :param filters:
158
+ :param filters: A mapping of filter registry.
158
159
 
159
160
  :rtype: tuple[str, FilterRegistry, list[Any], dict[Any, Any]]
160
161
  """
@@ -185,7 +186,7 @@ def map_post_filter(
185
186
 
186
187
  :param value: A string value that want to map with filter function.
187
188
  :param post_filter: A list of post-filter function name.
188
- :param filters: A filter registry.
189
+ :param filters: A mapping of filter registry.
189
190
 
190
191
  :rtype: T
191
192
  """
@@ -203,8 +204,8 @@ def map_post_filter(
203
204
  except Exception as err:
204
205
  logger.warning(str(err))
205
206
  raise UtilException(
206
- f"The post-filter function: {func_name} does not fit with "
207
- f"{value} (type: {type(value).__name__})."
207
+ f"The post-filter: {func_name!r} does not fit with {value!r} "
208
+ f"(type: {type(value).__name__})."
208
209
  ) from None
209
210
  return value
210
211
 
@@ -258,10 +259,10 @@ def str2template(
258
259
  with the workflow parameter types that is `str`, `int`, `datetime`, and
259
260
  `list`.
260
261
 
261
- :param value: A string value that want to map with params
262
- :param params: A parameter value that getting with matched regular
263
- expression.
264
- :param filters:
262
+ :param value: (str) A string value that want to map with params.
263
+ :param params: (DictData) A parameter value that getting with matched
264
+ regular expression.
265
+ :param filters: A mapping of filter registry.
265
266
 
266
267
  :rtype: str
267
268
  """
@@ -281,11 +282,14 @@ def str2template(
281
282
  for i in (found.post_filters.strip().removeprefix("|").split("|"))
282
283
  if i != ""
283
284
  ]
284
- if not hasdot(caller, params):
285
- raise UtilException(f"The params does not set caller: {caller!r}.")
286
285
 
287
286
  # NOTE: from validate step, it guarantees that caller exists in params.
288
- getter: Any = getdot(caller, params)
287
+ try:
288
+ getter: Any = getdot(caller, params)
289
+ except ValueError as err:
290
+ raise UtilException(
291
+ f"Params does not set caller: {caller!r}."
292
+ ) from err
289
293
 
290
294
  # NOTE:
291
295
  # If type of getter caller is not string type, and it does not use to
@@ -301,25 +305,33 @@ def str2template(
301
305
 
302
306
  value: str = value.replace(found.full, getter, 1)
303
307
 
308
+ if value == "None":
309
+ return None
310
+
304
311
  return search_env_replace(value)
305
312
 
306
313
 
307
- def param2template(value: T, params: DictData) -> T:
314
+ def param2template(
315
+ value: T,
316
+ params: DictData,
317
+ filters: dict[str, FilterRegistry] | None = None,
318
+ ) -> T:
308
319
  """Pass param to template string that can search by ``RE_CALLER`` regular
309
320
  expression.
310
321
 
311
322
  :param value: A value that want to map with params
312
323
  :param params: A parameter value that getting with matched regular
313
324
  expression.
325
+ :param filters: A filter mapping for mapping with `map_post_filter` func.
314
326
 
315
327
  :rtype: T
316
328
  :returns: An any getter value from the params input.
317
329
  """
318
- filters: dict[str, FilterRegistry] = make_filter_registry()
330
+ filters: dict[str, FilterRegistry] = filters or make_filter_registry()
319
331
  if isinstance(value, dict):
320
- return {k: param2template(value[k], params) for k in value}
332
+ return {k: param2template(value[k], params, filters) for k in value}
321
333
  elif isinstance(value, (list, tuple, set)):
322
- return type(value)([param2template(i, params) for i in value])
334
+ return type(value)([param2template(i, params, filters) for i in value])
323
335
  elif not isinstance(value, str):
324
336
  return value
325
337
  return str2template(value, params, filters=filters)
@@ -329,8 +341,9 @@ def param2template(value: T, params: DictData) -> T:
329
341
  def datetime_format(value: datetime, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
330
342
  """Format datetime object to string with the format.
331
343
 
332
- :param value: A datetime value that want to format to string value.
333
- :param fmt: A format string pattern that passing to the `dt.strftime`
344
+ :param value: (datetime) A datetime value that want to format to string
345
+ value.
346
+ :param fmt: (str) A format string pattern that passing to the `dt.strftime`
334
347
  method.
335
348
 
336
349
  :rtype: str
@@ -340,3 +353,9 @@ def datetime_format(value: datetime, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
340
353
  raise UtilException(
341
354
  "This custom function should pass input value with datetime type."
342
355
  )
356
+
357
+
358
+ @custom_filter("coalesce") # pragma: no cov
359
+ def coalesce(value: T | None, default: Any) -> T:
360
+ """Coalesce with default value if the main value is None."""
361
+ return default if value is None else value