ddeutil-workflow 0.0.19__py3-none-any.whl → 0.0.20__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
 
@@ -501,11 +515,13 @@ class PyStage(BaseStage):
501
515
  return to
502
516
 
503
517
  @handler_result()
504
- def execute(self, params: DictData) -> Result:
518
+ def execute(self, params: DictData, *, run_id: str | None = None) -> Result:
505
519
  """Execute the Python statement that pass all globals and input params
506
520
  to globals argument on ``exec`` build-in function.
507
521
 
508
522
  :param params: A parameter that want to pass before run any statement.
523
+ :param run_id: A running stage ID for this execution.
524
+
509
525
  :rtype: Result
510
526
  """
511
527
  # NOTE: Replace the run statement that has templating value.
@@ -518,12 +534,16 @@ class PyStage(BaseStage):
518
534
  lc: DictData = {}
519
535
 
520
536
  # NOTE: Start exec the run statement.
521
- logger.info(f"({self.run_id}) [STAGE]: Py-Execute: {self.name}")
537
+ logger.info(f"({run_id}) [STAGE]: Py-Execute: {self.name}")
538
+
539
+ # WARNING: The exec build-in function is vary dangerous. So, it
540
+ # should us the re module to validate exec-string before running.
522
541
  exec(run, _globals, lc)
523
542
 
524
543
  return Result(
525
544
  status=0,
526
545
  context={"locals": lc, "globals": _globals},
546
+ run_id=run_id,
527
547
  )
528
548
 
529
549
 
@@ -603,7 +623,7 @@ class HookStage(BaseStage):
603
623
  )
604
624
 
605
625
  @handler_result()
606
- def execute(self, params: DictData) -> Result:
626
+ def execute(self, params: DictData, *, run_id: str | None = None) -> Result:
607
627
  """Execute the Hook function that already in the hook registry.
608
628
 
609
629
  :raise ValueError: When the necessary arguments of hook function do not
@@ -613,6 +633,9 @@ class HookStage(BaseStage):
613
633
 
614
634
  :param params: A parameter that want to pass before run any statement.
615
635
  :type params: DictData
636
+ :param run_id: A running stage ID for this execution.
637
+ :type: str | None
638
+
616
639
  :rtype: Result
617
640
  """
618
641
  t_func_hook: str = param2template(self.uses, params)
@@ -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(