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/__about__.py +1 -1
- ddeutil/workflow/__init__.py +16 -10
- ddeutil/workflow/api/route.py +2 -2
- ddeutil/workflow/audit.py +28 -37
- ddeutil/workflow/{hook.py → call.py} +27 -27
- ddeutil/workflow/conf.py +47 -12
- ddeutil/workflow/job.py +80 -118
- ddeutil/workflow/result.py +126 -25
- ddeutil/workflow/scheduler.py +165 -150
- ddeutil/workflow/{stage.py → stages.py} +103 -37
- ddeutil/workflow/utils.py +20 -2
- ddeutil/workflow/workflow.py +137 -112
- {ddeutil_workflow-0.0.33.dist-info → ddeutil_workflow-0.0.34.dist-info}/METADATA +18 -17
- ddeutil_workflow-0.0.34.dist-info/RECORD +26 -0
- ddeutil_workflow-0.0.33.dist-info/RECORD +0 -26
- {ddeutil_workflow-0.0.33.dist-info → ddeutil_workflow-0.0.34.dist-info}/LICENSE +0 -0
- {ddeutil_workflow-0.0.33.dist-info → ddeutil_workflow-0.0.34.dist-info}/WHEEL +0 -0
- {ddeutil_workflow-0.0.33.dist-info → ddeutil_workflow-0.0.34.dist-info}/top_level.txt +0 -0
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 .
|
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
|
-
"
|
496
|
-
|
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,
|
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"
|
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
|
-
"
|
540
|
-
|
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
|
-
|
581
|
-
|
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
|
-
|
618
|
-
|
619
|
-
|
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
|
-
|
703
|
-
|
704
|
-
|
705
|
-
|
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
|
-
|
717
|
-
{
|
718
|
-
|
719
|
-
|
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)
|
ddeutil/workflow/result.py
CHANGED
@@ -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
|
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
|
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
|
-
|
56
|
-
|
65
|
+
@dataclass(frozen=True)
|
66
|
+
class BaseTraceLog(ABC): # pragma: no cov
|
67
|
+
"""Base Trace Log dataclass object."""
|
57
68
|
|
58
|
-
|
69
|
+
run_id: str
|
70
|
+
parent_run_id: Optional[str] = None
|
59
71
|
|
60
|
-
|
61
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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()
|