ddeutil-workflow 0.0.33__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.
ddeutil/workflow/job.py CHANGED
@@ -39,7 +39,7 @@ from .exceptions import (
39
39
  UtilException,
40
40
  )
41
41
  from .result import Result, Status
42
- from .stage import Stage
42
+ from .stages import Stage
43
43
  from .templates import has_template
44
44
  from .utils import (
45
45
  cross_product,
@@ -400,10 +400,15 @@ class Job(BaseModel):
400
400
  # NOTE: If the job ID did not set, it will use index of jobs key
401
401
  # instead.
402
402
  _id: str = self.id or str(len(to["jobs"]) + 1)
403
+
404
+ errors: DictData = (
405
+ {"errors": output.pop("errors", {})} if "errors" in output else {}
406
+ )
407
+
403
408
  to["jobs"][_id] = (
404
- {"strategies": output}
409
+ {"strategies": output, **errors}
405
410
  if self.strategy.is_set()
406
- else output.get(next(iter(output), "DUMMY"), {})
411
+ else {**output.get(next(iter(output), "DUMMY"), {}), **errors}
407
412
  )
408
413
  return to
409
414
 
@@ -412,7 +417,6 @@ class Job(BaseModel):
412
417
  strategy: DictData,
413
418
  params: DictData,
414
419
  *,
415
- run_id: str | None = None,
416
420
  result: Result | None = None,
417
421
  event: Event | None = None,
418
422
  ) -> Result:
@@ -432,7 +436,6 @@ class Job(BaseModel):
432
436
  :param strategy: A strategy metrix value that use on this execution.
433
437
  This value will pass to the `matrix` key for templating.
434
438
  :param params: A dynamic parameters that will deepcopy to the context.
435
- :param run_id: A job running ID for this strategy execution.
436
439
  :param result: (Result) A result object for keeping context and status
437
440
  data.
438
441
  :param event: An event manager that pass to the PoolThreadExecutor.
@@ -440,9 +443,7 @@ class Job(BaseModel):
440
443
  :rtype: Result
441
444
  """
442
445
  if result is None: # pragma: no cov
443
- result: Result = Result(
444
- run_id=(run_id or gen_id(self.id or "", unique=True))
445
- )
446
+ result: Result = Result(run_id=gen_id(self.id or "", unique=True))
446
447
 
447
448
  strategy_id: str = gen_id(strategy)
448
449
 
@@ -492,8 +493,11 @@ class Job(BaseModel):
492
493
  # "stages": filter_func(context.pop("stages", {})),
493
494
  #
494
495
  "stages": context.pop("stages", {}),
495
- "error": JobException(error_msg),
496
- "error_message": error_msg,
496
+ "errors": {
497
+ "class": JobException(error_msg),
498
+ "name": "JobException",
499
+ "message": error_msg,
500
+ },
497
501
  },
498
502
  },
499
503
  )
@@ -516,10 +520,18 @@ class Job(BaseModel):
516
520
  # "stages": { { "stage-id-1": ... }, ... }
517
521
  # }
518
522
  #
523
+ # IMPORTANT:
524
+ # This execution change all stage running IDs to the current job
525
+ # running ID, but it still trac log to the same parent running ID
526
+ # (with passing `run_id` and `parent_run_id` to the stage
527
+ # execution arguments).
528
+ #
519
529
  try:
520
530
  stage.set_outputs(
521
531
  stage.handler_execute(
522
- params=context, run_id=result.run_id
532
+ params=context,
533
+ run_id=result.run_id,
534
+ parent_run_id=result.parent_run_id,
523
535
  ).context,
524
536
  to=context,
525
537
  )
@@ -527,17 +539,21 @@ class Job(BaseModel):
527
539
  result.trace.error(f"[JOB]: {err.__class__.__name__}: {err}")
528
540
  if config.job_raise_error:
529
541
  raise JobException(
530
- f"Get stage execution error: {err.__class__.__name__}: "
542
+ f"Stage execution error: {err.__class__.__name__}: "
531
543
  f"{err}"
532
544
  ) from None
545
+
533
546
  return result.catch(
534
547
  status=1,
535
548
  context={
536
549
  strategy_id: {
537
550
  "matrix": strategy,
538
551
  "stages": context.pop("stages", {}),
539
- "error": err,
540
- "error_message": f"{err.__class__.__name__}: {err}",
552
+ "errors": {
553
+ "class": err,
554
+ "name": err.__class__.__name__,
555
+ "message": f"{err.__class__.__name__}: {err}",
556
+ },
541
557
  },
542
558
  },
543
559
  )
@@ -560,6 +576,7 @@ class Job(BaseModel):
560
576
  params: DictData,
561
577
  *,
562
578
  run_id: str | None = None,
579
+ parent_run_id: str | None = None,
563
580
  result: Result | None = None,
564
581
  ) -> Result:
565
582
  """Job execution with passing dynamic parameters from the workflow
@@ -568,6 +585,7 @@ class Job(BaseModel):
568
585
 
569
586
  :param params: An input parameters that use on job execution.
570
587
  :param run_id: A job running ID for this execution.
588
+ :param parent_run_id: A parent workflow running ID for this release.
571
589
  :param result: (Result) A result object for keeping context and status
572
590
  data.
573
591
 
@@ -577,8 +595,12 @@ class Job(BaseModel):
577
595
  # NOTE: I use this condition because this method allow passing empty
578
596
  # params and I do not want to create new dict object.
579
597
  if result is None: # pragma: no cov
580
- run_id: str = run_id or gen_id(self.id or "", unique=True)
581
- result: Result = Result(run_id=run_id)
598
+ result: Result = Result(
599
+ run_id=(run_id or gen_id(self.id or "", unique=True)),
600
+ parent_run_id=parent_run_id,
601
+ )
602
+ elif parent_run_id:
603
+ result.set_parent_run_id(parent_run_id)
582
604
 
583
605
  # NOTE: Normal Job execution without parallel strategy matrix. It uses
584
606
  # for-loop to control strategy execution sequentially.
@@ -614,110 +636,50 @@ class Job(BaseModel):
614
636
  for strategy in self.strategy.make()
615
637
  ]
616
638
 
617
- return (
618
- self.__catch_fail_fast(event, futures=futures, result=result)
619
- if self.strategy.fail_fast
620
- else self.__catch_all_completed(futures=futures, result=result)
621
- )
622
-
623
- @staticmethod
624
- def __catch_fail_fast(
625
- event: Event,
626
- futures: list[Future],
627
- result: Result,
628
- *,
629
- timeout: int = 1800,
630
- ) -> Result:
631
- """Job parallel pool futures catching with fail-fast mode. That will
632
- stop and set event on all not done futures if it receives the first
633
- exception from all running futures.
634
-
635
- :param event: An event manager instance that able to set stopper on the
636
- observing multithreading.
637
- :param futures: A list of futures.
638
- :param result: (Result) A result object for keeping context and status
639
- data.
640
- :param timeout: A timeout to waiting all futures complete.
641
-
642
- :rtype: Result
643
- """
644
- context: DictData = {}
645
- status: Status = Status.SUCCESS
646
-
647
- # NOTE: Get results from a collection of tasks with a timeout that has
648
- # the first exception.
649
- done, not_done = wait(
650
- futures, timeout=timeout, return_when=FIRST_EXCEPTION
651
- )
652
- nd: str = (
653
- f", the strategies do not run is {not_done}" if not_done else ""
654
- )
655
- result.trace.debug(f"[JOB]: Strategy is set Fail Fast{nd}")
656
-
657
- # NOTE:
658
- # Stop all running tasks with setting the event manager and cancel
659
- # any scheduled tasks.
660
- #
661
- if len(done) != len(futures):
662
- event.set()
663
- for future in not_done:
664
- future.cancel()
665
-
666
- future: Future
667
- for future in done:
668
-
669
- # NOTE: Handle the first exception from feature
670
- if err := future.exception():
671
- status: Status = Status.FAILED
672
- result.trace.error(
673
- f"[JOB]: Fail-fast catching:\n\t{future.exception()}"
674
- )
675
- context.update(
676
- {
677
- "error": err,
678
- "error_message": f"{err.__class__.__name__}: {err}",
679
- },
680
- )
681
- continue
682
-
683
- # NOTE: Update the result context to main job context.
684
- future.result()
685
-
686
- return result.catch(status=status, context=context)
687
-
688
- @staticmethod
689
- def __catch_all_completed(
690
- futures: list[Future],
691
- result: Result,
692
- *,
693
- timeout: int = 1800,
694
- ) -> Result:
695
- """Job parallel pool futures catching with all-completed mode.
696
-
697
- :param futures: A list of futures.
698
- :param result: (Result) A result object for keeping context and status
699
- data.
700
- :param timeout: A timeout to waiting all futures complete.
639
+ context: DictData = {}
640
+ status: Status = Status.SUCCESS
641
+ fail_fast_flag: bool = self.strategy.fail_fast
701
642
 
702
- :rtype: Result
703
- """
704
- context: DictData = {}
705
- status: Status = Status.SUCCESS
706
-
707
- for future in as_completed(futures, timeout=timeout):
708
- try:
709
- future.result()
710
- except JobException as err:
711
- status = Status.FAILED
712
- result.trace.error(
713
- f"[JOB]: All-completed catching:\n\t"
714
- f"{err.__class__.__name__}:\n\t{err}"
643
+ if fail_fast_flag:
644
+ # NOTE: Get results from a collection of tasks with a timeout
645
+ # that has the first exception.
646
+ done, not_done = wait(
647
+ futures, timeout=1800, return_when=FIRST_EXCEPTION
715
648
  )
716
- context.update(
717
- {
718
- "error": err,
719
- "error_message": f"{err.__class__.__name__}: {err}",
720
- },
649
+ nd: str = (
650
+ f", the strategies do not run is {not_done}"
651
+ if not_done
652
+ else ""
721
653
  )
654
+ result.trace.debug(f"[JOB]: Strategy is set Fail Fast{nd}")
655
+
656
+ # NOTE: Stop all running tasks with setting the event manager
657
+ # and cancel any scheduled tasks.
658
+ if len(done) != len(futures):
659
+ event.set()
660
+ for future in not_done:
661
+ future.cancel()
662
+ else:
663
+ done = as_completed(futures, timeout=1800)
664
+
665
+ for future in done:
666
+ try:
667
+ future.result()
668
+ except JobException as err:
669
+ status = Status.FAILED
670
+ ls: str = "Fail-Fast" if fail_fast_flag else "All-Completed"
671
+ result.trace.error(
672
+ f"[JOB]: {ls} Catch:\n\t{err.__class__.__name__}:"
673
+ f"\n\t{err}"
674
+ )
675
+ context.update(
676
+ {
677
+ "errors": {
678
+ "class": err,
679
+ "name": err.__class__.__name__,
680
+ "message": f"{err.__class__.__name__}: {err}",
681
+ },
682
+ },
683
+ )
722
684
 
723
685
  return result.catch(status=status, context=context)
@@ -3,12 +3,19 @@
3
3
  # Licensed under the MIT License. See LICENSE in the project root for
4
4
  # license information.
5
5
  # ------------------------------------------------------------------------------
6
+ """This is the Result module. It is the data context transfer objects that use
7
+ by all object in this package.
8
+ """
6
9
  from __future__ import annotations
7
10
 
11
+ import os
12
+ from abc import ABC, abstractmethod
8
13
  from dataclasses import field
9
14
  from datetime import datetime
10
15
  from enum import IntEnum
11
- from threading import Event
16
+ from inspect import Traceback, currentframe, getframeinfo
17
+ from pathlib import Path
18
+ from threading import Event, get_ident
12
19
  from typing import Optional
13
20
 
14
21
  from pydantic import ConfigDict
@@ -19,11 +26,14 @@ from .__types import DictData, TupleStr
19
26
  from .conf import config, get_logger
20
27
  from .utils import cut_id, gen_id, get_dt_now
21
28
 
22
- logger = get_logger("ddeutil.workflow.audit")
29
+ logger = get_logger("ddeutil.workflow")
23
30
 
24
31
  __all__: TupleStr = (
25
32
  "Result",
26
33
  "Status",
34
+ "TraceLog",
35
+ "default_gen_id",
36
+ "get_dt_tznow",
27
37
  )
28
38
 
29
39
 
@@ -52,25 +62,109 @@ class Status(IntEnum):
52
62
  WAIT: int = 2
53
63
 
54
64
 
55
- class TraceLog: # pragma: no cov
56
- """Trace Log object."""
65
+ @dataclass(frozen=True)
66
+ class BaseTraceLog(ABC): # pragma: no cov
67
+ """Base Trace Log dataclass object."""
57
68
 
58
- __slots__: TupleStr = ("run_id",)
69
+ run_id: str
70
+ parent_run_id: Optional[str] = None
59
71
 
60
- def __init__(self, run_id: str):
61
- self.run_id: str = run_id
72
+ @abstractmethod
73
+ def writer(self, message: str, is_err: bool = False) -> None: ...
74
+
75
+ @abstractmethod
76
+ def make_message(self, message: str) -> str: ...
62
77
 
63
78
  def debug(self, message: str):
64
- logger.debug(f"({cut_id(self.run_id)}) {message}")
79
+ msg: str = self.make_message(message)
80
+
81
+ # NOTE: Write file if debug mode.
82
+ if config.debug:
83
+ self.writer(msg)
84
+
85
+ logger.debug(msg, stacklevel=2)
65
86
 
66
87
  def info(self, message: str):
67
- logger.info(f"({cut_id(self.run_id)}) {message}")
88
+ msg: str = self.make_message(message)
89
+ self.writer(msg)
90
+ logger.info(msg, stacklevel=2)
68
91
 
69
92
  def warning(self, message: str):
70
- logger.warning(f"({cut_id(self.run_id)}) {message}")
93
+ msg: str = self.make_message(message)
94
+ self.writer(msg)
95
+ logger.warning(msg, stacklevel=2)
71
96
 
72
97
  def error(self, message: str):
73
- logger.error(f"({cut_id(self.run_id)}) {message}")
98
+ msg: str = self.make_message(message)
99
+ self.writer(msg, is_err=True)
100
+ logger.error(msg, stacklevel=2)
101
+
102
+
103
+ class TraceLog(BaseTraceLog): # pragma: no cov
104
+ """Trace Log object that write file to the local storage."""
105
+
106
+ @property
107
+ def log_file(self) -> Path:
108
+ log_file: Path = (
109
+ config.log_path / f"run_id={self.parent_run_id or self.run_id}"
110
+ )
111
+ if not log_file.exists():
112
+ log_file.mkdir(parents=True)
113
+ return log_file
114
+
115
+ @property
116
+ def cut_id(self) -> str:
117
+ """Combine cutting ID of parent running ID if it set."""
118
+ cut_run_id: str = cut_id(self.run_id)
119
+ if not self.parent_run_id:
120
+ return f"{cut_run_id} -> {' ' * 6}"
121
+
122
+ cut_parent_run_id: str = cut_id(self.parent_run_id)
123
+ return f"{cut_parent_run_id} -> {cut_run_id}"
124
+
125
+ def make_message(self, message: str) -> str:
126
+ return f"({self.cut_id}) {message}"
127
+
128
+ def writer(self, message: str, is_err: bool = False) -> None:
129
+ """The path of logging data will store by format:
130
+
131
+ ... ./logs/run_id=<run-id>/stdout.txt
132
+ ... ./logs/run_id=<run-id>/stderr.txt
133
+
134
+ :param message:
135
+ :param is_err:
136
+ """
137
+ if not config.enable_write_log:
138
+ return
139
+
140
+ frame_info: Traceback = getframeinfo(currentframe().f_back.f_back)
141
+ filename: str = frame_info.filename.split(os.path.sep)[-1]
142
+ lineno: int = frame_info.lineno
143
+
144
+ # NOTE: set process and thread IDs.
145
+ process: int = os.getpid()
146
+ thread: int = get_ident()
147
+
148
+ write_file: str = "stderr.txt" if is_err else "stdout.txt"
149
+ with (self.log_file / write_file).open(
150
+ mode="at", encoding="utf-8"
151
+ ) as f:
152
+ msg_fmt: str = f"{config.log_format_file}\n"
153
+ print(msg_fmt)
154
+ f.write(
155
+ msg_fmt.format(
156
+ **{
157
+ "datetime": get_dt_tznow().strftime(
158
+ config.log_datetime_format
159
+ ),
160
+ "process": process,
161
+ "thread": thread,
162
+ "message": message,
163
+ "filename": filename,
164
+ "lineno": lineno,
165
+ }
166
+ )
167
+ )
74
168
 
75
169
 
76
170
  @dataclass(
@@ -94,6 +188,26 @@ class Result:
94
188
  event: Event = field(default_factory=Event, compare=False)
95
189
  ts: datetime = field(default_factory=get_dt_tznow, compare=False)
96
190
 
191
+ @classmethod
192
+ def construct_with_rs_or_id(
193
+ cls,
194
+ result: Result | None = None,
195
+ run_id: str | None = None,
196
+ parent_run_id: str | None = None,
197
+ id_logic: str | None = None,
198
+ ) -> Self: # pragma: no cov
199
+ """Create the Result object or set parent running id if passing Result
200
+ object.
201
+ """
202
+ if result is None:
203
+ result: Result = cls(
204
+ run_id=(run_id or gen_id(id_logic or "", unique=True)),
205
+ parent_run_id=parent_run_id,
206
+ )
207
+ elif parent_run_id:
208
+ result.set_parent_run_id(parent_run_id)
209
+ return result
210
+
97
211
  def set_run_id(self, running_id: str) -> Self:
98
212
  """Set a running ID.
99
213
 
@@ -130,26 +244,13 @@ class Result:
130
244
  self.__dict__["context"].update(context or {})
131
245
  return self
132
246
 
133
- def receive(self, result: Result) -> Self:
134
- """Receive context from another result object.
135
-
136
- :rtype: Self
137
- """
138
- self.__dict__["status"] = result.status
139
- self.__dict__["context"].update(result.context)
140
-
141
- # NOTE: Update running ID from an incoming result.
142
- self.parent_run_id = result.parent_run_id
143
- self.run_id = result.run_id
144
- return self
145
-
146
247
  @property
147
248
  def trace(self) -> TraceLog:
148
249
  """Return TraceLog object that passing its running ID.
149
250
 
150
251
  :rtype: TraceLog
151
252
  """
152
- return TraceLog(self.run_id)
253
+ return TraceLog(self.run_id, self.parent_run_id)
153
254
 
154
255
  def alive_time(self) -> float: # pragma: no cov
155
256
  return (get_dt_tznow() - self.ts).total_seconds()