ddeutil-workflow 0.0.19__py3-none-any.whl → 0.0.21__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
@@ -13,8 +13,9 @@ handle stage error on this stage model. I think stage model should have a lot of
13
13
  usecase and it does not worry when I want to create a new one.
14
14
 
15
15
  Execution --> Ok --> Result with 0
16
+
16
17
  --> Error --> Result with 1 (if env var was set)
17
- --> Raise StageException
18
+ --> Raise StageException(...)
18
19
 
19
20
  On the context I/O that pass to a stage object at execute process. The
20
21
  execute method receives a `params={"params": {...}}` value for mapping to
@@ -68,16 +69,13 @@ logger = get_logger("ddeutil.workflow")
68
69
 
69
70
 
70
71
  __all__: TupleStr = (
71
- "BaseStage",
72
72
  "EmptyStage",
73
73
  "BashStage",
74
74
  "PyStage",
75
75
  "HookStage",
76
76
  "TriggerStage",
77
77
  "Stage",
78
- "HookSearchData",
79
78
  "extract_hook",
80
- "handler_result",
81
79
  )
82
80
 
83
81
 
@@ -90,15 +88,17 @@ def handler_result(message: str | None = None) -> DecoratorResult:
90
88
  environment variable,`WORKFLOW_CORE_STAGE_RAISE_ERROR`.
91
89
 
92
90
  Execution --> Ok --> Result
93
- status: 0
94
- context:
95
- outputs: ...
91
+ |-status: 0
92
+ |-context:
93
+ |-outputs: ...
94
+
96
95
  --> Error --> Result (if env var was set)
97
- status: 1
98
- context:
99
- error: ...
100
- error_message: ...
101
- --> Error --> Raise StageException
96
+ |-status: 1
97
+ |-context:
98
+ |-error: ...
99
+ |-error_message: ...
100
+
101
+ --> Error --> Raise StageException(...)
102
102
 
103
103
  On the last step, it will set the running ID on a return result object
104
104
  from current stage ID before release the final result.
@@ -119,13 +119,18 @@ def handler_result(message: str | None = None) -> DecoratorResult:
119
119
 
120
120
  @wraps(func)
121
121
  def wrapped(self: Stage, *args, **kwargs):
122
+
123
+ if not (run_id := kwargs.get("run_id")):
124
+ run_id: str = gen_id(self.name + (self.id or ""), unique=True)
125
+ kwargs["run_id"] = run_id
126
+
122
127
  try:
123
128
  # NOTE: Start calling origin function with a passing args.
124
- return func(self, *args, **kwargs).set_run_id(self.run_id)
129
+ return func(self, *args, **kwargs)
125
130
  except Exception as err:
126
131
  # NOTE: Start catching error from the stage execution.
127
132
  logger.error(
128
- f"({self.run_id}) [STAGE]: {err.__class__.__name__}: {err}"
133
+ f"({run_id}) [STAGE]: {err.__class__.__name__}: {err}"
129
134
  )
130
135
  if config.stage_raise_error:
131
136
  # NOTE: If error that raise from stage execution course by
@@ -148,7 +153,8 @@ def handler_result(message: str | None = None) -> DecoratorResult:
148
153
  "error": err,
149
154
  "error_message": f"{err.__class__.__name__}: {err}",
150
155
  },
151
- ).set_run_id(self.run_id)
156
+ run_id=run_id,
157
+ )
152
158
 
153
159
  return wrapped
154
160
 
@@ -159,6 +165,8 @@ class BaseStage(BaseModel, ABC):
159
165
  """Base Stage Model that keep only id and name fields for the stage
160
166
  metadata. If you want to implement any custom stage, you can use this class
161
167
  to parent and implement ``self.execute()`` method only.
168
+
169
+ This class is the abstraction class for any stage class.
162
170
  """
163
171
 
164
172
  id: Optional[str] = Field(
@@ -176,12 +184,15 @@ class BaseStage(BaseModel, ABC):
176
184
  description="A stage condition statement to allow stage executable.",
177
185
  alias="if",
178
186
  )
179
- run_id: Optional[str] = Field(
180
- default=None,
181
- description="A running stage ID.",
182
- repr=False,
183
- exclude=True,
184
- )
187
+
188
+ @property
189
+ def iden(self) -> str:
190
+ """Return identity of this stage object that return the id field first.
191
+ If the id does not set, it will use name field instead.
192
+
193
+ :rtype: str
194
+ """
195
+ return self.id or self.name
185
196
 
186
197
  @model_validator(mode="after")
187
198
  def __prepare_running_id__(self) -> Self:
@@ -194,8 +205,6 @@ class BaseStage(BaseModel, ABC):
194
205
 
195
206
  :rtype: Self
196
207
  """
197
- if self.run_id is None:
198
- self.run_id = gen_id(self.name + (self.id or ""), unique=True)
199
208
 
200
209
  # VALIDATE: Validate stage id and name should not dynamic with params
201
210
  # template. (allow only matrix)
@@ -206,21 +215,14 @@ class BaseStage(BaseModel, ABC):
206
215
 
207
216
  return self
208
217
 
209
- def get_running_id(self, run_id: str) -> Self:
210
- """Return Stage model object that changing stage running ID with an
211
- input running ID.
212
-
213
- :param run_id: A replace stage running ID.
214
- :rtype: Self
215
- """
216
- return self.model_copy(update={"run_id": run_id})
217
-
218
218
  @abstractmethod
219
- def execute(self, params: DictData) -> Result:
219
+ def execute(self, params: DictData, *, run_id: str | None = None) -> Result:
220
220
  """Execute abstraction method that action something by sub-model class.
221
221
  This is important method that make this class is able to be the stage.
222
222
 
223
223
  :param params: A parameter data that want to use in this execution.
224
+ :param run_id: A running stage ID for this execution.
225
+
224
226
  :rtype: Result
225
227
  """
226
228
  raise NotImplementedError("Stage should implement ``execute`` method.")
@@ -248,10 +250,9 @@ class BaseStage(BaseModel, ABC):
248
250
  :rtype: DictData
249
251
  """
250
252
  if self.id is None and not config.stage_default_id:
251
- logger.debug(
252
- f"({self.run_id}) [STAGE]: Output does not set because this "
253
- f"stage does not set ID or default stage ID config flag not be "
254
- f"True."
253
+ logger.warning(
254
+ "Output does not set because this stage does not set ID or "
255
+ "default stage ID config flag not be True."
255
256
  )
256
257
  return to
257
258
 
@@ -267,7 +268,6 @@ class BaseStage(BaseModel, ABC):
267
268
  )
268
269
 
269
270
  # NOTE: Set the output to that stage generated ID with ``outputs`` key.
270
- logger.debug(f"({self.run_id}) [STAGE]: Set outputs to {_id!r}")
271
271
  to["stages"][_id] = {"outputs": output}
272
272
  return to
273
273
 
@@ -283,18 +283,22 @@ class BaseStage(BaseModel, ABC):
283
283
  :param params: A parameters that want to pass to condition template.
284
284
  :rtype: bool
285
285
  """
286
+ # NOTE: Return false result if condition does not set.
286
287
  if self.condition is None:
287
288
  return False
288
289
 
289
290
  params: DictData = {} if params is None else params
290
- _g: DictData = globals() | params
291
+
291
292
  try:
292
- rs: bool = eval(param2template(self.condition, params), _g, {})
293
+ # WARNING: The eval build-in function is vary dangerous. So, it
294
+ # should us the re module to validate eval-string before running.
295
+ rs: bool = eval(
296
+ param2template(self.condition, params), globals() | params, {}
297
+ )
293
298
  if not isinstance(rs, bool):
294
299
  raise TypeError("Return type of condition does not be boolean")
295
300
  return not rs
296
301
  except Exception as err:
297
- logger.error(f"({self.run_id}) [STAGE]: {err}")
298
302
  raise StageException(f"{err.__class__.__name__}: {err}") from err
299
303
 
300
304
 
@@ -319,7 +323,8 @@ class EmptyStage(BaseStage):
319
323
  ge=0,
320
324
  )
321
325
 
322
- def execute(self, params: DictData) -> Result:
326
+ @handler_result()
327
+ def execute(self, params: DictData, *, run_id: str | None = None) -> Result:
323
328
  """Execution method for the Empty stage that do only logging out to
324
329
  stdout. This method does not use the `handler_result` decorator because
325
330
  it does not get any error from logging function.
@@ -329,15 +334,17 @@ class EmptyStage(BaseStage):
329
334
 
330
335
  :param params: A context data that want to add output result. But this
331
336
  stage does not pass any output.
337
+ :param run_id: A running stage ID for this execution.
338
+
332
339
  :rtype: Result
333
340
  """
334
341
  logger.info(
335
- f"({self.run_id}) [STAGE]: Empty-Execute: {self.name!r}: "
342
+ f"({run_id}) [STAGE]: Empty-Execute: {self.name!r}: "
336
343
  f"( {param2template(self.echo, params=params) or '...'} )"
337
344
  )
338
345
  if self.sleep > 0:
339
346
  time.sleep(self.sleep)
340
- return Result(status=0, context={})
347
+ return Result(status=0, context={}, run_id=run_id)
341
348
 
342
349
 
343
350
  class BashStage(BaseStage):
@@ -369,17 +376,25 @@ class BashStage(BaseStage):
369
376
  )
370
377
 
371
378
  @contextlib.contextmanager
372
- def prepare_bash(self, bash: str, env: DictStr) -> Iterator[TupleStr]:
379
+ def create_sh_file(
380
+ self, bash: str, env: DictStr, run_id: str | None = None
381
+ ) -> Iterator[TupleStr]:
373
382
  """Return context of prepared bash statement that want to execute. This
374
383
  step will write the `.sh` file before giving this file name to context.
375
384
  After that, it will auto delete this file automatic.
376
385
 
377
386
  :param bash: A bash statement that want to execute.
378
387
  :param env: An environment variable that use on this bash statement.
388
+ :param run_id: A running stage ID that use for writing sh file instead
389
+ generate by UUID4.
379
390
  :rtype: Iterator[TupleStr]
380
391
  """
381
- f_name: str = f"{uuid.uuid4()}.sh"
392
+ run_id: str = run_id or uuid.uuid4()
393
+ f_name: str = f"{run_id}.sh"
382
394
  f_shebang: str = "bash" if sys.platform.startswith("win") else "sh"
395
+
396
+ logger.debug(f"({run_id}) [STAGE]: Start create `{f_name}` file.")
397
+
383
398
  with open(f"./{f_name}", mode="w", newline="\n") as f:
384
399
  # NOTE: write header of `.sh` file
385
400
  f.write(f"#!/bin/{f_shebang}\n\n")
@@ -393,29 +408,27 @@ class BashStage(BaseStage):
393
408
  # NOTE: Make this .sh file able to executable.
394
409
  make_exec(f"./{f_name}")
395
410
 
396
- logger.debug(
397
- f"({self.run_id}) [STAGE]: Start create `.sh` file and running a "
398
- f"bash statement."
399
- )
400
-
401
411
  yield [f_shebang, f_name]
402
412
 
403
413
  # Note: Remove .sh file that use to run bash.
404
414
  Path(f"./{f_name}").unlink()
405
415
 
406
416
  @handler_result()
407
- def execute(self, params: DictData) -> Result:
417
+ def execute(self, params: DictData, *, run_id: str | None = None) -> Result:
408
418
  """Execute the Bash statement with the Python build-in ``subprocess``
409
419
  package.
410
420
 
411
421
  :param params: A parameter data that want to use in this execution.
422
+ :param run_id: A running stage ID for this execution.
423
+
412
424
  :rtype: Result
413
425
  """
414
426
  bash: str = param2template(dedent(self.bash), params)
415
- with self.prepare_bash(
416
- bash=bash, env=param2template(self.env, params)
427
+
428
+ logger.info(f"({run_id}) [STAGE]: Shell-Execute: {self.name}")
429
+ with self.create_sh_file(
430
+ bash=bash, env=param2template(self.env, params), run_id=run_id
417
431
  ) as sh:
418
- logger.info(f"({self.run_id}) [STAGE]: Shell-Execute: {sh}")
419
432
  rs: CompletedProcess = subprocess.run(
420
433
  sh, shell=False, capture_output=True, text=True
421
434
  )
@@ -437,6 +450,7 @@ class BashStage(BaseStage):
437
450
  "stdout": rs.stdout.rstrip("\n") or None,
438
451
  "stderr": rs.stderr.rstrip("\n") or None,
439
452
  },
453
+ run_id=run_id,
440
454
  )
441
455
 
442
456
 
@@ -482,6 +496,7 @@ class PyStage(BaseStage):
482
496
 
483
497
  :param output: A output data that want to extract to an output key.
484
498
  :param to: A context data that want to add output result.
499
+
485
500
  :rtype: DictData
486
501
  """
487
502
  # NOTE: The output will fileter unnecessary keys from locals.
@@ -501,11 +516,13 @@ class PyStage(BaseStage):
501
516
  return to
502
517
 
503
518
  @handler_result()
504
- def execute(self, params: DictData) -> Result:
519
+ def execute(self, params: DictData, *, run_id: str | None = None) -> Result:
505
520
  """Execute the Python statement that pass all globals and input params
506
521
  to globals argument on ``exec`` build-in function.
507
522
 
508
523
  :param params: A parameter that want to pass before run any statement.
524
+ :param run_id: A running stage ID for this execution.
525
+
509
526
  :rtype: Result
510
527
  """
511
528
  # NOTE: Replace the run statement that has templating value.
@@ -518,12 +535,16 @@ class PyStage(BaseStage):
518
535
  lc: DictData = {}
519
536
 
520
537
  # NOTE: Start exec the run statement.
521
- logger.info(f"({self.run_id}) [STAGE]: Py-Execute: {self.name}")
538
+ logger.info(f"({run_id}) [STAGE]: Py-Execute: {self.name}")
539
+
540
+ # WARNING: The exec build-in function is vary dangerous. So, it
541
+ # should us the re module to validate exec-string before running.
522
542
  exec(run, _globals, lc)
523
543
 
524
544
  return Result(
525
545
  status=0,
526
546
  context={"locals": lc, "globals": _globals},
547
+ run_id=run_id,
527
548
  )
528
549
 
529
550
 
@@ -603,7 +624,7 @@ class HookStage(BaseStage):
603
624
  )
604
625
 
605
626
  @handler_result()
606
- def execute(self, params: DictData) -> Result:
627
+ def execute(self, params: DictData, *, run_id: str | None = None) -> Result:
607
628
  """Execute the Hook function that already in the hook registry.
608
629
 
609
630
  :raise ValueError: When the necessary arguments of hook function do not
@@ -613,10 +634,12 @@ class HookStage(BaseStage):
613
634
 
614
635
  :param params: A parameter that want to pass before run any statement.
615
636
  :type params: DictData
637
+ :param run_id: A running stage ID for this execution.
638
+ :type: str | None
639
+
616
640
  :rtype: Result
617
641
  """
618
- t_func_hook: str = param2template(self.uses, params)
619
- t_func: TagFunc = extract_hook(t_func_hook)()
642
+ t_func: TagFunc = extract_hook(param2template(self.uses, params))()
620
643
 
621
644
  # VALIDATE: check input task caller parameters that exists before
622
645
  # calling.
@@ -637,7 +660,7 @@ class HookStage(BaseStage):
637
660
  args[k] = args.pop(k.removeprefix("_"))
638
661
 
639
662
  logger.info(
640
- f"({self.run_id}) [STAGE]: Hook-Execute: {t_func.name}@{t_func.tag}"
663
+ f"({run_id}) [STAGE]: Hook-Execute: {t_func.name}@{t_func.tag}"
641
664
  )
642
665
  rs: DictData = t_func(**param2template(args, params))
643
666
 
@@ -648,7 +671,7 @@ class HookStage(BaseStage):
648
671
  f"Return type: '{t_func.name}@{t_func.tag}' does not serialize "
649
672
  f"to result model, you change return type to `dict`."
650
673
  )
651
- return Result(status=0, context=rs)
674
+ return Result(status=0, context=rs, run_id=run_id)
652
675
 
653
676
 
654
677
  class TriggerStage(BaseStage):
@@ -675,11 +698,13 @@ class TriggerStage(BaseStage):
675
698
  )
676
699
 
677
700
  @handler_result("Raise from TriggerStage")
678
- def execute(self, params: DictData) -> Result:
701
+ def execute(self, params: DictData, *, run_id: str | None = None) -> Result:
679
702
  """Trigger another workflow execution. It will waiting the trigger
680
703
  workflow running complete before catching its result.
681
704
 
682
705
  :param params: A parameter data that want to use in this execution.
706
+ :param run_id: A running stage ID for this execution.
707
+
683
708
  :rtype: Result
684
709
  """
685
710
  # NOTE: Lazy import this workflow object.
@@ -690,11 +715,12 @@ class TriggerStage(BaseStage):
690
715
 
691
716
  # NOTE: Set running workflow ID from running stage ID to external
692
717
  # params on Loader object.
693
- wf: Workflow = Workflow.from_loader(
694
- name=_trigger, externals={"run_id": self.run_id}
695
- )
696
- logger.info(f"({self.run_id}) [STAGE]: Trigger-Execute: {_trigger!r}")
697
- return wf.execute(params=param2template(self.params, params))
718
+ wf: Workflow = Workflow.from_loader(name=_trigger)
719
+ logger.info(f"({run_id}) [STAGE]: Trigger-Execute: {_trigger!r}")
720
+ return wf.execute(
721
+ params=param2template(self.params, params),
722
+ run_id=run_id,
723
+ ).set_run_id(run_id)
698
724
 
699
725
 
700
726
  # NOTE:
ddeutil/workflow/utils.py CHANGED
@@ -13,7 +13,7 @@ from abc import ABC, abstractmethod
13
13
  from ast import Call, Constant, Expr, Module, Name, parse
14
14
  from collections.abc import Iterator
15
15
  from dataclasses import field
16
- from datetime import date, datetime
16
+ from datetime import date, datetime, timedelta
17
17
  from functools import wraps
18
18
  from hashlib import md5
19
19
  from importlib import import_module
@@ -48,26 +48,34 @@ AnyModelType = type[AnyModel]
48
48
  logger = logging.getLogger("ddeutil.workflow")
49
49
 
50
50
 
51
- def get_dt_now(tz: ZoneInfo | None = None) -> datetime: # pragma: no cov
51
+ def get_dt_now(
52
+ tz: ZoneInfo | None = None, offset: float = 0.0
53
+ ) -> datetime: # pragma: no cov
52
54
  """Return the current datetime object.
53
55
 
54
56
  :param tz:
57
+ :param offset:
55
58
  :return: The current datetime object that use an input timezone or UTC.
56
59
  """
57
- return datetime.now(tz=(tz or ZoneInfo("UTC")))
60
+ return datetime.now(tz=(tz or ZoneInfo("UTC"))) - timedelta(seconds=offset)
58
61
 
59
62
 
60
63
  def get_diff_sec(
61
- dt: datetime, tz: ZoneInfo | None = None
64
+ dt: datetime, tz: ZoneInfo | None = None, offset: float = 0.0
62
65
  ) -> int: # pragma: no cov
63
66
  """Return second value that come from diff of an input datetime and the
64
67
  current datetime with specific timezone.
65
68
 
66
69
  :param dt:
67
70
  :param tz:
71
+ :param offset:
68
72
  """
69
73
  return round(
70
- (dt - datetime.now(tz=(tz or ZoneInfo("UTC")))).total_seconds()
74
+ (
75
+ dt
76
+ - datetime.now(tz=(tz or ZoneInfo("UTC")))
77
+ - timedelta(seconds=offset)
78
+ ).total_seconds()
71
79
  )
72
80
 
73
81
 
@@ -350,12 +358,10 @@ class Result:
350
358
 
351
359
  status: int = field(default=2)
352
360
  context: DictData = field(default_factory=dict)
353
- start_at: datetime = field(default_factory=get_dt_now, compare=False)
354
- end_at: Optional[datetime] = field(default=None, compare=False)
355
361
 
356
362
  # NOTE: Ignore this field to compare another result model with __eq__.
357
- _run_id: Optional[str] = field(default=None)
358
- _parent_run_id: Optional[str] = field(default=None, compare=False)
363
+ run_id: Optional[str] = field(default=None)
364
+ parent_run_id: Optional[str] = field(default=None, compare=False)
359
365
 
360
366
  @model_validator(mode="after")
361
367
  def __prepare_run_id(self) -> Self:
@@ -373,7 +379,7 @@ class Result:
373
379
  :param running_id: A running ID that want to update on this model.
374
380
  :rtype: Self
375
381
  """
376
- self._run_id = running_id
382
+ self.run_id = running_id
377
383
  return self
378
384
 
379
385
  def set_parent_run_id(self, running_id: str) -> Self:
@@ -382,17 +388,9 @@ class Result:
382
388
  :param running_id: A running ID that want to update on this model.
383
389
  :rtype: Self
384
390
  """
385
- self._parent_run_id: str = running_id
391
+ self.parent_run_id: str = running_id
386
392
  return self
387
393
 
388
- @property
389
- def parent_run_id(self) -> str:
390
- return self._parent_run_id
391
-
392
- @property
393
- def run_id(self) -> str:
394
- return self._run_id
395
-
396
394
  def catch(self, status: int, context: DictData) -> Self:
397
395
  """Catch the status and context to current data."""
398
396
  self.__dict__["status"] = status
@@ -408,8 +406,8 @@ class Result:
408
406
  self.__dict__["context"].update(result.context)
409
407
 
410
408
  # NOTE: Update running ID from an incoming result.
411
- self._parent_run_id = result.parent_run_id
412
- self._run_id = result.run_id
409
+ self.parent_run_id = result.parent_run_id
410
+ self.run_id = result.run_id
413
411
  return self
414
412
 
415
413
  def receive_jobs(self, result: Result) -> Self:
@@ -427,8 +425,8 @@ class Result:
427
425
  self.__dict__["context"]["jobs"].update(result.context)
428
426
 
429
427
  # NOTE: Update running ID from an incoming result.
430
- self._parent_run_id: str = result.parent_run_id
431
- self._run_id: str = result.run_id
428
+ self.parent_run_id: str = result.parent_run_id
429
+ self.run_id: str = result.run_id
432
430
  return self
433
431
 
434
432
 
@@ -576,7 +574,14 @@ def get_args_from_filter(
576
574
 
577
575
  @custom_filter("fmt") # pragma: no cov
578
576
  def datetime_format(value: datetime, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
579
- """Format datetime object to string with the format."""
577
+ """Format datetime object to string with the format.
578
+
579
+ :param value: A datetime value that want to format to string value.
580
+ :param fmt: A format string pattern that passing to the `dt.strftime`
581
+ method.
582
+
583
+ :rtype: str
584
+ """
580
585
  if isinstance(value, datetime):
581
586
  return value.strftime(fmt)
582
587
  raise UtilException(