ddeutil-workflow 0.0.32__py3-none-any.whl → 0.0.34__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.
@@ -42,10 +42,10 @@ from pydantic.functional_validators import model_validator
42
42
  from typing_extensions import Self
43
43
 
44
44
  from .__types import DictData, DictStr, TupleStr
45
+ from .call import TagFunc, extract_call
45
46
  from .conf import config, get_logger
46
47
  from .exceptions import StageException
47
- from .hook import TagFunc, extract_hook
48
- from .result import Result
48
+ from .result import Result, Status
49
49
  from .templates import not_in_template, param2template
50
50
  from .utils import (
51
51
  cut_id,
@@ -60,7 +60,7 @@ __all__: TupleStr = (
60
60
  "EmptyStage",
61
61
  "BashStage",
62
62
  "PyStage",
63
- "HookStage",
63
+ "CallStage",
64
64
  "TriggerStage",
65
65
  "Stage",
66
66
  )
@@ -100,7 +100,7 @@ class BaseStage(BaseModel, ABC):
100
100
  return self.id or self.name
101
101
 
102
102
  @model_validator(mode="after")
103
- def __prepare_running_id__(self) -> Self:
103
+ def __prepare_running_id(self) -> Self:
104
104
  """Prepare stage running ID that use default value of field and this
105
105
  method will validate name and id fields should not contain any template
106
106
  parameter (exclude matrix template).
@@ -121,36 +121,45 @@ class BaseStage(BaseModel, ABC):
121
121
  return self
122
122
 
123
123
  @abstractmethod
124
- def execute(self, params: DictData, *, run_id: str | None = None) -> Result:
124
+ def execute(
125
+ self, params: DictData, *, result: Result | None = None
126
+ ) -> Result:
125
127
  """Execute abstraction method that action something by sub-model class.
126
128
  This is important method that make this class is able to be the stage.
127
129
 
128
130
  :param params: A parameter data that want to use in this execution.
129
- :param run_id: A running stage ID for this execution.
131
+ :param result: (Result) A result object for keeping context and status
132
+ data.
130
133
 
131
134
  :rtype: Result
132
135
  """
133
136
  raise NotImplementedError("Stage should implement ``execute`` method.")
134
137
 
135
138
  def handler_execute(
136
- self, params: DictData, *, run_id: str | None = None
139
+ self,
140
+ params: DictData,
141
+ *,
142
+ run_id: str | None = None,
143
+ parent_run_id: str | None = None,
144
+ result: Result | None = None,
137
145
  ) -> Result:
138
- """Handler result from the stage execution.
146
+ """Handler execution result from the stage `execute` method.
139
147
 
140
148
  This stage exception handler still use ok-error concept, but it
141
149
  allows you force catching an output result with error message by
142
150
  specific environment variable,`WORKFLOW_CORE_STAGE_RAISE_ERROR`.
143
151
 
144
152
  Execution --> Ok --> Result
145
- |-status: 0
153
+ |-status: Status.SUCCESS
146
154
  |-context:
147
155
  |-outputs: ...
148
156
 
149
157
  --> Error --> Result (if env var was set)
150
- |-status: 1
151
- |-context:
152
- |-error: ...
153
- |-error_message: ...
158
+ |-status: Status.FAILED
159
+ |-errors:
160
+ |-class: ...
161
+ |-name: ...
162
+ |-message: ...
154
163
 
155
164
  --> Error --> Raise StageException(...)
156
165
 
@@ -158,31 +167,35 @@ class BaseStage(BaseModel, ABC):
158
167
  from current stage ID before release the final result.
159
168
 
160
169
  :param params: A parameter data that want to use in this execution.
161
- :param run_id: A running stage ID for this execution.
170
+ :param run_id: (str) A running stage ID for this execution.
171
+ :param parent_run_id: A parent workflow running ID for this release.
172
+ :param result: (Result) A result object for keeping context and status
173
+ data.
162
174
 
163
175
  :rtype: Result
164
176
  """
165
- if not run_id:
166
- run_id: str = gen_id(self.name + (self.id or ""), unique=True)
177
+ if result is None:
178
+ result: Result = Result(
179
+ run_id=(
180
+ run_id or gen_id(self.name + (self.id or ""), unique=True)
181
+ ),
182
+ parent_run_id=parent_run_id,
183
+ )
184
+ elif parent_run_id:
185
+ result.set_parent_run_id(parent_run_id)
167
186
 
168
- rs_raise: Result = Result(status=1, run_id=run_id)
169
187
  try:
170
- # NOTE: Start calling origin function with a passing args.
171
- return self.execute(params, run_id=run_id)
188
+ return self.execute(params, result=result)
172
189
  except Exception as err:
173
- # NOTE: Start catching error from the stage execution.
174
- logger.error(
175
- f"({cut_id(run_id)}) [STAGE]: {err.__class__.__name__}: "
176
- f"{err}"
177
- )
190
+ result.trace.error(f"[STAGE]: {err.__class__.__name__}: {err}")
191
+
178
192
  if config.stage_raise_error:
179
193
  # NOTE: If error that raise from stage execution course by
180
194
  # itself, it will return that error with previous
181
195
  # dependency.
182
196
  if isinstance(err, StageException):
183
- raise StageException(
184
- f"{self.__class__.__name__}: \n\t{err}"
185
- ) from err
197
+ raise
198
+
186
199
  raise StageException(
187
200
  f"{self.__class__.__name__}: \n\t"
188
201
  f"{err.__class__.__name__}: {err}"
@@ -190,11 +203,14 @@ class BaseStage(BaseModel, ABC):
190
203
 
191
204
  # NOTE: Catching exception error object to result with
192
205
  # error_message and error keys.
193
- return rs_raise.catch(
194
- status=1,
206
+ return result.catch(
207
+ status=Status.FAILED,
195
208
  context={
196
- "error": err,
197
- "error_message": f"{err.__class__.__name__}: {err}",
209
+ "errors": {
210
+ "class": err,
211
+ "name": err.__class__.__name__,
212
+ "message": f"{err.__class__.__name__}: {err}",
213
+ },
198
214
  },
199
215
  )
200
216
 
@@ -238,8 +254,12 @@ class BaseStage(BaseModel, ABC):
238
254
  else gen_id(param2template(self.name, params=to))
239
255
  )
240
256
 
257
+ errors: DictData = (
258
+ {"errors": output.pop("errors", {})} if "errors" in output else {}
259
+ )
260
+
241
261
  # NOTE: Set the output to that stage generated ID with ``outputs`` key.
242
- to["stages"][_id] = {"outputs": output}
262
+ to["stages"][_id] = {"outputs": output, **errors}
243
263
  return to
244
264
 
245
265
  def is_skipped(self, params: DictData | None = None) -> bool:
@@ -295,7 +315,9 @@ class EmptyStage(BaseStage):
295
315
  ge=0,
296
316
  )
297
317
 
298
- def execute(self, params: DictData, *, run_id: str | None = None) -> Result:
318
+ def execute(
319
+ self, params: DictData, *, result: Result | None = None
320
+ ) -> Result:
299
321
  """Execution method for the Empty stage that do only logging out to
300
322
  stdout. This method does not use the `handler_result` decorator because
301
323
  it does not get any error from logging function.
@@ -305,22 +327,26 @@ class EmptyStage(BaseStage):
305
327
 
306
328
  :param params: A context data that want to add output result. But this
307
329
  stage does not pass any output.
308
- :param run_id: A running stage ID for this execution.
330
+ :param result: (Result) A result object for keeping context and status
331
+ data.
309
332
 
310
333
  :rtype: Result
311
334
  """
312
- logger.info(
313
- f"({cut_id(run_id)}) [STAGE]: Empty-Execute: {self.name!r}: "
335
+ if result is None: # pragma: no cov
336
+ result: Result = Result(
337
+ run_id=gen_id(self.name + (self.id or ""), unique=True)
338
+ )
339
+
340
+ result.trace.info(
341
+ f"[STAGE]: Empty-Execute: {self.name!r}: "
314
342
  f"( {param2template(self.echo, params=params) or '...'} )"
315
343
  )
316
344
  if self.sleep > 0:
317
345
  if self.sleep > 30:
318
- logger.info(
319
- f"({cut_id(run_id)}) [STAGE]: ... sleep "
320
- f"({self.sleep} seconds)"
321
- )
346
+ result.trace.info(f"[STAGE]: ... sleep ({self.sleep} seconds)")
322
347
  time.sleep(self.sleep)
323
- return Result(status=0, context={}, run_id=run_id)
348
+
349
+ return result.catch(status=Status.SUCCESS)
324
350
 
325
351
 
326
352
  class BashStage(BaseStage):
@@ -334,7 +360,7 @@ class BashStage(BaseStage):
334
360
 
335
361
  Data Validate:
336
362
  >>> stage = {
337
- ... "name": "Shell stage execution",
363
+ ... "name": "The Shell stage execution",
338
364
  ... "bash": 'echo "Hello $FOO"',
339
365
  ... "env": {
340
366
  ... "FOO": "BAR",
@@ -391,20 +417,30 @@ class BashStage(BaseStage):
391
417
  # Note: Remove .sh file that use to run bash.
392
418
  Path(f"./{f_name}").unlink()
393
419
 
394
- def execute(self, params: DictData, *, run_id: str | None = None) -> Result:
420
+ def execute(
421
+ self, params: DictData, *, result: Result | None = None
422
+ ) -> Result:
395
423
  """Execute the Bash statement with the Python build-in ``subprocess``
396
424
  package.
397
425
 
398
426
  :param params: A parameter data that want to use in this execution.
399
- :param run_id: A running stage ID for this execution.
427
+ :param result: (Result) A result object for keeping context and status
428
+ data.
400
429
 
401
430
  :rtype: Result
402
431
  """
432
+ if result is None: # pragma: no cov
433
+ result: Result = Result(
434
+ run_id=gen_id(self.name + (self.id or ""), unique=True)
435
+ )
436
+
403
437
  bash: str = param2template(dedent(self.bash), params)
404
438
 
405
- logger.info(f"({cut_id(run_id)}) [STAGE]: Shell-Execute: {self.name}")
439
+ result.trace.info(f"[STAGE]: Shell-Execute: {self.name}")
406
440
  with self.create_sh_file(
407
- bash=bash, env=param2template(self.env, params), run_id=run_id
441
+ bash=bash,
442
+ env=param2template(self.env, params),
443
+ run_id=result.run_id,
408
444
  ) as sh:
409
445
  rs: CompletedProcess = subprocess.run(
410
446
  sh, shell=False, capture_output=True, text=True
@@ -420,14 +456,13 @@ class BashStage(BaseStage):
420
456
  f"Subprocess: {err}\nRunning Statement:\n---\n"
421
457
  f"```bash\n{bash}\n```"
422
458
  )
423
- return Result(
424
- status=0,
459
+ return result.catch(
460
+ status=Status.SUCCESS,
425
461
  context={
426
462
  "return_code": rs.returncode,
427
463
  "stdout": rs.stdout.rstrip("\n") or None,
428
464
  "stderr": rs.stderr.rstrip("\n") or None,
429
465
  },
430
- run_id=run_id,
431
466
  )
432
467
 
433
468
 
@@ -459,12 +494,24 @@ class PyStage(BaseStage):
459
494
  )
460
495
 
461
496
  @staticmethod
462
- def pick_keys_from_locals(values: DictData) -> Iterator[str]:
463
- from inspect import ismodule
497
+ def filter_locals(values: DictData) -> Iterator[str]:
498
+ """Filter a locals input values.
499
+
500
+ :param values: (DictData) A locals values that want to filter.
501
+
502
+ :rtype: Iterator[str]
503
+ """
504
+ from inspect import isclass, ismodule
464
505
 
465
506
  for value in values:
466
- if value == "__annotations__" or ismodule(values[value]):
507
+
508
+ if (
509
+ value == "__annotations__"
510
+ or ismodule(values[value])
511
+ or isclass(values[value])
512
+ ):
467
513
  continue
514
+
468
515
  yield value
469
516
 
470
517
  def set_outputs(self, output: DictData, to: DictData) -> DictData:
@@ -480,7 +527,7 @@ class PyStage(BaseStage):
480
527
  lc: DictData = output.get("locals", {})
481
528
  super().set_outputs(
482
529
  (
483
- {k: lc[k] for k in self.pick_keys_from_locals(lc)}
530
+ {k: lc[k] for k in self.filter_locals(lc)}
484
531
  | {k: output[k] for k in output if k.startswith("error")}
485
532
  ),
486
533
  to=to,
@@ -492,15 +539,23 @@ class PyStage(BaseStage):
492
539
  to.update({k: gb[k] for k in to if k in gb})
493
540
  return to
494
541
 
495
- def execute(self, params: DictData, *, run_id: str | None = None) -> Result:
542
+ def execute(
543
+ self, params: DictData, *, result: Result | None = None
544
+ ) -> Result:
496
545
  """Execute the Python statement that pass all globals and input params
497
546
  to globals argument on ``exec`` build-in function.
498
547
 
499
548
  :param params: A parameter that want to pass before run any statement.
500
- :param run_id: A running stage ID for this execution.
549
+ :param result: (Result) A result object for keeping context and status
550
+ data.
501
551
 
502
552
  :rtype: Result
503
553
  """
554
+ if result is None: # pragma: no cov
555
+ result: Result = Result(
556
+ run_id=gen_id(self.name + (self.id or ""), unique=True)
557
+ )
558
+
504
559
  # NOTE: Replace the run statement that has templating value.
505
560
  run: str = param2template(dedent(self.run), params)
506
561
 
@@ -511,21 +566,19 @@ class PyStage(BaseStage):
511
566
  lc: DictData = {}
512
567
 
513
568
  # NOTE: Start exec the run statement.
514
- logger.info(f"({cut_id(run_id)}) [STAGE]: Py-Execute: {self.name}")
569
+ result.trace.info(f"[STAGE]: Py-Execute: {self.name}")
515
570
 
516
571
  # WARNING: The exec build-in function is very dangerous. So, it
517
572
  # should use the re module to validate exec-string before running.
518
573
  exec(run, _globals, lc)
519
574
 
520
- return Result(
521
- status=0,
522
- context={"locals": lc, "globals": _globals},
523
- run_id=run_id,
575
+ return result.catch(
576
+ status=Status.SUCCESS, context={"locals": lc, "globals": _globals}
524
577
  )
525
578
 
526
579
 
527
- class HookStage(BaseStage):
528
- """Hook executor that hook the Python function from registry with tag
580
+ class CallStage(BaseStage):
581
+ """Call executor that call the Python function from registry with tag
529
582
  decorator function in ``utils`` module and run it with input arguments.
530
583
 
531
584
  This stage is different with PyStage because the PyStage is just calling
@@ -543,35 +596,43 @@ class HookStage(BaseStage):
543
596
 
544
597
  uses: str = Field(
545
598
  description=(
546
- "A pointer that want to load function from the hook registry."
599
+ "A pointer that want to load function from the call registry."
547
600
  ),
548
601
  )
549
602
  args: DictData = Field(
550
603
  default_factory=dict,
551
- description="An arguments that want to pass to the hook function.",
604
+ description="An arguments that want to pass to the call function.",
552
605
  alias="with",
553
606
  )
554
607
 
555
- def execute(self, params: DictData, *, run_id: str | None = None) -> Result:
556
- """Execute the Hook function that already in the hook registry.
608
+ def execute(
609
+ self, params: DictData, *, result: Result | None = None
610
+ ) -> Result:
611
+ """Execute the Call function that already in the call registry.
557
612
 
558
- :raise ValueError: When the necessary arguments of hook function do not
613
+ :raise ValueError: When the necessary arguments of call function do not
559
614
  set from the input params argument.
560
- :raise TypeError: When the return type of hook function does not be
615
+ :raise TypeError: When the return type of call function does not be
561
616
  dict type.
562
617
 
563
618
  :param params: A parameter that want to pass before run any statement.
564
619
  :type params: DictData
565
- :param run_id: A running stage ID for this execution.
620
+ :param result: (Result) A result object for keeping context and status
621
+ data.
566
622
  :type: str | None
567
623
 
568
624
  :rtype: Result
569
625
  """
570
- t_func: TagFunc = extract_hook(param2template(self.uses, params))()
626
+ if result is None: # pragma: no cov
627
+ result: Result = Result(
628
+ run_id=gen_id(self.name + (self.id or ""), unique=True)
629
+ )
630
+
631
+ t_func: TagFunc = extract_call(param2template(self.uses, params))()
571
632
 
572
633
  # VALIDATE: check input task caller parameters that exists before
573
634
  # calling.
574
- args: DictData = param2template(self.args, params)
635
+ args: DictData = {"result": result} | param2template(self.args, params)
575
636
  ips = inspect.signature(t_func)
576
637
  if any(
577
638
  (k.removeprefix("_") not in args and k not in args)
@@ -587,20 +648,20 @@ class HookStage(BaseStage):
587
648
  if k.removeprefix("_") in args:
588
649
  args[k] = args.pop(k.removeprefix("_"))
589
650
 
590
- logger.info(
591
- f"({cut_id(run_id)}) [STAGE]: Hook-Execute: "
592
- f"{t_func.name}@{t_func.tag}"
593
- )
651
+ if "result" not in ips.parameters:
652
+ args.pop("result")
653
+
654
+ result.trace.info(f"[STAGE]: Call-Execute: {t_func.name}@{t_func.tag}")
594
655
  rs: DictData = t_func(**param2template(args, params))
595
656
 
596
657
  # VALIDATE:
597
- # Check the result type from hook function, it should be dict.
658
+ # Check the result type from call function, it should be dict.
598
659
  if not isinstance(rs, dict):
599
660
  raise TypeError(
600
661
  f"Return type: '{t_func.name}@{t_func.tag}' does not serialize "
601
662
  f"to result model, you change return type to `dict`."
602
663
  )
603
- return Result(status=0, context=rs, run_id=run_id)
664
+ return result.catch(status=Status.SUCCESS, context=rs)
604
665
 
605
666
 
606
667
  class TriggerStage(BaseStage):
@@ -626,31 +687,37 @@ class TriggerStage(BaseStage):
626
687
  description="A parameter that want to pass to workflow execution.",
627
688
  )
628
689
 
629
- def execute(self, params: DictData, *, run_id: str | None = None) -> Result:
690
+ def execute(
691
+ self, params: DictData, *, result: Result | None = None
692
+ ) -> Result:
630
693
  """Trigger another workflow execution. It will wait the trigger
631
694
  workflow running complete before catching its result.
632
695
 
633
696
  :param params: A parameter data that want to use in this execution.
634
- :param run_id: A running stage ID for this execution.
697
+ :param result: (Result) A result object for keeping context and status
698
+ data.
635
699
 
636
700
  :rtype: Result
637
701
  """
638
702
  # NOTE: Lazy import this workflow object.
639
703
  from . import Workflow
640
704
 
705
+ if result is None: # pragma: no cov
706
+ result: Result = Result(
707
+ run_id=gen_id(self.name + (self.id or ""), unique=True)
708
+ )
709
+
641
710
  # NOTE: Loading workflow object from trigger name.
642
711
  _trigger: str = param2template(self.trigger, params=params)
643
712
 
644
713
  # NOTE: Set running workflow ID from running stage ID to external
645
714
  # params on Loader object.
646
- wf: Workflow = Workflow.from_loader(name=_trigger)
647
- logger.info(
648
- f"({cut_id(run_id)}) [STAGE]: Trigger-Execute: {_trigger!r}"
649
- )
650
- return wf.execute(
715
+ workflow: Workflow = Workflow.from_loader(name=_trigger)
716
+ result.trace.info(f"[STAGE]: Trigger-Execute: {_trigger!r}")
717
+ return workflow.execute(
651
718
  params=param2template(self.params, params),
652
- run_id=run_id,
653
- ).set_run_id(run_id)
719
+ result=result,
720
+ )
654
721
 
655
722
 
656
723
  # NOTE:
@@ -661,19 +728,37 @@ class TriggerStage(BaseStage):
661
728
  Stage = Union[
662
729
  PyStage,
663
730
  BashStage,
664
- HookStage,
731
+ CallStage,
665
732
  TriggerStage,
666
733
  EmptyStage,
667
734
  ]
668
735
 
669
736
 
670
737
  # TODO: Not implement this stages yet
671
- class ParallelStage(BaseModel): # pragma: no cov
738
+ class ParallelStage(BaseStage): # pragma: no cov
672
739
  parallel: list[Stage]
673
740
  max_parallel_core: int = Field(default=2)
674
741
 
742
+ def execute(
743
+ self, params: DictData, *, result: Result | None = None
744
+ ) -> Result: ...
745
+
746
+
747
+ # TODO: Not implement this stages yet
748
+ class ForEachStage(BaseStage): # pragma: no cov
749
+ foreach: list[str]
750
+ stages: list[Stage]
751
+
752
+ def execute(
753
+ self, params: DictData, *, result: Result | None = None
754
+ ) -> Result: ...
755
+
675
756
 
676
757
  # TODO: Not implement this stages yet
677
- class ForEachStage(BaseModel): # pragma: no cov
758
+ class HookStage(BaseStage): # pragma: no cov
678
759
  foreach: list[str]
679
760
  stages: list[Stage]
761
+
762
+ def execute(
763
+ self, params: DictData, *, result: Result | None = None
764
+ ) -> Result: ...
ddeutil/workflow/utils.py CHANGED
@@ -8,7 +8,7 @@ from __future__ import annotations
8
8
  import logging
9
9
  import stat
10
10
  import time
11
- from collections.abc import Iterator
11
+ from collections.abc import Iterator, Mapping
12
12
  from datetime import datetime, timedelta
13
13
  from hashlib import md5
14
14
  from inspect import isfunction
@@ -199,7 +199,7 @@ def cross_product(matrix: Matrix) -> Iterator[DictData]:
199
199
  )
200
200
 
201
201
 
202
- def batch(iterable: Iterator[Any], n: int) -> Iterator[Any]:
202
+ def batch(iterable: Iterator[Any] | range, n: int) -> Iterator[Any]:
203
203
  """Batch data into iterators of length n. The last batch may be shorter.
204
204
 
205
205
  Example:
@@ -240,3 +240,21 @@ def cut_id(run_id: str, *, num: int = 6) -> str:
240
240
  :rtype: str
241
241
  """
242
242
  return run_id[-num:]
243
+
244
+
245
+ def deep_update(origin: DictData, u: Mapping) -> DictData:
246
+ """Deep update dict.
247
+
248
+ Example:
249
+ >>> deep_update(
250
+ ... origin={"jobs": {"job01": "foo"}},
251
+ ... u={"jobs": {"job02": "bar"}},
252
+ ... )
253
+ {"jobs": {"job01": "foo", "job02": "bar"}}
254
+ """
255
+ for k, value in u.items():
256
+ if isinstance(value, Mapping) and value:
257
+ origin[k] = deep_update(origin.get(k, {}), value)
258
+ else:
259
+ origin[k] = value
260
+ return origin